Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
2 changes: 2 additions & 0 deletions apps/web/app/lib/security.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ function csp(scriptSrc: string[]): string {
// `script-src` is enforced. All static inline styles have been migrated to classes in app.css,
// shrinking this to a handful of injection-free numeric/colour values; with no HTML-injection
// sink today, this stays defense-in-depth.
// <Links nonce=""> in root.tsx relies on `style-src` retaining 'self' without a nonce source,
// so its empty link nonce remains inert.
"style-src 'self' 'unsafe-inline'",
"font-src 'self'",
"img-src 'self' data:",
Expand Down
81 changes: 81 additions & 0 deletions apps/web/app/root.render.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// 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', () => {
expect(layout).toBeDefined();
const links = layoutElements('Links');

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.

Тестът валидира само първия <Links> елемент (links[0]). Ако намерението е инвариантата да важи за всеки <Links> в Layout, обмислете проверка върху всички намерени елементи (напр. итерация през links). В текущия код има само един, така че това е само превантивна бележка за устойчивост при бъдещи промени.

expect(links[0]).toBeDefined();
const nonce = attribute(links[0]!, 'nonce');
expect(
nonce?.initializer && ts.isStringLiteral(nonce.initializer) && nonce.initializer.text,
).toBe('');
});

it('suppresses hydration warnings on body attributes', () => {
expect(layout).toBeDefined();
const bodies = layoutElements('body');
expect(bodies[0]).toBeDefined();
const suppressHydrationWarning = attribute(bodies[0]!, 'suppressHydrationWarning');
expect(suppressHydrationWarning).toBeDefined();
expect(suppressHydrationWarning?.initializer).toBeUndefined();
});

it('keeps body hydration suppression scoped to its only attribute', () => {
expect(layout).toBeDefined();
const bodies = layoutElements('body');
expect(bodies[0]).toBeDefined();

const attributes = bodies[0]!.attributes.properties;
expect(attributes).toHaveLength(1);

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.

Тази проверка toHaveLength(1) прави теста крехък: всяко бъдещо легитимно добавяне на атрибут към <body> (напр. className, lang, data-*) ще счупи теста, дори когато suppressHydrationWarning продължава да присъства коректно. Ако целта е да се гарантира именно наличието на suppressHydrationWarning, по-устойчиво е да се провери, че атрибутът присъства (както прави вторият тест), вместо да се фиксира точният брой атрибути. Ако строгостта е умишлена (за да предотврати мълчаливо добавяне на pre-hydration атрибути към <body>), добавете кратък коментар, който да обясни намерението, за да не изглежда като случайно ограничение.

expect(ts.isJsxAttribute(attributes[0]!)).toBe(true);
expect((attributes[0]! as ts.JsxAttribute).name.getText(rootSource)).toBe(
'suppressHydrationWarning',
);
});
});
35 changes: 33 additions & 2 deletions apps/web/app/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,13 +125,44 @@ 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 throughout <Links>.

`<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 without it on the client. Only the
stylesheet links surface this mismatch: icon links are React 19 hoistables matched by
href/rel, where nonce is not compared. The shell stylesheets make this a development-console
warning on every page; production react-dom does no attribute hydration diffing (issue #274).

Passing an explicit non-null nonce here wins over the context nonce (RR falls back only when
the prop is null/undefined), so both server and client render `nonce=""` — they match.
This regressed in react-router 7.18.0 (remix-run/react-router#15170, “Use the ServerRouter
nonce for nonce-aware SSR components”); before that <Links> did not read the context nonce.
Revisit/remove this workaround if a future RR release changes that behaviour.

The explicit value flows through the whole <Links> subtree, including criticalCss <style> and
PrefetchPageLinks modulepreload links. This app uses neither today; if enabled they receive an
empty nonce, inert under `style-src 'self' 'unsafe-inline'` but relevant if style-src gains a
nonce source. Scripts retain their real nonce via <Scripts nonce> / renderToReadableStream.
*/}
<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 sets no <body> attributes before hydration: accessibility and overflow changes run in
useEffect afterwards. This suppresses extension noise, but would also mask a pre-hydration body
attribute mismatch (for example, from an inline script). The flag is SHALLOW: it suppresses
only <body>'s own attributes/text, never its children, so page-level mismatches still surface.
*/}
<body suppressHydrationWarning>
{children}
<ScrollRestoration nonce={nonce} getKey={scrollKey} />
<Scripts nonce={nonce} />
Expand Down