Skip to content

refactor(ui)!: migrate to React 19 ref-as-prop, drop forwardRef (#268) - #441

Merged
bntvllnt merged 4 commits into
mainfrom
refactor/react-19-migration-268
Jun 17, 2026
Merged

refactor(ui)!: migrate to React 19 ref-as-prop, drop forwardRef (#268)#441
bntvllnt merged 4 commits into
mainfrom
refactor/react-19-migration-268

Conversation

@bntvllnt

@bntvllnt bntvllnt commented Jun 17, 2026

Copy link
Copy Markdown
Collaborator

Summary

Migrates @vllnt/ui off the React APIs deprecated in React 19, eliminating every no-react19-deprecated-apis react-doctor warning in the library's canonical source (packages/ui/src/components).

  • forwardRef → ref-as-prop (React 19 passes ref as a normal function-component prop)
  • useContext(X)use(X)
  • React peer dependency bumped to >=19.0.0 (see Breaking change)

⚠️ BREAKING — drops React 18 support, requires React 19

ref-as-prop on function components only works in React 19, so dropping forwardRef makes React 19 a hard requirement. The react/react-dom peer range moves >=18.0.0>=19.0.0. Copy-paste registry consumers on React 18 will get non-functional refs. The repo already targets React 19 everywhere else (registry app on ^19.2.0, @types/react@19, test/build env on React 19.2.4), so this aligns the peer with reality.

Warning count — rule no-react19-deprecated-apis, packages/ui/src/components

count
Before 456 (400 forwardRef + 56 useContext)
After 0

The rule is defined requires: ["react:19"] in react-doctor 0.5.6, so it only runs once a project is detected as React 19. The 456 baseline was measured after bumping the peer to >=19; on the unmodified >=18 peer the rule is suppressed and reports 0. The issue's original "439" came from a React-19-detected context / older doctor build.

How — re-runnable codemod

The deterministic 90% is a committed, re-runnable script: scripts/migrate-react19.sh. It runs the two official codemods over packages/ui/src/components and corrects the ref-prop type:

npx codemod@latest run react-19-remove-forward-ref  -t packages/ui/src/components
npx codemod@latest run react-19-use-context-hook     -t packages/ui/src/components
# then: ref: React.RefObject<T>  ->  ref?: React.Ref<T>   (optional, correct variance)

Hand-fixed the cases the codemod cannot (documented in the script header): 3 forwardRef(NamedComponent) wrappers (file-upload, form, number-input), 1 typeof forwardRef<…> alias (transaction-list), ~13 components whose intersection prop types the codemod dropped, ForwardedRef receiver params widened for the now-optional ref, and 5 max-lines-per-function overflows after reformatting. Removed a pre-existing as cast in form.tsx that only existed to undo forwardRef's generic erasure. Regenerated apps/registry copies via pnpm registry:build.

🚨 Rebase + re-run after #409–413 merge

The parallel component-family PRs (#409#413, ~73 new components) ship forwardRef + displayName — they have to, to build green against the current React 18 workspace. They are not covered by this diff. After they merge, rebase this branch and re-run scripts/migrate-react19.sh (plus the documented hand-fix review) to convert the new components and reach a truly 0 count. Coordinate merge order with the team lead.

Docs

docs/agents/COMPONENTS.md — the "forwardRef + displayName, no exceptions" contract is replaced with the React 19 ref-as-prop + use() contract.

Validation (all run, all green)

  • pnpm --filter @vllnt/ui build (tsup ESM + DTS typecheck against React 19 types) — pass, 0 type errors
  • pnpm --filter @vllnt/ui test:once — 1382 tests / 241 files pass
  • pnpm build (root; registry app Next.js build) — pass
  • eslint on changed files — clean
  • pnpm doctorno-react19-deprecated-apis: 456 → 0

No as / @ts-ignore / any introduced; "use client" directives preserved; behavior-preserving. The 73 other react-doctor warnings (a11y / perf / bugs) are unrelated rules tracked separately in #271 / #277.

Closes #268

Replace the deprecated React APIs flagged by react-doctor
`no-react19-deprecated-apis` across packages/ui/src/components:

- `forwardRef` -> ref-as-prop (ref passed as a normal function-component prop)
- `useContext(X)` -> `use(X)`

Bump the react/react-dom peer dependency to >=19.0.0 and regenerate the
registry copies. Drives `no-react19-deprecated-apis` from 456 to 0 (measured
with React 19 detection; the rule only runs on React 19+ projects).

BREAKING CHANGE: @vllnt/ui now requires React 19 (peer react/react-dom
>=19.0.0). Components use ref-as-prop, which does not work under React 18.

Closes #268
@vllnt-pilot

vllnt-pilot Bot commented Jun 17, 2026

Copy link
Copy Markdown

ntk preview

App Status Preview
ui-registry Ready https://pr-441-ui-registry.preview.vllnt.ai
storybook Ready https://pr-441-storybook.preview.vllnt.ai

Built from 80e3f7c2 · public + no-index (ADR-082) · torn down on close


if (shortcut) {
const tokens = shortcut.split(SHORTCUT_SEPARATOR).filter(Boolean);
bntvllnt added 2 commits June 17, 2026 14:23
#268)

- docs/agents/COMPONENTS.md: replace the forwardRef + displayName contract with
  the React 19 ref-as-prop + use() contract (React 19 now required).
- scripts/migrate-react19.sh: re-runnable codemod (forwardRef -> ref-as-prop,
  useContext -> use) so newly added components — e.g. the #409-413 family PRs
  that still ship forwardRef — can be converted by re-running after they merge.

Refs #268
pnpm-lock.yaml @vllnt/ui importer react/react-dom specifier >=18.0.0 -> >=19.0.0 to match the package.json peer bump; resolved versions unchanged (19.2.4). Fixes ERR_PNPM_OUTDATED_LOCKFILE on `pnpm install --frozen-lockfile` in CI.
# Conflicts:
#	apps/registry/registry.json
#	apps/registry/registry/default/ai-sidebar/ai-sidebar.tsx
#	packages/ui/src/components/ai-sidebar/ai-sidebar.tsx
@bntvllnt
bntvllnt merged commit 4ee153e into main Jun 17, 2026
2 checks passed
@bntvllnt
bntvllnt deleted the refactor/react-19-migration-268 branch June 17, 2026 20:48
@vllnt-pilot
vllnt-pilot Bot temporarily deployed to Preview · pr-441 (ntk) June 17, 2026 20:54 Destroyed
bntvllnt added a commit that referenced this pull request Jun 17, 2026
## What

Clears the remaining react-doctor **State & Effects** warnings in
`packages/ui/src/components` (issue #271), using React 19 patterns now
available on the base branch. All changes are behavior-preserving and
match the React 19 ref-as-prop base (no `forwardRef` reintroduced).

## Rules cleared (before -> after, scope: `packages/ui/src/components`)

| Rule | Before | After | Fix |
|---|---|---|---|
| `prefer-use-effect-event` | 6 | 0 | wrap effect-captured callbacks in
`useEffectEvent`, drop them from deps |
| `no-derived-useState` | 4 | 0 | lazy initializer (`useState(() =>
prop)`) so prop-seeded state isn't a flagged stale copy |
| `no-cascading-set-state` | 1 | 0 | collapse cascading `setState` into
a `useReducer` |
| `no-prop-callback-in-effect` | 1 | 0 | route the prop callback through
`useEffectEvent` so a parent can't drive an update loop |
| **Total** | **12** | **0** | |

(`no-effect-event-handler` and `no-derived-state-effect` already had 0
in this scope on the base — the derived-state subset was cleared earlier
by #387.)

## Files touched

- `flow-diagram/flow-fullscreen.tsx`, `keyboard-shortcuts-help/`,
`table-of-contents-panel/`, `slideshow/` — `useEffectEvent` for keydown
handlers.
- `search-bar/` — `onSearch` via `useEffectEvent` (+ regression tests).
- `tldr-section/` — skeleton `useReducer` (+ regression test).
- `date-picker/`, `number-ticker/`, `thinking-block/`,
`spinner/unicode-spinner.tsx` — lazy initializers.
- Regenerated `apps/registry/registry/default/*` (8 files) +
`registry.json` via `registry:build`.

## Validation

- `pnpm --filter @vllnt/ui build` (tsup ESM + DTS): pass
- `tsc --noEmit --project tsconfig.build.json` (CI typecheck gate): pass
- `pnpm --filter @vllnt/ui test:once`: 1385 tests pass (incl. 3 new
regression tests)
- `pnpm lint` (full `eslint .`): pass
- `pnpm registry:build`: pass
- react-doctor delta: 12 -> 0 target warnings, no new warnings
introduced

## Stacking

**STACKED on #441** (`refactor/react-19-migration-268`) — the remaining
work needs React 19 `useEffectEvent`, which lives in #441, not yet on
`main`. GitHub auto-retargets this PR's base to `main` when #441 merges.
**Review/merge after #441.**

Closes #271

---------

Co-authored-by: bntvllnt <bntvllnt@users.noreply.github.com>
bntvllnt added a commit that referenced this pull request Jun 27, 2026
Closes #432

Makes `main` ready to cut **stable 0.3.0** (per `docs/RELEASING.md`).
**Docs + one test-guard tweak — this PR does not release.** Merge →
dispatch Publish is the separate next step.

> **Refreshed 2026-06-26:** rebuilt on top of current `main` (was 9 days
stale / conflicting). Reconciled with everything that landed since it
was authored — React 19 migration (#441), the core/forms/charts/motion
families, and the count-derivation fix (#450).

## Changes
- **Changelogs** (`CHANGELOG.md` + `packages/ui/CHANGELOG.md`):
`[Unreleased]` → dated **`[0.3.0] - 2026-06-26`** (the publish
workflow's release gate greps `## [0.3.0]`), dropped the now-satisfied
pre-release notes, added the granular 0.3.0-train families (form
primitives, charts/dataviz, AI/core primitives, motion/effects), a
**Fixed** entry (theme-adaptive `CandlestickChart`/`SparklineGrid`
colors), the `v0.3.0` link reference, and a fresh empty `[Unreleased]`.
- **Component count → 309 everywhere.** The single source of truth is
`registry.items.length` (= 309 after #450). Synced every prose surface
that drifted: `README.md` (×3), `packages/ui/README.md`,
`packages/ui/package.json` `"description"`, `docs/ARCHITECTURE.md`, and
the ROADMAP. (The earlier 144→225 / 295 numbers are superseded.)
- **ROADMAP**: status `planning` → `release prep`; `milestone TBD` →
real 0.3.0 milestone; headline counts recomputed to **+169** net (140 →
309); the curated component list is relabeled as a highlight subset (it
enumerates the first ~85, not all 169).
- **RELEASING.md**: documents the `apps/registry` version bump, the full
doc-sync step (now incl. `packages/ui/package.json` description + the
count-guard test), and the **post-publish** `PUBLISHED_VERSION` flip.
- **`apps/registry/lib/component-count.test.ts`** (added on `main` by
#450): the guard asserted the count lives in `[Unreleased]`. Cutting the
release moves it into `[0.3.0]` and empties `[Unreleased]`, which would
break that guard. Retargeted it to assert the **newest** count snapshot
in the changelog (first match, file is newest-first) — works both
between releases and immediately after a cut. Still pins to
`registry.items.length`.

## Deliberately NOT here
- **No `PUBLISHED_VERSION` flip.** It stays `^0.2.1` until 0.3.0 is live
on npm `latest`; flipping before publish would make the deployed
registry advertise an unpublished version and break `npx shadcn add`.
Documented post-publish follow-up.
- No version bump — `packages/ui` and `apps/registry` are already
`0.3.0`.

## Verification
Count guard simulated against the real files (no local `node_modules` in
the branch worktree; CI runs the real suite):
- newest `CHANGELOG.md` count snapshot = **309** ==
`registry.items.length`
- `home/{en,fr}.mdx` count claims all = **309**
- PWA manifest derives from the registry (unchanged on `main`)

## Release sequence after this merges
1. Merge this PR (canary republishes `0.3.0-canary.<sha>`).
2. **Actions → Publish → Run workflow** on `main` → tags `v0.3.0`,
publishes `--tag latest`, GitHub Release.
3. Post-publish PR: flip `PUBLISHED_VERSION` → `0.3.0` + regenerate
registry (drift/integrity guards verify it).

---------

Co-authored-by: bntvllnt <bntvllnt@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

react-doctor: React 19 migration — no-react19-deprecated-apis (439 warnings)

2 participants