Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions apps/web/app/root.render.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// The nonce mismatch is created by ServerRouter's FrameworkContext, but reproducing that
// server/client split needs Router internals (a manifest and data-router state) rather than the
// app's rendering harness. Keep this regression test structural: it protects the two exact
// Layout props that make the server and client markup agree without coupling to those internals.
import ts from 'typescript';
import { describe, expect, it } from 'vitest';
// Read root.tsx as a raw string via Vite's `?raw` import (typed by vite/client) rather than node:fs —
// apps/web test files are typechecked under the Workers config (tsconfig.cloudflare.json), which has no
// Node types, so `node:fs`/`node:url` would not resolve.
import rootRaw from './root.tsx?raw';

const rootSource = ts.createSourceFile(
'root.tsx',
rootRaw,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TSX,
);

const layout = rootSource.statements.find(
(statement): statement is ts.FunctionDeclaration =>
ts.isFunctionDeclaration(statement) && statement.name?.text === 'Layout',
);

function layoutElements(tagName: string): ts.JsxOpeningLikeElement[] {
const elements: ts.JsxOpeningLikeElement[] = [];
const visit = (node: ts.Node) => {
const element = ts.isJsxElement(node)
? node.openingElement
: ts.isJsxSelfClosingElement(node)
? node
: undefined;
if (element?.tagName.getText(rootSource) === tagName) {
elements.push(element);
}
ts.forEachChild(node, visit);
};
ts.forEachChild(layout!, visit);
return elements;
}

function attribute(element: ts.JsxOpeningLikeElement, name: string): ts.JsxAttribute | undefined {
return element.attributes.properties.find(
(property): property is ts.JsxAttribute =>
ts.isJsxAttribute(property) && property.name.getText(rootSource) === name,
);
}

describe('root Layout hydration guards', () => {
it('passes an explicit empty nonce to Links', () => {
const nonce = attribute(layoutElements('Links')[0]!, 'nonce');

expect(layout).toBeDefined();
expect(
nonce?.initializer && ts.isStringLiteral(nonce.initializer) && nonce.initializer.text,
).toBe('');
});

it('suppresses hydration warnings on body attributes', () => {
const suppressHydrationWarning = attribute(
layoutElements('body')[0]!,
'suppressHydrationWarning',
);

expect(layout).toBeDefined();
expect(suppressHydrationWarning).toBeDefined();
expect(suppressHydrationWarning?.initializer).toBeUndefined();
});
});
29 changes: 27 additions & 2 deletions apps/web/app/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,13 +125,38 @@ export function Layout({ children }: { children: React.ReactNode }) {
<meta name="twitter:card" content="summary_large_image" />
{imageUrl && <meta name="twitter:image" content={imageUrl} />}
<Meta />
<Links />
{/*
Force an empty (but present) nonce on the stylesheet/icon <link>s.

`<ServerRouter nonce>` seeds React Router's FrameworkContext nonce — needed for the
streaming <script> chunks — and <Links> reads that same context nonce and stamps it on
every <link> it renders. The client's <HydratedRouter> never receives the nonce (it isn't
serialized in the hydration handoff), so <Links> renders the links WITHOUT a nonce on the
client. Server `nonce="…"` vs client `nonce={undefined}` is a hydration attribute mismatch
on EVERY page (issue #274).

Passing an explicit non-null nonce here wins over the context nonce (RR only falls back to
the context value when the prop is null), so both server and client render `nonce=""` —
they match. The links never needed a real nonce anyway: the CSP is `style-src 'self'
'unsafe-inline'` (no style nonce), so the value is cosmetic. Scripts keep their real nonce
via <Scripts nonce> / renderToReadableStream, so the CSP script gate is unaffected.
*/}
<Links nonce="" />
{schemaOrg && (
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: schemaOrg }} />
)}
<script src="/assets/accessibility/accessibility.js" defer />
</head>
<body>
{/*
`suppressHydrationWarning` on <body>: browser extensions (ColorZilla → `cz-shortcut-listen`,
Grammarly → `data-gr-*`, password managers, etc.) inject attributes onto <body> BEFORE React
hydrates, which React then reports as a „server ≠ client" attribute mismatch on every page.
The app itself sets no attributes on <body>, so the only source is the visitor's extensions —
noise we can't control, and exactly the console clutter #274 set out to clear so real mismatches
aren't masked. The flag is SHALLOW (it suppresses mismatches on <body>'s own attributes/text
only, never its children), so a genuine app-level mismatch inside the page still surfaces.
*/}
<body suppressHydrationWarning>
{children}
<ScrollRestoration nonce={nonce} getKey={scrollKey} />
<Scripts nonce={nonce} />
Expand Down