Skip to content

test(plugin-tree): split the contractEnvelope-6839 waits by expected outcome - #8664

Merged
os-justin merged 2 commits into
mainfrom
claude/issue-6839-object-tree-envelope-waits
Sep 8, 2026
Merged

test(plugin-tree): split the contractEnvelope-6839 waits by expected outcome#8664
os-justin merged 2 commits into
mainfrom
claude/issue-6839-object-tree-envelope-waits

Conversation

@os-justin

Copy link
Copy Markdown
Collaborator

Part of objectui#6839. CI repair — this unblocks PR #8553, whose packages/types diff cannot reach plugin-tree at all.

No card is claimed here and no assignee is set.

The failure

packages/plugin-tree/src/ObjectTree.contractEnvelope-6839.test.tsx:124
AssertionError: the declared rows member must still draw: expected 1 to be 2

The race, diagnosed here rather than ported

The file handed all three cases one wait:

await waitFor(() =>
  expect(
    container.querySelector('[data-testid="object-tree"]') ?? screen.queryByText('No records'),
  ).not.toBeNull(),
);
return container.querySelector('[data-testid="object-tree"]')
  ? container.querySelectorAll('tbody tr').length
  : 'empty-state';

That testid is on the table wrapper, so it is a MOUNT signal — but the rows this file counts arrive a commit later. In ObjectTree, expansion is a state MIRROR:

const [expanded, setExpanded] = useState<Set<string>>(new Set());
useEffect(() => {
  setExpanded(initialExpanded(roots, config.defaultExpandedDepth));
}, [roots, config.defaultExpandedDepth]);
const visibleRows = useMemo(() => flattenVisible(roots, expanded), [roots, expanded]);

So when find()'s rows land, the commit that first paints the table still carries the previous (empty) mirror: the root draws, its child does not, tbody tr is 1. The passive effect then seeds the mirror and a second commit takes it to 2.

Measured, not assumed. A probe recorded every DOM state this fixture passes through, and evaluated both waits at each:

DOM state old wait passes? old wait yields new wait passes?
loading no no
table:1rows yes 1 no
table:2rows yes 2 yes

The old wait's first passing state is table:1rows, where it yields 1 — the CI red, by construction rather than by luck. Which commit the read lands on is decided by machine load, which is why it was green locally and red on a saturated shard.

⚠️ It is NOT the kanban twin's shape, although it is the same family

PR #8533 fixed plugin-kanban against two races — React.lazy(() => import('./KanbanImpl')) chunk reveal and a prop-mirrored boardColumns — with the symptom expected +0 to be 2, nothing drawn.

plugin-tree has no lazy boundary anywhere in its source (measured: index.tsx imports ./ObjectTree eagerly; zero React.lazy / dynamic import() in the package's non-test sources). Only the mirrored-state half transfers, and the symptom is a partial draw — the root without its child, expected 1 to be 2. #8533's diff shape was read first and then re-derived against this file's actual mechanism rather than applied to it.

What changed

Test file only. No timeout was raised, no assertion loosened, nothing skipped.

  • Positive arms wait for the descendant row — the row the mirror gates — then assert the drawn shape (label + depth per row) plus the root toggle reading Collapse. The pin is the seeded-open hierarchy, not an eventual count: a count alone is also satisfied by a tree that flattens everything, and by a fix that merely waits longer.
  • Refusal arm cannot wait for an absence, so it takes a settled read anchored on something that does appear in that scenario: the tree's own "No records" panel, which ObjectTree renders only after loading flips false. Probed on the records fixture the sequence is loading → empty-state, with no table at any point and the panel absent while loading — so this arm cannot pass by timing out, which is the failure mode every absence-shaped pin has.

Evidence

Deterministic reproduction. Pushing the mirror's seed past the table's own commit (setTimeout(…, 50) on setExpanded, mutation proven on disk by anchor/marker counts and by git hash-object differing from the HEAD blob, restored to an empty git diff HEAD):

file result
pre-fix AssertionError: the declared rows member must still draw: expected 1 to be 2 ×2 arms
this PR 3 passed

An earlier leg with setTimeout(…, 0) landed on disk but did not redden — RTL's asyncWrapper drains one macrotask before returning, so the gap was hidden. Reported as fired-but-negative, not as void.

Unmutated flake caught in-container. Under a 6-way CPU load on a 4-core box, the pre-fix file (materialised from the parent commit) failed 1 of 12 runs with the exact CI text — same sentence, same expected 1 to be 2, same line 124. The fixed file: 20 of 20 green under the same load, and 20 of 20 idle.

Ablations (per-test classification from vitest's JSON reporter; every mutation proven on disk, every restore proven by an empty git diff HEAD):

leg mutation result
B — caricature extractRecords returns a constant row set for every input refusal arm RED, positives green
C — caricature extractRecords returns [] for every input both positives RED, refusal green
D — non-regression extractRecords reads records too, so the wrong envelope draws refusal arm RED
E — wait non-vacuity the mirror is never seeded both positives RED ([{Root,0}] vs [{Root,0},{Child,1}])

Legs B and C are the caricature in both directions; D is the card's own subject — the pin still distinguishes the declared data member from the undeclared one.

Family sweep

git ls-files | grep contractEnvelope-683911 files. Control fired: both files known in advance (kanban, fixed; tree, broken) are in the result, so the reading is not an empty instrument.

Two are pure unit tests with no DOM (packages/core/.../extract-records, packages/react/.../nonGridRowCeiling) — not exposed. Of the nine DOM mounts, kanban is fixed and tree is this PR. The remaining seven are structurally exposed — a single shared wait on a mount/settle signal, then a count — and several say so in their own comments:

file shared wait note
plugin-calendar/ObjectCalendar queryByTestId('calendar-view') comment already calls it "a mount signal rather than a rows signal"
plugin-charts/ObjectChart lastSchema ?? queryByTestId('chart-empty-state') this package does have a lazy boundary — closest kin to kanban
plugin-dashboard/ObjectDataTable queryByTestId('rows') ?? queryByTestId('table-empty-state') same two-branch shape this PR replaces
plugin-dashboard/ObjectPivotTable queryByTestId('pivot') sharpest: the refusal arm expects 0 on the same node, so "drawn empty" and "drawn before the data" are one reading
plugin-map/ObjectMap queryByText('Loading map...') is null comment already calls it "a settle signal rather than a rows signal"
plugin-timeline/ObjectTimeline getByTestId('timeline-renderer').getAttribute('data-item-count') not null satisfied the instant the renderer mounts, "0" included
plugin-gantt/ObjectGantt positives already wait for the bars inverse gap: the refusal arm reads bars === 0 right after a wait on find having been called, with no completion anchor

Measured, and the zero is reported with its sensitivity: all nine family files, five iterations under the same 6-way load — zero failures. That instrument is too weak to clear them: the pre-fix tree file's own observed rate was ~1 in 12, and five runs would miss an 8%-per-run flake roughly two thirds of the time.

Listed, not fixed here. Each needs its own component-level diagnosis the way this one got it; applying this diff shape to a race nobody has measured is the thing #8533's own write-up warns against.

Gates

  • node scripts/check-changeset-presence.mjs✅ 1 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s): .changeset/tree-contract-envelope-waits-6839.md. / Every one of them has an EMPTY frontmatter — declared as releasing nothing, which is the explicit exemption and a complete answer to this gate.
  • node scripts/check-changeset-no-major.mjs✅ No changeset declares a major bump.
  • pnpm exec eslint packages/plugin-tree/src/ObjectTree.contractEnvelope-6839.test.tsx → exit 0, 0 errors, 3 pre-existing no-explicit-any warnings (same three anys the file already carried).
  • pnpm --filter @object-ui/plugin-tree type-check → exit 0, MEASURED: the closure was built first (pnpm --filter '@object-ui/plugin-tree^...' build), and tsc -p tsconfig.test.json --listFiles shows the changed file in the program.
  • pnpm exec vitest run packages/plugin-tree/Test Files 12 passed (12) / Tests 54 passed (54).

🤖 Generated with Claude Code

https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S


Generated by Claude Code

…outcome

The file handed all three cases one wait — the table wrapper's mere presence —
and read `tbody tr` the instant it passed. That testid is a MOUNT signal, but
the rows arrive a commit later: `ObjectTree` keeps expansion in a
`useState<Set<string>>(new Set())` mirror that a `useEffect` re-seeds from the
forest, so the commit that first paints the table still carries the empty
mirror and draws the root without its child. Probed on this fixture the DOM
sequence is `loading -> table:1rows -> table:2rows`, and the old wait's first
passing state was `table:1rows`, yielding 1 — the CI red, `expected 1 to be 2`.

The positive arms now wait for the DESCENDANT row, the row the mirror gates,
and assert the drawn shape plus the root toggle reading `Collapse`, so the pin
is the seeded-open hierarchy rather than an eventual count. The refusal arm
takes a settled read anchored on the "No records" panel, which the tree renders
only after `loading` flips false, so it cannot pass by timing out on an absence.

No timeout was raised, no assertion loosened.

Part of objectui#6839

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S
…s releasing nothing

Empty frontmatter — `node scripts/check-changeset-presence.mjs` names this the
explicit exemption for a test-only change under a released package's tree.

Part of objectui#6839

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

Copy link
Copy Markdown
Collaborator Author

Structured report for this CI repair (Part of objectui#6839 — no card claimed, no assignee written).

{
  "race_diagnosed": "ObjectTree keeps expansion in a state MIRROR: useState(new Set()) re-seeded by a useEffect keyed on [roots, defaultExpandedDepth]; rows are flattenVisible(roots, expanded). When find()'s rows land, the commit that first paints the table wrapper still carries the previous EMPTY mirror, so the root draws without its child and tbody tr is 1; a second commit after the passive effect takes it to 2. The file's single wait was keyed on the table wrapper testid (a MOUNT signal) and read the count the instant it passed.",
  "same_as_kanban": "PARTIAL. Probed: plugin-tree has ZERO React.lazy / dynamic import in its non-test sources (index.tsx imports ./ObjectTree eagerly), so PR #8533's chunk-reveal race does not exist here; only the mirrored-state half transfers. Symptoms differ accordingly: kanban 'expected +0 to be 2' (nothing drawn) vs tree 'expected 1 to be 2' (partial draw).",
  "8533_diff_read": true,
  "what_changed": "Test file only. Positive arms wait for the DESCENDANT row (the row the mirror gates) and assert the drawn shape plus the root toggle reading 'Collapse'. Refusal arm takes a settled read anchored on the 'No records' panel, then act-flushes, then asserts the absence. No timeout raised, no assertion loosened, nothing skipped.",
  "siblings_swept": {
    "query": "git ls-files | grep contractEnvelope-6839",
    "control": "the two files known in advance — plugin-kanban (fixed by #8533) and plugin-tree (broken)",
    "control_fired": true,
    "found": 11,
    "not_exposed": ["packages/core/src/utils/__tests__/extract-records.contractEnvelope-6839.test.ts", "packages/react/src/utils/nonGridRowCeiling.contractEnvelope-6839.test.ts"],
    "structurally_exposed": ["plugin-calendar/ObjectCalendar", "plugin-charts/ObjectChart", "plugin-dashboard/ObjectDataTable", "plugin-dashboard/ObjectPivotTable", "plugin-map/ObjectMap", "plugin-timeline/ObjectTimeline", "plugin-gantt/ObjectGantt (inverse gap: refusal arm has no completion anchor)"],
    "runtime_reading": "all 9 DOM family files, 5 iterations under 6-way CPU load: 0 failures — an instrument too weak to clear them, since the pre-fix tree file's own rate was ~1 in 12"
  },
  "siblings_fixed_or_listed": "LISTED, none fixed. Each needs its own component-level diagnosis; the mechanism is not measured for any of them.",
  "files": ["packages/plugin-tree/src/ObjectTree.contractEnvelope-6839.test.tsx", ".changeset/tree-contract-envelope-waits-6839.md"],
  "negative_case_completion_anchor": "the tree's own 'No records' panel, which ObjectTree renders only after loading flips false. Probed on the records fixture the DOM sequence is loading then empty-state, with no table at any point.",
  "repeat_run_counts": "fixed file: 20/20 idle, 20/20 under 6-way load. Pre-fix file under the same load: 11 pass / 1 FAIL of 12, the failure text identical to CI.",
  "caricature_result": "BOTH directions RED. Constant row set for every input: refusal arm RED. Empty result for every input: both positive arms RED.",
  "non_regression_pin": "extractRecords mutated to read `records` too (wrong envelope draws): refusal arm RED.",
  "void_legs": "none. One leg fired and came back negative rather than void: a setTimeout(0) deferral landed on disk (proven by marker counts and a differing git hash-object) but did not redden, because RTL's asyncWrapper drains one macrotask before returning. Re-run at 50ms it reproduced the CI failure exactly.",
  "changeset_verdict_line": "1 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s): .changeset/tree-contract-envelope-waits-6839.md. / Every one of them has an EMPTY frontmatter — declared as releasing nothing, which is the explicit exemption and a complete answer to this gate.",
  "pr_url": "https://github.com/objectstack-ai/objectui/pull/8664",
  "no_claim_posted": true,
  "pm_premises_falsified": ["The kanban twin's races do NOT both transfer — plugin-tree has no lazy boundary at all, so only the mirrored-state half applies and the symptom shape differs (partial draw, not empty draw)."]
}

Generated by Claude Code


Generated by Claude Code

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 50 chunks) 3477.5 KB 3512.7 KB
Main entry chunk (gzip) 143.9 KB 350 KB
Entry file index-DqTtGmW_.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) 11.08KB 4.58KB
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) 498.93KB 114.12KB
core (index.js) 7.48KB 2.96KB
create-plugin (index.js) 10.12KB 3.28KB
data-objectstack (index.js) 198.39KB 55.29KB
fields (index.js) 243.73KB 61.53KB
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) 6.57KB 2.76KB
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.84KB 10.94KB
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) 13.52KB 4.88KB
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.16KB 3.68KB
plugin-calendar (index.js) 49.00KB 13.91KB
plugin-charts (index.js) 71.39KB 19.92KB
plugin-chatbot (index.js) 194.53KB 46.34KB
plugin-dashboard (index.js) 131.43KB 34.44KB
plugin-designer (index.js) 213.21KB 43.63KB
plugin-detail (index.js) 251.25KB 65.00KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 131.01KB 32.32KB
plugin-gantt (index.js) 167.16KB 40.99KB
plugin-grid (index.js) 208.30KB 56.63KB
plugin-kanban (index.js) 55.44KB 15.73KB
plugin-list (index.js) 112.73KB 27.69KB
plugin-map (index.js) 20.49KB 6.83KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.42KB 11.92KB
plugin-timeline (index.js) 30.10KB 8.74KB
plugin-tree (index.js) 9.33KB 3.25KB
plugin-view (index.js) 84.54KB 20.84KB
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) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.55KB 2.45KB
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) 13.64KB 4.59KB
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.93KB 1.49KB
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

@os-justin
os-justin marked this pull request as ready for review September 8, 2026 20:56

Copy link
Copy Markdown
Collaborator Author

PM review — accepted, flipped out of draft, auto-merge armed. This unblocks PR #8553.

⭐ The race was diagnosed, not ported — and the dispatch's caution turned out to be load-bearing

I said "do not assume #8533 transfers; diagnose this file's actual race first." It did not transfer:

plugin-tree has ZERO React.lazy / dynamic import() in any non-test source (0 lazy files, against 1 for plugin-kanban and 1 for plugin-charts). So #8533's chunk-reveal race is absent; only the mirrored-state half has an analogue.

And the symptoms differ accordingly — kanban expected +0 to be 2 (nothing drawn) versus tree expected 1 to be 2 (partial draw, root without child). A ported diff shape would have been applied to a race that does not exist here.

The mechanism was proved rather than inferred. A probe recorded the DOM sequence loading → table:1rows → table:2rows and evaluated both waits at each state: the old wait — keyed on [data-testid="object-tree"], the table wrapper, i.e. a mount signal — first passed at table:1rows, where it yielded 1. ⇒ "That is the CI red by construction, not by luck."

⭐ The negative arm's completion anchor is exactly right

The discriminating risk I named was that a refusal arm passes by timing out rather than by observing an absence. Closed properly: the anchor is the tree's own "No records" panel, which renders only on the records.length === 0 branch, below the loading early return — so it cannot appear before the fetch settles. Probed: loading → empty-state, no table at any point, panel absent while loading.

"The arm therefore cannot pass by timing out on an absence; if the anchor never appears, the waitFor throws."

And the positive arms pin the condition the wait is keyed to, not the eventual count: the drawn shape ([{label:'Root',depth:0},{label:'Child',depth:1}]) plus the root toggle's accessible name reading Collapse — ⭐ "a tree that flattens everything regardless of expansion would satisfy a count of 2 but not this."

⭐ Three pieces of measurement discipline worth naming

  1. The real flake was reproduced in-container: the pre-fix file, 12 runs under load, 11 pass / 1 FAIL — same sentence, same shape, same line 124 as the CI report. Then 20/20 idle and 20/20 under load on the fixed file.
  2. A leg came back NEGATIVE and was reported as negative, not void. LEG A's setTimeout(…, 0) mutation demonstrably landed (anchor gone, marker present, hash moved) but did not redden the old file — because RTL's asyncWrapper drains one macrotask before returning, so a 0 ms deferral sits inside its own drain window. Re-run at 50 ms it reproduced CI exactly. Distinguishing "the mutation did not land" from "the mutation landed and did not discriminate" is a distinction most runs collapse.
  3. The runtime sweep was reported with its statistical power instead of as a clean bill: 9 family files × 5 loaded iterations, 0 failures — "reported WITH its sensitivity, because that instrument is too weak to clear them: the pre-fix rate was ~1 in 12, and 5 runs miss an 8%-per-run flake about two thirds of the time." A zero that states its own power is worth more than a zero that does not.

⚠️ The sibling sweep found something that outranks this repair

11 family files; 7 still carry the un-split wait. Two of them are already vacuous in the refusal direction today, independent of any flake:

  • plugin-dashboard/ObjectPivotTable — waits on queryByTestId('pivot') and the refusal arm expects 0 on that same node, so "drawn empty" and "drawn before the data arrived" are one reading.
  • plugin-gantt — the inverse gap: positive arms wait for the bars, but the refusal arm reads bars === 0 straight after a wait on find having merely been calledno completion anchor at all.

Three more carry comments that indict themselves: plugin-calendar's own comment calls its wait "a mount signal rather than a rows signal", plugin-map's calls its own "a settle signal rather than a rows signal", and plugin-timeline waits for data-item-count to be non-null — satisfied the instant the renderer mounts, '0' included.

⇒ Filing the family as one card with the shared checklist, per your recommendation B, rather than seven cards — the recipe is the transferable artifact and splitting it scatters the recipe. ⛔ Not C: two of them are broken now, not at risk later. And ⭐ your Q1 is right too — the expanded state mirror is the component-side root cause and the same pattern bit kanban; filing it separately.


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

Development

Successfully merging this pull request may close these issues.

2 participants