Skip to content

fix(app-router): isolate global-not-found chunk to fix CSS cascade (#1549)#1654

Merged
james-elicx merged 1 commit into
mainfrom
fix/issue-1549-global-not-found-css-order
May 28, 2026
Merged

fix(app-router): isolate global-not-found chunk to fix CSS cascade (#1549)#1654
james-elicx merged 1 commit into
mainfrom
fix/issue-1549-global-not-found-css-order

Conversation

@james-elicx

Copy link
Copy Markdown
Member

Summary

  • Closes App Router: CSS ordering wrong for global-not-found.tsx #1549.
  • When app/global-not-found.tsx is defined, route-miss 404s render that module standalone (skipping the root layout). The entry was registering it as a static import * as, so the bundler co-located its CSS with the root layout's CSS in a single chunk. Lightningcss then dropped overlapping declarations between the two stylesheets as dead code, breaking the CSS cascade — the layout's rules silently overrode global-not-found's.
  • Switch to () => import("…/global-not-found.tsx") so the module gets its own JS chunk, and have the fallback renderer accept a (cached) loader instead of the eagerly-imported module.
  • Adds a regression test asserting the SSR markup for /does-not-exist links only global-not-found's stylesheet — never the layout's — so the cascade can never resolve to the wrong color.

Ported from Next.js: test/e2e/app-dir/initial-css-order/initial-css-order.test.ts.

Test plan

  • vp test run tests/app-fallback-renderer.test.ts tests/entry-templates.test.ts tests/nextjs-compat/global-not-found.test.ts — all 41 tests pass (including the new CSS-link-order test).
  • vp test run tests/app-router.test.ts — full app-router suite (332 tests) still passes.
  • vp check — types and lints clean.

…rridden by layout CSS (#1549)

When `app/global-not-found.tsx` is defined, route-miss 404s render that
module standalone (it ships its own `<html>`/`<body>` and replaces the
root layout). Previously the entry registered global-not-found.tsx as a
static `import * as`, which co-located its CSS with the root layout's CSS
in a single chunk. Lightningcss then dropped overlapping declarations
between the two stylesheets as dead code, so any rule global-not-found
defined that the layout also defined was silently removed — breaking the
CSS cascade for 404s.

Switching the entry to `() => import("…/global-not-found.tsx")` puts the
module in its own JS chunk and lets the SSR pipeline (and `Resources`
metadata) treat its CSS independently of the rest of the app. The
fallback renderer now accepts a `loadGlobalNotFoundModule` loader (cached
per worker) instead of an eagerly-imported module.

Also adds a regression test asserting the SSR markup for `/does-not-exist`
links only global-not-found's stylesheet — never the root layout's — so
the cascade can never resolve to the wrong color.

Ported from Next.js: test/e2e/app-dir/initial-css-order/initial-css-order.test.ts
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review

@pkg-pr-new

pkg-pr-new Bot commented May 28, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/vinext@1654

commit: 75c3f81

@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
web preview production

@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.

Clean fix. The root cause analysis is correct — static import * as puts global-not-found in the same chunk as the root layout, and lightningcss deduplicates overlapping declarations across concatenated sheets. Switching to dynamic import() gives the module its own chunk and its own CSS asset.

A few observations, nothing blocking:

Cached rejection promise. resolveGlobalNotFoundModule() caches the promise unconditionally. If the dynamic import() rejects (e.g. syntax error during dev), the rejected promise is cached forever for the lifetime of the worker/dev-server instance. The comment says "failures are surfaced on every call so they don't get swallowed" — that's true (every caller will see the rejection), but the module can never recover without restarting. In dev this is fine since HMR restarts the module graph, but worth noting. Not a blocker since the previous eager-import behavior had the same failure mode (module-level import error = worker crash).

Promise.resolve().then(loader) vs just loader(). The extra microtask hop via Promise.resolve().then(loadGlobalNotFoundModule) is harmless but unnecessary — loadGlobalNotFoundModule already returns a Promise (it's a dynamic import()). loadGlobalNotFoundModule() alone would work identically and be marginally cleaner. Totally cosmetic.

The test coverage is solid — the CSS link extraction helper, the positive/negative assertions on which stylesheets appear for matched vs 404 routes, and the unit test updates for the manifest and entry generator all cover the change well. The Next.js test reference is properly cited.

LGTM.

@ask-bonk

ask-bonk Bot commented May 28, 2026

Copy link
Copy Markdown
Contributor

Review posted and PR approved. Here's a summary:

Verdict: Approved

The fix correctly identifies that a static import * as co-locates global-not-found's CSS with the root layout's CSS in a single chunk, where lightningcss drops overlapping declarations as dead code. Switching to () => import(...) gives the module its own chunk with isolated CSS.

Two minor observations (non-blocking):

  1. The cached promise in resolveGlobalNotFoundModule() means a rejected import (e.g. syntax error) is permanently cached for the worker lifetime — same failure mode as the previous eager import though, so not a regression.
  2. Promise.resolve().then(loader) adds an unnecessary microtask hop since the loader already returns a Promise — purely cosmetic.

Test coverage is thorough: CSS link extraction, positive/negative stylesheet assertions for matched vs 404 routes, and updated unit tests for the manifest and entry generator.

github run

@james-elicx james-elicx marked this pull request as ready for review May 28, 2026 15:07
const globalNotFoundModule = ${globalNotFoundVar ? globalNotFoundVar : "null"};
// See Next.js test: test/e2e/app-dir/initial-css-order/initial-css-order.test.ts
const __loadGlobalNotFoundModule = ${
globalNotFoundImportSpecifier ? `() => import(${globalNotFoundImportSpecifier})` : "null"
@james-elicx james-elicx merged commit 5bcbee6 into main May 28, 2026
40 checks passed
@james-elicx james-elicx deleted the fix/issue-1549-global-not-found-css-order branch May 28, 2026 15:32
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.

App Router: CSS ordering wrong for global-not-found.tsx

2 participants