Skip to content

fix(app): preserve next/dynamic CSS cascade order - #2879

Open
james-elicx wants to merge 8 commits into
mainfrom
codex/fix-next-dynamic-css
Open

fix(app): preserve next/dynamic CSS cascade order#2879
james-elicx wants to merge 8 commits into
mainfrom
codex/fix-next-dynamic-css

Conversation

@james-elicx

Copy link
Copy Markdown
Member

Summary

Fixes the four non-deferred next-dynamic-css failures from Actions run 31439707085, job 93624401572:

  • should have correct order of styles between global and css modules
  • should have correct order of styles on client component that is sharing styles with next/dynamic
  • should have correct order of styles on next/dynamic loaded component
  • should have correct order of global styles between layout and pages

The implementation gives shared global styles stable build ownership, preserves graph-wide CSS import order across RSC and client-reference manifests, reconciles late dynamic-owner duplicates, and keeps React stylesheet resource identity compatible across SSR and hydration. CSS-module ordering is derived from resolved targets, including extensionless aliases and transitive JavaScript import boundaries.

Validation

  • Exact Next.js v16.2.6 E2E: test/e2e/app-dir/next-dynamic-css/next-dynamic-css.test.ts — 4/4 passed
  • Local production Playwright regressions: 6/6 passed
  • Focused Vitest: 155 passed, 2 skipped
  • vp check
  • vp run vinext#build

The fixture intentionally throws during SSR to exercise client recovery, so the upstream test continues to log its expected HTTP 500 while all CSS assertions pass.

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@pkg-pr-new

pkg-pr-new Bot commented Aug 11, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@2879
npm i https://pkg.pr.new/create-vinext-app@2879
npm i https://pkg.pr.new/@vinext/types@2879
npm i https://pkg.pr.new/vinext@2879

commit: 0f484b6

@github-actions

Copy link
Copy Markdown
Contributor
Example Preview Production Original
app-router-cloudflare preview production
pages-router-cloudflare preview production
app-router-playground preview production original
realworld-api-rest preview production
nextra-docs-template preview production
benchmarks preview production
hackernews preview production original
workers-cache preview production
web preview production

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk

ask-bonk Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Review: fix(app): preserve next/dynamic CSS cascade order

Reviewed the CSS-cascade implementation and test wiring. The core approach (stable global-CSS ownership, graph-wide order reconciliation, browser-side dedup) is reasonable, vp check passes, and the changed helpers are clean. But there are a few issues worth addressing before merge — one of them will break CI.

Blocking

1. New test is miscategorized as unit and will fail the Vitest CI job

tests/app-router-next-dynamic-css-order.test.ts launches a real Playwright browser (chromium.launch(), line 51) and runs a full production build + preview() server. But it is not listed in vite.config.ts's integration project include, nor in the unit project exclude, so it falls through to the unit project (include: ["tests/**/*.test.ts", ...]).

Two concrete consequences:

  • The unit Vitest job (.github/workflows/ci.yml:50, via ./.github/actions/setup) does not install Playwright browsers. I reproduced the failure locally:

    Error: browserType.launch: Executable doesn't exist at .../chrome-headless-shell
    

    This is a hard CI failure (or, worse, non-deterministic if a browser happens to be cached from another job).

  • Even ignoring the browser, this test spins up a Vite build + dev/preview server. Per the repo's own guidance in vite.config.ts:180-184, any test calling startFixtureServer()/createServer()/build must live in the integration project to avoid Vite deps-optimizer cache races. createBuilder().buildApp() + preview() clearly qualifies.

    It's also the only Vitest file that drives Playwright directly — every other browser test lives under tests/e2e/ with its own Playwright config.

    Suggested fix: move the test into the integration project include list, and confirm the integration CI job installs browsers (the E2E jobs cache them, but the integration Vitest job uses the plain setup action). If browser install isn't available there, this should be an E2E test under tests/e2e/ instead.

Non-blocking (should address)

2. No unit coverage for the new pure helpers

The only functional coverage is the browser E2E test above, which is skipped whenever browsers aren't installed. AGENTS.md is explicit: "add a focused unit test for the new helper module." Several of the new functions are pure and trivially unit-testable without a browser:

  • normalizeRscAssetsManifestCssOrderSource / reorderClientReferenceCss (build/rsc-css-order.ts) — feed a manifest string + client manifest, assert reordering.
  • inlineStyleCoversStylesheetHref, dedupeGlobalCssOwnerStylesheetLinks (server/app-inline-css-client.ts) — testable with jsdom.
  • createRscCssResourceCrossOriginPlugin transform — assert the crossOrigin injection given the known input string.

build-optimization.test.ts only references appGlobalCssOwnerChunkName as a config identity; the actual ordering logic is untested.

3. createRscCssResourceCrossOriginPlugin is a brittle string patch with a silent-failure mode

rsc-css-resource-crossorigin.ts does code.replace('"data-rsc-css-href": href', ...) against @vitejs/plugin-rsc's generated Resources component (produced via ResourcesFn.toString()). If the plugin's output format changes (minification, spacing, or renaming that token — I confirmed it currently matches at plugin-Cbs9j6lP.js:2072), the replace silently no-ops and CSS crossorigin parity regresses with no error and no failing test. Consider at minimum a unit test that pins the current match, and ideally a build-time assertion/warning if the expected token isn't found.

4. reorderClientReferenceCss correctness edge cases (build/rsc-css-order.ts:60-72)

  • It returns on the first client-manifest key whose ownFirst prefix-matches css, and leaves css.slice(ownFirst.length) untouched. If a client reference's CSS is assembled from more than one client chunk, only the first chunk's slice gets reordered.
  • hrefMatchesFile matches on endsWith("/" + file) (line 57), so two CSS files sharing a basename in different directories could cross-match in the css.find(...) at line 68.
  • The ?? file fallback (line 68) inserts a bare manifest-relative path (no base prefix) when a dependency isn't found in css, which could produce a mismatched/duplicate href instead of surfacing the inconsistency.

These are edge cases, but with no unit tests exercising them they're easy to regress.

5. installGlobalCssOwnerStylesheetDedupe() runs at module top level with no environment guard

app-browser-entry.ts:206 calls it unconditionally at import time; the function touches document.head directly (no typeof document guard) and installs a permanent MutationObserver on document.head childList. The browser entry is browser-only today (it imports react-dom/client and calls hydrateRoot), so this is safe in practice — but the previous removeStylesheetLinksCoveredByInlineCss was only invoked inside a function, not at top level. Worth a one-line guard or a comment noting the browser-only invariant, since the observer lives for the page lifetime and fires on every head mutation.

6. computeDynamicImportPreloads ordering change is global, not scoped to this fixture

lazy-chunks.ts now emits imported-chunk files (JS+CSS) before the importing chunk's own files (the build-optimization.test.ts snapshot changed accordingly: widget-helper.js now precedes widget.js). This is the correct execution order, but it changes preload ordering for every next/dynamic boundary in every app, not just the CSS-cascade fixture. That's likely fine (matches static-dependency execution order), but please confirm it's intentional and that no downstream consumer relied on own-first ordering.

Nits

  • The generated owner module ends with a bare expression statement globalThis[Symbol.for("vinext.css.owner")]; (app-global-css-owner.ts:103,105). If that's a treeshaking side-effect marker, a short comment would help; as written it reads as dead code.
  • normalizeRscAssetsManifestCssOrderSource silently no-ops (via the JSON.parse catch) when the RSC manifest contains runtime-placeholder hrefs (renderBuiltUrl / asset-prefix / CDN configs), because serializeValueWithRuntime emits unquoted JS expressions that aren't valid JSON. Fails safe, but the CSS-order fix won't apply for those deployments — worth a comment or a tracked follow-up.

@ask-bonk

ask-bonk Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Posted the review to PR #2879. Summary of findings:

Review posted

Blocking (1):

  • New test miscategorized as unit, will break CI. tests/app-router-next-dynamic-css-order.test.ts launches Playwright's chromium and runs a full production build + preview, but isn't in the integration project include (nor the unit exclude), so it runs in the unit project. The unit Vitest CI job doesn't install browsers — I reproduced the Executable doesn't exist failure locally. It also violates the repo's rule that build/server tests must live in integration to avoid deps-optimizer cache races.

Non-blocking (should address):
2. No unit coverage for the new pure helpers (rsc-css-order.ts, app-inline-css-client.ts, crossorigin transform) — only the skippable browser E2E covers them, contrary to AGENTS.md guidance.
3. createRscCssResourceCrossOriginPlugin is a brittle string-patch against @vitejs/plugin-rsc's generated code with a silent no-op failure mode.
4. reorderClientReferenceCss edge cases: first-match-only reordering, endsWith basename ambiguity, and a fallback that can inject mismatched hrefs.
5. installGlobalCssOwnerStylesheetDedupe() runs at module top level touching document.head with no guard (safe today, but a behavior change from the prior in-function call).
6. computeDynamicImportPreloads ordering flip is a global change affecting every next/dynamic boundary — confirm intentional.

Nits: the bare globalThis[Symbol.for(...)] marker reads as dead code; manifest normalization silently no-ops under renderBuiltUrl/asset-prefix configs.

The core approach is sound and vp check passes, but item #1 needs fixing before merge.

github run

@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared 0f484b6 against base d558d62 using alternating same-runner rounds. Next.js was unchanged and skipped.

0 improved · 1 regressed · 5 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 135.2 KB 135.4 KB ⚫ +0.2%
Client entry size (gzip) vinext 122.7 KB 122.9 KB ⚫ +0.2%
Dev server cold start vinext 2.34 s 2.32 s ⚫ -0.9%
Production build time vinext 2.45 s 2.55 s 🔴 +3.9%
RSC entry closure size (gzip) vinext 114.2 KB 114.2 KB ⚫ -0.0%
Server bundle size (gzip) vinext 193.0 KB 193.0 KB ⚫ 0.0%

View detailed results and traces

🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review: fix(app): preserve next/dynamic CSS cascade order

Re-reviewed after the earlier pass. The chromium crash from the previous review is fixed (it.runIf(HAS_CHROMIUM) + chromium.executablePath() existence check at tests/app-router-next-dynamic-css-order.test.ts:18-19), so the hard "Executable doesn't exist" failure is gone. But the deeper test-placement issue still holds, and it's now reinforced by this PR's own base commit.

Blocking

1. New test still runs a full build + preview server in the parallel unit project

tests/app-router-next-dynamic-css-order.test.ts is not in vite.config.ts's integration include, nor in the unit exclude, so it falls through to the unit project (include: ["tests/**/*.test.ts", ...]). The unit project runs files in parallel; only integration sets fileParallelism: false.

The guarded browser tests skip cleanly when no browser is present — good. But beforeAll (lines 42-54) unconditionally runs createBuilder().buildApp() + preview(), and two non-browser it(...) cases (lines 151, 179) always run. So even with no chromium, this test performs a full production build + preview server in parallel with the rest of the unit suite, against a checked-in shared fixture (tests/fixtures/next-dynamic-css) and its dist/.

This is exactly what the unit exclude comment warns against (vite.config.ts:178-184): "Integration tests: spin up Vite dev servers against shared fixture dirs. Must run serially to avoid Vite deps optimizer cache races (node_modules/.vite/*) that produce 'outdated pre-bundle' 500s. When adding a test that calls startFixtureServer() or createServer(), move it here."

Crucially, the immediate base commit of this PRd558d62 "fix(ci): serialize tests sharing Vite optimizer cache (#2874)" — moved sass-tsconfig-paths.test.ts and tsconfig-path-alias-resolution.test.ts (both createBuilder().buildApp()-against-fixture tests) into integration for precisely this reason. Adding a new build+preview-against-shared-fixture test back into the parallel unit project directly re-introduces the race class that base commit just fixed.

