diff --git a/integration/single-fetch-test.ts b/integration/single-fetch-test.ts index 38e6dd9ac3..7fa939a816 100644 --- a/integration/single-fetch-test.ts +++ b/integration/single-fetch-test.ts @@ -1583,6 +1583,89 @@ test.describe("single-fetch", () => { expect(errorLogs.length).toBe(1); }); + test("supports nonce on streaming script tags", async ({ page }) => { + let fixture = await createFixture({ + files: { + ...files, + "app/root.tsx": js` + import { Links, Meta, Outlet, Scripts } from "react-router"; + export function loader() { + return { + message: "ROOT", + }; + } + export default function Root() { + return ( + + + + + + + + + + + ); + } + `, + "app/entry.server.tsx": js` + import { PassThrough } from "node:stream"; + import type { EntryContext } from "@react-router/node"; + import { createReadableStreamFromReadable } from "@react-router/node"; + import { RemixServer } from "react-router"; + import { renderToPipeableStream } from "react-dom/server"; + export default function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext + ) { + return new Promise((resolve, reject) => { + const { pipe } = renderToPipeableStream( + , + { + onShellReady() { + const body = new PassThrough(); + const stream = createReadableStreamFromReadable(body); + responseHeaders.set("Content-Type", "text/html"); + resolve( + new Response(stream, { + headers: responseHeaders, + status: responseStatusCode, + }) + ); + pipe(body); + }, + onShellError(error: unknown) { + reject(error); + }, + onError(error: unknown) { + responseStatusCode = 500; + }, + } + ); + }); + } + `, + }, + }); + let appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/data", true); + let scripts = await page.$$("script"); + expect(scripts.length).toBe(6); + let remixScriptsCount = 0; + for (let script of scripts) { + let content = await script.innerHTML(); + if (content.includes("window.__remix")) { + remixScriptsCount++; + expect(await script.getAttribute("nonce")).toEqual("the-nonce"); + } + } + expect(remixScriptsCount).toBe(4); + }); + test.describe("client loaders", () => { test("when no routes have client loaders", async ({ page }) => { let fixture = await createFixture( diff --git a/packages/react-router/lib/dom/ssr/server.tsx b/packages/react-router/lib/dom/ssr/server.tsx index fd83438d12..57d95a12dd 100644 --- a/packages/react-router/lib/dom/ssr/server.tsx +++ b/packages/react-router/lib/dom/ssr/server.tsx @@ -12,6 +12,7 @@ export interface RemixServerProps { context: EntryContext; url: string | URL; abortDelay?: number; + nonce?: string; } /** @@ -23,6 +24,7 @@ export function RemixServer({ context, url, abortDelay, + nonce, }: RemixServerProps): ReactElement { if (typeof url === "string") { url = new URL(url); @@ -98,6 +100,7 @@ export function RemixServer({ identifier={0} reader={context.serverHandoffStream.getReader()} textDecoder={new TextDecoder()} + nonce={nonce} /> ) : null} diff --git a/packages/react-router/lib/dom/ssr/single-fetch.tsx b/packages/react-router/lib/dom/ssr/single-fetch.tsx index c072ba4f0f..3329d3b58e 100644 --- a/packages/react-router/lib/dom/ssr/single-fetch.tsx +++ b/packages/react-router/lib/dom/ssr/single-fetch.tsx @@ -41,6 +41,7 @@ interface StreamTransferProps { identifier: number; reader: ReadableStreamDefaultReader; textDecoder: TextDecoder; + nonce?: string; } // StreamTransfer recursively renders down chunks of the `serverHandoffStream` @@ -50,6 +51,7 @@ export function StreamTransfer({ identifier, reader, textDecoder, + nonce, }: StreamTransferProps) { // If the user didn't render the component then we don't have to // bother streaming anything in @@ -86,6 +88,7 @@ export function StreamTransfer({ let { done, value } = promise.result; let scriptTag = value ? (