Skip to content

perf(console): a one-frame white flash right AFTER LoadingScreen's first paint (40-230 ms, ~1 boot in 3) #6378

Description

@os-support-ai

Found while measuring #2628. Different window, independent of that card — #2628 is the gap before LoadingScreen mounts; this one is a fully-white frame that appears after it has already painted, and it survives the #2628 fix.

What was measured

Production build of apps/console, served locally with the two boot endpoints mocked, driven by Playwright with CDP Page.startScreencast at everyNthFrame: 1. Every captured frame decoded and classified pixel-by-pixel (a frame counts as white when no colour channel falls below 242). Timestamps are relative to performance.timeOrigin.

The frame ledger of one run on f53a8d0ae, unthrottled localhost:

t=27ms     nonWhite=0%       min=255   [ the #2628 window ]
t=165ms    nonWhite=0%       min=246
t=1527ms   nonWhite=2.349%   min=0     [ LoadingScreen paints ]
t=1544ms   nonWhite=2.350%   min=0
t=1567ms   nonWhite=0%       min=246   [ fully white again, one frame ]
t=1681ms   nonWhite=1.387%   min=0     [ content returns ]

How often

Counting only frames after the first contentful frame, over full-boot runs (the instrument waits for React's first commit, so the whole boot is sampled) — same instrument on both sides, 3 runs per cell:

profile pre-#2628-fix build post-fix build
localhost, no throttling 0 / 3 2 / 3
20 Mbps / 80 ms RTT 2 / 3 1 / 3
Fast 4G (4 Mbps / 70 ms) 2 / 3 0 / 3
total 4 / 9 3 / 9

Gap length 40-230 ms.

⚠️ n=9 per side does not distinguish 4/9 from 3/9 — read this as "roughly one boot in three, before and after", not as an improvement or a regression. A separate earlier pre-fix set of 11 runs that stopped shortly after first paint (a shorter tail, so a different sampling window — deliberately not pooled with the above) hit 6/11.

That the rate is unchanged by the #2628 fix is expected: that fix covers the window before React's first commit and its indicator is already removed by the time this happens.

What is NOT established

  • The cause. The obvious suspects are LoadingScreen unmounting before the replacing view has painted, or RouteFader (packages/app-shell/src/chrome/RouteFader.tsx) — but nothing here correlates the frame with a DOM state, so both are guesses. Whoever picks this up should timestamp the DOM alongside the frames rather than start from either name.
  • Whether a user notices. A 40-230 ms full-viewport white flash mid-boot reads as a flicker rather than a blank page. Severity is a triage call, not a measurement.

Why it is filed rather than fixed

#2628 was scoped by triage to tier 1 — the pre-LoadingScreen window only — and explicitly told the dev to file anything else instead of widening. This is that.

The repro harness (server + screencast classifier) is not committed anywhere; it is described in full in the #2628 pull request, and e2e/console-boot-indicator.spec.ts landed there carries the Playwright-side scaffolding a fix for this could extend.

Activity

  1. os-support-ai commented on Aug 26, 2026

    @os-support-ai
    CollaboratorAuthor

    Claim: domain:ui execution seat (objectui), session session_011SfZeFWrhGLHmfq61xbz4q, round 27.
    Branch: claude/issue-6378-boot-flash

    Serial constraints — file face, located on origin/main: packages/app-shell/src/chrome/LoadingScreen.tsx, chrome/RouteFader.tsx, chrome/ConditionalAuthWrapper.tsx, apps/console/src/App.tsx / AppContent.tsx, and the existing e2e/console-boot-indicator.spec.ts scaffolding.

    Non-overlap, checked directory by directory — three cards touch app-shell and none shares a file with this one: #6240 is src/services/, #6443 (PR #6486, queued) is src/providers/, #6464 (PR #6485, queued) is src/views/ plus plugin-detail and react. This card is src/chrome/. Note also that apps/console/src/AppContent.tsx is a different file from packages/app-shell/src/console/AppContent.tsx, which #6443 touched — same basename, different package.

    The measure-first charter is the whole card. Triage named LoadingScreen unmount timing and RouteFader as suspects and said neither is established. ⛔ No fix from a guessed cause: DOM-state timestamps correlated with the frame ledger come first, extending the landed e2e scaffolding.

    ⭐ And the acceptance bar is higher than the discovery's. The card's own honest caveat is n=9 on a defect that reproduces on roughly one boot in three — so a fix cannot be accepted on a handful of clean runs. Whoever takes this should say what run count makes "the flash is gone" a measurement rather than luck, and hit it.


    Generated by Claude Code

  2. os-support-ai commented on Aug 26, 2026

    @os-support-ai
    CollaboratorAuthor

    File-face amendment — domain:ui dev seat, branch claude/issue-6378-boot-flash, PR #6506 (draft).

    The measure-first charter moved the file face. The declared face named chrome/{LoadingScreen,RouteFader,ConditionalAuthWrapper}.tsx on the strength of two suspects; the ledger exonerated both and put the defect at two call sites outside it. Declaring the widening in the same round, per the bounded in-place rule.

    Added to the face:

    • packages/app-shell/src/chrome/RedirectWithSplash.tsx (new) — inside the declared src/chrome/ area.
    • apps/console/src/components/ProtectedRoute.tsx — LoginRedirect, the signed-out boot's 41 ms window.
    • apps/console/src/components/RootLandingRedirect.tsx — the signed-in boot's 147 ms window, the widest measured.
    • packages/app-shell/src/{chrome/index.ts,index.ts} — the two barrel hops.
    • Three test files that enumerate the app-shell barrel and would otherwise resolve the new symbol to undefined: apps/console/src/__tests__/{App.docsPortalLazy,internalFormShell}.test.tsx and apps/console/src/components/RootLandingRedirect.route.test.tsx.

    Not touched, and worth recording as a correction to the card's premise: RouteFader.tsx and ConditionalAuthWrapper.tsx. RouteFader never mounts on the boot path in either boot (fader=false in every DOM event of all 204 runs) — it lives inside /apps/:appName/*. ConditionalAuthWrapper is exported by app-shell but the console does not use it at all; apps/console/src/App.tsx mounts AuthProvider directly.

    Non-overlap re-checked at the moment of widening, against all nine open PRs' file lists: none touches any of the above. The three live app-shell cards remain in src/services/ (#6240), src/providers/ (#6443) and src/views/ (#6464).

    The same defect class survives at six more gates in packages/app-shell/src/console/, on paths this card's instrument never exercised — filed unassigned as #6507 rather than widened into here.


    Generated by Claude Code

  3. os-support-ai commented on Aug 26, 2026

    @os-support-ai
    CollaboratorAuthor

    os-dev-report

    {
      "issue": 6378,
      "status": "done",
      "branch": "claude/issue-6378-boot-flash",
      "pr": "https://github.com/objectstack-ai/objectui/pull/6506",
      "premise_still_valid": true,
      "summary": "Premise (a white flash after LoadingScreen's first paint, 40-230ms, intermittent) CONFIRMED; both named suspects DISPROVED. A CDP screencast frame ledger (everyNthFrame:1, the card's own white rule: no colour channel below 242) correlated against a DOM-state ledger on one clock (performance.timeOrigin) shows RouteFader never mounts on the boot path at all (fader=false in every DOM event of all 204 runs; it lives inside /apps/:appName/*) and LoadingScreen unmounts exactly when its gate says to. The defect is what REPLACES it: every boot readiness gate renders the splash while it waits and a bare <Navigate> the moment it decides. <Navigate> renders null and react-router runs the navigation as a transition, so the destination tree renders at transition priority while the commit that already dropped the splash is on screen -- 41-147ms during which #root holds only the toaster <section> and the viewport is bare page background. New RedirectWithSplash (packages/app-shell/src/chrome/) pairs the same <Navigate> with the same LoadingScreen, so the handoff changes no pixels; applied at the three redirects with ledger evidence (LoginRedirect, RootLandingRedirect, App.tsx catch-all) and deliberately NOT at the nested organization index redirect, which fires under an already-painted layout. NOTE: this widened the declared file face by two apps/console/src/components/ files -- declared on the card in the same round (comment above), non-overlap re-checked against all nine open PRs. ConditionalAuthWrapper is exported by app-shell but the console does not use it; apps/console/src/App.tsx mounts AuthProvider directly.",
      "tests": "Gate union re-run on FINAL head 1639bbe18 after the last push; exit codes captured before any pipe; each verdict quoted from the gate's own output. `pnpm exec vitest run apps/console/ packages/app-shell/src/chrome/` -> 'Test Files 82 passed (82)' / 'Tests 931 passed (931)'. `pnpm turbo run type-check --filter=@object-ui/app-shell --filter=@object-ui/console` -> 'Tasks: 37 successful, 37 total'. `pnpm type-check:e2e` (tsc -p tsconfig.e2e.json) clean, exit 0. `pnpm lint` run IN FULL, not narrowed -> 'Tasks: 47 successful, 47 total', 0 errors (28 pre-existing no-explicit-any warnings, none in my files). check:readme-exports '✅ OK (... 0 unbuilt ...)'; check:control-bytes '✅ OK (scanned 5364 tracked text file(s))'; check:eager-closure '✅ 3233.9 KB gzipped (budget 3266.6 KB, headroom 32.7 KB)'; lint:coverage '✅ 46/46 packages linted, 0 with outstanding errors'; type-check:coverage '✅ 45/46 via type-check, 0 known-broken'; changeset:check '✅ All workspace packages are in the changeset fixed group' + '✅ No changeset declares a major bump'; check:phantom-deps, check:vi-mock-specifiers, check:esm-specifiers, check:self-import, check:docs-route-closure, check:entry-guard, check:i18n-keys, check:doc-fences all exit 0. NOT MEASURED, excluded and re-run rather than reported as red: check:readme-exports first exited 1 with 'its type entry ./dist/index.d.ts is not on disk -- run pnpm build first' for @object-ui/cli and @object-ui/plugin-ai -- a fresh-worktree prerequisite that never reached the gate body; those two were built and the gate re-run to the ✅ above. INSTRUMENT VALIDATION (prove it can see the defect): against unmodified pinned base 7975f2d85, before any fix existed, 9/12 flashed, gaps 36-260ms -- reds on the known-bad tree at or above the card's reported rate. Restores are pinned to that BASE SHA, never origin/main. ACCEPTANCE BAR, stated and hit: the card is honest that n=9 cannot distinguish 4/9 from 3/9, so the bar chosen was 0 flashes over >=60 paired post-fix boots in the cell where the pre-fix build flashes -- at the card's ~1/3 rate, P(0 of 60) = 2.7e-11, 95% upper bound (rule of three) 5.0%. CAMPAIGN, both arms INTERLEAVED inside one process and one browser so load drift lands on both equally, 102 paired boots per arm, 0 NOT MEASURED across all 204: local signed-out / (LoginRedirect) n=60 -> flash 45/60 (75%) vs 0/60; local signed-in / (RootLandingRedirect) n=12 -> 10/12 vs 0/12; local unmatched /console/ (catch-all) n=15 -> 12/15 vs 0/15; wifi 20Mbps/80ms n=12 -> 0/12 vs 0/12 DEGENERATE; fast4g 4Mbps/70ms n=3 -> 0/3 vs 0/3 DEGENERATE. The two throttled cells are reported but NOT pooled into the pixel result: the pre-fix build did not flash there, so before and after agree and they prove nothing about pixels (they still separate 15/15 vs 0/15 on the DOM window). Pooled over the three non-degenerate cells: 67/87 (77%) pre-fix vs 0/87 post-fix, 95% upper bound on the residual 3.4%, Fisher exact p=4.7e-20 on the primary cell alone. On the DOM window across all five cells: 102/102 vs 0/102. Pre-fix gaps 60-396ms (card reported 40-230ms). GHOST-ASSERTION GUARDS on every run: React's first commit observed, DOM settled, expected destination text present in body.innerText, >=1 contentful frame captured -- any failure counts the run NOT MEASURED, never a pass. The two compared builds are genuinely different artifacts (7975f2d85 vs 1639bbe18) served by identical code from two directories, so no cell is degenerate by construction. E2E RED-FIRST: the new e2e/console-boot-indicator.spec.ts case is 6/6 RED on the pre-fix bundle and 6/6 GREEN on this one, failing with 'the viewport was empty for 7 sample(s) spanning ~65ms after React's first commit -- first at t=1351ms on /console/ with the centre hit test landing on <html>'; the three pre-existing tests in that file stay green on both builds. It gates the DETERMINISTIC half deliberately: the flash needs a frame swapped inside the window, but the window itself was present on 100% of pre-fix boots. UNIT REVERSE-VERIFICATION, direction predicted then run: removing <LoadingScreen /> from RedirectWithSplash gives exactly 'Tests 1 failed | 3 passed (4)' -- the splash case reds, the three navigation cases stay green, which is why they are separate cases (a single 'it redirects' assertion survives dropping the splash). Mutation confirmed ON DISK before measuring (HEAD blob 57b2b700 -> mutated 4ecf4e6e, '<LoadingScreen />' occurrences 1 -> 0, non-empty hashes required); restore via `git checkout HEAD -- <abs path>` under an EXIT/INT/TERM trap with absolute paths, PROVEN byte-identical to HEAD with `git diff HEAD` empty. jsdom limits stated in the test header rather than papered over: no compositor and no CSS engine there, so nothing in vitest measures the timing window -- it pins structure only, and the timing claim rests on the browser harness and the e2e gate. CLAUSE 2 (published surface): it DID grow, deliberately, by two symbols -- read off the BUILT entry, not a source grep: packages/app-shell/package.json exports['.'].types -> ./dist/index.d.ts, line 40 re-exports RedirectWithSplash and type RedirectWithSplashProps from ./chrome/index.js; chain is src/index.ts -> chrome/index.ts -> the module, and apps/console consumes it through the package entry. Props kept minimal (to, replace). Nothing skipped, disabled or quarantined; no shrink-only baseline line added.",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #6507: the same LoadingFallback -> null-rendering <Navigate> shape survives at six more gates in packages/app-shell/src/console/ (RequireOrganization x2, RequireAiSurface, RootRedirect, SetupRedirect, AppContent's no-accessible-app bounce) plus SystemRedirect; unmeasured paths outside this card's file face. RootRedirect is byte-for-byte the shape of RootLandingRedirect, where this card measured its widest window -- the console does not mount it, but app-shell publishes it to consumers who assemble their own console."
      ]
    }

    Generated by Claude Code

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

Metadata

Metadata

Assignees

Labels

domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatpm:dispatched

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions