merge async

async-changeset
Rich Harris 7 months ago
commit 42e7e24714

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

@ -1,5 +0,0 @@
---
'svelte': patch
---
fix: ensure tracking returns true, even if in unowned

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

@ -585,7 +585,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

@ -7,7 +7,7 @@
"license": "MIT", "license": "MIT",
"packageManager": "pnpm@9.4.0", "packageManager": "pnpm@9.4.0",
"engines": { "engines": {
"pnpm": "^9.0.0" "pnpm": ">=9.0.0"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
@ -44,6 +44,6 @@
"typescript": "^5.5.4", "typescript": "^5.5.4",
"typescript-eslint": "^8.2.0", "typescript-eslint": "^8.2.0",
"v8-natives": "^1.2.5", "v8-natives": "^1.2.5",
"vitest": "^2.0.5" "vitest": "^2.1.9"
} }
} }

@ -1,5 +1,31 @@
# svelte # svelte
## 5.19.10
### Patch Changes
- fix: when re-connecting unowned deriveds, remove their unowned flag ([#15255](https://github.com/sveltejs/svelte/pull/15255))
- fix: allow mutation of private derived state ([#15228](https://github.com/sveltejs/svelte/pull/15228))
## 5.19.9
### Patch Changes
- fix: ensure unowned derived dependencies are not duplicated when reactions are skipped ([#15232](https://github.com/sveltejs/svelte/pull/15232))
- fix: hydrate `href` that is part of spread attributes ([#15226](https://github.com/sveltejs/svelte/pull/15226))
## 5.19.8
### Patch Changes
- fix: properly set `value` property of custom elements ([#15206](https://github.com/sveltejs/svelte/pull/15206))
- fix: ensure custom element updates don't run in hydration mode ([#15217](https://github.com/sveltejs/svelte/pull/15217))
- fix: ensure tracking returns true, even if in unowned ([#15214](https://github.com/sveltejs/svelte/pull/15214))
## 5.19.7 ## 5.19.7
### Patch Changes ### Patch Changes

@ -128,7 +128,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

@ -2,7 +2,7 @@
"name": "svelte", "name": "svelte",
"description": "Cybernetically enhanced web apps", "description": "Cybernetically enhanced web apps",
"license": "MIT", "license": "MIT",
"version": "5.19.7", "version": "5.19.10",
"type": "module", "type": "module",
"types": "./types/index.d.ts", "types": "./types/index.d.ts",
"engines": { "engines": {
@ -143,7 +143,7 @@
"source-map": "^0.7.4", "source-map": "^0.7.4",
"tiny-glob": "^0.2.9", "tiny-glob": "^0.2.9",
"typescript": "^5.5.4", "typescript": "^5.5.4",
"vitest": "^2.0.5" "vitest": "^2.1.9"
}, },
"dependencies": { "dependencies": {
"@ampproject/remapping": "^2.3.0", "@ampproject/remapping": "^2.3.0",

@ -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;

@ -297,12 +297,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`);
} }
/** /**

@ -429,6 +429,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,
@ -620,7 +621,7 @@ export function analyze_component(root, source, options) {
has_props_rune: false, has_props_rune: false,
component_slots: new Set(), component_slots: new Set(),
expression: null, expression: null,
private_derived_state: [], derived_state: [],
function_depth: scope.function_depth, function_depth: scope.function_depth,
instance_scope: instance.scope, instance_scope: instance.scope,
reactive_statement: null, reactive_statement: null,
@ -691,7 +692,7 @@ export function analyze_component(root, source, options) {
reactive_statements: analysis.reactive_statements, reactive_statements: analysis.reactive_statements,
component_slots: new Set(), component_slots: new Set(),
expression: null, expression: null,
private_derived_state: [], derived_state: [],
function_depth: scope.function_depth function_depth: scope.function_depth
}; };

@ -19,7 +19,7 @@ export interface AnalysisState {
component_slots: Set<string>; component_slots: Set<string>;
/** Information about the current expression/directive/block value */ /** Information about the current expression/directive/block value */
expression: ExpressionMetadata | null; expression: ExpressionMetadata | null;
private_derived_state: string[]; derived_state: string[];
function_depth: number; function_depth: number;
// legacy stuff // legacy stuff

@ -56,7 +56,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;
@ -75,6 +75,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':

@ -8,20 +8,20 @@ import { get_rune } from '../../scope.js';
*/ */
export function ClassBody(node, context) { export function ClassBody(node, context) {
/** @type {string[]} */ /** @type {string[]} */
const private_derived_state = []; const derived_state = [];
for (const definition of node.body) { for (const definition of node.body) {
if ( if (
definition.type === 'PropertyDefinition' && definition.type === 'PropertyDefinition' &&
definition.key.type === 'PrivateIdentifier' && (definition.key.type === 'PrivateIdentifier' || definition.key.type === 'Identifier') &&
definition.value?.type === 'CallExpression' definition.value?.type === 'CallExpression'
) { ) {
const rune = get_rune(definition.value, context.state.scope); const rune = get_rune(definition.value, context.state.scope);
if (rune === '$derived' || rune === '$derived.by') { if (rune === '$derived' || rune === '$derived.by') {
private_derived_state.push(definition.key.name); derived_state.push(definition.key.name);
} }
} }
} }
context.next({ ...context.state, private_derived_state }); context.next({ ...context.state, derived_state });
} }

@ -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);
} }
@ -35,22 +39,19 @@ export function validate_assignment(node, argument, state) {
} }
} }
let object = /** @type {Expression | Super} */ (argument); if (
argument.type === 'MemberExpression' &&
/** @type {Expression | PrivateIdentifier | null} */ argument.object.type === 'ThisExpression' &&
let property = null; (((argument.property.type === 'PrivateIdentifier' || argument.property.type === 'Identifier') &&
state.derived_state.includes(argument.property.name)) ||
while (object.type === 'MemberExpression') { (argument.property.type === 'Literal' &&
property = object.property; argument.property.value &&
object = object.object; typeof argument.property.value === 'string' &&
} state.derived_state.includes(argument.property.value)))
) {
if (object.type === 'ThisExpression' && property?.type === 'PrivateIdentifier') {
if (state.private_derived_state.includes(property.name)) {
e.constant_assignment(node, 'derived state'); e.constant_assignment(node, 'derived state');
} }
} }
}
/** /**
* @param {NodeLike} node * @param {NodeLike} node

@ -591,6 +591,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'];

@ -137,6 +137,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(''));
} }

@ -246,6 +246,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

@ -58,6 +58,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` */

@ -1,5 +1,5 @@
import { DEV } from 'esm-env'; import { DEV } from 'esm-env';
import { hydrating } from '../hydration.js'; import { hydrating, set_hydrating } from '../hydration.js';
import { get_descriptors, get_prototype_of } from '../../../shared/utils.js'; import { get_descriptors, get_prototype_of } from '../../../shared/utils.js';
import { create_event, delegate } from './events.js'; import { create_event, delegate } from './events.js';
import { add_form_reset_listener, autofocus } from './misc.js'; import { add_form_reset_listener, autofocus } from './misc.js';
@ -213,6 +213,12 @@ export function set_custom_element_data(node, prop, value) {
// or effect // or effect
var previous_reaction = active_reaction; var previous_reaction = active_reaction;
var previous_effect = active_effect; var previous_effect = active_effect;
// If we're hydrating but the custom element is from Svelte, and it already scaffolded,
// then it might run block logic in hydration mode, which we have to prevent.
let was_hydrating = hydrating;
if (hydrating) {
set_hydrating(false);
}
set_active_reaction(null); set_active_reaction(null);
set_active_effect(null); set_active_effect(null);
@ -239,6 +245,9 @@ export function set_custom_element_data(node, prop, value) {
} finally { } finally {
set_active_reaction(previous_reaction); set_active_reaction(previous_reaction);
set_active_effect(previous_effect); set_active_effect(previous_effect);
if (was_hydrating) {
set_hydrating(true);
}
} }
} }
@ -262,6 +271,13 @@ export function set_attributes(
is_custom_element = false, is_custom_element = false,
skip_warning = false skip_warning = false
) { ) {
// If we're hydrating but the custom element is from Svelte, and it already scaffolded,
// then it might run block logic in hydration mode, which we have to prevent.
let is_hydrating_custom_element = hydrating && is_custom_element;
if (is_hydrating_custom_element) {
set_hydrating(false);
}
var current = prev || {}; var current = prev || {};
var is_option_element = element.tagName === 'OPTION'; var is_option_element = element.tagName === 'OPTION';
@ -363,9 +379,10 @@ export function set_attributes(
element.style.cssText = value + ''; element.style.cssText = value + '';
} else if (key === 'autofocus') { } else if (key === 'autofocus') {
autofocus(/** @type {HTMLElement} */ (element), Boolean(value)); autofocus(/** @type {HTMLElement} */ (element), Boolean(value));
} else if (key === '__value' || (key === 'value' && value != null)) { } else if (!is_custom_element && (key === '__value' || (key === 'value' && value != null))) {
// @ts-ignore // @ts-ignore We're not running this for custom elements because __value is actually
element.value = element[key] = element.__value = value; // how Lit stores the current value on the element, and messing with that would break things.
element.value = element.__value = value;
} else if (key === 'selected' && is_option_element) { } else if (key === 'selected' && is_option_element) {
set_selected(/** @type {HTMLOptionElement} */ (element), value); set_selected(/** @type {HTMLOptionElement} */ (element), value);
} else { } else {
@ -402,19 +419,19 @@ export function set_attributes(
// @ts-ignore // @ts-ignore
element[name] = value; element[name] = value;
} else if (typeof value !== 'function') { } else if (typeof value !== 'function') {
if (hydrating && (name === 'src' || name === 'href' || name === 'srcset')) {
if (!skip_warning) check_src_in_dev_hydration(element, name, value ?? '');
} else {
set_attribute(element, name, value); set_attribute(element, name, value);
} }
} }
}
if (key === 'style' && '__styles' in element) { if (key === 'style' && '__styles' in element) {
// reset styles to force style: directive to update // reset styles to force style: directive to update
element.__styles = {}; element.__styles = {};
} }
} }
if (is_hydrating_custom_element) {
set_hydrating(true);
}
return current; return current;
} }

@ -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++;
}

@ -97,7 +97,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 { async_derived, derived, derived_safe_equal } from './reactivity/deriveds.js'; export { async_derived, derived, derived_safe_equal } from './reactivity/deriveds.js';
export { export {

@ -12,7 +12,6 @@ import {
import { import {
active_reaction, active_reaction,
active_effect, active_effect,
remove_reactions,
set_signal_status, set_signal_status,
skip_reaction, skip_reaction,
update_reaction, update_reaction,

@ -10,7 +10,15 @@ import {
import { get_descriptor, is_function } from '../../shared/utils.js'; import { get_descriptor, is_function } from '../../shared/utils.js';
import { mutable_source, set, source, update } from './sources.js'; import { mutable_source, set, source, update } from './sources.js';
import { derived, derived_safe_equal } from './deriveds.js'; import { derived, derived_safe_equal } from './deriveds.js';
import { active_effect, get, captured_signals, set_active_effect, untrack } from '../runtime.js'; import {
active_effect,
get,
captured_signals,
set_active_effect,
untrack,
active_reaction,
set_active_reaction
} from '../runtime.js';
import { safe_equals } from './equality.js'; import { safe_equals } from './equality.js';
import * as e from '../errors.js'; import * as e from '../errors.js';
import { import {
@ -241,26 +249,6 @@ export function spread_props(...props) {
return new Proxy({ props }, spread_props_handler); return new Proxy({ props }, spread_props_handler);
} }
/**
* @template T
* @param {() => T} fn
* @returns {T}
*/
function with_parent_branch(fn) {
var effect = active_effect;
var previous_effect = active_effect;
while (effect !== null && (effect.f & (BRANCH_EFFECT | ROOT_EFFECT)) === 0) {
effect = effect.parent;
}
try {
set_active_effect(effect);
return fn();
} finally {
set_active_effect(previous_effect);
}
}
/** /**
* This function is responsible for synchronizing a possibly bound prop with the inner component state. * This function is responsible for synchronizing a possibly bound prop with the inner component state.
* It is used whenever the compiler sees that the component writes to the prop, or when it has a default prop_value. * It is used whenever the compiler sees that the component writes to the prop, or when it has a default prop_value.
@ -335,8 +323,8 @@ export function prop(props, key, flags, fallback) {
} else { } else {
// Svelte 4 did not trigger updates when a primitive value was updated to the same value. // Svelte 4 did not trigger updates when a primitive value was updated to the same value.
// Replicate that behavior through using a derived // Replicate that behavior through using a derived
var derived_getter = with_parent_branch(() => var derived_getter = (immutable ? derived : derived_safe_equal)(
(immutable ? derived : derived_safe_equal)(() => /** @type {V} */ (props[key])) () => /** @type {V} */ (props[key])
); );
derived_getter.f |= LEGACY_DERIVED_PROP; derived_getter.f |= LEGACY_DERIVED_PROP;
getter = () => { getter = () => {
@ -380,8 +368,7 @@ export function prop(props, key, flags, fallback) {
// The derived returns the current value. The underlying mutable // The derived returns the current value. The underlying mutable
// source is written to from various places to persist this value. // source is written to from various places to persist this value.
var inner_current_value = mutable_source(prop_value); var inner_current_value = mutable_source(prop_value);
var current_value = with_parent_branch(() => var current_value = derived(() => {
derived(() => {
var parent_value = getter(); var parent_value = getter();
var child_value = get(inner_current_value); var child_value = get(inner_current_value);
@ -393,8 +380,7 @@ export function prop(props, key, flags, fallback) {
was_from_child = false; was_from_child = false;
return (inner_current_value.v = parent_value); return (inner_current_value.v = parent_value);
}) });
);
if (!immutable) current_value.equals = safe_equals; if (!immutable) current_value.equals = safe_equals;

@ -209,18 +209,28 @@ export function check_dirtiness(reaction) {
(is_disconnected || is_unowned_connected) && (is_disconnected || is_unowned_connected) &&
(active_effect === null || (active_effect.f & DESTROYED) === 0) (active_effect === null || (active_effect.f & DESTROYED) === 0)
) { ) {
var derived = /** @type {Derived} */ (reaction);
var parent = derived.parent;
for (i = 0; i < length; i++) { for (i = 0; i < length; i++) {
dependency = dependencies[i]; dependency = dependencies[i];
// We always re-add all reactions (even duplicates) if the derived was // We always re-add all reactions (even duplicates) if the derived was
// previously disconnected // previously disconnected, however we don't if it was unowned as we
if (is_disconnected || !dependency?.reactions?.includes(reaction)) { // de-duplicate dependencies in that case
(dependency.reactions ??= []).push(reaction); if (is_disconnected || !dependency?.reactions?.includes(derived)) {
(dependency.reactions ??= []).push(derived);
} }
} }
if (is_disconnected) { if (is_disconnected) {
reaction.f ^= DISCONNECTED; derived.f ^= DISCONNECTED;
}
// If the unowned derived is now fully connected to the graph again (it's unowned and reconnected, has a parent
// and the parent is not unowned), then we can mark it as connected again, removing the need for the unowned
// flag
if (is_unowned_connected && parent !== null && (parent.f & UNOWNED) === 0) {
derived.f ^= UNOWNED;
} }
} }
@ -423,15 +433,9 @@ export function update_reaction(reaction) {
skipped_deps = 0; skipped_deps = 0;
untracked_writes = null; untracked_writes = null;
active_reaction = (flags & (BRANCH_EFFECT | ROOT_EFFECT)) === 0 ? reaction : null; active_reaction = (flags & (BRANCH_EFFECT | ROOT_EFFECT)) === 0 ? reaction : null;
// prettier-ignore
skip_reaction = skip_reaction =
(flags & UNOWNED) !== 0 && (flags & UNOWNED) !== 0 &&
(!is_flushing_effect || (!is_flushing_effect || previous_reaction === null || previous_untracking);
// If we were previously not in a reactive context and we're reading an unowned derived
// that was created inside another reaction, then we don't fully know the real owner and thus
// we need to skip adding any reactions for this unowned
((previous_reaction === null || previous_untracking) &&
/** @type {Derived} */ (reaction).parent !== null));
derived_sources = null; derived_sources = null;
set_component_context(reaction.ctx); set_component_context(reaction.ctx);
@ -697,10 +701,7 @@ function flush_queued_root_effects(root_effects) {
effect.f ^= CLEAN; effect.f ^= CLEAN;
} }
/** @type {Effect[]} */ var collected_effects = process_effects(effect);
var collected_effects = [];
process_effects(effect, collected_effects);
flush_queued_effects(collected_effects); flush_queued_effects(collected_effects);
} }
} finally { } finally {
@ -805,11 +806,11 @@ export function schedule_effect(signal) {
* effects to be flushed. * effects to be flushed.
* *
* @param {Effect} effect * @param {Effect} effect
* @param {Effect[]} collected_effects * @param {Effect[]} effects
* @param {Boundary} [boundary] * @param {Boundary} [boundary]
* @returns {void} * @returns {Effect[]}
*/ */
function process_effects(effect, collected_effects, boundary) { function process_effects(effect, effects = [], boundary) {
var current_effect = effect.first; var current_effect = effect.first;
main_loop: while (current_effect !== null) { main_loop: while (current_effect !== null) {
@ -825,14 +826,14 @@ function process_effects(effect, collected_effects, boundary) {
} else if ((flags & BOUNDARY_EFFECT) !== 0) { } else if ((flags & BOUNDARY_EFFECT) !== 0) {
var b = /** @type {Boundary} */ (current_effect.b); var b = /** @type {Boundary} */ (current_effect.b);
process_effects(current_effect, collected_effects, b); process_effects(current_effect, effects, b);
if (!b.suspended) { if (!b.suspended) {
// no more async work to happen // no more async work to happen
b.commit(); b.commit();
} }
} else if ((flags & EFFECT) !== 0) { } else if ((flags & EFFECT) !== 0) {
collected_effects.push(current_effect); effects.push(current_effect);
} else if (is_branch) { } else if (is_branch) {
current_effect.f ^= CLEAN; current_effect.f ^= CLEAN;
} else { } else {
@ -879,6 +880,8 @@ function process_effects(effect, collected_effects, boundary) {
current_effect = sibling; current_effect = sibling;
} }
return effects;
} }
/** /**
@ -975,7 +978,10 @@ export function get(signal) {
skipped_deps++; skipped_deps++;
} else if (new_deps === null) { } else if (new_deps === null) {
new_deps = [signal]; new_deps = [signal];
} else { } else if (!skip_reaction || !new_deps.includes(signal)) {
// Normally we can push duplicated dependencies to `new_deps`, but if we're inside
// an unowned derived because skip_reaction is true, then we need to ensure that
// we don't have duplicates
new_deps.push(signal); new_deps.push(signal);
} }
} }

@ -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',

@ -4,5 +4,5 @@
* The current version, as set in package.json. * The current version, as set in package.json.
* @type {string} * @type {string}
*/ */
export const VERSION = '5.19.7'; export const VERSION = '5.19.10';
export const PUBLIC_VERSION = '5'; export const PUBLIC_VERSION = '5';

@ -1 +1 @@
<!--[--><a href="/foo">foo</a><!--]--> <!--[--><a href="/foo">foo</a> <a href="/foo">foo</a><!--]-->

@ -3,3 +3,4 @@
</script> </script>
<a href={browser ? '/foo' : '/bar'}>foo</a> <a href={browser ? '/foo' : '/bar'}>foo</a>
<a {...{href: browser ? '/foo' : '/bar'}}>foo</a>

@ -1,19 +1,24 @@
import { test } from '../../test'; import { test } from '../../test';
export default test({ export default test({
mode: ['client', 'server'], mode: ['client'],
async test({ assert, target }) { async test({ assert, target }) {
const my_element = /** @type HTMLElement & { object: { test: true }; } */ ( const my_element = /** @type HTMLElement & { object: { test: true }; } */ (
target.querySelector('my-element') target.querySelector('my-element')
); );
const my_link = /** @type HTMLAnchorElement & { object: { test: true }; } */ (
target.querySelector('a')
);
assert.equal(my_element.getAttribute('string'), 'test'); assert.equal(my_element.getAttribute('string'), 'test');
assert.equal(my_element.hasAttribute('object'), false); assert.equal(my_element.hasAttribute('object'), false);
assert.deepEqual(my_element.object, { test: true }); assert.deepEqual(my_element.object, { test: true });
const my_link = /** @type HTMLAnchorElement & { object: { test: true }; } */ (
target.querySelector('a')
);
assert.equal(my_link.getAttribute('string'), 'test'); assert.equal(my_link.getAttribute('string'), 'test');
assert.equal(my_link.hasAttribute('object'), false); assert.equal(my_link.hasAttribute('object'), false);
assert.deepEqual(my_link.object, { test: true }); assert.deepEqual(my_link.object, { test: true });
const [value1, value2] = target.querySelectorAll('value-element');
assert.equal(value1.shadowRoot?.innerHTML, '<span>test</span>');
assert.equal(value2.shadowRoot?.innerHTML, '<span>test</span>');
} }
}); });

@ -1,2 +1,22 @@
<script module>
customElements.define('value-element', class extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
set value(v) {
if (this.__value !== v) {
this.__value = v;
this.shadowRoot.innerHTML = `<span>${v}</span>`;
}
}
});
</script>
<my-element string="test" object={{ test: true }}></my-element> <my-element string="test" object={{ test: true }}></my-element>
<a is="my-link" string="test" object={{ test: true }}></a> <a is="my-link" string="test" object={{ test: true }}></a>
<value-element value="test"></value-element>
<value-element {...{value: "test"}}></value-element>

@ -0,0 +1,25 @@
import { flushSync } from 'svelte';
import { test } from '../../test';
export default test({
async test({ assert, target }) {
let [btn1, btn2] = target.querySelectorAll('button');
btn1?.click();
flushSync();
btn2?.click();
flushSync();
btn1?.click();
flushSync();
btn1?.click();
flushSync();
assert.htmlEqual(
target.innerHTML,
`<button>linked.current</button>\n3\n<button>count</button>\n1`
);
}
});

@ -0,0 +1,18 @@
<script>
import { untrack } from 'svelte';
let count = $state(0);
let state = $state({current: count});
let linked = $derived.by(() => {
count;
untrack(() => state.current = count);
return untrack(() => state);
});
linked.current++;
</script>
<button onclick={() => linked.current++}>linked.current</button> {linked.current}
<button onclick={() => count++}>count</button> {count}

@ -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}

@ -224,20 +224,75 @@ describe('signals', () => {
}; };
}); });
test('https://perf.js.hyoo.ru/#!bench=9h2as6_u0mfnn #2', () => {
let res: number[] = [];
const numbers = Array.from({ length: 2 }, (_, i) => i);
const fib = (n: number): number => (n < 2 ? 1 : fib(n - 1) + fib(n - 2));
const hard = (n: number, l: string) => n + fib(16);
const A = state(0);
const B = state(0);
return () => {
const C = derived(() => ($.get(A) % 2) + ($.get(B) % 2));
const D = derived(() => numbers.map((i) => i + ($.get(A) % 2) - ($.get(B) % 2)));
const E = derived(() => hard($.get(C) + $.get(A) + $.get(D)[0]!, 'E'));
const F = derived(() => hard($.get(D)[0]! && $.get(B), 'F'));
const G = derived(() => $.get(C) + ($.get(C) || $.get(E) % 2) + $.get(D)[0]! + $.get(F));
const destroy = effect_root(() => {
effect(() => {
res.push(hard($.get(G), 'H'));
});
effect(() => {
res.push($.get(G));
});
effect(() => {
res.push(hard($.get(F), 'J'));
});
});
flushSync();
let i = 2;
while (--i) {
res.length = 0;
set(B, 1);
set(A, 1 + i * 2);
flushSync();
set(A, 2 + i * 2);
set(B, 2);
flushSync();
assert.equal(res.length, 4);
assert.deepEqual(res, [3198, 1601, 3195, 1598]);
}
destroy();
assert(A.reactions === null);
assert(B.reactions === null);
};
});
test('effects correctly handle unowned derived values that do not change', () => { test('effects correctly handle unowned derived values that do not change', () => {
const log: number[] = []; const log: number[] = [];
return () => {
let count = state(0); let count = state(0);
const read = () => { const read = () => {
const x = derived(() => ({ count: $.get(count) })); const x = derived(() => ({ count: $.get(count) }));
return $.get(x); return $.get(x);
}; };
const derivedCount = derived(() => read().count); const derivedCount = derived(() => read().count);
const destroy = effect_root(() => {
user_effect(() => { user_effect(() => {
log.push($.get(derivedCount)); log.push($.get(derivedCount));
}); });
});
return () => {
flushSync(() => set(count, 1)); flushSync(() => set(count, 1));
// Ensure we're not leaking consumers // Ensure we're not leaking consumers
assert.deepEqual(count.reactions?.length, 1); assert.deepEqual(count.reactions?.length, 1);
@ -248,6 +303,8 @@ describe('signals', () => {
// Ensure we're not leaking consumers // Ensure we're not leaking consumers
assert.deepEqual(count.reactions?.length, 1); assert.deepEqual(count.reactions?.length, 1);
assert.deepEqual(log, [0, 1, 2, 3]); assert.deepEqual(log, [0, 1, 2, 3]);
destroy();
}; };
}); });
@ -343,14 +400,24 @@ describe('signals', () => {
}; };
}); });
test('two effects with an unowned derived that has some dependencies', () => {
const log: Array<Array<any>> = [];
return () => {
let some_state = state({}); let some_state = state({});
let some_deps = derived(() => { let some_deps = derived(() => {
return [$.get(some_state)]; return [$.get(some_state)];
}); });
let destroy2: any;
test('two effects with an unowned derived that has some dependencies', () => { const destroy = effect_root(() => {
const log: Array<Array<any>> = []; render_effect(() => {
$.untrack(() => {
log.push($.get(some_deps));
});
});
destroy2 = effect_root(() => {
render_effect(() => { render_effect(() => {
log.push($.get(some_deps)); log.push($.get(some_deps));
}); });
@ -358,10 +425,44 @@ describe('signals', () => {
render_effect(() => { render_effect(() => {
log.push($.get(some_deps)); log.push($.get(some_deps));
}); });
});
});
return () => { set(some_state, {});
flushSync();
assert.deepEqual(log, [[{}], [{}], [{}], [{}], [{}]]);
destroy2();
set(some_state, {});
flushSync();
assert.deepEqual(log, [[{}], [{}], [{}], [{}], [{}]]);
log.length = 0;
const destroy3 = effect_root(() => {
render_effect(() => {
$.untrack(() => {
log.push($.get(some_deps));
});
log.push($.get(some_deps));
});
});
set(some_state, {});
flushSync(); flushSync();
assert.deepEqual(log, [[{}], [{}]]);
assert.deepEqual(log, [[{}], [{}], [{}], [{}]]);
destroy3();
assert(some_state.reactions === null);
destroy();
assert(some_state.reactions === null);
}; };
}); });
@ -818,6 +919,28 @@ describe('signals', () => {
}; };
}); });
test('unowned deriveds dependencies are correctly de-duped', () => {
return () => {
let a = state(0);
let b = state(true);
let c = derived(() => $.get(a));
let d = derived(() => ($.get(b) ? 1 : $.get(a) + $.get(c) + $.get(a)));
$.get(d);
assert.equal(d.deps?.length, 1);
$.get(d);
set(a, 1);
set(b, false);
$.get(d);
assert.equal(d.deps?.length, 3);
};
});
test('unowned deriveds correctly update', () => { test('unowned deriveds correctly update', () => {
return () => { return () => {
const arr1 = proxy<{ a: number }[]>([]); const arr1 = proxy<{ a: number }[]>([]);

@ -0,0 +1,9 @@
<script>
class Test{
#der = $derived({test: 0});
set test(v){
this.#der.test = 45;
}
}
</script>

@ -0,0 +1,14 @@
[
{
"code": "constant_assignment",
"message": "Cannot assign to derived state",
"start": {
"column": 3,
"line": 6
},
"end": {
"column": 29,
"line": 6
}
}
]

@ -0,0 +1,9 @@
<script>
class Test{
der = $derived({ test: 0 });
set test(v){
this["der"] = { test: 45 };
}
}
</script>

@ -0,0 +1,14 @@
[
{
"code": "constant_assignment",
"message": "Cannot assign to derived state",
"start": {
"column": 3,
"line": 6
},
"end": {
"column": 27,
"line": 6
}
}
]

@ -0,0 +1,9 @@
<script>
class Test{
#der = $derived({ test: 0 });
set test(v){
this.#der = { test: 45 };
}
}
</script>

@ -0,0 +1,14 @@
[
{
"code": "constant_assignment",
"message": "Cannot assign to derived state",
"start": {
"column": 3,
"line": 6
},
"end": {
"column": 26,
"line": 6
}
}
]

@ -0,0 +1,9 @@
<script>
class Test{
der = $derived({ test: 0 });
set test(v){
this.der = { test: 45 };
}
}
</script>

@ -3005,6 +3005,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;

@ -11,14 +11,14 @@
"prod": "npm run build && node dist/server/ssr-prod", "prod": "npm run build && node dist/server/ssr-prod",
"preview": "vite preview", "preview": "vite preview",
"download": "node scripts/download.js", "download": "node scripts/download.js",
"hash": "node scripts/hash.js | pbcopy && echo \"copied URL to clipboard\"" "hash": "node scripts/hash.js"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/vite-plugin-svelte": "^4.0.0-next.6", "@sveltejs/vite-plugin-svelte": "^4.0.0-next.6",
"polka": "^1.0.0-next.25", "polka": "^1.0.0-next.25",
"svelte": "workspace:*", "svelte": "workspace:*",
"tiny-glob": "^0.2.9", "tiny-glob": "^0.2.9",
"vite": "^6.0.9", "vite": "^5.4.14",
"vite-plugin-inspect": "^0.8.4" "vite-plugin-inspect": "^0.8.4"
} }
} }

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save