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

@ -120,7 +120,11 @@ This turned out to be buggy and unpredictable, particularly when working with de
## 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

@ -339,6 +339,15 @@ declare namespace $effect {
declare function $props(): any;
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
/** @deprecated */
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
* @returns {never}
*/
export function props_duplicate(node) {
e(node, 'props_duplicate', `Cannot use \`$props()\` more than once\nhttps://svelte.dev/e/props_duplicate`);
export function props_id_invalid_placement(node) {
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,
exports: [],
uses_props: false,
props_id: null,
uses_rest_props: false,
uses_slots: false,
uses_component_bindings: false,

@ -55,7 +55,7 @@ export function CallExpression(node, context) {
case '$props':
if (context.state.has_props_rune) {
e.props_duplicate(node);
e.props_duplicate(node, rune);
}
context.state.has_props_rune = true;
@ -74,6 +74,32 @@ export function CallExpression(node, context) {
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.raw':
case '$derived':

@ -25,6 +25,10 @@ export function validate_assignment(node, argument, state) {
e.constant_assignment(node, 'derived state');
}
if (binding?.node === state.analysis.props_id) {
e.constant_assignment(node, '$props.id()');
}
if (binding?.kind === 'each') {
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'))));
}
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) {
body.push(
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;
}
if (rune === '$props.id') {
// skip
continue;
}
if (rune === '$props') {
/** @type {string[]} */
const seen = ['$$slots', '$$events', '$$legacy'];

@ -129,6 +129,12 @@ export function build_template_chunk(
if (value.right.value === null) {
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 {
value = b.logical('??', value, b.literal(''));
}

@ -244,6 +244,13 @@ export function server_component(analysis, options) {
.../** @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;
if (should_inject_context) {

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

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

@ -249,3 +249,23 @@ export function append(anchor, 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,
template,
template_with_script,
text
text,
props_id
} from './dom/template.js';
export { derived, derived_safe_equal } from './reactivity/deriveds.js';
export {

@ -28,14 +28,15 @@ const INVALID_ATTR_NAME_CHAR_REGEX =
* @param {Payload} to_copy
* @returns {Payload}
*/
export function copy_payload({ out, css, head }) {
export function copy_payload({ out, css, head, uid }) {
return {
out,
css: new Set(css),
head: {
title: head.title,
out: head.out
}
},
uid
};
}
@ -48,6 +49,7 @@ export function copy_payload({ out, css, head }) {
export function assign_payload(p1, p2) {
p1.out = p2.out;
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 = [];
function props_id_generator() {
let uid = 1;
return () => 's' + uid++;
}
/**
* 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.
* @template {Record<string, any>} Props
* @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}
*/
export function render(component, options = {}) {
/** @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;
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 { html } from './blocks/html.js';

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

@ -433,6 +433,7 @@ const RUNES = /** @type {const} */ ([
'$state.raw',
'$state.snapshot',
'$props',
'$props.id',
'$bindable',
'$derived',
'$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 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
/** @deprecated */
export const apply: never;

Loading…
Cancel
Save