diff --git a/.changeset/hip-singers-vanish.md b/.changeset/hip-singers-vanish.md new file mode 100644 index 0000000000..9dce4d98a8 --- /dev/null +++ b/.changeset/hip-singers-vanish.md @@ -0,0 +1,5 @@ +--- +'svelte': minor +--- + +feat: SSR-safe ID generation with `$props.id()` diff --git a/documentation/docs/02-runes/05-$props.md b/documentation/docs/02-runes/05-$props.md index 4b1775bf5a..f300fb239d 100644 --- a/documentation/docs/02-runes/05-$props.md +++ b/documentation/docs/02-runes/05-$props.md @@ -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 + + +
+``` \ No newline at end of file diff --git a/documentation/docs/98-reference/.generated/compile-errors.md b/documentation/docs/98-reference/.generated/compile-errors.md index 2fef3bd45d..a4ecbb31d5 100644 --- a/documentation/docs/98-reference/.generated/compile-errors.md +++ b/documentation/docs/98-reference/.generated/compile-errors.md @@ -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 diff --git a/packages/svelte/messages/compile-errors/script.md b/packages/svelte/messages/compile-errors/script.md index 0aa6fbed90..795c0b007d 100644 --- a/packages/svelte/messages/compile-errors/script.md +++ b/packages/svelte/messages/compile-errors/script.md @@ -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 diff --git a/packages/svelte/src/ambient.d.ts b/packages/svelte/src/ambient.d.ts index fbcecba8e4..a1484718cc 100644 --- a/packages/svelte/src/ambient.d.ts +++ b/packages/svelte/src/ambient.d.ts @@ -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; diff --git a/packages/svelte/src/compiler/errors.js b/packages/svelte/src/compiler/errors.js index 53a6ac6849..93eeee539c 100644 --- a/packages/svelte/src/compiler/errors.js +++ b/packages/svelte/src/compiler/errors.js @@ -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`); } /** diff --git a/packages/svelte/src/compiler/phases/2-analyze/index.js b/packages/svelte/src/compiler/phases/2-analyze/index.js index ad9db24e1e..846abcf7df 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/index.js +++ b/packages/svelte/src/compiler/phases/2-analyze/index.js @@ -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, diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js index 0a6b3f3ee5..ce520cc980 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js @@ -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': diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/utils.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/utils.js index 5fe2a8f24e..2d90c85364 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/utils.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/utils.js @@ -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); } diff --git a/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js b/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js index f4a6c9a414..2e6307a4b7 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js @@ -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))))) diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/VariableDeclaration.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/VariableDeclaration.js index afb90bbec7..31e712cdcc 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/VariableDeclaration.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/VariableDeclaration.js @@ -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']; diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/utils.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/utils.js index 00634f229e..9214a13c94 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/utils.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/utils.js @@ -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('')); } diff --git a/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js b/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js index 982b75e12f..df3d831d3c 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js @@ -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) { diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/VariableDeclaration.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/VariableDeclaration.js index 31de811ac7..c4c31d7eb3 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/VariableDeclaration.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/VariableDeclaration.js @@ -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 diff --git a/packages/svelte/src/compiler/phases/types.d.ts b/packages/svelte/src/compiler/phases/types.d.ts index fe32dbba3e..abe2b115de 100644 --- a/packages/svelte/src/compiler/phases/types.d.ts +++ b/packages/svelte/src/compiler/phases/types.d.ts @@ -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` */ diff --git a/packages/svelte/src/internal/client/dom/template.js b/packages/svelte/src/internal/client/dom/template.js index bcbae393ec..3e4f45aba8 100644 --- a/packages/svelte/src/internal/client/dom/template.js +++ b/packages/svelte/src/internal/client/dom/template.js @@ -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++; +} diff --git a/packages/svelte/src/internal/client/index.js b/packages/svelte/src/internal/client/index.js index 3f3b29b029..8eaa5d66e1 100644 --- a/packages/svelte/src/internal/client/index.js +++ b/packages/svelte/src/internal/client/index.js @@ -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 { diff --git a/packages/svelte/src/internal/server/index.js b/packages/svelte/src/internal/server/index.js index 89b3c33df8..c4e5d318dc 100644 --- a/packages/svelte/src/internal/server/index.js +++ b/packages/svelte/src/internal/server/index.js @@ -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{id}
diff --git a/packages/svelte/tests/runtime-runes/samples/props-id/_config.js b/packages/svelte/tests/runtime-runes/samples/props-id/_config.js new file mode 100644 index 0000000000..9d91b98e0f --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/props-id/_config.js @@ -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, + ` + +c2
+c3
+c4
+ ` + ); + } else { + assert.htmlEqual( + target.innerHTML, + ` + +s2
+s3
+s4
+ ` + ); + } + + let button = target.querySelector('button'); + flushSync(() => button?.click()); + + if (variant === 'dom') { + assert.htmlEqual( + target.innerHTML, + ` + +c2
+c3
+c4
+c5
+ ` + ); + } else { + // `c6` because this runs after the `dom` tests + // (slightly brittle but good enough for now) + assert.htmlEqual( + target.innerHTML, + ` + +s2
+s3
+s4
+c6
+ ` + ); + } + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/props-id/main.svelte b/packages/svelte/tests/runtime-runes/samples/props-id/main.svelte new file mode 100644 index 0000000000..646bb2ebde --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/props-id/main.svelte @@ -0,0 +1,19 @@ + + + + +