Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,6 @@ name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
build:
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
lib
node_modules
package-lock.json
test-results
playwright-report
27 changes: 27 additions & 0 deletions __tests__/react19/bodyAttributes.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<>
<Helmet bodyAttributes={{ className: 'outer' }} />
<Helmet bodyAttributes={{ className: 'inner' }} />
</>
);

expect(document.body).toHaveAttribute('class', 'inner');
});

it('restores outer instance body attributes when inner instance unmounts', () => {
render(
<>
<Helmet bodyAttributes={{ className: 'outer' }} />
<Helmet bodyAttributes={{ className: 'inner' }} />
</>
);

expect(document.body).toHaveAttribute('class', 'inner');

// Simulate unmounting the inner instance by re-rendering with only the outer
render(<Helmet bodyAttributes={{ className: 'outer' }} />);

expect(document.body).toHaveAttribute('class', 'outer');
});
});

describe('Declarative API', () => {
Expand Down
40 changes: 40 additions & 0 deletions __tests__/react19/htmlAttributes.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<>
<Helmet htmlAttributes={{ lang: 'en' }} />
<Helmet htmlAttributes={{ lang: 'ja' }} />
</>
);

expect(document.documentElement).toHaveAttribute('lang', 'ja');
});

it('restores outer instance attributes when inner instance unmounts', () => {
// Render both outer and inner Helmet instances
render(
<>
<Helmet htmlAttributes={{ lang: 'en' }} />
<Helmet htmlAttributes={{ lang: 'ja' }} />
</>
);

expect(document.documentElement).toHaveAttribute('lang', 'ja');

// Simulate unmounting the inner instance by re-rendering with only the outer
render(<Helmet htmlAttributes={{ lang: 'en' }} />);

expect(document.documentElement).toHaveAttribute('lang', 'en');
});

it('merges non-conflicting attributes from multiple instances', () => {
render(
<>
<Helmet htmlAttributes={{ lang: 'en' }} />
<Helmet htmlAttributes={{ class: 'inner' }} />
</>
);

expect(document.documentElement).toHaveAttribute('lang', 'en');
expect(document.documentElement).toHaveAttribute('class', 'inner');
});
});

describe('Declarative API', () => {
Expand Down
6 changes: 3 additions & 3 deletions __tests__/server/helmetData.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ describe('Helmet Data', () => {
<Helmet helmetData={helmetData} base={{ target: '_blank', href: 'http://localhost/' }} />
);

const head = helmetData.context.helmet;
const head = helmetData.context.helmet!;

expect(head.base).toBeDefined();
expect(head.base.toString).toBeDefined();
Expand All @@ -41,7 +41,7 @@ describe('Helmet Data', () => {
</Helmet>
);

const head = helmetData.context.helmet;
const head = helmetData.context.helmet!;

expect(head.base).toBeDefined();
expect(head.base.toString).toBeDefined();
Expand All @@ -62,7 +62,7 @@ describe('Helmet Data', () => {
</div>
);

const head = helmetData.context.helmet;
const head = helmetData.context.helmet!;

expect(head.base).toBeDefined();
expect(head.base.toString).toBeDefined();
Expand Down
4 changes: 2 additions & 2 deletions src/HelmetData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export interface HelmetDataType {
}

interface HelmetDataContext {
helmet: HelmetServerState;
helmet: HelmetServerState | null;
}

export const isDocument = !!(
Expand All @@ -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),
Expand Down
2 changes: 1 addition & 1 deletion src/Provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export const Context = React.createContext(defaultValue);

interface ProviderProps {
context?: {
helmet?: HelmetServerState;
helmet?: HelmetServerState | null;
};
}

Expand Down
57 changes: 38 additions & 19 deletions src/React19Dispatcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' }
Expand Down Expand Up @@ -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
Expand All @@ -88,24 +116,21 @@ interface React19DispatcherProps extends HelmetProps {
* manipulation since React 19 doesn't handle those.
*/
export default class React19Dispatcher extends Component<React19DispatcherProps> {
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 {
Expand Down Expand Up @@ -191,13 +216,7 @@ export default class React19Dispatcher extends Component<React19DispatcherProps>
});
}

init() {
this.applyNonHostedAttributes();
}

render() {
this.init();

return React.createElement(
React.Fragment,
null,
Expand Down
2 changes: 1 addition & 1 deletion src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ const mapStateOnServer = (props: MappedServerState) => {
} = props;
let { linkTags, metaTags, scriptTags } = props;
let priorityMethods = {
toComponent: () => {},
toComponent: (): React.ReactElement[] => [],
toString: () => '',
};
if (prioritizeSeoTags) {
Expand Down
Loading