Back to React On Rails

RSC FOUC ShakaPerf Investigation

internal/analysis/rsc-fouc-shakaperf-investigation.md

17.0.117.9 KB
Original Source

RSC FOUC ShakaPerf Investigation

Verdict

AreaStatusFinding
React on Rails dummy appProvenCurrent main fixes the RSC use client CSS FOUC repro.
Old/pre-fix React on RailsProvenReproduces the bug as an unstyled first paint.
ShakaPerf coverageProven for dummy appShakaPerf catches the old/fixed difference and passes fixed/fixed.
hichee downstream validationNot proven yetPackage/manifest evidence exists. ShakaPerf browser proof is still TODO.
Unit testsNot delivered hereAdd after hichee is ShakaPerf-proven and/or the upstream package fix is released/pinned.

Bug target

The bug is CSS for a React Server Components client boundary arriving too late or not being emitted with the RSC response.

Failure mode:

  1. An RSC page renders a component behind a 'use client' boundary.
  2. That client component imports CSS.
  3. The server output does not include a stylesheet signal for that CSS before the boundary is painted.
  4. The user can see the component with transparent background, default text color, no padding, and no border radius before the CSS is applied.

The fixed behavior is that RSC CSS is represented before hydration, so the boundary is styled at first visible paint.

ShakaPerf results

ShakaPerf report screenshots

Old/pre-fix vs current/fixed first paint. ShakaPerf reports a visual change (25.48%) on the RSC CSS probe.

Current/fixed vs current/fixed first paint. Same test, same fixed side on both servers. ShakaPerf reports no difference (0.00%).

Natural first-visible assertion. This does not block app JS; the old/pre-fix side fails the first-visible computed-style assertion.

Selector screenshots

Old/pre-fix selector capture: unstyled first paint.

Current/fixed selector capture: styled first paint.

Test matrix

TestControlExperimentShakaPerf resultInvestigation resultPurpose
Deterministic first-paint visual testold/pre-fixcurrent/fixedFAIL: 1 visual mismatchPASS: real A/B diff detectedBlocks app JS before navigation to isolate server-rendered first paint.
Deterministic first-paint visual testcurrent/fixedcurrent/fixedPASSPASSControl run proving the test is stable when both sides are fixed.
Natural first-visible assertionold/pre-fixcurrent/fixedFAIL: assertion errorPASS: old side is unstyled at first visible frameDoes not block JS. Uses waitUntil: "commit" and RAF computed-style assertion.
Natural first-visible assertioncurrent/fixedcurrent/fixedPASSPASSControl run proving the assertion is not inherently flaky.

Main ShakaPerf evidence

EvidenceResultArtifact
Old/pre-fix first-paint probe image840x24, expected green CSS 0.0%, white/plain background 95.12%, dark text 1.45%.image
Current/fixed first-paint probe image214x36, expected green CSS 70.35%, white/plain background 0.0%.image
Old/pre-fix vs current/fixed visual diff1851 diff pixels.ShakaPerf report screenshot, selector diff
Current/fixed vs current/fixed visual diff0 diff pixels.ShakaPerf report screenshot, report
Natural first-visible old/pre-fix assertionbackgroundColor: rgba(0, 0, 0, 0), color: rgb(0, 0, 0), padding: 0px, borderRadius: 0px, width: 840, height: 24.log, old image, fixed image

ShakaPerf limitation / workaround

This is not a global ShakaPerf limitation.

Normal settled screenshots can miss transient FOUC because ShakaPerf captures after page preparation and the test function complete. For this bug, two valid patterns worked:

  1. deterministic first-paint isolation: block app JS in beforeNavigate, then compare the server-rendered first paint;
  2. natural first-visible assertion: do not block JS, navigate with waitUntil: "commit", and assert computed styles on the first visible animation frame.

For a pure visual "capture exactly at first visible frame" report card, ShakaPerf would need a first-class capture hook inside the test function or a dedicated first-visible capture mode.

Artifact index

Artifacts: rsc-fouc-shakaperf-artifacts/

Artifact index: rsc-fouc-shakaperf-artifacts/README.md

ShakaPerf setup and tests

AreaArtifacts
Setup/test bundleREADME
Deterministic first-paint AB testrsc-fouc.abtest.ts
Natural first-visible assertion AB testnatural-first-visible-assertion.abtest.ts
Main ShakaPerf configabtests.config.ts
Twin server setupDockerfile, Procfile
Generated ShakaPerf instructions followedsetup/generated-shakaperf-skills/

Key ShakaPerf reports

RunReportLog
Old/pre-fix vs current/fixed deterministic first paintfull-report.htmllog
Current/fixed vs current/fixed deterministic first paintfull-report.htmllog
Old/pre-fix vs current/fixed natural first-visible assertionfull-report.htmllog
Current/fixed vs current/fixed natural first-visible assertionfull-report.htmllog

Implementation comparison

Downstream React on Rails implementation

Reference: react_on_rails#3587

General shape:

  • patches [email protected] locally so client-reference manifest entries include css?: string[];
  • adds a React on Rails Pro resolver that dedupes, sorts, and prefixes those CSS hrefs;
  • wraps the RSC tree with React 19 <link rel="stylesheet" precedence="ror-rsc"> elements before renderToPipeableStream;
  • relies on React 19 stylesheet precedence behavior to hoist/block stylesheet application before the boundary commits;
  • includes downstream integration around Rails asset paths and the Pro renderer.

Upstream package implementation

References:

General shape:

  • records CSS metadata in the RSC client manifest;
  • wraps component-shaped client references with stylesheet link records at the package/loader layer;
  • sets globalThis.__reactFlightClientManifest during server render so React can find the manifest;
  • changes deferred Suspense replacement from $RC to $RR where needed so replacement waits for CSS.

Comparison

LayerDownstream implementationUpstream package implementation
Manifest CSS metadataYes, via local pnpm patch against [email protected].Yes, in package/plugin code.
Where stylesheet links are emittedReact on Rails Pro renderer wraps the RSC tree.Package/loader layer wraps client references.
Deferred Suspense CSS gatingRelies on React 19 stylesheet precedence behavior for emitted links.Adds React $RR behavior for deferred replacement waiting on CSS.
Rails asset prefix handlingExplicitly handled by the downstream resolver.Needs integration/pinning verification downstream.
StatusMerged downstream bridge.Open/upstream package work.

Conclusion: the implementations overlap but are not identical. The downstream implementation is a bridge/integration fix for React on Rails. The upstream package implementation is the more complete package-level direction. Once the upstream package fix is released and pinned, the downstream bridge should be reviewed for deletion or reduction to Rails-specific integration only.

Reference: react_on_rails#3577

Status:

  • open;
  • updates to react-on-rails-rsc 19.0.5-rc.5;
  • includes expectations around RSC stream CSS records/preload behavior;
  • should be rechecked after the upstream package fix is released and pinned.

Current npm dist-tags observed during the investigation:

TagVersion
latest19.0.4
rc19.0.5-rc.5

hichee downstream validation

Reference: private shakacode/hichee#9379.

This is not proven with ShakaPerf yet. What we have so far is package and manifest evidence:

  • PR head uses vendored react-on-rails-rsc-19.0.5-rc.1;
  • that package contains the upstream RSC CSS metadata/loader/server-global path;
  • the hichee patch path also handles ReScript make exports;
  • local production manifests show CSS metadata on the PR/head side and none on the compared base side.

Manifest evidence from the local production build artifacts:

hichee sideManifest entriesEntries with CSSUnique CSS files
base9200
PR head101875

No hichee browser/ShakaPerf proof is claimed here. That is still the next step.

Unit-test plan

No new unit tests are included in this PR.

The safest order is:

  1. prove hichee with ShakaPerf;
  2. confirm whether the downstream bridge stays or is replaced by the upstream package fix;
  3. add unit tests against that final path.

Good unit-test targets after that:

TestLocation / owner
client-reference manifest entries include CSS metadata for CSS imported behind 'use client';upstream package or downstream patch if bridge remains
CSS href resolver handles prefix, dedupe, sort, and missing css;downstream React on Rails Pro if resolver remains
renderer emits React 19 stylesheet links from manifest CSS;downstream React on Rails Pro if renderer bridge remains
unpatched/no-CSS manifests behave as no-op;downstream React on Rails Pro
ReScript make exports are classified as component-shaped client references;upstream package if ReScript support is required there
hichee RSC page does not first-paint unstyled;ShakaPerf visual/assertion coverage, not unit-level

Follow-ups

  1. Prove the hichee branch with ShakaPerf.
  2. Recheck react_on_rails#3577 after the upstream package fix is released/pinned.
  3. Decide whether the downstream bridge in react_on_rails#3587 should stay, shrink to Rails asset integration, or be removed after upstream package adoption.
  4. Add unit tests after the target integration path is settled.
  5. If we want ShakaPerf to produce a first-visible screenshot without blocking JS, add/request a first-visible capture hook or capture mode.