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) {