Skip to content

Autoloading

A page that declares its components in the markup does not have to import them all up front. Map each data-component token to a dynamic import and the registry downloads a chunk when an element needs one.

registerManifest()

js
import { registerManifest } from '@studiometa/js-toolkit';

registerManifest({
  Accordion: () => import('./components/Accordion.js'),
  Slider: () => import('./components/Slider.js'),
  Map: { load: () => import('./components/Map.js'), mountStrategy: 'visible' },
});

An entry is either the importer itself, or an object with load and an optional mountStrategy.

  • One name, one entry, across eager classes and manifests alike. A token an eager class or an earlier manifest owns gives a manifest.duplicate-token warning and is ignored.
  • No dependency and no bundler knowledge. The value is a function that returns a promise. import.meta.glob, import.meta.webpackContext and a generated manifest all produce that shape.
  • The registry stays the only constructor. The import ends in registerComponent(ComponentClass).
  • One import per name, one failure report per name. A failure emits one component.load-failed diagnostic and is never retried.
  • A class whose config.name differs from its token gives a registry.lazy-name-mismatch warning.
  • The class is resolved out of the imported module for you, so a hand-written entry is Slider: () => import('./Slider.js') with nothing to unwrap.

One knob, not two

Deferring the import and deferring the mount are one decision:

data-mount  >  manifest entry mountStrategy  >  'eager'

The manifest entry stands in the middle slot for the config.mountStrategy of a class that cannot be read yet. Once the class registers, the chain reads config.mountStrategy and the entry is deleted from the map.

There is no data-load and no compatibility shim for it. A page that used data-load renames one attribute.

What that buys

One scheduling algorithm covers both halves. A name resolves to one source, and one controller holds the current decision of the element/component pair. The arrival of the class is a change of source, so the pass that replaces a controller turns a spent import trigger into a mount:

  • a one-shot trigger (visible, idle, interaction) mounts on the import that proved its condition;
  • a reversible one (in-view, media:) is observed again.

An unloaded declaration is invisible to $query(), $closest(), $watchChildren() and getInstances(), because nothing is constructed at discovery.

whenDOMSettled() covers an eager lazy component: the import promise joins the lifecycle-work set only for the eager trigger, so swap() waits for download, registration and mount — and still never waits on a viewport, an idle callback, an interaction or a media query.

Generating a manifest from a glob

defineManifest() turns a bundler glob into a manifest, deriving each token from the path:

js
import { defineManifest, fromMetaGlob } from '@studiometa/js-toolkit';

registerManifest(
  defineManifest({
    modules: fromMetaGlob(import.meta.glob('./components/*/index.js')),
  }),
);
  • index.js falls back to the parent directory name, so ./components/Slider/index.js is Slider.
  • A package-wide mountStrategy emits entry wrappers only when it is not eager.
  • Duplicate tokens give a warning and keep the first path.

For webpack, fromWebpackContext() takes an import.meta.webpackContext result. Neither adapter adds a bundler dependency — core names none of them.

Lazy children

config.components takes a dynamic import too, and the key supplies the name:

js
static config = {
  name: 'Accordion',
  components: {
    AccordionItem: () => import('./AccordionItem.js'),
    Icon: IconClass,
  },
};

registerComponent() defers a thunk instead of resolving it, and the value becomes a lazy entry of the same registry under its key. Everything after that is identical to a manifest entry.

  • The key supplies the name, so a lazy child is a name the registry knows with nothing downloaded — and on<Child><Event> resolution works on it immediately.
  • A manifest can declare the parent only. A child behind a thunk is its own chunk, so a family splits where the author splits it.
  • First wins, quietly, unlike registerManifest(). Several parents declaring the same lazy child is the normal case.
  • The entry gets no mountStrategy field. Until the class arrives the chain is data-mount > eager; after it, the usual chain reads the merged config, so a lazy child that is a subclass inherits the strategy of its base.
  • A value written with class that does not extend Base is reported as component.invalid-family-declaration, where it is declared.

What v3's autoload did that v4 does not need

v3 shipped 1033 source lines of autoload across seven modules. The registry and the mount strategies absorb almost all of it:

v3v4
ComponentLoader.start() discovery observerthe one document observer
the four load triggers of __schedule()mount-strategies.ts
per-element and per-record cleanup bookkeepingone controller map holding both halves
recursive registration of configured childrenone registerFamily() loop over config.components
the ComponentRecord state machineone Map<string, Promise<void>> — the promise is the state
readEagerTokens and <meta name="js-toolkit:eager">data-mount on the element

The children arrays of a v3 manifest are dead data.

MIT Licensed