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 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 6cb52156bc..82af370aea 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,21 +181,256 @@ 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!" }); - }); + 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, + "#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", - "Unable to decode turbo-stream response" + "#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 89048930cc..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,594 @@ 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) { + 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(); + 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\\":u,\\"__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\\":u,\\"__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).toMatch('\\"stack\\":u,'); + // 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"); + 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 { + 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( + { + turboV3: true, + 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"); + 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 { + 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( + { + turboV3: true, + 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\\":u,\\"__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\\":u,\\"__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).toMatch('\\"stack\\":u,'); + // 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"); + 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 { await data["routes/defer"].data.lazy; expect(true).toBe(false); } catch (e) { diff --git a/integration/helpers/create-fixture.ts b/integration/helpers/create-fixture.ts index 672faef6dd..44285dc344 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(/^\//, "")), @@ -125,7 +128,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, turboV3), }; }, postDocument: () => { @@ -167,7 +170,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, turboV3) : null, }; }; @@ -395,6 +398,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 10e04ebd50..fe213c6614 100644 --- a/integration/helpers/vite.ts +++ b/integration/helpers/vite.ts @@ -29,6 +29,7 @@ export const reactRouterConfig = ({ prerender, appDirectory, splitRouteModules, + turboV3, viteEnvironmentApi, }: { ssr?: boolean; @@ -38,6 +39,7 @@ export const reactRouterConfig = ({ splitRouteModules?: NonNullable< Config["future"] >["unstable_splitRouteModules"]; + turboV3?: boolean; viteEnvironmentApi?: boolean; }) => { let config: Config = { @@ -46,6 +48,7 @@ export const reactRouterConfig = ({ prerender, appDirectory, future: { + turboV3, unstable_splitRouteModules: splitRouteModules, unstable_viteEnvironmentApi: viteEnvironmentApi, }, 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..491d0cd8cc 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(); @@ -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/integration/transition-test.ts b/integration/transition-test.ts index 1521257e6b..ffa6c30874 100644 --- a/integration/transition-test.ts +++ b/integration/transition-test.ts @@ -255,9 +255,9 @@ 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.value as Record)).toEqual([ + expect(Object.keys(decoded as Record)).toEqual([ "routes/page.child", ]); diff --git a/packages/react-router-dev/config/config.ts b/packages/react-router-dev/config/config.ts index 83f4cca2da..8a0db686e7 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. @@ -488,6 +489,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/.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 }, diff --git a/packages/react-router/__tests__/server-runtime/data-test.ts b/packages/react-router/__tests__/server-runtime/data-test.ts index ba0cbe11f3..caccd35ac0 100644 --- a/packages/react-router/__tests__/server-runtime/data-test.ts +++ b/packages/react-router/__tests__/server-runtime/data-test.ts @@ -23,6 +23,7 @@ describe("loaders", () => { }, entry: { module: {} }, prerender: [], + future: {}, } as unknown as ServerBuild; let handler = createRequestHandler(build); @@ -35,7 +36,7 @@ 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"); + const decoded = await decodeViaTurboStream(res.body, global, false); + 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 e7f4d7a264..a776e047a7 100644 --- a/packages/react-router/lib/dom-export/hydrated-router.tsx +++ b/packages/react-router/lib/dom-export/hydrated-router.tsx @@ -81,10 +81,13 @@ 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.value as typeof localSsrInfo.context.state; + ssrInfo!.context.state = value as typeof localSsrInfo.context.state; localSsrInfo.stateDecodingPromise!.value = true; }) .catch((e) => { @@ -177,8 +180,9 @@ function createHydratedRouter(): DataRouter { dataStrategy: getSingleFetchDataStrategy( ssrInfo.manifest, ssrInfo.routeModules, + () => router, ssrInfo.context.ssr, - () => router + ssrInfo.context.future.turboV3 ?? false ), patchRoutesOnNavigation: getPatchRoutesOnNavigationFunction( ssrInfo.manifest, @@ -259,6 +263,13 @@ export function HydratedRouter() { ssrInfo.context.isSpaMode ); + if (ssrInfo.context.future.turboV3) { + import("turbo-stream"); + } else { + // @ts-expect-error - bad tsconfig + import("../../vendor/turbo-stream-v2/turbo-stream"); + } + // 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/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 6eb700ea2f..d0be2f3d92 100644 --- a/packages/react-router/lib/dom/ssr/entry.ts +++ b/packages/react-router/lib/dom/ssr/entry.ts @@ -31,6 +31,8 @@ export interface FrameworkContextObject { error?: unknown; } >; + streamFinished?: boolean; + nonce?: string; }; } @@ -41,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 0093462ba9..9b46b3795c 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 { @@ -108,7 +107,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 517b1a6bc6..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,7 +328,32 @@ 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, plugins: [ (value) => { diff --git a/packages/react-router/package.json b/packages/react-router/package.json index ade9b23d29..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": "2.4.0" + "turbo-stream": "^3.1.0" }, "devDependencies": { "@types/set-cookie-parser": "^2.4.1", 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..77082330e5 --- /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"; + +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..b75248ad5e --- /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"; + +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); + } + }, + }); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e96e3e5817..c8aadf3f96 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: ^3.1.0 + version: 3.1.0 devDependencies: '@types/set-cookie-parser': specifier: ^2.4.1 @@ -7635,8 +7635,8 @@ packages: engines: {node: '>=8.0.0'} hasBin: true - turbo-stream@2.4.0: - resolution: {integrity: sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g==} + turbo-stream@3.1.0: + resolution: {integrity: sha512-tVI25WEXl4fckNEmrq70xU1XumxUwEx/FZD5AgEcV8ri7Wvrg2o7GEq8U7htrNx3CajciGm+kDyhRf5JB6t7/A==} type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} @@ -16005,7 +16005,7 @@ snapshots: wcwidth: 1.0.1 yargs: 17.7.2 - turbo-stream@2.4.0: {} + turbo-stream@3.1.0: {} type-check@0.4.0: dependencies: