Skip to content

test(app-shell): close the approvals teardown race that trips the network-escape guard - #7764

Merged
os-sam merged 1 commit into
mainfrom
claude/issue-7439-approvals-teardown-race
Sep 5, 2026
Merged

test(app-shell): close the approvals teardown race that trips the network-escape guard#7764
os-sam merged 1 commit into
mainfrom
claude/issue-7439-approvals-teardown-race

Conversation

@claude

@claude claude Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Fixes #7439

Everything below was measured in this run, on 8f9a2406c.

Which of the two candidate causes it is

The card offered two. Measured: it is (1), the teardown window in the test file — not an un-awaited effect in RecordDetailView.tsx.

The late probe was captured with a stack trace at the moment the network-escape guard records an escape:

at guardedFetch            vitest.setup.network-escape-guard.ts
at fetchJson               packages/app-shell/src/hooks/useRecordApprovals.ts:267
at Object.refresh          packages/app-shell/src/hooks/useRecordApprovals.ts:375
at                         packages/app-shell/src/views/RecordDetailView.tsx:1096
at                         packages/app-shell/src/hooks/useConsoleActionRuntime.tsx:156
at                         packages/app-shell/src/hooks/useConsoleActionRuntime.tsx:438
at ActionRunner.execute    packages/core/src/actions/ActionRunner.ts:1066
at                         packages/app-shell/src/views/DeclaredActionsBar.tsx:321

RecordDetailView.tsx:1096 is handleApprovalActionDone, reached from the api handler's refreshAfter branch (useConsoleActionRuntime.tsx:438). It is the record page's declared, intentional post-decision re-read: the call is void-ed on purpose, with a comment saying so, and nothing in the console is in a position to await it. A second escape in the same run came from RecordDetailView.tsx:1037, the record-invalidation effect that notifyDataChanged drives — a normal React passive effect. Neither is a dropped promise; no runtime source is at fault, so the file surface stays inside the test file and Clause-2 stays no.

What ends early is the test: the decision cases await only authFetchSpy being called, i.e. the moment the POST is dispatched, and then return with the follow-up read still in flight.

Second measured fact — the hook order that makes the window real. Vitest runs afterEach in reverse registration order, so this file's own teardown runs first, ahead of the root setup's RTL cleanup() and ahead of the guard's assertion. A probe registered immediately after that teardown reported fetch is still the double? false on all 13 tests: the real fetch is back while the tree is still mounted and cleanup() has not run yet.

The repair

Install the fetch double once at module scope and never tear it down. The per-test router still swaps, so a call count still means "this test's reads"; there is simply no point in the file's lifetime at which the real fetch is installed. A block comment states why it must outlive afterEach, so the next cleanup does not put the teardown back.

Not done, deliberately: no KNOWN_ESCAPES entry, no skip, no quarantine, no widened timeout.

Evidence — deterministic first, repetition only as a supplement

A single green run is worthless here, so the ordering was forced rather than looped. The forcing holds the decision POST's response until teardown (the call is still recorded synchronously, so the test body's waitFor still resolves and the test still returns), then releases it from a hook positioned to run after this file's teardown. Same forcing, same guard instrumentation, three legs:

leg tree result escapes recorded
L1 pre-fix (e546222b3) exit 1, 2 of 13 fail 4
L2 with the fix exit 0, 13 of 13 pass 0
L3 fix, with the removed teardown put back exit 1, 11 of 13 fail 33

L1's two red tests are POSTs to the REQUEST, not to the business record it is opened on and folds a declared decision output into the nested outputs body — the second is the one the card reports. L1 reproduced the card verbatim: same URL http://localhost:3000/api/v1/approvals/requests?object=qif_report&recordId=QIF202607310002, and the escape attributed to the same test, folds a declared decision output into the nested outputs body. L1 vs L2 is the clean comparison — identical forcing, identical instrumentation, the diff is the only variable.

L3 is supplementary and is not "pre-fix behaviour": with the double hoisted, re-adding vi.unstubAllGlobals() destroys it permanently rather than re-opening a window, which is why it is far redder than L1. It does establish that the removed line is load-bearing.

Repetition, reported honestly as a supplement and not as proof: the file ran to completion 4 times on the fixed tree across this work. Three of those were file-scoped and gave 13 of 13 each (two plain runs and the L2 leg); the fourth covered it inside the 27-file, 188-test neighbouring-suite run, also green. That says nothing about the next run; the L1/L2 pair is the argument.

The instrument fired. The same guard instrumentation that reported 0 escapes in L2 reported 4 in L1 and 33 in L3, in the same file with the same command — so the zero is a reading, not a silent instrument.

The repaired test can still fail when the behaviour is genuinely wrong. The decision-output fold in useConsoleActionRuntime.tsx was mutated so its prefix test never matches. Result: exactly 1 of 13 red — folds a declared decision output into the nested outputs body — reporting the nested outputs object missing from the body, with the other 12 green. Restored afterwards; git hash-object equals the HEAD blob and git diff HEAD is empty.

Every mutation in this run ran under a trap ... EXIT INT TERM, was proven to have reached disk before the run (injected-text and removed-text counts, both directions), and was proven restored afterwards by object hash against the HEAD blob plus an empty git diff HEAD.

Verification, all on 8f9a2406c

  • pnpm exec turbo run lint --concurrency=247 successful, 47 total, 0 errors (repo-wide; not narrowed).
  • pnpm --filter @object-ui/app-shell run type-check — green. It is a real reading, not a vacuous one: an earlier iteration failed inside this very test file at TS2348, so the edited file is inside the compiled set.
  • pnpm exec vitest run scripts/__tests__/network-escape-ledger.test.ts packages/app-shell/src/views/RecordDetailView packages/app-shell/src/hooks/useRecordApprovals27 files, 188 tests, all pass. The ledger pin is included because it is the reconcile gate for KNOWN_ESCAPES; this change adds no line to that list.
  • Gates derived by hand from this repo's own package.json and .github/workflows/ (the dispatch-gate deriver lives in the sibling repo and answers only about that tree): check-changeset-presence, check-changeset-no-major, check-changeset-fixed, check-changeset-overwrite, check-control-bytes, check-vi-mock-inherit, check-vi-mock-specifiers, check-lint-coverage, check-type-check-coverage, check-shell-escape-residue — all exit 0.
  • Changeset: empty frontmatter, declaring that this releases nothing. check-changeset-presence names it and calls that "a complete answer to this gate".

Exit codes were captured before any pipe throughout, and each verdict above quotes the gate's own line rather than a bare status.

Serial constraints, re-measured at implementation time

Across all 11 open PRs (225 files): RecordDetailView 0, useRecordApprovals 0, vitest.setup.network-escape-guard.ts 0. Controls that had to fire and did: PRs touching packages/ 9, PRs touching .changeset/ 7. One PR (#7685) touches app-shell/src/views/ but only under metadata-admin/. Nothing contended.


Generated by Claude Code

…file

`RecordDetailView.approvalDeclaredActions.test.tsx` installed its `fetch`
double inside `stubApprovalsApi` and tore it down in an unconditional
`afterEach`. A decision dispatched through `DeclaredActionsBar` carries
`refreshAfter: true`, so on success the record page re-reads the approval
state — `handleApprovalActionDone` calls `void approvals.refresh()` and the
`notifyDataChanged` beside it runs the same read again through the
record-invalidation effect. Neither is awaited by the console or by the test,
which asserts on the POST and returns, so a
`GET /api/v1/approvals/requests?object=…` was still in flight at teardown.

Vitest runs `afterEach` in reverse registration order, so this file's teardown
ran first — before RTL `cleanup()` and before the network-escape guard's
assertion — and restored the real `fetch` while that read was pending. Whether
the read landed before or after the restore was pure timing.

Install one double at module scope instead and never remove it: the per-test
router still swaps, so call counts keep meaning "this test's reads", but the
window where a late probe can reach a real socket no longer exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KbJQ1y1J12nZxYzFWhP8Q3
@github-actions github-actions Bot added the tests label Sep 5, 2026
@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) 3187.0 KB 3191.4 KB
Main entry chunk (gzip) 143.2 KB 350 KB
Entry file index-fw3Pes87.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) 182.08KB 50.62KB
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.19KB 46.37KB
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

@os-sam
os-sam marked this pull request as ready for review September 5, 2026 16:27
@os-sam
os-sam added this pull request to the merge queue Sep 5, 2026
Merged via the queue into main with commit 8ac2828 Sep 5, 2026
34 checks passed
@os-sam
os-sam deleted the claude/issue-7439-approvals-teardown-race branch September 5, 2026 16:41
os-sam pushed a commit that referenced this pull request Sep 5, 2026
…teardown goes

The guard's failure message prescribed `vi.stubGlobal('fetch', router) +
vi.unstubAllGlobals()` with no word about WHERE the unstub goes. That pair is
safe only when nothing the component started is still in flight at teardown,
and objectui#7439 is the measured counter-example: a file with exactly the
prescribed shape still escaped intermittently, because a decision carrying
`refreshAfter: true` makes the record page re-read approval state after the
test body returns and the prescribed teardown put the real `fetch` back while
that read was pending.

Vitest runs `afterEach` in reverse registration order, so a teardown written in
the test file runs before the root setup's RTL `cleanup()` and before the
guard's own assertion. The message now says so, and gives the two remedies in
order: unmount before unstubbing (which the referenced DatasetReportRenderer
`afterEach` already does, with its own measured note), and — when the component
can issue a read after the test body returns — install one double at module
scope and never tear it down, following the worked example PR #7764 landed.

The shrink-only ledger pin printed a second copy of the same prescription. It
now points at the guard's text instead of restating it: one ruling written
twice, with one copy rotting, is the defect this card is about.

No change to what the guard catches: `KNOWN_ESCAPES` and the verdict logic are
untouched, and only the thrown message's template literal moved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KbJQ1y1J12nZxYzFWhP8Q3
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants