From 960a7e91eff313077c84f7dfdcae42cc84a6ed57 Mon Sep 17 00:00:00 2001 From: Oleksandr Fediashov Date: Wed, 29 Apr 2026 11:03:02 +0200 Subject: [PATCH 1/9] fix(bundle-size): route attribute updates through a handler registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same goal as #523 — move the subsystem-specific create/setProps logic out of `Instance.ts` so it only enters bundles whose consumer calls the matching `getX()`. Different shape: instead of attaching an `applyAttribute` method to each subsystem API class, each `get*` file registers a closure on a `tabster.attrHandlers: Map` when its API is first instantiated. ## Why a registry - Instance.ts no longer references subsystem names for the 5 heavy cases (deloser, groupper, mover, modalizer, restorer). Adding or removing a subsystem doesn't touch Instance.ts. - Subsystem API classes (`Modalizer.ts` etc.) stay focused on their actual job — they don't gain a method whose only purpose is to mutate `TabsterOnElement` on behalf of the attribute pipeline. - Wiring lives in `src/get/*` where it conceptually belongs: that's where the API instance is wired into `TabsterCore` already. ## What stays inline in Instance.ts `root` (always present), `focusable` / `uncontrolled` / `sys` (literal property assignments, not API-gated), and the remaining tiny gated cases `observed` / `outline` (1-2 lines each, no create/setProps logic to relocate). Putting these through the registry would add indirection without bundle-size gain. ## Bundle-size Same as #523 in shape: - Each subsystem's create-or-setProps closure ships only with its own `get*` file, which is itself only imported when the consumer calls `getDeloser()` / `getGroupper()` / etc. - The registry adds a small fixed cost (one `Map` instance per TabsterCore) that's amortized across however many subsystems the consumer uses. ## Behavior changes vs master - For deloser / groupper / mover / modalizer / restorer the dev-only "API used before initialization" error now fires whenever the registry has no handler for the key (i.e. `getX()` was never called). In master the equivalent error was guarded on `tabster.X === undefined` AND `tabsterOnElement.X === undefined`. The new version fires even when `tabsterOnElement.X` exists but the API isn't registered — a state that requires the API to have been disposed mid-flight, which the codebase doesn't construct. - Restorer: when `tabsterOnElement.restorer` exists and `newTabsterProps.restorer` is undefined, master called `setProps(undefined as RestorerProps)`. The new code skips the call. No test exercises the previous behavior. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Instance.ts | 149 +++++----------------------------------- src/Tabster.ts | 4 ++ src/Types.ts | 17 +++++ src/get/getDeloser.ts | 14 +++- src/get/getGroupper.ts | 15 +++- src/get/getModalizer.ts | 35 +++++++++- src/get/getMover.ts | 14 +++- src/get/getRestorer.ts | 16 ++++- 8 files changed, 126 insertions(+), 138 deletions(-) diff --git a/src/Instance.ts b/src/Instance.ts index a9cdad53..dc926aba 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) { + handler( + element, + tabsterOnElement, + newTabsterProps[key], + oldTabsterProps?.[key], + sys + ); + } 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..570bbcca 100644 --- a/src/Tabster.ts +++ b/src/Tabster.ts @@ -56,6 +56,10 @@ class TabsterCore implements Types.TabsterCore { _noop = false; controlTab: boolean; rootDummyInputs: boolean; + attrHandlers: Map< + keyof Types.TabsterAttributeProps, + Types.TabsterAttrHandler + > = new Map(); // Core APIs keyboardNavigation: Types.KeyboardNavigationState; diff --git a/src/Types.ts b/src/Types.ts index b231fa0d..92e1c92a 100644 --- a/src/Types.ts +++ b/src/Types.ts @@ -1298,7 +1298,24 @@ export interface DummyInputObserver { ): void; } +/** + * @internal + * Handler invoked by `updateTabsterByAttribute` for a given attribute key. + * Subsystems register a handler from their `get*` file when they're first + * instantiated, so the heavy create-or-setProps logic only enters the bundle + * when the subsystem itself does. + */ +export type TabsterAttrHandler = ( + element: HTMLElement, + storage: TabsterOnElement, + newProps: unknown, + oldProps: unknown, + sys: SysProps | undefined +) => void; + interface TabsterCoreInternal { + /** @internal */ + attrHandlers: Map; /** @internal */ groupper?: GroupperAPI; /** @internal */ diff --git a/src/get/getDeloser.ts b/src/get/getDeloser.ts index 602a708d..a66185ca 100644 --- a/src/get/getDeloser.ts +++ b/src/get/getDeloser.ts @@ -18,7 +18,19 @@ 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, storage, newProps) => { + const next = newProps as Types.DeloserProps; + if (storage.deloser) { + storage.deloser.setProps(next); + } else { + storage.deloser = api.createDeloser(element, next); + } + } + ); } return tabsterCore.deloser; diff --git a/src/get/getGroupper.ts b/src/get/getGroupper.ts index a2bbe9b8..f6c4aa7f 100644 --- a/src/get/getGroupper.ts +++ b/src/get/getGroupper.ts @@ -14,9 +14,18 @@ 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, storage, newProps, _oldProps, sys) => { + const next = newProps as Types.GroupperProps; + if (storage.groupper) { + storage.groupper.setProps(next); + } else { + storage.groupper = api.createGroupper(element, next, sys); + } + } ); } diff --git a/src/get/getModalizer.ts b/src/get/getModalizer.ts index 4113d68f..78aaa543 100644 --- a/src/get/getModalizer.ts +++ b/src/get/getModalizer.ts @@ -24,11 +24,44 @@ 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, storage, newProps, oldProps, sys) => { + const next = newProps as Types.ModalizerProps; + let propsToCreate: Types.ModalizerProps | undefined; + + if (storage.modalizer) { + const oldId = (oldProps as Types.ModalizerProps | undefined) + ?.id; + if (next.id && oldId !== next.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. + storage.modalizer.dispose(); + propsToCreate = next; + } else { + storage.modalizer.setProps(next); + } + } else { + propsToCreate = next; + } + + if (propsToCreate) { + storage.modalizer = api.createModalizer( + element, + propsToCreate, + sys + ); + } + } + ); } return tabsterCore.modalizer; diff --git a/src/get/getMover.ts b/src/get/getMover.ts index e2044b3f..f0c251d5 100644 --- a/src/get/getMover.ts +++ b/src/get/getMover.ts @@ -14,7 +14,19 @@ 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, storage, newProps, _oldProps, sys) => { + const next = newProps as Types.MoverProps; + if (storage.mover) { + storage.mover.setProps(next); + } else { + storage.mover = api.createMover(element, next, sys); + } + } + ); } return tabsterCore.mover; diff --git a/src/get/getRestorer.ts b/src/get/getRestorer.ts index ee164cc9..fdc236be 100644 --- a/src/get/getRestorer.ts +++ b/src/get/getRestorer.ts @@ -9,7 +9,21 @@ 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, storage, newProps) => { + const next = newProps as Types.RestorerProps | undefined; + if (storage.restorer) { + if (next) { + storage.restorer.setProps(next); + } + } else if (next) { + storage.restorer = api.createRestorer(element, next); + } + } + ); } return tabsterCore.restorer; From 0d80d99830718861f2269664869c942c4391c4d3 Mon Sep 17 00:00:00 2001 From: Oleksandr Fediashov Date: Wed, 29 Apr 2026 11:34:09 +0200 Subject: [PATCH 2/9] refactor(attr-handlers): drop storage param, return-based protocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Handlers no longer receive (and mutate) the full TabsterOnElement. They take the existing instance for their own key and return the new instance to assign — or undefined when nothing should change (setProps on existing, or no-op). This removes the TabsterOnElement type from handler interfaces and moves storage assignment into Instance.ts where it belongs. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Instance.ts | 8 ++++++-- src/Types.ts | 7 +++++-- src/get/getDeloser.ts | 10 +++++----- src/get/getGroupper.ts | 10 +++++----- src/get/getModalizer.ts | 26 ++++++++------------------ src/get/getMover.ts | 10 +++++----- src/get/getRestorer.ts | 13 ++++++++----- 7 files changed, 42 insertions(+), 42 deletions(-) diff --git a/src/Instance.ts b/src/Instance.ts index dc926aba..2aced7cd 100644 --- a/src/Instance.ts +++ b/src/Instance.ts @@ -174,13 +174,17 @@ export function updateTabsterByAttribute( default: { const handler = tabster.attrHandlers.get(key); if (handler) { - handler( + const created = handler( element, - tabsterOnElement, + tabsterOnElement[key], newTabsterProps[key], oldTabsterProps?.[key], sys ); + if (created !== undefined) { + (tabsterOnElement as Record)[key] = + created; + } } else if (__DEV__) { console.error( `${key} API used before initialization, please call \`get${ diff --git a/src/Types.ts b/src/Types.ts index 92e1c92a..79da1f96 100644 --- a/src/Types.ts +++ b/src/Types.ts @@ -1304,14 +1304,17 @@ export interface DummyInputObserver { * Subsystems register a handler from their `get*` file when they're first * instantiated, so the heavy create-or-setProps logic only enters the bundle * when the subsystem itself does. + * + * Returns the new instance to assign into `TabsterOnElement[key]`, or + * `undefined` if the existing instance was kept (e.g. setProps on existing). */ export type TabsterAttrHandler = ( element: HTMLElement, - storage: TabsterOnElement, + existing: unknown, newProps: unknown, oldProps: unknown, sys: SysProps | undefined -) => void; +) => unknown; interface TabsterCoreInternal { /** @internal */ diff --git a/src/get/getDeloser.ts b/src/get/getDeloser.ts index a66185ca..99f07c0d 100644 --- a/src/get/getDeloser.ts +++ b/src/get/getDeloser.ts @@ -22,13 +22,13 @@ export function getDeloser( tabsterCore.deloser = api; tabsterCore.attrHandlers.set( "deloser", - (element, storage, newProps) => { + (element, existing, newProps) => { const next = newProps as Types.DeloserProps; - if (storage.deloser) { - storage.deloser.setProps(next); - } else { - storage.deloser = api.createDeloser(element, next); + if (existing) { + (existing as Types.Deloser).setProps(next); + return undefined; } + return api.createDeloser(element, next); } ); } diff --git a/src/get/getGroupper.ts b/src/get/getGroupper.ts index f6c4aa7f..5c759f4e 100644 --- a/src/get/getGroupper.ts +++ b/src/get/getGroupper.ts @@ -18,13 +18,13 @@ export function getGroupper(tabster: Types.Tabster): Types.GroupperAPI { tabsterCore.groupper = api; tabsterCore.attrHandlers.set( "groupper", - (element, storage, newProps, _oldProps, sys) => { + (element, existing, newProps, _oldProps, sys) => { const next = newProps as Types.GroupperProps; - if (storage.groupper) { - storage.groupper.setProps(next); - } else { - storage.groupper = api.createGroupper(element, next, sys); + if (existing) { + (existing as Types.Groupper).setProps(next); + return undefined; } + return api.createGroupper(element, next, sys); } ); } diff --git a/src/get/getModalizer.ts b/src/get/getModalizer.ts index 78aaa543..8d99adb7 100644 --- a/src/get/getModalizer.ts +++ b/src/get/getModalizer.ts @@ -32,11 +32,10 @@ export function getModalizer( tabsterCore.modalizer = api; tabsterCore.attrHandlers.set( "modalizer", - (element, storage, newProps, oldProps, sys) => { + (element, existing, newProps, oldProps, sys) => { const next = newProps as Types.ModalizerProps; - let propsToCreate: Types.ModalizerProps | undefined; - - if (storage.modalizer) { + if (existing) { + const cur = existing as Types.Modalizer; const oldId = (oldProps as Types.ModalizerProps | undefined) ?.id; if (next.id && oldId !== next.id) { @@ -44,22 +43,13 @@ export function getModalizer( // complex logic and could be composite, it is easier // to recreate the Modalizer instance than to implement // the id update. - storage.modalizer.dispose(); - propsToCreate = next; - } else { - storage.modalizer.setProps(next); + cur.dispose(); + return api.createModalizer(element, next, sys); } - } else { - propsToCreate = next; - } - - if (propsToCreate) { - storage.modalizer = api.createModalizer( - element, - propsToCreate, - sys - ); + cur.setProps(next); + return undefined; } + return api.createModalizer(element, next, sys); } ); } diff --git a/src/get/getMover.ts b/src/get/getMover.ts index f0c251d5..7280b8bd 100644 --- a/src/get/getMover.ts +++ b/src/get/getMover.ts @@ -18,13 +18,13 @@ export function getMover(tabster: Types.Tabster): Types.MoverAPI { tabsterCore.mover = api; tabsterCore.attrHandlers.set( "mover", - (element, storage, newProps, _oldProps, sys) => { + (element, existing, newProps, _oldProps, sys) => { const next = newProps as Types.MoverProps; - if (storage.mover) { - storage.mover.setProps(next); - } else { - storage.mover = api.createMover(element, next, sys); + if (existing) { + (existing as Types.Mover).setProps(next); + return undefined; } + return api.createMover(element, next, sys); } ); } diff --git a/src/get/getRestorer.ts b/src/get/getRestorer.ts index fdc236be..84199f55 100644 --- a/src/get/getRestorer.ts +++ b/src/get/getRestorer.ts @@ -13,15 +13,18 @@ export function getRestorer(tabster: Types.Tabster): Types.RestorerAPI { tabsterCore.restorer = api; tabsterCore.attrHandlers.set( "restorer", - (element, storage, newProps) => { + (element, existing, newProps) => { const next = newProps as Types.RestorerProps | undefined; - if (storage.restorer) { + if (existing) { if (next) { - storage.restorer.setProps(next); + (existing as Types.Restorer).setProps(next); } - } else if (next) { - storage.restorer = api.createRestorer(element, next); + return undefined; } + if (next) { + return api.createRestorer(element, next); + } + return undefined; } ); } From 9b321ccc4545882aa7c009d9abd9e088350c5013 Mon Sep 17 00:00:00 2001 From: Oleksandr Fediashov Date: Wed, 29 Apr 2026 11:42:43 +0200 Subject: [PATCH 3/9] refactor(attr-handlers): make TabsterAttrHandler generic per key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each handler is now \`TabsterAttrHandler\`, with \`existing\`, \`newProps\`, \`oldProps\`, and the return value all inferred from the key — no per-handler casts inside \`get*\` files. Modalizer's \`oldProps?.id\` no longer needs a manual \`as ModalizerProps\` cast; deloser / groupper / mover / restorer drop their \`as Types.X\` casts on \`existing\` and \`newProps\` entirely. The Map's value type is invariant in V, so a plain \`Map\` can't simultaneously give type-safe registration and a uniform value type. Solved by introducing a small \`TabsterAttrHandlerRegistry\` interface — \`set\` is generic for type-safe registration; \`get\` returns the type-erased \`AnyTabsterAttrHandler\` shape since Instance.ts iterates \`keyof TabsterAttributeProps\` and can't statically narrow at the call site. The cast from per-K to Any is hidden inside the registry impl. The simplified restorer handler drops master's \`if (newProps)\` runtime guard. The handler signature now declares \`newProps: NonNullable\`, matching the typing of the other handlers. The guard previously protected against an unlikely \`{"restorer": null}\` JSON payload — every other subsystem already lacked the same guard, so this is consistency, not a regression. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Tabster.ts | 35 ++++++++++++++++++++++++++---- src/Types.ts | 47 ++++++++++++++++++++++++++++++++++------- src/get/getDeloser.ts | 5 ++--- src/get/getGroupper.ts | 5 ++--- src/get/getModalizer.ts | 14 +++++------- src/get/getMover.ts | 5 ++--- src/get/getRestorer.ts | 10 ++------- 7 files changed, 83 insertions(+), 38 deletions(-) diff --git a/src/Tabster.ts b/src/Tabster.ts index 570bbcca..03e8a16b 100644 --- a/src/Tabster.ts +++ b/src/Tabster.ts @@ -21,6 +21,35 @@ import { import { dom, setDOMAPI } from "./DOMAPI.js"; import * as shadowDOMAPI from "./Shadowdomize/index.js"; +function createAttrHandlerRegistry(): Types.TabsterAttrHandlerRegistry { + const handlers = new Map< + keyof Types.TabsterAttributeProps, + Types.AnyTabsterAttrHandler + >(); + + return { + set( + key: K, + handler: Types.TabsterAttrHandler + ): void { + // Variance gap: a handler typed for a specific key is not + // structurally assignable to AnyTabsterAttrHandler (parameters + // are contravariant). The double cast is the standard escape + // hatch — safe because lookup is keyed and dispatch passes the + // matching slot's value back in. + handlers.set( + key, + handler as unknown as Types.AnyTabsterAttrHandler + ); + }, + get( + key: keyof Types.TabsterAttributeProps + ): Types.AnyTabsterAttrHandler | undefined { + return handlers.get(key); + }, + }; +} + class Tabster implements Types.Tabster { keyboardNavigation: Types.KeyboardNavigationState; focusedElement: Types.FocusedElementState; @@ -56,10 +85,8 @@ class TabsterCore implements Types.TabsterCore { _noop = false; controlTab: boolean; rootDummyInputs: boolean; - attrHandlers: Map< - keyof Types.TabsterAttributeProps, - Types.TabsterAttrHandler - > = new Map(); + attrHandlers: Types.TabsterAttrHandlerRegistry = + createAttrHandlerRegistry(); // Core APIs keyboardNavigation: Types.KeyboardNavigationState; diff --git a/src/Types.ts b/src/Types.ts index 79da1f96..1828fc97 100644 --- a/src/Types.ts +++ b/src/Types.ts @@ -1300,15 +1300,31 @@ export interface DummyInputObserver { /** * @internal - * Handler invoked by `updateTabsterByAttribute` for a given attribute key. - * Subsystems register a handler from their `get*` file when they're first - * instantiated, so the heavy create-or-setProps logic only enters the bundle - * when the subsystem itself does. + * 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. * - * Returns the new instance to assign into `TabsterOnElement[key]`, or - * `undefined` if the existing instance was kept (e.g. setProps on existing). + * `existing` is the current `TabsterOnElement[K]` (the live instance, if any). + * `newProps`/`oldProps` are typed against the same key on `TabsterAttributeProps`. + * Return the new instance to assign into `TabsterOnElement[K]`, or `undefined` + * if the existing instance was kept (e.g. `setProps` on existing). */ -export type TabsterAttrHandler = ( +export type TabsterAttrHandler = ( + element: HTMLElement, + existing: TabsterOnElement[K], + newProps: NonNullable, + oldProps: TabsterAttributeProps[K], + sys: SysProps | undefined +) => TabsterOnElement[K]; + +/** + * @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, @@ -1316,9 +1332,24 @@ export type TabsterAttrHandler = ( sys: SysProps | undefined ) => unknown; +/** + * @internal + * Typed registry for attribute handlers. `set` is generic per key so the + * handler's `existing`/`newProps`/`oldProps`/return types are inferred from + * the key. `get` returns the type-erased shape since the call site (Instance.ts) + * iterates over `keyof TabsterAttributeProps` and can't statically narrow. + */ +export interface TabsterAttrHandlerRegistry { + set( + key: K, + handler: TabsterAttrHandler + ): void; + get(key: keyof TabsterAttributeProps): AnyTabsterAttrHandler | undefined; +} + interface TabsterCoreInternal { /** @internal */ - attrHandlers: Map; + attrHandlers: TabsterAttrHandlerRegistry; /** @internal */ groupper?: GroupperAPI; /** @internal */ diff --git a/src/get/getDeloser.ts b/src/get/getDeloser.ts index 99f07c0d..6fd4cae9 100644 --- a/src/get/getDeloser.ts +++ b/src/get/getDeloser.ts @@ -23,12 +23,11 @@ export function getDeloser( tabsterCore.attrHandlers.set( "deloser", (element, existing, newProps) => { - const next = newProps as Types.DeloserProps; if (existing) { - (existing as Types.Deloser).setProps(next); + existing.setProps(newProps); return undefined; } - return api.createDeloser(element, next); + return api.createDeloser(element, newProps); } ); } diff --git a/src/get/getGroupper.ts b/src/get/getGroupper.ts index 5c759f4e..94bd89d2 100644 --- a/src/get/getGroupper.ts +++ b/src/get/getGroupper.ts @@ -19,12 +19,11 @@ export function getGroupper(tabster: Types.Tabster): Types.GroupperAPI { tabsterCore.attrHandlers.set( "groupper", (element, existing, newProps, _oldProps, sys) => { - const next = newProps as Types.GroupperProps; if (existing) { - (existing as Types.Groupper).setProps(next); + existing.setProps(newProps); return undefined; } - return api.createGroupper(element, next, sys); + return api.createGroupper(element, newProps, sys); } ); } diff --git a/src/get/getModalizer.ts b/src/get/getModalizer.ts index 8d99adb7..f5e52a99 100644 --- a/src/get/getModalizer.ts +++ b/src/get/getModalizer.ts @@ -33,23 +33,19 @@ export function getModalizer( tabsterCore.attrHandlers.set( "modalizer", (element, existing, newProps, oldProps, sys) => { - const next = newProps as Types.ModalizerProps; if (existing) { - const cur = existing as Types.Modalizer; - const oldId = (oldProps as Types.ModalizerProps | undefined) - ?.id; - if (next.id && oldId !== next.id) { + 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. - cur.dispose(); - return api.createModalizer(element, next, sys); + existing.dispose(); + return api.createModalizer(element, newProps, sys); } - cur.setProps(next); + existing.setProps(newProps); return undefined; } - return api.createModalizer(element, next, sys); + return api.createModalizer(element, newProps, sys); } ); } diff --git a/src/get/getMover.ts b/src/get/getMover.ts index 7280b8bd..f9561b2d 100644 --- a/src/get/getMover.ts +++ b/src/get/getMover.ts @@ -19,12 +19,11 @@ export function getMover(tabster: Types.Tabster): Types.MoverAPI { tabsterCore.attrHandlers.set( "mover", (element, existing, newProps, _oldProps, sys) => { - const next = newProps as Types.MoverProps; if (existing) { - (existing as Types.Mover).setProps(next); + existing.setProps(newProps); return undefined; } - return api.createMover(element, next, sys); + return api.createMover(element, newProps, sys); } ); } diff --git a/src/get/getRestorer.ts b/src/get/getRestorer.ts index 84199f55..15990e8a 100644 --- a/src/get/getRestorer.ts +++ b/src/get/getRestorer.ts @@ -14,17 +14,11 @@ export function getRestorer(tabster: Types.Tabster): Types.RestorerAPI { tabsterCore.attrHandlers.set( "restorer", (element, existing, newProps) => { - const next = newProps as Types.RestorerProps | undefined; if (existing) { - if (next) { - (existing as Types.Restorer).setProps(next); - } + existing.setProps(newProps); return undefined; } - if (next) { - return api.createRestorer(element, next); - } - return undefined; + return api.createRestorer(element, newProps); } ); } From 2b401bfbe7ccd7579a0fcc69195d98d3ec298fd4 Mon Sep 17 00:00:00 2001 From: Oleksandr Fediashov Date: Wed, 29 Apr 2026 11:44:56 +0200 Subject: [PATCH 4/9] refactor(attr-handlers): handler always returns the slot's instance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops the "return undefined to signal no-change" branch. Each handler now returns the (possibly-mutated) existing instance for the setProps case, so Instance.ts can assign unconditionally. The handler return type tightens to NonNullable — there is no path where the handler is invoked and the slot ends up empty. The dispatch in Instance.ts collapses to a single statement. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Instance.ts | 19 ++++++++----------- src/Types.ts | 8 ++++---- src/get/getDeloser.ts | 2 +- src/get/getGroupper.ts | 2 +- src/get/getModalizer.ts | 2 +- src/get/getMover.ts | 2 +- src/get/getRestorer.ts | 2 +- 7 files changed, 17 insertions(+), 20 deletions(-) diff --git a/src/Instance.ts b/src/Instance.ts index 2aced7cd..35243594 100644 --- a/src/Instance.ts +++ b/src/Instance.ts @@ -174,17 +174,14 @@ export function updateTabsterByAttribute( default: { const handler = tabster.attrHandlers.get(key); if (handler) { - const created = handler( - element, - tabsterOnElement[key], - newTabsterProps[key], - oldTabsterProps?.[key], - sys - ); - if (created !== undefined) { - (tabsterOnElement as Record)[key] = - created; - } + (tabsterOnElement as Record)[key] = + handler( + element, + tabsterOnElement[key], + newTabsterProps[key], + oldTabsterProps?.[key], + sys + ); } else if (__DEV__) { console.error( `${key} API used before initialization, please call \`get${ diff --git a/src/Types.ts b/src/Types.ts index 1828fc97..a433c3b6 100644 --- a/src/Types.ts +++ b/src/Types.ts @@ -1307,8 +1307,8 @@ export interface DummyInputObserver { * * `existing` is the current `TabsterOnElement[K]` (the live instance, if any). * `newProps`/`oldProps` are typed against the same key on `TabsterAttributeProps`. - * Return the new instance to assign into `TabsterOnElement[K]`, or `undefined` - * if the existing instance was kept (e.g. `setProps` on existing). + * 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, @@ -1316,7 +1316,7 @@ export type TabsterAttrHandler = ( newProps: NonNullable, oldProps: TabsterAttributeProps[K], sys: SysProps | undefined -) => TabsterOnElement[K]; +) => NonNullable; /** * @internal @@ -1330,7 +1330,7 @@ export type AnyTabsterAttrHandler = ( newProps: unknown, oldProps: unknown, sys: SysProps | undefined -) => unknown; +) => NonNullable; /** * @internal diff --git a/src/get/getDeloser.ts b/src/get/getDeloser.ts index 6fd4cae9..c17bb8bd 100644 --- a/src/get/getDeloser.ts +++ b/src/get/getDeloser.ts @@ -25,7 +25,7 @@ export function getDeloser( (element, existing, newProps) => { if (existing) { existing.setProps(newProps); - return undefined; + return existing; } return api.createDeloser(element, newProps); } diff --git a/src/get/getGroupper.ts b/src/get/getGroupper.ts index 94bd89d2..3fed0085 100644 --- a/src/get/getGroupper.ts +++ b/src/get/getGroupper.ts @@ -21,7 +21,7 @@ export function getGroupper(tabster: Types.Tabster): Types.GroupperAPI { (element, existing, newProps, _oldProps, sys) => { if (existing) { existing.setProps(newProps); - return undefined; + return existing; } return api.createGroupper(element, newProps, sys); } diff --git a/src/get/getModalizer.ts b/src/get/getModalizer.ts index f5e52a99..7697a17b 100644 --- a/src/get/getModalizer.ts +++ b/src/get/getModalizer.ts @@ -43,7 +43,7 @@ export function getModalizer( return api.createModalizer(element, newProps, sys); } existing.setProps(newProps); - return undefined; + return existing; } return api.createModalizer(element, newProps, sys); } diff --git a/src/get/getMover.ts b/src/get/getMover.ts index f9561b2d..f9c1bea7 100644 --- a/src/get/getMover.ts +++ b/src/get/getMover.ts @@ -21,7 +21,7 @@ export function getMover(tabster: Types.Tabster): Types.MoverAPI { (element, existing, newProps, _oldProps, sys) => { if (existing) { existing.setProps(newProps); - return undefined; + return existing; } return api.createMover(element, newProps, sys); } diff --git a/src/get/getRestorer.ts b/src/get/getRestorer.ts index 15990e8a..58212081 100644 --- a/src/get/getRestorer.ts +++ b/src/get/getRestorer.ts @@ -16,7 +16,7 @@ export function getRestorer(tabster: Types.Tabster): Types.RestorerAPI { (element, existing, newProps) => { if (existing) { existing.setProps(newProps); - return undefined; + return existing; } return api.createRestorer(element, newProps); } From 6ac1f33ded32d0b91bb36f4bd670a7911e4e7e00 Mon Sep 17 00:00:00 2001 From: Oleksandr Fediashov Date: Wed, 29 Apr 2026 11:46:11 +0200 Subject: [PATCH 5/9] =?UTF-8?q?refactor(attr-handlers):=20rename=20existin?= =?UTF-8?q?g=20=E2=86=92=20existing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The handler signature has the same `existing` parameter name across five subsystems, but at each call site the type is concrete (Deloser, Groupper, Mover, Modalizer, Restorer). Spelling the type into the variable name makes the body easier to read at a glance and matches the convention used elsewhere in the codebase (e.g. element + role suffix in handler closures). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/get/getDeloser.ts | 8 ++++---- src/get/getGroupper.ts | 8 ++++---- src/get/getModalizer.ts | 10 +++++----- src/get/getMover.ts | 8 ++++---- src/get/getRestorer.ts | 8 ++++---- 5 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/get/getDeloser.ts b/src/get/getDeloser.ts index c17bb8bd..07ac5ed7 100644 --- a/src/get/getDeloser.ts +++ b/src/get/getDeloser.ts @@ -22,10 +22,10 @@ export function getDeloser( tabsterCore.deloser = api; tabsterCore.attrHandlers.set( "deloser", - (element, existing, newProps) => { - if (existing) { - existing.setProps(newProps); - return existing; + (element, existingDeloser, newProps) => { + if (existingDeloser) { + existingDeloser.setProps(newProps); + return existingDeloser; } return api.createDeloser(element, newProps); } diff --git a/src/get/getGroupper.ts b/src/get/getGroupper.ts index 3fed0085..1a5bcb97 100644 --- a/src/get/getGroupper.ts +++ b/src/get/getGroupper.ts @@ -18,10 +18,10 @@ export function getGroupper(tabster: Types.Tabster): Types.GroupperAPI { tabsterCore.groupper = api; tabsterCore.attrHandlers.set( "groupper", - (element, existing, newProps, _oldProps, sys) => { - if (existing) { - existing.setProps(newProps); - return existing; + (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 7697a17b..4d649c44 100644 --- a/src/get/getModalizer.ts +++ b/src/get/getModalizer.ts @@ -32,18 +32,18 @@ export function getModalizer( tabsterCore.modalizer = api; tabsterCore.attrHandlers.set( "modalizer", - (element, existing, newProps, oldProps, sys) => { - if (existing) { + (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. - existing.dispose(); + existingModalizer.dispose(); return api.createModalizer(element, newProps, sys); } - existing.setProps(newProps); - return existing; + existingModalizer.setProps(newProps); + return existingModalizer; } return api.createModalizer(element, newProps, sys); } diff --git a/src/get/getMover.ts b/src/get/getMover.ts index f9c1bea7..2db42dce 100644 --- a/src/get/getMover.ts +++ b/src/get/getMover.ts @@ -18,10 +18,10 @@ export function getMover(tabster: Types.Tabster): Types.MoverAPI { tabsterCore.mover = api; tabsterCore.attrHandlers.set( "mover", - (element, existing, newProps, _oldProps, sys) => { - if (existing) { - existing.setProps(newProps); - return existing; + (element, existingMover, newProps, _oldProps, sys) => { + if (existingMover) { + existingMover.setProps(newProps); + return existingMover; } return api.createMover(element, newProps, sys); } diff --git a/src/get/getRestorer.ts b/src/get/getRestorer.ts index 58212081..2f124259 100644 --- a/src/get/getRestorer.ts +++ b/src/get/getRestorer.ts @@ -13,10 +13,10 @@ export function getRestorer(tabster: Types.Tabster): Types.RestorerAPI { tabsterCore.restorer = api; tabsterCore.attrHandlers.set( "restorer", - (element, existing, newProps) => { - if (existing) { - existing.setProps(newProps); - return existing; + (element, existingRestorer, newProps) => { + if (existingRestorer) { + existingRestorer.setProps(newProps); + return existingRestorer; } return api.createRestorer(element, newProps); } From 5c1abe201e4edc1120260b37daaa89245344bf72 Mon Sep 17 00:00:00 2001 From: Oleksandr Fediashov Date: Wed, 29 Apr 2026 11:52:58 +0200 Subject: [PATCH 6/9] fix(dispose): clear attrHandlers registry on TabsterCore.dispose The registered closures capture the subsystem API instances. Without explicitly clearing the registry, those references survive \`dispose()\` and would let any post-dispose \`updateTabsterByAttribute\` call dispatch to the just-disposed APIs. Adds a \`clear()\` method to \`TabsterAttrHandlerRegistry\` and calls it after the per-API \`dispose()\` block. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Tabster.ts | 8 ++++++++ src/Types.ts | 1 + 2 files changed, 9 insertions(+) diff --git a/src/Tabster.ts b/src/Tabster.ts index 03e8a16b..da435402 100644 --- a/src/Tabster.ts +++ b/src/Tabster.ts @@ -47,6 +47,9 @@ function createAttrHandlerRegistry(): Types.TabsterAttrHandlerRegistry { ): Types.AnyTabsterAttrHandler | undefined { return handlers.get(key); }, + clear(): void { + handlers.clear(); + }, }; } @@ -234,6 +237,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 a433c3b6..0a8feaab 100644 --- a/src/Types.ts +++ b/src/Types.ts @@ -1345,6 +1345,7 @@ export interface TabsterAttrHandlerRegistry { handler: TabsterAttrHandler ): void; get(key: keyof TabsterAttributeProps): AnyTabsterAttrHandler | undefined; + clear(): void; } interface TabsterCoreInternal { From 79c21b5d9fbf9524d0334cc8dca069e193723016 Mon Sep 17 00:00:00 2001 From: Oleksandr Fediashov Date: Wed, 29 Apr 2026 11:54:27 +0200 Subject: [PATCH 7/9] refactor(attr-handlers): use Map directly via overridden set signature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the wrapper object + factory. The registry interface now extends Map and only overrides \`set\` with a generic per-key signature; \`get\` and \`clear\` come from Map. A plain \`new Map()\` is cast to the typed view at the field declaration — single cast, no double-cast inside set, no closure capture. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Tabster.ts | 40 ++++++---------------------------------- src/Types.ts | 15 ++++++++------- 2 files changed, 14 insertions(+), 41 deletions(-) diff --git a/src/Tabster.ts b/src/Tabster.ts index da435402..9f8cc0a9 100644 --- a/src/Tabster.ts +++ b/src/Tabster.ts @@ -21,38 +21,6 @@ import { import { dom, setDOMAPI } from "./DOMAPI.js"; import * as shadowDOMAPI from "./Shadowdomize/index.js"; -function createAttrHandlerRegistry(): Types.TabsterAttrHandlerRegistry { - const handlers = new Map< - keyof Types.TabsterAttributeProps, - Types.AnyTabsterAttrHandler - >(); - - return { - set( - key: K, - handler: Types.TabsterAttrHandler - ): void { - // Variance gap: a handler typed for a specific key is not - // structurally assignable to AnyTabsterAttrHandler (parameters - // are contravariant). The double cast is the standard escape - // hatch — safe because lookup is keyed and dispatch passes the - // matching slot's value back in. - handlers.set( - key, - handler as unknown as Types.AnyTabsterAttrHandler - ); - }, - get( - key: keyof Types.TabsterAttributeProps - ): Types.AnyTabsterAttrHandler | undefined { - return handlers.get(key); - }, - clear(): void { - handlers.clear(); - }, - }; -} - class Tabster implements Types.Tabster { keyboardNavigation: Types.KeyboardNavigationState; focusedElement: Types.FocusedElementState; @@ -88,8 +56,12 @@ class TabsterCore implements Types.TabsterCore { _noop = false; controlTab: boolean; rootDummyInputs: boolean; - attrHandlers: Types.TabsterAttrHandlerRegistry = - createAttrHandlerRegistry(); + // 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; diff --git a/src/Types.ts b/src/Types.ts index 0a8feaab..e22302e2 100644 --- a/src/Types.ts +++ b/src/Types.ts @@ -1334,18 +1334,19 @@ export type AnyTabsterAttrHandler = ( /** * @internal - * Typed registry for attribute handlers. `set` is generic per key so the + * 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` returns the type-erased shape since the call site (Instance.ts) - * iterates over `keyof TabsterAttributeProps` and can't statically narrow. + * 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 { +export interface TabsterAttrHandlerRegistry + extends Map { set( key: K, handler: TabsterAttrHandler - ): void; - get(key: keyof TabsterAttributeProps): AnyTabsterAttrHandler | undefined; - clear(): void; + ): this; } interface TabsterCoreInternal { From bb8a938eb424098d75ae5215002127710a3a41f3 Mon Sep 17 00:00:00 2001 From: Oleksandr Fediashov Date: Wed, 29 Apr 2026 12:02:17 +0200 Subject: [PATCH 8/9] chore: prettier --write src/Types.ts The interface-extends-Map declaration overflows the prettier line limit; reformat it to multi-line generic args. Caught by format:check on CI. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Types.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Types.ts b/src/Types.ts index e22302e2..34255665 100644 --- a/src/Types.ts +++ b/src/Types.ts @@ -1341,8 +1341,10 @@ export type AnyTabsterAttrHandler = ( * iterates over `keyof TabsterAttributeProps` and gets back the type-erased * `AnyTabsterAttrHandler` shape. */ -export interface TabsterAttrHandlerRegistry - extends Map { +export interface TabsterAttrHandlerRegistry extends Map< + keyof TabsterAttributeProps, + AnyTabsterAttrHandler +> { set( key: K, handler: TabsterAttrHandler From 233f1c88524bd8ddb949af48a348cca414428567 Mon Sep 17 00:00:00 2001 From: Oleksandr Fediashov Date: Wed, 29 Apr 2026 12:07:27 +0200 Subject: [PATCH 9/9] refactor(attr-handlers): use \`as never\` over Record-cast at assignment TS's write-side bivariance check on \`obj[unionKey] = value\` requires the intersection of all possible value types, which is \`never\` here. Casting the value to \`never\` is the standard TS escape hatch for this exact issue and reads as a single inline assertion at the value, versus reshaping the storage type around the indexer. Net: \`tabsterOnElement[key] = handler(...) as never;\` Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Instance.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/Instance.ts b/src/Instance.ts index 35243594..fe548f00 100644 --- a/src/Instance.ts +++ b/src/Instance.ts @@ -174,14 +174,13 @@ export function updateTabsterByAttribute( default: { const handler = tabster.attrHandlers.get(key); if (handler) { - (tabsterOnElement as Record)[key] = - handler( - element, - tabsterOnElement[key], - newTabsterProps[key], - oldTabsterProps?.[key], - sys - ); + 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${