Skip to content

refactor(bundle-size): route attribute updates through a handler registry (alt to #523)#529

Merged
layershifter merged 9 commits into
microsoft:masterfrom
layershifter:refactor/attr-handler-registry
Apr 29, 2026
Merged

refactor(bundle-size): route attribute updates through a handler registry (alt to #523)#529
layershifter merged 9 commits into
microsoft:masterfrom
layershifter:refactor/attr-handler-registry

Conversation

@layershifter
Copy link
Copy Markdown
Member

Alternative to #523

Same goal — pull the per-subsystem create/setProps logic out of Instance.ts's switch so a getGroupper-only bundle no longer ships the dead case "deloser": / "modalizer": / etc. branches. Different mechanism.

#523 attaches an applyAttribute(element, storage, newProps, …) method to each subsystem API class (DeloserAPI, GroupperAPI, ModalizerAPI, MoverAPI, RestorerAPI).

This PR introduces tabster.attrHandlers: Map<key, handler>. Each get* file registers a closure on the map when its API is first instantiated. Instance.ts looks up the handler by key — it has no idea which subsystems exist.

Tradeoffs

#523 (method on API) this PR (registry)
Instance.ts knows subsystem names yes (5 case labels with API references) no (single default branch via Map lookup)
Subsystem API class touches TabsterOnElement yes (applyAttribute(…, storage, …) mutates it) no (closure in get* does)
Discoverability via jump-to-symbol direct (ModalizerAPI.applyAttribute) indirect (via registry)
Type safety on per-handler signatures strong (per-API typed method) weak (handler unifies via unknown, casts inside)
Adding a new subsystem edit Instance.ts + add method on API edit get* only
Runtime cost on attribute mutation direct method call one Map.get per attribute key

Why I prefer this shape

updateTabsterByAttribute is the attribute pipeline. The fact that "modalizer" or "groupper" exist as concepts is a layering above it. With a registry the pipeline becomes generic, and the layer that knows about subsystems (the wiring, in get*) is where the subsystem-specific code lives. That matches how the rest of TabsterCore works — tabster.modalizer etc. are populated from getModalizer(), not declared statically.

What still lives in Instance.ts's switch

The cases that have no create-or-setProps logic to relocate:

  • rootRootAPI is always present, no API-gating
  • focusable / uncontrolled / sys — literal property assignments
  • observed / outline — 1-2 lines each, no factory call

Putting these through the registry would add indirection without saving bytes.

Bundle-size

Identical shape to #523's gain: each subsystem's create-or-setProps closure ships only with its own get* file, which is only imported when the consumer calls getDeloser() / getGroupper() / getMover() / getModalizer() / getRestorer(). The registry itself is one Map per TabsterCore, amortized.

Behavior changes vs master

Both this PR and #523 introduce the same two:

  1. The dev-only "X API used before initialization" error now fires whenever the registry has no handler — i.e. getX() was never called — instead of being conditional on both tabster.X and tabsterOnElement.X being undefined. The state where they diverge requires the API to have been disposed mid-flight, which the codebase doesn't construct.
  2. Restorer: when tabsterOnElement.restorer exists and newTabsterProps.restorer is undefined, master called setProps(undefined as RestorerProps). The new code skips. No test exercises the previous behavior.

Verification

  • npm run type-check (lib + tests + stories) passes
  • npm run lint:check passes
  • npm run format:check passes
  • Full puppeteer suite — runs in CI

Out of scope

The dispose loop at Instance.ts:74-118 still hardcodes the list of "Part" subsystems with .dispose(). It doesn't reference subsystem APIs (just tabsterOnElement[key].dispose()), so there's no bundle-size benefit to making it generic. Keeping it inline.

Open this PR alongside #523 — pick whichever shape you prefer. Closing one when the other lands is fine.

🤖 Generated with Claude Code

Same goal as microsoft#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<key, handler>`
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 microsoft#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) <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 29, 2026

📊 Bundle size report

Package & Exports Baseline (minified/GZIP) PR Change
tabster
all exports
113.989 kB
30.989 kB
113.84 kB
30.937 kB
-149 B
-52 B
tabster
createTabster (core)
40.45 kB
12.087 kB
39.761 kB
11.89 kB
-689 B
-197 B
tabster
getCrossOrigin
110.866 kB
30.322 kB
110.623 kB
30.244 kB
-243 B
-78 B
tabster
getDeloser
49.865 kB
14.399 kB
49.268 kB
14.233 kB
-597 B
-166 B
tabster
getGroupper
47.589 kB
13.755 kB
47 kB
13.592 kB
-589 B
-163 B
tabster
getModalizer
49.601 kB
14.517 kB
49.072 kB
14.378 kB
-529 B
-139 B
tabster
getMover
55.248 kB
16.048 kB
54.653 kB
15.887 kB
-595 B
-161 B
tabster
getObservedElement
46.253 kB
13.678 kB
45.564 kB
13.483 kB
-689 B
-195 B
tabster
getOutline
49.556 kB
14.411 kB
48.867 kB
14.216 kB
-689 B
-195 B
tabster
getRestorer
43.113 kB
12.687 kB
42.518 kB
12.519 kB
-595 B
-168 B

🤖 This report was generated against 1f90765aecd3c5914d21278031d35cbf763f36fb

layershifter and others added 8 commits April 29, 2026 11:34
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) <noreply@anthropic.com>
Each handler is now \`TabsterAttrHandler<K extends keyof TabsterAttributeProps>\`,
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<key, handler>\`
can't simultaneously give type-safe registration and a uniform value
type. Solved by introducing a small \`TabsterAttrHandlerRegistry\`
interface — \`set<K>\` 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<RestorerProps>\`, 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) <noreply@anthropic.com>
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<TabsterOnElement[K]> — 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
Drop the wrapper object + factory. The registry interface now extends
Map<key, AnyTabsterAttrHandler> 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
@layershifter layershifter merged commit 53dc35c into microsoft:master Apr 29, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants