Skip to content

Feature/v2 react 19 - #955

Draft
pdavies88 wants to merge 15 commits into
developfrom
feature/v2-react-19
Draft

Feature/v2 react 19#955
pdavies88 wants to merge 15 commits into
developfrom
feature/v2-react-19

Conversation

@pdavies88

Copy link
Copy Markdown
Contributor

Description of the Change

Closes #

How to test the Change

Changelog Entry

Added - New feature
Changed - Existing functionality
Deprecated - Soon-to-be removed feature
Removed - Feature
Fixed - Bug fix
Security - Vulnerability
Developer - Non-functional update

Credits

Props @username, ...

Checklist:

wadebekker and others added 13 commits July 20, 2026 12:52
Bumps react/react-dom to 19 across every package and project via a
single root-level override, plus the dependent changes needed to
actually build and run cleanly under it:

- @types/react and testing-library versions bumped workspace-wide to
  their react-19-compatible releases
- fixed react 19's removal of the global JSX namespace (JSX.Element)
  in core, epio-search, and block-primitives
- switched block-primitives to the classic JSX transform, since its
  compiled output gets bundled directly into wp-admin block-editor
  builds, where react itself is externalized to WordPress core's
  own globally-provided copy but react/jsx-runtime is not - the
  automatic runtime was silently creating a dual-react-version
  conflict that crashed the block editor
- pinned webpack and webpack-dev-server versions needed to keep
  wp/10up-theme's build working under the shifted dependency graph
- bumped @wordpress/* and @10up/block-components in block-primitives
  to versions that declare react 19 support
propTypes/defaultProps on function components are silently ignored
under react 19, so they no longer serve any purpose in these two
projects. Removes the prop-types dependency entirely and disables
the now-permanently-unsatisfiable react/prop-types eslint rule for
both projects.
Regenerated package-lock.json from a fully clean node_modules install
rather than the accumulated result of many incremental installs made
throughout the upgrade. Collapses several duplicate nested copies of
@wordpress/data, @wordpress/element, and redux that had built up along
the way into single shared copies.
Three majors on the code path that turns WordPress block HTML into React
elements, and nothing in the repo audited it. Its failure mode is a subtle
markup difference rather than an error, so a green tsc and a green suite
both pass straight through it. This adds the audit first, then the bump.

packages/core/parity/ compares v3.0.16 against v6.1.7 over 60 cases per
build (server via htmlparser2, browser via jsdom), each two ways: rendered
markup, and a normalised element tree. Fixtures are real Gutenberg output
shape — one per block component in src/react/blocks, plus the silent-failure
surface (style-to-js 1→2, attribute mapping, entities, whitespace, void and
malformed elements, SVG casing, implied tbody). Scenarios include the
replace + nested domToReact path that BaseBlocksRenderer actually uses.

Both runs self-test against known-divergent input first: a harness that
cannot detect anything also reports clean.

Result: 0 markup and 0 tree divergences. The single behavioural difference
is that v3 emitted `children: null` on void elements where v6 leaves it
undefined — benign, since React renders both as nothing. The pre-existing
parseSeo inline snapshot failed on exactly that and nothing else, which is
independent confirmation from the other direction; it is updated here.

Code changes the bump required, both caught by tsc:

- Element['children'] is now ChildNode[] (includes CDATA) while domToReact
  takes DOMNode[]. Adds getChildNodes() to src/dom — it filters rather than
  casts, because the filter is true: CDATA only arises in XML mode. Exported,
  since consumers recursing with domToReact(element.children, …) hit the
  identical mismatch through core's re-export.
- The replace callback gained an index parameter; the nested callback in
  BaseBlocksRenderer forwards it.

domhandler@6 and friends ship ESM-only builds, which broke all 25
@headstartwp/next suites with "Cannot use import statement outside a
module" — jest does not transform node_modules by default. Root jest config
gains a transformIgnorePatterns carve-out. Consumers with their own jest
setups will hit this too; it belongs in the v2 upgrade notes.

The v3 baseline is installed into parity/node_modules as a non-workspace
package, not a devDependency: it peers react <=18 against this repo's React
19, and an npm alias cannot be overridden around that because the resolved
package name is still html-react-parser. core's `files` whitelist means
parity/ never publishes.

Verified: core 46/46 suites 242 tests, next 28/28 suites 113 tests.
Renovates the test harness before further core surgery, per the upgrade
playbook: a mis-migrated mock silently changes what is asserted, so the
guard has to be trustworthy before it guards anything.

msw 2 replaces the res/ctx response-composition API with returning a real
Response, so all handlers move from `rest.get(url, (req, res, ctx) =>
res(ctx.json(x)))` to `http.get(url, () => HttpResponse.json(x))`. Request
data moves onto a real Request: `req.url.searchParams` becomes
`new URL(request.url).searchParams`, and `req.params` becomes a separate
`params` argument. The redirect mock builds a Response directly, since
`compose`/`context` are gone.

Behaviour of every handler is preserved deliberately, including the
revisions endpoint requiring *both* auth headers rather than either — that
looks like a bug but changing it is not this commit's job.

Three pieces of infrastructure this exposed:

- msw 2 uses the platform Fetch API. Stock jsdom does not provide
  Request/Response/ReadableStream/TextEncoder, and `isomorphic-fetch`'s
  whatwg-fetch types are not interchangeable with the ones msw 2 expects.
  Switches to the jest-fixed-jsdom environment and drops isomorphic-fetch.
  Set at the root config because core and next both load core's server.
- `customExportConditions: ['']`, without which the resolver picks msw's
  browser build inside jsdom and msw/node's interceptors never engage.
- The transform pattern gains `[cm]?`. Several ESM-only dependencies ship
  .mjs entry points, and a transformIgnorePatterns carve-out is useless if
  the transform pattern never matches the file.

server.listen now uses onUnhandledRequest: 'error'. Under msw 1's default
an unmocked request fell through to the real network and only warned, so a
handler that stopped matching could pass silently. That immediately caught
one such request in fetchHookData-cache.

Two classes of test needed changes, both real:

- msw 1 intercepted at the http/XHR layer, so suites replacing global.fetch
  coexisted with it. msw 2 intercepts fetch itself and now sits in front of
  those mocks. Adds disableRequestInterception() to core's test exports for
  the cases that genuinely cannot use handlers — asserting on Next.js fetch
  options (cache, next.revalidate) that msw never sees.
- Two "mutates data properly" tests were racing SWR's post-mutate
  revalidation. msw 1's slower interception meant the mutated value always
  won; msw 2's native-fetch path is fast enough that it does not. Pinned
  with revalidate: false, which is what those tests actually mean to assert.

Verified: core 46/46 suites / 242 tests, next 28/28 suites / 113 tests,
lint clean on both, html-react-parser parity harness still clean.
react-inspector was the last thing in @headstartwp/core blocking a clean
React 19 install. 6.0.2 peers `react ^16.8.4 || ^17 || ^18`, so consumers
installing core on React 19 hit an ERESOLVE — and it was a real runtime
dependency, not a dev one, despite its only use being the lazily-loaded
DebugBlock.

Neither fixed release is reachable from here. 8.0.0 and 9.0.0 both peer
`^18 || ^19`, but both are exports-only packages with no root main/types,
and core's tsconfig uses classic "moduleResolution": "node", which cannot
read an exports map — each produces TS2307. Modernising moduleResolution
would fix it, but that changes how every import in core resolves and wants
its own change and verification pass.

Since the dependency's entire job was dumping two objects inside a
debug-only block, removing it is the better trade: it drops a runtime
dependency from the published package and a resolution constraint from
core's tsconfig at the same time.

The replacement is a <details>-based collapsible tree. It deliberately does
not serialise, because DebugBlock passes it its own props — which include
domNode — so it handles circular references, DOM nodes, react elements,
functions, symbols, bigints and Map/Set/Date/RegExp explicitly. Verified
against all of those plus a parser element with a parent back-reference;
JSON.stringify throws on several of them. expandLevel keeps
react-inspector's semantics, where 0 means everything collapsed including
the root, which is what both call sites pass.

Also drops the lazy() wrapper: the component is now local and small, and
the previous lazy import had no Suspense boundary around it.

Kept out of the public component index — it is an implementation detail of
DebugBlock, not new API surface to support.

Verified: core 46/46 suites / 242 tests, lint 0 errors, tsc clean.
Phase 3 of the React 19 upgrade: the release metadata and the deprecation
pass, on top of the React 19 / html-react-parser / msw work already on this
branch.

Changeset marks core, next and epio-search major, and a Changesets `linked`
group keeps those three on one version line from here on. block-primitives
is deliberately excluded — it stays on 0.x with `react ^18 || ^19` because
its only runtime is the WordPress block editor, which ships React 18.3 as
of WP 7.1, and the @wordpress/* packages it depends on declare that same
dual range. next-redis-cache-provider is already independently at 2.0.0.

Peer ranges become bounded rather than open-ended:

- react/react-dom: `>= 17.0.2` -> `^19.0.0`. v1 already permitted React 19
  through that open range, so this removes nothing consumers had; it
  replaces an untested claim with a tested line.
- next: `>= 12.0.0` -> `^15.5.21 || ^16.2.11`. The open range was a
  liability — 2026's high-severity Next.js advisories reach into majors
  12-14 and are first patched only in 15.5.x, so there is nothing upstream
  to backport. Both floors are the patched releases of their lines.
- engines.node: `>=20.9.0` across all four packages.

The internal @headstartwp/core dependency in next is left at ^1.5.0 on
purpose: Changesets rewrites it on `changeset version`, and hand-editing it
to ^2.0.0 now would break installs against the current 1.6.0.

Pages router is deprecated, not removed (code and example projects keep
working, removal targeted at v3). HeadlessApp, fetchHookData,
prepareFetchHookData, addHookData, handleError, withSiteContext,
getSiteFromContext, previewHandler and revalidateHandler all gain
@deprecated JSDoc, and the consumer-facing entry points among them emit a
one-time dev-mode warning pointing at the @headstartwp/next/app equivalent.

Two deliberate omissions in that warning pass: getSiteFromContext and
prepareFetchHookData are tagged but do not warn, because fetchHookData and
handleError call them internally — warning there would fire on the
library's own calls and blame the consumer for an API they never used.

Also fixes Yoast.tsx for html-react-parser 6, which the earlier phase
missed: `tsc -b` on next was incremental and never re-checked it. Same
class of change as BaseBlocksRenderer — ChildNode[] vs DOMNode[] via the
new getChildNodes(), plus attributesToProps now typing values as
`string | boolean`, which affects the URL props and the React key.

Adds SECURITY.md with the supported-versions statement, including what the
1.x row actually means: its open Next.js range permits majors that can no
longer be patched.

Verified: core 46/46 suites / 242 tests, next 28/28 / 113 tests, both
builds clean, lint clean on both, `changeset status` reports exactly
core/next/epio-search at major.
Every block this package ships declares its matching predicate — and
sometimes `exclude` — through a merged namespace `defaultProps`. React 19
stopped applying `defaultProps` to function components under the automatic
JSX runtime, so `<ParagraphBlock />` (which compiles to `jsx()`) arrived at
BaseBlocksRenderer with `props.test` undefined. The fallback that checks
`block.type.test` does not catch it either, because the static is named
`defaultProps`, not `test`.

Effect before this fix: all 20 shipped blocks silently stop matching under
React 19. No error — the renderer just emits the original HTML. This is the
exact failure mode the upgrade was meant to hunt for, and it survived
PR #951's defaultProps sweep because these are namespace-merged rather than
assigned directly.

Two reasons it went unnoticed. The legacy `createElement` path still applies
defaultProps in React 19.2.7, so a check written that way passes while real
JSX usage does not. And every existing BlocksRenderer test passes an
explicit `test`/`tagName` prop, so none of them exercised a shipped block's
own defaults.

Fixed centrally in BaseBlocksRenderer via getBlockProps(), which layers
`block.type.defaultProps` underneath the element's own props. Explicit props
still win, matching React's precedence. Doing it here rather than rewriting
each block also covers consumer blocks written to the same documented
pattern, which break identically.

Adds regression tests that use the shipped blocks the way the docs tell
consumers to. They discriminate via a marker `component`, because an
unmatched block is not an error — the renderer emits near-identical HTML,
which is what made a first attempt at these tests pass against the bug.
Confirmed they fail with the fix reverted.
Supersedes the deprecation approach: v2 does not support the Next.js pages
router at all. Pages-router consumers stay on v1, which keeps its own
supported line.

Removed outright rather than deprecated: HeadlessApp, fetchHookData,
prepareFetchHookData, addHookData, handleError, withSiteContext,
getSiteFromContext, previewHandler, revalidateHandler, their tests, and the
one-time deprecation-warning helper added in the previous commit.

Kept, because the app router still needs them: handlers/types.ts
(PreviewData — the app-router preview handler shares the cookie payload
shape) and data/convertToPath.ts (used by rsc prepareQuery).

The client hooks are kept but ported. All eight — usePost, usePosts,
useSearch, useSearchNative, useTerms, useAppSettings, useAuthorArchive,
usePostOrPosts — run through usePrepareFetch, which read next/router. It
now reads useParams() from next/navigation. The shapes line up: what the
pages router exposed as router.query.path is the [...path] catch-all
segment, which useParams() returns directly, matching the routeParams that
the server-side prepareQuery already takes.

Two v1 behaviours have no app-router equivalent and are gone:

- Locale. The pages router had built-in i18n (router.locale /
  defaultLocale); the app router does not. Polylang's language now comes
  from the `lang` route segment, which is what prepareQuery does
  server-side.
- Preview. router.isPreview has no client counterpart — draft mode is
  readable only on the server via draftMode(). Preview fetching is
  server-side in v2; a client component that needs the alternative preview
  auth header can still set it through options.fetchStrategyOptions.

Removes the three pages-router example projects (wp-nextjs,
wp-multisite-nextjs, wp-multisite-i18n-nextjs) and drops them from the
changeset ignore list. Each already had an app-router counterpart
(wp-nextjs-app, wp-multisite-nextjs-app, wp-polylang-nextjs-app), so no
demonstration coverage is lost.

Changeset and SECURITY.md updated to describe removal rather than
deprecation, and to state the router split as the headline v2 change.

Verified: core 46/46 suites / 246 tests, next 20/20 suites / 84 tests,
next builds and lints clean, changeset status still reports exactly
core/next/epio-search at major.
@changeset-bot

changeset-bot Bot commented Aug 17, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: b379aa7

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 4 packages
Name Type
@headstartwp/core Major
@headstartwp/next Major
@headstartwp/epio-search Major
@headstartwp/block-primitives Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercel Bot commented Aug 17, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
headstartwp-app-router Ready Ready Preview Aug 17, 2026 2:34am
headstarwp Error Error Aug 17, 2026 2:34am

Request Review

The pages-router removal broke CI in a way that would only have shown up on
the first PR: nextjs_bundle_analysis.yml is built entirely around
projects/wp-nextjs, which no longer exists on this branch. Removed. The
app-router bundle analysis workflow stays and becomes the sole bundle-budget
reference — which also means the outstanding budget number has to be
re-derived from that workflow rather than the pages-router one.

Node 24 everywhere. unit-tests was still running a [16.x, 18.x, 20.x] matrix
— two EOL releases and one that reached EOL in April 2026 — and build-test
was on [18, 20, 22]. Both now run 24, as do the remaining workflows, .nvmrc
and the root engines.

Unlike v1, the published packages take the raised floor too:
engines.node >=24.0.0 on core, next, epio-search and block-primitives. v2 is
a new major, so this is the release where a floor can move without stranding
anyone — v1 stays permissive for the pages-router consumers who cannot
follow. The changeset and SECURITY.md are updated to match; they previously
quoted >=20.9.0.

Also carries the publish:v1 script and release-v1.yml across from the v1
branch, so whichever line lands on trunk first, the other's release path
exists rather than being reintroduced later.

Verified: core 46/46 suites / 247 tests, next 20/20 / 84 tests, both build
clean, html-react-parser parity harness still clean.
The 148,480 byte budget was inherited from when two workflows enforced it —
one over projects/wp-nextjs and one over wp-nextjs-app. The pages-router
project was removed when v2 became app-router only, so this is now the sole
bundle under measurement and the number had to be re-derived against it.

Measured by building wp-nextjs-app on each branch and running the same
`nextjs-bundle-analysis report` CI runs:

  v1 (React 18.3.1)   278,767 raw   85,850 gzip
  v2 (React 19.2.7)   330,304 raw  101,229 gzip

React 19 costs +15,379 bytes gzip (+17.9%) on shared first-load JS. That is
a real regression, accepted deliberately, and it belongs in the release
notes rather than buried — the changeset now states it.

The old budget would not have caught it. At 148,480 the guard carried 42%
slack on v1 and would still carry 32% on v2; it would not fire until the
bundle grew another 47KB gzip, roughly three more React-19-sized
regressions. New value is 112,000 — about 10.6% headroom over the measured
figure, enough to absorb dependency drift without a false alarm on every
update, tight enough that a genuine regression trips it.
budgetPercentIncreaseRed stays at 20 as the separate alarm on change between
base and head.

Adds BUNDLE-BUDGET.md recording the measurements and how to re-derive, so a
future change is visibly either a deliberate acceptance or drift nobody
noticed.

v1 keeps the old 148,480 deliberately: its measured bundle is 85,850, the
loose budget is harmless there, and re-tuning would be churn on a branch
whose point is stability.
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.

2 participants