Skip to content

test(plugin-kanban): split the contractEnvelope-6839 waits by expected outcome - #8533

Merged
os-justin merged 1 commit into
mainfrom
claude/issue-8532-kanban-contract-envelope-rows-wait
Sep 8, 2026
Merged

test(plugin-kanban): split the contractEnvelope-6839 waits by expected outcome#8533
os-justin merged 1 commit into
mainfrom
claude/issue-8532-kanban-contract-envelope-rows-wait

Conversation

@os-justin

Copy link
Copy Markdown
Collaborator

Fixes #8532

Test (shard 2/4) was red on main, failing identically on PRs whose diffs cannot reach plugin-kanban (#8494, #8521) and stalling five queued PRs. Only the first it in ObjectKanban.contractEnvelope-6839.test.tsx failed, always with expected +0 to be 2.

What the mechanism actually is

The brief's diagnosis — a mount signal read as a rows signal — is right in direction, but it names the wrong gate. Measured here, 'Negotiation' is not a first-paint signal either.

ObjectKanban reaches KanbanImpl through React.lazy(() => import('./KanbanImpl')) behind a Suspense (packages/plugin-kanban/src/index.tsx). Instrumented on this tree, the header is absent at render, absent when find has been called, and still absent when find has settled; it appears only when that chunk resolves:

afterRender    header=false cards=absent
findSettled    header=false cards=absent
mutation       header=true  cards=2       <- header and rows arrive together, locally

KanbanImpl then mirrors its columns prop into boardColumns state and re-syncs through a useEffect (KanbanImpl.tsx), and both the header text and the cards are drawn from that mirror. So the header lands in whatever state the mirror was seeded with at its own mount.

Chunk load and data commit are therefore two independent races, and nothing in the helper ordered them. When the data commit wins, the reveal carries the rows and the read sees 2 — every local run, where the chunk is cold. When the chunk wins, the reveal draws the column with an empty list and the synchronous read sees 0. cards() answered [] both when the list was absent and when it was empty, so nothing distinguished the two.

The repair

The four cases need opposite waits, so they no longer share one:

case expects now waits
data, bare array, value 2 cards for the rowswaitFor(() => expect(cards()).toHaveLength(2))
records (refusal) 0 cards a settled readfind has answered, the board has drawn the card list itself, and React's queued work is flushed

Waiting on the list rather than the header is the part that matters for the refusal arm: it is the very node cards() reads, and the board draws it on every arm (a zero-card column fills it with a dashed placeholder that is not a listitem), so an empty list is no longer indistinguishable from a missing one.

No timeout was raised, no case skipped or quarantined, and cardsThrough is still called once per case and never inside a waitFor predicate (objectui#7802). The predicates that are now inside waitFor are pure screen reads — they mount nothing, which is exactly the property cardsThrough does not have.

Ablation — reversed, because the subject is a test

A green run proves nothing here, so the repair was checked against the production behaviour it pins. @object-ui/core is aliased to packages/core/src in vitest.config.mts, so the mutation reaches the runner from source with no dist in the resolution path. Each leg proved the mutation on disk in both directions and restored by state (git diff HEAD empty and git hash-object equal to git rev-parse HEAD:PATH), under a trap ... EXIT INT TERM with absolute paths.

leg result
ablate extractRecords' data arm 1 failed, 3 passed — only data went red: the declared rows member must still draw both cards: expected [] to have a length of 2 but got +0
ablate the value arm 1 failed, 3 passed — only value went red
nothing ablated 4 passed — the records refusal still holds at 0

The refusal case stayed green through both ablations, and each positive ablation reddened exactly one case. The failure message is the case's own because string, carried into the waitFor timeout.

Reproduction — declared honestly

I could not reproduce the original failure locally. The file is green cold, green with the lazy chunk pre-warmed, green with a deliberately expensive first render (60 columns / 900 rows), and green 8/8 under real contention (six niced CPU hogs, load average 6.07 on 4 cores). Locally the cold dynamic import always loses the race to the data path, which is precisely why the reveal commit carries the rows here and need not on a runner whose transform cache is warm and whose scheduler is contended.

The repair does not rest on that reproduction: it removes the dependence on which race won, rather than widening the window on the same race.

Verification

  • pnpm exec vitest run packages/plugin-kanban/33 files, 214 tests, all passing
  • pnpm --filter '@object-ui/plugin-kanban^...' build then pnpm --filter '@object-ui/plugin-kanban' type-checkexit 0; tsc -p tsconfig.test.json --listFiles confirms the changed test file is in the program (1669 files), so this is measured coverage rather than assumed
  • check:control-bytes, check:vi-mock-specifiers, check:vi-mock-inherit, check-changeset-presence, check-changeset-no-major, check-changeset-fixed, check-changeset-overwrite, check-unreferenced-sources — all exit 0
  • check-governed-queue-guard --testNOT GOVERNED, ordinary route
  • Zero-width scan over the changed file (the no-irregular-whitespace skipStrings: true gap) — 0 hits, with a lit control that fired

Declared narrowing: lint was run on the changed file only, not repo-wide. eslint --print-config reports no parserOptions.project and no projectService, so type-aware linting is not enabled and a one-file diff cannot move the verdict on any untouched file; --format json reports 1 file linted, 0 errors, 0 warnings. Repo-wide lint belongs to CI.

Changeset: empty frontmatter — test-only, no published source changed.

Left in draft for contract review; auto-merge not armed.

🤖 Generated with Claude Code

https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S


Generated by Claude Code

…d outcome

The pin waited on the `'Negotiation'` column header and then read the cards
synchronously. Measured here, that header is not a first-paint signal: the
board reaches `KanbanImpl` through `React.lazy` behind a `Suspense`, so the
header appears only when that chunk resolves — after `render`, after `find` is
called, and after `find` settles. `KanbanImpl` mirrors its `columns` prop into
`boardColumns` state, and both the header text and the cards draw from that
mirror, so the header lands in whatever state the mirror was seeded with at its
own mount. Chunk load and data commit are two independent races and nothing
ordered them; when the chunk won, the reveal drew an empty list and the read
saw 0. That is the `expected +0 to be 2` failure on shard 2/4.

The four cases need opposite waits, so they no longer share one. The three
positive arms wait FOR the rows, which is immune to which race won. The
`records` refusal arm has no arrival to wait for, so it takes a settled read:
`find` has answered, the board has drawn the card list itself (not merely the
header), and React's queued work is flushed. Waiting on the list is what
separates "the list is empty" from "the list is absent" — `cards()` answered
`[]` for both.

No timeout was raised, no case skipped, and `cardsThrough` is still called once
per case and never inside a `waitFor` predicate (objectui#7802).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S
@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) 3475.0 KB 3512.7 KB
Main entry chunk (gzip) 143.9 KB 350 KB
Entry file index-CI564s7H.js
Status FAIL

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.

Which half objected:

Eager-closure half Verdict
Aggregate closure ceiling ✅ pass
Per-chunk ceilings ❌ over its ceiling
Ceiling sensitivity (headroom) ✅ pass
Ceiling freshness (checkout vs. base branch) ✅ pass

📦 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.87KB 114.10KB
core (index.js) 7.48KB 2.96KB
create-plugin (index.js) 10.12KB 3.28KB
data-objectstack (index.js) 191.36KB 53.16KB
fields (index.js) 243.24KB 61.42KB
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) 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.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) 248.46KB 63.90KB
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.40KB 15.71KB
plugin-list (index.js) 112.74KB 27.70KB
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 added this pull request to the merge queue Sep 8, 2026
Merged via the queue into main with commit 90a9aab Sep 8, 2026
33 of 34 checks passed
@os-justin
os-justin deleted the claude/issue-8532-kanban-contract-envelope-rows-wait branch September 8, 2026 07:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants