From 2e5d97e796fa17082b09d109e5aa5c5b866d636f Mon Sep 17 00:00:00 2001 From: Mikhail Yarmaliuk Date: Sat, 5 Sep 2026 01:22:01 -0700 Subject: [PATCH] fix: render root tag attributes without a document element on react 19 --- .github/workflows/pr-check.yml | 8 +++- __tests__/server/index.tsx | 75 +++++++++++++++++++++++++++++++++- src/server/index.ts | 17 +++++--- 3 files changed, 92 insertions(+), 8 deletions(-) diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index a7ef837..bd02ffd 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -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 diff --git a/__tests__/server/index.tsx b/__tests__/server/index.tsx index 840cc04..034dcd3 100644 --- a/__tests__/server/index.tsx +++ b/__tests__/server/index.tsx @@ -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 = 'Test 1'; @@ -21,7 +23,7 @@ describe('ServerManager', () => { , - 'container-id', + containerId, ); const result = ServerManager.inject(htmlStr, manager); @@ -31,6 +33,77 @@ describe('ServerManager', () => { ); }); + it.each([ + { + source: 'manager', + htmlStr: + 'Original', + tags: ( + <> + {/* eslint-disable-next-line jsx-a11y/html-has-lang -- lang comes from the input HTML. */} + + + + ), + }, + { + source: 'input HTML', + htmlStr: + 'Original', + tags: , + }, + ])( + '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( + <> + Changed + + , + containerId, + ); + + const result = ServerManager.inject(htmlStr, manager); + + expect(result.match(/Changed', + ); + }, + ); + + it('should preserve React attribute serialization on root tags', () => { + const htmlStr = + '\nTest'; + const manager = new Manager(); + + manager.isServer = true; + manager.pushTags( + <> + + \'D\''} + aria-hidden={false} + /> + , + containerId, + ); + + const result = ServerManager.inject(htmlStr, manager); + + expect(result.match(/Test', + ); + }); + it('should return meta manager state', () => { const manager = new Manager(); diff --git a/src/server/index.ts b/src/server/index.ts index eb243a8..abfef10 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -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(''); - const [bodyTagWithProps] = ReactDOMServer.renderToString( - React.createElement('body', manager.getRootTagProps(body)), - ).split(''); + // Render a neutral element so React 19 does not insert document structure. + const [htmlTagWithProps] = ReactDOMServer.renderToStaticMarkup( + React.createElement('div', manager.getRootTagProps(html)), + ) + .replace(/^
'); + const [bodyTagWithProps] = ReactDOMServer.renderToStaticMarkup( + React.createElement('div', manager.getRootTagProps(body)), + ) + .replace(/^
'); return htmlStr .replace(/?(?.+)<\/head>/s, `${htmlMeta}`)