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 <rich.harris@vercel.com>
pull/15274/head
adiGuba 8 months ago committed by GitHub
parent 73220b8667
commit 85f83ec435
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': minor
---
feat: SSR-safe ID generation with `$props.id()`

@ -199,3 +199,24 @@ You can, of course, separate the type declaration from the annotation:
> [!NOTE] Interfaces for native DOM elements are provided in the `svelte/elements` module (see [Typing wrapper components](typescript#Typing-wrapper-components)) > [!NOTE] Interfaces for native DOM elements are provided in the `svelte/elements` module (see [Typing wrapper components](typescript#Typing-wrapper-components))
Adding types is recommended, as it ensures that people using your component can easily discover which props they should provide. Adding types is recommended, as it ensures that people using your component can easily discover which props they should provide.
## `$props.id()`
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.
This is useful for linking elements via attributes like `for` and `aria-labelledby`.
```svelte
<script>
const uid = $props.id();
</script>
<form>
<label for="{uid}-firstname">First Name: </label>
<input id="{uid}-firstname" type="text" />
<label for="{uid}-lastname">Last Name: </label>
<input id="{uid}-lastname" type="text" />
</form>
```

@ -573,7 +573,13 @@ Unrecognised compiler option %keypath%
### props_duplicate ### props_duplicate
``` ```
Cannot use `$props()` more than once Cannot use `%rune%()` more than once
```
### props_id_invalid_placement
```
`$props.id()` can only be used at the top level of components as a variable declaration initializer
``` ```
### props_illegal_name ### props_illegal_name

@ -120,7 +120,11 @@ This turned out to be buggy and unpredictable, particularly when working with de
## props_duplicate ## props_duplicate
> Cannot use `$props()` more than once > Cannot use `%rune%()` more than once
## props_id_invalid_placement
> `$props.id()` can only be used at the top level of components as a variable declaration initializer
## props_illegal_name ## props_illegal_name

@ -339,6 +339,15 @@ declare namespace $effect {
declare function $props(): any; declare function $props(): any;
declare namespace $props { declare namespace $props {
/**
* 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.
*
* This is useful for linking elements via attributes like `for` and `aria-labelledby`.
* @since 5.20.0
*/
export function id(): string;
// prevent intellisense from being unhelpful // prevent intellisense from being unhelpful
/** @deprecated */ /** @deprecated */
export const apply: never; export const apply: never;

@ -279,12 +279,22 @@ export function module_illegal_default_export(node) {
} }
/** /**
* Cannot use `$props()` more than once * Cannot use `%rune%()` more than once
* @param {null | number | NodeLike} node
* @param {string} rune
* @returns {never}
*/
export function props_duplicate(node, rune) {
e(node, 'props_duplicate', `Cannot use \`${rune}()\` more than once\nhttps://svelte.dev/e/props_duplicate`);
}
/**
* `$props.id()` can only be used at the top level of components as a variable declaration initializer
* @param {null | number | NodeLike} node * @param {null | number | NodeLike} node
* @returns {never} * @returns {never}
*/ */
export function props_duplicate(node) { export function props_id_invalid_placement(node) {
e(node, 'props_duplicate', `Cannot use \`$props()\` more than once\nhttps://svelte.dev/e/props_duplicate`); 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`);
} }
/** /**

@ -416,6 +416,7 @@ export function analyze_component(root, source, options) {
immutable: runes || options.immutable, immutable: runes || options.immutable,
exports: [], exports: [],
uses_props: false, uses_props: false,
props_id: null,
uses_rest_props: false, uses_rest_props: false,
uses_slots: false, uses_slots: false,
uses_component_bindings: false, uses_component_bindings: false,

@ -55,7 +55,7 @@ export function CallExpression(node, context) {
case '$props': case '$props':
if (context.state.has_props_rune) { if (context.state.has_props_rune) {
e.props_duplicate(node); e.props_duplicate(node, rune);
} }
context.state.has_props_rune = true; context.state.has_props_rune = true;
@ -74,6 +74,32 @@ export function CallExpression(node, context) {
break; break;
case '$props.id': {
const grand_parent = get_parent(context.path, -2);
if (context.state.analysis.props_id) {
e.props_duplicate(node, rune);
}
if (
parent.type !== 'VariableDeclarator' ||
parent.id.type !== 'Identifier' ||
context.state.ast_type !== 'instance' ||
context.state.scope !== context.state.analysis.instance.scope ||
grand_parent.type !== 'VariableDeclaration'
) {
e.props_id_invalid_placement(node);
}
if (node.arguments.length > 0) {
e.rune_invalid_arguments(node, rune);
}
context.state.analysis.props_id = parent.id;
break;
}
case '$state': case '$state':
case '$state.raw': case '$state.raw':
case '$derived': case '$derived':

@ -25,6 +25,10 @@ export function validate_assignment(node, argument, state) {
e.constant_assignment(node, 'derived state'); e.constant_assignment(node, 'derived state');
} }
if (binding?.node === state.analysis.props_id) {
e.constant_assignment(node, '$props.id()');
}
if (binding?.kind === 'each') { if (binding?.kind === 'each') {
e.each_item_invalid_assignment(node); e.each_item_invalid_assignment(node);
} }

@ -562,6 +562,11 @@ export function client_component(analysis, options) {
component_block.body.unshift(b.stmt(b.call('$.check_target', b.id('new.target')))); component_block.body.unshift(b.stmt(b.call('$.check_target', b.id('new.target'))));
} }
if (analysis.props_id) {
// need to be placed on first line of the component for hydration
component_block.body.unshift(b.const(analysis.props_id, b.call('$.props_id')));
}
if (state.events.size > 0) { if (state.events.size > 0) {
body.push( body.push(
b.stmt(b.call('$.delegate', b.array(Array.from(state.events).map((name) => b.literal(name))))) b.stmt(b.call('$.delegate', b.array(Array.from(state.events).map((name) => b.literal(name)))))

@ -42,6 +42,11 @@ export function VariableDeclaration(node, context) {
continue; continue;
} }
if (rune === '$props.id') {
// skip
continue;
}
if (rune === '$props') { if (rune === '$props') {
/** @type {string[]} */ /** @type {string[]} */
const seen = ['$$slots', '$$events', '$$legacy']; const seen = ['$$slots', '$$events', '$$legacy'];

@ -129,6 +129,12 @@ export function build_template_chunk(
if (value.right.value === null) { if (value.right.value === null) {
value = { ...value, right: b.literal('') }; value = { ...value, right: b.literal('') };
} }
} else if (
state.analysis.props_id &&
value.type === 'Identifier' &&
value.name === state.analysis.props_id.name
) {
// do nothing ($props.id() is never null/undefined)
} else { } else {
value = b.logical('??', value, b.literal('')); value = b.logical('??', value, b.literal(''));
} }

@ -244,6 +244,13 @@ export function server_component(analysis, options) {
.../** @type {Statement[]} */ (template.body) .../** @type {Statement[]} */ (template.body)
]); ]);
if (analysis.props_id) {
// need to be placed on first line of the component for hydration
component_block.body.unshift(
b.const(analysis.props_id, b.call('$.props_id', b.id('$$payload')))
);
}
let should_inject_context = dev || analysis.needs_context; let should_inject_context = dev || analysis.needs_context;
if (should_inject_context) { if (should_inject_context) {

@ -24,6 +24,11 @@ export function VariableDeclaration(node, context) {
continue; continue;
} }
if (rune === '$props.id') {
// skip
continue;
}
if (rune === '$props') { if (rune === '$props') {
let has_rest = false; let has_rest = false;
// remove $bindable() from props declaration // remove $bindable() from props declaration
@ -156,6 +161,10 @@ export function VariableDeclaration(node, context) {
} }
} }
if (declarations.length === 0) {
return b.empty;
}
return { return {
...node, ...node,
declarations declarations

@ -44,6 +44,8 @@ export interface ComponentAnalysis extends Analysis {
exports: Array<{ name: string; alias: string | null }>; exports: Array<{ name: string; alias: string | null }>;
/** Whether the component uses `$$props` */ /** Whether the component uses `$$props` */
uses_props: boolean; uses_props: boolean;
/** The component ID variable name, if any */
props_id: Identifier | null;
/** Whether the component uses `$$restProps` */ /** Whether the component uses `$$restProps` */
uses_rest_props: boolean; uses_rest_props: boolean;
/** Whether the component uses `$$slots` */ /** Whether the component uses `$$slots` */

@ -249,3 +249,23 @@ export function append(anchor, dom) {
anchor.before(/** @type {Node} */ (dom)); anchor.before(/** @type {Node} */ (dom));
} }
let uid = 1;
/**
* Create (or hydrate) an unique UID for the component instance.
*/
export function props_id() {
if (
hydrating &&
hydrate_node &&
hydrate_node.nodeType === 8 &&
hydrate_node.textContent?.startsWith('#s')
) {
const id = hydrate_node.textContent.substring(1);
hydrate_next();
return id;
}
return 'c' + uid++;
}

@ -96,7 +96,8 @@ export {
mathml_template, mathml_template,
template, template,
template_with_script, template_with_script,
text text,
props_id
} from './dom/template.js'; } from './dom/template.js';
export { derived, derived_safe_equal } from './reactivity/deriveds.js'; export { derived, derived_safe_equal } from './reactivity/deriveds.js';
export { export {

@ -28,14 +28,15 @@ const INVALID_ATTR_NAME_CHAR_REGEX =
* @param {Payload} to_copy * @param {Payload} to_copy
* @returns {Payload} * @returns {Payload}
*/ */
export function copy_payload({ out, css, head }) { export function copy_payload({ out, css, head, uid }) {
return { return {
out, out,
css: new Set(css), css: new Set(css),
head: { head: {
title: head.title, title: head.title,
out: head.out out: head.out
} },
uid
}; };
} }
@ -48,6 +49,7 @@ export function copy_payload({ out, css, head }) {
export function assign_payload(p1, p2) { export function assign_payload(p1, p2) {
p1.out = p2.out; p1.out = p2.out;
p1.head = p2.head; p1.head = p2.head;
p1.uid = p2.uid;
} }
/** /**
@ -83,17 +85,27 @@ export function element(payload, tag, attributes_fn = noop, children_fn = noop)
*/ */
export let on_destroy = []; export let on_destroy = [];
function props_id_generator() {
let uid = 1;
return () => 's' + uid++;
}
/** /**
* Only available on the server and when compiling with the `server` option. * Only available on the server and when compiling with the `server` option.
* 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. * 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.
* @template {Record<string, any>} Props * @template {Record<string, any>} Props
* @param {import('svelte').Component<Props> | ComponentType<SvelteComponent<Props>>} component * @param {import('svelte').Component<Props> | ComponentType<SvelteComponent<Props>>} component
* @param {{ props?: Omit<Props, '$$slots' | '$$events'>; context?: Map<any, any> }} [options] * @param {{ props?: Omit<Props, '$$slots' | '$$events'>; context?: Map<any, any>, uid?: () => string }} [options]
* @returns {RenderOutput} * @returns {RenderOutput}
*/ */
export function render(component, options = {}) { export function render(component, options = {}) {
/** @type {Payload} */ /** @type {Payload} */
const payload = { out: '', css: new Set(), head: { title: '', out: '' } }; const payload = {
out: '',
css: new Set(),
head: { title: '', out: '' },
uid: options.uid ?? props_id_generator()
};
const prev_on_destroy = on_destroy; const prev_on_destroy = on_destroy;
on_destroy = []; on_destroy = [];
@ -526,6 +538,17 @@ export function once(get_value) {
}; };
} }
/**
* Create an unique ID
* @param {Payload} payload
* @returns {string}
*/
export function props_id(payload) {
const uid = payload.uid();
payload.out += '<!--#' + uid + '-->';
return uid;
}
export { attr, clsx }; export { attr, clsx };
export { html } from './blocks/html.js'; export { html } from './blocks/html.js';

@ -18,6 +18,8 @@ export interface Payload {
title: string; title: string;
out: string; out: string;
}; };
/** Function that generates a unique ID */
uid: () => string;
} }
export interface RenderOutput { export interface RenderOutput {

@ -433,6 +433,7 @@ const RUNES = /** @type {const} */ ([
'$state.raw', '$state.raw',
'$state.snapshot', '$state.snapshot',
'$props', '$props',
'$props.id',
'$bindable', '$bindable',
'$derived', '$derived',
'$derived.by', '$derived.by',

@ -0,0 +1,5 @@
<script>
let id = $props.id();
</script>
<p>{id}</p>

@ -0,0 +1,61 @@
import { flushSync } from 'svelte';
import { test } from '../../test';
export default test({
test({ assert, target, variant }) {
if (variant === 'dom') {
assert.htmlEqual(
target.innerHTML,
`
<button>toggle</button>
<h1>c1</h1>
<p>c2</p>
<p>c3</p>
<p>c4</p>
`
);
} else {
assert.htmlEqual(
target.innerHTML,
`
<button>toggle</button>
<h1>s1</h1>
<p>s2</p>
<p>s3</p>
<p>s4</p>
`
);
}
let button = target.querySelector('button');
flushSync(() => button?.click());
if (variant === 'dom') {
assert.htmlEqual(
target.innerHTML,
`
<button>toggle</button>
<h1>c1</h1>
<p>c2</p>
<p>c3</p>
<p>c4</p>
<p>c5</p>
`
);
} else {
// `c6` because this runs after the `dom` tests
// (slightly brittle but good enough for now)
assert.htmlEqual(
target.innerHTML,
`
<button>toggle</button>
<h1>s1</h1>
<p>s2</p>
<p>s3</p>
<p>s4</p>
<p>c6</p>
`
);
}
}
});

@ -0,0 +1,19 @@
<script>
import Child from './Child.svelte';
let id = $props.id();
let show = $state(false);
</script>
<button onclick={() => show = !show}>toggle</button>
<h1>{id}</h1>
<Child />
<Child />
<Child />
{#if show}
<Child />
{/if}

@ -2995,6 +2995,15 @@ declare namespace $effect {
declare function $props(): any; declare function $props(): any;
declare namespace $props { declare namespace $props {
/**
* 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.
*
* This is useful for linking elements via attributes like `for` and `aria-labelledby`.
* @since 5.20.0
*/
export function id(): string;
// prevent intellisense from being unhelpful // prevent intellisense from being unhelpful
/** @deprecated */ /** @deprecated */
export const apply: never; export const apply: never;

Loading…
Cancel
Save