From 8d92831f08ef5659fb837f2100e83dd7b677390c Mon Sep 17 00:00:00 2001 From: Nathan Sarang-Walters Date: Tue, 30 Dec 2025 18:14:22 -0800 Subject: [PATCH 1/2] Fix useId in JSX renderer --- src/pretty.js | 42 +++++++++++++++++++++++++------- test/pretty.test.jsx | 57 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 8 deletions(-) diff --git a/src/pretty.js b/src/pretty.js index 5fc197db..fe630744 100644 --- a/src/pretty.js +++ b/src/pretty.js @@ -16,8 +16,16 @@ import { isDirty, unsetDirty } from './lib/util.js'; -import { COMMIT, DIFF, DIFFED, RENDER, SKIP_EFFECTS } from './lib/constants.js'; -import { options, Fragment } from 'preact'; +import { + COMMIT, + DIFF, + DIFFED, + RENDER, + SKIP_EFFECTS, + PARENT, + CHILDREN +} from './lib/constants.js'; +import { options, Fragment, h } from 'preact'; // components without names, kept as a hash for later comparison to return consistent UnnamedComponentXX names. const UNNAMED = []; @@ -47,8 +55,19 @@ export default function renderToStringPretty(vnode, context, opts, _inner) { const previousSkipEffects = options[SKIP_EFFECTS]; options[SKIP_EFFECTS] = true; + const parent = h(Fragment, null); + parent[CHILDREN] = [vnode]; + try { - return _renderToStringPretty(vnode, context || {}, opts, _inner); + return _renderToStringPretty( + vnode, + context || {}, + opts, + _inner, + false, + undefined, + parent + ); } finally { // options._commit, we don't schedule any effects in this library right now, // so we can pass an empty queue to this hook. @@ -64,7 +83,8 @@ function _renderToStringPretty( opts, inner, isSvgMode, - selectValue + selectValue, + parent ) { if (vnode == null || typeof vnode === 'boolean') { return ''; @@ -81,6 +101,7 @@ function _renderToStringPretty( if (Array.isArray(vnode)) { let rendered = ''; + parent[CHILDREN] = vnode; for (let i = 0; i < vnode.length; i++) { if (pretty && i > 0) rendered = rendered + '\n'; rendered = @@ -91,7 +112,8 @@ function _renderToStringPretty( opts, inner, isSvgMode, - selectValue + selectValue, + parent ); } return rendered; @@ -100,6 +122,7 @@ function _renderToStringPretty( // VNodes have {constructor:undefined} to prevent JSON injection: if (vnode.constructor !== undefined) return ''; + vnode[PARENT] = parent; if (options[DIFF]) options[DIFF](vnode); let nodeName = vnode.type, @@ -124,7 +147,8 @@ function _renderToStringPretty( opts, opts.shallowHighOrder !== false, isSvgMode, - selectValue + selectValue, + vnode ); } else { let rendered; @@ -203,7 +227,8 @@ function _renderToStringPretty( opts, opts.shallowHighOrder !== false, isSvgMode, - selectValue + selectValue, + vnode ); if (options[DIFFED]) options[DIFFED](vnode); @@ -384,7 +409,8 @@ function _renderToStringPretty( opts, true, childSvgMode, - selectValue + selectValue, + vnode ); if (shouldPrettyFormatChildren && !hasLarge && isLargeString(ret)) diff --git a/test/pretty.test.jsx b/test/pretty.test.jsx index e2c9fa4c..60f5aa5c 100644 --- a/test/pretty.test.jsx +++ b/test/pretty.test.jsx @@ -1,6 +1,7 @@ import basicRender from '../src/index.js'; import { render } from '../src/jsx.js'; import { h, Fragment } from 'preact'; +import { useId } from 'preact/hooks'; import { expect, describe, it } from 'vitest'; import { dedent, svgAttributes, htmlAttributes } from './utils.jsx'; @@ -267,4 +268,60 @@ describe('pretty', () => { } }); }); + + describe('useId', () => { + it('should produce unique IDs for sibling components', () => { + function Foo() { + const id = useId(); + return
; + } + + const App = () => { + return ( + <> + + + + ); + }; + + const html = render(); + // Each Foo component should have a unique ID + expect(html).to.contain('id="P0-0"'); + expect(html).to.contain('id="P0-1"'); + }); + + it('should produce unique IDs in nested components', () => { + function Child() { + const id = useId(); + return ; + } + + function Parent() { + const id = useId(); + return ( +
+ +
+ ); + } + + const App = () => { + return ( + <> + + + + ); + }; + + const html = render(); + // Should have 4 unique IDs total (2 Parents + 2 Children) + const idMatches = html.match(/id="P0-\d+"/g); + expect(idMatches).to.have.length(4); + // All IDs should be unique + const uniqueIds = new Set(idMatches); + expect(uniqueIds.size).to.equal(4); + }); + }); }); From d623187a1a1876928de412c45b1c8515f7a3ff82 Mon Sep 17 00:00:00 2001 From: Nathan Sarang-Walters Date: Tue, 30 Dec 2025 19:48:53 -0800 Subject: [PATCH 2/2] Add changeset --- .changeset/friendly-queens-juggle.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/friendly-queens-juggle.md diff --git a/.changeset/friendly-queens-juggle.md b/.changeset/friendly-queens-juggle.md new file mode 100644 index 00000000..b1ad2b83 --- /dev/null +++ b/.changeset/friendly-queens-juggle.md @@ -0,0 +1,5 @@ +--- +'preact-render-to-string': patch +--- + +Ensure `useId()` produces unique IDs when using the JSX renderer