Skip to content

v3.0.0 — React 19 support, TypeScript cleanup, switch to pnpm#260

Merged
staylor merged 14 commits intomainfrom
v3.0.0
Mar 3, 2026
Merged

v3.0.0 — React 19 support, TypeScript cleanup, switch to pnpm#260
staylor merged 14 commits intomainfrom
v3.0.0

Conversation

@staylor
Copy link
Copy Markdown
Owner

@staylor staylor commented Mar 2, 2026

3.0.0

React 19 Support

On React 19+, <Helmet> renders native JSX elements (<title>, <meta>, <link>, <base>, <script>, <style>, <noscript>) that React automatically hoists to <head>, instead of manual DOM manipulation. HelmetProvider becomes a transparent passthrough.

The existing API is fully compatible — no code changes required for consumers upgrading from v2.

  • Runtime version detection via React.version
  • htmlAttributes / bodyAttributes still applied via direct DOM manipulation (no React 19 equivalent)
  • helmetData prop, SSR context, prioritizeSeoTags, and onChangeClientState are not used on the React 19 path

On React 16–18, behavior is completely unchanged.

Breaking Changes

  • HelmetServerState.titleAttributes removed — this field was never populated by mapStateOnServer (title attributes were always bundled inside the title datum). If you were referencing helmet.titleAttributes, it was always undefined.
  • HelmetDatum.toComponent() return type changed from React.Component<any> to React.ReactElement[] to match what was actually returned.
  • MappedServerState is no longer exported from the package entry point (internal type).
  • HelmetData.setHelmet() now accepts null (matches actual runtime behavior).

TypeScript Cleanup

  • New tsconfig.build.json for declaration emission (replaces fragile raw tsc CLI invocation)
  • All 5 @ts-ignore comments removed, replaced with proper types
  • Internal Helmet class methods marked private — cleaner .d.ts output
  • Explicit public type exports instead of export * from './types'
  • Removed legacy IE styleSheet API branch in client.ts
  • tsconfig.json cleaned up: explicit include/exclude, ESNext module

Package Changes

  • Switched from yarn to pnpm
  • "typings""types" (modern field name)
  • Added "exports" field with proper import/require/types conditions
  • peerDependencies now includes ^19.0.0

Tests

61 new test cases covering the React 19 code path (mocking isReact19 to true):

  • Title (API + Declarative, titleTemplate, defaultTitle)
  • Meta, Link, Base, Script, Style, Noscript tags
  • HTML/Body attributes (DOM manipulation + cleanup on unmount)
  • Provider passthrough behavior
  • Combined/integration scenarios

All 298 tests pass (237 original + 61 new).

staylor added 6 commits March 2, 2026 17:12
BREAKING CHANGES:

- React 19 support: On React 19+, <Helmet> renders native JSX elements
  that React hoists to <head>, instead of manual DOM manipulation.
  HelmetProvider becomes a transparent passthrough. The existing API is
  fully compatible — no code changes required for consumers.

- TypeScript: HelmetServerState no longer includes a titleAttributes
  field (it was never populated — title attributes were always bundled
  inside the title datum). HelmetDatum.toComponent() return type changed
  from React.Component<any> to React.ReactElement[]. MappedServerState
  is no longer exported from the package entry point.

- Package manager: Switched from yarn to pnpm.

- Package exports: Added exports field with proper import/require
  conditions. Renamed typings to types in package.json.

React 19 changes:
- New React19Dispatcher renders <title>, <meta>, <link>, <base>,
  <script>, <style>, <noscript> as actual JSX elements
- Runtime version detection via React.version
- htmlAttributes/bodyAttributes still applied via direct DOM
  manipulation (no React 19 equivalent)
- HelmetProvider is a no-op passthrough on React 19+
- helmetData prop and SSR context are not used on React 19
- prioritizeSeoTags has no effect on React 19

TypeScript cleanup:
- Dedicated tsconfig.build.json for declaration emission
- All @ts-ignore comments removed (5 total), replaced with proper types
- Internal Helmet methods marked private (cleaner .d.ts output)
- Explicit public type exports instead of wildcard re-export
- Fixed incorrect types: HelmetDatum.toComponent(), setHelmet() nullability
- Removed legacy IE styleSheet API branch in client.ts

Peer dependencies now include React 19:
  react: ^16.6.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
- Remove .circleci/config.yml
- Add .github/workflows/ci.yml running tests, type checks, and lint
  across Node 18, 20, and 22 using pnpm
- Update README badge from CircleCI to GitHub Actions
- SSR e2e (vitest, Node environment): renders pages with renderToString
  and verifies helmet context output for title, meta, link, base, style,
  script, noscript, html/body attributes, titleTemplate, nested components

- Browser e2e (Playwright + Vite dev server): navigates to pages in a
  real browser and verifies tags appear in <head>, html/body attributes
  are set, title is correct, nested components resolve correctly

- Shared fixture app with 4 pages: meta (declarative), title-template,
  api (prop-style), nested (multiple Helmet instances)

- CI: SSR e2e runs in the build matrix, browser e2e runs as a separate
  job with Playwright chromium

Scripts:
  test:e2e:server  — SSR tests via vitest
  test:e2e:browser — browser tests via Playwright
  test:e2e         — both
  test:all         — unit + e2e
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR releases v3.0.0 of react-helmet-async, adding a React 19+ runtime path that renders native <title>/<meta>/<link>/... elements for React-managed head hoisting, while keeping the existing React 16–18 behavior intact. It also modernizes the TypeScript/build setup and switches the repo tooling/CI from yarn + CircleCI to pnpm + GitHub Actions, adding E2E coverage.

Changes:

  • Add React 19 detection and a new dispatcher that renders native head elements (plus html/body attribute handling).
  • TypeScript/build cleanup (new tsconfig.build.json, declaration emission changes, exported type surface adjustments).
  • Tooling/CI overhaul: pnpm migration, GitHub Actions CI, and new Vitest/Playwright E2E tests.

Reviewed changes

Copilot reviewed 36 out of 40 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
vitest.config.ts Excludes build/e2e outputs from unit test runs.
tsconfig.json Uses build config as a base; configures typecheck-only settings for dev/tests.
tsconfig.build.json New build TS config for declaration-only output to lib/.
src/types.ts Updates public types (notably toComponent() return type; server state shape).
src/reactVersion.ts Adds runtime React major-version detection for React 19 path selection.
src/index.tsx Switches to explicit type exports; routes React 19 to React19Dispatcher.
src/client.ts Removes legacy IE stylesheet handling; replaces @ts-ignore with typed access.
src/React19Dispatcher.tsx New React 19 dispatcher: renders native elements + manages html/body attrs.
src/Provider.tsx Makes provider a passthrough on React 19; retains HelmetData on 16–18.
src/HelmetData.ts Updates setHelmet typing to allow null; keeps SSR bootstrap behavior.
src/Dispatcher.tsx Adjusts types for setHelmet(null) and removes @ts-ignore usage.
package.json Bumps to 3.0.0; adds exports map; switches scripts to pnpm; adds e2e deps.
e2e/vitest.config.ts Adds node-environment Vitest config for SSR E2E tests.
e2e/server.test.tsx Adds SSR E2E assertions for rendered HTML + Helmet server outputs.
e2e/playwright.config.ts Adds Playwright config + webServer wiring for browser E2E.
e2e/fixtures/vite.config.ts Adds Vite fixture server config for browser E2E runs.
e2e/fixtures/index.html Adds minimal HTML template for fixture app.
e2e/fixtures/entry-server.tsx Adds SSR entry that returns HTML + helmet context for tests.
e2e/fixtures/entry-client.tsx Adds client entry to mount the fixture app for browser E2E.
e2e/fixtures/App.tsx Fixture app pages covering declarative/API Helmet usage + nesting.
e2e/browser.test.ts Browser E2E coverage validating head tags + html/body attributes.
tests/react19/utils.tsx Test helpers for the mocked React 19 path under React 18.
tests/react19/title.test.tsx Unit tests for React 19 title behavior (API + declarative).
tests/react19/style.test.tsx Unit tests for React 19 style tag rendering.
tests/react19/script.test.tsx Unit tests for React 19 script tag rendering.
tests/react19/provider.test.tsx Unit tests for provider passthrough behavior on React 19.
tests/react19/noscript.test.tsx Unit tests for React 19 noscript rendering.
tests/react19/meta.test.tsx Unit tests for React 19 meta rendering + updates.
tests/react19/link.test.tsx Unit tests for React 19 link rendering + updates.
tests/react19/htmlAttributes.test.tsx Unit tests for React 19 htmlAttributes DOM application/cleanup.
tests/react19/combined.test.tsx Integration-style unit tests combining multiple tag types/instances.
tests/react19/bodyAttributes.test.tsx Unit tests for React 19 bodyAttributes DOM application/cleanup.
tests/react19/base.test.tsx Unit tests for React 19 base tag rendering.
README.md Documents React 19 behavior changes + pnpm-based development/test commands.
.husky/commit-msg Updates commit-msg hook to use pnpm.
.gitignore Ignores Playwright outputs (test-results, playwright-report).
.github/workflows/ci.yml Adds GitHub Actions CI (typecheck/lint/unit + SSR e2e + browser e2e).
.circleci/config.yml Removes legacy CircleCI config.
Comments suppressed due to low confidence (4)

src/React19Dispatcher.tsx:44

  • applyAttributes tracks managed html/body attributes using a new hardcoded "data-rh-managed" marker, while the rest of the library uses the shared HELMET_ATTRIBUTE ("data-rh") for attribute bookkeeping. Using a different marker makes cleanup inconsistent across code paths and complicates interop with existing behavior. Consider reusing HELMET_ATTRIBUTE (or at least centralizing the marker in constants) so html/body attribute management is consistent.
    src/Provider.tsx:42
  • HelmetProvider’s context typing still assumes helmet?: HelmetServerState, but on the client setHelmet(null) is now valid. Consider updating ProviderProps.context/helmet typing to allow null so consumers don’t need unsafe casts and the runtime behavior is accurately reflected in the public types.
    src/Dispatcher.tsx:55
  • Dispatcher.emitChange relies on a double-cast (as MappedServerState then as unknown as HelmetServerState) to satisfy types. This suppresses real mismatches between reducePropsToState/mapStateOnServer and HelmetServerState and can hide runtime issues. Prefer fixing the underlying types/return values (e.g., make mapStateOnServer explicitly return HelmetServerState and ensure all toComponent() implementations match) so this cast is unnecessary.
    if (Provider.canUseDOM) {
      handleStateChangeOnClient(state);
    } else if (mapStateOnServer) {
      serverState = mapStateOnServer(state as MappedServerState) as unknown as HelmetServerState;
    }
    setHelmet(serverState);

tests/react19/provider.test.tsx:36

  • This test defines const context = {} but never passes it to HelmetProvider/HelmetData, so the assertion will always pass regardless of provider behavior. To actually verify React 19 behavior, render through (or the relevant API entry point) and then assert on context.helmet.
  it('does not populate context with helmet state', () => {
    const context = {} as any;

    // Render through the provider with context
    render(
      <Helmet>
        <title>Test</title>
      </Helmet>
    );

    // On React 19, context.helmet should not be populated
    // (the provider is a passthrough, no server state is set)
    expect(context.helmet).toBeUndefined();
  });

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +32 to 34
setHelmet: (serverState: HelmetServerState | null) => {
this.context.helmet = serverState!;
},
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HelmetData.value.setHelmet now accepts null but assigns it into this.context.helmet via a non-null assertion (serverState!). This makes the type lie and can leave context.helmet as null at runtime. Update HelmetDataContext (and related public context typings) to allow null and assign serverState directly without the non-null assertion.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot open a new pull request to apply changes based on this feedback

Comment on lines 34 to 37
export interface HelmetDatum {
toString(): string;
toComponent(): React.Component<any>;
toComponent(): React.ReactElement[];
}
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HelmetDatum.toComponent is now typed to return React.ReactElement[]. Ensure all implementations comply—for example, server-side priority.toComponent() currently returns undefined when prioritizeSeoTags is false, which would violate this type and can break consumers calling .toComponent().

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot open a new pull request to apply changes based on this feedback

Comment on lines +95 to +99
componentWillUnmount() {
// Clean up html/body attributes
applyAttributes(TAG_NAMES.HTML, {});
applyAttributes(TAG_NAMES.BODY, {});
}
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React19Dispatcher’s html/body attribute handling isn’t instance-aware: applyAttributes() writes directly to the single /, and componentWillUnmount blindly clears managed attributes. With multiple instances, unmounting an inner one can remove attributes that should fall back to an outer instance (since the outer may not re-render). Consider reintroducing shared instance bookkeeping for html/body attributes (similar to reducePropsToState in the legacy path) so updates/unmounts compute the correct merged attributes instead of clearing globally.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot open a new pull request to apply changes based on this feedback

Comment on lines +194 to +201
init() {
this.applyNonHostedAttributes();
}

render() {
this.init();

return React.createElement(
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React19Dispatcher is performing DOM mutations during render via init() → applyNonHostedAttributes(). Side effects in render can break concurrent rendering/StrictMode and can run on the server unexpectedly. Move this work to componentDidMount (and keep it in componentDidUpdate), and remove init() from render.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot open a new pull request to apply changes based on this feedback

Copy link
Copy Markdown
Contributor

Copilot AI commented Mar 3, 2026

@staylor I've opened a new pull request, #261, to work on those changes. Once the pull request is ready, I'll request review from you.

Copy link
Copy Markdown
Contributor

Copilot AI commented Mar 3, 2026

@staylor I've opened a new pull request, #262, to work on those changes. Once the pull request is ready, I'll request review from you.

Copy link
Copy Markdown
Contributor

Copilot AI commented Mar 3, 2026

@staylor I've opened a new pull request, #263, to work on those changes. Once the pull request is ready, I'll request review from you.

Copy link
Copy Markdown
Contributor

Copilot AI commented Mar 3, 2026

@staylor I've opened a new pull request, #264, to work on those changes. Once the pull request is ready, I'll request review from you.

Copilot AI and others added 5 commits March 3, 2026 06:50
…tion

Co-authored-by: staylor <52932+staylor@users.noreply.github.com>
Co-authored-by: staylor <52932+staylor@users.noreply.github.com>
…oTags is false

Co-authored-by: staylor <52932+staylor@users.noreply.github.com>
Co-authored-by: staylor <52932+staylor@users.noreply.github.com>
Co-authored-by: staylor <52932+staylor@users.noreply.github.com>
@staylor
Copy link
Copy Markdown
Owner Author

staylor commented Mar 3, 2026

@copilot tests are failing after I merged your changes - fix them, also update the GitHub action run on all branches, not just main

Copy link
Copy Markdown
Contributor

Copilot AI commented Mar 3, 2026

@staylor I've opened a new pull request, #265, to work on those changes. Once the pull request is ready, I'll request review from you.

Copilot AI and others added 2 commits March 3, 2026 07:08
Co-authored-by: staylor <52932+staylor@users.noreply.github.com>
Co-authored-by: staylor <52932+staylor@users.noreply.github.com>
@staylor staylor merged commit ba5d5fb into main Mar 3, 2026
4 checks passed
@slorber
Copy link
Copy Markdown
Contributor

slorber commented Mar 11, 2026

Thanks for the update

On React 19+, renders native JSX elements

Does this mean that we lose the title deduplication feature that I described here?

#239 (comment)

@Tobbe
Copy link
Copy Markdown

Tobbe commented Mar 12, 2026

Thanks for the update

On React 19+, renders native JSX elements

Does this mean that we lose the title deduplication feature that I described here?

#239 (comment)

@slorber According to the research done by @birdofpreyru you do indeed lose title deduplication. Read more in the README here https://github.com/birdofpreyru/react-helmet#drpogodinreact-helmet-vs-react-helmet-async

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.

5 participants