codebase-doctor migration dossier

Session 3fd51510 · generated 2026-09-26 10:25:26 UTC

react ^17.0.2 → 18.3.1

codebase-doctor-sandbox/pantry-list · codebase-doctor/react-18.3.1-upgrade from main

Ready for pull request

  1. Analyse — done
  2. Rules — done
  3. Risk — done
  4. Plan — done
  5. Approve — done
  6. Implement — done
  7. Verify — done
  8. Pull request — current step
11
files use react
16 imports · 19 API usages
9
files need attention
4 high-risk
8
breaking changes apply
3 automated · 5 manual/review
passed
verification
iteration 2 · 55040d8

01Risk & priority

Which files matter most, scored from concrete API evidence.

  • High ≥70 4
  • Medium 40–69 0
  • Low 1–39 5
  • Stable 0 2
  1. 90 src/legacy/toast.jsx react-bc-1 +40, react-bc-8 +20, root bootstrap +30
  2. 90 src/legacy/toast.test.jsx react-bc-1 +40, react-bc-3 +20, react-bc-12 +10, test harness +20
  3. 80 src/index.jsx react-bc-1 +40, react-bc-6 +10, root bootstrap +30
  4. 70 src/ssr-entry.jsx react-bc-2 +40, root bootstrap +30
  5. 20 src/App.jsx react-bc-4 +20
  6. 20 src/components/AddItemForm.jsx react-bc-4 +20
  7. 20 src/components/ItemList.jsx react-bc-4 +20
  8. 20 src/components/SyncStatus.jsx react-bc-4 +20

02Breaking changes

The rules that apply to this repository — and why.

high react-bc-1 automated confirmed by docs Applied automatically

ReactDOM.render() is deprecated in React 18 (the app keeps running in legacy React 17 mode) — replace with createRoot().render()

3 files: src/index.jsx, src/legacy/toast.jsx, src/legacy/toast.test.jsx

Transformed 3 file(s); no remaining usages of the old API.

high react-bc-2 automated confirmed by docs Applied automatically

ReactDOM.hydrate() is deprecated in React 18 (legacy mode) — replace with hydrateRoot()

1 file: src/ssr-entry.jsx

Transformed 1 file(s); no remaining usages of the old API.

medium react-bc-3 automated Applied automatically

act() from react-dom/test-utils is deprecated — import act from 'react' (available from React 18.3)

1 file: src/legacy/toast.test.jsx

Transformed 1 file(s); no remaining usages of the old API.

medium react-bc-4 manual confirmed by docs Completed manually

Automatic batching: setState calls in timeouts/promises/native handlers are now batched — may affect code or tests relying on intermediate renders

How to fix Review any tests that assert on intermediate render states inside setTimeout, Promise.then, or native event handlers. Wrap those assertions in act() or use flushSync from 'react-dom' to opt out of automatic batching where needed.

4 files: src/App.jsx, src/components/AddItemForm.jsx, src/components/ItemList.jsx, src/components/SyncStatus.jsx

Completed manually: Audited async setState: only SyncStatus updates state in a promise callback; its test now expects the single batched render

low react-bc-6 manual confirmed by docs Completed manually

Strict Mode now double-invokes effects (mount → unmount → mount) in development to surface side effects

How to fix Audit useEffect, useLayoutEffect, and class lifecycle methods for non-idempotent side effects (network requests, analytics calls, subscriptions). Ensure cleanup functions are implemented so double-invocation does not cause observable problems in development.

1 file: src/index.jsx

Completed manually: No component uses effects with external side effects; double-invoking is harmless

low react-bc-7 manual Completed manually

ReactDOM.unstable_batchedUpdates is redundant under React 18 automatic batching

How to fix With React 18's automatic batching, explicit calls to unstable_batchedUpdates are no longer needed in most cases. Remove them and verify behaviour. If you need to force synchronous rendering, use flushSync from 'react-dom' instead.

1 file: src/legacy/store.js

Completed manually: Removed unstable_batchedUpdates from store.js; subscribers are notified directly

medium react-bc-8 manual confirmed by docs Completed manually

ReactDOM.unmountComponentAtNode() is deprecated in React 18 — call root.unmount() on the root returned by createRoot()

How to fix Keep the root returned by createRoot(container) and call root.unmount() instead of unmountComponentAtNode(container). In tests, unmount the root the test created (React Testing Library unmounts automatically).

1 file: src/legacy/toast.jsx

Completed manually: toast.jsx keeps the root returned by createRoot and calls root.unmount() instead of unmountComponentAtNode

low react-bc-12 manual confirmed by docs Completed manually

Tests that render with react-dom directly need globalThis.IS_REACT_ACT_ENVIRONMENT = true under React 18

How to fix In the test setup file (e.g. setupTests.js / jest.setup.js), set globalThis.IS_REACT_ACT_ENVIRONMENT = true; so React 18 knows it runs in an act-aware test environment. React Testing Library ≥ 13 sets this automatically for tests that use it.

1 file: src/legacy/toast.test.jsx

Completed manually: Added jest.setup.js setting globalThis.IS_REACT_ACT_ENVIRONMENT = true (jest.setupFiles)

03Compatibility

Declared packages whose peer ranges could block the install.

Checked 602 packages via package-lock.json (lockfileVersion 3).

PackageRequiresSourceResolution
@testing-library/react@12.1.5react <18.0.0lockfileauto upgraded to ^14.3.1 in the dependency step

04Migration plan

What will change, in order, and what has happened so far.

Plan b3b654d66018 approved 2026-09-26 10:19:06 UTC. Approval is bound to this exact plan — a changed plan needs a new approval.

Estimated effort HIGH · 10 steps · 4 applied automatically · 6 completed manually

  1. Update react, react-dom, @testing-library/react to 18.3.1 and install
    Applied automatically config automated 2 files commit 798ad60
    • react ^17.0.2 → ^18.3.1
    • react-dom ^17.0.2 → ^18.3.1 (companion)
    • @testing-library/react ^12.1.5 → ^14.3.1 (peer-compat)

    Ran npm install --no-audit --no-fund; installed react@18.3.1, react-dom@18.3.1.

  2. ReactDOM.render() is deprecated in React 18 (the app keeps running in legacy React 17 mode) — replace with createRoot().render()
    Applied automatically codemod automated 3 files commit 72cea40

    Transformed 3 file(s); no remaining usages of the old API.

  3. ReactDOM.hydrate() is deprecated in React 18 (legacy mode) — replace with hydrateRoot()
    Applied automatically codemod automated 1 file commit 148d1eb

    Transformed 1 file(s); no remaining usages of the old API.

  4. act() from react-dom/test-utils is deprecated — import act from 'react' (available from React 18.3)
    Applied automatically codemod automated 1 file commit f8756bf

    Transformed 1 file(s); no remaining usages of the old API.

  5. Automatic batching: setState calls in timeouts/promises/native handlers are now batched — may affect code or tests relying on intermediate renders
    Completed manually manual human 4 files commit 1138182

    Completed manually: Audited async setState: only SyncStatus updates state in a promise callback; its test now expects the single batched render

  6. ReactDOM.unmountComponentAtNode() is deprecated in React 18 — call root.unmount() on the root returned by createRoot()
    Completed manually manual human 1 file commit d9fc42f

    Completed manually: toast.jsx keeps the root returned by createRoot and calls root.unmount() instead of unmountComponentAtNode

  7. Strict Mode now double-invokes effects (mount → unmount → mount) in development to surface side effects
    Completed manually manual human 1 file

    Completed manually: No component uses effects with external side effects; double-invoking is harmless

  8. ReactDOM.unstable_batchedUpdates is redundant under React 18 automatic batching
    Completed manually manual human 1 file commit 55040d8

    Completed manually: Removed unstable_batchedUpdates from store.js; subscribers are notified directly

  9. Tests that render with react-dom directly need globalThis.IS_REACT_ACT_ENVIRONMENT = true under React 18
    Completed manually manual human 1 file commit 1472489

    Completed manually: Added jest.setup.js setting globalThis.IS_REACT_ACT_ENVIRONMENT = true (jest.setupFiles)

  10. Update and verify tests
    Completed manually test human 3 files

    Completed manually: Test suite updated for React 18 (SyncStatus batching expectation, act environment)

05Affected files

Every file that uses the package family, with line-level evidence.

11 files

src/legacy/toast.jsx react-bc-1react-bc-8 90
LineUsageSourceRules
1import reactimport React from 'react';—
2import react-domimport ReactDOM from 'react-dom';—
17render · react-domReactDOM.render(<Toast message={message} />, host);react-bc-1
19unmountComponentAtNode · react-domReactDOM.unmountComponentAtNode(host);react-bc-8
src/legacy/toast.test.jsx test react-bc-3react-bc-12react-bc-1 90
LineUsageSourceRules
1import reactimport React from 'react';—
2import react-domimport ReactDOM from 'react-dom';—
3import react-dom/test-utilsimport { act } from 'react-dom/test-utils';—
3act · react-dom/test-utilsimport { act } from 'react-dom/test-utils';react-bc-3 react-bc-12
13act · react-dom/test-utilsact(() => {react-bc-3 react-bc-12
18act · react-dom/test-utilsact(() => {react-bc-3 react-bc-12
27act · react-dom/test-utilsact(() => {react-bc-3 react-bc-12
28render · react-domReactDOM.render(<p>host</p>, container);react-bc-1 react-bc-12
src/index.jsx react-bc-1react-bc-6 80
LineUsageSourceRules
1import reactimport React from 'react';—
2import react-domimport ReactDOM from 'react-dom';—
5render · react-domReactDOM.render(react-bc-1
6StrictMode · react<React.StrictMode>react-bc-6
src/ssr-entry.jsx react-bc-2 70
LineUsageSourceRules
1import reactimport React from 'react';—
2import react-domimport ReactDOM from 'react-dom';—
6hydrate · react-domReactDOM.hydrate(<App />, document.getElementById('root'));react-bc-2
src/App.jsx react-bc-4 20
LineUsageSourceRules
1import reactimport React, { useReducer } from 'react';—
1useReducer · reactimport React, { useReducer } from 'react';react-bc-4
23useReducer · reactconst [items, dispatch] = useReducer(itemsReducer, []);react-bc-4
src/components/AddItemForm.jsx react-bc-4 20
LineUsageSourceRules
1import reactimport React, { useState } from 'react';—
1useState · reactimport React, { useState } from 'react';react-bc-4
4useState · reactconst [name, setName] = useState('');react-bc-4
src/components/ItemList.jsx react-bc-4 20
LineUsageSourceRules
1import reactimport React from 'react';—
3Component · reactexport default class ItemList extends React.Component {react-bc-4
src/components/SyncStatus.jsx react-bc-4 20
LineUsageSourceRules
1import reactimport React, { useState } from 'react';—
1useState · reactimport React, { useState } from 'react';react-bc-4
7useState · reactconst [status, setStatus] = useState('idle');react-bc-4
8useState · reactconst [syncedAt, setSyncedAt] = useState(null);react-bc-4
src/legacy/store.js react-bc-7 10
LineUsageSourceRules
1import react-domimport ReactDOM from 'react-dom';—
13unstable_batchedUpdates · react-domReactDOM.unstable_batchedUpdates(() => {react-bc-7
src/App.test.jsx test 0
LineUsageSourceRules
1import reactimport React from 'react';—
src/components/SyncStatus.test.jsx test 0
LineUsageSourceRules
1import reactimport React from 'react';—

06Changes

Each step's commit on the migration branch.

9 files changed on codebase-doctor/react-18.3.1-upgrade since main.

Update react, react-dom, @testing-library/react to 18.3.1 and install798ad60
 package-lock.json | 83 ++++++++++++++++++++++++-------------------------------
 package.json      |  6 ++--
 2 files changed, 39 insertions(+), 50 deletions(-)
diff --git a/package-lock.json b/package-lock.json@@ lockfile diff omitted: +36 −47 lines @@diff --git a/package.json b/package.jsonindex 3712010..20c83d0 100644--- a/package.json+++ b/package.json@@ -10,14 +10,14 @@     "lint": "eslint src"   },   "dependencies": {-    "react": "^17.0.2",-    "react-dom": "^17.0.2"+    "react": "^18.3.1",+    "react-dom": "^18.3.1"   },   "devDependencies": {     "@babel/core": "^7.24.0",     "@babel/preset-env": "^7.24.0",     "@babel/preset-react": "^7.24.0",-    "@testing-library/react": "^12.1.5",+    "@testing-library/react": "^14.3.1",     "babel-jest": "^29.7.0",     "esbuild": "^0.23.0",     "eslint": "^9.9.0",
ReactDOM.render() is deprecated in React 18 (the app keeps running in legacy React 17 mode) — replace with createRoot().render()72cea40
 src/index.jsx             | 7 +++----
 src/legacy/toast.jsx      | 3 ++-
 src/legacy/toast.test.jsx | 4 ++--
 3 files changed, 7 insertions(+), 7 deletions(-)
diff --git a/src/index.jsx b/src/index.jsxindex c1f31c5..8e50ea1 100644--- a/src/index.jsx+++ b/src/index.jsx@@ -1,10 +1,9 @@ import React from 'react';-import ReactDOM from 'react-dom';+import { createRoot } from 'react-dom/client'; import App from './App'; -ReactDOM.render(+createRoot(document.getElementById('root')).render(   <React.StrictMode>     <App />-  </React.StrictMode>,-  document.getElementById('root')+  </React.StrictMode> );diff --git a/src/legacy/toast.jsx b/src/legacy/toast.jsxindex 6fc7919..ef8f0f5 100644--- a/src/legacy/toast.jsx+++ b/src/legacy/toast.jsx@@ -1,5 +1,6 @@ import React from 'react'; import ReactDOM from 'react-dom';+import { createRoot } from 'react-dom/client';  function Toast({ message }) {   return (@@ -14,7 +15,7 @@ function Toast({ message }) { export function showToast(message, ms = 3000) {   const host = document.createElement('div');   document.body.appendChild(host);-  ReactDOM.render(<Toast message={message} />, host);+  createRoot(host).render(<Toast message={message} />);   setTimeout(() => {     ReactDOM.unmountComponentAtNode(host);     host.remove();diff --git a/src/legacy/toast.test.jsx b/src/legacy/toast.test.jsxindex fe5b58c..6d09d4e 100644--- a/src/legacy/toast.test.jsx+++ b/src/legacy/toast.test.jsx@@ -1,5 +1,5 @@ import React from 'react';-import ReactDOM from 'react-dom';+import { createRoot } from 'react-dom/client'; import { act } from 'react-dom/test-utils'; import { showToast } from './toast'; @@ -25,7 +25,7 @@ test('toasts can render inside an existing tree', () => {   const container = document.createElement('div');   document.body.appendChild(container);   act(() => {-    ReactDOM.render(<p>host</p>, container);+    createRoot(container).render(<p>host</p>);   });   expect(container.textContent).toBe('host'); });
ReactDOM.hydrate() is deprecated in React 18 (legacy mode) — replace with hydrateRoot()148d1eb
 src/ssr-entry.jsx | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/ssr-entry.jsx b/src/ssr-entry.jsxindex d20be01..91718cd 100644--- a/src/ssr-entry.jsx+++ b/src/ssr-entry.jsx@@ -1,6 +1,6 @@ import React from 'react';-import ReactDOM from 'react-dom';+import { hydrateRoot } from 'react-dom/client'; import App from './App';  // Entry used when the page is server-rendered: attach to the existing markup.-ReactDOM.hydrate(<App />, document.getElementById('root'));+hydrateRoot(document.getElementById('root'), <App />);
act() from react-dom/test-utils is deprecated — import act from 'react' (available from React 18.3)f8756bf
 src/legacy/toast.test.jsx | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/legacy/toast.test.jsx b/src/legacy/toast.test.jsxindex 6d09d4e..225ce96 100644--- a/src/legacy/toast.test.jsx+++ b/src/legacy/toast.test.jsx@@ -1,6 +1,6 @@ import React from 'react'; import { createRoot } from 'react-dom/client';-import { act } from 'react-dom/test-utils';+import { act } from 'react'; import { showToast } from './toast';  afterEach(() => {
Automatic batching: setState calls in timeouts/promises/native handlers are now batched — may affect code or tests relying on intermediate renders1138182
 src/components/SyncStatus.test.jsx | 3 +--
 1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/src/components/SyncStatus.test.jsx b/src/components/SyncStatus.test.jsxindex 49fda29..cefe32d 100644--- a/src/components/SyncStatus.test.jsx+++ b/src/components/SyncStatus.test.jsx@@ -9,10 +9,9 @@ test('shows the sync time once the save resolves', async () => {   fireEvent.click(screen.getByText('Sync'));   await waitFor(() => expect(screen.getByText('Synced at 12:00')).toBeTruthy()); -  // Every state the component rendered, in order.+  // React 18 batches both updates in the promise callback into one render.   expect(renders).toEqual([     { status: 'idle', syncedAt: null },-    { status: 'idle', syncedAt: '12:00' },     { status: 'done', syncedAt: '12:00' },   ]); });
ReactDOM.unmountComponentAtNode() is deprecated in React 18 — call root.unmount() on the root returned by createRoot()d9fc42f
 src/legacy/toast.jsx | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/legacy/toast.jsx b/src/legacy/toast.jsxindex ef8f0f5..f8ca3a3 100644--- a/src/legacy/toast.jsx+++ b/src/legacy/toast.jsx@@ -1,5 +1,4 @@ import React from 'react';-import ReactDOM from 'react-dom'; import { createRoot } from 'react-dom/client';  function Toast({ message }) {@@ -15,9 +14,10 @@ function Toast({ message }) { export function showToast(message, ms = 3000) {   const host = document.createElement('div');   document.body.appendChild(host);-  createRoot(host).render(<Toast message={message} />);+  const root = createRoot(host);+  root.render(<Toast message={message} />);   setTimeout(() => {-    ReactDOM.unmountComponentAtNode(host);+    root.unmount();     host.remove();   }, ms); }
ReactDOM.unstable_batchedUpdates is redundant under React 18 automatic batching55040d8
 src/legacy/store.js | 10 ++--------
 1 file changed, 2 insertions(+), 8 deletions(-)
diff --git a/src/legacy/store.js b/src/legacy/store.jsindex a9a6496..fd27c82 100644--- a/src/legacy/store.js+++ b/src/legacy/store.js@@ -1,8 +1,4 @@-import ReactDOM from 'react-dom';--// A tiny external store. Before React 18, notifying several subscribers from a-// WebSocket or timer callback caused one render per subscriber, so updates are-// wrapped in unstable_batchedUpdates.+// A tiny external store. React 18 batches the subscriber updates automatically. export function createStore(initial) {   let state = initial;   const listeners = new Set();@@ -10,9 +6,7 @@ export function createStore(initial) {     get: () => state,     set(next) {       state = next;-      ReactDOM.unstable_batchedUpdates(() => {-        listeners.forEach((listener) => listener(state));-      });+      listeners.forEach((listener) => listener(state));     },     subscribe(listener) {       listeners.add(listener);
Tests that render with react-dom directly need globalThis.IS_REACT_ACT_ENVIRONMENT = true under React 181472489
 jest.setup.js | 2 ++
 package.json  | 5 ++++-
 2 files changed, 6 insertions(+), 1 deletion(-)
diff --git a/jest.setup.js b/jest.setup.jsnew file mode 100644index 0000000..aed3b03--- /dev/null+++ b/jest.setup.js@@ -0,0 +1,2 @@+// React 18: tell React this is an act-aware test environment.+globalThis.IS_REACT_ACT_ENVIRONMENT = true;diff --git a/package.json b/package.jsonindex 20c83d0..97fdded 100644--- a/package.json+++ b/package.json@@ -27,6 +27,9 @@     "jest-environment-jsdom": "^29.7.0"   },   "jest": {-    "testEnvironment": "jsdom"+    "testEnvironment": "jsdom",+    "setupFiles": [+      "<rootDir>/jest.setup.js"+    ]   } }

07Verification

Does the upgraded code pass the repository's own checks?

  1. #1 failed: lint, test (1 test) commit f8756bf · 2026-09-26 10:24:19 UTC
  2. #2 passed commit 55040d8 · 2026-09-26 10:25:25 UTC
Verification passed iteration 2 · commit 55040d8 · 2026-09-26 10:25:25 UTC
CheckResultCommandDetail
Installed versionsPASS—react@18.3.1, react-dom@18.3.1
LintPASSnpm run lint
TestPASSnpm run test
BuildPASSnpm run build

08Remaining work

What still needs a human before (or after) merging.

Nothing left open: every step was applied, completed manually or is not applicable.

09Metrics

Measured values, labelled estimates, and what is not measured.

measured

Files using react11
Files with breaking-change evidence9
Files changed on branch9
Steps applied automatically4 / 10
Steps completed manually6
Steps still open (manual / skipped / failed)0
Elapsed session time (wall-clock)6 min

estimate

Manual effort
~4.5 h9 affected files × 30 min/file (heuristic, not benchmarked)
Time saved
~4.4 hestimated manual effort − measured elapsed time

not measured

  • Bobcoin consumption — not measured (not exposed to the MCP server)
  • Accuracy — not measured (no ground-truth benchmark)

10Repository

How the repository was analysed.

Repositorycodebase-doctor-sandbox/pantry-list
Default branchmain
Migration branchcodebase-doctor/react-18.3.1-upgrade
Languagejavascript
Package managernpm
Test frameworkjest
Lint / test / buildnpm run lint · npm run test · npm run build
Source files scanned13
Rule setreact-17-to-18.json + your migration docs
Session3fd51510-5cd8-41f8-8271-285c542bb72f