Skip to content

Commit 85f83ec

Browse files
adigubaRich-Harris
andauthored
feat: $props.id(), a SSR-safe ID generation (#15185)
* first impl of $$uid * fix * $props.id() * fix errors * rename $.create_uid() into $.props_id() * fix message * relax const requirement, validate assignments instead * oops * simplify * non-constants should be lowercased * ditto * start at 1 * add docs * changeset * add test * add docs * doc : add code example * fix type reported by bennymi --------- Co-authored-by: Rich Harris <[email protected]>
1 parent 73220b8 commit 85f83ec

File tree

24 files changed

+272
-11
lines changed

24 files changed

+272
-11
lines changed

.changeset/hip-singers-vanish.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'svelte': minor
3+
---
4+
5+
feat: SSR-safe ID generation with `$props.id()`

documentation/docs/02-runes/05-$props.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,3 +199,24 @@ You can, of course, separate the type declaration from the annotation:
199199
> [!NOTE] Interfaces for native DOM elements are provided in the `svelte/elements` module (see [Typing wrapper components](typescript#Typing-wrapper-components))
200200
201201
Adding types is recommended, as it ensures that people using your component can easily discover which props they should provide.
202+
203+
204+
## `$props.id()`
205+
206+
This rune, added in version 5.20.0, generates an ID that is unique to the current component instance. When hydrating a server-rendered component, the value will be consistent between server and client.
207+
208+
This is useful for linking elements via attributes like `for` and `aria-labelledby`.
209+
210+
```svelte
211+
<script>
212+
const uid = $props.id();
213+
</script>
214+
215+
<form>
216+
<label for="{uid}-firstname">First Name: </label>
217+
<input id="{uid}-firstname" type="text" />
218+
219+
<label for="{uid}-lastname">Last Name: </label>
220+
<input id="{uid}-lastname" type="text" />
221+
</form>
222+
```

documentation/docs/98-reference/.generated/compile-errors.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -573,7 +573,13 @@ Unrecognised compiler option %keypath%
573573
### props_duplicate
574574

575575
```
576-
Cannot use `$props()` more than once
576+
Cannot use `%rune%()` more than once
577+
```
578+
579+
### props_id_invalid_placement
580+
581+
```
582+
`$props.id()` can only be used at the top level of components as a variable declaration initializer
577583
```
578584

579585
### props_illegal_name

packages/svelte/messages/compile-errors/script.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,11 @@ This turned out to be buggy and unpredictable, particularly when working with de
120120
121121
## props_duplicate
122122

123-
> Cannot use `$props()` more than once
123+
> Cannot use `%rune%()` more than once
124+
125+
## props_id_invalid_placement
126+
127+
> `$props.id()` can only be used at the top level of components as a variable declaration initializer
124128
125129
## props_illegal_name
126130

packages/svelte/src/ambient.d.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,15 @@ declare namespace $effect {
339339
declare function $props(): any;
340340

341341
declare namespace $props {
342+
/**
343+
* Generates an ID that is unique to the current component instance. When hydrating a server-rendered component,
344+
* the value will be consistent between server and client.
345+
*
346+
* This is useful for linking elements via attributes like `for` and `aria-labelledby`.
347+
* @since 5.20.0
348+
*/
349+
export function id(): string;
350+
342351
// prevent intellisense from being unhelpful
343352
/** @deprecated */
344353
export const apply: never;

packages/svelte/src/compiler/errors.js

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -279,12 +279,22 @@ export function module_illegal_default_export(node) {
279279
}
280280

281281
/**
282-
* Cannot use `$props()` more than once
282+
* Cannot use `%rune%()` more than once
283+
* @param {null | number | NodeLike} node
284+
* @param {string} rune
285+
* @returns {never}
286+
*/
287+
export function props_duplicate(node, rune) {
288+
e(node, 'props_duplicate', `Cannot use \`${rune}()\` more than once\nhttps://svelte.dev/e/props_duplicate`);
289+
}
290+
291+
/**
292+
* `$props.id()` can only be used at the top level of components as a variable declaration initializer
283293
* @param {null | number | NodeLike} node
284294
* @returns {never}
285295
*/
286-
export function props_duplicate(node) {
287-
e(node, 'props_duplicate', `Cannot use \`$props()\` more than once\nhttps://svelte.dev/e/props_duplicate`);
296+
export function props_id_invalid_placement(node) {
297+
e(node, 'props_id_invalid_placement', `\`$props.id()\` can only be used at the top level of components as a variable declaration initializer\nhttps://svelte.dev/e/props_id_invalid_placement`);
288298
}
289299

290300
/**

packages/svelte/src/compiler/phases/2-analyze/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,7 @@ export function analyze_component(root, source, options) {
416416
immutable: runes || options.immutable,
417417
exports: [],
418418
uses_props: false,
419+
props_id: null,
419420
uses_rest_props: false,
420421
uses_slots: false,
421422
uses_component_bindings: false,

packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ export function CallExpression(node, context) {
5555

5656
case '$props':
5757
if (context.state.has_props_rune) {
58-
e.props_duplicate(node);
58+
e.props_duplicate(node, rune);
5959
}
6060

6161
context.state.has_props_rune = true;
@@ -74,6 +74,32 @@ export function CallExpression(node, context) {
7474

7575
break;
7676

77+
case '$props.id': {
78+
const grand_parent = get_parent(context.path, -2);
79+
80+
if (context.state.analysis.props_id) {
81+
e.props_duplicate(node, rune);
82+
}
83+
84+
if (
85+
parent.type !== 'VariableDeclarator' ||
86+
parent.id.type !== 'Identifier' ||
87+
context.state.ast_type !== 'instance' ||
88+
context.state.scope !== context.state.analysis.instance.scope ||
89+
grand_parent.type !== 'VariableDeclaration'
90+
) {
91+
e.props_id_invalid_placement(node);
92+
}
93+
94+
if (node.arguments.length > 0) {
95+
e.rune_invalid_arguments(node, rune);
96+
}
97+
98+
context.state.analysis.props_id = parent.id;
99+
100+
break;
101+
}
102+
77103
case '$state':
78104
case '$state.raw':
79105
case '$derived':

packages/svelte/src/compiler/phases/2-analyze/visitors/shared/utils.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ export function validate_assignment(node, argument, state) {
2525
e.constant_assignment(node, 'derived state');
2626
}
2727

28+
if (binding?.node === state.analysis.props_id) {
29+
e.constant_assignment(node, '$props.id()');
30+
}
31+
2832
if (binding?.kind === 'each') {
2933
e.each_item_invalid_assignment(node);
3034
}

packages/svelte/src/compiler/phases/3-transform/client/transform-client.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -562,6 +562,11 @@ export function client_component(analysis, options) {
562562
component_block.body.unshift(b.stmt(b.call('$.check_target', b.id('new.target'))));
563563
}
564564

565+
if (analysis.props_id) {
566+
// need to be placed on first line of the component for hydration
567+
component_block.body.unshift(b.const(analysis.props_id, b.call('$.props_id')));
568+
}
569+
565570
if (state.events.size > 0) {
566571
body.push(
567572
b.stmt(b.call('$.delegate', b.array(Array.from(state.events).map((name) => b.literal(name)))))

0 commit comments

Comments
 (0)