Skip to content

One component with switchable options, virtualization and windowing - #4

Open
yaotzin1 wants to merge 9 commits into
mainfrom
feature/unified-options-and-windowing
Open

One component with switchable options, virtualization and windowing#4
yaotzin1 wants to merge 9 commits into
mainfrom
feature/unified-options-and-windowing

Conversation

@yaotzin1

Copy link
Copy Markdown
Owner

Every capability was becoming a component: a tree was TreeGridwright, row actions were BubbleMenu composed by hand, editing was editableColumns plus InlineEditProvider composed by hand. Each worked. None of them worked together without reassembling the component's own arrangement, and the tree was a parallel implementation that could never gain anything added to <Gridwright />.

They are now options on one component, and they compose:

<Gridwright
    columns={columns}
    data={folders}
    tree={{ getRowId, getChildren, controllerRef: setTree }}
    virtual
    rowActions={actions}
    onCellEdit={commit}
/>

Each piece is still exported for a layout composed by hand, and TreeGridwright survives as an alias with no second implementation behind it.

Scale, which is two problems

  • virtual renders only the rows on screen, carrying the rest in two spacer <tr> rows so the element stays a real <table role="grid"> with real column alignment. aria-rowcount and aria-rowindex carry the true numbers.
  • createWindowedDataSource holds blockSize * maxBlocks rows however large the result set is.

Measured in the browser at ten million rows: 23 rendered rows, 2 blocks cached, 400 rows resident, 2 range requests.

Two bugs the test suite did not find

Driving the playground in Chrome found both.

  • The last nine and a half million rows were unreachable. A browser will not render an element taller than about 2^24 pixels, so the scrollbar stopped at row ~419,000 while aria-rowcount claimed ten million, silently. Above that height scroll position is now a ratio over the result set, with the cost written down rather than hidden.
  • Two overlapping window fetches could leave the grid empty and ready. A block being loaded for a request that was then aborted was joined by the next request, resolved carrying nothing, and the survivor built its window from an empty cache. A load is now only joined while its own request is alive, and blocks carry the generation of the cache they were fetched for.

Both have regression tests.

Documentation

Updated everywhere it was wrong: README, docs/tree.md, docs/data-sources.md, docs/extensibility.md, the docs index, the playground README, the two agent rules that claimed this package has no virtualization, and DEPENDENCY_MAP.md. New: docs/virtualization.md and specs/unified-options/ with all eight artifacts.

Semver

Minor. Six optional props, one optional column field, two optional GridTable props, and new exports. No signature, removal or default changed, so a 0.3.0 grid renders identically. Version 0.4.0.

Verification

npm run verify: 297 unit tests, 12 smoke tests, lint and typecheck green. Plus a manual pass over the playground in Chrome: every switch and combination, ten million rows scrolled to row 10,000,000, and an edit committed over the windowed source with the block cache dropped and refetched.

🤖 Generated with Claude Code

https://claude.ai/code/session_011ndm72nQiMLvEqekHNJuAV

yaotzin1 and others added 3 commits September 10, 2026 16:58
…wing

Every capability was becoming a component. A tree was TreeGridwright, row
actions were BubbleMenu composed by hand, editing was editableColumns plus
InlineEditProvider composed by hand. Each worked; none of them worked
together without the consumer reassembling the component's own arrangement,
and the tree was a parallel implementation that could never gain anything
added to Gridwright.

They are now options on one component: tree, virtual, rowActions,
onCellEdit, and icon on a column. They compose, and each piece is still
exported for a layout composed by hand. TreeGridwright survives as an alias
over <Gridwright tree={...} /> with no second implementation behind it.

Scale, which is two problems rather than one:

- virtual renders only the rows on screen, carrying the rest in two spacer
  <tr> rows so the element stays a real table with real column alignment and
  real grid semantics. aria-rowcount and aria-rowindex carry the truth.
- createWindowedDataSource holds blockSize * maxBlocks rows however large the
  result set is. Ten million rows becomes a scrolling problem rather than an
  impossible one.

Driving the playground in a browser found two things the suite did not:

- A browser will not render an element taller than about 2^24 pixels, so the
  scrollbar could only reach row 419,000 of ten million, silently, while
  aria-rowcount claimed all of them. Above that height scroll position is now
  a ratio over the result set, with the cost written down.
- Two overlapping window fetches could leave the grid empty and 'ready': a
  block being loaded for a request that was then aborted was joined by the
  next request, resolved carrying nothing, and the survivor built its window
  from an empty cache. A load is now only joined while its own request lives,
  and blocks carry the generation of the cache they were fetched for.

Documentation updated everywhere it was wrong: README, docs/tree.md,
docs/data-sources.md, docs/extensibility.md, the docs index, the playground
README, the two agent rules that said this package has no virtualization, and
DEPENDENCY_MAP. New: docs/virtualization.md and specs/unified-options with all
eight artifacts. Version 0.4.0.

npm run verify: 297 unit tests, 12 smoke tests, lint and typecheck green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ndm72nQiMLvEqekHNJuAV
…e pages

Using the pages rather than running the suite is what found these. The suite
had been green through all of them.

- Virtualization over an ordinary paginating source never placed its rows.
  Only the windowed source publishes where its rows start, and a missing
  offset was read as zero, so the fetched page was drawn over rows one to a
  hundred while the rows on screen stayed skeletons for ever. It now falls
  back to the query and treats rows as unplaceable until they settle, rather
  than drawing them somewhere wrong.
- Switching editing off took the grid down. The engine resolves columns in an
  effect, so for one render the rows still held editable cells while the prop
  was already gone, and those cells threw for want of a provider. The tree
  made it permanent: its column wrapping was keyed on ids alone and so never
  re-wrapped. Toggling an icon had the same cause.
- A column's icon sat beside its editable trigger rather than inside it, so
  clicking the icon did nothing, which reads as "this cell is not editable".
- The row action menu opened a row too low. It is positioned inside a
  zero-height anchor but was measured against the grid root, so every menu
  was out by whatever sat above it, usually the toolbar.

Each has a regression test, and each was confirmed to fail without its fix.

The other two pages now carry the new capabilities too:

- The vanilla page gains the windowed source over ten million rows, with no
  framework involved anywhere on it, plus per-row icons it draws itself. The
  mock API gained /api/people/range, so those ten million rows are a real
  network boundary rather than a function pretending to be one.
- The React page gains row actions, inline editing and windowing as switches
  over its paginating REST source, with edits written to the mock table so
  they survive the next fetch.

npm run verify: 303 unit tests, 12 smoke tests, lint and typecheck green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ndm72nQiMLvEqekHNJuAV
A tree grid's rows are placements, so `row.data` is a node there and the
consumer's row everywhere else. A menu written for a flat grid therefore
broke the moment `tree` was switched on, which makes the switch a lie, and
the alternative in the typed example was a double cast.

`rowDataOf(row)` answers with the consumer's row either way. Tested on both
shapes with a single handler, and used in the type-checked example.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ndm72nQiMLvEqekHNJuAV
@yaotzin1

Copy link
Copy Markdown
Owner Author

Two follow-up commits, both driven by using the pages rather than by the suite.

Four bugs the playground found, each with a regression test confirmed to fail without its fix:

  • Virtualization over an ordinary paginating source never placed its rows. Only the windowed source publishes where its rows start, and a missing offset was read as zero, so the fetched page was drawn over rows one to a hundred while the rows on screen stayed skeletons for ever.
  • Switching editing off took the grid down. The engine resolves columns in an effect, so for one render the rows still held editable cells while the prop was already gone. The tree made it permanent: its column wrapping was keyed on ids alone and so never re-wrapped.
  • A column's icon sat beside its editable trigger rather than inside it, so clicking the icon did nothing.
  • The row action menu opened a row too low: positioned inside a zero-height anchor, measured against the grid root.

All three pages now carry the new capabilities. The vanilla page gets the windowed source over ten million rows with no framework involved anywhere on it, talking to a new /api/people/range endpoint so the memory claim crosses a real network boundary. The React page gets row actions, inline editing and windowing as switches over its paginating REST source.

rowDataOf(row) was added because a tree's rows are placements: without it, a menu written for a flat grid breaks the moment tree is switched on, which makes the switch a lie.

npm run verify: 304 unit tests, 12 smoke tests, lint and typecheck green.

🤖 Generated with Claude Code

https://claude.ai/code/session_011ndm72nQiMLvEqekHNJuAV

yaotzin1 and others added 6 commits September 10, 2026 17:58
…act page

Three things, all reported from using the pages.

The row menu opened at the table's trailing edge. On a wide table that is a
journey away from the row you are pointing at, and it lands on top of the
last column when it gets there. It now opens beside the pointer, on the row
the pointer is over, clamped to stay inside the grid, and falls back to the
row's trailing edge when the row was reached by keyboard, since there is no
pointer to be near then. Placed once per row rather than followed
continuously: a menu that slides while you approach it is a menu you cannot
click.

The vanilla page had no tree, which left the impression that hierarchy is a
React feature. It is not: the nested set, the controller and the flattening
stage are all core. That page now draws one itself with string
concatenation, including expansion, search that keeps the folders of a
match, nested-set counters, and a row menu the page builds from plain
buttons over the same controller. `docs/tree.md` gained the recipe.

The React page now has a tree too, as one more switch beside row actions,
inline editing and windowing, so the claim that these compose can be checked
on the page that shows the plain component.

npm run verify: 308 unit tests, 12 smoke tests, lint and typecheck green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ndm72nQiMLvEqekHNJuAV
…inks

The tree panel's controls were in the page's `.controls` grid, which spread
three buttons across a wide row and pushed the search box under a label. They
are a plain flex row now. They also sit outside `.gw-root`, so none of the
grid's custom properties reached them and they rendered unstyled; they borrow
the page's own tokens instead.

The links between the three pages still described the third one as "tree,
bubble menu and inline editing", which stopped being true when it became the
page where every option is a switch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ndm72nQiMLvEqekHNJuAV
Three questions, and one of them was about a real gap.

**Storing a tree.** `onCommit` already carried everything a database needs:
`insert` names the parent and the index, `move` names both parents,
`remove` names the scope, `update` carries the row and what it replaced.
What was missing was saying so. `docs/persistence.md` now writes down the
table behind each change, why the nested set is derived rather than stored,
what an edge table is for when a row has several parents, and which refusals
a server must make on its own. Both playground trees post their changes to
the mock API, which keeps an adjacency list: add or rename something and
reload the page, and it is still there.

**Storing inline edits.** The same answer, one hook earlier: `onCellEdit`
gets the row's own id, the column and the value. The vanilla page now sends
one, refuses an empty name with a 422, and shows what came back rather than
what it hoped for.

**Why the vanilla page had none of the adapter's tricks.** Partly because
they are DOM work, and partly because the window arithmetic was locked
inside a React hook, where nothing without React could reach it. It was
never a React concern: four numbers in, a slice and two spacer heights out.
It is now `computeVirtualWindow` and `scrollOffsetForIndex` in the core, and
`useVirtualRows` is the binding that reads two DOM numbers once per frame.
The vanilla page virtualizes ten million rows with it, edits cells in place
and opens a row menu, all written by the page.

Two bugs found while doing it:

- A tree whose rows arrived from a server rendered as a list of roots.
  `defaultExpandedDepth` was consumed on the first normalise, which for an
  asynchronous source happens with no rows in hand.
- `hidden` did not hide a grid part: every layout rule uses a class
  selector, which outranks a bare attribute selector, so the pagination
  footer stayed on screen under a virtualized body.

npm run verify: 319 unit tests, 12 smoke tests, lint and typecheck green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ndm72nQiMLvEqekHNJuAV
The core window arithmetic, the tree that rendered as a list of roots, and
`hidden` not hiding a part were all in the work and none of them were in the
changelog.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ndm72nQiMLvEqekHNJuAV
…nstyled

Reported as "none of them work". All three did work; nothing about the page
said so.

- The switches lived in the data source panel, a screen and a half above the
  grid they act on, while the first table on the page is the tree, which has
  no editors. Anyone following "click a name to edit it" clicked a tree row
  and nothing happened. They now sit directly above their own grid, with a
  note saying which table they apply to and what to click.
- The row menu was appended to `document.body`, outside `.gw-root`, so none
  of the stylesheet's custom properties reached it: it rendered as bare text
  on a transparent background, which reads as broken rather than as styled
  differently. It is appended inside the grid root now.
- Ticking "virtual" over a twenty-five row page changed nothing anyone could
  see, because there was nothing to window. It now also raises the page size,
  which is what virtualization means for a page: the scrollbar is the
  navigation and the page is the data window behind it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ndm72nQiMLvEqekHNJuAV
…ens the menu

Two asks in one change.

**The menu opens on a left click.** `rowActionsTrigger` gained `click` and
the default includes it, pinned until you click elsewhere or press Escape.
A click landing on a button, a link or a field is left alone, because that
click belongs to the control it landed on: clicking an editable cell must
open its editor, not a menu over it. The vanilla pages do the same, through
one shared helper.

**The pages' JavaScript is no longer wrapped in HTML.** Each page was a
thousand-line inline module, which meant nothing could be shared between
them and nothing checked any of it. Every page is now markup plus one file
under `examples/playground/js/`, with the package loader, the row menu, the
icons and the two DOM helpers in `js/shared/`, and the framework-free tree
in a module of its own. ESLint covers the directory; its first run found a
piece of dead state on the React page.

That split immediately produced the bug it was supposed to prevent: a
relative script path resolved against `/` and the vanilla page silently
served an empty grid with no console error. CI now requests every module
each page references, which is the check that would have caught it, plus the
stored tree endpoint.

npm run verify: 322 unit tests, 12 smoke tests, lint and typecheck green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ndm72nQiMLvEqekHNJuAV
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