Skip to content

fix(Table): key its column and plugin caches on identities - #10940

Draft
vjeux wants to merge 5 commits into
meta:mainfrom
vjeux:fix/table-caches-at-commit
Draft

vjeux wants to merge 5 commits into
meta:mainfrom
vjeux:fix/table-caches-at-commit

Conversation

@vjeux

@vjeux vjeux commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

Render-time ref writes fixed, each an identity cache: resolvedColumnsRef in core/Table/BaseTable.tsx and prevRef in core/Table/useBaseTablePlugins.ts. Red-first: yes; one column test and two plugin tests fail on main.

#10161 added architecture:react-component-runtime INV17 on main; the Table record's Owns item cites it.

User impact

People with tables in an app that uses transitions. BaseTable reuses its last column array while the columns are shallowly equal. useBaseTablePlugins reuses its last plugin array while every plugin is the same instance. Both caches were written while rendering.

A render React discards, as a suspended transition does, left its own columns or plugins there. The next committed render then missed the cache, and every memoized row rendered again.

Problem and solution fit

Each cache now comes from one internal helper, internByIdentity, which returns one array per list of objects, compared by identity and in order. No state or ref is written during render, so a render React discards cannot replace the committed array. Plugin validation stays an Effect, once per merged array.

Output was never wrong; a miss only costs identity. The tree plugin keeps its own column cache during render, so after a discarded render its rows still re-render.

Base plugins compare by content, so an inline base array of the same plugins keeps its array (FR12). The Table record names the helper under Owns, since two modules use it.

Expected behavior and authority

  • architecture:react-component-runtime INV3 and INV4: each new effect names what it syncs, acquires nothing and can replay; no rendered value comes from a ref mutation.
  • The spec owner's decision for Table: three spots follow the rule, and eight stay as documented exceptions. Paging and sorting stay as fast as today. The owner's /approve-spec on this pull request approves its record change. The three map to lines as follows:
Spot Where Change
Column settings' active keys plugins/columnSettings/useTableColumnSettings.tsx Merged before this PR.
Column identity cache BaseTable.tsx, resolvedColumnsRef This PR.
Plugin array cache useBaseTablePlugins.ts, prevRef This PR.

Smallest restoration

One internal helper for both caches, and one validation Effect. No API changes.

Evidence

  • Reproduction before the change: the two discarded-render tests and the inline base-array test fail against main's sources, with the whole test file run.
  • Result after the change, at 1fe0959: they pass.
  • Representative unchanged path: the existing Table and plugin tests still pass.
  • Regression test or other mutation-sensitive proof: each test fails with only its own cache restored to the render-time write.

Scope

  • One defect is restored.
  • Separable contradictory or unsettled tagalongs were removed or split.
  • No new public API, visual representation, default, or interaction model is hidden under the bug-fix label.
  • Public text and artifacts contain no internal Meta context.

Testing

At this PR's head, 51bbf3c, the Table folder runs 42 files and 790 tests. All runs below are at 1fe0959, before main's merge.

Each test commits a table, then renders it again in a transition while a sibling suspends for good, so React never commits that render. A cell renderer records every row it renders.

Cache Test
resolvedColumnsRef Red-first: a discarded render drops a column. The next commit passes a new array of the committed columns, and no row renders again.
prevRef Red-first: a discarded render passes another plugin. The next commit passes a new record of the committed plugin, and no row renders again.
Both Guard: a custom plugin whose transformColumns returns a new column each call renders, and re-renders with its parent. Its key changes on every render, as its array did on main.
prevRef Guard: under StrictMode, an invalid plugin warns once. The hook validates in an Effect, once per merged array.
resolvedColumnsRef Red-first against the looping version: a table with no columns prop re-renders when its data array is new.
prevRef Red-first against main and the looping version: the hook keeps one array for an inline array of the same base plugins.
prevRef Red-first against the looping version: the hook settles when its caller writes its plugin objects inline.
internByIdentity Guard, in internByIdentity.test.ts: a new array of the same objects gets the first array. A new object with the same contents, another order or another count gets another. An edit to the first array reaches no other caller.
resolvedColumnsRef Guard: a caller edits its columns array after rendering, and another table with the same column objects still shows two columns.
resolvedColumnsRef Guard: the same columns return the same array across a discarded render, so no row renders again. It fails when the helper overwrites an entry.
  • pnpm exec vitest run packages/core/src/Table --project ui: 42 files, 789 tests pass.
  • The helper stores a copy of the first array for each list, never a caller's own. Both copy guards fail when it keeps the caller's array, which it would when no plugin reshapes the columns.
  • Table.perf.test.tsx run whole against main's sources: the three red-first tests fail, and the other 21 pass. The StrictMode guard fails at d057c47, which validated during render.
  • With only BaseTable.tsx restored, only the column test fails. With only useBaseTablePlugins.ts restored, only the two plugin tests fail.
  • The looping version kept each cache in state adjusted during render: bank/table-caches-state-loops (53527af945). Generated columns and inline plugins are new on every pass, so its adjustment never settled. All three tests marked against it fail there with "Too many re-renders". The helper's result depends only on the items, so it cannot loop.
  • Render cost against main, in jsdom: a counting plugin sees 4 passes for 4 page changes on both, with stable or inline column objects. The time per change, over 40 page changes in two rounds, is within noise of main:
Columns Rows Main, ms per change This PR, ms per change
Stable 20 4.38, 4.29 4.39, 4.26
Inline objects 20 2.59, 2.47 2.56, 2.63
Stable 200 23.11, 23.23 23.65, 22.82
Inline objects 200 22.64, 22.87 24.42, 22.61
  • @astryxdesign/core typecheck: 0 errors. ESLint and Prettier on the changed files: clean. node scripts/check-knowledge.mjs: aligned.
  • Open pull requests on these files: #8861 merges cleanly with this change. #4690 and feat(Table): Promise-based infinite loading with useTableInfiniteScroll #10570, both open, already conflict with main, in regions this change does not touch.
  • History: main merged at 7e7fbae on Nov 11, with no conflicts, so CI runs on a current base. The change's own diff is unchanged.
  • History: on Nov 12 the caches moved from a commit-time write to a cache keyed on identities, after the state-adjusted-during-render version looped.
  • History: main merged at 638f2fe on Nov 12, after docs(react-component-runtime): latest-value ref sync and render-time ref writes #10161 added INV17, with no conflicts. The change's own diff is unchanged, and the Table folder then runs 42 files and 790 tests.

Writing rubric results

component:Table's ownership boundary gains one present-tense item under Owns: what internByIdentity.ts does, who imports it, and why no existing helper serves. The verification map row for FR12 and PR1 adds the helper and its test as evidence.

  • R1 narration: none; the item states what the helper does.
  • R2 research: none.
  • R3 evidence: the row names test and source files, no pull request.
  • R4 rejected alternatives: none.
  • R5 change references: none.
  • R6 open questions: none touched.
  • R7 project status: none.

@vercel

vercel Bot commented Nov 10, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated
astryx Ready Ready Preview Nov 12, 2026 7:01am UTC

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Nov 10, 2026
@github-actions

github-actions Bot commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

Current run: The current CI run concluded skipped. Current analysis is unavailable.


Generated by PR Enrichment workflow | View current CI run

@astracat-bot

astracat-bot Bot commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

AI review status for this pull request.

Review status Updated
🔄 In progress (for maintainers only) Nov 12, 2026, 7:01 AM UTC

@cixzhang

Copy link
Copy Markdown
Contributor

/approve-spec d057c47

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes: the commit-time caches fix the re-renders after a discarded render (both new tests fail on main and pass here), but two small issues remain.

  • Under StrictMode, each plugin validation warning now prints twice on mount and on every plugin change, because the second render pass misses the cache and mergePlugins validates again (useBaseTablePlugins.ts:196-201); validate in the layout effect only when the committed array changes.
  • The guard test's plugin adds a column keyed value, which testColumns already uses, so React logs duplicate-key errors and the assertion relies on unsupported behavior (Table.perf.test.tsx:713); use a unique key such as extra.

A few non-blocking comment wording suggestions are inline. CI has not run on this head yet because the pull request is a draft.

[Automated review]

Comment thread packages/core/src/Table/BaseTable.tsx Resolved
@vjeux
vjeux force-pushed the fix/table-caches-at-commit branch from d057c47 to 00016b6 Compare November 10, 2026 03:19
@cixzhang

Copy link
Copy Markdown
Contributor

/approve-spec 00016b6

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes: both caches now keep their committed arrays, and the earlier StrictMode double-warning and duplicate-key issues are fixed; one wording issue remains.

  • Miss: the changeset says the next committed render "no longer re-renders every row", but a Table with the tree plugin over expandable rows still re-renders every row after a discarded render with other columns, because that plugin writes its own column cache during render; narrow the sentence to Table's own column and plugin caches (.changeset/table-caches-at-commit.md:5).

CI's test jobs have not run on this head because the pull request is a draft.

[Automated review]

Comment thread .changeset/table-caches-at-commit.md Resolved
BaseTable reuses its last column array while the columns are shallowly
equal, and useBaseTablePlugins reuses its last plugin array while every
plugin is the same instance. Both caches were written while rendering.
A render React discards, as a suspended transition does, left its own
columns or plugins there. The next committed render then missed the
cache, and every memoized row rendered again.

Each cache now holds the last committed array. A render reads it, and
a layout effect writes it at commit. The plugin hook also validates
its plugins there, only when the committed array changes, so each
warning prints once under StrictMode.
@cixzhang

Copy link
Copy Markdown
Contributor

/approve-spec b18ca6f

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved pending CI: CI has not run on this head yet.

Both Table caches now keep their committed arrays, so a render React discards no longer makes the next commit re-render every row, and the earlier StrictMode double warning, duplicate test column key and changeset wording are fixed. CI's test jobs skip drafts, so mark the pull request ready for review; its branch is in a fork, so a maintainer adds the ci:run label so CI runs.

[Automated review]

@cixzhang cixzhang added the ci:run A maintainer lets this outside contributor's pull request run CI (AST-030 FR17) label Nov 10, 2026
@cixzhang

Copy link
Copy Markdown
Contributor

/approve-spec 7998c38

@astracat-bot astracat-bot Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes: both Table caches now keep their committed arrays, so a render React discards no longer makes the next commit re-render every row, but the new commit-time writes never run inside a hidden <Activity>.

  • Miss: inside a hidden <Activity> React runs no layout effect, so once the columns or a plugin change while hidden, every later hidden render that passes a new array of the same columns or a new record of the same plugin, and the reveal, re-render every row where main re-renders none (useBaseTablePlugins.ts:203-210, BaseTable.tsx:622-624); write both caches in an insertion effect, as BottomSheet.tsx already does for its commit-time ref, and add the hidden case beside the two discarded-render tests.

Swapping the two useIsomorphicLayoutEffect calls for useInsertionEffect fixes the hidden case and keeps all 19 Table.perf tests passing. CI's test jobs skip drafts, so they have not run on this head.

Automated review (maintainer link)

Comment thread packages/core/src/Table/BaseTable.tsx Resolved
The column identity cache and the merged plugin array lived in refs
that render read and a layout effect wrote. Each now comes from one
internal helper that returns one array per list of objects, compared
by identity and in order. It keeps a copy, never a caller's array. No
state or ref is written during render, and a render React discards
cannot replace the committed array.

State adjusted during render loops for generated columns and for
plugins written inline, which are new on every pass. The helper's
result depends only on the items, so it cannot loop, and it adds no
render pass. Base plugins now compare by content, so an inline base
array keeps its merged array. Plugin validation stays an Effect, once
per merged array.
@vjeux vjeux changed the title fix(Table): keep its column and plugin caches at commit fix(Table): key its column and plugin caches on identities Nov 12, 2026
The Owns item names architecture:react-component-runtime in full for
INV17, so the knowledge check can follow the reference.
@github-actions github-actions Bot added the needs:spec-owner-review Current knowledge records await owner approval label Nov 12, 2026
@cixzhang

Copy link
Copy Markdown
Contributor

/approve-spec 3a98d1f

@github-actions github-actions Bot added needs:spec-owner-review Current knowledge records await owner approval and removed needs:spec-owner-review Current knowledge records await owner approval labels Nov 12, 2026
@cixzhang

Copy link
Copy Markdown
Contributor

/approve-spec 1fe0959

@github-actions github-actions Bot removed the needs:spec-owner-review Current knowledge records await owner approval label Nov 12, 2026

@astracat-bot astracat-bot Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes: both Table caches now come from one identity-keyed helper, so a render React discards no longer makes the next commit re-render every row, and the change itself looks right. Two things need fixing first:

  • The new Owns item in docs/specs/components/Table/Table.spec.md (line 132) cites architecture:react-component-runtime INV17, which main does not have while #10161 is open, so land #10161 first and update this branch, or drop the citation.
  • The description says that running Table.perf.test.tsx against main's sources fails two red-first tests and passes the other 17, but the file now has 24 tests and three fail on main (the two discarded-render tests and the inline base-array test at line 771), so please restate it as three red-first and 21 passing.

Non-blocking: useBaseTablePlugins now returns one array shared by every caller with the same plugins while its return type stays mutable, so a caller that mutates it changes other callers' arrays. CI's test jobs skip drafts, so they have not run on this head.

Claims and files checked

Claims:

  • Changeset: Table keeps one array per list of columns and per list of plugins, compared by identity: verified (BaseTable.tsx:594, useBaseTablePlugins.ts:190).
  • Changeset: a discarded render can no longer replace the array the table last showed: verified (no ref is written during render; internByIdentity.ts:29-41).
  • Changeset: the next commit reuses the arrays, so rows skip rendering when columns and plugins match: verified (Table.perf.test.tsx:628, :691, traced against the base sources).
  • Changeset: the hook keeps its array for a new base array of the same plugins: verified (Table.perf.test.tsx:771).
  • Changeset: a plugin with its own render-time column cache, such as the tree plugin, still re-renders every row: verified (useTableTreeData.tsx:642).
  • Description: both render-time ref writes are replaced by one identity helper that returns one array per ordered list: verified.
  • Description: one red-first test per cache fails on main: false; the plugin cache has two (:691, :771).
  • Description: before the change both caches were written during render, so a discarded render left its own array: verified in the base sources.
  • Description: no state or ref is written during render, and validation runs in an Effect once per merged array: verified (useBaseTablePlugins.ts:194-206).
  • Description: output was never wrong, and the tree plugin still re-renders after a discarded render: verified.
  • Description: base plugins compare by content, so an inline base array keeps its array: verified (:771).
  • Description: the Table record names the helper under Owns, and the verification row adds the helper and its test: verified (Table.spec.md:129-135, :468).
  • Description: the Owns item's cited invariant comes from an earlier pull request this one merges after: verified that it is absent on main (blocking above).
  • Description: the new Effect names what it syncs, acquires nothing and can replay, and no rendered value comes from a ref: verified.
  • Description: a spec owner decided which Table spots follow the rule and records it on this pull request: partly verified; an engineering owner approved the spec at this head, but the split of spots is not stated on the pull request.
  • Description: column settings' active keys were fixed before this pull request: verified (state in the base useTableColumnSettings.tsx).
  • Description: the ten tests in the Testing table exist as described: verified (Table.perf.test.tsx:628-815, internByIdentity.test.ts).
  • Description: the helper stores a copy and the copy guards cover it: verified (internByIdentity.ts:39, Table.perf.test.tsx:801, internByIdentity.test.ts).
  • Description: against main's sources two red-first tests fail and 17 pass: false; 24 tests, three red-first, 21 passing.
  • Description: with one file restored, only that cache's tests fail: consistent with the test logic (one column test, two plugin tests).
  • Description: the Table folder's test run, typecheck, lint, format and knowledge check pass: not confirmed here; CI skipped this draft.
  • Description: the earlier state-based version looped, and render cost is within noise of main: not checked.
  • Description: other open pull requests on these files merge or conflict as stated: not checked.
  • Description: main was merged in with no conflicts and the change moved to identity keys afterwards: verified from the commits.
  • Description: writing rubric results for the record change: verified (present tense, no pull request reference in the row).

Files:

  • .changeset/table-caches-at-commit.md: clear.
  • docs/specs/components/Table/Table.spec.md: INV17 citation (blocking above); verification row clear.
  • packages/core/src/Table/BaseTable.tsx: clear.
  • packages/core/src/Table/Table.perf.test.tsx: clear; the description's count of its tests is stale.
  • packages/core/src/Table/internByIdentity.test.ts: clear.
  • packages/core/src/Table/internByIdentity.ts: clear.
  • packages/core/src/Table/useBaseTablePlugins.ts: clear; non-blocking note on the shared result.

Automated review (maintainer link)

- `Table/internByIdentity.ts`: keeps resolved columns and merged plugins stable
across renders by input identity, with no state or ref; each entry is written
once, on first sight of an identity path, and never changed
(`architecture:react-component-runtime` INV17's lazy one-time

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: architecture:react-component-runtime on main has no INV17 (its invariants run INV16, then INV18); INV17 is added by #10161, which is still open. Merged first, this current record cites a claim that does not exist, and the knowledge check does not catch prose citations. Land #10161 and update this branch, or drop the parenthetical citation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This merges after #10161, which adds INV17, and main is merged in then. The description says so.

Comment on lines +174 to +175
* @returns Stable array of plugins suitable for BaseTable. Every caller with
* the same plugins gets this one array, so treat it as read-only.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: this hook is exported, and its result is now one module-cached array shared by every caller with the same plugins, while the return type stays mutable TablePlugin<T>[]. A caller that pushes to it changes other callers' arrays and every later render with those plugins; this sentence is the only guard.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A ReadonlyArray return type would change the exported hook's type. BaseTable's plugins prop takes TablePlugin<T>[], so callers that pass the result on would stop compiling. So @returns asks callers to treat it as read-only, and a readonly type is left for an owner's API decision.

@vjeux

vjeux commented Nov 12, 2026

Copy link
Copy Markdown
Contributor Author

Thanks. 1: this merges after #10161, which adds INV17, and main gets merged in then. 2: the description's red-first counts are corrected: three tests fail on main's sources, and the other 21 pass.

@cixzhang

Copy link
Copy Markdown
Contributor

/approve-spec 51bbf3c

This branch was successfully deployed

1 active deployment
Preview — 51bbf3ce Deployed Nov 12, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci:run A maintainer lets this outside contributor's pull request run CI (AST-030 FR17) CLA Signed This label is managed by the Meta Open Source bot.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants