From dd58ad906b16bb0cdf596dbfa28720bae3a397c4 Mon Sep 17 00:00:00 2001 From: Jacob Ebey Date: Mon, 3 Feb 2025 12:52:46 -0800 Subject: [PATCH 01/14] feat: turbo-stream v3 --- integration/helpers/create-fixture.ts | 4 +-- integration/transition-test.ts | 2 +- .../__tests__/server-runtime/data-test.ts | 2 +- packages/react-router/__tests__/setup.ts | 4 +++ .../lib/dom-export/hydrated-router.tsx | 2 +- .../react-router/lib/dom/ssr/single-fetch.tsx | 33 ++----------------- .../react-router/lib/server-runtime/routes.ts | 4 +-- .../lib/server-runtime/single-fetch.ts | 32 +++--------------- packages/react-router/package.json | 2 +- pnpm-lock.yaml | 11 ++++--- 10 files changed, 25 insertions(+), 71 deletions(-) diff --git a/integration/helpers/create-fixture.ts b/integration/helpers/create-fixture.ts index cc62dd0dbc..6bbb053f13 100644 --- a/integration/helpers/create-fixture.ts +++ b/integration/helpers/create-fixture.ts @@ -118,7 +118,7 @@ export async function createFixture(init: FixtureInit, mode?: ServerMode) { status: 200, statusText: "OK", headers: new Headers(), - data: (await decodeViaTurboStream(stream, global)).value, + data: (await decodeViaTurboStream(stream, global)), }; }, postDocument: () => { @@ -160,7 +160,7 @@ export async function createFixture(init: FixtureInit, mode?: ServerMode) { statusText: response.statusText, headers: response.headers, data: response.body - ? (await decodeViaTurboStream(response.body!, global)).value + ? (await decodeViaTurboStream(response.body!, global)) : null, }; }; diff --git a/integration/transition-test.ts b/integration/transition-test.ts index 1521257e6b..cf589f745c 100644 --- a/integration/transition-test.ts +++ b/integration/transition-test.ts @@ -257,7 +257,7 @@ test.describe("rendering", () => { }); const decoded = await decodeViaTurboStream(body, global); - expect(Object.keys(decoded.value as Record)).toEqual([ + expect(Object.keys(decoded as Record)).toEqual([ "routes/page.child", ]); diff --git a/packages/react-router/__tests__/server-runtime/data-test.ts b/packages/react-router/__tests__/server-runtime/data-test.ts index 4e29ad03e0..72ad0f7bc8 100644 --- a/packages/react-router/__tests__/server-runtime/data-test.ts +++ b/packages/react-router/__tests__/server-runtime/data-test.ts @@ -35,6 +35,6 @@ describe("loaders", () => { let res = await handler(request); if (!res.body) throw new Error("No body"); const decoded = await decodeViaTurboStream(res.body, global); - expect((decoded.value as any)[routeId].data).toEqual("/random"); + expect((decoded as any)[routeId].data).toEqual("/random"); }); }); diff --git a/packages/react-router/__tests__/setup.ts b/packages/react-router/__tests__/setup.ts index 8c208adb43..984344051d 100644 --- a/packages/react-router/__tests__/setup.ts +++ b/packages/react-router/__tests__/setup.ts @@ -28,6 +28,10 @@ if (!globalThis.TextEncoderStream) { const { TextEncoderStream } = require("node:stream/web"); globalThis.TextEncoderStream = TextEncoderStream; } +if (!globalThis.TextDecoderStream) { + const { TextDecoderStream } = require("node:stream/web"); + globalThis.TextDecoderStream = TextDecoderStream; +} if (!globalThis.TransformStream) { const { TransformStream } = require("node:stream/web"); diff --git a/packages/react-router/lib/dom-export/hydrated-router.tsx b/packages/react-router/lib/dom-export/hydrated-router.tsx index b6026ebdf3..15cd7af2d8 100644 --- a/packages/react-router/lib/dom-export/hydrated-router.tsx +++ b/packages/react-router/lib/dom-export/hydrated-router.tsx @@ -84,7 +84,7 @@ function createHydratedRouter(): DataRouter { ssrInfo.stateDecodingPromise = decodeViaTurboStream(stream, window) .then((value) => { ssrInfo!.context.state = - value.value as typeof localSsrInfo.context.state; + value as typeof localSsrInfo.context.state; localSsrInfo.stateDecodingPromise!.value = true; }) .catch((e) => { diff --git a/packages/react-router/lib/dom/ssr/single-fetch.tsx b/packages/react-router/lib/dom/ssr/single-fetch.tsx index dfb9227372..1cbe54d7e7 100644 --- a/packages/react-router/lib/dom/ssr/single-fetch.tsx +++ b/packages/react-router/lib/dom/ssr/single-fetch.tsx @@ -445,8 +445,8 @@ async function fetchAndDecode( invariant(res.body, "No response body to decode"); try { - let decoded = await decodeViaTurboStream(res.body, window); - return { status: res.status, data: decoded.value }; + let decoded: any = await decodeViaTurboStream(res.body, window); + return { status: res.status, data: decoded }; } catch (e) { // Can't clone after consuming the body via turbo-stream so we can't // include the body here. In an ideal world we'd look for a turbo-stream @@ -464,28 +464,9 @@ export function decodeViaTurboStream( body: ReadableStream, global: Window | typeof globalThis ) { - return decode(body, { + return decode(body.pipeThrough(new TextDecoderStream()), { plugins: [ (type: string, ...rest: unknown[]) => { - // Decode Errors back into Error instances using the right type and with - // the right (potentially undefined) stacktrace - if (type === "SanitizedError") { - let [name, message, stack] = rest as [ - string, - string, - string | undefined - ]; - let Constructor = Error; - // @ts-expect-error - if (name && name in global && typeof global[name] === "function") { - // @ts-expect-error - Constructor = global[name]; - } - let error = new Constructor(message); - error.stack = stack; - return { value: error }; - } - if (type === "ErrorResponse") { let [data, status, statusText] = rest as [ unknown, @@ -500,14 +481,6 @@ export function decodeViaTurboStream( if (type === "SingleFetchRedirect") { return { value: { [SingleFetchRedirectSymbol]: rest[0] } }; } - - if (type === "SingleFetchClassInstance") { - return { value: rest[0] }; - } - - if (type === "SingleFetchFallback") { - return { value: undefined }; - } }, ], }); diff --git a/packages/react-router/lib/server-runtime/routes.ts b/packages/react-router/lib/server-runtime/routes.ts index 538dfb4202..d0ed81c57a 100644 --- a/packages/react-router/lib/server-runtime/routes.ts +++ b/packages/react-router/lib/server-runtime/routes.ts @@ -93,8 +93,8 @@ export function createStaticHandlerDataRoutes( controller.close(); }, }); - let decoded = await decodeViaTurboStream(stream, global); - let data = decoded.value as SingleFetchResults; + let decoded: any = await decodeViaTurboStream(stream, global); + let data = decoded as SingleFetchResults; invariant( data && route.id in data, "Unable to decode prerendered data" diff --git a/packages/react-router/lib/server-runtime/single-fetch.ts b/packages/react-router/lib/server-runtime/single-fetch.ts index 517b1a6bc6..b984f8245d 100644 --- a/packages/react-router/lib/server-runtime/single-fetch.ts +++ b/packages/react-router/lib/server-runtime/single-fetch.ts @@ -327,44 +327,20 @@ export function encodeViaTurboStream( return encode(data, { signal: controller.signal, + redactErrors: serverMode === ServerMode.Production, plugins: [ (value) => { - // Even though we sanitized errors on context.errors prior to responding, - // we still need to handle this for any deferred data that rejects with an - // Error - as those will not be sanitized yet - if (value instanceof Error) { - let { name, message, stack } = - serverMode === ServerMode.Production - ? sanitizeError(value, serverMode) - : value; - return ["SanitizedError", name, message, stack]; - } - if (value instanceof ErrorResponseImpl) { let { data, status, statusText } = value; return ["ErrorResponse", data, status, statusText]; } if ( - value && - typeof value === "object" && - SingleFetchRedirectSymbol in value + SingleFetchRedirectSymbol in (value as any) ) { - return ["SingleFetchRedirect", value[SingleFetchRedirectSymbol]]; + return ["SingleFetchRedirect", (value as any)[SingleFetchRedirectSymbol]]; } }, ], - postPlugins: [ - (value) => { - if (!value) return; - if (typeof value !== "object") return; - - return [ - "SingleFetchClassInstance", - Object.fromEntries(Object.entries(value)), - ]; - }, - () => ["SingleFetchFallback"], - ], - }); + }).pipeThrough(new TextEncoderStream()); } diff --git a/packages/react-router/package.json b/packages/react-router/package.json index ade9b23d29..51a928a42b 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -83,7 +83,7 @@ "@types/cookie": "^0.6.0", "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0", - "turbo-stream": "2.4.0" + "turbo-stream": "https://pkg.pr.new/jacob-ebey/turbo-stream@7aeb647" }, "devDependencies": { "@types/set-cookie-parser": "^2.4.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f126ecc57e..9ab797689c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -618,8 +618,8 @@ importers: specifier: ^2.6.0 version: 2.6.0 turbo-stream: - specifier: 2.4.0 - version: 2.4.0 + specifier: https://pkg.pr.new/jacob-ebey/turbo-stream@7aeb647 + version: https://pkg.pr.new/jacob-ebey/turbo-stream@7aeb647 devDependencies: '@types/set-cookie-parser': specifier: ^2.4.1 @@ -7595,8 +7595,9 @@ packages: engines: {node: '>=8.0.0'} hasBin: true - turbo-stream@2.4.0: - resolution: {integrity: sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g==} + turbo-stream@https://pkg.pr.new/jacob-ebey/turbo-stream@7aeb647: + resolution: {tarball: https://pkg.pr.new/jacob-ebey/turbo-stream@7aeb647} + version: 2.4.1 type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} @@ -15965,7 +15966,7 @@ snapshots: wcwidth: 1.0.1 yargs: 17.7.2 - turbo-stream@2.4.0: {} + turbo-stream@https://pkg.pr.new/jacob-ebey/turbo-stream@7aeb647: {} type-check@0.4.0: dependencies: From 62f87ab00e850c3c309d633aeae119388e661866 Mon Sep 17 00:00:00 2001 From: Jacob Ebey Date: Tue, 4 Feb 2025 09:37:58 -0800 Subject: [PATCH 02/14] update version --- packages/react-router/package.json | 2 +- pnpm-lock.yaml | 11 +++++------ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/react-router/package.json b/packages/react-router/package.json index 51a928a42b..a07606780d 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -83,7 +83,7 @@ "@types/cookie": "^0.6.0", "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0", - "turbo-stream": "https://pkg.pr.new/jacob-ebey/turbo-stream@7aeb647" + "turbo-stream": "^3.0.0" }, "devDependencies": { "@types/set-cookie-parser": "^2.4.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9ab797689c..9fe65657a1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -618,8 +618,8 @@ importers: specifier: ^2.6.0 version: 2.6.0 turbo-stream: - specifier: https://pkg.pr.new/jacob-ebey/turbo-stream@7aeb647 - version: https://pkg.pr.new/jacob-ebey/turbo-stream@7aeb647 + specifier: ^3.0.0 + version: 3.0.0 devDependencies: '@types/set-cookie-parser': specifier: ^2.4.1 @@ -7595,9 +7595,8 @@ packages: engines: {node: '>=8.0.0'} hasBin: true - turbo-stream@https://pkg.pr.new/jacob-ebey/turbo-stream@7aeb647: - resolution: {tarball: https://pkg.pr.new/jacob-ebey/turbo-stream@7aeb647} - version: 2.4.1 + turbo-stream@3.0.0: + resolution: {integrity: sha512-cD7s7mh0XtUa+sE5Ej1ShRrWGKiAZ1X/l4clV7Dyg4A+u9jf2pbTTKDApeW0B7Za5o/tNCGzNTLFHBAVxeQC6g==} type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} @@ -15966,7 +15965,7 @@ snapshots: wcwidth: 1.0.1 yargs: 17.7.2 - turbo-stream@https://pkg.pr.new/jacob-ebey/turbo-stream@7aeb647: {} + turbo-stream@3.0.0: {} type-check@0.4.0: dependencies: From bf5729d14dcf13808ce95d99a7220c7c39dd7de8 Mon Sep 17 00:00:00 2001 From: Jacob Ebey Date: Tue, 4 Feb 2025 09:40:07 -0800 Subject: [PATCH 03/14] Create turbo-v3.md --- .changeset/turbo-v3.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/turbo-v3.md diff --git a/.changeset/turbo-v3.md b/.changeset/turbo-v3.md new file mode 100644 index 0000000000..8a57150968 --- /dev/null +++ b/.changeset/turbo-v3.md @@ -0,0 +1,6 @@ +--- +"integration": minor +"react-router": minor +--- + +feat: turbo-stream v3 From a24b23a40e8631548073fcc9d4dfdd1b3097657d Mon Sep 17 00:00:00 2001 From: Jacob Ebey Date: Fri, 7 Feb 2025 11:05:00 -0800 Subject: [PATCH 04/14] update error redaction to match existing behavior --- integration/error-sanitization-test.ts | 12 +- .../lib/server-runtime/single-fetch.ts | 2 +- packages/react-router/package.json | 2 +- pnpm-lock.yaml | 107 ++++++++++-------- 4 files changed, 65 insertions(+), 58 deletions(-) diff --git a/integration/error-sanitization-test.ts b/integration/error-sanitization-test.ts index 89048930cc..abb525d388 100644 --- a/integration/error-sanitization-test.ts +++ b/integration/error-sanitization-test.ts @@ -182,7 +182,7 @@ test.describe("Error Sanitization", () => { // This is the turbo-stream encoding - the fact that stack goes right // into __type means it has no value expect(html).toMatch( - '\\"message\\",\\"Unexpected Server Error\\",\\"stack\\",\\"__type\\",\\"Error\\"' + '{\\\"message\\\":\\\"Unexpected Server Error\\\",\\\"stack\\\":u,\\\"__type\\\":\\\"Error\\\"}' ); expect(html).not.toMatch(/ at /i); expect(errorLogs.length).toBe(1); @@ -198,7 +198,7 @@ test.describe("Error Sanitization", () => { // This is the turbo-stream encoding - the fact that stack goes right // into __type means it has no value expect(html).toMatch( - '\\"message\\",\\"Unexpected Server Error\\",\\"stack\\",\\"__type\\",\\"Error\\"' + '{\\\"message\\\":\\\"Unexpected Server Error\\\",\\\"stack\\\":u,\\\"__type\\\":\\\"Error\\\"}' ); expect(html).not.toMatch(/ at /i); expect(errorLogs.length).toBe(1); @@ -223,7 +223,7 @@ test.describe("Error Sanitization", () => { expect(html).toMatch("Defer Error"); expect(html).not.toMatch("RESOLVED"); expect(html).toMatch("Unexpected Server Error"); - expect(html).not.toMatch("stack"); + expect(html).toMatch("\\\"stack\\\":u,"); // defer errors are not logged to the server console since the request // has "succeeded" expect(errorLogs.length).toBe(0); @@ -586,7 +586,7 @@ test.describe("Error Sanitization", () => { // This is the turbo-stream encoding - the fact that stack goes right // into __type means it has no value expect(html).toMatch( - '\\"message\\",\\"Unexpected Server Error\\",\\"stack\\",\\"__type\\",\\"Error\\"' + '{\\\"message\\\":\\\"Unexpected Server Error\\\",\\\"stack\\\":u,\\\"__type\\\":\\\"Error\\\"}' ); expect(html).not.toMatch(/ at /i); expect(errorLogs[0][0]).toEqual("App Specific Error Logging:"); @@ -604,7 +604,7 @@ test.describe("Error Sanitization", () => { // This is the turbo-stream encoding - the fact that stack goes right // into __type means it has no value expect(html).toMatch( - '\\"message\\",\\"Unexpected Server Error\\",\\"stack\\",\\"__type\\",\\"Error\\"' + '{\\\"message\\\":\\\"Unexpected Server Error\\\",\\\"stack\\\":u,\\\"__type\\\":\\\"Error\\\"}' ); expect(html).not.toMatch(/ at /i); expect(errorLogs[0][0]).toEqual("App Specific Error Logging:"); @@ -631,7 +631,7 @@ test.describe("Error Sanitization", () => { expect(html).toMatch("Defer Error"); expect(html).not.toMatch("RESOLVED"); expect(html).toMatch("Unexpected Server Error"); - expect(html).not.toMatch("stack"); + expect(html).toMatch("\\\"stack\\\":u,"); // defer errors are not logged to the server console since the request // has "succeeded" expect(errorLogs.length).toBe(0); diff --git a/packages/react-router/lib/server-runtime/single-fetch.ts b/packages/react-router/lib/server-runtime/single-fetch.ts index b984f8245d..dc987bdf9b 100644 --- a/packages/react-router/lib/server-runtime/single-fetch.ts +++ b/packages/react-router/lib/server-runtime/single-fetch.ts @@ -327,7 +327,7 @@ export function encodeViaTurboStream( return encode(data, { signal: controller.signal, - redactErrors: serverMode === ServerMode.Production, + redactErrors: serverMode === ServerMode.Development ? false : "Unexpected Server Error", plugins: [ (value) => { if (value instanceof ErrorResponseImpl) { diff --git a/packages/react-router/package.json b/packages/react-router/package.json index a07606780d..2228b2ef37 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -83,7 +83,7 @@ "@types/cookie": "^0.6.0", "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0", - "turbo-stream": "^3.0.0" + "turbo-stream": "^3.1.0" }, "devDependencies": { "@types/set-cookie-parser": "^2.4.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9fe65657a1..b3c83c0ad8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -75,7 +75,7 @@ importers: version: 5.17.0 '@testing-library/react': specifier: ^13.4.0 - version: 13.4.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + version: 13.4.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@testing-library/user-event': specifier: ^14.5.2 version: 14.5.2(@testing-library/dom@8.17.1) @@ -183,13 +183,13 @@ importers: version: 2.4.2 react: specifier: ^18.2.0 - version: 18.2.0 + version: 18.3.1 react-dom: specifier: ^18.2.0 - version: 18.2.0(react@18.2.0) + version: 18.3.1(react@18.3.1) react-test-renderer: specifier: ^18.2.0 - version: 18.2.0(react@18.2.0) + version: 18.2.0(react@18.3.1) remark-gfm: specifier: 3.0.1 version: 3.0.1 @@ -306,10 +306,10 @@ importers: version: 2.8.8 react: specifier: ^18.2.0 - version: 18.2.0 + version: 18.3.1 react-dom: specifier: ^18.2.0 - version: 18.2.0(react@18.2.0) + version: 18.3.1(react@18.3.1) react-router: specifier: workspace:* version: link:../packages/react-router @@ -372,10 +372,10 @@ importers: version: 5.1.11 react: specifier: ^18.2.0 - version: 18.2.0 + version: 18.3.1 react-dom: specifier: ^18.2.0 - version: 18.2.0(react@18.2.0) + version: 18.3.1(react@18.3.1) react-router: specifier: workspace:* version: link:../../../packages/react-router @@ -439,10 +439,10 @@ importers: version: 5.1.11 react: specifier: ^18.2.0 - version: 18.2.0 + version: 18.3.1 react-dom: specifier: ^18.2.0 - version: 18.2.0(react@18.2.0) + version: 18.3.1(react@18.3.1) react-router: specifier: workspace:* version: link:../../../packages/react-router @@ -494,10 +494,10 @@ importers: version: 3.20240701.0 react: specifier: ^18.2.0 - version: 18.2.0 + version: 18.3.1 react-dom: specifier: ^18.2.0 - version: 18.2.0(react@18.2.0) + version: 18.3.1(react@18.3.1) react-router: specifier: workspace:* version: link:../../../packages/react-router @@ -618,18 +618,18 @@ importers: specifier: ^2.6.0 version: 2.6.0 turbo-stream: - specifier: ^3.0.0 - version: 3.0.0 + specifier: ^3.1.0 + version: 3.1.0 devDependencies: '@types/set-cookie-parser': specifier: ^2.4.1 version: 2.4.7 react: specifier: ^18.2.0 - version: 18.2.0 + version: 18.3.1 react-dom: specifier: ^18.2.0 - version: 18.2.0(react@18.2.0) + version: 18.3.1(react@18.3.1) rimraf: specifier: ^6.0.1 version: 6.0.1 @@ -666,10 +666,10 @@ importers: version: 4.0.1 react: specifier: ^18.2.0 - version: 18.2.0 + version: 18.3.1 react-dom: specifier: ^18.2.0 - version: 18.2.0(react@18.2.0) + version: 18.3.1(react@18.3.1) react-router: specifier: workspace:* version: link:../react-router @@ -884,10 +884,10 @@ importers: devDependencies: react: specifier: ^18.2.0 - version: 18.2.0 + version: 18.3.1 react-dom: specifier: ^18.2.0 - version: 18.2.0(react@18.2.0) + version: 18.3.1(react@18.3.1) tsup: specifier: ^8.3.0 version: 8.3.0(jiti@1.21.0)(postcss@8.4.49)(typescript@5.4.5)(yaml@2.6.0) @@ -1062,10 +1062,10 @@ importers: version: 5.1.11 react: specifier: ^18.2.0 - version: 18.2.0 + version: 18.3.1 react-dom: specifier: ^18.2.0 - version: 18.2.0(react@18.2.0) + version: 18.3.1(react@18.3.1) react-router: specifier: workspace:* version: link:../../packages/react-router @@ -1111,10 +1111,10 @@ importers: version: 1.10.0 react: specifier: ^18.2.0 - version: 18.2.0 + version: 18.3.1 react-dom: specifier: ^18.2.0 - version: 18.2.0(react@18.2.0) + version: 18.3.1(react@18.3.1) react-router: specifier: workspace:* version: link:../../packages/react-router @@ -1160,10 +1160,10 @@ importers: version: 5.1.11 react: specifier: ^18.2.0 - version: 18.2.0 + version: 18.3.1 react-dom: specifier: ^18.2.0 - version: 18.2.0(react@18.2.0) + version: 18.3.1(react@18.3.1) react-router: specifier: workspace:* version: link:../../packages/react-router @@ -1200,10 +1200,10 @@ importers: version: 5.1.11 react: specifier: ^18.2.0 - version: 18.2.0 + version: 18.3.1 react-dom: specifier: ^18.2.0 - version: 18.2.0(react@18.2.0) + version: 18.3.1(react@18.3.1) react-router: specifier: workspace:* version: link:../../packages/react-router @@ -1237,10 +1237,10 @@ importers: version: 5.1.11 react: specifier: ^18.2.0 - version: 18.2.0 + version: 18.3.1 react-dom: specifier: ^18.2.0 - version: 18.2.0(react@18.2.0) + version: 18.3.1(react@18.3.1) react-router: specifier: workspace:* version: link:../../packages/react-router @@ -6876,10 +6876,10 @@ packages: resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==} engines: {node: '>= 0.8'} - react-dom@18.2.0: - resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==} + react-dom@18.3.1: + resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} peerDependencies: - react: ^18.2.0 + react: ^18.3.1 react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -6904,8 +6904,8 @@ packages: peerDependencies: react: ^18.2.0 - react@18.2.0: - resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==} + react@18.3.1: + resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} read-cache@1.0.0: @@ -7129,6 +7129,9 @@ packages: scheduler@0.23.0: resolution: {integrity: sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==} + scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + selfsigned@2.4.1: resolution: {integrity: sha512-th5B4L2U+eGLq1TVh7zNRGBapioSORUeymIydxgFpwww9d2qyKvtuPU2jJuHvYAwwqi2Y596QBL3eEqcPEYL8Q==} engines: {node: '>=10'} @@ -7595,8 +7598,8 @@ packages: engines: {node: '>=8.0.0'} hasBin: true - turbo-stream@3.0.0: - resolution: {integrity: sha512-cD7s7mh0XtUa+sE5Ej1ShRrWGKiAZ1X/l4clV7Dyg4A+u9jf2pbTTKDApeW0B7Za5o/tNCGzNTLFHBAVxeQC6g==} + turbo-stream@3.1.0: + resolution: {integrity: sha512-tVI25WEXl4fckNEmrq70xU1XumxUwEx/FZD5AgEcV8ri7Wvrg2o7GEq8U7htrNx3CajciGm+kDyhRf5JB6t7/A==} type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} @@ -10265,13 +10268,13 @@ snapshots: lodash: 4.17.21 redent: 3.0.0 - '@testing-library/react@13.4.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': + '@testing-library/react@13.4.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.24.1 '@testing-library/dom': 8.17.1 '@types/react-dom': 18.2.7 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) '@testing-library/user-event@14.5.2(@testing-library/dom@8.17.1)': dependencies: @@ -15083,11 +15086,11 @@ snapshots: iconv-lite: 0.4.24 unpipe: 1.0.0 - react-dom@18.2.0(react@18.2.0): + react-dom@18.3.1(react@18.3.1): dependencies: loose-envify: 1.4.0 - react: 18.2.0 - scheduler: 0.23.0 + react: 18.3.1 + scheduler: 0.23.2 react-is@16.13.1: {} @@ -15097,20 +15100,20 @@ snapshots: react-refresh@0.14.0: {} - react-shallow-renderer@16.15.0(react@18.2.0): + react-shallow-renderer@16.15.0(react@18.3.1): dependencies: object-assign: 4.1.1 - react: 18.2.0 + react: 18.3.1 react-is: 18.2.0 - react-test-renderer@18.2.0(react@18.2.0): + react-test-renderer@18.2.0(react@18.3.1): dependencies: - react: 18.2.0 + react: 18.3.1 react-is: 18.2.0 - react-shallow-renderer: 16.15.0(react@18.2.0) + react-shallow-renderer: 16.15.0(react@18.3.1) scheduler: 0.23.0 - react@18.2.0: + react@18.3.1: dependencies: loose-envify: 1.4.0 @@ -15406,6 +15409,10 @@ snapshots: dependencies: loose-envify: 1.4.0 + scheduler@0.23.2: + dependencies: + loose-envify: 1.4.0 + selfsigned@2.4.1: dependencies: '@types/node-forge': 1.3.11 @@ -15965,7 +15972,7 @@ snapshots: wcwidth: 1.0.1 yargs: 17.7.2 - turbo-stream@3.0.0: {} + turbo-stream@3.1.0: {} type-check@0.4.0: dependencies: From 76fad7d8d6b0cb8ada7454a47503c9d88cb31308 Mon Sep 17 00:00:00 2001 From: Jacob Ebey Date: Fri, 7 Feb 2025 11:11:14 -0800 Subject: [PATCH 05/14] fix lockfile --- pnpm-lock.yaml | 97 +++++++++++++++++++++++--------------------------- 1 file changed, 45 insertions(+), 52 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b3c83c0ad8..9d4a7290bc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -75,7 +75,7 @@ importers: version: 5.17.0 '@testing-library/react': specifier: ^13.4.0 - version: 13.4.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 13.4.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@testing-library/user-event': specifier: ^14.5.2 version: 14.5.2(@testing-library/dom@8.17.1) @@ -183,13 +183,13 @@ importers: version: 2.4.2 react: specifier: ^18.2.0 - version: 18.3.1 + version: 18.2.0 react-dom: specifier: ^18.2.0 - version: 18.3.1(react@18.3.1) + version: 18.2.0(react@18.2.0) react-test-renderer: specifier: ^18.2.0 - version: 18.2.0(react@18.3.1) + version: 18.2.0(react@18.2.0) remark-gfm: specifier: 3.0.1 version: 3.0.1 @@ -306,10 +306,10 @@ importers: version: 2.8.8 react: specifier: ^18.2.0 - version: 18.3.1 + version: 18.2.0 react-dom: specifier: ^18.2.0 - version: 18.3.1(react@18.3.1) + version: 18.2.0(react@18.2.0) react-router: specifier: workspace:* version: link:../packages/react-router @@ -372,10 +372,10 @@ importers: version: 5.1.11 react: specifier: ^18.2.0 - version: 18.3.1 + version: 18.2.0 react-dom: specifier: ^18.2.0 - version: 18.3.1(react@18.3.1) + version: 18.2.0(react@18.2.0) react-router: specifier: workspace:* version: link:../../../packages/react-router @@ -439,10 +439,10 @@ importers: version: 5.1.11 react: specifier: ^18.2.0 - version: 18.3.1 + version: 18.2.0 react-dom: specifier: ^18.2.0 - version: 18.3.1(react@18.3.1) + version: 18.2.0(react@18.2.0) react-router: specifier: workspace:* version: link:../../../packages/react-router @@ -494,10 +494,10 @@ importers: version: 3.20240701.0 react: specifier: ^18.2.0 - version: 18.3.1 + version: 18.2.0 react-dom: specifier: ^18.2.0 - version: 18.3.1(react@18.3.1) + version: 18.2.0(react@18.2.0) react-router: specifier: workspace:* version: link:../../../packages/react-router @@ -626,10 +626,10 @@ importers: version: 2.4.7 react: specifier: ^18.2.0 - version: 18.3.1 + version: 18.2.0 react-dom: specifier: ^18.2.0 - version: 18.3.1(react@18.3.1) + version: 18.2.0(react@18.2.0) rimraf: specifier: ^6.0.1 version: 6.0.1 @@ -666,10 +666,10 @@ importers: version: 4.0.1 react: specifier: ^18.2.0 - version: 18.3.1 + version: 18.2.0 react-dom: specifier: ^18.2.0 - version: 18.3.1(react@18.3.1) + version: 18.2.0(react@18.2.0) react-router: specifier: workspace:* version: link:../react-router @@ -884,10 +884,10 @@ importers: devDependencies: react: specifier: ^18.2.0 - version: 18.3.1 + version: 18.2.0 react-dom: specifier: ^18.2.0 - version: 18.3.1(react@18.3.1) + version: 18.2.0(react@18.2.0) tsup: specifier: ^8.3.0 version: 8.3.0(jiti@1.21.0)(postcss@8.4.49)(typescript@5.4.5)(yaml@2.6.0) @@ -1062,10 +1062,10 @@ importers: version: 5.1.11 react: specifier: ^18.2.0 - version: 18.3.1 + version: 18.2.0 react-dom: specifier: ^18.2.0 - version: 18.3.1(react@18.3.1) + version: 18.2.0(react@18.2.0) react-router: specifier: workspace:* version: link:../../packages/react-router @@ -1111,10 +1111,10 @@ importers: version: 1.10.0 react: specifier: ^18.2.0 - version: 18.3.1 + version: 18.2.0 react-dom: specifier: ^18.2.0 - version: 18.3.1(react@18.3.1) + version: 18.2.0(react@18.2.0) react-router: specifier: workspace:* version: link:../../packages/react-router @@ -1160,10 +1160,10 @@ importers: version: 5.1.11 react: specifier: ^18.2.0 - version: 18.3.1 + version: 18.2.0 react-dom: specifier: ^18.2.0 - version: 18.3.1(react@18.3.1) + version: 18.2.0(react@18.2.0) react-router: specifier: workspace:* version: link:../../packages/react-router @@ -1200,10 +1200,10 @@ importers: version: 5.1.11 react: specifier: ^18.2.0 - version: 18.3.1 + version: 18.2.0 react-dom: specifier: ^18.2.0 - version: 18.3.1(react@18.3.1) + version: 18.2.0(react@18.2.0) react-router: specifier: workspace:* version: link:../../packages/react-router @@ -1237,10 +1237,10 @@ importers: version: 5.1.11 react: specifier: ^18.2.0 - version: 18.3.1 + version: 18.2.0 react-dom: specifier: ^18.2.0 - version: 18.3.1(react@18.3.1) + version: 18.2.0(react@18.2.0) react-router: specifier: workspace:* version: link:../../packages/react-router @@ -6876,10 +6876,10 @@ packages: resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==} engines: {node: '>= 0.8'} - react-dom@18.3.1: - resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} + react-dom@18.2.0: + resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==} peerDependencies: - react: ^18.3.1 + react: ^18.2.0 react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -6904,8 +6904,8 @@ packages: peerDependencies: react: ^18.2.0 - react@18.3.1: - resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} + react@18.2.0: + resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==} engines: {node: '>=0.10.0'} read-cache@1.0.0: @@ -7129,9 +7129,6 @@ packages: scheduler@0.23.0: resolution: {integrity: sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==} - scheduler@0.23.2: - resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} - selfsigned@2.4.1: resolution: {integrity: sha512-th5B4L2U+eGLq1TVh7zNRGBapioSORUeymIydxgFpwww9d2qyKvtuPU2jJuHvYAwwqi2Y596QBL3eEqcPEYL8Q==} engines: {node: '>=10'} @@ -10268,13 +10265,13 @@ snapshots: lodash: 4.17.21 redent: 3.0.0 - '@testing-library/react@13.4.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@testing-library/react@13.4.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: '@babel/runtime': 7.24.1 '@testing-library/dom': 8.17.1 '@types/react-dom': 18.2.7 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) '@testing-library/user-event@14.5.2(@testing-library/dom@8.17.1)': dependencies: @@ -15086,11 +15083,11 @@ snapshots: iconv-lite: 0.4.24 unpipe: 1.0.0 - react-dom@18.3.1(react@18.3.1): + react-dom@18.2.0(react@18.2.0): dependencies: loose-envify: 1.4.0 - react: 18.3.1 - scheduler: 0.23.2 + react: 18.2.0 + scheduler: 0.23.0 react-is@16.13.1: {} @@ -15100,20 +15097,20 @@ snapshots: react-refresh@0.14.0: {} - react-shallow-renderer@16.15.0(react@18.3.1): + react-shallow-renderer@16.15.0(react@18.2.0): dependencies: object-assign: 4.1.1 - react: 18.3.1 + react: 18.2.0 react-is: 18.2.0 - react-test-renderer@18.2.0(react@18.3.1): + react-test-renderer@18.2.0(react@18.2.0): dependencies: - react: 18.3.1 + react: 18.2.0 react-is: 18.2.0 - react-shallow-renderer: 16.15.0(react@18.3.1) + react-shallow-renderer: 16.15.0(react@18.2.0) scheduler: 0.23.0 - react@18.3.1: + react@18.2.0: dependencies: loose-envify: 1.4.0 @@ -15409,10 +15406,6 @@ snapshots: dependencies: loose-envify: 1.4.0 - scheduler@0.23.2: - dependencies: - loose-envify: 1.4.0 - selfsigned@2.4.1: dependencies: '@types/node-forge': 1.3.11 From bb408d504d7b288300c505b3ce3f4e428a5eb305 Mon Sep 17 00:00:00 2001 From: Jacob Ebey Date: Tue, 11 Feb 2025 10:27:25 -0800 Subject: [PATCH 06/14] fix: ensure deferred promises settle --- integration/error-sanitization-test.ts | 12 +++--- integration/helpers/create-fixture.ts | 4 +- .../lib/dom-export/hydrated-router.tsx | 3 +- packages/react-router/lib/dom/ssr/entry.ts | 1 + .../react-router/lib/dom/ssr/single-fetch.tsx | 2 +- .../react-router/lib/server-runtime/server.ts | 41 ++++++++++++++++--- .../lib/server-runtime/single-fetch.ts | 12 +++--- 7 files changed, 54 insertions(+), 21 deletions(-) diff --git a/integration/error-sanitization-test.ts b/integration/error-sanitization-test.ts index abb525d388..4c6f570873 100644 --- a/integration/error-sanitization-test.ts +++ b/integration/error-sanitization-test.ts @@ -182,7 +182,7 @@ test.describe("Error Sanitization", () => { // This is the turbo-stream encoding - the fact that stack goes right // into __type means it has no value expect(html).toMatch( - '{\\\"message\\\":\\\"Unexpected Server Error\\\",\\\"stack\\\":u,\\\"__type\\\":\\\"Error\\\"}' + '{\\"message\\":\\"Unexpected Server Error\\",\\"stack\\":u,\\"__type\\":\\"Error\\"}' ); expect(html).not.toMatch(/ at /i); expect(errorLogs.length).toBe(1); @@ -198,7 +198,7 @@ test.describe("Error Sanitization", () => { // This is the turbo-stream encoding - the fact that stack goes right // into __type means it has no value expect(html).toMatch( - '{\\\"message\\\":\\\"Unexpected Server Error\\\",\\\"stack\\\":u,\\\"__type\\\":\\\"Error\\\"}' + '{\\"message\\":\\"Unexpected Server Error\\",\\"stack\\":u,\\"__type\\":\\"Error\\"}' ); expect(html).not.toMatch(/ at /i); expect(errorLogs.length).toBe(1); @@ -223,7 +223,7 @@ test.describe("Error Sanitization", () => { expect(html).toMatch("Defer Error"); expect(html).not.toMatch("RESOLVED"); expect(html).toMatch("Unexpected Server Error"); - expect(html).toMatch("\\\"stack\\\":u,"); + expect(html).toMatch('\\"stack\\":u,'); // defer errors are not logged to the server console since the request // has "succeeded" expect(errorLogs.length).toBe(0); @@ -586,7 +586,7 @@ test.describe("Error Sanitization", () => { // This is the turbo-stream encoding - the fact that stack goes right // into __type means it has no value expect(html).toMatch( - '{\\\"message\\\":\\\"Unexpected Server Error\\\",\\\"stack\\\":u,\\\"__type\\\":\\\"Error\\\"}' + '{\\"message\\":\\"Unexpected Server Error\\",\\"stack\\":u,\\"__type\\":\\"Error\\"}' ); expect(html).not.toMatch(/ at /i); expect(errorLogs[0][0]).toEqual("App Specific Error Logging:"); @@ -604,7 +604,7 @@ test.describe("Error Sanitization", () => { // This is the turbo-stream encoding - the fact that stack goes right // into __type means it has no value expect(html).toMatch( - '{\\\"message\\\":\\\"Unexpected Server Error\\\",\\\"stack\\\":u,\\\"__type\\\":\\\"Error\\\"}' + '{\\"message\\":\\"Unexpected Server Error\\",\\"stack\\":u,\\"__type\\":\\"Error\\"}' ); expect(html).not.toMatch(/ at /i); expect(errorLogs[0][0]).toEqual("App Specific Error Logging:"); @@ -631,7 +631,7 @@ test.describe("Error Sanitization", () => { expect(html).toMatch("Defer Error"); expect(html).not.toMatch("RESOLVED"); expect(html).toMatch("Unexpected Server Error"); - expect(html).toMatch("\\\"stack\\\":u,"); + expect(html).toMatch('\\"stack\\":u,'); // defer errors are not logged to the server console since the request // has "succeeded" expect(errorLogs.length).toBe(0); diff --git a/integration/helpers/create-fixture.ts b/integration/helpers/create-fixture.ts index 6bbb053f13..aa389beaea 100644 --- a/integration/helpers/create-fixture.ts +++ b/integration/helpers/create-fixture.ts @@ -118,7 +118,7 @@ export async function createFixture(init: FixtureInit, mode?: ServerMode) { status: 200, statusText: "OK", headers: new Headers(), - data: (await decodeViaTurboStream(stream, global)), + data: await decodeViaTurboStream(stream, global), }; }, postDocument: () => { @@ -160,7 +160,7 @@ export async function createFixture(init: FixtureInit, mode?: ServerMode) { statusText: response.statusText, headers: response.headers, data: response.body - ? (await decodeViaTurboStream(response.body!, global)) + ? await decodeViaTurboStream(response.body!, global) : null, }; }; diff --git a/packages/react-router/lib/dom-export/hydrated-router.tsx b/packages/react-router/lib/dom-export/hydrated-router.tsx index 15cd7af2d8..675291f204 100644 --- a/packages/react-router/lib/dom-export/hydrated-router.tsx +++ b/packages/react-router/lib/dom-export/hydrated-router.tsx @@ -83,8 +83,7 @@ function createHydratedRouter(): DataRouter { ssrInfo.context.stream = undefined; ssrInfo.stateDecodingPromise = decodeViaTurboStream(stream, window) .then((value) => { - ssrInfo!.context.state = - value as typeof localSsrInfo.context.state; + ssrInfo!.context.state = value as typeof localSsrInfo.context.state; localSsrInfo.stateDecodingPromise!.value = true; }) .catch((e) => { diff --git a/packages/react-router/lib/dom/ssr/entry.ts b/packages/react-router/lib/dom/ssr/entry.ts index 6eb700ea2f..c51442591a 100644 --- a/packages/react-router/lib/dom/ssr/entry.ts +++ b/packages/react-router/lib/dom/ssr/entry.ts @@ -31,6 +31,7 @@ export interface FrameworkContextObject { error?: unknown; } >; + streamFinished?: boolean; }; } diff --git a/packages/react-router/lib/dom/ssr/single-fetch.tsx b/packages/react-router/lib/dom/ssr/single-fetch.tsx index 1cbe54d7e7..f4c60a90a2 100644 --- a/packages/react-router/lib/dom/ssr/single-fetch.tsx +++ b/packages/react-router/lib/dom/ssr/single-fetch.tsx @@ -108,7 +108,7 @@ export function StreamTransfer({ ' + ) + ); + } + }, + }) + ), + { + headers: response.headers, + status: response.status, + statusText: response.statusText, + } + ); } async function handleResourceRequest( diff --git a/packages/react-router/lib/server-runtime/single-fetch.ts b/packages/react-router/lib/server-runtime/single-fetch.ts index dc987bdf9b..1818227315 100644 --- a/packages/react-router/lib/server-runtime/single-fetch.ts +++ b/packages/react-router/lib/server-runtime/single-fetch.ts @@ -327,7 +327,8 @@ export function encodeViaTurboStream( return encode(data, { signal: controller.signal, - redactErrors: serverMode === ServerMode.Development ? false : "Unexpected Server Error", + redactErrors: + serverMode === ServerMode.Development ? false : "Unexpected Server Error", plugins: [ (value) => { if (value instanceof ErrorResponseImpl) { @@ -335,10 +336,11 @@ export function encodeViaTurboStream( return ["ErrorResponse", data, status, statusText]; } - if ( - SingleFetchRedirectSymbol in (value as any) - ) { - return ["SingleFetchRedirect", (value as any)[SingleFetchRedirectSymbol]]; + if (SingleFetchRedirectSymbol in (value as any)) { + return [ + "SingleFetchRedirect", + (value as any)[SingleFetchRedirectSymbol], + ]; } }, ], From 5d5519a55ddcc2aa25130523bb197bfb67fd3545 Mon Sep 17 00:00:00 2001 From: Jacob Ebey Date: Tue, 11 Feb 2025 10:47:37 -0800 Subject: [PATCH 07/14] oops --- packages/react-router/lib/server-runtime/server.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/react-router/lib/server-runtime/server.ts b/packages/react-router/lib/server-runtime/server.ts index b872b9352b..6dce870f62 100644 --- a/packages/react-router/lib/server-runtime/server.ts +++ b/packages/react-router/lib/server-runtime/server.ts @@ -140,7 +140,7 @@ export const createRequestHandler: CreateRequestHandlerFunction = ( Object.assign(params, matches[0].params); } - let response: Response; + let response: Response | undefined; if (url.pathname.endsWith(".data")) { let handlerUrl = new URL(request.url); handlerUrl.pathname = handlerUrl.pathname @@ -230,6 +230,10 @@ export const createRequestHandler: CreateRequestHandlerFunction = ( ); } + if (!response) { + return new Response("Unknown Server Error", { status: 500 }); + } + if (request.method === "HEAD") { return new Response(null, { headers: response.headers, @@ -496,6 +500,10 @@ async function handleDocumentRequest( } } + if (!response) { + return undefined; + } + return new Response( response.body?.pipeThrough( new TransformStream({ From d645caa698b9b63c88d199890577c4c63118f619 Mon Sep 17 00:00:00 2001 From: Jacob Ebey Date: Tue, 11 Feb 2025 11:06:07 -0800 Subject: [PATCH 08/14] update tests that count scripts --- integration/link-test.ts | 2 +- integration/single-fetch-test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/integration/link-test.ts b/integration/link-test.ts index 1c7ffb3c23..91a70a74ab 100644 --- a/integration/link-test.ts +++ b/integration/link-test.ts @@ -600,7 +600,7 @@ test.describe("route module link export", () => { let app = new PlaywrightFixture(appFixture, page); await app.goto("/"); let scripts = await page.$$("script"); - expect(scripts.length).toEqual(6); + expect(scripts.length).toEqual(7); expect(await scripts[0].innerText()).toContain("__reactRouterContext"); let moduleScript = scripts[1]; expect(await moduleScript.getAttribute("type")).toBe("module"); diff --git a/integration/single-fetch-test.ts b/integration/single-fetch-test.ts index e4a61eeac6..b21440dd7e 100644 --- a/integration/single-fetch-test.ts +++ b/integration/single-fetch-test.ts @@ -3570,7 +3570,7 @@ test.describe("single-fetch", () => { let app = new PlaywrightFixture(appFixture, page); await app.goto("/data", true); let scripts = await page.$$("script"); - expect(scripts.length).toBe(6); + expect(scripts.length).toBe(7); let remixScriptsCount = 0; for (let script of scripts) { let content = await script.innerHTML(); From b91d1947a041371c0883f7f306fb9937467e10c5 Mon Sep 17 00:00:00 2001 From: Jacob Ebey Date: Tue, 11 Feb 2025 11:25:59 -0800 Subject: [PATCH 09/14] add nonce to finalization script --- integration/single-fetch-test.ts | 2 +- packages/react-router/lib/dom/ssr/components.tsx | 1 + packages/react-router/lib/dom/ssr/entry.ts | 1 + packages/react-router/lib/server-runtime/server.ts | 4 +++- 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/integration/single-fetch-test.ts b/integration/single-fetch-test.ts index b21440dd7e..491d0cd8cc 100644 --- a/integration/single-fetch-test.ts +++ b/integration/single-fetch-test.ts @@ -3579,7 +3579,7 @@ test.describe("single-fetch", () => { expect(await script.getAttribute("nonce")).toEqual("the-nonce"); } } - expect(remixScriptsCount).toBe(4); + expect(remixScriptsCount).toBe(5); }); test("supports loaders that return undefined", async ({ page }) => { diff --git a/packages/react-router/lib/dom/ssr/components.tsx b/packages/react-router/lib/dom/ssr/components.tsx index 47dd5060a4..882f2542e4 100644 --- a/packages/react-router/lib/dom/ssr/components.tsx +++ b/packages/react-router/lib/dom/ssr/components.tsx @@ -648,6 +648,7 @@ export function Scripts(props: ScriptsProps) { // fetch streaming scripts if (renderMeta) { renderMeta.didRenderScripts = true; + renderMeta.nonce = props.nonce; } let matches = getActiveMatches(routerMatches, null, isSpaMode); diff --git a/packages/react-router/lib/dom/ssr/entry.ts b/packages/react-router/lib/dom/ssr/entry.ts index c51442591a..527280ecb7 100644 --- a/packages/react-router/lib/dom/ssr/entry.ts +++ b/packages/react-router/lib/dom/ssr/entry.ts @@ -32,6 +32,7 @@ export interface FrameworkContextObject { } >; streamFinished?: boolean; + nonce?: string; }; } diff --git a/packages/react-router/lib/server-runtime/server.ts b/packages/react-router/lib/server-runtime/server.ts index 6dce870f62..5f9c72cdee 100644 --- a/packages/react-router/lib/server-runtime/server.ts +++ b/packages/react-router/lib/server-runtime/server.ts @@ -380,6 +380,7 @@ async function handleDocumentRequest( let renderMeta: { didRenderScripts?: boolean; + nonce?: string; } = {}; // Server UI state to send to the client. @@ -512,9 +513,10 @@ async function handleDocumentRequest( // open in the browser causing promises to never resolve. This will error the stream // in the browser and allow the promises to settle. if (renderMeta.didRenderScripts){ + const script = renderMeta.nonce ? `' + `${script}if (!window.__reactRouterContext.streamDone)window.__reactRouterContext.streamController.error(new Error("Server aborted."));` ) ); } From d5af379cc30f1ad08210ba04f03e1abf2324cb59 Mon Sep 17 00:00:00 2001 From: Jacob Ebey Date: Wed, 12 Feb 2025 11:42:15 -0800 Subject: [PATCH 10/14] steal fetch for the test to not rely on playwright network interception --- integration/error-boundary-v2-test.ts | 32 ++++++++++++++++++--------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/integration/error-boundary-v2-test.ts b/integration/error-boundary-v2-test.ts index 6cb52156bc..c281aa76af 100644 --- a/integration/error-boundary-v2-test.ts +++ b/integration/error-boundary-v2-test.ts @@ -40,6 +40,7 @@ test.describe("ErrorBoundary", () => { `, "app/routes/parent.tsx": js` + import { useEffect } from "react"; import { Link, Outlet, @@ -48,11 +49,25 @@ test.describe("ErrorBoundary", () => { useRouteError, } from "react-router"; - export function loader() { - return "PARENT LOADER"; + export function loader({ request }) { + const url = new URL(request.url); + return {message: "PARENT LOADER", error: url.searchParams.has('error') }; } - export default function Component() { + export default function Component({ loaderData }) { + useEffect(() => { + let ogFetch = window.fetch; + if (loaderData.error) { + window.fetch = async (...args) => { + return new Response('CDN Error!', { status: 500 }); + }; + + return () => { + window.fetch = ogFetch; + }; + } + }, [loaderData.error]); + return (
-

{useLoaderData()}

+

{loaderData.message}

) @@ -166,19 +181,16 @@ test.describe("ErrorBoundary", () => { test("Network errors that never reach the Remix server", async ({ page, }) => { + let app = new PlaywrightFixture(appFixture, page); // Cause a .data request to trigger an HTTP error that never reaches the // Remix server, and ensure we properly handle it at the ErrorBoundary - await page.route(/\/parent\/child-with-boundary\.data$/, (route) => { - route.fulfill({ status: 500, body: "CDN Error!" }); - }); - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/parent"); + await app.goto("/parent?error"); await app.clickLink("/parent/child-with-boundary"); await waitForAndAssert( page, app, "#parent-error", - "Unable to decode turbo-stream response" + "500" ); }); }); From fbd98e405a2469e3c0c2c4d98936509d0098c026 Mon Sep 17 00:00:00 2001 From: Jacob Ebey Date: Wed, 12 Feb 2025 14:33:10 -0800 Subject: [PATCH 11/14] feat: add future flag --- integration/defer-test.ts | 2035 +++++++++++++---- integration/error-boundary-v2-test.ts | 242 +- integration/error-sanitization-test.ts | 588 +++++ integration/helpers/create-fixture.ts | 8 +- integration/helpers/vite.ts | 3 + packages/react-router-dev/config/config.ts | 2 + .../lib/dom-export/hydrated-router.tsx | 15 +- packages/react-router/lib/dom/ssr/entry.ts | 4 +- .../react-router/lib/dom/ssr/single-fetch.tsx | 108 +- .../react-router/lib/server-runtime/routes.ts | 6 +- .../react-router/lib/server-runtime/server.ts | 18 +- .../lib/server-runtime/single-fetch.ts | 70 +- .../vendor/turbo-stream-v2/flatten.ts | 223 ++ .../vendor/turbo-stream-v2/turbo-stream.ts | 280 +++ .../vendor/turbo-stream-v2/unflatten.ts | 275 +++ .../vendor/turbo-stream-v2/utils.ts | 84 + 16 files changed, 3519 insertions(+), 442 deletions(-) create mode 100644 packages/react-router/vendor/turbo-stream-v2/flatten.ts create mode 100644 packages/react-router/vendor/turbo-stream-v2/turbo-stream.ts create mode 100644 packages/react-router/vendor/turbo-stream-v2/unflatten.ts create mode 100644 packages/react-router/vendor/turbo-stream-v2/utils.ts diff --git a/integration/defer-test.ts b/integration/defer-test.ts index b325cbe7ba..d7cbf7b7b4 100644 --- a/integration/defer-test.ts +++ b/integration/defer-test.ts @@ -32,21 +32,22 @@ declare global { }; } -test.describe("non-aborted", () => { - let fixture: Fixture; - let appFixture: AppFixture; - - test.beforeEach(async ({ context }) => { - await context.route(/.data/, async (route) => { - await new Promise((resolve) => setTimeout(resolve, 50)); - route.continue(); +test.describe("turbo-stream-v2", () => { + test.describe("non-aborted", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + test.beforeEach(async ({ context }) => { + await context.route(/.data/, async (route) => { + await new Promise((resolve) => setTimeout(resolve, 50)); + route.continue(); + }); }); - }); - test.beforeAll(async () => { - fixture = await createFixture({ - files: { - "app/components/counter.tsx": js` + test.beforeAll(async () => { + fixture = await createFixture({ + files: { + "app/components/counter.tsx": js` import { useState } from "react"; export default function Counter({ id }) { @@ -59,7 +60,7 @@ test.describe("non-aborted", () => { ) } `, - "app/components/interactive.tsx": js` + "app/components/interactive.tsx": js` import { useEffect, useState } from "react"; export default function Interactive() { @@ -74,7 +75,7 @@ test.describe("non-aborted", () => { ) : null; } `, - "app/root.tsx": js` + "app/root.tsx": js` import { Links, Meta, Outlet, Scripts, useLoaderData } from "react-router"; import Counter from "~/components/counter"; import Interactive from "~/components/interactive"; @@ -114,7 +115,7 @@ test.describe("non-aborted", () => { } `, - "app/routes/_index.tsx": js` + "app/routes/_index.tsx": js` import { Link, useLoaderData } from "react-router"; import Counter from "~/components/counter"; @@ -144,7 +145,7 @@ test.describe("non-aborted", () => { } `, - "app/routes/deferred-noscript-resolved.tsx": js` + "app/routes/deferred-noscript-resolved.tsx": js` import { Suspense } from "react"; import { Await, Link, useLoaderData } from "react-router"; import Counter from "~/components/counter"; @@ -178,7 +179,7 @@ test.describe("non-aborted", () => { } `, - "app/routes/deferred-noscript-unresolved.tsx": js` + "app/routes/deferred-noscript-unresolved.tsx": js` import { Suspense } from "react"; import { Await, Link, useLoaderData } from "react-router"; import Counter from "~/components/counter"; @@ -216,7 +217,7 @@ test.describe("non-aborted", () => { } `, - "app/routes/deferred-script-resolved.tsx": js` + "app/routes/deferred-script-resolved.tsx": js` import { Suspense } from "react"; import { Await, Link, useLoaderData } from "react-router"; import Counter from "~/components/counter"; @@ -251,7 +252,7 @@ test.describe("non-aborted", () => { } `, - "app/routes/deferred-script-unresolved.tsx": js` + "app/routes/deferred-script-unresolved.tsx": js` import { Suspense } from "react"; import { Await, Link, useLoaderData } from "react-router"; import Counter from "~/components/counter"; @@ -294,7 +295,7 @@ test.describe("non-aborted", () => { } `, - "app/routes/deferred-script-rejected.tsx": js` + "app/routes/deferred-script-rejected.tsx": js` import { Suspense } from "react"; import { Await, Link, useLoaderData } from "react-router"; import Counter from "~/components/counter"; @@ -334,7 +335,7 @@ test.describe("non-aborted", () => { } `, - "app/routes/deferred-script-unrejected.tsx": js` + "app/routes/deferred-script-unrejected.tsx": js` import { Suspense } from "react"; import { Await, Link, useLoaderData } from "react-router"; import Counter from "~/components/counter"; @@ -378,7 +379,7 @@ test.describe("non-aborted", () => { } `, - "app/routes/deferred-script-rejected-no-error-element.tsx": js` + "app/routes/deferred-script-rejected-no-error-element.tsx": js` import { Suspense } from "react"; import { Await, Link, useLoaderData } from "react-router"; import Counter from "~/components/counter"; @@ -421,7 +422,7 @@ test.describe("non-aborted", () => { } `, - "app/routes/deferred-script-unrejected-no-error-element.tsx": js` + "app/routes/deferred-script-unrejected-no-error-element.tsx": js` import { Suspense } from "react"; import { Await, Link, useLoaderData } from "react-router"; import Counter from "~/components/counter"; @@ -468,7 +469,7 @@ test.describe("non-aborted", () => { } `, - "app/routes/deferred-manual-resolve.tsx": js` + "app/routes/deferred-manual-resolve.tsx": js` import { Suspense } from "react"; import { Await, Link, useLoaderData } from "react-router"; import Counter from "~/components/counter"; @@ -534,401 +535,411 @@ test.describe("non-aborted", () => { ); } `, - }, + }, + }); + + // This creates an interactive app using playwright. + appFixture = await createAppFixture(fixture); + originalConsoleError = console.error; + console.error = () => {}; }); - // This creates an interactive app using playwright. - appFixture = await createAppFixture(fixture); - originalConsoleError = console.error; - console.error = () => {}; - }); + test.afterAll(() => { + console.error = originalConsoleError; + appFixture.close(); + }); - test.afterAll(() => { - console.error = originalConsoleError; - appFixture.close(); - }); + function counterHtml(id: string, val: number) { + return `

${val}

`; + } - function counterHtml(id: string, val: number) { - return `

${val}

`; - } - - test("works with critical JSON like data", async ({ page }) => { - let response = await fixture.requestDocument("/"); - let html = await response.text(); - let criticalHTML = html.slice(0, html.indexOf("") + 7); - expect(criticalHTML).toContain(counterHtml(ROOT_ID, 0)); - expect(criticalHTML).toContain(counterHtml(INDEX_ID, 0)); - let deferredHTML = html.slice(html.indexOf("") + 7); - expect(deferredHTML).not.toBe(""); - expect(deferredHTML).not.toContain('

{ + let response = await fixture.requestDocument("/"); + let html = await response.text(); + let criticalHTML = html.slice(0, html.indexOf("") + 7); + expect(criticalHTML).toContain(counterHtml(ROOT_ID, 0)); + expect(criticalHTML).toContain(counterHtml(INDEX_ID, 0)); + let deferredHTML = html.slice(html.indexOf("") + 7); + expect(deferredHTML).not.toBe(""); + expect(deferredHTML).not.toContain('

{ - let response = await fixture.requestDocument("/deferred-noscript-resolved"); - let html = await response.text(); - let criticalHTML = html.slice(0, html.indexOf("") + 7); - expect(criticalHTML).toContain(counterHtml(ROOT_ID, 0)); - expect(criticalHTML).toContain(counterHtml(DEFERRED_ID, 0)); - expect(criticalHTML).toContain(FALLBACK_ID); - expect(criticalHTML).not.toContain(counterHtml(RESOLVED_DEFERRED_ID, 0)); - let deferredHTML = html.slice(html.indexOf("") + 7); - expect(deferredHTML).toContain(counterHtml(RESOLVED_DEFERRED_ID, 0)); - - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/deferred-noscript-resolved"); - await page.waitForSelector(`#${ROOT_ID}`); - await page.waitForSelector(`#${DEFERRED_ID}`); - await page.waitForSelector(`#${RESOLVED_DEFERRED_ID}`); - }); + test("resolved promises do not render in initial payload", async ({ + page, + }) => { + let response = await fixture.requestDocument( + "/deferred-noscript-resolved" + ); + let html = await response.text(); + let criticalHTML = html.slice(0, html.indexOf("") + 7); + expect(criticalHTML).toContain(counterHtml(ROOT_ID, 0)); + expect(criticalHTML).toContain(counterHtml(DEFERRED_ID, 0)); + expect(criticalHTML).toContain(FALLBACK_ID); + expect(criticalHTML).not.toContain(counterHtml(RESOLVED_DEFERRED_ID, 0)); + let deferredHTML = html.slice(html.indexOf("") + 7); + expect(deferredHTML).toContain(counterHtml(RESOLVED_DEFERRED_ID, 0)); + + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/deferred-noscript-resolved"); + await page.waitForSelector(`#${ROOT_ID}`); + await page.waitForSelector(`#${DEFERRED_ID}`); + await page.waitForSelector(`#${RESOLVED_DEFERRED_ID}`); + }); - test("slow promises render in subsequent payload", async ({ page }) => { - let response = await fixture.requestDocument( - "/deferred-noscript-unresolved" - ); - let html = await response.text(); - let criticalHTML = html.slice(0, html.indexOf("") + 7); - expect(criticalHTML).toContain(counterHtml(ROOT_ID, 0)); - expect(criticalHTML).toContain(counterHtml(DEFERRED_ID, 0)); - expect(criticalHTML).toContain(`

`); - expect(criticalHTML).not.toContain(RESOLVED_DEFERRED_ID); - let deferredHTML = html.slice(html.indexOf("") + 7); - expect(deferredHTML).toContain(counterHtml(RESOLVED_DEFERRED_ID, 0)); - - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/deferred-noscript-unresolved"); - await page.waitForSelector(`#${ROOT_ID}`); - await page.waitForSelector(`#${DEFERRED_ID}`); - await page.waitForSelector(`#${RESOLVED_DEFERRED_ID}`); - }); + test("slow promises render in subsequent payload", async ({ page }) => { + let response = await fixture.requestDocument( + "/deferred-noscript-unresolved" + ); + let html = await response.text(); + let criticalHTML = html.slice(0, html.indexOf("") + 7); + expect(criticalHTML).toContain(counterHtml(ROOT_ID, 0)); + expect(criticalHTML).toContain(counterHtml(DEFERRED_ID, 0)); + expect(criticalHTML).toContain(`
`); + expect(criticalHTML).not.toContain(RESOLVED_DEFERRED_ID); + let deferredHTML = html.slice(html.indexOf("") + 7); + expect(deferredHTML).toContain(counterHtml(RESOLVED_DEFERRED_ID, 0)); + + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/deferred-noscript-unresolved"); + await page.waitForSelector(`#${ROOT_ID}`); + await page.waitForSelector(`#${DEFERRED_ID}`); + await page.waitForSelector(`#${RESOLVED_DEFERRED_ID}`); + }); - test("resolved promises render in initial payload and hydrates", async ({ - page, - }) => { - let response = await fixture.requestDocument("/deferred-script-resolved"); - let html = await response.text(); - let criticalHTML = html.slice(0, html.indexOf("") + 7); - expect(criticalHTML).toContain(counterHtml(ROOT_ID, 0)); - expect(criticalHTML).toContain(counterHtml(DEFERRED_ID, 0)); - expect(criticalHTML).toContain(FALLBACK_ID); - let deferredHTML = html.slice(html.indexOf("") + 7); - expect(deferredHTML).toContain(counterHtml(RESOLVED_DEFERRED_ID, 0)); - - let app = new PlaywrightFixture(appFixture, page); - let assertConsole = monitorConsole(page); - await app.goto("/deferred-script-resolved", true); - await page.waitForSelector(`#${ROOT_ID}`); - await page.waitForSelector(`#${DEFERRED_ID}`); - await page.waitForSelector(`#${RESOLVED_DEFERRED_ID}`); - - await ensureInteractivity(page, ROOT_ID); - await ensureInteractivity(page, DEFERRED_ID); - await ensureInteractivity(page, RESOLVED_DEFERRED_ID); - - await assertConsole(); - }); + test("resolved promises render in initial payload and hydrates", async ({ + page, + }) => { + let response = await fixture.requestDocument("/deferred-script-resolved"); + let html = await response.text(); + let criticalHTML = html.slice(0, html.indexOf("") + 7); + expect(criticalHTML).toContain(counterHtml(ROOT_ID, 0)); + expect(criticalHTML).toContain(counterHtml(DEFERRED_ID, 0)); + expect(criticalHTML).toContain(FALLBACK_ID); + let deferredHTML = html.slice(html.indexOf("") + 7); + expect(deferredHTML).toContain(counterHtml(RESOLVED_DEFERRED_ID, 0)); + + let app = new PlaywrightFixture(appFixture, page); + let assertConsole = monitorConsole(page); + await app.goto("/deferred-script-resolved", true); + await page.waitForSelector(`#${ROOT_ID}`); + await page.waitForSelector(`#${DEFERRED_ID}`); + await page.waitForSelector(`#${RESOLVED_DEFERRED_ID}`); + + await ensureInteractivity(page, ROOT_ID); + await ensureInteractivity(page, DEFERRED_ID); + await ensureInteractivity(page, RESOLVED_DEFERRED_ID); + + await assertConsole(); + }); - test("slow to resolve promises render in subsequent payload and hydrates", async ({ - page, - }) => { - let response = await fixture.requestDocument("/deferred-script-unresolved"); - let html = await response.text(); - let criticalHTML = html.slice(0, html.indexOf("") + 7); - expect(criticalHTML).toContain(counterHtml(ROOT_ID, 0)); - expect(criticalHTML).toContain(counterHtml(DEFERRED_ID, 0)); - expect(criticalHTML).toContain(`
`); - expect(criticalHTML).not.toContain(RESOLVED_DEFERRED_ID); - let deferredHTML = html.slice(html.indexOf("") + 7); - expect(deferredHTML).toContain(counterHtml(RESOLVED_DEFERRED_ID, 0)); - - let app = new PlaywrightFixture(appFixture, page); - let assertConsole = monitorConsole(page); - await app.goto("/deferred-script-unresolved", true); - await page.waitForSelector(`#${ROOT_ID}`); - await page.waitForSelector(`#${DEFERRED_ID}`); - await page.waitForSelector(`#${RESOLVED_DEFERRED_ID}`); - - await ensureInteractivity(page, ROOT_ID); - await ensureInteractivity(page, DEFERRED_ID); - await ensureInteractivity(page, RESOLVED_DEFERRED_ID); - - await assertConsole(); - }); + test("slow to resolve promises render in subsequent payload and hydrates", async ({ + page, + }) => { + let response = await fixture.requestDocument( + "/deferred-script-unresolved" + ); + let html = await response.text(); + let criticalHTML = html.slice(0, html.indexOf("") + 7); + expect(criticalHTML).toContain(counterHtml(ROOT_ID, 0)); + expect(criticalHTML).toContain(counterHtml(DEFERRED_ID, 0)); + expect(criticalHTML).toContain(`
`); + expect(criticalHTML).not.toContain(RESOLVED_DEFERRED_ID); + let deferredHTML = html.slice(html.indexOf("") + 7); + expect(deferredHTML).toContain(counterHtml(RESOLVED_DEFERRED_ID, 0)); + + let app = new PlaywrightFixture(appFixture, page); + let assertConsole = monitorConsole(page); + await app.goto("/deferred-script-unresolved", true); + await page.waitForSelector(`#${ROOT_ID}`); + await page.waitForSelector(`#${DEFERRED_ID}`); + await page.waitForSelector(`#${RESOLVED_DEFERRED_ID}`); + + await ensureInteractivity(page, ROOT_ID); + await ensureInteractivity(page, DEFERRED_ID); + await ensureInteractivity(page, RESOLVED_DEFERRED_ID); + + await assertConsole(); + }); - test("rejected promises render in initial payload and hydrates", async ({ - page, - }) => { - let response = await fixture.requestDocument("/deferred-script-rejected"); - let html = await response.text(); - let criticalHTML = html.slice(0, html.indexOf("") + 7); - expect(criticalHTML).toContain(counterHtml(ROOT_ID, 0)); - expect(criticalHTML).toContain(counterHtml(DEFERRED_ID, 0)); - expect(criticalHTML).toContain(FALLBACK_ID); - let deferredHTML = html.slice(html.indexOf("") + 7); - expect(deferredHTML).toContain(counterHtml(ERROR_ID, 0)); - - let app = new PlaywrightFixture(appFixture, page); - let assertConsole = monitorConsole(page); - await app.goto("/deferred-script-rejected", true); - await page.waitForSelector(`#${ROOT_ID}`); - await page.waitForSelector(`#${DEFERRED_ID}`); - await page.waitForSelector(`#${ERROR_ID}`); - - await ensureInteractivity(page, ROOT_ID); - await ensureInteractivity(page, DEFERRED_ID); - await ensureInteractivity(page, ERROR_ID); - - await assertConsole(); - }); + test("rejected promises render in initial payload and hydrates", async ({ + page, + }) => { + let response = await fixture.requestDocument("/deferred-script-rejected"); + let html = await response.text(); + let criticalHTML = html.slice(0, html.indexOf("") + 7); + expect(criticalHTML).toContain(counterHtml(ROOT_ID, 0)); + expect(criticalHTML).toContain(counterHtml(DEFERRED_ID, 0)); + expect(criticalHTML).toContain(FALLBACK_ID); + let deferredHTML = html.slice(html.indexOf("") + 7); + expect(deferredHTML).toContain(counterHtml(ERROR_ID, 0)); + + let app = new PlaywrightFixture(appFixture, page); + let assertConsole = monitorConsole(page); + await app.goto("/deferred-script-rejected", true); + await page.waitForSelector(`#${ROOT_ID}`); + await page.waitForSelector(`#${DEFERRED_ID}`); + await page.waitForSelector(`#${ERROR_ID}`); + + await ensureInteractivity(page, ROOT_ID); + await ensureInteractivity(page, DEFERRED_ID); + await ensureInteractivity(page, ERROR_ID); + + await assertConsole(); + }); - test("slow to reject promises render in subsequent payload and hydrates", async ({ - page, - }) => { - let response = await fixture.requestDocument("/deferred-script-unrejected"); - let html = await response.text(); - let criticalHTML = html.slice(0, html.indexOf("") + 7); - expect(criticalHTML).toContain(counterHtml(ROOT_ID, 0)); - expect(criticalHTML).toContain(counterHtml(DEFERRED_ID, 0)); - expect(criticalHTML).toContain(`
`); - expect(criticalHTML).not.toContain(ERROR_ID); - let deferredHTML = html.slice(html.indexOf("") + 7); - expect(deferredHTML).toContain(counterHtml(ERROR_ID, 0)); - - let app = new PlaywrightFixture(appFixture, page); - let assertConsole = monitorConsole(page); - await app.goto("/deferred-script-unrejected", true); - await page.waitForSelector(`#${ROOT_ID}`); - await page.waitForSelector(`#${DEFERRED_ID}`); - await page.waitForSelector(`#${ERROR_ID}`); - - await ensureInteractivity(page, ROOT_ID); - await ensureInteractivity(page, DEFERRED_ID); - await ensureInteractivity(page, ERROR_ID); - - await assertConsole(); - }); + test("slow to reject promises render in subsequent payload and hydrates", async ({ + page, + }) => { + let response = await fixture.requestDocument( + "/deferred-script-unrejected" + ); + let html = await response.text(); + let criticalHTML = html.slice(0, html.indexOf("") + 7); + expect(criticalHTML).toContain(counterHtml(ROOT_ID, 0)); + expect(criticalHTML).toContain(counterHtml(DEFERRED_ID, 0)); + expect(criticalHTML).toContain(`
`); + expect(criticalHTML).not.toContain(ERROR_ID); + let deferredHTML = html.slice(html.indexOf("") + 7); + expect(deferredHTML).toContain(counterHtml(ERROR_ID, 0)); + + let app = new PlaywrightFixture(appFixture, page); + let assertConsole = monitorConsole(page); + await app.goto("/deferred-script-unrejected", true); + await page.waitForSelector(`#${ROOT_ID}`); + await page.waitForSelector(`#${DEFERRED_ID}`); + await page.waitForSelector(`#${ERROR_ID}`); + + await ensureInteractivity(page, ROOT_ID); + await ensureInteractivity(page, DEFERRED_ID); + await ensureInteractivity(page, ERROR_ID); + + await assertConsole(); + }); - test("rejected promises bubble to ErrorBoundary on hydrate", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/deferred-script-rejected-no-error-element", true); - await page.waitForSelector(`#${ROOT_ID}`); - await page.waitForSelector(`#${ERROR_BOUNDARY_ID}`); + test("rejected promises bubble to ErrorBoundary on hydrate", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/deferred-script-rejected-no-error-element", true); + await page.waitForSelector(`#${ROOT_ID}`); + await page.waitForSelector(`#${ERROR_BOUNDARY_ID}`); - await ensureInteractivity(page, ROOT_ID); - await ensureInteractivity(page, ERROR_BOUNDARY_ID); - }); + await ensureInteractivity(page, ROOT_ID); + await ensureInteractivity(page, ERROR_BOUNDARY_ID); + }); - test("slow to reject promises bubble to ErrorBoundary on hydrate", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/deferred-script-unrejected-no-error-element", true); - await page.waitForSelector(`#${ROOT_ID}`); - await page.waitForSelector(`#${ERROR_BOUNDARY_ID}`); + test("slow to reject promises bubble to ErrorBoundary on hydrate", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/deferred-script-unrejected-no-error-element", true); + await page.waitForSelector(`#${ROOT_ID}`); + await page.waitForSelector(`#${ERROR_BOUNDARY_ID}`); - await ensureInteractivity(page, ROOT_ID); - await ensureInteractivity(page, ERROR_BOUNDARY_ID); - }); + await ensureInteractivity(page, ROOT_ID); + await ensureInteractivity(page, ERROR_BOUNDARY_ID); + }); - test("routes are interactive when deferred promises are suspended and after resolve in subsequent payload", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - let assertConsole = monitorConsole(page); - app.goto("/deferred-manual-resolve", false); + test("routes are interactive when deferred promises are suspended and after resolve in subsequent payload", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + let assertConsole = monitorConsole(page); + app.goto("/deferred-manual-resolve", false); - await page.waitForSelector(`#${ROOT_ID}`); - await page.waitForSelector(`#${DEFERRED_ID}`); - await page.waitForSelector(`#${MANUAL_FALLBACK_ID}`); - let idElement = await page.waitForSelector(`#${RESOLVED_DEFERRED_ID}`); - let id = await idElement.innerText(); - expect(id).toBeTruthy(); + await page.waitForSelector(`#${ROOT_ID}`); + await page.waitForSelector(`#${DEFERRED_ID}`); + await page.waitForSelector(`#${MANUAL_FALLBACK_ID}`); + let idElement = await page.waitForSelector(`#${RESOLVED_DEFERRED_ID}`); + let id = await idElement.innerText(); + expect(id).toBeTruthy(); - // Ensure the deferred promise is suspended - await page.waitForSelector(`#${MANUAL_RESOLVED_ID}`, { state: "hidden" }); + // Ensure the deferred promise is suspended + await page.waitForSelector(`#${MANUAL_RESOLVED_ID}`, { state: "hidden" }); - await page.waitForSelector("#interactive"); - await ensureInteractivity(page, ROOT_ID); - await ensureInteractivity(page, DEFERRED_ID); - await ensureInteractivity(page, RESOLVED_DEFERRED_ID); + await page.waitForSelector("#interactive"); + await ensureInteractivity(page, ROOT_ID); + await ensureInteractivity(page, DEFERRED_ID); + await ensureInteractivity(page, RESOLVED_DEFERRED_ID); - global.__deferredManualResolveCache.deferreds[id].resolve("value"); + global.__deferredManualResolveCache.deferreds[id].resolve("value"); - await ensureInteractivity(page, MANUAL_RESOLVED_ID); - await ensureInteractivity(page, RESOLVED_DEFERRED_ID, 2); - await ensureInteractivity(page, DEFERRED_ID, 2); - await ensureInteractivity(page, ROOT_ID, 2); + await ensureInteractivity(page, MANUAL_RESOLVED_ID); + await ensureInteractivity(page, RESOLVED_DEFERRED_ID, 2); + await ensureInteractivity(page, DEFERRED_ID, 2); + await ensureInteractivity(page, ROOT_ID, 2); - await assertConsole(); - }); + await assertConsole(); + }); - test("routes are interactive when deferred promises are suspended and after rejection in subsequent payload", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - let assertConsole = monitorConsole(page); - await app.goto("/deferred-manual-resolve", false); - - await page.waitForSelector(`#${ROOT_ID}`); - await page.waitForSelector(`#${DEFERRED_ID}`); - await page.waitForSelector(`#${MANUAL_FALLBACK_ID}`); - let idElement = await page.waitForSelector(`#${RESOLVED_DEFERRED_ID}`); - let id = await idElement.innerText(); - expect(id).toBeTruthy(); - - await page.waitForSelector("#interactive"); - await ensureInteractivity(page, ROOT_ID); - await ensureInteractivity(page, DEFERRED_ID); - await ensureInteractivity(page, RESOLVED_DEFERRED_ID); - - global.__deferredManualResolveCache.deferreds[id].reject( - new Error("error") - ); - - await ensureInteractivity(page, ROOT_ID, 2); - await ensureInteractivity(page, DEFERRED_ID, 2); - await ensureInteractivity(page, RESOLVED_DEFERRED_ID, 2); - await ensureInteractivity(page, MANUAL_ERROR_ID); - - await assertConsole(); - }); + test("routes are interactive when deferred promises are suspended and after rejection in subsequent payload", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + let assertConsole = monitorConsole(page); + await app.goto("/deferred-manual-resolve", false); + + await page.waitForSelector(`#${ROOT_ID}`); + await page.waitForSelector(`#${DEFERRED_ID}`); + await page.waitForSelector(`#${MANUAL_FALLBACK_ID}`); + let idElement = await page.waitForSelector(`#${RESOLVED_DEFERRED_ID}`); + let id = await idElement.innerText(); + expect(id).toBeTruthy(); + + await page.waitForSelector("#interactive"); + await ensureInteractivity(page, ROOT_ID); + await ensureInteractivity(page, DEFERRED_ID); + await ensureInteractivity(page, RESOLVED_DEFERRED_ID); + + global.__deferredManualResolveCache.deferreds[id].reject( + new Error("error") + ); - test("client transition with resolved promises work", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - let assertConsole = monitorConsole(page); - await app.goto("/"); + await ensureInteractivity(page, ROOT_ID, 2); + await ensureInteractivity(page, DEFERRED_ID, 2); + await ensureInteractivity(page, RESOLVED_DEFERRED_ID, 2); + await ensureInteractivity(page, MANUAL_ERROR_ID); - await page.waitForSelector("#interactive"); - await ensureInteractivity(page, ROOT_ID); - await ensureInteractivity(page, INDEX_ID); + await assertConsole(); + }); - await app.clickLink("/deferred-script-resolved"); + test("client transition with resolved promises work", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + let assertConsole = monitorConsole(page); + await app.goto("/"); - await ensureInteractivity(page, ROOT_ID, 2); - await ensureInteractivity(page, DEFERRED_ID); - await ensureInteractivity(page, RESOLVED_DEFERRED_ID); + await page.waitForSelector("#interactive"); + await ensureInteractivity(page, ROOT_ID); + await ensureInteractivity(page, INDEX_ID); - await assertConsole(); - }); + await app.clickLink("/deferred-script-resolved"); - test("client transition with unresolved promises work", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - let assertConsole = monitorConsole(page); - await app.goto("/"); + await ensureInteractivity(page, ROOT_ID, 2); + await ensureInteractivity(page, DEFERRED_ID); + await ensureInteractivity(page, RESOLVED_DEFERRED_ID); - await page.waitForSelector("#interactive"); - await ensureInteractivity(page, ROOT_ID); - await ensureInteractivity(page, INDEX_ID); + await assertConsole(); + }); - await app.clickLink("/deferred-script-unresolved"); + test("client transition with unresolved promises work", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + let assertConsole = monitorConsole(page); + await app.goto("/"); - await ensureInteractivity(page, ROOT_ID, 2); - await ensureInteractivity(page, DEFERRED_ID); - await ensureInteractivity(page, RESOLVED_DEFERRED_ID); + await page.waitForSelector("#interactive"); + await ensureInteractivity(page, ROOT_ID); + await ensureInteractivity(page, INDEX_ID); - await assertConsole(); - }); + await app.clickLink("/deferred-script-unresolved"); - test("client transition with rejected promises work", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - let assertConsole = monitorConsole(page); - await app.goto("/"); + await ensureInteractivity(page, ROOT_ID, 2); + await ensureInteractivity(page, DEFERRED_ID); + await ensureInteractivity(page, RESOLVED_DEFERRED_ID); - await page.waitForSelector("#interactive"); - await ensureInteractivity(page, ROOT_ID); - await ensureInteractivity(page, INDEX_ID); + await assertConsole(); + }); - app.clickLink("/deferred-script-rejected"); + test("client transition with rejected promises work", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + let assertConsole = monitorConsole(page); + await app.goto("/"); - await ensureInteractivity(page, DEFERRED_ID); - await ensureInteractivity(page, ERROR_ID); - await ensureInteractivity(page, DEFERRED_ID, 2); - await ensureInteractivity(page, ROOT_ID, 2); + await page.waitForSelector("#interactive"); + await ensureInteractivity(page, ROOT_ID); + await ensureInteractivity(page, INDEX_ID); - await assertConsole(); - }); + app.clickLink("/deferred-script-rejected"); - test("client transition with unrejected promises work", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - let assertConsole = monitorConsole(page); - await app.goto("/"); + await ensureInteractivity(page, DEFERRED_ID); + await ensureInteractivity(page, ERROR_ID); + await ensureInteractivity(page, DEFERRED_ID, 2); + await ensureInteractivity(page, ROOT_ID, 2); - await page.waitForSelector("#interactive"); - await ensureInteractivity(page, ROOT_ID); - await ensureInteractivity(page, INDEX_ID); + await assertConsole(); + }); - await app.clickLink("/deferred-script-unrejected"); + test("client transition with unrejected promises work", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + let assertConsole = monitorConsole(page); + await app.goto("/"); - await ensureInteractivity(page, DEFERRED_ID); - await ensureInteractivity(page, ERROR_ID); - await ensureInteractivity(page, DEFERRED_ID, 2); - await ensureInteractivity(page, ROOT_ID, 2); + await page.waitForSelector("#interactive"); + await ensureInteractivity(page, ROOT_ID); + await ensureInteractivity(page, INDEX_ID); - await assertConsole(); - }); + await app.clickLink("/deferred-script-unrejected"); - test("client transition with rejected promises bubble to ErrorBoundary", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); + await ensureInteractivity(page, DEFERRED_ID); + await ensureInteractivity(page, ERROR_ID); + await ensureInteractivity(page, DEFERRED_ID, 2); + await ensureInteractivity(page, ROOT_ID, 2); - await page.waitForSelector("#interactive"); - await ensureInteractivity(page, ROOT_ID); - await ensureInteractivity(page, INDEX_ID); + await assertConsole(); + }); - await app.clickLink("/deferred-script-rejected-no-error-element"); + test("client transition with rejected promises bubble to ErrorBoundary", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); - await ensureInteractivity(page, ERROR_BOUNDARY_ID); - await ensureInteractivity(page, ROOT_ID, 2); - }); + await page.waitForSelector("#interactive"); + await ensureInteractivity(page, ROOT_ID); + await ensureInteractivity(page, INDEX_ID); - test("client transition with unrejected promises bubble to ErrorBoundary", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); + await app.clickLink("/deferred-script-rejected-no-error-element"); - await page.waitForSelector("#interactive"); - await ensureInteractivity(page, ROOT_ID); - await ensureInteractivity(page, INDEX_ID); + await ensureInteractivity(page, ERROR_BOUNDARY_ID); + await ensureInteractivity(page, ROOT_ID, 2); + }); - await app.clickLink("/deferred-script-unrejected-no-error-element"); + test("client transition with unrejected promises bubble to ErrorBoundary", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); - await ensureInteractivity(page, ERROR_BOUNDARY_ID); - await ensureInteractivity(page, ROOT_ID, 2); - }); -}); + await page.waitForSelector("#interactive"); + await ensureInteractivity(page, ROOT_ID); + await ensureInteractivity(page, INDEX_ID); -test.describe("aborted", () => { - let fixture: Fixture; - let appFixture: AppFixture; + await app.clickLink("/deferred-script-unrejected-no-error-element"); - test.beforeEach(async ({ context }) => { - await context.route(/\.data$/, async (route) => { - await new Promise((resolve) => setTimeout(resolve, 50)); - route.continue(); + await ensureInteractivity(page, ERROR_BOUNDARY_ID); + await ensureInteractivity(page, ROOT_ID, 2); }); }); - test.beforeAll(async () => { - fixture = await createFixture({ - files: { - "app/entry.server.tsx": js` + test.describe("aborted", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + test.beforeEach(async ({ context }) => { + await context.route(/\.data$/, async (route) => { + await new Promise((resolve) => setTimeout(resolve, 50)); + route.continue(); + }); + }); + + test.beforeAll(async () => { + fixture = await createFixture({ + files: { + "app/entry.server.tsx": js` import { PassThrough } from "node:stream"; import type { AppLoadContext, EntryContext } from "react-router"; import { createReadableStreamFromReadable } from "@react-router/node"; @@ -1047,7 +1058,7 @@ test.describe("aborted", () => { }); } `, - "app/components/counter.tsx": js` + "app/components/counter.tsx": js` import { useState } from "react"; export default function Counter({ id }) { @@ -1060,7 +1071,7 @@ test.describe("aborted", () => { ) } `, - "app/components/interactive.tsx": js` + "app/components/interactive.tsx": js` import { useEffect, useState } from "react"; export default function Interactive() { @@ -1075,7 +1086,7 @@ test.describe("aborted", () => { ) : null; } `, - "app/root.tsx": js` + "app/root.tsx": js` import { Links, Meta, Outlet, Scripts, useLoaderData } from "react-router"; import Counter from "~/components/counter"; import Interactive from "~/components/interactive"; @@ -1115,7 +1126,7 @@ test.describe("aborted", () => { } `, - "app/routes/deferred-server-aborted.tsx": js` + "app/routes/deferred-server-aborted.tsx": js` import { Suspense } from "react"; import { Await, Link, useLoaderData } from "react-router"; import Counter from "~/components/counter"; @@ -1159,7 +1170,7 @@ test.describe("aborted", () => { } `, - "app/routes/deferred-server-aborted-no-error-element.tsx": js` + "app/routes/deferred-server-aborted-no-error-element.tsx": js` import { Suspense } from "react"; import { Await, Link, useLoaderData } from "react-router"; import Counter from "~/components/counter"; @@ -1205,43 +1216,1271 @@ test.describe("aborted", () => { ); } `, - }, + }, + }); + + // This creates an interactive app using playwright. + appFixture = await createAppFixture(fixture); + + originalConsoleError = console.error; + console.error = () => {}; }); - // This creates an interactive app using playwright. - appFixture = await createAppFixture(fixture); + test.afterAll(() => { + console.error = originalConsoleError; + appFixture.close(); + }); - originalConsoleError = console.error; - console.error = () => {}; - }); + test("server aborts render the errorElement", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/deferred-server-aborted"); + await page.waitForSelector(`#${ROOT_ID}`); + await page.waitForSelector(`#${DEFERRED_ID}`); + await page.waitForSelector(`#${ERROR_ID}`); - test.afterAll(() => { - console.error = originalConsoleError; - appFixture.close(); - }); + await ensureInteractivity(page, ROOT_ID); + await ensureInteractivity(page, DEFERRED_ID); + await ensureInteractivity(page, ERROR_ID); + }); - test("server aborts render the errorElement", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/deferred-server-aborted"); - await page.waitForSelector(`#${ROOT_ID}`); - await page.waitForSelector(`#${DEFERRED_ID}`); - await page.waitForSelector(`#${ERROR_ID}`); + test("server aborts render the ErrorBoundary when no errorElement", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/deferred-server-aborted-no-error-element"); + await page.waitForSelector(`#${ROOT_ID}`); + await page.waitForSelector(`#${ERROR_BOUNDARY_ID}`); - await ensureInteractivity(page, ROOT_ID); - await ensureInteractivity(page, DEFERRED_ID); - await ensureInteractivity(page, ERROR_ID); + await ensureInteractivity(page, ROOT_ID); + await ensureInteractivity(page, ERROR_BOUNDARY_ID); + }); }); +}); + +test.describe("turbo-stream-v3", () => { + test.describe("non-aborted", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + test.beforeEach(async ({ context }) => { + await context.route(/.data/, async (route) => { + await new Promise((resolve) => setTimeout(resolve, 50)); + route.continue(); + }); + }); + + test.beforeAll(async () => { + fixture = await createFixture({ + turboV3: true, + files: { + "app/components/counter.tsx": js` + import { useState } from "react"; + + export default function Counter({ id }) { + let [count, setCount] = useState(0); + return ( +
+ +

{count}

+
+ ) + } + `, + "app/components/interactive.tsx": js` + import { useEffect, useState } from "react"; + + export default function Interactive() { + let [interactive, setInteractive] = useState(false); + useEffect(() => { + setInteractive(true); + }, []); + return interactive ? ( +
+

interactive

+
+ ) : null; + } + `, + "app/root.tsx": js` + import { Links, Meta, Outlet, Scripts, useLoaderData } from "react-router"; + import Counter from "~/components/counter"; + import Interactive from "~/components/interactive"; + + export const meta: MetaFunction = () => { + return [{ title: "New Remix App" }]; + }; + + export const loader = () => ({ + id: "${ROOT_ID}", + }); + + export default function Root() { + let { id } = useLoaderData(); + return ( + + + + + + + + +
+

{id}

+ + + +
+ + {/* Send arbitrary data so safari renders the initial shell before + the document finishes downloading. */} + {Array(10000).fill(null).map((_, i)=>

YOOOOOOOOOO {i}

)} + + + ); + } + `, + + "app/routes/_index.tsx": js` + import { Link, useLoaderData } from "react-router"; + import Counter from "~/components/counter"; + + export function loader() { + return { + id: "${INDEX_ID}", + }; + } + + export default function Index() { + let { id } = useLoaderData(); + return ( +
+

{id}

+ + +
    +
  • deferred-script-resolved
  • +
  • deferred-script-unresolved
  • +
  • deferred-script-rejected
  • +
  • deferred-script-unrejected
  • +
  • deferred-script-rejected-no-error-element
  • +
  • deferred-script-unrejected-no-error-element
  • +
+
+ ); + } + `, + + "app/routes/deferred-noscript-resolved.tsx": js` + import { Suspense } from "react"; + import { Await, Link, useLoaderData } from "react-router"; + import Counter from "~/components/counter"; + + export function loader() { + return { + deferredId: "${DEFERRED_ID}", + resolvedId: Promise.resolve("${RESOLVED_DEFERRED_ID}"), + }; + } + + export default function Deferred() { + let { deferredId, resolvedId } = useLoaderData(); + return ( +
+

{deferredId}

+ + fallback
}> + ( +
+

{resolvedDeferredId}

+ +
+ )} + /> + +
+ ); + } + `, + + "app/routes/deferred-noscript-unresolved.tsx": js` + import { Suspense } from "react"; + import { Await, Link, useLoaderData } from "react-router"; + import Counter from "~/components/counter"; + + export function loader() { + return { + deferredId: "${DEFERRED_ID}", + resolvedId: new Promise( + (resolve) => setTimeout(() => { + resolve("${RESOLVED_DEFERRED_ID}"); + }, 10) + ), + }; + } + + export default function Deferred() { + let { deferredId, resolvedId } = useLoaderData(); + return ( +
+

{deferredId}

+ + fallback
}> + ( +
+

{resolvedDeferredId}

+ +
+ )} + /> + +
+ ); + } + `, + + "app/routes/deferred-script-resolved.tsx": js` + import { Suspense } from "react"; + import { Await, Link, useLoaderData } from "react-router"; + import Counter from "~/components/counter"; + + export function loader() { + return { + deferredId: "${DEFERRED_ID}", + resolvedId: Promise.resolve("${RESOLVED_DEFERRED_ID}"), + deferredUndefined: Promise.resolve(undefined), + }; + } - test("server aborts render the ErrorBoundary when no errorElement", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/deferred-server-aborted-no-error-element"); - await page.waitForSelector(`#${ROOT_ID}`); - await page.waitForSelector(`#${ERROR_BOUNDARY_ID}`); + export default function Deferred() { + let { deferredId, resolvedId } = useLoaderData(); + return ( +
+

{deferredId}

+ + fallback
}> + ( +
+

{resolvedDeferredId}

+ +
+ )} + /> + +
+ ); + } + `, - await ensureInteractivity(page, ROOT_ID); - await ensureInteractivity(page, ERROR_BOUNDARY_ID); + "app/routes/deferred-script-unresolved.tsx": js` + import { Suspense } from "react"; + import { Await, Link, useLoaderData } from "react-router"; + import Counter from "~/components/counter"; + + export function loader() { + return { + deferredId: "${DEFERRED_ID}", + resolvedId: new Promise( + (resolve) => setTimeout(() => { + resolve("${RESOLVED_DEFERRED_ID}"); + }, 10) + ), + deferredUndefined: new Promise( + (resolve) => setTimeout(() => { + resolve(undefined); + }, 10) + ), + }; + } + + export default function Deferred() { + let { deferredId, resolvedId } = useLoaderData(); + return ( +
+

{deferredId}

+ + fallback
}> + ( +
+

{resolvedDeferredId}

+ +
+ )} + /> + +
+ ); + } + `, + + "app/routes/deferred-script-rejected.tsx": js` + import { Suspense } from "react"; + import { Await, Link, useLoaderData } from "react-router"; + import Counter from "~/components/counter"; + + export function loader() { + return { + deferredId: "${DEFERRED_ID}", + resolvedId: Promise.reject(new Error("${RESOLVED_DEFERRED_ID}")), + }; + } + + export default function Deferred() { + let { deferredId, resolvedId } = useLoaderData(); + return ( +
+

{deferredId}

+ + fallback
}> + + error + +
+ } + children={(resolvedDeferredId) => ( +
+

{resolvedDeferredId}

+ +
+ )} + /> + +
+ ); + } + `, + + "app/routes/deferred-script-unrejected.tsx": js` + import { Suspense } from "react"; + import { Await, Link, useLoaderData } from "react-router"; + import Counter from "~/components/counter"; + + export function loader() { + return { + deferredId: "${DEFERRED_ID}", + resolvedId: new Promise( + (_, reject) => setTimeout(() => { + reject(new Error("${RESOLVED_DEFERRED_ID}")); + }, 10) + ), + }; + } + + export default function Deferred() { + let { deferredId, resolvedId, resolvedUndefined } = useLoaderData(); + return ( +
+

{deferredId}

+ + fallback
}> + + error + + + } + children={(resolvedDeferredId) => ( +
+

{resolvedDeferredId}

+ +
+ )} + /> + + + ); + } + `, + + "app/routes/deferred-script-rejected-no-error-element.tsx": js` + import { Suspense } from "react"; + import { Await, Link, useLoaderData } from "react-router"; + import Counter from "~/components/counter"; + + export function loader() { + return { + deferredId: "${DEFERRED_ID}", + resolvedId: Promise.reject(new Error("${RESOLVED_DEFERRED_ID}")), + }; + } + + export default function Deferred() { + let { deferredId, resolvedId } = useLoaderData(); + return ( +
+

{deferredId}

+ + fallback
}> + ( +
+

{resolvedDeferredId}

+ +
+ )} + /> + + + ); + } + + export function ErrorBoundary() { + return ( +
+ error + +
+ ); + } + `, + + "app/routes/deferred-script-unrejected-no-error-element.tsx": js` + import { Suspense } from "react"; + import { Await, Link, useLoaderData } from "react-router"; + import Counter from "~/components/counter"; + + export function loader() { + return { + deferredId: "${DEFERRED_ID}", + resolvedId: new Promise( + (_, reject) => setTimeout(() => { + reject(new Error("${RESOLVED_DEFERRED_ID}")); + }, 10) + ), + }; + } + + export default function Deferred() { + let { deferredId, resolvedId } = useLoaderData(); + return ( +
+

{deferredId}

+ + fallback
}> + ( +
+

{resolvedDeferredId}

+ +
+ )} + /> + + + ); + } + + export function ErrorBoundary() { + return ( +
+ error + +
+ ); + } + `, + + "app/routes/deferred-manual-resolve.tsx": js` + import { Suspense } from "react"; + import { Await, Link, useLoaderData } from "react-router"; + import Counter from "~/components/counter"; + + export function loader() { + global.__deferredManualResolveCache = global.__deferredManualResolveCache || { + nextId: 1, + deferreds: {}, + }; + + let id = "" + global.__deferredManualResolveCache.nextId++; + let promise = new Promise((resolve, reject) => { + global.__deferredManualResolveCache.deferreds[id] = { resolve, reject }; + }); + + return { + deferredId: "${DEFERRED_ID}", + resolvedId: new Promise( + (resolve) => setTimeout(() => { + resolve("${RESOLVED_DEFERRED_ID}"); + }, 10) + ), + id, + manualValue: promise, + }; + } + + export default function Deferred() { + let { deferredId, resolvedId, id, manualValue } = useLoaderData(); + return ( +
+

{deferredId}

+ + fallback
}> + ( +
+

{id}

+ +
+ )} + /> + + manual fallback}> + + error + + + } + children={(value) => ( +
+
{JSON.stringify(value)}
+ +
+ )} + /> +
+ + ); + } + `, + }, + }); + + // This creates an interactive app using playwright. + appFixture = await createAppFixture(fixture); + originalConsoleError = console.error; + console.error = () => {}; + }); + + test.afterAll(() => { + console.error = originalConsoleError; + appFixture.close(); + }); + + function counterHtml(id: string, val: number) { + return `

${val}

`; + } + + test("works with critical JSON like data", async ({ page }) => { + let response = await fixture.requestDocument("/"); + let html = await response.text(); + let criticalHTML = html.slice(0, html.indexOf("") + 7); + expect(criticalHTML).toContain(counterHtml(ROOT_ID, 0)); + expect(criticalHTML).toContain(counterHtml(INDEX_ID, 0)); + let deferredHTML = html.slice(html.indexOf("") + 7); + expect(deferredHTML).not.toBe(""); + expect(deferredHTML).not.toContain('

{ + let response = await fixture.requestDocument( + "/deferred-noscript-resolved" + ); + let html = await response.text(); + let criticalHTML = html.slice(0, html.indexOf("") + 7); + expect(criticalHTML).toContain(counterHtml(ROOT_ID, 0)); + expect(criticalHTML).toContain(counterHtml(DEFERRED_ID, 0)); + expect(criticalHTML).toContain(FALLBACK_ID); + expect(criticalHTML).not.toContain(counterHtml(RESOLVED_DEFERRED_ID, 0)); + let deferredHTML = html.slice(html.indexOf("") + 7); + expect(deferredHTML).toContain(counterHtml(RESOLVED_DEFERRED_ID, 0)); + + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/deferred-noscript-resolved"); + await page.waitForSelector(`#${ROOT_ID}`); + await page.waitForSelector(`#${DEFERRED_ID}`); + await page.waitForSelector(`#${RESOLVED_DEFERRED_ID}`); + }); + + test("slow promises render in subsequent payload", async ({ page }) => { + let response = await fixture.requestDocument( + "/deferred-noscript-unresolved" + ); + let html = await response.text(); + let criticalHTML = html.slice(0, html.indexOf("") + 7); + expect(criticalHTML).toContain(counterHtml(ROOT_ID, 0)); + expect(criticalHTML).toContain(counterHtml(DEFERRED_ID, 0)); + expect(criticalHTML).toContain(`

`); + expect(criticalHTML).not.toContain(RESOLVED_DEFERRED_ID); + let deferredHTML = html.slice(html.indexOf("") + 7); + expect(deferredHTML).toContain(counterHtml(RESOLVED_DEFERRED_ID, 0)); + + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/deferred-noscript-unresolved"); + await page.waitForSelector(`#${ROOT_ID}`); + await page.waitForSelector(`#${DEFERRED_ID}`); + await page.waitForSelector(`#${RESOLVED_DEFERRED_ID}`); + }); + + test("resolved promises render in initial payload and hydrates", async ({ + page, + }) => { + let response = await fixture.requestDocument("/deferred-script-resolved"); + let html = await response.text(); + let criticalHTML = html.slice(0, html.indexOf("") + 7); + expect(criticalHTML).toContain(counterHtml(ROOT_ID, 0)); + expect(criticalHTML).toContain(counterHtml(DEFERRED_ID, 0)); + expect(criticalHTML).toContain(FALLBACK_ID); + let deferredHTML = html.slice(html.indexOf("") + 7); + expect(deferredHTML).toContain(counterHtml(RESOLVED_DEFERRED_ID, 0)); + + let app = new PlaywrightFixture(appFixture, page); + let assertConsole = monitorConsole(page); + await app.goto("/deferred-script-resolved", true); + await page.waitForSelector(`#${ROOT_ID}`); + await page.waitForSelector(`#${DEFERRED_ID}`); + await page.waitForSelector(`#${RESOLVED_DEFERRED_ID}`); + + await ensureInteractivity(page, ROOT_ID); + await ensureInteractivity(page, DEFERRED_ID); + await ensureInteractivity(page, RESOLVED_DEFERRED_ID); + + await assertConsole(); + }); + + test("slow to resolve promises render in subsequent payload and hydrates", async ({ + page, + }) => { + let response = await fixture.requestDocument( + "/deferred-script-unresolved" + ); + let html = await response.text(); + let criticalHTML = html.slice(0, html.indexOf("") + 7); + expect(criticalHTML).toContain(counterHtml(ROOT_ID, 0)); + expect(criticalHTML).toContain(counterHtml(DEFERRED_ID, 0)); + expect(criticalHTML).toContain(`
`); + expect(criticalHTML).not.toContain(RESOLVED_DEFERRED_ID); + let deferredHTML = html.slice(html.indexOf("") + 7); + expect(deferredHTML).toContain(counterHtml(RESOLVED_DEFERRED_ID, 0)); + + let app = new PlaywrightFixture(appFixture, page); + let assertConsole = monitorConsole(page); + await app.goto("/deferred-script-unresolved", true); + await page.waitForSelector(`#${ROOT_ID}`); + await page.waitForSelector(`#${DEFERRED_ID}`); + await page.waitForSelector(`#${RESOLVED_DEFERRED_ID}`); + + await ensureInteractivity(page, ROOT_ID); + await ensureInteractivity(page, DEFERRED_ID); + await ensureInteractivity(page, RESOLVED_DEFERRED_ID); + + await assertConsole(); + }); + + test("rejected promises render in initial payload and hydrates", async ({ + page, + }) => { + let response = await fixture.requestDocument("/deferred-script-rejected"); + let html = await response.text(); + let criticalHTML = html.slice(0, html.indexOf("") + 7); + expect(criticalHTML).toContain(counterHtml(ROOT_ID, 0)); + expect(criticalHTML).toContain(counterHtml(DEFERRED_ID, 0)); + expect(criticalHTML).toContain(FALLBACK_ID); + let deferredHTML = html.slice(html.indexOf("") + 7); + expect(deferredHTML).toContain(counterHtml(ERROR_ID, 0)); + + let app = new PlaywrightFixture(appFixture, page); + let assertConsole = monitorConsole(page); + await app.goto("/deferred-script-rejected", true); + await page.waitForSelector(`#${ROOT_ID}`); + await page.waitForSelector(`#${DEFERRED_ID}`); + await page.waitForSelector(`#${ERROR_ID}`); + + await ensureInteractivity(page, ROOT_ID); + await ensureInteractivity(page, DEFERRED_ID); + await ensureInteractivity(page, ERROR_ID); + + await assertConsole(); + }); + + test("slow to reject promises render in subsequent payload and hydrates", async ({ + page, + }) => { + let response = await fixture.requestDocument( + "/deferred-script-unrejected" + ); + let html = await response.text(); + let criticalHTML = html.slice(0, html.indexOf("") + 7); + expect(criticalHTML).toContain(counterHtml(ROOT_ID, 0)); + expect(criticalHTML).toContain(counterHtml(DEFERRED_ID, 0)); + expect(criticalHTML).toContain(`
`); + expect(criticalHTML).not.toContain(ERROR_ID); + let deferredHTML = html.slice(html.indexOf("") + 7); + expect(deferredHTML).toContain(counterHtml(ERROR_ID, 0)); + + let app = new PlaywrightFixture(appFixture, page); + let assertConsole = monitorConsole(page); + await app.goto("/deferred-script-unrejected", true); + await page.waitForSelector(`#${ROOT_ID}`); + await page.waitForSelector(`#${DEFERRED_ID}`); + await page.waitForSelector(`#${ERROR_ID}`); + + await ensureInteractivity(page, ROOT_ID); + await ensureInteractivity(page, DEFERRED_ID); + await ensureInteractivity(page, ERROR_ID); + + await assertConsole(); + }); + + test("rejected promises bubble to ErrorBoundary on hydrate", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/deferred-script-rejected-no-error-element", true); + await page.waitForSelector(`#${ROOT_ID}`); + await page.waitForSelector(`#${ERROR_BOUNDARY_ID}`); + + await ensureInteractivity(page, ROOT_ID); + await ensureInteractivity(page, ERROR_BOUNDARY_ID); + }); + + test("slow to reject promises bubble to ErrorBoundary on hydrate", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/deferred-script-unrejected-no-error-element", true); + await page.waitForSelector(`#${ROOT_ID}`); + await page.waitForSelector(`#${ERROR_BOUNDARY_ID}`); + + await ensureInteractivity(page, ROOT_ID); + await ensureInteractivity(page, ERROR_BOUNDARY_ID); + }); + + test("routes are interactive when deferred promises are suspended and after resolve in subsequent payload", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + let assertConsole = monitorConsole(page); + app.goto("/deferred-manual-resolve", false); + + await page.waitForSelector(`#${ROOT_ID}`); + await page.waitForSelector(`#${DEFERRED_ID}`); + await page.waitForSelector(`#${MANUAL_FALLBACK_ID}`); + let idElement = await page.waitForSelector(`#${RESOLVED_DEFERRED_ID}`); + let id = await idElement.innerText(); + expect(id).toBeTruthy(); + + // Ensure the deferred promise is suspended + await page.waitForSelector(`#${MANUAL_RESOLVED_ID}`, { state: "hidden" }); + + await page.waitForSelector("#interactive"); + await ensureInteractivity(page, ROOT_ID); + await ensureInteractivity(page, DEFERRED_ID); + await ensureInteractivity(page, RESOLVED_DEFERRED_ID); + + global.__deferredManualResolveCache.deferreds[id].resolve("value"); + + await ensureInteractivity(page, MANUAL_RESOLVED_ID); + await ensureInteractivity(page, RESOLVED_DEFERRED_ID, 2); + await ensureInteractivity(page, DEFERRED_ID, 2); + await ensureInteractivity(page, ROOT_ID, 2); + + await assertConsole(); + }); + + test("routes are interactive when deferred promises are suspended and after rejection in subsequent payload", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + let assertConsole = monitorConsole(page); + await app.goto("/deferred-manual-resolve", false); + + await page.waitForSelector(`#${ROOT_ID}`); + await page.waitForSelector(`#${DEFERRED_ID}`); + await page.waitForSelector(`#${MANUAL_FALLBACK_ID}`); + let idElement = await page.waitForSelector(`#${RESOLVED_DEFERRED_ID}`); + let id = await idElement.innerText(); + expect(id).toBeTruthy(); + + await page.waitForSelector("#interactive"); + await ensureInteractivity(page, ROOT_ID); + await ensureInteractivity(page, DEFERRED_ID); + await ensureInteractivity(page, RESOLVED_DEFERRED_ID); + + global.__deferredManualResolveCache.deferreds[id].reject( + new Error("error") + ); + + await ensureInteractivity(page, ROOT_ID, 2); + await ensureInteractivity(page, DEFERRED_ID, 2); + await ensureInteractivity(page, RESOLVED_DEFERRED_ID, 2); + await ensureInteractivity(page, MANUAL_ERROR_ID); + + await assertConsole(); + }); + + test("client transition with resolved promises work", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + let assertConsole = monitorConsole(page); + await app.goto("/"); + + await page.waitForSelector("#interactive"); + await ensureInteractivity(page, ROOT_ID); + await ensureInteractivity(page, INDEX_ID); + + await app.clickLink("/deferred-script-resolved"); + + await ensureInteractivity(page, ROOT_ID, 2); + await ensureInteractivity(page, DEFERRED_ID); + await ensureInteractivity(page, RESOLVED_DEFERRED_ID); + + await assertConsole(); + }); + + test("client transition with unresolved promises work", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + let assertConsole = monitorConsole(page); + await app.goto("/"); + + await page.waitForSelector("#interactive"); + await ensureInteractivity(page, ROOT_ID); + await ensureInteractivity(page, INDEX_ID); + + await app.clickLink("/deferred-script-unresolved"); + + await ensureInteractivity(page, ROOT_ID, 2); + await ensureInteractivity(page, DEFERRED_ID); + await ensureInteractivity(page, RESOLVED_DEFERRED_ID); + + await assertConsole(); + }); + + test("client transition with rejected promises work", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + let assertConsole = monitorConsole(page); + await app.goto("/"); + + await page.waitForSelector("#interactive"); + await ensureInteractivity(page, ROOT_ID); + await ensureInteractivity(page, INDEX_ID); + + app.clickLink("/deferred-script-rejected"); + + await ensureInteractivity(page, DEFERRED_ID); + await ensureInteractivity(page, ERROR_ID); + await ensureInteractivity(page, DEFERRED_ID, 2); + await ensureInteractivity(page, ROOT_ID, 2); + + await assertConsole(); + }); + + test("client transition with unrejected promises work", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + let assertConsole = monitorConsole(page); + await app.goto("/"); + + await page.waitForSelector("#interactive"); + await ensureInteractivity(page, ROOT_ID); + await ensureInteractivity(page, INDEX_ID); + + await app.clickLink("/deferred-script-unrejected"); + + await ensureInteractivity(page, DEFERRED_ID); + await ensureInteractivity(page, ERROR_ID); + await ensureInteractivity(page, DEFERRED_ID, 2); + await ensureInteractivity(page, ROOT_ID, 2); + + await assertConsole(); + }); + + test("client transition with rejected promises bubble to ErrorBoundary", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + + await page.waitForSelector("#interactive"); + await ensureInteractivity(page, ROOT_ID); + await ensureInteractivity(page, INDEX_ID); + + await app.clickLink("/deferred-script-rejected-no-error-element"); + + await ensureInteractivity(page, ERROR_BOUNDARY_ID); + await ensureInteractivity(page, ROOT_ID, 2); + }); + + test("client transition with unrejected promises bubble to ErrorBoundary", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + + await page.waitForSelector("#interactive"); + await ensureInteractivity(page, ROOT_ID); + await ensureInteractivity(page, INDEX_ID); + + await app.clickLink("/deferred-script-unrejected-no-error-element"); + + await ensureInteractivity(page, ERROR_BOUNDARY_ID); + await ensureInteractivity(page, ROOT_ID, 2); + }); + }); + + test.describe("aborted", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + test.beforeEach(async ({ context }) => { + await context.route(/\.data$/, async (route) => { + await new Promise((resolve) => setTimeout(resolve, 50)); + route.continue(); + }); + }); + + test.beforeAll(async () => { + fixture = await createFixture({ + turboV3: true, + files: { + "app/entry.server.tsx": js` + import { PassThrough } from "node:stream"; + import type { AppLoadContext, EntryContext } from "react-router"; + import { createReadableStreamFromReadable } from "@react-router/node"; + import { ServerRouter } from "react-router"; + import { isbot } from "isbot"; + import { renderToPipeableStream } from "react-dom/server"; + + // Exported for use by the server runtime so we can abort the + // turbo-stream encode() call + export const streamTimeout = 250; + const renderTimeout = streamTimeout + 250; + + export default function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext, + loadContext: AppLoadContext, + ) { + return isbot(request.headers.get("user-agent") || "") + ? handleBotRequest( + request, + responseStatusCode, + responseHeaders, + remixContext + ) + : handleBrowserRequest( + request, + responseStatusCode, + responseHeaders, + remixContext + ); + } + + function handleBotRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext + ) { + return new Promise((resolve, reject) => { + let didError = false; + + let { pipe, abort } = renderToPipeableStream( + , + { + onAllReady() { + let body = new PassThrough(); + let stream = createReadableStreamFromReadable(body); + + responseHeaders.set("Content-Type", "text/html"); + + resolve( + new Response(stream, { + headers: responseHeaders, + status: didError ? 500 : responseStatusCode, + }) + ); + + pipe(body); + }, + onShellError(error: unknown) { + reject(error); + }, + onError(error: unknown) { + didError = true; + + console.error(error); + }, + } + ); + + setTimeout(abort, renderTimeout); + }); + } + + function handleBrowserRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext + ) { + return new Promise((resolve, reject) => { + let didError = false; + + let { pipe, abort } = renderToPipeableStream( + , + { + onShellReady() { + let body = new PassThrough(); + let stream = createReadableStreamFromReadable(body); + + responseHeaders.set("Content-Type", "text/html"); + + resolve( + new Response(stream, { + headers: responseHeaders, + status: didError ? 500 : responseStatusCode, + }) + ); + + pipe(body); + }, + onShellError(err: unknown) { + reject(err); + }, + onError(error: unknown) { + didError = true; + + console.error(error); + }, + } + ); + + setTimeout(abort, renderTimeout); + }); + } + `, + "app/components/counter.tsx": js` + import { useState } from "react"; + + export default function Counter({ id }) { + let [count, setCount] = useState(0); + return ( +
+ +

