Skip to content

feat: $props.id(), a SSR-safe ID generation #15185

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 20 commits into from
Feb 11, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/hip-singers-vanish.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'svelte': minor
---

feat: SSR-safe ID generation with `$props.id()`
21 changes: 21 additions & 0 deletions documentation/docs/02-runes/05-$props.md
Original file line number Diff line number Diff line change
Expand Up @@ -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>
```
8 changes: 7 additions & 1 deletion documentation/docs/98-reference/.generated/compile-errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion packages/svelte/messages/compile-errors/script.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 9 additions & 0 deletions packages/svelte/src/ambient.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
16 changes: 13 additions & 3 deletions packages/svelte/src/compiler/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
}

/**
Expand Down
1 change: 1 addition & 0 deletions packages/svelte/src/compiler/phases/2-analyze/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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':
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)))))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(''));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -156,6 +161,10 @@ export function VariableDeclaration(node, context) {
}
}

if (declarations.length === 0) {
return b.empty;
}

return {
...node,
declarations
Expand Down
2 changes: 2 additions & 0 deletions packages/svelte/src/compiler/phases/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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` */
Expand Down
20 changes: 20 additions & 0 deletions packages/svelte/src/internal/client/dom/template.js
Original file line number Diff line number Diff line change
Expand Up @@ -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++;
}
3 changes: 2 additions & 1 deletion packages/svelte/src/internal/client/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
31 changes: 27 additions & 4 deletions packages/svelte/src/internal/server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
}

Expand All @@ -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;
}

/**
Expand Down Expand Up @@ -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 = [];
Expand Down Expand Up @@ -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';
Expand Down
2 changes: 2 additions & 0 deletions packages/svelte/src/internal/server/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export interface Payload {
title: string;
out: string;
};
/** Function that generates a unique ID */
uid: () => string;
}

export interface RenderOutput {
Expand Down
1 change: 1 addition & 0 deletions packages/svelte/src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,7 @@ const RUNES = /** @type {const} */ ([
'$state.raw',
'$state.snapshot',
'$props',
'$props.id',
'$bindable',
'$derived',
'$derived.by',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<script>
let id = $props.id();
</script>

<p>{id}</p>
Loading
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy