summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGravatar Robin Neal <robinneal429@gmail.com> 2023-05-04 15:23:00 +0100
committerGravatar GitHub <noreply@github.com> 2023-05-04 15:23:00 +0100
commitca329bbcae7a6075af4f428f6f64466e9d152c8f (patch)
tree0139a99c0cc60b22758e75ebb519893a5447ebb2
parentdfb9e4270a7c05c292a549777408278fcbe162ab (diff)
downloadastro-ca329bbcae7a6075af4f428f6f64466e9d152c8f.tar.gz
astro-ca329bbcae7a6075af4f428f6f64466e9d152c8f.tar.zst
astro-ca329bbcae7a6075af4f428f6f64466e9d152c8f.zip
Generate unique ids within each React island (#6976)
-rw-r--r--.changeset/happy-ears-call.md5
-rw-r--r--packages/astro/test/fixtures/react-component/src/components/WithId.jsx6
-rw-r--r--packages/astro/test/fixtures/react-component/src/pages/index.astro3
-rw-r--r--packages/astro/test/react-component.test.js9
-rw-r--r--packages/integrations/react/client.js7
-rw-r--r--packages/integrations/react/context.js24
-rw-r--r--packages/integrations/react/server.js31
7 files changed, 71 insertions, 14 deletions
diff --git a/.changeset/happy-ears-call.md b/.changeset/happy-ears-call.md
new file mode 100644
index 000000000..42171e51b
--- /dev/null
+++ b/.changeset/happy-ears-call.md
@@ -0,0 +1,5 @@
+---
+'@astrojs/react': patch
+---
+
+Prevent ID collisions in React.useId
diff --git a/packages/astro/test/fixtures/react-component/src/components/WithId.jsx b/packages/astro/test/fixtures/react-component/src/components/WithId.jsx
new file mode 100644
index 000000000..0abe91c72
--- /dev/null
+++ b/packages/astro/test/fixtures/react-component/src/components/WithId.jsx
@@ -0,0 +1,6 @@
+import React from 'react';
+
+export default function () {
+ const id = React.useId();
+ return <p className='react-use-id' id={id}>{id}</p>;
+}
diff --git a/packages/astro/test/fixtures/react-component/src/pages/index.astro b/packages/astro/test/fixtures/react-component/src/pages/index.astro
index abd3d4299..3afd8233f 100644
--- a/packages/astro/test/fixtures/react-component/src/pages/index.astro
+++ b/packages/astro/test/fixtures/react-component/src/pages/index.astro
@@ -8,6 +8,7 @@ import Pure from '../components/Pure.jsx';
import TypeScriptComponent from '../components/TypeScriptComponent';
import CloneElement from '../components/CloneElement';
import WithChildren from '../components/WithChildren';
+import WithId from '../components/WithId';
const someProps = {
text: 'Hello world!',
@@ -34,5 +35,7 @@ const someProps = {
<CloneElement />
<WithChildren client:load>test</WithChildren>
<WithChildren client:load children="test" />
+ <WithId client:idle />
+ <WithId client:idle />
</body>
</html>
diff --git a/packages/astro/test/react-component.test.js b/packages/astro/test/react-component.test.js
index 7205b0342..3565342c2 100644
--- a/packages/astro/test/react-component.test.js
+++ b/packages/astro/test/react-component.test.js
@@ -42,16 +42,21 @@ describe('React Components', () => {
expect($('#pure')).to.have.lengthOf(1);
// test 8: Check number of islands
- expect($('astro-island[uid]')).to.have.lengthOf(7);
+ expect($('astro-island[uid]')).to.have.lengthOf(9);
// test 9: Check island deduplication
const uniqueRootUIDs = new Set($('astro-island').map((i, el) => $(el).attr('uid')));
- expect(uniqueRootUIDs.size).to.equal(6);
+ expect(uniqueRootUIDs.size).to.equal(8);
// test 10: Should properly render children passed as props
const islandsWithChildren = $('.with-children');
expect(islandsWithChildren).to.have.lengthOf(2);
expect($(islandsWithChildren[0]).html()).to.equal($(islandsWithChildren[1]).html());
+
+ // test 11: Should generate unique React.useId per island
+ const islandsWithId = $('.react-use-id');
+ expect(islandsWithId).to.have.lengthOf(2);
+ expect($(islandsWithId[0]).attr('id')).to.not.equal($(islandsWithId[1]).attr('id'))
});
it('Can load Vue', async () => {
diff --git a/packages/integrations/react/client.js b/packages/integrations/react/client.js
index 3807ab410..366d499e3 100644
--- a/packages/integrations/react/client.js
+++ b/packages/integrations/react/client.js
@@ -13,6 +13,9 @@ function isAlreadyHydrated(element) {
export default (element) =>
(Component, props, { default: children, ...slotted }, { client }) => {
if (!element.hasAttribute('ssr')) return;
+ const renderOptions = {
+ identifierPrefix: element.getAttribute('prefix')
+ }
for (const [key, value] of Object.entries(slotted)) {
props[key] = createElement(StaticHtml, { value, name: key });
}
@@ -28,10 +31,10 @@ export default (element) =>
}
if (client === 'only') {
return startTransition(() => {
- createRoot(element).render(componentEl);
+ createRoot(element, renderOptions).render(componentEl);
});
}
return startTransition(() => {
- hydrateRoot(element, componentEl);
+ hydrateRoot(element, componentEl, renderOptions);
});
};
diff --git a/packages/integrations/react/context.js b/packages/integrations/react/context.js
new file mode 100644
index 000000000..5d9b1d7b1
--- /dev/null
+++ b/packages/integrations/react/context.js
@@ -0,0 +1,24 @@
+const contexts = new WeakMap();
+
+const ID_PREFIX = 'r';
+
+function getContext(rendererContextResult) {
+ if (contexts.has(rendererContextResult)) {
+ return contexts.get(rendererContextResult);
+ }
+ const ctx = {
+ currentIndex: 0,
+ get id() {
+ return ID_PREFIX + this.currentIndex.toString();
+ },
+ };
+ contexts.set(rendererContextResult, ctx);
+ return ctx;
+}
+
+export function incrementId(rendererContextResult) {
+ const ctx = getContext(rendererContextResult)
+ const id = ctx.id;
+ ctx.currentIndex++;
+ return id;
+}
diff --git a/packages/integrations/react/server.js b/packages/integrations/react/server.js
index 01a135a9b..0d85984f9 100644
--- a/packages/integrations/react/server.js
+++ b/packages/integrations/react/server.js
@@ -1,6 +1,7 @@
import React from 'react';
import ReactDOM from 'react-dom/server';
import StaticHtml from './static-html.js';
+import { incrementId } from './context.js';
const slotName = (str) => str.trim().replace(/[-_]([a-z])/g, (_, w) => w.toUpperCase());
const reactTypeof = Symbol.for('react.element');
@@ -58,6 +59,12 @@ async function getNodeWritable() {
}
async function renderToStaticMarkup(Component, props, { default: children, ...slotted }, metadata) {
+ let prefix;
+ if (this && this.result) {
+ prefix = incrementId(this.result)
+ }
+ const attrs = { prefix };
+
delete props['class'];
const slots = {};
for (const [key, value] of Object.entries(slotted)) {
@@ -74,29 +81,33 @@ async function renderToStaticMarkup(Component, props, { default: children, ...sl
newProps.children = React.createElement(StaticHtml, { value: newChildren });
}
const vnode = React.createElement(Component, newProps);
+ const renderOptions = {
+ identifierPrefix: prefix
+ }
let html;
if (metadata && metadata.hydrate) {
if ('renderToReadableStream' in ReactDOM) {
- html = await renderToReadableStreamAsync(vnode);
+ html = await renderToReadableStreamAsync(vnode, renderOptions);
} else {
- html = await renderToPipeableStreamAsync(vnode);
+ html = await renderToPipeableStreamAsync(vnode, renderOptions);
}
} else {
if ('renderToReadableStream' in ReactDOM) {
- html = await renderToReadableStreamAsync(vnode);
+ html = await renderToReadableStreamAsync(vnode, renderOptions);
} else {
- html = await renderToStaticNodeStreamAsync(vnode);
+ html = await renderToStaticNodeStreamAsync(vnode, renderOptions);
}
}
- return { html };
+ return { html, attrs };
}
-async function renderToPipeableStreamAsync(vnode) {
+async function renderToPipeableStreamAsync(vnode, options) {
const Writable = await getNodeWritable();
let html = '';
return new Promise((resolve, reject) => {
let error = undefined;
let stream = ReactDOM.renderToPipeableStream(vnode, {
+ ...options,
onError(err) {
error = err;
reject(error);
@@ -118,11 +129,11 @@ async function renderToPipeableStreamAsync(vnode) {
});
}
-async function renderToStaticNodeStreamAsync(vnode) {
+async function renderToStaticNodeStreamAsync(vnode, options) {
const Writable = await getNodeWritable();
let html = '';
return new Promise((resolve, reject) => {
- let stream = ReactDOM.renderToStaticNodeStream(vnode);
+ let stream = ReactDOM.renderToStaticNodeStream(vnode, options);
stream.on('error', (err) => {
reject(err);
});
@@ -164,8 +175,8 @@ async function readResult(stream) {
}
}
-async function renderToReadableStreamAsync(vnode) {
- return await readResult(await ReactDOM.renderToReadableStream(vnode));
+async function renderToReadableStreamAsync(vnode, options) {
+ return await readResult(await ReactDOM.renderToReadableStream(vnode, options));
}
export default {