diff --git a/.eslintignore b/.eslintignore index dbd9643de0..49a812d5b4 100644 --- a/.eslintignore +++ b/.eslintignore @@ -6,6 +6,15 @@ examples/**/dist/ worker-configuration.d.ts /playground/ /playground-local/ +integration/helpers/**/dist/ +integration/helpers/**/build/ +playwright-report/ +test-results/ +build.utils.d.ts +.wrangler/ +.tmp/ +.react-router/ +.react-router-parcel/ packages/**/dist/ packages/react-router-dom/server.d.ts packages/react-router-dom/server.js diff --git a/.github/workflows/integration-full.yml b/.github/workflows/integration-full.yml index fd2f79bfa9..205cc2b7f9 100644 --- a/.github/workflows/integration-full.yml +++ b/.github/workflows/integration-full.yml @@ -31,7 +31,7 @@ jobs: uses: ./.github/workflows/shared-integration.yml with: os: "ubuntu-latest" - node_version: "[20, 22]" + node_version: "[22, 24]" browser: '["chromium", "firefox"]' integration-windows: @@ -40,7 +40,7 @@ jobs: uses: ./.github/workflows/shared-integration.yml with: os: "windows-latest" - node_version: "[20, 22]" + node_version: "[22, 24]" browser: '["msedge"]' integration-macos: @@ -49,5 +49,5 @@ jobs: uses: ./.github/workflows/shared-integration.yml with: os: "macos-latest" - node_version: "[20, 22]" + node_version: "[22, 24]" browser: '["webkit"]' diff --git a/.github/workflows/integration-pr-ubuntu.yml b/.github/workflows/integration-pr-ubuntu.yml index ac25713e08..056f0d1e46 100644 --- a/.github/workflows/integration-pr-ubuntu.yml +++ b/.github/workflows/integration-pr-ubuntu.yml @@ -31,5 +31,5 @@ jobs: uses: ./.github/workflows/shared-integration.yml with: os: "ubuntu-latest" - node_version: "[22]" + node_version: "[24]" browser: '["chromium"]' diff --git a/.github/workflows/integration-pr-windows-macos.yml b/.github/workflows/integration-pr-windows-macos.yml index a36aef144d..4423d376b4 100644 --- a/.github/workflows/integration-pr-windows-macos.yml +++ b/.github/workflows/integration-pr-windows-macos.yml @@ -21,7 +21,7 @@ jobs: uses: ./.github/workflows/shared-integration.yml with: os: "ubuntu-latest" - node_version: "[22]" + node_version: "[24]" browser: '["firefox"]' integration-msedge: @@ -29,10 +29,10 @@ jobs: if: github.repository == 'remix-run/react-router' uses: ./.github/workflows/shared-integration.yml with: - os: "windows-latest" - node_version: "[22]" + os: "windows-2025" + node_version: "[24]" browser: '["msedge"]' - timeout: 60 + timeout: 120 integration-webkit: name: "👀 Integration Test" @@ -40,5 +40,5 @@ jobs: uses: ./.github/workflows/shared-integration.yml with: os: "macos-latest" - node_version: "[22]" + node_version: "[24]" browser: '["webkit"]' diff --git a/.github/workflows/shared-integration.yml b/.github/workflows/shared-integration.yml index e7adb0dd47..4dd3df2cfd 100644 --- a/.github/workflows/shared-integration.yml +++ b/.github/workflows/shared-integration.yml @@ -9,7 +9,7 @@ on: node_version: required: true # this is limited to string | boolean | number (https://github.community/t/can-action-inputs-be-arrays/16457) - # but we want to pass an array (node_version: "[20, 22]"), + # but we want to pass an array (node_version: "[22, 24]"), # so we'll need to manually stringify it for now type: string browser: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index af709fdaaa..5eda492b5f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -26,8 +26,8 @@ jobs: fail-fast: false matrix: node: - - 20 - 22 + - 24 runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index b13e7650a9..53e9a5baf1 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,7 @@ node_modules/ .wireit .eslintcache +.parcel-cache .tmp tsup.config.bundled_*.mjs build.utils.d.ts diff --git a/.nvmrc b/.nvmrc index 2edeafb09d..8fdd954df9 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -20 \ No newline at end of file +22 \ No newline at end of file diff --git a/contributors.yml b/contributors.yml index a603914de0..7cab60befb 100644 --- a/contributors.yml +++ b/contributors.yml @@ -132,6 +132,7 @@ - hampelm - harshmangalam - hernanif1 +- hi-ogawa - HK-SHAO - holynewbie - hongji00 diff --git a/integration/action-test.ts b/integration/action-test.ts index c08e6d4bdb..70fa4bd9e7 100644 --- a/integration/action-test.ts +++ b/integration/action-test.ts @@ -7,206 +7,229 @@ import { } from "./helpers/create-fixture.js"; import type { Fixture, AppFixture } from "./helpers/create-fixture.js"; import { PlaywrightFixture, selectHtml } from "./helpers/playwright-fixture.js"; +import type { TemplateName } from "./helpers/vite.js"; -test.describe("actions", () => { - let fixture: Fixture; - let appFixture: AppFixture; - - let FIELD_NAME = "message"; - let WAITING_VALUE = "Waiting..."; - let SUBMITTED_VALUE = "Submission"; - let THROWS_REDIRECT = "redirect-throw"; - let REDIRECT_TARGET = "page"; - let PAGE_TEXT = "PAGE_TEXT"; - - test.beforeAll(async () => { - fixture = await createFixture({ - files: { - "app/routes/urlencoded.tsx": js` - import { Form, useActionData } from "react-router"; - - export let action = async ({ request }) => { - let formData = await request.formData(); - return formData.get("${FIELD_NAME}"); - }; - - export default function Actions() { - let data = useActionData() - - return ( -
- ); - } - `, - - "app/routes/request-text.tsx": js` - import { Form, useActionData } from "react-router"; - - export let action = async ({ request }) => { - let text = await request.text(); - return text; - }; - - export default function Actions() { - let data = useActionData() - - return ( - - ); - } - `, - - [`app/routes/${THROWS_REDIRECT}.jsx`]: js` - import { redirect, Form } from "react-router"; - - export function action() { - throw redirect("/${REDIRECT_TARGET}") - } - - export default function () { - return ( - - ) - } - `, - - [`app/routes/${REDIRECT_TARGET}.jsx`]: js` - export default function () { - returnbut Edge puts it in some weird code editor markup: // //"LUNCH"- expect(await app.getHtml()).toContain(LUNCH); + await page.getByText(LUNCH); }); test("Form can hit an action", async ({ page }) => { @@ -264,7 +264,7 @@ test.describe("useFetcher", () => { // abut Edge puts it in some weird code editor markup: // //"LUNCH"- expect(await app.getHtml()).toContain(CHEESESTEAK); + await page.getByText(CHEESESTEAK); }); }); @@ -288,9 +288,7 @@ test.describe("useFetcher", () => { await page.fill("#fetcher-input", "input value"); await app.clickElement("#fetcher-submit-json"); await page.waitForSelector(`#fetcher-idle`); - expect(await app.getHtml()).toMatch( - 'ACTION (application/json) input value"' - ); + await page.getByText('ACTION (application/json) input value"'); }); test("submit can hit an action with null json", async ({ page }) => { @@ -299,7 +297,7 @@ test.describe("useFetcher", () => { await app.clickElement("#fetcher-submit-json-null"); await new Promise((r) => setTimeout(r, 1000)); await page.waitForSelector(`#fetcher-idle`); - expect(await app.getHtml()).toMatch('ACTION (application/json) null"'); + await page.getByText('ACTION (application/json) null"'); }); test("submit can hit an action with text", async ({ page }) => { @@ -308,9 +306,7 @@ test.describe("useFetcher", () => { await page.fill("#fetcher-input", "input value"); await app.clickElement("#fetcher-submit-text"); await page.waitForSelector(`#fetcher-idle`); - expect(await app.getHtml()).toMatch( - 'ACTION (text/plain;charset=UTF-8) input value"' - ); + await page.getByText('ACTION (text/plain;charset=UTF-8) input value"'); }); test("submit can hit an action with empty text", async ({ page }) => { @@ -319,7 +315,7 @@ test.describe("useFetcher", () => { await app.clickElement("#fetcher-submit-text-empty"); await new Promise((r) => setTimeout(r, 1000)); await page.waitForSelector(`#fetcher-idle`); - expect(await app.getHtml()).toMatch('ACTION (text/plain;charset=UTF-8) "'); + await page.getByText('ACTION (text/plain;charset=UTF-8) "'); }); test("submit can hit an action only route", async ({ page }) => { @@ -360,21 +356,19 @@ test.describe("useFetcher", () => { let app = new PlaywrightFixture(appFixture, page); await app.goto("/fetcher-echo", true); - expect(await app.getHtml("pre")).toMatch( - JSON.stringify(["idle/undefined"]) - ); + await page.getByText(JSON.stringify(["idle/undefined"])); await page.fill("#fetcher-input", "1"); await app.clickElement("#fetcher-load"); await page.waitForSelector("#fetcher-idle"); - expect(await app.getHtml("pre")).toMatch( + await page.getByText( JSON.stringify(["idle/undefined", "loading/undefined", "idle/LOADER 1"]) ); await page.fill("#fetcher-input", "2"); await app.clickElement("#fetcher-load"); await page.waitForSelector("#fetcher-idle"); - expect(await app.getHtml("pre")).toMatch( + await page.getByText( JSON.stringify([ "idle/undefined", "loading/undefined", @@ -391,14 +385,12 @@ test.describe("useFetcher", () => { let app = new PlaywrightFixture(appFixture, page); await app.goto("/fetcher-echo", true); - expect(await app.getHtml("pre")).toMatch( - JSON.stringify(["idle/undefined"]) - ); + await page.getByText(JSON.stringify(["idle/undefined"])); await page.fill("#fetcher-input", "1"); await app.clickElement("#fetcher-submit"); await page.waitForSelector("#fetcher-idle"); - expect(await app.getHtml("pre")).toMatch( + await page.getByText( JSON.stringify([ "idle/undefined", "submitting/undefined", @@ -410,7 +402,7 @@ test.describe("useFetcher", () => { await page.fill("#fetcher-input", "2"); await app.clickElement("#fetcher-submit"); await page.waitForSelector("#fetcher-idle"); - expect(await app.getHtml("pre")).toMatch( + await page.getByText( JSON.stringify([ "idle/undefined", "submitting/undefined", diff --git a/integration/helpers/create-fixture.ts b/integration/helpers/create-fixture.ts index 038a339a3d..f935390bd8 100644 --- a/integration/helpers/create-fixture.ts +++ b/integration/helpers/create-fixture.ts @@ -9,6 +9,7 @@ import getPort from "get-port"; import stripIndent from "strip-indent"; import { sync as spawnSync, spawn } from "cross-spawn"; import type { JsonObject } from "type-fest"; +import { createRequestListener } from "@mjackson/node-fetch-server"; import { type ServerBuild, @@ -45,7 +46,10 @@ export function json(value: JsonObject) { return JSON.stringify(value, null, 2); } +const defaultTemplateName = "vite-5-template" satisfies TemplateName; + export async function createFixture(init: FixtureInit, mode?: ServerMode) { + let templateName = init.templateName ?? defaultTemplateName; let projectDir = await createFixtureProject(init, mode); let buildPath = url.pathToFileURL( path.join(projectDir, "build/server/index.js") @@ -134,8 +138,22 @@ export async function createFixture(init: FixtureInit, mode?: ServerMode) { }; } - let app: ServerBuild = await import(buildPath); - let handler = createRequestHandler(app, mode || ServerMode.Production); + let build: ServerBuild | null = null; + type RequestHandler = (request: Request) => Promise; + let handler: RequestHandler; + if (templateName.includes("parcel")) { + let serverBuild = await import(buildPath); + handler = (serverBuild?.requestHandler ?? + serverBuild?.default?.requestHandler) as RequestHandler; + if (!handler) { + throw new Error( + "Expected a 'requestHandler' export in Parcel server build" + ); + } + } else { + build = (await import(buildPath)) as ServerBuild; + handler = createRequestHandler(build, mode || ServerMode.Production); + } let requestDocument = async (href: string, init?: RequestInit) => { let url = new URL(href, "test://test"); @@ -184,8 +202,10 @@ export async function createFixture(init: FixtureInit, mode?: ServerMode) { }; return { + templateName, projectDir, - build: app, + build, + handler, isSpaMode: init.spaMode, prerender: init.prerender, requestDocument, @@ -313,7 +333,26 @@ export async function createAppFixture(fixture: Fixture, mode?: ServerMode) { }); } - if (!fixture.build) { + if (fixture.templateName.includes("parcel")) { + return new Promise(async (accept) => { + let port = await getPort(); + let app = express(); + app.use(express.static(path.join(fixture.projectDir, "public"))); + app.use( + "/client", + express.static(path.join(fixture.projectDir, "build/client")) + ); + + app.all("*", createRequestListener(fixture.handler)); + + let server = app.listen(port); + + accept({ stop: server.close.bind(server), port }); + }); + } + + const build = fixture.build; + if (!build) { return Promise.reject( new Error("Cannot start app server without a build") ); @@ -327,7 +366,7 @@ export async function createAppFixture(fixture: Fixture, mode?: ServerMode) { app.all( "*", createExpressHandler({ - build: fixture.build, + build, mode: mode || ServerMode.Production, }) ); @@ -366,9 +405,9 @@ export async function createFixtureProject( init: FixtureInit = {}, mode?: ServerMode ): Promise { - let template = init.templateName ?? "vite-5-template"; - let integrationTemplateDir = path.resolve(__dirname, template); - let projectName = `rr-${template}-${Math.random().toString(32).slice(2)}`; + let templateName = init.templateName ?? defaultTemplateName; + let integrationTemplateDir = path.resolve(__dirname, templateName); + let projectName = `rr-${templateName}-${Math.random().toString(32).slice(2)}`; let projectDir = path.join(TMP_DIR, projectName); let port = init.port ?? (await getPort()); @@ -406,12 +445,56 @@ export async function createFixtureProject( projectDir ); - build(projectDir, init.buildStdio, mode); + if (templateName.includes("parcel")) { + parcelBuild(projectDir, init.buildStdio, mode); + } else { + reactRouterBuild(projectDir, init.buildStdio, mode); + } return projectDir; } -function build(projectDir: string, buildStdio?: Writable, mode?: ServerMode) { +function parcelBuild( + projectDir: string, + buildStdio?: Writable, + mode?: ServerMode +) { + let parcelBin = "node_modules/parcel/lib/bin.js"; + + let buildArgs: string[] = [parcelBin, "build", "--no-cache"]; + + let buildSpawn = spawnSync("node", buildArgs, { + cwd: projectDir, + env: { + ...process.env, + NODE_ENV: mode || ServerMode.Production, + }, + }); + + // These logs are helpful for debugging. Remove comments if needed. + // console.log("spawning node " + buildArgs.join(" ") + ":\n"); + // console.log(" STDOUT:"); + // console.log(" " + buildSpawn.stdout.toString("utf-8")); + // console.log(" STDERR:"); + // console.log(" " + buildSpawn.stderr.toString("utf-8")); + + if (buildStdio) { + buildStdio.write(buildSpawn.stdout.toString("utf-8")); + buildStdio.write(buildSpawn.stderr.toString("utf-8")); + buildStdio.end(); + } + + if (buildSpawn.error || buildSpawn.status) { + console.error(buildSpawn.stderr.toString("utf-8")); + throw buildSpawn.error || new Error(`Build failed, check the output above`); + } +} + +function reactRouterBuild( + projectDir: string, + buildStdio?: Writable, + mode?: ServerMode +) { // We have a "require" instead of a dynamic import in readConfig gated // behind mode === ServerMode.Test to make jest happy, but that doesn't // work for ESM configs, those MUST be dynamic imports. So we need to diff --git a/integration/helpers/rsc-parcel-framework/.gitignore b/integration/helpers/rsc-parcel-framework/.gitignore new file mode 100644 index 0000000000..0ad794a35c --- /dev/null +++ b/integration/helpers/rsc-parcel-framework/.gitignore @@ -0,0 +1,5 @@ +.parcel-cache +.react-router +.react-router-parcel +build +node_modules diff --git a/integration/helpers/rsc-parcel-framework/.parcelrc b/integration/helpers/rsc-parcel-framework/.parcelrc new file mode 100644 index 0000000000..49bb729196 --- /dev/null +++ b/integration/helpers/rsc-parcel-framework/.parcelrc @@ -0,0 +1,3 @@ +{ + "extends": "parcel-config-react-router-experimental" +} diff --git a/integration/helpers/rsc-parcel-framework/app/index.ts b/integration/helpers/rsc-parcel-framework/app/index.ts new file mode 100644 index 0000000000..3c5c0797d5 --- /dev/null +++ b/integration/helpers/rsc-parcel-framework/app/index.ts @@ -0,0 +1,3 @@ +import requestHandler from "virtual:react-router/request-handler"; + +export { requestHandler }; diff --git a/integration/helpers/rsc-parcel-framework/app/root.tsx b/integration/helpers/rsc-parcel-framework/app/root.tsx new file mode 100644 index 0000000000..9f824e6c53 --- /dev/null +++ b/integration/helpers/rsc-parcel-framework/app/root.tsx @@ -0,0 +1,18 @@ +import { Links, Meta, Outlet, ScrollRestoration } from "react-router"; + +export default function App() { + return ( + + + + + + + + + + + + + ); +} diff --git a/integration/helpers/rsc-parcel-framework/app/routes.ts b/integration/helpers/rsc-parcel-framework/app/routes.ts new file mode 100644 index 0000000000..4c05936cb6 --- /dev/null +++ b/integration/helpers/rsc-parcel-framework/app/routes.ts @@ -0,0 +1,4 @@ +import { type RouteConfig } from "@react-router/dev/routes"; +import { flatRoutes } from "@react-router/fs-routes"; + +export default flatRoutes() satisfies RouteConfig; diff --git a/integration/helpers/rsc-parcel-framework/app/routes/_index.tsx b/integration/helpers/rsc-parcel-framework/app/routes/_index.tsx new file mode 100644 index 0000000000..ecfc25c614 --- /dev/null +++ b/integration/helpers/rsc-parcel-framework/app/routes/_index.tsx @@ -0,0 +1,16 @@ +import type { MetaFunction } from "react-router"; + +export const meta: MetaFunction = () => { + return [ + { title: "New React Router App" }, + { name: "description", content: "Welcome to React Router!" }, + ]; +}; + +export default function Index() { + return ( + ++ ); +} diff --git a/integration/helpers/rsc-parcel-framework/package.json b/integration/helpers/rsc-parcel-framework/package.json new file mode 100644 index 0000000000..0ea4e32178 --- /dev/null +++ b/integration/helpers/rsc-parcel-framework/package.json @@ -0,0 +1,44 @@ +{ + "name": "@playground/rsc-parcel-framework", + "private": true, + "targets": { + "server": { + "source": "app/index.ts", + "distDir": "build", + "context": "react-server", + "scopeHoist": false, + "includeNodeModules": { + "express": false + } + } + }, + "scripts": { + "clean": "rm -rf dist .parcel-cache .react-router .react-router-parcel", + "dev": "parcel --no-cache --no-autoinstall", + "build": "parcel build --no-cache --no-autoinstall", + "start": "node start.js", + "typecheck": "react-router typegen && pnpm build && tsc" + }, + "devDependencies": { + "@react-router/dev": "workspace:*", + "@react-router/fs-routes": "workspace:*", + "@types/express": "^5.0.0", + "@types/node": "^22.13.1", + "@types/parcel-env": "0.0.8", + "@types/react-dom": "^19.0.3", + "@types/react": "^19.0.8", + "parcel": "2.15.0", + "parcel-config-react-router-experimental": "1.0.21", + "typescript": "^5.1.6" + }, + "dependencies": { + "@mjackson/node-fetch-server": "0.6.1", + "@parcel/runtime-rsc": "2.15.0", + "express": "^4.21.2", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router": "workspace:*", + "react-server-dom-parcel": "^19.0.0", + "remix-utils": "^8.7.0" + } +} diff --git a/integration/helpers/rsc-parcel-framework/public/favicon.ico b/integration/helpers/rsc-parcel-framework/public/favicon.ico new file mode 100644 index 0000000000..5dbdfcddcb Binary files /dev/null and b/integration/helpers/rsc-parcel-framework/public/favicon.ico differ diff --git a/integration/helpers/rsc-parcel-framework/start.js b/integration/helpers/rsc-parcel-framework/start.js new file mode 100644 index 0000000000..0abc11d55a --- /dev/null +++ b/integration/helpers/rsc-parcel-framework/start.js @@ -0,0 +1,19 @@ +const { createRequestListener } = require("@mjackson/node-fetch-server"); +const express = require("express"); +const reactRouterRequestHandler = + require("./build/server/index.js").requestHandler; + +const app = express(); + +app.use(express.static("public")); +app.use("/client", express.static("build/client")); + +app.get("/.well-known/appspecific/com.chrome.devtools.json", (_, res) => { + res.status(404); + res.send("Not Found"); +}); + +app.use(createRequestListener(reactRouterRequestHandler)); + +app.listen(3000); +console.log("Server listening on port 3000 (http://localhost:3000)"); diff --git a/integration/helpers/rsc-parcel-framework/tsconfig.json b/integration/helpers/rsc-parcel-framework/tsconfig.json new file mode 100644 index 0000000000..edcc15fde0 --- /dev/null +++ b/integration/helpers/rsc-parcel-framework/tsconfig.json @@ -0,0 +1,20 @@ +{ + "include": [ + "**/*.ts", + "**/*.tsx", + "./.react-router/types/**/*", + "./.react-router-parcel/types/**/*" + ], + "compilerOptions": { + "strict": true, + "jsx": "react-jsx", + "allowSyntheticDefaultImports": true, + "moduleResolution": "bundler", + "module": "esnext", + "isolatedModules": true, + "esModuleInterop": true, + "target": "es2022", + "noEmit": true, + "rootDirs": [".", "./.react-router/types", "./.react-router-parcel/types"] + } +} diff --git a/integration/helpers/rsc-parcel/.gitignore b/integration/helpers/rsc-parcel/.gitignore new file mode 100644 index 0000000000..77738287f0 --- /dev/null +++ b/integration/helpers/rsc-parcel/.gitignore @@ -0,0 +1 @@ +dist/ \ No newline at end of file diff --git a/integration/helpers/rsc-parcel/package.json b/integration/helpers/rsc-parcel/package.json new file mode 100644 index 0000000000..da7c3e5e76 --- /dev/null +++ b/integration/helpers/rsc-parcel/package.json @@ -0,0 +1,47 @@ +{ + "name": "@playground/rsc-parcel", + "private": true, + "source": "src/entry.ssr.tsx", + "server": "dist/server.js", + "targets": { + "server": { + "context": "react-server", + "includeNodeModules": { + "express": false + } + } + }, + "scripts": { + "dev": "parcel --no-cache", + "build": "parcel build --no-cache", + "start": "node dist/server.js", + "typecheck": "tsc" + }, + "devDependencies": { + "@parcel/packager-react-static": "2.15.0", + "@parcel/transformer-react-static": "2.15.0", + "@types/express": "^5.0.0", + "@types/node": "^22.13.1", + "@types/parcel-env": "0.0.8", + "@types/react-dom": "^19.0.3", + "@types/react": "^19.0.8", + "browserify-zlib": "^0.2.0", + "buffer": "^5.5.0||^6.0.0", + "events": "^3.1.0", + "parcel": "2.15.0", + "path-browserify": "^1.0.0", + "querystring-es3": "^0.2.1", + "stream-http": "^3.1.0", + "typescript": "^5.1.6", + "url": "^0.11.0" + }, + "dependencies": { + "@mjackson/node-fetch-server": "0.6.1", + "@parcel/runtime-rsc": "2.15.0", + "express": "^4.21.2", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-router": "workspace:*", + "react-server-dom-parcel": "^19.1.0" + } +} diff --git a/integration/helpers/rsc-parcel/public/favicon.ico b/integration/helpers/rsc-parcel/public/favicon.ico new file mode 100644 index 0000000000..5dbdfcddcb Binary files /dev/null and b/integration/helpers/rsc-parcel/public/favicon.ico differ diff --git a/integration/helpers/rsc-parcel/src/entry.browser.tsx b/integration/helpers/rsc-parcel/src/entry.browser.tsx new file mode 100644 index 0000000000..d604446534 --- /dev/null +++ b/integration/helpers/rsc-parcel/src/entry.browser.tsx @@ -0,0 +1,50 @@ +"use client-entry"; + +import * as React from "react"; +import { hydrateRoot } from "react-dom/client"; +import { + unstable_createCallServer as createCallServer, + unstable_getServerStream as getServerStream, + unstable_RSCHydratedRouter as RSCHydratedRouter, +} from "react-router"; +import type { unstable_ServerPayload as ServerPayload } from "react-router/rsc"; +import { + createFromReadableStream, + encodeReply, + setServerCallback, + // @ts-expect-error +} from "react-server-dom-parcel/client"; + +const callServer = createCallServer({ + decode: (body) => createFromReadableStream(body, { callServer }), + encodeAction: (args) => encodeReply(args), +}); + +setServerCallback(callServer); + +createFromReadableStream(getServerStream(), { assets: "manifest" }).then( + (payload: ServerPayload) => { + React.startTransition(() => { + hydrateRoot( + document, +Welcome to React Router
++ + ); + }); + } +); + +if (process.env.NODE_ENV !== "production") { + const ogError = console.error.bind(console); + console.error = (...args) => { + if (args[1] === Symbol.for("react-router.redirect")) { + return; + } + ogError(...args); + }; +} diff --git a/integration/helpers/rsc-parcel/src/entry.rsc.ts b/integration/helpers/rsc-parcel/src/entry.rsc.ts new file mode 100644 index 0000000000..6e52b6a682 --- /dev/null +++ b/integration/helpers/rsc-parcel/src/entry.rsc.ts @@ -0,0 +1,43 @@ +"use server-entry"; + +import { + decodeAction, + decodeReply, + loadServerAction, + renderToReadableStream, + // @ts-expect-error +} from "react-server-dom-parcel/server.edge"; +import { + type unstable_DecodeCallServerFunction as DecodeCallServerFunction, + type unstable_DecodeFormActionFunction as DecodeFormActionFunction, + unstable_matchRSCServerRequest as matchRSCServerRequest, +} from "react-router/rsc"; + +import { routes } from "./routes"; + +import "./entry.browser.tsx"; + +const decodeCallServer: DecodeCallServerFunction = async (actionId, reply) => { + const args = await decodeReply(reply); + const action = await loadServerAction(actionId); + return action.bind(null, ...args); +}; + +const decodeFormAction: DecodeFormActionFunction = async (formData) => { + return await decodeAction(formData); +}; + +export function callServer(request: Request) { + return matchRSCServerRequest({ + decodeCallServer, + decodeFormAction, + request, + routes, + generateResponse(match) { + return new Response(renderToReadableStream(match.payload), { + status: match.statusCode, + headers: match.headers, + }); + }, + }); +} diff --git a/integration/helpers/rsc-parcel/src/entry.ssr.tsx b/integration/helpers/rsc-parcel/src/entry.ssr.tsx new file mode 100644 index 0000000000..8e3a58c135 --- /dev/null +++ b/integration/helpers/rsc-parcel/src/entry.ssr.tsx @@ -0,0 +1,57 @@ +import { createRequestListener } from "@mjackson/node-fetch-server"; +import express from "express"; +// @ts-expect-error - no types +import { renderToReadableStream as renderHTMLToReadableStream } from "react-dom/server.edge" assert { env: "react-client" }; +import { + unstable_routeRSCServerRequest as routeRSCServerRequest, + unstable_RSCStaticRouter as RSCStaticRouter, +} from "react-router" assert { env: "react-client" }; +// @ts-expect-error +import { createFromReadableStream } from "react-server-dom-parcel/client.edge" assert { env: "react-client" }; + +import { callServer } from "./entry.rsc" assert { env: "react-server" }; + +const app = express(); + +app.use(express.static("public")); + +app.use("/client", express.static("dist/client")); + +app.get("/.well-known/appspecific/com.chrome.devtools.json", (req, res) => { + res.status(404); + res.end(); +}); + +app.use( + createRequestListener(async (request) => { + return routeRSCServerRequest({ + request, + callServer, + decode: createFromReadableStream, + async renderHTML(getPayload) { + return await renderHTMLToReadableStream( ++ , + { + bootstrapScriptContent: ( + callServer as unknown as { bootstrapScript: string } + ).bootstrapScript, + } + ); + }, + }); + }) +); + +const port = parseInt(process.env.RR_PORT || "3000", 10); +const server = app.listen(port, () => { + console.log(`Server started on http://localhost:${port}`); +}); + +// Restart the server when it changes. +if (module.hot) { + module.hot.dispose(() => { + server.close(); + }); + + module.hot.accept(); +} diff --git a/integration/helpers/rsc-parcel/src/routes.ts b/integration/helpers/rsc-parcel/src/routes.ts new file mode 100644 index 0000000000..d29bd3aca2 --- /dev/null +++ b/integration/helpers/rsc-parcel/src/routes.ts @@ -0,0 +1,16 @@ +import type { unstable_ServerRouteObject as ServerRouteObject } from "react-router/rsc"; + +export const routes = [ + { + id: "root", + path: "", + lazy: () => import("./routes/root"), + children: [ + { + id: "home", + index: true, + lazy: () => import("./routes/home"), + }, + ], + }, +] satisfies ServerRouteObject[]; diff --git a/integration/helpers/rsc-parcel/src/routes/home.tsx b/integration/helpers/rsc-parcel/src/routes/home.tsx new file mode 100644 index 0000000000..b5e522802e --- /dev/null +++ b/integration/helpers/rsc-parcel/src/routes/home.tsx @@ -0,0 +1,3 @@ +export default function HomeRoute() { + return Home
; +} diff --git a/integration/helpers/rsc-parcel/src/routes/root.tsx b/integration/helpers/rsc-parcel/src/routes/root.tsx new file mode 100644 index 0000000000..778a4858d6 --- /dev/null +++ b/integration/helpers/rsc-parcel/src/routes/root.tsx @@ -0,0 +1,22 @@ +import { Links, Outlet, ScrollRestoration } from "react-router"; + +export function Layout({ children }: { children: React.ReactNode }) { + return ( + + + + +Vite (RSC) ++ + + {children} + + + + ); +} + +export default function ServerComponent() { + return ; +} diff --git a/integration/helpers/rsc-parcel/tsconfig.json b/integration/helpers/rsc-parcel/tsconfig.json new file mode 100644 index 0000000000..009d4507cf --- /dev/null +++ b/integration/helpers/rsc-parcel/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "strict": true, + "jsx": "react-jsx", + "allowSyntheticDefaultImports": true, + "moduleResolution": "bundler", + "module": "esnext", + "isolatedModules": true, + "esModuleInterop": true, + "target": "es2022", + "noEmit": true + } +} diff --git a/integration/helpers/rsc-vite/.gitignore b/integration/helpers/rsc-vite/.gitignore new file mode 100644 index 0000000000..de4d1f007d --- /dev/null +++ b/integration/helpers/rsc-vite/.gitignore @@ -0,0 +1,2 @@ +dist +node_modules diff --git a/integration/helpers/rsc-vite/package.json b/integration/helpers/rsc-vite/package.json new file mode 100644 index 0000000000..12413185f1 --- /dev/null +++ b/integration/helpers/rsc-vite/package.json @@ -0,0 +1,29 @@ +{ + "name": "integration-rsc-vite", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build --app", + "start": "cross-env NODE_ENV=production node server.js", + "typecheck": "tsc" + }, + "devDependencies": { + "@hiogawa/vite-rsc": "0.4.2", + "@types/express": "^5.0.0", + "@types/node": "^22.13.1", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@vitejs/plugin-react": "^4.5.2", + "typescript": "^5.1.6", + "vite": "^6.2.0" + }, + "dependencies": { + "@mjackson/node-fetch-server": "0.6.1", + "compression": "^1.8.0", + "express": "^4.21.2", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router": "workspace:*" + } +} diff --git a/integration/helpers/rsc-vite/public/favicon.ico b/integration/helpers/rsc-vite/public/favicon.ico new file mode 100644 index 0000000000..5dbdfcddcb Binary files /dev/null and b/integration/helpers/rsc-vite/public/favicon.ico differ diff --git a/integration/helpers/rsc-vite/server.js b/integration/helpers/rsc-vite/server.js new file mode 100644 index 0000000000..c897acd7ad --- /dev/null +++ b/integration/helpers/rsc-vite/server.js @@ -0,0 +1,28 @@ +import { parseArgs } from "node:util"; +import { createRequestListener } from "@mjackson/node-fetch-server"; +import compression from "compression"; +import express from "express"; + +import rscRequestHandler from "./dist/rsc/index.js"; + +const app = express(); + +app.use(compression()); +app.use(express.static("dist/client")); + +app.get("/.well-known/appspecific/com.chrome.devtools.json", (req, res) => { + res.status(404); + res.end(); +}); + +app.use(createRequestListener(rscRequestHandler)); + +const { values } = parseArgs({ + options: { p: { type: "string", default: "3000" } }, + allowPositionals: true, +}); + +const port = parseInt(values.p, 10); +app.listen(port, () => { + console.log(`Server started on http://localhost:${port}`); +}); diff --git a/integration/helpers/rsc-vite/src/entry.browser.tsx b/integration/helpers/rsc-vite/src/entry.browser.tsx new file mode 100644 index 0000000000..d952ef68ae --- /dev/null +++ b/integration/helpers/rsc-vite/src/entry.browser.tsx @@ -0,0 +1,36 @@ +import { startTransition, StrictMode } from "react"; +import { hydrateRoot } from "react-dom/client"; +import { + createFromReadableStream, + encodeReply, + setServerCallback, +} from "@hiogawa/vite-rsc/browser"; +import type { unstable_DecodeServerResponseFunction as DecodeServerResponseFunction } from "react-router"; +import { + unstable_createCallServer as createCallServer, + unstable_getServerStream as getServerStream, + unstable_RSCHydratedRouter as RSCHydratedRouter, +} from "react-router"; +import type { unstable_ServerPayload as ServerPayload } from "react-router/rsc"; + +const decode: DecodeServerResponseFunction = ( + body: ReadableStream +) => createFromReadableStream(body); + +setServerCallback( + createCallServer({ + decode, + encodeAction: (args) => encodeReply(args), + }) +); + +createFromReadableStream (getServerStream()).then((payload) => { + startTransition(() => { + hydrateRoot( + document, + + + ); + }); +}); diff --git a/integration/helpers/rsc-vite/src/entry.rsc.tsx b/integration/helpers/rsc-vite/src/entry.rsc.tsx new file mode 100644 index 0000000000..ebd05d15de --- /dev/null +++ b/integration/helpers/rsc-vite/src/entry.rsc.tsx @@ -0,0 +1,46 @@ +import { + decodeAction, + decodeReply, + loadServerAction, + renderToReadableStream, +} from "@hiogawa/vite-rsc/rsc"; +import { + type unstable_DecodeCallServerFunction as DecodeCallServerFunction, + type unstable_DecodeFormActionFunction as DecodeFormActionFunction, + unstable_matchRSCServerRequest as matchRSCServerRequest, +} from "react-router/rsc"; + +import { routes } from "./routes"; + +const decodeCallServer: DecodeCallServerFunction = async (actionId, reply) => { + const args = await decodeReply(reply); + const action = await loadServerAction(actionId); + return action.bind(null, ...args); +}; + +const decodeFormAction: DecodeFormActionFunction = async (formData) => { + return await decodeAction(formData); +}; + +export async function callServer(request: Request) { + return await matchRSCServerRequest({ + decodeCallServer, + decodeFormAction, + request, + routes, + generateResponse(match) { + return new Response(renderToReadableStream(match.payload), { + status: match.statusCode, + headers: match.headers, + }); + }, + }); +} + +export default async function handler(request: Request) { + const ssr = await import.meta.viteRsc.loadSsrModule< + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + typeof import("./entry.ssr") + >("index"); + return ssr.default(request, callServer); +} diff --git a/integration/helpers/rsc-vite/src/entry.ssr.tsx b/integration/helpers/rsc-vite/src/entry.ssr.tsx new file mode 100644 index 0000000000..e7d58cad43 --- /dev/null +++ b/integration/helpers/rsc-vite/src/entry.ssr.tsx @@ -0,0 +1,28 @@ +import bootstrapScriptContent from "virtual:vite-rsc/bootstrap-script-content"; +import { createFromReadableStream } from "@hiogawa/vite-rsc/ssr"; +// @ts-expect-error +import * as ReactDomServer from "react-dom/server.edge"; +import { + unstable_RSCStaticRouter as RSCStaticRouter, + unstable_routeRSCServerRequest as routeRSCServerRequest, +} from "react-router"; + +export default async function handler( + request: Request, + callServer: (request: Request) => Promise+ +) { + return routeRSCServerRequest({ + request, + callServer, + decode: (body) => createFromReadableStream(body), + renderHTML(getPayload) { + return ReactDomServer.renderToReadableStream( + , + { + bootstrapScriptContent, + signal: request.signal, + } + ); + }, + }); +} diff --git a/integration/helpers/rsc-vite/src/routes.ts b/integration/helpers/rsc-vite/src/routes.ts new file mode 100644 index 0000000000..d29bd3aca2 --- /dev/null +++ b/integration/helpers/rsc-vite/src/routes.ts @@ -0,0 +1,16 @@ +import type { unstable_ServerRouteObject as ServerRouteObject } from "react-router/rsc"; + +export const routes = [ + { + id: "root", + path: "", + lazy: () => import("./routes/root"), + children: [ + { + id: "home", + index: true, + lazy: () => import("./routes/home"), + }, + ], + }, +] satisfies ServerRouteObject[]; diff --git a/integration/helpers/rsc-vite/src/routes/home.tsx b/integration/helpers/rsc-vite/src/routes/home.tsx new file mode 100644 index 0000000000..b5e522802e --- /dev/null +++ b/integration/helpers/rsc-vite/src/routes/home.tsx @@ -0,0 +1,3 @@ +export default function HomeRoute() { + return Home
; +} diff --git a/integration/helpers/rsc-vite/src/routes/root.tsx b/integration/helpers/rsc-vite/src/routes/root.tsx new file mode 100644 index 0000000000..d498b32ffa --- /dev/null +++ b/integration/helpers/rsc-vite/src/routes/root.tsx @@ -0,0 +1,22 @@ +import { Links, Outlet, ScrollRestoration } from "react-router"; + +export function Layout({ children }: { children: React.ReactNode }) { + return ( + + + + +Vite (RSC) ++ + + {children} + + + + ); +} + +export default function RootRoute() { + return ; +} diff --git a/integration/helpers/rsc-vite/tsconfig.json b/integration/helpers/rsc-vite/tsconfig.json new file mode 100644 index 0000000000..b795eea593 --- /dev/null +++ b/integration/helpers/rsc-vite/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "allowImportingTsExtensions": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "moduleResolution": "Bundler", + "module": "ESNext", + "target": "ESNext", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "types": ["vite/client", "@hiogawa/vite-rsc/types"], + "jsx": "react-jsx" + } +} diff --git a/integration/helpers/rsc-vite/vite.config.ts b/integration/helpers/rsc-vite/vite.config.ts new file mode 100644 index 0000000000..274649c19c --- /dev/null +++ b/integration/helpers/rsc-vite/vite.config.ts @@ -0,0 +1,16 @@ +import rsc from "@hiogawa/vite-rsc/plugin"; +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [ + react(), + rsc({ + entries: { + client: "src/entry.browser.tsx", + rsc: "src/entry.rsc.tsx", + ssr: "src/entry.ssr.tsx", + }, + }), + ], +}); diff --git a/integration/helpers/vite.ts b/integration/helpers/vite.ts index 862877b916..f30f6b699c 100644 --- a/integration/helpers/vite.ts +++ b/integration/helpers/vite.ts @@ -1,4 +1,5 @@ -import { spawn, spawnSync, type ChildProcess } from "node:child_process"; +import type { ChildProcess } from "node:child_process"; +import { sync as spawnSync, spawn } from "cross-spawn"; import { cp, mkdir, readFile, writeFile } from "node:fs/promises"; import { createRequire } from "node:module"; import { platform } from "node:os"; @@ -185,14 +186,27 @@ export const EXPRESS_SERVER = (args: { app.listen(port, () => console.log('http://localhost:' + port)); `; -export type TemplateName = - | "cloudflare-dev-proxy-template" +type FrameworkModeViteMajorTemplateName = | "vite-5-template" | "vite-6-template" | "vite-7-beta-template" | "vite-plugin-cloudflare-template" | "vite-rolldown-template"; +type FrameworkModeRscTemplateName = "rsc-parcel-framework"; + +type FrameworkModeCloudflareTemplateName = + | "cloudflare-dev-proxy-template" + | "vite-plugin-cloudflare-template"; + +export type RscBundlerTemplateName = "rsc-vite" | "rsc-parcel"; + +export type TemplateName = + | FrameworkModeViteMajorTemplateName + | FrameworkModeRscTemplateName + | FrameworkModeCloudflareTemplateName + | RscBundlerTemplateName; + export const viteMajorTemplates = [ { templateName: "vite-5-template", templateDisplayName: "Vite 5" }, { templateName: "vite-6-template", templateDisplayName: "Vite 6" }, @@ -202,7 +216,15 @@ export const viteMajorTemplates = [ templateDisplayName: "Vite Rolldown", }, ] as const satisfies Array<{ - templateName: TemplateName; + templateName: FrameworkModeViteMajorTemplateName; + templateDisplayName: string; +}>; + +export const rscBundlerTemplates = [ + { templateName: "rsc-vite", templateDisplayName: "RSC (Vite)" }, + { templateName: "rsc-parcel", templateDisplayName: "RSC (Parcel)" }, +] as const satisfies Array<{ + templateName: RscBundlerTemplateName; templateDisplayName: string; }>; @@ -320,7 +342,7 @@ type ServerArgs = { basename?: string; }; -const createDev = +export const createDev = (nodeArgs: string[]) => async ({ cwd, port, env, basename }: ServerArgs): Promise<() => unknown> => { let proc = node(nodeArgs, { cwd, env }); @@ -460,7 +482,9 @@ async function waitForServer( await waitOn({ resources: [ - `http://${args.host ?? "localhost"}:${args.port}${args.basename ?? "/"}`, + `http://${args.host ?? "localhost"}:${args.port}${ + args.basename ?? "/favicon.ico" + }`, ], timeout: platform() === "win32" ? 20000 : 10000, }).catch((err) => { diff --git a/integration/package.json b/integration/package.json index 36ec79ee46..fae185affc 100644 --- a/integration/package.json +++ b/integration/package.json @@ -8,6 +8,7 @@ "typecheck": "tsc" }, "dependencies": { + "@mjackson/node-fetch-server": "0.6.1", "@playwright/test": "^1.49.1", "@react-router/dev": "workspace:*", "@react-router/express": "workspace:*", diff --git a/integration/playwright.config.ts b/integration/playwright.config.ts index da6159be6b..965aacd57d 100644 --- a/integration/playwright.config.ts +++ b/integration/playwright.config.ts @@ -18,7 +18,8 @@ const config: PlaywrightTestConfig = { }, /* Maximum time one test can run for. */ timeout: isWindows ? 60_000 : 30_000, - fullyParallel: true, + fullyParallel: !(isWindows && process.env.CI), + workers: isWindows && process.env.CI ? 1 : undefined, expect: { /* Maximum time expect() should wait for the condition to be met. */ timeout: isWindows ? 10_000 : 5_000, diff --git a/integration/revalidate-test.ts b/integration/revalidate-test.ts index b14c73ff5d..5ccdfa79b8 100644 --- a/integration/revalidate-test.ts +++ b/integration/revalidate-test.ts @@ -77,7 +77,7 @@ test.describe("Revalidation", () => { let data = useLoaderData(); return ( <> - {'Value:' + data.value}
+{'Value:' + data.value}
> ); @@ -122,7 +122,7 @@ test.describe("Revalidation", () => { let revalidator = useRevalidator(); return ( <> - {'Value:' + data.value}
+{'Value:' + data.value}