Skip to content

Commit ca329bb

Browse files
authored
Generate unique ids within each React island (#6976)
1 parent dfb9e42 commit ca329bb

7 files changed

Lines changed: 71 additions & 14 deletions

File tree

.changeset/happy-ears-call.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@astrojs/react': patch
3+
---
4+
5+
Prevent ID collisions in React.useId
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import React from 'react';
2+
3+
export default function () {
4+
const id = React.useId();
5+
return <p className='react-use-id' id={id}>{id}</p>;
6+
}

packages/astro/test/fixtures/react-component/src/pages/index.astro

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import Pure from '../components/Pure.jsx';
88
import TypeScriptComponent from '../components/TypeScriptComponent';
99
import CloneElement from '../components/CloneElement';
1010
import WithChildren from '../components/WithChildren';
11+
import WithId from '../components/WithId';
1112
1213
const someProps = {
1314
text: 'Hello world!',
@@ -34,5 +35,7 @@ const someProps = {
3435
<CloneElement />
3536
<WithChildren client:load>test</WithChildren>
3637
<WithChildren client:load children="test" />
38+
<WithId client:idle />
39+
<WithId client:idle />
3740
</body>
3841
</html>

packages/astro/test/react-component.test.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,16 +42,21 @@ describe('React Components', () => {
4242
expect($('#pure')).to.have.lengthOf(1);
4343

4444
// test 8: Check number of islands
45-
expect($('astro-island[uid]')).to.have.lengthOf(7);
45+
expect($('astro-island[uid]')).to.have.lengthOf(9);
4646

4747
// test 9: Check island deduplication
4848
const uniqueRootUIDs = new Set($('astro-island').map((i, el) => $(el).attr('uid')));
49-
expect(uniqueRootUIDs.size).to.equal(6);
49+
expect(uniqueRootUIDs.size).to.equal(8);
5050

5151
// test 10: Should properly render children passed as props
5252
const islandsWithChildren = $('.with-children');
5353
expect(islandsWithChildren).to.have.lengthOf(2);
5454
expect($(islandsWithChildren[0]).html()).to.equal($(islandsWithChildren[1]).html());
55+
56+
// test 11: Should generate unique React.useId per island
57+
const islandsWithId = $('.react-use-id');
58+
expect(islandsWithId).to.have.lengthOf(2);
59+
expect($(islandsWithId[0]).attr('id')).to.not.equal($(islandsWithId[1]).attr('id'))
5560
});
5661

5762
it('Can load Vue', async () => {

packages/integrations/react/client.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ function isAlreadyHydrated(element) {
1313
export default (element) =>
1414
(Component, props, { default: children, ...slotted }, { client }) => {
1515
if (!element.hasAttribute('ssr')) return;
16+
const renderOptions = {
17+
identifierPrefix: element.getAttribute('prefix')
18+
}
1619
for (const [key, value] of Object.entries(slotted)) {
1720
props[key] = createElement(StaticHtml, { value, name: key });
1821
}
@@ -28,10 +31,10 @@ export default (element) =>
2831
}
2932
if (client === 'only') {
3033
return startTransition(() => {
31-
createRoot(element).render(componentEl);
34+
createRoot(element, renderOptions).render(componentEl);
3235
});
3336
}
3437
return startTransition(() => {
35-
hydrateRoot(element, componentEl);
38+
hydrateRoot(element, componentEl, renderOptions);
3639
});
3740
};
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
const contexts = new WeakMap();
2+
3+
const ID_PREFIX = 'r';
4+
5+
function getContext(rendererContextResult) {
6+
if (contexts.has(rendererContextResult)) {
7+
return contexts.get(rendererContextResult);
8+
}
9+
const ctx = {
10+
currentIndex: 0,
11+
get id() {
12+
return ID_PREFIX + this.currentIndex.toString();
13+
},
14+
};
15+
contexts.set(rendererContextResult, ctx);
16+
return ctx;
17+
}
18+
19+
export function incrementId(rendererContextResult) {
20+
const ctx = getContext(rendererContextResult)
21+
const id = ctx.id;
22+
ctx.currentIndex++;
23+
return id;
24+
}

packages/integrations/react/server.js

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React from 'react';
22
import ReactDOM from 'react-dom/server';
33
import StaticHtml from './static-html.js';
4+
import { incrementId } from './context.js';
45

56
const slotName = (str) => str.trim().replace(/[-_]([a-z])/g, (_, w) => w.toUpperCase());
67
const reactTypeof = Symbol.for('react.element');
@@ -58,6 +59,12 @@ async function getNodeWritable() {
5859
}
5960

6061
async function renderToStaticMarkup(Component, props, { default: children, ...slotted }, metadata) {
62+
let prefix;
63+
if (this && this.result) {
64+
prefix = incrementId(this.result)
65+
}
66+
const attrs = { prefix };
67+
6168
delete props['class'];
6269
const slots = {};
6370
for (const [key, value] of Object.entries(slotted)) {
@@ -74,29 +81,33 @@ async function renderToStaticMarkup(Component, props, { default: children, ...sl
7481
newProps.children = React.createElement(StaticHtml, { value: newChildren });
7582
}
7683
const vnode = React.createElement(Component, newProps);
84+
const renderOptions = {
85+
identifierPrefix: prefix
86+
}
7787
let html;
7888
if (metadata && metadata.hydrate) {
7989
if ('renderToReadableStream' in ReactDOM) {
80-
html = await renderToReadableStreamAsync(vnode);
90+
html = await renderToReadableStreamAsync(vnode, renderOptions);
8191
} else {
82-
html = await renderToPipeableStreamAsync(vnode);
92+
html = await renderToPipeableStreamAsync(vnode, renderOptions);
8393
}
8494
} else {
8595
if ('renderToReadableStream' in ReactDOM) {
86-
html = await renderToReadableStreamAsync(vnode);
96+
html = await renderToReadableStreamAsync(vnode, renderOptions);
8797
} else {
88-
html = await renderToStaticNodeStreamAsync(vnode);
98+
html = await renderToStaticNodeStreamAsync(vnode, renderOptions);
8999
}
90100
}
91-
return { html };
101+
return { html, attrs };
92102
}
93103

94-
async function renderToPipeableStreamAsync(vnode) {
104+
async function renderToPipeableStreamAsync(vnode, options) {
95105
const Writable = await getNodeWritable();
96106
let html = '';
97107
return new Promise((resolve, reject) => {
98108
let error = undefined;
99109
let stream = ReactDOM.renderToPipeableStream(vnode, {
110+
...options,
100111
onError(err) {
101112
error = err;
102113
reject(error);
@@ -118,11 +129,11 @@ async function renderToPipeableStreamAsync(vnode) {
118129
});
119130
}
120131

121-
async function renderToStaticNodeStreamAsync(vnode) {
132+
async function renderToStaticNodeStreamAsync(vnode, options) {
122133
const Writable = await getNodeWritable();
123134
let html = '';
124135
return new Promise((resolve, reject) => {
125-
let stream = ReactDOM.renderToStaticNodeStream(vnode);
136+
let stream = ReactDOM.renderToStaticNodeStream(vnode, options);
126137
stream.on('error', (err) => {
127138
reject(err);
128139
});
@@ -164,8 +175,8 @@ async function readResult(stream) {
164175
}
165176
}
166177

167-
async function renderToReadableStreamAsync(vnode) {
168-
return await readResult(await ReactDOM.renderToReadableStream(vnode));
178+
async function renderToReadableStreamAsync(vnode, options) {
179+
return await readResult(await ReactDOM.renderToReadableStream(vnode, options));
169180
}
170181

171182
export default {

0 commit comments

Comments
 (0)