Skip to content

fix: one engine emission, structural brands, and a verified wrap-rows exit - #448

Open
blove wants to merge 4 commits into
mainfrom
lane2-int
Open

fix: one engine emission, structural brands, and a verified wrap-rows exit#448
blove wants to merge 4 commits into
mainfrom
lane2-int

Conversation

@blove

@blove blove commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Summary

The last three follow-ups in our lane. Two turned out to be bigger than filed.


1. A branded type was nominally split — and five brands were affected, not one

pretable-model.ts carried an as unknown as because PretableGroupId's unique symbol was emitted once by tsc into grid-core/dist and again by tsup into core/dist — two unique symbols from two declaration files are two different types.

The audit found five brands crossing that boundary, and one of them was worse than a cast:

brand status
groupIdBrand live — cast #1
rowModelDescriptor live — cast #2, plus a silent failure
columnDescriptor latent (required key would have blocked)
gridUiCoreType latent-weak (optional key — variance marker meaningless across the seam)
rowLayoutControllerType not published today; converted so no bad exemplar remains

RowOf<> / RowIdOf<> / ColumnsOf<> match structurally on rowModelDescriptor, so across the seam they resolved to never with no diagnostic at all. Strictly worse than a cast, which at least marks the spot.

Approach: string-literal brand keys (readonly "~pretableGroupId"). Structural, so N emissions are one type regardless of future bundling topology. The "re-export the declaration" alternatives were rejected because @pretable-internal/* are private and unpublished — both would require publishing an internal engine's entire surface.

Brands were only half of it

TypeScript relates a deferred conditional type by the identity of its alias declaration, and PretableAggregateOutputOf<> is one — no brand change can reach that. So the engine now has a single emission: @pretable/react reaches it through ɵcreateGridUiCore re-exported from @pretable/core, rather than importing @pretable-internal/grid-core directly.

Independently verified: react/dist/index.mjs defines createGridUiCore zero times and imports it from '@pretable/core', where it is defined once. Two runtime copies became one.

A correction to the brief

I said typecheck:public would expose the split. It would not — react/dist/index.d.ts imports PretableGroupId from @pretable/core rather than re-emitting it, so consumer-level type tests saw one consistent brand. The split lived in react's own dist-resolved compile and in the .api.md reports, which printed readonly [groupIdBrand] while never declaring groupIdBrand — not even self-consistent. Assertions went where the defect is, plus a fail-closed report guard.

pretable-model.ts casts: 8 → 5; as unknown as: 2 → 0. The five that remain are unrelated local narrowings.

2. A hand-copied union that had already broken the build

moveFocus's movement parameter was a hand-written copy of the string union. When first-column/last-column were added, the stale copy rejected them and broke the build. It now imports PretableIndexedFocusMovement; the cast at the call site is gone.

Pinned so the drift cannot return: adding a member to the engine union leaves pretable-model.ts with an empty diff and fails only the enumeration assertion.

3. The wrap-rows claim was true — the summary above it was not

wrap-rows was a keyboard trap until recently (WCAG 2.1.2, 120 presses without escape), so "Tab leaves in one press" is the claim that the trap is gone — and it was the one configuration nothing on the site rendered.

A fixture now drives it. Chromium and WebKit produced byte-identical traces:

from Tab forward Shift+Tab back
top-left cell (entry) 12 1
mid-grid 8 9
header cell, first or last column 1 1

The flagged sentence lives in the header table under "While the cursor is on it", and the surface hands Tab back on a header cell before it ever looks at the column index — so one press is correct in its stated scope, now verified rather than assumed.

What was false is the page's opening summary: "one Tab enters it… and one more Tab leaves", unqualified. Under wrap-rows that is 8 and 12 on a twelve-cell grid. 12 is exactly rows × columns; backward from that same cell is 1, because it is already at the other release corner. The round-trip sentence was also wrong — Shift+Tab restores the cell the walk left from, not where it started.

Test Plan

  • 132/132 browser tests across Chromium and WebKit on the integrated lane.
  • Full unit suite, typecheck, typecheck:public, lint, prettier, build, api:check — clean.
  • Mutation-proved throughout. Two worth noting: widening the movement union by a bogus member builds successfully and fails only the type test — catching drift the compiler does not; and restoring the wrap-rows corner clamp fails 10 of 18 exit tests while the four header tests per engine keep passing, since the header release is a separate branch.
  • The rowModelDescriptor mutation catches the silent never case, which had no diagnostic before.

Found, not fixed — two are latent hazards

A scan turned up ~29 duplicated types. The diagnosed class is fixed; the rest are reported rather than sprawling the diff. Two are real hazards where a copy was silently widened:

  • SurfaceFacade.getSnapshot().editing.status is string, not PretableEditStatus — and is compared against "editing" at pretable-surface.tsx:3273.
  • csv.ts:348 takes {kind: string}, not PretableExpansionDefault — compared against "expanded", so renaming that literal would silently flip CSV completeness.

Also: PretableSortDirection has 8 byte-identical copies; PretableExportScope 10; ColumnType and PretableColumnType are byte-identical and both published from @pretable/core — two public names for one union.

🤖 Generated with Claude Code

blove and others added 3 commits August 15, 2026 18:49
…rect the Tab claim

`wrap-rows` was a WCAG 2.1.2 keyboard trap — Tab and Shift+Tab consumed
unconditionally, clamped at both corners, 120 presses never out. The fix
was to RELEASE at the corners, and until now that fix rested on a jsdom
test. jsdom has no sequential focus order at all, so it can only say which
presses the surface calls `preventDefault()` on; "the grid did not consume
the press" and "focus left the grid" are different statements, and only the
second is the absence of a trap. No page on the site rendered a `wrap-rows`
grid, so nobody had ever driven the one configuration that used to trap.

Adds `/fixtures/tab-wrap-rows` (3 columns x 4 rows, `wrap-rows`, bracketed
by two text inputs) and a spec that asserts EXACT press counts in Chromium
and WebKit. Text inputs rather than buttons as sentinels: macOS keeps bare
buttons out of Safari's tab order while CI's Linux WebKit includes them, so
a button sentinel pins an operating system. Naming the landing element is
what distinguishes a one-press release from a lap of the document.

Measured, identical in both engines:

- Tab from the top-left cell: 12 presses out — exactly rows x columns.
- Tab from mid-grid (r2,bravo): 8 presses out.
- Shift+Tab from the top-left cell: 1 press out.
- Shift+Tab from (r3,charlie): 9 presses out.
- Tab / Shift+Tab from a header cell, first column or last: 1 press, both
  directions.

So the header table's "in one press, whatever `tabBehavior` is set to" is
TRUE — it is scoped to the header, and the surface hands Tab back on a
header cell before it looks at the column index. What was false is the
page's opening summary, which promised "one more Tab leaves" without
qualification: under `wrap-rows` that is 8 and 12 presses on a grid of
twelve cells. That sentence now names the default and points at the walk,
the header row names its own scope, and the round-trip sentence says the
grid restores the cell it left FROM — the release corner, not where the
walk started.

Mutation-proved by restoring the corner clamp in `pretable-surface.tsx`:
10 of the 18 tests fail (5 per engine, `presses: null`), and the four
header tests per engine keep passing, which is correct — the header release
is a different branch. Reverted; no `packages/` change ships here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…he casts

`@pretable/core` bundles `@pretable-internal/grid-core` and
`@pretable-internal/row-model` (`noExternal`), so `tsup`'s bundled `.d.ts`
re-emits their declarations alongside the copies `tsc` writes into each
package's own `dist`. `@pretable/react` compiled against BOTH, and paid for it
with three casts in `pretable-model.ts`.

Two things made two emissions two types:

1. `unique symbol` brands, which are nominal PER DECLARATION FILE. Four were
   affected — `groupIdBrand`, `rowModelDescriptor`, `columnDescriptor`,
   `gridUiCoreType` — plus `rowLayoutControllerType` in renderer-dom, which had
   not leaked yet. All are now string-literal keys (`~pretableGroupId`), which
   are structural, so N copies are one type. Nominality is unchanged: the
   branded types are intersections nothing inhabits without a cast, and `~`
   cannot be written as an identifier. The silent half mattered more than the
   casts: `RowOf` / `RowIdOf` / `ColumnsOf` match structurally on the row-model
   brand and resolved to `never` across the seam with no diagnostic.

2. Deferred conditional types, which TypeScript relates by ALIAS IDENTITY —
   `PretableAggregateOutputOf<TAggregate>` cannot be fixed by any brand change.
   So the packages that compile alongside `@pretable/core` now reach the engine
   THROUGH it: `@pretable/react` imports `ɵcreateGridUiCore` and friends from
   `@pretable/core` rather than from `@pretable-internal/grid-core`, and
   `@pretable-internal/renderer-dom` types its row model from `@pretable/core`
   too. One declaration emission, and one runtime copy of the engine instead of
   two.

Also fixes the hand-copied `moveFocus` union: `PretableReactGrid.moveFocus`
imports `PretableIndexedFocusMovement` instead of respelling it (the copy was
two members behind and the repair had been a cast at the call site in
`pretable-surface.tsx`), the duplicated `{pageRows?: number}` becomes the new
`PretableIndexedMoveFocusOptions`, `pretable-surface.tsx` stops shadowing
`PretableFocusDirection` with a local copy and stops respelling
`PretableMoveFocusOptions`, and `PretableGridUiSnapshot` becomes an alias of
`PretableGridUiState` rather than a 74-line structural copy of it (which also
retires `PretableReactRowRange(Index)`, second names for
`PretableIndexedRowRange(Index)`).

Guards:
- `scripts/__tests__/public-api-symbol-brands.test.mjs` fails if a symbol-keyed
  brand reappears in any published API report; the package set is discovered
  from `api-extractor.json`, so a new package is covered the day it is added.
- `packages/react/src/__tests__/cross-emission-type-identity.types.ts` asserts
  the two emissions are the same types, including `RowOf` not collapsing.
- `type-tests/react/focus-movement-union.types.tsx` pins the movement union and
  its options to the engine's, in both directions.
- `packages/react/src/__tests__/move-focus-movements.test.tsx` asserts every
  movement still moves focus where it should, and that `pageRows` arrives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 16, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
pretable Ignored Ignored Aug 17, 2026 3:34am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

Vercel preview ready

Preview: https://pretable-4sm0o0sa4-cacheplane.vercel.app
Commit: e39ee834ba07d71ba03f70dca7a07e1d7406bf9c

Updated automatically by the deploy-preview job.

The conflict was substantive, not textual: main added a `pageRows` option to
the inline copy of the move-focus options while this branch was replacing that
copy with the imported `PretableMoveFocusOptions`. Keeping only one side would
have dropped either the de-duplication or the new option, so `pageRows` moves
onto the named type where the copy used to spell it.
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.

1 participant