Javascript modules ordering

Hello all,

This proposal places itself in the context of my work on client-side component management.
See previous related discussions:

When working on this topic, I realized that the order in which the modules are loaded is important.

High level view

A high level view of how the components are loaded is the following:

  1. The component manager service is loaded, and exposed as, for instance, "component-manager"
  2. Javascript modules providing components import "component-manager" and use its APIs to register components
  3. Finally, the component manager imports "component-manager" and call the resolve method to resolve all the registered components (e.g., only load the components with the highest priority in case same role and name)

Relevant technical concepts

Module loading

JavaScript modules are guaranteed to be loaded in their order of declaration. In the example below, moduleA is always going to be loaded before moduleB.

<script type="module" src="./src/moduleA.js"></script>
<script type="module" src="./src/moduleB.js"></script>

Dependencies resolution

An exception to this module loading order is in case of import/export dependencies between modules. In the example below, if moduleA and moduleB import framework.
The (network) loading order will be moduleA -> moduleB -> framework.
But, the execution order will be framework -> moduleA -> moduleB since A and B waits for the imported module to be available before being executed.
The console will always print

A
B

index.html

<script type="module" src="./src/moduleA.js"></script>
<script type="module" src="./src/moduleB.js"></script>
<script type="module" src="./src/framework.js"></script>

moduleA.js

import {log} from "framework";
log("A");

moduleB.js

import {log} from "framework";
log("B");

framework.js

export function log(x) {
  console.log(x);
}

Proposal

In conclusion, the declaration order for the component manager service and the modules registering components is not significant. There is an explicit import/export relationship between them, and we can be sure that the service is going to be available when registering the components (steps 1 and 2 in the high level view).
What’s more difficult is to guarantee that step 3 happens strictly after step 2 (i.e., after all the components are registered).

Option 1: special case

The easiest option is to consider that this need for a given module to be loaded last is exceptional.
We declared a new UIExtension with a lower priority than JavascriptImportmapUIExtension that injects a dedicated script.

Option 2: feature

We define a feature provider/consumer concepts, where providers and consumer declared shared IDs.
When generating the scripts elements, we guarantee that all providers of a given ID are declared before their consumers.
For instance, all modules providing client-side components would have the components ID as providers, and the resolver module would have the components ID as consumer.

Cons:

  • This forces all the modules providing components to declare this special ID. This feels error-prone as it is easy to forget. It is also verbose as I’m expecting most of our modules to provider components overtime

Option 3: priority

This version is close to option 2, but instead of declaring relationships between module, some modules can declare a priority (positive integer, default 1000).
Modules are sorted by their priority, then by alphabetical order of their WebJar ID (groupId:artifactId)

Pros:

  • Less verbose than option 2 as only module needed a special ordering need to use this property
  • More generic than option 1
    Cons:
  • Risk of YANGNI over option 1 if it happens that we only set a priority 0 for the use case present above
  • More generic than option 1, but also more complex to implement and maintain

Conclusion

+0 for option 3, unless we find other interesting use cases.
+1 for option 1 as long as we don’t find other use cases for option 3.
-1 for option 2 that seems overengineered and hard to maintain.

Thanks, WDYT?

+1 for option 1, i.e. let’s see fist how often we need this. Do you have any real use cases in mind?

Would what you describe mean that modules that are loaded later, e.g., when the user clicks some button, cannot register or implement components? If yes, that seems quite a limitation.

Indeed, that’s something that deserves clarification. The components are registered (see example below) statically, but they are resolved lazily when

  1. loaded by calling the get/getAll methods of the resolver
  2. the component is injected using constructor injection (so indirectly by a call to get on another component)

So any component that could be loaded will (eargly) load a very thin declaration, e.g.,

manager.registerComponent(Symbol.for("componentA"), async () => {
  return (await import("./componentA")).ComponentA; // only call when actually resolved, lasily
});

And, the actual implementation is only loaded when needed. So in your example when a button is pressed.

Note that your question lead me to some more experiments and my PoC is currently buggy on this aspect.
I don’t think the fix is too difficult, but I’ll keep you updated in case I find some more issues along the way.

Actually my PoC have the correct behaviour, I just made a typo in a test that lead me to think that something was wrong. All good.

I did not mention any use case because nothing comes to mind. Just mentioning it as call for suggestions.