From cedd8282b195c97cc22a252ced9cad2d4cc96fca Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 19:42:50 +0000 Subject: [PATCH 1/3] Add React 19.3 adoption codemods and recipe Add three JSSG codemods for the APIs that stabilized in React 19.3 (plus the 19.2 leftovers), and a recipe that chains them: - react-19-3-unprefix-stable-apis: rename unstable_ViewTransition, unstable_addTransitionType, unstable_Activity and experimental_useEffectEvent to their stable names across ESM and CJS imports, member access, JSX tags, re-exports and usage sites. - react-19-3-use-browser: replace the useState(false) + useEffect mounted-state pattern with use(browser()) from react-dom, splitting the component so the old early return becomes a Suspense fallback. - react-19-3-fragment-ref-wrappers: replace display:contents wrapper elements that only hold a ref with , guarded by a check that every ref usage is on the FragmentInstance API. - react-19-3-migration-recipe: runs the above together with react-19-remove-context-provider. Also add the lockfile importer entries for the new workspace packages and list the codemods in the root README. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01C1zn9AbvKVCERf15x3zJ68 --- README.md | 40 + codemods/fragment-ref-wrappers/README.md | 54 ++ codemods/fragment-ref-wrappers/codemod.yaml | 20 + codemods/fragment-ref-wrappers/package.json | 15 + .../fragment-ref-wrappers/scripts/codemod.ts | 238 ++++++ .../tests/add-fragment-import/expected.tsx | 15 + .../tests/add-fragment-import/input.tsx | 15 + .../tests/add-fragment-import/metrics.json | 10 + .../tests/default-react-import/expected.tsx | 14 + .../tests/default-react-import/input.tsx | 14 + .../tests/default-react-import/metrics.json | 10 + .../tests/extra-props-no-change/expected.tsx | 10 + .../tests/extra-props-no-change/input.tsx | 10 + .../fragment-already-imported/expected.tsx | 14 + .../tests/fragment-already-imported/input.tsx | 14 + .../fragment-already-imported/metrics.json | 10 + .../no-react-import-ref-prop/expected.tsx | 11 + .../tests/no-react-import-ref-prop/input.tsx | 9 + .../no-react-import-ref-prop/metrics.json | 10 + .../expected.tsx | 12 + .../ref-passed-elsewhere-no-change/input.tsx | 12 + .../tests/two-wrappers/expected.tsx | 17 + .../tests/two-wrappers/input.tsx | 17 + .../tests/two-wrappers/metrics.json | 10 + .../unsupported-usage-no-change/expected.tsx | 13 + .../unsupported-usage-no-change/input.tsx | 13 + codemods/fragment-ref-wrappers/tsconfig.json | 27 + codemods/fragment-ref-wrappers/workflow.yaml | 14 + .../react-19-3-migration-recipe/README.md | 18 + .../react-19-3-migration-recipe/codemod.yaml | 20 + .../react-19-3-migration-recipe/package.json | 11 + .../scripts/metadata-tests.mjs | 68 ++ .../react-19-3-migration-recipe/workflow.yaml | 24 + codemods/unprefix-stable-apis/README.md | 42 ++ codemods/unprefix-stable-apis/codemod.yaml | 20 + codemods/unprefix-stable-apis/package.json | 15 + .../unprefix-stable-apis/scripts/codemod.ts | 230 ++++++ .../activity-and-effect-event/expected.tsx | 17 + .../tests/activity-and-effect-event/input.tsx | 17 + .../activity-and-effect-event/metrics.json | 10 + .../alias-with-different-name/expected.tsx | 9 + .../tests/alias-with-different-name/input.tsx | 9 + .../alias-with-different-name/metrics.json | 10 + .../tests/aliased-named-import/expected.tsx | 16 + .../tests/aliased-named-import/input.tsx | 16 + .../tests/aliased-named-import/metrics.json | 10 + .../tests/cjs-require/expected.tsx | 17 + .../tests/cjs-require/input.tsx | 17 + .../tests/cjs-require/metrics.json | 10 + .../default-import-member-access/expected.tsx | 5 + .../default-import-member-access/input.tsx | 5 + .../default-import-member-access/metrics.json | 10 + .../tests/member-access/expected.tsx | 16 + .../tests/member-access/input.tsx | 16 + .../tests/member-access/metrics.json | 10 + .../tests/other-module-no-change/expected.tsx | 12 + .../tests/other-module-no-change/input.tsx | 12 + .../tests/plain-named-import/expected.tsx | 18 + .../tests/plain-named-import/input.tsx | 18 + .../tests/plain-named-import/metrics.json | 10 + .../tests/re-export/expected.tsx | 3 + .../tests/re-export/input.tsx | 3 + .../tests/re-export/metrics.json | 10 + .../stable-name-already-taken/expected.tsx | 10 + .../tests/stable-name-already-taken/input.tsx | 10 + .../stable-name-already-taken/metrics.json | 10 + codemods/unprefix-stable-apis/tsconfig.json | 27 + codemods/unprefix-stable-apis/workflow.yaml | 14 + codemods/use-browser/README.md | 58 ++ codemods/use-browser/codemod.yaml | 20 + codemods/use-browser/package.json | 15 + codemods/use-browser/scripts/codemod.ts | 683 ++++++++++++++++++ .../arrow-component-with-fc-type/expected.tsx | 20 + .../arrow-component-with-fc-type/input.tsx | 15 + .../arrow-component-with-fc-type/metrics.json | 10 + .../destructured-props-function/expected.tsx | 16 + .../destructured-props-function/input.tsx | 11 + .../destructured-props-function/metrics.json | 10 + .../tests/existing-imports/expected.tsx | 15 + .../tests/existing-imports/input.tsx | 11 + .../tests/existing-imports/metrics.json | 10 + .../expected.tsx | 16 + .../export-default-props-identifier/input.tsx | 11 + .../metrics.json | 10 + .../fallback-uses-prop-no-change/expected.tsx | 10 + .../fallback-uses-prop-no-change/input.tsx | 10 + .../expected.tsx | 32 + .../layout-effect-keeps-used-hooks/input.tsx | 29 + .../metrics.json | 10 + .../tests/namespace-hooks/expected.tsx | 17 + .../tests/namespace-hooks/input.tsx | 12 + .../tests/namespace-hooks/metrics.json | 10 + .../no-react-import-no-change/expected.tsx | 10 + .../tests/no-react-import-no-change/input.tsx | 10 + .../expected.tsx | 10 + .../state-used-elsewhere-no-change/input.tsx | 10 + codemods/use-browser/tsconfig.json | 27 + codemods/use-browser/workflow.yaml | 14 + pnpm-lock.yaml | 38 + 99 files changed, 2673 insertions(+) create mode 100644 codemods/fragment-ref-wrappers/README.md create mode 100644 codemods/fragment-ref-wrappers/codemod.yaml create mode 100644 codemods/fragment-ref-wrappers/package.json create mode 100644 codemods/fragment-ref-wrappers/scripts/codemod.ts create mode 100644 codemods/fragment-ref-wrappers/tests/add-fragment-import/expected.tsx create mode 100644 codemods/fragment-ref-wrappers/tests/add-fragment-import/input.tsx create mode 100644 codemods/fragment-ref-wrappers/tests/add-fragment-import/metrics.json create mode 100644 codemods/fragment-ref-wrappers/tests/default-react-import/expected.tsx create mode 100644 codemods/fragment-ref-wrappers/tests/default-react-import/input.tsx create mode 100644 codemods/fragment-ref-wrappers/tests/default-react-import/metrics.json create mode 100644 codemods/fragment-ref-wrappers/tests/extra-props-no-change/expected.tsx create mode 100644 codemods/fragment-ref-wrappers/tests/extra-props-no-change/input.tsx create mode 100644 codemods/fragment-ref-wrappers/tests/fragment-already-imported/expected.tsx create mode 100644 codemods/fragment-ref-wrappers/tests/fragment-already-imported/input.tsx create mode 100644 codemods/fragment-ref-wrappers/tests/fragment-already-imported/metrics.json create mode 100644 codemods/fragment-ref-wrappers/tests/no-react-import-ref-prop/expected.tsx create mode 100644 codemods/fragment-ref-wrappers/tests/no-react-import-ref-prop/input.tsx create mode 100644 codemods/fragment-ref-wrappers/tests/no-react-import-ref-prop/metrics.json create mode 100644 codemods/fragment-ref-wrappers/tests/ref-passed-elsewhere-no-change/expected.tsx create mode 100644 codemods/fragment-ref-wrappers/tests/ref-passed-elsewhere-no-change/input.tsx create mode 100644 codemods/fragment-ref-wrappers/tests/two-wrappers/expected.tsx create mode 100644 codemods/fragment-ref-wrappers/tests/two-wrappers/input.tsx create mode 100644 codemods/fragment-ref-wrappers/tests/two-wrappers/metrics.json create mode 100644 codemods/fragment-ref-wrappers/tests/unsupported-usage-no-change/expected.tsx create mode 100644 codemods/fragment-ref-wrappers/tests/unsupported-usage-no-change/input.tsx create mode 100644 codemods/fragment-ref-wrappers/tsconfig.json create mode 100644 codemods/fragment-ref-wrappers/workflow.yaml create mode 100644 codemods/react-19-3-migration-recipe/README.md create mode 100644 codemods/react-19-3-migration-recipe/codemod.yaml create mode 100644 codemods/react-19-3-migration-recipe/package.json create mode 100644 codemods/react-19-3-migration-recipe/scripts/metadata-tests.mjs create mode 100644 codemods/react-19-3-migration-recipe/workflow.yaml create mode 100644 codemods/unprefix-stable-apis/README.md create mode 100644 codemods/unprefix-stable-apis/codemod.yaml create mode 100644 codemods/unprefix-stable-apis/package.json create mode 100644 codemods/unprefix-stable-apis/scripts/codemod.ts create mode 100644 codemods/unprefix-stable-apis/tests/activity-and-effect-event/expected.tsx create mode 100644 codemods/unprefix-stable-apis/tests/activity-and-effect-event/input.tsx create mode 100644 codemods/unprefix-stable-apis/tests/activity-and-effect-event/metrics.json create mode 100644 codemods/unprefix-stable-apis/tests/alias-with-different-name/expected.tsx create mode 100644 codemods/unprefix-stable-apis/tests/alias-with-different-name/input.tsx create mode 100644 codemods/unprefix-stable-apis/tests/alias-with-different-name/metrics.json create mode 100644 codemods/unprefix-stable-apis/tests/aliased-named-import/expected.tsx create mode 100644 codemods/unprefix-stable-apis/tests/aliased-named-import/input.tsx create mode 100644 codemods/unprefix-stable-apis/tests/aliased-named-import/metrics.json create mode 100644 codemods/unprefix-stable-apis/tests/cjs-require/expected.tsx create mode 100644 codemods/unprefix-stable-apis/tests/cjs-require/input.tsx create mode 100644 codemods/unprefix-stable-apis/tests/cjs-require/metrics.json create mode 100644 codemods/unprefix-stable-apis/tests/default-import-member-access/expected.tsx create mode 100644 codemods/unprefix-stable-apis/tests/default-import-member-access/input.tsx create mode 100644 codemods/unprefix-stable-apis/tests/default-import-member-access/metrics.json create mode 100644 codemods/unprefix-stable-apis/tests/member-access/expected.tsx create mode 100644 codemods/unprefix-stable-apis/tests/member-access/input.tsx create mode 100644 codemods/unprefix-stable-apis/tests/member-access/metrics.json create mode 100644 codemods/unprefix-stable-apis/tests/other-module-no-change/expected.tsx create mode 100644 codemods/unprefix-stable-apis/tests/other-module-no-change/input.tsx create mode 100644 codemods/unprefix-stable-apis/tests/plain-named-import/expected.tsx create mode 100644 codemods/unprefix-stable-apis/tests/plain-named-import/input.tsx create mode 100644 codemods/unprefix-stable-apis/tests/plain-named-import/metrics.json create mode 100644 codemods/unprefix-stable-apis/tests/re-export/expected.tsx create mode 100644 codemods/unprefix-stable-apis/tests/re-export/input.tsx create mode 100644 codemods/unprefix-stable-apis/tests/re-export/metrics.json create mode 100644 codemods/unprefix-stable-apis/tests/stable-name-already-taken/expected.tsx create mode 100644 codemods/unprefix-stable-apis/tests/stable-name-already-taken/input.tsx create mode 100644 codemods/unprefix-stable-apis/tests/stable-name-already-taken/metrics.json create mode 100644 codemods/unprefix-stable-apis/tsconfig.json create mode 100644 codemods/unprefix-stable-apis/workflow.yaml create mode 100644 codemods/use-browser/README.md create mode 100644 codemods/use-browser/codemod.yaml create mode 100644 codemods/use-browser/package.json create mode 100644 codemods/use-browser/scripts/codemod.ts create mode 100644 codemods/use-browser/tests/arrow-component-with-fc-type/expected.tsx create mode 100644 codemods/use-browser/tests/arrow-component-with-fc-type/input.tsx create mode 100644 codemods/use-browser/tests/arrow-component-with-fc-type/metrics.json create mode 100644 codemods/use-browser/tests/destructured-props-function/expected.tsx create mode 100644 codemods/use-browser/tests/destructured-props-function/input.tsx create mode 100644 codemods/use-browser/tests/destructured-props-function/metrics.json create mode 100644 codemods/use-browser/tests/existing-imports/expected.tsx create mode 100644 codemods/use-browser/tests/existing-imports/input.tsx create mode 100644 codemods/use-browser/tests/existing-imports/metrics.json create mode 100644 codemods/use-browser/tests/export-default-props-identifier/expected.tsx create mode 100644 codemods/use-browser/tests/export-default-props-identifier/input.tsx create mode 100644 codemods/use-browser/tests/export-default-props-identifier/metrics.json create mode 100644 codemods/use-browser/tests/fallback-uses-prop-no-change/expected.tsx create mode 100644 codemods/use-browser/tests/fallback-uses-prop-no-change/input.tsx create mode 100644 codemods/use-browser/tests/layout-effect-keeps-used-hooks/expected.tsx create mode 100644 codemods/use-browser/tests/layout-effect-keeps-used-hooks/input.tsx create mode 100644 codemods/use-browser/tests/layout-effect-keeps-used-hooks/metrics.json create mode 100644 codemods/use-browser/tests/namespace-hooks/expected.tsx create mode 100644 codemods/use-browser/tests/namespace-hooks/input.tsx create mode 100644 codemods/use-browser/tests/namespace-hooks/metrics.json create mode 100644 codemods/use-browser/tests/no-react-import-no-change/expected.tsx create mode 100644 codemods/use-browser/tests/no-react-import-no-change/input.tsx create mode 100644 codemods/use-browser/tests/state-used-elsewhere-no-change/expected.tsx create mode 100644 codemods/use-browser/tests/state-used-elsewhere-no-change/input.tsx create mode 100644 codemods/use-browser/tsconfig.json create mode 100644 codemods/use-browser/workflow.yaml diff --git a/README.md b/README.md index 377c30b..3b5744e 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,46 @@ npx codemod react-19-migration-recipe --target See [react-19-migration-recipe](https://app.codemod.com/registry/react-19-migration-recipe). +#### `react-19-3-migration-recipe` + +Runs all React 19.3 adoption codemods in sequence. + +```bash +npx codemod react-19-3-migration-recipe --target +``` + +See [react-19-3-migration-recipe](https://app.codemod.com/registry/react-19-3-migration-recipe). + +#### `react-19-3-unprefix-stable-apis` + +Renames `unstable_ViewTransition`, `unstable_addTransitionType`, `unstable_Activity`, and `experimental_useEffectEvent` to their stable names. + +```bash +npx codemod react-19-3-unprefix-stable-apis --target +``` + +See [react-19-3-unprefix-stable-apis](https://app.codemod.com/registry/react-19-3-unprefix-stable-apis). + +#### `react-19-3-use-browser` + +Replaces the mounted-state pattern for browser-only components with `use(browser())` from `react-dom`. + +```bash +npx codemod react-19-3-use-browser --target +``` + +See [react-19-3-use-browser](https://app.codemod.com/registry/react-19-3-use-browser). + +#### `react-19-3-fragment-ref-wrappers` + +Replaces `display: contents` wrapper elements that only hold a ref with Fragment refs. + +```bash +npx codemod react-19-3-fragment-ref-wrappers --target +``` + +See [react-19-3-fragment-ref-wrappers](https://app.codemod.com/registry/react-19-3-fragment-ref-wrappers). + #### `react-19-use-context-hook` Replaces usages of `React.useContext(...)` with `React.use(...)`. diff --git a/codemods/fragment-ref-wrappers/README.md b/codemods/fragment-ref-wrappers/README.md new file mode 100644 index 0000000..61844e6 --- /dev/null +++ b/codemods/fragment-ref-wrappers/README.md @@ -0,0 +1,54 @@ +# react-19-3-fragment-ref-wrappers + +Replace wrapper elements that exist only to hold a ref with React 19.3's Fragment refs. + +The codemod targets `
` and `` elements whose only props are `ref={someRef}` and `style={{ display: "contents" }}`. That combination is the common workaround for "I need a DOM handle on a group of siblings but no wrapper in the layout", and Fragment refs solve it without the extra node. + +Before the wrapper is removed, every use of the ref in the file is checked against the `FragmentInstance` API (`addEventListener`, `removeEventListener`, `dispatchEvent`, `focus`, `focusLast`, `blur`, `observeUsing`, `unobserveUsing`, `getClientRects`, `getRootNode`, `compareDocumentPosition`, `scrollIntoView`). If the ref is used any other way, for example `ref.current.style` or passing the ref to a custom hook, the element is left alone. + +## Usage + +```bash +npx codemod react-19-3-fragment-ref-wrappers --target +``` + +## Example + +```tsx +// Before +import { useEffect, useRef } from "react"; + +export function Row({ children }) { + const rowRef = useRef(null); + useEffect(() => { + rowRef.current.addEventListener("focusin", onFocus); + return () => rowRef.current.removeEventListener("focusin", onFocus); + }, []); + return ( +
+ {children} +
+ ); +} + +// After +import { Fragment, useEffect, useRef } from "react"; + +export function Row({ children }) { + const rowRef = useRef(null); + useEffect(() => { + rowRef.current.addEventListener("focusin", onFocus); + return () => rowRef.current.removeEventListener("focusin", onFocus); + }, []); + return ( + + {children} + + ); +} +``` + +## Notes + +- TypeScript type arguments on `useRef(null)` are not rewritten. Update them to the Fragment instance type exported by your `@types/react` version after running the codemod. +- Files that reference React through a default or namespace import get ``; otherwise a named `Fragment` import is added. diff --git a/codemods/fragment-ref-wrappers/codemod.yaml b/codemods/fragment-ref-wrappers/codemod.yaml new file mode 100644 index 0000000..0c1006f --- /dev/null +++ b/codemods/fragment-ref-wrappers/codemod.yaml @@ -0,0 +1,20 @@ +schema_version: "1.0" + +name: "react-19-3-fragment-ref-wrappers" +version: "0.1.0" +description: "Replace display:contents wrapper elements that only exist to hold a ref with a Fragment ref" +author: "Codemod " +license: "MIT" +workflow: "workflow.yaml" +repository: "https://github.com/codemod/react-codemod/tree/main/codemods/fragment-ref-wrappers" + +targets: + languages: ["tsx"] + +keywords: ["React", "Fragment", "ref"] + +registry: + access: "public" + visibility: "public" + +capabilities: [] diff --git a/codemods/fragment-ref-wrappers/package.json b/codemods/fragment-ref-wrappers/package.json new file mode 100644 index 0000000..aa8f33d --- /dev/null +++ b/codemods/fragment-ref-wrappers/package.json @@ -0,0 +1,15 @@ +{ + "name": "react-19-3-fragment-ref-wrappers", + "version": "0.1.0", + "description": "Replace display:contents wrapper elements that only exist to hold a ref with a Fragment ref", + "type": "module", + "scripts": { + "test": "pnpm dlx codemod@latest jssg test -l tsx ./scripts/codemod.ts", + "check-types": "tsc --noEmit" + }, + "devDependencies": { + "@codemod.com/jssg-types": "latest", + "@types/node": "latest", + "typescript": "latest" + } +} diff --git a/codemods/fragment-ref-wrappers/scripts/codemod.ts b/codemods/fragment-ref-wrappers/scripts/codemod.ts new file mode 100644 index 0000000..0af8f1a --- /dev/null +++ b/codemods/fragment-ref-wrappers/scripts/codemod.ts @@ -0,0 +1,238 @@ +import type { Transform, Edit, SgNode } from "codemod:ast-grep"; +import type TSX from "codemod:ast-grep/langs/tsx"; +import { useMetricAtom } from "codemod:metrics"; + +function metricFile(filename: string): string { + const cwd = process.cwd() + "/"; + return filename.startsWith(cwd) ? filename.slice(cwd.length) : filename; +} + +const REACT_MODULE = "react"; +const WRAPPER_TAGS = new Set(["div", "span"]); + +/** Methods available on a FragmentInstance (the value of a Fragment ref). */ +const FRAGMENT_INSTANCE_METHODS = new Set([ + "addEventListener", + "removeEventListener", + "dispatchEvent", + "focus", + "focusLast", + "blur", + "observeUsing", + "unobserveUsing", + "getClientRects", + "getRootNode", + "compareDocumentPosition", + "scrollIntoView", +]); + +type ReactImports = { + namespaces: Set; + fragmentLocalName: string | null; + firstNamedImport: SgNode | null; + lastImport: SgNode | null; +}; + +type Wrapper = { + element: SgNode; + opening: SgNode; + closing: SgNode; + refAttr: SgNode; + refName: string; +}; + +function namedChildren(node: SgNode | null): SgNode[] { + if (!node) return []; + return node.children().filter((child) => child.isNamed() && child.kind() !== "comment"); +} + +function sourceText(node: SgNode): string | null { + const fragment = node.find({ rule: { kind: "string_fragment" } }); + if (fragment) return fragment.text(); + const text = node.text(); + return text.length >= 2 ? text.slice(1, -1) : null; +} + +function importSource(node: SgNode): string | null { + const source = node.field("source") ?? node.find({ rule: { kind: "string" } }); + return source ? sourceText(source) : null; +} + +function isTypeOnlyImport(node: SgNode): boolean { + return node.children().some((child) => !child.isNamed() && child.text() === "type"); +} + +function collectReactImports(rootNode: SgNode): ReactImports { + const result: ReactImports = { namespaces: new Set(), fragmentLocalName: null, firstNamedImport: null, lastImport: null }; + + for (const imp of rootNode.findAll({ rule: { kind: "import_statement" } })) { + if (imp.parent()?.kind() !== "program") continue; + result.lastImport = imp; + if (importSource(imp) !== REACT_MODULE || isTypeOnlyImport(imp)) continue; + + const importClause = imp.find({ rule: { kind: "import_clause" } }); + const defaultIdentifier = importClause?.children().find((child) => child.kind() === "identifier"); + if (defaultIdentifier) result.namespaces.add(defaultIdentifier.text()); + const namespaceImport = imp.find({ rule: { kind: "namespace_import" } }); + const namespaceName = namespaceImport?.field("name") ?? namespaceImport?.find({ rule: { kind: "identifier" } }); + if (namespaceName) result.namespaces.add(namespaceName.text()); + + if (imp.find({ rule: { kind: "named_imports" } }) && !result.firstNamedImport) result.firstNamedImport = imp; + for (const specifier of imp.findAll({ rule: { kind: "import_specifier" } })) { + if (specifier.field("name")?.text() === "Fragment") { + result.fragmentLocalName = specifier.field("alias")?.text() ?? "Fragment"; + } + } + } + + return result; +} + +function attributeName(attr: SgNode): string | null { + const name = attr.children().find((child) => child.kind() === "property_identifier"); + return name ? name.text() : null; +} + +function attributeExpression(attr: SgNode): SgNode | null { + const expression = attr.children().find((child) => child.kind() === "jsx_expression"); + return expression ? namedChildren(expression)[0] ?? null : null; +} + +function isDisplayContentsStyle(node: SgNode | null): boolean { + if (!node || node.kind() !== "object") return false; + const compact = node.text().replace(/\s+/g, ""); + return /^\{(?:display|"display"|'display'):(?:"contents"|'contents'),?\}$/.test(compact); +} + +function findWrapper(element: SgNode): Wrapper | null { + const opening = element.children().find((child) => child.kind() === "jsx_opening_element"); + const closing = element.children().find((child) => child.kind() === "jsx_closing_element"); + if (!opening || !closing) return null; + + const tagName = opening.field("name"); + if (!tagName || tagName.kind() !== "identifier" || !WRAPPER_TAGS.has(tagName.text())) return null; + + const attributeNodes = opening.children().filter((child) => child.kind() === "jsx_attribute" || child.kind() === "jsx_spread_attribute"); + if (attributeNodes.length !== 2 || attributeNodes.some((attr) => attr.kind() === "jsx_spread_attribute")) return null; + + let refAttr: SgNode | null = null; + let refName: string | null = null; + let hasContentsStyle = false; + for (const attr of attributeNodes) { + const name = attributeName(attr); + const expression = attributeExpression(attr); + if (name === "ref" && expression && expression.kind() === "identifier") { + refAttr = attr; + refName = expression.text(); + } else if (name === "style" && isDisplayContentsStyle(expression)) { + hasContentsStyle = true; + } + } + if (!refAttr || !refName || !hasContentsStyle) return null; + + return { element, opening, closing, refAttr, refName }; +} + +/** Every reference to the ref must be its declaration, this wrapper's ref prop, or `ref.current.`. */ +function refUsagesAreFragmentSafe(rootNode: SgNode, wrapper: Wrapper): boolean { + const usages = rootNode.findAll({ rule: { kind: "identifier", regex: `^${wrapper.refName}$` } }); + + for (const usage of usages) { + const parent = usage.parent(); + if (!parent) return false; + + if (parent.kind() === "variable_declarator" && parent.field("name")?.id() === usage.id()) continue; + + if (parent.kind() === "jsx_expression") { + const attr = parent.parent(); + if (attr && attr.id() === wrapper.refAttr.id()) continue; + return false; + } + + if (parent.kind() === "member_expression" && parent.field("object")?.id() === usage.id()) { + if (parent.field("property")?.text() !== "current") return false; + const access = parent.parent(); + if (!access || access.kind() !== "member_expression" || access.field("object")?.id() !== parent.id()) return false; + const method = access.field("property")?.text() ?? ""; + if (!FRAGMENT_INSTANCE_METHODS.has(method)) return false; + continue; + } + + return false; + } + + return true; +} + +function fragmentImportEdit(rootNode: SgNode, imports: ReactImports): Edit | null { + if (imports.firstNamedImport) { + const namedImports = imports.firstNamedImport.find({ rule: { kind: "named_imports" } }); + if (namedImports) { + const text = namedImports.text(); + let insertPos = namedImports.range().end.index - 1; + let offset = text.length - 1; + while (offset > 0 && /\s/.test(text[offset - 1] ?? "")) { + offset--; + insertPos--; + } + const inner = text.slice(1, -1).trim(); + return { startPos: insertPos, endPos: insertPos, insertedText: inner.length > 0 ? ", Fragment" : " Fragment " }; + } + } + + const statement = 'import { Fragment } from "react";'; + if (imports.lastImport) { + const insertPos = imports.lastImport.range().end.index; + return { startPos: insertPos, endPos: insertPos, insertedText: `\n${statement}` }; + } + + // Keep a leading directive such as "use client" first. + const first = namedChildren(rootNode)[0]; + if (first && first.kind() === "expression_statement" && namedChildren(first)[0]?.kind() === "string") { + const insertPos = first.range().end.index; + return { startPos: insertPos, endPos: insertPos, insertedText: `\n\n${statement}` }; + } + + return { startPos: 0, endPos: 0, insertedText: `${statement}\n\n` }; +} + +const transform: Transform = async (root) => { + const rootNode = root.root(); + const edits: Edit[] = []; + const metric = useMetricAtom("fragment-ref-wrapper-replacements"); + + const wrappers: Wrapper[] = []; + for (const element of rootNode.findAll({ rule: { kind: "jsx_element" } })) { + const wrapper = findWrapper(element); + if (wrapper && refUsagesAreFragmentSafe(rootNode, wrapper)) wrappers.push(wrapper); + } + if (wrappers.length === 0) return null; + + const imports = collectReactImports(rootNode); + let fragmentName: string; + if (imports.fragmentLocalName) { + fragmentName = imports.fragmentLocalName; + } else { + const namespace = [...imports.namespaces][0]; + if (namespace) { + fragmentName = `${namespace}.Fragment`; + } else { + if (rootNode.find({ rule: { any: [{ kind: "identifier", regex: "^Fragment$" }, { kind: "type_identifier", regex: "^Fragment$" }] } })) { + return null; + } + fragmentName = "Fragment"; + const importEdit = fragmentImportEdit(rootNode, imports); + if (importEdit) edits.push(importEdit); + } + } + + for (const wrapper of wrappers) { + edits.push(wrapper.opening.replace(`<${fragmentName} ${wrapper.refAttr.text()}>`)); + edits.push(wrapper.closing.replace(``)); + } + + metric.increment({ file: metricFile(root.filename()) }, wrappers.length); + return rootNode.commitEdits(edits); +}; + +export default transform; diff --git a/codemods/fragment-ref-wrappers/tests/add-fragment-import/expected.tsx b/codemods/fragment-ref-wrappers/tests/add-fragment-import/expected.tsx new file mode 100644 index 0000000..c539a26 --- /dev/null +++ b/codemods/fragment-ref-wrappers/tests/add-fragment-import/expected.tsx @@ -0,0 +1,15 @@ +import { useEffect, useRef, Fragment } from "react"; + +export function Cards({ items }) { + const groupRef = useRef(null); + useEffect(() => { + groupRef.current.observeUsing(new IntersectionObserver(() => {})); + }, []); + return ( + + {items.map((item) => ( +
{item.title}
+ ))} +
+ ); +} diff --git a/codemods/fragment-ref-wrappers/tests/add-fragment-import/input.tsx b/codemods/fragment-ref-wrappers/tests/add-fragment-import/input.tsx new file mode 100644 index 0000000..98774ce --- /dev/null +++ b/codemods/fragment-ref-wrappers/tests/add-fragment-import/input.tsx @@ -0,0 +1,15 @@ +import { useEffect, useRef } from "react"; + +export function Cards({ items }) { + const groupRef = useRef(null); + useEffect(() => { + groupRef.current.observeUsing(new IntersectionObserver(() => {})); + }, []); + return ( +
+ {items.map((item) => ( +
{item.title}
+ ))} +
+ ); +} diff --git a/codemods/fragment-ref-wrappers/tests/add-fragment-import/metrics.json b/codemods/fragment-ref-wrappers/tests/add-fragment-import/metrics.json new file mode 100644 index 0000000..cdf0b0a --- /dev/null +++ b/codemods/fragment-ref-wrappers/tests/add-fragment-import/metrics.json @@ -0,0 +1,10 @@ +{ + "fragment-ref-wrapper-replacements": [ + { + "cardinality": { + "file": "tests/add-fragment-import/input.tsx" + }, + "count": 1 + } + ] +} diff --git a/codemods/fragment-ref-wrappers/tests/default-react-import/expected.tsx b/codemods/fragment-ref-wrappers/tests/default-react-import/expected.tsx new file mode 100644 index 0000000..58e09a1 --- /dev/null +++ b/codemods/fragment-ref-wrappers/tests/default-react-import/expected.tsx @@ -0,0 +1,14 @@ +import React, { useRef } from "react"; + +export function Toolbar({ children }) { + const ref = useRef(null); + const focusFirst = () => ref.current?.focus(); + return ( + <> + + + {children} + + + ); +} diff --git a/codemods/fragment-ref-wrappers/tests/default-react-import/input.tsx b/codemods/fragment-ref-wrappers/tests/default-react-import/input.tsx new file mode 100644 index 0000000..b123d6b --- /dev/null +++ b/codemods/fragment-ref-wrappers/tests/default-react-import/input.tsx @@ -0,0 +1,14 @@ +import React, { useRef } from "react"; + +export function Toolbar({ children }) { + const ref = useRef(null); + const focusFirst = () => ref.current?.focus(); + return ( + <> + +
+ {children} +
+ + ); +} diff --git a/codemods/fragment-ref-wrappers/tests/default-react-import/metrics.json b/codemods/fragment-ref-wrappers/tests/default-react-import/metrics.json new file mode 100644 index 0000000..bf8d265 --- /dev/null +++ b/codemods/fragment-ref-wrappers/tests/default-react-import/metrics.json @@ -0,0 +1,10 @@ +{ + "fragment-ref-wrapper-replacements": [ + { + "cardinality": { + "file": "tests/default-react-import/input.tsx" + }, + "count": 1 + } + ] +} diff --git a/codemods/fragment-ref-wrappers/tests/extra-props-no-change/expected.tsx b/codemods/fragment-ref-wrappers/tests/extra-props-no-change/expected.tsx new file mode 100644 index 0000000..fe76674 --- /dev/null +++ b/codemods/fragment-ref-wrappers/tests/extra-props-no-change/expected.tsx @@ -0,0 +1,10 @@ +import { useRef } from "react"; + +export function Labelled({ children }) { + const ref = useRef(null); + return ( +
+ {children} +
+ ); +} diff --git a/codemods/fragment-ref-wrappers/tests/extra-props-no-change/input.tsx b/codemods/fragment-ref-wrappers/tests/extra-props-no-change/input.tsx new file mode 100644 index 0000000..fe76674 --- /dev/null +++ b/codemods/fragment-ref-wrappers/tests/extra-props-no-change/input.tsx @@ -0,0 +1,10 @@ +import { useRef } from "react"; + +export function Labelled({ children }) { + const ref = useRef(null); + return ( +
+ {children} +
+ ); +} diff --git a/codemods/fragment-ref-wrappers/tests/fragment-already-imported/expected.tsx b/codemods/fragment-ref-wrappers/tests/fragment-already-imported/expected.tsx new file mode 100644 index 0000000..ecf9151 --- /dev/null +++ b/codemods/fragment-ref-wrappers/tests/fragment-already-imported/expected.tsx @@ -0,0 +1,14 @@ +import { Fragment, useEffect, useRef } from "react"; + +export function Row({ children, onFocus }) { + const rowRef = useRef(null); + useEffect(() => { + rowRef.current.addEventListener("focusin", onFocus); + return () => rowRef.current.removeEventListener("focusin", onFocus); + }, [onFocus]); + return ( + + {children} + + ); +} diff --git a/codemods/fragment-ref-wrappers/tests/fragment-already-imported/input.tsx b/codemods/fragment-ref-wrappers/tests/fragment-already-imported/input.tsx new file mode 100644 index 0000000..bd9daab --- /dev/null +++ b/codemods/fragment-ref-wrappers/tests/fragment-already-imported/input.tsx @@ -0,0 +1,14 @@ +import { Fragment, useEffect, useRef } from "react"; + +export function Row({ children, onFocus }) { + const rowRef = useRef(null); + useEffect(() => { + rowRef.current.addEventListener("focusin", onFocus); + return () => rowRef.current.removeEventListener("focusin", onFocus); + }, [onFocus]); + return ( +
+ {children} +
+ ); +} diff --git a/codemods/fragment-ref-wrappers/tests/fragment-already-imported/metrics.json b/codemods/fragment-ref-wrappers/tests/fragment-already-imported/metrics.json new file mode 100644 index 0000000..7663f8d --- /dev/null +++ b/codemods/fragment-ref-wrappers/tests/fragment-already-imported/metrics.json @@ -0,0 +1,10 @@ +{ + "fragment-ref-wrapper-replacements": [ + { + "cardinality": { + "file": "tests/fragment-already-imported/input.tsx" + }, + "count": 1 + } + ] +} diff --git a/codemods/fragment-ref-wrappers/tests/no-react-import-ref-prop/expected.tsx b/codemods/fragment-ref-wrappers/tests/no-react-import-ref-prop/expected.tsx new file mode 100644 index 0000000..0432667 --- /dev/null +++ b/codemods/fragment-ref-wrappers/tests/no-react-import-ref-prop/expected.tsx @@ -0,0 +1,11 @@ +"use client"; + +import { Fragment } from "react"; + +export function Group({ ref, children }) { + return ( + + {children} + + ); +} diff --git a/codemods/fragment-ref-wrappers/tests/no-react-import-ref-prop/input.tsx b/codemods/fragment-ref-wrappers/tests/no-react-import-ref-prop/input.tsx new file mode 100644 index 0000000..96bba02 --- /dev/null +++ b/codemods/fragment-ref-wrappers/tests/no-react-import-ref-prop/input.tsx @@ -0,0 +1,9 @@ +"use client"; + +export function Group({ ref, children }) { + return ( + + {children} + + ); +} diff --git a/codemods/fragment-ref-wrappers/tests/no-react-import-ref-prop/metrics.json b/codemods/fragment-ref-wrappers/tests/no-react-import-ref-prop/metrics.json new file mode 100644 index 0000000..eb82392 --- /dev/null +++ b/codemods/fragment-ref-wrappers/tests/no-react-import-ref-prop/metrics.json @@ -0,0 +1,10 @@ +{ + "fragment-ref-wrapper-replacements": [ + { + "cardinality": { + "file": "tests/no-react-import-ref-prop/input.tsx" + }, + "count": 1 + } + ] +} diff --git a/codemods/fragment-ref-wrappers/tests/ref-passed-elsewhere-no-change/expected.tsx b/codemods/fragment-ref-wrappers/tests/ref-passed-elsewhere-no-change/expected.tsx new file mode 100644 index 0000000..01a6b52 --- /dev/null +++ b/codemods/fragment-ref-wrappers/tests/ref-passed-elsewhere-no-change/expected.tsx @@ -0,0 +1,12 @@ +import { useRef } from "react"; +import { useClickOutside } from "./use-click-outside"; + +export function Menu({ children, onClose }) { + const ref = useRef(null); + useClickOutside(ref, onClose); + return ( +
+ {children} +
+ ); +} diff --git a/codemods/fragment-ref-wrappers/tests/ref-passed-elsewhere-no-change/input.tsx b/codemods/fragment-ref-wrappers/tests/ref-passed-elsewhere-no-change/input.tsx new file mode 100644 index 0000000..01a6b52 --- /dev/null +++ b/codemods/fragment-ref-wrappers/tests/ref-passed-elsewhere-no-change/input.tsx @@ -0,0 +1,12 @@ +import { useRef } from "react"; +import { useClickOutside } from "./use-click-outside"; + +export function Menu({ children, onClose }) { + const ref = useRef(null); + useClickOutside(ref, onClose); + return ( +
+ {children} +
+ ); +} diff --git a/codemods/fragment-ref-wrappers/tests/two-wrappers/expected.tsx b/codemods/fragment-ref-wrappers/tests/two-wrappers/expected.tsx new file mode 100644 index 0000000..f1d4432 --- /dev/null +++ b/codemods/fragment-ref-wrappers/tests/two-wrappers/expected.tsx @@ -0,0 +1,17 @@ +import { useRef, Fragment } from "react"; + +export function Columns({ left, right }) { + const leftRef = useRef(null); + const rightRef = useRef(null); + return ( +
{ + if (event.key === "ArrowRight") rightRef.current.focus(); + if (event.key === "ArrowLeft") leftRef.current.focusLast(); + }} + > + {left} + {right} +
+ ); +} diff --git a/codemods/fragment-ref-wrappers/tests/two-wrappers/input.tsx b/codemods/fragment-ref-wrappers/tests/two-wrappers/input.tsx new file mode 100644 index 0000000..17b69fc --- /dev/null +++ b/codemods/fragment-ref-wrappers/tests/two-wrappers/input.tsx @@ -0,0 +1,17 @@ +import { useRef } from "react"; + +export function Columns({ left, right }) { + const leftRef = useRef(null); + const rightRef = useRef(null); + return ( +
{ + if (event.key === "ArrowRight") rightRef.current.focus(); + if (event.key === "ArrowLeft") leftRef.current.focusLast(); + }} + > +
{left}
+
{right}
+
+ ); +} diff --git a/codemods/fragment-ref-wrappers/tests/two-wrappers/metrics.json b/codemods/fragment-ref-wrappers/tests/two-wrappers/metrics.json new file mode 100644 index 0000000..a4e12f6 --- /dev/null +++ b/codemods/fragment-ref-wrappers/tests/two-wrappers/metrics.json @@ -0,0 +1,10 @@ +{ + "fragment-ref-wrapper-replacements": [ + { + "cardinality": { + "file": "tests/two-wrappers/input.tsx" + }, + "count": 2 + } + ] +} diff --git a/codemods/fragment-ref-wrappers/tests/unsupported-usage-no-change/expected.tsx b/codemods/fragment-ref-wrappers/tests/unsupported-usage-no-change/expected.tsx new file mode 100644 index 0000000..56c51ce --- /dev/null +++ b/codemods/fragment-ref-wrappers/tests/unsupported-usage-no-change/expected.tsx @@ -0,0 +1,13 @@ +import { useEffect, useRef } from "react"; + +export function Fader({ children }) { + const ref = useRef(null); + useEffect(() => { + ref.current.style.opacity = "1"; + }, []); + return ( +
+ {children} +
+ ); +} diff --git a/codemods/fragment-ref-wrappers/tests/unsupported-usage-no-change/input.tsx b/codemods/fragment-ref-wrappers/tests/unsupported-usage-no-change/input.tsx new file mode 100644 index 0000000..56c51ce --- /dev/null +++ b/codemods/fragment-ref-wrappers/tests/unsupported-usage-no-change/input.tsx @@ -0,0 +1,13 @@ +import { useEffect, useRef } from "react"; + +export function Fader({ children }) { + const ref = useRef(null); + useEffect(() => { + ref.current.style.opacity = "1"; + }, []); + return ( +
+ {children} +
+ ); +} diff --git a/codemods/fragment-ref-wrappers/tsconfig.json b/codemods/fragment-ref-wrappers/tsconfig.json new file mode 100644 index 0000000..decec57 --- /dev/null +++ b/codemods/fragment-ref-wrappers/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ESNext", + "lib": [ + "ESNext" + ], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": [ + "@codemod.com/jssg-types", + "node" + ], + "allowImportingTsExtensions": true, + "noEmit": true, + "skipLibCheck": true, + "verbatimModuleSyntax": false, + "erasableSyntaxOnly": true, + "strict": true, + "strictNullChecks": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true + }, + "exclude": [ + "tests" + ] +} diff --git a/codemods/fragment-ref-wrappers/workflow.yaml b/codemods/fragment-ref-wrappers/workflow.yaml new file mode 100644 index 0000000..813a9f6 --- /dev/null +++ b/codemods/fragment-ref-wrappers/workflow.yaml @@ -0,0 +1,14 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/codemod/codemod/refs/heads/main/schemas/workflow.json + +version: "1" + +nodes: + - id: apply-transforms + name: Apply AST Transformations + type: automatic + steps: + - name: "Replace display:contents ref wrappers with Fragment refs" + js-ast-grep: + js_file: scripts/codemod.ts + language: "tsx" + semantic_analysis: file diff --git a/codemods/react-19-3-migration-recipe/README.md b/codemods/react-19-3-migration-recipe/README.md new file mode 100644 index 0000000..72bca2d --- /dev/null +++ b/codemods/react-19-3-migration-recipe/README.md @@ -0,0 +1,18 @@ +# react-19-3-migration-recipe + +Run all React 19.3 adoption codemods in sequence. + +React 19.3 has no breaking changes, so this recipe does not fix anything that stops working. It moves code from experimental names and pre-19.3 workarounds onto the stable APIs. + +## Usage + +```bash +npx codemod react-19-3-migration-recipe --target +``` + +This recipe applies the following codemods: + +1. [**react-19-3-unprefix-stable-apis**](https://app.codemod.com/registry/react-19-3-unprefix-stable-apis) — rename `unstable_ViewTransition`, `unstable_addTransitionType`, `unstable_Activity`, and `experimental_useEffectEvent` to their stable names +2. [**react-19-remove-context-provider**](https://app.codemod.com/registry/react-19-remove-context-provider) — replace `Context.Provider` with `Context` +3. [**react-19-3-use-browser**](https://app.codemod.com/registry/react-19-3-use-browser) — replace the mounted-state pattern for browser-only components with `use(browser())` +4. [**react-19-3-fragment-ref-wrappers**](https://app.codemod.com/registry/react-19-3-fragment-ref-wrappers) — replace `display: contents` wrapper elements that only hold a ref with Fragment refs diff --git a/codemods/react-19-3-migration-recipe/codemod.yaml b/codemods/react-19-3-migration-recipe/codemod.yaml new file mode 100644 index 0000000..33b589b --- /dev/null +++ b/codemods/react-19-3-migration-recipe/codemod.yaml @@ -0,0 +1,20 @@ +schema_version: "1.0" + +name: "react-19-3-migration-recipe" +version: "0.1.0" +description: "Run all React 19.3 adoption codemods in sequence" +author: "Codemod " +license: "MIT" +workflow: "workflow.yaml" +repository: "https://github.com/codemod/react-codemod/tree/main/codemods/react-19-3-migration-recipe" + +targets: + languages: ["tsx"] + +keywords: ["React", "migration", "recipe"] + +registry: + access: "public" + visibility: "public" + +capabilities: [] diff --git a/codemods/react-19-3-migration-recipe/package.json b/codemods/react-19-3-migration-recipe/package.json new file mode 100644 index 0000000..3327741 --- /dev/null +++ b/codemods/react-19-3-migration-recipe/package.json @@ -0,0 +1,11 @@ +{ + "name": "react-19-3-migration-recipe", + "version": "0.1.0", + "description": "Run all React 19.3 adoption codemods in sequence", + "type": "module", + "scripts": { + "test": "node --test ./scripts/metadata-tests.mjs", + "check-types": "node --check ./scripts/metadata-tests.mjs" + }, + "license": "MIT" +} diff --git a/codemods/react-19-3-migration-recipe/scripts/metadata-tests.mjs b/codemods/react-19-3-migration-recipe/scripts/metadata-tests.mjs new file mode 100644 index 0000000..105cbc2 --- /dev/null +++ b/codemods/react-19-3-migration-recipe/scripts/metadata-tests.mjs @@ -0,0 +1,68 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const packageDir = path.dirname(path.dirname(__filename)); +const repoRoot = path.resolve(packageDir, "..", ".."); + +const packageJson = JSON.parse(readFileSync(path.join(packageDir, "package.json"), "utf8")); +const codemodYaml = readFileSync(path.join(packageDir, "codemod.yaml"), "utf8"); +const workflowYaml = readFileSync(path.join(packageDir, "workflow.yaml"), "utf8"); +const readme = readFileSync(path.join(packageDir, "README.md"), "utf8"); + +function extractSources(yamlText) { + return [...yamlText.matchAll(/source:\s*"([^"]+)"/g)].map((match) => match[1]); +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function findPackageDirByName(packageName) { + const codemodsDir = path.join(repoRoot, "codemods"); + + for (const slug of readdirSync(codemodsDir)) { + const packageJsonPath = path.join(codemodsDir, slug, "package.json"); + + if (!existsSync(packageJsonPath)) { + continue; + } + + const candidatePackageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")); + + if (candidatePackageJson.name === packageName) { + return path.join(codemodsDir, slug); + } + } + + return undefined; +} + +test("codemod metadata stays in sync with package.json", () => { + assert.match(codemodYaml, new RegExp(`name: "${packageJson.name.replaceAll("/", "\\/")}"`)); + assert.match(codemodYaml, new RegExp(`version: "${escapeRegExp(packageJson.version)}"`)); + assert.match(codemodYaml, /workflow: "workflow\.yaml"/); +}); + +test("workflow only references codemods that exist in this workspace", () => { + const sources = extractSources(workflowYaml); + + assert.equal(sources.length, 4); + + for (const source of sources) { + const codemodDir = findPackageDirByName(source); + + assert.ok(codemodDir, `Missing package for ${source}`); + assert.ok(existsSync(path.join(codemodDir, "codemod.yaml")), `Missing codemod.yaml for ${source}`); + + const referencedPackageJson = JSON.parse(readFileSync(path.join(codemodDir, "package.json"), "utf8")); + assert.equal(referencedPackageJson.name, source); + } +}); + +test("README usage stays aligned with the published package name", () => { + assert.match(readme, new RegExp(`npx codemod ${packageJson.name} --target `)); +}); diff --git a/codemods/react-19-3-migration-recipe/workflow.yaml b/codemods/react-19-3-migration-recipe/workflow.yaml new file mode 100644 index 0000000..6ec21da --- /dev/null +++ b/codemods/react-19-3-migration-recipe/workflow.yaml @@ -0,0 +1,24 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/codemod/codemod/refs/heads/main/schemas/workflow.json + +version: "1" + +nodes: + - id: react-19-3-migration + name: React 19.3 Migration + type: automatic + steps: + - name: "Rename unstable_ and experimental_ APIs to their stable names" + codemod: + source: "react-19-3-unprefix-stable-apis" + + - name: "Replace Context.Provider with Context" + codemod: + source: "react-19-remove-context-provider" + + - name: "Replace mounted-state browser-only components with use(browser())" + codemod: + source: "react-19-3-use-browser" + + - name: "Replace display:contents ref wrappers with Fragment refs" + codemod: + source: "react-19-3-fragment-ref-wrappers" diff --git a/codemods/unprefix-stable-apis/README.md b/codemods/unprefix-stable-apis/README.md new file mode 100644 index 0000000..66184d4 --- /dev/null +++ b/codemods/unprefix-stable-apis/README.md @@ -0,0 +1,42 @@ +# react-19-3-unprefix-stable-apis + +Rename experimental React APIs that became stable in React 19.2 and 19.3 to their stable, unprefixed names. + +| Before | After | Stable since | +| --- | --- | --- | +| `unstable_ViewTransition` | `ViewTransition` | 19.3 | +| `unstable_addTransitionType` | `addTransitionType` | 19.3 | +| `unstable_Activity` | `Activity` | 19.2 | +| `experimental_useEffectEvent` | `useEffectEvent` | 19.2 | + +The codemod handles named imports (with or without aliases), `React.unstable_ViewTransition` member access through a default or namespace import, CommonJS `require("react")` destructuring, JSX tag names, re-exports, and every usage site of a renamed binding. + +`unstable_startGestureTransition` is not touched because it has not been stabilized. + +## Usage + +```bash +npx codemod react-19-3-unprefix-stable-apis --target +``` + +## Example + +```tsx +// Before +import { unstable_ViewTransition as ViewTransition, unstable_addTransitionType } from "react"; + +startTransition(() => { + unstable_addTransitionType("nav-forward"); + setPage(next); +}); + +// After +import { ViewTransition, addTransitionType } from "react"; + +startTransition(() => { + addTransitionType("nav-forward"); + setPage(next); +}); +``` + +If the stable name is already taken by another binding in the file, the import keeps the old local name (`import { ViewTransition as unstable_ViewTransition } from "react"`) so nothing else in the file has to change. diff --git a/codemods/unprefix-stable-apis/codemod.yaml b/codemods/unprefix-stable-apis/codemod.yaml new file mode 100644 index 0000000..82e2ea3 --- /dev/null +++ b/codemods/unprefix-stable-apis/codemod.yaml @@ -0,0 +1,20 @@ +schema_version: "1.0" + +name: "react-19-3-unprefix-stable-apis" +version: "0.1.0" +description: "Rename unstable_/experimental_ React APIs that became stable in React 19.2 and 19.3 to their stable names" +author: "Codemod " +license: "MIT" +workflow: "workflow.yaml" +repository: "https://github.com/codemod/react-codemod/tree/main/codemods/unprefix-stable-apis" + +targets: + languages: ["tsx"] + +keywords: ["React", "ViewTransition", "Activity", "useEffectEvent"] + +registry: + access: "public" + visibility: "public" + +capabilities: [] diff --git a/codemods/unprefix-stable-apis/package.json b/codemods/unprefix-stable-apis/package.json new file mode 100644 index 0000000..3fe1152 --- /dev/null +++ b/codemods/unprefix-stable-apis/package.json @@ -0,0 +1,15 @@ +{ + "name": "react-19-3-unprefix-stable-apis", + "version": "0.1.0", + "description": "Rename unstable_/experimental_ React APIs that became stable in React 19.2 and 19.3 to their stable names", + "type": "module", + "scripts": { + "test": "pnpm dlx codemod@latest jssg test -l tsx ./scripts/codemod.ts", + "check-types": "tsc --noEmit" + }, + "devDependencies": { + "@codemod.com/jssg-types": "latest", + "@types/node": "latest", + "typescript": "latest" + } +} diff --git a/codemods/unprefix-stable-apis/scripts/codemod.ts b/codemods/unprefix-stable-apis/scripts/codemod.ts new file mode 100644 index 0000000..1551751 --- /dev/null +++ b/codemods/unprefix-stable-apis/scripts/codemod.ts @@ -0,0 +1,230 @@ +import type { Transform, Edit, SgNode } from "codemod:ast-grep"; +import type TSX from "codemod:ast-grep/langs/tsx"; +import { useMetricAtom } from "codemod:metrics"; + +function metricFile(filename: string): string { + const cwd = process.cwd() + "/"; + return filename.startsWith(cwd) ? filename.slice(cwd.length) : filename; +} + +const REACT_MODULE = "react"; + +/** + * Experimental export name -> stable export name. + * ViewTransition and addTransitionType stabilized in React 19.3, + * Activity and useEffectEvent stabilized in React 19.2. + */ +const RENAMES: Record = { + unstable_ViewTransition: "ViewTransition", + unstable_addTransitionType: "addTransitionType", + unstable_Activity: "Activity", + experimental_useEffectEvent: "useEffectEvent", +}; + +function stableName(name: string | undefined): string | null { + if (!name) return null; + return Object.prototype.hasOwnProperty.call(RENAMES, name) ? RENAMES[name] ?? null : null; +} + +function namedChildren(node: SgNode | null): SgNode[] { + if (!node) return []; + return node.children().filter((child) => child.isNamed() && child.kind() !== "comment"); +} + +function sourceText(node: SgNode): string | null { + const fragment = node.find({ rule: { kind: "string_fragment" } }); + if (fragment) return fragment.text(); + const text = node.text(); + return text.length >= 2 ? text.slice(1, -1) : null; +} + +function importSource(node: SgNode): string | null { + const source = node.field("source") ?? node.find({ rule: { kind: "string" } }); + return source ? sourceText(source) : null; +} + +function isReactRequireCall(node: SgNode | null): boolean { + if (!node || node.kind() !== "call_expression") return false; + const fn = node.field("function"); + if (!fn || fn.kind() !== "identifier" || fn.text() !== "require") return false; + const args = namedChildren(node.field("arguments")); + const first = args[0]; + return args.length === 1 && !!first && first.kind() === "string" && sourceText(first) === REACT_MODULE; +} + +function isInsideImport(node: SgNode): boolean { + return node.ancestors().some((ancestor) => ancestor.kind() === "import_statement"); +} + +/** Local names that refer to the React module object (default import, namespace import, require binding). */ +function findReactNamespaceBindings(rootNode: SgNode): Set { + const names = new Set(); + + for (const imp of rootNode.findAll({ rule: { kind: "import_statement" } })) { + if (importSource(imp) !== REACT_MODULE) continue; + const importClause = imp.find({ rule: { kind: "import_clause" } }); + const defaultIdentifier = importClause?.children().find((child) => child.kind() === "identifier"); + if (defaultIdentifier) names.add(defaultIdentifier.text()); + const namespaceImport = imp.find({ rule: { kind: "namespace_import" } }); + const namespaceName = namespaceImport?.field("name") ?? namespaceImport?.find({ rule: { kind: "identifier" } }); + if (namespaceName) names.add(namespaceName.text()); + } + + for (const declarator of rootNode.findAll({ rule: { kind: "variable_declarator" } })) { + const name = declarator.field("name"); + if (name && name.kind() === "identifier" && isReactRequireCall(declarator.field("value"))) { + names.add(name.text()); + } + } + + return names; +} + +/** True when `name` is already bound somewhere in the file, outside of `ignore`. */ +function isNameTaken(rootNode: SgNode, name: string, ignore: SgNode): boolean { + const ignoreStart = ignore.range().start.index; + const ignoreEnd = ignore.range().end.index; + const matches = rootNode.findAll({ + rule: { + any: [ + { kind: "identifier", regex: `^${name}$` }, + { kind: "shorthand_property_identifier_pattern", regex: `^${name}$` }, + { kind: "type_identifier", regex: `^${name}$` }, + ], + }, + }); + return matches.some((match) => { + const start = match.range().start.index; + return start < ignoreStart || start >= ignoreEnd; + }); +} + +const transform: Transform = async (root) => { + const rootNode = root.root(); + const edits: Edit[] = []; + const metric = useMetricAtom("unprefix-stable-apis-renames"); + let renames = 0; + + // Old local name -> new local name, for bindings whose local identifier changes. + const localRenames = new Map(); + // Ranges of nodes we already rewrote, so the usage-site pass skips them. + const handledStarts = new Set(); + + // 1. ESM named imports from "react". + for (const importNode of rootNode.findAll({ rule: { kind: "import_statement" } })) { + if (importSource(importNode) !== REACT_MODULE) continue; + + for (const specifier of importNode.findAll({ rule: { kind: "import_specifier" } })) { + const nameNode = specifier.field("name"); + const newName = stableName(nameNode?.text()); + if (!nameNode || !newName) continue; + const oldName = nameNode.text(); + const alias = specifier.field("alias"); + + if (alias) { + edits.push(specifier.replace(alias.text() === newName ? newName : `${newName} as ${alias.text()}`)); + } else if (isNameTaken(rootNode, newName, specifier)) { + edits.push(specifier.replace(`${newName} as ${oldName}`)); + } else { + edits.push(specifier.replace(newName)); + localRenames.set(oldName, newName); + } + handledStarts.add(specifier.range().start.index); + renames++; + } + } + + // 2. CommonJS destructuring: const { unstable_ViewTransition } = require("react"). + for (const declarator of rootNode.findAll({ rule: { kind: "variable_declarator" } })) { + const pattern = declarator.field("name"); + if (!pattern || pattern.kind() !== "object_pattern" || !isReactRequireCall(declarator.field("value"))) continue; + + for (const entry of namedChildren(pattern)) { + if (entry.kind() === "pair_pattern") { + const key = entry.field("key"); + const newName = stableName(key?.text()); + if (!key || !newName) continue; + const value = entry.field("value"); + if (value && value.kind() === "identifier" && value.text() === newName) { + // { unstable_ViewTransition: ViewTransition } -> { ViewTransition } + edits.push(entry.replace(newName)); + } else { + edits.push(key.replace(newName)); + } + handledStarts.add(entry.range().start.index); + renames++; + } else if (entry.kind() === "shorthand_property_identifier_pattern") { + const oldName = entry.text(); + const newName = stableName(oldName); + if (!newName) continue; + if (isNameTaken(rootNode, newName, entry)) { + edits.push(entry.replace(`${newName}: ${oldName}`)); + } else { + edits.push(entry.replace(newName)); + localRenames.set(oldName, newName); + } + handledStarts.add(entry.range().start.index); + renames++; + } + } + } + + // 3. Member access through the module object: React.unstable_ViewTransition, . + const namespaceBindings = findReactNamespaceBindings(rootNode); + if (namespaceBindings.size > 0) { + for (const member of rootNode.findAll({ rule: { kind: "member_expression" } })) { + const object = member.field("object"); + const property = member.field("property"); + if (!object || !property || object.kind() !== "identifier" || !namespaceBindings.has(object.text())) continue; + const newName = stableName(property.text()); + if (!newName) continue; + edits.push(property.replace(newName)); + renames++; + } + } + + // 4. Usage sites of bindings whose local name changed. + for (const [oldName, newName] of localRenames) { + const usages = rootNode.findAll({ + rule: { + any: [ + { kind: "identifier", regex: `^${oldName}$` }, + { kind: "type_identifier", regex: `^${oldName}$` }, + { kind: "shorthand_property_identifier", regex: `^${oldName}$` }, + ], + }, + }); + + for (const usage of usages) { + if (isInsideImport(usage)) continue; + if (usage.ancestors().some((ancestor) => handledStarts.has(ancestor.range().start.index))) continue; + if (handledStarts.has(usage.range().start.index)) continue; + + const parent = usage.parent(); + if (usage.kind() === "shorthand_property_identifier") { + // { unstable_ViewTransition } -> { unstable_ViewTransition: ViewTransition } keeps the object key stable. + edits.push(usage.replace(`${oldName}: ${newName}`)); + continue; + } + + if (parent && parent.kind() === "export_specifier") { + const exportAlias = parent.field("alias"); + if (!exportAlias) { + // export { unstable_ViewTransition } -> export { ViewTransition as unstable_ViewTransition } + edits.push(usage.replace(`${newName} as ${oldName}`)); + continue; + } + if (exportAlias.range().start.index === usage.range().start.index) continue; + } + + edits.push(usage.replace(newName)); + } + } + + if (edits.length === 0) return null; + + metric.increment({ file: metricFile(root.filename()) }, renames); + return rootNode.commitEdits(edits); +}; + +export default transform; diff --git a/codemods/unprefix-stable-apis/tests/activity-and-effect-event/expected.tsx b/codemods/unprefix-stable-apis/tests/activity-and-effect-event/expected.tsx new file mode 100644 index 0000000..7c79321 --- /dev/null +++ b/codemods/unprefix-stable-apis/tests/activity-and-effect-event/expected.tsx @@ -0,0 +1,17 @@ +import { useEffect, Activity, useEffectEvent } from "react"; + +export function Chat({ roomId, visible, onVisit }) { + const onConnected = useEffectEvent(() => { + onVisit(roomId); + }); + + useEffect(() => { + onConnected(); + }, [roomId]); + + return ( + +

{roomId}

+
+ ); +} diff --git a/codemods/unprefix-stable-apis/tests/activity-and-effect-event/input.tsx b/codemods/unprefix-stable-apis/tests/activity-and-effect-event/input.tsx new file mode 100644 index 0000000..5c9d29a --- /dev/null +++ b/codemods/unprefix-stable-apis/tests/activity-and-effect-event/input.tsx @@ -0,0 +1,17 @@ +import { useEffect, unstable_Activity, experimental_useEffectEvent } from "react"; + +export function Chat({ roomId, visible, onVisit }) { + const onConnected = experimental_useEffectEvent(() => { + onVisit(roomId); + }); + + useEffect(() => { + onConnected(); + }, [roomId]); + + return ( + +

{roomId}

+
+ ); +} diff --git a/codemods/unprefix-stable-apis/tests/activity-and-effect-event/metrics.json b/codemods/unprefix-stable-apis/tests/activity-and-effect-event/metrics.json new file mode 100644 index 0000000..c96c3c3 --- /dev/null +++ b/codemods/unprefix-stable-apis/tests/activity-and-effect-event/metrics.json @@ -0,0 +1,10 @@ +{ + "unprefix-stable-apis-renames": [ + { + "cardinality": { + "file": "tests/activity-and-effect-event/input.tsx" + }, + "count": 2 + } + ] +} diff --git a/codemods/unprefix-stable-apis/tests/alias-with-different-name/expected.tsx b/codemods/unprefix-stable-apis/tests/alias-with-different-name/expected.tsx new file mode 100644 index 0000000..2666dae --- /dev/null +++ b/codemods/unprefix-stable-apis/tests/alias-with-different-name/expected.tsx @@ -0,0 +1,9 @@ +import { ViewTransition as VT, Activity as Offscreen } from 'react'; + +export function Row({ hidden, children }) { + return ( + + {children} + + ); +} diff --git a/codemods/unprefix-stable-apis/tests/alias-with-different-name/input.tsx b/codemods/unprefix-stable-apis/tests/alias-with-different-name/input.tsx new file mode 100644 index 0000000..e972a8e --- /dev/null +++ b/codemods/unprefix-stable-apis/tests/alias-with-different-name/input.tsx @@ -0,0 +1,9 @@ +import { unstable_ViewTransition as VT, unstable_Activity as Offscreen } from 'react'; + +export function Row({ hidden, children }) { + return ( + + {children} + + ); +} diff --git a/codemods/unprefix-stable-apis/tests/alias-with-different-name/metrics.json b/codemods/unprefix-stable-apis/tests/alias-with-different-name/metrics.json new file mode 100644 index 0000000..d38daca --- /dev/null +++ b/codemods/unprefix-stable-apis/tests/alias-with-different-name/metrics.json @@ -0,0 +1,10 @@ +{ + "unprefix-stable-apis-renames": [ + { + "cardinality": { + "file": "tests/alias-with-different-name/input.tsx" + }, + "count": 2 + } + ] +} diff --git a/codemods/unprefix-stable-apis/tests/aliased-named-import/expected.tsx b/codemods/unprefix-stable-apis/tests/aliased-named-import/expected.tsx new file mode 100644 index 0000000..4a6862e --- /dev/null +++ b/codemods/unprefix-stable-apis/tests/aliased-named-import/expected.tsx @@ -0,0 +1,16 @@ +import { startTransition, ViewTransition, addTransitionType } from "react"; + +export function Gallery({ items, index, setIndex }) { + function next() { + startTransition(() => { + addTransitionType("slide-next"); + setIndex(index + 1); + }); + } + + return ( + + + + ); +} diff --git a/codemods/unprefix-stable-apis/tests/aliased-named-import/input.tsx b/codemods/unprefix-stable-apis/tests/aliased-named-import/input.tsx new file mode 100644 index 0000000..4631168 --- /dev/null +++ b/codemods/unprefix-stable-apis/tests/aliased-named-import/input.tsx @@ -0,0 +1,16 @@ +import { startTransition, unstable_ViewTransition as ViewTransition, unstable_addTransitionType as addTransitionType } from "react"; + +export function Gallery({ items, index, setIndex }) { + function next() { + startTransition(() => { + addTransitionType("slide-next"); + setIndex(index + 1); + }); + } + + return ( + + + + ); +} diff --git a/codemods/unprefix-stable-apis/tests/aliased-named-import/metrics.json b/codemods/unprefix-stable-apis/tests/aliased-named-import/metrics.json new file mode 100644 index 0000000..0532027 --- /dev/null +++ b/codemods/unprefix-stable-apis/tests/aliased-named-import/metrics.json @@ -0,0 +1,10 @@ +{ + "unprefix-stable-apis-renames": [ + { + "cardinality": { + "file": "tests/aliased-named-import/input.tsx" + }, + "count": 2 + } + ] +} diff --git a/codemods/unprefix-stable-apis/tests/cjs-require/expected.tsx b/codemods/unprefix-stable-apis/tests/cjs-require/expected.tsx new file mode 100644 index 0000000..c2ea486 --- /dev/null +++ b/codemods/unprefix-stable-apis/tests/cjs-require/expected.tsx @@ -0,0 +1,17 @@ +const React = require("react"); +const { ViewTransition, addTransitionType, useState } = require("react"); + +function Card({ children }) { + const [open, setOpen] = useState(false); + return ( + + {children} + + ); +} + +function markNavigation() { + addTransitionType("navigation"); +} + +module.exports = { Card, markNavigation }; diff --git a/codemods/unprefix-stable-apis/tests/cjs-require/input.tsx b/codemods/unprefix-stable-apis/tests/cjs-require/input.tsx new file mode 100644 index 0000000..5787012 --- /dev/null +++ b/codemods/unprefix-stable-apis/tests/cjs-require/input.tsx @@ -0,0 +1,17 @@ +const React = require("react"); +const { unstable_ViewTransition: ViewTransition, unstable_addTransitionType, useState } = require("react"); + +function Card({ children }) { + const [open, setOpen] = useState(false); + return ( + + {children} + + ); +} + +function markNavigation() { + unstable_addTransitionType("navigation"); +} + +module.exports = { Card, markNavigation }; diff --git a/codemods/unprefix-stable-apis/tests/cjs-require/metrics.json b/codemods/unprefix-stable-apis/tests/cjs-require/metrics.json new file mode 100644 index 0000000..df2c192 --- /dev/null +++ b/codemods/unprefix-stable-apis/tests/cjs-require/metrics.json @@ -0,0 +1,10 @@ +{ + "unprefix-stable-apis-renames": [ + { + "cardinality": { + "file": "tests/cjs-require/input.tsx" + }, + "count": 4 + } + ] +} diff --git a/codemods/unprefix-stable-apis/tests/default-import-member-access/expected.tsx b/codemods/unprefix-stable-apis/tests/default-import-member-access/expected.tsx new file mode 100644 index 0000000..e117251 --- /dev/null +++ b/codemods/unprefix-stable-apis/tests/default-import-member-access/expected.tsx @@ -0,0 +1,5 @@ +import React from "react"; + +export function Panel({ children }) { + return {children}; +} diff --git a/codemods/unprefix-stable-apis/tests/default-import-member-access/input.tsx b/codemods/unprefix-stable-apis/tests/default-import-member-access/input.tsx new file mode 100644 index 0000000..f3e492e --- /dev/null +++ b/codemods/unprefix-stable-apis/tests/default-import-member-access/input.tsx @@ -0,0 +1,5 @@ +import React from "react"; + +export function Panel({ children }) { + return {children}; +} diff --git a/codemods/unprefix-stable-apis/tests/default-import-member-access/metrics.json b/codemods/unprefix-stable-apis/tests/default-import-member-access/metrics.json new file mode 100644 index 0000000..e9619e4 --- /dev/null +++ b/codemods/unprefix-stable-apis/tests/default-import-member-access/metrics.json @@ -0,0 +1,10 @@ +{ + "unprefix-stable-apis-renames": [ + { + "cardinality": { + "file": "tests/default-import-member-access/input.tsx" + }, + "count": 2 + } + ] +} diff --git a/codemods/unprefix-stable-apis/tests/member-access/expected.tsx b/codemods/unprefix-stable-apis/tests/member-access/expected.tsx new file mode 100644 index 0000000..fbeb9fb --- /dev/null +++ b/codemods/unprefix-stable-apis/tests/member-access/expected.tsx @@ -0,0 +1,16 @@ +import * as React from "react"; + +export function Sidebar({ open, children }) { + return ( + + {children} + + ); +} + +export function useLogger(onEvent) { + const handle = React.useEffectEvent(onEvent); + React.useEffect(() => { + handle("mounted"); + }, [handle]); +} diff --git a/codemods/unprefix-stable-apis/tests/member-access/input.tsx b/codemods/unprefix-stable-apis/tests/member-access/input.tsx new file mode 100644 index 0000000..11ae8d6 --- /dev/null +++ b/codemods/unprefix-stable-apis/tests/member-access/input.tsx @@ -0,0 +1,16 @@ +import * as React from "react"; + +export function Sidebar({ open, children }) { + return ( + + {children} + + ); +} + +export function useLogger(onEvent) { + const handle = React.experimental_useEffectEvent(onEvent); + React.useEffect(() => { + handle("mounted"); + }, [handle]); +} diff --git a/codemods/unprefix-stable-apis/tests/member-access/metrics.json b/codemods/unprefix-stable-apis/tests/member-access/metrics.json new file mode 100644 index 0000000..c84b090 --- /dev/null +++ b/codemods/unprefix-stable-apis/tests/member-access/metrics.json @@ -0,0 +1,10 @@ +{ + "unprefix-stable-apis-renames": [ + { + "cardinality": { + "file": "tests/member-access/input.tsx" + }, + "count": 5 + } + ] +} diff --git a/codemods/unprefix-stable-apis/tests/other-module-no-change/expected.tsx b/codemods/unprefix-stable-apis/tests/other-module-no-change/expected.tsx new file mode 100644 index 0000000..bc038d4 --- /dev/null +++ b/codemods/unprefix-stable-apis/tests/other-module-no-change/expected.tsx @@ -0,0 +1,12 @@ +import { unstable_ViewTransition } from "react-experimental-shim"; +import { unstable_startGestureTransition as startGestureTransition, unstable_SuspenseList as SuspenseList } from "react"; + +export function App({ children }) { + return ( + + {children} + + ); +} + +export { startGestureTransition }; diff --git a/codemods/unprefix-stable-apis/tests/other-module-no-change/input.tsx b/codemods/unprefix-stable-apis/tests/other-module-no-change/input.tsx new file mode 100644 index 0000000..bc038d4 --- /dev/null +++ b/codemods/unprefix-stable-apis/tests/other-module-no-change/input.tsx @@ -0,0 +1,12 @@ +import { unstable_ViewTransition } from "react-experimental-shim"; +import { unstable_startGestureTransition as startGestureTransition, unstable_SuspenseList as SuspenseList } from "react"; + +export function App({ children }) { + return ( + + {children} + + ); +} + +export { startGestureTransition }; diff --git a/codemods/unprefix-stable-apis/tests/plain-named-import/expected.tsx b/codemods/unprefix-stable-apis/tests/plain-named-import/expected.tsx new file mode 100644 index 0000000..ce24f97 --- /dev/null +++ b/codemods/unprefix-stable-apis/tests/plain-named-import/expected.tsx @@ -0,0 +1,18 @@ +import { ViewTransition, addTransitionType, startTransition } from "react"; + +export function Page({ setRoute }) { + const go = (route: string) => { + startTransition(() => { + addTransitionType("nav-forward"); + setRoute(route); + }); + }; + + const transitions = { unstable_ViewTransition: ViewTransition }; + + return ( + + + + ); +} diff --git a/codemods/unprefix-stable-apis/tests/plain-named-import/input.tsx b/codemods/unprefix-stable-apis/tests/plain-named-import/input.tsx new file mode 100644 index 0000000..47f0391 --- /dev/null +++ b/codemods/unprefix-stable-apis/tests/plain-named-import/input.tsx @@ -0,0 +1,18 @@ +import { unstable_ViewTransition, unstable_addTransitionType, startTransition } from "react"; + +export function Page({ setRoute }) { + const go = (route: string) => { + startTransition(() => { + unstable_addTransitionType("nav-forward"); + setRoute(route); + }); + }; + + const transitions = { unstable_ViewTransition }; + + return ( + + + + ); +} diff --git a/codemods/unprefix-stable-apis/tests/plain-named-import/metrics.json b/codemods/unprefix-stable-apis/tests/plain-named-import/metrics.json new file mode 100644 index 0000000..b81df5f --- /dev/null +++ b/codemods/unprefix-stable-apis/tests/plain-named-import/metrics.json @@ -0,0 +1,10 @@ +{ + "unprefix-stable-apis-renames": [ + { + "cardinality": { + "file": "tests/plain-named-import/input.tsx" + }, + "count": 2 + } + ] +} diff --git a/codemods/unprefix-stable-apis/tests/re-export/expected.tsx b/codemods/unprefix-stable-apis/tests/re-export/expected.tsx new file mode 100644 index 0000000..1251691 --- /dev/null +++ b/codemods/unprefix-stable-apis/tests/re-export/expected.tsx @@ -0,0 +1,3 @@ +import { ViewTransition, useEffectEvent } from "react"; + +export { ViewTransition as unstable_ViewTransition, useEffectEvent as useEvent }; diff --git a/codemods/unprefix-stable-apis/tests/re-export/input.tsx b/codemods/unprefix-stable-apis/tests/re-export/input.tsx new file mode 100644 index 0000000..48fd2e5 --- /dev/null +++ b/codemods/unprefix-stable-apis/tests/re-export/input.tsx @@ -0,0 +1,3 @@ +import { unstable_ViewTransition, experimental_useEffectEvent } from "react"; + +export { unstable_ViewTransition, experimental_useEffectEvent as useEvent }; diff --git a/codemods/unprefix-stable-apis/tests/re-export/metrics.json b/codemods/unprefix-stable-apis/tests/re-export/metrics.json new file mode 100644 index 0000000..040676a --- /dev/null +++ b/codemods/unprefix-stable-apis/tests/re-export/metrics.json @@ -0,0 +1,10 @@ +{ + "unprefix-stable-apis-renames": [ + { + "cardinality": { + "file": "tests/re-export/input.tsx" + }, + "count": 2 + } + ] +} diff --git a/codemods/unprefix-stable-apis/tests/stable-name-already-taken/expected.tsx b/codemods/unprefix-stable-apis/tests/stable-name-already-taken/expected.tsx new file mode 100644 index 0000000..7e5d866 --- /dev/null +++ b/codemods/unprefix-stable-apis/tests/stable-name-already-taken/expected.tsx @@ -0,0 +1,10 @@ +import { ViewTransition as unstable_ViewTransition } from "react"; +import { ViewTransition } from "./my-view-transition"; + +export function Both({ children }) { + return ( + + {children} + + ); +} diff --git a/codemods/unprefix-stable-apis/tests/stable-name-already-taken/input.tsx b/codemods/unprefix-stable-apis/tests/stable-name-already-taken/input.tsx new file mode 100644 index 0000000..ac407d6 --- /dev/null +++ b/codemods/unprefix-stable-apis/tests/stable-name-already-taken/input.tsx @@ -0,0 +1,10 @@ +import { unstable_ViewTransition } from "react"; +import { ViewTransition } from "./my-view-transition"; + +export function Both({ children }) { + return ( + + {children} + + ); +} diff --git a/codemods/unprefix-stable-apis/tests/stable-name-already-taken/metrics.json b/codemods/unprefix-stable-apis/tests/stable-name-already-taken/metrics.json new file mode 100644 index 0000000..d7259c4 --- /dev/null +++ b/codemods/unprefix-stable-apis/tests/stable-name-already-taken/metrics.json @@ -0,0 +1,10 @@ +{ + "unprefix-stable-apis-renames": [ + { + "cardinality": { + "file": "tests/stable-name-already-taken/input.tsx" + }, + "count": 1 + } + ] +} diff --git a/codemods/unprefix-stable-apis/tsconfig.json b/codemods/unprefix-stable-apis/tsconfig.json new file mode 100644 index 0000000..decec57 --- /dev/null +++ b/codemods/unprefix-stable-apis/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ESNext", + "lib": [ + "ESNext" + ], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": [ + "@codemod.com/jssg-types", + "node" + ], + "allowImportingTsExtensions": true, + "noEmit": true, + "skipLibCheck": true, + "verbatimModuleSyntax": false, + "erasableSyntaxOnly": true, + "strict": true, + "strictNullChecks": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true + }, + "exclude": [ + "tests" + ] +} diff --git a/codemods/unprefix-stable-apis/workflow.yaml b/codemods/unprefix-stable-apis/workflow.yaml new file mode 100644 index 0000000..d642f67 --- /dev/null +++ b/codemods/unprefix-stable-apis/workflow.yaml @@ -0,0 +1,14 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/codemod/codemod/refs/heads/main/schemas/workflow.json + +version: "1" + +nodes: + - id: apply-transforms + name: Apply AST Transformations + type: automatic + steps: + - name: "Rename unstable_ and experimental_ React APIs to their stable names" + js-ast-grep: + js_file: scripts/codemod.ts + language: "tsx" + semantic_analysis: file diff --git a/codemods/use-browser/README.md b/codemods/use-browser/README.md new file mode 100644 index 0000000..828c13a --- /dev/null +++ b/codemods/use-browser/README.md @@ -0,0 +1,58 @@ +# react-19-3-use-browser + +Replace the "wait until mounted" pattern for browser-only components with React 19.3's `use(browser())` from `react-dom`. + +The codemod looks for function components that contain all three of these statements at the top level of their body: + +1. `const [mounted, setMounted] = useState(false);` +2. `useEffect(() => { setMounted(true); }, []);` (or `useLayoutEffect`) +3. `if (!mounted) return ;` (or `return null;`) + +It removes the state and effect, calls `use(browser())` in their place, and splits the component in two so the old early-return value becomes a `` fallback. On the server the fallback is rendered into the HTML exactly like before, and in the browser the component renders in a single pass instead of rendering the fallback first and re-rendering after an effect. + +## Usage + +```bash +npx codemod react-19-3-use-browser --target +``` + +## Example + +```tsx +// Before +import { useEffect, useState } from "react"; + +export function LocalClock({ format }: { format: string }) { + const [mounted, setMounted] = useState(false); + useEffect(() => { + setMounted(true); + }, []); + if (!mounted) return ; + return ; +} + +// After +import { Suspense, use } from "react"; +import { browser } from "react-dom"; + +export function LocalClock(props: { format: string }) { + return ( + }> + + + ); +} + +function LocalClockBrowserOnly({ format }: { format: string }) { + use(browser()); + return ; +} +``` + +## What is left alone + +- Components where the `mounted` flag or its setter is used anywhere else, since removing the state would change behavior. +- Components whose fallback expression references destructured props, because the fallback moves into the wrapper component. +- `typeof window` checks and framework helpers such as `dynamic(..., { ssr: false })`. Those need a hand review. +- CommonJS files and files without an ES module import of `react`. +- Unused `useState` / `useEffect` imports are removed when nothing else in the file references them. diff --git a/codemods/use-browser/codemod.yaml b/codemods/use-browser/codemod.yaml new file mode 100644 index 0000000..85698c8 --- /dev/null +++ b/codemods/use-browser/codemod.yaml @@ -0,0 +1,20 @@ +schema_version: "1.0" + +name: "react-19-3-use-browser" +version: "0.1.0" +description: "Replace the mounted-state pattern for browser-only components with use(browser()) from react-dom" +author: "Codemod " +license: "MIT" +workflow: "workflow.yaml" +repository: "https://github.com/codemod/react-codemod/tree/main/codemods/use-browser" + +targets: + languages: ["tsx"] + +keywords: ["React", "browser", "SSR", "Suspense"] + +registry: + access: "public" + visibility: "public" + +capabilities: [] diff --git a/codemods/use-browser/package.json b/codemods/use-browser/package.json new file mode 100644 index 0000000..e519695 --- /dev/null +++ b/codemods/use-browser/package.json @@ -0,0 +1,15 @@ +{ + "name": "react-19-3-use-browser", + "version": "0.1.0", + "description": "Replace the mounted-state pattern for browser-only components with use(browser()) from react-dom", + "type": "module", + "scripts": { + "test": "pnpm dlx codemod@latest jssg test -l tsx ./scripts/codemod.ts", + "check-types": "tsc --noEmit" + }, + "devDependencies": { + "@codemod.com/jssg-types": "latest", + "@types/node": "latest", + "typescript": "latest" + } +} diff --git a/codemods/use-browser/scripts/codemod.ts b/codemods/use-browser/scripts/codemod.ts new file mode 100644 index 0000000..504b624 --- /dev/null +++ b/codemods/use-browser/scripts/codemod.ts @@ -0,0 +1,683 @@ +import type { Transform, Edit, SgNode } from "codemod:ast-grep"; +import type TSX from "codemod:ast-grep/langs/tsx"; +import { useMetricAtom } from "codemod:metrics"; + +function metricFile(filename: string): string { + const cwd = process.cwd() + "/"; + return filename.startsWith(cwd) ? filename.slice(cwd.length) : filename; +} + +const REACT_MODULE = "react"; +const REACT_DOM_MODULE = "react-dom"; +const EFFECT_HOOKS = new Set(["useEffect", "useLayoutEffect"]); + +type ReactBindings = { + /** Local names that refer to the React module object (default or namespace import). */ + namespaces: Set; + /** React export name -> local name for named imports. */ + named: Map; + /** The first ESM import from "react" (used to add specifiers). */ + firstImport: SgNode | null; + /** The first ESM import from "react-dom" that has named imports. */ + firstDomNamedImport: SgNode | null; + /** react-dom export name -> local name for named imports. */ + domNamed: Map; + lastImport: SgNode | null; +}; + +type HookCall = { style: "named"; localName: string } | { style: "member"; object: string }; + +type MountedPattern = { + stateStmt: SgNode; + effectStmt: SgNode; + guardStmt: SgNode; + stateName: string; + setterName: string; + fallback: SgNode | null; + hookStyle: HookCall; +}; + +type ComponentMatch = { + fnNode: SgNode; + nameNode: SgNode; + topStmt: SgNode; + declarator: SgNode | null; + pattern: MountedPattern; +}; + +function namedChildren(node: SgNode | null): SgNode[] { + if (!node) return []; + return node.children().filter((child) => child.isNamed() && child.kind() !== "comment"); +} + +function sourceText(node: SgNode): string | null { + const fragment = node.find({ rule: { kind: "string_fragment" } }); + if (fragment) return fragment.text(); + const text = node.text(); + return text.length >= 2 ? text.slice(1, -1) : null; +} + +function importSource(node: SgNode): string | null { + const source = node.field("source") ?? node.find({ rule: { kind: "string" } }); + return source ? sourceText(source) : null; +} + +function isTypeOnlyImport(node: SgNode): boolean { + return node.children().some((child) => !child.isNamed() && child.text() === "type"); +} + +function quoteOf(importNode: SgNode | null): string { + const source = importNode?.field("source") ?? importNode?.find({ rule: { kind: "string" } }); + return source?.text().startsWith("'") ? "'" : '"'; +} + +function collectReactBindings(rootNode: SgNode): ReactBindings { + const bindings: ReactBindings = { + namespaces: new Set(), + named: new Map(), + firstImport: null, + firstDomNamedImport: null, + domNamed: new Map(), + lastImport: null, + }; + + for (const imp of rootNode.findAll({ rule: { kind: "import_statement" } })) { + if (imp.parent()?.kind() !== "program") continue; + bindings.lastImport = imp; + const source = importSource(imp); + if (source !== REACT_MODULE && source !== REACT_DOM_MODULE) continue; + if (isTypeOnlyImport(imp)) continue; + + const named = source === REACT_MODULE ? bindings.named : bindings.domNamed; + for (const specifier of imp.findAll({ rule: { kind: "import_specifier" } })) { + const name = specifier.field("name")?.text(); + if (!name) continue; + named.set(name, specifier.field("alias")?.text() ?? name); + } + + if (source === REACT_MODULE) { + if (!bindings.firstImport) bindings.firstImport = imp; + const importClause = imp.find({ rule: { kind: "import_clause" } }); + const defaultIdentifier = importClause?.children().find((child) => child.kind() === "identifier"); + if (defaultIdentifier) bindings.namespaces.add(defaultIdentifier.text()); + const namespaceImport = imp.find({ rule: { kind: "namespace_import" } }); + const namespaceName = namespaceImport?.field("name") ?? namespaceImport?.find({ rule: { kind: "identifier" } }); + if (namespaceName) bindings.namespaces.add(namespaceName.text()); + } else if (!bindings.firstDomNamedImport && imp.find({ rule: { kind: "named_imports" } })) { + bindings.firstDomNamedImport = imp; + } + } + + return bindings; +} + +/** Identify a call to a React export (`useState(...)` or `React.useState(...)`). */ +function reactHookCall(call: SgNode, exportName: string, bindings: ReactBindings): HookCall | null { + if (call.kind() !== "call_expression") return null; + const fn = call.field("function"); + if (!fn) return null; + + if (fn.kind() === "identifier") { + return bindings.named.get(exportName) === fn.text() ? { style: "named", localName: fn.text() } : null; + } + + if (fn.kind() === "member_expression") { + const object = fn.field("object"); + const property = fn.field("property"); + if ( + object && + property && + object.kind() === "identifier" && + bindings.namespaces.has(object.text()) && + property.text() === exportName + ) { + return { style: "member", object: object.text() }; + } + } + + return null; +} + +function isLiteral(node: SgNode | null, kind: "true" | "false"): boolean { + return !!node && node.kind() === kind; +} + +/** `const [x, setX] = useState(false);` */ +function matchStateStatement( + stmt: SgNode, + bindings: ReactBindings, +): { stateName: string; setterName: string; hookStyle: HookCall } | null { + if (stmt.kind() !== "lexical_declaration" && stmt.kind() !== "variable_declaration") return null; + const declarators = namedChildren(stmt).filter((child) => child.kind() === "variable_declarator"); + const declarator = declarators[0]; + if (declarators.length !== 1 || !declarator) return null; + + const pattern = declarator.field("name"); + const value = declarator.field("value"); + if (!pattern || !value || pattern.kind() !== "array_pattern") return null; + const elements = namedChildren(pattern); + const stateNode = elements[0]; + const setterNode = elements[1]; + if (elements.length !== 2 || !stateNode || !setterNode) return null; + if (stateNode.kind() !== "identifier" || setterNode.kind() !== "identifier") return null; + + const hookStyle = reactHookCall(value, "useState", bindings); + if (!hookStyle) return null; + const args = namedChildren(value.field("arguments")); + if (args.length !== 1 || !isLiteral(args[0] ?? null, "false")) return null; + + return { stateName: stateNode.text(), setterName: setterNode.text(), hookStyle }; +} + +function isSetterTrueCall(node: SgNode | null, setterName: string): boolean { + if (!node || node.kind() !== "call_expression") return false; + const fn = node.field("function"); + if (!fn || fn.kind() !== "identifier" || fn.text() !== setterName) return false; + const args = namedChildren(node.field("arguments")); + return args.length === 1 && isLiteral(args[0] ?? null, "true"); +} + +/** `useEffect(() => { setX(true); }, []);` */ +function matchEffectStatement(stmt: SgNode, setterName: string, bindings: ReactBindings): boolean { + if (stmt.kind() !== "expression_statement") return false; + const call = namedChildren(stmt)[0]; + if (!call || call.kind() !== "call_expression") return false; + + const isEffect = [...EFFECT_HOOKS].some((hook) => reactHookCall(call, hook, bindings) !== null); + if (!isEffect) return false; + + const args = namedChildren(call.field("arguments")); + const callback = args[0]; + const deps = args[1]; + if (args.length !== 2 || !callback || !deps) return false; + if (deps.kind() !== "array" || namedChildren(deps).length !== 0) return false; + if (callback.kind() !== "arrow_function" && callback.kind() !== "function_expression") return false; + if (namedChildren(callback.field("parameters")).length !== 0) return false; + + const body = callback.field("body"); + if (!body) return false; + if (body.kind() === "statement_block") { + const statements = namedChildren(body); + const only = statements[0]; + if (statements.length !== 1 || !only || only.kind() !== "expression_statement") return false; + return isSetterTrueCall(namedChildren(only)[0] ?? null, setterName); + } + return isSetterTrueCall(body, setterName); +} + +function isNegatedState(condition: SgNode, stateName: string): boolean { + let expr: SgNode | null = condition; + while (expr && expr.kind() === "parenthesized_expression") { + expr = namedChildren(expr)[0] ?? null; + } + if (!expr) return false; + + if (expr.kind() === "unary_expression") { + const argument = expr.field("argument"); + return expr.text().startsWith("!") && !!argument && argument.kind() === "identifier" && argument.text() === stateName; + } + + if (expr.kind() === "binary_expression") { + const left = expr.field("left"); + const right = expr.field("right"); + const operator = expr.field("operator")?.text() ?? ""; + if (!left || !right || (operator !== "===" && operator !== "==")) return false; + return ( + (left.kind() === "identifier" && left.text() === stateName && isLiteral(right, "false")) || + (right.kind() === "identifier" && right.text() === stateName && isLiteral(left, "false")) + ); + } + + return false; +} + +/** `if (!x) return ;` -> the fallback expression (null for `return null` / bare return). */ +function matchGuardStatement( + stmt: SgNode, + stateName: string, +): { fallback: SgNode | null } | null { + if (stmt.kind() !== "if_statement") return null; + if (stmt.field("alternative")) return null; + const condition = stmt.field("condition"); + const consequence = stmt.field("consequence"); + if (!condition || !consequence || !isNegatedState(condition, stateName)) return null; + + let returnStmt: SgNode | null = null; + if (consequence.kind() === "return_statement") { + returnStmt = consequence; + } else if (consequence.kind() === "statement_block") { + const statements = namedChildren(consequence); + const only = statements[0]; + if (statements.length === 1 && only && only.kind() === "return_statement") returnStmt = only; + } + if (!returnStmt) return null; + + let fallback = namedChildren(returnStmt)[0] ?? null; + while (fallback && fallback.kind() === "parenthesized_expression") { + fallback = namedChildren(fallback)[0] ?? null; + } + if (fallback && (fallback.kind() === "null" || fallback.kind() === "undefined")) fallback = null; + return { fallback }; +} + +/** Insert a specifier at the end of a `{ ... }` named-imports list, keeping the existing spacing. */ +function appendSpecifierEdit(namedImports: SgNode, specifier: string): Edit { + const text = namedImports.text(); + let insertPos = namedImports.range().end.index - 1; + let offset = text.length - 1; + while (offset > 0 && /\s/.test(text[offset - 1] ?? "")) { + offset--; + insertPos--; + } + const inner = text.slice(1, -1).trim(); + return { startPos: insertPos, endPos: insertPos, insertedText: inner.length > 0 ? `, ${specifier}` : ` ${specifier} ` }; +} + +function countIdentifiers(scope: SgNode | SgNode, name: string, exclude: SgNode[]): number { + const excludedRanges = exclude.map((node) => [node.range().start.index, node.range().end.index] as const); + return scope + .findAll({ + rule: { + any: [ + { kind: "identifier", regex: `^${name}$` }, + { kind: "shorthand_property_identifier", regex: `^${name}$` }, + ], + }, + }) + .filter((node) => { + const start = node.range().start.index; + return !excludedRanges.some(([s, e]) => start >= s && start < e); + }).length; +} + +function findMountedPattern(body: SgNode, bindings: ReactBindings): MountedPattern | null { + const statements = namedChildren(body); + + for (let i = 0; i < statements.length; i++) { + const stateStmt = statements[i]; + if (!stateStmt) continue; + const state = matchStateStatement(stateStmt, bindings); + if (!state) continue; + + let effectStmt: SgNode | null = null; + let guardStmt: SgNode | null = null; + let fallback: SgNode | null = null; + + for (let j = i + 1; j < statements.length; j++) { + const candidate = statements[j]; + if (!candidate) continue; + if (!effectStmt) { + if (matchEffectStatement(candidate, state.setterName, bindings)) effectStmt = candidate; + continue; + } + const guard = matchGuardStatement(candidate, state.stateName); + if (guard) { + guardStmt = candidate; + fallback = guard.fallback; + break; + } + } + if (!effectStmt || !guardStmt) continue; + + const own = [stateStmt, effectStmt, guardStmt]; + if (countIdentifiers(body, state.stateName, own) > 0) continue; + if (countIdentifiers(body, state.setterName, own) > 0) continue; + + return { stateStmt, effectStmt, guardStmt, fallback, ...state }; + } + + return null; +} + +function isComponentName(name: string): boolean { + return /^[A-Z]/.test(name); +} + +function isAsyncFunction(fn: SgNode): boolean { + return fn.children().some((child) => !child.isNamed() && child.text() === "async"); +} + +/** Walk up to the statement that sits directly in the program. */ +function topLevelStatement(node: SgNode): SgNode | null { + let current: SgNode | null = node; + while (current) { + const parent: SgNode | null = current.parent(); + if (!parent) return null; + if (parent.kind() === "program") return current; + current = parent; + } + return null; +} + +function findComponents(rootNode: SgNode, bindings: ReactBindings): ComponentMatch[] { + const matches: ComponentMatch[] = []; + + for (const fnNode of rootNode.findAll({ rule: { kind: "function_declaration" } })) { + const nameNode = fnNode.field("name"); + const body = fnNode.field("body"); + if (!nameNode || !body || !isComponentName(nameNode.text()) || isAsyncFunction(fnNode)) continue; + const topStmt = topLevelStatement(fnNode); + if (!topStmt || (topStmt.id() !== fnNode.id() && topStmt.kind() !== "export_statement")) continue; + if (topStmt.kind() === "export_statement" && fnNode.parent()?.id() !== topStmt.id()) continue; + const pattern = findMountedPattern(body, bindings); + if (!pattern) continue; + matches.push({ fnNode, nameNode, topStmt, declarator: null, pattern }); + } + + for (const declarator of rootNode.findAll({ rule: { kind: "variable_declarator" } })) { + const nameNode = declarator.field("name"); + const fnNode = declarator.field("value"); + if (!nameNode || !fnNode || nameNode.kind() !== "identifier" || !isComponentName(nameNode.text())) continue; + if (fnNode.kind() !== "arrow_function" && fnNode.kind() !== "function_expression") continue; + if (isAsyncFunction(fnNode)) continue; + const body = fnNode.field("body"); + if (!body || body.kind() !== "statement_block") continue; + const declaration = declarator.parent(); + if (!declaration || (declaration.kind() !== "lexical_declaration" && declaration.kind() !== "variable_declaration")) continue; + if (namedChildren(declaration).filter((child) => child.kind() === "variable_declarator").length !== 1) continue; + const topStmt = topLevelStatement(declaration); + if (!topStmt) continue; + if (topStmt.id() !== declaration.id() && !(topStmt.kind() === "export_statement" && declaration.parent()?.id() === topStmt.id())) continue; + const pattern = findMountedPattern(body, bindings); + if (!pattern) continue; + matches.push({ fnNode, nameNode, topStmt, declarator, pattern }); + } + + return matches.sort((a, b) => a.fnNode.range().start.index - b.fnNode.range().start.index); +} + +function lineIndent(source: string, index: number): string { + let lineStart = index; + while (lineStart > 0 && source[lineStart - 1] !== "\n") lineStart--; + const line = source.slice(lineStart, index); + return /^[ \t]*$/.test(line) ? line : ""; +} + +/** Range covering a statement plus its leading indentation and trailing newline. */ +function statementLineRange(source: string, node: SgNode): [number, number] { + let start = node.range().start.index; + let end = node.range().end.index; + let lineStart = start; + while (lineStart > 0 && source[lineStart - 1] !== "\n") lineStart--; + if (/^[ \t]*$/.test(source.slice(lineStart, start))) start = lineStart; + while (source[end] === " " || source[end] === "\t") end++; + if (source[end] === "\r") end++; + if (source[end] === "\n") end++; + return [start, end]; +} + +function applyTextEdits(text: string, edits: Edit[]): string { + const sorted = [...edits].sort((a, b) => b.startPos - a.startPos); + let result = text; + for (const edit of sorted) { + result = result.slice(0, edit.startPos) + edit.insertedText + result.slice(edit.endPos); + } + return result; +} + +type ParamPlan = { + wrapperParams: string; + innerParams: string; + propsSpread: string; + paramsNode: SgNode | null; +}; + +function patternBindingNames(pattern: SgNode): Set { + const names = new Set(); + for (const node of pattern.findAll({ + rule: { any: [{ kind: "shorthand_property_identifier_pattern" }, { kind: "identifier" }] }, + })) { + const parent = node.parent(); + if (parent && parent.kind() === "pair_pattern" && parent.field("key")?.id() === node.id()) continue; + names.add(node.text()); + } + return names; +} + +function fallbackReferences(fallback: SgNode | null, names: Set): boolean { + if (!fallback || names.size === 0) return false; + const referenced = fallback.findAll({ + rule: { any: [{ kind: "identifier" }, { kind: "shorthand_property_identifier" }] }, + }); + return referenced.some((node) => names.has(node.text())); +} + +function planParams(match: ComponentMatch): ParamPlan | null { + const fn = match.fnNode; + const single = fn.field("parameter"); + if (single) { + if (single.kind() !== "identifier") return null; + return { wrapperParams: single.text(), innerParams: single.text(), propsSpread: ` {...${single.text()}}`, paramsNode: single }; + } + + const params = fn.field("parameters"); + if (!params) return { wrapperParams: "()", innerParams: "()", propsSpread: "", paramsNode: null }; + const entries = namedChildren(params); + if (entries.length === 0) return { wrapperParams: params.text(), innerParams: params.text(), propsSpread: "", paramsNode: params }; + const entry = entries[0]; + if (entries.length !== 1 || !entry) return null; + if (entry.kind() !== "required_parameter" && entry.kind() !== "optional_parameter") return null; + + const parts = namedChildren(entry); + const pattern = parts[0]; + if (!pattern) return null; + const typeAnnotation = parts.find((part) => part.kind() === "type_annotation"); + + if (pattern.kind() === "identifier") { + return { wrapperParams: params.text(), innerParams: params.text(), propsSpread: ` {...${pattern.text()}}`, paramsNode: params }; + } + + if (pattern.kind() === "object_pattern") { + if (fallbackReferences(match.pattern.fallback, patternBindingNames(pattern))) return null; + return { + wrapperParams: `(props${typeAnnotation ? typeAnnotation.text() : ""})`, + innerParams: params.text(), + propsSpread: " {...props}", + paramsNode: params, + }; + } + + return null; +} + +function isNameUsed(rootNode: SgNode, name: string): boolean { + return rootNode.find({ + rule: { + any: [ + { kind: "identifier", regex: `^${name}$` }, + { kind: "type_identifier", regex: `^${name}$` }, + { kind: "shorthand_property_identifier_pattern", regex: `^${name}$` }, + ], + }, + }) !== null; +} + +function rebuildImport( + importNode: SgNode, + drop: Set, + add: string[], + source: string, +): string { + const quote = quoteOf(importNode); + const importClause = importNode.find({ rule: { kind: "import_clause" } }); + const defaultIdentifier = importClause?.children().find((child) => child.kind() === "identifier"); + const namespaceImport = importNode.find({ rule: { kind: "namespace_import" } }); + const specifiers = importNode + .findAll({ rule: { kind: "import_specifier" } }) + .filter((specifier) => !drop.has(specifier.field("name")?.text() ?? "")) + .map((specifier) => specifier.text()); + const named = [...specifiers, ...add]; + + const clauses: string[] = []; + if (defaultIdentifier) clauses.push(defaultIdentifier.text()); + if (namespaceImport) clauses.push(namespaceImport.text()); + if (named.length > 0) clauses.push(`{ ${named.join(", ")} }`); + if (clauses.length === 0) return ""; + return `import ${clauses.join(", ")} from ${quote}${source}${quote};`; +} + +const transform: Transform = async (root) => { + const rootNode = root.root(); + const source = rootNode.text(); + const metric = useMetricAtom("use-browser-transformations"); + + const bindings = collectReactBindings(rootNode); + const reactImport = bindings.firstImport; + if (!reactImport) return null; + + const candidates = findComponents(rootNode, bindings); + if (candidates.length === 0) return null; + + const edits: Edit[] = []; + const transformed: ComponentMatch[] = []; + const usedInnerNames = new Set(); + + // Decide how `use`, `Suspense`, and `browser` are referenced. + const memberObject = candidates.find((match) => match.pattern.hookStyle.style === "member")?.pattern.hookStyle; + const namedStyle = candidates.some((match) => match.pattern.hookStyle.style === "named"); + const useMember = memberObject && memberObject.style === "member" && !namedStyle ? memberObject.object : null; + + const addToReact: string[] = []; + let useName: string; + let suspenseName: string; + if (useMember) { + useName = `${useMember}.use`; + suspenseName = `${useMember}.Suspense`; + } else { + const existingUse = bindings.named.get("use"); + const existingSuspense = bindings.named.get("Suspense"); + if (!existingUse && isNameUsed(rootNode, "use")) return null; + if (!existingSuspense && isNameUsed(rootNode, "Suspense")) return null; + useName = existingUse ?? "use"; + suspenseName = existingSuspense ?? "Suspense"; + if (!existingSuspense) addToReact.push("Suspense"); + if (!existingUse) addToReact.push("use"); + } + + const existingBrowser = bindings.domNamed.get("browser"); + if (!existingBrowser && isNameUsed(rootNode, "browser")) return null; + const browserName = existingBrowser ?? "browser"; + + for (const match of candidates) { + const innerName = `${match.nameNode.text()}BrowserOnly`; + if (usedInnerNames.has(innerName) || isNameUsed(rootNode, innerName)) continue; + const params = planParams(match); + if (!params) continue; + const body = match.fnNode.field("body"); + if (!body) continue; + + const { pattern } = match; + const indent = lineIndent(source, pattern.stateStmt.range().start.index); + const baseIndent = lineIndent(source, match.topStmt.range().start.index); + const fallbackText = pattern.fallback ? pattern.fallback.text() : "null"; + + // Wrapper: keep the original name and exports, render the inner component inside Suspense. + const wrapperBody = [ + "{", + `${indent}return (`, + `${indent} <${suspenseName} fallback={${fallbackText}}>`, + `${indent} <${innerName}${params.propsSpread} />`, + `${indent} `, + `${indent});`, + `${baseIndent}}`, + ].join("\n"); + if (params.paramsNode && params.wrapperParams !== params.paramsNode.text()) { + edits.push(params.paramsNode.replace(params.wrapperParams)); + } + + // Inner: the original function with the mounted state swapped for use(browser()). + const fnStart = match.fnNode.range().start.index; + const fnText = match.fnNode.text(); + const innerEdits: Edit[] = []; + const rel = (index: number) => index - fnStart; + innerEdits.push({ + startPos: rel(pattern.stateStmt.range().start.index), + endPos: rel(pattern.stateStmt.range().end.index), + insertedText: `${useName}(${browserName}());`, + }); + for (const removed of [pattern.effectStmt, pattern.guardStmt]) { + const [start, end] = statementLineRange(source, removed); + innerEdits.push({ startPos: rel(start), endPos: rel(end), insertedText: "" }); + } + if (match.declarator === null) { + innerEdits.push({ + startPos: rel(match.nameNode.range().start.index), + endPos: rel(match.nameNode.range().end.index), + insertedText: innerName, + }); + } + let innerText = applyTextEdits(fnText, innerEdits); + if (match.declarator !== null) { + const typeAnnotation = match.declarator.field("type"); + innerText = `const ${innerName}${typeAnnotation ? typeAnnotation.text() : ""} = ${innerText};`; + } + + // Replace the body through the end of the top-level statement in one edit so the + // inner component lands right after the wrapper without overlapping edits. + const bodyStart = body.range().start.index; + const topEnd = match.topStmt.range().end.index; + const tail = source.slice(body.range().end.index, topEnd); + edits.push({ + startPos: bodyStart, + endPos: topEnd, + insertedText: `${wrapperBody}${tail}\n\n${baseIndent}${innerText}`, + }); + + usedInnerNames.add(innerName); + transformed.push(match); + } + + if (transformed.length === 0) return null; + + // Imports: drop hooks that are no longer referenced, add use/Suspense/browser. + const removedNodes = transformed.flatMap((match) => [match.pattern.stateStmt, match.pattern.effectStmt]); + const dropFromReact = new Set(); + for (const hook of ["useState", ...EFFECT_HOOKS]) { + const localName = bindings.named.get(hook); + if (!localName) continue; + const remaining = countIdentifiers(rootNode, localName, removedNodes) - 1; // minus the import specifier itself + if (remaining <= 0) dropFromReact.add(hook); + } + + const quote = quoteOf(reactImport); + const browserImport = existingBrowser ? "" : `import { ${browserName} } from ${quote}${REACT_DOM_MODULE}${quote};`; + + let browserImportAdded = existingBrowser !== undefined; + if (!browserImportAdded && bindings.firstDomNamedImport) { + const namedImports = bindings.firstDomNamedImport.find({ rule: { kind: "named_imports" } }); + if (namedImports) { + edits.push(appendSpecifierEdit(namedImports, browserName)); + browserImportAdded = true; + } + } + + const reactImportsToClean = rootNode + .findAll({ rule: { kind: "import_statement" } }) + .filter((imp) => imp.parent()?.kind() === "program" && importSource(imp) === REACT_MODULE && !isTypeOnlyImport(imp)); + + for (const imp of reactImportsToClean) { + const isFirst = imp.id() === reactImport.id(); + const specifierNames = imp + .findAll({ rule: { kind: "import_specifier" } }) + .map((specifier) => specifier.field("name")?.text() ?? ""); + const needsDrop = specifierNames.some((name) => dropFromReact.has(name)); + const adds = isFirst ? addToReact : []; + const trailing = isFirst && !browserImportAdded ? browserImport : ""; + if (!needsDrop && adds.length === 0 && trailing === "") continue; + + const rebuilt = rebuildImport(imp, dropFromReact, adds, REACT_MODULE); + if (rebuilt === "" && trailing === "") { + const [start, end] = statementLineRange(source, imp); + edits.push({ startPos: start, endPos: end, insertedText: "" }); + } else { + const text = [rebuilt, trailing].filter((part) => part !== "").join("\n"); + edits.push(imp.replace(text)); + } + if (isFirst) browserImportAdded = true; + } + + metric.increment({ file: metricFile(root.filename()) }, transformed.length); + return rootNode.commitEdits(edits); +}; + +export default transform; diff --git a/codemods/use-browser/tests/arrow-component-with-fc-type/expected.tsx b/codemods/use-browser/tests/arrow-component-with-fc-type/expected.tsx new file mode 100644 index 0000000..eaecc4a --- /dev/null +++ b/codemods/use-browser/tests/arrow-component-with-fc-type/expected.tsx @@ -0,0 +1,20 @@ +import { type FC, Suspense, use } from 'react'; +import { browser } from 'react-dom'; +import { Skeleton } from './skeleton'; + +export const Viewport: FC<{ label: string }> = (props) => { + return ( + }> + + + ); +}; + +const ViewportBrowserOnly: FC<{ label: string }> = ({ label }) => { + use(browser()); + return ( +

+ {label}: {window.innerWidth}px +

+ ); +}; diff --git a/codemods/use-browser/tests/arrow-component-with-fc-type/input.tsx b/codemods/use-browser/tests/arrow-component-with-fc-type/input.tsx new file mode 100644 index 0000000..a5131fd --- /dev/null +++ b/codemods/use-browser/tests/arrow-component-with-fc-type/input.tsx @@ -0,0 +1,15 @@ +import { useEffect, useState, type FC } from 'react'; +import { Skeleton } from './skeleton'; + +export const Viewport: FC<{ label: string }> = ({ label }) => { + const [hasMounted, setHasMounted] = useState(false); + useEffect(() => { + setHasMounted(true); + }, []); + if (hasMounted === false) return ; + return ( +

+ {label}: {window.innerWidth}px +

+ ); +}; diff --git a/codemods/use-browser/tests/arrow-component-with-fc-type/metrics.json b/codemods/use-browser/tests/arrow-component-with-fc-type/metrics.json new file mode 100644 index 0000000..6f8b3ed --- /dev/null +++ b/codemods/use-browser/tests/arrow-component-with-fc-type/metrics.json @@ -0,0 +1,10 @@ +{ + "use-browser-transformations": [ + { + "cardinality": { + "file": "tests/arrow-component-with-fc-type/input.tsx" + }, + "count": 1 + } + ] +} diff --git a/codemods/use-browser/tests/destructured-props-function/expected.tsx b/codemods/use-browser/tests/destructured-props-function/expected.tsx new file mode 100644 index 0000000..18dae76 --- /dev/null +++ b/codemods/use-browser/tests/destructured-props-function/expected.tsx @@ -0,0 +1,16 @@ +import { Suspense, use } from "react"; +import { browser } from "react-dom"; +import { ClockSkeleton, formatNow } from "./clock"; + +export function LocalClock(props: { format: string }) { + return ( + }> + + + ); +} + +function LocalClockBrowserOnly({ format }: { format: string }) { + use(browser()); + return ; +} diff --git a/codemods/use-browser/tests/destructured-props-function/input.tsx b/codemods/use-browser/tests/destructured-props-function/input.tsx new file mode 100644 index 0000000..c67d445 --- /dev/null +++ b/codemods/use-browser/tests/destructured-props-function/input.tsx @@ -0,0 +1,11 @@ +import { useEffect, useState } from "react"; +import { ClockSkeleton, formatNow } from "./clock"; + +export function LocalClock({ format }: { format: string }) { + const [mounted, setMounted] = useState(false); + useEffect(() => { + setMounted(true); + }, []); + if (!mounted) return ; + return ; +} diff --git a/codemods/use-browser/tests/destructured-props-function/metrics.json b/codemods/use-browser/tests/destructured-props-function/metrics.json new file mode 100644 index 0000000..0f8a565 --- /dev/null +++ b/codemods/use-browser/tests/destructured-props-function/metrics.json @@ -0,0 +1,10 @@ +{ + "use-browser-transformations": [ + { + "cardinality": { + "file": "tests/destructured-props-function/input.tsx" + }, + "count": 1 + } + ] +} diff --git a/codemods/use-browser/tests/existing-imports/expected.tsx b/codemods/use-browser/tests/existing-imports/expected.tsx new file mode 100644 index 0000000..3ca00c7 --- /dev/null +++ b/codemods/use-browser/tests/existing-imports/expected.tsx @@ -0,0 +1,15 @@ +import { Suspense, use } from "react"; +import { createPortal, browser } from "react-dom"; + +export function Portal(props) { + return ( + + + + ); +} + +function PortalBrowserOnly({ children }) { + use(browser()); + return createPortal(children, document.body); +} diff --git a/codemods/use-browser/tests/existing-imports/input.tsx b/codemods/use-browser/tests/existing-imports/input.tsx new file mode 100644 index 0000000..0a13bed --- /dev/null +++ b/codemods/use-browser/tests/existing-imports/input.tsx @@ -0,0 +1,11 @@ +import { Suspense, use, useEffect, useState } from "react"; +import { createPortal } from "react-dom"; + +export function Portal({ children }) { + const [ready, setReady] = useState(false); + useEffect(() => { + setReady(true); + }, []); + if (!ready) return null; + return createPortal(children, document.body); +} diff --git a/codemods/use-browser/tests/existing-imports/metrics.json b/codemods/use-browser/tests/existing-imports/metrics.json new file mode 100644 index 0000000..f05b9a0 --- /dev/null +++ b/codemods/use-browser/tests/existing-imports/metrics.json @@ -0,0 +1,10 @@ +{ + "use-browser-transformations": [ + { + "cardinality": { + "file": "tests/existing-imports/input.tsx" + }, + "count": 1 + } + ] +} diff --git a/codemods/use-browser/tests/export-default-props-identifier/expected.tsx b/codemods/use-browser/tests/export-default-props-identifier/expected.tsx new file mode 100644 index 0000000..d173c5c --- /dev/null +++ b/codemods/use-browser/tests/export-default-props-identifier/expected.tsx @@ -0,0 +1,16 @@ +import React, { Suspense, use } from "react"; +import { browser } from "react-dom"; +import { readTheme } from "./theme"; + +export default function ThemeBadge(props) { + return ( + + + + ); +} + +function ThemeBadgeBrowserOnly(props) { + use(browser()); + return {readTheme()}; +} diff --git a/codemods/use-browser/tests/export-default-props-identifier/input.tsx b/codemods/use-browser/tests/export-default-props-identifier/input.tsx new file mode 100644 index 0000000..ddbc3c9 --- /dev/null +++ b/codemods/use-browser/tests/export-default-props-identifier/input.tsx @@ -0,0 +1,11 @@ +import React, { useState, useEffect } from "react"; +import { readTheme } from "./theme"; + +export default function ThemeBadge(props) { + const [isClient, setIsClient] = useState(false); + useEffect(() => setIsClient(true), []); + if (!isClient) { + return null; + } + return {readTheme()}; +} diff --git a/codemods/use-browser/tests/export-default-props-identifier/metrics.json b/codemods/use-browser/tests/export-default-props-identifier/metrics.json new file mode 100644 index 0000000..8e587fe --- /dev/null +++ b/codemods/use-browser/tests/export-default-props-identifier/metrics.json @@ -0,0 +1,10 @@ +{ + "use-browser-transformations": [ + { + "cardinality": { + "file": "tests/export-default-props-identifier/input.tsx" + }, + "count": 1 + } + ] +} diff --git a/codemods/use-browser/tests/fallback-uses-prop-no-change/expected.tsx b/codemods/use-browser/tests/fallback-uses-prop-no-change/expected.tsx new file mode 100644 index 0000000..55322d6 --- /dev/null +++ b/codemods/use-browser/tests/fallback-uses-prop-no-change/expected.tsx @@ -0,0 +1,10 @@ +import { useEffect, useState } from "react"; + +export function Avatar({ size }: { size: number }) { + const [mounted, setMounted] = useState(false); + useEffect(() => { + setMounted(true); + }, []); + if (!mounted) return
; + return ; +} diff --git a/codemods/use-browser/tests/fallback-uses-prop-no-change/input.tsx b/codemods/use-browser/tests/fallback-uses-prop-no-change/input.tsx new file mode 100644 index 0000000..55322d6 --- /dev/null +++ b/codemods/use-browser/tests/fallback-uses-prop-no-change/input.tsx @@ -0,0 +1,10 @@ +import { useEffect, useState } from "react"; + +export function Avatar({ size }: { size: number }) { + const [mounted, setMounted] = useState(false); + useEffect(() => { + setMounted(true); + }, []); + if (!mounted) return
; + return ; +} diff --git a/codemods/use-browser/tests/layout-effect-keeps-used-hooks/expected.tsx b/codemods/use-browser/tests/layout-effect-keeps-used-hooks/expected.tsx new file mode 100644 index 0000000..3c1b461 --- /dev/null +++ b/codemods/use-browser/tests/layout-effect-keeps-used-hooks/expected.tsx @@ -0,0 +1,32 @@ +import { useState, useEffect, Suspense, use } from "react"; +import { browser } from "react-dom"; + +function Left() { + return ( + Loading

}> + +
+ ); +} + +function LeftBrowserOnly() { + use(browser()); + return

{screen.width}

; +} + +export function Right() { + const [count, setCount] = useState(0); + useEffect(() => { + setCount((c) => c + 1); + }, []); + return

{count}

; +} + +export default function Layout() { + return ( + <> + + + + ); +} diff --git a/codemods/use-browser/tests/layout-effect-keeps-used-hooks/input.tsx b/codemods/use-browser/tests/layout-effect-keeps-used-hooks/input.tsx new file mode 100644 index 0000000..3ee7064 --- /dev/null +++ b/codemods/use-browser/tests/layout-effect-keeps-used-hooks/input.tsx @@ -0,0 +1,29 @@ +import { useLayoutEffect, useState, useEffect } from "react"; + +function Left() { + const [mounted, setMounted] = useState(false); + useLayoutEffect(() => { + setMounted(true); + }, []); + if (!mounted) { + return

Loading

; + } + return

{screen.width}

; +} + +export function Right() { + const [count, setCount] = useState(0); + useEffect(() => { + setCount((c) => c + 1); + }, []); + return

{count}

; +} + +export default function Layout() { + return ( + <> + + + + ); +} diff --git a/codemods/use-browser/tests/layout-effect-keeps-used-hooks/metrics.json b/codemods/use-browser/tests/layout-effect-keeps-used-hooks/metrics.json new file mode 100644 index 0000000..669a022 --- /dev/null +++ b/codemods/use-browser/tests/layout-effect-keeps-used-hooks/metrics.json @@ -0,0 +1,10 @@ +{ + "use-browser-transformations": [ + { + "cardinality": { + "file": "tests/layout-effect-keeps-used-hooks/input.tsx" + }, + "count": 1 + } + ] +} diff --git a/codemods/use-browser/tests/namespace-hooks/expected.tsx b/codemods/use-browser/tests/namespace-hooks/expected.tsx new file mode 100644 index 0000000..40f43eb --- /dev/null +++ b/codemods/use-browser/tests/namespace-hooks/expected.tsx @@ -0,0 +1,17 @@ +import * as React from "react"; +import { browser } from "react-dom"; + +function Widget() { + return ( + + + + ); +} + +function WidgetBrowserOnly() { + React.use(browser()); + return
{navigator.language}
; +} + +export { Widget }; diff --git a/codemods/use-browser/tests/namespace-hooks/input.tsx b/codemods/use-browser/tests/namespace-hooks/input.tsx new file mode 100644 index 0000000..382cde5 --- /dev/null +++ b/codemods/use-browser/tests/namespace-hooks/input.tsx @@ -0,0 +1,12 @@ +import * as React from "react"; + +function Widget() { + const [mounted, setMounted] = React.useState(false); + React.useEffect(() => { + setMounted(true); + }, []); + if (!mounted) return null; + return
{navigator.language}
; +} + +export { Widget }; diff --git a/codemods/use-browser/tests/namespace-hooks/metrics.json b/codemods/use-browser/tests/namespace-hooks/metrics.json new file mode 100644 index 0000000..85d3c97 --- /dev/null +++ b/codemods/use-browser/tests/namespace-hooks/metrics.json @@ -0,0 +1,10 @@ +{ + "use-browser-transformations": [ + { + "cardinality": { + "file": "tests/namespace-hooks/input.tsx" + }, + "count": 1 + } + ] +} diff --git a/codemods/use-browser/tests/no-react-import-no-change/expected.tsx b/codemods/use-browser/tests/no-react-import-no-change/expected.tsx new file mode 100644 index 0000000..790e6b4 --- /dev/null +++ b/codemods/use-browser/tests/no-react-import-no-change/expected.tsx @@ -0,0 +1,10 @@ +import { useEffect, useState } from "preact/hooks"; + +export function Panel() { + const [mounted, setMounted] = useState(false); + useEffect(() => { + setMounted(true); + }, []); + if (!mounted) return null; + return
ready
; +} diff --git a/codemods/use-browser/tests/no-react-import-no-change/input.tsx b/codemods/use-browser/tests/no-react-import-no-change/input.tsx new file mode 100644 index 0000000..790e6b4 --- /dev/null +++ b/codemods/use-browser/tests/no-react-import-no-change/input.tsx @@ -0,0 +1,10 @@ +import { useEffect, useState } from "preact/hooks"; + +export function Panel() { + const [mounted, setMounted] = useState(false); + useEffect(() => { + setMounted(true); + }, []); + if (!mounted) return null; + return
ready
; +} diff --git a/codemods/use-browser/tests/state-used-elsewhere-no-change/expected.tsx b/codemods/use-browser/tests/state-used-elsewhere-no-change/expected.tsx new file mode 100644 index 0000000..6814b2e --- /dev/null +++ b/codemods/use-browser/tests/state-used-elsewhere-no-change/expected.tsx @@ -0,0 +1,10 @@ +import { useEffect, useState } from "react"; + +export function Panel() { + const [mounted, setMounted] = useState(false); + useEffect(() => { + setMounted(true); + }, []); + if (!mounted) return null; + return
ready
; +} diff --git a/codemods/use-browser/tests/state-used-elsewhere-no-change/input.tsx b/codemods/use-browser/tests/state-used-elsewhere-no-change/input.tsx new file mode 100644 index 0000000..6814b2e --- /dev/null +++ b/codemods/use-browser/tests/state-used-elsewhere-no-change/input.tsx @@ -0,0 +1,10 @@ +import { useEffect, useState } from "react"; + +export function Panel() { + const [mounted, setMounted] = useState(false); + useEffect(() => { + setMounted(true); + }, []); + if (!mounted) return null; + return
ready
; +} diff --git a/codemods/use-browser/tsconfig.json b/codemods/use-browser/tsconfig.json new file mode 100644 index 0000000..decec57 --- /dev/null +++ b/codemods/use-browser/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ESNext", + "lib": [ + "ESNext" + ], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": [ + "@codemod.com/jssg-types", + "node" + ], + "allowImportingTsExtensions": true, + "noEmit": true, + "skipLibCheck": true, + "verbatimModuleSyntax": false, + "erasableSyntaxOnly": true, + "strict": true, + "strictNullChecks": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true + }, + "exclude": [ + "tests" + ] +} diff --git a/codemods/use-browser/workflow.yaml b/codemods/use-browser/workflow.yaml new file mode 100644 index 0000000..96a55b3 --- /dev/null +++ b/codemods/use-browser/workflow.yaml @@ -0,0 +1,14 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/codemod/codemod/refs/heads/main/schemas/workflow.json + +version: "1" + +nodes: + - id: apply-transforms + name: Apply AST Transformations + type: automatic + steps: + - name: "Replace mounted-state browser-only components with use(browser())" + js-ast-grep: + js_file: scripts/codemod.ts + language: "tsx" + semantic_analysis: file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cb7df11..e762395 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,6 +59,18 @@ importers: specifier: latest version: 6.0.3 + codemods/fragment-ref-wrappers: + devDependencies: + '@codemod.com/jssg-types': + specifier: latest + version: 1.5.2 + '@types/node': + specifier: latest + version: 25.6.0 + typescript: + specifier: latest + version: 6.0.3 + codemods/manual-bind-to-arrow: devDependencies: '@codemod.com/jssg-types': @@ -107,6 +119,8 @@ importers: specifier: latest version: 6.0.3 + codemods/react-19-3-migration-recipe: {} + codemods/react-19-migration-recipe: {} codemods/react-dom-to-react-dom-factories: @@ -345,6 +359,18 @@ importers: specifier: latest version: 6.0.3 + codemods/unprefix-stable-apis: + devDependencies: + '@codemod.com/jssg-types': + specifier: latest + version: 1.5.2 + '@types/node': + specifier: latest + version: 25.6.0 + typescript: + specifier: latest + version: 6.0.3 + codemods/update-react-imports: dependencies: '@jssg/utils': @@ -361,6 +387,18 @@ importers: specifier: latest version: 6.0.3 + codemods/use-browser: + devDependencies: + '@codemod.com/jssg-types': + specifier: latest + version: 1.5.2 + '@types/node': + specifier: latest + version: 25.6.0 + typescript: + specifier: latest + version: 6.0.3 + codemods/use-context-hook: dependencies: '@jssg/utils': From c0f6ddd7be0e8b1dbf3124f8633977542d01b84e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 19:44:31 +0000 Subject: [PATCH 2/3] Match metrics fixture format (no trailing newline) The JSSG test runner compares metrics.json byte for byte and the existing fixtures have no trailing newline. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01C1zn9AbvKVCERf15x3zJ68 --- .../tests/add-fragment-import/metrics.json | 2 +- .../tests/default-react-import/metrics.json | 2 +- .../tests/fragment-already-imported/metrics.json | 2 +- .../tests/no-react-import-ref-prop/metrics.json | 2 +- codemods/fragment-ref-wrappers/tests/two-wrappers/metrics.json | 2 +- .../tests/activity-and-effect-event/metrics.json | 2 +- .../tests/alias-with-different-name/metrics.json | 2 +- .../tests/aliased-named-import/metrics.json | 2 +- codemods/unprefix-stable-apis/tests/cjs-require/metrics.json | 2 +- .../tests/default-import-member-access/metrics.json | 2 +- codemods/unprefix-stable-apis/tests/member-access/metrics.json | 2 +- .../unprefix-stable-apis/tests/plain-named-import/metrics.json | 2 +- codemods/unprefix-stable-apis/tests/re-export/metrics.json | 2 +- .../tests/stable-name-already-taken/metrics.json | 2 +- .../use-browser/tests/arrow-component-with-fc-type/metrics.json | 2 +- .../use-browser/tests/destructured-props-function/metrics.json | 2 +- codemods/use-browser/tests/existing-imports/metrics.json | 2 +- .../tests/export-default-props-identifier/metrics.json | 2 +- .../tests/layout-effect-keeps-used-hooks/metrics.json | 2 +- codemods/use-browser/tests/namespace-hooks/metrics.json | 2 +- 20 files changed, 20 insertions(+), 20 deletions(-) diff --git a/codemods/fragment-ref-wrappers/tests/add-fragment-import/metrics.json b/codemods/fragment-ref-wrappers/tests/add-fragment-import/metrics.json index cdf0b0a..523f28d 100644 --- a/codemods/fragment-ref-wrappers/tests/add-fragment-import/metrics.json +++ b/codemods/fragment-ref-wrappers/tests/add-fragment-import/metrics.json @@ -7,4 +7,4 @@ "count": 1 } ] -} +} \ No newline at end of file diff --git a/codemods/fragment-ref-wrappers/tests/default-react-import/metrics.json b/codemods/fragment-ref-wrappers/tests/default-react-import/metrics.json index bf8d265..76c5a08 100644 --- a/codemods/fragment-ref-wrappers/tests/default-react-import/metrics.json +++ b/codemods/fragment-ref-wrappers/tests/default-react-import/metrics.json @@ -7,4 +7,4 @@ "count": 1 } ] -} +} \ No newline at end of file diff --git a/codemods/fragment-ref-wrappers/tests/fragment-already-imported/metrics.json b/codemods/fragment-ref-wrappers/tests/fragment-already-imported/metrics.json index 7663f8d..9394589 100644 --- a/codemods/fragment-ref-wrappers/tests/fragment-already-imported/metrics.json +++ b/codemods/fragment-ref-wrappers/tests/fragment-already-imported/metrics.json @@ -7,4 +7,4 @@ "count": 1 } ] -} +} \ No newline at end of file diff --git a/codemods/fragment-ref-wrappers/tests/no-react-import-ref-prop/metrics.json b/codemods/fragment-ref-wrappers/tests/no-react-import-ref-prop/metrics.json index eb82392..70b88a0 100644 --- a/codemods/fragment-ref-wrappers/tests/no-react-import-ref-prop/metrics.json +++ b/codemods/fragment-ref-wrappers/tests/no-react-import-ref-prop/metrics.json @@ -7,4 +7,4 @@ "count": 1 } ] -} +} \ No newline at end of file diff --git a/codemods/fragment-ref-wrappers/tests/two-wrappers/metrics.json b/codemods/fragment-ref-wrappers/tests/two-wrappers/metrics.json index a4e12f6..a9363e3 100644 --- a/codemods/fragment-ref-wrappers/tests/two-wrappers/metrics.json +++ b/codemods/fragment-ref-wrappers/tests/two-wrappers/metrics.json @@ -7,4 +7,4 @@ "count": 2 } ] -} +} \ No newline at end of file diff --git a/codemods/unprefix-stable-apis/tests/activity-and-effect-event/metrics.json b/codemods/unprefix-stable-apis/tests/activity-and-effect-event/metrics.json index c96c3c3..4f0e352 100644 --- a/codemods/unprefix-stable-apis/tests/activity-and-effect-event/metrics.json +++ b/codemods/unprefix-stable-apis/tests/activity-and-effect-event/metrics.json @@ -7,4 +7,4 @@ "count": 2 } ] -} +} \ No newline at end of file diff --git a/codemods/unprefix-stable-apis/tests/alias-with-different-name/metrics.json b/codemods/unprefix-stable-apis/tests/alias-with-different-name/metrics.json index d38daca..100c7a7 100644 --- a/codemods/unprefix-stable-apis/tests/alias-with-different-name/metrics.json +++ b/codemods/unprefix-stable-apis/tests/alias-with-different-name/metrics.json @@ -7,4 +7,4 @@ "count": 2 } ] -} +} \ No newline at end of file diff --git a/codemods/unprefix-stable-apis/tests/aliased-named-import/metrics.json b/codemods/unprefix-stable-apis/tests/aliased-named-import/metrics.json index 0532027..4002a5c 100644 --- a/codemods/unprefix-stable-apis/tests/aliased-named-import/metrics.json +++ b/codemods/unprefix-stable-apis/tests/aliased-named-import/metrics.json @@ -7,4 +7,4 @@ "count": 2 } ] -} +} \ No newline at end of file diff --git a/codemods/unprefix-stable-apis/tests/cjs-require/metrics.json b/codemods/unprefix-stable-apis/tests/cjs-require/metrics.json index df2c192..cb3b251 100644 --- a/codemods/unprefix-stable-apis/tests/cjs-require/metrics.json +++ b/codemods/unprefix-stable-apis/tests/cjs-require/metrics.json @@ -7,4 +7,4 @@ "count": 4 } ] -} +} \ No newline at end of file diff --git a/codemods/unprefix-stable-apis/tests/default-import-member-access/metrics.json b/codemods/unprefix-stable-apis/tests/default-import-member-access/metrics.json index e9619e4..22454b1 100644 --- a/codemods/unprefix-stable-apis/tests/default-import-member-access/metrics.json +++ b/codemods/unprefix-stable-apis/tests/default-import-member-access/metrics.json @@ -7,4 +7,4 @@ "count": 2 } ] -} +} \ No newline at end of file diff --git a/codemods/unprefix-stable-apis/tests/member-access/metrics.json b/codemods/unprefix-stable-apis/tests/member-access/metrics.json index c84b090..38b35ad 100644 --- a/codemods/unprefix-stable-apis/tests/member-access/metrics.json +++ b/codemods/unprefix-stable-apis/tests/member-access/metrics.json @@ -7,4 +7,4 @@ "count": 5 } ] -} +} \ No newline at end of file diff --git a/codemods/unprefix-stable-apis/tests/plain-named-import/metrics.json b/codemods/unprefix-stable-apis/tests/plain-named-import/metrics.json index b81df5f..7b79c0d 100644 --- a/codemods/unprefix-stable-apis/tests/plain-named-import/metrics.json +++ b/codemods/unprefix-stable-apis/tests/plain-named-import/metrics.json @@ -7,4 +7,4 @@ "count": 2 } ] -} +} \ No newline at end of file diff --git a/codemods/unprefix-stable-apis/tests/re-export/metrics.json b/codemods/unprefix-stable-apis/tests/re-export/metrics.json index 040676a..07e6927 100644 --- a/codemods/unprefix-stable-apis/tests/re-export/metrics.json +++ b/codemods/unprefix-stable-apis/tests/re-export/metrics.json @@ -7,4 +7,4 @@ "count": 2 } ] -} +} \ No newline at end of file diff --git a/codemods/unprefix-stable-apis/tests/stable-name-already-taken/metrics.json b/codemods/unprefix-stable-apis/tests/stable-name-already-taken/metrics.json index d7259c4..88e73ea 100644 --- a/codemods/unprefix-stable-apis/tests/stable-name-already-taken/metrics.json +++ b/codemods/unprefix-stable-apis/tests/stable-name-already-taken/metrics.json @@ -7,4 +7,4 @@ "count": 1 } ] -} +} \ No newline at end of file diff --git a/codemods/use-browser/tests/arrow-component-with-fc-type/metrics.json b/codemods/use-browser/tests/arrow-component-with-fc-type/metrics.json index 6f8b3ed..c0dffe3 100644 --- a/codemods/use-browser/tests/arrow-component-with-fc-type/metrics.json +++ b/codemods/use-browser/tests/arrow-component-with-fc-type/metrics.json @@ -7,4 +7,4 @@ "count": 1 } ] -} +} \ No newline at end of file diff --git a/codemods/use-browser/tests/destructured-props-function/metrics.json b/codemods/use-browser/tests/destructured-props-function/metrics.json index 0f8a565..3b9a131 100644 --- a/codemods/use-browser/tests/destructured-props-function/metrics.json +++ b/codemods/use-browser/tests/destructured-props-function/metrics.json @@ -7,4 +7,4 @@ "count": 1 } ] -} +} \ No newline at end of file diff --git a/codemods/use-browser/tests/existing-imports/metrics.json b/codemods/use-browser/tests/existing-imports/metrics.json index f05b9a0..2e3840a 100644 --- a/codemods/use-browser/tests/existing-imports/metrics.json +++ b/codemods/use-browser/tests/existing-imports/metrics.json @@ -7,4 +7,4 @@ "count": 1 } ] -} +} \ No newline at end of file diff --git a/codemods/use-browser/tests/export-default-props-identifier/metrics.json b/codemods/use-browser/tests/export-default-props-identifier/metrics.json index 8e587fe..9d60271 100644 --- a/codemods/use-browser/tests/export-default-props-identifier/metrics.json +++ b/codemods/use-browser/tests/export-default-props-identifier/metrics.json @@ -7,4 +7,4 @@ "count": 1 } ] -} +} \ No newline at end of file diff --git a/codemods/use-browser/tests/layout-effect-keeps-used-hooks/metrics.json b/codemods/use-browser/tests/layout-effect-keeps-used-hooks/metrics.json index 669a022..fa6e9cb 100644 --- a/codemods/use-browser/tests/layout-effect-keeps-used-hooks/metrics.json +++ b/codemods/use-browser/tests/layout-effect-keeps-used-hooks/metrics.json @@ -7,4 +7,4 @@ "count": 1 } ] -} +} \ No newline at end of file diff --git a/codemods/use-browser/tests/namespace-hooks/metrics.json b/codemods/use-browser/tests/namespace-hooks/metrics.json index 85d3c97..bf252ea 100644 --- a/codemods/use-browser/tests/namespace-hooks/metrics.json +++ b/codemods/use-browser/tests/namespace-hooks/metrics.json @@ -7,4 +7,4 @@ "count": 1 } ] -} +} \ No newline at end of file From 2be0c98bc5239d0448147ac393975fa9d21bd4c4 Mon Sep 17 00:00:00 2001 From: Mohamad Mohebifar Date: Thu, 10 Sep 2026 12:48:21 -0700 Subject: [PATCH 3/3] chore: add changeset --- .changeset/short-groups-sip.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .changeset/short-groups-sip.md diff --git a/.changeset/short-groups-sip.md b/.changeset/short-groups-sip.md new file mode 100644 index 0000000..4b6c9e8 --- /dev/null +++ b/.changeset/short-groups-sip.md @@ -0,0 +1,8 @@ +--- +"react-19-3-migration-recipe": patch +"react-19-3-fragment-ref-wrappers": patch +"react-19-3-unprefix-stable-apis": patch +"react-19-3-use-browser": patch +--- + +Release react 19.3 codemods