From 4b464a9ac2c566841d08722181e4d5f34f36f64b Mon Sep 17 00:00:00 2001 From: Remix Run Bot Date: Thu, 14 May 2026 14:42:20 +0000 Subject: [PATCH 01/23] chore: format --- CHANGELOG.md | 3 +-- packages/react-router-dev/CHANGELOG.md | 1 + packages/react-router/CHANGELOG.md | 3 +-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 325cc08f7d..b8fdc10677 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -199,10 +199,9 @@ Date: 2026-05-13 ### Unstable Changes -⚠️ _[Unstable features](https://reactrouter.com/community/api-development-strategy#unstable-flags) are not recommended for production use_ +⚠️ _[Unstable features](https://reactrouter.com/community/api-development-strategy#unstable-flags) are not recommended for production use_ - `react-router` - Add a new `unstable_useRouterState()` hook that consolidates access to active and pending router states (RFC: #12358) ([#15017](https://github.com/remix-run/react-router/pull/15017)) - - Data/Framework/RSC only — throws when used without a data router - This should allow you to consolidate usages of the following hooks which will likely be deprecated and removed in a future major version - `useLocation` diff --git a/packages/react-router-dev/CHANGELOG.md b/packages/react-router-dev/CHANGELOG.md index 6fd57ce369..e3670fd2bd 100644 --- a/packages/react-router-dev/CHANGELOG.md +++ b/packages/react-router-dev/CHANGELOG.md @@ -11,6 +11,7 @@ strip the base prefix from server-build virtual module import paths, causing "Failed to load url /root.tsx" errors. The fix uses `/@fs/` absolute paths for those imports to bypass Vite's base-stripping logic. + - Updated dependencies: - [`react-router@7.15.1`](https://github.com/remix-run/react-router/releases/tag/react-router@7.15.1) - [`@react-router/node@7.15.1`](https://github.com/remix-run/react-router/releases/tag/@react-router/node@7.15.1) diff --git a/packages/react-router/CHANGELOG.md b/packages/react-router/CHANGELOG.md index 282156b275..df0d2788db 100644 --- a/packages/react-router/CHANGELOG.md +++ b/packages/react-router/CHANGELOG.md @@ -12,10 +12,9 @@ ### Unstable Changes -⚠️ _[Unstable features](https://reactrouter.com/community/api-development-strategy#unstable-flags) are not recommended for production use_ +⚠️ _[Unstable features](https://reactrouter.com/community/api-development-strategy#unstable-flags) are not recommended for production use_ - Add a new `unstable_useRouterState()` hook that consolidates access to active and pending router states (RFC: #12358) ([#15017](https://github.com/remix-run/react-router/pull/15017)) - - Data/Framework/RSC only — throws when used without a data router - This should allow you to consolidate usages of the following hooks which will likely be deprecated and removed in a future major version - `useLocation` From c35008f7f4abbf0872a4934e4e83e0f18046eb40 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 14 May 2026 10:58:41 -0400 Subject: [PATCH 02/23] feat(@react-router/dev): log future flag warnings for upcoming v8 flags (#15029) * feat(@react-router/dev): log future flag warnings for upcoming v8 flags During config resolution, warn about stable v8_ future flags the user hasn't yet opted into, pointing them to the upgrade guide. This mirrors the pattern used in Remix v2 to nudge users toward adopting future flags before they become mandatory. Warns for: v8_middleware, v8_splitRouteModules, v8_viteEnvironmentApi, and v8_passThroughRequests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix tests * fix --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- integration/cli-test.ts | 12 +++- .../.changes/minor.v8-future-flag-warnings.md | 3 + .../__tests__/future-flags-test.ts | 57 +++++++++++++++++++ packages/react-router-dev/config/config.ts | 39 +++++++++++++ 4 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 packages/react-router-dev/.changes/minor.v8-future-flag-warnings.md create mode 100644 packages/react-router-dev/__tests__/future-flags-test.ts diff --git a/integration/cli-test.ts b/integration/cli-test.ts index 366dfa71c4..77b99c5eae 100644 --- a/integration/cli-test.ts +++ b/integration/cli-test.ts @@ -112,7 +112,17 @@ test.describe("cli", () => { test("routes", async () => { const cwd = await createProject(); let { stdout, stderr, status } = run(["routes"], { cwd }); - expect(stdout.toString().trim()).toBe(dedent` + + // Filter out future flag warnings for the format: + // ⚠️ Future Flag Warning: Route module splitting behavior is changing in React Router v8. + // You can use the `future.v8_splitRouteModules` flag to opt in early. + // -> https://reactrouter.com/upgrading/future-flags#v8_splitRouteModules + let filteredStdOut = stdout.toString().split("\n"); + while (filteredStdOut[0]?.includes("Future Flag Warning:")) { + filteredStdOut.splice(0, 3); + } + + expect(filteredStdOut.join("\n").trim()).toBe(dedent` diff --git a/packages/react-router-dev/.changes/minor.v8-future-flag-warnings.md b/packages/react-router-dev/.changes/minor.v8-future-flag-warnings.md new file mode 100644 index 0000000000..a19b06c39f --- /dev/null +++ b/packages/react-router-dev/.changes/minor.v8-future-flag-warnings.md @@ -0,0 +1,3 @@ +Log future flag warnings for upcoming React Router v8 flags + +- `v8_middleware`, `v8_splitRouteModules`, `v8_viteEnvironmentApi`, `v8_passThroughRequests` diff --git a/packages/react-router-dev/__tests__/future-flags-test.ts b/packages/react-router-dev/__tests__/future-flags-test.ts new file mode 100644 index 0000000000..59c5c8c9f6 --- /dev/null +++ b/packages/react-router-dev/__tests__/future-flags-test.ts @@ -0,0 +1,57 @@ +import { logFutureFlagWarnings } from "../config/config"; + +describe("logFutureFlagWarnings", () => { + let logSpy: jest.SpyInstance; + + beforeEach(() => { + logSpy = jest.spyOn(console, "log").mockImplementation(() => {}); + }); + + afterEach(() => { + logSpy.mockRestore(); + }); + + it("warns about all stable v8_ flags when none are set", () => { + logFutureFlagWarnings({}); + + expect(logSpy).toHaveBeenCalledTimes(4); + expect(logSpy.mock.calls[0][0]).toContain("v8_middleware"); + expect(logSpy.mock.calls[1][0]).toContain("v8_splitRouteModules"); + expect(logSpy.mock.calls[2][0]).toContain("v8_viteEnvironmentApi"); + expect(logSpy.mock.calls[3][0]).toContain("v8_passThroughRequests"); + }); + + it("does not warn about flags that are already opted in (true)", () => { + logFutureFlagWarnings({ + v8_middleware: true, + v8_splitRouteModules: true, + v8_viteEnvironmentApi: true, + v8_passThroughRequests: true, + }); + + expect(logSpy).not.toHaveBeenCalled(); + }); + + it("does not warn about flags that are explicitly opted out (false)", () => { + logFutureFlagWarnings({ + v8_middleware: false, + v8_splitRouteModules: false, + v8_viteEnvironmentApi: false, + v8_passThroughRequests: false, + }); + + expect(logSpy).not.toHaveBeenCalled(); + }); + + it("only warns about flags that are not yet set", () => { + logFutureFlagWarnings({ + v8_middleware: true, + v8_passThroughRequests: false, + }); + + // Only v8_splitRouteModules and v8_viteEnvironmentApi are missing + expect(logSpy).toHaveBeenCalledTimes(2); + expect(logSpy.mock.calls[0][0]).toContain("v8_splitRouteModules"); + expect(logSpy.mock.calls[1][0]).toContain("v8_viteEnvironmentApi"); + }); +}); diff --git a/packages/react-router-dev/config/config.ts b/packages/react-router-dev/config/config.ts index 52d1ff1f1d..9c95a1d89e 100644 --- a/packages/react-router-dev/config/config.ts +++ b/packages/react-router-dev/config/config.ts @@ -753,9 +753,48 @@ async function resolveConfig({ await preset.reactRouterConfigResolved?.({ reactRouterConfig }); } + logFutureFlagWarnings(userAndPresetConfigs.future || {}); + return ok(reactRouterConfig); } +function logFutureFlagWarning(flag: string, message: string): void { + console.log( + colors.yellow( + ` ⚠️ Future Flag Warning: ${message}\n` + + ` You can use the \`future.${flag}\` flag to opt in early.\n` + + ` -> https://reactrouter.com/upgrading/future-flags#${flag}`, + ), + ); +} + +export function logFutureFlagWarnings(future: Partial): void { + if (future.v8_middleware === undefined) { + logFutureFlagWarning( + "v8_middleware", + "Route middleware support is changing in React Router v8.", + ); + } + if (future.v8_splitRouteModules === undefined) { + logFutureFlagWarning( + "v8_splitRouteModules", + "Route module splitting behavior is changing in React Router v8.", + ); + } + if (future.v8_viteEnvironmentApi === undefined) { + logFutureFlagWarning( + "v8_viteEnvironmentApi", + "Vite Environment API usage is changing in React Router v8.", + ); + } + if (future.v8_passThroughRequests === undefined) { + logFutureFlagWarning( + "v8_passThroughRequests", + "Request handling behavior is changing in React Router v8.", + ); + } +} + type ChokidarEventName = ChokidarEmitArgs[0]; type ChangeHandler = (args: { From 16eb79e3c301d63628895960700faab38526c9a9 Mon Sep 17 00:00:00 2001 From: dfedoryshchev <64079946+dfedoryshchev@users.noreply.github.com> Date: Fri, 15 May 2026 16:51:46 +0100 Subject: [PATCH 03/23] test: fix typo in test description (#15053) --- packages/react-router/__tests__/generatePath-test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-router/__tests__/generatePath-test.tsx b/packages/react-router/__tests__/generatePath-test.tsx index 3bf3a18433..c0bad0d374 100644 --- a/packages/react-router/__tests__/generatePath-test.tsx +++ b/packages/react-router/__tests__/generatePath-test.tsx @@ -141,7 +141,7 @@ describe("generatePath", () => { }); }); - it("throws only on on missing named parameters, but not missing splat params", () => { + it("throws only on missing named parameters, but not missing splat params", () => { expect(() => generatePath(":foo")).toThrow(); expect(() => generatePath("/:foo")).toThrow(); expect(() => generatePath("*")).not.toThrow(); From cfb5fb0f41bfd8034bd5931f0b9a8e4da8ed0f11 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Mon, 18 May 2026 11:20:19 -0400 Subject: [PATCH 04/23] Disable manifest handler when lazy route discovery is disabled (#15068) --- .../.changes/patch.disable-manifest-route.md | 1 + .../__tests__/server-runtime/server-test.ts | 116 ++++++++++++++++++ .../__tests__/server-runtime/utils.ts | 3 +- .../react-router/lib/server-runtime/server.ts | 5 +- 4 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 packages/react-router/.changes/patch.disable-manifest-route.md diff --git a/packages/react-router/.changes/patch.disable-manifest-route.md b/packages/react-router/.changes/patch.disable-manifest-route.md new file mode 100644 index 0000000000..4e80b06f25 --- /dev/null +++ b/packages/react-router/.changes/patch.disable-manifest-route.md @@ -0,0 +1 @@ +Disable manifest path when lazy route dicovery is disabled diff --git a/packages/react-router/__tests__/server-runtime/server-test.ts b/packages/react-router/__tests__/server-runtime/server-test.ts index 725c49792e..10a28d06e2 100644 --- a/packages/react-router/__tests__/server-runtime/server-test.ts +++ b/packages/react-router/__tests__/server-runtime/server-test.ts @@ -2081,6 +2081,122 @@ describe("shared server runtime", () => { }); }); + describe("manifest requests", () => { + test("returns manifest patches", async () => { + let build = mockServerBuild({ + root: { + default: {}, + }, + "routes/a": { + path: "a", + }, + "routes/a.b": { + path: "a/b", + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request( + `${baseUrl}/__manifest?paths=%2Fa&version=${build.assets.version}`, + ); + + let result = await handler(request); + expect(result.status).toBe(200); + expect(await result.json()).toEqual({ + "routes/a": { + hasAction: false, + hasClientAction: false, + hasClientLoader: false, + hasClientMiddleware: false, + hasErrorBoundary: false, + hasLoader: false, + id: "routes/a", + module: "", + path: "a", + }, + }); + }); + + test("returns nested manifest patches", async () => { + let build = mockServerBuild({ + root: { + default: {}, + }, + "routes/a": { + path: "a", + }, + "routes/a.b": { + path: "b", + parentId: "routes/a", + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request( + `${baseUrl}/__manifest?paths=%2Fa%2Fb&version=${build.assets.version}`, + ); + + let result = await handler(request); + expect(result.status).toBe(200); + expect(await result.json()).toEqual({ + "routes/a": { + hasAction: false, + hasClientAction: false, + hasClientLoader: false, + hasClientMiddleware: false, + hasErrorBoundary: false, + hasLoader: false, + id: "routes/a", + module: "", + path: "a", + }, + "routes/a.b": { + hasAction: false, + hasClientAction: false, + hasClientLoader: false, + hasClientMiddleware: false, + hasErrorBoundary: false, + hasLoader: false, + id: "routes/a.b", + parentId: "routes/a", + module: "", + path: "b", + }, + }); + }); + + test("disabled when route discovery is disabled", async () => { + let build = mockServerBuild( + { + root: { + default: {}, + }, + "routes/a": { + path: "a", + }, + "routes/a.b": { + path: "a/b", + }, + }, + { + routeDiscovery: { + mode: "initial", + manifestPath: "", + }, + }, + ); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request( + `${baseUrl}/__manifest?paths=%2Fa&version=${build.assets.version}`, + ); + + let result = await handler(request); + expect(result.status).toBe(404); + expect(await result.text()).toBe(""); + }); + }); + test("provides load context to server entrypoint", async () => { let rootLoader = jest.fn(() => { return "root"; diff --git a/packages/react-router/__tests__/server-runtime/utils.ts b/packages/react-router/__tests__/server-runtime/utils.ts index fb5759a4fd..51fe758889 100644 --- a/packages/react-router/__tests__/server-runtime/utils.ts +++ b/packages/react-router/__tests__/server-runtime/utils.ts @@ -37,6 +37,7 @@ export function mockServerBuild( handleError?: HandleErrorFunction; handleDocumentRequest?: HandleDocumentRequestFunction; instrumentations?: ServerInstrumentation[]; + routeDiscovery?: ServerBuild["routeDiscovery"]; } = {}, ): ServerBuild { return { @@ -47,7 +48,7 @@ export function mockServerBuild( }, prerender: [], isSpaMode: false, - routeDiscovery: { + routeDiscovery: opts.routeDiscovery || { mode: "lazy", manifestPath: "/__manifest", }, diff --git a/packages/react-router/lib/server-runtime/server.ts b/packages/react-router/lib/server-runtime/server.ts index 40ab71d101..9c756c5260 100644 --- a/packages/react-router/lib/server-runtime/server.ts +++ b/packages/react-router/lib/server-runtime/server.ts @@ -191,7 +191,10 @@ function derive(build: ServerBuild, mode?: string) { build.routeDiscovery.manifestPath, build.basename, ); - if (requestUrl.pathname === manifestUrl) { + if ( + build.routeDiscovery.mode === "lazy" && + requestUrl.pathname === manifestUrl + ) { try { let res = await handleManifestRequest( build, From dc996ea6a4edc08c37ca1f80e2c5c6c88e64377e Mon Sep 17 00:00:00 2001 From: Jeevan Mohan Pawar <35501212+jeevan6996@users.noreply.github.com> Date: Mon, 18 May 2026 16:42:23 +0100 Subject: [PATCH 05/23] fix(router): pass configured window to createBrowserURLImpl (#15066) --- contributors.yml | 1 + .../patch.fix-browser-history-window-url-base.md | 3 +++ .../__tests__/router/browser-test.ts | 10 ++++++++++ packages/react-router/lib/router/history.ts | 16 ++++++++++------ packages/react-router/lib/router/router.ts | 2 +- 5 files changed, 25 insertions(+), 7 deletions(-) create mode 100644 packages/react-router/.changes/patch.fix-browser-history-window-url-base.md diff --git a/contributors.yml b/contributors.yml index d1fb4af96a..a1626c4de7 100644 --- a/contributors.yml +++ b/contributors.yml @@ -202,6 +202,7 @@ - jb-1980 - jclarkin - jdufresne +- jeevan6996 - jenseng - JeraldVin - JesusTheHun diff --git a/packages/react-router/.changes/patch.fix-browser-history-window-url-base.md b/packages/react-router/.changes/patch.fix-browser-history-window-url-base.md new file mode 100644 index 0000000000..911076a94c --- /dev/null +++ b/packages/react-router/.changes/patch.fix-browser-history-window-url-base.md @@ -0,0 +1,3 @@ +Fix browser URL creation to use the configured history window instead of the global window. + +- Pass the history/router window through to `createBrowserURLImpl` so custom window contexts keep the correct URL origin. diff --git a/packages/react-router/__tests__/router/browser-test.ts b/packages/react-router/__tests__/router/browser-test.ts index b994d0f1bf..748d851f8e 100644 --- a/packages/react-router/__tests__/router/browser-test.ts +++ b/packages/react-router/__tests__/router/browser-test.ts @@ -4,6 +4,7 @@ import { type BrowserHistory, createBrowserHistory, } from "../../lib/router/history"; +import { JSDOM } from "jsdom"; import InitialLocationDefaultKey from "./TestSequences/InitialLocationDefaultKey"; import Listen from "./TestSequences/Listen"; @@ -59,6 +60,15 @@ describe("a browser history", () => { expect(unencodedHref).toEqual("/#abc"); }); + it("uses the configured window when creating URLs", () => { + const customWindow = new JSDOM(``, { + url: "https://example.com/", + }).window as unknown as Window; + const customHistory = createBrowserHistory({ window: customWindow }); + + expect(customHistory.createURL("/test").origin).toBe("https://example.com"); + }); + describe("listen", () => { it("does not immediately call listeners", () => { Listen(history); diff --git a/packages/react-router/lib/router/history.ts b/packages/react-router/lib/router/history.ts index 29821d3bcf..46254b02c8 100644 --- a/packages/react-router/lib/router/history.ts +++ b/packages/react-router/lib/router/history.ts @@ -729,7 +729,7 @@ function getUrlBasedHistory( } function createURL(to: To): URL { - return createBrowserURLImpl(to); + return createBrowserURLImpl(window, to); } let history: History = { @@ -774,16 +774,20 @@ function getUrlBasedHistory( return history; } -export function createBrowserURLImpl(to: To, isAbsolute = false): URL { +export function createBrowserURLImpl( + windowImpl: Window, + to: To, + isAbsolute = false, +): URL { let base = "http://localhost"; - if (typeof window !== "undefined") { + if (windowImpl) { // window.location.origin is "null" (the literal string value) in Firefox // under certain conditions, notably when serving from a local HTML file // See https://bugzilla.mozilla.org/show_bug.cgi?id=878297 base = - window.location.origin !== "null" - ? window.location.origin - : window.location.href; + windowImpl.location.origin !== "null" + ? windowImpl.location.origin + : windowImpl.location.href; } invariant(base, "No window.location.(origin|href) available to create URL"); diff --git a/packages/react-router/lib/router/router.ts b/packages/react-router/lib/router/router.ts index 90a98040ec..713e3dba62 100644 --- a/packages/react-router/lib/router/router.ts +++ b/packages/react-router/lib/router/router.ts @@ -3228,7 +3228,7 @@ export function createRouter(init: RouterInit): Router { } else if (isAbsoluteUrl(location)) { // We skip `history.createURL` here for absolute URLs because we don't // want to inherit the current `window.location` base URL - const url = createBrowserURLImpl(location, true); + const url = createBrowserURLImpl(routerWindow, location, true); isDocumentReload = // Hard reload if it's an absolute URL to a new origin url.origin !== routerWindow.location.origin || From 04dcb508898fac3a4f05418062fc2955d7265914 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Mon, 18 May 2026 11:45:24 -0400 Subject: [PATCH 06/23] Move change file workflow to 2-step run (#15069) --- .github/workflows/change-file-comment.yml | 52 +++++++++++++++ .../{changes-file.yml => change-file.yml} | 17 +++-- scripts/changes/check-pr.ts | 63 ++++++++++++++----- 3 files changed, 110 insertions(+), 22 deletions(-) create mode 100644 .github/workflows/change-file-comment.yml rename .github/workflows/{changes-file.yml => change-file.yml} (65%) diff --git a/.github/workflows/change-file-comment.yml b/.github/workflows/change-file-comment.yml new file mode 100644 index 0000000000..ff51cbfeef --- /dev/null +++ b/.github/workflows/change-file-comment.yml @@ -0,0 +1,52 @@ +name: 📝 Change File (Comment) + +# Runs after the "Change File (Check)" workflow completes. That workflow runs +# under the `pull_request` trigger with a read-only token (safe for PRs from +# forks); this workflow runs under `workflow_run` with write permissions to +# post the sticky comment, but never executes any PR code — it only reads +# the artifact produced by the upstream workflow. + +on: + workflow_run: + workflows: ["📝 Change File (Check)"] + types: [completed] + +jobs: + comment: + name: 📝 Post Comment + if: > + github.event.workflow_run.conclusion == 'success' && + github.repository == 'remix-run/react-router' + runs-on: ubuntu-latest + permissions: + actions: read # Read artifact + issues: write + pull-requests: write + + steps: + - name: ⬇️ Checkout repo + uses: actions/checkout@v6 + + - name: 📦 Setup pnpm + uses: pnpm/action-setup@v6 + + - name: ⎔ Setup node + uses: actions/setup-node@v6 + with: + node-version-file: ".nvmrc" + cache: pnpm + + - name: 📥 Install deps + run: pnpm install --frozen-lockfile + + - name: 📥 Download result from upstream workflow + uses: actions/download-artifact@v4 + with: + name: pr-check-result + github-token: ${{ github.token }} + run-id: ${{ github.event.workflow_run.id }} + + - name: 💬 Post comment + env: + GITHUB_TOKEN: ${{ github.token }} + run: node scripts/changes/check-pr.ts comment pr-check-result.json diff --git a/.github/workflows/changes-file.yml b/.github/workflows/change-file.yml similarity index 65% rename from .github/workflows/changes-file.yml rename to .github/workflows/change-file.yml index 76098443a1..5c57a09c9b 100644 --- a/.github/workflows/changes-file.yml +++ b/.github/workflows/change-file.yml @@ -1,4 +1,4 @@ -name: 📝 Change File Check +name: 📝 Change File (Check) on: pull_request: @@ -16,8 +16,8 @@ jobs: if: github.repository == 'remix-run/react-router' runs-on: ubuntu-latest permissions: - issues: write - pull-requests: write + contents: read + pull-requests: read steps: - name: ⬇️ Checkout repo @@ -35,7 +35,14 @@ jobs: - name: 📥 Install deps run: pnpm install --frozen-lockfile - - name: 📝 Check change file and update PR comment + - name: 📝 Check for change file env: GITHUB_TOKEN: ${{ github.token }} - run: node scripts/changes/check-pr.ts ${{ github.event.pull_request.number }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: node scripts/changes/check-pr.ts check "$PR_NUMBER" + + - name: 📤 Upload result + uses: actions/upload-artifact@v4 + with: + name: pr-check-result + path: pr-check-result.json diff --git a/scripts/changes/check-pr.ts b/scripts/changes/check-pr.ts index 62852d2942..d1eaf6a8d3 100644 --- a/scripts/changes/check-pr.ts +++ b/scripts/changes/check-pr.ts @@ -2,15 +2,25 @@ * Checks whether the current PR contains a change file and posts (or * updates) a sticky comment on the PR with the result. * - * Usage (called by the changes-file GitHub Actions workflow): - * node scripts/changes/check-pr.ts + * Two-phase to avoid running with write permissions in PRs from forks + * See https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/ + * + * check Reads the PR file list and writes pr-check-result.json. + * Safe to run with a read-only token (Workflow A). + * + * comment Reads pr-check-result.json and posts/updates the sticky + * comment. Runs in Workflow B (workflow_run) with write + * permissions; never sees PR contents. * * Usage: - * node scripts/changes/check-pr.ts + * node scripts/changes/check-pr.ts check + * node scripts/changes/check-pr.ts comment * * Environment: - * GITHUB_TOKEN - Required. GitHub token with pull-requests:write permission. + * GITHUB_TOKEN - Required. GitHub token with appropriate permissions + * for the selected mode. */ +import * as fs from "node:fs"; import { createPrComment, getPrComments, @@ -43,21 +53,36 @@ pnpm run changes:add // Matches packages/*/.changes/*.md but not .gitkeep const CHANGE_FILE_RE = /^packages\/[^/]+\/\.changes\/[^/]+\.md$/; -async function main() { - let arg = process.argv[2]; - let prNumber = arg ? parseInt(arg, 10) : NaN; - if (!arg || isNaN(prNumber)) { - console.error("Usage: node scripts/changes/check-pr.ts "); - process.exit(1); - } +// Needs to match change-file.yml/change-file-comment.yml +const ARTIFACT_FILE = "pr-check-result.json"; // + +let [mode, arg] = process.argv.slice(2); + +if (mode === "check") { + let prNumber = parseInt(arg ?? "", 10); + if (isNaN(prNumber)) usage(); + await check(prNumber); +} else if (mode === "comment") { + if (!arg) usage(); + await comment(arg); +} else { + usage(); +} - // Check for change files via the GitHub API — no git fetch needed +async function check(prNumber: number) { let files = await getPrFiles(prNumber); let found = files.some((f) => CHANGE_FILE_RE.test(f.filename)); + console.log( + `Writing artifact to ${ARTIFACT_FILE}:`, + JSON.stringify({ prNumber, found }), + ); + fs.writeFileSync(ARTIFACT_FILE, JSON.stringify({ prNumber, found })); +} + +async function comment(resultPath: string) { + let { prNumber, found } = JSON.parse(fs.readFileSync(resultPath, "utf8")); let body = found ? COMMENT_FOUND : COMMENT_MISSING; - console.log(`Change files found: ${found}`); - // Find existing sticky comment let comments = await getPrComments(prNumber); let existing = comments.find( (c) => @@ -74,7 +99,11 @@ async function main() { } } -main().catch((err) => { - console.error("Error:", err.message); +function usage(): never { + console.error( + "Usage:\n" + + " node scripts/changes/check-pr.ts check \n" + + " node scripts/changes/check-pr.ts comment ", + ); process.exit(1); -}); +} From 55b1d992a9e741e345b33501b00e3c67091c3fc5 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Mon, 18 May 2026 12:33:17 -0400 Subject: [PATCH 07/23] Consolidate pr workflows (#15070) * Consolidate pr workflows * Update --- .github/workflows/change-file.yml | 48 ---- .github/workflows/close-feature-pr.yml | 28 --- ...change-file-comment.yml => pr-actions.yml} | 26 +- .github/workflows/pr-checks.yml | 53 ++++ scripts/changes/check-pr.ts | 109 -------- scripts/pr.ts | 232 ++++++++++++++++++ scripts/utils/github.ts | 14 ++ 7 files changed, 312 insertions(+), 198 deletions(-) delete mode 100644 .github/workflows/change-file.yml delete mode 100644 .github/workflows/close-feature-pr.yml rename .github/workflows/{change-file-comment.yml => pr-actions.yml} (58%) create mode 100644 .github/workflows/pr-checks.yml delete mode 100644 scripts/changes/check-pr.ts create mode 100644 scripts/pr.ts diff --git a/.github/workflows/change-file.yml b/.github/workflows/change-file.yml deleted file mode 100644 index 5c57a09c9b..0000000000 --- a/.github/workflows/change-file.yml +++ /dev/null @@ -1,48 +0,0 @@ -name: 📝 Change File (Check) - -on: - pull_request: - branches: - - dev - types: [opened, synchronize, reopened] - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - check: - name: 📝 Change File Check - if: github.repository == 'remix-run/react-router' - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: read - - steps: - - name: ⬇️ Checkout repo - uses: actions/checkout@v6 - - - name: 📦 Setup pnpm - uses: pnpm/action-setup@v6 - - - name: ⎔ Setup node - uses: actions/setup-node@v6 - with: - node-version-file: ".nvmrc" - cache: pnpm - - - name: 📥 Install deps - run: pnpm install --frozen-lockfile - - - name: 📝 Check for change file - env: - GITHUB_TOKEN: ${{ github.token }} - PR_NUMBER: ${{ github.event.pull_request.number }} - run: node scripts/changes/check-pr.ts check "$PR_NUMBER" - - - name: 📤 Upload result - uses: actions/upload-artifact@v4 - with: - name: pr-check-result - path: pr-check-result.json diff --git a/.github/workflows/close-feature-pr.yml b/.github/workflows/close-feature-pr.yml deleted file mode 100644 index 9937a8c448..0000000000 --- a/.github/workflows/close-feature-pr.yml +++ /dev/null @@ -1,28 +0,0 @@ -# Close a singular pull request that implements a feature that has not -# gone through the Proposal process -# Triggered by adding the `feature-request` label to an issue - -name: 🚪 Check Feature PR - -on: - pull_request_target: - types: [labeled] - -jobs: - close-feature-pr: - name: 🚪 Check Feature PR - if: github.repository == 'remix-run/react-router' && github.event.label.name == 'feature-request' - runs-on: ubuntu-latest - permissions: - pull-requests: write - steps: - - name: ⬇️ Checkout repo - uses: actions/checkout@v6 - - - name: 🚪 Close PR - env: - GH_TOKEN: ${{ github.token }} - run: | - gh pr comment ${{ github.event.pull_request.number }} -F ./scripts/close-feature-pr.md - gh pr edit ${{ github.event.pull_request.number }} --remove-label ${{ github.event.label.name }} - gh pr close ${{ github.event.pull_request.number }} diff --git a/.github/workflows/change-file-comment.yml b/.github/workflows/pr-actions.yml similarity index 58% rename from .github/workflows/change-file-comment.yml rename to .github/workflows/pr-actions.yml index ff51cbfeef..68d24d1e92 100644 --- a/.github/workflows/change-file-comment.yml +++ b/.github/workflows/pr-actions.yml @@ -1,29 +1,29 @@ -name: 📝 Change File (Comment) +name: PR (Actions) -# Runs after the "Change File (Check)" workflow completes. That workflow runs -# under the `pull_request` trigger with a read-only token (safe for PRs from -# forks); this workflow runs under `workflow_run` with write permissions to -# post the sticky comment, but never executes any PR code — it only reads -# the artifact produced by the upstream workflow. +# Triggered when "PR (Check)" completes. Downloads the artifact produced by +# the upstream workflow and applies the recorded actions (sticky comments, +# label changes, close, etc.) with write permissions. Never executes PR +# source code — only reads the JSON artifact. on: workflow_run: - workflows: ["📝 Change File (Check)"] + workflows: ["PR (Check)"] types: [completed] jobs: - comment: - name: 📝 Post Comment + actions: + name: PR (Actions) if: > github.event.workflow_run.conclusion == 'success' && github.repository == 'remix-run/react-router' runs-on: ubuntu-latest permissions: - actions: read # Read artifact issues: write pull-requests: write + actions: read steps: + # Check's out the base (main) branch - not the PR branch - name: ⬇️ Checkout repo uses: actions/checkout@v6 @@ -42,11 +42,11 @@ jobs: - name: 📥 Download result from upstream workflow uses: actions/download-artifact@v4 with: - name: pr-check-result + name: pr-checks-result github-token: ${{ github.token }} run-id: ${{ github.event.workflow_run.id }} - - name: 💬 Post comment + - name: 💬 Apply actions env: GITHUB_TOKEN: ${{ github.token }} - run: node scripts/changes/check-pr.ts comment pr-check-result.json + run: node scripts/pr.ts actions pr-checks-result.json diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml new file mode 100644 index 0000000000..ef7785e294 --- /dev/null +++ b/.github/workflows/pr-checks.yml @@ -0,0 +1,53 @@ +name: PR (Check) + +# Read-only PR inspection. Computes a list of "actions" (sticky comments, +# label changes, close, etc.) and uploads them as an artifact. The PR (Actions) +# workflow consumes the artifact and applies the actions with write +# permissions — keeping that step out of the PR's untrusted code path. + +on: + pull_request: + types: [opened, synchronize, reopened, labeled] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event.action }}-${{ github.event.label.name }} + cancel-in-progress: true + +jobs: + check: + name: 🔍 Check PR + if: github.repository == 'remix-run/react-router' + runs-on: ubuntu-latest + permissions: + pull-requests: read + + steps: + - name: ⬇️ Checkout repo + uses: actions/checkout@v6 + + - name: 📦 Setup pnpm + uses: pnpm/action-setup@v6 + + - name: ⎔ Setup node + uses: actions/setup-node@v6 + with: + node-version-file: ".nvmrc" + cache: pnpm + + - name: 📥 Install deps + run: pnpm install --frozen-lockfile + + - name: 🔍 Run checks + env: + GITHUB_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_BASE: ${{ github.event.pull_request.base.ref }} + EVENT_ACTION: ${{ github.event.action }} + LABEL_NAME: ${{ github.event.label.name }} + run: node scripts/pr.ts check + + - name: 📤 Upload result + uses: actions/upload-artifact@v4 + with: + name: pr-checks-result + path: pr-checks-result.json diff --git a/scripts/changes/check-pr.ts b/scripts/changes/check-pr.ts deleted file mode 100644 index d1eaf6a8d3..0000000000 --- a/scripts/changes/check-pr.ts +++ /dev/null @@ -1,109 +0,0 @@ -/** - * Checks whether the current PR contains a change file and posts (or - * updates) a sticky comment on the PR with the result. - * - * Two-phase to avoid running with write permissions in PRs from forks - * See https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/ - * - * check Reads the PR file list and writes pr-check-result.json. - * Safe to run with a read-only token (Workflow A). - * - * comment Reads pr-check-result.json and posts/updates the sticky - * comment. Runs in Workflow B (workflow_run) with write - * permissions; never sees PR contents. - * - * Usage: - * node scripts/changes/check-pr.ts check - * node scripts/changes/check-pr.ts comment - * - * Environment: - * GITHUB_TOKEN - Required. GitHub token with appropriate permissions - * for the selected mode. - */ -import * as fs from "node:fs"; -import { - createPrComment, - getPrComments, - getPrFiles, - updatePrComment, -} from "../utils/github.ts"; - -const COMMENT_MARKER = ""; - -const COMMENT_FOUND = `${COMMENT_MARKER} -### ✅ Change File Found - -A [change file](https://github.com/remix-run/react-router/blob/main/docs/community/contributing.md#change-files) file exists in this PR. Thanks!`; - -const COMMENT_MISSING = `${COMMENT_MARKER} -### ⚠️ No Change File Found - -This PR doesn't include a [change file](https://github.com/remix-run/react-router/blob/main/docs/community/contributing.md#change-files) which is used for automated release notes. -If your change affects users, please add one (or more) change files and commit the generated file(s). - -\`\`\`sh -pnpm run changes:add -\`\`\` - -> This script requires Node 24+. If you are on a lower version, please [add a file manually](https://github.com/remix-run/react-router/blob/main/docs/community/contributing.md#change-files) - -> Not every PR needs a change file — you can skip this step if the change is internal-only -> (tests, tooling, docs)`; - -// Matches packages/*/.changes/*.md but not .gitkeep -const CHANGE_FILE_RE = /^packages\/[^/]+\/\.changes\/[^/]+\.md$/; - -// Needs to match change-file.yml/change-file-comment.yml -const ARTIFACT_FILE = "pr-check-result.json"; // - -let [mode, arg] = process.argv.slice(2); - -if (mode === "check") { - let prNumber = parseInt(arg ?? "", 10); - if (isNaN(prNumber)) usage(); - await check(prNumber); -} else if (mode === "comment") { - if (!arg) usage(); - await comment(arg); -} else { - usage(); -} - -async function check(prNumber: number) { - let files = await getPrFiles(prNumber); - let found = files.some((f) => CHANGE_FILE_RE.test(f.filename)); - console.log( - `Writing artifact to ${ARTIFACT_FILE}:`, - JSON.stringify({ prNumber, found }), - ); - fs.writeFileSync(ARTIFACT_FILE, JSON.stringify({ prNumber, found })); -} - -async function comment(resultPath: string) { - let { prNumber, found } = JSON.parse(fs.readFileSync(resultPath, "utf8")); - let body = found ? COMMENT_FOUND : COMMENT_MISSING; - - let comments = await getPrComments(prNumber); - let existing = comments.find( - (c) => - c.user?.login === "github-actions[bot]" && - c.body?.includes(COMMENT_MARKER), - ); - - if (existing) { - console.log(`Updating existing comment #${existing.id}`); - await updatePrComment(existing.id, body); - } else { - console.log("Creating new comment"); - await createPrComment(prNumber, body); - } -} - -function usage(): never { - console.error( - "Usage:\n" + - " node scripts/changes/check-pr.ts check \n" + - " node scripts/changes/check-pr.ts comment ", - ); - process.exit(1); -} diff --git a/scripts/pr.ts b/scripts/pr.ts new file mode 100644 index 0000000000..42b5339630 --- /dev/null +++ b/scripts/pr.ts @@ -0,0 +1,232 @@ +/** + * Runs a set of checks against a PR and applies the resulting actions. + * + * Two-phase to avoid running with write permissions on PRs from forks. + * See https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/ + * + * check Inspects the PR via the GitHub API and writes a list of + * "actions" to pr-checks-result.json. Safe to run with a + * read-only token (Workflow A: 🔍 Check PR). + * + * actions Reads pr-checks-result.json and applies each action. Runs + * in Workflow B (PR (Actions)) under `workflow_run` with + * write permissions but never executes any PR code. + * + * Usage: + * node scripts/pr.ts check + * node scripts/pr.ts actions + * + * Environment (check): + * GITHUB_TOKEN - Required (read-only PR scope is enough). + * PR_NUMBER - Required. github.event.pull_request.number + * PR_BASE - Required. github.event.pull_request.base.ref + * EVENT_ACTION - Required. github.event.action (opened|synchronize|reopened|labeled) + * LABEL_NAME - Optional. github.event.label.name (set when EVENT_ACTION=labeled) + * + * Environment (actions): + * GITHUB_TOKEN - Required (issues:write + pull-requests:write). + */ +import * as fs from "node:fs"; +import * as util from "node:util"; + +import { + closePr, + createPrComment, + getPrComments, + getPrFiles, + removePrLabel, + updatePrComment, +} from "./utils/github.ts"; + +type Action = + | { type: "upsert-sticky-comment"; marker: string; body: string } + | { type: "create-comment"; body: string } + | { type: "remove-label"; label: string } + | { type: "close-pr" }; + +type CheckContext = { + prNumber: number; + baseBranch: string; + eventAction: string; + labelName: string; +}; + +type Check = (ctx: CheckContext) => Promise; + +const CHANGE_FILE_MARKER = ""; + +const CHANGE_FILE_FOUND_COMMENT = `${CHANGE_FILE_MARKER} +### ✅ Change File Found + +A [change file](https://github.com/remix-run/react-router/blob/main/docs/community/contributing.md#change-files) file exists in this PR. Thanks!`; + +const CHANGE_FILE_MISSING_COMMENT = `${CHANGE_FILE_MARKER} +### ⚠️ No Change File Found + +This PR doesn't include a [change file](https://github.com/remix-run/react-router/blob/main/docs/community/contributing.md#change-files) which is used for automated release notes. +If your change affects users, please add one (or more) change files and commit the generated file(s). + +\`\`\`sh +pnpm run changes:add +\`\`\` + +> This script requires Node 24+. If you are on a lower version, please [add a file manually](https://github.com/remix-run/react-router/blob/main/docs/community/contributing.md#change-files) + +> Not every PR needs a change file — you can skip this step if the change is internal-only +> (tests, tooling, docs)`; + +const CLOSE_FEATURE_PR_COMMENT = `\ +To align with our new [Open Governance](https://remix.run/blog/rr-governance) model, we are now asking that all new features go through the [Proposal/RFC process](https://github.com/remix-run/react-router/blob/main/GOVERNANCE.md#new-feature-process) and that we don't open PRs until a proposal has been accepted and advanced to Stage 1. + +If this feature doesn't have a Proposal, please [open one](https://github.com/remix-run/react-router/discussions/new?category=proposals) so we can evaluate/discuss the proposed feature. You can link to this PR as an example of a potential implementation and we can re-open it if the proposal advances. + +If this PR already has a Proposal but it has not yet been accepted, let's continue the discussion in the Proposal until it gets accepted and then we can look to open a PR. Feel free to link to this PR or to a branch in a forked repo to show what a potential implementation might look like. + +If you have any questions, you can always reach out on [Discord](https://rmx.as/discord). Thanks again for providing feedback and helping us make React Router even better! +`; + +let { positionals } = util.parseArgs({ allowPositionals: true }); +let [mode, arg] = positionals; + +if (mode === "check") { + await runChecks(); +} else if (mode === "actions") { + if (!arg) usage(); + await runActions(arg); +} else { + usage(); +} + +// ---------- Checks ---------- + +async function runChecks() { + let prNumber = parseInt(requireEnv("PR_NUMBER"), 10); + if (isNaN(prNumber)) { + console.error("PR_NUMBER must be numeric"); + process.exit(1); + } + + let checks: Check[] = [changeFileCheck, featurePrCheck]; + + let ctx: CheckContext = { + prNumber, + baseBranch: requireEnv("PR_BASE"), + eventAction: requireEnv("EVENT_ACTION"), + labelName: process.env.LABEL_NAME ?? "", + }; + console.log("ctx:", ctx); + + let result: { prNumber: number; actions: Action[] } = { + prNumber, + actions: [], + }; + + for (let check of checks) { + result.actions.push(...(await check(ctx))); + } + + // Matches pr-checks.yml/pr-actions.yml workflow artifact name + let filename = "pr-checks-result.json"; + console.log(`Writing ${filename}:`, JSON.stringify(result)); + fs.writeFileSync(filename, JSON.stringify(result)); +} + +async function changeFileCheck(ctx: CheckContext): Promise { + if (ctx.baseBranch !== "dev") return []; + if (!["opened", "synchronize", "reopened"].includes(ctx.eventAction)) { + return []; + } + + let files = await getPrFiles(ctx.prNumber); + let regex = /^packages\/[^/]+\/\.changes\/[^/]+\.md$/; + let found = files.some((f) => regex.test(f.filename)); + console.log(`changeFileCheck: found=${found}`); + return [ + { + type: "upsert-sticky-comment", + marker: CHANGE_FILE_MARKER, + body: found ? CHANGE_FILE_FOUND_COMMENT : CHANGE_FILE_MISSING_COMMENT, + }, + ]; +} + +async function featurePrCheck(ctx: CheckContext): Promise { + if (ctx.eventAction !== "labeled") return []; + if (ctx.labelName !== "feature-request") return []; + + console.log(`featurePrCheck: closing PR ${ctx.prNumber}`); + return [ + { type: "create-comment", body: CLOSE_FEATURE_PR_COMMENT }, + { type: "remove-label", label: ctx.labelName }, + { type: "close-pr" }, + ]; +} + +// ---------- Action dispatch ---------- + +async function runActions(resultPath: string) { + let { prNumber, actions } = JSON.parse( + fs.readFileSync(resultPath, "utf8"), + ) as { prNumber: number; actions: Action[] }; + + if (actions.length === 0) { + console.log("No actions to apply"); + return; + } + + for (let action of actions) { + switch (action.type) { + case "upsert-sticky-comment": { + let comments = await getPrComments(prNumber); + let existing = comments.find( + (c) => + c.user?.login === "github-actions[bot]" && + c.body?.includes(action.marker), + ); + if (existing) { + console.log(`Updating sticky comment #${existing.id}`); + await updatePrComment(existing.id, action.body); + } else { + console.log("Creating sticky comment"); + await createPrComment(prNumber, action.body); + } + return; + } + case "create-comment": { + console.log("Creating comment"); + await createPrComment(prNumber, action.body); + return; + } + case "remove-label": { + console.log(`Removing label '${action.label}'`); + await removePrLabel(prNumber, action.label); + return; + } + case "close-pr": { + console.log(`Closing PR ${prNumber}`); + await closePr(prNumber); + return; + } + } + } +} + +// Utils + +function usage(): never { + console.error( + "Usage:\n" + + " node scripts/pr.ts check\n" + + " node scripts/pr.ts actions ", + ); + process.exit(1); +} + +function requireEnv(name: string): string { + let value = process.env[name]; + if (!value) { + console.error(`Missing required env var: ${name}`); + process.exit(1); + } + return value; +} diff --git a/scripts/utils/github.ts b/scripts/utils/github.ts index b100b0eeb3..4338937465 100644 --- a/scripts/utils/github.ts +++ b/scripts/utils/github.ts @@ -237,3 +237,17 @@ export async function deletePrComment(commentId: number) { comment_id: commentId, }); } + +/** + * Remove a label from a PR (or issue) + */ +export async function removePrLabel(prNumber: number, label: string) { + await request( + "DELETE /repos/{owner}/{repo}/issues/{issue_number}/labels/{name}", + { + ...requestOptions(), + issue_number: prNumber, + name: label, + }, + ); +} From 50511dc3da31fb4689b9683779661fd754cebcdf Mon Sep 17 00:00:00 2001 From: Jacob Ebey Date: Mon, 18 May 2026 11:26:03 -0700 Subject: [PATCH 08/23] fix(node): honor writable backpressure when streaming to Node writables (#15071) Co-authored-by: Ian Duvall --- contributors.yml | 1 + .../.changes/patch.writable-backpressure.md | 4 + .../__tests__/stream-test.ts | 140 ++++++++++++++++++ packages/react-router-node/stream.ts | 138 ++++++++++++++++- 4 files changed, 279 insertions(+), 4 deletions(-) create mode 100644 packages/react-router-node/.changes/patch.writable-backpressure.md create mode 100644 packages/react-router-node/__tests__/stream-test.ts diff --git a/contributors.yml b/contributors.yml index a1626c4de7..670b0f1c19 100644 --- a/contributors.yml +++ b/contributors.yml @@ -174,6 +174,7 @@ - hsbtr - hyesungoh - iamnishanth +- ianduvall - ianflynnwork - IbraRouisDev - igniscyan diff --git a/packages/react-router-node/.changes/patch.writable-backpressure.md b/packages/react-router-node/.changes/patch.writable-backpressure.md new file mode 100644 index 0000000000..9700863367 --- /dev/null +++ b/packages/react-router-node/.changes/patch.writable-backpressure.md @@ -0,0 +1,4 @@ +Honor Node writable backpressure in `writeReadableStreamToWritable` and `writeAsyncIterableToWritable` + +- Await `'drain'` when `writable.write()` returns `false` instead of letting chunks accumulate in the writable's internal buffer. +- Reject (rather than hang) if the writable errors or closes mid-stream. diff --git a/packages/react-router-node/__tests__/stream-test.ts b/packages/react-router-node/__tests__/stream-test.ts new file mode 100644 index 0000000000..459e80ad88 --- /dev/null +++ b/packages/react-router-node/__tests__/stream-test.ts @@ -0,0 +1,140 @@ +/** + * @jest-environment node + */ + +import { Writable } from "node:stream"; + +import { + writeAsyncIterableToWritable, + writeReadableStreamToWritable, +} from "../stream"; + +function createBackpressureSamplingWritable( + highWaterMark: number, + writeDelayMs: number, +) { + let writable: Writable = new Writable({ + highWaterMark, + write(_chunk, _encoding, callback) { + setTimeout(callback, writeDelayMs); + }, + }); + + let maxBufferedLength = 0; + let originalWrite = writable.write.bind(writable); + writable.write = function (chunk: any, ...rest: any[]) { + let result = originalWrite(chunk, ...rest); + maxBufferedLength = Math.max(maxBufferedLength, writable.writableLength); + return result; + } as typeof writable.write; + + return { + writable, + getMaxBufferedLength: () => maxBufferedLength, + }; +} + +function withTimeout(promise: Promise, ms: number): Promise { + let timeout: ReturnType; + let timeoutPromise = new Promise((_, reject) => { + timeout = setTimeout(() => reject(new Error("Timed out")), ms); + }); + + return Promise.race([promise, timeoutPromise]).finally(() => + clearTimeout(timeout), + ); +} + +describe("writeReadableStreamToWritable", () => { + it("respects writable backpressure", async () => { + let highWaterMark = 16; + let chunkSize = 8; + let numChunks = 100; + let { writable, getMaxBufferedLength } = createBackpressureSamplingWritable( + highWaterMark, + 5, + ); + + let readable = new ReadableStream({ + start(controller) { + for (let i = 0; i < numChunks; i++) { + controller.enqueue(new Uint8Array(chunkSize)); + } + controller.close(); + }, + }); + + await writeReadableStreamToWritable(readable, writable); + + expect(getMaxBufferedLength()).toBeLessThanOrEqual( + highWaterMark + chunkSize, + ); + }); + + it("rejects if the writable errors while waiting for the next chunk", async () => { + let writableError = new Error("Writable failed"); + let writable = new Writable({ + write(_chunk, _encoding, callback) { + callback(); + }, + }); + let readable = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(1)); + }, + }); + + let writePromise = writeReadableStreamToWritable(readable, writable); + + setTimeout(() => writable.destroy(writableError), 10); + + await expect(withTimeout(writePromise, 100)).rejects.toThrow( + "Writable failed", + ); + }); +}); + +describe("writeAsyncIterableToWritable", () => { + it("respects writable backpressure", async () => { + let highWaterMark = 16; + let chunkSize = 8; + let numChunks = 100; + let { writable, getMaxBufferedLength } = createBackpressureSamplingWritable( + highWaterMark, + 5, + ); + + async function* chunks() { + for (let i = 0; i < numChunks; i++) { + yield new Uint8Array(chunkSize); + } + } + + await writeAsyncIterableToWritable(chunks(), writable); + + expect(getMaxBufferedLength()).toBeLessThanOrEqual( + highWaterMark + chunkSize, + ); + }); + + it("rejects if the writable closes while waiting for the next chunk", async () => { + let writable = new Writable({ + write(_chunk, _encoding, callback) { + callback(); + }, + }); + + async function* chunks() { + yield new Uint8Array(1); + await new Promise(() => {}); + } + + let writePromise = writeAsyncIterableToWritable(chunks(), writable); + + setTimeout(() => writable.destroy(), 10); + + await expect(withTimeout(writePromise, 100)).rejects.toThrow( + "Writable closed before stream finished", + ); + }); +}); diff --git a/packages/react-router-node/stream.ts b/packages/react-router-node/stream.ts index 806dd25e68..ae155121b1 100644 --- a/packages/react-router-node/stream.ts +++ b/packages/react-router-node/stream.ts @@ -7,39 +7,169 @@ export async function writeReadableStreamToWritable( ) { let reader = stream.getReader(); let flushable = writable as { flush?: Function }; + let writableError = monitorWritableError(writable); try { while (true) { - let { done, value } = await reader.read(); + writableError.throwIfClosed(); + + let { done, value } = await writableError.race(reader.read()); if (done) { writable.end(); break; } - writable.write(value); + writableError.throwIfClosed(); + + let canContinueWriting = writable.write(value); if (typeof flushable.flush === "function") { flushable.flush(); } + + if (!canContinueWriting) { + await waitForDrain(writable, writableError); + } } } catch (error: unknown) { + try { + reader.cancel(error).catch(() => {}); + } catch { + // Ignore cancellation errors so we preserve the original write failure. + } writable.destroy(error as Error); throw error; + } finally { + writableError.cleanup(); + try { + reader.releaseLock(); + } catch { + // Ignore release errors so we preserve the original write failure. + } } } +interface WritableErrorMonitor { + cleanup(): void; + race(promise: Promise): Promise; + throwIfClosed(): void; +} + +function monitorWritableError(writable: Writable): WritableErrorMonitor { + let settled = false; + let writableError: Error | undefined; + let rejectWritableError!: (error: Error) => void; + let writableErrorPromise = new Promise((_, reject) => { + rejectWritableError = reject; + }); + writableErrorPromise.catch(() => {}); + + function cleanup() { + writable.off("error", onError); + writable.off("close", onClose); + } + + function reject(error: Error) { + if (settled) { + return; + } + + settled = true; + writableError = error; + cleanup(); + rejectWritableError(error); + } + + function onError(error: Error) { + reject(error); + } + + function onClose() { + reject(new Error("Writable closed before stream finished")); + } + + writable.once("error", onError); + writable.once("close", onClose); + + return { + cleanup, + race(promise: Promise) { + return Promise.race([promise, writableErrorPromise]); + }, + throwIfClosed() { + if (writableError) { + throw writableError; + } + + if (writable.destroyed || writable.writableEnded) { + throw new Error("Cannot write to a destroyed or ended writable stream"); + } + }, + }; +} + +function waitForDrain( + writable: Writable, + writableError: WritableErrorMonitor, +): Promise { + let cleanup = () => {}; + let drainPromise = new Promise((resolve) => { + function onDrain() { + cleanup(); + resolve(); + } + + cleanup = function cleanup() { + writable.off("drain", onDrain); + }; + + writable.once("drain", onDrain); + }); + + return writableError.race(drainPromise).finally(cleanup); +} + export async function writeAsyncIterableToWritable( iterable: AsyncIterable, writable: Writable, ) { + let writableError = monitorWritableError(writable); + let iterator = iterable[Symbol.asyncIterator](); + let completed = false; + try { - for await (let chunk of iterable) { - writable.write(chunk); + while (true) { + writableError.throwIfClosed(); + + let { done, value: chunk } = await writableError.race(iterator.next()); + + if (done) { + completed = true; + break; + } + + writableError.throwIfClosed(); + + let canContinueWriting = writable.write(chunk); + + if (!canContinueWriting) { + await waitForDrain(writable, writableError); + } } + writable.end(); } catch (error: any) { + if (!completed) { + try { + Promise.resolve(iterator.return?.()).catch(() => {}); + } catch { + // Ignore return errors so we preserve the original write failure. + } + } writable.destroy(error); throw error; + } finally { + writableError.cleanup(); } } From 0d636fe2a7f61848d0dff95ff695cacf13a652aa Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Mon, 18 May 2026 12:50:41 -0400 Subject: [PATCH 09/23] Cleanup workflows --- .github/workflows/close-no-repro-issues.yml | 49 ---------- .../delete-changeset-bot-comments.yml | 34 ------- .github/workflows/preview.yml | 10 +- package.json | 4 +- .../changes/delete-changeset-bot-comments.ts | 89 ----------------- scripts/close-feature-pr.md | 7 -- scripts/close-no-repro-issues.md | 9 -- scripts/close-no-repro-issues.ts | 74 -------------- scripts/constants.js | 9 -- .../branch.ts} | 2 +- scripts/{pr-preview.ts => previews/pr.ts} | 8 +- scripts/utils.js | 96 ------------------- 12 files changed, 12 insertions(+), 379 deletions(-) delete mode 100644 .github/workflows/close-no-repro-issues.yml delete mode 100644 .github/workflows/delete-changeset-bot-comments.yml delete mode 100644 scripts/changes/delete-changeset-bot-comments.ts delete mode 100644 scripts/close-feature-pr.md delete mode 100644 scripts/close-no-repro-issues.md delete mode 100644 scripts/close-no-repro-issues.ts delete mode 100644 scripts/constants.js rename scripts/{setup-installable-branch.ts => previews/branch.ts} (98%) rename scripts/{pr-preview.ts => previews/pr.ts} (94%) delete mode 100644 scripts/utils.js diff --git a/.github/workflows/close-no-repro-issues.yml b/.github/workflows/close-no-repro-issues.yml deleted file mode 100644 index 98337a2d38..0000000000 --- a/.github/workflows/close-no-repro-issues.yml +++ /dev/null @@ -1,49 +0,0 @@ -# This is a bulk-close script that was used initially to find and close issues -# without a repro, but moving forward we'll likely use the singular version -# (close-no-repro-issue.yml) on new issues which is driven by a label added to -# the issue - -name: 🚪 Close issues without a reproduction - -on: - workflow_dispatch: - inputs: - dryRun: - type: boolean - description: "Dry Run? (no issues will be closed)" - default: false - -concurrency: ${{ github.workflow }}-${{ github.ref }} - -jobs: - close-no-repro-issues: - name: 🚪 Close issues - if: github.repository == 'remix-run/react-router' - runs-on: ubuntu-latest - env: - CI: "true" - GH_TOKEN: ${{ github.token }} - steps: - - name: ⬇️ Checkout repo - uses: actions/checkout@v6 - - - name: 📦 Setup pnpm - uses: pnpm/action-setup@v6 - - - name: ⎔ Setup node - uses: actions/setup-node@v6 - with: - # required for --experimental-strip-types - node-version: 22 - cache: "pnpm" - - - name: 📥 Install deps - run: pnpm install --frozen-lockfile - - - name: 🚪 Close Issues (Dry Run) - if: ${{ inputs.dryRun }} - run: node --experimental-strip-types ./scripts/close-no-repro-issues.ts --dryRun - - - name: 🚪 Close Issues - if: ${{ ! inputs.dryRun }} - run: node --experimental-strip-types ./scripts/close-no-repro-issues.ts diff --git a/.github/workflows/delete-changeset-bot-comments.yml b/.github/workflows/delete-changeset-bot-comments.yml deleted file mode 100644 index 255c20ea91..0000000000 --- a/.github/workflows/delete-changeset-bot-comments.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: 🗑️ Delete Changeset Bot Comments - -on: - workflow_dispatch: - -jobs: - delete-comments: - name: 🗑️ Delete Changeset Bot Comments - if: github.repository == 'remix-run/react-router' - runs-on: ubuntu-latest - permissions: - pull-requests: write - - steps: - - name: ⬇️ Checkout repo - uses: actions/checkout@v6 - - - name: 📦 Setup pnpm - uses: pnpm/action-setup@v6 - - - name: ⎔ Setup node - uses: actions/setup-node@v6 - with: - node-version-file: ".nvmrc" - cache: pnpm - - - name: 📥 Install deps - run: pnpm install --frozen-lockfile - - - name: 🗑️ Delete changeset-bot comments - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - node scripts/changes/delete-changeset-bot-comments.ts diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index c78af646d0..d5d44d74d4 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -76,7 +76,7 @@ jobs: - name: Build/push branch (push) if: github.event_name == 'push' run: | - pnpm run setup-installable-branch preview/dev + pnpm run previews:branch preview/dev git push --force --set-upstream origin preview/dev echo "💿 pushed installable branch: https://github.com/$GITHUB_REPOSITORY/commit/$(git rev-parse HEAD)" @@ -86,17 +86,17 @@ jobs: env: GITHUB_TOKEN: ${{ github.token }} run: | - pnpm run setup-installable-branch preview/pr-${{ github.event.pull_request.number }} + pnpm run previews:branch preview/pr-${{ github.event.pull_request.number }} git push --force --set-upstream origin preview/pr-${{ github.event.pull_request.number }} echo "pushed installable branch: https://github.com/$GITHUB_REPOSITORY/commit/$(git rev-parse HEAD)" - pnpm run pr-preview comment ${{ github.event.pull_request.number }} preview/pr-${{ github.event.pull_request.number }} + pnpm run previews:pr comment ${{ github.event.pull_request.number }} preview/pr-${{ github.event.pull_request.number }} # Build and normal push for experimental releases to avoid unintended force # pushes over remote branches in case of a branch name collision - name: Build/push branch (workflow_dispatch) if: github.event_name == 'workflow_dispatch' run: | - pnpm run setup-installable-branch ${{ inputs.installableBranch }} + pnpm run previews:branch ${{ inputs.installableBranch }} git push --set-upstream origin ${{ inputs.installableBranch }} echo "💿 pushed installable branch: https://github.com/$GITHUB_REPOSITORY/commit/$(git rev-parse HEAD)" @@ -106,4 +106,4 @@ jobs: env: GITHUB_TOKEN: ${{ github.token }} run: | - pnpm run pr-preview cleanup ${{ github.event.pull_request.number }} preview/pr-${{ github.event.pull_request.number }} + pnpm run previews:pr cleanup ${{ github.event.pull_request.number }} preview/pr-${{ github.event.pull_request.number }} diff --git a/package.json b/package.json index 8afbd78863..f5ffa293f6 100644 --- a/package.json +++ b/package.json @@ -22,9 +22,9 @@ "format:check": "prettier --ignore-path .prettierignore --check .", "lint": "eslint --cache .", "playground": "node ./scripts/playground.js", - "pr-preview": "node ./scripts/pr-preview.ts", + "previews:branch": "node ./scripts/previews/branch.ts", + "previews:pre": "node ./scripts/previews/pr.ts", "release-comments": "node scripts/release-comments.ts", - "setup-installable-branch": "node scripts/setup-installable-branch.ts", "test": "jest", "test:inspect": "node --inspect-brk ./node_modules/.bin/jest", "typegen": "pnpm run --recursive --parallel typegen", diff --git a/scripts/changes/delete-changeset-bot-comments.ts b/scripts/changes/delete-changeset-bot-comments.ts deleted file mode 100644 index 29d931e4d6..0000000000 --- a/scripts/changes/delete-changeset-bot-comments.ts +++ /dev/null @@ -1,89 +0,0 @@ -/** - * Finds and deletes comments from `changeset-bot` on open PRs created since 1/1/2026 - * - * Usage: - * node scripts/changes/delete-changeset-bot-comments.ts [--dry-run] - * - * Environment: - * GITHUB_TOKEN - Required. GitHub token with pull-requests:write permission. - */ -import { - createPrComment, - deletePrComment, - getPrComments, - listOpenPrs, -} from "../utils/github.ts"; - -const CHANGESET_BOT = "changeset-bot[bot]"; -const CUTOFF = new Date(2026, 0, 1); - -const ADD_CHANGE_FILE = - "👋 We've moved away from Changesets to our own internal " + - "[changes process](https://github.com/remix-run/react-router/blob/main/docs/community/contributing.md#change-files). " + - "Please manually add a change file to this branch, or you can merge in the " + - "latest `dev` branch and run `pnpm run changes:add` to add a change file."; - -const MIGRATE_CHANGE_FILE = - "👋 We've moved away from Changesets to our own internal " + - "[changes process](https://github.com/remix-run/react-router/blob/main/docs/community/contributing.md#change-files). " + - "Please convert your changesets file to a change file in the proper package directory " + - "(i.e., `packages/react-router/.changes/patch.fix-some-bug.md`)."; - -const dryRun = process.argv.includes("--dry-run"); - -if (dryRun) { - console.log("[DRY RUN] No comments will be deleted.\n"); -} - -console.log( - `Fetching open PRs created after ${CUTOFF.toISOString().slice(0, 10)}...\n`, -); - -let prs = await listOpenPrs({ - createdAfter: CUTOFF, - base: "dev", -}); -console.log(`Found ${prs.length} open PR${prs.length === 1 ? "" : "s"}.\n`); - -let totalDeleted = 0; -let totalSkipped = 0; - -for (let pr of prs) { - let comments = await getPrComments(pr.number); - let botComments = comments.filter((c) => c.user?.login === CHANGESET_BOT); - - if (botComments.length === 0) continue; - - console.log(`PR #${pr.number}: ${pr.title}`); - - for (let comment of botComments) { - let preview = (comment.body ?? "").slice(0, 80).replace(/\n/g, " "); - if (dryRun) { - console.log( - ` [DRY RUN] Would delete comment #${comment.id}: "${preview}"`, - ); - totalSkipped++; - } else { - let hasChangeFile = comment.body?.includes("Changeset detected"); - await deletePrComment(comment.id); - await createPrComment( - pr.number, - hasChangeFile ? MIGRATE_CHANGE_FILE : ADD_CHANGE_FILE, - ); - console.log(` Deleted comment #${comment.id}: "${preview}"`); - totalDeleted++; - } - } - - console.log(); -} - -if (dryRun) { - console.log( - `Done (dry run): ${totalSkipped} comment${totalSkipped === 1 ? "" : "s"} would be deleted.`, - ); -} else { - console.log( - `Done: ${totalDeleted} comment${totalDeleted === 1 ? "" : "s"} deleted.`, - ); -} diff --git a/scripts/close-feature-pr.md b/scripts/close-feature-pr.md deleted file mode 100644 index 1609a73a52..0000000000 --- a/scripts/close-feature-pr.md +++ /dev/null @@ -1,7 +0,0 @@ -To align with our new [Open Governance](https://remix.run/blog/rr-governance) model, we are now asking that all new features go through the [Proposal/RFC process](https://github.com/remix-run/react-router/blob/main/GOVERNANCE.md#new-feature-process) and that we don't open PRs until a proposal has been accepted and advanced to Stage 1. - -If this feature doesn't have a Proposal, please [open one](https://github.com/remix-run/react-router/discussions/new?category=proposals) so we can evaluate/discuss the proposed feature. You can link to this PR as an example of a potential implementation and we can re-open it if the proposal advances. - -If this PR already has a Proposal but it has not yet been accepted, let's continue the discussion in the Proposal until it gets accepted and then we can look to open a PR. Feel free to link to this PR or to a branch in a forked repo to show what a potential implementation might look like. - -If you have any questions, you can always reach out on [Discord](https://rmx.as/discord). Thanks again for providing feedback and helping us make React Router even better! diff --git a/scripts/close-no-repro-issues.md b/scripts/close-no-repro-issues.md deleted file mode 100644 index eb930ef8aa..0000000000 --- a/scripts/close-no-repro-issues.md +++ /dev/null @@ -1,9 +0,0 @@ -To align with our new [Open Governance](https://remix.run/blog/rr-governance) model, we are now requiring that all issues have a [**minimal** and **runnable** reproduction](https://github.com/remix-run/react-router/blob/main/GOVERNANCE.md#bugissue-process). To that end, we're doing some housekeeping in the repo to clean up existing issues that do not have a valid reproduction. This should get us down to a more manageable number of issues and allow us to be more responsive to existing and newly filed issues. - -We're using a GitHub actions script to identify issues without a reproduction by looking for a [StackBlitz](https://stackblitz.com/), [CodeSandbox](https://codesandbox.io/), or [GitHub](https://github.com) link in the issue body. This won't be perfect, so if this issue has a reproduction on another platform, please comment back on here, and we can re-open the issue. Similarly, if there's a reproduction buried in a comment, please move the link into the description and comment back. Please tag `@brophdawg11` or `@brookslybrand` in your comment so we get a notification as well 🙂. - -If this issue did not have a reproduction but is still valid, or if you wish to start with a fresh issue, please [create a new issue](https://github.com/remix-run/react-router/issues/new?template=bug_report.yml) with a fresh reproduction against v7 and link to this issue in the new description. - -If this is a feature request, please open a new [Proposal Discussion](https://github.com/remix-run/react-router/discussions/new?category=proposals) in React Router, and if it gets enough community support, it can be considered for implementation. - -If you have any questions, you can always reach out on [Discord](https://rmx.as/discord). Thanks again for providing feedback and helping us make React Router even better! diff --git a/scripts/close-no-repro-issues.ts b/scripts/close-no-repro-issues.ts deleted file mode 100644 index 7d2f562a2e..0000000000 --- a/scripts/close-no-repro-issues.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { execSync } from "node:child_process"; -import { parseArgs } from "node:util"; - -const sleep = (ms: number) => - new Promise((resolve) => setTimeout(resolve, ms)); - -const ignoredIssues = new Set([9991, 12570, 13607, 13659, 11940]); - -const { values: args } = parseArgs({ - options: { - dryRun: { - type: "boolean", - default: false, - }, - }, - strict: true, -}); - -run(); - -async function run() { - let issuesCmd = `gh issue list --search "is:issue state:open label:bug sort:created-asc" --limit 250 --json number,body`; - console.log(`Executing command: ${issuesCmd}`); - let result = execSync(issuesCmd).toString(); - let allIssues = JSON.parse(result) as { number: number; body: string }[]; - let noReproIssues = allIssues.filter(({ number, body }) => { - return ( - !ignoredIssues.has(number) && - !/https?:\/\/stackblitz\.com\//.test(body) && - !/https?:\/\/codesandbox\.io\//.test(body) && - !/https?:\/\/github\.com\//.test( - body - // Remove uploaded image URLs and links to react router source code - // and new issue links before looking for git repo reproductions - .replace("https://github.com/user-attachments/", "") - .replace("https://github.com/remix-run/react-router/", ""), - ) - ); - }); - - console.log( - `Found ${noReproIssues.length} issues without a reproduction:\n` + - noReproIssues.map((i) => i.number).join(","), - ); - - for (let issue of noReproIssues) { - console.log(`--- Processing issue #${issue.number} ---`); - let commentCmd = `gh issue comment ${issue.number} -F ./scripts/close-no-repro-issues.md`; - let commentResult = runCmdIfTokenExists(commentCmd); - console.log(`Commented on issue #${issue.number}: ${commentResult}`); - await sleep(250); - - let closeCmd = `gh issue close ${issue.number} -r "not planned"`; - runCmdIfTokenExists(closeCmd); - // No log here since the GH CLI already logs for issue close - await sleep(250); - } - - console.log("Done!"); -} - -function runCmdIfTokenExists(cmd: string) { - if (args.dryRun) { - console.log(`⚠️ Dry run, skipping command: ${cmd}`); - return ""; - } - - if (process.env.CI !== "true") { - console.log(`⚠️ Local run without CI env var, skipping command: ${cmd}`); - return ""; - } - - return execSync(cmd).toString(); -} diff --git a/scripts/constants.js b/scripts/constants.js deleted file mode 100644 index c2b3ebeea4..0000000000 --- a/scripts/constants.js +++ /dev/null @@ -1,9 +0,0 @@ -const path = require("path"); - -const ROOT_DIR = path.resolve(__dirname, ".."); -const EXAMPLES_DIR = path.resolve(ROOT_DIR, "examples"); - -module.exports = { - ROOT_DIR, - EXAMPLES_DIR, -}; diff --git a/scripts/setup-installable-branch.ts b/scripts/previews/branch.ts similarity index 98% rename from scripts/setup-installable-branch.ts rename to scripts/previews/branch.ts index 0ff48079ea..44653e0403 100644 --- a/scripts/setup-installable-branch.ts +++ b/scripts/previews/branch.ts @@ -1,7 +1,7 @@ import * as fsp from "node:fs/promises"; import * as path from "node:path"; import * as util from "node:util"; -import { logAndExec } from "./utils/process.ts"; +import { logAndExec } from "../utils/process.ts"; /** * This script prepares a base branch (usually `dev`) to be PNPM-installable diff --git a/scripts/pr-preview.ts b/scripts/previews/pr.ts similarity index 94% rename from scripts/pr-preview.ts rename to scripts/previews/pr.ts index 19eab42ec7..c13a868b44 100644 --- a/scripts/pr-preview.ts +++ b/scripts/previews/pr.ts @@ -11,7 +11,7 @@ * - `cleanup `: Deletes the preview branch from the remote repository * and adds a cleanup notification comment to the PR. * - * Usage: `node pr-preview.ts ` + * Usage: `node scripts/previews/pr.ts ` */ import { parseArgs } from "node:util"; @@ -21,8 +21,8 @@ import { deletePrComment, getPrComments, updatePrComment, -} from "./utils/github.ts"; -import { logAndExec } from "./utils/process.ts"; +} from "../utils/github.ts"; +import { logAndExec } from "../utils/process.ts"; const STICKY_MARKER = ""; const CLEANUP_MARKER = ""; @@ -57,7 +57,7 @@ if (commands[command]) { } function printUsage() { - console.error("Usage: node pr-preview.ts "); + console.error("Usage: node scripts/previews/pr.ts "); console.error( " comment - Add preview comment to PR", ); diff --git a/scripts/utils.js b/scripts/utils.js deleted file mode 100644 index 3c5e91cd3d..0000000000 --- a/scripts/utils.js +++ /dev/null @@ -1,96 +0,0 @@ -const fsp = require("fs").promises; -const path = require("path"); -const { execSync } = require("child_process"); -const jsonfile = require("jsonfile"); - -const { ROOT_DIR, EXAMPLES_DIR } = require("./constants"); - -/** - * @param {string} packageName - * @param {string} [directory] - * @returns {string} - */ -function packageJson(packageName, directory) { - return path.join(ROOT_DIR, directory, packageName, "package.json"); -} - -/** - * @param {string} packageName - * @returns {Promise} - */ -async function getPackageVersion(packageName) { - let file = packageJson(packageName, "packages"); - let json = await jsonfile.readFile(file); - return json.version; -} - -/** - * @returns {void} - */ -function ensureCleanWorkingDirectory() { - let status = execSync(`git status --porcelain`).toString().trim(); - let lines = status.split("\n"); - invariant( - lines.every((line) => line === "" || line.startsWith("?")), - "Working directory is not clean. Please commit or stash your changes.", - ); -} - -/** - * @param {string} packageName - * @param {(json: import('type-fest').PackageJson) => any} transform - */ -async function updatePackageConfig(packageName, transform) { - let file = packageJson(packageName, "packages"); - let json = await jsonfile.readFile(file); - transform(json); - await jsonfile.writeFile(file, json, { spaces: 2 }); -} - -/** - * @param {string} example - * @param {(json: import('type-fest').PackageJson) => any} transform - */ -async function updateExamplesPackageConfig(example, transform) { - let file = path.join(EXAMPLES_DIR, example, "package.json"); - if (!(await fileExists(file))) return; - - let json = await jsonfile.readFile(file); - transform(json); - await jsonfile.writeFile(file, json, { spaces: 2 }); -} - -/** - * @param {string} filePath - * @returns {Promise} - */ -async function fileExists(filePath) { - try { - await fsp.stat(filePath); - return true; - } catch ( - // eslint-disable-next-line no-unused-vars - e - ) { - return false; - } -} - -/** - * @param {*} cond - * @param {string} message - * @returns {asserts cond} - */ -function invariant(cond, message) { - if (!cond) throw new Error(message); -} - -module.exports = { - fileExists, - packageJson, - getPackageVersion, - ensureCleanWorkingDirectory, - invariant, - updatePackageConfig, - updateExamplesPackageConfig, -}; From 5b2e59fa3f564ede027a8fc3bdda70b15fa8a9af Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Tue, 19 May 2026 11:06:34 -0400 Subject: [PATCH 10/23] Fix preview build typo --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f5ffa293f6..4795344167 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "lint": "eslint --cache .", "playground": "node ./scripts/playground.js", "previews:branch": "node ./scripts/previews/branch.ts", - "previews:pre": "node ./scripts/previews/pr.ts", + "previews:pr": "node ./scripts/previews/pr.ts", "release-comments": "node scripts/release-comments.ts", "test": "jest", "test:inspect": "node --inspect-brk ./node_modules/.bin/jest", From e96962bc6159a2290632849b55872a3878753342 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Tue, 19 May 2026 11:15:30 -0400 Subject: [PATCH 11/23] fix: remove stale unpkg field from react-router-dom (#15075) Co-authored-by: Henry Dineen Co-authored-by: Claude Sonnet 4.6 (1M context) --- contributors.yml | 1 + .../patch.remove-staleinvalid-unpkg-field-packagejson-removed.md | 1 + packages/react-router-dom/package.json | 1 - 3 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 packages/react-router-dom/.changes/patch.remove-staleinvalid-unpkg-field-packagejson-removed.md diff --git a/contributors.yml b/contributors.yml index 670b0f1c19..a5c54400b9 100644 --- a/contributors.yml +++ b/contributors.yml @@ -164,6 +164,7 @@ - harucn - HelpMe-Pls - HenriqueLimas +- henryqdineen - hernanif1 - HeyyyNeo - hi-ogawa diff --git a/packages/react-router-dom/.changes/patch.remove-staleinvalid-unpkg-field-packagejson-removed.md b/packages/react-router-dom/.changes/patch.remove-staleinvalid-unpkg-field-packagejson-removed.md new file mode 100644 index 0000000000..465f178005 --- /dev/null +++ b/packages/react-router-dom/.changes/patch.remove-staleinvalid-unpkg-field-packagejson-removed.md @@ -0,0 +1 @@ +Remove stale/invalid `unpkg` field from `package.json`. This was removed from other packages with the release of v7 but missed in the `react-router-dom` re-export package diff --git a/packages/react-router-dom/package.json b/packages/react-router-dom/package.json index fd24333062..a2e927eb3b 100644 --- a/packages/react-router-dom/package.json +++ b/packages/react-router-dom/package.json @@ -19,7 +19,6 @@ "author": "Remix Software ", "sideEffects": false, "main": "./dist/main.js", - "unpkg": "./dist/umd/react-router-dom.production.min.js", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", "exports": { From 98e8b3bd707dcf42dab0ff85dc1e9322d0f1d0cf Mon Sep 17 00:00:00 2001 From: Jeevan Mohan Pawar <35501212+jeevan6996@users.noreply.github.com> Date: Wed, 20 May 2026 20:27:58 +0100 Subject: [PATCH 12/23] fix(serve): normalize assetsBuildDirectory separators in react-router-serve (#14982) * fix(serve): normalize assets build path separators across OSes * chore: add CLA contributor entry * chore(serve): use path.join default assets build path * serve: remove legacy assets path normalization * Update change file --------- Co-authored-by: Matt Brophy --- .../.changes/patch.windows-assets-directory-separators.md | 1 + packages/react-router-serve/cli.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 packages/react-router-serve/.changes/patch.windows-assets-directory-separators.md diff --git a/packages/react-router-serve/.changes/patch.windows-assets-directory-separators.md b/packages/react-router-serve/.changes/patch.windows-assets-directory-separators.md new file mode 100644 index 0000000000..732b3f6b44 --- /dev/null +++ b/packages/react-router-serve/.changes/patch.windows-assets-directory-separators.md @@ -0,0 +1 @@ +Normalize `assetsBuildDirectory` path separators in `react-router-serve` so Windows-built server artifacts can serve `/assets/*` correctly when run on Linux. diff --git a/packages/react-router-serve/cli.ts b/packages/react-router-serve/cli.ts index a8d7144838..10ff635292 100644 --- a/packages/react-router-serve/cli.ts +++ b/packages/react-router-serve/cli.ts @@ -88,7 +88,7 @@ async function run() { if ((isRSCBuild = isRSCServerBuild(buildModule))) { const config = { publicPath: "/", - assetsBuildDirectory: "../client", + assetsBuildDirectory: path.join("..", "client"), ...(buildModule.unstable_reactRouterServeConfig || {}), }; build = { From 3ed77afcde0ad9aea79f1afe5f05a700b201f289 Mon Sep 17 00:00:00 2001 From: Remix Run Bot Date: Wed, 20 May 2026 19:28:42 +0000 Subject: [PATCH 13/23] chore: format --- ...patch.remove-staleinvalid-unpkg-field-packagejson-removed.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-router-dom/.changes/patch.remove-staleinvalid-unpkg-field-packagejson-removed.md b/packages/react-router-dom/.changes/patch.remove-staleinvalid-unpkg-field-packagejson-removed.md index 465f178005..111c8ccd1c 100644 --- a/packages/react-router-dom/.changes/patch.remove-staleinvalid-unpkg-field-packagejson-removed.md +++ b/packages/react-router-dom/.changes/patch.remove-staleinvalid-unpkg-field-packagejson-removed.md @@ -1 +1 @@ -Remove stale/invalid `unpkg` field from `package.json`. This was removed from other packages with the release of v7 but missed in the `react-router-dom` re-export package +Remove stale/invalid `unpkg` field from `package.json`. This was removed from other packages with the release of v7 but missed in the `react-router-dom` re-export package From d7980d272a9e1377fe14ebfa7b6e60f61ab4b232 Mon Sep 17 00:00:00 2001 From: dfedoryshchev <64079946+dfedoryshchev@users.noreply.github.com> Date: Mon, 25 May 2026 21:20:51 +0100 Subject: [PATCH 14/23] test: fix duplicate "route" in fetcher-layout test descriptions (#15094) --- integration/fetcher-layout-test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/integration/fetcher-layout-test.ts b/integration/fetcher-layout-test.ts index 09397ce5bb..3b40a528a7 100644 --- a/integration/fetcher-layout-test.ts +++ b/integration/fetcher-layout-test.ts @@ -255,7 +255,7 @@ test("fetcher calls layout route loader when at parameterized route", async ({ expect(dataElement.text()).toBe("layout loader data"); }); -test("fetcher calls parameterized route route action", async ({ page }) => { +test("fetcher calls parameterized route action", async ({ page }) => { let app = new PlaywrightFixture(appFixture, page); await app.goto("/layout-action/foo"); await app.clickElement("#param-fetcher"); @@ -266,7 +266,7 @@ test("fetcher calls parameterized route route action", async ({ page }) => { expect(dataElement.text()).toBe("foo"); }); -test("fetcher calls parameterized route route loader", async ({ page }) => { +test("fetcher calls parameterized route loader", async ({ page }) => { let app = new PlaywrightFixture(appFixture, page); await app.goto("/layout-loader/foo"); await app.clickElement("#param-fetcher"); From 4cde90be1ccae1c27a893ab5e87f64a35f308979 Mon Sep 17 00:00:00 2001 From: Ryuya Yanagi <57742720+apple-yagi@users.noreply.github.com> Date: Tue, 26 May 2026 22:49:53 +0900 Subject: [PATCH 15/23] Support array of objects for `script:ld+json` meta descriptor (#15082) * feat: support array of objects for script:ld+json meta descriptor * Apply suggestions from code review Co-authored-by: Matt Brophy --------- Co-authored-by: Matt Brophy --- .../patch.support-array-script-ld-json.md | 1 + .../__tests__/dom/ssr/meta-test.tsx | 42 +++++++++++++++++++ .../react-router/lib/dom/ssr/routeModules.ts | 2 +- 3 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 packages/react-router/.changes/patch.support-array-script-ld-json.md diff --git a/packages/react-router/.changes/patch.support-array-script-ld-json.md b/packages/react-router/.changes/patch.support-array-script-ld-json.md new file mode 100644 index 0000000000..8f21f48d94 --- /dev/null +++ b/packages/react-router/.changes/patch.support-array-script-ld-json.md @@ -0,0 +1 @@ +Widen `MetaDescriptor` `script:ld+json` type from `LdJsonObject` to `LdJsonObject | LdJsonObject[]` to permit multiple JSON-LD schemas in a single `