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
3 changes: 3 additions & 0 deletions contributors.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
- arjunyel
- arka1002
- Armanio
- ArnasDon
- arnassavickas
- aroyan
- arpitjain099
Expand All @@ -50,6 +51,7 @@
- AviVahl
- awreese
- aymanemadidi
- ayushchaudhary
- ayushmanchhabra
- babafemij-k
- bangseongbeom
Expand Down Expand Up @@ -224,6 +226,7 @@
- jplhomer
- jrakotoharisoa
- jrestall
- JSap0914
- juanpprieto
- jungwoo3490
- justjavac
Expand Down
8 changes: 3 additions & 5 deletions docs/api/framework-conventions/entry.server.tsx.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,11 +145,9 @@ _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 [`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.
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.

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.
For an example, please refer to the default [`entry.server.tsx`][streaming-entry-server].

**Thrown Responses**

Expand All @@ -160,5 +158,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
[node-streaming-entry-server]: https://github.com/remix-run/react-router/blob/main/packages/react-router-dev/config/defaults/entry.server.node.tsx
[streaming-entry-server]: https://github.com/remix-run/react-router/blob/main/packages/react-router-dev/config/defaults/entry.server.tsx
[templates-repo]: https://github.com/remix-run/react-router-templates
23 changes: 23 additions & 0 deletions docs/api/hooks/useMatches.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,29 @@ Returns the active route matches, useful for accessing `loaderData` for
parent/child routes or the route [`handle`](../../start/framework/route-module#handle)
property

Pairing the route `handle` with `useMatches` gets very powerful since you can put
whatever you want on a route handle and have access to `useMatches` anywhere.
Please see the [handle](../../how-to/using-handle) documentation for an example
of breadcrumbs via `useMatches`/`handle`.

```tsx
import { useMatches } from "react-router";

function SomeComponent() {
const matches = useMatches();
// matches[i].id // route id
// matches[i].pathname // the portion of the URL the route matched
// matches[i].params // the parsed params from the URL
// matches[i].loaderData // the data from the loader
// matches[i].handle // the route handle with any app specific data
}
```

<docs-info>useMatches only works with a data router like `createBrowserRouter`,
since they know the full route tree up front and can provide all of the current
matches. Additionally, `useMatches` will not match down into any descendant route
trees since the router isn't aware of the descendant routes.</docs-info>

## Signature

```tsx
Expand Down
4 changes: 4 additions & 0 deletions docs/start/framework/deploying.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,3 +98,7 @@ EdgeOne Pages maintains their own template for React Router. Checkout the [EdgeO
### DeployHQ

DeployHQ maintains their own guide for deploying React Router to your own server. Checkout the [DeployHQ Guide](https://www.deployhq.com/guides/deploy-react-router-from-github) for more information.

### Hostinger

Hostinger supports deploying React Router applications with server rendering on its managed Node.js hosting, including automatic deployments from GitHub. Checkout the [Hostinger Guide](https://www.hostinger.com/web-apps-hosting/react-router-hosting) for more information.
80 changes: 59 additions & 21 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 = "<template id=";
const deferredHTMLStartString = '<script id="_R_">';

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

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

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

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

export default function Root() {
let { id } = useLoaderData();
let location = useLocation();
return (
<html lang="en">
<head>
Expand All @@ -114,13 +122,13 @@ test.describe("non-aborted", () => {
<Links />
</head>
<body>
<div id={id}>
<main id={id}>
<p>{id}</p>
<Counter id={id} />
<Outlet />
<Interactive />
</div>
<Scripts />
</main>
{location.pathname.startsWith("/deferred-noscript-") ? null : <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 @@ -571,11 +579,10 @@ test.describe("non-aborted", () => {
expect(status).toBe(200);
expect(criticalHTML).toContain(counterHtml(ROOT_ID, 0));
expect(criticalHTML).toContain(counterHtml(INDEX_ID, 0));
expect(deferredHTML.replace("</body></html>", "")).not.toBe("");
expect(deferredHTML).not.toContain('<p id="count-');
expect(deferredHTML).toBe("");
});

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

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

test("slow to resolve promises render in subsequent payload", async () => {
test("unresolved promises render in subsequent payload", async ({ page }) => {
let { status, criticalHTML, deferredHTML } = await getHtmlSections(
fixture,
"/deferred-script-unresolved",
Expand All @@ -625,9 +649,22 @@ 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 @@ -639,8 +676,9 @@ test.describe("non-aborted", () => {
expect(status).toBe(200);
expect(criticalHTML).toContain(counterHtml(ROOT_ID, 0));
expect(criticalHTML).toContain(counterHtml(DEFERRED_ID, 0));
expect(deferredHTML).toContain(FALLBACK_ID);
expect(deferredHTML).toContain(counterHtml(ERROR_ID, 0));
expect(criticalHTML).toContain(counterHtml(ERROR_ID, 0));
expect(criticalHTML).not.toContain(FALLBACK_ID);
expect(deferredHTML).not.toContain(FALLBACK_ID);
});

test("slow to reject promises render in subsequent payload", async () => {
Expand All @@ -652,8 +690,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
5 changes: 1 addition & 4 deletions integration/link-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -623,12 +623,9 @@ 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();
// React: $RC("B:1","S:1")
expect(scripts.length).toEqual(7);
expect(scripts.length).toEqual(4);

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

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

const tsx = dedent;
const css = dedent;
Expand Down Expand Up @@ -177,4 +183,15 @@ 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: 11 additions & 0 deletions packages/react-router-dev/.changes/minor.web-streams-entry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
Change the default `entry.server.tsx` to use React's `renderToReadableStream` which is now available in React Router v8's Node 22 baseline

- Framework mode apps no longer require a custom `entry.server.tsx` file to run in non-Node runtimes (i.e., Cloudflare)
- This should not have any functional changes for your app
- You may see a small performance boost because of the reduced conversions between node streams and web streams
- You may eliminate initial fallback flickers for promises resolved prior to render
- Our testing showed that `renderToPipeableStream` would render the fallback and stream an immediate chunk with the resolved value
- `renderToReadableStream` skips the fallback and renders the resolved value in the critical HTML
- If you have your own `entry.server.tsx` using `renderToReadableStream`, you may be able to remove it from your app if the logic matches the [default implementation](https://github.com/remix-run/react-router/blob/main/packages/react-router-dev/config/defaults/entry.server.tsx)
- If you wish to continue using `renderToPipeableStream`, you can add your own `entry.server.tsx` file based on the previous [node implementation](https://github.com/remix-run/react-router/blob/react-router%408.0.0/packages/react-router-dev/config/defaults/entry.server.node.tsx)
- You can also remove `@react-router/node` from your dependencies if you also have `@react-router/serve` or `@react-router/express`, since we no longer need the dependency to determine server entry compatibility across runtimes
11 changes: 1 addition & 10 deletions packages/react-router-dev/cli/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ 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 @@ -170,22 +169,14 @@ 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.node.tsx`,
`entry.server.tsx`,
);

let isServerEntry = entry === "entry.server";
Expand Down
8 changes: 1 addition & 7 deletions packages/react-router-dev/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1045,12 +1045,6 @@ 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 @@ -1070,7 +1064,7 @@ export async function resolveEntryFiles({
});
}

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

let entryClientFilePath = userEntryClientFile
Expand Down
Loading
Loading