Skip to content
Merged
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
8 changes: 7 additions & 1 deletion .github/workflows/pr-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,15 @@ jobs:
- name: Typescript check
run: npm run ts:check

- name: Test
- name: Test (React 18)
run: npm run test -- --coverage

- name: Install React 19
run: npm install --no-save --ignore-scripts react@19.2.8 react-dom@19.2.8

- name: Test (React 19)
run: npm test

- uses: actions/upload-artifact@v4
with:
name: coverage-lcov
Expand Down
75 changes: 74 additions & 1 deletion __tests__/server/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { Manager } from '../../src';
import ServerManager from '../../src/server';

describe('ServerManager', () => {
const containerId = 'container-id';

it('should inject meta tags into HTML string', () => {
const htmlStr =
'<html lang="en"><head><title>Test 1</title></head><body data-test="body-test"></body></html>';
Expand All @@ -21,7 +23,7 @@ describe('ServerManager', () => {
<meta data-test="2" />
<meta data-test="1" />
</>,
'container-id',
containerId,
);

const result = ServerManager.inject(htmlStr, manager);
Expand All @@ -31,6 +33,77 @@ describe('ServerManager', () => {
);
});

it.each([
{
source: 'manager',
htmlStr:
'<html lang="en"><head><title>Original</title></head><body class="original"></body></html>',
tags: (
<>
{/* eslint-disable-next-line jsx-a11y/html-has-lang -- lang comes from the input HTML. */}
<html dir="rtl" className="x" />
<body className="y" />
</>
),
},
{
source: 'input HTML',
htmlStr:
'<html lang="fr" dir="rtl" class="x"><head><title>Original</title></head><body class="y"></body></html>',
tags: <html lang="en" />,
},
])(
'should merge root attributes from $source and inject meta into a single head',
({ htmlStr, tags }) => {
const manager = new Manager();

manager.isServer = true;
manager.pushTags(tags, containerId);
manager.pushTags(
<>
<title>Changed</title>
<meta name="description" content="Inside the head" />
</>,
containerId,
);

const result = ServerManager.inject(htmlStr, manager);

expect(result.match(/<head/g)).to.have.lengthOf(1);
expect(result).to.equal(
'<html lang="en" dir="rtl" class="x"><head><title>Changed</title><meta name="description" content="Inside the head"/></head><body class="y"></body></html>',
);
},
);

it('should preserve React attribute serialization on root tags', () => {
const htmlStr =
'<html title="A &amp; &quot;B&quot; &lt;C&gt; &#x27;D&#x27;" style="margin-top:2px"><head>\n<title>Test</title></head><body class="original" hidden style="padding-top:3px"></body></html>';
const manager = new Manager();

manager.isServer = true;
manager.pushTags(
<>
<html lang="en" className="x" hidden draggable={false} />
<body
className="y"
hidden={false}
style={{ marginTop: 4, lineHeight: 1.5 }}
data-label={'A & "B" <C> \'D\''}
aria-hidden={false}
/>
</>,
containerId,
);

const result = ServerManager.inject(htmlStr, manager);

expect(result.match(/<head/g)).to.have.lengthOf(1);
expect(result).to.equal(
'<html title="A &amp; &quot;B&quot; &lt;C&gt; &#x27;D&#x27;" style="margin-top:2px" lang="en" class="x" hidden="" draggable="false"><head><title>Test</title></head><body class="y" style="margin-top:4px;line-height:1.5" data-label="A &amp; &quot;B&quot; &lt;C&gt; &#x27;D&#x27;" aria-hidden="false"></body></html>',
);
});

it('should return meta manager state', () => {
const manager = new Manager();

Expand Down
17 changes: 11 additions & 6 deletions src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,17 @@ class ServerManager {
const htmlMeta = ReactDOMServer.renderToString(
[...meta.values()].map(({ element }) => element) as unknown as ReactElement,
);
const [htmlTagWithProps] = ReactDOMServer.renderToString(
React.createElement('html', manager.getRootTagProps(html)),
).split('</html>');
const [bodyTagWithProps] = ReactDOMServer.renderToString(
React.createElement('body', manager.getRootTagProps(body)),
).split('</body>');
// Render a neutral element so React 19 does not insert document structure.
const [htmlTagWithProps] = ReactDOMServer.renderToStaticMarkup(
React.createElement('div', manager.getRootTagProps(html)),
)
.replace(/^<div/, '<html')
.split('</div>');
const [bodyTagWithProps] = ReactDOMServer.renderToStaticMarkup(
React.createElement('div', manager.getRootTagProps(body)),
)
.replace(/^<div/, '<body')
.split('</div>');

return htmlStr
.replace(/<head[^/].+?>?(?<meta>.+)<\/head>/s, `<head>${htmlMeta}</head>`)
Expand Down
Loading