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)))))

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ export function VariableDeclaration(node, context) {
4242
continue;
4343
}
4444

45+
if (rune === '$props.id') {
46+
// skip
47+
continue;
48+
}
49+
4550
if (rune === '$props') {
4651
/** @type {string[]} */
4752
const seen = ['$$slots', '$$events', '$$legacy'];

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,12 @@ export function build_template_chunk(
129129
if (value.right.value === null) {
130130
value = { ...value, right: b.literal('') };
131131
}
132+
} else if (
133+
state.analysis.props_id &&
134+
value.type === 'Identifier' &&
135+
value.name === state.analysis.props_id.name
136+
) {
137+
// do nothing ($props.id() is never null/undefined)
132138
} else {
133139
value = b.logical('??', value, b.literal(''));
134140
}

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,13 @@ export function server_component(analysis, options) {
244244
.../** @type {Statement[]} */ (template.body)
245245
]);
246246

247+
if (analysis.props_id) {
248+
// need to be placed on first line of the component for hydration
249+
component_block.body.unshift(
250+
b.const(analysis.props_id, b.call('$.props_id', b.id('$$payload')))
251+
);
252+
}
253+
247254
let should_inject_context = dev || analysis.needs_context;
248255

249256
if (should_inject_context) {

packages/svelte/src/compiler/phases/3-transform/server/visitors/VariableDeclaration.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@ export function VariableDeclaration(node, context) {
2424
continue;
2525
}
2626

27+
if (rune === '$props.id') {
28+
// skip
29+
continue;
30+
}
31+
2732
if (rune === '$props') {
2833
let has_rest = false;
2934
// remove $bindable() from props declaration
@@ -156,6 +161,10 @@ export function VariableDeclaration(node, context) {
156161
}
157162
}
158163

164+
if (declarations.length === 0) {
165+
return b.empty;
166+
}
167+
159168
return {
160169
...node,
161170
declarations

packages/svelte/src/compiler/phases/types.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ export interface ComponentAnalysis extends Analysis {
4444
exports: Array<{ name: string; alias: string | null }>;
4545
/** Whether the component uses `$$props` */
4646
uses_props: boolean;
47+
/** The component ID variable name, if any */
48+
props_id: Identifier | null;
4749
/** Whether the component uses `$$restProps` */
4850
uses_rest_props: boolean;
4951
/** Whether the component uses `$$slots` */

packages/svelte/src/internal/client/dom/template.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,3 +249,23 @@ export function append(anchor, dom) {
249249

250250
anchor.before(/** @type {Node} */ (dom));
251251
}
252+
253+
let uid = 1;
254+
255+
/**
256+
* Create (or hydrate) an unique UID for the component instance.
257+
*/
258+
export function props_id() {
259+
if (
260+
hydrating &&
261+
hydrate_node &&
262+
hydrate_node.nodeType === 8 &&
263+
hydrate_node.textContent?.startsWith('#s')
264+
) {
265+
const id = hydrate_node.textContent.substring(1);
266+
hydrate_next();
267+
return id;
268+
}
269+
270+
return 'c' + uid++;
271+
}

packages/svelte/src/internal/client/index.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,8 @@ export {
9696
mathml_template,
9797
template,
9898
template_with_script,
99-
text
99+
text,
100+
props_id
100101
} from './dom/template.js';
101102
export { derived, derived_safe_equal } from './reactivity/deriveds.js';
102103
export {

packages/svelte/src/internal/server/index.js

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,15 @@ const INVALID_ATTR_NAME_CHAR_REGEX =
2828
* @param {Payload} to_copy
2929
* @returns {Payload}
3030
*/
31-
export function copy_payload({ out, css, head }) {
31+
export function copy_payload({ out, css, head, uid }) {
3232
return {
3333
out,
3434
css: new Set(css),
3535
head: {
3636
title: head.title,
3737
out: head.out
38-
}
38+
},
39+
uid
3940
};
4041
}
4142

@@ -48,6 +49,7 @@ export function copy_payload({ out, css, head }) {
4849
export function assign_payload(p1, p2) {
4950
p1.out = p2.out;
5051
p1.head = p2.head;
52+
p1.uid = p2.uid;
5153
}
5254

5355
/**
@@ -83,17 +85,27 @@ export function element(payload, tag, attributes_fn = noop, children_fn = noop)
8385
*/
8486
export let on_destroy = [];
8587

88+
function props_id_generator() {
89+
let uid = 1;
90+
return () => 's' + uid++;
91+
}
92+
8693
/**
8794
* Only available on the server and when compiling with the `server` option.
8895
* Takes a component and returns an object with `body` and `head` properties on it, which you can use to populate the HTML when server-rendering your app.
8996
* @template {Record<string, any>} Props
9097
* @param {import('svelte').Component<Props> | ComponentType<SvelteComponent<Props>>} component
91-
* @param {{ props?: Omit<Props, '$$slots' | '$$events'>; context?: Map<any, any> }} [options]
98+
* @param {{ props?: Omit<Props, '$$slots' | '$$events'>; context?: Map<any, any>, uid?: () => string }} [options]
9299
* @returns {RenderOutput}
93100
*/
94101
export function render(component, options = {}) {
95102
/** @type {Payload} */
96-
const payload = { out: '', css: new Set(), head: { title: '', out: '' } };
103+
const payload = {
104+
out: '',
105+
css: new Set(),
106+
head: { title: '', out: '' },
107+
uid: options.uid ?? props_id_generator()
108+
};
97109

98110
const prev_on_destroy = on_destroy;
99111
on_destroy = [];
@@ -526,6 +538,17 @@ export function once(get_value) {
526538
};
527539
}
528540

541+
/**
542+
* Create an unique ID
543+
* @param {Payload} payload
544+
* @returns {string}
545+
*/
546+
export function props_id(payload) {
547+
const uid = payload.uid();
548+
payload.out += '<!--#' + uid + '-->';
549+
return uid;
550+
}
551+
529552
export { attr, clsx };
530553

531554
export { html } from './blocks/html.js';

packages/svelte/src/internal/server/types.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ export interface Payload {
1818
title: string;
1919
out: string;
2020
};
21+
/** Function that generates a unique ID */
22+
uid: () => string;
2123
}
2224

2325
export interface RenderOutput {

packages/svelte/src/utils.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,7 @@ const RUNES = /** @type {const} */ ([
433433
'$state.raw',
434434
'$state.snapshot',
435435
'$props',
436+
'$props.id',
436437
'$bindable',
437438
'$derived',
438439
'$derived.by',
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<script>
2+
let id = $props.id();
3+
</script>
4+
5+
<p>{id}</p>

0 commit comments

Comments
 (0)