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,