Use valid CSS selectors in useId format (#32001)

For the `useId` algorithm we used colon `:` before and after.
https://github.com/facebook/react/pull/23360

This avoids collisions in general by using an unusual characters. It
also avoids collisions when concatenated with some other ID.
Unfortunately, `:` is not a valid character in `view-transition-name`.

This PR swaps the format from:

```
:r123:
```

To the unicode:

```
«r123»
```

Which is valid CSS selectors. This also allows them being used for
`querySelector()` which we didn't really find a legit use for but seems
ok-ish.

That way you can get a view-transition-name that you can manually
reference. E.g. to generate styles:

```js
const id = useId();
return <>
  <style>{`
    ::view-transition-group(${id}) { ... }
    ::view-transition-old(${id}) { ... }
    ::view-transition-new(${id}) { ... }
  `}</style>
  <ViewTransition name={id}>...</ViewTransition>
</>;
```
This commit is contained in:
Sebastian Markbåge 2025-02-25 12:45:18 -05:00 committed by GitHub
parent d42a90cf4f
commit 2e4db3344f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 26 additions and 21 deletions

View File

@ -1553,7 +1553,7 @@ describe('ReactHooksInspectionIntegration', () => {
expect(tree[0].id).toEqual(0); expect(tree[0].id).toEqual(0);
expect(tree[0].isStateEditable).toEqual(false); expect(tree[0].isStateEditable).toEqual(false);
expect(tree[0].name).toEqual('Id'); expect(tree[0].name).toEqual('Id');
expect(String(tree[0].value).startsWith(':r')).toBe(true); expect(String(tree[0].value).startsWith('\u00ABr')).toBe(true);
expect(normalizeSourceLoc(tree)[1]).toMatchInlineSnapshot(` expect(normalizeSourceLoc(tree)[1]).toMatchInlineSnapshot(`
{ {

View File

@ -858,7 +858,7 @@ export function makeId(
): string { ): string {
const idPrefix = resumableState.idPrefix; const idPrefix = resumableState.idPrefix;
let id = ':' + idPrefix + 'R' + treeId; let id = '\u00AB' + idPrefix + 'R' + treeId;
// Unless this is the first id at this level, append a number at the end // Unless this is the first id at this level, append a number at the end
// that represents the position of this useId hook among all the useId // that represents the position of this useId hook among all the useId
@ -867,7 +867,7 @@ export function makeId(
id += 'H' + localId.toString(32); id += 'H' + localId.toString(32);
} }
return id + ':'; return id + '\u00BB';
} }
function encodeHTMLTextNode(text: string): string { function encodeHTMLTextNode(text: string): string {

View File

@ -96,7 +96,7 @@ describe('useId', () => {
} }
function normalizeTreeIdForTesting(id) { function normalizeTreeIdForTesting(id) {
const result = id.match(/:(R|r)([a-z0-9]*)(H([0-9]*))?:/); const result = id.match(/\u00AB(R|r)([a-z0-9]*)(H([0-9]*))?\u00BB/);
if (result === undefined) { if (result === undefined) {
throw new Error('Invalid id format'); throw new Error('Invalid id format');
} }
@ -285,7 +285,7 @@ describe('useId', () => {
// 'R:' prefix, and the first character after that, which may not correspond // 'R:' prefix, and the first character after that, which may not correspond
// to a complete set of 5 bits. // to a complete set of 5 bits.
// //
// Example: :Rclalalalalalalala...: // Example: «Rclalalalalalalala...:
// //
// We can use this pattern to test large ids that exceed the bitwise // We can use this pattern to test large ids that exceed the bitwise
// safe range (32 bits). The algorithm should theoretically support ids // safe range (32 bits). The algorithm should theoretically support ids
@ -320,8 +320,8 @@ describe('useId', () => {
// Confirm that every id matches the expected pattern // Confirm that every id matches the expected pattern
for (let i = 0; i < divs.length; i++) { for (let i = 0; i < divs.length; i++) {
// Example: :Rclalalalalalalala...: // Example: «Rclalalalalalalala...:
expect(divs[i].id).toMatch(/^:R.(((al)*a?)((la)*l?))*:$/); expect(divs[i].id).toMatch(/^\u00ABR.(((al)*a?)((la)*l?))*\u00BB$/);
} }
}); });
@ -345,7 +345,7 @@ describe('useId', () => {
<div <div
id="container" id="container"
> >
:R0:, :R0H1:, :R0H2: «R0», «R0H1», «R0H2»
</div> </div>
`); `);
}); });
@ -370,7 +370,7 @@ describe('useId', () => {
<div <div
id="container" id="container"
> >
:R0: «R0»
</div> </div>
`); `);
}); });
@ -608,10 +608,10 @@ describe('useId', () => {
id="container" id="container"
> >
<div> <div>
:custom-prefix-R1: «custom-prefix-R1»
</div> </div>
<div> <div>
:custom-prefix-R2: «custom-prefix-R2»
</div> </div>
</div> </div>
`); `);
@ -625,13 +625,13 @@ describe('useId', () => {
id="container" id="container"
> >
<div> <div>
:custom-prefix-R1: «custom-prefix-R1»
</div> </div>
<div> <div>
:custom-prefix-R2: «custom-prefix-R2»
</div> </div>
<div> <div>
:custom-prefix-r0: «custom-prefix-r0»
</div> </div>
</div> </div>
`); `);
@ -672,11 +672,11 @@ describe('useId', () => {
id="container" id="container"
> >
<div> <div>
:R0: «R0»
<!-- --> <!-- -->
<div> <div>
:R7: «R7»
</div> </div>
</div> </div>
</div> </div>
@ -690,11 +690,11 @@ describe('useId', () => {
id="container" id="container"
> >
<div> <div>
:R0: «R0»
<!-- --> <!-- -->
<div> <div>
:R7: «R7»
</div> </div>
</div> </div>
</div> </div>

View File

@ -3595,7 +3595,7 @@ function mountId(): string {
const treeId = getTreeId(); const treeId = getTreeId();
// Use a captial R prefix for server-generated ids. // Use a captial R prefix for server-generated ids.
id = ':' + identifierPrefix + 'R' + treeId; id = '\u00AB' + identifierPrefix + 'R' + treeId;
// Unless this is the first id at this level, append a number at the end // Unless this is the first id at this level, append a number at the end
// that represents the position of this useId hook among all the useId // that represents the position of this useId hook among all the useId
@ -3605,11 +3605,16 @@ function mountId(): string {
id += 'H' + localId.toString(32); id += 'H' + localId.toString(32);
} }
id += ':'; id += '\u00BB';
} else { } else {
// Use a lowercase r prefix for client-generated ids. // Use a lowercase r prefix for client-generated ids.
const globalClientId = globalClientIdCounter++; const globalClientId = globalClientIdCounter++;
id = ':' + identifierPrefix + 'r' + globalClientId.toString(32) + ':'; id =
'\u00AB' +
identifierPrefix +
'r' +
globalClientId.toString(32) +
'\u00BB';
} }
hook.memoizedState = id; hook.memoizedState = id;