/** * Lifecycle management * ============================================================================ * * Plugins register their initialisation through `onDOMContentLoaded`. Besides * the initial page load, every registered callback is re-run on Hotwired Turbo * navigations (`turbo:load`): Turbo Drive swaps the
without a full page * reload, so without re-initialisation plugins such as PushMenu and TreeView * stop working after the first in-app link click (#563, #5890). * * Re-running init would normally leak listeners, because callbacks also bind to * `window`/`document`, which survive Turbo's swap. To prevent that, each * cycle has its own `AbortController`: callbacks should attach their * window/document-level listeners with the signal from `getLifecycleSignal()`. * The signal is aborted on `turbo:before-render`, tearing down the previous * cycle's listeners before the callbacks run again. Listeners bound to elements * inside don't need the signal — Turbo discards the old , so they * are cleaned up automatically. * * Turbo is not the only environment that renders after `DOMContentLoaded`: * client-side frameworks that build the layout themselves (GWT, and other * imperative widget toolkits) have an empty when the initial batch runs, * so the per-page init pass finds no sidebar and no menu. Those consumers call * the exported `initialize()` once the layout is attached — it performs the same * reset-then-replay cycle Turbo gets, without faking Turbo events. * * Unlike Turbo, such frameworks keep the same across a re-init, so * element-level listeners are NOT discarded for them. Callbacks should therefore * pass `getLifecycleSignal()` to every `addEventListener` they make — including * ones on elements — whenever the element can outlive the cycle. */ const lifecycleCallbacks: Array<() => void> = [] // Mutable state is held on an object so the lifecycle hooks below can update it // without reassigning top-level bindings. const lifecycleState = { controller: new AbortController(), hasInitialized: false, // True while the callback batch is executing — lets initialize() refuse // re-entrant calls made from inside a lifecycle callback. isReplaying: false } /** * The AbortSignal for the current lifecycle. Pass it as the * `{ signal }` option to window/document `addEventListener` calls made during * initialisation so they are removed automatically on the next Turbo render. */ const getLifecycleSignal = (): AbortSignal => lifecycleState.controller.signal const runLifecycleCallbacks = (): void => { if (lifecycleState.hasInitialized) { return } lifecycleState.hasInitialized = true lifecycleState.isReplaying = true try { for (const callback of lifecycleCallbacks) { callback() } } finally { lifecycleState.isReplaying = false } } const onDOMContentLoaded = (callback: () => void): void => { lifecycleCallbacks.push(callback) // Late registration: the batch for the current cycle has already run (the // script loaded after DOMContentLoaded, or a callback was registered after a // Turbo visit), so run the newcomer immediately rather than stranding it // until the next navigation. Preserves the original non-loading behaviour. if (lifecycleState.hasInitialized) { callback() } } /** * End the current lifecycle: abort the cycle's signal so listeners registered * with it are removed, then arm a fresh cycle for the next replay. * * Exported for SPA containers that unmount the AdminLTE layout: calling it * drops the window/document listeners the current cycle added without * immediately re-initialising. Internally it is also the first half of * `initialize()` and the `turbo:before-render` handler. */ const teardown = (): void => { lifecycleState.controller.abort() lifecycleState.controller = new AbortController() lifecycleState.hasInitialized = false } /** * Re-run every plugin's initialisation against the DOM as it stands right now. * * Intended for frameworks that render the layout after `DOMContentLoaded` has * already fired — call it once the sidebar and menu are attached, and PushMenu, * Treeview and ColorMode pick them up as if they had been in the initial HTML. * Delegated click handling never needs this; only the per-page init pass does. * * The previous cycle is torn down first, so calling it repeatedly does not stack * listeners registered with `getLifecycleSignal()`. Calling it before the * initial batch has run (while `document.readyState === 'loading'`) runs that * batch early, against whatever DOM exists at the time — the initial * `DOMContentLoaded` pass below still replays against the complete DOM. */ const initialize = (): void => { // Re-entrancy guard: a lifecycle callback calling initialize() would tear // down its own cycle mid-replay and recurse without end. if (lifecycleState.isReplaying) { return } teardown() runLifecycleCallbacks() } // Initial page load. Routed through initialize() so that an early initialize() // call (a framework initialising before the document finished loading) cannot // mark the cycle as done and suppress this pass — the replay tears the early // cycle down and re-runs every callback against the complete DOM. if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initialize, { once: true }) } else { runLifecycleCallbacks() } // Hotwired Turbo: drop the previous cycle's window/document listeners, then // re-run initialisation against the freshly rendered . The teardown has to // happen at `before-render` rather than as part of the replay, so the outgoing // 's listeners are gone before Turbo swaps in the new one — which is why // this is the two-step form of `initialize()` rather than a call to it. document.addEventListener('turbo:before-render', teardown) document.addEventListener('turbo:load', runLifecycleCallbacks) // ES2022 UTILITY FUNCTIONS /** * Check if an element has a specific data attribute using ES2022 Object.hasOwn() */ const hasDataAttribute = (element: HTMLElement, attribute: string): boolean => { return Object.hasOwn(element.dataset, attribute) } /** * Get the last element from a NodeList using ES2022 Array.at() */ const getLastElement =