Fix: add tests/app-router-next-dynamic-css-order.test.ts to the integration project include (and the unit exclude). If the parity assertions truly need a live browser as their primary signal, an tests/e2e/ Playwright test would be the more conventional home (it's currently the only Vitest file driving Playwright directly).

Non-blocking (should address)

2. Still no unit coverage for the new pure helpers

Confirmed only appGlobalCssOwnerChunkName is referenced in tests (as a config identity in build-optimization.test.ts:3592). The actual ordering/dedup logic is only exercised by the browser E2E above, which is skipped whenever browsers aren't installed — so in a browserless CI lane the new logic has effectively zero assertions. Several new functions are pure and unit-testable without a browser, per AGENTS.md ("add a focused unit test for the new helper module"):

  • normalizeRscAssetsManifestCssOrderSource / reorderClientReferenceCss (build/rsc-css-order.ts) — feed a manifest string + client manifest, assert reordering.
  • inlineStyleCoversStylesheetHref, dedupeGlobalCssOwnerStylesheetLinks (server/app-inline-css-client.ts) — jsdom.
  • createRscCssResourceCrossOriginPlugin transform — assert crossOrigin injection for the known input.

3. createRscCssResourceCrossOriginPlugin is a brittle string patch with a silent no-op

rsc-css-resource-crossorigin.ts:24-25 does code.replace('"data-rsc-css-href": href', ...). I confirmed the target still matches @vitejs/plugin-rsc's generated ResourcesFn.toString() output (node_modules/@vitejs/plugin-rsc/dist/plugin-Cbs9j6lP.js:2072). But that string is whitespace/token-sensitive: if the plugin minifies, reindents, or renames href, the code.includes(...) guard returns null and CSS crossorigin parity silently regresses — no error, no failing test. Pin the current match with a unit test, and consider a build-time warning when the expected token is absent.

4. reorderClientReferenceCss correctness edge cases (build/rsc-css-order.ts:57,60-72)

  • Returns on the first client-manifest key whose ownFirst prefix-matches css, then keeps css.slice(ownFirst.length) untouched (line 70). If a client reference's CSS is assembled from more than one client chunk, only the first chunk's slice is reordered.
  • hrefMatchesFile (line 57) matches on endsWith("/" + file). file is a manifest-relative path so collisions are less likely than a bare basename, but two entries sharing a suffix in different base directories could still cross-match in the css.find(...) at line 68.
  • The ?? file fallback (line 68) inserts a bare manifest-relative path (no base prefix) when a dependency isn't found in css, which can emit a mismatched/duplicate href instead of surfacing the inconsistency.

All three are untested (see #2), so they're easy to regress.

5. installGlobalCssOwnerStylesheetDedupe() runs at module top level, unguarded (app-browser-entry.ts:206)

Called unconditionally at import time; dedupeGlobalCssOwnerStylesheetLinks() touches document.head (no typeof document guard) and installs a permanent MutationObserver on document.head's childList that lives for the page lifetime. The browser entry is browser-only today, so it's safe in practice — but the prior removeStylesheetLinksCoveredByInlineCss() was only ever invoked inside a function/effect. A one-line guard or an invariant comment would make the top-level side effect intentional rather than incidental.

6. computeDynamicImportPreloads ordering flip is global (utils/lazy-chunks.ts:115-125)

CSS emission now happens after imported-chunk recursion, so preload order changes from [own.js, own.css, imported…] to [own.js, imported…, own.css] (the build-optimization.test.ts snapshot updates accordingly: widget-helper.js now precedes widget.css). This is the correct execution order, but it changes preload ordering for every next/dynamic boundary in every app, not just this fixture. Likely fine, but please confirm it's intentional and no consumer relied on own-first CSS ordering.

Nits

  • app-global-css-owner.ts:103,105 ends the generated owner module with a bare expression statement globalThis[Symbol.for("vinext.css.owner")];. If it's a treeshaking side-effect marker, a one-line comment would help; as written it reads as dead code.
  • normalizeRscAssetsManifestCssOrderSource silently no-ops (via the JSON.parse catch, rsc-css-order.ts:82-86) when the RSC manifest carries runtime-placeholder hrefs (renderBuiltUrl / asset-prefix / CDN configs) — those emit unquoted JS expressions that aren't valid JSON. Fails safe, but the CSS-order fix won't apply for those deployments; worth a comment or tracked follow-up.

The core approach (stable global-CSS ownership, graph-wide order reconciliation, browser-side dedup) is sound and vp check passes. Item #1 should be resolved before merge — it re-introduces the exact deps-optimizer race class the base commit d558d62 fixed.

@ask-bonk

ask-bonk Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2879.

Summary

Blocking (1):

  • New test still runs a full build + preview server in the parallel unit project. The chromium crash from the prior review is fixed (it.runIf(HAS_CHROMIUM) guard), but beforeAll unconditionally runs createBuilder().buildApp() + preview() against a shared checked-in fixture, and two non-browser it cases always run — all in the parallel unit project. This is exactly the deps-optimizer cache race class that this PR's immediate base commit d558d62 (fix(ci): serialize tests sharing Vite optimizer cache #2874) just fixed by moving createBuilder-against-fixture tests into the serial integration project. Fix: add the file to integration include / unit exclude, or make it a tests/e2e/ Playwright test.

Non-blocking:
2. No unit coverage for the new pure helpers (rsc-css-order.ts, app-inline-css-client.ts, crossorigin transform) — only the skippable browser E2E covers them.
3. createRscCssResourceCrossOriginPlugin is a brittle whitespace-sensitive string patch against plugin-rsc's generated code with a silent no-op failure mode (confirmed match at plugin-Cbs9j6lP.js:2072).
4. reorderClientReferenceCss edge cases: first-match-only reordering, endsWith suffix ambiguity, mismatched ?? file fallback.
5. installGlobalCssOwnerStylesheetDedupe() runs at module top level touching document.head + permanent MutationObserver, no guard.
6. computeDynamicImportPreloads CSS ordering flip is a global change affecting every next/dynamic boundary — confirm intentional.

Nits: bare globalThis[Symbol.for(...)] marker reads as dead code; manifest normalization silently no-ops under renderBuiltUrl/asset-prefix configs.

The core approach is sound and vp check passes; item #1 should be resolved before merge.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review: fix(app): preserve next/dynamic CSS cascade order

Re-reviewed at 906fa66. The blocking issue from the prior three passes is now fixedtests/app-router-next-dynamic-css-order.test.ts is added to the unit exclude (vite.config.ts:196) and the integration include (vite.config.ts:266), so it runs serially in the integration project and is skipped by the unit project. I verified both:

  • vp test run tests/app-router-next-dynamic-css-order.test.ts --project unitNo test files found (correctly excluded)
  • vp test run tests/app-router-next-dynamic-css-order.test.ts --project integration2 passed | 6 skipped (browser tests skip cleanly via it.runIf(HAS_CHROMIUM); the build + manifest-order assertions run and pass)

This resolves the deps-optimizer race class that base commit d558d62 (#2874) fixed. No blocking issues remain.

Non-blocking (should address)

1. Still no browserless unit coverage for the pure helpers

Confirmed only appGlobalCssOwnerChunkName is referenced in tests (build-optimization.test.ts:3591). In a browserless CI lane, the reorder/dedup/crossorigin logic is exercised only by the two non-browser assertions in the integration file (which read the built manifest). The functions themselves are pure and trivially unit-testable per AGENTS.md ("add a focused unit test for the new helper module"):

  • reorderClientReferenceCss / normalizeRscAssetsManifestCssOrderSource (build/rsc-css-order.ts) — feed a manifest string + client manifest, assert reordering (and the JSON.parse no-op path).
  • inlineStyleCoversStylesheetHref / dedupeGlobalCssOwnerStylesheetLinks (server/app-inline-css-client.ts) — jsdom.
  • createRscCssResourceCrossOriginPlugin transform — assert crossOrigin: "" injection for the known input, which doubles as the pin for #2.

2. createRscCssResourceCrossOriginPlugin is a brittle string patch with a silent no-op (rsc-css-resource-crossorigin.ts:24-25)

code.replace('"data-rsc-css-href": href', ...). I re-confirmed the target still matches @vitejs/plugin-rsc's generated ResourcesFn.toString() output at plugin-Cbs9j6lP.js:2072. But if the plugin minifies, reindents, or renames href, the code.includes(...) guard returns null and CSS crossorigin parity silently regresses — no error, no failing test. Pin the current token with a unit test, and consider a build-time warning when it's absent.

3. reorderClientReferenceCss edge cases (build/rsc-css-order.ts:57,60-72)

  • Returns on the first client-manifest key whose ownFirst prefix-matches css, keeping css.slice(ownFirst.length) untouched (line 70). If a client reference's CSS spans more than one client chunk, only the first chunk's slice reorders.
  • hrefMatchesFile (line 57) uses endsWith("/" + file); file is manifest-relative so collisions are unlikely, but two entries sharing a suffix across base dirs could still cross-match.
  • The ?? file fallback (line 68) is effectively dead given line 64 already verified ownFirstcss and dependenciesFirst is a permutation of ownFirst — but if that invariant ever breaks it silently inserts a bare, base-less path instead of surfacing the inconsistency. A unit test would lock the invariant in.

4. installGlobalCssOwnerStylesheetDedupe() runs at module top level (app-browser-entry.ts:206)

It has an idempotency guard (GLOBAL_CSS_OWNER_DEDUPE symbol, app-inline-css-client.ts:87), so double-install is handled. But dedupeGlobalCssOwnerStylesheetLinks() touches document.head with no typeof document guard and installs a permanent MutationObserver for the page lifetime. The server browser entry is browser-only today (hydrateRoot), so it's safe in practice — but the prior removeStylesheetLinksCoveredByInlineCss() was only ever called inside a function. A one-line invariant comment (or typeof document guard) would make the top-level side effect intentional rather than incidental.

5. computeDynamicImportPreloads ordering flip is global (utils/lazy-chunks.ts:115-125)

CSS emission now happens after imported-chunk recursion, so preload order changes from [own.js, own.css, imported…] to [own.js, imported…, own.css] (the build-optimization.test.ts:1655 snapshot updates accordingly: widget-helper.js now precedes widget.css). This is the correct execution order, but it changes preload ordering for every next/dynamic boundary in every app, not just this fixture. Likely fine (mirrors static-dependency execution order), but please confirm it's intentional and no consumer relied on own-first CSS ordering.

Nits

  • app-global-css-owner.ts:103,105 ends the generated owner module with a bare expression statement globalThis[Symbol.for("vinext.css.owner")];. If it's a treeshaking side-effect marker, a one-line comment would help; as written it reads as dead code.
  • normalizeRscAssetsManifestCssOrderSource silently no-ops (via the JSON.parse catch, rsc-css-order.ts:82-86) when the RSC manifest carries runtime-placeholder hrefs (renderBuiltUrl / asset-prefix / CDN configs) — those emit unquoted JS expressions that aren't valid JSON. Fails safe, but the CSS-order fix won't apply for those deployments; worth a comment or tracked follow-up.

The core approach (stable global-CSS ownership, graph-wide order reconciliation, browser-side dedup) is sound, vp check passes on the new files, and the previously blocking test-placement issue is resolved. The remaining items are non-blocking — none require a change before merge, though the unit coverage in #1 would meaningfully de-risk items #2#3.

@ask-bonk

ask-bonk Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2879.

Summary

Re-reviewed at 906fa66 and verified the current state against the prior three review passes.

Blocking: RESOLVED. The test-placement issue that failed CI in every prior pass is now fixed. tests/app-router-next-dynamic-css-order.test.ts is added to the unit exclude (vite.config.ts:196) and the integration include (vite.config.ts:266). I confirmed both empirically:

  • --project unitNo test files found (excluded)
  • --project integration2 passed | 6 skipped (browser tests skip cleanly; build + manifest-order assertions pass)

This closes the deps-optimizer race class that base commit d558d62 (#2874) fixed. No blocking issues remain.

Non-blocking (carried forward, all still valid):

  1. No browserless unit coverage for the pure helpers (rsc-css-order.ts, app-inline-css-client.ts, crossorigin transform) — only the integration manifest assertions exercise them.
  2. createRscCssResourceCrossOriginPlugin is a brittle string patch (confirmed match still at plugin-Cbs9j6lP.js:2072) with a silent no-op failure mode.
  3. reorderClientReferenceCss edge cases: first-match-only reordering, endsWith suffix ambiguity, and an effectively-dead ?? file fallback that would silently emit a base-less path if its invariant broke.
  4. installGlobalCssOwnerStylesheetDedupe() — I noted the prior reviews missed that it does have an idempotency guard (GLOBAL_CSS_OWNER_DEDUPE); the remaining concern is only the unguarded top-level document.head access / permanent observer.
  5. computeDynamicImportPreloads CSS ordering flip is a global change affecting every next/dynamic boundary — confirm intentional.

Nits: bare globalThis[Symbol.for(...)] marker reads as dead code; manifest normalization silently no-ops under renderBuiltUrl/asset-prefix configs.

The core approach is sound, vp check passes on the new files, and the PR is now mergeable — the remaining items are non-blocking, though adding the unit coverage in item #1 would de-risk items #2#3.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review: fix(app): preserve next/dynamic CSS cascade order

Re-reviewed at 0f484b6. The new commit test(css): harden cascade order helpers addresses essentially all of the prior-pass feedback. Verified each item empirically.

Previously-blocking issue: still RESOLVED

tests/app-router-next-dynamic-css-order.test.ts remains in the unit exclude (vite.config.ts:196) and integration include (vite.config.ts:266). Confirmed:

  • --project unitNo test files found (excluded)
  • --project integration2 passed | 6 skipped (browser tests skip via it.runIf(HAS_CHROMIUM); build + manifest-order assertions run and pass)

No blocking issues remain.

Prior non-blocking items — now addressed

#1 Unit coverage for pure helpers — DONE. New tests/app-css-order-helpers.test.ts (163 lines, 9 tests, all passing in the unit project, browserless) now covers reorderClientReferenceCss, normalizeRscAssetsManifestCssOrderSource (including the runtime-expression no-op path), addRscCssResourceCrossOrigin, inlineStyleCoversStylesheetHref, removeStylesheetLinksCoveredByInlineCss, dedupeGlobalCssOwnerStylesheetLinks, and the browserless installGlobalCssOwnerStylesheetDedupe guard.

#2 Brittle crossorigin string patch — DONE. rsc-css-resource-crossorigin.ts replaced the exact-string code.replace('"data-rsc-css-href": href', ...) with a whitespace/quote/identifier-tolerant regex (RESOURCE_LINK_HREF_RE), and now throws loudly ("changed its CSS resource module shape") instead of silently no-oping when the token is absent. A test pins both the match and the throw. Confirmed the regex still matches plugin-rsc's current output at plugin-Cbs9j6lP.js:2072.

#3 reorderClientReferenceCss edge cases — DONE. Rewritten to a segment-based matcher: it walks css left-to-right, matches each client chunk's ownFirst sequence, and resolves against the exact matched segment via a positional hrefByFile map (rsc-css-order.ts:88-91) rather than a whole-list find. This eliminates the first-match-only slice, the endsWith cross-match, and the base-less ?? file fallback that the prior reviews flagged. Because reordering is a positional permutation within a matched segment, no wrong/base-less href can be emitted — worst case is a benign reorder of a coincidentally-matching run.

#4 Unguarded top-level installGlobalCssOwnerStylesheetDedupe() — DONE. Now guarded with typeof document === "undefined" || typeof MutationObserver === "undefined" (app-inline-css-client.ts:87) plus the existing idempotency symbol. A test asserts it's a no-op outside a browser.

#5 Dead-code-looking globalThis[Symbol.for(...)] marker — DONE. Now preceded by // Keep this module as the stylesheet's stable chunk owner. (app-global-css-owner.ts:103,105).

#6 computeDynamicImportPreloads global ordering flip. Unchanged and confirmed intentional — collectStaticChunkFiles emits JS-first, then imported chunks, then own CSS, with an explanatory comment (lazy-chunks.ts:112-125) matching react-loadable/Vite preload semantics. The build-optimization.test.ts:1655 snapshot reflects it. This is the correct execution order; browsers/React dedupe preloads, so the global change is safe.

Remaining (minor, non-blocking)

  • buildApp() returns Promise.resolve() but does synchronous fs I/O (index.ts:6920-6937). Correct as written (the sync work completes before the resolved promise), but the Promise.resolve() wrapper is cosmetic — an async hook with no await, or dropping the wrapper, would read more clearly. Not a bug.
  • normalizeRscAssetsManifestCssOrderSource still fails-safe (no-op) under renderBuiltUrl/asset-prefix/CDN manifests because those emit non-JSON runtime expressions (now covered by a test asserting the verbatim pass-through). The CSS-order fix won't apply for those deployments; a tracked follow-up would be worthwhile, but the fail-safe behavior is correct.

Verdict

The core approach (stable global-CSS ownership, SCC-aware graph-order reconciliation, segment-based manifest reordering, browser-side dedup) is sound. vp check passes, the new unit tests pass in the unit project, and the integration test passes serially in the integration project. All prior blocking and non-blocking items are resolved or reduced to cosmetic notes. LGTM — mergeable.

@ask-bonk

ask-bonk Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2879.

Summary

Re-reviewed at the latest commit 0f484b6 (test(css): harden cascade order helpers), which landed after the prior three passes. All previously-raised items are now resolved or reduced to cosmetic notes.

Previously-blocking (test placement): still RESOLVED. Verified empirically — the build+preview test runs only in the serial integration project (2 passed | 6 skipped), and is excluded from the parallel unit project (No test files found).

Prior non-blocking items — now fixed by the new commit:

  1. Unit coverage added — new tests/app-css-order-helpers.test.ts (9 browserless tests, all passing) covers all the pure helpers.
  2. Crossorigin patch hardened — exact-string .replace swapped for a whitespace/identifier-tolerant regex that now throws loudly instead of silently no-oping; test pins both paths. Confirmed it still matches plugin-rsc output at plugin-Cbs9j6lP.js:2072.
  3. reorderClientReferenceCss rewritten to a positional, segment-based matcher — eliminates the first-match-only slice, endsWith cross-match, and base-less ?? file fallback.
  4. installGlobalCssOwnerStylesheetDedupe() guarded with typeof document/MutationObserver checks + idempotency symbol.
  5. Dead-code-looking marker now has an explanatory comment.
  6. computeDynamicImportPreloads ordering flip confirmed intentional (matches react-loadable/Vite execution order; browsers/React dedupe).

Remaining (cosmetic only): the buildApp() Promise.resolve() wrapper around sync fs I/O reads oddly (not a bug), and the manifest normalization still fails-safe under renderBuiltUrl/CDN configs (now test-covered, worth a tracked follow-up).

Verdict: LGTM — mergeable. Core approach is sound, vp check passes, and both test lanes pass.

github run

@james-elicx
james-elicx marked this pull request as ready for review August 11, 2026 01:58
@james-elicx

Copy link
Copy Markdown
Member Author

NO FINDINGS — independent closure review of exact head 0f484b6cb1472ae5cbd1855da9926f2f89e90634.

I reviewed the complete diff against d558d6245290c0235e6a0bd27d471b8110a82952, including the stable global-CSS owner chunks, RSC CSS ordering traversal, client-reference manifest normalization, React stylesheet resource identity, browser deduplication, lazy chunk behavior, and build/plugin wiring. I also compared the ported fixture and assertions against the Next.js v16.2.6 source test and verified the installed @vitejs/plugin-rsc@0.5.34 output shape expected by the resource-identity transform.

Validation on this exact SHA:

  • Targeted upstream Next.js parity: test/e2e/app-dir/next-dynamic-css/next-dynamic-css.test.ts — 4/4 passed, retry 0/0.
  • tests/app-css-order-helpers.test.ts — 9/9 passed.
  • tests/app-router-next-dynamic-css-order.test.ts — 8/8 passed.
  • Targeted vp check across all changed TypeScript/config files — clean (12 files).
  • vp run vinext#build — passed.
  • Remote head still matches the reviewed SHA, the worktree is clean, and the PR is mergeable.

No actionable correctness, parity, or regression gaps found.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant