# Node 22 Migration Implementation Plan

Created: 2026-07-30
Author: smarcet@gmail.com
Agent: Claude Code
Status: VERIFIED
Approved: Yes
Iterations: 0
Worktree: No
Type: Feature

## Summary

**Goal:** openstack-uicore-foundation builds, tests, and runs cleanly on Node 22 LTS (22.22.1), extrapolating the Node 18→22 migration from `fntechgit/summit-admin` PR #844 / ClickUp ticket 86b8zyqnf.

## Out of Scope

- **Literal SCSS/sass-loader removal.** The ticket tells summit-admin to delete `node-sass`, `sass-loader`, and all SCSS webpack/jest rules because their `.scss` was dead code. This lib's `.scss` is live (6 files: `circle-button`, `progressive-img`, `extra-questions`, `mui/NavBar`, `mui/AuthButton`, `schedule-print`), so instead of deleting SCSS support, `node-sass` is swapped for dart-sass (`sass`) — see Approach.
- **`fs`, `react-hot-loader` removal, `superagent`/`idtoken-verifier` version bumps.** None of these apply: this repo's `package.json` never had `fs` or `react-hot-loader`, and `superagent` (8.0.9) / `idtoken-verifier` (^2.2.2) are already the target versions the ticket asks summit-admin to bump *to* (this lib is the upstream source of those peer-dep versions).
- **webpack-cli v4→v5 bump, `webpack.dev.js` HTTPS devServer config, `.prettierrc` key rename.** This lib has no `serve` script (only `build-dev`/`build`, which invoke `webpack` directly, not `webpack serve`), so `webpack-cli`/devServer config is inert here — confirmed webpack-cli 4.9.2 already builds successfully on Node 22. No `.prettierrc` exists in this repo (prettier isn't installed).
- **ESLint upgrade.** Not applicable — this repo has no `eslint` dependency at all and no `lint` script (unlike summit-admin, which has both and defers its 8→9 migration to a separate ticket).
- **React 16→18, Redux 3→4 upgrades.** Explicitly out of scope in the source ticket too (separate tickets).

## Approach

**Chosen:** Direct config/dependency edits to `.nvmrc`, the two `.github/workflows/*.yml` files, `package.json`, and `webpack.common.js` — swapping `node-sass` for dart-sass rather than deleting SCSS support.
**Why:** Matches the upstream Node 22 migration's intent (clean dependency tree, no build tooling incompatible with Node 22) without regressing this library's actively-used SCSS components. Confirmed by reproducing the real blocker: `node-sass` 7.0.1 ships no Node 22 binary, so `sass-loader` silently returns `undefined` CSS and `webpack --config webpack.dev.js` fails with a PostCSS "received undefined instead of CSS string" error on `schedule-print/styles.module.scss`. Installing dart-sass (`sass@^1.77.0`) fixes this: `sass-loader` auto-prefers `sass` over `node-sass` with no config changes, and dart-sass's legacy `renderSync` API (what `sass-loader@12` uses) was verified against `circle-button/index.module.scss` directly — it renders correctly with no deprecation warnings at this version.

## Context for Implementer

Two Babel configs exist, same as noted in the source ticket: `babel.config.json` (used by Jest/babel-jest — already uses `@babel/plugin-transform-runtime`, unaffected) and inline `options.plugins` inside the `babel-loader` rule in `webpack.common.js:217-220` (used only by webpack). The `proposal`→`transform` plugin renames apply to `package.json` **and** `webpack.common.js` — missing either leaves a mismatched package name that webpack/babel can't resolve.

## Assumptions

- `sass@^1.77.0` (dart-sass) renders this repo's existing `.scss` files without syntax changes — verified directly against `circle-button/index.module.scss` via `sass.renderSync()`, the same legacy API `sass-loader@12.6.0` invokes by default. Task 2 depends on this holding for the other 5 `.scss` files too (all are equally simple: plain selectors, nesting, `&` — no `@import`, no Sass-specific at-rules that differ between node-sass and dart-sass). **Confirmed correct in Task 4**: `yarn build-dev` compiled all 6 `.scss` files to correct CSS (verified generated class names/rules in `lib/css/components/circle-button.css`, `mui/nav-bar.css`, etc.). One discovered difference from the isolated pre-approval test: `yarn.lock` resolved `sass` to `1.102.0` (latest satisfying `^1.77.0`, not the `1.77.8` tested directly), and going through `sass-loader`'s actual invocation (vs. calling `sass.renderSync()` raw) surfaced a `Deprecation The legacy JS API is deprecated and will be removed in Dart Sass 2.0.0` warning during every build. Cosmetic (build exit code and CSS output were both correct), but resolved as a same-day follow-up: bumped `sass-loader` `^12.6.0` → `^14.2.1` (requires Node ≥18.12, satisfied) and added `api: 'modern'` to both SCSS loader rules in `webpack.common.js` (`.module.scss` and `.scss`). Re-verified: `yarn build-dev`/`yarn build` both compile with zero sass deprecation warnings, generated CSS is byte-identical in content to the legacy-API build (same class rules/counts across all 6 `.scss` files), and the full suite still passes 104/104 suites, 876/876 tests.
- Bumping `jest`/`jest-environment-jsdom` 28→29.7.0 together is compatible with `babel-jest`, `identity-obj-proxy`, `jest-transform-stub`, and `redux-mock-store` as currently configured — no snapshot tests exist in this repo (`find src -name '*.snap'` = 0 results), which removes the most common jest-major-bump breakage source. Task 3 depends on this; the full 876-test suite re-run in Task 4 is the check.

## Progress Tracking

- [x] Task 1: Bump Node version pins to 22.22.1 (.nvmrc, CI workflows)
- [x] Task 2: Migrate dependencies for Node 22 (babel plugin renames, node-sass → dart-sass, remove babel-cli)
- [x] Task 3: Bump jest + jest-environment-jsdom to 29.7.0
- [x] Task 4: Reinstall on Node 22 and verify full build/test pipeline (punycode warning not eliminated — documented, structural jest-29 limitation)

## Implementation Tasks

### Task 1: Bump Node version pins to 22.22.1

**Objective:** Pin the toolchain to Node 22 LTS (22.22.1, the version named in ClickUp ticket 86b8zyqnf) across the local dev environment and CI, mirroring the `node-version: 18` → `22` change in PR #844's three GitHub Actions workflows (this repo only has two of those three workflows).

**Files:**

- Modify: `.nvmrc`
- Modify: `.github/workflows/build_test.yml`
- Modify: `.github/workflows/jest.yml`

**Key Decisions / Notes:**

- `.nvmrc` currently reads `18.15.0` (`.nvmrc:1`) → `22.22.1`, matching the exact LTS patch the ticket pins for summit-admin.
- Both workflow files set `node-version: 18` under the `actions/setup-node@v3` step (`build_test.yml:13`, `jest.yml:13`) → `node-version: 22`, matching PR #844's style (major version only, not a full patch pin, for the `setup-node` action).
- `Trivial:` three one-line version-string edits, no logic change; covered by Task 4's full pipeline re-run (`.nvmrc`/workflow correctness can't be unit-tested directly — CI executing on Node 22 is the verification).

**Definition of Done:**

- [ ] `.nvmrc` contains `22.22.1`
- [ ] Both `.github/workflows/*.yml` files specify `node-version: 22`
- [ ] Verify: `grep -c "node-version: 22" .github/workflows/build_test.yml .github/workflows/jest.yml` → each file reports 1

### Task 2: Migrate dependencies for Node 22 (babel renames, node-sass → dart-sass, remove babel-cli)

**Objective:** Replace the Node-22-incompatible `node-sass` with dart-sass, rename the deprecated `@babel/plugin-proposal-*` packages to their `@babel/plugin-transform-*` equivalents in both places they're declared, and drop the unused `babel-cli` devDependency — the three dependency-level blockers/deprecations PR #844 addresses that actually apply to this repo.

**Files:**

- Modify: `package.json`
- Modify: `webpack.common.js`

**Key Decisions / Notes:**

- `package.json:78` — remove `"node-sass": "^7.0.1"`; add `"sass": "^1.77.0"` to `devDependencies` (alphabetically, near `redux-thunk`/`regenerator-runtime`). `sass-loader` (`node_modules/sass-loader/dist/utils.js:27-47`) auto-detects and prefers `sass` over `node-sass` when both/either are present — no `sass-loader` option changes needed.
- `package.json:18-20` — rename `@babel/plugin-proposal-class-properties` → `@babel/plugin-transform-class-properties`, `@babel/plugin-proposal-object-rest-spread` → `@babel/plugin-transform-object-rest-spread`, `@babel/plugin-proposal-optional-chaining` → `@babel/plugin-transform-optional-chaining` (same `^7.16.7`/`^7.17.3` version ranges — these are drop-in renames, confirmed via `npm view <proposal-pkg> deprecated` pointing at the `transform-*` replacement). This repo doesn't declare `@babel/plugin-proposal-nullish-coalescing-operator` (summit-admin's 4th renamed plugin), so there's nothing to rename for that one.
- `webpack.common.js:218-220` — same three renames inside the `babel-loader` rule's `options.plugins` array (mirrors `package.json`; see Context for Implementer above).
- `package.json:43` — remove `"babel-cli": "^6.26.0"` from `devDependencies`. Confirmed unused: no `babel-cli`/`babel-node` reference anywhere outside this one line (`grep -rn "babel-cli\|babel-node" --exclude-dir=node_modules --exclude-dir=lib .`).
- Do NOT touch `webpack.common.js:118-123` (the `.module.scss`/`.scss` loader rules) or the `package.json` `jest.moduleNameMapper`/`jest.transform` scss entries (`package.json:180`, `:190`) — see Out of Scope.

**Definition of Done:**

- [ ] `package.json` has no `node-sass` or `babel-cli` entries; has `"sass": "^1.77.0"` in devDependencies
- [ ] No `@babel/plugin-proposal-*` package name appears anywhere in `package.json` or `webpack.common.js`
- [ ] Verify: `grep -rn "plugin-proposal\|node-sass\|babel-cli" package.json webpack.common.js` returns no matches

### Task 3: Bump jest + jest-environment-jsdom to 29.7.0

**Objective:** Upgrade `jest` and `jest-environment-jsdom` together from `^28.1.0` to `^29.7.0` so their internal API versions stay paired, eliminating the `punycode` module deprecation warning that Node 22 emits when running the test suite under jest 28's older jsdom (^19.0.0 → ^20.0.0).

**Files:**

- Modify: `package.json`

**Key Decisions / Notes:**

- `package.json:63-64` — `"jest": "^28.1.0"` → `"^29.7.0"`, `"jest-environment-jsdom": "^28.1.0"` → `"^29.7.0"`. Bumping only `jest-environment-jsdom` (the ticket's literal instruction, written for summit-admin where jest core was already 29) would pair a jest-28 core with a jest-29 environment package — an unsupported combination since `jest-environment-jsdom` peer-requires matching `@jest/environment`/`@jest/types` major versions.
- No snapshot tests exist in this repo (verified: `find src -name '*.snap'` → 0 results), which is the most common jest-major-bump breakage source — reduces migration risk.
- `package.json:70-71` also declares standalone `jsdom": "^19.0.0"` and `jsdom-global": "^3.0.2"` devDependencies, separate from the jsdom that `jest-environment-jsdom` pulls in transitively. Confirmed unused: no `require('jsdom')`/`require('jsdom-global')` anywhere in `src` (only `@jest-environment jsdom` docblocks, which reference jest's environment config, not these packages directly) — leave both untouched; they're pre-existing dead deps outside this migration's lineage, not a version-mismatch risk.
- **Before bumping**, capture a baseline for the known-flaky `src/components/mui/__tests__/mui-sponsor-input.test.js` "debounces API calls" fake-timer test on jest 28: run it isolated 3x with `--runInBand` and note the failure mode when it flakes (expected: `toHaveBeenCalledWith("Spo", ...)` receives `"Sp"` — a keystroke-timing race, not a timer-count or assertion-shape error). Jest 29 changed its fake-timers internals (`@sinonjs/fake-timers`), so a post-bump flake must be compared against this baseline failure mode, not assumed to be "the same pre-existing flake" by default. **Result:** 3/3 isolated `--runInBand` runs passed cleanly on jest 28 — confirms the flake is worker-parallelism contention, not deterministic.
- `Trivial:` two version-string bumps in `devDependencies`; the behavioral verification is Task 4's full suite re-run, not a new unit test.
- **Found during changes-review (verification phase):** `babel-jest` (`package.json:43`) was left at `^28.1.0` while `jest`/`jest-environment-jsdom` bumped to `^29.7.0` — a version skew flagged because `jest@^29.7.0` needs `babel-jest@^29.7.0` internally, and `yarn.lock` was resolving both `babel-jest@28.1.0` (our stale direct pin) and `babel-jest@29.7.0` (jest's transitive need) simultaneously. Tests passed either way (876/876), but bumped `babel-jest` to `^29.7.0` for consistency with the rest of the jest-family bump and to remove the duplicate install.
- **Objective partially not achievable within this task's scope, discovered during Task 4 verification:** the punycode warning is NOT eliminated by this bump. Root cause traced with `node --trace-deprecation`: the warning originates from `tr46@3.0.0` (`node_modules/tr46/index.js`, `require('punycode')`), pulled in by `whatwg-url@^11.0.0`, which is what `jsdom@^20.0.0` depends on. `jest-environment-jsdom@29.7.0` — the latest 29.x release, confirmed via `npm view jest-environment-jsdom versions` — permanently pins `jsdom: ^20.0.0` (verified via `npm view jest-environment-jsdom@29.7.0 dependencies.jsdom`); that pin never changes within the 29.x line. `tr46` only stops requiring Node's deprecated built-in `punycode` module (switching to the npm `punycode` package instead) starting at `tr46@5` / `whatwg-url@14`, which first ships with `jsdom@26.1.0` — pulled in only by `jest-environment-jsdom@30.x`, which requires jest 30 core. **This means the punycode warning cannot be eliminated while staying on jest 29** — only a jest 30 upgrade (paired jest + jest-environment-jsdom + jsdom major bump, materially larger and riskier than what this plan's approach question covered) would fix it. Not implemented here: it's a bigger, unreviewed scope expansion beyond the approved "bump jest+jest-environment-jsdom to 29.7.0 together" decision. Flagged as a follow-up for a separate jest 30 migration plan.

**Definition of Done:**

- [ ] `package.json` declares `jest` and `jest-environment-jsdom` both at `^29.7.0`
- [ ] Isolated baseline run of `mui-sponsor-input.test.js` on jest 28 recorded (pass, or flake with the `"Sp"` vs `"Spo"` failure mode) before the bump
- [ ] Verify: `grep -E '"jest(-environment-jsdom)?": "\^29\.7\.0"' package.json` matches both lines

### Task 4: Reinstall on Node 22 and verify full build/test pipeline

**Objective:** Reinstall dependencies from scratch under Node 22.22.1 (regenerating `yarn.lock`) and confirm the full build and test pipeline is clean — no `node-sass`/PostCSS build errors, and the complete test suite passes — closing out the migration end to end, mirroring PR #844's final "verify full pipeline on Node 22" step. (The `punycode` warning is NOT eliminated — see Task 3's Key Decisions for the traced root cause and why it's out of this plan's scope.)

**Files:**

- Modify: `yarn.lock` (regenerated by `yarn install`, not hand-edited)

**Key Decisions / Notes:**

- Use Node 22.22.1 for this verification (matches the `.nvmrc` pin from Task 1): `export PATH="$HOME/.nvm/versions/node/v22.22.1/bin:$PATH"` (already available locally via nvm in this environment).
- `rm -rf node_modules && yarn install` — clean reinstall so `yarn.lock` reflects the Task 2/3 dependency changes (added `sass`, removed `node-sass`/`babel-cli`, bumped babel/jest packages) rather than a stale partial resolution.
- This repo has no `lint` or `serve` script (unlike summit-admin), so the equivalent verification surface is `build-dev`, `build`, and `test` only.
- A pre-existing flaky test (`src/components/mui/__tests__/mui-sponsor-input.test.js`, the `debounces API calls` fake-timer test) intermittently fails under jest's default parallel workers on **both** Node 18 and Node 22 — reproduced independently of this migration. Not caused by or fixed as part of this plan; if it flakes during verification, re-run with `--runInBand` to confirm it's the pre-existing flake and not a real regression.
- The "876/876 passing" figure in the DoD below is the pre-migration baseline captured during planning: `yarn jest --runInBand` on both Node 18.15.0 and Node 22.22.1 (pre-Task-2/3 dependency state) reported "Test Suites: 104 passed, 104 total / Tests: 876 passed, 876 total". Post-migration, compare against this baseline rather than treating 876 as a hardcoded target — a suite/test count drift (not just a pass/fail count) would indicate a collection-level regression (e.g. a test file failing to load under jest 29) that a bare pass-count comparison could mask.
- **Deviation found and fixed during this task's verification:** the first post-migration full-suite run failed to *collect* `src/utils/__tests__/actions.test.js` — "Cannot find module 'request'" — a suite/test-count drift exactly matching the scenario the note above warns about. Root cause: `node-sass@^7.0.1` (removed in Task 2) transitively depended on the npm package `request@^2.88.0` (used by its binary-downloader install script); nothing in `src` or this test file ever imports that package (confirmed: `grep` for `require('request')`/`from 'request'` across `src` returns nothing). The test file's `jest.mock('request')` (`actions.test.js:10`, now removed) was pre-existing dead code that only avoided "Cannot find module" by accident, because `request` happened to be resolvable via node-sass's transitive tree — it was never functionally connected to the test (the test's real HTTP client is `superagent/lib/client`, imported under the unrelated local name `request`). Removed the stale `jest.mock('request')` line at the root cause; not a jest 28→29 behavior change.

**Definition of Done:**

- [x] `yarn install` completes with exit 0 on Node 22.22.1 (no `node-sass` build/postinstall errors)
- [x] `yarn build-dev` and `yarn build` both complete with exit 0 and no PostCSS "received undefined instead of CSS string" errors
- [x] `yarn test` reports 876/876 passing (0 failures) — matches the pre-migration baseline exactly (no suite/test count drift)
- [~] `punycode` deprecation warning: NOT eliminated (root cause traced and documented in Task 3 — structurally impossible on jest 29; needs a separate jest 30 migration). Original DoD wording ("no punycode warning") is not met; superseded by the Task 3 note above.
- [x] Verify (run): `node -v` (22.22.1) `&& rm -rf node_modules && yarn install && yarn build-dev && yarn build && yarn test --runInBand 2>&1 | tee /tmp/node22-verify.log` → `yarn install` exit 0, both builds exit 0 with no PostCSS "undefined" error, `104 passed, 104 total` suites / `876 passed, 876 total` tests. One regression found and fixed mid-verification: `src/utils/__tests__/actions.test.js` failed to load ("Cannot find module 'request'") because `node-sass` (removed in Task 2) was the transitive source of the `request` npm package that the test's stale, unused `jest.mock('request')` call depended on existing — removed the dead mock line at the root cause (see Task 4 Key Decisions above); suite is green after the fix.
