diff --git a/.github/workflows/release-comment-manual.yml b/.github/workflows/release-comment-manual.yml deleted file mode 100644 index 98513dd4fd..0000000000 --- a/.github/workflows/release-comment-manual.yml +++ /dev/null @@ -1,41 +0,0 @@ -# Used to manually trigger the release comments workflow - -name: πŸ’¬ Release Comment -on: - workflow_dispatch: - inputs: - dryRun: - description: "Should this be a dry run? (true/false)" - type: "boolean" - required: true - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -env: - CI: true - -jobs: - comment: - name: πŸ“ Comment on related issues and pull requests - if: github.repository == 'remix-run/react-router' - runs-on: ubuntu-latest - permissions: - issues: write # enable commenting on released issues - pull-requests: write # enable commenting on released pull requests - steps: - - name: ⬇️ Checkout repo - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - # IMPORTANT: if you make changes here, also make them in release.yml - - name: πŸ“ Comment on related issues and pull requests - uses: remix-run/release-comment-action@v0.5.1 - with: - DRY_RUN: ${{ github.event.inputs.dryRun }} - DIRECTORY_TO_CHECK: "./packages" - PACKAGE_NAME: "react-router" - ISSUE_LABELS_TO_REMOVE: "awaiting release" - ISSUE_LABELS_TO_KEEP_OPEN: "πŸ—Ί Roadmap" diff --git a/.github/workflows/release-comments.yml b/.github/workflows/release-comments.yml new file mode 100644 index 0000000000..147ca2ffe9 --- /dev/null +++ b/.github/workflows/release-comments.yml @@ -0,0 +1,39 @@ +name: πŸ’¬ Release Comments + +on: + workflow_call: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + comment: + name: πŸ“ Comment on released issues/pull requests + runs-on: ubuntu-latest + permissions: + issues: write # enable commenting on released issues + pull-requests: write # enable commenting on released pull requests + steps: + - name: ⬇️ Checkout repo + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: πŸ“¦ Setup pnpm + uses: pnpm/action-setup@v4 + + - name: βŽ” Setup node + uses: actions/setup-node@v6 + with: + node-version: 24 # Needed for node TS support + cache: "pnpm" + + - name: πŸ“₯ Install deps + run: pnpm install --frozen-lockfile + + - name: πŸ“ Comment on released issues and pull requests + env: + GH_TOKEN: ${{ github.token }} + run: pnpm run release-comments diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4e81985163..e2fa9bdfb2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,6 +18,7 @@ on: inputs: branch: required: true + description: Experimental release branch concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -32,7 +33,7 @@ jobs: has_change_files: ${{ steps.check.outputs.has_change_files }} steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Check for change files id: check @@ -51,22 +52,23 @@ jobs: pull_request: name: Open pull request needs: check - if: github.event_name == 'push' && needs.check.outputs.has_change_files == 'true' + if: needs.check.outputs.has_change_files == 'true' runs-on: ubuntu-latest permissions: contents: write # enable pushing changes to the origin pull-requests: write # enable opening a PR for the release steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: + fetch-depth: 0 token: ${{ secrets.FORMAT_PAT }} - name: Install pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v5 - name: Install Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: 24 # Needed to run typescript scripts directly cache: pnpm @@ -82,7 +84,7 @@ jobs: publish: name: Publish needs: check - if: github.event_name == 'push' && needs.check.outputs.has_change_files == 'false' + if: needs.check.outputs.has_change_files == 'false' runs-on: ubuntu-latest permissions: contents: write # enable pushing changes to the origin @@ -111,6 +113,11 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + comment: + name: πŸ“ Comment on released issues/pull requests + needs: publish + uses: ./.github/workflows/release-comments.yml + experimental-release: name: πŸ§ͺ Experimental Release if: github.repository == 'remix-run/react-router' && github.event_name == 'workflow_dispatch' diff --git a/CHANGELOG.md b/CHANGELOG.md index 8908b1787b..d467b70771 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ We manage release notes in this file instead of the paginated Github Releases Pa Table of Contents - [React Router Releases](#react-router-releases) + - [v7.14.2](#v7142) - [v7.14.1](#v7141) - [v7.14.0](#v7140) - [v7.13.2](#v7132) @@ -168,6 +169,63 @@ We manage release notes in this file instead of the paginated Github Releases Pa +## v7.14.2 + +Date: 2026-04-21 + +### Patch Changes + +- `react-router` - Remove the un-documented custom error serialization logic from the internal turbo-stream implementation. React Router only automatically handles serialization of `Error` and it's standard subtypes (`SyntaxError`, `TypeError`, etc.). ([#14992](https://github.com/remix-run/react-router/pull/14992)) +- `react-router` - Properly handle parent middleware redirects during `fetcher.load` ([#14974](https://github.com/remix-run/react-router/pull/14974)) +- `react-router` - Remove redundant `Omit` from `react-router/dom` `RouterProvider` ([#14874](https://github.com/remix-run/react-router/pull/14874)) +- `react-router` - Improved types for `generatePath`'s `param` arg ([#14984](https://github.com/remix-run/react-router/pull/14984)) + - Type errors when required params are omitted: + + ```ts + // Before + // Passes type checks, but throws at runtime πŸ’₯ + generatePath(":required", { required: null }); + + // After + generatePath(":required", { required: null }); + // ^^^^^^^^ Type 'null' is not assignable to type 'string'.ts(2322) + ``` + + - Allow omission of optional params: + + ```ts + // Before + generatePath(":optional?", {}); + // ^^ Property 'optional' is missing in type '{}' but required in type '{ optional: string | null | undefined; }'.ts(2741) + + // After + generatePath(":optional?", {}); + ``` + + - Allows extra keys: + + ```ts + // Before + generatePath(":a", { a: "1", b: "2" }); + // ^ Object literal may only specify known properties, and 'b' does not exist in type '{ a: string; }'.ts(2353) + + // After + generatePath(":a", { a: "1", b: "2" }); + ``` + +- `@react-router/dev` - Fix typegen for layouts without pages ([#14875](https://github.com/remix-run/react-router/pull/14875)) + - Previously, typegen could produce `pages: ;` in `.react-router/types/+routes.ts` when a route corresponded to 0 pages + - Now, `pages: never;` is correctly generated for those cases + +### Unstable Changes + +⚠️ _[Unstable features](https://reactrouter.com/community/api-development-strategy#unstable-flags) are not recommended for production use_ + +- `@react-router/dev` - For `unstable_reactRouterRSC` Vite plugin consumers, require `@vitejs/plugin-react` in user Vite config, and more reliably split route modules ([#14965](https://github.com/remix-run/react-router/pull/14965)) + - ⚠️ This is a breaking change if you have begun using the `unstable_reactRouterRSC` Vite plugin - please install `@vitejs/plugin-react` and add the `react` plugin to your Vite plugins array. + +**Full Changelog**: [`v7.14.1...v7.14.2`](https://github.com/remix-run/react-router/compare/react-router@7.14.1...react-router@7.14.2) + ## v7.14.1 Date: 2026-04-13 diff --git a/contributors.yml b/contributors.yml index c5f73aa73e..1c035b6794 100644 --- a/contributors.yml +++ b/contributors.yml @@ -321,6 +321,7 @@ - nowells - Nurai1 - nwleedev +- nyxsky404 - Obi-Dann - okalil - OlegDev1 @@ -486,5 +487,6 @@ - yuri-poliantsev - zeevick10 - zeromask1337 +- zeroqs - zheng-chuang - zxTomw diff --git a/docs/api/hooks/useMatch.md b/docs/api/hooks/useMatch.md index dad84bdc87..ef48934833 100644 --- a/docs/api/hooks/useMatch.md +++ b/docs/api/hooks/useMatch.md @@ -29,9 +29,9 @@ This is useful for components that need to know "active" state, e.g. ## Signature ```tsx -function useMatch, Path extends string>( +function useMatch( pattern: PathPattern | Path, -): PathMatch | null +): PathMatch> | null ``` ## Params diff --git a/docs/api/utils/generatePath.md b/docs/api/utils/generatePath.md index 489f76de84..44069b9bf7 100644 --- a/docs/api/utils/generatePath.md +++ b/docs/api/utils/generatePath.md @@ -35,9 +35,7 @@ generatePath("/users/:id", { id: "123" }); // "/users/123" ```tsx function generatePath( originalPath: Path, - params: { - [key in PathParam]: string | null; - } = as any, + params: GeneratePathParams = as any, ): string {} ``` diff --git a/docs/api/utils/matchPath.md b/docs/api/utils/matchPath.md index 13018d6b7b..3996c5c0cc 100644 --- a/docs/api/utils/matchPath.md +++ b/docs/api/utils/matchPath.md @@ -28,10 +28,10 @@ the match. ## Signature ```tsx -function matchPath, Path extends string>( +function matchPath( pattern: PathPattern | Path, pathname: string, -): PathMatch | null +): PathMatch> | null ``` ## Params diff --git a/integration/helpers/cloudflare-dev-proxy-template/tsconfig.json b/integration/helpers/cloudflare-dev-proxy-template/tsconfig.json index 6be050f9c6..8f318fcee7 100644 --- a/integration/helpers/cloudflare-dev-proxy-template/tsconfig.json +++ b/integration/helpers/cloudflare-dev-proxy-template/tsconfig.json @@ -14,6 +14,7 @@ "allowJs": true, "skipLibCheck": true, "baseUrl": ".", - "noEmit": true + "noEmit": true, + "rootDirs": [".", ".react-router/types/"] } } diff --git a/integration/helpers/playwright-fixture.ts b/integration/helpers/playwright-fixture.ts index 9d36e1eb3b..93ee4aa4e1 100644 --- a/integration/helpers/playwright-fixture.ts +++ b/integration/helpers/playwright-fixture.ts @@ -347,26 +347,6 @@ async function doAndWait( } await networkSettledPromise; - // I wish I knew why but Safari seems to get all screwed up without this. - // When you run doAndWait (via clicking a blink or submitting a form) and - // then waitForSelector(). It finds the selector element but thinks it's - // hidden for some unknown reason. It's intermittent, but waiting for the - // next animation frame delaying slightly before the waitForSelector() calls - // seems to fix it πŸ€·β€β™‚οΈ - // - // Test timeout of 30000ms exceeded. - // - // Error: page.waitForSelector: Target closed - // =========================== logs =========================== - // waiting for locator('text=ROOT_BOUNDARY_TEXT') to be visible - // locator resolved to hidden
ROOT_BOUNDARY_TEXT
- // locator resolved to hidden
ROOT_BOUNDARY_TEXT
- // ... and so on until the test times out - let userAgent = await page.evaluate(() => navigator.userAgent); - if (/Safari\//i.test(userAgent) && !/Chrome\//i.test(userAgent)) { - await page.evaluate(() => new Promise((r) => requestAnimationFrame(r))); - } - if (DEBUG) { console.log(`action done, network settled`); } diff --git a/integration/helpers/rsc-vite-framework/tsconfig.json b/integration/helpers/rsc-vite-framework/tsconfig.json index 68287df9cf..3fc8710e47 100644 --- a/integration/helpers/rsc-vite-framework/tsconfig.json +++ b/integration/helpers/rsc-vite-framework/tsconfig.json @@ -1,5 +1,6 @@ { "include": ["**/*.ts", "**/*.tsx", "./.react-router/types/**/*"], + "exclude": ["vite.config*"], "compilerOptions": { "allowImportingTsExtensions": true, "strict": true, diff --git a/integration/helpers/rsc-vite-framework/vite.config.ts b/integration/helpers/rsc-vite-framework/vite.config.ts index 41793b7754..735a60b72d 100644 --- a/integration/helpers/rsc-vite-framework/vite.config.ts +++ b/integration/helpers/rsc-vite-framework/vite.config.ts @@ -1,11 +1,13 @@ import { defineConfig } from "vite"; import { unstable_reactRouterRSC as reactRouterRSC } from "@react-router/dev/vite"; +import react from "@vitejs/plugin-react"; import rsc from "@vitejs/plugin-rsc"; export default defineConfig({ plugins: [ // @ts-ignore reactRouterRSC({ __runningWithinTheReactRouterMonoRepo: true }), + react(), rsc(), ], }); diff --git a/integration/helpers/vite.ts b/integration/helpers/vite.ts index f630968ecb..273fb18496 100644 --- a/integration/helpers/vite.ts +++ b/integration/helpers/vite.ts @@ -106,6 +106,7 @@ export const viteConfig = { ? "import { reactRouter } from '@react-router/dev/vite';" : [ "import { unstable_reactRouterRSC as reactRouterRSC } from '@react-router/dev/vite';", + "import react from '@vitejs/plugin-react';", "import rsc from '@vitejs/plugin-rsc';", ].join("\n") } @@ -118,10 +119,10 @@ export const viteConfig = { let useNativeTsconfigPaths = parseInt(vite.version.split(".")[0], 10) >= 8; let plugins = [ - ${args.mdx ? "mdx()," : ""} + ${args.mdx ? "{enforce: 'pre', ...mdx()}," : ""} ${args.vanillaExtract ? "vanillaExtractPlugin({ emitCssInSsr: true })," : ""} ${isRsc ? " reactRouterRSC({ __runningWithinTheReactRouterMonoRepo: true })," : "reactRouter(),"} - ${isRsc ? "rsc()," : ""} + ${isRsc ? "react(), rsc()," : ""} envOnlyMacros(), ]; diff --git a/integration/typegen-test.ts b/integration/typegen-test.ts index 99bd9a6af9..d50b27bf05 100644 --- a/integration/typegen-test.ts +++ b/integration/typegen-test.ts @@ -868,4 +868,23 @@ test.describe("typegen", () => { }); }); }); + + test("layout without pages", async ({ edit, $ }) => { + await edit({ + "app/routes.ts": tsx` + import { type RouteConfig, layout } from "@react-router/dev/routes"; + + export default [ + layout("routes/layout.tsx", []), + ] satisfies RouteConfig; + `, + "app/routes/layout.tsx": tsx` + import { Outlet } from "react-router" + export default function Component() { + return
+ } + `, + }); + await $("pnpm typecheck"); + }); }); diff --git a/integration/vite-hmr-hdr-rsc-test.ts b/integration/vite-hmr-hdr-rsc-test.ts index 13e7a90464..01f2b5ee00 100644 --- a/integration/vite-hmr-hdr-rsc-test.ts +++ b/integration/vite-hmr-hdr-rsc-test.ts @@ -365,7 +365,7 @@ test.describe("Vite HMR & HDR (RSC)", () => { await expect(clientComponent).toHaveText( "Imported Client Component HMR: 3", ); - await expect(clientButton).toHaveText("Count: 1"); + await expect(clientButton).toBeVisible(); await expect(hdrStatus).toHaveText( "HDR updated: route & direct 2 & indirect 2", ); @@ -373,6 +373,7 @@ test.describe("Vite HMR & HDR (RSC)", () => { expect(page.errors).toEqual([]); // switch from server-first to client route + const waitPromise = page.waitForLoadState("load"); await edit("app/routes/hmr/route.tsx", (contents) => contents .replace( @@ -381,43 +382,11 @@ test.describe("Vite HMR & HDR (RSC)", () => { ) .replace("HMR updated: 3", "Client Route HMR: 0"), ); - await page.waitForLoadState("networkidle"); - await expect(hmrStatus).toHaveText("Client Route HMR: 0"); - // adding/removing client component exports causes an HMR invalidation and a - // page reload. some browsers maintain input state, so we forcibly clear - await input.clear(); - await input.type("client stateful"); - expect(page.errors).toEqual([]); - await edit("app/routes/hmr/route.tsx", (contents) => - contents.replace("Client Route HMR: 0", "Client Route HMR: 1"), - ); - await page.waitForLoadState("networkidle"); - await expect(hmrStatus).toHaveText("Client Route HMR: 1"); - await expect(input).toHaveValue("client stateful"); - expect(page.errors).toEqual([]); - - // switch from client route back to server-first route - await edit("app/routes/hmr/route.tsx", (contents) => - contents - .replace( - "export default function ClientComponent", - "export function ServerComponent", - ) - .replace("Client Route HMR: 1", "Server Route HMR: 0"), - ); - await page.waitForLoadState("networkidle"); - await expect(hmrStatus).toHaveText("Server Route HMR: 0"); - // adding/removing client component exports causes an HMR invalidation and a - // page reload. some browsers maintain input state, so we forcibly clear - await input.clear(); - await input.type("server stateful"); - expect(page.errors).toEqual([]); - await edit("app/routes/hmr/route.tsx", (contents) => - contents.replace("Server Route HMR: 0", "Server Route HMR: 1"), + await waitPromise; + // await page.waitForLoadState("networkidle"); + await expect(page.locator("#index [data-mounted]")).toHaveText( + "Mounted: yes", ); - await page.waitForLoadState("networkidle"); - await expect(hmrStatus).toHaveText("Server Route HMR: 1"); - await expect(input).toHaveValue("server stateful"); - expect(page.errors).toEqual([]); + await expect(hmrStatus).toHaveText("Client Route HMR: 0"); }); }); diff --git a/integration/vite-hmr-hdr-test.ts b/integration/vite-hmr-hdr-test.ts index 0be7670205..067b01b1af 100644 --- a/integration/vite-hmr-hdr-test.ts +++ b/integration/vite-hmr-hdr-test.ts @@ -6,15 +6,14 @@ import dedent from "dedent"; import * as Express from "./helpers/express"; import { test } from "./helpers/fixtures"; import * as Stream from "./helpers/stream"; -import { viteMajorTemplates, getTemplates } from "./helpers/templates"; +import { viteMajorTemplates } from "./helpers/templates"; const tsx = dedent; const mdx = dedent; -const templates = [ - ...viteMajorTemplates, - ...getTemplates(["rsc-vite-framework"]), -]; +const templates = [...viteMajorTemplates]; + +// RSC Framework HMR/HDR behavior is covered in integration/vite-hmr-hdr-rsc-test.ts. templates.forEach((template) => { const isRsc = template.name.startsWith("rsc-"); diff --git a/integration/vite-plugin-order-validation-test.ts b/integration/vite-plugin-order-validation-test.ts index a3121503ca..d8edae05ce 100644 --- a/integration/vite-plugin-order-validation-test.ts +++ b/integration/vite-plugin-order-validation-test.ts @@ -31,12 +31,14 @@ test.describe("Vite plugin order validation", () => { "vite.config.js": dedent` import { defineConfig } from "vite"; import { unstable_reactRouterRSC as reactRouterRSC } from "@react-router/dev/vite"; + import react from "@vitejs/plugin-react"; import rsc from "@vitejs/plugin-rsc"; import mdx from "@mdx-js/rollup"; export default defineConfig({ plugins: [ reactRouterRSC(), + react(), rsc(), mdx(), ], diff --git a/package.json b/package.json index d61af2ba8b..8afbd78863 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "lint": "eslint --cache .", "playground": "node ./scripts/playground.js", "pr-preview": "node ./scripts/pr-preview.ts", - "prerelease": "pnpm build", + "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", diff --git a/packages/create-react-router/CHANGELOG.md b/packages/create-react-router/CHANGELOG.md index b108484a89..3a28edf758 100644 --- a/packages/create-react-router/CHANGELOG.md +++ b/packages/create-react-router/CHANGELOG.md @@ -1,5 +1,11 @@ # `create-react-router` +## v7.14.2 + +### Patch Changes + +- _No changes_ + ## v7.14.1 ### Patch Changes diff --git a/packages/create-react-router/package.json b/packages/create-react-router/package.json index 5ba5acde18..c05ba22ba3 100644 --- a/packages/create-react-router/package.json +++ b/packages/create-react-router/package.json @@ -1,6 +1,6 @@ { "name": "create-react-router", - "version": "7.14.1", + "version": "7.14.2", "description": "Create a new React Router app", "homepage": "https://reactrouter.com", "bugs": { diff --git a/packages/react-router-architect/CHANGELOG.md b/packages/react-router-architect/CHANGELOG.md index 38408a24b5..d16b8c1782 100644 --- a/packages/react-router-architect/CHANGELOG.md +++ b/packages/react-router-architect/CHANGELOG.md @@ -1,5 +1,13 @@ # `@react-router/architect` +## v7.14.2 + +### Patch Changes + +- Updated dependencies: + - [`react-router@7.14.2`](https://github.com/remix-run/react-router/releases/tag/react-router@7.14.2) + - [`@react-router/node@7.14.2`](https://github.com/remix-run/react-router/releases/tag/@react-router/node@7.14.2) + ## v7.14.1 ### Patch Changes diff --git a/packages/react-router-architect/package.json b/packages/react-router-architect/package.json index 84b108e664..60aee5f3e3 100644 --- a/packages/react-router-architect/package.json +++ b/packages/react-router-architect/package.json @@ -1,6 +1,6 @@ { "name": "@react-router/architect", - "version": "7.14.1", + "version": "7.14.2", "description": "Architect server request handler for React Router", "bugs": { "url": "https://github.com/remix-run/react-router/issues" diff --git a/packages/react-router-cloudflare/CHANGELOG.md b/packages/react-router-cloudflare/CHANGELOG.md index cbf276476e..f7cf5ab018 100644 --- a/packages/react-router-cloudflare/CHANGELOG.md +++ b/packages/react-router-cloudflare/CHANGELOG.md @@ -1,5 +1,12 @@ # `@react-router/cloudflare` +## v7.14.2 + +### Patch Changes + +- Updated dependencies: + - [`react-router@7.14.2`](https://github.com/remix-run/react-router/releases/tag/react-router@7.14.2) + ## v7.14.1 ### Patch Changes diff --git a/packages/react-router-cloudflare/package.json b/packages/react-router-cloudflare/package.json index 2720ca0ec9..6087fc99d0 100644 --- a/packages/react-router-cloudflare/package.json +++ b/packages/react-router-cloudflare/package.json @@ -1,6 +1,6 @@ { "name": "@react-router/cloudflare", - "version": "7.14.1", + "version": "7.14.2", "description": "Cloudflare platform abstractions for React Router", "bugs": { "url": "https://github.com/remix-run/react-router/issues" diff --git a/packages/react-router-dev/CHANGELOG.md b/packages/react-router-dev/CHANGELOG.md index fb5b992926..4830dafa98 100644 --- a/packages/react-router-dev/CHANGELOG.md +++ b/packages/react-router-dev/CHANGELOG.md @@ -1,5 +1,26 @@ # `@react-router/dev` +## v7.14.2 + +### Patch Changes + +- Fix typegen for layouts without pages ([[aabf4a1](https://github.com/remix-run/react-router/commit/aabf4a1)) + + Previously, typegen could produce `pages: ;` in `.react-router/types/+routes.ts` when a route corresponded to 0 pages. + Now, `pages: never;` is correctly generated for those cases. + +### Unstable Changes + +⚠️ _[Unstable features](https://reactrouter.com/community/api-development-strategy#unstable-flags) are not recommended for production use_ + +- For `unstable_reactRouterRSC` Vite plugin consumers, require `@vitejs/plugin-react` in user Vite config, and more reliably split route modules. ([#14965](https://github.com/remix-run/react-router/pull/14965)) ([[aabf4a1](https://github.com/remix-run/react-router/commit/aabf4a1)) + - ⚠️ This is a breaking change if you have begun using the `unstable_reactRouterRSC` Vite plugin - please install `@vitejs/plugin-react` and add the `react` plugin to your Vite plugins array. + +- Updated dependencies: + - [`react-router@7.14.2`](https://github.com/remix-run/react-router/releases/tag/react-router@7.14.2) + - [`@react-router/node@7.14.2`](https://github.com/remix-run/react-router/releases/tag/@react-router/node@7.14.2) + - [`@react-router/serve@7.14.2`](https://github.com/remix-run/react-router/releases/tag/@react-router/serve@7.14.2) + ## v7.14.1 ### Patch Changes diff --git a/packages/react-router-dev/__tests__/rsc-virtual-route-modules-test.ts b/packages/react-router-dev/__tests__/rsc-virtual-route-modules-test.ts new file mode 100644 index 0000000000..0d46cab2f0 --- /dev/null +++ b/packages/react-router-dev/__tests__/rsc-virtual-route-modules-test.ts @@ -0,0 +1,589 @@ +import * as assert from "node:assert"; +import * as ts from "typescript"; + +import { virtualRouteModulesPlugin } from "../vite/rsc/virtual-route-modules"; + +const plugin = virtualRouteModulesPlugin({ + enforceSplitRouteModules: () => false, + getRouteIdForFile() { + return "test-route-id"; + }, + isRootRouteModule() { + return false; + }, + async transformToJs(code: string, filename: string) { + return await ts.transpile(code, { + target: ts.ScriptTarget.ESNext, + module: ts.ModuleKind.ESNext, + jsx: ts.JsxEmit.ReactJSX, + }); + }, +}); + +const js = String.raw; + +const fullClientModule = js` + import "./side-effect.css"; + import { client } from "./client"; + import { server } from "./server"; + import { shared } from "./shared"; + export function loader() { console.log(server, shared); } + export function action() { console.log(server, shared); } + export function headers() { console.log(server, shared); } + export function clientLoader() { console.log(client, shared); } + export function clientAction() { console.log(client, shared); } + export function links() { console.log(client, shared); } + export function meta() { console.log(client, shared); } + export default function Route() { console.log(client, shared); } + export function Layout() { console.log(client, shared); } + export function ErrorBoundary() { console.log(client, shared); } + export function HydrateFallback() { console.log(client, shared); } +`; + +const fullServerModule = js` + import "./side-effect.css"; + import { client } from "./client"; + import { server } from "./server"; + import { shared } from "./shared"; + export function loader() { console.log(server, shared); } + export function action() { console.log(server, shared); } + export function headers() { console.log(server, shared); } + export function clientLoader() { console.log(client, shared); } + export function clientAction() { console.log(client, shared); } + export function links() { console.log(client, shared); } + export function meta() { console.log(client, shared); } + export function ServerComponent() { console.log(server, shared); } + export function ServerLayout() { console.log(server, shared); } + export function ServerErrorBoundary() { console.log(server, shared); } + export function ServerHydrateFallback() { console.log(server, shared); } +`; + +const mixedModule = js` + import "./side-effect.css"; + import { client } from "./client"; + import { server } from "./server"; + import { shared } from "./shared"; + export function loader() { console.log(server, shared); } + export function action() { console.log(server, shared); } + export function headers() { console.log(server, shared); } + export function clientLoader() { console.log(client, shared); } + export function clientAction() { console.log(client, shared); } + export function links() { console.log(client, shared); } + export function meta() { console.log(client, shared); } + export function ServerComponent() { console.log(server, shared); } + export function Layout() { console.log(client, shared); } + export function ErrorBoundary() { console.log(client, shared); } + export function HydrateFallback() { console.log(client, shared); } +`; + +const unsplittableModule = js` + import "./side-effect.css"; + import { client } from "./client"; + import { server } from "./server"; + import { shared } from "./shared"; + export const test = "test"; + export function loader() { console.log(server, shared); } + export function action() { console.log(server, shared); } + export function headers() { console.log(server, shared); } + export function clientLoader() { console.log(client, shared, test); } + export function clientAction() { console.log(client, shared, test); } + export function links() { console.log(client, shared); } + export function meta() { console.log(client, shared); } + export default function Route() { console.log(client, shared); } + export function Layout() { console.log(client, shared); } + export function ErrorBoundary() { console.log(client, shared); } + export function HydrateFallback() { console.log(client, shared, test); } +`; + +const transform = plugin.transform.bind({ + environment: { name: "rsc" }, +} as any); + +function withSharedChunkHmr(lines: string[]) { + return [ + ...lines, + 'import * as ___EnsureClientRouteModuleForHMR_REACT___ from "react";', + "export function EnsureClientRouteModuleForHMR___() { return ___EnsureClientRouteModuleForHMR_REACT___.createElement(___EnsureClientRouteModuleForHMR_REACT___.Fragment, null) }", + "", + ]; +} + +describe("route entry", () => { + describe("client environment", () => { + const transform = plugin.transform.bind({ + environment: { name: "client" }, + } as any); + + it("transforms full client modules", async () => { + const transformed = await transform(fullClientModule, "/test.js"); + assert.ok(transformed); + expect(transformed.code).toBe( + [ + '"use client";', + 'import * as React from "react";', + 'export const clientLoader = async (...args) => import("/test.js?client-route-module=clientLoader").then(mod => mod.clientLoader(...args));', + 'export const clientAction = async (...args) => import("/test.js?client-route-module=clientAction").then(mod => mod.clientAction(...args));', + 'export { links } from "/test.js?client-route-module=shared";', + 'export { meta } from "/test.js?client-route-module=shared";', + 'export { default } from "/test.js?client-route-module=shared";', + 'export { Layout } from "/test.js?client-route-module=shared";', + 'export { ErrorBoundary } from "/test.js?client-route-module=shared";', + 'export const HydrateFallback = React.lazy(() => import("/test.js?client-route-module=HydrateFallback").then(mod => ({ default: mod.HydrateFallback })));\n', + ].join("\n"), + ); + }); + + it("transforms full server modules", async () => { + const transformed = await transform(fullServerModule, "/test.js"); + assert.ok(transformed); + expect(transformed.code).toBe( + [ + '"use client";', + 'export const clientLoader = async (...args) => import("/test.js?client-route-module=clientLoader").then(mod => mod.clientLoader(...args));', + 'export const clientAction = async (...args) => import("/test.js?client-route-module=clientAction").then(mod => mod.clientAction(...args));', + 'export { links } from "/test.js?client-route-module=shared";', + 'export { meta } from "/test.js?client-route-module=shared";\n', + ].join("\n"), + ); + }); + + it("transforms mixed modules", async () => { + const transformed = await transform(mixedModule, "/test.js"); + assert.ok(transformed); + expect(transformed.code).toBe( + [ + '"use client";', + 'import * as React from "react";', + 'export const clientLoader = async (...args) => import("/test.js?client-route-module=clientLoader").then(mod => mod.clientLoader(...args));', + 'export const clientAction = async (...args) => import("/test.js?client-route-module=clientAction").then(mod => mod.clientAction(...args));', + 'export { links } from "/test.js?client-route-module=shared";', + 'export { meta } from "/test.js?client-route-module=shared";', + 'export { Layout } from "/test.js?client-route-module=shared";', + 'export { ErrorBoundary } from "/test.js?client-route-module=shared";', + 'export const HydrateFallback = React.lazy(() => import("/test.js?client-route-module=HydrateFallback").then(mod => ({ default: mod.HydrateFallback })));\n', + ].join("\n"), + ); + }); + + it("transforms unsplittable modules", async () => { + const transformed = await transform(unsplittableModule, "/test.js"); + assert.ok(transformed); + expect(transformed.code).toBe( + [ + '"use client";', + 'import * as React from "react";', + 'export { test } from "/test.js?client-route-module=shared";', + 'export { clientLoader } from "/test.js?client-route-module=shared";', + 'export { clientAction } from "/test.js?client-route-module=shared";', + 'export { links } from "/test.js?client-route-module=shared";', + 'export { meta } from "/test.js?client-route-module=shared";', + 'export { default } from "/test.js?client-route-module=shared";', + 'export { Layout } from "/test.js?client-route-module=shared";', + 'export { ErrorBoundary } from "/test.js?client-route-module=shared";', + 'export const HydrateFallback = React.lazy(() => import("/test.js?client-route-module=shared").then(mod => ({ default: mod.HydrateFallback })));\n', + ].join("\n"), + ); + }); + }); + + describe("server environment", () => { + function withCss(name: string) { + return [ + `import { ${name} as ${name}WithoutCss } from "/test.js?server-route-module=";`, + `export function ${name}(props) {`, + ` return React.createElement(React.Fragment, null,`, + ` import.meta.viteRsc.loadCss(),`, + ` React.createElement(${name}WithoutCss, props),`, + ` );`, + `}`, + ]; + } + + it("transforms full client modules", async () => { + const transformed = await transform(fullClientModule, "/test.js"); + assert.ok(transformed); + expect(transformed.code).toBe( + [ + 'export { loader } from "/test.js?server-route-module=";', + 'export { action } from "/test.js?server-route-module=";', + 'export { headers } from "/test.js?server-route-module=";', + 'export { clientLoader } from "/test.js?client-route-module=clientLoader";', + 'export { clientAction } from "/test.js?client-route-module=clientAction";', + 'export { links } from "/test.js?client-route-module=shared";', + 'export { meta } from "/test.js?client-route-module=shared";', + 'export { default } from "/test.js?client-route-module=shared";', + 'export { Layout } from "/test.js?client-route-module=shared";', + 'export { ErrorBoundary } from "/test.js?client-route-module=shared";', + 'export { HydrateFallback } from "/test.js?client-route-module=HydrateFallback";\n', + ].join("\n"), + ); + }); + + it("transforms full server modules", async () => { + const transformed = await transform(fullServerModule, "/test.js"); + assert.ok(transformed); + + expect(transformed.code).toBe( + [ + 'import * as React from "react";', + 'import { EnsureClientRouteModuleForHMR___ } from "/test.js?client-route-module=shared";', + "", + 'export { loader } from "/test.js?server-route-module=";', + 'export { action } from "/test.js?server-route-module=";', + 'export { headers } from "/test.js?server-route-module=";', + 'export { clientLoader } from "/test.js?client-route-module=clientLoader";', + 'export { clientAction } from "/test.js?client-route-module=clientAction";', + 'export { links } from "/test.js?client-route-module=shared";', + 'export { meta } from "/test.js?client-route-module=shared";', + ...withCss("ServerComponent").slice(0, 4), + " React.createElement(EnsureClientRouteModuleForHMR___, null),", + ...withCss("ServerComponent").slice(4), + ...withCss("ServerLayout").slice(0, 4), + " React.createElement(EnsureClientRouteModuleForHMR___, null),", + ...withCss("ServerLayout").slice(4), + ...withCss("ServerErrorBoundary").slice(0, 4), + " React.createElement(EnsureClientRouteModuleForHMR___, null),", + ...withCss("ServerErrorBoundary").slice(4), + ...withCss("ServerHydrateFallback").slice(0, 4), + " React.createElement(EnsureClientRouteModuleForHMR___, null),", + ...withCss("ServerHydrateFallback").slice(4), + ].join("\n") + "\n", + ); + }); + + it("transforms mixed modules", async () => { + const transformed = await transform(mixedModule, "/test.js"); + assert.ok(transformed); + expect(transformed.code).toBe( + [ + 'import * as React from "react";', + 'import { EnsureClientRouteModuleForHMR___ } from "/test.js?client-route-module=shared";', + "", + 'export { loader } from "/test.js?server-route-module=";', + 'export { action } from "/test.js?server-route-module=";', + 'export { headers } from "/test.js?server-route-module=";', + 'export { clientLoader } from "/test.js?client-route-module=clientLoader";', + 'export { clientAction } from "/test.js?client-route-module=clientAction";', + 'export { links } from "/test.js?client-route-module=shared";', + 'export { meta } from "/test.js?client-route-module=shared";', + ...withCss("ServerComponent").slice(0, 4), + " React.createElement(EnsureClientRouteModuleForHMR___, null),", + ...withCss("ServerComponent").slice(4), + 'export { Layout } from "/test.js?client-route-module=shared";', + 'export { ErrorBoundary } from "/test.js?client-route-module=shared";', + 'export { HydrateFallback } from "/test.js?client-route-module=HydrateFallback";', + ].join("\n") + "\n", + ); + }); + + it("transforms unsplittable modules", async () => { + const transformed = await transform(unsplittableModule, "/test.js"); + assert.ok(transformed); + expect(transformed.code).toBe( + [ + 'export { test } from "/test.js?server-route-module=";', + 'export { loader } from "/test.js?server-route-module=";', + 'export { action } from "/test.js?server-route-module=";', + 'export { headers } from "/test.js?server-route-module=";', + 'export { clientLoader } from "/test.js?client-route-module=shared";', + 'export { clientAction } from "/test.js?client-route-module=shared";', + 'export { links } from "/test.js?client-route-module=shared";', + 'export { meta } from "/test.js?client-route-module=shared";', + 'export { default } from "/test.js?client-route-module=shared";', + 'export { Layout } from "/test.js?client-route-module=shared";', + 'export { ErrorBoundary } from "/test.js?client-route-module=shared";', + 'export { HydrateFallback } from "/test.js?client-route-module=shared";\n', + ].join("\n"), + ); + }); + }); +}); + +describe("server-route-module", () => { + it("transforms full client modules", async () => { + const transformed = await transform( + fullClientModule, + "/test.js?server-route-module=", + ); + assert.ok(transformed); + expect(transformed.code).toBe( + [ + 'import "./side-effect.css";', + 'import { server } from "./server";', + 'import { shared } from "./shared";', + "export function loader() {\n console.log(server, shared);\n}", + "export function action() {\n console.log(server, shared);\n}", + "export function headers() {\n console.log(server, shared);\n}", + ].join("\n"), + ); + }); + + it("transforms full server modules", async () => { + const transformed = await transform( + fullServerModule, + "/test.js?server-route-module=", + ); + assert.ok(transformed); + expect(transformed.code).toBe( + [ + 'import "./side-effect.css";', + 'import { server } from "./server";', + 'import { shared } from "./shared";', + "export function loader() {\n console.log(server, shared);\n}", + "export function action() {\n console.log(server, shared);\n}", + "export function headers() {\n console.log(server, shared);\n}", + "export function ServerComponent() {\n console.log(server, shared);\n}", + "export function ServerLayout() {\n console.log(server, shared);\n}", + "export function ServerErrorBoundary() {\n console.log(server, shared);\n}", + "export function ServerHydrateFallback() {\n console.log(server, shared);\n}", + ].join("\n"), + ); + }); + + it("transforms mixed modules", async () => { + const transformed = await transform( + mixedModule, + "/test.js?server-route-module=", + ); + assert.ok(transformed); + expect(transformed.code).toBe( + [ + 'import "./side-effect.css";', + 'import { server } from "./server";', + 'import { shared } from "./shared";', + "export function loader() {\n console.log(server, shared);\n}", + "export function action() {\n console.log(server, shared);\n}", + "export function headers() {\n console.log(server, shared);\n}", + "export function ServerComponent() {\n console.log(server, shared);\n}", + ].join("\n"), + ); + }); +}); + +describe("client-route-module=shared", () => { + it("transforms full client modules", async () => { + const transformed = await transform( + fullClientModule, + "/test.js?client-route-module=shared", + ); + assert.ok(transformed); + expect(transformed.code).toBe( + withSharedChunkHmr([ + '"use client";', + 'import "./side-effect.css";', + 'import { client } from "./client";', + 'import { shared } from "./shared";', + "export function links() {\n console.log(client, shared);\n}", + "export function meta() {\n console.log(client, shared);\n}", + "export default function Route() {\n console.log(client, shared);\n}", + "export function Layout() {\n console.log(client, shared);\n}", + "export function ErrorBoundary() {\n console.log(client, shared);\n}", + ]).join("\n"), + ); + }); + + it("transforms full server modules", async () => { + const transformed = await transform( + fullServerModule, + "/test.js?client-route-module=shared", + ); + assert.ok(transformed); + expect(transformed.code).toBe( + withSharedChunkHmr([ + '"use client";', + 'import "./side-effect.css";', + 'import { client } from "./client";', + 'import { shared } from "./shared";', + "export function links() {\n console.log(client, shared);\n}", + "export function meta() {\n console.log(client, shared);\n}", + ]).join("\n"), + ); + }); + + it("transforms mixed modules", async () => { + const transformed = await transform( + mixedModule, + "/test.js?client-route-module=shared", + ); + assert.ok(transformed); + expect(transformed.code).toBe( + withSharedChunkHmr([ + '"use client";', + 'import "./side-effect.css";', + 'import { client } from "./client";', + 'import { shared } from "./shared";', + "export function links() {\n console.log(client, shared);\n}", + "export function meta() {\n console.log(client, shared);\n}", + "export function Layout() {\n console.log(client, shared);\n}", + "export function ErrorBoundary() {\n console.log(client, shared);\n}", + ]).join("\n"), + ); + }); + + it("transforms unsplittable modules", async () => { + const transformed = await transform( + unsplittableModule, + "/test.js?client-route-module=shared", + ); + assert.ok(transformed); + expect(transformed.code).toBe( + withSharedChunkHmr([ + '"use client";', + 'import "./side-effect.css";', + 'import { client } from "./client";', + 'import { shared } from "./shared";', + 'export const test = "test";', + "export function clientLoader() {\n console.log(client, shared, test);\n}", + "export function clientAction() {\n console.log(client, shared, test);\n}", + "export function links() {\n console.log(client, shared);\n}", + "export function meta() {\n console.log(client, shared);\n}", + "export default function Route() {\n console.log(client, shared);\n}", + "export function Layout() {\n console.log(client, shared);\n}", + "export function ErrorBoundary() {\n console.log(client, shared);\n}", + "export function HydrateFallback() {\n console.log(client, shared, test);\n}", + ]).join("\n"), + ); + }); +}); + +describe("client-route-module=clientLoader", () => { + it("transforms full client modules", async () => { + const transformed = await transform( + fullClientModule, + "/test.js?client-route-module=clientLoader", + ); + assert.ok(transformed); + expect(transformed.code).toBe( + [ + '"use client";', + 'import "./side-effect.css";', + 'import { client } from "./client";', + 'import { shared } from "./shared";', + "export function clientLoader() {\n console.log(client, shared);\n}", + ].join("\n"), + ); + }); + + it("transforms full server modules", async () => { + const transformed = await transform( + fullServerModule, + "/test.js?client-route-module=clientLoader", + ); + assert.ok(transformed); + expect(transformed.code).toBe( + [ + '"use client";', + 'import "./side-effect.css";', + 'import { client } from "./client";', + 'import { shared } from "./shared";', + "export function clientLoader() {\n console.log(client, shared);\n}", + ].join("\n"), + ); + }); + + it("transforms mixed modules", async () => { + const transformed = await transform( + mixedModule, + "/test.js?client-route-module=clientLoader", + ); + assert.ok(transformed); + expect(transformed.code).toBe( + [ + '"use client";', + 'import "./side-effect.css";', + 'import { client } from "./client";', + 'import { shared } from "./shared";', + "export function clientLoader() {\n console.log(client, shared);\n}", + ].join("\n"), + ); + }); +}); + +describe("client-route-module=clientAction", () => { + it("transforms full client modules", async () => { + const transformed = await transform( + fullClientModule, + "/test.js?client-route-module=clientAction", + ); + assert.ok(transformed); + expect(transformed.code).toBe( + [ + '"use client";', + 'import "./side-effect.css";', + 'import { client } from "./client";', + 'import { shared } from "./shared";', + "export function clientAction() {\n console.log(client, shared);\n}", + ].join("\n"), + ); + }); + + it("transforms full server modules", async () => { + const transformed = await transform( + fullServerModule, + "/test.js?client-route-module=clientAction", + ); + assert.ok(transformed); + expect(transformed.code).toBe( + [ + '"use client";', + 'import "./side-effect.css";', + 'import { client } from "./client";', + 'import { shared } from "./shared";', + "export function clientAction() {\n console.log(client, shared);\n}", + ].join("\n"), + ); + }); + + it("transforms mixed modules", async () => { + const transformed = await transform( + mixedModule, + "/test.js?client-route-module=clientAction", + ); + assert.ok(transformed); + expect(transformed.code).toBe( + [ + '"use client";', + 'import "./side-effect.css";', + 'import { client } from "./client";', + 'import { shared } from "./shared";', + "export function clientAction() {\n console.log(client, shared);\n}", + ].join("\n"), + ); + }); +}); + +describe("client-route-module=HydrateFallback", () => { + it("transforms full client modules", async () => { + const transformed = await transform( + fullClientModule, + "/test.js?client-route-module=HydrateFallback", + ); + assert.ok(transformed); + expect(transformed.code).toBe( + [ + '"use client";', + 'import "./side-effect.css";', + 'import { client } from "./client";', + 'import { shared } from "./shared";', + "export function HydrateFallback() {\n console.log(client, shared);\n}", + ].join("\n"), + ); + }); + + it("transforms mixed modules", async () => { + const transformed = await transform( + mixedModule, + "/test.js?client-route-module=HydrateFallback", + ); + assert.ok(transformed); + expect(transformed.code).toBe( + [ + '"use client";', + 'import "./side-effect.css";', + 'import { client } from "./client";', + 'import { shared } from "./shared";', + "export function HydrateFallback() {\n console.log(client, shared);\n}", + ].join("\n"), + ); + }); +}); diff --git a/packages/react-router-dev/package.json b/packages/react-router-dev/package.json index 5d0511d79d..834b4930fa 100644 --- a/packages/react-router-dev/package.json +++ b/packages/react-router-dev/package.json @@ -1,6 +1,6 @@ { "name": "@react-router/dev", - "version": "7.14.1", + "version": "7.14.2", "description": "Dev tools and CLI for React Router", "homepage": "https://reactrouter.com", "bugs": { diff --git a/packages/react-router-dev/typegen/generate.ts b/packages/react-router-dev/typegen/generate.ts index 5294375ee4..1b8fcb0098 100644 --- a/packages/react-router-dev/typegen/generate.ts +++ b/packages/react-router-dev/typegen/generate.ts @@ -178,7 +178,7 @@ function routeFilesType({ t.tsPropertySignature( t.identifier("page"), t.tsTypeAnnotation( - pages + pages.size > 0 ? t.tsUnionType( Array.from(pages).map((page) => t.tsLiteralType(t.stringLiteral(page)), diff --git a/packages/react-router-dev/vite/route-chunks.ts b/packages/react-router-dev/vite/route-chunks.ts index 9fef92936f..070155d46c 100644 --- a/packages/react-router-dev/vite/route-chunks.ts +++ b/packages/react-router-dev/vite/route-chunks.ts @@ -25,7 +25,11 @@ type Dependencies = { exportedVariableDeclarators: Set; }; -function codeToAst(code: string, cache: Cache, cacheKey: string): Babel.File { +export function codeToAst( + code: string, + cache: Cache, + cacheKey: string, +): Babel.File { // We use structuredClone to allow AST mutation without modifying the cache. return structuredClone( getOrSetFromCache(cache, `${cacheKey}::codeToAst`, code, () => diff --git a/packages/react-router-dev/vite/rsc/plugin.ts b/packages/react-router-dev/vite/rsc/plugin.ts index 0481cd70b7..14a15d3544 100644 --- a/packages/react-router-dev/vite/rsc/plugin.ts +++ b/packages/react-router-dev/vite/rsc/plugin.ts @@ -1,7 +1,6 @@ import type * as Vite from "vite"; import { init as initEsModuleLexer } from "es-module-lexer"; import * as Path from "pathe"; -import * as babel from "@babel/core"; import colors from "picocolors"; import { create } from "../virtual-module"; @@ -19,17 +18,13 @@ import { import { defineCompilerOptions, defineOptimizeDepsCompilerOptions, + getVite, preloadVite, } from "../vite"; import { hasDependency } from "../has-dependency"; import { getOptimizeDepsEntries } from "../optimize-deps-entries"; import { createVirtualRouteConfig } from "./virtual-route-config"; -import { - transformVirtualRouteModules, - parseRouteExports, - isVirtualClientRouteModuleId, - CLIENT_NON_COMPONENT_EXPORTS, -} from "./virtual-route-modules"; +import { virtualRouteModulesPlugin } from "./virtual-route-modules"; import { loadDotenv } from "../load-dotenv"; import { validatePluginOrder } from "../plugins/validate-plugin-order"; import { warnOnClientSourceMaps } from "../plugins/warn-on-client-source-maps"; @@ -67,6 +62,79 @@ export function reactRouterRSCVitePlugin(): Vite.PluginOption[] { ); } + function isRootRouteModule(id: string): boolean { + return path.normalize(id) === path.normalize(rootRouteFile); + } + + function getRouteIdForFile(file: string): string | undefined { + let normalizedFile = path.normalize(file); + let directMatch = routeIdByFile?.get(normalizedFile); + if (directMatch) { + return directMatch; + } + + return Array.from(routeIdByFile ?? []).find(([routeFile]) => + path.normalize(routeFile).endsWith(normalizedFile), + )?.[1]; + } + + function isMdxRouteModule(filename: string) { + let extension = path.extname(filename).toLowerCase(); + return extension === ".md" || extension === ".mdx"; + } + + function getTransformLanguage( + filename: string, + ): "ts" | "tsx" | "jsx" | undefined { + let extension = path.extname(filename).toLowerCase(); + + switch (extension) { + case ".ts": + case ".cts": + case ".mts": + return "ts"; + case ".tsx": + return "tsx"; + case ".js": + case ".cjs": + case ".mjs": + case ".jsx": + case ".md": + case ".mdx": + return "jsx"; + default: + return undefined; + } + } + + async function transformToJs( + code: string, + filename: string, + ): Promise { + await preloadVite(); + let vite = getVite(); + let lang = getTransformLanguage(filename); + + return ( + "transformWithOxc" in vite && typeof vite.transformWithOxc === "function" + ? await vite.transformWithOxc(code, filename, { + lang, + jsx: { + runtime: "automatic", + development: viteCommand !== "build", + target: "esnext", + }, + }) + : await vite.transformWithEsbuild(code, filename, { + loader: lang, + target: "esnext", + format: "esm", + jsx: "automatic", + jsxDev: viteCommand !== "build", + }) + ).code; + } + return [ { name: "react-router/rsc", @@ -96,8 +164,6 @@ export function reactRouterRSCVitePlugin(): Vite.PluginOption[] { if (userConfig.serverBundles) errors.push("serverBundles"); if (userConfig.future?.v8_middleware === false) errors.push("future.v8_middleware: false"); - if (userConfig.future?.v8_splitRouteModules) - errors.push("future.v8_splitRouteModules"); if (userConfig.future?.v8_viteEnvironmentApi === false) errors.push("future.v8_viteEnvironmentApi: false"); if (userConfig.future?.unstable_subResourceIntegrity) @@ -287,36 +353,6 @@ export function reactRouterRSCVitePlugin(): Vite.PluginOption[] { }, }, }, - build: { - rollupOptions: { - // Copied from https://github.com/vitejs/vite-plugin-react/blob/c602225271d4acf462ba00f8d6d8a2e42492c5cd/packages/common/warning.ts - onwarn(warning, defaultHandler) { - if ( - warning.code === "MODULE_LEVEL_DIRECTIVE" && - (warning.message.includes("use client") || - warning.message.includes("use server")) - ) { - return; - } - // https://github.com/vitejs/vite/issues/15012 - if ( - warning.code === "SOURCEMAP_ERROR" && - warning.message.includes("resolve original location") && - warning.pos === 0 - ) { - return; - } - if (viteUserConfig.build?.rollupOptions?.onwarn) { - viteUserConfig.build.rollupOptions.onwarn( - warning, - defaultHandler, - ); - } else { - defaultHandler(warning); - } - }, - }, - }, }; }, configResolved(viteConfig) { @@ -487,20 +523,17 @@ export function reactRouterRSCVitePlugin(): Vite.PluginOption[] { } }, }, - { - name: "react-router/rsc/virtual-route-modules", - transform(code, id) { - if (!routeIdByFile) return; - return transformVirtualRouteModules({ - code, - id, - viteCommand, - routeIdByFile, - rootRouteFile, - viteEnvironment: this.environment, - }); + virtualRouteModulesPlugin({ + environments: { + client: ["client", "ssr"], + server: ["rsc"], }, - }, + getRouteIdForFile, + isRootRouteModule, + transformToJs, + enforceSplitRouteModules: () => + config.future.v8_splitRouteModules === "enforce", + }), { name: "react-router/rsc/virtual-basename", resolveId(id) { @@ -546,142 +579,22 @@ export function reactRouterRSCVitePlugin(): Vite.PluginOption[] { return viteCommand === "serve" ? [ - `import RefreshRuntime from "${virtual.hmrRuntime.id}"`, - "RefreshRuntime.injectIntoGlobalHook(window)", - "window.$RefreshReg$ = () => {}", - "window.$RefreshSig$ = () => (type) => type", - "window.__vite_plugin_react_preamble_installed__ = true", + `if (import.meta.hot) { + import.meta.hot.accept(); + import.meta.hot.on('rsc:update', () => { + // Defer revalidation to the next animation frame so React Fast Refresh + // can apply pending client component updates first. Without this delay, + // the RSC payload (showing updated text) can arrive and be reconciled + // against a DOM that still has the old text, causing a hydration mismatch. + requestAnimationFrame(() => { + __reactRouterDataRouter.revalidate() + }); + }) +}`, ].join("\n") : ""; }, }, - { - name: "react-router/rsc/hmr/runtime", - enforce: "pre", - resolveId(id) { - if (id === virtual.hmrRuntime.id) return virtual.hmrRuntime.resolvedId; - }, - async load(id) { - if (id !== virtual.hmrRuntime.resolvedId) return; - - const reactRefreshDir = path.dirname( - require.resolve("react-refresh/package.json"), - ); - const reactRefreshRuntimePath = join( - reactRefreshDir, - "cjs/react-refresh-runtime.development.js", - ); - - return [ - "const exports = {}", - await readFile(reactRefreshRuntimePath, "utf8"), - await readFile( - require.resolve("./static/rsc-refresh-utils.mjs"), - "utf8", - ), - "export default exports", - ].join("\n"); - }, - }, - { - name: "react-router/rsc/hmr/react-refresh", - async transform(code, id, options) { - if (viteCommand !== "serve") return; - if (id.includes("/node_modules/")) return; - - const filepath = id.split("?")[0]; - const extensionsRE = /\.(jsx?|tsx?|mdx?)$/; - if (!extensionsRE.test(filepath)) return; - - const devRuntime = "react/jsx-dev-runtime"; - const ssr = options?.ssr === true; - const isJSX = filepath.endsWith("x"); - const useFastRefresh = !ssr && (isJSX || code.includes(devRuntime)); - if (!useFastRefresh) return; - - if (isVirtualClientRouteModuleId(id)) { - const routeId = routeIdByFile?.get(filepath); - return { code: addRefreshWrapper({ routeId, code, id }) }; - } - - const result = await babel.transformAsync(code, { - babelrc: false, - configFile: false, - filename: id, - sourceFileName: filepath, - parserOpts: { - sourceType: "module", - allowAwaitOutsideFunction: true, - }, - plugins: [[require("react-refresh/babel"), { skipEnvCheck: true }]], - sourceMaps: true, - }); - if (result === null) return; - - code = result.code!; - const refreshContentRE = /\$Refresh(?:Reg|Sig)\$\(/; - if (refreshContentRE.test(code)) { - code = addRefreshWrapper({ code, id }); - } - return { code, map: result.map }; - }, - }, - { - name: "react-router/rsc/hmr/updates", - async hotUpdate(this, { server, file, modules }) { - if (this.environment.name !== "rsc") return; - - const clientModules = - server.environments.client.moduleGraph.getModulesByFile(file); - - const vite = await import("vite"); - const isServerOnlyChange = - !clientModules || - clientModules.size === 0 || - // Handle CSS injected from server-first routes (with ?direct query - // string) since the client graph has a reference to the CSS - (vite.isCSSRequest(file) && - Array.from(clientModules).some((mod) => - mod.id?.includes("?direct"), - )); - - for (const mod of getModulesWithImporters(modules)) { - if (!mod.file) continue; - - const normalizedPath = path.normalize(mod.file); - const routeId = routeIdByFile?.get(normalizedPath); - if (routeId !== undefined) { - const routeSource = await readFile(normalizedPath, "utf8"); - const virtualRouteModuleCode = ( - await server.environments.rsc.pluginContainer.transform( - routeSource, - `${normalizedPath}?route-module`, - ) - ).code; - const { staticExports } = parseRouteExports(virtualRouteModuleCode); - const hasAction = staticExports.includes("action"); - const hasComponent = staticExports.includes("default"); - const hasErrorBoundary = staticExports.includes("ErrorBoundary"); - const hasLoader = staticExports.includes("loader"); - - server.hot.send({ - type: "custom", - event: "react-router:hmr", - data: { - routeId, - isServerOnlyChange, - hasAction, - hasComponent, - hasErrorBoundary, - hasLoader, - }, - }); - } - } - - return modules; - }, - }, { name: "react-router/rsc/virtual-react-router-serve-config", resolveId(id) { @@ -864,7 +777,6 @@ const virtual = { routeConfig: create("unstable_rsc/routes"), routeDiscovery: create("unstable_rsc/route-discovery"), injectHmrRuntime: create("unstable_rsc/inject-hmr-runtime"), - hmrRuntime: create("unstable_rsc/runtime"), basename: create("unstable_rsc/basename"), reactRouterServeConfig: create("unstable_rsc/react-router-serve-config"), }; @@ -884,87 +796,6 @@ function getRootDirectory(viteUserConfig: Vite.UserConfig) { return viteUserConfig.root ?? process.env.REACT_ROUTER_ROOT ?? process.cwd(); } -function getModulesWithImporters( - modules: Vite.EnvironmentModuleNode[], -): Set { - const visited = new Set(); - const result = new Set(); - - function walk(module: Vite.EnvironmentModuleNode) { - if (visited.has(module)) return; - - visited.add(module); - result.add(module); - - for (const importer of module.importers) { - walk(importer); - } - } - - for (const module of modules) { - walk(module); - } - - return result; -} - -function addRefreshWrapper({ - routeId, - code, - id, -}: { - routeId?: string; - code: string; - id: string; -}): string { - const acceptExports = - routeId !== undefined ? CLIENT_NON_COMPONENT_EXPORTS : []; - return ( - REACT_REFRESH_HEADER.replaceAll("__SOURCE__", JSON.stringify(id)) + - code + - REACT_REFRESH_FOOTER.replaceAll("__SOURCE__", JSON.stringify(id)) - .replaceAll("__ACCEPT_EXPORTS__", JSON.stringify(acceptExports)) - .replaceAll("__ROUTE_ID__", JSON.stringify(routeId)) - ); -} - -const REACT_REFRESH_HEADER = ` -import RefreshRuntime from "${virtual.hmrRuntime.id}"; - -const inWebWorker = typeof WorkerGlobalScope !== 'undefined' && self instanceof WorkerGlobalScope; -let prevRefreshReg; -let prevRefreshSig; - -if (import.meta.hot && !inWebWorker) { - if (!window.__vite_plugin_react_preamble_installed__) { - throw new Error( - "React Router Vite plugin can't detect preamble. Something is wrong." - ); - } - - prevRefreshReg = window.$RefreshReg$; - prevRefreshSig = window.$RefreshSig$; - window.$RefreshReg$ = (type, id) => { - RefreshRuntime.register(type, __SOURCE__ + " " + id) - }; - window.$RefreshSig$ = RefreshRuntime.createSignatureFunctionForTransform; -}`.replaceAll("\n", ""); // Header is all on one line so source maps aren't affected - -const REACT_REFRESH_FOOTER = ` -if (import.meta.hot && !inWebWorker) { - window.$RefreshReg$ = prevRefreshReg; - window.$RefreshSig$ = prevRefreshSig; - RefreshRuntime.__hmr_import(import.meta.url).then((currentExports) => { - RefreshRuntime.registerExportsForReactRefresh(__SOURCE__, currentExports); - import.meta.hot.accept((nextExports) => { - if (!nextExports) return; - __ROUTE_ID__ && window.__reactRouterRouteModuleUpdates.set(__ROUTE_ID__, nextExports); - const invalidateMessage = RefreshRuntime.validateRefreshBoundaryAndEnqueueUpdate(currentExports, nextExports, __ACCEPT_EXPORTS__); - if (invalidateMessage) import.meta.hot.invalidate(invalidateMessage); - }); - }); -}`; - const getClientBuildDirectory = ( reactRouterConfig: ResolvedReactRouterConfig, ) => path.join(reactRouterConfig.buildDirectory, "client"); diff --git a/packages/react-router-dev/vite/rsc/virtual-route-config.ts b/packages/react-router-dev/vite/rsc/virtual-route-config.ts index 1edddcb042..68fc91431e 100644 --- a/packages/react-router-dev/vite/rsc/virtual-route-config.ts +++ b/packages/react-router-dev/vite/rsc/virtual-route-config.ts @@ -1,6 +1,8 @@ import path from "pathe"; import type { RouteConfigEntry } from "../../routes"; +const js = String.raw; + export function createVirtualRouteConfig({ appDirectory, routeConfig, @@ -9,7 +11,85 @@ export function createVirtualRouteConfig({ routeConfig: RouteConfigEntry[]; }): { code: string; routeIdByFile: Map } { let routeIdByFile = new Map(); - let code = "export default ["; + let code = js`import * as React from "react"; +function frameworkRoute(lazy) { + return async () => { + const mod = await lazy(); + let Component; + let Layout; + let ErrorBoundary; + let HydrateFallback; + if ("default" in mod && mod.default) { + if ("ServerComponent" in mod && mod.ServerComponent) { + throw new Error("Module cannot have both a default export and a ServerComponent export"); + } + Component = mod.default; + } else if ("ServerComponent" in mod && mod.ServerComponent) { + Component = mod.ServerComponent; + } + if ("Layout" in mod && mod.Layout) { + if ("ServerLayout" in mod && mod.ServerLayout) { + throw new Error("Module cannot have both a Layout export and a ServerLayout export"); + } + Layout = mod.Layout; + } else if ("ServerLayout" in mod && mod.ServerLayout) { + Layout = mod.ServerLayout; + } + if ("ErrorBoundary" in mod && mod.ErrorBoundary) { + if ("ServerErrorBoundary" in mod && mod.ServerErrorBoundary) { + throw new Error( + "Module cannot have both an ErrorBoundary export and a ServerErrorBoundary export", + ); + } + ErrorBoundary = mod.ErrorBoundary; + } else if ("ServerErrorBoundary" in mod && mod.ServerErrorBoundary) { + ErrorBoundary = mod.ServerErrorBoundary; + } + if ("HydrateFallback" in mod && mod.HydrateFallback) { + if ("ServerHydrateFallback" in mod && mod.ServerHydrateFallback) { + throw new Error( + "Module cannot have both a HydrateFallback export and a ServerHydrateFallback export", + ); + } + HydrateFallback = mod.HydrateFallback; + } else if ("ServerHydrateFallback" in mod && mod.ServerHydrateFallback) { + HydrateFallback = mod.ServerHydrateFallback; + } + + const { + action, + clientAction, + clientLoader, + clientMiddleware, + handle, + headers, + links, + loader, + meta, + middleware, + shouldRevalidate, + } = mod; + + return { + Component, + ErrorBoundary, + HydrateFallback, + Layout, + action, + clientAction, + clientLoader, + clientMiddleware, + handle, + headers, + links, + loader, + meta, + middleware, + shouldRevalidate, + }; + }; +} +export default [`; const closeRouteSymbol = Symbol("CLOSE_ROUTE"); let stack: Array = [ @@ -27,9 +107,9 @@ export function createVirtualRouteConfig({ const routeFile = path.resolve(appDirectory, route.file); const routeId = route.id || createRouteId(route.file, appDirectory); routeIdByFile.set(routeFile, routeId); - code += `lazy: () => import(${JSON.stringify( - `${routeFile}?route-module`, - )}),`; + code += `lazy: frameworkRoute(() => import(${JSON.stringify( + `${routeFile}`, + )})),`; code += `id: ${JSON.stringify(routeId)},`; if (typeof route.path === "string") { diff --git a/packages/react-router-dev/vite/rsc/virtual-route-modules.ts b/packages/react-router-dev/vite/rsc/virtual-route-modules.ts index e3c0c566ba..b6382ed55f 100644 --- a/packages/react-router-dev/vite/rsc/virtual-route-modules.ts +++ b/packages/react-router-dev/vite/rsc/virtual-route-modules.ts @@ -1,7 +1,420 @@ +import { + init as initEsModuleLexer, + parse as esModuleLexer, +} from "es-module-lexer"; import type * as Vite from "vite"; + import * as babel from "../babel"; -import { parse as esModuleLexer } from "es-module-lexer"; +import type { Cache } from "../cache"; import { removeExports } from "../remove-exports"; +import { + type RouteChunkExportName, + type RouteChunkName, + detectRouteChunks as _detectRouteChunks, +} from "../route-chunks"; + +const ENSURE_CLIENT_ROUTE_MODULE_CHUNK_FOR_HMR = ` +import * as ___EnsureClientRouteModuleForHMR_REACT___ from "react"; +export function EnsureClientRouteModuleForHMR___() { return ___EnsureClientRouteModuleForHMR_REACT___.createElement(___EnsureClientRouteModuleForHMR_REACT___.Fragment, null) } +`; + +export function virtualRouteModulesPlugin({ + enforceSplitRouteModules, + environments: { client = ["client", "ssr"], server = ["rsc"] } = {}, + getRouteIdForFile, + isRootRouteModule, + transformToJs, + shouldTransform, +}: { + enforceSplitRouteModules: () => boolean; + environments?: { + client?: string[]; + server?: string[]; + }; + getRouteIdForFile(filename: string): string | undefined; + isRootRouteModule(filename: string): boolean; + order?: "pre" | "post"; + shouldTransform?(filename: string): boolean; + transformToJs: (code: string, filename: string) => Promise; +}) { + let clientEnvironments = new Set(client); + let serverEnvironments = new Set(server); + let cache: Cache = new Map(); + + async function createClientRouteEntry( + id: string, + code: string, + isRootRouteModule: boolean, + routeId: string, + ) { + let result = ""; + + let routeChunks = detectRouteChunks(cache, id, code, isRootRouteModule); + let { staticExports } = await parseRouteExports(code); + + validateRouteModuleExports(staticExports); + + let needsReactImport = false; + for (let exportName of staticExports) { + if (isServerRouteExport(exportName)) { + continue; + } + + if ( + (exportName === "clientAction" || exportName === "clientLoader") && + routeChunks.hasRouteChunkByExportName[ + exportName as RouteChunkExportName + ] + ) { + result += `export const ${exportName} = async (...args) => import("${createId(id, "client-route-module", exportName)}").then(mod => mod.${exportName}(...args));\n`; + } else if (exportName === "HydrateFallback") { + needsReactImport = true; + result += `export const ${exportName} = React.lazy(() => import("${createId( + id, + "client-route-module", + routeChunks.hasRouteChunkByExportName[ + exportName as RouteChunkExportName + ] + ? exportName + : "shared", + )}").then(mod => ({ default: mod.${exportName} })));\n`; + } else { + result += `export { ${exportName} } from "${createId( + id, + "client-route-module", + routeChunks.hasRouteChunkByExportName[ + exportName as RouteChunkExportName + ] + ? exportName + : "shared", + )}";\n`; + } + } + + if (needsReactImport) { + result = `import * as React from "react";\n${result}`; + } + + if (enforceSplitRouteModules() && !isRootRouteModule) { + let { hasRouteChunkByExportName } = routeChunks; + let hasClientAction = staticExports.includes("clientAction"); + let hasClientLoader = staticExports.includes("clientLoader"); + let hasClientMiddleware = staticExports.includes("clientMiddleware"); + let hasHydrateFallback = staticExports.includes("HydrateFallback"); + + validateRouteChunks({ + id: routeId, + valid: { + clientAction: + !hasClientAction || hasRouteChunkByExportName.clientAction, + clientLoader: + !hasClientLoader || hasRouteChunkByExportName.clientLoader, + clientMiddleware: + !hasClientMiddleware || hasRouteChunkByExportName.clientMiddleware, + HydrateFallback: + !hasHydrateFallback || hasRouteChunkByExportName.HydrateFallback, + }, + }); + } + + return { + code: '"use client";\n' + result, + }; + } + + async function createServerRouteEntry( + id: string, + code: string, + isRootRouteModule: boolean, + routeId: string, + ) { + let result = ""; + + let routeChunks = detectRouteChunks(cache, id, code, isRootRouteModule); + let { staticExports } = await parseRouteExports(code); + + validateRouteModuleExports(staticExports); + + let needsReactImport = false; + + for (let exportName of staticExports) { + if (isClientRouteExport(exportName)) { + result += `export { ${exportName} } from "${createId( + id, + "client-route-module", + routeChunks.hasRouteChunkByExportName[ + exportName as RouteChunkExportName + ] + ? exportName + : "shared", + )}";\n`; + } else if (isServerComponentExport(exportName)) { + needsReactImport = true; + result += `import { ${exportName} as ${exportName}WithoutCss } from "${createId(id, "server-route-module")}";\n`; + result += `export function ${exportName}(props) {\n`; + result += ` return React.createElement(React.Fragment, null,\n`; + result += ` import.meta.viteRsc.loadCss(),\n`; + result += ` React.createElement(EnsureClientRouteModuleForHMR___, null),\n`; + result += ` React.createElement(${exportName}WithoutCss, props),\n`; + result += ` );\n`; + result += `}\n`; + } else { + result += `export { ${exportName} } from "${createId(id, "server-route-module")}";\n`; + } + } + + if (needsReactImport) { + result = `import * as React from "react"; +import { EnsureClientRouteModuleForHMR___ } from "${createId(id, "client-route-module", "shared")}";\n +${result}`; + } + + if ( + isRootRouteModule && + !staticExports.includes("ErrorBoundary") && + !staticExports.includes("ServerErrorBoundary") + ) { + result += `export { ErrorBoundary } from "${createId(id, "client-route-module", "shared")}";\n`; + } + + if (enforceSplitRouteModules() && !isRootRouteModule) { + let { hasRouteChunkByExportName } = routeChunks; + let hasClientAction = staticExports.includes("clientAction"); + let hasClientLoader = staticExports.includes("clientLoader"); + let hasClientMiddleware = staticExports.includes("clientMiddleware"); + let hasHydrateFallback = staticExports.includes("HydrateFallback"); + + validateRouteChunks({ + id: routeId, + valid: { + clientAction: + !hasClientAction || hasRouteChunkByExportName.clientAction, + clientLoader: + !hasClientLoader || hasRouteChunkByExportName.clientLoader, + clientMiddleware: + !hasClientMiddleware || hasRouteChunkByExportName.clientMiddleware, + HydrateFallback: + !hasHydrateFallback || hasRouteChunkByExportName.HydrateFallback, + }, + }); + } + + return { + code: result, + }; + } + + function createServerRouteModule(code: string) { + const ast = babel.parse(code, { + sourceType: "module", + }); + removeExports(ast, CLIENT_ROUTE_EXPORTS); + return babel.generate(ast); + } + + async function createClientRouteModuleChunk( + id: string, + code: string, + chunk: "shared" | string, + routeId: string, + isRootRouteModule: boolean, + isDevMode: boolean, + ) { + let routeChunks = detectRouteChunks(cache, id, code, isRootRouteModule); + + const ast = babel.parse(code, { + sourceType: "module", + }); + const { staticExports } = await parseRouteExports(code); + + if (chunk === "shared") { + removeExports(ast, [ + ...SERVER_ROUTE_EXPORTS, + ...routeChunks.chunkedExports, + ]); + } else { + const toRemove = new Set([...SERVER_ROUTE_EXPORTS, ...staticExports]); + toRemove.delete(chunk); + removeExports(ast, Array.from(toRemove)); + } + + const generated = babel.generate(ast); + + let result = '"use client";\n' + generated.code; + + if (chunk === "shared") { + if ( + isRootRouteModule && + !staticExports.includes("ErrorBoundary") && + !staticExports.includes("ServerErrorBoundary") + ) { + const hasRootLayout = + staticExports.includes("Layout") || + staticExports.includes("ServerLayout"); + result += `\nimport { createElement as __rr_createElement } from "react";\n`; + result += `import { UNSAFE_RSCDefaultRootErrorBoundary } from "react-router";\n`; + result += `export function ErrorBoundary() {\n`; + result += ` return __rr_createElement(UNSAFE_RSCDefaultRootErrorBoundary, { hasRootLayout: ${hasRootLayout} });\n`; + result += `}\n`; + } + + result += ENSURE_CLIENT_ROUTE_MODULE_CHUNK_FOR_HMR; + } + + let hasAction = staticExports.includes("action"); + let hasLoader = staticExports.includes("loader"); + let hasComponent = + staticExports.includes("default") || + staticExports.includes("ServerComponent"); + let hasErrorBoundary = + staticExports.includes("ErrorBoundary") || + staticExports.includes("ServerErrorBoundary"); + + if (isDevMode) { + result += `export function ReactRouterHMRMeta___() {return null;};\n`; + result += `Object.assign(ReactRouterHMRMeta___, { + hasAction: ${JSON.stringify(hasAction)}, + hasComponent: ${JSON.stringify(hasComponent)}, + hasErrorBoundary: ${JSON.stringify(hasErrorBoundary)}, + hasLoader: ${JSON.stringify(hasLoader)}, + hasClientLoader: ${JSON.stringify(staticExports.includes("clientLoader"))}, + });\n`; + result += `\nif (import.meta.hot) {\n`; + result += ` import.meta.hot.accept((mod) => { + if (typeof __reactRouterDataRouter === "object") { + __reactRouterDataRouter._updateRoutesForHMR(new Map([[${JSON.stringify(routeId)}, { + routeModule: mod, + ...mod.ReactRouterHMRMeta___, + }]])); + + if (${chunk === "shared" ? "!mod.default || " : ""}mod.clientLoader || ( + mod.ReactRouterHMRMeta___.hasClientLoader || ReactRouterHMRMeta___.hasClientLoader || ReactRouterHMRMeta___.hasLoader + )) { + __reactRouterDataRouter.revalidate(); + } + } + }); + `; + result += `}\n`; + } + + return { + code: result, + }; + } + + return { + name: "react-router-rsc-virtual-route-modules", + enforce: "pre", + async transform(_code, id) { + const [filename, ...rest] = id.split("?"); + + const routeId = getRouteIdForFile(filename); + + if (!routeId || (shouldTransform && !shouldTransform?.(filename))) { + return; + } + + let isClientEnvironment = clientEnvironments.has(this.environment.name); + let isServerEnvironment = serverEnvironments.has(this.environment.name); + + if (!isClientEnvironment && !isServerEnvironment) { + return; + } + + // this. + let code = await transformToJs(_code, filename); + + let searchParams = + rest.length > 0 ? new URLSearchParams(rest.join("?")) : null; + + let clientRouteModuleType = searchParams?.get("client-route-module"); + let isServerRouteModule = searchParams?.has("server-route-module"); + + if (clientRouteModuleType) { + return await createClientRouteModuleChunk( + id, + code, + clientRouteModuleType, + routeId, + isRootRouteModule(filename), + this.environment.mode === "dev", + ); + } + + if (isServerRouteModule) { + return createServerRouteModule(code); + } + + if (isClientEnvironment) { + return await createClientRouteEntry( + id, + code, + isRootRouteModule(filename), + routeId, + ); + } + + return await createServerRouteEntry( + id, + code, + isRootRouteModule(filename), + routeId, + ); + }, + } satisfies Vite.Plugin; +} + +function createId( + id: string, + type: "client-route-module", + value: string, +): string; +function createId(id: string, type: "server-route-module"): string; +function createId( + id: string, + type: "client-route-module" | "server-route-module", + value?: string, +): string { + let [base, ...rest] = id.split("?"); + const searchParams = new URLSearchParams(rest.join("?")); + searchParams.delete("client-route-module"); + searchParams.delete("server-route-module"); + searchParams.set(type, value || ""); + return `${base}?${searchParams.toString()}`; +} + +export async function parseRouteExports(code: string) { + await initEsModuleLexer; + const [, exportSpecifiers] = esModuleLexer(code); + const staticExports = exportSpecifiers.map(({ n: name }) => name); + return { + staticExports, + hasClientExports: staticExports.some(isClientRouteExport), + }; +} + +export const CLIENT_NON_COMPONENT_EXPORTS = [ + "clientAction", + "clientLoader", + "clientMiddleware", + "handle", + "meta", + "links", + "shouldRevalidate", +] as const; +const CLIENT_ROUTE_EXPORTS = [ + ...CLIENT_NON_COMPONENT_EXPORTS, + "default", + "ErrorBoundary", + "HydrateFallback", + "Layout", +] as const; +type ClientRouteExport = (typeof CLIENT_ROUTE_EXPORTS)[number]; +const CLIENT_ROUTE_EXPORTS_SET = new Set(CLIENT_ROUTE_EXPORTS); +function isClientRouteExport(name: string): name is ClientRouteExport { + return CLIENT_ROUTE_EXPORTS_SET.has(name as ClientRouteExport); +} const SERVER_COMPONENT_EXPORTS = [ "ServerComponent", @@ -29,280 +442,114 @@ function isServerRouteExport(name: string): name is ServerRouteExport { return SERVER_ROUTE_EXPORTS_SET.has(name as ServerRouteExport); } -export const CLIENT_NON_COMPONENT_EXPORTS = [ +const CLIENT_MODULE_CHUNKS = new Set([ "clientAction", "clientLoader", "clientMiddleware", - "handle", - "meta", - "links", - "shouldRevalidate", -] as const; - -const CLIENT_ROUTE_EXPORTS = [ - ...CLIENT_NON_COMPONENT_EXPORTS, - "default", - "ErrorBoundary", "HydrateFallback", - "Layout", -] as const; -type ClientRouteExport = (typeof CLIENT_ROUTE_EXPORTS)[number]; -const CLIENT_ROUTE_EXPORTS_SET = new Set(CLIENT_ROUTE_EXPORTS); -function isClientRouteExport(name: string): name is ClientRouteExport { - return CLIENT_ROUTE_EXPORTS_SET.has(name as ClientRouteExport); -} +]); -const mutuallyExclusiveRouteExports = new Map([ +const MUTUALLY_EXCLUSIVE_ROUTE_EXPORTS = new Map([ ["ErrorBoundary", "ServerErrorBoundary"], ["HydrateFallback", "ServerHydrateFallback"], ["Layout", "ServerLayout"], ["default", "ServerComponent"], ]); -const ROUTE_EXPORTS = [ - ...SERVER_ROUTE_EXPORTS, - ...CLIENT_ROUTE_EXPORTS, -] as const; -type RouteExport = (typeof ROUTE_EXPORTS)[number]; -const ROUTE_EXPORTS_SET = new Set(ROUTE_EXPORTS); -function isRouteExport(name: string): name is RouteExport { - return ROUTE_EXPORTS_SET.has(name as RouteExport); -} -function isCustomRouteExport(name: string) { - return !isRouteExport(name); -} - -function hasReactServerCondition(viteEnvironment: Vite.Environment) { - return viteEnvironment.config.resolve.conditions.includes("react-server"); -} - -type ViteCommand = Vite.ConfigEnv["command"]; - -export function transformVirtualRouteModules({ - id, - code, - viteCommand, - routeIdByFile, - rootRouteFile, - viteEnvironment, -}: { - id: string; - code: string; - viteCommand: ViteCommand; - routeIdByFile: Map; - rootRouteFile: string; - viteEnvironment: Vite.Environment; -}) { - if (isVirtualRouteModuleId(id) || routeIdByFile.has(id)) { - return createVirtualRouteModuleCode({ - id, - code, - rootRouteFile, - viteCommand, - viteEnvironment, - }); - } - - if (isVirtualServerRouteModuleId(id)) { - return createVirtualServerRouteModuleCode({ - id, - code, - viteEnvironment, - }); +function validateRouteModuleExports(toValidate: string[]) { + let errors: [string, string][] = []; + for (let [clientExport, serverExport] of MUTUALLY_EXCLUSIVE_ROUTE_EXPORTS) { + if ( + toValidate.includes(clientExport) && + toValidate.includes(serverExport) + ) { + errors.push([clientExport, serverExport]); + } } - - if (isVirtualClientRouteModuleId(id)) { - return createVirtualClientRouteModuleCode({ - id, - code, - rootRouteFile, - viteCommand, - }); + if (errors.length > 0) { + throw new Error( + `Invalid route module exports. The following pairs of exports are mutually exclusive and cannot be exported from the same module:\n` + + errors + .map( + ([clientExport, serverExport]) => + `- ${clientExport} and ${serverExport}`, + ) + .join("\n"), + ); } } -async function createVirtualRouteModuleCode({ - id, - code: routeSource, - rootRouteFile, - viteCommand, - viteEnvironment, -}: { - id: string; - code: string; - rootRouteFile: string; - viteCommand: ViteCommand; - viteEnvironment: Vite.Environment; -}) { - const isReactServer = hasReactServerCondition(viteEnvironment); - const { staticExports, hasClientExports } = parseRouteExports(routeSource); - - for (const exportName of staticExports) { - if (mutuallyExclusiveRouteExports.has(exportName)) { - const conflictingExport = mutuallyExclusiveRouteExports.get(exportName)!; - if (staticExports.includes(conflictingExport)) { - throw new Error( - `Route module cannot export both "${exportName}" and "${conflictingExport}". Please choose one or the other.`, - ); - } - } +type RouteChunks = ReturnType; + +function detectRouteChunks( + cache: Cache, + id: string, + code: string, + isRootRouteModule: boolean, +): RouteChunks { + function noRouteChunks(): RouteChunks { + return { + chunkedExports: [], + hasRouteChunks: false, + hasRouteChunkByExportName: { + clientAction: false, + clientLoader: false, + clientMiddleware: false, + HydrateFallback: false, + }, + }; } - const clientModuleId = getVirtualClientModuleId(id); - const serverModuleId = getVirtualServerModuleId(id); - - let code = ""; - if (isReactServer && staticExports.some(isServerComponentExport)) { - code += `import React from "react";\n`; - } - for (const staticExport of staticExports) { - if (isReactServer && isServerComponentExport(staticExport)) { - code += `import { ${staticExport} as ${staticExport}WithoutCss } from "${serverModuleId}";\n`; - code += `export ${staticExport === "ServerComponent" ? "default " : " "}function ${staticExport.replace(/^Server/, "")}(props) {\n`; - code += ` return React.createElement(React.Fragment, null,\n`; - code += ` import.meta.viteRsc.loadCss(),\n`; - code += ` React.createElement(${staticExport}WithoutCss, props),\n`; - code += ` );\n`; - code += `}\n`; - } else if (isReactServer && isServerRouteExport(staticExport)) { - code += `export { ${staticExport} } from "${serverModuleId}";\n`; - } else if (isClientRouteExport(staticExport)) { - code += `export { ${staticExport} } from "${clientModuleId}";\n`; - } else if (isCustomRouteExport(staticExport)) { - code += `export { ${staticExport} } from "${isReactServer ? serverModuleId : clientModuleId}";\n`; - } + // If this is the root route, we disable chunking since the chunks would never + // be loaded on demand during navigation. Because the root route is matched + // for all requests, all of its chunks would always be loaded up front during + // the initial page load. Instead of firing off multiple requests to resolve + // the root route code, we want it to be downloaded in a single request. + if (isRootRouteModule) { + return noRouteChunks(); } if ( - isRootRouteFile({ id, rootRouteFile }) && - !staticExports.includes("ErrorBoundary") && - !staticExports.includes("ServerErrorBoundary") + !Array.from(CLIENT_MODULE_CHUNKS).some((exportName) => + code.includes(exportName), + ) ) { - code += `export { ErrorBoundary } from "${clientModuleId}";\n`; - } - - if (viteCommand === "serve" && !hasClientExports) { - code += `export { __ensureClientRouteModuleForHMR } from "${clientModuleId}";\n`; - } - - return code; -} - -function createVirtualServerRouteModuleCode({ - id, - code: routeSource, - viteEnvironment, -}: { - id: string; - code: string; - viteEnvironment: Vite.Environment; -}) { - if (!hasReactServerCondition(viteEnvironment)) { - throw new Error( - [ - "Virtual server route module was loaded outside of the RSC environment.", - `Environment Name: ${viteEnvironment.name}`, - `Module ID: ${id}`, - ].join("\n"), - ); + return noRouteChunks(); } - const { staticExports } = parseRouteExports(routeSource); - const clientModuleId = getVirtualClientModuleId(id); - const serverRouteModuleAst = babel.parse(routeSource, { - sourceType: "module", - }); - removeExports(serverRouteModuleAst, CLIENT_ROUTE_EXPORTS); - - const generatorResult = babel.generate(serverRouteModuleAst); - - for (const staticExport of staticExports) { - if (isClientRouteExport(staticExport)) { - generatorResult.code += "\n"; - generatorResult.code += `export { ${staticExport} } from "${clientModuleId}";\n`; - } - } + let [filename] = id.split("?"); - return generatorResult; + return _detectRouteChunks(code, cache, filename); } -function createVirtualClientRouteModuleCode({ +function validateRouteChunks({ id, - code: routeSource, - rootRouteFile, - viteCommand, + valid, }: { id: string; - code: string; - rootRouteFile: string; - viteCommand: ViteCommand; -}) { - const { staticExports, hasClientExports } = parseRouteExports(routeSource); - - const clientRouteModuleAst = babel.parse(routeSource, { - sourceType: "module", - }); - removeExports(clientRouteModuleAst, SERVER_ROUTE_EXPORTS); - - const generatorResult = babel.generate(clientRouteModuleAst); - generatorResult.code = '"use client";' + generatorResult.code; - - if ( - isRootRouteFile({ id, rootRouteFile }) && - !staticExports.includes("ErrorBoundary") && - !staticExports.includes("ServerErrorBoundary") - ) { - const hasRootLayout = staticExports.includes("Layout"); - generatorResult.code += `\nimport { createElement as __rr_createElement } from "react";\n`; - generatorResult.code += `import { UNSAFE_RSCDefaultRootErrorBoundary } from "react-router";\n`; - generatorResult.code += `export function ErrorBoundary() {\n`; - generatorResult.code += ` return __rr_createElement(UNSAFE_RSCDefaultRootErrorBoundary, { hasRootLayout: ${hasRootLayout} });\n`; - generatorResult.code += `}\n`; - } - - if (viteCommand === "serve" && !hasClientExports) { - generatorResult.code += `\nexport const __ensureClientRouteModuleForHMR = true;`; + valid: Record, boolean>; +}): void { + let invalidChunks = Object.entries(valid) + .filter(([_, isValid]) => !isValid) + .map(([chunkName]) => chunkName); + + if (invalidChunks.length === 0) { + return; } - return generatorResult; -} - -export function parseRouteExports(code: string) { - const [, exportSpecifiers] = esModuleLexer(code); - const staticExports = exportSpecifiers.map(({ n: name }) => name); - return { - staticExports, - hasClientExports: staticExports.some(isClientRouteExport), - }; -} - -function getVirtualClientModuleId(id: string): string { - return `${id.split("?")[0]}?client-route-module`; -} - -function getVirtualServerModuleId(id: string): string { - return `${id.split("?")[0]}?server-route-module`; -} - -function isVirtualRouteModuleId(id: string): boolean { - return /(\?|&)route-module(&|$)/.test(id); -} + let plural = invalidChunks.length > 1; -export function isVirtualClientRouteModuleId(id: string): boolean { - return /(\?|&)client-route-module(&|$)/.test(id); -} + throw new Error( + [ + `Error splitting route module: ${id}`, -function isVirtualServerRouteModuleId(id: string): boolean { - return /(\?|&)server-route-module(&|$)/.test(id); -} + invalidChunks.map((name) => `- ${name}`).join("\n"), -function isRootRouteFile({ - id, - rootRouteFile, -}: { - id: string; - rootRouteFile: string; -}): boolean { - const filePath = id.split("?")[0]; - return filePath === rootRouteFile; + `${plural ? "These exports" : "This export"} could not be split into ${ + plural ? "their own chunks" : "its own chunk" + } because ${ + plural ? "they share" : "it shares" + } code with other exports. You should extract any shared code into its own module and then import it within the route module.`, + ].join("\n\n"), + ); } diff --git a/packages/react-router-dom/CHANGELOG.md b/packages/react-router-dom/CHANGELOG.md index 6a4521c14e..2fd41ac56c 100644 --- a/packages/react-router-dom/CHANGELOG.md +++ b/packages/react-router-dom/CHANGELOG.md @@ -1,5 +1,12 @@ # react-router-dom +## v7.14.2 + +### Patch Changes + +- Updated dependencies: + - [`react-router@7.14.2`](https://github.com/remix-run/react-router/releases/tag/react-router@7.14.2) + ## v7.14.1 ### Patch Changes diff --git a/packages/react-router-dom/package.json b/packages/react-router-dom/package.json index bded240e0f..a843ed6dca 100644 --- a/packages/react-router-dom/package.json +++ b/packages/react-router-dom/package.json @@ -1,6 +1,6 @@ { "name": "react-router-dom", - "version": "7.14.1", + "version": "7.14.2", "description": "Declarative routing for React web applications", "keywords": [ "react", diff --git a/packages/react-router-express/CHANGELOG.md b/packages/react-router-express/CHANGELOG.md index e39b32d2e5..bf63dc77a5 100644 --- a/packages/react-router-express/CHANGELOG.md +++ b/packages/react-router-express/CHANGELOG.md @@ -1,5 +1,13 @@ # `@react-router/express` +## v7.14.2 + +### Patch Changes + +- Updated dependencies: + - [`react-router@7.14.2`](https://github.com/remix-run/react-router/releases/tag/react-router@7.14.2) + - [`@react-router/node@7.14.2`](https://github.com/remix-run/react-router/releases/tag/@react-router/node@7.14.2) + ## v7.14.1 ### Patch Changes diff --git a/packages/react-router-express/package.json b/packages/react-router-express/package.json index d4c267422d..24175906b3 100644 --- a/packages/react-router-express/package.json +++ b/packages/react-router-express/package.json @@ -1,6 +1,6 @@ { "name": "@react-router/express", - "version": "7.14.1", + "version": "7.14.2", "description": "Express server request handler for React Router", "bugs": { "url": "https://github.com/remix-run/react-router/issues" diff --git a/packages/react-router-fs-routes/CHANGELOG.md b/packages/react-router-fs-routes/CHANGELOG.md index 46f6107e6e..290f3b1c80 100644 --- a/packages/react-router-fs-routes/CHANGELOG.md +++ b/packages/react-router-fs-routes/CHANGELOG.md @@ -1,5 +1,12 @@ # `@react-router/fs-routes` +## v7.14.2 + +### Patch Changes + +- Updated dependencies: + - [`@react-router/dev@7.14.2`](https://github.com/remix-run/react-router/releases/tag/@react-router/dev@7.14.2) + ## v7.14.1 ### Patch Changes diff --git a/packages/react-router-fs-routes/package.json b/packages/react-router-fs-routes/package.json index b5fbd57738..e217b23532 100644 --- a/packages/react-router-fs-routes/package.json +++ b/packages/react-router-fs-routes/package.json @@ -1,6 +1,6 @@ { "name": "@react-router/fs-routes", - "version": "7.14.1", + "version": "7.14.2", "description": "File system routing conventions for React Router, for use within routes.ts", "bugs": { "url": "https://github.com/remix-run/react-router/issues" diff --git a/packages/react-router-node/CHANGELOG.md b/packages/react-router-node/CHANGELOG.md index 26fd243bd2..4c3a010e1e 100644 --- a/packages/react-router-node/CHANGELOG.md +++ b/packages/react-router-node/CHANGELOG.md @@ -1,5 +1,12 @@ # `@react-router/node` +## v7.14.2 + +### Patch Changes + +- Updated dependencies: + - [`react-router@7.14.2`](https://github.com/remix-run/react-router/releases/tag/react-router@7.14.2) + ## v7.14.1 ### Patch Changes diff --git a/packages/react-router-node/package.json b/packages/react-router-node/package.json index e23af560fb..3bd2dc78c6 100644 --- a/packages/react-router-node/package.json +++ b/packages/react-router-node/package.json @@ -1,6 +1,6 @@ { "name": "@react-router/node", - "version": "7.14.1", + "version": "7.14.2", "description": "Node.js platform abstractions for React Router", "bugs": { "url": "https://github.com/remix-run/react-router/issues" diff --git a/packages/react-router-remix-routes-option-adapter/CHANGELOG.md b/packages/react-router-remix-routes-option-adapter/CHANGELOG.md index c828c703e7..a5f649f56f 100644 --- a/packages/react-router-remix-routes-option-adapter/CHANGELOG.md +++ b/packages/react-router-remix-routes-option-adapter/CHANGELOG.md @@ -1,5 +1,12 @@ # `@react-router/remix-config-routes-adapter` +## v7.14.2 + +### Patch Changes + +- Updated dependencies: + - [`@react-router/dev@7.14.2`](https://github.com/remix-run/react-router/releases/tag/@react-router/dev@7.14.2) + ## v7.14.1 ### Patch Changes diff --git a/packages/react-router-remix-routes-option-adapter/package.json b/packages/react-router-remix-routes-option-adapter/package.json index 07e1b06969..59338ddeb4 100644 --- a/packages/react-router-remix-routes-option-adapter/package.json +++ b/packages/react-router-remix-routes-option-adapter/package.json @@ -1,6 +1,6 @@ { "name": "@react-router/remix-routes-option-adapter", - "version": "7.14.1", + "version": "7.14.2", "description": "Adapter for Remix's \"routes\" config option, for use within routes.ts", "bugs": { "url": "https://github.com/remix-run/react-router/issues" diff --git a/packages/react-router-serve/CHANGELOG.md b/packages/react-router-serve/CHANGELOG.md index f686db719d..dca4637576 100644 --- a/packages/react-router-serve/CHANGELOG.md +++ b/packages/react-router-serve/CHANGELOG.md @@ -1,5 +1,14 @@ # `@react-router/serve` +## v7.14.2 + +### Patch Changes + +- Updated dependencies: + - [`react-router@7.14.2`](https://github.com/remix-run/react-router/releases/tag/react-router@7.14.2) + - [`@react-router/express@7.14.2`](https://github.com/remix-run/react-router/releases/tag/@react-router/express@7.14.2) + - [`@react-router/node@7.14.2`](https://github.com/remix-run/react-router/releases/tag/@react-router/node@7.14.2) + ## v7.14.1 ### Patch Changes diff --git a/packages/react-router-serve/package.json b/packages/react-router-serve/package.json index bd3a61cc63..047536e71e 100644 --- a/packages/react-router-serve/package.json +++ b/packages/react-router-serve/package.json @@ -1,6 +1,6 @@ { "name": "@react-router/serve", - "version": "7.14.1", + "version": "7.14.2", "description": "Production application server for React Router", "bugs": { "url": "https://github.com/remix-run/react-router/issues" diff --git a/packages/react-router/CHANGELOG.md b/packages/react-router/CHANGELOG.md index 9d467b3e78..b22aeb4e9e 100644 --- a/packages/react-router/CHANGELOG.md +++ b/packages/react-router/CHANGELOG.md @@ -1,5 +1,51 @@ # `react-router` +## v7.14.2 + +### Patch Changes + +- Remove the un-documented custom error serialization logic from the internal turbo-stream implementation. React Router only automatically handles serialization of `Error` and it's standard subtypes (`SyntaxError`, `TypeError`, etc.). ([[aabf4a1](https://github.com/remix-run/react-router/commit/aabf4a1)) + +- Properly handle parent middleware redirects during `fetcher.load` ([[aabf4a1](https://github.com/remix-run/react-router/commit/aabf4a1)) + +- Remove redundant `Omit` from `react-router/dom` `RouterProvider` ([[aabf4a1](https://github.com/remix-run/react-router/commit/aabf4a1)) + +- Improved types for `generatePath`'s `param` arg ([[aabf4a1](https://github.com/remix-run/react-router/commit/aabf4a1)) + + Type errors when required params are omitted: + + ```ts + // Before + // Passes type checks, but throws at runtime πŸ’₯ + generatePath(":required", { required: null }); + + // After + generatePath(":required", { required: null }); + // ^^^^^^^^ Type 'null' is not assignable to type 'string'.ts(2322) + ``` + + Allow omission of optional params: + + ```ts + // Before + generatePath(":optional?", {}); + // ^^ Property 'optional' is missing in type '{}' but required in type '{ optional: string | null | undefined; }'.ts(2741) + + // After + generatePath(":optional?", {}); + ``` + + Allows extra keys: + + ```ts + // Before + generatePath(":a", { a: "1", b: "2" }); + // ^ Object literal may only specify known properties, and 'b' does not exist in type '{ a: string; }'.ts(2353) + + // After + generatePath(":a", { a: "1", b: "2" }); + ``` + ## v7.14.1 ### Patch Changes diff --git a/packages/react-router/__tests__/router/context-middleware-test.tsx b/packages/react-router/__tests__/router/context-middleware-test.tsx index b0031e8fea..78e3c64127 100644 --- a/packages/react-router/__tests__/router/context-middleware-test.tsx +++ b/packages/react-router/__tests__/router/context-middleware-test.tsx @@ -1787,6 +1787,77 @@ describe("context/middleware", () => { await router.navigate("/redirect"); expect(router.state.location.pathname).toBe("/target"); }); + + it("allows fetcher.load to follow redirects thrown from parent middleware", async () => { + router = createRouter({ + history: createMemoryHistory(), + routes: [ + { + path: "/", + }, + { + path: "/parent", + middleware: [ + async () => { + throw redirect("/target"); + }, + ], + children: [ + { + id: "child", + path: "child", + loader() { + return "CHILD"; + }, + }, + ], + }, + { + path: "/target", + }, + ], + }); + + await router.fetch("key", "source", "/parent/child"); + expect(router.state.location.pathname).toBe("/target"); + }); + + it("allows fetcher.submit to follow redirects thrown from parent middleware", async () => { + router = createRouter({ + history: createMemoryHistory(), + routes: [ + { + path: "/", + }, + { + path: "/parent", + middleware: [ + async () => { + throw redirect("/target"); + }, + ], + children: [ + { + id: "child", + path: "child", + action() { + return "CHILD"; + }, + }, + ], + }, + { + path: "/target", + }, + ], + }); + + await router.fetch("key", "source", "/parent/child", { + formMethod: "POST", + formData: createFormData({}), + }); + expect(router.state.location.pathname).toBe("/target"); + }); }); }); diff --git a/packages/react-router/__tests__/vendor/turbo-stream-test.ts b/packages/react-router/__tests__/vendor/turbo-stream-test.ts index 008be114f3..1bb967dd2a 100644 --- a/packages/react-router/__tests__/vendor/turbo-stream-test.ts +++ b/packages/react-router/__tests__/vendor/turbo-stream-test.ts @@ -186,6 +186,7 @@ test("should encode and decode an EvalError", async () => { const input = new EvalError("foo"); const output = await quickDecode(encode(input)); expect(output).toEqual(input); + expect((output as EvalError).name).toEqual("EvalError"); }); test("should encode and decode array", async () => { diff --git a/packages/react-router/lib/dom-export/dom-router-provider.tsx b/packages/react-router/lib/dom-export/dom-router-provider.tsx index 7a3040aee0..cad38523cf 100644 --- a/packages/react-router/lib/dom-export/dom-router-provider.tsx +++ b/packages/react-router/lib/dom-export/dom-router-provider.tsx @@ -6,6 +6,6 @@ import { RouterProvider as BaseRouterProvider } from "react-router"; export type RouterProviderProps = Omit; -export function RouterProvider(props: Omit) { +export function RouterProvider(props: RouterProviderProps) { return ; } diff --git a/packages/react-router/lib/dom/ssr/single-fetch.tsx b/packages/react-router/lib/dom/ssr/single-fetch.tsx index aed8994590..6e02210f4a 100644 --- a/packages/react-router/lib/dom/ssr/single-fetch.tsx +++ b/packages/react-router/lib/dom/ssr/single-fetch.tsx @@ -1,6 +1,9 @@ import * as React from "react"; -import { decode } from "../../../vendor/turbo-stream-v2/turbo-stream"; +import { + SUPPORTED_ERROR_TYPES, + decode, +} from "../../../vendor/turbo-stream-v2/turbo-stream"; import type { Router as DataRouter } from "../../router/router"; import { isDataWithResponseInit, isResponse } from "../../router/router"; import type { @@ -757,8 +760,13 @@ export function decodeViaTurboStream( string | undefined, ]; let Constructor = Error; - // @ts-expect-error - if (name && name in global && typeof global[name] === "function") { + if ( + name && + SUPPORTED_ERROR_TYPES.includes(name) && + name in global && + // @ts-expect-error + typeof global[name] === "function" + ) { // @ts-expect-error Constructor = global[name]; } diff --git a/packages/react-router/lib/hooks.tsx b/packages/react-router/lib/hooks.tsx index e7d917b046..21c10d5dd6 100644 --- a/packages/react-router/lib/hooks.tsx +++ b/packages/react-router/lib/hooks.tsx @@ -184,10 +184,9 @@ export function useNavigationType(): NavigationType { * @param pattern The pattern to match against the current {@link Location} * @returns The path match object if the pattern matches, `null` otherwise */ -export function useMatch< - ParamKey extends ParamParseKey, - Path extends string, ->(pattern: PathPattern | Path): PathMatch | null { +export function useMatch( + pattern: PathPattern | Path, +): PathMatch> | null { invariant( useInRouterContext(), // TODO: This error is probably because they somehow have 2 versions of the @@ -197,7 +196,7 @@ export function useMatch< let { pathname } = useLocation(); return React.useMemo( - () => matchPath(pattern, decodePath(pathname)), + () => matchPath(pattern, decodePath(pathname)), [pathname, pattern], ); } @@ -667,7 +666,7 @@ export function useParams< > { let { matches } = React.useContext(RouteContext); let routeMatch = matches[matches.length - 1]; - return routeMatch ? (routeMatch.params as any) : {}; + return (routeMatch?.params ?? {}) as any; } /** diff --git a/packages/react-router/lib/router/router.ts b/packages/react-router/lib/router/router.ts index bdd468a6aa..00cf3b79ac 100644 --- a/packages/react-router/lib/router/router.ts +++ b/packages/react-router/lib/router/router.ts @@ -2871,6 +2871,16 @@ export function createRouter(init: RouterInit): Router { ); let result = results[match.route.id]; + if (!result) { + // If this error came from a parent middleware before the loader ran, + // then it won't be tied to the fetcher target route + for (let match of matches) { + if (results[match.route.id]) { + result = results[match.route.id]; + break; + } + } + } // We can delete this so long as we weren't aborted by our our own fetcher // re-load which would have put _new_ controller is in fetchControllers if (fetchControllers.get(key) === abortController) { diff --git a/packages/react-router/lib/router/utils.ts b/packages/react-router/lib/router/utils.ts index 667ec3b1d6..3d462b0528 100644 --- a/packages/react-router/lib/router/utils.ts +++ b/packages/react-router/lib/router/utils.ts @@ -770,49 +770,63 @@ export type RouteManifest = Record; // prettier-ignore type Regex_az = "a" | "b" | "c" | "d" | "e" | "f" | "g" | "h" | "i" | "j" | "k" | "l" | "m" | "n" | "o" | "p" | "q" | "r" | "s" | "t" | "u" | "v" | "w" | "x" | "y" | "z" -// prettier-ignore -type Regez_AZ = "A" | "B" | "C" | "D" | "E" | "F" | "G" | "H" | "I" | "J" | "K" | "L" | "M" | "N" | "O" | "P" | "Q" | "R" | "S" | "T" | "U" | "V" | "W" | "X" | "Y" | "Z" +type Regex_AZ = Uppercase; type Regex_09 = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"; -type Regex_w = Regex_az | Regez_AZ | Regex_09 | "_"; -type ParamChar = Regex_w | "-"; - -// Emulates regex `+` -type RegexMatchPlus< - CharPattern extends string, - T extends string, -> = T extends `${infer First}${infer Rest}` - ? First extends CharPattern - ? RegexMatchPlus extends never - ? First - : `${First}${RegexMatchPlus}` - : never - : never; - -// Recursive helper for finding path parameters in the absence of wildcards -type _PathParam = - // split path into individual path segments - Path extends `${infer L}/${infer R}` - ? _PathParam | _PathParam - : // find params after `:` - Path extends `:${infer Param}` - ? Param extends `${infer Optional}?${string}` - ? RegexMatchPlus - : RegexMatchPlus - : // otherwise, there aren't any params present - never; - -export type PathParam = +type Regex_w = Regex_az | Regex_AZ | Regex_09 | "_"; + +// prettier-ignore +/** Emulates Regex `+` operator */ +type RegexMatchPlus = + _RegexMatchPlus extends infer result extends string ? + result extends '' ? never : result + : + never + +// prettier-ignore +type _RegexMatchPlus = + T extends `${infer head extends char}${infer rest}` ? + `${head}${_RegexMatchPlus}` + : + '' + +type ParamNameChar = Regex_w | "-"; + +type Simplify = { [K in keyof T]: T[K] } & {}; + +// prettier-ignore +type GeneratePathParams = Simplify< + & ParseParams + & { [key in string]: string | null | undefined } +> + +// prettier-ignore +type ParseParams = // check if path is just a wildcard - Path extends "*" | "/*" - ? "*" - : // look for wildcard at the end of the path - Path extends `${infer Rest}/*` - ? "*" | _PathParam - : // look for params in the absence of wildcards - _PathParam; + path extends '*' ? { '*': string } : + // look for wildcard at the end of the path + path extends `${infer rest}/*` ? { '*': string } & ParseParams : + // look for params in the absence of wildcards + _ParseParams; + +// prettier-ignore +type _ParseParams = + // split path into individual path segments + path extends `${infer left}/${infer right}` ? + _ParseParams & _ParseParams : + // look for optional param in segment + path extends `:${infer param}?${string}` ? + { [key in RegexMatchPlus]?: string | null | undefined } : + // look for required param in segment + path extends `:${infer param}` ? + { [key in RegexMatchPlus]: string } : + {}; + +// prettier-ignore +export type PathParam = (keyof ParseParams) & string; // eslint-disable-next-line @typescript-eslint/no-unused-vars type _tests = [ + // PathParam Expect, "*">>, Expect, "a">>, Expect, "b">>, @@ -821,6 +835,28 @@ type _tests = [ Expect, "a" | "c" | "*">>, Expect, "lang">>, Expect, "lang">>, + + // ParseParams + Expect, { "*": string }>>, + Expect, { a: string }>>, + Expect, { b: string }>>, + Expect, {}>>, + Expect>, { a: string; b: string }>>, + Expect< + Equal< + Simplify>, + { a: string; c: string; "*": string } + > + >, + Expect, { lang: string }>>, + Expect< + Equal, { lang?: string | null | undefined }> + >, + Expect>, { a: string }>>, + Expect>, { a: string }>>, + Expect< + Equal>, { a?: string | null | undefined }> + >, ]; // Attempt to parse the given string segment. If it fails, then just return the @@ -1365,9 +1401,7 @@ function matchRouteBranch< */ export function generatePath( originalPath: Path, - params: { - [key in PathParam]: string | null; - } = {} as any, + params: GeneratePathParams = {} as any, ): string { let path: string = originalPath; if (path.endsWith("*") && path !== "*" && !path.endsWith("/*")) { @@ -1394,15 +1428,14 @@ export function generatePath( // only apply the splat if it's the last segment if (isLastSegment && segment === "*") { - const star = "*" as PathParam; // Apply the splat - return stringify(params[star]); + return stringify(params["*" as keyof typeof params]); } const keyMatch = segment.match(/^:([\w-]+)(\??)(.*)/); if (keyMatch) { const [, key, optional, suffix] = keyMatch; - let param = params[key as PathParam]; + let param = params[key as keyof typeof params]; invariant(optional === "?" || param != null, `Missing ":${key}" param`); return encodeURIComponent(stringify(param)) + suffix; } @@ -1477,13 +1510,10 @@ type Mutable = { * @returns A path match object if the pattern matches the pathname, * or `null` if it does not match. */ -export function matchPath< - ParamKey extends ParamParseKey, - Path extends string, ->( +export function matchPath( pattern: PathPattern | Path, pathname: string, -): PathMatch | null { +): PathMatch> | null { if (typeof pattern === "string") { pattern = { path: pattern, caseSensitive: false, end: true }; } diff --git a/packages/react-router/lib/server-runtime/dev.ts b/packages/react-router/lib/server-runtime/dev.ts index 3298d37d78..1d2b2be036 100644 --- a/packages/react-router/lib/server-runtime/dev.ts +++ b/packages/react-router/lib/server-runtime/dev.ts @@ -19,7 +19,10 @@ export function getDevServerHooks(): DevServerHooks | undefined { export function getBuildTimeHeader(request: Request, headerName: string) { if (typeof process !== "undefined") { try { - if (process.env?.IS_RR_BUILD_REQUEST === "yes") { + if ( + process.env.hasOwnProperty("IS_RR_BUILD_REQUEST") && + process.env.IS_RR_BUILD_REQUEST === "yes" + ) { return request.headers.get(headerName); } } catch (e) {} diff --git a/packages/react-router/package.json b/packages/react-router/package.json index 5d61d61a85..ed9b2fa660 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -1,6 +1,6 @@ { "name": "react-router", - "version": "7.14.1", + "version": "7.14.2", "description": "Declarative routing for React", "keywords": [ "react", diff --git a/packages/react-router/vendor/turbo-stream-v2/turbo-stream.ts b/packages/react-router/vendor/turbo-stream-v2/turbo-stream.ts index e6d7824833..48ff40ef95 100644 --- a/packages/react-router/vendor/turbo-stream-v2/turbo-stream.ts +++ b/packages/react-router/vendor/turbo-stream-v2/turbo-stream.ts @@ -2,6 +2,7 @@ import { flatten } from "./flatten"; import { unflatten } from "./unflatten"; import { Deferred, + SUPPORTED_ERROR_TYPES, TYPE_ERROR, TYPE_PREVIOUS_RESOLVED, TYPE_PROMISE, @@ -13,6 +14,7 @@ import { } from "./utils"; export type { DecodePlugin, EncodePlugin }; +export { SUPPORTED_ERROR_TYPES }; export async function decode( readable: ReadableStream, diff --git a/packages/react-router/vendor/turbo-stream-v2/unflatten.ts b/packages/react-router/vendor/turbo-stream-v2/unflatten.ts index 2e686d8657..86e45358ad 100644 --- a/packages/react-router/vendor/turbo-stream-v2/unflatten.ts +++ b/packages/react-router/vendor/turbo-stream-v2/unflatten.ts @@ -19,6 +19,7 @@ import { TYPE_SYMBOL, TYPE_URL, type ThisDecode, + SUPPORTED_ERROR_TYPES, } from "./utils"; const globalObj = ( @@ -184,7 +185,11 @@ function hydrate(this: ThisDecode, index: number): any { case TYPE_ERROR: const [, message, errorType] = value; let error = - errorType && globalObj && globalObj[errorType] + errorType && + globalObj && + SUPPORTED_ERROR_TYPES.includes(errorType) && + errorType in globalObj && + typeof globalObj[errorType] === "function" ? new globalObj[errorType](message) : new Error(message); hydrated[index] = error; diff --git a/packages/react-router/vendor/turbo-stream-v2/utils.ts b/packages/react-router/vendor/turbo-stream-v2/utils.ts index e091c55318..51ccc7765f 100644 --- a/packages/react-router/vendor/turbo-stream-v2/utils.ts +++ b/packages/react-router/vendor/turbo-stream-v2/utils.ts @@ -44,6 +44,15 @@ export interface ThisEncode { signal?: AbortSignal; } +export const SUPPORTED_ERROR_TYPES = [ + "EvalError", + "RangeError", + "ReferenceError", + "SyntaxError", + "TypeError", + "URIError", +]; + export class Deferred { promise: Promise; resolve!: (value: T) => void; diff --git a/playground/rsc-vite-framework/app/routes/client-loader-hydrate/route.tsx b/playground/rsc-vite-framework/app/routes/client-loader-hydrate/route.tsx index 3b29c0fd24..149e6825fb 100644 --- a/playground/rsc-vite-framework/app/routes/client-loader-hydrate/route.tsx +++ b/playground/rsc-vite-framework/app/routes/client-loader-hydrate/route.tsx @@ -17,7 +17,7 @@ export default function ClientLoaderHydrateRoute({ }: Route.ComponentProps) { return (
-

Client loader

+

Client loader!

Loader data: {loaderData}

); diff --git a/playground/rsc-vite-framework/app/routes/client-loader-without-server-loader/route.tsx b/playground/rsc-vite-framework/app/routes/client-loader-without-server-loader/route.tsx index 0a54067815..55dad882fa 100644 --- a/playground/rsc-vite-framework/app/routes/client-loader-without-server-loader/route.tsx +++ b/playground/rsc-vite-framework/app/routes/client-loader-without-server-loader/route.tsx @@ -1,7 +1,7 @@ import type { Route } from "./+types/route"; export function clientLoader() { - return "hello, world from client loader"; + return "hello, world from client loader!"; } export default function ClientLoaderWithoutServerLoaderRoute({ @@ -9,7 +9,7 @@ export default function ClientLoaderWithoutServerLoaderRoute({ }: Route.ComponentProps) { return (
-

Client loader without server loader

+

Client loader without server loader!

Loader data: {loaderData}

); diff --git a/playground/rsc-vite-framework/app/routes/client-loader/route.tsx b/playground/rsc-vite-framework/app/routes/client-loader/route.tsx index e313a3cb77..2fecee23cb 100644 --- a/playground/rsc-vite-framework/app/routes/client-loader/route.tsx +++ b/playground/rsc-vite-framework/app/routes/client-loader/route.tsx @@ -7,7 +7,7 @@ export function loader() { } export function clientLoader() { - return "hello, world from client loader"; + return "hello, world from client loader!"; } export default function ClientLoaderRoute({ diff --git a/playground/rsc-vite-framework/package.json b/playground/rsc-vite-framework/package.json index ac33b6a9af..b210dae97d 100644 --- a/playground/rsc-vite-framework/package.json +++ b/playground/rsc-vite-framework/package.json @@ -21,6 +21,7 @@ "@types/react": "catalog:react-canary", "@types/react-dom": "catalog:react-canary", "@vitejs/plugin-rsc": "catalog:", + "@vitejs/plugin-react": "^6.0.1", "cross-env": "^7.0.3", "remark-frontmatter": "^5.0.0", "remark-mdx-frontmatter": "^5.2.0", diff --git a/playground/rsc-vite-framework/react-router.config.ts b/playground/rsc-vite-framework/react-router.config.ts index 75ea23d722..001a171cee 100644 --- a/playground/rsc-vite-framework/react-router.config.ts +++ b/playground/rsc-vite-framework/react-router.config.ts @@ -3,4 +3,7 @@ import type { Config } from "@react-router/dev/config"; export default { ssr: false, prerender: ["/", "/server-loader"], + future: { + v8_splitRouteModules: "enforce" + } } satisfies Config; diff --git a/playground/rsc-vite-framework/tsconfig.json b/playground/rsc-vite-framework/tsconfig.json index 65ee147911..ad2286bf43 100644 --- a/playground/rsc-vite-framework/tsconfig.json +++ b/playground/rsc-vite-framework/tsconfig.json @@ -1,5 +1,6 @@ { "include": ["**/*.ts", "**/*.tsx", "./.react-router/types/**/*"], + "exclude": ["vite.config.ts"], "compilerOptions": { "allowImportingTsExtensions": true, "strict": true, diff --git a/playground/rsc-vite-framework/vite.config.ts b/playground/rsc-vite-framework/vite.config.ts index dc1a4bfde2..074ab9b8e8 100644 --- a/playground/rsc-vite-framework/vite.config.ts +++ b/playground/rsc-vite-framework/vite.config.ts @@ -4,15 +4,17 @@ import rsc from "@vitejs/plugin-rsc"; import mdx from "@mdx-js/rollup"; import remarkFrontmatter from "remark-frontmatter"; import remarkMdxFrontmatter from "remark-mdx-frontmatter"; +import react from "@vitejs/plugin-react"; export default defineConfig({ build: { minify: false, }, plugins: [ - mdx({ remarkPlugins: [remarkFrontmatter, remarkMdxFrontmatter] }), + { enforce: "pre", ...mdx({ remarkPlugins: [remarkFrontmatter, remarkMdxFrontmatter] })}, // @ts-ignore reactRouterRSC({ __runningWithinTheReactRouterMonoRepo: true }), + react(), rsc(), ], }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d62156eced..ab9fdf7b9b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -114,7 +114,7 @@ importers: version: 10.1.0(jiti@2.4.2) eslint-config-react-app: specifier: ^7.0.1 - version: 7.0.1(@babel/plugin-syntax-flow@7.18.6(@babel/core@7.27.7))(@babel/plugin-transform-react-jsx@7.27.1(@babel/core@7.27.7))(eslint@10.1.0(jiti@2.4.2))(jest@29.7.0(@types/node@20.19.37)(babel-plugin-macros@3.1.0))(typescript@5.4.5) + version: 7.0.1(@babel/plugin-syntax-flow@7.18.6(@babel/core@7.27.7))(@babel/plugin-transform-react-jsx@7.27.1(@babel/core@7.27.7))(eslint@10.1.0(jiti@2.4.2))(jest@29.7.0(@types/node@22.19.15)(babel-plugin-macros@3.1.0))(typescript@5.4.5) eslint-plugin-flowtype: specifier: ^8.0.3 version: 8.0.3(@babel/plugin-syntax-flow@7.18.6(@babel/core@7.27.7))(@babel/plugin-transform-react-jsx@7.27.1(@babel/core@7.27.7))(eslint@10.1.0(jiti@2.4.2)) @@ -123,7 +123,7 @@ importers: version: 2.32.0(@typescript-eslint/parser@8.57.1(eslint@10.1.0(jiti@2.4.2))(typescript@5.4.5))(eslint@10.1.0(jiti@2.4.2)) eslint-plugin-jest: specifier: ^29.15.0 - version: 29.15.0(@typescript-eslint/eslint-plugin@8.57.1(@typescript-eslint/parser@8.57.1(eslint@10.1.0(jiti@2.4.2))(typescript@5.4.5))(eslint@10.1.0(jiti@2.4.2))(typescript@5.4.5))(eslint@10.1.0(jiti@2.4.2))(jest@29.7.0(@types/node@20.19.37)(babel-plugin-macros@3.1.0))(typescript@5.4.5) + version: 29.15.0(@typescript-eslint/eslint-plugin@8.57.1(@typescript-eslint/parser@8.57.1(eslint@10.1.0(jiti@2.4.2))(typescript@5.4.5))(eslint@10.1.0(jiti@2.4.2))(typescript@5.4.5))(eslint@10.1.0(jiti@2.4.2))(jest@29.7.0(@types/node@22.19.15)(babel-plugin-macros@3.1.0))(typescript@5.4.5) eslint-plugin-jsdoc: specifier: ^62.8.0 version: 62.8.0(eslint@10.1.0(jiti@2.4.2)) @@ -135,7 +135,7 @@ importers: version: 7.37.5(eslint@10.1.0(jiti@2.4.2)) eslint-plugin-react-hooks: specifier: next - version: 7.1.0-canary-705268dc-20260409(eslint@10.1.0(jiti@2.4.2)) + version: 7.1.1-canary-d1727fbf-20260417(eslint@10.1.0(jiti@2.4.2)) fast-glob: specifier: 3.2.11 version: 3.2.11 @@ -144,7 +144,7 @@ importers: version: 5.1.11 jest: specifier: ^29.6.4 - version: 29.7.0(@types/node@20.19.37)(babel-plugin-macros@3.1.0) + version: 29.7.0(@types/node@22.19.15)(babel-plugin-macros@3.1.0) jsonfile: specifier: ^6.1.0 version: 6.1.0 @@ -183,10 +183,10 @@ importers: version: 3.1.1 vite: specifier: ^6.3.0 - version: 6.4.1(@types/node@20.19.37)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0) + version: 6.4.1(@types/node@22.19.15)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0) vitest: specifier: ^4.1.0 - version: 4.1.0(@types/node@20.19.37)(jsdom@22.1.0)(msw@2.7.5(@types/node@20.19.37)(typescript@5.4.5))(vite@6.4.1(@types/node@20.19.37)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0)) + version: 4.1.0(@types/node@22.19.15)(jsdom@22.1.0)(msw@2.7.5(@types/node@22.19.15)(typescript@5.4.5))(vite@6.4.1(@types/node@22.19.15)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0)) integration: dependencies: @@ -395,7 +395,7 @@ importers: version: 5.0.1 '@types/node': specifier: ^22.13.1 - version: 22.14.0 + version: 22.19.15 '@types/react': specifier: ^18.0.27 version: 18.2.18 @@ -404,16 +404,16 @@ importers: version: 18.2.7 '@vitejs/plugin-react': specifier: ^4.5.2 - version: 4.5.2(vite@6.4.1(@types/node@22.14.0)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0)) + version: 4.5.2(vite@6.4.1(@types/node@22.19.15)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.21(react-dom@19.2.3(react@19.2.3))(react-server-dom-webpack@19.2.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(webpack@5.103.0))(react@19.2.3)(vite@6.4.1(@types/node@22.14.0)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0)) + version: 0.5.21(react-dom@19.2.3(react@19.2.3))(react-server-dom-webpack@19.2.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(webpack@5.103.0))(react@19.2.3)(vite@6.4.1(@types/node@22.19.15)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0)) typescript: specifier: 'catalog:' version: 5.4.5 vite: specifier: ^6.3.0 - version: 6.4.1(@types/node@22.14.0)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0) + version: 6.4.1(@types/node@22.19.15)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0) integration/helpers/rsc-vite-framework: dependencies: @@ -456,7 +456,7 @@ importers: version: 5.0.1 '@types/node': specifier: ^22.13.1 - version: 22.14.0 + version: 22.19.15 '@types/react': specifier: ^18.0.27 version: 18.2.18 @@ -468,13 +468,13 @@ importers: version: 1.19.0(babel-plugin-macros@3.1.0) '@vanilla-extract/vite-plugin': specifier: ^5.2.0 - version: 5.2.0(@types/node@22.14.0)(babel-plugin-macros@3.1.0)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(vite@8.0.0(@types/node@22.14.0)(esbuild@0.27.4)(jiti@2.4.2)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0))(yaml@2.8.0) + version: 5.2.0(@types/node@22.19.15)(babel-plugin-macros@3.1.0)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(vite@8.0.0(@types/node@22.19.15)(esbuild@0.27.4)(jiti@2.4.2)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0))(yaml@2.8.0) '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(vite@8.0.0(@types/node@22.14.0)(esbuild@0.27.4)(jiti@2.4.2)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0)) + version: 6.0.1(vite@8.0.0(@types/node@22.19.15)(esbuild@0.27.4)(jiti@2.4.2)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.21(react-dom@19.2.3(react@19.2.3))(react-server-dom-webpack@19.2.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(webpack@5.103.0(esbuild@0.27.4)))(react@19.2.3)(vite@8.0.0(@types/node@22.14.0)(esbuild@0.27.4)(jiti@2.4.2)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0)) + version: 0.5.21(react-dom@19.2.3(react@19.2.3))(react-server-dom-webpack@19.2.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(webpack@5.103.0(esbuild@0.27.4)))(react@19.2.3)(vite@8.0.0(@types/node@22.19.15)(esbuild@0.27.4)(jiti@2.4.2)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0)) cross-env: specifier: ^7.0.3 version: 7.0.3 @@ -483,10 +483,10 @@ importers: version: 5.4.5 vite: specifier: ^8.0.0 - version: 8.0.0(@types/node@22.14.0)(esbuild@0.27.4)(jiti@2.4.2)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0) + version: 8.0.0(@types/node@22.19.15)(esbuild@0.27.4)(jiti@2.4.2)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0) vite-env-only: specifier: ^3.0.1 - version: 3.0.1(vite@8.0.0(@types/node@22.14.0)(esbuild@0.27.4)(jiti@2.4.2)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0)) + version: 3.0.1(vite@8.0.0(@types/node@22.19.15)(esbuild@0.27.4)(jiti@2.4.2)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0)) integration/helpers/vite-5-template: dependencies: @@ -1725,7 +1725,7 @@ importers: version: 5.0.1 '@types/node': specifier: ^22.13.1 - version: 22.14.0 + version: 22.19.15 '@types/react': specifier: ^18.0.27 version: 18.2.18 @@ -1734,10 +1734,10 @@ importers: version: 18.2.7 '@vitejs/plugin-react': specifier: ^4.5.2 - version: 4.5.2(vite@6.4.1(@types/node@22.14.0)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0)) + version: 4.5.2(vite@6.4.1(@types/node@22.19.15)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.21(react-dom@19.2.3(react@19.2.3))(react-server-dom-webpack@19.2.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(webpack@5.103.0))(react@19.2.3)(vite@6.4.1(@types/node@22.14.0)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0)) + version: 0.5.21(react-dom@19.2.3(react@19.2.3))(react-server-dom-webpack@19.2.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(webpack@5.103.0))(react@19.2.3)(vite@6.4.1(@types/node@22.19.15)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0)) cross-env: specifier: ^7.0.3 version: 7.0.3 @@ -1746,7 +1746,7 @@ importers: version: 5.4.5 vite: specifier: ^6.3.0 - version: 6.4.1(@types/node@22.14.0)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0) + version: 6.4.1(@types/node@22.19.15)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0) playground/rsc-vite-7-framework: dependencies: @@ -1792,7 +1792,7 @@ importers: version: 5.0.1 '@types/node': specifier: ^22.13.1 - version: 22.14.0 + version: 22.19.15 '@types/react': specifier: ^18.0.27 version: 18.2.18 @@ -1801,7 +1801,7 @@ importers: version: 18.2.7 '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.21(react-dom@19.2.3(react@19.2.3))(react-server-dom-webpack@19.2.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(webpack@5.103.0))(react@19.2.3)(vite@7.3.1(@types/node@22.14.0)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0)) + version: 0.5.21(react-dom@19.2.3(react@19.2.3))(react-server-dom-webpack@19.2.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(webpack@5.103.0))(react@19.2.3)(vite@7.3.1(@types/node@22.19.15)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0)) cross-env: specifier: ^7.0.3 version: 7.0.3 @@ -1816,7 +1816,7 @@ importers: version: 5.4.5 vite: specifier: ^7.3.1 - version: 7.3.1(@types/node@22.14.0)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0) + version: 7.3.1(@types/node@22.19.15)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0) playground/rsc-vite-framework: dependencies: @@ -1862,16 +1862,19 @@ importers: version: 5.0.1 '@types/node': specifier: ^22.13.1 - version: 22.14.0 + version: 22.19.15 '@types/react': specifier: ^18.0.27 version: 18.2.18 '@types/react-dom': specifier: ^18.0.10 version: 18.2.7 + '@vitejs/plugin-react': + specifier: ^6.0.1 + version: 6.0.1(vite@8.0.0(@types/node@22.19.15)(esbuild@0.27.4)(jiti@2.4.2)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.21(react-dom@19.2.3(react@19.2.3))(react-server-dom-webpack@19.2.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(webpack@5.103.0(esbuild@0.27.4)))(react@19.2.3)(vite@8.0.0(@types/node@22.14.0)(esbuild@0.27.4)(jiti@2.4.2)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0)) + version: 0.5.21(react-dom@19.2.3(react@19.2.3))(react-server-dom-webpack@19.2.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(webpack@5.103.0(esbuild@0.27.4)))(react@19.2.3)(vite@8.0.0(@types/node@22.19.15)(esbuild@0.27.4)(jiti@2.4.2)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0)) cross-env: specifier: ^7.0.3 version: 7.0.3 @@ -1886,7 +1889,7 @@ importers: version: 5.4.5 vite: specifier: ^8.0.0 - version: 8.0.0(@types/node@22.14.0)(esbuild@0.27.4)(jiti@2.4.2)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0) + version: 8.0.0(@types/node@22.19.15)(esbuild@0.27.4)(jiti@2.4.2)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0) playground/split-route-modules: dependencies: @@ -4464,9 +4467,6 @@ packages: '@types/node@20.19.37': resolution: {integrity: sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==} - '@types/node@22.14.0': - resolution: {integrity: sha512-Kmpl+z84ILoG+3T/zQFyAJsU6EPTmOCj8/2+83fSN6djd6I4o7uOuGIH6vq3PrjY5BGitSbFuMN18j3iknubbA==} - '@types/node@22.19.15': resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==} @@ -5883,8 +5883,8 @@ packages: peerDependencies: eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 - eslint-plugin-react-hooks@7.1.0-canary-705268dc-20260409: - resolution: {integrity: sha512-1aCvpk6uXAllUERwNHOtcV2e86xO8h+om8fhcpfet0q1Re4exZ1fb/CFblHkw84WFyML76Ry84bysxbDE6Wvpw==} + eslint-plugin-react-hooks@7.1.1-canary-d1727fbf-20260417: + resolution: {integrity: sha512-lGcF2qMJEgVLjyqMqoYUWjMqIqyd09FfXpbw9yVyvsVz6rcPdoPZheTReIiNtC/CaF5YKpGlVhyRw2VnpFwuRQ==} engines: {node: '>=18'} peerDependencies: eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0 @@ -10948,14 +10948,6 @@ snapshots: '@img/sharp-win32-x64@0.34.5': optional: true - '@inquirer/confirm@5.1.9(@types/node@20.19.37)': - dependencies: - '@inquirer/core': 10.1.10(@types/node@20.19.37) - '@inquirer/type': 3.0.6(@types/node@20.19.37) - optionalDependencies: - '@types/node': 20.19.37 - optional: true - '@inquirer/confirm@5.1.9(@types/node@22.19.15)': dependencies: '@inquirer/core': 10.1.10(@types/node@22.19.15) @@ -10963,20 +10955,6 @@ snapshots: optionalDependencies: '@types/node': 22.19.15 - '@inquirer/core@10.1.10(@types/node@20.19.37)': - dependencies: - '@inquirer/figures': 1.0.11 - '@inquirer/type': 3.0.6(@types/node@20.19.37) - ansi-escapes: 4.3.2 - cli-width: 4.1.0 - mute-stream: 2.0.0 - signal-exit: 4.1.0 - wrap-ansi: 6.2.0 - yoctocolors-cjs: 2.1.2 - optionalDependencies: - '@types/node': 20.19.37 - optional: true - '@inquirer/core@10.1.10(@types/node@22.19.15)': dependencies: '@inquirer/figures': 1.0.11 @@ -10992,11 +10970,6 @@ snapshots: '@inquirer/figures@1.0.11': {} - '@inquirer/type@3.0.6(@types/node@20.19.37)': - optionalDependencies: - '@types/node': 20.19.37 - optional: true - '@inquirer/type@3.0.6(@types/node@22.19.15)': optionalDependencies: '@types/node': 22.19.15 @@ -11023,7 +10996,7 @@ snapshots: '@jest/console@29.7.0': dependencies: '@jest/types': 29.6.3 - '@types/node': 20.19.37 + '@types/node': 22.19.15 chalk: 4.1.2 jest-message-util: 29.7.0 jest-util: 29.7.0 @@ -11036,14 +11009,14 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.19.37 + '@types/node': 22.19.15 ansi-escapes: 4.3.2 chalk: 4.1.2 ci-info: 3.8.0 exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@20.19.37)(babel-plugin-macros@3.1.0) + jest-config: 29.7.0(@types/node@22.19.15)(babel-plugin-macros@3.1.0) jest-haste-map: 29.7.0 jest-message-util: 29.7.0 jest-regex-util: 29.6.3 @@ -11068,7 +11041,7 @@ snapshots: dependencies: '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.19.37 + '@types/node': 22.19.15 jest-mock: 29.7.0 '@jest/expect-utils@29.7.0': @@ -11086,7 +11059,7 @@ snapshots: dependencies: '@jest/types': 29.6.3 '@sinonjs/fake-timers': 10.0.2 - '@types/node': 20.19.37 + '@types/node': 22.19.15 jest-message-util: 29.7.0 jest-mock: 29.7.0 jest-util: 29.7.0 @@ -11108,7 +11081,7 @@ snapshots: '@jest/transform': 29.7.0 '@jest/types': 29.6.3 '@jridgewell/trace-mapping': 0.3.25 - '@types/node': 20.19.37 + '@types/node': 22.19.15 chalk: 4.1.2 collect-v8-coverage: 1.0.1 exit: 0.1.2 @@ -11178,7 +11151,7 @@ snapshots: '@jest/schemas': 29.6.3 '@types/istanbul-lib-coverage': 2.0.4 '@types/istanbul-reports': 3.0.1 - '@types/node': 20.19.37 + '@types/node': 22.19.15 '@types/yargs': 17.0.24 chalk: 4.1.2 @@ -11692,7 +11665,7 @@ snapshots: '@types/body-parser@1.19.5': dependencies: '@types/connect': 3.4.38 - '@types/node': 20.19.37 + '@types/node': 22.19.15 '@types/chai@5.2.3': dependencies: @@ -11702,11 +11675,11 @@ snapshots: '@types/compression@1.8.1': dependencies: '@types/express': 4.17.21 - '@types/node': 20.19.37 + '@types/node': 22.19.15 '@types/connect@3.4.38': dependencies: - '@types/node': 20.19.37 + '@types/node': 22.19.15 '@types/cookie@0.6.0': {} @@ -11714,7 +11687,7 @@ snapshots: '@types/cross-spawn@6.0.6': dependencies: - '@types/node': 20.19.37 + '@types/node': 22.19.15 '@types/debug@4.1.12': dependencies: @@ -11746,14 +11719,14 @@ snapshots: '@types/express-serve-static-core@4.17.43': dependencies: - '@types/node': 20.19.37 + '@types/node': 22.19.15 '@types/qs': 6.9.14 '@types/range-parser': 1.2.7 '@types/send': 0.17.4 '@types/express-serve-static-core@5.0.6': dependencies: - '@types/node': 20.19.37 + '@types/node': 22.19.15 '@types/qs': 6.9.14 '@types/range-parser': 1.2.7 '@types/send': 0.17.4 @@ -11774,20 +11747,20 @@ snapshots: '@types/glob@7.2.0': dependencies: '@types/minimatch': 5.1.2 - '@types/node': 20.19.37 + '@types/node': 22.19.15 '@types/glob@8.1.0': dependencies: '@types/minimatch': 5.1.2 - '@types/node': 20.19.37 + '@types/node': 22.19.15 '@types/graceful-fs@4.1.6': dependencies: - '@types/node': 20.19.37 + '@types/node': 22.19.15 '@types/gunzip-maybe@1.4.2': dependencies: - '@types/node': 20.19.37 + '@types/node': 22.19.15 '@types/hast@3.0.4': dependencies: @@ -11812,13 +11785,13 @@ snapshots: '@types/jsdom@20.0.1': dependencies: - '@types/node': 20.19.37 + '@types/node': 22.19.15 '@types/tough-cookie': 4.0.5 parse5: 7.1.2 '@types/jsdom@21.1.1': dependencies: - '@types/node': 20.19.37 + '@types/node': 22.19.15 '@types/tough-cookie': 4.0.5 parse5: 7.1.2 @@ -11854,7 +11827,7 @@ snapshots: '@types/morgan@1.9.10': dependencies: - '@types/node': 20.19.37 + '@types/node': 22.19.15 '@types/ms@0.7.34': {} @@ -11864,10 +11837,6 @@ snapshots: dependencies: undici-types: 6.21.0 - '@types/node@22.14.0': - dependencies: - undici-types: 6.21.0 - '@types/node@22.19.15': dependencies: undici-types: 6.21.0 @@ -11903,7 +11872,7 @@ snapshots: '@types/recursive-readdir@2.2.4': dependencies: - '@types/node': 20.19.37 + '@types/node': 22.19.15 '@types/scheduler@0.16.2': {} @@ -11912,22 +11881,22 @@ snapshots: '@types/send@0.17.4': dependencies: '@types/mime': 1.3.5 - '@types/node': 20.19.37 + '@types/node': 22.19.15 '@types/serve-static@1.15.5': dependencies: '@types/http-errors': 2.0.4 '@types/mime': 3.0.4 - '@types/node': 20.19.37 + '@types/node': 22.19.15 '@types/set-cookie-parser@2.4.7': dependencies: - '@types/node': 20.19.37 + '@types/node': 22.19.15 '@types/shelljs@0.8.16': dependencies: '@types/glob': 7.2.0 - '@types/node': 20.19.37 + '@types/node': 22.19.15 '@types/source-map-support@0.5.10': dependencies: @@ -11941,7 +11910,7 @@ snapshots: dependencies: '@types/cookiejar': 2.1.5 '@types/methods': 1.1.4 - '@types/node': 20.19.37 + '@types/node': 22.19.15 '@types/supertest@2.0.16': dependencies: @@ -11949,12 +11918,12 @@ snapshots: '@types/tar-fs@2.0.4': dependencies: - '@types/node': 20.19.37 + '@types/node': 22.19.15 '@types/tar-stream': 3.1.3 '@types/tar-stream@3.1.3': dependencies: - '@types/node': 20.19.37 + '@types/node': 22.19.15 '@types/tough-cookie@4.0.5': {} @@ -11964,7 +11933,7 @@ snapshots: '@types/wait-on@5.3.4': dependencies: - '@types/node': 20.19.37 + '@types/node': 22.19.15 '@types/yargs-parser@21.0.0': {} @@ -12163,27 +12132,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@vanilla-extract/compiler@0.5.0(@types/node@22.14.0)(babel-plugin-macros@3.1.0)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0)': - dependencies: - '@vanilla-extract/css': 1.19.0(babel-plugin-macros@3.1.0) - '@vanilla-extract/integration': 8.0.8(babel-plugin-macros@3.1.0) - vite: 6.4.1(@types/node@22.14.0)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0) - vite-node: 3.2.4(@types/node@22.14.0)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0) - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - jiti - - less - - lightningcss - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - tsx - - yaml - '@vanilla-extract/compiler@0.5.0(@types/node@22.19.15)(babel-plugin-macros@3.1.0)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0)': dependencies: '@vanilla-extract/css': 1.19.0(babel-plugin-macros@3.1.0) @@ -12240,26 +12188,6 @@ snapshots: '@vanilla-extract/private@1.0.9': {} - '@vanilla-extract/vite-plugin@5.2.0(@types/node@22.14.0)(babel-plugin-macros@3.1.0)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(vite@8.0.0(@types/node@22.14.0)(esbuild@0.27.4)(jiti@2.4.2)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0))(yaml@2.8.0)': - dependencies: - '@vanilla-extract/compiler': 0.5.0(@types/node@22.14.0)(babel-plugin-macros@3.1.0)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0) - '@vanilla-extract/integration': 8.0.8(babel-plugin-macros@3.1.0) - vite: 8.0.0(@types/node@22.14.0)(esbuild@0.27.4)(jiti@2.4.2)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0) - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - jiti - - less - - lightningcss - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - tsx - - yaml - '@vanilla-extract/vite-plugin@5.2.0(@types/node@22.19.15)(babel-plugin-macros@3.1.0)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(vite@5.1.3(@types/node@22.19.15)(lightningcss@1.32.0)(terser@5.44.1))(yaml@2.8.0)': dependencies: '@vanilla-extract/compiler': 0.5.0(@types/node@22.19.15)(babel-plugin-macros@3.1.0)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0) @@ -12340,18 +12268,6 @@ snapshots: - tsx - yaml - '@vitejs/plugin-react@4.5.2(vite@6.4.1(@types/node@22.14.0)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0))': - dependencies: - '@babel/core': 7.27.7 - '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.27.7) - '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.27.7) - '@rolldown/pluginutils': 1.0.0-beta.11 - '@types/babel__core': 7.20.5 - react-refresh: 0.17.0 - vite: 6.4.1(@types/node@22.14.0)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0) - transitivePeerDependencies: - - supports-color - '@vitejs/plugin-react@4.5.2(vite@6.4.1(@types/node@22.19.15)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0))': dependencies: '@babel/core': 7.27.7 @@ -12364,12 +12280,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitejs/plugin-react@6.0.1(vite@8.0.0(@types/node@22.14.0)(esbuild@0.27.4)(jiti@2.4.2)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0))': + '@vitejs/plugin-react@6.0.1(vite@8.0.0(@types/node@22.19.15)(esbuild@0.27.4)(jiti@2.4.2)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.7 - vite: 8.0.0(@types/node@22.14.0)(esbuild@0.27.4)(jiti@2.4.2)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0) + vite: 8.0.0(@types/node@22.19.15)(esbuild@0.27.4)(jiti@2.4.2)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0) - '@vitejs/plugin-rsc@0.5.21(react-dom@19.2.3(react@19.2.3))(react-server-dom-webpack@19.2.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(webpack@5.103.0(esbuild@0.27.4)))(react@19.2.3)(vite@8.0.0(@types/node@22.14.0)(esbuild@0.27.4)(jiti@2.4.2)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0))': + '@vitejs/plugin-rsc@0.5.21(react-dom@19.2.3(react@19.2.3))(react-server-dom-webpack@19.2.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(webpack@5.103.0(esbuild@0.27.4)))(react@19.2.3)(vite@8.0.0(@types/node@22.19.15)(esbuild@0.27.4)(jiti@2.4.2)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.5 es-module-lexer: 2.0.0 @@ -12381,12 +12297,12 @@ snapshots: srvx: 0.11.12 strip-literal: 3.1.0 turbo-stream: 3.1.0 - vite: 8.0.0(@types/node@22.14.0)(esbuild@0.27.4)(jiti@2.4.2)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0) - vitefu: 1.1.2(vite@8.0.0(@types/node@22.14.0)(esbuild@0.27.4)(jiti@2.4.2)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0)) + vite: 8.0.0(@types/node@22.19.15)(esbuild@0.27.4)(jiti@2.4.2)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0) + vitefu: 1.1.2(vite@8.0.0(@types/node@22.19.15)(esbuild@0.27.4)(jiti@2.4.2)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0)) optionalDependencies: react-server-dom-webpack: 19.2.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(webpack@5.103.0(esbuild@0.27.4)) - '@vitejs/plugin-rsc@0.5.21(react-dom@19.2.3(react@19.2.3))(react-server-dom-webpack@19.2.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(webpack@5.103.0))(react@19.2.3)(vite@6.4.1(@types/node@22.14.0)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0))': + '@vitejs/plugin-rsc@0.5.21(react-dom@19.2.3(react@19.2.3))(react-server-dom-webpack@19.2.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(webpack@5.103.0))(react@19.2.3)(vite@6.4.1(@types/node@22.19.15)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.5 es-module-lexer: 2.0.0 @@ -12398,12 +12314,12 @@ snapshots: srvx: 0.11.12 strip-literal: 3.1.0 turbo-stream: 3.1.0 - vite: 6.4.1(@types/node@22.14.0)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0) - vitefu: 1.1.2(vite@6.4.1(@types/node@22.14.0)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0)) + vite: 6.4.1(@types/node@22.19.15)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0) + vitefu: 1.1.2(vite@6.4.1(@types/node@22.19.15)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0)) optionalDependencies: react-server-dom-webpack: 19.2.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(webpack@5.103.0) - '@vitejs/plugin-rsc@0.5.21(react-dom@19.2.3(react@19.2.3))(react-server-dom-webpack@19.2.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(webpack@5.103.0))(react@19.2.3)(vite@7.3.1(@types/node@22.14.0)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0))': + '@vitejs/plugin-rsc@0.5.21(react-dom@19.2.3(react@19.2.3))(react-server-dom-webpack@19.2.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(webpack@5.103.0))(react@19.2.3)(vite@7.3.1(@types/node@22.19.15)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.5 es-module-lexer: 2.0.0 @@ -12415,8 +12331,8 @@ snapshots: srvx: 0.11.12 strip-literal: 3.1.0 turbo-stream: 3.1.0 - vite: 7.3.1(@types/node@22.14.0)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0) - vitefu: 1.1.2(vite@7.3.1(@types/node@22.14.0)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0)) + vite: 7.3.1(@types/node@22.19.15)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0) + vitefu: 1.1.2(vite@7.3.1(@types/node@22.19.15)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0)) optionalDependencies: react-server-dom-webpack: 19.2.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(webpack@5.103.0) @@ -12446,14 +12362,14 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.0(msw@2.7.5(@types/node@20.19.37)(typescript@5.4.5))(vite@6.4.1(@types/node@20.19.37)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0))': + '@vitest/mocker@4.1.0(msw@2.7.5(@types/node@22.19.15)(typescript@5.4.5))(vite@6.4.1(@types/node@22.19.15)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0))': dependencies: '@vitest/spy': 4.1.0 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - msw: 2.7.5(@types/node@20.19.37)(typescript@5.4.5) - vite: 6.4.1(@types/node@20.19.37)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0) + msw: 2.7.5(@types/node@22.19.15)(typescript@5.4.5) + vite: 6.4.1(@types/node@22.19.15)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0) '@vitest/pretty-format@4.1.0': dependencies: @@ -13227,13 +13143,13 @@ snapshots: path-type: 4.0.0 yaml: 1.10.2 - create-jest@29.7.0(@types/node@20.19.37)(babel-plugin-macros@3.1.0): + create-jest@29.7.0(@types/node@22.19.15)(babel-plugin-macros@3.1.0): dependencies: '@jest/types': 29.6.3 chalk: 4.1.2 exit: 0.1.2 graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@20.19.37)(babel-plugin-macros@3.1.0) + jest-config: 29.7.0(@types/node@22.19.15)(babel-plugin-macros@3.1.0) jest-util: 29.7.0 prompts: 2.4.2 transitivePeerDependencies: @@ -13738,7 +13654,7 @@ snapshots: optionalDependencies: source-map: 0.6.1 - eslint-config-react-app@7.0.1(@babel/plugin-syntax-flow@7.18.6(@babel/core@7.27.7))(@babel/plugin-transform-react-jsx@7.27.1(@babel/core@7.27.7))(eslint@10.1.0(jiti@2.4.2))(jest@29.7.0(@types/node@20.19.37)(babel-plugin-macros@3.1.0))(typescript@5.4.5): + eslint-config-react-app@7.0.1(@babel/plugin-syntax-flow@7.18.6(@babel/core@7.27.7))(@babel/plugin-transform-react-jsx@7.27.1(@babel/core@7.27.7))(eslint@10.1.0(jiti@2.4.2))(jest@29.7.0(@types/node@22.19.15)(babel-plugin-macros@3.1.0))(typescript@5.4.5): dependencies: '@babel/core': 7.27.7 '@babel/eslint-parser': 7.24.1(@babel/core@7.27.7)(eslint@10.1.0(jiti@2.4.2)) @@ -13750,7 +13666,7 @@ snapshots: eslint: 10.1.0(jiti@2.4.2) eslint-plugin-flowtype: 8.0.3(@babel/plugin-syntax-flow@7.18.6(@babel/core@7.27.7))(@babel/plugin-transform-react-jsx@7.27.1(@babel/core@7.27.7))(eslint@10.1.0(jiti@2.4.2)) eslint-plugin-import: 2.32.0(@typescript-eslint/parser@5.62.0(eslint@10.1.0(jiti@2.4.2))(typescript@5.4.5))(eslint@10.1.0(jiti@2.4.2)) - eslint-plugin-jest: 25.7.0(@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@10.1.0(jiti@2.4.2))(typescript@5.4.5))(eslint@10.1.0(jiti@2.4.2))(typescript@5.4.5))(eslint@10.1.0(jiti@2.4.2))(jest@29.7.0(@types/node@20.19.37)(babel-plugin-macros@3.1.0))(typescript@5.4.5) + eslint-plugin-jest: 25.7.0(@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@10.1.0(jiti@2.4.2))(typescript@5.4.5))(eslint@10.1.0(jiti@2.4.2))(typescript@5.4.5))(eslint@10.1.0(jiti@2.4.2))(jest@29.7.0(@types/node@22.19.15)(babel-plugin-macros@3.1.0))(typescript@5.4.5) eslint-plugin-jsx-a11y: 6.10.2(eslint@10.1.0(jiti@2.4.2)) eslint-plugin-react: 7.37.5(eslint@10.1.0(jiti@2.4.2)) eslint-plugin-react-hooks: 4.6.2(eslint@10.1.0(jiti@2.4.2)) @@ -13859,24 +13775,24 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-plugin-jest@25.7.0(@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@10.1.0(jiti@2.4.2))(typescript@5.4.5))(eslint@10.1.0(jiti@2.4.2))(typescript@5.4.5))(eslint@10.1.0(jiti@2.4.2))(jest@29.7.0(@types/node@20.19.37)(babel-plugin-macros@3.1.0))(typescript@5.4.5): + eslint-plugin-jest@25.7.0(@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@10.1.0(jiti@2.4.2))(typescript@5.4.5))(eslint@10.1.0(jiti@2.4.2))(typescript@5.4.5))(eslint@10.1.0(jiti@2.4.2))(jest@29.7.0(@types/node@22.19.15)(babel-plugin-macros@3.1.0))(typescript@5.4.5): dependencies: '@typescript-eslint/experimental-utils': 5.62.0(eslint@10.1.0(jiti@2.4.2))(typescript@5.4.5) eslint: 10.1.0(jiti@2.4.2) optionalDependencies: '@typescript-eslint/eslint-plugin': 5.62.0(@typescript-eslint/parser@5.62.0(eslint@10.1.0(jiti@2.4.2))(typescript@5.4.5))(eslint@10.1.0(jiti@2.4.2))(typescript@5.4.5) - jest: 29.7.0(@types/node@20.19.37)(babel-plugin-macros@3.1.0) + jest: 29.7.0(@types/node@22.19.15)(babel-plugin-macros@3.1.0) transitivePeerDependencies: - supports-color - typescript - eslint-plugin-jest@29.15.0(@typescript-eslint/eslint-plugin@8.57.1(@typescript-eslint/parser@8.57.1(eslint@10.1.0(jiti@2.4.2))(typescript@5.4.5))(eslint@10.1.0(jiti@2.4.2))(typescript@5.4.5))(eslint@10.1.0(jiti@2.4.2))(jest@29.7.0(@types/node@20.19.37)(babel-plugin-macros@3.1.0))(typescript@5.4.5): + eslint-plugin-jest@29.15.0(@typescript-eslint/eslint-plugin@8.57.1(@typescript-eslint/parser@8.57.1(eslint@10.1.0(jiti@2.4.2))(typescript@5.4.5))(eslint@10.1.0(jiti@2.4.2))(typescript@5.4.5))(eslint@10.1.0(jiti@2.4.2))(jest@29.7.0(@types/node@22.19.15)(babel-plugin-macros@3.1.0))(typescript@5.4.5): dependencies: '@typescript-eslint/utils': 8.57.1(eslint@10.1.0(jiti@2.4.2))(typescript@5.4.5) eslint: 10.1.0(jiti@2.4.2) optionalDependencies: '@typescript-eslint/eslint-plugin': 8.57.1(@typescript-eslint/parser@8.57.1(eslint@10.1.0(jiti@2.4.2))(typescript@5.4.5))(eslint@10.1.0(jiti@2.4.2))(typescript@5.4.5) - jest: 29.7.0(@types/node@20.19.37)(babel-plugin-macros@3.1.0) + jest: 29.7.0(@types/node@22.19.15)(babel-plugin-macros@3.1.0) typescript: 5.4.5 transitivePeerDependencies: - supports-color @@ -13924,7 +13840,7 @@ snapshots: dependencies: eslint: 10.1.0(jiti@2.4.2) - eslint-plugin-react-hooks@7.1.0-canary-705268dc-20260409(eslint@10.1.0(jiti@2.4.2)): + eslint-plugin-react-hooks@7.1.1-canary-d1727fbf-20260417(eslint@10.1.0(jiti@2.4.2)): dependencies: '@babel/core': 7.27.7 '@babel/parser': 7.27.7 @@ -14139,7 +14055,7 @@ snapshots: eval@0.1.8: dependencies: - '@types/node': 20.19.37 + '@types/node': 22.19.15 require-like: 0.1.2 event-target-shim@5.0.1: {} @@ -14955,7 +14871,7 @@ snapshots: '@jest/expect': 29.7.0 '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.19.37 + '@types/node': 22.19.15 chalk: 4.1.2 co: 4.6.0 dedent: 1.5.3(babel-plugin-macros@3.1.0) @@ -14975,16 +14891,16 @@ snapshots: - babel-plugin-macros - supports-color - jest-cli@29.7.0(@types/node@20.19.37)(babel-plugin-macros@3.1.0): + jest-cli@29.7.0(@types/node@22.19.15)(babel-plugin-macros@3.1.0): dependencies: '@jest/core': 29.7.0(babel-plugin-macros@3.1.0) '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 chalk: 4.1.2 - create-jest: 29.7.0(@types/node@20.19.37)(babel-plugin-macros@3.1.0) + create-jest: 29.7.0(@types/node@22.19.15)(babel-plugin-macros@3.1.0) exit: 0.1.2 import-local: 3.1.0 - jest-config: 29.7.0(@types/node@20.19.37)(babel-plugin-macros@3.1.0) + jest-config: 29.7.0(@types/node@22.19.15)(babel-plugin-macros@3.1.0) jest-util: 29.7.0 jest-validate: 29.7.0 yargs: 17.7.2 @@ -14994,7 +14910,7 @@ snapshots: - supports-color - ts-node - jest-config@29.7.0(@types/node@20.19.37)(babel-plugin-macros@3.1.0): + jest-config@29.7.0(@types/node@22.19.15)(babel-plugin-macros@3.1.0): dependencies: '@babel/core': 7.27.7 '@jest/test-sequencer': 29.7.0 @@ -15019,7 +14935,7 @@ snapshots: slash: 3.0.0 strip-json-comments: 3.1.1 optionalDependencies: - '@types/node': 20.19.37 + '@types/node': 22.19.15 transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -15049,7 +14965,7 @@ snapshots: '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 '@types/jsdom': 20.0.1 - '@types/node': 20.19.37 + '@types/node': 22.19.15 jest-mock: 29.7.0 jest-util: 29.7.0 jsdom: 22.1.0 @@ -15063,7 +14979,7 @@ snapshots: '@jest/environment': 29.7.0 '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.19.37 + '@types/node': 22.19.15 jest-mock: 29.7.0 jest-util: 29.7.0 @@ -15073,7 +14989,7 @@ snapshots: dependencies: '@jest/types': 29.6.3 '@types/graceful-fs': 4.1.6 - '@types/node': 20.19.37 + '@types/node': 22.19.15 anymatch: 3.1.3 fb-watchman: 2.0.2 graceful-fs: 4.2.11 @@ -15112,7 +15028,7 @@ snapshots: jest-mock@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 20.19.37 + '@types/node': 22.19.15 jest-util: 29.7.0 jest-pnp-resolver@1.2.3(jest-resolve@29.7.0): @@ -15147,7 +15063,7 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.19.37 + '@types/node': 22.19.15 chalk: 4.1.2 emittery: 0.13.1 graceful-fs: 4.2.11 @@ -15175,7 +15091,7 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.19.37 + '@types/node': 22.19.15 chalk: 4.1.2 cjs-module-lexer: 1.2.2 collect-v8-coverage: 1.0.1 @@ -15221,7 +15137,7 @@ snapshots: jest-util@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 20.19.37 + '@types/node': 22.19.15 chalk: 4.1.2 ci-info: 3.8.0 graceful-fs: 4.2.11 @@ -15240,7 +15156,7 @@ snapshots: dependencies: '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.19.37 + '@types/node': 22.19.15 ansi-escapes: 4.3.2 chalk: 4.1.2 emittery: 0.13.1 @@ -15255,17 +15171,17 @@ snapshots: jest-worker@29.7.0: dependencies: - '@types/node': 20.19.37 + '@types/node': 22.19.15 jest-util: 29.7.0 merge-stream: 2.0.0 supports-color: 8.1.1 - jest@29.7.0(@types/node@20.19.37)(babel-plugin-macros@3.1.0): + jest@29.7.0(@types/node@22.19.15)(babel-plugin-macros@3.1.0): dependencies: '@jest/core': 29.7.0(babel-plugin-macros@3.1.0) '@jest/types': 29.6.3 import-local: 3.1.0 - jest-cli: 29.7.0(@types/node@20.19.37)(babel-plugin-macros@3.1.0) + jest-cli: 29.7.0(@types/node@22.19.15)(babel-plugin-macros@3.1.0) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -16275,32 +16191,6 @@ snapshots: ms@2.1.3: {} - msw@2.7.5(@types/node@20.19.37)(typescript@5.4.5): - dependencies: - '@bundled-es-modules/cookie': 2.0.1 - '@bundled-es-modules/statuses': 1.0.1 - '@bundled-es-modules/tough-cookie': 0.1.6 - '@inquirer/confirm': 5.1.9(@types/node@20.19.37) - '@mswjs/interceptors': 0.37.6 - '@open-draft/deferred-promise': 2.2.0 - '@open-draft/until': 2.1.0 - '@types/cookie': 0.6.0 - '@types/statuses': 2.0.5 - graphql: 16.9.0 - headers-polyfill: 4.0.3 - is-node-process: 1.2.0 - outvariant: 1.4.3 - path-to-regexp: 6.3.0 - picocolors: 1.1.1 - strict-event-emitter: 0.5.1 - type-fest: 4.40.1 - yargs: 17.7.2 - optionalDependencies: - typescript: 5.4.5 - transitivePeerDependencies: - - '@types/node' - optional: true - msw@2.7.5(@types/node@22.19.15)(typescript@5.4.5): dependencies: '@bundled-es-modules/cookie': 2.0.1 @@ -18050,19 +17940,6 @@ snapshots: transitivePeerDependencies: - supports-color - vite-env-only@3.0.1(vite@8.0.0(@types/node@22.14.0)(esbuild@0.27.4)(jiti@2.4.2)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0)): - dependencies: - '@babel/core': 7.27.7 - '@babel/generator': 7.27.5 - '@babel/parser': 7.27.7 - '@babel/traverse': 7.27.7 - '@babel/types': 7.27.7 - babel-dead-code-elimination: 1.0.10 - micromatch: 4.0.5 - vite: 8.0.0(@types/node@22.14.0)(esbuild@0.27.4)(jiti@2.4.2)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0) - transitivePeerDependencies: - - supports-color - vite-env-only@3.0.1(vite@8.0.0(@types/node@22.19.15)(esbuild@0.27.4)(jiti@2.4.2)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0)): dependencies: '@babel/core': 7.27.7 @@ -18097,27 +17974,6 @@ snapshots: - tsx - yaml - vite-node@3.2.4(@types/node@22.14.0)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0): - dependencies: - cac: 6.7.14 - debug: 4.4.3 - es-module-lexer: 1.7.0 - pathe: 2.0.3 - vite: 6.4.1(@types/node@22.14.0)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0) - transitivePeerDependencies: - - '@types/node' - - jiti - - less - - lightningcss - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - tsx - - yaml - vite-node@3.2.4(@types/node@22.19.15)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0): dependencies: cac: 6.7.14 @@ -18211,23 +18067,6 @@ snapshots: tsx: 4.19.3 yaml: 2.8.0 - vite@6.4.1(@types/node@22.14.0)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0): - dependencies: - esbuild: 0.25.0 - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - postcss: 8.5.8 - rollup: 4.43.0 - tinyglobby: 0.2.15 - optionalDependencies: - '@types/node': 22.14.0 - fsevents: 2.3.3 - jiti: 2.4.2 - lightningcss: 1.32.0 - terser: 5.44.1 - tsx: 4.19.3 - yaml: 2.8.0 - vite@6.4.1(@types/node@22.19.15)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0): dependencies: esbuild: 0.25.0 @@ -18262,7 +18101,7 @@ snapshots: tsx: 4.19.3 yaml: 2.8.0 - vite@7.3.1(@types/node@22.14.0)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0): + vite@7.3.1(@types/node@22.19.15)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0): dependencies: esbuild: 0.27.4 fdir: 6.5.0(picomatch@4.0.3) @@ -18271,7 +18110,7 @@ snapshots: rollup: 4.43.0 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 22.14.0 + '@types/node': 22.19.15 fsevents: 2.3.3 jiti: 2.4.2 lightningcss: 1.32.0 @@ -18296,23 +18135,6 @@ snapshots: tsx: 4.19.3 yaml: 2.8.0 - vite@8.0.0(@types/node@22.14.0)(esbuild@0.27.4)(jiti@2.4.2)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0): - dependencies: - '@oxc-project/runtime': 0.115.0 - lightningcss: 1.32.0 - picomatch: 4.0.3 - postcss: 8.5.8 - rolldown: 1.0.0-rc.9 - tinyglobby: 0.2.15 - optionalDependencies: - '@types/node': 22.14.0 - esbuild: 0.27.4 - fsevents: 2.3.3 - jiti: 2.4.2 - terser: 5.44.1 - tsx: 4.19.3 - yaml: 2.8.0 - vite@8.0.0(@types/node@22.19.15)(esbuild@0.27.4)(jiti@2.4.2)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0): dependencies: '@oxc-project/runtime': 0.115.0 @@ -18334,22 +18156,22 @@ snapshots: optionalDependencies: vite: 6.4.1(@types/node@20.19.37)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0) - vitefu@1.1.2(vite@6.4.1(@types/node@22.14.0)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0)): + vitefu@1.1.2(vite@6.4.1(@types/node@22.19.15)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0)): optionalDependencies: - vite: 6.4.1(@types/node@22.14.0)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0) + vite: 6.4.1(@types/node@22.19.15)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0) - vitefu@1.1.2(vite@7.3.1(@types/node@22.14.0)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0)): + vitefu@1.1.2(vite@7.3.1(@types/node@22.19.15)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0)): optionalDependencies: - vite: 7.3.1(@types/node@22.14.0)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0) + vite: 7.3.1(@types/node@22.19.15)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0) - vitefu@1.1.2(vite@8.0.0(@types/node@22.14.0)(esbuild@0.27.4)(jiti@2.4.2)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0)): + vitefu@1.1.2(vite@8.0.0(@types/node@22.19.15)(esbuild@0.27.4)(jiti@2.4.2)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0)): optionalDependencies: - vite: 8.0.0(@types/node@22.14.0)(esbuild@0.27.4)(jiti@2.4.2)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0) + vite: 8.0.0(@types/node@22.19.15)(esbuild@0.27.4)(jiti@2.4.2)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0) - vitest@4.1.0(@types/node@20.19.37)(jsdom@22.1.0)(msw@2.7.5(@types/node@20.19.37)(typescript@5.4.5))(vite@6.4.1(@types/node@20.19.37)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0)): + vitest@4.1.0(@types/node@22.19.15)(jsdom@22.1.0)(msw@2.7.5(@types/node@22.19.15)(typescript@5.4.5))(vite@6.4.1(@types/node@22.19.15)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0)): dependencies: '@vitest/expect': 4.1.0 - '@vitest/mocker': 4.1.0(msw@2.7.5(@types/node@20.19.37)(typescript@5.4.5))(vite@6.4.1(@types/node@20.19.37)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0)) + '@vitest/mocker': 4.1.0(msw@2.7.5(@types/node@22.19.15)(typescript@5.4.5))(vite@6.4.1(@types/node@22.19.15)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0)) '@vitest/pretty-format': 4.1.0 '@vitest/runner': 4.1.0 '@vitest/snapshot': 4.1.0 @@ -18366,10 +18188,10 @@ snapshots: tinyexec: 1.0.4 tinyglobby: 0.2.15 tinyrainbow: 3.1.0 - vite: 6.4.1(@types/node@20.19.37)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0) + vite: 6.4.1(@types/node@22.19.15)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 20.19.37 + '@types/node': 22.19.15 jsdom: 22.1.0 transitivePeerDependencies: - msw diff --git a/scripts/changes/add.ts b/scripts/changes/add.ts index 3f40580ddb..ae7a6d73c4 100644 --- a/scripts/changes/add.ts +++ b/scripts/changes/add.ts @@ -9,6 +9,72 @@ import * as path from "node:path"; import prompts from "prompts"; import { getAllPackageDirNames, getPackagePath } from "../utils/packages.ts"; +// Common English stop words that add no meaning to a filename slug +const STOP_WORDS = new Set([ + "a", + "an", + "the", + "and", + "or", + "but", + "so", + "nor", + "yet", + "in", + "on", + "at", + "by", + "for", + "to", + "of", + "from", + "with", + "into", + "onto", + "about", + "as", + "via", + "is", + "are", + "was", + "were", + "be", + "been", + "being", + "have", + "has", + "had", + "do", + "does", + "did", + "it", + "its", + "this", + "that", + "these", + "those", + "we", + "us", + "our", + "i", + "me", + "my", + "you", + "your", + "he", + "him", + "his", + "she", + "her", + "they", + "them", + "their", + "now", + "then", + "also", + "just", +]); + const bumpTypes = ["patch", "minor", "major", "unstable"] as const; interface Package { @@ -122,8 +188,8 @@ function getPackages(): Package[] { } /** - * Converts a free-text description into a kebab-case slug of at most 6 words. - * Non-alphanumeric characters (other than spaces) are stripped before slugging. + * Converts a free-text description into a kebab-case slug of at most 6 + * meaningful words. Stop words and non-alphanumeric characters are stripped. */ function toSlug(description: string): string { return ( @@ -132,6 +198,7 @@ function toSlug(description: string): string { .replace(/[^a-z0-9\s]/g, "") .trim() .split(/\s+/) + .filter((w) => !STOP_WORDS.has(w)) .slice(0, 6) .join("-") || "change" ); diff --git a/scripts/changes/changes.ts b/scripts/changes/changes.ts index c6f7b13fd9..6e5f1644bc 100644 --- a/scripts/changes/changes.ts +++ b/scripts/changes/changes.ts @@ -11,6 +11,7 @@ import { getPackagePath, packageNameToDirectoryName, } from "../utils/packages.ts"; +import { getCommitSubject, getFileSha, parsePrNumber } from "../utils/git.ts"; const bumpTypes = ["major", "minor", "patch", "unstable"] as const; type BumpType = (typeof bumpTypes)[number]; @@ -38,6 +39,8 @@ interface ChangeFile { file: string; bump: BumpType; content: string; + gitSha?: string; + prNumber?: number; } interface ValidationError { @@ -171,7 +174,9 @@ function parsePackageChanges(packageDirName: string): ParsedPackageChanges { } // File is valid, add to changes - changes.push({ file, bump, content }); + let gitSha = getFileSha(filePath).substring(0, 7); + let prNumber = parsePrNumber(getCommitSubject(gitSha)) ?? undefined; + changes.push({ file, bump, content, gitSha, prNumber }); } if (errors.length > 0) { @@ -394,16 +399,22 @@ function hasBreakingChangePrefix(content: string): boolean { /** * Formats a changelog entry from change file content */ -function formatChangelogEntry(content: string): string { - let lines = content.trim().split("\n"); +function formatChangelogEntry(change: ChangeFile): string { + let lines = change.content.trim().split("\n"); + let base = "https://github.com/remix-run/react-router"; + // prettier-ignore + let link = + change.prNumber ? ` ([#${change.prNumber}](${base}/pull/${change.prNumber}))` : + change.gitSha ? ` ([${change.gitSha}](${base}/commit/${change.gitSha}))` : + ""; if (lines.length === 1) { - return `- ${lines[0]}`; + return `- ${lines[0]}${link}`; } // Multi-line: first line is bullet, rest are indented let [firstLine, ...restLines] = lines; - let formatted = [`- ${firstLine}`]; + let formatted = [`- ${firstLine}${link}`]; for (let line of restLines) { // Add proper indentation for continuation lines @@ -464,7 +475,7 @@ function generateBumpTypeSection( ); for (let change of changes) { - lines.push(formatChangelogEntry(change.content)); + lines.push(formatChangelogEntry(change)); if (includeBlankLine) { lines.push(""); } diff --git a/scripts/changes/migrate-changesets.ts b/scripts/changes/migrate-changesets.ts index f9b347972d..d8a38a20e1 100644 --- a/scripts/changes/migrate-changesets.ts +++ b/scripts/changes/migrate-changesets.ts @@ -17,10 +17,14 @@ * node scripts/changes/migrate-changesets-files.ts [--dry-run] */ +import * as cp from "node:child_process"; import * as fs from "node:fs"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; -import { packageNameToDirectoryName } from "../utils/packages.ts"; +import { + GITHUB_REPO_URL, + packageNameToDirectoryName, +} from "../utils/packages.ts"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(__dirname, "..", ".."); @@ -74,6 +78,34 @@ function parseChangeset(content: string): ParsedChangeset | null { return { packages, description }; } +/** + * Returns a markdown link to the PR or commit that introduced the given file. + * Prefers a PR link like `([#123](...))`, falls back to a short SHA link. + */ +function getSourceLink(filePath: string): string { + let result = cp.spawnSync( + "git", + ["log", "--format=%H %s", "-1", "--", filePath], + { encoding: "utf-8" }, + ); + + let line = result.stdout.trim(); + if (!line) return ""; + + let spaceIndex = line.indexOf(" "); + let sha = line.slice(0, spaceIndex); + let subject = line.slice(spaceIndex + 1); + + let prMatch = subject.match(/\(#(\d+)\)$/); + if (prMatch) { + let pr = prMatch[1]; + return ` ([#${pr}](${GITHUB_REPO_URL}/pull/${pr}))`; + } + + let shortSha = sha.slice(0, 8); + return ` ([${shortSha}](${GITHUB_REPO_URL}/commit/${sha}))`; +} + function slugify(name: string): string { return name .toLowerCase() @@ -185,7 +217,11 @@ async function main() { console.log(` βœ… ${packageName} β†’ ${destRelative}`); if (!dryRun) { - fs.writeFileSync(destPath, parsed.description + "\n", "utf-8"); + let link = getSourceLink(filePath); + let lines = parsed.description.trim().split("\n"); + lines[0] += link; + let content = lines.join("\n") + "\n"; + fs.writeFileSync(destPath, content, "utf-8"); } totalCreated++; diff --git a/scripts/changes/release.sh b/scripts/changes/release.sh index c845689e67..9fc01ad15a 100755 --- a/scripts/changes/release.sh +++ b/scripts/changes/release.sh @@ -76,8 +76,22 @@ elif [[ "${COMMAND}" == "finish" ]]; then git push + git branch -d release-pr &> /dev/null || true git branch -d release + set +e + git ls-remote --exit-code --heads origin release-pr + EXIT_CODE=$? + if [[ $EXIT_CODE == '0' ]]; then + git push origin --delete release-pr + fi + + git ls-remote --exit-code --heads origin release + EXIT_CODE=$? + if [[ $EXIT_CODE == '0' ]]; then + git push origin --delete release + fi + set -e fi set +e diff --git a/scripts/experimental.ts b/scripts/experimental.ts index 5ef44fed42..ddafdfc3cb 100644 --- a/scripts/experimental.ts +++ b/scripts/experimental.ts @@ -39,15 +39,16 @@ async function bumpVersion() { let sha = logAndExec("git rev-parse --short HEAD", true); invariant(sha != null, "Failed to get git SHA"); let version = `0.0.0-experimental-${sha}`; + let branch = `experimental/${sha}`; if (dryRun) { console.log( colorize( - ` [Dry Run] Would create and switch to branch experimental/${version}\n`, + ` [Dry Run] Would create and switch to branch ${branch}\n`, colors.yellow, ), ); } else { - logAndExec(`git checkout -b experimental/${version}`); + logAndExec(`git checkout -b ${branch}`); } for (let packageDirName of packageDirNames) { @@ -106,7 +107,7 @@ async function publishPackages() { // 4. Publish to npm let tag = "experimental"; - let publishCommand = `pnpm publish --recursive --filter "./packages/*" --access public --tag ${tag} --no-git-checks --report-summary`; + let publishCommand = `pnpm publish --recursive --filter "./packages/*" --access public --tag ${tag} --no-git-checks`; if (dryRun) { console.log( colorize( diff --git a/scripts/pr-preview.ts b/scripts/pr-preview.ts index 7a2b219371..19eab42ec7 100644 --- a/scripts/pr-preview.ts +++ b/scripts/pr-preview.ts @@ -71,16 +71,19 @@ async function comment() { ${STICKY_MARKER} ### Preview Build Available -Preview builds have been created for this PR. You can install them using: +Preview builds have been created for this PR. You can install \`react-router\` using: \`\`\`sh -# Install react-router pnpm install "remix-run/react-router#${branch}&path:packages/react-router" +\`\`\` + +And/or install other packages via: -# Install other packages as necessary +\`\`\`sh +pnpm install "remix-run/react-router#${branch}&path:packages/react-router-dev" +pnpm install "remix-run/react-router#${branch}&path:packages/react-router-express" pnpm install "remix-run/react-router#${branch}&path:packages/react-router-node" pnpm install "remix-run/react-router#${branch}&path:packages/react-router-serve" -pnpm install "remix-run/react-router#${branch}&path:packages/react-router-dev" \`\`\` These preview builds will be updated automatically as you push new commits.`; diff --git a/scripts/release-comments.ts b/scripts/release-comments.ts new file mode 100644 index 0000000000..c28aef00ac --- /dev/null +++ b/scripts/release-comments.ts @@ -0,0 +1,380 @@ +// 1. get all tags sorted by creation date +// 2. get all commits between current and last tag that changed ./packages using `git` +// 3. check if commit is a PR and get the number,title,body using `gh` +// 4. get issues that are linked in the PR using `gh api` +// 5. comment on PRs and issues with the release version using `gh issue comment` and `gh pr comment` +// 6. close issues that are referenced in the PRs using `gh issue close` + +import semver from "semver"; +import { logAndExec } from "./utils/process.ts"; + +let PACKAGE_NAME = "react-router"; // Package name used in git tags: react-router@x.x.x +let DIRECTORY_TO_CHECK = "packages/."; +let GITHUB_REPOSITORY = "remix-run/react-router"; +let PR_LABELS_TO_REMOVE = "awaiting release"; +let ISSUE_LABELS_TO_REMOVE = "awaiting release"; +let ISSUE_LABELS_TO_KEEP_OPEN = "πŸ—ΊοΈRoadmap"; +let DRY_RUN = process.argv.includes("--dry-run"); + +if (DRY_RUN) { + console.log("⚠️ Running in dry-run mode -- no changes will be made"); +} + +let { latest, previous } = findBoundingTags(); + +// Find the git comments between the tags +let gitCommits = getCommits(previous, latest); + +// Find any PRs associated with those commits +let prs = await findMergedPRs(gitCommits, latest); + +let plural = prs.length > 1 ? "s" : ""; +debug( + `> found ${prs.length} merged PR${plural} that changed ${DIRECTORY_TO_CHECK}`, +); + +// Comment on PRs + comment on/close linked issues +for (let pr of prs) { + await commentOnPrAndLinkedIssues(pr, latest); +} + +function findBoundingTags() { + // Determine the tags making up the delta from the prior release to this release + let packageRegex = new RegExp(`^${PACKAGE_NAME}@`); + let stdout = logAndExec( + [ + "git tag", + `-l ${PACKAGE_NAME}@*`, + "--sort -creatordate", + "--format %\\(refname:strip=2\\)", + ], + true, + ); + let stableGitTags = stdout + .split("\n") + .map((tag): Tag => ({ raw: tag, clean: tag.replace(packageRegex, "") })); + + let latest = stableGitTags[0]; + let expectedMajor = semver.major(latest.clean); + if (semver.minor(latest.clean) === 0 && semver.patch(latest.clean) === 0) { + expectedMajor -= 1; + } + let previous = stableGitTags.find( + (t) => t.clean !== latest.clean && semver.major(t.clean) === expectedMajor, + ); + invariant( + previous, + `No previous stable release found for prior major version ${expectedMajor}`, + ); + debug(JSON.stringify({ latest, previous })); + + return { previous, latest }; +} + +function getCommits(from: Tag, to: Tag): Array { + let stdout = logAndExec( + [ + "git", + "log", + "--pretty=format:%H", + `${from.raw}...${to.raw}`, + DIRECTORY_TO_CHECK!, + ], + true, + ); + + invariant(stdout.trim() !== "", "No commits found between tags"); + + let gitCommits = stdout.split("\n"); + debug(`> commitCount: ${gitCommits.length}`); + return gitCommits; +} + +async function commentOnPrAndLinkedIssues(pr: MergedPR, latest: Tag) { + let prComment = `πŸ€– Hello there,\n\nWe just published version \`${latest.clean}\` which includes this pull request. If you'd like to take it for a test run please try it out and let us know what you think!\n\nThanks!`; + + debug(`\nPR: https://github.com/${GITHUB_REPOSITORY}/pull/${pr.number}`); + + if (DRY_RUN) { + debug(`[dry-run] would comment on PR #${pr.number}`); + } else { + // Comment on PR + logAndExec(["gh", "pr", "comment", String(pr.number), "--body", prComment]); + + // Remove PR labels + logAndExec([ + "gh", + "pr", + "edit", + String(pr.number), + "--remove-label", + PR_LABELS_TO_REMOVE, + ]); + } + + let promises = pr.issues.map((issue) => commentOnIssue(issue, latest)); + + let results = await Promise.allSettled(promises); + let failures = results.filter((result) => result.status === "rejected"); + if (failures.length > 0) { + throw new Error( + `the following commands failed: ${JSON.stringify(failures)}`, + ); + } +} + +async function commentOnIssue(issue: number, latest: Tag) { + let issueComment = `πŸ€– Hello there,\n\nWe just published version \`${latest.clean}\` which involves this issue. If you'd like to take it for a test run please try it out and let us know what you think!\n\nThanks!`; + + debug(`Issue: https://github.com/${GITHUB_REPOSITORY}/issues/${issue}`); + + let shouldClose = true; + if (ISSUE_LABELS_TO_KEEP_OPEN) { + try { + let labels = getIssueLabels(String(issue)); + console.log("Labels on issue #" + issue + ": " + labels.join(", ")); + shouldClose = !labels.includes(ISSUE_LABELS_TO_KEEP_OPEN); + } catch (err) { + debug(`⚠️ Unable to get labels for issue #${issue}: ${String(err)}`); + } + } + + if (DRY_RUN) { + debug(`[dry-run] would comment on issue #${issue}`); + if (shouldClose) { + debug(`[dry-run] would close issue #${issue}`); + } + debug( + `[dry-run] would remove label "${ISSUE_LABELS_TO_REMOVE}" from issue #${issue}`, + ); + } else { + // Comment on linked issue + logAndExec([ + "gh", + "issue", + "comment", + String(issue), + "--body", + issueComment, + ]); + + // Close linked issue + if (shouldClose) { + logAndExec(["gh", "issue", "close", String(issue)]); + } else { + debug( + `Skipping close of issue #${issue} due to "${ISSUE_LABELS_TO_KEEP_OPEN}" label`, + ); + } + + // Remove labels from linked issue + logAndExec([ + "gh", + "issue", + "edit", + String(issue), + "--remove-label", + ISSUE_LABELS_TO_REMOVE, + ]); + } +} + +type MergedPR = { + number: number; + issues: Array; +}; + +type Tag = { + clean: string; + raw: string; +}; + +function getIssuesClosedViaBody(prBody: string): Array { + if (!prBody) return []; + + /** + * This regex matches for one of github's issue references for auto linking an issue to a PR + * as that only happens when the PR is sent to the default branch of the repo + * https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword + */ + let regex = + /(close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved)(:)?\s#([0-9]+)/gi; + + let matches = prBody.match(regex); + if (!matches) return []; + + let issuesMatch = matches.map((match) => { + let [, issueNumber] = match.split(" #"); + return parseInt(issueNumber, 10); + }); + + return issuesMatch; +} + +type PRResult = { number: number; title: string; url: string; body: string }; + +async function findMergedPRs( + commits: Array, + tag: Tag, +): Promise { + let result = await Promise.all( + commits.map(async (commit) => { + let stdout = logAndExec( + [ + "gh", + "pr", + "list", + "--search", + commit, + "--state", + "merged", + "--json", + "number,title,url,body", + ], + true, + ); + + let parsed = JSON.parse(stdout); + + if (parsed.length === 0) return; + + let pr = (parsed[0] as PRResult) ?? null; + + if (!pr) return; + + if (pr.title.includes(`Release ${tag.clean}`)) { + debug(`skipping release PR ${pr.number}`); + return; + } + + let linkedIssues = getIssuesLinkedToPullRequest(pr.url); + let issuesClosedViaBody = getIssuesClosedViaBody(pr.body); + + debug( + JSON.stringify({ pr: pr.number, linkedIssues, issuesClosedViaBody }), + ); + + let uniqueIssues = new Set([...linkedIssues, ...issuesClosedViaBody]); + + return { + number: pr.number, + issues: [...uniqueIssues], + }; + }), + ); + + return result.filter((pr: any): pr is MergedPR => pr != undefined); +} + +type ReferencedIssueResult = { + data: { + resource: { closingIssuesReferences: { nodes: Array<{ number: number }> } }; + }; +}; + +function getIssuesLinkedToPullRequest(prHtmlUrl: string): Array { + let gql = String.raw; + + let query = gql` + query ($prHtmlUrl: URI!, $endCursor: String) { + resource(url: $prHtmlUrl) { + ... on PullRequest { + closingIssuesReferences(first: 100, after: $endCursor) { + nodes { + number + } + pageInfo { + hasNextPage + endCursor + } + } + } + } + } + `; + + let stdout = logAndExec( + [ + "gh api graphql", + "--paginate", + `--field prHtmlUrl=${prHtmlUrl}`, + `--raw-field query='${trimNewlines(query)}'`, + ], + true, + ); + + debug(stdout); + + let parsed = JSON.parse(stdout); + + return ( + parsed as ReferencedIssueResult + ).data.resource.closingIssuesReferences.nodes.map((node) => node.number); +} + +type IssueLabelsResult = { + data: { + repository: { issue: { labels: { nodes: Array<{ name: string }> } } }; + }; +}; + +function getIssueLabels(number: string): Array { + let gql = String.raw; + + let query = gql` + query ($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + issue(number: $number) { + number + title + url + labels(first: 25) { + nodes { + name + } + } + } + } + } + `; + + let [owner, repo] = GITHUB_REPOSITORY.split("/"); + let stdout = logAndExec( + [ + "gh api graphql", + `--field owner=${owner}`, + `--field repo=${repo}`, + `--field number=${number}`, + `--raw-field query='${trimNewlines(query)}'`, + ], + true, + ); + + debug(stdout); + + let parsed = JSON.parse(stdout); + + return (parsed as IssueLabelsResult).data.repository.issue.labels.nodes.map( + (node) => node.name, + ); +} + +function trimNewlines(str: string) { + return str.replace(/[\n\r]+/g, "").replace(/ +/g, " "); +} + +function debug(message: string) { + console.debug(message); +} + +export function invariant(value: boolean, message?: string): asserts value; +export function invariant( + value: T | null | undefined, + message?: string, +): asserts value is T; +export function invariant(value: any, message?: string) { + if (value === false || value === null || typeof value === "undefined") { + console.warn("Test invariant failed:", message); + throw new Error(message); + } +} diff --git a/scripts/utils/git.ts b/scripts/utils/git.ts index eec179fa66..2d6bedd767 100644 --- a/scripts/utils/git.ts +++ b/scripts/utils/git.ts @@ -129,3 +129,38 @@ export function getRemoteTagTarget(tag: string): string | null { export function tagExists(tag: string): boolean { return getLocalTagTarget(tag) !== null || getRemoteTagTarget(tag) !== null; } + +/** + * Gets the git SHA of the commit that last modified a file. + * Falls back to HEAD if the file has no git history (e.g., untracked or newly staged). + */ +export function getFileSha(filePath: string): string { + let normalizedPath = filePath.replaceAll("\\", "/"); + try { + let sha = execGit(["log", "-1", "--format=%H", "--", normalizedPath]); + if (sha) return sha; + } catch {} + return execGit(["rev-parse", "HEAD"]); +} + +/** + * Gets the subject line (first line) of a commit message for a given SHA. + */ +export function getCommitSubject(sha: string): string { + return execGit(["log", "-1", "--format=%s", sha]); +} + +/** + * Parses a GitHub PR number from a commit subject line. + * Supports squash merge format "description (#123)" and + * merge commit format "Merge pull request #123 from ...". + */ +export function parsePrNumber(subject: string): number | null { + let squashMatch = subject.match(/\(#(\d+)\)\s*$/); + if (squashMatch) return parseInt(squashMatch[1], 10); + + let mergeMatch = subject.match(/^Merge pull request #(\d+)/i); + if (mergeMatch) return parseInt(mergeMatch[1], 10); + + return null; +} diff --git a/scripts/utils/process.ts b/scripts/utils/process.ts index ef5b6a121d..0fe4ae7f0e 100644 --- a/scripts/utils/process.ts +++ b/scripts/utils/process.ts @@ -15,7 +15,27 @@ export function getRootDir(): string { return process.cwd(); } -export function logAndExec(command: string, captureOutput = false): string { +export function logAndExec(args: string[], captureOutput?: boolean): string; +export function logAndExec(command: string, captureOutput?: boolean): string; +export function logAndExec( + commandOrArgs: string | string[], + captureOutput = false, +): string { + let command: string; + if (typeof commandOrArgs === "string") { + command = commandOrArgs; + } else { + command = [ + commandOrArgs[0], + // Quote each argument + ...commandOrArgs + .slice(1) + .map((arg) => + arg.startsWith("-") ? arg : `'${arg.replaceAll("'", "'\\''")}'`, + ), + ].join(" "); + } + console.log(`$ ${command}`); if (captureOutput) { return cp.execSync(command, { stdio: "pipe", encoding: "utf-8" }).trim();