Skip to content

test(plugin-detail): settle the fetched value before two RelatedList tests read it - #7757

Merged
os-sam merged 1 commit into
mainfrom
claude/issue-7579-relatedlist-serverpagination-gate
Sep 5, 2026
Merged

test(plugin-detail): settle the fetched value before two RelatedList tests read it#7757
os-sam merged 1 commit into
mainfrom
claude/issue-7579-relatedlist-serverpagination-gate

Conversation

@claude

@claude claude Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Fixes #7579

Two RelatedList tests read a value out of a chain their gate never settles. Both
now gate on the value itself. No runtime source is touched.

The defect, and the reproduction

RelatedList.serverpagination.test.tsx:117-118 gated the page-3 read on the page
label and then read the refetched rows:

await screen.findByText('Page 3 of 3');   // label only
expect(h.schema.data).toHaveLength(2);    // refetched data

Read from RelatedList.tsx on this base: totalPages is Math.ceil(total / pageSize)
whenever the server reported a total, and total arrived with page one; currentPage
flips synchronously on the click. So "Page 3 of 3" commits on the click's own render,
before the third window reaches relatedData, and the assertion samples the previous
page — which is why CI's received value was 5, the page size.

Instrument. A timing FACT mutated inside the component and nothing else: in the fetch
effect, the resolution of dataSource.find is deferred by 50ms. Three variants were used,
all of which delay only when rows land, never what lands:

probe deferral
A every windowed request carrying a non-zero $skip — the page-flip refetch chain
B every request in that effect — the whole row-fetch chain
C only the $skip >= 10 request — the third window alone

Measured on the base 81a2eb1fb, probe C:

FAIL packages/plugin-detail/src/__tests__/RelatedList.serverpagination.test.tsx
  > pages forward and back by refetching with a new $skip
AssertionError: expected [ { id: 'c5', name: 'Row 5' }, …(4) ] to have a length of 2 but got 5
  at RelatedList.serverpagination.test.tsx:118:27

That is the CI assertion string, received value included, reproduced deterministically.
Probe A produces the same failure at the same line with c0 in the received value, because
it delays the page-2 refetch as well and state is then one window further back.

Each probe leg proved itself on disk before running (anchor 1 to 0, marker 0 to 1, a
git diff --numstat naming the file) and restored via git checkout HEAD -- ABSOLUTE_PATH
with git hash-object equal to the HEAD blob and an empty git diff HEAD --stat, under a
trap ... EXIT INT TERM.

The census this card asked for, and what it found

#7075's census deferred getObjectSchema, which probes the schema chain; this site rides the
refetch chain, so that probe could not have reached it. Re-run here over the whole package,
pnpm exec vitest run packages/plugin-detail/:

run result
control, unmutated Test Files 132 passed (132), Tests 1199 passed (1199)
probe A 1 failed / 132, 1 failed / 1199RelatedList.serverpagination.test.tsx:118
probe B 2 failed / 132, 2 failed / 1199 — the above, plus RelatedList.wildcardInvalidation.test.tsx:133

Lit control: the site this card names goes red under all three probes, so a zero
elsewhere is a reading and not a dark instrument.

Dark control, which is what makes the probe discriminating rather than "a delay breaks
tests":
RelatedList.serverpagination.test.tsx:244 has the same text shape — click,
await screen.findByText('Page 2 of 2'), then expect(h.schema.data).toHaveLength(2) — and
stays GREEN under every probe. Its label is derived from hasMore, which setStates in the
same batch as the rows, so that label gate genuinely settles the value. Shape alone does not
predict the defect; the chain behind the label does.

The second site, and why it is the same defect

RelatedList.wildcardInvalidation.test.tsx closes its guard test by reading the row the
fetch produced, behind a gate that settles the CALL:

await waitFor(() => expect(ds.find).toHaveBeenCalledTimes(1));
... three foreign events dispatched, 20ms elapsed ...
expect(ds.find).toHaveBeenCalledTimes(1);
expect(h.schema?.data?.[0]?.id).toBe('line-1');   // measured red under probe B

Same one-line repair, so it is repaired here: settle the value before the foreign events
go out, which also makes the closing read non-vacuous — "still line-1" becomes a statement
about the guard rather than about rows that had not landed yet.

What the repair is, and what it deliberately is not

Both sites keep their existing gate and add a gate on the value the next read rides. Neither
is a toHaveBeenCalledWith gate on find: a mock call is issued one resolution before its
rows reach state (#6959), so a call gate would flake less often and fail identically. No
timeout was widened, nothing was skipped, no expected length was relaxed.

The page-2 gate in the same test was examined and deliberately left alone. It is the same
shape, but measurement says it is not the same defect: under probes A and B — both of which
delay the page-2 refetch — the assertion that follows it, expect(prevButton().disabled).toBe(false),
stayed green, because disabled is currentPage === 0, pure pagination state the click already
committed. Nothing there reads the fetch chain. Its toHaveBeenCalledWith is also doing a
different job from a gate: it pins the WIRE, { $filter, $top: 5, $skip: 5 }, which is the
contract this test exists to hold. Converting it to a value gate would delete that coverage.
It stays as a latent trap for whoever later adds a data read after it, and it is named here so
that is a decision rather than an accident.

The repaired gates can still fail

A gate that can no longer fail is worse than the flake, so the behaviour — not the timing —
was broken under the fix:

  • The third window never arrives (the fixture returns a never-settling promise for
    $skip >= 10): red at the new gate, RelatedList.serverpagination.test.tsx:127:47 in the
    mutated file, and the test's own duration goes 67ms green to 1062ms red — the waitFor
    exhausting its window and rethrowing, not a fast assertion.
  • The window arrives empty (wildcardInvalidation's fixture returns no rows): red at the
    new gate, :130:57.

Both mutations were proved on disk and restored with a HEAD-blob hash comparison under a trap.

Verification

Repo root, exit codes captured before any pipe, all on 9c142c03a:

command result
pnpm exec vitest run packages/plugin-detail/ 132 passed (132), 1199 passed (1199), exit 0
same two files under probes none / A / B / C 12 passed (12) in every mode, exit 0
pnpm --filter @object-ui/plugin-detail type-check exit 0
pnpm --filter @object-ui/plugin-detail lint exit 0 — 894 warnings, all pre-existing, 0 errors
check:control-bytes / check:vi-mock-specifiers / check:vi-mock-inherit exit 0
node scripts/check-changeset-presence.mjs exit 0

pnpm --filter @object-ui/plugin-detail^... build ran first, so tsconfig.test.json resolved
@object-ui/* through freshly built .d.ts. That project's --listFiles names both edited
files once each, so "type-check is clean" is a statement about this diff and not a project
that excludes tests.

Declared narrowing: lint here is the affected package's own eslint ., not the repo-wide
turbo run lint. eslint.config.js configures no type-aware linting — no project and no
projectService — so no verdict on an untouched file can move because of a change inside two
test files in one package. The repo-wide farm is CI's run.

Changeset

.changeset/7579-relatedlist-value-gates.md carries an empty frontmatter — this repo's
declared "releases nothing" form. check-changeset-presence.mjs requires a changeset for any
touch under a released package's src/, and src/__tests__/ is inside that. It is a
declaration, not a version bump: no published behaviour changes and no runtime source is in
this diff.

Follow-up filed, not left in prose

A fourth probe, deferring the lookup-label batch by the same 50ms, was designed and run. Its
package-wide result is NOT MEASURED, not a zero: the vitest worker dies of V8 heap
exhaustion. Running it file-by-file isolated that to one file,
RelatedList.lookupLabelResolution.test.tsx, which renders into the DOM inside a waitFor
predicate. That is a different failure mode with a different repair, so it is not touched here
and is filed with its measurement as #7756 — out of scope for this PR, and not left in prose.


Generated by Claude Code

…tests read it

`RelatedList.serverpagination.test.tsx` gated its page-3 read on the page
label alone and then read `h.schema.data`. The label is pagination state —
`currentPage` flips on the click and `totalPages` came from page one's
`total` — so "Page 3 of 3" commits before the third window reaches
`relatedData`, and the assertion samples the previous page. That is why the
CI value was 5, the page size.

Measured in this run, on 81a2eb1: deferring only the `$skip >= 10` window
by 50ms inside the fetch effect reproduces the CI failure byte-for-byte —
`expected [ { id: 'c5', name: 'Row 5' }, …(4) ] to have a length of 2 but
got 5` at RelatedList.serverpagination.test.tsx:118:27.

The repair keeps the label gate (it proves the view committed) and adds a
gate on the value the next line reads. ⛔ Not a `toHaveBeenCalledWith` gate
on `find`: a mock call is issued one resolution before its rows reach state
(objectui#6959), so that would flake less often and fail identically.

A census over `packages/plugin-detail/` with the same instrument found a
second site of the same class: `RelatedList.wildcardInvalidation.test.tsx`
gates on `find` having been CALLED once, then closes by reading the row that
call produces. Same one-line repair — settle the value before the foreign
events go out, so "still line-1" is a statement about the guard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KbJQ1y1J12nZxYzFWhP8Q3
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 50 chunks) 3186.5 KB 3191.4 KB
Main entry chunk (gzip) 143.2 KB 350 KB
Entry file index-CumQ-WR_.js
Status PASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 15.67KB 5.75KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 5.13KB 2.35KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 510.63KB 116.21KB
core (index.js) 6.96KB 2.79KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 180.00KB 50.20KB
fields (index.js) 242.44KB 61.25KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 4.28KB 1.75KB
i18n (index.js) 3.65KB 1.47KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.98KB 10.98KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 4.39KB 1.66KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 11.71KB 4.29KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 5.12KB 1.74KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 47.87KB 13.31KB
plugin-charts (index.js) 70.92KB 19.75KB
plugin-chatbot (index.js) 196.37KB 46.41KB
plugin-dashboard (index.js) 132.87KB 34.68KB
plugin-designer (index.js) 212.86KB 43.19KB
plugin-detail (index.js) 250.55KB 64.06KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 132.87KB 32.66KB
plugin-gantt (index.js) 167.26KB 41.00KB
plugin-grid (index.js) 209.29KB 56.78KB
plugin-kanban (index.js) 52.71KB 14.55KB
plugin-list (index.js) 113.28KB 27.59KB
plugin-map (index.js) 20.44KB 6.78KB
plugin-markdown (index.js) 13.93KB 4.81KB
plugin-report (index.js) 43.59KB 11.97KB
plugin-timeline (index.js) 30.84KB 8.85KB
plugin-tree (index.js) 9.20KB 3.19KB
plugin-view (index.js) 85.24KB 20.94KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 81.07KB 26.86KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 5.41KB 2.34KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 4.93KB 2.24KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 20.57KB 5.88KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 10.35KB 3.60KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.74KB 1.41KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 4.73KB 2.28KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@claude

claude Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor Author

PM ruling on both open questions — A and A. One of them corrects my own dispatch brief.

domain:ui PM seat, session_01KbJQ1y1J12nZxYzFWhP8Q3.

Q1 — the changeset: ⛔ my brief was wrong, the repo governs, and the seat was right to say so

My brief said: "A changeset is required only if a released package's source changed — it should not have; if you need one, that is the Clause-② void condition, so stop and report."

That contradicts this repo's binding rule. AGENTS.md:161, verbatim:

只要改了发版包的 src/.changeset/config.jsonfixed 组,含 apps/console),就必须新增一个 .changeset/*.md —— 这一条由 .github/workflows/changeset-presence.yml 机械强制(objectui#3387),pnpm changeset 写正常 bump,纯内部改动/只动测试就写空 frontmatter(--- 紧跟 ---)显式声明「不发版」,那是合法的一等通过写法。要的是「声明一次」,不是强制发版。

src/__tests__/ is inside src/. The shipped .changeset/7579-relatedlist-value-gates.md is exactly the sanctioned shape — empty frontmatter, then one line saying test-only, no published behaviour changes. Measured by the seat: check-changeset-presence.mjs exit 1 without it, exit 0 with it.

Ruling: A. The void condition my brief actually defines is a change under packages/*/src that is not a test file — and there is none. A changeset file is not under packages/*/src, and an empty-frontmatter changeset declares that nothing releases; it is not a version bump. Clause-② stays no, needs:contract-review stays off both carriers.

⭐ Option B would have left a red mechanical gate on a green PR in order to obey a sentence I wrote from memory instead of from AGENTS.md. The seat followed the repo, flagged the conflict, and did not choose silently — which is the whole point of the flag. The rule this seat carries forward: the void condition is "runtime source changed", never "a changeset exists".

Q2 — the page-2 gate: A, and the measurement is what decides it

The card asked for a judgment; the seat produced a reading rather than a shape argument, and it changes the answer.

Under probes A and B — both of which delay the page-2 refetch by 50ms — the assertion that follows that gate, expect(prevButton().disabled).toBe(false), stayed GREEN. Because disabled is currentPage === 0: pure pagination state the click already committed, not anything off the fetch chain.

⇒ It is the same shape as the repaired defect but not the same defect. And its toHaveBeenCalledWith is doing a different job from a gate: it pins the wire{ $filter, $top: 5, $skip: 5 } — which is the contract that test exists to hold. Converting it to a value gate would delete that coverage.

Ruling: A — leave it, and keep it named in the PR body as a latent trap for whoever later adds a data read after it. ⚠️ That naming is load-bearing: the card's own warning was that the page-2 gate "is not the model to copy", and this PR is the reason a future reader will find out why.

The census bound, stated as a bound

The package-wide census is NOT MEASURED, not a zeroRelatedList.lookupLabelResolution.test.tsx kills the vitest worker with V8 heap exhaustion under the same deferral (reproduced 4/4 in both the default pool and --pool=forks; 20 of 21 lookup-bearing files exit 0, that one exit 1). Filed as objectui#7756, verified open. ⭐ Saying "not measured" rather than reporting the census as complete is the reading this seat wants; a zero from a probe that crashed is not a zero.

⚠️ And the third worktree to confirm it: the branch carried an unpushed local commit (c293e2732) from the run a container restart killed — same one-line idea, with measurement claims this seat could not verify. Discarded back to the pushed tip, origin/main merged in, every measurement re-derived. My earlier "the restart left no half-finished work" was measured on remote refs and did not cover worktrees; that is now three PRs where it was false.

Landing once CI is terminal — 22 success / 3 skipped / 0 failed, 7 still running as of this comment.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants