diff --git a/.changeset/check-reworded-errors.md b/.changeset/check-reworded-errors.md new file mode 100644 index 0000000..bc6656e --- /dev/null +++ b/.changeset/check-reworded-errors.md @@ -0,0 +1,5 @@ +--- +'uptide': patch +--- + +`check` no longer reports a pre-existing type error as a breaking site when the upgrade only changes its wording, such as a type the message prints in full. An error at the same place with a different reason is still reported. diff --git a/.changeset/react-router-seven.md b/.changeset/react-router-seven.md new file mode 100644 index 0000000..941dd54 --- /dev/null +++ b/.changeset/react-router-seven.md @@ -0,0 +1,5 @@ +--- +'uptide': minor +--- + +Add a verified React Router 6 → 7 migration pack, which also runs for applications that declare only `react-router-dom`. It moves `react-router-dom` imports to `react-router`. It reports, with a guide for the agent, the rest of the upgrade: the removed `future` prop and router option, `react-router-dom/server`, lowercase `formMethod` comparisons and `json`/`defer` calls (found by syntax where the compiler is blind), the other removed exports, and `generatePath` calls that pass `null` for a required parameter. The six v6 future flags that become defaults are reported as decisions where the repository's router setup has not enabled them. React Router 7 → 8 is a separate upgrade. diff --git a/README.md b/README.md index 8cb30df..dd2e107 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,7 @@ verified with two or more public repositories and no false positive. The contrac | `lucide-react` | 0.x → 1.x | 100% | 100% | tradeblocks-org/tradeblocks, spizeck/SFPCA, cubicsteak/keep | verified | | `next` | 15.x → 16.x | 100% | 85% | sambecker/exif-photo-blog, stoczec/wortkarte | verified | | `react` | 18.x → 19.x | 100% | 69% | excalidraw/excalidraw, tldraw/tldraw | verified | +| `react-router` | 6.x → 7.x | 100% | 88% | Arize-ai/phoenix, getlago/lago-front | verified | | `@sentry/browser` | 10.x → 11.x | 100% | 100% | Automancer-Ltd/automancer-site, sbomify/sbomify | verified | | `@sentry/nextjs` | 10.x → 11.x | 100% | 100% | cohstats/coh3-stats, xdoubleu/tools.xdoubleu.com, maximunited/yomu | verified | | `@sentry/react` | 10.x → 11.x | 100% | 100% | owine/youth-activity-scheduler, successible/cleanslate | verified | diff --git a/docs/packs.md b/docs/packs.md index 1ad07b7..9df385a 100644 --- a/docs/packs.md +++ b/docs/packs.md @@ -235,6 +235,16 @@ executed. They are kept in `~/.cache/uptide/ground-truth` (`UPTIDE_GROUND_TRUTH_ `pack test --offline` runs without network after the first time. `pnpm packs:fetch` fills the cache for every pack (and `--corpus` for the repositories in `fixtures/corpus.json`). +The cache is large: each entry is a checkout with its dependencies installed. Measured on +2026-10-14, per pack: ai 2.1 GB, lucide-react 3.6, next 1.4, react 4.3, @sentry/browser 0.7, +@sentry/nextjs 4.3, @sentry/react 1.4, stripe 2.1, vitest 2.1, zod 2.2. Every pack at once is +about 24 GB, more than the 14 GB of disk a standard GitHub-hosted Linux runner documents, and +it grows with each pack. Keep it bounded the same way locally and on a runner: score one pack +at a time and evict its repositories before the next (`rm -rf +"$UPTIDE_GROUND_TRUTH_CACHE/__@"`, one directory per `ground-truth.json` +entry), so the cache holds at most one pack's ground truth, under 5 GB today. Registry +tarballs, kept beside it in `~/.cache/uptide`, add about 1 GB. + ## Scoring: `uptide pack test` ```sh diff --git a/packages/core/src/adapters/typescript/compile.test.ts b/packages/core/src/adapters/typescript/compile.test.ts index 6925019..0735423 100644 --- a/packages/core/src/adapters/typescript/compile.test.ts +++ b/packages/core/src/adapters/typescript/compile.test.ts @@ -119,6 +119,105 @@ describe('Signal B: compile against the target version', () => { ]); }); + it('does not report a pre-existing error as new because the upgrade rewords it', async () => { + // `Mode` widens in v2: the same broken assignment, at the same place, names one more + // member. It was broken before the upgrade, and the upgrade did not break it. + const dir = consumerCopy((d) => + writeFileSync( + join(d, 'src/mode.ts'), + "import type { Mode } from 'synthetic';\nexport const pick = (m: Mode): 'loose' => m;\n", + ), + ); + const signal = await compileAgainstTarget({ dir }, 'synthetic', join(ROOT, 'synthetic-v2')); + expect(signal.baselineErrors).toBe(1); + expect(signal.diagnostics.map((d) => `${d.file}:${d.line} ${d.code}`)).toEqual([ + 'src/chains.ts:6 2554', + ]); + }); + + it('reports a different reason at the place of a pre-existing error', async () => { + // `shapes` 2 requires one more property: the literal that already missed `a` and `b` now + // misses `c` too. Same file, code and position as the baseline error, and a new break. + const root = mkdtempSync(join(tmpdir(), 'uptide-same-place-')); + const release = (version: string, props: string): string => { + const pkg = join(root, 'shapes', version); + mkdirSync(pkg, { recursive: true }); + writeFileSync( + join(pkg, 'package.json'), + JSON.stringify({ name: 'shapes', version, types: 'index.d.ts' }), + ); + writeFileSync(join(pkg, 'index.d.ts'), `export interface Opts { ${props} }\n`); + return pkg; + }; + release('1.0.0', 'a: string; b: string;'); + const target = release('2.0.0', 'a: string; b: string; c: string;'); + const dir = join(root, 'repo'); + mkdirSync(join(dir, 'src'), { recursive: true }); + cpSync(join(root, 'shapes', '1.0.0'), join(dir, 'node_modules/shapes'), { recursive: true }); + writeFileSync( + join(dir, 'package.json'), + JSON.stringify({ name: 'repo', dependencies: { shapes: '1.0.0' } }), + ); + writeFileSync( + join(dir, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { strict: true, noEmit: true, skipLibCheck: true, types: [] }, + include: ['src'], + }), + ); + writeFileSync( + join(dir, 'src/index.ts'), + "import type { Opts } from 'shapes';\nexport const opts: Opts = {};\n", + ); + const signal = await compileAgainstTarget({ dir }, 'shapes', target); + expect(signal.baselineErrors).toBe(1); + expect(signal.diagnostics.map((d) => `${d.file}:${d.line} ${d.code} ${d.message}`)).toEqual([ + "src/index.ts:2 2739 Type '{}' is missing the following properties from type 'Opts': a, b, c", + ]); + }); + + it('reports a new error at the place of a pre-existing one that ends on the same reason', async () => { + // `opts` already breaks on property `a`; `shapes` 2 breaks `b` too and checks it first. Same + // file, code, position and last message (`Type 'string' is not assignable to type 'number'`), + // about another property: a new break, which only the property the chain names tells apart. + const root = mkdtempSync(join(tmpdir(), 'uptide-same-reason-')); + const release = (version: string, props: string): string => { + const pkg = join(root, 'shapes', version); + mkdirSync(pkg, { recursive: true }); + writeFileSync( + join(pkg, 'package.json'), + JSON.stringify({ name: 'shapes', version, types: 'index.d.ts' }), + ); + writeFileSync(join(pkg, 'index.d.ts'), `export interface Opts { ${props} }\n`); + return pkg; + }; + release('1.0.0', 'a: number; b: string;'); + const target = release('2.0.0', 'b: number; a: number;'); + const dir = join(root, 'repo'); + mkdirSync(join(dir, 'src'), { recursive: true }); + cpSync(join(root, 'shapes', '1.0.0'), join(dir, 'node_modules/shapes'), { recursive: true }); + writeFileSync( + join(dir, 'package.json'), + JSON.stringify({ name: 'repo', dependencies: { shapes: '1.0.0' } }), + ); + writeFileSync( + join(dir, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { strict: true, noEmit: true, skipLibCheck: true, types: [] }, + include: ['src'], + }), + ); + writeFileSync( + join(dir, 'src/index.ts'), + "import type { Opts } from 'shapes';\nconst value = { a: 'x', b: 'y' };\nexport const opts: Opts = value;\n", + ); + const signal = await compileAgainstTarget({ dir }, 'shapes', target); + expect(signal.baselineErrors).toBe(1); + expect(signal.diagnostics.map((d) => `${d.file}:${d.line} ${d.code} ${d.message}`)).toEqual([ + expect.stringMatching(/^src\/index\.ts:3 2322 .*Types of property 'b' are incompatible/s), + ]); + }); + it('scopes both baseline and target roots, following imports without loading unrelated files', async () => { const dir = consumerCopy((d) => { writeFileSync(join(d, 'src/root.ts'), "import './chains';\n"); diff --git a/packages/core/src/adapters/typescript/compile.ts b/packages/core/src/adapters/typescript/compile.ts index 3f79502..7f65a9d 100644 --- a/packages/core/src/adapters/typescript/compile.ts +++ b/packages/core/src/adapters/typescript/compile.ts @@ -42,8 +42,10 @@ import { resolvePackageDir } from './usages.js'; * here", not "because `items[].quantity` was removed". * * Both checks always run. Only diagnostics present in the overlay and absent from the - * baseline count (matched on file, code and message; positions are not part of the key), - * so a repository with pre-existing errors still gets the upgrade's errors. The overlay + * baseline count (matched on file, code and message, so a moved line still matches; or on + * file, code, position and the same chain of messages up to the types it prints, so an error + * the upgrade only rewords still matches), so a repository with pre-existing errors still gets the upgrade's + * errors. The overlay * is skipped only when the baseline is structurally broken: an invalid tsconfig, or most * files failing to resolve their imports. * @@ -104,6 +106,39 @@ function keyOf(d: ts.Diagnostic, repoDir: string): string { return `${file}|${d.code}|${messageOf(d).replace(/\s+/g, ' ').trim()}`; } +/** + * Every message of the chain, in order, with each type the compiler prints masked: what the + * error says about the code, without how it spells the types on the way. A quoted plain name + * (a property, a parameter, a named type) is kept, so the chain still says which member fails; + * a printed type expression (`'{ a: string; }'`, `'() => void'`) is what an upgrade rewords. + */ +function shapeOf(d: ts.Diagnostic): string { + const parts: string[] = []; + const walk = (chain: string | ts.DiagnosticMessageChain): void => { + const text = typeof chain === 'string' ? chain : chain.messageText; + parts.push( + text.replace(/'([^']*)'/g, (quoted, inner: string) => + /^[\w$.#"-]+$/.test(inner) ? quoted : "'…'", + ), + ); + if (typeof chain !== 'string') for (const next of chain.next ?? []) walk(next); + }; + walk(d.messageText); + return parts.join(' ').replace(/\s+/g, ' ').trim(); +} + +/** + * The same error at the same place: both programs check the same source text, so a position + * names one construct, and the same chain of messages with only printed types differing is the + * same complaint about it. An error the upgrade only rewords (a widened type in the elaborated + * assignment) was there before the upgrade, and the upgrade did not break that line. A chain + * that names another member, or another reason, at the same place is a different error, and is + * reported. + */ +function placeOf(d: ts.Diagnostic, repoDir: string): string { + return `${relative(repoDir, d.file?.fileName ?? '')}|${d.code}|${d.start ?? -1}|${shapeOf(d)}`; +} + function toDiagnostic(d: ts.Diagnostic, file: ts.SourceFile, repoDir: string): CompileDiagnostic { const start = d.start ?? 0; const from = file.getLineAndCharacterOfPosition(start); @@ -879,6 +914,7 @@ export async function compileAgainstTargets( }; } const known = new Set(baseline.map((d) => keyOf(d, repo.dir))); + const knownPlaces = new Set(baseline.map((d) => placeOf(d, repo.dir))); const overlay = mkdtempSync(join(tmpdir(), 'uptide-overlay-')); const deps = newLinks(); // Group members are already linked; nothing may fetch another version of them. @@ -899,7 +935,12 @@ export async function compileAgainstTargets( const overlaid = program.getSourceFile(file.fileName); if (!overlaid) continue; for (const d of program.getSemanticDiagnostics(overlaid)) { - if (isRepoError(d, repo.dir) && !known.has(keyOf(d, repo.dir))) fresh.push({ d, overlaid }); + if ( + isRepoError(d, repo.dir) && + !known.has(keyOf(d, repo.dir)) && + !knownPlaces.has(placeOf(d, repo.dir)) + ) + fresh.push({ d, overlaid }); } } // Causes are traced once every new diagnostic is known: a declaration that fails itself is a cause. diff --git a/packages/core/src/packs/react-router/RESEARCH.md b/packages/core/src/packs/react-router/RESEARCH.md new file mode 100644 index 0000000..8cd894e --- /dev/null +++ b/packages/core/src/packs/react-router/RESEARCH.md @@ -0,0 +1,260 @@ +# React Router 6 → 7: evidence and verification limits + +Read date for every source below: **2026-10-14**. Scope: `react-router` (and `react-router-dom`, +released in lockstep) from 6.x to 7.x, defaulting to 7.18.4. The queue asked for 6 → 8; the +evidence below says that is two upgrades, so this pack is 6 → 7 and `packs/queue.json` carries +7 → 8 as its own entry. No codemod was downloaded or run; no `npx` was used. reactrouter.com +and the codemod registry answer 403 through this environment's proxy, so every document is +cited from the repository at a pinned release commit (the npm tarballs ship the same files). + +## Source ledger + +| Tag | Commit | +| --- | --- | +| `react-router-dom@6.30.6` (latest 6.x) | `dead4962d6c33e962f266af910b7a09e3ece30cf` | +| `react-router@7.0.0` | `e8e3b004d94a949372e77ce97a543bff5bc6fb89` | +| `react-router@7.18.4` (latest 7.x) | `1b1e0b0e79b21692ce907933475233babdd16e3e` | +| `react-router@8.0.0` | `36122b36a8c183dfbe4bcd278c585a80617c921e` | +| `react-router@8.4.0` (latest 8.x) | `7ccdcecdd944e15be0cd0dcb702b86c9af1214a8` | + +- [Upgrading from v6](https://github.com/remix-run/react-router/blob/1b1e0b0e79b21692ce907933475233babdd16e3e/docs/upgrading/v6.md) + (7.18.4; same text at [7.0.0](https://github.com/remix-run/react-router/blob/e8e3b004d94a949372e77ce97a543bff5bc6fb89/docs/upgrading/v6.md)). + It does not exist in the v8 docs: deleted by `0b66c74b1` "v8 Docs Prep" (2026-06-17). +- [v6 future flags](https://github.com/remix-run/react-router/blob/dead4962d6c33e962f266af910b7a09e3ece30cf/docs/upgrading/future.md) (6.30.6). +- [Upgrading from v7](https://github.com/remix-run/react-router/blob/7ccdcecdd944e15be0cd0dcb702b86c9af1214a8/docs/upgrading/v7.md) (8.4.0). +- [react-router CHANGELOG](https://github.com/remix-run/react-router/blob/1b1e0b0e79b21692ce907933475233babdd16e3e/packages/react-router/CHANGELOG.md), + 7.0.0 (Major Changes) through 7.18.4; 8.0.0 at [8.4.0](https://github.com/remix-run/react-router/blob/7ccdcecdd944e15be0cd0dcb702b86c9af1214a8/packages/react-router/CHANGELOG.md). +- [react-router-dom CHANGELOG](https://github.com/remix-run/react-router/blob/1b1e0b0e79b21692ce907933475233babdd16e3e/packages/react-router-dom/CHANGELOG.md) + (7.0.0; the package directory is gone at 8.0.0) and its + [7.0.0 entry point](https://github.com/remix-run/react-router/blob/e8e3b004d94a949372e77ce97a543bff5bc6fb89/packages/react-router-dom/index.ts): + `export * from "react-router"` plus `HydratedRouter`, `RouterProvider` from `react-router/dom`. +- [Root CHANGELOG](https://github.com/remix-run/react-router/blob/1b1e0b0e79b21692ce907933475233babdd16e3e/CHANGELOG.md) (7.0.0 and 8.0.0 release notes). +- npm registry, 2026-10-14: `react-router` latest 8.4.0, `version-7` 7.18.4, `version-6` 6.30.6; + `react-router-dom` latest **7.18.4** (no 8.x); `@remix-run/router` stops at 1.23.4. + +## One upgrade or two + +Two: 6 → 7, then 7 → 8. + +1. There is no 6 → 8 guide. The v6 guide ends at v7 ("Congratulations, you're now on v7!") and + was removed from the v8 docs; the v8 guide is "Upgrading from v7" and starts with "update to + the latest minor version of v7.x". +2. Each guide's premise is the previous major's flags adopted on that major: "The v7 upgrade has + no breaking changes if you have enabled all future flags" (v6 guide); "By adopting all opt-in + APIs and future flags while still on v7, you should be able to update to React Router v8 with + minimal changes" (v7 guide). +3. The six v6 flags can be adopted one at a time only on 6.x: 7.0.0 removes the `future` props + and makes the option keys inert. A jump to 8 applies them all at once, unflagged. +4. Some v8 fixes need 7.x APIs: `match.loaderData` exists from 7.8.0, `RouterContextProvider` + from 7.9.0. +5. `react-router-dom` has no 8.x: v7 keeps it as a re-export to ease the v6 upgrade, v8 removes it. + +7 → 8 (React 19.2.7 and Node 22.22 minimums, ESM only, middleware always on and `context` typed +`Readonly`, `UIMatch.data` → `loaderData`, `hasErrorBoundary` removed, +`react-router-dom` removed, stale `future` keys rejected from 8.4) is queued separately. + +## Classified changes (6 → 7) + +**M** = mechanical rule (rewrites a reported site, safe whatever the surrounding code is); +**A** = assisted rule (a guide for the agent; options go to "Decisions for you"). A rule whose +edit is safe but that no ground-truth repository exercises yet stays **A** until one does +(marked *A, rewrite ready*): detection unchanged, the edit unit-tested but not wired; +**B** = behavior note (`decision`, scoped by `applies()` with evidence from the inventory); +**N** = not compiler-visible and no action, or not applicable to v6 code. + +| Change | Compiler | Class and treatment | Source | +| --- | --- | --- | --- | +| `react-router-dom` folded into `react-router`; v7 `react-router-dom` re-exports it, v8 removes it | no (on 7) | M `dom-imports` (deprecated): the module specifier only; declined when a test mocks `react-router-dom` by name (jest/vi mock, requireActual/importActual, `__mocks__/react-router-dom`), since the mock would stop applying | guide "Upgrade to v7", root CHANGELOG 7.0.0 | +| `RouterProvider`/`HydratedRouter` for DOM apps come from `react-router/dom` (wired to `flushSync`); the main entry's is for non-DOM code and tests | no | A `dom-entry-imports` (deprecated): also namespace and `export *` imports of `react-router-dom`. Not mechanical: `react-router/dom` needs `moduleResolution` bundler/node16/nodenext (Phoenix changed its tsconfig in the same commit) | guide, CHANGELOG 7.0.0 (#11851) | +| `react-router-dom/server` removed from the exports map (even with `react-router-dom` 7); its 7 names are in `react-router` | TS2307 | *A, rewrite ready* `dom-server-entry`: the specifier only | exports map of `react-router-dom@7`; not in the guide | +| `future` prop removed from BrowserRouter, HashRouter, (unstable_)HistoryRouter, MemoryRouter, Router, StaticRouter, RouterProvider | TS2322 | *A, rewrite ready* `future-prop`: the edit removes an inline object of `v7_*` boolean literals only, the router resolved through its import (aliases included); a variable (Lago's shared `ROUTER_FUTURE_FLAGS`) or computed value would stay with the agent, who removes what only fed it. Not wired: the final truth's only sites (Lago's) pass a variable | CHANGELOG 7.0.0 (flag removals) | +| The same prop passed through untyped props (`createElement(actual.BrowserRouter, { future, ...props })` in a jest mock) | no: the props are untyped | A `future-prop-untyped` (deprecated): syntactic detect of `createElement` on a router (imported, aliased or `x.BrowserRouter`) with a `future` key; v7 ignores the prop, so the site compiles and does nothing (Lago's own comment says so) | CHANGELOG 7.0.0 | +| `future` option of createBrowserRouter/HashRouter/MemoryRouter/StaticRouter/StaticHandler | no: v7 types it `{}` (TS2353 only from 8.4) | *A, rewrite ready* `future-option` (deprecated): inline v6 flag literals only; the options argument goes when it held only `future` | CHANGELOG 7.0.0, 8.4.0 | +| `json` / `JsonFunction` removed | TS2305 at the import; calls through the failed import are `any` | A `removed-json`: the import from the compiler, each call from syntax (`detect`, binding-aware, shadowed locals excluded). Options: return the value, `data(value, init)`, or `Response.json` | guide "json/defer", CHANGELOG 7.0.0 (#12146) | +| `defer`, `AbortedDeferredError`, `TypedDeferredData`, `UNSAFE_DeferredData`, `UNSAFE_DEFERRED_SYMBOL` removed | TS2305; calls and `instanceof` invisible | A `removed-defer`, same detection; raw promises are not aborted on navigation | guide, CHANGELOG 7.0.0 (#11744) | +| `Hash`, `Pathname`, `Search` (aliases of string), `V7_FormMethod`, `FutureConfig`, `UNSAFE_useRouteId` removed | TS2305/2724/2459 | A `removed-exports` | CHANGELOG 7.0.0 (#11840), export diff | +| `generatePath` param types (7.14.2): a required `:param` takes `string`, `null` is rejected (it threw at runtime); optional params may be omitted, extra keys allowed | TS2322 at the param | A `generate-path-params`: claims the compiler's `null … not assignable to type 'string'` at the param (the message never names generatePath) and the type diff of `generatePath`. Options (guard before building the path, an optional segment, or the proven value) are a decision; no non-null assertion or empty-string fallback | CHANGELOG 7.14.2 | +| `formMethod` always uppercase (`v7_normalizeFormMethod`) | TS2367/2678/2820 only where typed | *A, rewrite ready* `form-method-case`: comparisons and `case` clauses of a `.formMethod` read with a lowercase literal, in files importing React Router; found by syntax too, because through `any`/`string` the compiler is blind. `
` is untouched | guide, CHANGELOG 7.0.0 (#11697) | +| `` removed (`v7_partialHydration`) | TS2322 | A `fallback-element` → `HydrateFallback`/`hydrateFallbackElement`; B `partial-hydration` | guide, CHANGELOG 7.0.0 (#11725) | +| `unstable_dataStrategy`, `unstable_patchRoutesOnNavigation` (pre-6.28 names) | TS2353 | A `stabilized-options`; B `data-strategy` | CHANGELOG 6.28.0, 7.0.0 | +| `v7_relativeSplatPath` default | no | B `relative-splat-path`: applies when a multi-segment splat path exists and no setup enables the flag; options (extra `..` or split route) are a decision | guide | +| `v7_startTransition` default | no | B `start-transition`: routers without the flag | guide | +| `v7_fetcherPersist` default | no | B `fetcher-persist`: `useFetcher(s)` without the flag | guide | +| `v7_normalizeFormMethod` default | partly | B `normalize-form-method` plus the rule above | guide | +| `v7_skipActionErrorRevalidation` default | no | B `skip-action-error-revalidation`: route actions without the flag; options (validate first, or `shouldRevalidate`) are a decision | guide | +| navigate/submit/fetcher.load/submit/revalidate return promises | only where typed `void` | B `navigation-promises`: when an ESLint config enables no-floating/no-misused-promises | CHANGELOG 7.0.0 (#11521) | +| React 18 and Node 20 minimums | no | B `platform-minimums`: package.json react/engines, `.nvmrc`, `.node-version` | guide, CHANGELOG 7.0.0 | +| `@remix-run/router` folded in, no v7 release (`Router` → `DataRouter`, `Action` → `NavigationType`, history factories internal) | TS2305 when imported | B `remix-router-package`: declared or imported | CHANGELOG 7.0.0 (#11840) | +| `react-router-dom` dependency kept only as a re-export | no | B `dom-package`: declared in a package.json | react-router-dom 7.0.0 entry | +| `useFetcher` should become `useFetcher`; `useLoaderData` is generic and returns `any` by default | no (still compiles) | N; optional adoption (Phoenix did it; excluded from its ground truth) | CHANGELOG 7.0.0 (#12180) | +| `v7_prependBasename`, `v7_throwAbortReason`, Remix-only renames (`createRemixStub`, `RemixContext`, `installGlobals`) | no | N for v6 code | CHANGELOG 7.0.0 | +| `react-router-native`, `react-router-dom-v5-compat` have no v7 | n/a | N, out of scope (the pack does not remove packages) | root CHANGELOG 7.0.0 | +| 7.x minors: 7.5.1 static handler loaderData, 7.8.0 `UIMatch.data` deprecated, 7.15.0 `useTransitions`, 7.18.0 data-mode SSR error serialization | some | `also-review` decision; generic compiler findings | CHANGELOG 7.5.1–7.18.0 | + +No framework mode is introduced and no route structure is changed: the instructions forbid +both, and no rule edits route definitions. + +### Mechanical boundaries + +- `dom-imports` changes only the specifier of an import or re-export whose names all live in + `react-router`. It is `deprecated` (v7 still works with `react-router-dom`), so `fix` applies it + with `--include-deprecated`. The release group keeps both packages at the same exact version, + so a file that still imports `react-router-dom` shares the router context with one that moved. +- Rewrites ready but not wired until ground truth exercises them: `future-prop` (removes only + v6 flag literals, which v7 ignores whatever their value, the behavior they stood for being in + the flag notes; a leading comment on the property declines it). The two inline-literal + upgrades seen, wire-webapp and OpenMetadata, are out of the truth under the parent rule below, + and Lago's four sites pass a variable. Also `dom-server-entry` (the + specifier), `future-option` (the same literal-only removal as `future-prop`), + `form-method-case` (uppercase the literal compared with a `.formMethod` read: v7 never produces + lowercase). Their detection runs; `react-router.test.ts` keeps each edit tested. +- `json` and `defer` were never mechanical. They, the RouterProvider entry, `fallbackElement`, + `generatePath` params and stabilized options all have options or cross-file consequences: + assisted, and the agent is told to put its choice in decisions (the SDK agent prompt already + turns guide options into "Decisions for you"). + +## Release group and peers + +`react-router-dom` is the declared `releaseGroup` member: it is published at every +`react-router` 7 version and pins it exactly (7.x CHANGELOG; its 7.0.0 entry point re-exports +`react-router`). It is also the pack's **alternate lead** (#134): most v6 applications declare +only `react-router-dom`, so `check react-router` and `check react-router-dom` both run this pack +there, analyzing `react-router` at the version `react-router-dom` installs (hoisted, or beside it +in a pnpm store), and `fix` declares `react-router` at the target beside it before moving both. +`react-router-dom` is moved, not removed: v7 keeps it as a re-export and code still imports it; +removal belongs to the 7 → 8 upgrade (`dom-package` says so). Every upgrade drafted moved it with +the lead (Lago, and the excluded wire-webapp and OpenMetadata, to the same version; Phoenix +removed it). `@types/react-router-dom` is a v5-era stub nothing at v6 needs and is not +declared. `@react-router/*` packages are framework-mode tooling with no 6.x release, so no v6 +repository has one installed: nothing to declare. `requiresBuild` is not set: the changes the +compiler cannot see are runtime behavior (flags), not build contracts; the one build-visible +change, `react-router/dom` under `moduleResolution: node`, is a type error tsc reports. + +## Independent ground truth + +**Final truth: Arize-ai/phoenix and getlago/lago-front.** Both are public, at the parent of a +merged upgrade; installs use their lockfiles with scripts off. Expectations were drafted with +`pnpm packs:truth` (repository TypeScript, target linked) and the upgrade diff, then reviewed by +hand before the pack was scored against them; no expected finding was changed after scoring. +`ground-truth.json`'s `$comment` records every inclusion and exclusion. + +| Repository | Declares | Parent | Upgrade | Versions | Expected | +| --- | --- | --- | --- | --- | --- | +| [Arize-ai/phoenix](https://github.com/Arize-ai/phoenix/pull/6896) (`app/`, pnpm, TS 5.4.5) | both | `4d6c1dfb01e9406cf769efab14f0350ea2d0a0f8` | `3e9dc43bcc686e8ff3d57a028a9fcc15f3935490` | 6.25.1 → 7.4.0 | 29 `dom-imports`, 1 `dom-entry-imports` (no compiler diagnostic) | +| [getlago/lago-front](https://github.com/getlago/lago-front/commit/6055a5a3c8076522235fe2da452a209e99af8541) (pnpm, TS 6.0.3) | react-router-dom only | `84aa05d535edc326a4d104923e4e7293d1be6646` | `6055a5a3c8076522235fe2da452a209e99af8541` | 6.30.6 → 7.18.3 | 6 `future-prop` (4 typed, 2 untyped in a deleted jest mock), 8 `generate-path-params`, 1 `removed-exports` (`FutureConfig`), 3 `generic` (TS1192 default import; two test assertions v7 behavior breaks) | + +Lago is the usual v6 setup, which only the alternate lead makes analyzable, and holds every +expectation under a breaking rule. Exclusions within the two, decided before scoring: + +- Phoenix's `useLoaderData() as T` → `useLoaderData()` refactor (v7 compiles the + casts), its vite changes; merged imports where an existing `react-router` line only absorbed a + moved name (the moved `react-router-dom` line is the site). +- Lago: renamed test titles and added assertions, a jest filter for a warning v7 no longer emits, + Cypress flow edits, callers of the deleted mock helper and imports of the deleted flags, wallet + `as string` casts the refactor removed though they compile, and four TS7006 in + `InvoiceOverview.tsx` the draft reported and the upgrade never touched (linked-environment + React types, as in the Next pack's RESEARCH). + +### Parents that do not type-check + +One rule, applied to every drafted repository before scoring: run the parent's **own project +compile** (its type-check script and tsconfig, not a bare `tsc`). A parent whose compile passes +is kept. A parent whose compile fails is excluded, as the Next pack excluded its broken parents, +unless every error comes from an unbuilt workspace package that the upgrade does not touch; that +is shown by building the package as the repository's own build does and compiling again, and +the repository is kept with a note saying so. + +| Repository | Project compile at the parent | Errors | Outcome | +| --- | --- | ---: | --- | +| Arize-ai/phoenix | `tsc --noEmit` in `app/` (its `typecheck` script) | 0 | **kept** | +| getlago/lago-front | `node scripts/check-types.js` (`tsc --noEmit --incremental`, root `tsconfig.json`) | 188 | **kept, confined**: every error is in a file importing `lago-design-system`, whose `dist/types` is not built at a fresh install (TS2307 and its cascade; 0 elsewhere). The package is not part of the upgrade. With its declarations emitted as its build does (`tsc --build tsconfig.build.json` in `packages/design-system`), the parent compiles with 0 errors, the upgrade commit with 0, and the parent's code against the upgrade's dependencies reports exactly the 14 compiler sites in the truth (same files, lines and codes, none missing, none extra). Five files the upgrade edits carry design-system errors while it is unbuilt; that changes no expectation | +| wireapp/wire-webapp ([#22299](https://github.com/wireapp/wire-webapp/pull/22299), 6.30.6 → 7.18.0) | `tsc --project apps/webapp/tsconfig.type-check.json --noEmit` (its Nx `type-check` target) | 1979 | **excluded**: besides eight unbuilt workspace libraries (`@wireapp/api-client` 846, `react-ui-kit` 316, …), 140 errors come from `Components/icon`, a gitignored module generated by `bin/generate_react_icons.ts`, not a workspace package; and `testUtil.tsx`, which the upgrade edits, has 5 errors | +| open-metadata/OpenMetadata ([#31641](https://github.com/open-metadata/OpenMetadata/pull/31641), 6.30.4 → 7.18.1) | `yarn tsc:check` (`tsc --noEmit`, the UI directory's tsconfig) | 1481 | **excluded**: beyond the unbuilt `@openmetadata/ui-core-components`, it reports errors unrelated to any package build (TS6133 unused locals, TS2556 spread arguments, `ThreadType` TS2322), and `AppRoot.tsx`, which the upgrade edits, has a TS2307 | + +Both excluded repositories were drafted and reviewed before the rule was set; their review +still informs the pack (below) but they are not scored. OpenMetadata's sixteen TS2322 in +`EntityVersionPage.component.tsx` were checked with a real scripts-off install of +`react-router-dom@7.18.1` and the repository's own `tsc`: the same sixteen exist at the parent and +only their text changes (`backHandler: () => void` becomes `() => void | Promise` with v7's +navigate). Keyed on the message, the draft listed them as new and `check` reported one as a +breaking site; that is fixed in the engine by #141 (stacked under this PR): an error at the same +file, code and position whose chain of messages is unchanged once the type expressions it +prints are masked is pre-existing; a chain naming another member there is still reported. + +### Screened out + +- **jeffvli/feishin** `2504c0ec…` → `bf75dec0…` (6.30.1 → 7.9.4, ~40 import moves and a HashRouter + `future` prop): its lockfile is out of sync at the parent and the unfrozen install needs a + git-hosted dependency from codeload.github.com, which this environment's egress policy refuses + (403). Worth adding where that host is reachable. +- **medusajs/medusa** `a2d800c8…` → `5105fec2…` (`json` ×9 and `defer` ×1 removed, which would + exercise `removed-json`/`removed-defer`): the upgrade is one 79-file commit with a Vite 7 + migration across the monorepo, and the dashboard has no lockfile of its own. A candidate for + the next round; the two rules stay assisted meanwhile. +- **twentyhq/twenty** (3 sites), **Kong/insomnia** (one `defer`; git-hosted dependencies), + **coder**, **actual** (git dependency), **shlink**, **keycloak**, **medplum**: few or no + compiler-visible sites, or mixed/unfetchable upgrades. +- marmelab/atomic-crm #94 and react-admin #10440: version bumps with no router code changes, + mixed with dozens of unrelated bumps; mui/toolpad #4553: docs workspace only. + +## Measured `pack test` + +`pnpm uptide pack test react-router --write` on the final truth (Phoenix and Lago): +**verified** (2 public repositories, 0 breaking false positives, fixtures 9 cases and 29 sites +passing). + +| Rule / scoring group | Precision | Recall | TP | FP | FN | +| --- | ---: | ---: | ---: | ---: | ---: | +| dom-entry-imports | 100% | 100% | 1 | 0 | 0 | +| dom-imports | 8% | 100% | 29 | 336 | 0 | +| future-prop | 100% | 67% | 4 | 0 | 2 | +| future-prop-untyped | 0% | n/a | 0 | 2 | 0 | +| generate-path-params | 100% | 100% | 8 | 0 | 0 | +| generic | 100% | 33% | 1 | 0 | 2 | +| removed-exports | 100% | 100% | 1 | 0 | 0 | +| Overall sites | 12% | 96% | 46 | 336 | 2 | +| Breaking sites | 100% | 88% | 14 | 0 | 2 | + +- The 336 `dom-imports` false positives are `deprecated` import moves the upgrades did not make: + Lago's commit kept all 335 of its `react-router-dom` imports (v7 still serves them), and + Phoenix kept one (`ProjectsPage.tsx:16`). The rule is right that they can move, and the truth + is right that the upgrade did not need them; `fix` applies the move only with + `--include-deprecated`. +- The two `future-prop` misses are Lago's `routerFutureMock.ts:12` and `:14`, found but filed + under `future-prop-untyped` (deprecated: v7 ignores a prop passed through untyped + `createElement`), so they also count as the two `future-prop-untyped` false positives. The truth + files them as `future-prop` because the upgrade deleted them for the same reason; the pack + keeps them `deprecated` since nothing fails to compile or run there. +- The two `generic` misses are test assertions v7 behavior changes (`generatePath` escaping in + `utils.test.tsx:348`, Link href normalization in `Link.test.tsx:94`): runtime behavior with no + compiler site and no syntax to match (engine limit 7 below). +- Every other rule has no site in the final truth and is covered by fixtures and unit tests only. + The compile-signal fix (#141) changes nothing here: scored without it, the final truth gives + the same findings (it only mattered for OpenMetadata, now excluded). + +## What the engine could not express + +1. ~~A lead installed only transitively~~: resolved by alternate leads (#134). `check + react-router` answered "not a dependency" for a repository that declares only + `react-router-dom`. +2. **Ground truth in a monorepo workspace without its own lockfile** fails with + `UNSUPPORTED_PACKAGE_MANAGER`: the installer runs in `directory`, not at the lockfile's root. +3. **A package the upgrade removed** (`react-router-dom` in Phoenix) cannot be expressed in + `packs:truth`/`with`; it was linked at the target version instead, which is the install `fix` + would produce. `packs:truth` also leaves out the lines of a file the upgrade deleted (Lago's + `routerFutureMock.ts`), and keys new diagnostics on their message, so an error the upgrade + only rewords is listed as new (OpenMetadata); `check` did the same until #141. +8. **A parent whose own compile needs a build step** (Lago's design system) is scored unbuilt: + the ground-truth installer runs scripts off and builds nothing, so errors from the unbuilt + package are in both the baseline and the target and cancel out. Lago's built-world check + above shows they hide no expectation there; a repository where they did would be excluded. +4. **Fixtures cannot mark a detected site whose rewrite declines** (`keep` means neither), so + movable and non-movable `react-router-dom` imports are two rules, and so are typed and untyped + `future` props. +5. **Flag adoption per router**: `applies()` sees the inventory as text, so a flag enabled in one + router setup silences its note for the whole repository. +6. A compiler error on a line that also holds an unrelated type-diff usage can be filed under + that usage's symbol instead of the claiming rule (seen in a synthetic probe, not in the truth). +7. Behavior that only tests observe (generatePath escaping, Link href normalization, the + transition opt-out) has no finding to attach to: notes are decisions, not sites, so these + remain `generic` misses. diff --git a/packages/core/src/packs/react-router/behavior.ts b/packages/core/src/packs/react-router/behavior.ts new file mode 100644 index 0000000..5d30cda --- /dev/null +++ b/packages/core/src/packs/react-router/behavior.ts @@ -0,0 +1,196 @@ +import type { BehaviorNote } from '../contract.js'; +import type { PackContext } from '../types.js'; + +/** + * What compiles the same and behaves differently in React Router 7, each scoped by evidence + * from the repository's own router setup (`PackContext.inventory`, read before any edit). A v6 + * future flag the code already turned on changed nothing when v7 made it the default, so its + * note says so only where no setup enables it. + */ +const guide = + 'https://github.com/remix-run/react-router/blob/1b1e0b0e79b21692ce907933475233babdd16e3e/docs/upgrading/v6.md'; +const SOURCE = /\.[cm]?[jt]sx?$/; +const ROUTER_IMPORT = /\bfrom\s*['"]react-router(?:-dom)?(?:\/server)?['"]/; + +type File = { file: string; text: string }; +const files = (c: PackContext): File[] => c.inventory ?? []; +/** Source files that import React Router. */ +const routerFiles = (c: PackContext): File[] => + files(c).filter((f) => SOURCE.test(f.file) && ROUTER_IMPORT.test(f.text)); + +/** `file:line` for each match, the first three, and how many more. */ +function where(found: File[], pattern: RegExp, limit = 3): string[] { + const hits: string[] = []; + for (const f of found) { + const lines = f.text.split('\n'); + lines.forEach((line, i) => { + if (pattern.test(line)) hits.push(`\`${f.file}:${i + 1}\``); + }); + } + return hits.length > limit ? [...hits.slice(0, limit), `${hits.length - limit} more`] : hits; +} + +const SETUP = + /<(?:BrowserRouter|HashRouter|MemoryRouter|StaticRouter|RouterProvider|(?:unstable_)?HistoryRouter)\b|\bcreate(?:Browser|Hash|Memory|Static)Router\s*\(/; + +/** The flag is set to true somewhere: that router already behaved as v7 does. */ +const adopted = (c: PackContext, flag: string): boolean => + routerFiles(c).some((f) => new RegExp(`\\b${flag}\\s*:\\s*true\\b`).test(f.text)); + +function flagNote( + id: string, + flag: string, + summary: string, + evidence: RegExp, + what: string, +): BehaviorNote { + return { + id, + summary, + reported: ['decision'], + applies(c) { + if (adopted(c, flag)) return false; + const sites = where(routerFiles(c), evidence); + return sites.length ? `${what} (${sites.join(', ')}) without \`${flag}\`: ${summary}` : false; + }, + }; +} + +export const reactRouterBehavior: BehaviorNote[] = [ + flagNote( + 'relative-splat-path', + 'v7_relativeSplatPath', + 'relative paths inside a splat route (`path="dashboard/*"`) now resolve from the route itself, one segment further down: a relative `` or `navigate("..")` beneath it needs an extra `..`, or the route split into a parent `dashboard` and a child `*`. Choose which, per route.', + /\bpath\s*[:=]\s*\{?\s*["'`][^"'`]*[^"'`/*]\/\*["'`]/, + 'multi-segment splat routes', + ), + flagNote( + 'start-transition', + 'v7_startTransition', + 'router state updates run in `React.startTransition`: a `React.lazy` created inside a component suspends differently; move it to module scope. `` (7.15+) opts out.', + SETUP, + 'routers', + ), + flagNote( + 'fetcher-persist', + 'v7_fetcherPersist', + 'a fetcher now lives until it returns to idle, not until its component unmounts: `useFetchers()` can list fetchers whose component is gone.', + /\buseFetchers?\s*[<(]/, + 'fetchers', + ), + flagNote( + 'normalize-form-method', + 'v7_normalizeFormMethod', + '`formMethod` on `useNavigation()`, `useFetcher()` and `shouldRevalidate` is uppercase (`"POST"`): comparisons with lowercase methods are always false. The compiler rejects typed literals; values typed `string` or `any` and `.toLowerCase()` wrappers it cannot see.', + /\bformMethod\b/, + 'reads of `formMethod`', + ), + flagNote( + 'partial-hydration', + 'v7_partialHydration', + 'the `fallbackElement` prop is gone: a root route `HydrateFallback` (or `hydrateFallbackElement`) renders during the initial load, and `useNavigation().state` stays "idle" during it. Without one, a router with initial loaders renders nothing until they finish.', + /\bfallbackElement\b|\bhydrationData\b/, + 'initial-load fallbacks or hydration data', + ), + flagNote( + 'skip-action-error-revalidation', + 'v7_skipActionErrorRevalidation', + 'loaders no longer revalidate after an action returns or throws a 4xx/5xx result. An action that mutates data before failing needs `shouldRevalidate` to opt back in (`actionStatus`); otherwise validate before mutating. Choose per action.', + /\b(?:ActionFunction(?:Args)?|useActionData)\b|]*\baction=|\baction\s*:\s*(?:async\b|\w+Action\b|\(|function\b)/, + 'route actions', + ), + { + id: 'data-strategy', + summary: + 'a custom `dataStrategy` or `patchRoutesOnNavigation` runs under the v7 contract: review its results (keyed by route id) and the `unstable_` names stabilized in 6.28, against the 7.x `DataStrategyFunction` type.', + reported: ['decision'], + applies(c) { + const sites = where( + routerFiles(c), + /\b(?:unstable_)?(?:dataStrategy|patchRoutesOnNavigation|patchRoutesOnMiss)\b\s*[:,}]/, + ); + return sites.length ? `custom data loading (${sites.join(', ')}): ${this.summary}` : false; + }, + }, + { + id: 'navigation-promises', + summary: + '`navigate()`, `submit()`, `fetcher.load/submit` and `revalidate()` return promises: a lint rule against floating promises now reports their calls, and a callback typed to return `void` may reject them.', + reported: ['decision'], + applies(c) { + const lint = files(c).filter( + (f) => + /(?:^|\/)(?:\.eslintrc[^/]*|eslint\.config\.[cm]?[jt]s)$/.test(f.file) && + /no-(?:floating|misused)-promises/.test(f.text), + ); + const calls = where(routerFiles(c), /\buse(?:Navigate|Submit|Fetcher|Revalidator)\s*[<(]/); + return lint.length && calls.length + ? `${lint.map((f) => `\`${f.file}\``).join(', ')} enables no-floating-promises and the code navigates (${calls.join(', ')}): ${this.summary}` + : false; + }, + }, + { + id: 'platform-minimums', + summary: 'React Router 7 requires React 18 and Node 20.', + reported: ['decision'], + applies(c) { + const found: string[] = []; + for (const f of files(c)) { + if (/(?:^|\/)package\.json$/.test(f.file)) { + let pkg: Record | undefined>; + try { + pkg = JSON.parse(f.text); + } catch { + continue; + } + const react = pkg.dependencies?.react ?? pkg.peerDependencies?.react; + const major = Number(/(\d+)/.exec(react ?? '')?.[1]); + if (react && major < 18) found.push(`\`${f.file}\` declares react ${react}`); + const node = pkg.engines?.node; + const floor = Number(/(\d+)/.exec(node ?? '')?.[1]); + if (node && floor < 20) found.push(`\`${f.file}\` declares node ${node}`); + } else if (/(?:^|\/)\.(?:nvmrc|node-version)$/.test(f.file)) { + const version = Number(/(\d+)/.exec(f.text)?.[1]); + if (version < 20) found.push(`\`${f.file}\` pins Node ${f.text.trim()}`); + } + } + return found.length ? `${found.join('; ')}: ${this.summary}` : false; + }, + }, + { + id: 'remix-router-package', + summary: + '`@remix-run/router` is folded into `react-router` and has no v7 release: import its names from `react-router` (`Router` is `DataRouter`, `Action` is `NavigationType`; the history factories and `createRouter` are internal) and remove the dependency.', + reported: ['decision'], + applies(c) { + const declared = files(c).filter( + (f) => /(?:^|\/)package\.json$/.test(f.file) && /"@remix-run\/router"\s*:/.test(f.text), + ); + const imported = where( + files(c).filter((f) => SOURCE.test(f.file)), + /['"]@remix-run\/router['"]/, + ); + const evidence = [...declared.map((f) => `\`${f.file}\` declares it`), ...imported]; + return evidence.length ? `${evidence.join(', ')}: ${this.summary}` : false; + }, + }, + { + id: 'dom-package', + summary: + '`react-router-dom` 7 only re-exports `react-router` and v8 removes it: once nothing imports it, remove the dependency.', + reported: ['decision'], + applies(c) { + const declared = files(c).filter( + (f) => /(?:^|\/)package\.json$/.test(f.file) && /"react-router-dom"\s*:/.test(f.text), + ); + return declared.length + ? `${declared.map((f) => `\`${f.file}\``).join(', ')} declares react-router-dom: ${this.summary}` + : false; + }, + }, + { + id: 'also-review', + summary: `Also review the [v6 to v7 upgrade guide](${guide}); React Router 8 is a separate upgrade from the latest 7.x, with its own future flags adopted on 7 first.`, + reported: ['decision'], + }, +]; diff --git a/packages/core/src/packs/react-router/detect.ts b/packages/core/src/packs/react-router/detect.ts new file mode 100644 index 0000000..32cfd7d --- /dev/null +++ b/packages/core/src/packs/react-router/detect.ts @@ -0,0 +1,484 @@ +import { + type CallExpression, + type ExportDeclaration, + type ImportDeclaration, + Node, + Project, + type SourceFile, + SyntaxKind, +} from 'ts-morph'; +import type { Finding } from '../../domain/report.js'; +import { onReset } from '../../shared-state.js'; +import type { SourceSite } from '../contract.js'; +import type { PackContext, TransformResult } from '../types.js'; + +/** + * Sites of the React Router 6 → 7 upgrade that no type diff reports, found in the syntax of a + * file that uses the package, and the edits that are safe whatever the surrounding code is. + * Every edit re-parses the text it was given: the cached trees are only read. + */ +const parsed = new Map(); +onReset(() => parsed.clear()); +function parse(text: string): SourceFile { + let source = parsed.get(text); + if (!source) { + source = fresh(text); + if (parsed.size >= 16) parsed.delete(parsed.keys().next().value as string); + parsed.set(text, source); + } + return source; +} +function fresh(text: string): SourceFile { + return new Project({ useInMemoryFileSystem: true }).createSourceFile('consumer.tsx', text); +} + +function siteAt(source: SourceFile, node: Node, name: string): SourceSite { + const { line, column } = source.getLineAndColumnAtPos(node.getStart()); + return { line, column, snippet: (source.getFullText().split('\n')[line - 1] ?? '').trim(), name }; +} +const at = (source: SourceFile, node: Node, finding: Pick): boolean => { + const { line, column } = source.getLineAndColumnAtPos(node.getStart()); + return line === finding.usage.line && column === finding.usage.column; +}; + +const DOM = 'react-router-dom'; +const ROUTER_MODULES = new Set(['react-router', DOM]); +/** + * What `react-router-dom@7` takes from `react-router/dom`, not from `react-router`: the + * `RouterProvider` wired to `ReactDOM.flushSync`, and `HydratedRouter`. The main entry has a + * `RouterProvider` too, without `flushSync`: moving it there changes what it does. + */ +const DOM_ENTRY = new Set(['RouterProvider', 'HydratedRouter', 'RouterProviderProps']); + +type ModuleDeclaration = ImportDeclaration | ExportDeclaration; + +function declarationsFrom(source: SourceFile, specifier: string): ModuleDeclaration[] { + return [...source.getImportDeclarations(), ...source.getExportDeclarations()].filter( + (d) => d.getModuleSpecifierValue() === specifier, + ); +} + +/** Whether every name the declaration takes from `react-router-dom` is in `react-router` itself. */ +function movable(declaration: ModuleDeclaration): boolean { + if (Node.isImportDeclaration(declaration)) { + if (declaration.getNamespaceImport() || declaration.getDefaultImport()) return false; + const named = declaration.getNamedImports(); + // `import 'react-router-dom'` loads it for its side effects: nothing to move. + if (!named.length && !declaration.getImportClause()) return false; + return named.every((n) => !DOM_ENTRY.has(n.getName())); + } + const named = declaration.getNamedExports(); + if (!declaration.hasNamedExports() || declaration.getNamespaceExport()) return false; + return named.every((n) => !DOM_ENTRY.has(n.getName())); +} + +function domSites(text: string, wanted: (d: ModuleDeclaration) => boolean): SourceSite[] { + if (!text.includes(DOM)) return []; + const source = parse(text); + return declarationsFrom(source, DOM) + .filter((d) => (Node.isImportDeclaration(d) ? d.getImportClause() : true) && wanted(d)) + .map((d) => siteAt(source, d.getModuleSpecifier() as Node, DOM)); +} + +/** + * `import { Link } from 'react-router-dom'`: v7's `react-router-dom` only re-exports + * `react-router`, and v8 removes it. The site is the module specifier, the line the upgrade + * edits. + */ +export function domImportSites(text: string): SourceSite[] { + return domSites(text, movable); +} + +const TEST_FILE = /(?:^|\/)(?:__tests__|__mocks__)\/|\.(?:test|spec)\.[cm]?[jt]sx?$/; + +/** + * An import or re-export from `react-router-dom` that needs `react-router/dom`, or takes + * everything; and, outside tests, a `RouterProvider` or `HydratedRouter` imported from + * `react-router`, whose main entry is the non-DOM one in v7 (the guide keeps it for tests). + */ +export function domEntrySites(text: string, file = ''): SourceSite[] { + const sites = domSites(text, (d) => !movable(d)); + if (TEST_FILE.test(file) || !/\b(?:RouterProvider|HydratedRouter)\b/.test(text)) return sites; + const source = parse(text); + for (const declaration of declarationsFrom(source, 'react-router')) + if ( + Node.isImportDeclaration(declaration) && + !declaration.isTypeOnly() && + declaration + .getNamedImports() + .some((n) => !n.isTypeOnly() && ['RouterProvider', 'HydratedRouter'].includes(n.getName())) + ) + sites.push(siteAt(source, declaration.getModuleSpecifier(), 'react-router')); + return sites.sort((a, b) => a.line - b.line); +} + +const MOCK = + /\b(?:jest|vi)\s*\.\s*(?:mock|doMock|unmock|requireActual|importActual|requireMock|importMock)\s*(?:<[^>]*>)?\s*\(\s*(['"`])react-router-dom\1/; + +/** A test that mocks `react-router-dom` by name stops applying once the code imports `react-router`. */ +export function domMocks(text: string, context?: Pick): string[] { + const files = (context?.inventory ?? []).filter( + (f) => MOCK.test(f.text) || /(?:^|\/)__mocks__\/react-router-dom\.[cm]?[jt]sx?$/.test(f.file), + ); + return [...(MOCK.test(text) ? ['this file'] : []), ...files.map((f) => `\`${f.file}\``)]; +} + +/** The specifier at the reported site becomes `react-router`; nothing else in the file changes. */ +export function moveDomImport( + text: string, + finding: Pick, + context?: Pick, +): TransformResult { + const mocks = domMocks(text, context); + if (mocks.length) + return { + text, + applied: false, + reason: `tests mock react-router-dom by name (${mocks.slice(0, 3).join(', ')}): move each mock with the imports it stands in for`, + }; + const source = fresh(text); + const declaration = declarationsFrom(source, DOM).find((d) => + at(source, d.getModuleSpecifier() as Node, finding), + ); + if (!declaration || !movable(declaration)) + return { + text, + applied: false, + reason: 'the reported site is not a movable react-router-dom import', + }; + const literal = declaration.getModuleSpecifier() as Node; + const quote = literal.getText()[0]; + return { + text: `${text.slice(0, literal.getStart())}${quote}react-router${quote}${text.slice(literal.getEnd())}`, + applied: true, + reason: 'react-router-dom 7 re-exports these names from react-router: import them from there', + }; +} + +const SERVER = 'react-router-dom/server'; + +/** `react-router-dom/server` is not in v7's exports map, even with `react-router-dom` kept. */ +export function domServerSites(text: string): SourceSite[] { + if (!text.includes(SERVER)) return []; + const source = parse(text); + return declarationsFrom(source, SERVER).map((d) => + siteAt(source, d.getModuleSpecifier() as Node, SERVER), + ); +} + +/** Its seven names (StaticRouter, createStaticHandler, ...) are all exported by `react-router`. */ +export function moveDomServerImport( + text: string, + finding: Pick, +): TransformResult { + const source = fresh(text); + const literal = declarationsFrom(source, SERVER) + .map((d) => d.getModuleSpecifier() as Node) + .find((l) => at(source, l, finding)); + if (!literal) + return { + text, + applied: false, + reason: 'the reported site is not a react-router-dom/server import', + }; + const quote = literal.getText()[0]; + return { + text: `${text.slice(0, literal.getStart())}${quote}react-router${quote}${text.slice(literal.getEnd())}`, + applied: true, + reason: 'react-router-dom/server is gone in v7: its exports are in react-router', + }; +} + +/** Local names the file binds to `names` imported from react-router or react-router-dom. */ +function routerBindings(source: SourceFile, names: readonly string[]): Map { + const bound = new Map(); + for (const declaration of source.getImportDeclarations()) { + if (!ROUTER_MODULES.has(declaration.getModuleSpecifierValue())) continue; + for (const named of declaration.getNamedImports()) + if (names.includes(named.getName())) + bound.set((named.getAliasNode() ?? named.getNameNode()).getText(), named.getName()); + } + return bound; +} + +const imports = (text: string) => /['"]react-router(?:-dom)?['"]/.test(text); + +/** + * Calls of `json(...)`, `defer(...)` and `new AbortedDeferredError(...)` (or an `instanceof` + * check) bound to the removed exports. The compiler reports the import alone: once it fails, + * every call through it is `any` and compiles. + */ +export function removedCallSites(text: string, names: readonly string[]): SourceSite[] { + if (!imports(text) || !names.some((n) => text.includes(n))) return []; + const source = parse(text); + const bound = routerBindings(source, names); + if (!bound.size) return []; + const sites: SourceSite[] = []; + source.forEachDescendant((node) => { + if (!Node.isIdentifier(node) || !bound.has(node.getText())) return; + const parent = node.getParent(); + const called = + (Node.isCallExpression(parent) || Node.isNewExpression(parent)) && + parent.getExpression() === node; + const checked = + Node.isBinaryExpression(parent) && + parent.getOperatorToken().getKind() === SyntaxKind.InstanceOfKeyword && + parent.getRight() === node; + // A local that shadows the import is another function. + const declared = node.getSymbol()?.getDeclarations() ?? []; + if (!declared.every((d) => Node.isImportSpecifier(d))) return; + if (called || checked) sites.push(siteAt(source, node, bound.get(node.getText()) as string)); + }); + return sites; +} + +const FACTORIES: Record = { + createBrowserRouter: 1, + createHashRouter: 1, + createMemoryRouter: 1, + createStaticHandler: 1, + createStaticRouter: 2, +}; + +/** The `future` property of a router factory's options, at the call that passes it. */ +function futureOptions(source: SourceFile) { + const bound = routerBindings(source, Object.keys(FACTORIES)); + return source.getDescendantsOfKind(SyntaxKind.CallExpression).flatMap((call: CallExpression) => { + const callee = call.getExpression(); + const name = Node.isIdentifier(callee) ? bound.get(callee.getText()) : undefined; + if (!name) return []; + const options = call.getArguments()[FACTORIES[name] as number]; + if (!options || !Node.isObjectLiteralExpression(options)) return []; + const future = options.getProperty('future'); + return future && Node.isPropertyAssignment(future) ? [{ call, options, future }] : []; + }); +} + +/** + * `createBrowserRouter(routes, { future: { v7_relativeSplatPath: true } })`. v7 types + * `future` as an empty interface, so the stale keys compile and do nothing; 8.4 rejects them. + * Only an inline object of v6 flags is a site: a variable may be read elsewhere. + */ +export function futureOptionSites(text: string): SourceSite[] { + if (!text.includes('future') || !imports(text)) return []; + const source = parse(text); + return futureOptions(source) + .filter(({ future }) => staleFlags(future.getInitializer()) && !commented(future)) + .map(({ future }) => siteAt(source, future, 'future')); +} + +/** `{ v7_x: true, v7_y: false }`: only v6 flags, each a boolean literal. */ +function staleFlags(node: Node | undefined): boolean { + if (!node || !Node.isObjectLiteralExpression(node)) return false; + return node.getProperties().every((p) => { + if (!Node.isPropertyAssignment(p) || !/^v7_\w+$/.test(p.getName())) return false; + const value = p.getInitializer()?.getKind(); + return value === SyntaxKind.TrueKeyword || value === SyntaxKind.FalseKeyword; + }); +} + +/** A comment before the node may describe more than the flags: such a site is left to a person. */ +const commented = (node: Node): boolean => node.getLeadingCommentRanges().length > 0; + +/** `text` without [start, end). */ +const cut = (text: string, start: number, end: number): string => + text.slice(0, start) + text.slice(end); + +/** + * The `future` option goes; an options object left empty goes with it when it is the last + * argument. Text ranges, not a printer: the rest of the call keeps its layout and commas. + */ +export function removeFutureOption(text: string, finding: Pick): TransformResult { + const source = fresh(text); + const found = futureOptions(source).find(({ future }) => at(source, future, finding)); + if (!found) + return { text, applied: false, reason: 'the reported site is not a router `future` option' }; + const { call, options, future } = found; + if (!staleFlags(future.getInitializer()) || commented(future)) + return { + text, + applied: false, + reason: '`future` holds something other than v6 flags set to true or false', + }; + const args = call.getArguments(); + const properties = options.getProperties(); + const index = properties.indexOf(future); + let out: string; + if (properties.length === 1 && args[args.length - 1] === options && !commented(options)) + out = cut(text, (args[args.length - 2] as Node).getEnd(), options.getEnd()); + else if (index < properties.length - 1) + out = cut(text, future.getStart(), (properties[index + 1] as Node).getStart()); + else if (index > 0) out = cut(text, (properties[index - 1] as Node).getEnd(), future.getEnd()); + else out = cut(text, future.getStart(), future.getEnd()); + return { + text: out, + applied: true, + reason: 'v7 has no v6 future flags: their behavior is the default and the keys do nothing', + }; +} + +const ROUTERS = new Set([ + 'BrowserRouter', + 'HashRouter', + 'MemoryRouter', + 'Router', + 'StaticRouter', + 'RouterProvider', + 'HistoryRouter', + 'unstable_HistoryRouter', +]); + +/** + * ``: the prop is gone from every router + * component in v7 (TS2322). Removed when it is an inline object of v6 flags; a variable or + * anything computed is left for a person, who may need it elsewhere. + */ +export function removeFutureProp(text: string, finding: Pick): TransformResult { + const source = fresh(text); + const elements = [ + ...source.getDescendantsOfKind(SyntaxKind.JsxOpeningElement), + ...source.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement), + ]; + const element = elements.find((e) => { + const attribute = e.getAttribute('future'); + return attribute && (at(source, e.getTagNameNode(), finding) || at(source, attribute, finding)); + }); + const tag = element?.getTagNameNode().getText().split('.').pop() ?? ''; + // `HashRouter as Router`: the imported name decides, not the local one. + const imported = routerBindings(source, [...ROUTERS]).get(tag) ?? tag; + const attribute = element?.getAttribute('future'); + if (!element || !ROUTERS.has(imported) || !attribute || !Node.isJsxAttribute(attribute)) + return { + text, + applied: false, + reason: 'the reported site is not a router with a `future` prop', + }; + const value = attribute.getInitializer(); + if ( + !value || + !Node.isJsxExpression(value) || + !staleFlags(value.getExpression()) || + commented(attribute) + ) + return { + text, + applied: false, + reason: '`future` is not an inline object of v6 flags: it may be used elsewhere', + }; + attribute.remove(); + return { + text: source.getFullText(), + applied: true, + reason: 'router components take no `future` prop in v7: the v6 flags are the default', + }; +} + +/** + * `createElement(actual.BrowserRouter, { future: FLAGS, ...props })`: a router given v6 flags + * through untyped props (a jest mock of react-router-dom, a wrapper). The compiler sees nothing, + * and v7 ignores the prop. The site is the call. + */ +export function untypedFuturePropSites(text: string): SourceSite[] { + if (!text.includes('createElement') || !text.includes('future')) return []; + const source = parse(text); + const bound = routerBindings(source, [...ROUTERS]); + return source.getDescendantsOfKind(SyntaxKind.CallExpression).flatMap((call) => { + const callee = call.getExpression(); + const name = Node.isPropertyAccessExpression(callee) ? callee.getName() : callee.getText(); + if (name !== 'createElement') return []; + const [component, props] = call.getArguments(); + const router = !component + ? undefined + : Node.isPropertyAccessExpression(component) + ? component.getName() + : (bound.get(component.getText()) ?? undefined); + if (!router || !ROUTERS.has(router) || !props || !Node.isObjectLiteralExpression(props)) + return []; + const future = props.getProperty('future'); + return future ? [siteAt(source, call, router)] : []; + }); +} + +const METHODS = new Set(['get', 'post', 'put', 'patch', 'delete']); +const EQUALITY = new Set([ + SyntaxKind.EqualsEqualsEqualsToken, + SyntaxKind.ExclamationEqualsEqualsToken, + SyntaxKind.EqualsEqualsToken, + SyntaxKind.ExclamationEqualsToken, +]); + +function unwrap(node: Node): Node { + let current = node; + while (Node.isParenthesizedExpression(current) || Node.isNonNullExpression(current)) + current = current.getExpression(); + return current; +} +const readsFormMethod = (node: Node): boolean => { + const read = unwrap(node); + return Node.isPropertyAccessExpression(read) && read.getName() === 'formMethod'; +}; +const lowercaseMethod = (node: Node): boolean => { + const value = unwrap(node); + return ( + (Node.isStringLiteral(value) || Node.isNoSubstitutionTemplateLiteral(value)) && + METHODS.has(value.getLiteralText()) + ); +}; + +/** Each comparison of a `.formMethod` with a lowercase method, and the literal to uppercase. */ +function lowercaseComparisons(source: SourceFile): { site: Node; literal: Node }[] { + const found: { site: Node; literal: Node }[] = []; + for (const binary of source.getDescendantsOfKind(SyntaxKind.BinaryExpression)) { + if (!EQUALITY.has(binary.getOperatorToken().getKind())) continue; + const [left, right] = [binary.getLeft(), binary.getRight()]; + if (readsFormMethod(left) && lowercaseMethod(right)) + found.push({ site: binary, literal: right }); + else if (readsFormMethod(right) && lowercaseMethod(left)) + found.push({ site: binary, literal: left }); + } + for (const clause of source.getDescendantsOfKind(SyntaxKind.CaseClause)) { + const owner = clause.getFirstAncestorByKind(SyntaxKind.SwitchStatement); + if (owner && readsFormMethod(owner.getExpression()) && lowercaseMethod(clause.getExpression())) + found.push({ site: clause.getExpression(), literal: clause.getExpression() }); + } + return found; +} + +/** + * `navigation.formMethod === 'post'`: v7 always uppercases `formMethod` (the + * `v7_normalizeFormMethod` behavior), so a lowercase comparison is now always false. The + * compiler sees it only where `formMethod` is typed; through `any` or `string` it is blind. + */ +export function formMethodSites(text: string): SourceSite[] { + if (!text.includes('formMethod') || !imports(text)) return []; + const source = parse(text); + return lowercaseComparisons(source).map(({ site }) => siteAt(source, site, 'formMethod')); +} + +/** The lowercase method compared with `formMethod` on the reported line, uppercased. */ +export function uppercaseFormMethod( + text: string, + finding: Pick, +): TransformResult { + const source = fresh(text); + const literals = lowercaseComparisons(source) + .map(({ literal }) => unwrap(literal)) + .filter((l) => source.getLineAndColumnAtPos(l.getStart()).line === finding.usage.line) + .sort((a, b) => b.getStart() - a.getStart()); + if (!literals.length) + return { + text, + applied: false, + reason: 'no lowercase method is compared with `formMethod` on the reported line', + }; + let out = text; + for (const literal of literals) { + const raw = literal.getText(); + out = `${out.slice(0, literal.getStart())}${raw[0]}${raw.slice(1, -1).toUpperCase()}${raw.slice(-1)}${out.slice(literal.getEnd())}`; + } + return { + text: out, + applied: true, + reason: 'formMethod is uppercase in v7: compare with the uppercase method, as v6 did lowercase', + }; +} diff --git a/packages/core/src/packs/react-router/fixtures/dom-entry-test/before.ts b/packages/core/src/packs/react-router/fixtures/dom-entry-test/before.ts new file mode 100644 index 0000000..54c37da --- /dev/null +++ b/packages/core/src/packs/react-router/fixtures/dom-entry-test/before.ts @@ -0,0 +1,3 @@ +import { createMemoryRouter, RouterProvider } from 'react-router'; // @uptide dom-entry-imports keep at:'react-router' + +export const used = [createMemoryRouter, RouterProvider]; diff --git a/packages/core/src/packs/react-router/fixtures/dom-entry-test/filename.txt b/packages/core/src/packs/react-router/fixtures/dom-entry-test/filename.txt new file mode 100644 index 0000000..b9f670e --- /dev/null +++ b/packages/core/src/packs/react-router/fixtures/dom-entry-test/filename.txt @@ -0,0 +1 @@ +src/app/Routes.test.tsx diff --git a/packages/core/src/packs/react-router/fixtures/dom-entry/before.ts b/packages/core/src/packs/react-router/fixtures/dom-entry/before.ts new file mode 100644 index 0000000..3e62f64 --- /dev/null +++ b/packages/core/src/packs/react-router/fixtures/dom-entry/before.ts @@ -0,0 +1,8 @@ +import { createRoutesFromElements, Route, RouterProvider } from 'react-router'; // @uptide dom-entry-imports at:'react-router' +import { HydratedRouter as Hydrated } from "react-router"; // @uptide dom-entry-imports at:"react-router" +import type { RouterProviderProps } from 'react-router'; // @uptide dom-entry-imports keep at:'react-router' +import { RouterProvider as DomProvider } from 'react-router/dom'; // @uptide dom-entry-imports keep at:'react-router/dom' +import { Link } from 'react-router'; // @uptide dom-entry-imports keep at:'react-router' + +export const used = [createRoutesFromElements, Route, RouterProvider, Hydrated, DomProvider, Link]; +export type Props = RouterProviderProps; diff --git a/packages/core/src/packs/react-router/fixtures/dom-imports/after.ts b/packages/core/src/packs/react-router/fixtures/dom-imports/after.ts new file mode 100644 index 0000000..af804de --- /dev/null +++ b/packages/core/src/packs/react-router/fixtures/dom-imports/after.ts @@ -0,0 +1,18 @@ +import { Link, useNavigate, type NavigateOptions } from 'react-router'; // @uptide dom-imports at:'react-router-dom' +import type { Location } from "react-router"; // @uptide dom-imports at:"react-router-dom" +import { + Outlet, + useParams, +} from 'react-router'; // @uptide dom-imports at:'react-router-dom' +import { createBrowserRouter, RouterProvider } from 'react-router-dom'; // @uptide dom-entry-imports at:'react-router-dom' +import * as Router from 'react-router-dom'; // @uptide dom-entry-imports at:'react-router-dom' +import 'react-router-dom'; // @uptide dom-imports keep at:'react-router-dom' +import { useMatches } from 'react-router'; // @uptide dom-imports keep at:'react-router' +import { routes } from './react-router-dom-routes'; // @uptide dom-imports keep at:'./react-router-dom-routes' + +export { NavLink, matchPath } from 'react-router'; // @uptide dom-imports at:'react-router-dom' +export * from 'react-router-dom'; // @uptide dom-entry-imports at:'react-router-dom' + +export const router = createBrowserRouter(routes); +export const used = [Link, useNavigate, Outlet, useParams, RouterProvider, Router, useMatches]; +export type Used = [NavigateOptions, Location]; diff --git a/packages/core/src/packs/react-router/fixtures/dom-imports/before.ts b/packages/core/src/packs/react-router/fixtures/dom-imports/before.ts new file mode 100644 index 0000000..582d2f3 --- /dev/null +++ b/packages/core/src/packs/react-router/fixtures/dom-imports/before.ts @@ -0,0 +1,18 @@ +import { Link, useNavigate, type NavigateOptions } from 'react-router-dom'; // @uptide dom-imports at:'react-router-dom' +import type { Location } from "react-router-dom"; // @uptide dom-imports at:"react-router-dom" +import { + Outlet, + useParams, +} from 'react-router-dom'; // @uptide dom-imports at:'react-router-dom' +import { createBrowserRouter, RouterProvider } from 'react-router-dom'; // @uptide dom-entry-imports at:'react-router-dom' +import * as Router from 'react-router-dom'; // @uptide dom-entry-imports at:'react-router-dom' +import 'react-router-dom'; // @uptide dom-imports keep at:'react-router-dom' +import { useMatches } from 'react-router'; // @uptide dom-imports keep at:'react-router' +import { routes } from './react-router-dom-routes'; // @uptide dom-imports keep at:'./react-router-dom-routes' + +export { NavLink, matchPath } from 'react-router-dom'; // @uptide dom-imports at:'react-router-dom' +export * from 'react-router-dom'; // @uptide dom-entry-imports at:'react-router-dom' + +export const router = createBrowserRouter(routes); +export const used = [Link, useNavigate, Outlet, useParams, RouterProvider, Router, useMatches]; +export type Used = [NavigateOptions, Location]; diff --git a/packages/core/src/packs/react-router/fixtures/dom-server-entry/before.ts b/packages/core/src/packs/react-router/fixtures/dom-server-entry/before.ts new file mode 100644 index 0000000..082c33a --- /dev/null +++ b/packages/core/src/packs/react-router/fixtures/dom-server-entry/before.ts @@ -0,0 +1,6 @@ +import { StaticRouter, createStaticHandler } from 'react-router-dom/server'; // @uptide dom-server-entry at:'react-router-dom/server' +import type { StaticHandlerContext } from "react-router-dom/server"; // @uptide dom-server-entry at:"react-router-dom/server" +import { renderToString } from 'react-dom/server'; // @uptide dom-server-entry keep at:'react-dom/server' + +export const handler = createStaticHandler([]); +export const render = (context: StaticHandlerContext) => renderToString(StaticRouter as never); diff --git a/packages/core/src/packs/react-router/fixtures/form-method-case/after.ts b/packages/core/src/packs/react-router/fixtures/form-method-case/after.ts new file mode 100644 index 0000000..4e9ffc3 --- /dev/null +++ b/packages/core/src/packs/react-router/fixtures/form-method-case/after.ts @@ -0,0 +1,17 @@ +import { useFetcher, useNavigation } from 'react-router'; // @uptide dom-imports at:'react-router-dom' + +export function Saving() { + const navigation = useNavigation(); + const fetcher = useFetcher(); + const saving = navigation.formMethod === 'post'; // @uptide form-method-case at:navigation.formMethod + const deleting = 'delete' !== fetcher.formMethod; // @uptide form-method-case at:'delete' + const anyMethod = (fetcher as any).formMethod == "put"; // @uptide form-method-case at:(fetcher + switch (navigation.formMethod) { + case 'patch': // @uptide form-method-case at:'patch' + return 'patching'; + } + const upper = navigation.formMethod === 'POST'; // @uptide form-method-case keep at:navigation.formMethod + const lowered = navigation.formMethod?.toLowerCase() === 'post'; // @uptide form-method-case keep at:navigation.formMethod + const other = navigation.state === 'loading'; // @uptide form-method-case keep at:navigation.state + return [saving, deleting, anyMethod, upper, lowered, other]; +} diff --git a/packages/core/src/packs/react-router/fixtures/form-method-case/before.ts b/packages/core/src/packs/react-router/fixtures/form-method-case/before.ts new file mode 100644 index 0000000..4d2c259 --- /dev/null +++ b/packages/core/src/packs/react-router/fixtures/form-method-case/before.ts @@ -0,0 +1,17 @@ +import { useFetcher, useNavigation } from 'react-router-dom'; // @uptide dom-imports at:'react-router-dom' + +export function Saving() { + const navigation = useNavigation(); + const fetcher = useFetcher(); + const saving = navigation.formMethod === 'post'; // @uptide form-method-case at:navigation.formMethod + const deleting = 'delete' !== fetcher.formMethod; // @uptide form-method-case at:'delete' + const anyMethod = (fetcher as any).formMethod == "put"; // @uptide form-method-case at:(fetcher + switch (navigation.formMethod) { + case 'patch': // @uptide form-method-case at:'patch' + return 'patching'; + } + const upper = navigation.formMethod === 'POST'; // @uptide form-method-case keep at:navigation.formMethod + const lowered = navigation.formMethod?.toLowerCase() === 'post'; // @uptide form-method-case keep at:navigation.formMethod + const other = navigation.state === 'loading'; // @uptide form-method-case keep at:navigation.state + return [saving, deleting, anyMethod, upper, lowered, other]; +} diff --git a/packages/core/src/packs/react-router/fixtures/future-option/after.ts b/packages/core/src/packs/react-router/fixtures/future-option/after.ts new file mode 100644 index 0000000..2b20091 --- /dev/null +++ b/packages/core/src/packs/react-router/fixtures/future-option/after.ts @@ -0,0 +1,22 @@ +import { createBrowserRouter, createMemoryRouter as memory } from 'react-router'; // @uptide dom-imports at:'react-router-dom' +import { createStaticRouter } from 'react-router'; + +const routes = [{ path: '/' }]; +const flags = { v7_fetcherPersist: true }; + +export const a = createBrowserRouter(routes, { future: { v7_relativeSplatPath: true, v7_fetcherPersist: true } }); // @uptide future-option at:future +export const b = createBrowserRouter(routes, { + basename: '/app', + future: { // @uptide future-option at:future + v7_normalizeFormMethod: true, + v7_skipActionErrorRevalidation: false, + }, +}); +export const c = memory(routes, { + future: { v7_partialHydration: true }, // @uptide future-option at:future +}); +export const d = createStaticRouter(routes, {} as never, { future: { v7_relativeSplatPath: true } }); // @uptide future-option at:future +export const e = createBrowserRouter(routes, { future: flags }); // @uptide future-option keep at:future +export const f = createBrowserRouter(routes, { future: { v7_relativeSplatPath: !!flags } }); // @uptide future-option keep at:future +const createRouter = (r: unknown, o: { future: object }) => [r, o]; +export const g = createRouter(routes, { future: { v7_relativeSplatPath: true } }); // @uptide future-option keep at:future diff --git a/packages/core/src/packs/react-router/fixtures/future-option/before.ts b/packages/core/src/packs/react-router/fixtures/future-option/before.ts new file mode 100644 index 0000000..e2f859e --- /dev/null +++ b/packages/core/src/packs/react-router/fixtures/future-option/before.ts @@ -0,0 +1,22 @@ +import { createBrowserRouter, createMemoryRouter as memory } from 'react-router-dom'; // @uptide dom-imports at:'react-router-dom' +import { createStaticRouter } from 'react-router'; + +const routes = [{ path: '/' }]; +const flags = { v7_fetcherPersist: true }; + +export const a = createBrowserRouter(routes, { future: { v7_relativeSplatPath: true, v7_fetcherPersist: true } }); // @uptide future-option at:future +export const b = createBrowserRouter(routes, { + basename: '/app', + future: { // @uptide future-option at:future + v7_normalizeFormMethod: true, + v7_skipActionErrorRevalidation: false, + }, +}); +export const c = memory(routes, { + future: { v7_partialHydration: true }, // @uptide future-option at:future +}); +export const d = createStaticRouter(routes, {} as never, { future: { v7_relativeSplatPath: true } }); // @uptide future-option at:future +export const e = createBrowserRouter(routes, { future: flags }); // @uptide future-option keep at:future +export const f = createBrowserRouter(routes, { future: { v7_relativeSplatPath: !!flags } }); // @uptide future-option keep at:future +const createRouter = (r: unknown, o: { future: object }) => [r, o]; +export const g = createRouter(routes, { future: { v7_relativeSplatPath: true } }); // @uptide future-option keep at:future diff --git a/packages/core/src/packs/react-router/fixtures/future-prop-untyped/before.ts b/packages/core/src/packs/react-router/fixtures/future-prop-untyped/before.ts new file mode 100644 index 0000000..928fcca --- /dev/null +++ b/packages/core/src/packs/react-router/fixtures/future-prop-untyped/before.ts @@ -0,0 +1,14 @@ +import { ComponentType, createElement, ReactElement } from 'react'; +import { MemoryRouter as Memory } from 'react-router'; + +const FLAGS = { v7_relativeSplatPath: true }; +type RouterModule = { BrowserRouter: ComponentType> }; + +export const withFlags = (actual: RouterModule) => ({ + BrowserRouter: (props: Record): ReactElement => + createElement(actual.BrowserRouter, { future: FLAGS, ...props }), // @uptide future-prop-untyped at:createElement + MemoryRouter: (props: Record): ReactElement => + createElement(Memory, { future: FLAGS, ...props }), // @uptide future-prop-untyped at:createElement +}); +export const plain = (actual: RouterModule) => createElement(actual.BrowserRouter, { basename: '/' }); // @uptide future-prop-untyped keep at:createElement +export const other = (Widget: ComponentType) => createElement(Widget, { future: FLAGS }); // @uptide future-prop-untyped keep at:createElement diff --git a/packages/core/src/packs/react-router/fixtures/future-prop/before.ts b/packages/core/src/packs/react-router/fixtures/future-prop/before.ts new file mode 100644 index 0000000..e3db1ee --- /dev/null +++ b/packages/core/src/packs/react-router/fixtures/future-prop/before.ts @@ -0,0 +1,23 @@ +import { BrowserRouter, RouterProvider, createBrowserRouter } from 'react-router'; // @uptide dom-entry-imports at:'react-router' +import { MemoryRouter, HashRouter as Router } from 'react-router'; +import { Widget as Panel } from './widget'; + +const router = createBrowserRouter([]); +const flags = { v7_startTransition: true }; + +export const App = () => ; // @uptide future-prop at:RouterProvider message:"Type '{ router: DataRouter; future: { v7_startTransition: boolean; }; }' is not assignable to type 'IntrinsicAttributes & RouterProviderProps'. Property 'future' does not exist on type 'IntrinsicAttributes & RouterProviderProps'." +export const Legacy = () => ( + +
+ +); +export const Shared = () => ; // @uptide future-prop keep at:RouterProvider message:"Property 'future' does not exist on type 'IntrinsicAttributes & RouterProviderProps'." +export const Computed = () => ; // @uptide future-prop keep at:MemoryRouter message:"Property 'future' does not exist on type 'IntrinsicAttributes & MemoryRouterProps'." +export const Aliased = () => x; // @uptide future-prop at:Router message:"Property 'future' does not exist on type 'IntrinsicAttributes & HashRouterProps'." +export const NotARouter = () => ; // @uptide future-prop keep at:Panel message:"Property 'future' does not exist on type 'IntrinsicAttributes & PanelProps'." diff --git a/packages/core/src/packs/react-router/fixtures/removed-calls/after.ts b/packages/core/src/packs/react-router/fixtures/removed-calls/after.ts new file mode 100644 index 0000000..f20f7d1 --- /dev/null +++ b/packages/core/src/packs/react-router/fixtures/removed-calls/after.ts @@ -0,0 +1,27 @@ +import { json, defer, AbortedDeferredError as Aborted, type LoaderFunctionArgs } from 'react-router'; // @uptide dom-imports at:'react-router-dom' + +export async function loader({ params }: LoaderFunctionArgs) { + if (!params.id) throw json({ message: 'missing id' }, { status: 400 }); // @uptide removed-json at:json + return json({ id: params.id }); // @uptide removed-json at:json +} + +export function slow() { + return defer({ list: Promise.resolve([]) }); // @uptide removed-defer at:defer +} + +export function isAborted(error: unknown) { + return error instanceof Aborted; // @uptide removed-defer at:Aborted +} + +export function local() { + const json = (value: unknown) => JSON.stringify(value); + return json({ kept: true }); // @uptide removed-json keep at:json +} + +export const body = JSON.stringify({ json: 1 }); // @uptide removed-json keep at:JSON +export const parse = (r: Response) => r.json(); // @uptide removed-json keep at:r.json + +export function later(run: () => void) { + const defer = (fn: () => void) => setTimeout(fn); + return defer(run); // @uptide removed-defer keep at:defer +} diff --git a/packages/core/src/packs/react-router/fixtures/removed-calls/before.ts b/packages/core/src/packs/react-router/fixtures/removed-calls/before.ts new file mode 100644 index 0000000..c690a00 --- /dev/null +++ b/packages/core/src/packs/react-router/fixtures/removed-calls/before.ts @@ -0,0 +1,27 @@ +import { json, defer, AbortedDeferredError as Aborted, type LoaderFunctionArgs } from 'react-router-dom'; // @uptide dom-imports at:'react-router-dom' + +export async function loader({ params }: LoaderFunctionArgs) { + if (!params.id) throw json({ message: 'missing id' }, { status: 400 }); // @uptide removed-json at:json + return json({ id: params.id }); // @uptide removed-json at:json +} + +export function slow() { + return defer({ list: Promise.resolve([]) }); // @uptide removed-defer at:defer +} + +export function isAborted(error: unknown) { + return error instanceof Aborted; // @uptide removed-defer at:Aborted +} + +export function local() { + const json = (value: unknown) => JSON.stringify(value); + return json({ kept: true }); // @uptide removed-json keep at:json +} + +export const body = JSON.stringify({ json: 1 }); // @uptide removed-json keep at:JSON +export const parse = (r: Response) => r.json(); // @uptide removed-json keep at:r.json + +export function later(run: () => void) { + const defer = (fn: () => void) => setTimeout(fn); + return defer(run); // @uptide removed-defer keep at:defer +} diff --git a/packages/core/src/packs/react-router/ground-truth.json b/packages/core/src/packs/react-router/ground-truth.json new file mode 100644 index 0000000..2bc5b30 --- /dev/null +++ b/packages/core/src/packs/react-router/ground-truth.json @@ -0,0 +1,289 @@ +{ + "$comment": "Ground truth for the react-router pack (docs/packs.md, \"Ground truth\"). Each public repository is pinned at the commit before its own React Router 6 to 7 upgrade. Expected findings come from two sources that are not Uptide, drafted by pnpm packs:truth and read by hand before the pack was scored: the diagnostics the repository's TypeScript reports with react-router and react-router-dom at the upgrade's version linked in and not without them (Phoenix removed react-router-dom in the same commit; it is linked at the same version as react-router, which re-exports it, so the compiler sees the v7 API either way), and the lines the upgrade commit changed for react-router. One site per construct that the upgrade needed to change: an import line from react-router-dom that moved to react-router is a site; an existing react-router import line touched only to merge a moved name into it is not (it compiles unchanged at 7). Left out on purpose, because React Router 7 does not require them: Phoenix's `useLoaderData() as T` to `useLoaderData()` rewrites with tiny-invariant checks and the lines that follow from them (v7 still compiles the casts: useLoaderData returns any), its vite.config.mts @ts-expect-error comments, and its vite/vitest bumps. Phoenix's src/pages/projects/ProjectsPage.tsx:16 still imports react-router-dom after its upgrade, so it is not an expected site. Lago (PR #4287) is drafted the same way, with react-router-dom linked at 7.18.3 (react-router is not declared there; it is analyzed through react-router-dom). Kept from Lago, decided before scoring: every compiler site, each a separate construct (four typed future props, FutureConfig, eight generatePath null params, the TS1192 default import); the two createElement(..., { future }) calls of src/test-utils/routerFutureMock.ts, which the upgrade deletes and packs:truth does not list because the file is gone at the upgrade commit; and the two test assertions v7 behavior makes fail (generatePath escaping, Link href normalization), as generic. Left out of Lago: renamed test titles and added assertions, the jest-setup-early.ts filter for a v7_startTransition warning v7 never emits, the Cypress flow edits (navigation by click instead of visitApp, extra waits), the callers of the deleted mock helper and imports of the deleted flags, the wallet refactor's removed `as string` casts that still compile, and four TS7006 diagnostics in src/pages/InvoiceOverview.tsx that the draft reports and the upgrade never touched (an artifact of the linked environment, as docs/packs.md's next RESEARCH notes for ambient React types). A parent is kept only if its own project compile passes (the repository's type-check script and tsconfig, not a bare tsc), or if every error it reports comes from an unbuilt workspace package the upgrade does not touch, shown by building that package and compiling again (RESEARCH.md, \"Parents that do not type-check\"). Phoenix's app/ compiles clean. Lago's scripts/check-types.js reports 188 errors, all from importing lago-design-system before it is built; with that package's declarations emitted as its own build does, the parent and the upgrade commit both compile clean, and the parent's code against the upgrade's dependencies reports exactly the fourteen compiler sites below. wire-webapp (PR #22299) and OpenMetadata (PR #31641) were drafted and are left out under that rule: wire's tsconfig.type-check.json reports 1979 errors at the parent, including a gitignored generated module (src/script/components/icon.tsx) that no workspace build produces and five in testUtil.tsx, a file the upgrade edits; OpenMetadata's tsc:check reports 1481, including unrelated ones (unused locals, spread arguments) and one in AppRoot.tsx, a file the upgrade edits.", + "package": "react-router", + "repos": [ + { + "repo": "Arize-ai/phoenix", + "commit": "4d6c1dfb01e9406cf769efab14f0350ea2d0a0f8", + "migration": "https://github.com/Arize-ai/phoenix/commit/3e9dc43bcc686e8ff3d57a028a9fcc15f3935490", + "directory": "app", + "from": "6.25.1", + "to": "7.4.0", + "why": "Arize Phoenix web app (app/, pnpm): a data router with createBrowserRouter and loaders on React 18, react-router and react-router-dom both declared; the upgrade moves every react-router-dom import to react-router and RouterProvider to react-router/dom (with moduleResolution bundler), and drops react-router-dom", + "findings": [ + { + "file": "src/Routes.tsx", + "line": 2, + "rule": "dom-entry-imports", + "note": "RouterProvider moves to react-router/dom; the same commit sets moduleResolution bundler so that it resolves" + }, + { + "file": "src/Routes.tsx", + "line": 3, + "rule": "dom-imports" + }, + { + "file": "src/components/Link.tsx", + "line": 2, + "rule": "dom-imports" + }, + { + "file": "src/components/auth/CreateAPIKeyDialog.tsx", + "line": 3, + "rule": "dom-imports" + }, + { + "file": "src/components/button/LinkButton.tsx", + "line": 2, + "rule": "dom-imports" + }, + { + "file": "src/components/nav/Navbar.tsx", + "line": 2, + "rule": "dom-imports" + }, + { + "file": "src/pages/ModelRoot.tsx", + "line": 3, + "rule": "dom-imports" + }, + { + "file": "src/pages/auth/LoginPage.tsx", + "line": 2, + "rule": "dom-imports" + }, + { + "file": "src/pages/auth/ResetPasswordWithTokenPage.tsx", + "line": 3, + "rule": "dom-imports" + }, + { + "file": "src/pages/dataset/datasetLoader.ts", + "line": 2, + "rule": "dom-imports" + }, + { + "file": "src/pages/examples/examplesLoader.tsx", + "line": 2, + "rule": "dom-imports" + }, + { + "file": "src/pages/experiment/ExperimentComparePage.tsx", + "line": 2, + "rule": "dom-imports" + }, + { + "file": "src/pages/experiment/ExperimentRunFilterConditionField.tsx", + "line": 7, + "rule": "dom-imports" + }, + { + "file": "src/pages/experiment/experimentCompareLoader.ts", + "line": 2, + "rule": "dom-imports" + }, + { + "file": "src/pages/experiments/experimentsLoader.tsx", + "line": 2, + "rule": "dom-imports" + }, + { + "file": "src/pages/home/homeLoader.ts", + "line": 3, + "rule": "dom-imports" + }, + { + "file": "src/pages/playground/Playground.tsx", + "line": 4, + "rule": "dom-imports" + }, + { + "file": "src/pages/playground/PlaygroundDatasetExamplesTable.tsx", + "line": 20, + "rule": "dom-imports" + }, + { + "file": "src/pages/playground/PlaygroundDatasetPicker.tsx", + "line": 2, + "rule": "dom-imports" + }, + { + "file": "src/pages/playground/PlaygroundExamplePage.tsx", + "line": 2, + "rule": "dom-imports" + }, + { + "file": "src/pages/project/projectLoader.ts", + "line": 2, + "rule": "dom-imports" + }, + { + "file": "src/pages/prompt/PromptVersionsList.tsx", + "line": 3, + "rule": "dom-imports" + }, + { + "file": "src/pages/prompt/promptLoader.tsx", + "line": 2, + "rule": "dom-imports" + }, + { + "file": "src/pages/prompt/promptPlaygroundLoader.tsx", + "line": 1, + "rule": "dom-imports" + }, + { + "file": "src/pages/prompt/promptVersionLoader.tsx", + "line": 2, + "rule": "dom-imports" + }, + { + "file": "src/pages/prompt/promptVersionsLoader.tsx", + "line": 5, + "rule": "dom-imports" + }, + { + "file": "src/pages/prompts/PromptsTable.tsx", + "line": 3, + "rule": "dom-imports" + }, + { + "file": "src/pages/prompts/promptsLoader.ts", + "line": 2, + "rule": "dom-imports" + }, + { + "file": "src/pages/trace/TraceDetails.tsx", + "line": 4, + "rule": "dom-imports" + }, + { + "file": "src/pages/trace/sessionLoader.ts", + "line": 2, + "rule": "dom-imports" + } + ] + }, + { + "repo": "getlago/lago-front", + "commit": "84aa05d535edc326a4d104923e4e7293d1be6646", + "migration": "https://github.com/getlago/lago-front/commit/6055a5a3c8076522235fe2da452a209e99af8541", + "from": "6.30.6", + "to": "7.18.3", + "with": { + "react-router-dom": "7.18.3" + }, + "why": "Lago web app (pnpm, TypeScript 6.0.3, React 18): declares only react-router-dom 6.30.6, the usual v6 setup, so react-router is analyzed through it; v6 future flags passed as a shared variable to BrowserRouter and MemoryRouter and injected untyped into jest router mocks, the removed FutureConfig type, and generatePath calls passing null for required params", + "findings": [ + { + "file": "src/App.tsx", + "line": 128, + "rule": "future-prop", + "note": "TS2322: ; the prop is a variable, so the edit is assisted" + }, + { + "file": "src/App.tsx", + "line": 141, + "rule": "future-prop", + "note": "TS2322: " + }, + { + "file": "src/components/activityLogs/__tests__/utils.test.tsx", + "line": 348, + "rule": "generic", + "note": "generatePath escapes interpolated params in 7.x: the expected path changes" + }, + { + "file": "src/components/activityLogs/utils.ts", + "line": 123, + "rule": "generate-path-params", + "note": "TS2322: a required generatePath param given string | null" + }, + { + "file": "src/components/invoices/details/ViewFeeDetailsDrawer.tsx", + "line": 413, + "rule": "future-prop", + "note": "TS2322: " + }, + { + "file": "src/components/wallets/WalletAlerts.tsx", + "line": 223, + "rule": "generate-path-params", + "note": "TS2322: customerId ?? null" + }, + { + "file": "src/components/wallets/WalletTransactionList.tsx", + "line": 198, + "rule": "generate-path-params", + "note": "TS2322: customerId ?? null" + }, + { + "file": "src/core/router/__tests__/Link.test.tsx", + "line": 94, + "rule": "generic", + "note": " renders a normalized href in 7.x: the expected href changes" + }, + { + "file": "src/core/router/futureFlags.ts", + "line": 1, + "rule": "removed-exports", + "note": "TS2305: FutureConfig is not exported by react-router-dom 7; the file is deleted" + }, + { + "file": "src/hooks/wallet/useWalletActions.ts", + "line": 74, + "rule": "generate-path-params", + "note": "TS2322: customerId ?? null" + }, + { + "file": "src/hooks/wallet/useWalletActions.ts", + "line": 101, + "rule": "generate-path-params", + "note": "TS2322: customerId ?? null" + }, + { + "file": "src/pages/wallet/WalletDetails.tsx", + "line": 244, + "rule": "generate-path-params", + "note": "TS2322: customerId ?? null" + }, + { + "file": "src/pages/wallet/WalletDetails.tsx", + "line": 281, + "rule": "generate-path-params", + "note": "TS2322: customerId ?? null" + }, + { + "file": "src/pages/wallet/WalletDetails.tsx", + "line": 336, + "rule": "generate-path-params", + "note": "TS2322: customerId ?? null" + }, + { + "file": "src/test-utils.tsx", + "line": 6, + "rule": "generic", + "note": "TS1192: react-router-dom 7 has no default export; `import Router` becomes `import * as Router`" + }, + { + "file": "src/test-utils.tsx", + "line": 47, + "rule": "future-prop", + "note": "TS2322: " + }, + { + "file": "src/test-utils/routerFutureMock.ts", + "line": 12, + "rule": "future-prop", + "note": "createElement(actual.BrowserRouter, { future, ...props }) with untyped props: no compiler error, the file is deleted by the upgrade" + }, + { + "file": "src/test-utils/routerFutureMock.ts", + "line": 14, + "rule": "future-prop", + "note": "createElement(actual.MemoryRouter, { future, ...props }), likewise" + } + ] + } + ] +} diff --git a/packages/core/src/packs/react-router/index.ts b/packages/core/src/packs/react-router/index.ts new file mode 100644 index 0000000..a58285c --- /dev/null +++ b/packages/core/src/packs/react-router/index.ts @@ -0,0 +1,239 @@ +import { definePack } from '../contract.js'; +import { reactRouterBehavior } from './behavior.js'; +import { + domEntrySites, + domImportSites, + domServerSites, + formMethodSites, + futureOptionSites, + moveDomImport, + removedCallSites, + untypedFuturePropSites, +} from './detect.js'; + +/** + * react-router >=6 <7 → >=7 <8, with react-router-dom (a re-export of react-router in v7) + * moving in lockstep. Sources read 2026-10-14, pinned to the release commits below; the full + * classification, why 6 → 8 is two upgrades, and the ground truth are in RESEARCH.md. Only + * edits that hold whatever the surrounding code is are mechanical; the six v6 future flags + * become defaults and are behavior notes, scoped by the repository's router setup. + */ +const repo = 'https://github.com/remix-run/react-router/blob'; +const v7 = '1b1e0b0e79b21692ce907933475233babdd16e3e'; // react-router@7.18.4 +const v6 = 'dead4962d6c33e962f266af910b7a09e3ece30cf'; // react-router-dom@6.30.6 +const upgrading = `${repo}/${v7}/docs/upgrading/v6.md`; + +export const reactRouterPack = definePack({ + meta: { + package: 'react-router', + from: '>=6 <7', + to: '>=7 <8', + sources: [ + { + title: 'Upgrading from v6, pinned at react-router@7.18.4 (read 2026-10-14)', + url: upgrading, + }, + { + title: 'v6 future flags, pinned at react-router-dom@6.30.6 (read 2026-10-14)', + url: `${repo}/${v6}/docs/upgrading/future.md`, + }, + { + title: 'react-router CHANGELOG 7.0.0 to 7.18.4 (read 2026-10-14)', + url: `${repo}/${v7}/packages/react-router/CHANGELOG.md`, + }, + { + title: 'react-router-dom CHANGELOG 7.0.0 (read 2026-10-14)', + url: `${repo}/${v7}/packages/react-router-dom/CHANGELOG.md`, + }, + { + title: 'react-router-dom 7 entry: re-exports react-router and react-router/dom', + url: `${repo}/e8e3b004d94a949372e77ce97a543bff5bc6fb89/packages/react-router-dom/index.ts`, + }, + { + title: 'Release notes 7.0.0, root CHANGELOG (read 2026-10-14)', + url: `${repo}/${v7}/CHANGELOG.md`, + }, + ], + maintainer: 'uptide-dev', + }, + defaultTarget: '7.18.4', + releaseGroup: [ + { + // Published at every react-router 7 version, pinning it exactly; the guide upgrades both. + name: 'react-router-dom', + source: `${repo}/${v7}/packages/react-router-dom/CHANGELOG.md`, + }, + ], + // What most v6 applications declare instead: it installs react-router at its own version. + // `check react-router-dom` is this pack, and `fix` declares react-router beside it. + alternateLeads: [ + { + name: 'react-router-dom', + source: `${repo}/${v7}/docs/upgrading/v6.md`, + }, + ], + inventory: [ + 'package.json', + '{.nvmrc,.node-version}', + '{.eslintrc,.eslintrc.json,.eslintrc.js,.eslintrc.cjs,eslint.config.js,eslint.config.mjs,eslint.config.cjs,eslint.config.ts}', + '{src,app,lib,client,test,tests,__tests__,__mocks__}/**/*.{ts,tsx,js,jsx,mts,mjs,cts,cjs}', + '*.{ts,tsx,js,jsx,mts,mjs}', + ], + rules: [ + { + id: 'dom-imports', + summary: + 'import from react-router instead of react-router-dom, which v7 keeps only as a re-export', + severity: 'deprecated', + kinds: [], + symbols: /$^/, + detect: domImportSites, + rewrite: (text, finding, context) => moveDomImport(text, finding, context), + guide: + 'react-router-dom 7 is `export * from "react-router"` plus RouterProvider and HydratedRouter from "react-router/dom", and React Router 8 removes it. The guide moves every import to "react-router". Only the module specifier changes. A test that mocks "react-router-dom" by name (jest.mock, vi.mock, vi.importActual, __mocks__/react-router-dom) no longer stands in for code that imports "react-router": move the mock and the imports together, or leave both.', + }, + { + id: 'dom-entry-imports', + summary: + 'RouterProvider and HydratedRouter come from react-router/dom in a DOM app in v7; a namespace or wildcard react-router-dom import needs a choice', + severity: 'deprecated', + kinds: [], + symbols: /$^/, + detect: (text, file) => domEntrySites(text, file), + guide: + 'In a DOM app, import RouterProvider (and HydratedRouter) from "react-router/dom": it is the one wired to ReactDOM.flushSync; the "react-router" RouterProvider is for non-DOM code and Jest tests, as the guide says. Every other name comes from "react-router". "react-router/dom" resolves only under moduleResolution bundler, node16 or nodenext; under node/node10 keep this import on react-router-dom 7 for now. A decision for the owner: say which entry each file uses and why.', + }, + { + id: 'dom-server-entry', + summary: + 'react-router-dom/server is gone in v7: StaticRouter, createStaticHandler and the rest come from react-router', + severity: 'breaking', + kinds: ['type'], + symbols: /^TS2307$/, + message: /react-router-dom\/server/, + detect: domServerSites, + guide: + 'react-router-dom 7 exports only its main entry, so "react-router-dom/server" no longer resolves even when react-router-dom is kept. StaticRouter, StaticRouterProvider, createStaticHandler, createStaticRouter and their types are exported by "react-router": change the module specifier to "react-router" and nothing else.', + }, + { + id: 'future-prop', + summary: 'router components take no `future` prop in v7: the v6 flags are the default', + severity: 'breaking', + kinds: ['type', 'removed', 'signature'], + symbols: + /(?:^TS2322$|(?:^|:)(?:BrowserRouter|HashRouter|MemoryRouter|Router|StaticRouter|RouterProvider|(?:unstable_)?HistoryRouter)$)/, + message: /Property 'future' does not exist/, + guide: + 'Remove the `future` prop: every v6 flag it set is v7 behavior, and the prop no longer exists. A flag set to false or left out changes behavior now; the behavior notes say which. When the prop is a variable, remove the variable too if nothing else reads it.', + }, + { + id: 'future-prop-untyped', + summary: + 'a router given the v6 `future` prop through untyped props (`createElement`, a jest mock): v7 ignores it', + severity: 'deprecated', + kinds: [], + symbols: /$^/, + detect: untypedFuturePropSites, + guide: + 'The compiler cannot see a `future` prop passed through untyped props (`createElement(actual.BrowserRouter, { future, ...props })` in a jest mock of react-router-dom). v7 ignores it, so it keeps compiling and does nothing: remove it, and the mock or helper that exists only to inject it, keeping every other prop.', + }, + { + id: 'future-option', + summary: 'the `future` option of a router factory holds v6 flags that v7 ignores', + severity: 'deprecated', + kinds: [], + symbols: /$^/, + detect: futureOptionSites, + guide: + 'v7 types `future` as an empty interface: the v6 keys still compile, do nothing, and become errors in 8.4. Remove the `future` property (and the options argument when it held nothing else); keep any other option.', + }, + { + id: 'fallback-element', + summary: + '`` is removed: the root route renders `HydrateFallback`', + severity: 'breaking', + kinds: ['type', 'removed', 'signature'], + symbols: /(?:^TS2322$|(?:^|:)RouterProvider$)/, + message: /Property 'fallbackElement' does not exist/, + guide: + 'Move the element to the root route as `hydrateFallbackElement` (route objects) or a `HydrateFallback` component, and remove the prop. With several root routes, it belongs on the one that renders first. useNavigation().state is "idle" during the initial load now: code that showed a spinner from it during the first load needs the fallback instead.', + }, + { + id: 'removed-json', + summary: + '`json()` is removed: return the data, or `data(value, init)` for a status or headers', + severity: 'breaking', + kinds: ['type', 'removed'], + symbols: /(?:^TS(?:2305|2724|2614)$|(?:^|:)(?:json|JsonFunction)$)/, + message: /(?:^$|'(?:json|JsonFunction)')/, + detect: (text) => removedCallSites(text, ['json']), + guide: + 'Choose per call, and report the choice in decisions: `return json(value)` becomes `return value` (v7 hands loader data to the component as is: a Date or class instance stays one instead of becoming JSON); `json(value, { status, headers })` becomes `data(value, { status, headers })` from "react-router"; where a real Response is needed (a fetch handler, a test that reads `.json()` or `.status` on the result), use `Response.json(value, init)`. Tests that call a loader and read the Response change with it.', + }, + { + id: 'removed-defer', + summary: + '`defer()` and `AbortedDeferredError` are removed: return the promises in a plain object', + severity: 'breaking', + kinds: ['type', 'removed'], + symbols: + /(?:^TS(?:2305|2724|2614)$|(?:^|:)(?:defer|AbortedDeferredError|TypedDeferredData|UNSAFE_DeferredData|UNSAFE_DEFERRED_SYMBOL)$)/, + message: + /(?:^$|'(?:defer|AbortedDeferredError|TypedDeferredData|UNSAFE_DeferredData|UNSAFE_DEFERRED_SYMBOL)')/, + detect: (text) => removedCallSites(text, ['defer', 'AbortedDeferredError']), + guide: + '`return defer({ a, b })` becomes `return { a, b }`; with an init, `data({ a, b }, init)`. `` and `useAsyncValue` read the promises as before. v6 aborted deferred promises on navigation (AbortedDeferredError); raw promises are not aborted: code that caught that error, or relied on cancellation, needs a decision.', + }, + { + id: 'removed-exports', + summary: + 'type aliases and internals removed from v7: Hash, Pathname, Search are `string`, V7_FormMethod is FormMethod; FutureConfig and UNSAFE_useRouteId have no replacement', + severity: 'breaking', + kinds: ['type', 'removed'], + symbols: + /(?:^TS(?:2305|2724|2459|2614)$|(?:^|:)(?:Hash|Pathname|Search|V7_FormMethod|FutureConfig|UNSAFE_useRouteId)$)/, + message: /(?:^$|'(?:Hash|Pathname|Search|V7_FormMethod|FutureConfig|UNSAFE_useRouteId)')/, + guide: + '`Hash`, `Pathname` and `Search` were aliases of `string`: use `string`. `V7_FormMethod` is v7 `FormMethod` (uppercase). `FutureConfig` described the v6 flags, which are gone: delete what uses it. `UNSAFE_useRouteId` has no public replacement: stop and say so rather than reaching into internals.', + }, + { + id: 'form-method-case', + summary: + '`formMethod` is uppercase in v7: a comparison with a lowercase method is always false', + severity: 'breaking', + kinds: ['type'], + symbols: /^TS(?:2367|2678|2820)$/, + message: + /"(?:get|post|put|patch|delete)"[\s\S]*"(?:GET|POST|PUT|PATCH|DELETE)"|"(?:GET|POST|PUT|PATCH|DELETE)"[\s\S]*"(?:get|post|put|patch|delete)"/, + detect: formMethodSites, + guide: + 'Compare `formMethod` with the uppercase method ("POST"). A value typed `FormMethod` that is assigned a lowercase literal takes the uppercase one. `` and `submit({ method: "post" })` still accept lowercase: leave them.', + }, + { + id: 'generate-path-params', + summary: + '`generatePath` requires a string for each required `:param` (7.14.2): `null` no longer type-checks', + severity: 'breaking', + kinds: ['signature', 'type', 'required'], + symbols: /(?:(?:^|:)generatePath$|^TS2322$)/, + // The compiler reports the param's line with no word of generatePath: what it says there. + message: /(?:^$|null' is not assignable to type 'string')/, + guide: + 'Since 7.14.2 a required `:param` of `generatePath` takes a string; `null` (which threw at runtime) is rejected, an optional `:param?` may be omitted, and extra keys are allowed. Choose per call, and report the choice in decisions: return or render nothing before building the path while the value is missing; make the segment optional (`:id?`) only if the route really is; or pass the value once it is proven present. Never add a non-null assertion, `as string` or an empty-string fallback that builds a wrong URL.', + }, + { + id: 'stabilized-options', + summary: + '`unstable_dataStrategy` and `unstable_patchRoutesOnNavigation` are `dataStrategy` and `patchRoutesOnNavigation`', + severity: 'breaking', + kinds: ['type', 'signature'], + symbols: /^TS(?:2353|2561|2345|2769)$/, + message: /unstable_(?:dataStrategy|patchRoutesOnNavigation|patchRoutesOnMiss)/, + guide: + 'Rename the option, then check the function against the v7 `DataStrategyFunction` / `PatchRoutesOnNavigationFunction` types: dataStrategy returns results keyed by route id (not an array) since 6.28. Keep what it loads and when.', + }, + ], + behavior: reactRouterBehavior, + instructions: + 'Sources read 2026-10-14; see RESEARCH.md. Migrate only the reported sites to React Router 7, from the compiler error and the guide above. Keep routes, paths, loaders, actions and what each returns to the component as they are. Never introduce framework mode (routes.ts, the Vite plugin, route modules), never restructure the route tree, and never cast to any or suppress a diagnostic to make it compile. Where the guide offers options (json, defer, the RouterProvider entry), choose one and report it in decisions with the files.', +}); diff --git a/packages/core/src/packs/react-router/react-router.test.ts b/packages/core/src/packs/react-router/react-router.test.ts new file mode 100644 index 0000000..8434e6b --- /dev/null +++ b/packages/core/src/packs/react-router/react-router.test.ts @@ -0,0 +1,180 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import type { Finding } from '../../domain/report.js'; +import { packDecisions } from '../decisions.js'; +import { runFixtures } from '../tooling/fixtures.js'; +import type { PackContext } from '../types.js'; +import { + domImportSites, + formMethodSites, + futureOptionSites, + moveDomImport, + moveDomServerImport, + removeFutureOption, + removeFutureProp, + uppercaseFormMethod, +} from './detect.js'; +import { reactRouterPack } from './index.js'; + +it('react-router: every fixture rewrites and detects exactly its marked sites', () => { + const result = runFixtures(reactRouterPack, fileURLToPath(new URL('.', import.meta.url))); + expect(result.cases.length).toBeGreaterThan(0); + expect(result.unknown).toEqual([]); + expect(result.rewriteFailures).toEqual([]); + expect(result.falsePositives).toEqual([]); + expect(result.falseNegatives).toEqual([]); +}); + +const site = (line: number, column: number) => ({ usage: { line, column } }) as Finding; +const context = (inventory: { file: string; text: string }[]): PackContext => ({ + from: '6.30.1', + to: '7.18.4', + includeDeprecated: true, + inventory, +}); +const decisions = (inventory: { file: string; text: string }[]) => + packDecisions(reactRouterPack, context(inventory), []).join('\n'); + +describe('react-router-dom imports', () => { + const code = "import { Link } from 'react-router-dom';\n"; + const [at] = domImportSites(code); + + it('moves the specifier when nothing mocks react-router-dom by name', () => { + expect(at).toMatchObject({ line: 1, column: 22 }); + const out = moveDomImport(code, site(at?.line ?? 0, at?.column ?? 0), context([])); + expect(out).toMatchObject({ applied: true, text: "import { Link } from 'react-router';\n" }); + }); + + it.each([ + ['a vi.mock elsewhere', 'src/a.test.tsx', "vi.mock('react-router-dom', async () => ({}));"], + [ + 'a jest.requireActual', + 'src/b.test.tsx', + "jest.mock('x', () => jest.requireActual('react-router-dom'));", + ], + [ + 'vi.importActual with a type', + 'src/c.test.ts', + 'await vi.importActual("react-router-dom");', + ], + ['a __mocks__ module', '__mocks__/react-router-dom.ts', 'export const useNavigate = () => {};'], + ])('declines when %s would stop standing in for the moved import', (_, file, text) => { + const out = moveDomImport(code, site(1, 22), context([{ file, text }])); + expect(out.applied).toBe(false); + expect(out.reason).toContain(file); + }); + + it('declines in a file that mocks react-router-dom itself', () => { + const own = `${code}vi.mock('react-router-dom');\n`; + expect(moveDomImport(own, site(1, 22), context([])).applied).toBe(false); + }); +}); + +describe('behavior notes are scoped by the router setup', () => { + const main = (future: string) => ({ + file: 'src/main.tsx', + text: `import { createBrowserRouter, RouterProvider } from 'react-router-dom';\nconst router = createBrowserRouter([{ path: 'dashboard/*', action: async () => null }], { future: { ${future} } });\nexport const App = () => ;\n`, + }); + + it('names the files and lines where a default flag changes behavior', () => { + const text = decisions([main('v7_fetcherPersist: true')]); + expect(text).toContain('multi-segment splat routes (`src/main.tsx:2`)'); + expect(text).toContain('routers (`src/main.tsx:2`, `src/main.tsx:3`)'); + expect(text).toContain('route actions (`src/main.tsx:2`)'); + }); + + it('omits a note whose flag the repository already enabled', () => { + const text = decisions([main('v7_relativeSplatPath: true, v7_startTransition: true')]); + expect(text).not.toContain('v7_relativeSplatPath'); + expect(text).not.toContain('v7_startTransition'); + expect(text).toContain('v7_skipActionErrorRevalidation'); + }); + + it('omits flag notes without a router, and package notes without the package', () => { + const text = decisions([ + { + file: 'package.json', + text: '{"dependencies":{"react":"^18.3.1","react-router":"6.30.1"}}', + }, + ]); + expect(text).not.toMatch(/v7_|react-router-dom|@remix-run\/router|requires React 18/); + expect(text).toContain('v6 to v7 upgrade guide'); + }); + + it('reports platform floors and the packages v7 folds in', () => { + const text = decisions([ + { + file: 'package.json', + text: '{"dependencies":{"react":"^17.0.2","react-router-dom":"6.30.1","@remix-run/router":"1.23.0"},"engines":{"node":">=18"}}', + }, + { file: '.nvmrc', text: '18.20.4\n' }, + ]); + expect(text).toContain('`package.json` declares react ^17.0.2'); + expect(text).toContain('`.nvmrc` pins Node 18.20.4'); + expect(text).toContain('`package.json` declares react-router-dom'); + expect(text).toContain('`package.json` declares it'); + }); +}); + +describe('rewrites kept assisted until ground truth exercises them', () => { + // Not wired into the pack: no public upgrade in the ground truth has such a site yet + // (RESEARCH.md). The agent gets the guide; these stay correct for when one does. + it.each(['dom-server-entry', 'form-method-case', 'future-option', 'future-prop'])( + '%s has no rewrite', + (id) => { + expect(reactRouterPack.rules.find((r) => r.id === id)?.rewrite).toBeUndefined(); + }, + ); + + it('moves react-router-dom/server to react-router', () => { + const code = "import { StaticRouter } from 'react-router-dom/server';\n"; + expect(moveDomServerImport(code, site(1, 30)).text).toBe( + "import { StaticRouter } from 'react-router';\n", + ); + }); + + it('removes the v6 future option, and the options argument it leaves empty', () => { + const code = + "import { createBrowserRouter } from 'react-router-dom';\nexport const r = createBrowserRouter([], { future: { v7_relativeSplatPath: true } });\n"; + const [at] = futureOptionSites(code); + expect(removeFutureOption(code, site(at?.line ?? 0, at?.column ?? 0)).text).toContain( + 'createBrowserRouter([]);', + ); + }); + + it('removes an inline v6 future prop, and declines one it cannot prove unused', () => { + // The future-prop fixture's sites: its markers say which the edit must take and which decline. + const code = readFileSync(new URL('fixtures/future-prop/before.ts', import.meta.url), 'utf8'); + const lines = code.split('\n'); + const sites = lines.flatMap((text, i) => { + const marker = /@uptide future-prop( keep)? at:(\w+)/.exec(text); + return marker?.[2] + ? [{ line: i + 1, column: text.indexOf(`<${marker[2]}`) + 2, keep: !!marker[1] }] + : []; + }); + expect(sites.map((s) => s.keep)).toEqual([false, false, true, true, false, true]); + let text = code; + for (const s of [...sites].reverse()) { + const out = removeFutureProp(text, site(s.line, s.column)); + expect(out.applied).toBe(!s.keep); + text = out.text; + } + expect(text).toContain('; // @uptide'); + expect(text).toContain('x; // @uptide'); + expect(text).toContain('future={flags}'); + expect(text).toContain('future={{ v7_startTransition: Boolean(flags) }}'); + expect(text).toContain(''); + }); + + it('uppercases the method compared with formMethod', () => { + const code = + "import { useNavigation } from 'react-router-dom';\nexport const f = (n: ReturnType) => n.formMethod === 'post';\n"; + const [at] = formMethodSites(code); + expect(uppercaseFormMethod(code, site(at?.line ?? 0, at?.column ?? 0)).text).toContain( + "n.formMethod === 'POST'", + ); + }); +}); diff --git a/packages/core/src/packs/react-router/verification.json b/packages/core/src/packs/react-router/verification.json new file mode 100644 index 0000000..3991ed7 --- /dev/null +++ b/packages/core/src/packs/react-router/verification.json @@ -0,0 +1,10 @@ +{ + "status": "verified", + "truth": "sha256-2b693b9c3d4700152a748eaa3605836c48251cffd77ef3b9e3c2435abcf3d3c4", + "repos": 2, + "breaking": { + "precision": 1, + "recall": 0.875, + "falsePositives": 0 + } +} diff --git a/packages/core/src/packs/registry.ts b/packages/core/src/packs/registry.ts index c06ae6c..05fcda5 100644 --- a/packages/core/src/packs/registry.ts +++ b/packages/core/src/packs/registry.ts @@ -10,6 +10,8 @@ import { nextPack } from './next/index.js'; import nextVerification from './next/verification.json' with { type: 'json' }; import { reactPack } from './react/index.js'; import reactVerification from './react/verification.json' with { type: 'json' }; +import { reactRouterPack } from './react-router/index.js'; +import reactRouterVerification from './react-router/verification.json' with { type: 'json' }; import { sentryBrowserPack } from './sentry-browser/index.js'; import sentryBrowserVerification from './sentry-browser/verification.json' with { type: 'json' }; import { sentryNextjsPack } from './sentry-nextjs/index.js'; @@ -32,6 +34,11 @@ export const REGISTRY: readonly RegisteredPack[] = [ }, { dir: 'next', pack: nextPack, verification: nextVerification as PackVerification }, { dir: 'react', pack: reactPack, verification: reactVerification as PackVerification }, + { + dir: 'react-router', + pack: reactRouterPack, + verification: reactRouterVerification as PackVerification, + }, { dir: 'sentry-browser', pack: sentryBrowserPack, diff --git a/packages/core/src/packs/tooling/registry-file.test.ts b/packages/core/src/packs/tooling/registry-file.test.ts index ece12ae..080b644 100644 --- a/packages/core/src/packs/tooling/registry-file.test.ts +++ b/packages/core/src/packs/tooling/registry-file.test.ts @@ -1,3 +1,8 @@ +import { spawnSync } from 'node:child_process'; +import { copyFileSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { expect, it } from 'vitest'; import { parseRegistry, renderRegistry } from './registry-file.js'; @@ -19,3 +24,30 @@ it('keeps an entry too long for one line through a later render', () => { const next = renderRegistry([...parseRegistry(text), { dir: 'ai', name: 'aiPack' }]); expect(parseRegistry(next).map((e) => e.dir)).toEqual(['ai', 'sentry-nextjs', 'zod']); }); + +it('orders imports as biome does when one pack name prefixes another', () => { + const repo = fileURLToPath(new URL('../../../../../', import.meta.url)); + const dir = mkdtempSync(join(tmpdir(), 'uptide-pack-registry-')); + try { + const entries = [ + { dir: 'react-router', name: 'reactRouterPack' }, + { dir: 'react', name: 'reactPack' }, + ]; + const text = renderRegistry(entries); + // Biome compares path segments: `./react/` before `./react-router/`, not bytes (`-` < `/`). + expect(text.indexOf("'./react/index.js'")).toBeLessThan(text.indexOf("'./react-router/")); + expect(parseRegistry(text)).toEqual([...entries].reverse()); + writeFileSync(join(dir, 'registry.ts'), text); + copyFileSync(join(repo, 'biome.json'), join(dir, 'biome.json')); + copyFileSync(join(repo, '.gitignore'), join(dir, '.gitignore')); + spawnSync('git', ['init', '-q'], { cwd: dir }); + const biome = spawnSync(join(repo, 'node_modules', '.bin', 'biome'), ['check', 'registry.ts'], { + cwd: dir, + encoding: 'utf8', + }); + expect(biome.stdout + biome.stderr).toContain('No fixes applied'); + expect(biome.status).toBe(0); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/packages/core/src/packs/tooling/registry-file.ts b/packages/core/src/packs/tooling/registry-file.ts index ca37b92..ef93ee1 100644 --- a/packages/core/src/packs/tooling/registry-file.ts +++ b/packages/core/src/packs/tooling/registry-file.ts @@ -26,7 +26,7 @@ export function renderRegistry(entries: readonly RegistryEntry[]): string { ], ]), ]; - imports.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); + imports.sort(([a], [b]) => byPath(a, b)); const rows = sorted.map((e) => { const row = ` { dir: '${e.dir}', pack: ${e.name}, verification: ${verificationName(e)} as PackVerification },`; // Past biome's line width (100) the entry is written the way biome wraps it. @@ -52,6 +52,16 @@ export function renderRegistry(entries: readonly RegistryEntry[]): string { ].join('\n'); } +/** Segment by segment, as biome compares them: `./react/` before `./react-router/`. */ +function byPath(a: string, b: string): number { + const [x, y] = [a.split('/'), b.split('/')]; + for (let i = 0; i < Math.min(x.length, y.length); i++) { + const [p, q] = [x[i] as string, y[i] as string]; + if (p !== q) return p < q ? -1 : 1; + } + return x.length - y.length; +} + function verificationName(e: RegistryEntry): string { return `${e.name.replace(/Pack$/, '')}Verification`; } diff --git a/packs/queue.json b/packs/queue.json index dd4e886..aa278bc 100644 --- a/packs/queue.json +++ b/packs/queue.json @@ -258,9 +258,9 @@ "package": "react-router", "members": ["react-router", "@types/react-router-dom", "react-router-dom"], "from": ">=6 <7", - "to": ">=8 <9", - "why": "declared directly in 23 of 52 sample repositories, 21 of them on an older major; 103 breaking API changes, 83 distinct (51 removed or renamed, 32 change shapes) from 6.30.6 to 8.4.0; upgraded with @types/react-router-dom, react-router-dom (react-router-dom re-exports react-router, needs react moved)", - "status": "todo", + "to": ">=7 <8", + "why": "declared directly in 23 of 52 sample repositories, 21 of them on an older major; 103 breaking API changes, 83 distinct (51 removed or renamed, 32 change shapes) from 6.30.6 to 8.4.0; upgraded with @types/react-router-dom, react-router-dom (react-router-dom re-exports react-router, needs react moved); split 2026-10-14: React Router documents 6 to 8 as two upgrades (the v6 guide exists only in the v7 docs, each guide assumes the previous major's future flags were adopted on that major, and react-router-dom has no 8.x), so this entry is the 6 to 7 pack and 7 to 8 is the next entry; the counts above measure 6.30.6 to 8.4.0", + "status": "verified", "repos": 23, "reposBehind": 21, "weeklyDownloads": 0, @@ -270,6 +270,17 @@ "guide": 9 } }, + { + "package": "react-router", + "members": ["react-router"], + "from": ">=7 <8", + "to": ">=8 <9", + "why": "split from the 6 to 8 entry above: the second upgrade, from the latest 7.x with the v8 future flags adopted on 7 (https://github.com/remix-run/react-router/blob/7ccdcecdd944e15be0cd0dcb702b86c9af1214a8/docs/upgrading/v7.md): react-router-dom removed, React 19.2.7 and Node 22.22 minimums, ESM only, middleware always on (context typed Readonly), UIMatch.data removed for loaderData, hasErrorBoundary removed, stale future keys rejected from 8.4; not measured separately", + "status": "todo", + "repos": 23, + "reposBehind": 21, + "weeklyDownloads": 0 + }, { "package": "prettier", "members": ["prettier", "eslint-plugin-prettier", "prettier-plugin-svelte"],