diff --git a/documentation/docs/02-runes/05-$props.md b/documentation/docs/02-runes/05-$props.md index 4b1775bf5a61..f300fb239d77 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/06-runtime/03-lifecycle-hooks.md b/documentation/docs/06-runtime/03-lifecycle-hooks.md index a3dbe04b0029..2b97ca796fed 100644 --- a/documentation/docs/06-runtime/03-lifecycle-hooks.md +++ b/documentation/docs/06-runtime/03-lifecycle-hooks.md @@ -45,8 +45,6 @@ If a function is returned from `onMount`, it will be called when the component i ## `onDestroy` -> EXPORT_SNIPPET: svelte#onDestroy - Schedules a callback to run immediately before the component is unmounted. Out of `onMount`, `beforeUpdate`, `afterUpdate` and `onDestroy`, this is the only one that runs inside a server-side component. diff --git a/documentation/docs/98-reference/.generated/compile-errors.md b/documentation/docs/98-reference/.generated/compile-errors.md index 2fef3bd45d50..a4ecbb31d569 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/CHANGELOG.md b/packages/svelte/CHANGELOG.md index e112bf620991..ff3b08fe7a3f 100644 --- a/packages/svelte/CHANGELOG.md +++ b/packages/svelte/CHANGELOG.md @@ -1,5 +1,23 @@ # svelte +## 5.20.0 + +### Minor Changes + +- feat: SSR-safe ID generation with `$props.id()` ([#15185](https://github.com/sveltejs/svelte/pull/15185)) + +### Patch Changes + +- fix: take private and public into account for `constant_assignment` of derived state ([#15276](https://github.com/sveltejs/svelte/pull/15276)) + +- fix: value/checked not correctly set using spread ([#15239](https://github.com/sveltejs/svelte/pull/15239)) + +- chore: tweak effect self invalidation logic, run transition dispatches without reactive context ([#15275](https://github.com/sveltejs/svelte/pull/15275)) + +- fix: use `importNode` to clone templates for Firefox ([#15272](https://github.com/sveltejs/svelte/pull/15272)) + +- fix: recurse into `$derived` for ownership validation ([#15166](https://github.com/sveltejs/svelte/pull/15166)) + ## 5.19.10 ### Patch Changes diff --git a/packages/svelte/messages/compile-errors/script.md b/packages/svelte/messages/compile-errors/script.md index 0aa6fbed90d8..795c0b007dca 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/package.json b/packages/svelte/package.json index a4594b2a5c7f..bea1efd7b425 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -2,7 +2,7 @@ "name": "svelte", "description": "Cybernetically enhanced web apps", "license": "MIT", - "version": "5.19.10", + "version": "5.20.0", "type": "module", "types": "./types/index.d.ts", "engines": { diff --git a/packages/svelte/src/ambient.d.ts b/packages/svelte/src/ambient.d.ts index fbcecba8e47c..a1484718cc77 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 53a6ac6849ec..93eeee539cc3 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 ad9db24e1e65..846abcf7dfc9 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/types.d.ts b/packages/svelte/src/compiler/phases/2-analyze/types.d.ts index 14b14f9c84c1..70796a0d59b5 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/types.d.ts +++ b/packages/svelte/src/compiler/phases/2-analyze/types.d.ts @@ -19,7 +19,7 @@ export interface AnalysisState { component_slots: Set; /** Information about the current expression/directive/block value */ expression: ExpressionMetadata | null; - derived_state: string[]; + derived_state: { name: string; private: boolean }[]; function_depth: number; // legacy stuff diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/Attribute.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/Attribute.js index 41144fc74c5c..42e449896928 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/Attribute.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/Attribute.js @@ -23,11 +23,6 @@ export function Attribute(node, context) { if (node.name === 'value' && parent.name === 'option') { mark_subtree_dynamic(context.path); } - - // special case - if (node.name === 'loading' && parent.name === 'img') { - mark_subtree_dynamic(context.path); - } } if (is_event_attribute(node)) { 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 0a6b3f3ee520..ce520cc98055 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/ClassBody.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/ClassBody.js index ed397258f804..0463e4da8563 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/ClassBody.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/ClassBody.js @@ -7,7 +7,7 @@ import { get_rune } from '../../scope.js'; * @param {Context} context */ export function ClassBody(node, context) { - /** @type {string[]} */ + /** @type {{name: string, private: boolean}[]} */ const derived_state = []; for (const definition of node.body) { @@ -18,7 +18,10 @@ export function ClassBody(node, context) { ) { const rune = get_rune(definition.value, context.state.scope); if (rune === '$derived' || rune === '$derived.by') { - derived_state.push(definition.key.name); + derived_state.push({ + name: definition.key.name, + private: definition.key.type === 'PrivateIdentifier' + }); } } } 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 5fe2a8f24ecc..1507123e1342 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 @@ -1,4 +1,4 @@ -/** @import { AssignmentExpression, Expression, Literal, Node, Pattern, PrivateIdentifier, Super, UpdateExpression, VariableDeclarator } from 'estree' */ +/** @import { AssignmentExpression, Expression, Identifier, Literal, Node, Pattern, PrivateIdentifier, Super, UpdateExpression, VariableDeclarator } from 'estree' */ /** @import { AST, Binding } from '#compiler' */ /** @import { AnalysisState, Context } from '../../types' */ /** @import { Scope } from '../../../scope' */ @@ -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); } @@ -34,16 +38,22 @@ export function validate_assignment(node, argument, state) { e.snippet_parameter_assignment(node); } } - if ( argument.type === 'MemberExpression' && argument.object.type === 'ThisExpression' && (((argument.property.type === 'PrivateIdentifier' || argument.property.type === 'Identifier') && - state.derived_state.includes(argument.property.name)) || + state.derived_state.some( + (derived) => + derived.name === /** @type {PrivateIdentifier | Identifier} */ (argument.property).name && + derived.private === (argument.property.type === 'PrivateIdentifier') + )) || (argument.property.type === 'Literal' && argument.property.value && typeof argument.property.value === 'string' && - state.derived_state.includes(argument.property.value))) + state.derived_state.some( + (derived) => + derived.name === /** @type {Literal} */ (argument.property).value && !derived.private + ))) ) { e.constant_assignment(node, 'derived state'); } 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 f4a6c9a4147b..2e6307a4b7a6 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/ClassBody.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/ClassBody.js index 7b3a9a4d0e29..ed800e5226ce 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/ClassBody.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/ClassBody.js @@ -190,22 +190,21 @@ export function ClassBody(node, context) { 'method', b.id('$.ADD_OWNER'), [b.id('owner')], - Array.from(public_state) - // Only run ownership addition on $state fields. - // Theoretically someone could create a `$state` while creating `$state.raw` or inside a `$derived.by`, - // but that feels so much of an edge case that it doesn't warrant a perf hit for the common case. - .filter(([_, { kind }]) => kind === 'state') - .map(([name]) => - b.stmt( - b.call( - '$.add_owner', - b.call('$.get', b.member(b.this, b.private_id(name))), - b.id('owner'), - b.literal(false), - is_ignored(node, 'ownership_invalid_binding') && b.true - ) + [ + b.stmt( + b.call( + '$.add_owner_to_class', + b.this, + b.id('owner'), + b.array( + Array.from(public_state).map(([name]) => + b.thunk(b.call('$.get', b.member(b.this, b.private_id(name)))) + ) + ), + is_ignored(node, 'ownership_invalid_binding') && b.true ) - ), + ) + ], true ) ); diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js index c7e218d52143..98036aa9b609 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js @@ -300,11 +300,6 @@ export function RegularElement(node, context) { build_class_directives(class_directives, node_id, context, is_attributes_reactive); build_style_directives(style_directives, node_id, context, is_attributes_reactive); - // Apply the src and loading attributes for elements after the element is appended to the document - if (node.name === 'img' && (has_spread || lookup.has('loading'))) { - context.state.after_update.push(b.stmt(b.call('$.handle_lazy_img', node_id))); - } - if ( is_load_error_element(node.name) && (has_spread || has_use || lookup.has('onload') || lookup.has('onerror')) 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 afb90bbec7f9..31e712cdcc4d 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/component.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/component.js index 15e4f68e9e49..2bae4486dc58 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/component.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/component.js @@ -180,37 +180,18 @@ export function build_component(node, component_name, context, anchor = context. const expression = /** @type {Expression} */ (context.visit(attribute.expression)); if (dev && attribute.name !== 'this') { - let should_add_owner = true; - - if (attribute.expression.type !== 'SequenceExpression') { - const left = object(attribute.expression); - - if (left?.type === 'Identifier') { - const binding = context.state.scope.get(left.name); - - // Only run ownership addition on $state fields. - // Theoretically someone could create a `$state` while creating `$state.raw` or inside a `$derived.by`, - // but that feels so much of an edge case that it doesn't warrant a perf hit for the common case. - if (binding?.kind === 'derived' || binding?.kind === 'raw_state') { - should_add_owner = false; - } - } - } - - if (should_add_owner) { - binding_initializers.push( - b.stmt( - b.call( - b.id('$.add_owner_effect'), - expression.type === 'SequenceExpression' - ? expression.expressions[0] - : b.thunk(expression), - b.id(component_name), - is_ignored(node, 'ownership_invalid_binding') && b.true - ) + binding_initializers.push( + b.stmt( + b.call( + b.id('$.add_owner_effect'), + expression.type === 'SequenceExpression' + ? expression.expressions[0] + : b.thunk(expression), + b.id(component_name), + is_ignored(node, 'ownership_invalid_binding') && b.true ) - ); - } + ) + ); } if (expression.type === 'SequenceExpression') { 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 00634f229eeb..9214a13c94ca 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 982b75e12f53..df3d831d3cc3 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 31de811ac76f..c4c31d7eb304 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 fe32dbba3e4a..abe2b115de02 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/dev/ownership.js b/packages/svelte/src/internal/client/dev/ownership.js index 2a2527803af9..62119b36dbd6 100644 --- a/packages/svelte/src/internal/client/dev/ownership.js +++ b/packages/svelte/src/internal/client/dev/ownership.js @@ -6,7 +6,7 @@ import { render_effect, user_pre_effect } from '../reactivity/effects.js'; import { dev_current_component_function } from '../context.js'; import { get_prototype_of } from '../../shared/utils.js'; import * as w from '../warnings.js'; -import { FILENAME } from '../../../constants.js'; +import { FILENAME, UNINITIALIZED } from '../../../constants.js'; /** @type {Record>} */ const boundaries = {}; @@ -140,6 +140,25 @@ export function add_owner_effect(get_object, Component, skip_warning = false) { }); } +/** + * @param {any} _this + * @param {Function} owner + * @param {Array<() => any>} getters + * @param {boolean} skip_warning + */ +export function add_owner_to_class(_this, owner, getters, skip_warning) { + _this[ADD_OWNER].current ||= getters.map(() => UNINITIALIZED); + + for (let i = 0; i < getters.length; i += 1) { + const current = getters[i](); + // For performance reasons we only re-add the owner if the state has changed + if (current !== _this[ADD_OWNER][i]) { + _this[ADD_OWNER].current[i] = current; + add_owner(current, owner, false, skip_warning); + } + } +} + /** * @param {ProxyMetadata | null} from * @param {ProxyMetadata} to @@ -196,7 +215,19 @@ function add_owner_to_object(object, owner, seen) { if (proto === Object.prototype) { // recurse until we find a state proxy for (const key in object) { - add_owner_to_object(object[key], owner, seen); + if (Object.getOwnPropertyDescriptor(object, key)?.get) { + // Similar to the class case; the getter could update with a new state + let current = UNINITIALIZED; + render_effect(() => { + const next = object[key]; + if (current !== next) { + current = next; + add_owner_to_object(next, owner, seen); + } + }); + } else { + add_owner_to_object(object[key], owner, seen); + } } } else if (proto === Array.prototype) { // recurse until we find a state proxy @@ -221,9 +252,10 @@ function has_owner(metadata, component) { return ( metadata.owners.has(component) || // This helps avoid false positives when using HMR, where the component function is replaced - [...metadata.owners].some( - (owner) => /** @type {any} */ (owner)[FILENAME] === /** @type {any} */ (component)?.[FILENAME] - ) || + (FILENAME in component && + [...metadata.owners].some( + (owner) => /** @type {any} */ (owner)[FILENAME] === component[FILENAME] + )) || (metadata.parent !== null && has_owner(metadata.parent, component)) ); } diff --git a/packages/svelte/src/internal/client/dom/elements/attributes.js b/packages/svelte/src/internal/client/dom/elements/attributes.js index 4a0f0cea0e00..2dba2d797a4a 100644 --- a/packages/svelte/src/internal/client/dom/elements/attributes.js +++ b/packages/svelte/src/internal/client/dom/elements/attributes.js @@ -399,15 +399,18 @@ export function set_attributes( if (name === 'value' || name === 'checked') { // removing value/checked also removes defaultValue/defaultChecked — preserve let input = /** @type {HTMLInputElement} */ (element); - + const use_default = prev === undefined; if (name === 'value') { - let prev = input.defaultValue; + let previous = input.defaultValue; input.removeAttribute(name); - input.defaultValue = prev; + input.defaultValue = previous; + // @ts-ignore + input.value = input.__value = use_default ? previous : null; } else { - let prev = input.defaultChecked; + let previous = input.defaultChecked; input.removeAttribute(name); - input.defaultChecked = prev; + input.defaultChecked = previous; + input.checked = use_default ? previous : false; } } else { element.removeAttribute(key); @@ -520,28 +523,3 @@ function srcset_url_equal(element, srcset) { ) ); } - -/** - * @param {HTMLImageElement} element - * @returns {void} - */ -export function handle_lazy_img(element) { - // If we're using an image that has a lazy loading attribute, we need to apply - // the loading and src after the img element has been appended to the document. - // Otherwise the lazy behaviour will not work due to our cloneNode heuristic for - // templates. - if (!hydrating && element.loading === 'lazy') { - var src = element.src; - // @ts-expect-error - element[LOADING_ATTR_SYMBOL] = null; - element.loading = 'eager'; - element.removeAttribute('src'); - requestAnimationFrame(() => { - // @ts-expect-error - if (element[LOADING_ATTR_SYMBOL] !== 'eager') { - element.loading = 'lazy'; - } - element.src = src; - }); - } -} diff --git a/packages/svelte/src/internal/client/dom/elements/transitions.js b/packages/svelte/src/internal/client/dom/elements/transitions.js index b3c16cdd080f..fbc1da95df95 100644 --- a/packages/svelte/src/internal/client/dom/elements/transitions.js +++ b/packages/svelte/src/internal/client/dom/elements/transitions.js @@ -14,6 +14,7 @@ import { current_each_item } from '../blocks/each.js'; import { TRANSITION_GLOBAL, TRANSITION_IN, TRANSITION_OUT } from '../../../../constants.js'; import { BLOCK_EFFECT, EFFECT_RAN, EFFECT_TRANSPARENT } from '../../constants.js'; import { queue_micro_task } from '../task.js'; +import { without_reactive_context } from './bindings/shared.js'; /** * @param {Element} element @@ -21,7 +22,9 @@ import { queue_micro_task } from '../task.js'; * @returns {void} */ function dispatch_event(element, type) { - element.dispatchEvent(new CustomEvent(type)); + without_reactive_context(() => { + element.dispatchEvent(new CustomEvent(type)); + }); } /** diff --git a/packages/svelte/src/internal/client/dom/operations.js b/packages/svelte/src/internal/client/dom/operations.js index 627bf917eee1..83565d17ae68 100644 --- a/packages/svelte/src/internal/client/dom/operations.js +++ b/packages/svelte/src/internal/client/dom/operations.js @@ -11,6 +11,9 @@ export var $window; /** @type {Document} */ export var $document; +/** @type {boolean} */ +export var is_firefox; + /** @type {() => Node | null} */ var first_child_getter; /** @type {() => Node | null} */ @@ -27,6 +30,7 @@ export function init_operations() { $window = window; $document = document; + is_firefox = /Firefox/.test(navigator.userAgent); var element_prototype = Element.prototype; var node_prototype = Node.prototype; diff --git a/packages/svelte/src/internal/client/dom/template.js b/packages/svelte/src/internal/client/dom/template.js index bcbae393ecff..6ff3b0fa19a0 100644 --- a/packages/svelte/src/internal/client/dom/template.js +++ b/packages/svelte/src/internal/client/dom/template.js @@ -1,6 +1,6 @@ /** @import { Effect, TemplateNode } from '#client' */ import { hydrate_next, hydrate_node, hydrating, set_hydrate_node } from './hydration.js'; -import { create_text, get_first_child } from './operations.js'; +import { create_text, get_first_child, is_firefox } from './operations.js'; import { create_fragment_from_html } from './reconciler.js'; import { active_effect } from '../runtime.js'; import { TEMPLATE_FRAGMENT, TEMPLATE_USE_IMPORT_NODE } from '../../../constants.js'; @@ -48,7 +48,7 @@ export function template(content, flags) { } var clone = /** @type {TemplateNode} */ ( - use_import_node ? document.importNode(node, true) : node.cloneNode(true) + use_import_node || is_firefox ? document.importNode(node, true) : node.cloneNode(true) ); if (is_fragment) { @@ -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 3f3b29b029dd..d78f6d452e84 100644 --- a/packages/svelte/src/internal/client/index.js +++ b/packages/svelte/src/internal/client/index.js @@ -10,6 +10,7 @@ export { mark_module_start, mark_module_end, add_owner_effect, + add_owner_to_class, skip_ownership_validation } from './dev/ownership.js'; export { check_target, legacy_api } from './dev/legacy.js'; @@ -34,7 +35,6 @@ export { set_attributes, set_custom_element_data, set_xlink_attribute, - handle_lazy_img, set_value, set_checked, set_selected, @@ -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/client/runtime.js b/packages/svelte/src/internal/client/runtime.js index 8b0f84268c5a..8a9ca9065b47 100644 --- a/packages/svelte/src/internal/client/runtime.js +++ b/packages/svelte/src/internal/client/runtime.js @@ -39,6 +39,7 @@ import { set_component_context, set_dev_current_component_function } from './context.js'; +import { is_firefox } from './dom/operations.js'; const FLUSH_MICROTASK = 0; const FLUSH_SYNC = 1; @@ -333,7 +334,7 @@ export function handle_error(error, effect, previous_effect, component_context) current_context = current_context.p; } - const indent = /Firefox/.test(navigator.userAgent) ? ' ' : '\t'; + const indent = is_firefox ? ' ' : '\t'; define_property(error, 'message', { value: error.message + `\n${component_stack.map((name) => `\n${indent}in ${name}`).join('')}\n` }); @@ -369,22 +370,18 @@ export function handle_error(error, effect, previous_effect, component_context) /** * @param {Value} signal * @param {Effect} effect - * @param {number} [depth] + * @param {boolean} [root] */ -function schedule_possible_effect_self_invalidation(signal, effect, depth = 0) { +function schedule_possible_effect_self_invalidation(signal, effect, root = true) { var reactions = signal.reactions; if (reactions === null) return; for (var i = 0; i < reactions.length; i++) { var reaction = reactions[i]; if ((reaction.f & DERIVED) !== 0) { - schedule_possible_effect_self_invalidation( - /** @type {Derived} */ (reaction), - effect, - depth + 1 - ); + schedule_possible_effect_self_invalidation(/** @type {Derived} */ (reaction), effect, false); } else if (effect === reaction) { - if (depth === 0) { + if (root) { set_signal_status(reaction, DIRTY); } else if ((reaction.f & CLEAN) !== 0) { set_signal_status(reaction, MAYBE_DIRTY); @@ -457,6 +454,8 @@ export function update_reaction(reaction) { if ( is_runes() && untracked_writes !== null && + !untracking && + deps !== null && (reaction.f & (DERIVED | MAYBE_DIRTY | DIRTY)) === 0 ) { for (i = 0; i < /** @type {Source[]} */ (untracked_writes).length; i++) { @@ -679,10 +678,7 @@ function flush_queued_root_effects(root_effects) { effect.f ^= CLEAN; } - /** @type {Effect[]} */ - var collected_effects = []; - - process_effects(effect, collected_effects); + var collected_effects = process_effects(effect); flush_queued_effects(collected_effects); } } finally { @@ -783,13 +779,14 @@ export function schedule_effect(signal) { * effects to be flushed. * * @param {Effect} effect - * @param {Effect[]} collected_effects - * @returns {void} + * @returns {Effect[]} */ -function process_effects(effect, collected_effects) { - var current_effect = effect.first; +function process_effects(effect) { + /** @type {Effect[]} */ var effects = []; + var current_effect = effect.first; + main_loop: while (current_effect !== null) { var flags = current_effect.f; var is_branch = (flags & BRANCH_EFFECT) !== 0; @@ -797,34 +794,32 @@ function process_effects(effect, collected_effects) { var sibling = current_effect.next; if (!is_skippable_branch && (flags & INERT) === 0) { - if ((flags & RENDER_EFFECT) !== 0) { - if (is_branch) { - current_effect.f ^= CLEAN; - } else { - // Ensure we set the effect to be the active reaction - // to ensure that unowned deriveds are correctly tracked - // because we're flushing the current effect - var previous_active_reaction = active_reaction; - try { - active_reaction = current_effect; - if (check_dirtiness(current_effect)) { - update_effect(current_effect); - } - } catch (error) { - handle_error(error, current_effect, null, current_effect.ctx); - } finally { - active_reaction = previous_active_reaction; + if ((flags & EFFECT) !== 0) { + effects.push(current_effect); + } else if (is_branch) { + current_effect.f ^= CLEAN; + } else { + // Ensure we set the effect to be the active reaction + // to ensure that unowned deriveds are correctly tracked + // because we're flushing the current effect + var previous_active_reaction = active_reaction; + try { + active_reaction = current_effect; + if (check_dirtiness(current_effect)) { + update_effect(current_effect); } + } catch (error) { + handle_error(error, current_effect, null, current_effect.ctx); + } finally { + active_reaction = previous_active_reaction; } + } - var child = current_effect.first; + var child = current_effect.first; - if (child !== null) { - current_effect = child; - continue; - } - } else if ((flags & EFFECT) !== 0) { - effects.push(current_effect); + if (child !== null) { + current_effect = child; + continue; } } @@ -847,13 +842,7 @@ function process_effects(effect, collected_effects) { current_effect = sibling; } - // We might be dealing with many effects here, far more than can be spread into - // an array push call (callstack overflow). So let's deal with each effect in a loop. - for (var i = 0; i < effects.length; i++) { - child = effects[i]; - collected_effects.push(child); - process_effects(child, collected_effects); - } + return effects; } /** diff --git a/packages/svelte/src/internal/server/index.js b/packages/svelte/src/internal/server/index.js index 89b3c33df887..c4e5d318dcd0 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} Props * @param {import('svelte').Component | ComponentType>} component - * @param {{ props?: Omit; context?: Map }} [options] + * @param {{ props?: Omit; context?: Map, 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 += ''; + return uid; +} + export { attr, clsx }; export { html } from './blocks/html.js'; diff --git a/packages/svelte/src/internal/server/types.d.ts b/packages/svelte/src/internal/server/types.d.ts index e6c235147b5f..8a241deecd18 100644 --- a/packages/svelte/src/internal/server/types.d.ts +++ b/packages/svelte/src/internal/server/types.d.ts @@ -18,6 +18,8 @@ export interface Payload { title: string; out: string; }; + /** Function that generates a unique ID */ + uid: () => string; } export interface RenderOutput { diff --git a/packages/svelte/src/utils.js b/packages/svelte/src/utils.js index e8e1bc224ce4..d4d106d56deb 100644 --- a/packages/svelte/src/utils.js +++ b/packages/svelte/src/utils.js @@ -433,6 +433,7 @@ const RUNES = /** @type {const} */ ([ '$state.raw', '$state.snapshot', '$props', + '$props.id', '$bindable', '$derived', '$derived.by', diff --git a/packages/svelte/src/version.js b/packages/svelte/src/version.js index ada6f9019d7e..b24607615685 100644 --- a/packages/svelte/src/version.js +++ b/packages/svelte/src/version.js @@ -4,5 +4,5 @@ * The current version, as set in package.json. * @type {string} */ -export const VERSION = '5.19.10'; +export const VERSION = '5.20.0'; export const PUBLIC_VERSION = '5'; diff --git a/packages/svelte/tests/runtime-runes/samples/attribute-spread-input/_config.js b/packages/svelte/tests/runtime-runes/samples/attribute-spread-input/_config.js new file mode 100644 index 000000000000..ab941255037e --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/attribute-spread-input/_config.js @@ -0,0 +1,33 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ target, assert }) { + // Test for https://github.com/sveltejs/svelte/issues/15237 + const [setValues, clearValue] = target.querySelectorAll('button'); + const [text1, text2, check1, check2] = target.querySelectorAll('input'); + + assert.equal(text1.value, ''); + assert.equal(text2.value, ''); + assert.equal(check1.checked, false); + assert.equal(check2.checked, false); + + flushSync(() => { + setValues.click(); + }); + + assert.equal(text1.value, 'message'); + assert.equal(text2.value, 'message'); + assert.equal(check1.checked, true); + assert.equal(check2.checked, true); + + flushSync(() => { + clearValue.click(); + }); + + assert.equal(text1.value, ''); + assert.equal(text2.value, ''); + assert.equal(check1.checked, false); + assert.equal(check2.checked, false); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/attribute-spread-input/main.svelte b/packages/svelte/tests/runtime-runes/samples/attribute-spread-input/main.svelte new file mode 100644 index 000000000000..4bb4365ee270 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/attribute-spread-input/main.svelte @@ -0,0 +1,22 @@ + + + + + + + + + + diff --git a/packages/svelte/tests/runtime-runes/samples/non-local-mutation-with-binding-8/CounterBinding.svelte b/packages/svelte/tests/runtime-runes/samples/non-local-mutation-with-binding-8/CounterBinding.svelte new file mode 100644 index 000000000000..d6da559fb176 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/non-local-mutation-with-binding-8/CounterBinding.svelte @@ -0,0 +1,7 @@ + + +

Binding

+ + diff --git a/packages/svelte/tests/runtime-runes/samples/non-local-mutation-with-binding-8/CounterContext.svelte b/packages/svelte/tests/runtime-runes/samples/non-local-mutation-with-binding-8/CounterContext.svelte new file mode 100644 index 000000000000..b935f0a472dc --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/non-local-mutation-with-binding-8/CounterContext.svelte @@ -0,0 +1,13 @@ + + +

Context

+ + diff --git a/packages/svelte/tests/runtime-runes/samples/non-local-mutation-with-binding-8/_config.js b/packages/svelte/tests/runtime-runes/samples/non-local-mutation-with-binding-8/_config.js new file mode 100644 index 000000000000..d6d12d01cd09 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/non-local-mutation-with-binding-8/_config.js @@ -0,0 +1,34 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +// Tests that ownership is widened with $derived (on class or on its own) that contains $state +export default test({ + compileOptions: { + dev: true + }, + + test({ assert, target, warnings }) { + const [root, counter_context1, counter_context2, counter_binding1, counter_binding2] = + target.querySelectorAll('button'); + + counter_context1.click(); + counter_context2.click(); + counter_binding1.click(); + counter_binding2.click(); + flushSync(); + + assert.equal(warnings.length, 0); + + root.click(); + flushSync(); + counter_context1.click(); + counter_context2.click(); + counter_binding1.click(); + counter_binding2.click(); + flushSync(); + + assert.equal(warnings.length, 0); + }, + + warnings: [] +}); diff --git a/packages/svelte/tests/runtime-runes/samples/non-local-mutation-with-binding-8/main.svelte b/packages/svelte/tests/runtime-runes/samples/non-local-mutation-with-binding-8/main.svelte new file mode 100644 index 000000000000..aaade26e162c --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/non-local-mutation-with-binding-8/main.svelte @@ -0,0 +1,46 @@ + + +

Parent

+ + + + diff --git a/packages/svelte/tests/runtime-runes/samples/props-id/Child.svelte b/packages/svelte/tests/runtime-runes/samples/props-id/Child.svelte new file mode 100644 index 000000000000..ad8bbd6f01ff --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/props-id/Child.svelte @@ -0,0 +1,5 @@ + + +

{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 000000000000..9d91b98e0fa6 --- /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, + ` + +

c1

+

c2

+

c3

+

c4

+ ` + ); + } else { + assert.htmlEqual( + target.innerHTML, + ` + +

s1

+

s2

+

s3

+

s4

+ ` + ); + } + + let button = target.querySelector('button'); + flushSync(() => button?.click()); + + if (variant === 'dom') { + assert.htmlEqual( + target.innerHTML, + ` + +

c1

+

c2

+

c3

+

c4

+

c5

+ ` + ); + } else { + // `c6` because this runs after the `dom` tests + // (slightly brittle but good enough for now) + assert.htmlEqual( + target.innerHTML, + ` + +

s1

+

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 000000000000..646bb2ebdefe --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/props-id/main.svelte @@ -0,0 +1,19 @@ + + + + +

{id}

+ + + + + +{#if show} + +{/if} diff --git a/packages/svelte/tests/snapshot/samples/skip-static-subtree/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/skip-static-subtree/_expected/client/index.svelte.js index 9b203b97e82d..46d376aca2f9 100644 --- a/packages/svelte/tests/snapshot/samples/skip-static-subtree/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/skip-static-subtree/_expected/client/index.svelte.js @@ -42,12 +42,8 @@ export default function Skip_static_subtree($$anchor, $$props) { $.reset(select); var img = $.sibling(select, 2); - var div_2 = $.sibling(img, 2); - var img_1 = $.child(div_2); - $.reset(div_2); + $.next(2); $.template_effect(() => $.set_text(text, $$props.title)); - $.handle_lazy_img(img); - $.handle_lazy_img(img_1); $.append($$anchor, fragment); } \ No newline at end of file diff --git a/packages/svelte/tests/validator/samples/reassign-derived-private-public-field/errors.json b/packages/svelte/tests/validator/samples/reassign-derived-private-public-field/errors.json new file mode 100644 index 000000000000..fe51488c7066 --- /dev/null +++ b/packages/svelte/tests/validator/samples/reassign-derived-private-public-field/errors.json @@ -0,0 +1 @@ +[] diff --git a/packages/svelte/tests/validator/samples/reassign-derived-private-public-field/input.svelte b/packages/svelte/tests/validator/samples/reassign-derived-private-public-field/input.svelte new file mode 100644 index 000000000000..92f356492042 --- /dev/null +++ b/packages/svelte/tests/validator/samples/reassign-derived-private-public-field/input.svelte @@ -0,0 +1,13 @@ + \ No newline at end of file diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index eb3e93e4b5df..77d78477ee93 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -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; 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