Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions docs/api/framework-conventions/entry.server.tsx.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,9 +145,11 @@ _Note that you generally want to avoid logging when the request was aborted, sin

**Streaming Rendering Errors**

When you are streaming your HTML responses via [`renderToReadableStream`][rendertoreadablestream], your own `handleError` implementation will only handle errors encountered during the initial shell render. If you encounter a rendering error during subsequent streamed rendering you will need to handle these errors manually since the React Router server has already sent the Response by that point. You can handle these errors in the `onError` callback function.
When you are streaming your HTML responses via [`renderToPipeableStream`][rendertopipeablestream] or [`renderToReadableStream`][rendertoreadablestream], your own `handleError` implementation will only handle errors encountered during the initial shell render. If you encounter a rendering error during subsequent streamed rendering you will need to handle these errors manually since the React Router server has already sent the Response by that point.

For an example, please refer to the default [`entry.server.tsx`][streaming-entry-server].
For `renderToPipeableStream`, you can handle these errors in the `onError` callback function. You will need to toggle a boolean in `onShellReady` so you know if the error was a shell rendering error (and can be ignored) or an async

For an example, please refer to the default [`entry.server.tsx`][node-streaming-entry-server] for Node.

**Thrown Responses**

Expand All @@ -158,5 +160,5 @@ Note that this does not handle thrown `Response` instances from your `loader`/`a
[streaming]: ../../how-to/suspense
[rendertopipeablestream]: https://react.dev/reference/react-dom/server/renderToPipeableStream
[rendertoreadablestream]: https://react.dev/reference/react-dom/server/renderToReadableStream
[streaming-entry-server]: https://github.com/remix-run/react-router/blob/main/packages/react-router-dev/config/defaults/entry.server.tsx
[node-streaming-entry-server]: https://github.com/remix-run/react-router/blob/main/packages/react-router-dev/config/defaults/entry.server.node.tsx
[templates-repo]: https://github.com/remix-run/react-router-templates
80 changes: 21 additions & 59 deletions integration/defer-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ function counterHtml(id: string, val: number) {
return `<p id="count-${id}">${val}</p>`;
}

const deferredHTMLStartString = '<script id="_R_">';
const deferredHTMLStartString = "<template id=";

async function getHtmlSections(
fixture: Fixture,
Expand All @@ -46,14 +46,7 @@ async function getHtmlSections(
let html = await response.text();
let deferredIndex = html.indexOf(deferredHTMLStartString);

// If nothing is deferred then we never get the streaming JS code
if (deferredIndex === -1) {
return {
status: response.status,
criticalHTML: html,
deferredHTML: "",
};
}
expect(deferredIndex).toBeGreaterThan(-1);

return {
status: response.status,
Expand Down Expand Up @@ -98,7 +91,7 @@ test.describe("non-aborted", () => {
}
`,
"app/root.tsx": js`
import { Links, Meta, Outlet, Scripts, useLoaderData, useLocation } from "react-router";
import { Links, Meta, Outlet, Scripts, useLoaderData } from "react-router";
import Counter from "~/components/counter";
import Interactive from "~/components/interactive";

Expand All @@ -112,7 +105,6 @@ test.describe("non-aborted", () => {

export default function Root() {
let { id } = useLoaderData();
let location = useLocation();
return (
<html lang="en">
<head>
Expand All @@ -122,13 +114,13 @@ test.describe("non-aborted", () => {
<Links />
</head>
<body>
<main id={id}>
<div id={id}>
<p>{id}</p>
<Counter id={id} />
<Outlet />
<Interactive />
</main>
{location.pathname.startsWith("/deferred-noscript-") ? null : <Scripts />}
</div>
<Scripts />
{/* Send arbitrary data so safari renders the initial shell before
the document finishes downloading. */}
{Array(1000).fill(null).map((_, i)=><p key={i}>YOOOOOOOOOO {i}</p>)}
Expand Down Expand Up @@ -579,10 +571,11 @@ test.describe("non-aborted", () => {
expect(status).toBe(200);
expect(criticalHTML).toContain(counterHtml(ROOT_ID, 0));
expect(criticalHTML).toContain(counterHtml(INDEX_ID, 0));
expect(deferredHTML).toBe("");
expect(deferredHTML.replace("</body></html>", "")).not.toBe("");
expect(deferredHTML).not.toContain('<p id="count-');
});

test("resolved promises render in initial payload (noscript)", async () => {
test("resolved promises do not render in initial payload", async () => {
let { status, criticalHTML, deferredHTML } = await getHtmlSections(
fixture,
"/deferred-noscript-resolved",
Expand All @@ -591,14 +584,12 @@ test.describe("non-aborted", () => {
expect(status).toBe(200);
expect(criticalHTML).toContain(counterHtml(ROOT_ID, 0));
expect(criticalHTML).toContain(counterHtml(DEFERRED_ID, 0));
expect(criticalHTML).toContain(counterHtml(RESOLVED_DEFERRED_ID, 0));
expect(criticalHTML).not.toContain(FALLBACK_ID);
expect(deferredHTML).not.toContain(FALLBACK_ID);
expect(criticalHTML).not.toContain(counterHtml(RESOLVED_DEFERRED_ID, 0));
expect(deferredHTML).toContain(FALLBACK_ID);
expect(deferredHTML).toContain(counterHtml(RESOLVED_DEFERRED_ID, 0));
});

test("unresolved promises render in subsequent payload (noscript)", async ({
page,
}) => {
test("slow promises render in subsequent payload", async () => {
let { status, criticalHTML, deferredHTML } = await getHtmlSections(
fixture,
"/deferred-noscript-unresolved",
Expand All @@ -607,23 +598,9 @@ test.describe("non-aborted", () => {
expect(status).toBe(200);
expect(criticalHTML).toContain(counterHtml(ROOT_ID, 0));
expect(criticalHTML).toContain(counterHtml(DEFERRED_ID, 0));
expect(criticalHTML).toContain(`<div id="${FALLBACK_ID}">`);
expect(criticalHTML).not.toContain(RESOLVED_DEFERRED_ID);
expect(deferredHTML).toContain(`<div id="${FALLBACK_ID}">`);
expect(deferredHTML).toContain(counterHtml(RESOLVED_DEFERRED_ID, 0));

// Hydrates out-of-order streamed content, but does not become interactive
// because we didn't include Scripts
let app = new PlaywrightFixture(appFixture, page);
await app.goto("/deferred-noscript-unresolved", true);
await page.waitForSelector(`main #${RESOLVED_DEFERRED_ID}`);
await page.waitForSelector(
`main #count-${RESOLVED_DEFERRED_ID}:has-text("0")`,
);
await page.locator(`main #increment-${RESOLVED_DEFERRED_ID}`).click();
await new Promise((r) => setTimeout(r, 100));
expect(
await page.locator(`main #count-${RESOLVED_DEFERRED_ID}`).innerText(),
).toBe("0");
});

