Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

20 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Stimulus Lazy Loader

Load Stimulus controllers on demand with Vite glob imports. The loader discovers controllers in the initial document and watches later DOM changes without requiring Turbo-specific hooks.

Features

  • Lazy dynamic imports for controllers that appear in the DOM
  • Optional static eager modules for critical controllers
  • Incremental discovery scoped to added or changed DOM subtrees
  • Automatic support for Turbo Drive, Frames, Streams, and morphs
  • Concurrent import deduplication
  • Complete disconnect semantics for pending loads and DOM observation
  • TypeScript declarations for the public API and registry entries

Installation

npm install @emaia/stimulus-lazy-loader

Lazy Controllers

import { Application } from "@hotwired/stimulus"
import { registerControllers } from "@emaia/stimulus-lazy-loader"

const application = Application.start()
const controllers = import.meta.glob("./**/*_controller.{js,ts}")

registerControllers(application, controllers)

Vite transforms each lazy glob entry into an importer function. The loader calls an importer only after its controller identifier appears in data-controller.

Eager And Lazy Controllers

Critical controllers can be included in Vite's static module graph while all other controllers remain lazy:

import { Application } from "@hotwired/stimulus"
import { registerControllers } from "@emaia/stimulus-lazy-loader"

const application = Application.start()

const lazyControllers = import.meta.glob(
  "./**/*_controller.{js,ts}",
)

const criticalControllers = import.meta.glob(
  [
    "./navigation_controller.ts",
    "./turbo/progress_controller.ts",
  ],
  { eager: true },
)

registerControllers(application, {
  ...lazyControllers,
  ...criticalControllers,
})

The eager entries are spread last, replacing matching lazy entries in the registry. They are registered during loader initialization even when their identifiers are not yet present in the DOM.

Do not combine the eager glob with import: "default". Eager module objects and lazy importer functions have different shapes, which lets the loader distinguish them safely. A default-exported controller class is also a function and would be ambiguous with a lazy importer.

Use eager loading selectively. It removes the dynamic chunk boundary but increases startup download, parsing, and execution for every page that includes the entrypoint. Heavy or conditional controllers should normally remain lazy.

Registry Precedence

When multiple paths resolve to the same Stimulus identifier, the later registry entry wins. Resolution happens before eager modules are registered, so a later lazy application override can replace an earlier eager vendor controller:

registerControllers(application, {
  ...vendorControllers,
  ...criticalVendorControllers,
  ...applicationControllers,
}, {
  warnOnDuplicate: false,
})

Options

registerControllers(application, controllers, {
  // Warn when two paths resolve to the same identifier. Default: true.
  warnOnDuplicate: false,

  // Log when a data-controller identifier has no registry entry. Default: true.
  warnOnMissing: false,
})

Explicit Loading

Use loadController() to start a known lazy import before its element is added:

const loader = registerControllers(application, controllers)

await loader.loadController("editor")

Automatic and explicit requests for the same exact identifier share one registration promise. Case and underscore variants that resolve to the same registry entry also share one module import, while each exact token is still registered separately so Stimulus can connect it. A failed import clears its module state so a later call can retry. A rediscovery received while an import is in flight is replayed once after that load settles; when the import fails, this replay causes one automatic retry for that in-flight cycle.

For stricter registry validation in TypeScript, provide Vite's module generic:

import type { ControllerModule } from "@emaia/stimulus-lazy-loader"

const controllers = import.meta.glob<ControllerModule>(
  "./**/*_controller.{js,ts}",
)

The default Vite inference uses unknown, so the main API also accepts a runtime-validated ControllerRegistryInput.

Cleanup

const loader = registerControllers(application, controllers)

loader.disconnect()

disconnect() is idempotent. It stops DOM observation, discards pending mutation records, rejects pending public loads with an AbortError, and prevents imports from registering controllers after shutdown. Dynamic import downloads cannot be physically cancelled once the browser has started them.

Calls to loadController() after disconnect also reject with AbortError.

How Discovery Works

  1. Builds an identifier map from the supplied registry paths.
  2. Resolves duplicate identifiers before registering eager winners.
  3. Observes document for inserted elements and data-controller changes.
  4. Scans the initial document once.
  5. Scans only added subtrees or directly changed elements afterward.
  6. Imports each normalized lazy module once and registers each exact Stimulus identifier once concurrently.

MutationObserver already batches synchronous DOM changes. The loader therefore adds no debounce delay. Observing document also keeps discovery active when Turbo replaces the entire <body>.

Controller Paths

./dropdown_controller.ts                         -> dropdown
./controllers/user_card_controller.js            -> user-card
./components/modal_controller.ts                  -> modal
./controllers/admin/settings/billing_controller.ts -> admin--settings--billing
./javascript/admin/dashboard_controller.js         -> javascript--admin--dashboard
<div data-controller="dropdown"></div>
<div data-controller="user-card"></div>
<div data-controller="admin--settings--billing"></div>

Matching is case-insensitive for path lookup. Underscores in file names become dashes and nested directories become Stimulus -- namespace separators.

The path prefix is discarded through the first exact directory segment named controllers/ or components/. Later segments remain part of the Stimulus namespace. Names such as mycontrollers/ and webcomponents/ are also ordinary namespace segments. Otherwise, only a leading ./ or ../ is removed. Run the glob from the controllers directory, or include a controllers/ segment, when preceding directories should not appear in the identifier.

Upgrading

See UPGRADE.md for version-specific migration instructions and behavioral changes.

Visual State

Lazy loading cannot guarantee that connect() runs before the first paint. Initial visibility and layout should be represented in server-rendered HTML and CSS with tools such as hidden, inert, state attributes, or skeletons. Use eager loading only when a controller must be available with the application entrypoint, not as a substitute for a safe initial DOM state.

Requirements

  • @hotwired/stimulus 3.x
  • Vite with import.meta.glob, or an equivalent registry supplied by another bundler

License

MIT

About

Automatically registers Stimulus controllers as they appear in the DOM.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages