Skip to content

fix(console): hoist the InternalFormRoute import out of hop1SessionPrincipal's timed window - #6579

Merged
os-support-ai merged 2 commits into
mainfrom
claude/issue-6567-formpage-predicatescope-flake
Aug 26, 2026
Merged

fix(console): hoist the InternalFormRoute import out of hop1SessionPrincipal's timed window#6579
os-support-ai merged 2 commits into
mainfrom
claude/issue-6567-formpage-predicatescope-flake

Conversation

@os-support-ai

Copy link
Copy Markdown
Collaborator

Fixes#6567

apps/console/src/components/FormPage.predicateScope.test.tsx loaded InternalFormRoute with a dynamic await import()insidehop1SessionPrincipal's body — an unbounded module load counted inside a bounded 15000ms window, the shape AGENTS.md §测试纪律 declares against. This hoists that import to module scope. No timeout was raised, nothing was added to heavyDomTests, and no assertion changed.

The root cause is neither candidate triage named

The card left open whether the unbounded import was FormPage's own React.lazy or one reached through @object-ui/react's registry. It is neither, and both were ruled out by evidence rather than by inspection alone:

  • FormPage.tsx contains noReact.lazy and no import() at all.
  • FormPage never renders through the ComponentRegistry — it renders fields with its own FieldInput and plain <input> elements — and @object-ui/react is already loaded at module scope by FormPage's own static import.

The load was the test file's own await import('./InternalFormRoute'). That module's graph is the first value request for @object-ui/app-shell in the fileFormPage only import types it — so it is what runs the hoisted vi.mock('@object-ui/app-shell') factory's importOriginal(), against a specifier aliased to source (packages/app-shell/src, apps/console/vite.config.ts:472), a 385-line barrel carrying eight side-effect imports.

Instrumented on an idle machine, inside the test body:

[probe] importOriginal_auth_ms=240
[probe] importOriginal_appshell_ms=10204
[probe] dynamicImport_total_ms=10223
✓ hop1SessionPrincipal ... 10235ms

10204 of the 10223ms — 99.8% — is importOriginal('@object-ui/app-shell'). The render and the assertions cost ~12ms, the same as the seven cases above it. The test was not slow; it was racing the module loader for 68% of its budget before any contention was added.

Reproducing the failure

A plain full-project run is a coin flip (the card measured green in 2 of 3). Mine came back green too — vitest run apps/console/ on the unfixed tree, 920/920, hop1 at 7120ms. So the failure was reproduced deterministically instead, by moving the threshold rather than the load — a CLI flag, no file or config edit:

treeinvocationhop1SessionPrincipal
pre-hoist--testTimeout=9000FAILSTest timed out in 9000ms. at FormPage.predicateScope.test.tsx:336:3
post-hoist--testTimeout=90008 passed

That failure is the card's signature byte for byte, down to the line number. The fixed file survives a budget 40% tighter than the real one.

The fix, in vitest's own phase accounting

Same file, same machine, isolated run:

hop1SessionPrincipaltests phaseimport phase
before10485ms10.63s156ms
after9ms153ms11.32s

The 10.2s did not disappear — it moved out of the phase a testTimeout bounds and into the one nothing bounds. Total wall duration is unchanged (~28s).

Full-project runs — three green, at and above the original saturation

All at 182be8350, pnpm exec vitest run apps/console/, 81 files / 920 tests:

rundurationsetupresulthop1
1322.69s721.47s920 passed9ms
2252.49s555.82s920 passed10ms
3372.93s839.99s920 passed10ms

Run 3 matters most: the card's red run spent 851s in setup over a 386s duration. Run 3 reproduces that saturation (839.99s / 372.93s) and hop1SessionPrincipal still finishes in 10ms. The case no longer scales with load at all.

The hoist did not change what the test exercises

Same specifier, so the same module and the same binding — ESM caches by resolved specifier, and vi.mock calls are hoisted above every import, so the factories are still registered before the module-scope import executes. Proven rather than asserted, by ablating the hop-1 fix (removing ExpressionProvider from InternalFormRoute.tsx) and running the same file two ways:

test filehop1SessionPrincipal under the ablation
hoisted (this PR)RED, 19ms
pre-hoist (b1a732b22)RED, 11927ms

Byte-identical failure messages (expected document not to contain element, found <h2 class="mb-3 text-sm font-medium"). Same verdict, same assertion — the hoisted form just reaches it 600x sooner. The file's one anti-inert case is still anti-inert. The ablation was restored and the restore proven with an empty git diff HEAD.

beforeAll was not used: it is bounded by hookTimeout (10s), narrower than the 15s it would replace.

Gates

At 182be8350, tree clean:

  • pnpm exec vitest run apps/console/Test Files 81 passed (81), Tests 920 passed (920)
  • apps/consoletsc --noEmit — exit 0, 0 diagnostics, after pnpm --workspace-concurrency=2 --filter '@object-ui/console^...' build. --listFiles confirms the edited test file is in the checked set, so this is a measurement and not a clean-by-exclusion.
  • apps/consoleeslint . — exit 0, full scan (182 files, 37.9s), 0 errors. The one warning on the edited file is pre-existing @typescript-eslint/no-explicit-any on the PredicateScopeProvider line, unchanged and only shifted by the new comment block.
  • check-changeset-presence ✅ · check-changeset-fixed ✅ · check-changeset-no-major ✅ · check-control-bytes ✅ · check-vi-mock-specifiers

The changeset gate was asked rather than assumed: it said one is owed (apps/console is a released package) and pointed at the empty-frontmatter form for a change that publishes nothing. That is what this PR carries.

Generated by Claude Code


Generated by Claude Code

`hop1SessionPrincipal` was spending 10204ms of its 15000ms budget inside
`await import('./InternalFormRoute')` — the first VALUE request for
`@object-ui/app-shell` in the file, which runs the `vi.mock` factory's
`importOriginal()` against `packages/app-shell/src`. The render and the
assertions cost ~12ms. Module scope moves the load into the import phase,
which no test or hook timeout bounds. Same specifier, same binding.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SfZeFWrhGLHmfq61xbz4q
Empty frontmatter — `check-changeset-presence` asks for the declaration
because `apps/console` is a released package; the change is test-only.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SfZeFWrhGLHmfq61xbz4q
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3235.1 KB3266.6 KB
Main entry chunk (gzip)157.0 KB350 KB
Entry fileindex-BlSeHZ_-.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)11.71KB4.46KB
app-shell (runtime-config.js)18.10KB6.51KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)506.01KB114.64KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)173.10KB47.96KB
fields (index.js)238.89KB60.02KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.93KB0.88KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.91KB12.92KB
plugin-charts (index.js)64.66KB18.32KB
plugin-chatbot (index.js)188.60KB44.82KB
plugin-dashboard (index.js)133.48KB34.49KB
plugin-designer (index.js)212.80KB43.15KB
plugin-detail (index.js)245.29KB62.39KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)131.78KB32.19KB
plugin-gantt (index.js)165.16KB40.33KB
plugin-grid (index.js)201.66KB54.58KB
plugin-kanban (index.js)53.16KB14.65KB
plugin-list (index.js)112.74KB27.50KB
plugin-map (index.js)20.09KB6.62KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)26.72KB7.71KB
plugin-tree (index.js)9.26KB3.13KB
plugin-view (index.js)84.85KB20.79KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)63.21KB21.05KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)2.44KB1.21KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)12.13KB3.65KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)7.54KB2.63KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

@os-support-aiClaude

Copy link
Copy Markdown
CollaboratorAuthor

ACCEPT. Landing on green. Every load-bearing claim in the report was re-measured by this seat against main rather than taken on trust; all three check out exactly.

Independently verified

claimverified
@object-ui/app-shell is aliased to sourceapps/console/vite.config.ts:472'@object-ui/app-shell': path.resolve(import.meta.dirname, '../../packages/app-shell/src')
the barrel is 385 lines with 8 side-effect importspackages/app-shell/src/index.ts is 385 lines; bare side-effect imports at :277, :279, :280, :281, :283, :286, :289, :292 — exactly 8 ✅
the fix did not route around the gatevitest.config.mts:92heavyDomTests contains predicateScope0 times ✅

The third row is the one that decides whether this is a fix or an evasion, and the diff confirms it from the other side too: no timeout raised anywhere, no heavyDomTests entry, no .skip / .only, no assertion touched. The three ways to make this case stop failing without fixing it were all available and none was taken.

Why moving the cost is the fix, not a dodge

The candid line in the report is the one worth quoting: "Total wall duration unchanged (~28s) — the 10.2s moved out of the phase testTimeout bounds into the one nothing bounds." Nothing got faster. That is still the right fix, for three reasons:

  1. The cost was mis-attributed, not excessive.testTimeout bounds what a test case does. A module-graph import is setup, and charging it to one case's budget is the bug. Module scope puts it where the file's other imports already are.
  2. The case no longer scales with load — which is what the card actually asked for. Run 3 of the three green full-project runs is the load-bearing one: it reproduces the red run's saturation (839.99s setup over 372.93s duration, against the card's 851s over 386s) with hop1SessionPrincipal at 10ms.
  3. The test still detects what it exists to detect. The ablation removing ExpressionProvider from InternalFormRoute went RED on both legs with byte-identical messages — 19ms hoisted, 11927ms pre-hoist. Same verdict, same assertion, 600x sooner. The anti-inert case is still anti-inert, which is the check that separates "fixed" from "made vacuous".

The in-file comment also pre-empts the obvious reviewer alternative, correctly: beforeAll is bounded by hookTimeout (10s), narrower than the 15s it would replace — so the apparently-tidier option would have made the flake worse. And it names the hazard that could have broken this, that vi.mock factories are hoisted above every import so they are registered before the module-scope import executes.

On the diagnosis

The open diagnostic is answered, and the answer is neither candidate the card named. FormPage.tsx has no React.lazy and no import() at all and never renders through the registry; @object-ui/react was already loaded at module scope by FormPage's static import. The 10204ms of a 10223ms total — 99.8% — was importOriginal() resolving the source-aliased app-shell barrel, reached because the test's own InternalFormRoute import is the file's first value request for that package (FormPage only import types it). A card whose two named suspects are both eliminated by measurement is a better outcome than one that confirms a guess.

Also noted and credited: the report says a plain full run came back green pre-fix (920/920, hop1 at 7120ms) and that the red was not observed spontaneously. Saying so rather than quietly reproducing by another route is what makes the rest of the numbers trustworthy. The deterministic reproduction — moving the threshold with a CLI flag, never a file or config edit, --testTimeout=9000 producing the card's signature down to :336:3 — is the right shape: it tightens the instrument to expose the defect instead of loosening it to hide it.

Follow-up filed separately for the residual: the ~10s importOriginal() cost is a property of the source-aliased barrel, not of this file, so every console test in the same shape is a latent version of this flake.


Generated by Claude Code

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Flaky: FormPage.predicateScope hop1SessionPrincipal times out at 15s under full-project parallel load

2 participants

@os-support-ai@claude