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/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,