{count}

+
+ ) + } + `, + "app/components/interactive.tsx": js` + import { useEffect, useState } from "react"; + + export default function Interactive() { + let [interactive, setInteractive] = useState(false); + useEffect(() => { + setInteractive(true); + }, []); + return interactive ? ( +
+

interactive

+
+ ) : null; + } + `, + "app/root.tsx": js` + import { Links, Meta, Outlet, Scripts, useLoaderData } from "react-router"; + import Counter from "~/components/counter"; + import Interactive from "~/components/interactive"; + + export const meta: MetaFunction = () => { + return [{ title: "New Remix App" }]; + }; + + export const loader = () => ({ + id: "${ROOT_ID}", + }); + + export default function Root() { + let { id } = useLoaderData(); + return ( + + + + + + + + +
+

{id}

+ + + +
+ + {/* Send arbitrary data so safari renders the initial shell before + the document finishes downloading. */} + {Array(6000).fill(null).map((_, i)=>

YOOOOOOOOOO {i}

)} + + + ); + } + `, + + "app/routes/deferred-server-aborted.tsx": js` + import { Suspense } from "react"; + import { Await, Link, useLoaderData } from "react-router"; + import Counter from "~/components/counter"; + + export function loader() { + return { + deferredId: "${DEFERRED_ID}", + resolvedId: new Promise( + (resolve) => setTimeout(() => { + resolve("${RESOLVED_DEFERRED_ID}"); + }, 10000) + ), + }; + } + + export default function Deferred() { + let { deferredId, resolvedId } = useLoaderData(); + return ( +
+

{deferredId}

+ + fallback
}> + + error + +
+ } + children={(resolvedDeferredId) => ( +
+

{resolvedDeferredId}

+ +
+ )} + /> + +
+ ); + } + `, + + "app/routes/deferred-server-aborted-no-error-element.tsx": js` + import { Suspense } from "react"; + import { Await, Link, useLoaderData } from "react-router"; + import Counter from "~/components/counter"; + + export function loader() { + return { + deferredId: "${DEFERRED_ID}", + resolvedId: new Promise( + (resolve) => setTimeout(() => { + resolve("${RESOLVED_DEFERRED_ID}"); + }, 10000) + ), + }; + } + + export default function Deferred() { + let { deferredId, resolvedId } = useLoaderData(); + return ( +
+

{deferredId}

+ + fallback
}> + ( +
+

{resolvedDeferredId}

+ +
+ )} + /> + +
+ ); + } + + export function ErrorBoundary() { + return ( +
+ error + +
+ ); + } + `, + }, + }); + + // This creates an interactive app using playwright. + appFixture = await createAppFixture(fixture); + + originalConsoleError = console.error; + console.error = () => {}; + }); + + test.afterAll(() => { + console.error = originalConsoleError; + appFixture.close(); + }); + + test("server aborts render the errorElement", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/deferred-server-aborted"); + await page.waitForSelector(`#${ROOT_ID}`); + await page.waitForSelector(`#${DEFERRED_ID}`); + await page.waitForSelector(`#${ERROR_ID}`); + + await ensureInteractivity(page, ROOT_ID); + await ensureInteractivity(page, DEFERRED_ID); + await ensureInteractivity(page, ERROR_ID); + }); + + test("server aborts render the ErrorBoundary when no errorElement", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/deferred-server-aborted-no-error-element"); + await page.waitForSelector(`#${ROOT_ID}`); + await page.waitForSelector(`#${ERROR_BOUNDARY_ID}`); + + await ensureInteractivity(page, ROOT_ID); + await ensureInteractivity(page, ERROR_BOUNDARY_ID); + }); }); }); diff --git a/integration/error-boundary-v2-test.ts b/integration/error-boundary-v2-test.ts index c281aa76af..82af370aea 100644 --- a/integration/error-boundary-v2-test.ts +++ b/integration/error-boundary-v2-test.ts @@ -186,13 +186,251 @@ test.describe("ErrorBoundary", () => { // Remix server, and ensure we properly handle it at the ErrorBoundary await app.goto("/parent?error"); await app.clickLink("/parent/child-with-boundary"); + await waitForAndAssert(page, app, "#parent-error", "500"); + }); + }); + + function runBoundaryTests() { + test("No errors", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent"); + await app.clickLink("/parent/child-with-boundary"); + await waitForAndAssert(page, app, "#child-data", "CHILD LOADER"); + }); + + test("Throwing a Response to own boundary", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent"); + await app.clickLink("/parent/child-with-boundary?type=response"); await waitForAndAssert( page, app, - "#parent-error", - "500" + "#child-error-response", + "418 Loader Response" ); }); + + test("Throwing an Error to own boundary", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent"); + await app.clickLink("/parent/child-with-boundary?type=error"); + await waitForAndAssert(page, app, "#child-error", "Loader Error"); + }); + + test("Throwing a render error to own boundary", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent"); + await app.clickLink("/parent/child-with-boundary?type=render"); + await waitForAndAssert(page, app, "#child-error", "Render Error"); + }); + + test("Throwing a Response to parent boundary", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent"); + await app.clickLink("/parent/child-without-boundary?type=response"); + await waitForAndAssert( + page, + app, + "#parent-error-response", + "418 Loader Response" + ); + }); + + test("Throwing an Error to parent boundary", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent"); + await app.clickLink("/parent/child-without-boundary?type=error"); + await waitForAndAssert(page, app, "#parent-error", "Loader Error"); + }); + + test("Throwing a render error to parent boundary", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent"); + await app.clickLink("/parent/child-without-boundary?type=render"); + await waitForAndAssert(page, app, "#parent-error", "Render Error"); + }); + } +}); + +test.describe("ErrorBoundary turboV3", () => { + let fixture: Fixture; + let appFixture: AppFixture; + let oldConsoleError: () => void; + + test.beforeAll(async () => { + fixture = await createFixture({ + turboV3: true, + files: { + "app/root.tsx": js` + import { Links, Meta, Outlet, Scripts } from "react-router"; + + export default function Root() { + return ( + + + + + + +
+ +
+ + + + ); + } + `, + + "app/routes/parent.tsx": js` + import { useEffect } from "react"; + import { + Link, + Outlet, + isRouteErrorResponse, + useLoaderData, + useRouteError, + } from "react-router"; + + export function loader({ request }) { + const url = new URL(request.url); + return {message: "PARENT LOADER", error: url.searchParams.has('error') }; + } + + export default function Component({ loaderData }) { + useEffect(() => { + let ogFetch = window.fetch; + if (loaderData.error) { + window.fetch = async (...args) => { + return new Response('CDN Error!', { status: 500 }); + }; + + return () => { + window.fetch = ogFetch; + }; + } + }, [loaderData.error]); + + return ( +
+ +

{loaderData.message}

+ +
+ ) + } + + export function ErrorBoundary() { + let error = useRouteError(); + return isRouteErrorResponse(error) ? +

{error.status + ' ' + error.data}

: +

{error.message}

; + } + `, + + "app/routes/parent.child-with-boundary.tsx": js` + import { + isRouteErrorResponse, + useLoaderData, + useLocation, + useRouteError, + } from "react-router"; + + export function loader({ request }) { + let errorType = new URL(request.url).searchParams.get('type'); + if (errorType === 'response') { + throw new Response('Loader Response', { status: 418 }); + } else if (errorType === 'error') { + throw new Error('Loader Error'); + } + return "CHILD LOADER"; + } + + export default function Component() {; + let data = useLoaderData(); + if (new URLSearchParams(useLocation().search).get('type') === "render") { + throw new Error("Render Error"); + } + return

{data}

; + } + + export function ErrorBoundary() { + let error = useRouteError(); + return isRouteErrorResponse(error) ? +

{error.status + ' ' + error.data}

: +

{error.message}

; + } + `, + + "app/routes/parent.child-without-boundary.tsx": js` + import { useLoaderData, useLocation } from "react-router"; + + export function loader({ request }) { + let errorType = new URL(request.url).searchParams.get('type'); + if (errorType === 'response') { + throw new Response('Loader Response', { status: 418 }); + } else if (errorType === 'error') { + throw new Error('Loader Error'); + } + return "CHILD LOADER"; + } + + export default function Component() {; + let data = useLoaderData(); + if (new URLSearchParams(useLocation().search).get('type') === "render") { + throw new Error("Render Error"); + } + return

{data}

; + } + `, + }, + }); + + appFixture = await createAppFixture(fixture, ServerMode.Development); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test.beforeEach(({ page }) => { + oldConsoleError = console.error; + console.error = () => {}; + }); + + test.afterEach(() => { + console.error = oldConsoleError; + }); + + test.describe("without JavaScript", () => { + test.use({ javaScriptEnabled: false }); + runBoundaryTests(); + }); + + test.describe("with JavaScript", () => { + test.use({ javaScriptEnabled: true }); + runBoundaryTests(); + + test("Network errors that never reach the Remix server", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + // Cause a .data request to trigger an HTTP error that never reaches the + // Remix server, and ensure we properly handle it at the ErrorBoundary + await app.goto("/parent?error"); + await app.clickLink("/parent/child-with-boundary"); + await waitForAndAssert(page, app, "#parent-error", "500"); + }); }); function runBoundaryTests() { diff --git a/integration/error-sanitization-test.ts b/integration/error-sanitization-test.ts index 4c6f570873..8873ad88ed 100644 --- a/integration/error-sanitization-test.ts +++ b/integration/error-sanitization-test.ts @@ -173,6 +173,592 @@ test.describe("Error Sanitization", () => { expect(html).not.toMatch(/stack/i); }); + test("sanitizes loader errors in document requests", async () => { + let response = await fixture.requestDocument("/?loader"); + let html = await response.text(); + expect(html).toMatch("Index Error"); + expect(html).not.toMatch("LOADER"); + expect(html).toMatch("MESSAGE:Unexpected Server Error"); + // This is the turbo-stream encoding - the fact that stack goes right + // into __type means it has no value + expect(html).toMatch( + '\\"message\\",\\"Unexpected Server Error\\",\\"stack\\",\\"__type\\",\\"Error\\"' + ); + expect(html).not.toMatch(/ at /i); + expect(errorLogs.length).toBe(1); + expect(errorLogs[0][0].message).toMatch("Loader Error"); + expect(errorLogs[0][0].stack).toMatch(" at "); + }); + + test("sanitizes render errors in document requests", async () => { + let response = await fixture.requestDocument("/?render"); + let html = await response.text(); + expect(html).toMatch("Index Error"); + expect(html).toMatch("MESSAGE:Unexpected Server Error"); + // This is the turbo-stream encoding - the fact that stack goes right + // into __type means it has no value + expect(html).toMatch( + '\\"message\\",\\"Unexpected Server Error\\",\\"stack\\",\\"__type\\",\\"Error\\"' + ); + expect(html).not.toMatch(/ at /i); + expect(errorLogs.length).toBe(1); + expect(errorLogs[0][0].message).toMatch("Render Error"); + expect(errorLogs[0][0].stack).toMatch(" at "); + }); + + test("renders deferred document without errors", async () => { + let response = await fixture.requestDocument("/defer"); + let html = await response.text(); + expect(html).toMatch("Defer Route"); + expect(html).toMatch("RESOLVED"); + expect(html).not.toMatch("MESSAGE:"); + // Defer errors are not not part of the JSON blob but rather rejected + // against a pending promise and therefore are inlined JS. + expect(html).not.toMatch("x.stack=e.stack;"); + }); + + test("sanitizes defer errors in document requests", async () => { + let response = await fixture.requestDocument("/defer?loader"); + let html = await response.text(); + expect(html).toMatch("Defer Error"); + expect(html).not.toMatch("RESOLVED"); + expect(html).toMatch("Unexpected Server Error"); + expect(html).not.toMatch("stack"); + // defer errors are not logged to the server console since the request + // has "succeeded" + expect(errorLogs.length).toBe(0); + }); + + test("returns data without errors", async () => { + let { data } = await fixture.requestSingleFetchData("/_root.data"); + expect(data).toEqual({ + root: { + data: null, + }, + "routes/_index": { + data: "LOADER", + }, + }); + }); + + test("sanitizes loader errors in data requests", async () => { + let { data } = await fixture.requestSingleFetchData("/_root.data?loader"); + expect(data).toEqual({ + root: { + data: null, + }, + "routes/_index": { + error: new Error("Unexpected Server Error"), + }, + }); + expect(errorLogs.length).toBe(1); + expect(errorLogs[0][0].message).toMatch("Loader Error"); + expect(errorLogs[0][0].stack).toMatch(" at "); + }); + + test("returns deferred data without errors", async () => { + let { data } = await fixture.requestSingleFetchData("/defer.data"); + // @ts-expect-error + expect(await data["routes/defer"].data.lazy).toEqual("RESOLVED"); + }); + + test("sanitizes loader errors in deferred data requests", async () => { + let { data } = await fixture.requestSingleFetchData("/defer.data?loader"); + try { + // @ts-expect-error + await data["routes/defer"].data.lazy; + expect(true).toBe(false); + } catch (e) { + expect((e as Error).message).toBe("Unexpected Server Error"); + expect((e as Error).stack).toBeUndefined(); + } + // defer errors are not logged to the server console since the request + // has "succeeded" + expect(errorLogs.length).toBe(0); + }); + + test("sanitizes loader errors in resource requests", async () => { + let response = await fixture.requestResource("/resource?loader"); + let text = await response.text(); + expect(text).toBe("Unexpected Server Error"); + expect(errorLogs.length).toBe(1); + expect(errorLogs[0][0].message).toMatch("Loader Error"); + expect(errorLogs[0][0].stack).toMatch(" at "); + }); + + test("does not sanitize mismatched route errors in data requests", async () => { + let { data } = await fixture.requestSingleFetchData("/not-a-route.data"); + expect(data).toEqual({ + root: { + error: new ErrorResponseImpl( + 404, + "Not Found", + 'Error: No route matches URL "/not-a-route"' + ), + }, + }); + expect(errorLogs).toEqual([ + [new Error('No route matches URL "/not-a-route"')], + ]); + }); + + test("does not support hydration of Error subclasses", async ({ page }) => { + let response = await fixture.requestDocument("/?subclass"); + let html = await response.text(); + expect(html).toMatch("

MESSAGE:Unexpected Server Error"); + expect(html).toMatch("

NAME:Error"); + + // Hydration + let appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/?subclass", true); + html = await app.getHtml(); + expect(html).toMatch("

MESSAGE:Unexpected Server Error"); + expect(html).toMatch("

NAME:Error"); + }); + }); + + test.describe("serverMode=development", () => { + test.beforeAll(async () => { + fixture = await createFixture( + { + files: routeFiles, + }, + ServerMode.Development + ); + }); + let ogEnv = process.env.NODE_ENV; + test.beforeEach(() => { + ogEnv = process.env.NODE_ENV; + process.env.NODE_ENV = "development"; + }); + test.afterEach(() => { + process.env.NODE_ENV = ogEnv; + }); + + test("renders document without errors", async () => { + let response = await fixture.requestDocument("/"); + let html = await response.text(); + expect(html).toMatch("Index Route"); + expect(html).toMatch("LOADER"); + expect(html).not.toMatch("MESSAGE:"); + expect(html).not.toMatch(/stack/i); + }); + + test("does not sanitize loader errors in document requests", async () => { + let response = await fixture.requestDocument("/?loader"); + let html = await response.text(); + expect(html).toMatch("Index Error"); + expect(html).not.toMatch("LOADER"); + expect(html).toMatch("

MESSAGE:Loader Error"); + expect(html).toMatch("

STACK:Error: Loader Error"); + expect(errorLogs.length).toBe(1); + expect(errorLogs[0][0].message).toMatch("Loader Error"); + expect(errorLogs[0][0].stack).toMatch(" at "); + }); + + test("does not sanitize render errors in document requests", async () => { + let response = await fixture.requestDocument("/?render"); + let html = await response.text(); + expect(html).toMatch("Index Error"); + expect(html).toMatch("

MESSAGE:Render Error"); + expect(html).toMatch("

STACK:Error: Render Error"); + expect(errorLogs.length).toBe(1); + expect(errorLogs[0][0].message).toMatch("Render Error"); + expect(errorLogs[0][0].stack).toMatch(" at "); + }); + + test("renders deferred document without errors", async () => { + let response = await fixture.requestDocument("/defer"); + let html = await response.text(); + expect(html).toMatch("Defer Route"); + expect(html).toMatch("RESOLVED"); + expect(html).not.toMatch("MESSAGE:"); + expect(html).not.toMatch(/"stack":/i); + }); + + test("does not sanitize defer errors in document requests", async () => { + let response = await fixture.requestDocument("/defer?loader"); + let html = await response.text(); + expect(html).toMatch("Defer Error"); + expect(html).not.toMatch("RESOLVED"); + expect(html).toMatch("

REJECTED

"); + expect(html).toMatch("Error: REJECTED\\\\n at "); + // defer errors are not logged to the server console since the request + // has "succeeded" + expect(errorLogs.length).toBe(0); + }); + + test("returns data without errors", async () => { + let { data } = await fixture.requestSingleFetchData("/_root.data"); + expect(data).toEqual({ + root: { + data: null, + }, + "routes/_index": { + data: "LOADER", + }, + }); + }); + + test("does not sanitize loader errors in data requests", async () => { + let { data } = await fixture.requestSingleFetchData("/_root.data?loader"); + expect(data).toEqual({ + root: { + data: null, + }, + "routes/_index": { + error: new Error("Loader Error"), + }, + }); + expect(errorLogs.length).toBe(1); + expect(errorLogs[0][0].message).toMatch("Loader Error"); + expect(errorLogs[0][0].stack).toMatch(" at "); + }); + + test("returns deferred data without errors", async () => { + let { data } = await fixture.requestSingleFetchData("/defer.data"); + // @ts-expect-error + expect(await data["routes/defer"].data.lazy).toEqual("RESOLVED"); + }); + + test("does not sanitize loader errors in deferred data requests", async () => { + let { data } = await fixture.requestSingleFetchData("/defer.data?loader"); + try { + // @ts-expect-error + await data["routes/defer"].data.lazy; + expect(true).toBe(false); + } catch (e) { + expect((e as Error).message).toBe("REJECTED"); + expect((e as Error).stack).not.toBeUndefined(); + } + + // defer errors are not logged to the server console since the request + // has "succeeded" + expect(errorLogs.length).toBe(0); + }); + + test("does not sanitize loader errors in resource requests", async () => { + let response = await fixture.requestResource("/resource?loader"); + let text = await response.text(); + expect(text).toBe("Unexpected Server Error\n\nError: Loader Error"); + expect(errorLogs.length).toBe(1); + expect(errorLogs[0][0].message).toMatch("Loader Error"); + expect(errorLogs[0][0].stack).toMatch(" at "); + }); + + test("does not sanitize mismatched route errors in data requests", async () => { + let { data } = await fixture.requestSingleFetchData("/not-a-route.data"); + expect(data).toEqual({ + root: { + error: new ErrorResponseImpl( + 404, + "Not Found", + 'Error: No route matches URL "/not-a-route"' + ), + }, + }); + expect(errorLogs).toEqual([ + [new Error('No route matches URL "/not-a-route"')], + ]); + }); + + test("supports hydration of Error subclasses", async ({ page }) => { + let response = await fixture.requestDocument("/?subclass"); + let html = await response.text(); + expect(html).toMatch("

MESSAGE:thisisnotathing is not defined"); + expect(html).toMatch("

NAME:ReferenceError"); + expect(html).toMatch( + "

STACK:ReferenceError: thisisnotathing is not defined" + ); + + // Hydration + let appFixture = await createAppFixture(fixture, ServerMode.Development); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/?subclass", true); + html = await app.getHtml(); + expect(html).toMatch("

MESSAGE:thisisnotathing is not defined"); + expect(html).toMatch("

NAME:ReferenceError"); + expect(html).toMatch( + "STACK:ReferenceError: thisisnotathing is not defined" + ); + }); + }); + + test.describe("serverMode=production (user-provided handleError)", () => { + test.beforeAll(async () => { + fixture = await createFixture( + { + files: { + "app/entry.server.tsx": js` + import { PassThrough } from "node:stream"; + + import { createReadableStreamFromReadable } from "@react-router/node"; + import { ServerRouter, isRouteErrorResponse } from "react-router"; + import { renderToPipeableStream } from "react-dom/server"; + + export default function handleRequest( + request, + responseStatusCode, + responseHeaders, + remixContext + ) { + return new Promise((resolve, reject) => { + let shellRendered = false; + const { pipe, abort } = renderToPipeableStream( + , + { + onShellReady() { + shellRendered = true; + 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) { + reject(error); + }, + onError(error) { + responseStatusCode = 500; + // Log streaming rendering errors from inside the shell. Don't log + // errors encountered during initial shell rendering since they'll + // reject and get logged in handleDocumentRequest. + if (shellRendered) { + console.error(error); + } + }, + } + ); + + setTimeout(abort, 5000); + }); + } + + export function handleError( + error: unknown, + { request }: { request: Request }, + ) { + console.error("App Specific Error Logging:"); + console.error(" Request: " + request.method + " " + request.url); + if (isRouteErrorResponse(error)) { + console.error(" Status: " + error.status + " " + error.statusText); + console.error(" Error: " + error.error.message); + console.error(" Stack: " + error.error.stack); + } else if (error instanceof Error) { + console.error(" Error: " + error.message); + console.error(" Stack: " + error.stack); + } else { + console.error("Dunno what this is"); + } + } + `, + ...routeFiles, + }, + }, + ServerMode.Production + ); + }); + + test("renders document without errors", async () => { + let response = await fixture.requestDocument("/"); + let html = await response.text(); + expect(html).toMatch("Index Route"); + expect(html).toMatch("LOADER"); + expect(html).not.toMatch("MESSAGE:"); + expect(html).not.toMatch(/stack/i); + }); + + test("sanitizes loader errors in document requests", async () => { + let response = await fixture.requestDocument("/?loader"); + let html = await response.text(); + expect(html).toMatch("Index Error"); + expect(html).not.toMatch("LOADER"); + expect(html).toMatch("MESSAGE:Unexpected Server Error"); + // This is the turbo-stream encoding - the fact that stack goes right + // into __type means it has no value + expect(html).toMatch( + '\\"message\\",\\"Unexpected Server Error\\",\\"stack\\",\\"__type\\",\\"Error\\"' + ); + expect(html).not.toMatch(/ at /i); + expect(errorLogs[0][0]).toEqual("App Specific Error Logging:"); + expect(errorLogs[1][0]).toEqual(" Request: GET test://test/?loader"); + expect(errorLogs[2][0]).toEqual(" Error: Loader Error"); + expect(errorLogs[3][0]).toMatch(" at "); + expect(errorLogs.length).toBe(4); + }); + + test("sanitizes render errors in document requests", async () => { + let response = await fixture.requestDocument("/?render"); + let html = await response.text(); + expect(html).toMatch("Index Error"); + expect(html).toMatch("MESSAGE:Unexpected Server Error"); + // This is the turbo-stream encoding - the fact that stack goes right + // into __type means it has no value + expect(html).toMatch( + '\\"message\\",\\"Unexpected Server Error\\",\\"stack\\",\\"__type\\",\\"Error\\"' + ); + expect(html).not.toMatch(/ at /i); + expect(errorLogs[0][0]).toEqual("App Specific Error Logging:"); + expect(errorLogs[1][0]).toEqual(" Request: GET test://test/?render"); + expect(errorLogs[2][0]).toEqual(" Error: Render Error"); + expect(errorLogs[3][0]).toMatch(" at "); + expect(errorLogs.length).toBe(4); + }); + + test("renders deferred document without errors", async () => { + let response = await fixture.requestDocument("/defer"); + let html = await response.text(); + expect(html).toMatch("Defer Route"); + expect(html).toMatch("RESOLVED"); + expect(html).not.toMatch("MESSAGE:"); + // Defer errors are not not part of the JSON blob but rather rejected + // against a pending promise and therefore are inlined JS. + expect(html).not.toMatch("x.stack=e.stack;"); + }); + + test("sanitizes defer errors in document requests", async () => { + let response = await fixture.requestDocument("/defer?loader"); + let html = await response.text(); + expect(html).toMatch("Defer Error"); + expect(html).not.toMatch("RESOLVED"); + expect(html).toMatch("Unexpected Server Error"); + expect(html).not.toMatch("stack"); + // defer errors are not logged to the server console since the request + // has "succeeded" + expect(errorLogs.length).toBe(0); + }); + + test("returns data without errors", async () => { + let { data } = await fixture.requestSingleFetchData("/_root.data"); + expect(data).toEqual({ + root: { + data: null, + }, + "routes/_index": { + data: "LOADER", + }, + }); + }); + + test("sanitizes loader errors in data requests", async () => { + let { data } = await fixture.requestSingleFetchData("/_root.data?loader"); + expect(data).toEqual({ + root: { data: null }, + "routes/_index": { error: new Error("Unexpected Server Error") }, + }); + expect(errorLogs[0][0]).toEqual("App Specific Error Logging:"); + expect(errorLogs[1][0]).toEqual( + " Request: GET test://test/_root.data?loader" + ); + expect(errorLogs[2][0]).toEqual(" Error: Loader Error"); + expect(errorLogs[3][0]).toMatch(" at "); + expect(errorLogs.length).toBe(4); + }); + + test("returns deferred data without errors", async () => { + let { data } = await fixture.requestSingleFetchData("/defer.data"); + // @ts-expect-error + expect(await data["routes/defer"].data.lazy).toBe("RESOLVED"); + }); + + test("sanitizes loader errors in deferred data requests", async () => { + let { data } = await fixture.requestSingleFetchData("/defer.data?loader"); + try { + // @ts-expect-error + await data["routes/defer"].data.lazy; + expect(true).toBe(false); + } catch (e) { + expect((e as Error).message).toBe("Unexpected Server Error"); + expect((e as Error).stack).toBeUndefined(); + } + // defer errors are not logged to the server console since the request + // has "succeeded" + expect(errorLogs.length).toBe(0); + }); + + test("sanitizes loader errors in resource requests", async () => { + let response = await fixture.requestResource("/resource?loader"); + let text = await response.text(); + expect(text).toBe("Unexpected Server Error"); + expect(errorLogs[0][0]).toEqual("App Specific Error Logging:"); + expect(errorLogs[0][0]).toEqual("App Specific Error Logging:"); + expect(errorLogs[1][0]).toEqual( + " Request: GET test://test/resource?loader" + ); + expect(errorLogs[2][0]).toEqual(" Error: Loader Error"); + expect(errorLogs[3][0]).toMatch(" at "); + expect(errorLogs.length).toBe(4); + }); + + test("does not sanitize mismatched route errors in data requests", async () => { + let { data } = await fixture.requestSingleFetchData("/not-a-route.data"); + expect(data).toEqual({ + root: { + error: new ErrorResponseImpl( + 404, + "Not Found", + 'Error: No route matches URL "/not-a-route"' + ), + }, + }); + expect(errorLogs[0][0]).toEqual("App Specific Error Logging:"); + expect(errorLogs[1][0]).toEqual( + " Request: GET test://test/not-a-route.data" + ); + expect(errorLogs[2][0]).toEqual(" Status: 404 Not Found"); + expect(errorLogs[3][0]).toEqual( + ' Error: No route matches URL "/not-a-route"' + ); + expect(errorLogs[4][0]).toMatch(" at "); + expect(errorLogs.length).toBe(5); + }); + }); +}); + +test.describe("Error Sanitization turboV3", () => { + let fixture: Fixture; + let oldConsoleError: () => void; + let errorLogs: any[] = []; + + test.beforeEach(() => { + oldConsoleError = console.error; + errorLogs = []; + console.error = (...args) => errorLogs.push(args); + }); + + test.afterEach(() => { + console.error = oldConsoleError; + }); + + test.describe("serverMode=production", () => { + test.beforeAll(async () => { + fixture = await createFixture( + { + turboV3: true, + files: routeFiles, + }, + ServerMode.Production + ); + }); + + test("renders document without errors", async () => { + let response = await fixture.requestDocument("/"); + let html = await response.text(); + expect(html).toMatch("Index Route"); + expect(html).toMatch("LOADER"); + expect(html).not.toMatch("MESSAGE:"); + expect(html).not.toMatch(/stack/i); + }); + test("sanitizes loader errors in document requests", async () => { let response = await fixture.requestDocument("/?loader"); let html = await response.text(); @@ -322,6 +908,7 @@ test.describe("Error Sanitization", () => { test.beforeAll(async () => { fixture = await createFixture( { + turboV3: true, files: routeFiles, }, ServerMode.Development @@ -489,6 +1076,7 @@ test.describe("Error Sanitization", () => { test.beforeAll(async () => { fixture = await createFixture( { + turboV3: true, files: { "app/entry.server.tsx": js` import { PassThrough } from "node:stream"; diff --git a/integration/helpers/create-fixture.ts b/integration/helpers/create-fixture.ts index aa389beaea..ec0e2e63e6 100644 --- a/integration/helpers/create-fixture.ts +++ b/integration/helpers/create-fixture.ts @@ -31,6 +31,7 @@ export interface FixtureInit { spaMode?: boolean; prerender?: boolean; port?: number; + turboV3?: boolean; } export type Fixture = Awaited>; @@ -49,6 +50,8 @@ export async function createFixture(init: FixtureInit, mode?: ServerMode) { path.join(projectDir, "build/server/index.js") ).href; + const turboV3 = init.turboV3 ?? false; + let getBrowserAsset = async (asset: string) => { return fse.readFile( path.join(projectDir, "public", asset.replace(/^\//, "")), @@ -118,7 +121,7 @@ export async function createFixture(init: FixtureInit, mode?: ServerMode) { status: 200, statusText: "OK", headers: new Headers(), - data: await decodeViaTurboStream(stream, global), + data: await decodeViaTurboStream(stream, global, turboV3), }; }, postDocument: () => { @@ -160,7 +163,7 @@ export async function createFixture(init: FixtureInit, mode?: ServerMode) { statusText: response.statusText, headers: response.headers, data: response.body - ? await decodeViaTurboStream(response.body!, global) + ? await decodeViaTurboStream(response.body!, global, turboV3) : null, }; }; @@ -385,6 +388,7 @@ export async function createFixtureProject( : { "react-router.config.ts": reactRouterConfig({ ssr: !spaMode, + turboV3: init.turboV3, }), }), ...init.files, diff --git a/integration/helpers/vite.ts b/integration/helpers/vite.ts index 3c0d41b174..43bc5fef84 100644 --- a/integration/helpers/vite.ts +++ b/integration/helpers/vite.ts @@ -29,6 +29,7 @@ export const reactRouterConfig = ({ prerender, appDirectory, splitRouteModules, + turboV3, }: { ssr?: boolean; basename?: string; @@ -37,6 +38,7 @@ export const reactRouterConfig = ({ splitRouteModules?: NonNullable< Config["future"] >["unstable_splitRouteModules"]; + turboV3?: boolean; }) => { let config: Config = { ssr, @@ -44,6 +46,7 @@ export const reactRouterConfig = ({ prerender, appDirectory, future: { + turboV3, unstable_splitRouteModules: splitRouteModules, }, }; diff --git a/packages/react-router-dev/config/config.ts b/packages/react-router-dev/config/config.ts index 00210c2fae..eb307bd222 100644 --- a/packages/react-router-dev/config/config.ts +++ b/packages/react-router-dev/config/config.ts @@ -84,6 +84,7 @@ type ServerBundlesBuildManifest = BaseBuildManifest & { type ServerModuleFormat = "esm" | "cjs"; interface FutureConfig { + turboV3: boolean; unstable_optimizeDeps: boolean; /** * Automatically split route modules into multiple chunks when possible. @@ -484,6 +485,7 @@ async function resolveConfig({ } let future: FutureConfig = { + turboV3: reactRouterUserConfig.future?.turboV3 ?? false, unstable_optimizeDeps: reactRouterUserConfig.future?.unstable_optimizeDeps ?? false, unstable_splitRouteModules: diff --git a/packages/react-router/lib/dom-export/hydrated-router.tsx b/packages/react-router/lib/dom-export/hydrated-router.tsx index 675291f204..1d14037700 100644 --- a/packages/react-router/lib/dom-export/hydrated-router.tsx +++ b/packages/react-router/lib/dom-export/hydrated-router.tsx @@ -81,7 +81,11 @@ function createHydratedRouter(): DataRouter { let stream = ssrInfo.context.stream; invariant(stream, "No stream found for single fetch decoding"); ssrInfo.context.stream = undefined; - ssrInfo.stateDecodingPromise = decodeViaTurboStream(stream, window) + ssrInfo.stateDecodingPromise = decodeViaTurboStream( + stream, + window, + ssrInfo.context.future.turboV3 ?? false + ) .then((value) => { ssrInfo!.context.state = value as typeof localSsrInfo.context.state; localSsrInfo.stateDecodingPromise!.value = true; @@ -170,7 +174,8 @@ function createHydratedRouter(): DataRouter { dataStrategy: getSingleFetchDataStrategy( ssrInfo.manifest, ssrInfo.routeModules, - () => router + () => router, + ssrInfo.context.future.turboV3 ?? false ), patchRoutesOnNavigation: getPatchRoutesOnNavigationFunction( ssrInfo.manifest, @@ -251,6 +256,12 @@ export function HydratedRouter() { ssrInfo.context.isSpaMode ); + if (ssrInfo.context.future.turboV3) { + import("turbo-stream"); + } else { + import("../../vendor/turbo-stream-v2/turbo-stream.js"); + } + // We need to include a wrapper RemixErrorBoundary here in case the root error // boundary also throws and we need to bubble up outside of the router entirely. // Then we need a stateful location here so the user can back-button navigate diff --git a/packages/react-router/lib/dom/ssr/entry.ts b/packages/react-router/lib/dom/ssr/entry.ts index 527280ecb7..d0be2f3d92 100644 --- a/packages/react-router/lib/dom/ssr/entry.ts +++ b/packages/react-router/lib/dom/ssr/entry.ts @@ -43,7 +43,9 @@ export interface EntryContext extends FrameworkContextObject { serverHandoffStream?: ReadableStream; } -export interface FutureConfig {} +export interface FutureConfig { + turboV3?: boolean; +} export interface AssetsManifest { entry: { diff --git a/packages/react-router/lib/dom/ssr/single-fetch.tsx b/packages/react-router/lib/dom/ssr/single-fetch.tsx index f4c60a90a2..1214638fed 100644 --- a/packages/react-router/lib/dom/ssr/single-fetch.tsx +++ b/packages/react-router/lib/dom/ssr/single-fetch.tsx @@ -1,5 +1,4 @@ import * as React from "react"; -import { decode } from "turbo-stream"; import type { Router as DataRouter } from "../../router/router"; import { isResponse } from "../../router/router"; import type { @@ -134,17 +133,18 @@ export function StreamTransfer({ export function getSingleFetchDataStrategy( manifest: AssetsManifest, routeModules: RouteModules, - getRouter: () => DataRouter + getRouter: () => DataRouter, + turboV3: boolean ): DataStrategyFunction { return async ({ request, matches, fetcherKey }) => { // Actions are simple and behave the same for navigations and fetchers if (request.method !== "GET") { - return singleFetchActionStrategy(request, matches); + return singleFetchActionStrategy(request, matches, turboV3); } // Fetcher loads are singular calls to one loader if (fetcherKey) { - return singleFetchLoaderFetcherStrategy(request, matches); + return singleFetchLoaderFetcherStrategy(request, matches, turboV3); } // Navigational loads are more complex... @@ -153,7 +153,8 @@ export function getSingleFetchDataStrategy( routeModules, getRouter(), request, - matches + matches, + turboV3 ); }; } @@ -162,7 +163,8 @@ export function getSingleFetchDataStrategy( // navigations and fetchers) async function singleFetchActionStrategy( request: Request, - matches: DataStrategyFunctionArgs["matches"] + matches: DataStrategyFunctionArgs["matches"], + turboV3: boolean ) { let actionMatch = matches.find((m) => m.shouldLoad); invariant(actionMatch, "No action match found"); @@ -171,7 +173,7 @@ async function singleFetchActionStrategy( let result = await handler(async () => { let url = singleFetchUrl(request.url); let init = await createRequestInit(request); - let { data, status } = await fetchAndDecode(url, init); + let { data, status } = await fetchAndDecode(url, init, turboV3); actionStatus = status; return unwrapSingleFetchResult( data as SingleFetchResult, @@ -202,7 +204,8 @@ async function singleFetchLoaderNavigationStrategy( routeModules: RouteModules, router: DataRouter, request: Request, - matches: DataStrategyFunctionArgs["matches"] + matches: DataStrategyFunctionArgs["matches"], + turboV3: boolean ) { // Track which routes need a server load - in case we need to tack on a // `_routes` param @@ -269,7 +272,8 @@ async function singleFetchLoaderNavigationStrategy( handler, url, init, - m.route.id + m.route.id, + turboV3 ); results[m.route.id] = { type: "data", result }; } catch (e) { @@ -333,7 +337,7 @@ async function singleFetchLoaderNavigationStrategy( ); } - let data = await fetchAndDecode(url, init); + let data = await fetchAndDecode(url, init, turboV3); singleFetchDfd.resolve(data.data as SingleFetchResults); } catch (e) { singleFetchDfd.reject(e as Error); @@ -348,14 +352,21 @@ async function singleFetchLoaderNavigationStrategy( // Fetcher loader calls are much simpler than navigational loader calls async function singleFetchLoaderFetcherStrategy( request: Request, - matches: DataStrategyFunctionArgs["matches"] + matches: DataStrategyFunctionArgs["matches"], + turboV3: boolean ) { let fetcherMatch = matches.find((m) => m.shouldLoad); invariant(fetcherMatch, "No fetcher match found"); let result = await fetcherMatch.resolve(async (handler) => { let url = stripIndexParam(singleFetchUrl(request.url)); let init = await createRequestInit(request); - return fetchSingleLoader(handler, url, init, fetcherMatch!.route.id); + return fetchSingleLoader( + handler, + url, + init, + fetcherMatch!.route.id, + turboV3 + ); }); return { [fetcherMatch.route.id]: result }; } @@ -366,12 +377,13 @@ function fetchSingleLoader( >[0], url: URL, init: RequestInit, - routeId: string + routeId: string, + turboV3: boolean ) { return handler(async () => { let singleLoaderUrl = new URL(url); singleLoaderUrl.searchParams.set("_routes", routeId); - let { data } = await fetchAndDecode(singleLoaderUrl, init); + let { data } = await fetchAndDecode(singleLoaderUrl, init, turboV3); return unwrapSingleFetchResults(data as SingleFetchResults, routeId); }); } @@ -416,7 +428,8 @@ export function singleFetchUrl(reqUrl: URL | string) { async function fetchAndDecode( url: URL, - init: RequestInit + init: RequestInit, + turboV3: boolean ): Promise<{ status: number; data: unknown }> { let res = await fetch(url, init); @@ -445,7 +458,7 @@ async function fetchAndDecode( invariant(res.body, "No response body to decode"); try { - let decoded: any = await decodeViaTurboStream(res.body, window); + let decoded: any = await decodeViaTurboStream(res.body, window, turboV3); return { status: res.status, data: decoded }; } catch (e) { // Can't clone after consuming the body via turbo-stream so we can't @@ -460,13 +473,60 @@ async function fetchAndDecode( // Note: If you change this function please change the corresponding // encodeViaTurboStream function in server-runtime -export function decodeViaTurboStream( +export async function decodeViaTurboStream( body: ReadableStream, - global: Window | typeof globalThis + global: Window | typeof globalThis, + turboV3: boolean ) { - return decode(body.pipeThrough(new TextDecoderStream()), { + if (turboV3) { + const { decode } = await import("turbo-stream"); + return decode(body.pipeThrough(new TextDecoderStream()), { + plugins: [ + (type: string, ...rest: unknown[]) => { + if (type === "ErrorResponse") { + let [data, status, statusText] = rest as [ + unknown, + number, + string | undefined + ]; + return { + value: new ErrorResponseImpl(status, statusText, data), + }; + } + + if (type === "SingleFetchRedirect") { + return { value: { [SingleFetchRedirectSymbol]: rest[0] } }; + } + }, + ], + }); + } + + const { decode } = await import( + "../../../vendor/turbo-stream-v2/turbo-stream.js" + ); + return decode(body, { plugins: [ (type: string, ...rest: unknown[]) => { + // Decode Errors back into Error instances using the right type and with + // the right (potentially undefined) stacktrace + if (type === "SanitizedError") { + let [name, message, stack] = rest as [ + string, + string, + string | undefined + ]; + let Constructor = Error; + // @ts-expect-error + if (name && name in global && typeof global[name] === "function") { + // @ts-expect-error + Constructor = global[name]; + } + let error = new Constructor(message); + error.stack = stack; + return { value: error }; + } + if (type === "ErrorResponse") { let [data, status, statusText] = rest as [ unknown, @@ -481,9 +541,17 @@ export function decodeViaTurboStream( if (type === "SingleFetchRedirect") { return { value: { [SingleFetchRedirectSymbol]: rest[0] } }; } + + if (type === "SingleFetchClassInstance") { + return { value: rest[0] }; + } + + if (type === "SingleFetchFallback") { + return { value: undefined }; + } }, ], - }); + }).then((res) => res.value); } function unwrapSingleFetchResults( diff --git a/packages/react-router/lib/server-runtime/routes.ts b/packages/react-router/lib/server-runtime/routes.ts index d0ed81c57a..9ed072950f 100644 --- a/packages/react-router/lib/server-runtime/routes.ts +++ b/packages/react-router/lib/server-runtime/routes.ts @@ -93,7 +93,11 @@ export function createStaticHandlerDataRoutes( controller.close(); }, }); - let decoded: any = await decodeViaTurboStream(stream, global); + let decoded: any = await decodeViaTurboStream( + stream, + global, + future.turboV3 ?? false + ); let data = decoded as SingleFetchResults; invariant( data && route.id in data, diff --git a/packages/react-router/lib/server-runtime/server.ts b/packages/react-router/lib/server-runtime/server.ts index 5f9c72cdee..45e2c1c5f3 100644 --- a/packages/react-router/lib/server-runtime/server.ts +++ b/packages/react-router/lib/server-runtime/server.ts @@ -191,7 +191,8 @@ export const createRequestHandler: CreateRequestHandlerFunction = ( result, request.signal, _build.entry.module.streamTimeout, - serverMode + serverMode, + _build.future.turboV3 ?? false ), { status: SINGLE_FETCH_REDIRECT_STATUS, @@ -328,7 +329,8 @@ async function handleSingleFetchRequest( result, request.signal, build.entry.module.streamTimeout, - serverMode + serverMode, + build.future.turboV3 ?? false ), { status: status || 200, @@ -407,7 +409,8 @@ async function handleDocumentRequest( state, request.signal, build.entry.module.streamTimeout, - serverMode + serverMode, + build.future.turboV3 ?? false ), renderMeta, future: build.future, @@ -482,7 +485,8 @@ async function handleDocumentRequest( state, request.signal, build.entry.module.streamTimeout, - serverMode + serverMode, + build.future.turboV3 ?? false ), renderMeta, }; @@ -512,8 +516,10 @@ async function handleDocumentRequest( // If we render scripts, react's render might be aborted leaving the stream transfer // open in the browser causing promises to never resolve. This will error the stream // in the browser and allow the promises to settle. - if (renderMeta.didRenderScripts){ - const script = renderMeta.nonce ? `` diff --git a/packages/react-router/lib/server-runtime/single-fetch.ts b/packages/react-router/lib/server-runtime/single-fetch.ts index 1818227315..252354ea51 100644 --- a/packages/react-router/lib/server-runtime/single-fetch.ts +++ b/packages/react-router/lib/server-runtime/single-fetch.ts @@ -24,6 +24,8 @@ import { ServerMode } from "./mode"; import { getDocumentHeaders } from "./headers"; import type { ServerBuild } from "./build"; +import { encode as encodeV2 } from "../../vendor/turbo-stream-v2/turbo-stream"; + export type { SingleFetchResult, SingleFetchResults }; export { SingleFetchRedirectSymbol }; @@ -309,7 +311,8 @@ export function encodeViaTurboStream( data: any, requestSignal: AbortSignal, streamTimeout: number | undefined, - serverMode: ServerMode + serverMode: ServerMode, + turboV3: boolean ) { let controller = new AbortController(); // How long are we willing to wait for all of the promises in `data` to resolve @@ -325,24 +328,71 @@ export function encodeViaTurboStream( ); requestSignal.addEventListener("abort", () => clearTimeout(timeoutId)); - return encode(data, { + if (turboV3) { + return encode(data, { + signal: controller.signal, + redactErrors: + serverMode === ServerMode.Development + ? false + : "Unexpected Server Error", + plugins: [ + (value) => { + if (value instanceof ErrorResponseImpl) { + let { data, status, statusText } = value; + return ["ErrorResponse", data, status, statusText]; + } + + if (SingleFetchRedirectSymbol in (value as any)) { + return [ + "SingleFetchRedirect", + (value as any)[SingleFetchRedirectSymbol], + ]; + } + }, + ], + }).pipeThrough(new TextEncoderStream()); + } + + return encodeV2(data, { signal: controller.signal, - redactErrors: - serverMode === ServerMode.Development ? false : "Unexpected Server Error", plugins: [ (value) => { + // Even though we sanitized errors on context.errors prior to responding, + // we still need to handle this for any deferred data that rejects with an + // Error - as those will not be sanitized yet + if (value instanceof Error) { + let { name, message, stack } = + serverMode === ServerMode.Production + ? sanitizeError(value, serverMode) + : value; + return ["SanitizedError", name, message, stack]; + } + if (value instanceof ErrorResponseImpl) { let { data, status, statusText } = value; return ["ErrorResponse", data, status, statusText]; } - if (SingleFetchRedirectSymbol in (value as any)) { - return [ - "SingleFetchRedirect", - (value as any)[SingleFetchRedirectSymbol], - ]; + if ( + value && + typeof value === "object" && + SingleFetchRedirectSymbol in value + ) { + return ["SingleFetchRedirect", value[SingleFetchRedirectSymbol]]; } }, ], - }).pipeThrough(new TextEncoderStream()); + postPlugins: [ + (value) => { + if (!value) return; + if (typeof value !== "object") return; + + return [ + "SingleFetchClassInstance", + Object.fromEntries(Object.entries(value)), + ]; + }, + () => ["SingleFetchFallback"], + ], + }); } diff --git a/packages/react-router/vendor/turbo-stream-v2/flatten.ts b/packages/react-router/vendor/turbo-stream-v2/flatten.ts new file mode 100644 index 0000000000..f9d2b6a525 --- /dev/null +++ b/packages/react-router/vendor/turbo-stream-v2/flatten.ts @@ -0,0 +1,223 @@ +import { + HOLE, + NAN, + NEGATIVE_INFINITY, + NEGATIVE_ZERO, + NULL, + POSITIVE_INFINITY, + UNDEFINED, + TYPE_BIGINT, + TYPE_DATE, + TYPE_ERROR, + TYPE_MAP, + TYPE_NULL_OBJECT, + TYPE_PREVIOUS_RESOLVED, + TYPE_PROMISE, + TYPE_REGEXP, + TYPE_SET, + TYPE_SYMBOL, + TYPE_URL, + type ThisEncode, +} from "./utils.js"; + +export function flatten(this: ThisEncode, input: unknown): number | [number] { + const { indices } = this; + const existing = indices.get(input); + if (existing) return [existing]; + + if (input === undefined) return UNDEFINED; + if (input === null) return NULL; + if (Number.isNaN(input)) return NAN; + if (input === Number.POSITIVE_INFINITY) return POSITIVE_INFINITY; + if (input === Number.NEGATIVE_INFINITY) return NEGATIVE_INFINITY; + if (input === 0 && 1 / input < 0) return NEGATIVE_ZERO; + + const index = this.index++; + indices.set(input, index); + stringify.call(this, input, index); + return index; +} + +function stringify(this: ThisEncode, input: unknown, index: number) { + const { deferred, plugins, postPlugins } = this; + const str = this.stringified; + + const stack: [unknown, number][] = [[input, index]]; + while (stack.length > 0) { + const [input, index] = stack.pop()!; + + const partsForObj = (obj: any) => + Object.keys(obj) + .map((k) => `"_${flatten.call(this, k)}":${flatten.call(this, obj[k])}`) + .join(","); + let error: Error | null = null; + + switch (typeof input) { + case "boolean": + case "number": + case "string": + str[index] = JSON.stringify(input); + break; + case "bigint": + str[index] = `["${TYPE_BIGINT}","${input}"]`; + break; + case "symbol": { + const keyFor = Symbol.keyFor(input); + if (!keyFor) { + error = new Error( + "Cannot encode symbol unless created with Symbol.for()" + ); + } else { + str[index] = `["${TYPE_SYMBOL}",${JSON.stringify(keyFor)}]`; + } + break; + } + case "object": { + if (!input) { + str[index] = `${NULL}`; + break; + } + + const isArray = Array.isArray(input); + let pluginHandled = false; + if (!isArray && plugins) { + for (const plugin of plugins) { + const pluginResult = plugin(input); + if (Array.isArray(pluginResult)) { + pluginHandled = true; + const [pluginIdentifier, ...rest] = pluginResult; + str[index] = `[${JSON.stringify(pluginIdentifier)}`; + if (rest.length > 0) { + str[index] += `,${rest + .map((v) => flatten.call(this, v)) + .join(",")}`; + } + str[index] += "]"; + break; + } + } + } + + if (!pluginHandled) { + let result = isArray ? "[" : "{"; + if (isArray) { + for (let i = 0; i < input.length; i++) + result += + (i ? "," : "") + + (i in input ? flatten.call(this, input[i]) : HOLE); + str[index] = `${result}]`; + } else if (input instanceof Date) { + str[index] = `["${TYPE_DATE}",${input.getTime()}]`; + } else if (input instanceof URL) { + str[index] = `["${TYPE_URL}",${JSON.stringify(input.href)}]`; + } else if (input instanceof RegExp) { + str[index] = `["${TYPE_REGEXP}",${JSON.stringify( + input.source + )},${JSON.stringify(input.flags)}]`; + } else if (input instanceof Set) { + if (input.size > 0) { + str[index] = `["${TYPE_SET}",${[...input] + .map((val) => flatten.call(this, val)) + .join(",")}]`; + } else { + str[index] = `["${TYPE_SET}"]`; + } + } else if (input instanceof Map) { + if (input.size > 0) { + str[index] = `["${TYPE_MAP}",${[...input] + .flatMap(([k, v]) => [ + flatten.call(this, k), + flatten.call(this, v), + ]) + .join(",")}]`; + } else { + str[index] = `["${TYPE_MAP}"]`; + } + } else if (input instanceof Promise) { + str[index] = `["${TYPE_PROMISE}",${index}]`; + deferred[index] = input; + } else if (input instanceof Error) { + str[index] = `["${TYPE_ERROR}",${JSON.stringify(input.message)}`; + if (input.name !== "Error") { + str[index] += `,${JSON.stringify(input.name)}`; + } + str[index] += "]"; + } else if (Object.getPrototypeOf(input) === null) { + str[index] = `["${TYPE_NULL_OBJECT}",{${partsForObj(input)}}]`; + } else if (isPlainObject(input)) { + str[index] = `{${partsForObj(input)}}`; + } else { + error = new Error("Cannot encode object with prototype"); + } + } + break; + } + default: { + const isArray = Array.isArray(input); + let pluginHandled = false; + if (!isArray && plugins) { + for (const plugin of plugins) { + const pluginResult = plugin(input); + if (Array.isArray(pluginResult)) { + pluginHandled = true; + const [pluginIdentifier, ...rest] = pluginResult; + str[index] = `[${JSON.stringify(pluginIdentifier)}`; + if (rest.length > 0) { + str[index] += `,${rest + .map((v) => flatten.call(this, v)) + .join(",")}`; + } + str[index] += "]"; + break; + } + } + } + + if (!pluginHandled) { + error = new Error("Cannot encode function or unexpected type"); + } + } + } + + if (error) { + let pluginHandled = false; + + if (postPlugins) { + for (const plugin of postPlugins) { + const pluginResult = plugin(input); + if (Array.isArray(pluginResult)) { + pluginHandled = true; + const [pluginIdentifier, ...rest] = pluginResult; + str[index] = `[${JSON.stringify(pluginIdentifier)}`; + if (rest.length > 0) { + str[index] += `,${rest + .map((v) => flatten.call(this, v)) + .join(",")}`; + } + str[index] += "]"; + break; + } + } + } + + if (!pluginHandled) { + throw error; + } + } + } +} + +const objectProtoNames = Object.getOwnPropertyNames(Object.prototype) + .sort() + .join("\0"); + +function isPlainObject( + thing: unknown +): thing is Record { + const proto = Object.getPrototypeOf(thing); + return ( + proto === Object.prototype || + proto === null || + Object.getOwnPropertyNames(proto).sort().join("\0") === objectProtoNames + ); +} diff --git a/packages/react-router/vendor/turbo-stream-v2/turbo-stream.ts b/packages/react-router/vendor/turbo-stream-v2/turbo-stream.ts new file mode 100644 index 0000000000..e8e18bc601 --- /dev/null +++ b/packages/react-router/vendor/turbo-stream-v2/turbo-stream.ts @@ -0,0 +1,280 @@ +import { flatten } from "./flatten"; +import { unflatten } from "./unflatten"; +import { + Deferred, + TYPE_ERROR, + TYPE_PREVIOUS_RESOLVED, + TYPE_PROMISE, + createLineSplittingTransform, + type DecodePlugin, + type EncodePlugin, + type ThisDecode, + type ThisEncode, +} from "./utils"; + +export type { DecodePlugin, EncodePlugin }; + +export async function decode( + readable: ReadableStream, + options?: { plugins?: DecodePlugin[] } +) { + const { plugins } = options ?? {}; + + const done = new Deferred(); + const reader = readable + .pipeThrough(createLineSplittingTransform()) + .getReader(); + + const decoder: ThisDecode = { + values: [], + hydrated: [], + deferred: {}, + plugins, + }; + + const decoded = await decodeInitial.call(decoder, reader); + + let donePromise = done.promise; + if (decoded.done) { + done.resolve(); + } else { + donePromise = decodeDeferred + .call(decoder, reader) + .then(done.resolve) + .catch((reason) => { + for (const deferred of Object.values(decoder.deferred)) { + deferred.reject(reason); + } + + done.reject(reason); + }); + } + + return { + done: donePromise.then(() => reader.closed), + value: decoded.value, + }; +} + +async function decodeInitial( + this: ThisDecode, + reader: ReadableStreamDefaultReader +) { + const read = await reader.read(); + if (!read.value) { + throw new SyntaxError(); + } + + let line: unknown; + try { + line = JSON.parse(read.value); + } catch (reason) { + throw new SyntaxError(); + } + + return { + done: read.done, + value: unflatten.call(this, line), + }; +} + +async function decodeDeferred( + this: ThisDecode, + reader: ReadableStreamDefaultReader +) { + let read = await reader.read(); + while (!read.done) { + if (!read.value) continue; + const line = read.value; + switch (line[0]) { + case TYPE_PROMISE: { + const colonIndex = line.indexOf(":"); + const deferredId = Number(line.slice(1, colonIndex)); + const deferred = this.deferred[deferredId]; + if (!deferred) { + throw new Error(`Deferred ID ${deferredId} not found in stream`); + } + const lineData = line.slice(colonIndex + 1); + let jsonLine: unknown; + try { + jsonLine = JSON.parse(lineData); + } catch (reason) { + throw new SyntaxError(); + } + + const value = unflatten.call(this, jsonLine); + deferred.resolve(value); + + break; + } + case TYPE_ERROR: { + const colonIndex = line.indexOf(":"); + const deferredId = Number(line.slice(1, colonIndex)); + const deferred = this.deferred[deferredId]; + if (!deferred) { + throw new Error(`Deferred ID ${deferredId} not found in stream`); + } + const lineData = line.slice(colonIndex + 1); + let jsonLine: unknown; + try { + jsonLine = JSON.parse(lineData); + } catch (reason) { + throw new SyntaxError(); + } + const value = unflatten.call(this, jsonLine); + deferred.reject(value); + break; + } + default: + throw new SyntaxError(); + } + read = await reader.read(); + } +} + +export function encode( + input: unknown, + options?: { + plugins?: EncodePlugin[]; + postPlugins?: EncodePlugin[]; + signal?: AbortSignal; + } +) { + const { plugins, postPlugins, signal } = options ?? {}; + + const encoder: ThisEncode = { + deferred: {}, + index: 0, + indices: new Map(), + stringified: [], + plugins, + postPlugins, + signal, + }; + const textEncoder = new TextEncoder(); + let lastSentIndex = 0; + const readable = new ReadableStream({ + async start(controller) { + const id = flatten.call(encoder, input); + if (Array.isArray(id)) { + throw new Error("This should never happen"); + } + if (id < 0) { + controller.enqueue(textEncoder.encode(`${id}\n`)); + } else { + controller.enqueue( + textEncoder.encode(`[${encoder.stringified.join(",")}]\n`) + ); + lastSentIndex = encoder.stringified.length - 1; + } + + const seenPromises = new WeakSet>(); + if (Object.keys(encoder.deferred).length) { + let raceDone!: () => void; + const racePromise = new Promise((resolve, reject) => { + raceDone = resolve as () => void; + if (signal) { + const rejectPromise = () => + reject(signal.reason || new Error("Signal was aborted.")); + if (signal.aborted) { + rejectPromise(); + } else { + signal.addEventListener("abort", (event) => { + rejectPromise(); + }); + } + } + }); + while (Object.keys(encoder.deferred).length > 0) { + for (const [deferredId, deferred] of Object.entries( + encoder.deferred + )) { + if (seenPromises.has(deferred)) continue; + seenPromises.add( + // biome-ignore lint/suspicious/noAssignInExpressions: + (encoder.deferred[Number(deferredId)] = Promise.race([ + racePromise, + deferred, + ]) + .then( + (resolved) => { + const id = flatten.call(encoder, resolved); + if (Array.isArray(id)) { + controller.enqueue( + textEncoder.encode( + `${TYPE_PROMISE}${deferredId}:[["${TYPE_PREVIOUS_RESOLVED}",${id[0]}]]\n` + ) + ); + encoder.index++; + lastSentIndex++; + } else if (id < 0) { + controller.enqueue( + textEncoder.encode( + `${TYPE_PROMISE}${deferredId}:${id}\n` + ) + ); + } else { + const values = encoder.stringified + .slice(lastSentIndex + 1) + .join(","); + controller.enqueue( + textEncoder.encode( + `${TYPE_PROMISE}${deferredId}:[${values}]\n` + ) + ); + lastSentIndex = encoder.stringified.length - 1; + } + }, + (reason) => { + if ( + !reason || + typeof reason !== "object" || + !(reason instanceof Error) + ) { + reason = new Error("An unknown error occurred"); + } + + const id = flatten.call(encoder, reason); + if (Array.isArray(id)) { + controller.enqueue( + textEncoder.encode( + `${TYPE_ERROR}${deferredId}:[["${TYPE_PREVIOUS_RESOLVED}",${id[0]}]]\n` + ) + ); + encoder.index++; + lastSentIndex++; + } else if (id < 0) { + controller.enqueue( + textEncoder.encode(`${TYPE_ERROR}${deferredId}:${id}\n`) + ); + } else { + const values = encoder.stringified + .slice(lastSentIndex + 1) + .join(","); + controller.enqueue( + textEncoder.encode( + `${TYPE_ERROR}${deferredId}:[${values}]\n` + ) + ); + lastSentIndex = encoder.stringified.length - 1; + } + } + ) + .finally(() => { + delete encoder.deferred[Number(deferredId)]; + })) + ); + } + await Promise.race(Object.values(encoder.deferred)); + } + + raceDone(); + } + await Promise.all(Object.values(encoder.deferred)); + + controller.close(); + }, + }); + + return readable; +} diff --git a/packages/react-router/vendor/turbo-stream-v2/unflatten.ts b/packages/react-router/vendor/turbo-stream-v2/unflatten.ts new file mode 100644 index 0000000000..5dbb7b2b93 --- /dev/null +++ b/packages/react-router/vendor/turbo-stream-v2/unflatten.ts @@ -0,0 +1,275 @@ +import { + Deferred, + HOLE, + NAN, + NEGATIVE_INFINITY, + NEGATIVE_ZERO, + NULL, + POSITIVE_INFINITY, + UNDEFINED, + TYPE_BIGINT, + TYPE_DATE, + TYPE_ERROR, + TYPE_MAP, + TYPE_NULL_OBJECT, + TYPE_PREVIOUS_RESOLVED, + TYPE_PROMISE, + TYPE_REGEXP, + TYPE_SET, + TYPE_SYMBOL, + TYPE_URL, + type ThisDecode, +} from "./utils.js"; + +const globalObj = ( + typeof window !== "undefined" + ? window + : typeof globalThis !== "undefined" + ? globalThis + : undefined +) as Record | undefined; + +export function unflatten(this: ThisDecode, parsed: unknown): unknown { + const { hydrated, values } = this; + if (typeof parsed === "number") return hydrate.call(this, parsed); + + if (!Array.isArray(parsed) || !parsed.length) throw new SyntaxError(); + + const startIndex = values.length; + for (const value of parsed) { + values.push(value); + } + hydrated.length = values.length; + + return hydrate.call(this, startIndex); +} + +function hydrate(this: ThisDecode, index: number): any { + const { hydrated, values, deferred, plugins } = this; + + let result: unknown; + const stack = [ + [ + index, + (v: unknown) => { + result = v; + }, + ] as const, + ]; + + let postRun: Array<() => void> = []; + + while (stack.length > 0) { + const [index, set] = stack.pop()!; + + switch (index) { + case UNDEFINED: + set(undefined); + continue; + case NULL: + set(null); + continue; + case NAN: + set(NaN); + continue; + case POSITIVE_INFINITY: + set(Infinity); + continue; + case NEGATIVE_INFINITY: + set(-Infinity); + continue; + case NEGATIVE_ZERO: + set(-0); + continue; + } + + if (hydrated[index]) { + set(hydrated[index]); + continue; + } + + const value = values[index]; + if (!value || typeof value !== "object") { + hydrated[index] = value; + set(value); + continue; + } + + if (Array.isArray(value)) { + if (typeof value[0] === "string") { + const [type, b, c] = value; + switch (type) { + case TYPE_DATE: + set((hydrated[index] = new Date(b))); + continue; + case TYPE_URL: + set((hydrated[index] = new URL(b))); + continue; + case TYPE_BIGINT: + set((hydrated[index] = BigInt(b))); + continue; + case TYPE_REGEXP: + set((hydrated[index] = new RegExp(b, c))); + continue; + case TYPE_SYMBOL: + set((hydrated[index] = Symbol.for(b))); + continue; + case TYPE_SET: + const newSet = new Set(); + hydrated[index] = newSet; + for (let i = 1; i < value.length; i++) + stack.push([ + value[i], + (v) => { + newSet.add(v); + }, + ]); + set(newSet); + continue; + case TYPE_MAP: + const map = new Map(); + hydrated[index] = map; + for (let i = 1; i < value.length; i += 2) { + const r: any[] = []; + stack.push([ + value[i + 1], + (v) => { + r[1] = v; + }, + ]); + stack.push([ + value[i], + (k) => { + r[0] = k; + }, + ]); + postRun.push(() => { + map.set(r[0], r[1]); + }); + } + set(map); + continue; + case TYPE_NULL_OBJECT: + const obj = Object.create(null); + hydrated[index] = obj; + for (const key of Object.keys(b).reverse()) { + const r: any[] = []; + stack.push([ + b[key], + (v) => { + r[1] = v; + }, + ]); + stack.push([ + Number(key.slice(1)), + (k) => { + r[0] = k; + }, + ]); + postRun.push(() => { + obj[r[0]] = r[1]; + }); + } + set(obj); + continue; + case TYPE_PROMISE: + if (hydrated[b]) { + set((hydrated[index] = hydrated[b])); + } else { + const d = new Deferred(); + deferred[b] = d; + set((hydrated[index] = d.promise)); + } + continue; + case TYPE_ERROR: + const [, message, errorType] = value; + let error = + errorType && globalObj && globalObj[errorType] + ? new globalObj[errorType](message) + : new Error(message); + hydrated[index] = error; + set(error); + continue; + case TYPE_PREVIOUS_RESOLVED: + set((hydrated[index] = hydrated[b])); + continue; + default: + // Run plugins at the end so we have a chance to resolve primitives + // without running into a loop + if (Array.isArray(plugins)) { + const r: unknown[] = []; + const vals = value.slice(1); + for (let i = 0; i < vals.length; i++) { + const v = vals[i]; + stack.push([ + v, + (v) => { + r[i] = v; + }, + ]); + } + postRun.push(() => { + for (const plugin of plugins) { + const result = plugin(value[0], ...r); + if (result) { + set((hydrated[index] = result.value)); + return; + } + } + throw new SyntaxError(); + }); + continue; + } + throw new SyntaxError(); + } + } else { + const array: unknown[] = []; + hydrated[index] = array; + + for (let i = 0; i < value.length; i++) { + const n = value[i]; + if (n !== HOLE) { + stack.push([ + n, + (v) => { + array[i] = v; + }, + ]); + } + } + set(array); + continue; + } + } else { + const object: Record = {}; + hydrated[index] = object; + + for (const key of Object.keys(value).reverse()) { + const r: any[] = []; + stack.push([ + (value as Record)[key], + (v) => { + r[1] = v; + }, + ]); + stack.push([ + Number(key.slice(1)), + (k) => { + r[0] = k; + }, + ]); + postRun.push(() => { + object[r[0]] = r[1]; + }); + } + set(object); + continue; + } + } + + while (postRun.length > 0) { + postRun.pop()!(); + } + + return result; +} diff --git a/packages/react-router/vendor/turbo-stream-v2/utils.ts b/packages/react-router/vendor/turbo-stream-v2/utils.ts new file mode 100644 index 0000000000..fc2f393264 --- /dev/null +++ b/packages/react-router/vendor/turbo-stream-v2/utils.ts @@ -0,0 +1,84 @@ +export const HOLE = -1; +export const NAN = -2; +export const NEGATIVE_INFINITY = -3; +export const NEGATIVE_ZERO = -4; +export const NULL = -5; +export const POSITIVE_INFINITY = -6; +export const UNDEFINED = -7; + +export const TYPE_BIGINT = "B"; +export const TYPE_DATE = "D"; +export const TYPE_ERROR = "E"; +export const TYPE_MAP = "M"; +export const TYPE_NULL_OBJECT = "N"; +export const TYPE_PROMISE = "P"; +export const TYPE_REGEXP = "R"; +export const TYPE_SET = "S"; +export const TYPE_SYMBOL = "Y"; +export const TYPE_URL = "U"; +export const TYPE_PREVIOUS_RESOLVED = "Z"; + +export type DecodePlugin = ( + type: string, + ...data: unknown[] +) => { value: unknown } | false | null | undefined; + +export type EncodePlugin = ( + value: unknown +) => [string, ...unknown[]] | false | null | undefined; + +export interface ThisDecode { + values: unknown[]; + hydrated: unknown[]; + deferred: Record>; + plugins?: DecodePlugin[]; +} + +export interface ThisEncode { + index: number; + indices: Map; + stringified: string[]; + deferred: Record>; + plugins?: EncodePlugin[]; + postPlugins?: EncodePlugin[]; + signal?: AbortSignal; +} + +export class Deferred { + promise: Promise; + resolve!: (value: T) => void; + reject!: (reason: unknown) => void; + + constructor() { + this.promise = new Promise((resolve, reject) => { + this.resolve = resolve; + this.reject = reject; + }); + } +} + +export function createLineSplittingTransform() { + const decoder = new TextDecoder(); + let leftover = ""; + + return new TransformStream({ + transform(chunk, controller) { + const str = decoder.decode(chunk, { stream: true }); + const parts = (leftover + str).split("\n"); + + // The last part might be a partial line, so keep it for the next chunk. + leftover = parts.pop() || ""; + + for (const part of parts) { + controller.enqueue(part); + } + }, + + flush(controller) { + // If there's any leftover data, enqueue it before closing. + if (leftover) { + controller.enqueue(leftover); + } + }, + }); +} From 8a7e37d50035aea85dc16030a6bb7d6207e062bd Mon Sep 17 00:00:00 2001 From: Jacob Ebey Date: Thu, 13 Feb 2025 09:28:23 -0800 Subject: [PATCH 12/14] fix build --- packages/react-router/lib/dom/ssr/single-fetch.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-router/lib/dom/ssr/single-fetch.tsx b/packages/react-router/lib/dom/ssr/single-fetch.tsx index dcfd7f9272..9b46b3795c 100644 --- a/packages/react-router/lib/dom/ssr/single-fetch.tsx +++ b/packages/react-router/lib/dom/ssr/single-fetch.tsx @@ -603,7 +603,7 @@ export async function decodeViaTurboStream( } }, ], - }).then((res) => res.value); + }).then((res: { value: unknown }) => res.value); } function unwrapSingleFetchResults( From 696f53e264b1371f9d508523eb69b751f4691721 Mon Sep 17 00:00:00 2001 From: Jacob Ebey Date: Thu, 13 Feb 2025 09:32:21 -0800 Subject: [PATCH 13/14] update types --- integration/error-sanitization-test.ts | 12 ------------ integration/transition-test.ts | 2 +- 2 files changed, 1 insertion(+), 13 deletions(-) diff --git a/integration/error-sanitization-test.ts b/integration/error-sanitization-test.ts index 8873ad88ed..df6f3f0bc7 100644 --- a/integration/error-sanitization-test.ts +++ b/integration/error-sanitization-test.ts @@ -258,14 +258,12 @@ test.describe("Error Sanitization", () => { test("returns deferred data without errors", async () => { let { data } = await fixture.requestSingleFetchData("/defer.data"); - // @ts-expect-error expect(await data["routes/defer"].data.lazy).toEqual("RESOLVED"); }); test("sanitizes loader errors in deferred data requests", async () => { let { data } = await fixture.requestSingleFetchData("/defer.data?loader"); try { - // @ts-expect-error await data["routes/defer"].data.lazy; expect(true).toBe(false); } catch (e) { @@ -418,14 +416,12 @@ test.describe("Error Sanitization", () => { test("returns deferred data without errors", async () => { let { data } = await fixture.requestSingleFetchData("/defer.data"); - // @ts-expect-error expect(await data["routes/defer"].data.lazy).toEqual("RESOLVED"); }); test("does not sanitize loader errors in deferred data requests", async () => { let { data } = await fixture.requestSingleFetchData("/defer.data?loader"); try { - // @ts-expect-error await data["routes/defer"].data.lazy; expect(true).toBe(false); } catch (e) { @@ -666,14 +662,12 @@ test.describe("Error Sanitization", () => { test("returns deferred data without errors", async () => { let { data } = await fixture.requestSingleFetchData("/defer.data"); - // @ts-expect-error expect(await data["routes/defer"].data.lazy).toBe("RESOLVED"); }); test("sanitizes loader errors in deferred data requests", async () => { let { data } = await fixture.requestSingleFetchData("/defer.data?loader"); try { - // @ts-expect-error await data["routes/defer"].data.lazy; expect(true).toBe(false); } catch (e) { @@ -844,14 +838,12 @@ test.describe("Error Sanitization turboV3", () => { test("returns deferred data without errors", async () => { let { data } = await fixture.requestSingleFetchData("/defer.data"); - // @ts-expect-error expect(await data["routes/defer"].data.lazy).toEqual("RESOLVED"); }); test("sanitizes loader errors in deferred data requests", async () => { let { data } = await fixture.requestSingleFetchData("/defer.data?loader"); try { - // @ts-expect-error await data["routes/defer"].data.lazy; expect(true).toBe(false); } catch (e) { @@ -1005,14 +997,12 @@ test.describe("Error Sanitization turboV3", () => { test("returns deferred data without errors", async () => { let { data } = await fixture.requestSingleFetchData("/defer.data"); - // @ts-expect-error expect(await data["routes/defer"].data.lazy).toEqual("RESOLVED"); }); test("does not sanitize loader errors in deferred data requests", async () => { let { data } = await fixture.requestSingleFetchData("/defer.data?loader"); try { - // @ts-expect-error await data["routes/defer"].data.lazy; expect(true).toBe(false); } catch (e) { @@ -1254,14 +1244,12 @@ test.describe("Error Sanitization turboV3", () => { test("returns deferred data without errors", async () => { let { data } = await fixture.requestSingleFetchData("/defer.data"); - // @ts-expect-error expect(await data["routes/defer"].data.lazy).toBe("RESOLVED"); }); test("sanitizes loader errors in deferred data requests", async () => { let { data } = await fixture.requestSingleFetchData("/defer.data?loader"); try { - // @ts-expect-error await data["routes/defer"].data.lazy; expect(true).toBe(false); } catch (e) { diff --git a/integration/transition-test.ts b/integration/transition-test.ts index cf589f745c..ffa6c30874 100644 --- a/integration/transition-test.ts +++ b/integration/transition-test.ts @@ -255,7 +255,7 @@ test.describe("rendering", () => { controller.enqueue(new Uint8Array(buffer)); }, }); - const decoded = await decodeViaTurboStream(body, global); + const decoded = await decodeViaTurboStream(body, global, false); expect(Object.keys(decoded as Record)).toEqual([ "routes/page.child", From 66d5af831b7fe6801e4d5f3440f696ac20e2bcbb Mon Sep 17 00:00:00 2001 From: Jacob Ebey Date: Thu, 13 Feb 2025 09:45:49 -0800 Subject: [PATCH 14/14] remove lint rule --- packages/react-router/.eslintrc.js | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/react-router/.eslintrc.js b/packages/react-router/.eslintrc.js index a634ea0440..6af77a3cad 100644 --- a/packages/react-router/.eslintrc.js +++ b/packages/react-router/.eslintrc.js @@ -7,7 +7,6 @@ module.exports = { }, rules: { strict: 0, - "no-restricted-syntax": ["error", "LogicalExpression[operator='??']"], "no-restricted-globals": [ "error", { name: "__dirname", message: restrictedGlobalsError },