diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d1a09833..c92e711e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -2,9 +2,6 @@ name: CI
on:
push:
- branches: [main]
- pull_request:
- branches: [main]
jobs:
build:
diff --git a/.gitignore b/.gitignore
index a131e2fa..b0efe51c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
lib
node_modules
+package-lock.json
test-results
playwright-report
diff --git a/__tests__/react19/bodyAttributes.test.tsx b/__tests__/react19/bodyAttributes.test.tsx
index 672cab72..068ab152 100644
--- a/__tests__/react19/bodyAttributes.test.tsx
+++ b/__tests__/react19/bodyAttributes.test.tsx
@@ -61,6 +61,33 @@ describe('React 19 – body attributes', () => {
expect(document.body).not.toHaveAttribute('class');
expect(document.body).not.toHaveAttribute('data-rh-managed');
});
+
+ it('inner instance overrides outer instance for conflicting body attributes', () => {
+ render(
+ <>
+
+
+ >
+ );
+
+ expect(document.body).toHaveAttribute('class', 'inner');
+ });
+
+ it('restores outer instance body attributes when inner instance unmounts', () => {
+ render(
+ <>
+
+
+ >
+ );
+
+ expect(document.body).toHaveAttribute('class', 'inner');
+
+ // Simulate unmounting the inner instance by re-rendering with only the outer
+ render();
+
+ expect(document.body).toHaveAttribute('class', 'outer');
+ });
});
describe('Declarative API', () => {
diff --git a/__tests__/react19/htmlAttributes.test.tsx b/__tests__/react19/htmlAttributes.test.tsx
index b38310d4..d2e4b8d1 100644
--- a/__tests__/react19/htmlAttributes.test.tsx
+++ b/__tests__/react19/htmlAttributes.test.tsx
@@ -88,6 +88,46 @@ describe('React 19 – html attributes', () => {
const htmlTag = document.documentElement;
expect(htmlTag).not.toHaveAttribute('lang');
});
+
+ it('inner instance overrides outer instance for conflicting attributes', () => {
+ render(
+ <>
+
+
+ >
+ );
+
+ expect(document.documentElement).toHaveAttribute('lang', 'ja');
+ });
+
+ it('restores outer instance attributes when inner instance unmounts', () => {
+ // Render both outer and inner Helmet instances
+ render(
+ <>
+
+
+ >
+ );
+
+ expect(document.documentElement).toHaveAttribute('lang', 'ja');
+
+ // Simulate unmounting the inner instance by re-rendering with only the outer
+ render();
+
+ expect(document.documentElement).toHaveAttribute('lang', 'en');
+ });
+
+ it('merges non-conflicting attributes from multiple instances', () => {
+ render(
+ <>
+
+
+ >
+ );
+
+ expect(document.documentElement).toHaveAttribute('lang', 'en');
+ expect(document.documentElement).toHaveAttribute('class', 'inner');
+ });
});
describe('Declarative API', () => {
diff --git a/__tests__/server/helmetData.test.tsx b/__tests__/server/helmetData.test.tsx
index 0c2ec67f..66d3d8fe 100644
--- a/__tests__/server/helmetData.test.tsx
+++ b/__tests__/server/helmetData.test.tsx
@@ -25,7 +25,7 @@ describe('Helmet Data', () => {
);
- const head = helmetData.context.helmet;
+ const head = helmetData.context.helmet!;
expect(head.base).toBeDefined();
expect(head.base.toString).toBeDefined();
@@ -41,7 +41,7 @@ describe('Helmet Data', () => {
);
- const head = helmetData.context.helmet;
+ const head = helmetData.context.helmet!;
expect(head.base).toBeDefined();
expect(head.base.toString).toBeDefined();
@@ -62,7 +62,7 @@ describe('Helmet Data', () => {
);
- const head = helmetData.context.helmet;
+ const head = helmetData.context.helmet!;
expect(head.base).toBeDefined();
expect(head.base.toString).toBeDefined();
diff --git a/src/HelmetData.ts b/src/HelmetData.ts
index b8f57d16..3a020c88 100644
--- a/src/HelmetData.ts
+++ b/src/HelmetData.ts
@@ -14,7 +14,7 @@ export interface HelmetDataType {
}
interface HelmetDataContext {
- helmet: HelmetServerState;
+ helmet: HelmetServerState | null;
}
export const isDocument = !!(
@@ -30,7 +30,7 @@ export default class HelmetData implements HelmetDataType {
value = {
setHelmet: (serverState: HelmetServerState | null) => {
- this.context.helmet = serverState!;
+ this.context.helmet = serverState;
},
helmetInstances: {
get: () => (this.canUseDOM ? instances : this.instances),
diff --git a/src/Provider.tsx b/src/Provider.tsx
index 9b929eaa..5161d7bd 100644
--- a/src/Provider.tsx
+++ b/src/Provider.tsx
@@ -11,7 +11,7 @@ export const Context = React.createContext(defaultValue);
interface ProviderProps {
context?: {
- helmet?: HelmetServerState;
+ helmet?: HelmetServerState | null;
};
}
diff --git a/src/React19Dispatcher.tsx b/src/React19Dispatcher.tsx
index d3a91fb2..561aefc8 100644
--- a/src/React19Dispatcher.tsx
+++ b/src/React19Dispatcher.tsx
@@ -4,6 +4,11 @@ import { TAG_NAMES, HTML_TAG_MAP, REACT_TAG_MAP } from './constants';
import { isDocument } from './HelmetData';
import type { HelmetProps } from './types';
+// Module-level registry of all mounted React19Dispatcher instances.
+// This enables instance-aware merging of html/body attributes so that
+// unmounting one instance correctly falls back to the outer instance's attrs.
+const react19Instances: React19Dispatcher[] = [];
+
/**
* Converts React-style prop names to HTML attribute names.
* e.g. { className: 'foo' } → { class: 'foo' }
@@ -71,6 +76,29 @@ const applyAttributes = (tagName: string, attributes: { [key: string]: any }) =>
}
};
+/**
+ * Merges html/body attributes from all registered instances (earlier instances
+ * are overridden by later ones for conflicting keys, matching the legacy
+ * reducePropsToState behavior) and applies the result to the DOM.
+ */
+const syncAllAttributes = () => {
+ const htmlAttrs: { [key: string]: any } = {};
+ const bodyAttrs: { [key: string]: any } = {};
+
+ for (const instance of react19Instances) {
+ const { htmlAttributes, bodyAttributes } = instance.props;
+ if (htmlAttributes) {
+ Object.assign(htmlAttrs, toHtmlAttributes(htmlAttributes));
+ }
+ if (bodyAttributes) {
+ Object.assign(bodyAttrs, toHtmlAttributes(bodyAttributes));
+ }
+ }
+
+ applyAttributes(TAG_NAMES.HTML, htmlAttrs);
+ applyAttributes(TAG_NAMES.BODY, bodyAttrs);
+};
+
interface React19DispatcherProps extends HelmetProps {
/**
* The processed props including mapped children. These come from Helmet's
@@ -88,24 +116,21 @@ interface React19DispatcherProps extends HelmetProps {
* manipulation since React 19 doesn't handle those.
*/
export default class React19Dispatcher extends Component {
- componentDidUpdate() {
- this.applyNonHostedAttributes();
+ componentDidMount() {
+ react19Instances.push(this);
+ syncAllAttributes();
}
- componentWillUnmount() {
- // Clean up html/body attributes
- applyAttributes(TAG_NAMES.HTML, {});
- applyAttributes(TAG_NAMES.BODY, {});
+ componentDidUpdate() {
+ syncAllAttributes();
}
- applyNonHostedAttributes() {
- const { htmlAttributes, bodyAttributes } = this.props;
- if (htmlAttributes) {
- applyAttributes(TAG_NAMES.HTML, toHtmlAttributes(htmlAttributes));
- }
- if (bodyAttributes) {
- applyAttributes(TAG_NAMES.BODY, toHtmlAttributes(bodyAttributes));
+ componentWillUnmount() {
+ const index = react19Instances.indexOf(this);
+ if (index !== -1) {
+ react19Instances.splice(index, 1);
}
+ syncAllAttributes();
}
resolveTitle(): string | undefined {
@@ -191,13 +216,7 @@ export default class React19Dispatcher extends Component
});
}
- init() {
- this.applyNonHostedAttributes();
- }
-
render() {
- this.init();
-
return React.createElement(
React.Fragment,
null,
diff --git a/src/server.ts b/src/server.ts
index 52106e88..3a491d18 100644
--- a/src/server.ts
+++ b/src/server.ts
@@ -185,7 +185,7 @@ const mapStateOnServer = (props: MappedServerState) => {
} = props;
let { linkTags, metaTags, scriptTags } = props;
let priorityMethods = {
- toComponent: () => {},
+ toComponent: (): React.ReactElement[] => [],
toString: () => '',
};
if (prioritizeSeoTags) {