test("resolved promises render in initial payload", async () => {
Expand All @@ -635,12 +612,11 @@ test.describe("non-aborted", () => {
expect(status).toBe(200);
expect(criticalHTML).toContain(counterHtml(ROOT_ID, 0));
expect(criticalHTML).toContain(counterHtml(DEFERRED_ID, 0));
expect(criticalHTML).toContain(counterHtml(RESOLVED_DEFERRED_ID, 0));
expect(criticalHTML).not.toContain(FALLBACK_ID);
expect(deferredHTML).not.toContain(FALLBACK_ID);
expect(deferredHTML).toContain(FALLBACK_ID);
expect(deferredHTML).toContain(counterHtml(RESOLVED_DEFERRED_ID, 0));
});

test("unresolved promises render in subsequent payload", async ({ page }) => {
test("slow to resolve promises render in subsequent payload", async () => {
let { status, criticalHTML, deferredHTML } = await getHtmlSections(
fixture,
"/deferred-script-unresolved",
Expand All @@ -649,22 +625,9 @@ test.describe("non-aborted", () => {
expect(status).toBe(200);
expect(criticalHTML).toContain(counterHtml(ROOT_ID, 0));
expect(criticalHTML).toContain(counterHtml(DEFERRED_ID, 0));
expect(criticalHTML).toContain(`<div id="${FALLBACK_ID}">`);
expect(criticalHTML).not.toContain(RESOLVED_DEFERRED_ID);
expect(deferredHTML).toContain(`<div id="${FALLBACK_ID}">`);
expect(deferredHTML).toContain(counterHtml(RESOLVED_DEFERRED_ID, 0));

// Hydrates out-of-order streamed content and becomes interactive
let app = new PlaywrightFixture(appFixture, page);
await app.goto("/deferred-script-unresolved", true);
await page.waitForSelector(`main #${RESOLVED_DEFERRED_ID}`);
await page.waitForSelector(
`main #count-${RESOLVED_DEFERRED_ID}:has-text("0")`,
);
await page.locator(`main #increment-${RESOLVED_DEFERRED_ID}`).click();
await new Promise((r) => setTimeout(r, 100));
expect(
await page.locator(`main #count-${RESOLVED_DEFERRED_ID}`).innerText(),
).toBe("1");
});

test("rejected promises render in initial payload", async () => {
Expand All @@ -676,9 +639,8 @@ test.describe("non-aborted", () => {
expect(status).toBe(200);
expect(criticalHTML).toContain(counterHtml(ROOT_ID, 0));
expect(criticalHTML).toContain(counterHtml(DEFERRED_ID, 0));
expect(criticalHTML).toContain(counterHtml(ERROR_ID, 0));
expect(criticalHTML).not.toContain(FALLBACK_ID);
expect(deferredHTML).not.toContain(FALLBACK_ID);
expect(deferredHTML).toContain(FALLBACK_ID);
expect(deferredHTML).toContain(counterHtml(ERROR_ID, 0));
});

test("slow to reject promises render in subsequent payload", async () => {
Expand All @@ -690,8 +652,8 @@ test.describe("non-aborted", () => {
expect(status).toBe(200);
expect(criticalHTML).toContain(counterHtml(ROOT_ID, 0));
expect(criticalHTML).toContain(counterHtml(DEFERRED_ID, 0));
expect(criticalHTML).toContain(`<div id="${FALLBACK_ID}">`);
expect(criticalHTML).not.toContain(ERROR_ID);
expect(deferredHTML).toContain(`<div id="${FALLBACK_ID}">`);
expect(deferredHTML).toContain(counterHtml(ERROR_ID, 0));
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,33 +3,22 @@ import { ServerRouter } from "react-router";
import { isbot } from "isbot";
import { renderToReadableStream } from "react-dom/server";

export const streamTimeout = 5_000;

export default async function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
routerContext: EntryContext,
_loadContext: RouterContextProvider,
) {
// https://httpwg.org/specs/rfc9110.html#HEAD
if (request.method.toUpperCase() === "HEAD") {
return new Response(null, {
status: responseStatusCode,
headers: responseHeaders,
});
}

let shellRendered = false;
let userAgent = request.headers.get("user-agent");
const userAgent = request.headers.get("user-agent");

const body = await renderToReadableStream(
<ServerRouter context={routerContext} url={request.url} />,
{
signal: AbortSignal.timeout(streamTimeout + 1000),
onError(error: unknown) {
responseStatusCode = 500;
// Log streaming rendering errors from inside the shell. Don't log
// 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) {
Expand Down
5 changes: 4 additions & 1 deletion integration/link-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -623,9 +623,12 @@ test.describe("route module link export", () => {
// Scripts:
// RR: window.__reactRouterContext
// RR: window.__reactRouterManifest/window.__reactRouterRouteModules
// React: requestAnimationFrame(function(){$RT=performance.now()});
// RR: window.__reactRouterContext.streamController.enqueue()
// React: $RC=function(b,c,e){...
// RR: window.__reactRouterContext.streamController.close();
expect(scripts.length).toEqual(4);
// React: $RC("B:1","S:1")
expect(scripts.length).toEqual(7);

expect(await scripts[0].innerText()).toContain(
"__reactRouterContext",
Expand Down
19 changes: 1 addition & 18 deletions integration/vite-plugin-cloudflare-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,7 @@ import { expect } from "@playwright/test";
import dedent from "dedent";
import getPort from "get-port";

import {
type Files,
test,
viteConfig,
createProject,
build,
} from "./helpers/vite.js";
import { type Files, test, viteConfig } from "./helpers/vite.js";

const tsx = dedent;
const css = dedent;
Expand Down Expand Up @@ -183,15 +177,4 @@ test.describe("vite-plugin-cloudflare", () => {
"20px",
);
});

test("builds project with default server entry", async () => {
const files = defineFiles();
const cwd = await createProject(
await files({ port: 0 }),
"vite-plugin-cloudflare-template",
);
const buildResult = build({ cwd });

expect(buildResult.status).toBe(0);
});
});
11 changes: 0 additions & 11 deletions packages/react-router-dev/.changes/minor.web-streams-entry.md

This file was deleted.

11 changes: 10 additions & 1 deletion packages/react-router-dev/cli/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { readFile, writeFile, copyFile } from "node:fs/promises";
import { createRequire } from "node:module";
import * as path from "node:path";
import exitHook from "exit-hook";
import { readPackageJSON } from "pkg-types";
import colors from "picocolors";
// Workaround for "ERR_REQUIRE_CYCLE_MODULE" in Node 22.10.0+
import "react-router";
Expand Down Expand Up @@ -169,14 +170,22 @@ export async function generateEntry(

await copyFile(defaultEntry, outputFile);
} else {
let pkgJson = await readPackageJSON(rootDirectory);
let deps = pkgJson.dependencies ?? {};

if (!deps["@react-router/node"]) {
console.error(colors.red(`No default server entry detected.`));
return;
}

let defaultEntryClient = path.resolve(
defaultsDirectory,
"entry.client.tsx",
);

let defaultEntryServer = path.resolve(
defaultsDirectory,
`entry.server.tsx`,
`entry.server.node.tsx`,
);

let isServerEntry = entry === "entry.server";
Expand Down
8 changes: 7 additions & 1 deletion packages/react-router-dev/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1045,6 +1045,12 @@ export async function resolveEntryFiles({
let pkgJson = await readPackageJSON(packageJsonDirectory);
let deps = pkgJson.dependencies ?? {};

if (!deps["@react-router/node"]) {
throw new Error(
`Could not determine server runtime. Please install @react-router/node, or provide a custom entry.server.tsx/jsx file in your app directory.`,
);
}

if (!deps["isbot"]) {
console.log(
"adding `isbot@5` to your package.json, you should commit this change",
Expand All @@ -1064,7 +1070,7 @@ export async function resolveEntryFiles({
});
}

entryServerFile = `entry.server.tsx`;
entryServerFile = `entry.server.node.tsx`;
}

let entryClientFilePath = userEntryClientFile
Expand Down
Loading