diff --git a/src/Instance.ts b/src/Instance.ts index a9cdad53..fe548f00 100644 --- a/src/Instance.ts +++ b/src/Instance.ts @@ -123,26 +123,6 @@ export function updateTabsterByAttribute( const sys = newTabsterProps.sys; switch (key) { - case "deloser": - if (tabsterOnElement.deloser) { - tabsterOnElement.deloser.setProps( - newTabsterProps.deloser as Types.DeloserProps - ); - } else { - if (tabster.deloser) { - tabsterOnElement.deloser = - tabster.deloser.createDeloser( - element, - newTabsterProps.deloser as Types.DeloserProps - ); - } else if (__DEV__) { - console.error( - "Deloser API used before initialization, please call `getDeloser()`" - ); - } - } - break; - case "root": if (tabsterOnElement.root) { tabsterOnElement.root.setProps( @@ -158,117 +138,10 @@ export function updateTabsterByAttribute( tabster.root.onRoot(tabsterOnElement.root); break; - case "modalizer": - { - let newModalizerProps: Types.ModalizerProps | undefined; - const modalizerAPI = tabster.modalizer; - - if (tabsterOnElement.modalizer) { - const props = - newTabsterProps.modalizer as Types.ModalizerProps; - const newModalizerId = props.id; - if ( - newModalizerId && - oldTabsterProps?.modalizer?.id !== newModalizerId - ) { - // Modalizer id is changed, given the modalizers have complex logic and could be - // composite, it is easier to recreate the Modalizer instance than to implement - // the id update. - tabsterOnElement.modalizer.dispose(); - newModalizerProps = props; - } else { - tabsterOnElement.modalizer.setProps(props); - } - } else { - if (modalizerAPI) { - newModalizerProps = newTabsterProps.modalizer; - } else if (__DEV__) { - console.error( - "Modalizer API used before initialization, please call `getModalizer()`" - ); - } - } - - if (modalizerAPI && newModalizerProps) { - tabsterOnElement.modalizer = - modalizerAPI.createModalizer( - element, - newModalizerProps, - sys - ); - } - } - - break; - - case "restorer": - if (tabsterOnElement.restorer) { - tabsterOnElement.restorer.setProps( - newTabsterProps.restorer as Types.RestorerProps - ); - } else { - if (tabster.restorer) { - if (newTabsterProps.restorer) { - tabsterOnElement.restorer = - tabster.restorer.createRestorer( - element, - newTabsterProps.restorer - ); - } - } else if (__DEV__) { - console.error( - "Restorer API used before initialization, please call `getRestorer()`" - ); - } - } - - break; - case "focusable": tabsterOnElement.focusable = newTabsterProps.focusable; break; - case "groupper": - if (tabsterOnElement.groupper) { - tabsterOnElement.groupper.setProps( - newTabsterProps.groupper as Types.GroupperProps - ); - } else { - if (tabster.groupper) { - tabsterOnElement.groupper = - tabster.groupper.createGroupper( - element, - newTabsterProps.groupper as Types.GroupperProps, - sys - ); - } else if (__DEV__) { - console.error( - "Groupper API used before initialization, please call `getGroupper()`" - ); - } - } - break; - - case "mover": - if (tabsterOnElement.mover) { - tabsterOnElement.mover.setProps( - newTabsterProps.mover as Types.MoverProps - ); - } else { - if (tabster.mover) { - tabsterOnElement.mover = tabster.mover.createMover( - element, - newTabsterProps.mover as Types.MoverProps, - sys - ); - } else if (__DEV__) { - console.error( - "Mover API used before initialization, please call `getMover()`" - ); - } - } - break; - case "observed": if (tabster.observedElement) { tabsterOnElement.observed = newTabsterProps.observed; @@ -298,10 +171,24 @@ export function updateTabsterByAttribute( tabsterOnElement.sys = newTabsterProps.sys; break; - default: - console.error( - `Unknown key '${key}' in data-tabster attribute value.` - ); + default: { + const handler = tabster.attrHandlers.get(key); + if (handler) { + tabsterOnElement[key] = handler( + element, + tabsterOnElement[key], + newTabsterProps[key], + oldTabsterProps?.[key], + sys + ) as never; + } else if (__DEV__) { + console.error( + `${key} API used before initialization, please call \`get${ + key[0].toUpperCase() + key.slice(1) + }()\`` + ); + } + } } } diff --git a/src/Tabster.ts b/src/Tabster.ts index 16bb3c70..9f8cc0a9 100644 --- a/src/Tabster.ts +++ b/src/Tabster.ts @@ -56,6 +56,12 @@ class TabsterCore implements Types.TabsterCore { _noop = false; controlTab: boolean; rootDummyInputs: boolean; + // Variance gap: per-key handler types are contravariant in their + // parameters, so a fully-typed Map> can't unify + // them. Cast a plain Map to the typed view; the override on `set` keeps + // registration type-safe per key, while `get` falls back to the Map's + // value type (the type-erased shape). + attrHandlers = new Map() as Types.TabsterAttrHandlerRegistry; // Core APIs keyboardNavigation: Types.KeyboardNavigationState; @@ -203,6 +209,11 @@ class TabsterCore implements Types.TabsterCore { this._dummyObserver.dispose(); + // Drop handler closures — they capture the API instances we just + // disposed, and any post-dispose updateTabsterByAttribute call would + // otherwise dispatch to those zombies. + this.attrHandlers.clear(); + clearElementCache(this.getWindow); this._storage = new WeakMap(); diff --git a/src/Types.ts b/src/Types.ts index b231fa0d..34255665 100644 --- a/src/Types.ts +++ b/src/Types.ts @@ -1298,7 +1298,62 @@ export interface DummyInputObserver { ): void; } +/** + * @internal + * Per-attribute-key handler invoked by `updateTabsterByAttribute`. Subsystems + * register a handler from their `get*` file when they're first instantiated, + * so the create-or-setProps logic only enters the bundle when the subsystem + * itself does. + * + * `existing` is the current `TabsterOnElement[K]` (the live instance, if any). + * `newProps`/`oldProps` are typed against the same key on `TabsterAttributeProps`. + * Returns the instance that should occupy `TabsterOnElement[K]` after this + * call — either the (possibly-mutated) `existing` or a freshly created one. + */ +export type TabsterAttrHandler = ( + element: HTMLElement, + existing: TabsterOnElement[K], + newProps: NonNullable, + oldProps: TabsterAttributeProps[K], + sys: SysProps | undefined +) => NonNullable; + +/** + * @internal + * Type-erased handler shape used internally for storage and dispatch. + * Callers should use the generic `TabsterAttrHandler` for type-safe + * registration. + */ +export type AnyTabsterAttrHandler = ( + element: HTMLElement, + existing: unknown, + newProps: unknown, + oldProps: unknown, + sys: SysProps | undefined +) => NonNullable; + +/** + * @internal + * Typed view over `Map`. + * Only `set` is overridden so that registration is generic per key — the + * handler's `existing`/`newProps`/`oldProps`/return types are inferred from + * the key. `get`/`clear` come from `Map`. The call site (Instance.ts) + * iterates over `keyof TabsterAttributeProps` and gets back the type-erased + * `AnyTabsterAttrHandler` shape. + */ +export interface TabsterAttrHandlerRegistry extends Map< + keyof TabsterAttributeProps, + AnyTabsterAttrHandler +> { + set( + key: K, + handler: TabsterAttrHandler + ): this; +} + interface TabsterCoreInternal { + /** @internal */ + attrHandlers: TabsterAttrHandlerRegistry; /** @internal */ groupper?: GroupperAPI; /** @internal */ diff --git a/src/get/getDeloser.ts b/src/get/getDeloser.ts index 602a708d..07ac5ed7 100644 --- a/src/get/getDeloser.ts +++ b/src/get/getDeloser.ts @@ -18,7 +18,18 @@ export function getDeloser( const tabsterCore = tabster.core; if (!tabsterCore.deloser) { - tabsterCore.deloser = new DeloserAPI(tabsterCore, props); + const api = new DeloserAPI(tabsterCore, props); + tabsterCore.deloser = api; + tabsterCore.attrHandlers.set( + "deloser", + (element, existingDeloser, newProps) => { + if (existingDeloser) { + existingDeloser.setProps(newProps); + return existingDeloser; + } + return api.createDeloser(element, newProps); + } + ); } return tabsterCore.deloser; diff --git a/src/get/getGroupper.ts b/src/get/getGroupper.ts index a2bbe9b8..1a5bcb97 100644 --- a/src/get/getGroupper.ts +++ b/src/get/getGroupper.ts @@ -14,9 +14,17 @@ export function getGroupper(tabster: Types.Tabster): Types.GroupperAPI { const tabsterCore = tabster.core; if (!tabsterCore.groupper) { - tabsterCore.groupper = new GroupperAPI( - tabsterCore, - tabsterCore.getWindow + const api = new GroupperAPI(tabsterCore, tabsterCore.getWindow); + tabsterCore.groupper = api; + tabsterCore.attrHandlers.set( + "groupper", + (element, existingGroupper, newProps, _oldProps, sys) => { + if (existingGroupper) { + existingGroupper.setProps(newProps); + return existingGroupper; + } + return api.createGroupper(element, newProps, sys); + } ); } diff --git a/src/get/getModalizer.ts b/src/get/getModalizer.ts index 4113d68f..4d649c44 100644 --- a/src/get/getModalizer.ts +++ b/src/get/getModalizer.ts @@ -24,11 +24,30 @@ export function getModalizer( const tabsterCore = tabster.core; if (!tabsterCore.modalizer) { - tabsterCore.modalizer = new ModalizerAPI( + const api = new ModalizerAPI( tabsterCore, alwaysAccessibleSelector, accessibleCheck ); + tabsterCore.modalizer = api; + tabsterCore.attrHandlers.set( + "modalizer", + (element, existingModalizer, newProps, oldProps, sys) => { + if (existingModalizer) { + if (newProps.id && oldProps?.id !== newProps.id) { + // Modalizer id is changed, given the modalizers have + // complex logic and could be composite, it is easier + // to recreate the Modalizer instance than to implement + // the id update. + existingModalizer.dispose(); + return api.createModalizer(element, newProps, sys); + } + existingModalizer.setProps(newProps); + return existingModalizer; + } + return api.createModalizer(element, newProps, sys); + } + ); } return tabsterCore.modalizer; diff --git a/src/get/getMover.ts b/src/get/getMover.ts index e2044b3f..2db42dce 100644 --- a/src/get/getMover.ts +++ b/src/get/getMover.ts @@ -14,7 +14,18 @@ export function getMover(tabster: Types.Tabster): Types.MoverAPI { const tabsterCore = tabster.core; if (!tabsterCore.mover) { - tabsterCore.mover = new MoverAPI(tabsterCore, tabsterCore.getWindow); + const api = new MoverAPI(tabsterCore, tabsterCore.getWindow); + tabsterCore.mover = api; + tabsterCore.attrHandlers.set( + "mover", + (element, existingMover, newProps, _oldProps, sys) => { + if (existingMover) { + existingMover.setProps(newProps); + return existingMover; + } + return api.createMover(element, newProps, sys); + } + ); } return tabsterCore.mover; diff --git a/src/get/getRestorer.ts b/src/get/getRestorer.ts index ee164cc9..2f124259 100644 --- a/src/get/getRestorer.ts +++ b/src/get/getRestorer.ts @@ -9,7 +9,18 @@ import type * as Types from "../Types.js"; export function getRestorer(tabster: Types.Tabster): Types.RestorerAPI { const tabsterCore = tabster.core; if (!tabsterCore.restorer) { - tabsterCore.restorer = new RestorerAPI(tabsterCore); + const api = new RestorerAPI(tabsterCore); + tabsterCore.restorer = api; + tabsterCore.attrHandlers.set( + "restorer", + (element, existingRestorer, newProps) => { + if (existingRestorer) { + existingRestorer.setProps(newProps); + return existingRestorer; + } + return api.createRestorer(element, newProps); + } + ); } return tabsterCore.restorer;