Skip to content

node:module register hook chaining order behavior is incorrect #51876

Description

@yklcs

Version

v21.6.1

Platform

Darwin mbpll.local 23.1.0 Darwin Kernel Version 23.1.0: Mon Oct 9 21:28:12 PDT 2023; root:xnu-10002.41.9~6/RELEASE_ARM64_T8103 arm64

Subsystem

module

What steps will reproduce the bug?

Chaining hooks via multiple register calls: https://gist.github.com/yklcs/9b4182e365aead6bad32e637621fd6fb

How often does it reproduce? Is there a required condition?

No response

What is the expected behavior? Why is that the expected behavior?

Loader hooks should get executed in registration order (FIFO). This is the behavior consistent with documentation:

The chaining section for node:module states:

In this example, the registered hooks will form chains. If both first.mjs and second.mjs define a resolve hook, both will be called, in the order they were registered. The same applies to all the other hooks.

The hooks section for node:module states:

Hooks are part of a chain, even if that chain consists of only one custom (user-provided) hook and the default hook, which is always present. Hook functions nest: each one must always return a plain object, and chaining happens as a result of each function calling next<hookName>(), which is a reference to the subsequent loader's hook.

loader 1 initialize
loader 2 initialize
loader 3 initialize

loader 1 resolve
loader 2 resolve
loader 3 resolve

loader 1 load
loader 2 load
loader 3 load

What do you see instead?

Recursive calls to next<hookName> are executed in LIFO order, with the first calls happening in FIFO order:

loader 1 initialize
loader 1 resolve
loader 1 load

loader 2 initialize
loader 2 resolve
loader 1 resolve
loader 2 load
loader 1 load

loader 3 initialize
loader 3 resolve
loader 2 resolve
loader 1 resolve
loader 3 load
loader 2 load
loader 1 load

This appears to be incorrect because:

1. The same hook is executed multiple times (n^2 behavior)

2. The dependency chain between hooks is broken. For example, if we tried to import by chaining https-loader and typescript-loader (as is the intended use case for chaining hooks), https-loader must run before typescript-loader. However, the current behavior gives us the following loading order:

https-loader load

typescript-loader load
https-loader load

Or, when chained in reverse:

typescript-loader load

https-loader load
typescript-loader load

Where neither version can function as intended.

Additional information

No response

Activity

  1. added a commit that references this issue on Feb 26, 2024
  2. yklcs commented on Feb 26, 2024

    @yklcs
    Author

    I'm not sure anymore what the intended behavior of register hook chaining is.
    Public facing documentation describes a FIFO approach where hooks registered first are ran first, and next<hookName>() function calls the next registered function.

    In this example, the registered hooks will form chains. If both first.mjs and second.mjs define a resolve hook, both will be called, in the order they were registered.

    Hook functions nest: each one must always return a plain object, and chaining happens as a result of each function calling next<hookName>(), which is a reference to the subsequent loader's hook.

    Meanwhile, #42623 shows that LIFO is intended, and tests are written in a way that the n^2 calling behavior above is intended.

    I drafted a PR that shows how FIFO would work at #51878.

  3. JakobJingleheimer commented on Feb 26, 2024

    @JakobJingleheimer
    Member

    The intended behaviour is LIFO:

    A is registered first and is called last (assuming nothing special).

    B is registered last and is called first.

    Subsequently, C is registered dynamically, making it "last" so next chain run, it is called first.

    (Node's default hook) ← A ← B ← C

  4. yklcs commented on Feb 26, 2024

    @yklcs
    Author

    I see. Appears to be a documentation issue if LIFO is intended, since https://nodejs.org/api/module.html is pretty clear that hooks are ran FIFO.

  5. JakobJingleheimer commented on Feb 26, 2024

    @JakobJingleheimer
    Member

    Unless something changed that I'm forgetting (and I use this at my job, on node 20, so I'm pretty sure it hasn't changed), the chain is a stack.

    From

    [the example in the docs](https://nodejs.org/api/module.html#chaining)
    // entrypoint.mjs
    import { register } from 'node:module';
    
    register('./first.mjs', import.meta.url);
    register('./second.mjs', import.meta.url);
    await import('./my-app.mjs');

    first.mjs is registered first, and second.mjs is registered second. These names are misleading (when I wrote the original docs, I specifically chose non-ordinal names to avoid this confusion).

    The subsequent dynamic import of my-app.mjs is loaded via: (node's default) ← first ← second.

  6. JakobJingleheimer commented on Feb 26, 2024

    @JakobJingleheimer
    Member

    I'll update the docs today to restore the less confusing names and specifically mention LIFO (I'm pretty sure they previously did).

  7. himself65 commented on Mar 1, 2024

    @himself65
    Member

    Looks same as #50886 which also confusing me

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions