diff --git a/.changeset/react-pack.md b/.changeset/react-pack.md new file mode 100644 index 0000000..1e683ab --- /dev/null +++ b/.changeset/react-pack.md @@ -0,0 +1,13 @@ +--- +"uptide": patch +--- + +A candidate migration pack for React 18 → 19, scored against excalidraw/excalidraw and +tldraw/tldraw at the commit before their own upgrade. Rewrites by rule: `useRef()` → +`useRef(undefined)`, `ref={(el) => (x = el)}` → a block body, and `React.MutableRefObject` +→ `React.RefObject`. `RefObject`, the removed global `JSX` namespace, untyped +`element.props`, the removed react-dom APIs and `PropsWithRef` go to the agent with the guide; +what the compiler cannot see (errors no longer re-thrown, removed legacy APIs, `act` moved to +"react", Strict Mode and Suspense changes) is listed for review. It stays a candidate: `check` +does not yet move `@types/react` with `react`, and it reports 477 breaking sites that the +compiler accepts, so `check`, `list` and `fix` treat `react` as generic. diff --git a/packages/core/src/packs/react/detect.ts b/packages/core/src/packs/react/detect.ts new file mode 100644 index 0000000..55bb02d --- /dev/null +++ b/packages/core/src/packs/react/detect.ts @@ -0,0 +1,84 @@ +import { type ArrowFunction, 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 { TransformResult } from '../types.js'; + +/** + * Where an implicit return in a ref callback is: `ref={(el) => (inputRef.current = el)}`. + * React 19 lets a ref callback return a cleanup function, so its types reject any other + * return value, and an arrow function that assigns returns the assigned value. No type diff + * reports it (the prop's type is the same), and Text alone cannot tell a `ref` attribute from + * an object key named `ref`; the syntax tree can. + */ +const parsed = new Map(); +onReset(() => parsed.clear()); +function parse(text: string): SourceFile { + let source = parsed.get(text); + if (!source) { + source = new Project({ useInMemoryFileSystem: true }).createSourceFile('consumer.tsx', text); + if (parsed.size >= 16) parsed.delete(parsed.keys().next().value as string); + parsed.set(text, source); + } + return source; +} + +interface RefAssignment { + attribute: Node; + arrow: ArrowFunction; + /** The assignment the arrow returns, without the parentheses around it. */ + assignment: Node; +} + +/** `ref={(el) => (x = el)}`: a `ref` attribute whose arrow function returns an assignment. */ +function refAssignments(source: SourceFile): RefAssignment[] { + const found: RefAssignment[] = []; + for (const attribute of source.getDescendantsOfKind(SyntaxKind.JsxAttribute)) { + if (attribute.getNameNode().getText() !== 'ref') continue; + const initializer = attribute.getInitializer(); + if (!initializer || !Node.isJsxExpression(initializer)) continue; + const arrow = initializer.getExpression(); + if (!arrow || !Node.isArrowFunction(arrow)) continue; + let body: Node = arrow.getBody(); + while (Node.isParenthesizedExpression(body)) body = body.getExpression(); + if ( + Node.isBinaryExpression(body) && + body.getOperatorToken().getKind() === SyntaxKind.EqualsToken + ) + found.push({ attribute, arrow, assignment: body }); + } + return found; +} + +export function refCallbackReturnSites(text: string): SourceSite[] { + if (!text.includes('ref')) return []; + const source = parse(text); + return refAssignments(source).map(({ attribute }) => { + const { line, column } = source.getLineAndColumnAtPos(attribute.getStart()); + return { + line, + column, + snippet: (text.split('\n')[line - 1] ?? '').trim(), + name: 'ref', + }; + }); +} + +/** `(el) => (x = el)` becomes `(el) => { x = el; }`: the same assignment, nothing returned. */ +export function dropRefCallbackReturn( + text: string, + finding: Pick, +): TransformResult { + const source = parse(text); + for (const { attribute, arrow, assignment } of refAssignments(source)) { + const at = source.getLineAndColumnAtPos(attribute.getStart()); + if (at.line !== finding.usage.line || at.column !== finding.usage.column) continue; + const body = arrow.getBody(); + return { + text: `${text.slice(0, body.getStart())}{ ${assignment.getText()}; }${text.slice(body.getEnd())}`, + applied: true, + reason: 'a ref callback returns nothing: the assignment becomes a statement', + }; + } + return { text, applied: false, reason: 'the reported site is not a ref callback that assigns' }; +} diff --git a/packages/core/src/packs/react/fixtures/mutable-ref-object/after.ts b/packages/core/src/packs/react/fixtures/mutable-ref-object/after.ts new file mode 100644 index 0000000..df50ed3 --- /dev/null +++ b/packages/core/src/packs/react/fixtures/mutable-ref-object/after.ts @@ -0,0 +1,10 @@ +import * as React from 'react'; +import type { MutableRefObject } from 'react'; + +export type Handle = { + timer: React.RefObject; // @uptide mutable-ref-object at:React.MutableRefObject kind:deprecated path:MutableRefObject + // Imported by name: the import needs RefObject beside it, which is not the reported site. + data: MutableRefObject; // @uptide mutable-ref-object keep at:MutableRefObject kind:deprecated path:MutableRefObject + // Already the new type. + box: React.RefObject; +}; diff --git a/packages/core/src/packs/react/fixtures/mutable-ref-object/before.ts b/packages/core/src/packs/react/fixtures/mutable-ref-object/before.ts new file mode 100644 index 0000000..d4e22de --- /dev/null +++ b/packages/core/src/packs/react/fixtures/mutable-ref-object/before.ts @@ -0,0 +1,10 @@ +import * as React from 'react'; +import type { MutableRefObject } from 'react'; + +export type Handle = { + timer: React.MutableRefObject; // @uptide mutable-ref-object at:React.MutableRefObject kind:deprecated path:MutableRefObject + // Imported by name: the import needs RefObject beside it, which is not the reported site. + data: MutableRefObject; // @uptide mutable-ref-object keep at:MutableRefObject kind:deprecated path:MutableRefObject + // Already the new type. + box: React.RefObject; +}; diff --git a/packages/core/src/packs/react/fixtures/ref-callback-return/after.ts b/packages/core/src/packs/react/fixtures/ref-callback-return/after.ts new file mode 100644 index 0000000..f9e6228 --- /dev/null +++ b/packages/core/src/packs/react/fixtures/ref-callback-return/after.ts @@ -0,0 +1,26 @@ +import { useRef } from 'react'; + +export function Field({ onMount }: { onMount: (el: HTMLInputElement | null) => void }) { + const input = useRef(null); + let last: HTMLInputElement | null = null; + return ( +
+ { input.current = el; }} // @uptide ref-callback-return at:ref + /> + { last = el; }} // @uptide ref-callback-return keep at:ref + /> + { last = el; }} // @uptide ref-callback-return at:ref + name="b" + /> + + onMount(el)} /> + (el ? (last = el) : null)} /> +
+ ); +} + +// An object key named ref is not a ref prop. +export const config = { ref: (el: unknown) => (el as { x: number }).x }; diff --git a/packages/core/src/packs/react/fixtures/ref-callback-return/before.ts b/packages/core/src/packs/react/fixtures/ref-callback-return/before.ts new file mode 100644 index 0000000..e7f44e4 --- /dev/null +++ b/packages/core/src/packs/react/fixtures/ref-callback-return/before.ts @@ -0,0 +1,26 @@ +import { useRef } from 'react'; + +export function Field({ onMount }: { onMount: (el: HTMLInputElement | null) => void }) { + const input = useRef(null); + let last: HTMLInputElement | null = null; + return ( +
+ (input.current = el)} // @uptide ref-callback-return at:ref + /> + { last = el; }} // @uptide ref-callback-return keep at:ref + /> + (last = el)} // @uptide ref-callback-return at:ref + name="b" + /> + + onMount(el)} /> + (el ? (last = el) : null)} /> +
+ ); +} + +// An object key named ref is not a ref prop. +export const config = { ref: (el: unknown) => (el as { x: number }).x }; diff --git a/packages/core/src/packs/react/fixtures/use-ref-argument/after.ts b/packages/core/src/packs/react/fixtures/use-ref-argument/after.ts new file mode 100644 index 0000000..f85096c --- /dev/null +++ b/packages/core/src/packs/react/fixtures/use-ref-argument/after.ts @@ -0,0 +1,13 @@ +import * as React from 'react'; +import { useRef } from 'react'; + +export function Timer() { + const timer = useRef(undefined); // @uptide use-ref-argument at:useRef kind:signature path:TS2554 message:"Expected 1 arguments, but got 0." + const handler = React.useRef<(...args: string[]) => void>(undefined); // @uptide use-ref-argument at:React.useRef kind:signature path:TS2554 message:"Expected 1 arguments, but got 0." + const anything = useRef(undefined); // @uptide use-ref-argument at:useRef kind:signature path:TS2554 message:"Expected 1 arguments, but got 0." + // Already has an argument: nothing to add. + const input = useRef(null); // @uptide use-ref-argument keep at:useRef kind:signature path:TS2554 message:"Expected 1 arguments, but got 0." + // Two calls on one line: the reported site is ambiguous, so the rule declines. + const pair = [useRef(), useRef()]; // @uptide use-ref-argument keep at:useRef kind:signature path:TS2554 message:"Expected 1 arguments, but got 0." + return { timer, handler, anything, input, pair }; +} diff --git a/packages/core/src/packs/react/fixtures/use-ref-argument/before.ts b/packages/core/src/packs/react/fixtures/use-ref-argument/before.ts new file mode 100644 index 0000000..8744c0a --- /dev/null +++ b/packages/core/src/packs/react/fixtures/use-ref-argument/before.ts @@ -0,0 +1,13 @@ +import * as React from 'react'; +import { useRef } from 'react'; + +export function Timer() { + const timer = useRef(); // @uptide use-ref-argument at:useRef kind:signature path:TS2554 message:"Expected 1 arguments, but got 0." + const handler = React.useRef<(...args: string[]) => void>(); // @uptide use-ref-argument at:React.useRef kind:signature path:TS2554 message:"Expected 1 arguments, but got 0." + const anything = useRef(); // @uptide use-ref-argument at:useRef kind:signature path:TS2554 message:"Expected 1 arguments, but got 0." + // Already has an argument: nothing to add. + const input = useRef(null); // @uptide use-ref-argument keep at:useRef kind:signature path:TS2554 message:"Expected 1 arguments, but got 0." + // Two calls on one line: the reported site is ambiguous, so the rule declines. + const pair = [useRef(), useRef()]; // @uptide use-ref-argument keep at:useRef kind:signature path:TS2554 message:"Expected 1 arguments, but got 0." + return { timer, handler, anything, input, pair }; +} diff --git a/packages/core/src/packs/react/ground-truth.json b/packages/core/src/packs/react/ground-truth.json new file mode 100644 index 0000000..0686b23 --- /dev/null +++ b/packages/core/src/packs/react/ground-truth.json @@ -0,0 +1,725 @@ +{ + "$comment": "Ground truth for the react pack (docs/packs.md, \"Ground truth\"). Each public repository is pinned at the commit before its own React 18 to 19 upgrade. Expected findings come from two sources that are not Uptide, drafted by pnpm packs:truth and read by hand: the diagnostics the repository's TypeScript reports with react, react-dom, @types/react and @types/react-dom at the upgrade's versions linked in and not without them (errors and deprecations), and the lines the upgrade commit changed for react (one site per construct). Left out on purpose: comment-only and insertion-only lines, imports that follow a site, changes for other packages (vitest, testing-library, TypeScript target types, setTimeout typings), tldraw's casts of spread menu-item props and docs code.tsx lines that stayed valid, and excalidraw's forwardRef to ref-prop rewrite of DebugCanvas, which React 19 does not require. Rules are assigned from each site's code and text; the JSX element diagnostics of apps/dotcom/client (1745 of them) have one cause, the \"jsx\" setting of its tsconfig, and are one expected site there, at the line the upgrade changed; a JSX.Element written in code stays a site of its own.", + "package": "react", + "repos": [ + { + "repo": "excalidraw/excalidraw", + "commit": "9ee0b8ffcbd3664a47748a93262860321a203821", + "migration": "https://github.com/excalidraw/excalidraw/commit/31e8476c78aca31df1342dc26903e42301d2c3fd", + "from": "18.2.0", + "to": "19.0.0", + "with": { + "react-dom": "19.0.0", + "@types/react": "19.0.10", + "@types/react-dom": "19.0.4" + }, + "why": "Excalidraw: a React 18 canvas editor with a library package, an app and tests; forwardRef components, RefObject props, global JSX types and ReactDOM.unmountComponentAtNode in tests", + "findings": [ + { + "file": "dev-docs/src/components/Homepage/index.tsx", + "line": 1, + "rule": "generic" + }, + { + "file": "dev-docs/src/pages/index.tsx", + "line": 1, + "rule": "generic" + }, + { + "file": "examples/excalidraw/with-nextjs/src/app/page.tsx", + "line": 19, + "rule": "global-jsx-namespace" + }, + { + "file": "excalidraw-app/collab/CollabError.tsx", + "line": 22, + "rule": "use-ref-argument" + }, + { + "file": "packages/excalidraw/components/Actions.tsx", + "line": 410, + "rule": "global-jsx-namespace" + }, + { + "file": "packages/excalidraw/components/App.tsx", + "line": 1259, + "rule": "global-jsx-namespace" + }, + { + "file": "packages/excalidraw/components/App.tsx", + "line": 1773, + "rule": "ref-object-nullable" + }, + { + "file": "packages/excalidraw/components/ButtonIcon.tsx", + "line": 32, + "rule": "global-jsx-namespace" + }, + { + "file": "packages/excalidraw/components/ButtonIconCycle.tsx", + "line": 23, + "rule": "global-jsx-namespace" + }, + { + "file": "packages/excalidraw/components/ButtonIconSelect.tsx", + "line": 40, + "rule": "global-jsx-namespace" + }, + { + "file": "packages/excalidraw/components/DefaultSidebar.tsx", + "line": 102, + "rule": "global-jsx-namespace" + }, + { + "file": "packages/excalidraw/components/EyeDropper.tsx", + "line": 210, + "rule": "ref-object-nullable" + }, + { + "file": "packages/excalidraw/components/FontPicker/FontPickerList.tsx", + "line": 159, + "rule": "ref-object-nullable" + }, + { + "file": "packages/excalidraw/components/FontPicker/keyboardNavHandlers.ts", + "line": 7, + "rule": "ref-object-nullable", + "note": "changed by the upgrade" + }, + { + "file": "packages/excalidraw/components/HelpDialog.tsx", + "line": 109, + "rule": "global-jsx-namespace" + }, + { + "file": "packages/excalidraw/components/IconPicker.tsx", + "line": 117, + "rule": "global-jsx-namespace" + }, + { + "file": "packages/excalidraw/components/IconPicker.tsx", + "line": 221, + "rule": "global-jsx-namespace" + }, + { + "file": "packages/excalidraw/components/JSONExportDialog.tsx", + "line": 45, + "rule": "global-jsx-namespace" + }, + { + "file": "packages/excalidraw/components/LayerUI.tsx", + "line": 197, + "rule": "global-jsx-namespace" + }, + { + "file": "packages/excalidraw/components/LayerUI.tsx", + "line": 198, + "rule": "global-jsx-namespace" + }, + { + "file": "packages/excalidraw/components/LayerUI.tsx", + "line": 253, + "rule": "global-jsx-namespace" + }, + { + "file": "packages/excalidraw/components/LayerUI.tsx", + "line": 332, + "rule": "global-jsx-namespace" + }, + { + "file": "packages/excalidraw/components/LayerUI.tsx", + "line": 352, + "rule": "global-jsx-namespace" + }, + { + "file": "packages/excalidraw/components/LayerUI.tsx", + "line": 492, + "rule": "global-jsx-namespace" + }, + { + "file": "packages/excalidraw/components/LayerUI.tsx", + "line": 538, + "rule": "global-jsx-namespace" + }, + { + "file": "packages/excalidraw/components/LibraryMenuItems.tsx", + "line": 63, + "rule": "ref-object-nullable" + }, + { + "file": "packages/excalidraw/components/MagicButton.tsx", + "line": 35, + "rule": "global-jsx-namespace" + }, + { + "file": "packages/excalidraw/components/MobileMenu.tsx", + "line": 76, + "rule": "global-jsx-namespace" + }, + { + "file": "packages/excalidraw/components/MobileMenu.tsx", + "line": 80, + "rule": "global-jsx-namespace" + }, + { + "file": "packages/excalidraw/components/MobileMenu.tsx", + "line": 96, + "rule": "global-jsx-namespace" + }, + { + "file": "packages/excalidraw/components/MobileMenu.tsx", + "line": 139, + "rule": "global-jsx-namespace" + }, + { + "file": "packages/excalidraw/components/MobileMenu.tsx", + "line": 146, + "rule": "global-jsx-namespace" + }, + { + "file": "packages/excalidraw/components/Sidebar/Sidebar.tsx", + "line": 104, + "rule": "ref-object-nullable" + }, + { + "file": "packages/excalidraw/components/Sidebar/SidebarTrigger.tsx", + "line": 39, + "rule": "global-jsx-namespace" + }, + { + "file": "packages/excalidraw/components/Stack.tsx", + "line": 13, + "rule": "ref-object-nullable", + "note": "changed by the upgrade" + }, + { + "file": "packages/excalidraw/components/Stack.tsx", + "line": 17, + "rule": "global-jsx-namespace" + }, + { + "file": "packages/excalidraw/components/Stack.tsx", + "line": 39, + "rule": "global-jsx-namespace" + }, + { + "file": "packages/excalidraw/components/Stats/index.tsx", + "line": 188, + "rule": "global-jsx-namespace" + }, + { + "file": "packages/excalidraw/components/TTDDialog/MermaidToExcalidraw.tsx", + "line": 52, + "rule": "ref-object-nullable" + }, + { + "file": "packages/excalidraw/components/TTDDialog/MermaidToExcalidraw.tsx", + "line": 123, + "rule": "ref-object-nullable" + }, + { + "file": "packages/excalidraw/components/TTDDialog/TTDDialog.tsx", + "line": 171, + "rule": "ref-object-nullable" + }, + { + "file": "packages/excalidraw/components/TTDDialog/TTDDialog.tsx", + "line": 382, + "rule": "ref-object-nullable" + }, + { + "file": "packages/excalidraw/components/TTDDialog/TTDDialogOutput.tsx", + "line": 16, + "rule": "ref-object-nullable", + "note": "changed by the upgrade" + }, + { + "file": "packages/excalidraw/components/TTDDialog/common.ts", + "line": 15, + "rule": "ref-object-nullable", + "note": "changed by the upgrade" + }, + { + "file": "packages/excalidraw/components/TTDDialog/common.ts", + "line": 43, + "rule": "ref-object-nullable", + "note": "changed by the upgrade" + }, + { + "file": "packages/excalidraw/components/TTDDialog/common.ts", + "line": 47, + "rule": "mutable-ref-object" + }, + { + "file": "packages/excalidraw/components/TTDDialog/common.ts", + "line": 137, + "rule": "mutable-ref-object" + }, + { + "file": "packages/excalidraw/components/canvases/InteractiveCanvas.tsx", + "line": 20, + "rule": "ref-object-nullable", + "note": "changed by the upgrade" + }, + { + "file": "packages/excalidraw/components/dropdownMenu/DropdownMenuContent.tsx", + "line": 33, + "rule": "ref-object-nullable" + }, + { + "file": "packages/excalidraw/components/dropdownMenu/DropdownMenuItemContent.tsx", + "line": 17, + "rule": "global-jsx-namespace" + }, + { + "file": "packages/excalidraw/components/footer/Footer.tsx", + "line": 73, + "rule": "global-jsx-namespace" + }, + { + "file": "packages/excalidraw/components/footer/Footer.tsx", + "line": 80, + "rule": "global-jsx-namespace" + }, + { + "file": "packages/excalidraw/components/welcome-screen/WelcomeScreen.Center.tsx", + "line": 22, + "rule": "global-jsx-namespace" + }, + { + "file": "packages/excalidraw/element/flowchart.test.tsx", + "line": 9, + "rule": "removed-react-dom" + }, + { + "file": "packages/excalidraw/element/textWysiwyg.test.tsx", + "line": 25, + "rule": "removed-react-dom" + }, + { + "file": "packages/excalidraw/hooks/useOutsideClick.ts", + "line": 5, + "rule": "ref-object-nullable", + "note": "changed by the upgrade" + }, + { + "file": "packages/excalidraw/hooks/useScrollPosition.ts", + "line": 8, + "rule": "ref-object-nullable", + "note": "changed by the upgrade" + }, + { + "file": "packages/excalidraw/tests/App.test.tsx", + "line": 15, + "rule": "removed-react-dom" + }, + { + "file": "packages/excalidraw/tests/align.test.tsx", + "line": 58, + "rule": "removed-react-dom" + }, + { + "file": "packages/excalidraw/tests/clipboard.test.tsx", + "line": 66, + "rule": "removed-react-dom" + }, + { + "file": "packages/excalidraw/tests/contextmenu.test.tsx", + "line": 42, + "rule": "removed-react-dom" + }, + { + "file": "packages/excalidraw/tests/cropElement.test.tsx", + "line": 20, + "rule": "removed-react-dom" + }, + { + "file": "packages/excalidraw/tests/dragCreate.test.tsx", + "line": 18, + "rule": "removed-react-dom" + }, + { + "file": "packages/excalidraw/tests/elementLocking.test.tsx", + "line": 12, + "rule": "removed-react-dom" + }, + { + "file": "packages/excalidraw/tests/flip.test.tsx", + "line": 47, + "rule": "removed-react-dom" + }, + { + "file": "packages/excalidraw/tests/linearElementEditor.test.tsx", + "line": 47, + "rule": "removed-react-dom" + }, + { + "file": "packages/excalidraw/tests/move.test.tsx", + "line": 20, + "rule": "removed-react-dom" + }, + { + "file": "packages/excalidraw/tests/multiPointCreate.test.tsx", + "line": 18, + "rule": "removed-react-dom" + }, + { + "file": "packages/excalidraw/tests/regressionTests.test.tsx", + "line": 47, + "rule": "removed-react-dom" + }, + { + "file": "packages/excalidraw/tests/resize.test.tsx", + "line": 24, + "rule": "removed-react-dom" + }, + { + "file": "packages/excalidraw/tests/rotate.test.tsx", + "line": 9, + "rule": "removed-react-dom" + }, + { + "file": "packages/excalidraw/tests/selection.test.tsx", + "line": 21, + "rule": "removed-react-dom" + }, + { + "file": "packages/excalidraw/tests/zindex.test.tsx", + "line": 23, + "rule": "removed-react-dom" + } + ] + }, + { + "repo": "tldraw/tldraw", + "commit": "5957867a9c5c205a0b0c3665be443138b8014a79", + "migration": "https://github.com/tldraw/tldraw/commit/f7c148187c8113aa102592e07d87b7877a937623", + "from": "18.3.1", + "to": "19.2.1", + "with": { + "react-dom": "19.2.1", + "@types/react": "19.2.7", + "@types/react-dom": "19.2.3" + }, + "why": "tldraw: a React 18 SDK monorepo with an app, docs and templates; RefObject hooks, useRef without an argument, element.props typed unknown, JSX types", + "findings": [ + { + "file": "apps/docs/components/content/code.tsx", + "line": 118, + "rule": "element-props-unknown" + }, + { + "file": "apps/docs/components/content/code.tsx", + "line": 124, + "rule": "element-props-unknown" + }, + { + "file": "apps/docs/components/content/code.tsx", + "line": 125, + "rule": "element-props-unknown" + }, + { + "file": "apps/docs/components/content/code.tsx", + "line": 131, + "rule": "element-props-unknown" + }, + { + "file": "apps/docs/components/content/code.tsx", + "line": 132, + "rule": "element-props-unknown" + }, + { + "file": "apps/docs/components/content/code.tsx", + "line": 170, + "rule": "element-props-unknown" + }, + { + "file": "apps/docs/components/content/copy-button.tsx", + "line": 10, + "rule": "ref-object-nullable", + "note": "changed by the upgrade" + }, + { + "file": "apps/docs/components/content/pre.tsx", + "line": 29, + "rule": "ref-object-nullable" + }, + { + "file": "apps/dotcom/client/src/fairy/Fairy.tsx", + "line": 99, + "rule": "ref-object-nullable", + "note": "changed by the upgrade" + }, + { + "file": "apps/dotcom/client/src/fairy/Fairy.tsx", + "line": 366, + "rule": "ref-object-nullable" + }, + { + "file": "apps/dotcom/client/src/fairy/fairy-ui/manual/FairyManualPanel.tsx", + "line": 22, + "rule": "ref-object-nullable", + "note": "changed by the upgrade" + }, + { + "file": "apps/dotcom/client/src/fairy/fairy-ui/manual/FairyManualPanel.tsx", + "line": 29, + "rule": "ref-object-nullable" + }, + { + "file": "apps/dotcom/client/src/fairy/fairy-ui/manual/FairyManualPanel.tsx", + "line": 30, + "rule": "ref-object-nullable" + }, + { + "file": "apps/dotcom/client/src/fairy/fairy-ui/manual/FairyManualPanel.tsx", + "line": 31, + "rule": "ref-object-nullable" + }, + { + "file": "apps/dotcom/client/src/fairy/fairy-ui/manual/FairyManualPanel.tsx", + "line": 52, + "rule": "ref-object-nullable", + "note": "changed by the upgrade" + }, + { + "file": "apps/dotcom/client/src/fairy/fairy-ui/manual/FairyManualPanel.tsx", + "line": 64, + "rule": "ref-object-nullable" + }, + { + "file": "apps/dotcom/client/src/fairy/fairy-ui/manual/FairyManualPanel.tsx", + "line": 83, + "rule": "ref-object-nullable" + }, + { + "file": "apps/dotcom/client/src/fairy/fairy-ui/manual/FairyManualPanel.tsx", + "line": 286, + "rule": "ref-object-nullable" + }, + { + "file": "apps/dotcom/client/src/pages/admin.tsx", + "line": 222, + "rule": "ref-object-nullable" + }, + { + "file": "apps/dotcom/client/src/pages/admin.tsx", + "line": 1032, + "rule": "ref-object-nullable", + "note": "changed by the upgrade" + }, + { + "file": "apps/dotcom/client/tsconfig.json", + "line": 15, + "rule": "global-jsx-namespace", + "note": "one cause for 1745 JSX element diagnostics in this app: with \"jsx\": \"preserve\" and no \"jsxImportSource\", element types resolve through the global JSX namespace and the UMD React namespace, which the second copy of @types/react (18.3.23, kept for a dependency pinned to 16 || 17 || 18) and @types/react 19 both declare; the upgrade added \"jsxImportSource\": \"react\" here, which reads them from react/jsx-runtime" + }, + { + "file": "apps/examples/src/ExampleWrapper.tsx", + "line": 111, + "rule": "global-jsx-namespace" + }, + { + "file": "packages/editor/src/lib/components/ErrorBoundary.tsx", + "line": 15, + "rule": "props-with-ref" + }, + { + "file": "packages/editor/src/lib/components/default-components/DefaultBrush.tsx", + "line": 18, + "rule": "ref-object-nullable" + }, + { + "file": "packages/editor/src/lib/components/default-components/DefaultCanvas.tsx", + "line": 46, + "rule": "ref-object-nullable" + }, + { + "file": "packages/editor/src/lib/components/default-components/DefaultCanvas.tsx", + "line": 50, + "rule": "ref-object-nullable" + }, + { + "file": "packages/editor/src/lib/components/default-components/DefaultCanvas.tsx", + "line": 51, + "rule": "ref-object-nullable" + }, + { + "file": "packages/editor/src/lib/components/default-components/DefaultCollaboratorHint.tsx", + "line": 33, + "rule": "ref-object-nullable" + }, + { + "file": "packages/editor/src/lib/components/default-components/DefaultCursor.tsx", + "line": 28, + "rule": "ref-object-nullable" + }, + { + "file": "packages/editor/src/lib/components/default-components/DefaultSelectionBackground.tsx", + "line": 15, + "rule": "ref-object-nullable" + }, + { + "file": "packages/editor/src/lib/components/default-components/DefaultSelectionForeground.tsx", + "line": 27, + "rule": "ref-object-nullable" + }, + { + "file": "packages/editor/src/lib/hooks/useEvent.tsx", + "line": 30, + "rule": "use-ref-argument" + }, + { + "file": "packages/editor/src/lib/hooks/useFixSafariDoubleTapZoomPencilEvents.ts", + "line": 12, + "rule": "ref-object-nullable", + "note": "changed by the upgrade" + }, + { + "file": "packages/editor/src/lib/hooks/useGestureEvents.ts", + "line": 78, + "rule": "ref-object-nullable", + "note": "changed by the upgrade" + }, + { + "file": "packages/editor/src/lib/hooks/usePassThroughMouseOverEvents.ts", + "line": 7, + "rule": "ref-object-nullable", + "note": "changed by the upgrade" + }, + { + "file": "packages/editor/src/lib/hooks/usePassThroughWheelEvents.ts", + "line": 7, + "rule": "ref-object-nullable", + "note": "changed by the upgrade" + }, + { + "file": "packages/editor/src/lib/hooks/useScreenBounds.ts", + "line": 5, + "rule": "ref-object-nullable", + "note": "changed by the upgrade" + }, + { + "file": "packages/editor/src/lib/hooks/useTransform.ts", + "line": 6, + "rule": "ref-object-nullable", + "note": "changed by the upgrade" + }, + { + "file": "packages/editor/src/lib/license/Watermark.tsx", + "line": 49, + "rule": "ref-object-nullable" + }, + { + "file": "packages/editor/src/lib/license/Watermark.tsx", + "line": 97, + "rule": "ref-object-nullable" + }, + { + "file": "packages/tldraw/src/lib/ui/components/CursorChatBubble.tsx", + "line": 49, + "rule": "ref-object-nullable", + "note": "changed by the upgrade" + }, + { + "file": "packages/tldraw/src/lib/ui/components/CursorChatBubble.tsx", + "line": 80, + "rule": "ref-object-nullable" + }, + { + "file": "packages/tldraw/src/lib/ui/components/CursorChatBubble.tsx", + "line": 108, + "rule": "ref-object-nullable" + }, + { + "file": "packages/tldraw/src/lib/ui/components/HelperButtons/StopFollowing.tsx", + "line": 16, + "rule": "global-jsx-namespace" + }, + { + "file": "packages/tldraw/src/lib/ui/components/Minimap/DefaultMinimap.tsx", + "line": 27, + "rule": "use-ref-argument" + }, + { + "file": "packages/tldraw/src/lib/ui/components/PageMenu/PageItemInput.tsx", + "line": 51, + "rule": "global-jsx-namespace" + }, + { + "file": "packages/tldraw/src/lib/ui/components/PageMenu/PageItemInput.tsx", + "line": 51, + "rule": "ref-callback-return", + "note": "changed by the upgrade" + }, + { + "file": "packages/tldraw/src/lib/ui/components/Toolbar/DefaultImageToolbar.tsx", + "line": 66, + "rule": "use-ref-argument" + }, + { + "file": "packages/tldraw/src/lib/ui/components/Toolbar/DefaultRichTextToolbar.tsx", + "line": 41, + "rule": "use-ref-argument" + }, + { + "file": "packages/tldraw/src/lib/ui/components/primitives/TldrawUiContextualToolbar.tsx", + "line": 55, + "rule": "ref-object-nullable", + "note": "changed by the upgrade" + }, + { + "file": "packages/tldraw/src/lib/ui/components/primitives/TldrawUiContextualToolbar.tsx", + "line": 108, + "rule": "generic" + }, + { + "file": "packages/tldraw/src/lib/ui/components/primitives/TldrawUiTooltip.tsx", + "line": 414, + "rule": "element-props-unknown" + }, + { + "file": "packages/tldraw/src/lib/ui/components/primitives/TldrawUiTooltip.tsx", + "line": 430, + "rule": "element-props-unknown" + }, + { + "file": "packages/tldraw/src/lib/ui/components/primitives/TldrawUiTooltip.tsx", + "line": 440, + "rule": "element-props-unknown" + }, + { + "file": "packages/tldraw/src/lib/ui/components/primitives/TldrawUiTooltip.tsx", + "line": 456, + "rule": "element-props-unknown" + }, + { + "file": "packages/tldraw/src/lib/ui/components/primitives/TldrawUiTooltip.tsx", + "line": 466, + "rule": "global-jsx-namespace" + }, + { + "file": "packages/tldraw/src/lib/ui/components/primitives/menus/TldrawUiMenuActionCheckboxItem.tsx", + "line": 20, + "rule": "global-jsx-namespace" + }, + { + "file": "packages/tldraw/src/lib/ui/components/primitives/menus/TldrawUiMenuActionItem.tsx", + "line": 14, + "rule": "global-jsx-namespace" + }, + { + "file": "templates/agent/client/components/ChatInput.tsx", + "line": 18, + "rule": "ref-object-nullable", + "note": "changed by the upgrade" + }, + { + "file": "templates/agent/client/components/ChatPanel.tsx", + "line": 76, + "rule": "ref-object-nullable" + } + ] + } + ] +} diff --git a/packages/core/src/packs/react/index.ts b/packages/core/src/packs/react/index.ts new file mode 100644 index 0000000..8969bda --- /dev/null +++ b/packages/core/src/packs/react/index.ts @@ -0,0 +1,266 @@ +import { definePack, type PackRule } from '../contract.js'; +import type { TransformResult } from '../types.js'; +import { dropRefCallbackReturn, refCallbackReturnSites } from './detect.js'; + +/** + * react >=18 <19 → >=19 <20, with react-dom and the @types packages that move with it. Taken + * from the official React 19 upgrade guide and the 19.0.0 changelog (`meta.sources`), checked + * 2026-10-08 against react 19.3.0. What the compiler rejects is `breaking`, what it only + * strikes through is `deprecated`, and what compiles and behaves differently is a behavior + * note, listed for review. `types-react-codemod preset-19` and `react/19/migration-recipe` + * cover several of these; a rule that overlaps one says what the codemod leaves undone. + */ + +/** Where a line's trailing `//` comment starts, outside strings; the line's length without one. */ +function codeEnd(line: string): number { + let quote: string | undefined; + for (let i = 0; i < line.length; i++) { + const c = line[i]; + if (quote) { + if (c === '\\') i++; + else if (c === quote) quote = undefined; + } else if (c === '"' || c === "'" || c === '`') quote = c; + else if (c === '/' && line[i + 1] === '/') return i; + } + return line.length; +} + +/** The index just after the `>` closing the type arguments that open at `open`, or -1. */ +function closeTypeArguments(code: string, open: number): number { + let depth = 0; + for (let i = open; i < code.length; i++) { + const c = code[i]; + // `=>` inside a function type is an arrow, not a closing bracket. + if (c === '=' && code[i + 1] === '>') i++; + else if (c === '<') depth++; + else if (c === '>' && --depth === 0) return i + 1; + } + return -1; +} + +/** `useRef()` and `useRef()` at the reported line become `useRef(undefined)`. */ +function useRefArgument(text: string, line: number): TransformResult { + const lines = text.split('\n'); + const current = lines[line - 1] ?? ''; + const end = codeEnd(current); + const code = current.slice(0, end); + const empty: number[] = []; + for (const match of code.matchAll(/(?` at the reported line becomes `React.RefObject`. */ +function mutableRefObject(text: string, line: number): TransformResult { + const lines = text.split('\n'); + const current = lines[line - 1] ?? ''; + const end = codeEnd(current); + const code = current.slice(0, end); + const names = [...code.matchAll(/(?` is `RefObject`: every ref is mutable in React 19', + }; +} + +const rules: PackRule[] = [ + { + id: 'use-ref-argument', + summary: '`useRef` requires an argument: `useRef()` is `useRef(undefined)`', + severity: 'breaking', + kinds: ['signature', 'required', 'type'], + symbols: /^TS2554$/, + message: /Expected 1 arguments?, but got 0/, + guide: + 'In React 19 types `useRef` always takes an initial value. `useRef()` is `useRef(undefined)`, which still types as `RefObject`. Keep the type argument; do not change the initial value to anything but `undefined`. `types-react-codemod preset-19` (`useRef-required-initial`) does the same edit; it skips an aliased `useRef` import.', + rewrite: (text, finding) => useRefArgument(text, finding.usage.line), + }, + { + id: 'ref-callback-return', + summary: + 'a ref callback must not return a value: `ref={(el) => (x = el)}` is `ref={(el) => { x = el; }}`', + severity: 'breaking', + // Sites come from `detect`: the type of the `ref` prop is the one that rejects the return. + kinds: [], + symbols: /$^/, + guide: + 'A ref callback may return a cleanup function in React 19, so its types reject any other return value. An arrow function that assigns returns what it assigned: give it a block body and keep the assignment. `types-react-codemod` has `no-implicit-ref-callback-return` for the `ref` prop and skips other props that take refs.', + detect: (text) => refCallbackReturnSites(text), + rewrite: (text, finding) => dropRefCallbackReturn(text, finding), + }, + { + id: 'mutable-ref-object', + summary: '`MutableRefObject` is `RefObject`: every ref is mutable in React 19', + severity: 'deprecated', + kinds: ['deprecated'], + symbols: /MutableRefObject$/, + guide: + '`MutableRefObject` is deprecated; `RefObject` is `{ current: T }` and `useRef` returns it. Replace the type and import `RefObject` where the code imports `MutableRefObject`.', + rewrite: (text, finding) => mutableRefObject(text, finding.usage.line), + }, + { + id: 'ref-object-nullable', + summary: + '`RefObject` no longer includes `null`: write `RefObject` where a ref starts empty', + severity: 'breaking', + kinds: ['type', 'signature'], + symbols: /^TS(?:2322|2345)$/, + message: /RefObject(null)` is `RefObject` in React 19 types, and `RefObject` has a `current` of exactly `T`. A parameter, prop or field typed `RefObject` that receives such a ref becomes `RefObject`: edit the declaration the error points through, not the call site, and handle `current` being null where it is read. `types-react-codemod` has `refobject-defaults` for annotations; it skips an aliased `RefObject` import.', + }, + { + id: 'global-jsx-namespace', + summary: + 'the global `JSX` namespace is gone: import `JSX` from "react", and type the JSX runtime in tsconfig', + severity: 'breaking', + kinds: ['type', 'signature', 'required'], + symbols: /^TS(?:2741|2746|2786|7026)$/, + guide: + 'The global `JSX` namespace was removed from @types/react: use `import type { JSX } from "react"` (or `React.JSX`) where code names `JSX.Element`, and move any `declare global { namespace JSX { ... } }` augmentation into `declare module "react/jsx-runtime"` (or "react" with `jsx: react`, "react/jsx-dev-runtime" with `react-jsxdev`). With `jsx: react-jsx` and several copies of the React types, setting `jsxImportSource: "react"` in tsconfig points the compiler at one. Many diagnostics at JSX elements (7026, 2786, 2741, 2746) can come from this one cause: fix it once, then recompile. `types-react-codemod` has `scoped-jsx`.', + }, + { + id: 'element-props-unknown', + summary: + '`ReactElement["props"]` is `unknown` unless the element is typed: `ReactElement<{ ... }>`', + severity: 'breaking', + kinds: ['type', 'signature'], + symbols: /^TS(?:2339|18046)$/, + message: /(?:\.props' is of type 'unknown'|Property 'props' does not exist on type)/, + guide: + 'An element typed `ReactElement` has `props: unknown`. Give the element the props type it is read as (`ReactElement<{ className?: string }>`, narrowed with `isValidElement`), and cast with `as` where code receives a `ReactNode` child. Never type it `any` to silence the error. `types-react-codemod react-element-default-any-props` adds the explicit `any` this advises against; review each site.', + }, + { + id: 'removed-react-dom', + summary: + '`ReactDOM.render`, `hydrate`, `unmountComponentAtNode` and `findDOMNode` are removed: use `createRoot`, `hydrateRoot`, `root.unmount()` and a ref', + severity: 'breaking', + kinds: ['removed', 'type'], + symbols: /(?:^TS2339$|(?:^|:)(?:render|hydrate|unmountComponentAtNode|findDOMNode)$)/, + message: /(?:'(?:render|hydrate|unmountComponentAtNode|findDOMNode)'|^$)/, + guide: + 'Import `createRoot` or `hydrateRoot` from "react-dom/client". `ReactDOM.render(el, container)` is `createRoot(container).render(el)`, and the root is what `unmount()` is called on (keep it where `unmountComponentAtNode(container)` was); `ReactDOM.hydrate` is `hydrateRoot(container, el)`; `findDOMNode(node)` is a ref on the DOM node. In tests, `act` comes from "react", and the Testing Library `cleanup` unmounts. The `react/19/migration-recipe` codemod rewrites `ReactDOM.render` and `hydrate`; it leaves `unmountComponentAtNode` and `findDOMNode` for you.', + }, + { + id: 'props-with-ref', + summary: '`PropsWithRef

` is `P`: `ref` is a regular prop', + severity: 'deprecated', + kinds: ['deprecated'], + symbols: /PropsWithRef$/, + guide: + '`PropsWithRef

` is deprecated: `ref` is a prop in React 19, so use `P`. Leave `PropsWithChildren` and the rest of the type as written.', + }, +]; + +export const reactPack = definePack({ + meta: { + package: 'react', + from: '>=18 <19', + to: '>=19 <20', + sources: [ + { + title: 'React 19 Upgrade Guide', + url: 'https://react.dev/blog/2024/04/25/react-19-upgrade-guide', + }, + { + title: 'React 19.0.0 changelog', + url: 'https://github.com/facebook/react/blob/main/CHANGELOG.md#1900-december-5-2024', + }, + { + title: 'types-react-codemod (preset-19)', + url: 'https://github.com/eps1lon/types-react-codemod', + }, + ], + maintainer: 'uptide-dev', + }, + defaultTarget: '19.3.0', + rules, + behavior: [ + { + id: 'errors-not-rethrown', + summary: + 'errors thrown in render are no longer re-thrown: uncaught ones go to `window.reportError`, caught ones to `console.error`; error reporting that relied on the re-throw needs `onUncaughtError` and `onCaughtError` on `createRoot`', + reported: ['decision'], + }, + { + id: 'removed-legacy-apis', + summary: + '`propTypes` and `defaultProps` on function components, legacy context (`contextTypes`, `getChildContext`), string refs, module-pattern factories, `React.createFactory` and `react-test-renderer/shallow` are removed at runtime: default parameters, `createContext`, ref callbacks and JSX replace them, and the compiler only sees some of them', + reported: ['decision'], + }, + { + id: 'act-moved-to-react', + summary: + '`act` from "react-dom/test-utils" warns: import it from "react"; the other test-utils exports are removed', + reported: ['decision'], + }, + { + id: 'element-ref-deprecated', + summary: + 'reading `element.ref` warns: `ref` is a prop, read `element.props.ref`; cloneElement and Children code that touches the ref should be looked at', + reported: ['decision'], + }, + { + id: 'strict-mode-memoization', + summary: + "in development, Strict Mode reuses the first render's `useMemo` and `useCallback` results on the second render and double-invokes ref callbacks on mount; code that was Strict Mode safe does not notice", + reported: ['decision'], + }, + { + id: 'suspense-fallback-first', + summary: + 'a suspended component commits the nearest Suspense fallback at once and renders its siblings afterwards, so siblings of a suspending component no longer render before the fallback shows', + reported: ['decision'], + }, + { + id: 'umd-removed', + summary: + 'React no longer ships UMD builds: a script tag needs an ES module CDN, or a bundler', + reported: ['decision'], + }, + { + id: 'javascript-urls-blocked', + summary: + '`javascript:` URLs in `href` and `src` throw, and an empty `src` or `href` (except on `a`) is no longer set', + reported: ['decision'], + }, + { + id: 'use-reducer-types', + summary: + '`useReducer>(reducer)` no longer types: pass no type arguments and annotate the reducer, or pass ``', + reported: ['decision'], + }, + ], + instructions: + 'Migrate only the reported site to React 19, from the compiler error and the guide above. Keep every value, handler, effect and render result as it is. Never cast to `any` or suppress a diagnostic to make it compile. If the site needs a decision the guide leaves open (which props type an element is read as, whether a root can be unmounted from where the code stands), say so instead of choosing.', +}); diff --git a/packages/core/src/packs/react/react.test.ts b/packages/core/src/packs/react/react.test.ts new file mode 100644 index 0000000..029cc26 --- /dev/null +++ b/packages/core/src/packs/react/react.test.ts @@ -0,0 +1,13 @@ +import { fileURLToPath } from 'node:url'; +import { expect, it } from 'vitest'; +import { runFixtures } from '../tooling/fixtures.js'; +import { reactPack } from './index.js'; + +it('react: every fixture rewrites and detects exactly its marked sites', () => { + const result = runFixtures(reactPack, 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([]); +}); diff --git a/packages/core/src/packs/react/verification.json b/packages/core/src/packs/react/verification.json new file mode 100644 index 0000000..e27cc54 --- /dev/null +++ b/packages/core/src/packs/react/verification.json @@ -0,0 +1,10 @@ +{ + "status": "candidate", + "truth": "sha256-0afe6281b88ad7b3e34d418d3fe1fccc7f84817945b557cb30a3134c5c3fc359", + "repos": 2, + "breaking": { + "precision": 0.883, + "recall": 0.6434, + "falsePositives": 11 + } +} diff --git a/packages/core/src/packs/registry.ts b/packages/core/src/packs/registry.ts index e40329f..1fce583 100644 --- a/packages/core/src/packs/registry.ts +++ b/packages/core/src/packs/registry.ts @@ -4,6 +4,8 @@ import { aiPack } from './ai/index.js'; import aiVerification from './ai/verification.json' with { type: 'json' }; import type { PackVerification, RegisteredPack } from './contract.js'; +import { reactPack } from './react/index.js'; +import reactVerification from './react/verification.json' with { type: 'json' }; import { stripePack } from './stripe/index.js'; import stripeVerification from './stripe/verification.json' with { type: 'json' }; import { zodPack } from './zod/index.js'; @@ -11,6 +13,7 @@ import zodVerification from './zod/verification.json' with { type: 'json' }; export const REGISTRY: readonly RegisteredPack[] = [ { dir: 'ai', pack: aiPack, verification: aiVerification as PackVerification }, + { dir: 'react', pack: reactPack, verification: reactVerification as PackVerification }, { dir: 'stripe', pack: stripePack, verification: stripeVerification as PackVerification }, { dir: 'zod', pack: zodPack, verification: zodVerification as PackVerification }, ];