Merge branch 'main' into out-of-order-rendering

pull/17038/head
Rich Harris 4 weeks ago
commit c34348e44e

@ -0,0 +1,5 @@
---
"svelte": patch
---
feat: experimental `fork` API

@ -135,6 +135,54 @@ If a `<svelte:boundary>` with a `pending` snippet is encountered during SSR, tha
> [!NOTE] In the future, we plan to add a streaming implementation that renders the content in the background. > [!NOTE] In the future, we plan to add a streaming implementation that renders the content in the background.
## Forking
The [`fork(...)`](svelte#fork) API, added in 5.42, makes it possible to run `await` expressions that you _expect_ to happen in the near future. This is mainly intended for frameworks like SvelteKit to implement preloading when (for example) users signal an intent to navigate.
```svelte
<script>
import { fork } from 'svelte';
import Menu from './Menu.svelte';
let open = $state(false);
/** @type {import('svelte').Fork | null} */
let pending = null;
function preload() {
pending ??= fork(() => {
open = true;
});
}
function discard() {
pending?.discard();
pending = null;
}
</script>
<button
onfocusin={preload}
onfocusout={discard}
onpointerenter={preload}
onpointerleave={discard}
onclick={() => {
pending?.commit();
pending = null;
// in case `pending` didn't exist
// (if it did, this is a no-op)
open = true;
}}
>open menu</button>
{#if open}
<!-- any async work inside this component will start
as soon as the fork is created -->
<Menu onclose={() => open = false} />
{/if}
```
## Caveats ## Caveats
As an experimental feature, the details of how `await` is handled (and related APIs like `$effect.pending()`) are subject to breaking changes outside of a semver major release, though we intend to keep such changes to a bare minimum. As an experimental feature, the details of how `await` is handled (and related APIs like `$effect.pending()`) are subject to breaking changes outside of a semver major release, though we intend to keep such changes to a bare minimum.

@ -130,6 +130,12 @@ $effect(() => {
Often when encountering this issue, the value in question shouldn't be state (for example, if you are pushing to a `logs` array in an effect, make `logs` a normal array rather than `$state([])`). In the rare cases where you really _do_ need to write to state in an effect — [which you should avoid]($effect#When-not-to-use-$effect) — you can read the state with [untrack](svelte#untrack) to avoid adding it as a dependency. Often when encountering this issue, the value in question shouldn't be state (for example, if you are pushing to a `logs` array in an effect, make `logs` a normal array rather than `$state([])`). In the rare cases where you really _do_ need to write to state in an effect — [which you should avoid]($effect#When-not-to-use-$effect) — you can read the state with [untrack](svelte#untrack) to avoid adding it as a dependency.
### experimental_async_fork
```
Cannot use `fork(...)` unless the `experimental.async` compiler option is `true`
```
### flush_sync_in_effect ### flush_sync_in_effect
``` ```
@ -140,6 +146,18 @@ The `flushSync()` function can be used to flush any pending effects synchronousl
This restriction only applies when using the `experimental.async` option, which will be active by default in Svelte 6. This restriction only applies when using the `experimental.async` option, which will be active by default in Svelte 6.
### fork_discarded
```
Cannot commit a fork that was already committed or discarded
```
### fork_timing
```
Cannot create a fork inside an effect or when state changes are pending
```
### get_abort_signal_outside_reaction ### get_abort_signal_outside_reaction
``` ```

@ -100,6 +100,10 @@ $effect(() => {
Often when encountering this issue, the value in question shouldn't be state (for example, if you are pushing to a `logs` array in an effect, make `logs` a normal array rather than `$state([])`). In the rare cases where you really _do_ need to write to state in an effect — [which you should avoid]($effect#When-not-to-use-$effect) — you can read the state with [untrack](svelte#untrack) to avoid adding it as a dependency. Often when encountering this issue, the value in question shouldn't be state (for example, if you are pushing to a `logs` array in an effect, make `logs` a normal array rather than `$state([])`). In the rare cases where you really _do_ need to write to state in an effect — [which you should avoid]($effect#When-not-to-use-$effect) — you can read the state with [untrack](svelte#untrack) to avoid adding it as a dependency.
## experimental_async_fork
> Cannot use `fork(...)` unless the `experimental.async` compiler option is `true`
## flush_sync_in_effect ## flush_sync_in_effect
> Cannot use `flushSync` inside an effect > Cannot use `flushSync` inside an effect
@ -108,6 +112,14 @@ The `flushSync()` function can be used to flush any pending effects synchronousl
This restriction only applies when using the `experimental.async` option, which will be active by default in Svelte 6. This restriction only applies when using the `experimental.async` option, which will be active by default in Svelte 6.
## fork_discarded
> Cannot commit a fork that was already committed or discarded
## fork_timing
> Cannot create a fork inside an effect or when state changes are pending
## get_abort_signal_outside_reaction ## get_abort_signal_outside_reaction
> `getAbortSignal()` can only be called inside an effect or derived > `getAbortSignal()` can only be called inside an effect or derived

@ -1,12 +1,7 @@
/** @import { ArrowFunctionExpression, Expression, FunctionDeclaration, FunctionExpression } from 'estree' */ /** @import { AST } from '#compiler' */
/** @import { AST, DelegatedEvent } from '#compiler' */
/** @import { Context } from '../types' */ /** @import { Context } from '../types' */
import { cannot_be_set_statically, is_capture_event, is_delegated } from '../../../../utils.js'; import { cannot_be_set_statically, can_delegate_event } from '../../../../utils.js';
import { import { get_attribute_chunks, is_event_attribute } from '../../../utils/ast.js';
get_attribute_chunks,
get_attribute_expression,
is_event_attribute
} from '../../../utils/ast.js';
import { mark_subtree_dynamic } from './shared/fragment.js'; import { mark_subtree_dynamic } from './shared/fragment.js';
/** /**
@ -64,181 +59,8 @@ export function Attribute(node, context) {
context.state.analysis.uses_event_attributes = true; context.state.analysis.uses_event_attributes = true;
} }
const expression = get_attribute_expression(node); node.metadata.delegated =
const delegated_event = get_delegated_event(node.name.slice(2), expression, context); parent?.type === 'RegularElement' && can_delegate_event(node.name.slice(2));
if (delegated_event !== null) {
if (delegated_event.hoisted) {
delegated_event.function.metadata.hoisted = true;
}
node.metadata.delegated = delegated_event;
}
}
}
}
/** @type {DelegatedEvent} */
const unhoisted = { hoisted: false };
/**
* Checks if given event attribute can be delegated/hoisted and returns the corresponding info if so
* @param {string} event_name
* @param {Expression | null} handler
* @param {Context} context
* @returns {null | DelegatedEvent}
*/
function get_delegated_event(event_name, handler, context) {
// Handle delegated event handlers. Bail out if not a delegated event.
if (!handler || !is_delegated(event_name)) {
return null;
}
// If we are not working with a RegularElement, then bail out.
const element = context.path.at(-1);
if (element?.type !== 'RegularElement') {
return null;
}
/** @type {FunctionExpression | FunctionDeclaration | ArrowFunctionExpression | null} */
let target_function = null;
let binding = null;
if (element.metadata.has_spread) {
// event attribute becomes part of the dynamic spread array
return unhoisted;
}
if (handler.type === 'ArrowFunctionExpression' || handler.type === 'FunctionExpression') {
target_function = handler;
} else if (handler.type === 'Identifier') {
binding = context.state.scope.get(handler.name);
if (context.state.analysis.module.scope.references.has(handler.name)) {
// If a binding with the same name is referenced in the module scope (even if not declared there), bail out
return unhoisted;
}
if (binding != null) {
for (const { path } of binding.references) {
const parent = path.at(-1);
if (parent === undefined) return unhoisted;
const grandparent = path.at(-2);
/** @type {AST.RegularElement | null} */
let element = null;
/** @type {string | null} */
let event_name = null;
if (parent.type === 'OnDirective') {
element = /** @type {AST.RegularElement} */ (grandparent);
event_name = parent.name;
} else if (
parent.type === 'ExpressionTag' &&
grandparent?.type === 'Attribute' &&
is_event_attribute(grandparent)
) {
element = /** @type {AST.RegularElement} */ (path.at(-3));
const attribute = /** @type {AST.Attribute} */ (grandparent);
event_name = get_attribute_event_name(attribute.name);
}
if (element && event_name) {
if (
element.type !== 'RegularElement' ||
element.metadata.has_spread ||
!is_delegated(event_name)
) {
return unhoisted;
}
} else if (parent.type !== 'FunctionDeclaration' && parent.type !== 'VariableDeclarator') {
return unhoisted;
}
}
} }
// If the binding is exported, bail out
if (context.state.analysis.exports.find((node) => node.name === handler.name)) {
return unhoisted;
}
if (binding?.is_function()) {
target_function = binding.initial;
}
}
// If we can't find a function, or the function has multiple parameters, bail out
if (target_function == null || target_function.params.length > 1) {
return unhoisted;
}
const visited_references = new Set();
const scope = target_function.metadata.scope;
for (const [reference] of scope.references) {
// Bail out if the arguments keyword is used or $host is referenced
if (reference === 'arguments' || reference === '$host') return unhoisted;
// Bail out if references a store subscription
if (scope.get(`$${reference}`)?.kind === 'store_sub') return unhoisted;
const binding = scope.get(reference);
const local_binding = context.state.scope.get(reference);
// if the function access a snippet that can't be hoisted we bail out
if (
local_binding !== null &&
local_binding.initial?.type === 'SnippetBlock' &&
!local_binding.initial.metadata.can_hoist
) {
return unhoisted;
}
// If we are referencing a binding that is shadowed in another scope then bail out (unless it's declared within the function).
if (
local_binding !== null &&
binding !== null &&
local_binding.node !== binding.node &&
scope.declarations.get(reference) !== binding
) {
return unhoisted;
}
// If we have multiple references to the same store using $ prefix, bail out.
if (
binding !== null &&
binding.kind === 'store_sub' &&
visited_references.has(reference.slice(1))
) {
return unhoisted;
}
// If we reference the index within an each block, then bail out.
if (binding !== null && binding.initial?.type === 'EachBlock') return unhoisted;
if (
binding !== null &&
// Bail out if the binding is a rest param
(binding.declaration_kind === 'rest_param' ||
// Bail out if we reference anything from the EachBlock (for now) that mutates in non-runes mode,
(((!context.state.analysis.runes && binding.kind === 'each') ||
// or any normal not reactive bindings that are mutated.
binding.kind === 'normal') &&
binding.updated))
) {
return unhoisted;
}
visited_references.add(reference);
}
return { hoisted: true, function: target_function };
}
/**
* @param {string} event_name
*/
function get_attribute_event_name(event_name) {
event_name = event_name.slice(2);
if (is_capture_event(event_name)) {
event_name = event_name.slice(0, -7);
} }
return event_name;
} }

@ -6,13 +6,6 @@
* @param {Context} context * @param {Context} context
*/ */
export function visit_function(node, context) { export function visit_function(node, context) {
// TODO retire this in favour of a more general solution based on bindings
node.metadata = {
hoisted: false,
hoisted_params: [],
scope: context.state.scope
};
if (context.state.expression) { if (context.state.expression) {
for (const [name] of context.state.scope.references) { for (const [name] of context.state.scope.references) {
const binding = context.state.scope.get(name); const binding = context.state.scope.get(name);

@ -1,6 +1,6 @@
/** @import { ArrowFunctionExpression, AssignmentExpression, BlockStatement, Expression, FunctionDeclaration, FunctionExpression, Identifier, Node, Pattern, UpdateExpression } from 'estree' */ /** @import { BlockStatement, Expression, Identifier } from 'estree' */
/** @import { Binding } from '#compiler' */ /** @import { Binding } from '#compiler' */
/** @import { ClientTransformState, ComponentClientTransformState, ComponentContext } from './types.js' */ /** @import { ClientTransformState, ComponentClientTransformState } from './types.js' */
/** @import { Analysis } from '../../types.js' */ /** @import { Analysis } from '../../types.js' */
/** @import { Scope } from '../../scope.js' */ /** @import { Scope } from '../../scope.js' */
import * as b from '#compiler/builders'; import * as b from '#compiler/builders';
@ -12,9 +12,6 @@ import {
PROPS_IS_UPDATED, PROPS_IS_UPDATED,
PROPS_IS_BINDABLE PROPS_IS_BINDABLE
} from '../../../../constants.js'; } from '../../../../constants.js';
import { dev } from '../../../state.js';
import { walk } from 'zimmerframe';
import { validate_mutation } from './visitors/shared/utils.js';
/** /**
* @param {Binding} binding * @param {Binding} binding
@ -46,125 +43,6 @@ export function build_getter(node, state) {
return node; return node;
} }
/**
* @param {FunctionDeclaration | FunctionExpression | ArrowFunctionExpression} node
* @param {ComponentContext} context
* @returns {Pattern[]}
*/
function get_hoisted_params(node, context) {
const scope = context.state.scope;
/** @type {Identifier[]} */
const params = [];
/**
* We only want to push if it's not already present to avoid name clashing
* @param {Identifier} id
*/
function push_unique(id) {
if (!params.find((param) => param.name === id.name)) {
params.push(id);
}
}
for (const [reference] of scope.references) {
let binding = scope.get(reference);
if (binding !== null && !scope.declarations.has(reference) && binding.initial !== node) {
if (binding.kind === 'store_sub') {
// We need both the subscription for getting the value and the store for updating
push_unique(b.id(binding.node.name));
binding = /** @type {Binding} */ (scope.get(binding.node.name.slice(1)));
}
let expression = context.state.transform[reference]?.read(b.id(binding.node.name));
if (
// If it's a destructured derived binding, then we can extract the derived signal reference and use that.
// TODO this code is bad, we need to kill it
expression != null &&
typeof expression !== 'function' &&
expression.type === 'MemberExpression' &&
expression.object.type === 'CallExpression' &&
expression.object.callee.type === 'Identifier' &&
expression.object.callee.name === '$.get' &&
expression.object.arguments[0].type === 'Identifier'
) {
push_unique(b.id(expression.object.arguments[0].name));
} else if (
// If we are referencing a simple $$props value, then we need to reference the object property instead
(binding.kind === 'prop' || binding.kind === 'bindable_prop') &&
!is_prop_source(binding, context.state)
) {
push_unique(b.id('$$props'));
} else if (
// imports don't need to be hoisted
binding.declaration_kind !== 'import'
) {
// create a copy to remove start/end tags which would mess up source maps
push_unique(b.id(binding.node.name));
// rest props are often accessed through the $$props object for optimization reasons,
// but we can't know if the delegated event handler will use it, so we need to add both as params
if (binding.kind === 'rest_prop' && context.state.analysis.runes) {
push_unique(b.id('$$props'));
}
}
}
}
if (dev) {
// this is a little hacky, but necessary for ownership validation
// to work inside hoisted event handlers
/**
* @param {AssignmentExpression | UpdateExpression} node
* @param {{ next: () => void, stop: () => void }} context
*/
function visit(node, { next, stop }) {
if (validate_mutation(node, /** @type {any} */ (context), node) !== node) {
params.push(b.id('$$ownership_validator'));
stop();
} else {
next();
}
}
walk(/** @type {Node} */ (node), null, {
AssignmentExpression: visit,
UpdateExpression: visit
});
}
return params;
}
/**
* @param {FunctionDeclaration | FunctionExpression | ArrowFunctionExpression} node
* @param {ComponentContext} context
* @returns {Pattern[]}
*/
export function build_hoisted_params(node, context) {
const hoisted_params = get_hoisted_params(node, context);
node.metadata.hoisted_params = hoisted_params;
/** @type {Pattern[]} */
const params = [];
if (node.params.length === 0) {
if (hoisted_params.length > 0) {
// For the event object
params.push(b.id(context.state.scope.generate('_')));
}
} else {
for (const param of node.params) {
params.push(/** @type {Pattern} */ (context.visit(param)));
}
}
params.push(...hoisted_params);
return params;
}
/** /**
* @param {Binding} binding * @param {Binding} binding
* @param {ComponentClientTransformState} state * @param {ComponentClientTransformState} state

@ -1,7 +1,5 @@
/** @import { FunctionDeclaration } from 'estree' */ /** @import { FunctionDeclaration } from 'estree' */
/** @import { ComponentContext } from '../types' */ /** @import { ComponentContext } from '../types' */
import { build_hoisted_params } from '../utils.js';
import * as b from '#compiler/builders';
/** /**
* @param {FunctionDeclaration} node * @param {FunctionDeclaration} node
@ -10,14 +8,5 @@ import * as b from '#compiler/builders';
export function FunctionDeclaration(node, context) { export function FunctionDeclaration(node, context) {
const state = { ...context.state, in_constructor: false, in_derived: false }; const state = { ...context.state, in_constructor: false, in_derived: false };
if (node.metadata?.hoisted === true) {
const params = build_hoisted_params(node, context);
const body = context.visit(node.body, state);
context.state.hoisted.push(/** @type {FunctionDeclaration} */ ({ ...node, params, body }));
return b.empty;
}
context.next(state); context.next(state);
} }

@ -7,7 +7,6 @@ import * as b from '#compiler/builders';
import * as assert from '../../../../utils/assert.js'; import * as assert from '../../../../utils/assert.js';
import { get_rune } from '../../../scope.js'; import { get_rune } from '../../../scope.js';
import { get_prop_source, is_prop_source, is_state_source, should_proxy } from '../utils.js'; import { get_prop_source, is_prop_source, is_state_source, should_proxy } from '../utils.js';
import { is_hoisted_function } from '../../utils.js';
import { get_value } from './shared/declarations.js'; import { get_value } from './shared/declarations.js';
/** /**
@ -32,13 +31,6 @@ export function VariableDeclaration(node, context) {
rune === '$state.snapshot' || rune === '$state.snapshot' ||
rune === '$host' rune === '$host'
) { ) {
if (init != null && is_hoisted_function(init)) {
context.state.hoisted.push(
b.const(declarator.id, /** @type {Expression} */ (context.visit(init)))
);
continue;
}
declarations.push(/** @type {VariableDeclarator} */ (context.visit(declarator))); declarations.push(/** @type {VariableDeclarator} */ (context.visit(declarator)));
continue; continue;
} }
@ -295,16 +287,6 @@ export function VariableDeclaration(node, context) {
const has_props = bindings.some((binding) => binding.kind === 'bindable_prop'); const has_props = bindings.some((binding) => binding.kind === 'bindable_prop');
if (!has_state && !has_props) { if (!has_state && !has_props) {
const init = declarator.init;
if (init != null && is_hoisted_function(init)) {
context.state.hoisted.push(
b.const(declarator.id, /** @type {Expression} */ (context.visit(init)))
);
continue;
}
declarations.push(/** @type {VariableDeclarator} */ (context.visit(declarator))); declarations.push(/** @type {VariableDeclarator} */ (context.visit(declarator)));
continue; continue;
} }

@ -26,40 +26,12 @@ export function visit_event_attribute(node, context) {
let handler = build_event_handler(tag.expression, tag.metadata.expression, context); let handler = build_event_handler(tag.expression, tag.metadata.expression, context);
if (node.metadata.delegated) { if (node.metadata.delegated) {
let delegated_assignment;
if (!context.state.events.has(event_name)) { if (!context.state.events.has(event_name)) {
context.state.events.add(event_name); context.state.events.add(event_name);
} }
// Hoist function if we can, otherwise we leave the function as is
if (node.metadata.delegated.hoisted) {
if (node.metadata.delegated.function === tag.expression) {
const func_name = context.state.scope.root.unique('on_' + event_name);
context.state.hoisted.push(b.var(func_name, handler));
handler = func_name;
}
const hoisted_params = /** @type {Expression[]} */ (
node.metadata.delegated.function.metadata.hoisted_params
);
// When we hoist a function we assign an array with the function and all
// hoisted closure params.
if (hoisted_params) {
const args = [handler, ...hoisted_params];
delegated_assignment = b.array(args);
} else {
delegated_assignment = handler;
}
} else {
delegated_assignment = handler;
}
context.state.init.push( context.state.init.push(
b.stmt( b.stmt(b.assignment('=', b.member(context.state.node, '__' + event_name), handler))
b.assignment('=', b.member(context.state.node, '__' + event_name), delegated_assignment)
)
); );
} else { } else {
const statement = b.stmt( const statement = b.stmt(

@ -1,14 +1,11 @@
/** @import { ArrowFunctionExpression, FunctionExpression, Node } from 'estree' */ /** @import { ArrowFunctionExpression, FunctionExpression, Node } from 'estree' */
/** @import { ComponentContext } from '../../types' */ /** @import { ComponentContext } from '../../types' */
import { build_hoisted_params } from '../../utils.js';
/** /**
* @param {ArrowFunctionExpression | FunctionExpression} node * @param {ArrowFunctionExpression | FunctionExpression} node
* @param {ComponentContext} context * @param {ComponentContext} context
*/ */
export const visit_function = (node, context) => { export const visit_function = (node, context) => {
const metadata = node.metadata;
let state = { ...context.state, in_constructor: false, in_derived: false }; let state = { ...context.state, in_constructor: false, in_derived: false };
if (node.type === 'FunctionExpression') { if (node.type === 'FunctionExpression') {
@ -16,15 +13,5 @@ export const visit_function = (node, context) => {
state.in_constructor = parent.type === 'MethodDefinition' && parent.kind === 'constructor'; state.in_constructor = parent.type === 'MethodDefinition' && parent.kind === 'constructor';
} }
if (metadata?.hoisted === true) {
const params = build_hoisted_params(node, context);
return /** @type {FunctionExpression} */ ({
...node,
params,
body: context.visit(node.body, state)
});
}
context.next(state); context.next(state);
}; };

@ -1,36 +1,17 @@
/** @import { Context } from 'zimmerframe' */
/** @import { TransformState } from './types.js' */ /** @import { TransformState } from './types.js' */
/** @import { AST, Binding, Namespace, ValidatedCompileOptions } from '#compiler' */ /** @import { AST, Binding, Namespace, ValidatedCompileOptions } from '#compiler' */
/** @import { Node, Expression, CallExpression, MemberExpression } from 'estree' */ /** @import { Node, Expression, CallExpression, MemberExpression } from 'estree' */
import { import {
regex_ends_with_whitespaces, regex_ends_with_whitespaces,
regex_not_whitespace, regex_not_whitespace,
regex_starts_with_newline,
regex_starts_with_whitespaces regex_starts_with_whitespaces
} from '../patterns.js'; } from '../patterns.js';
import * as b from '#compiler/builders';
import * as e from '../../errors.js'; import * as e from '../../errors.js';
import { walk } from 'zimmerframe'; import { walk } from 'zimmerframe';
import { extract_identifiers } from '../../utils/ast.js'; import { extract_identifiers } from '../../utils/ast.js';
import check_graph_for_cycles from '../2-analyze/utils/check_graph_for_cycles.js'; import check_graph_for_cycles from '../2-analyze/utils/check_graph_for_cycles.js';
import is_reference from 'is-reference'; import is_reference from 'is-reference';
import { set_scope } from '../scope.js'; import { set_scope } from '../scope.js';
import { dev } from '../../state.js';
/**
* @param {Node} node
* @returns {boolean}
*/
export function is_hoisted_function(node) {
if (
node.type === 'ArrowFunctionExpression' ||
node.type === 'FunctionExpression' ||
node.type === 'FunctionDeclaration'
) {
return node.metadata?.hoisted === true;
}
return false;
}
/** /**
* Match Svelte 4 behaviour by sorting ConstTag nodes in topological order * Match Svelte 4 behaviour by sorting ConstTag nodes in topological order

@ -59,7 +59,7 @@ export function create_attribute(name, start, end, value) {
name, name,
value, value,
metadata: { metadata: {
delegated: null, delegated: false,
needs_clsx: false needs_clsx: false
} }
}; };

@ -134,29 +134,3 @@ export interface ComponentAnalysis extends Analysis {
*/ */
awaited_statements: Map<Statement | ModuleDeclaration, { id: Identifier; has_await: boolean }>; awaited_statements: Map<Statement | ModuleDeclaration, { id: Identifier; has_await: boolean }>;
} }
declare module 'estree' {
interface ArrowFunctionExpression {
metadata: {
hoisted: boolean;
hoisted_params: Pattern[];
scope: Scope;
};
}
interface FunctionExpression {
metadata: {
hoisted: boolean;
hoisted_params: Pattern[];
scope: Scope;
};
}
interface FunctionDeclaration {
metadata: {
hoisted: boolean;
hoisted_params: Pattern[];
scope: Scope;
};
}
}

@ -5,8 +5,6 @@ import type {
VariableDeclaration, VariableDeclaration,
VariableDeclarator, VariableDeclarator,
Expression, Expression,
FunctionDeclaration,
FunctionExpression,
Identifier, Identifier,
MemberExpression, MemberExpression,
Node, Node,
@ -27,13 +25,6 @@ import type { _CSS } from './css';
*/ */
export type Namespace = 'html' | 'svg' | 'mathml'; export type Namespace = 'html' | 'svg' | 'mathml';
export type DelegatedEvent =
| {
hoisted: true;
function: ArrowFunctionExpression | FunctionExpression | FunctionDeclaration;
}
| { hoisted: false };
export namespace AST { export namespace AST {
export interface BaseNode { export interface BaseNode {
type: string; type: string;
@ -531,7 +522,7 @@ export namespace AST {
/** @internal */ /** @internal */
metadata: { metadata: {
/** May be set if this is an event attribute */ /** May be set if this is an event attribute */
delegated: null | DelegatedEvent; delegated: boolean;
/** May be `true` if this is a `class` attribute that needs `clsx` */ /** May be `true` if this is a `class` attribute that needs `clsx` */
needs_clsx: boolean; needs_clsx: boolean;
}; };

@ -42,8 +42,7 @@ export function arrow(params, body, async = false) {
body, body,
expression: body.type !== 'BlockStatement', expression: body.type !== 'BlockStatement',
generator: false, generator: false,
async, async
metadata: /** @type {any} */ (null) // should not be used by codegen
}; };
} }
@ -237,8 +236,7 @@ export function function_declaration(id, params, body, async = false) {
params, params,
body, body,
generator: false, generator: false,
async, async
metadata: /** @type {any} */ (null) // should not be used by codegen
}; };
} }
@ -595,8 +593,7 @@ function function_builder(id, params, body, async = false) {
params, params,
body, body,
generator: false, generator: false,
async, async
metadata: /** @type {any} */ (null) // should not be used by codegen
}; };
} }

@ -241,7 +241,7 @@ function init_update_callbacks(context) {
return (l.u ??= { a: [], b: [], m: [] }); return (l.u ??= { a: [], b: [], m: [] });
} }
export { flushSync } from './internal/client/reactivity/batch.js'; export { flushSync, fork } from './internal/client/reactivity/batch.js';
export { export {
createContext, createContext,
getContext, getContext,

@ -33,6 +33,10 @@ export function unmount() {
e.lifecycle_function_unavailable('unmount'); e.lifecycle_function_unavailable('unmount');
} }
export function fork() {
e.lifecycle_function_unavailable('fork');
}
export async function tick() {} export async function tick() {}
export async function settled() {} export async function settled() {}

@ -352,4 +352,20 @@ export type MountOptions<Props extends Record<string, any> = Record<string, any>
props: Props; props: Props;
}); });
/**
* Represents work that is happening off-screen, such as data being preloaded
* in anticipation of the user navigating
* @since 5.42
*/
export interface Fork {
/**
* Commit the fork. The promise will resolve once the state change has been applied
*/
commit(): Promise<void>;
/**
* Discard the fork
*/
discard(): void;
}
export * from './index-client.js'; export * from './index-client.js';

@ -19,7 +19,7 @@ export const EFFECT_RAN = 1 << 15;
* This is on a block effect 99% of the time but may also be on a branch effect if its parent block effect was pruned * This is on a block effect 99% of the time but may also be on a branch effect if its parent block effect was pruned
*/ */
export const EFFECT_TRANSPARENT = 1 << 16; export const EFFECT_TRANSPARENT = 1 << 16;
export const INSPECT_EFFECT = 1 << 17; export const EAGER_EFFECT = 1 << 17;
export const HEAD_EFFECT = 1 << 18; export const HEAD_EFFECT = 1 << 18;
export const EFFECT_PRESERVED = 1 << 19; export const EFFECT_PRESERVED = 1 << 19;
export const USER_EFFECT = 1 << 20; export const USER_EFFECT = 1 << 20;

@ -1,6 +1,6 @@
import { UNINITIALIZED } from '../../../constants.js'; import { UNINITIALIZED } from '../../../constants.js';
import { snapshot } from '../../shared/clone.js'; import { snapshot } from '../../shared/clone.js';
import { inspect_effect, render_effect, validate_effect } from '../reactivity/effects.js'; import { eager_effect, render_effect, validate_effect } from '../reactivity/effects.js';
import { untrack } from '../runtime.js'; import { untrack } from '../runtime.js';
import { get_stack } from './tracing.js'; import { get_stack } from './tracing.js';
@ -19,7 +19,7 @@ export function inspect(get_value, inspector, show_stack = false) {
// stack traces. As a consequence, reading the value might result // stack traces. As a consequence, reading the value might result
// in an error (an `$inspect(object.property)` will run before the // in an error (an `$inspect(object.property)` will run before the
// `{#if object}...{/if}` that contains it) // `{#if object}...{/if}` that contains it)
inspect_effect(() => { eager_effect(() => {
try { try {
var value = get_value(); var value = get_value();
} catch (e) { } catch (e) {

@ -1,5 +1,4 @@
/** @import { Effect, TemplateNode } from '#client' */ /** @import { Effect, TemplateNode } from '#client' */
import { is_runes } from '../../context.js';
import { Batch, current_batch } from '../../reactivity/batch.js'; import { Batch, current_batch } from '../../reactivity/batch.js';
import { import {
branch, branch,
@ -8,7 +7,6 @@ import {
pause_effect, pause_effect,
resume_effect resume_effect
} from '../../reactivity/effects.js'; } from '../../reactivity/effects.js';
import { set_should_intro, should_intro } from '../../render.js';
import { hydrate_node, hydrating } from '../hydration.js'; import { hydrate_node, hydrating } from '../hydration.js';
import { create_text, should_defer_append } from '../operations.js'; import { create_text, should_defer_append } from '../operations.js';
@ -126,6 +124,22 @@ export class BranchManager {
} }
}; };
/**
* @param {Batch} batch
*/
#discard = (batch) => {
this.#batches.delete(batch);
const keys = Array.from(this.#batches.values());
for (const [k, branch] of this.#offscreen) {
if (!keys.includes(k)) {
destroy_effect(branch.effect);
this.#offscreen.delete(k);
}
}
};
/** /**
* *
* @param {any} key * @param {any} key
@ -173,7 +187,8 @@ export class BranchManager {
} }
} }
batch.add_callback(this.#commit); batch.oncommit(this.#commit);
batch.ondiscard(this.#discard);
} else { } else {
if (hydrating) { if (hydrating) {
this.anchor = hydrate_node; this.anchor = hydrate_node;

@ -310,7 +310,7 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f
} }
} }
batch.add_callback(commit); batch.oncommit(commit);
} else { } else {
commit(); commit();
} }

@ -7,7 +7,7 @@ import { add_form_reset_listener, autofocus } from './misc.js';
import * as w from '../../warnings.js'; import * as w from '../../warnings.js';
import { LOADING_ATTR_SYMBOL } from '#client/constants'; import { LOADING_ATTR_SYMBOL } from '#client/constants';
import { queue_micro_task } from '../task.js'; import { queue_micro_task } from '../task.js';
import { is_capture_event, is_delegated, normalize_attribute } from '../../../../utils.js'; import { is_capture_event, can_delegate_event, normalize_attribute } from '../../../../utils.js';
import { import {
active_effect, active_effect,
active_reaction, active_reaction,
@ -378,7 +378,7 @@ function set_attributes(
const opts = {}; const opts = {};
const event_handle_key = '$$' + key; const event_handle_key = '$$' + key;
let event_name = key.slice(2); let event_name = key.slice(2);
var delegated = is_delegated(event_name); var delegated = can_delegate_event(event_name);
if (is_capture_event(event_name)) { if (is_capture_event(event_name)) {
event_name = event_name.slice(0, -7); event_name = event_name.slice(0, -7);

@ -1,5 +1,5 @@
import { teardown } from '../../reactivity/effects.js'; import { teardown } from '../../reactivity/effects.js';
import { define_property, is_array } from '../../../shared/utils.js'; import { define_property } from '../../../shared/utils.js';
import { hydrating } from '../hydration.js'; import { hydrating } from '../hydration.js';
import { queue_micro_task } from '../task.js'; import { queue_micro_task } from '../task.js';
import { FILENAME } from '../../../../constants.js'; import { FILENAME } from '../../../../constants.js';
@ -258,12 +258,7 @@ export function handle_event_propagation(event) {
// -> the target could not have been disabled because it emits the event in the first place // -> the target could not have been disabled because it emits the event in the first place
event.target === current_target) event.target === current_target)
) { ) {
if (is_array(delegated)) { delegated.call(current_target, event);
var [fn, ...data] = delegated;
fn.apply(current_target, [event, ...data]);
} else {
delegated.call(current_target, event);
}
} }
} catch (error) { } catch (error) {
if (throw_error) { if (throw_error) {

@ -229,6 +229,22 @@ export function effect_update_depth_exceeded() {
} }
} }
/**
* Cannot use `fork(...)` unless the `experimental.async` compiler option is `true`
* @returns {never}
*/
export function experimental_async_fork() {
if (DEV) {
const error = new Error(`experimental_async_fork\nCannot use \`fork(...)\` unless the \`experimental.async\` compiler option is \`true\`\nhttps://svelte.dev/e/experimental_async_fork`);
error.name = 'Svelte error';
throw error;
} else {
throw new Error(`https://svelte.dev/e/experimental_async_fork`);
}
}
/** /**
* Cannot use `flushSync` inside an effect * Cannot use `flushSync` inside an effect
* @returns {never} * @returns {never}
@ -245,6 +261,38 @@ export function flush_sync_in_effect() {
} }
} }
/**
* Cannot commit a fork that was already committed or discarded
* @returns {never}
*/
export function fork_discarded() {
if (DEV) {
const error = new Error(`fork_discarded\nCannot commit a fork that was already committed or discarded\nhttps://svelte.dev/e/fork_discarded`);
error.name = 'Svelte error';
throw error;
} else {
throw new Error(`https://svelte.dev/e/fork_discarded`);
}
}
/**
* Cannot create a fork inside an effect or when state changes are pending
* @returns {never}
*/
export function fork_timing() {
if (DEV) {
const error = new Error(`fork_timing\nCannot create a fork inside an effect or when state changes are pending\nhttps://svelte.dev/e/fork_timing`);
error.name = 'Svelte error';
throw error;
} else {
throw new Error(`https://svelte.dev/e/fork_timing`);
}
}
/** /**
* `getAbortSignal()` can only be called inside an effect or derived * `getAbortSignal()` can only be called inside an effect or derived
* @returns {never} * @returns {never}

@ -19,8 +19,8 @@ import {
state as source, state as source,
set, set,
increment, increment,
flush_inspect_effects, flush_eager_effects,
set_inspect_effects_deferred set_eager_effects_deferred
} from './reactivity/sources.js'; } from './reactivity/sources.js';
import { PROXY_PATH_SYMBOL, STATE_SYMBOL } from '#client/constants'; import { PROXY_PATH_SYMBOL, STATE_SYMBOL } from '#client/constants';
import { UNINITIALIZED } from '../../constants.js'; import { UNINITIALIZED } from '../../constants.js';
@ -421,9 +421,9 @@ function inspectable_array(array) {
* @param {any[]} args * @param {any[]} args
*/ */
return function (...args) { return function (...args) {
set_inspect_effects_deferred(); set_eager_effects_deferred();
var result = value.apply(this, args); var result = value.apply(this, args);
flush_inspect_effects(); flush_eager_effects();
return result; return result;
}; };
} }

@ -1,3 +1,4 @@
/** @import { Fork } from 'svelte' */
/** @import { Derived, Effect, Reaction, Source, Value } from '#client' */ /** @import { Derived, Effect, Reaction, Source, Value } from '#client' */
import { import {
BLOCK_EFFECT, BLOCK_EFFECT,
@ -12,25 +13,35 @@ import {
ROOT_EFFECT, ROOT_EFFECT,
MAYBE_DIRTY, MAYBE_DIRTY,
DERIVED, DERIVED,
BOUNDARY_EFFECT BOUNDARY_EFFECT,
EAGER_EFFECT
} from '#client/constants'; } from '#client/constants';
import { async_mode_flag } from '../../flags/index.js'; import { async_mode_flag } from '../../flags/index.js';
import { deferred, define_property } from '../../shared/utils.js'; import { deferred, define_property } from '../../shared/utils.js';
import { import {
active_effect, active_effect,
get, get,
increment_write_version,
is_dirty, is_dirty,
is_updating_effect, is_updating_effect,
set_is_updating_effect, set_is_updating_effect,
set_signal_status, set_signal_status,
tick,
update_effect update_effect
} from '../runtime.js'; } from '../runtime.js';
import * as e from '../errors.js'; import * as e from '../errors.js';
import { flush_tasks, queue_micro_task } from '../dom/task.js'; import { flush_tasks, queue_micro_task } from '../dom/task.js';
import { DEV } from 'esm-env'; import { DEV } from 'esm-env';
import { invoke_error_boundary } from '../error-handling.js'; import { invoke_error_boundary } from '../error-handling.js';
import { old_values, source, update } from './sources.js'; import {
import { inspect_effect, unlink_effect } from './effects.js'; flush_eager_effects,
eager_effects,
old_values,
set_eager_effects,
source,
update
} from './sources.js';
import { eager_effect, unlink_effect } from './effects.js';
/** /**
* @typedef {{ * @typedef {{
@ -90,14 +101,20 @@ export class Batch {
* They keys of this map are identical to `this.#current` * They keys of this map are identical to `this.#current`
* @type {Map<Source, any>} * @type {Map<Source, any>}
*/ */
#previous = new Map(); previous = new Map();
/** /**
* When the batch is committed (and the DOM is updated), we need to remove old branches * When the batch is committed (and the DOM is updated), we need to remove old branches
* and append new ones by calling the functions added inside (if/each/key/etc) blocks * and append new ones by calling the functions added inside (if/each/key/etc) blocks
* @type {Set<() => void>} * @type {Set<() => void>}
*/ */
#callbacks = new Set(); #commit_callbacks = new Set();
/**
* If a fork is discarded, we need to destroy any effects that are no longer needed
* @type {Set<(batch: Batch) => void>}
*/
#discard_callbacks = new Set();
/** /**
* The number of async effects that are currently in flight * The number of async effects that are currently in flight
@ -135,6 +152,8 @@ export class Batch {
*/ */
skipped_effects = new Set(); skipped_effects = new Set();
is_fork = false;
/** /**
* *
* @param {Effect[]} root_effects * @param {Effect[]} root_effects
@ -159,15 +178,15 @@ export class Batch {
this.#traverse_effect_tree(root, target); this.#traverse_effect_tree(root, target);
} }
this.#resolve(); if (!this.is_fork) {
this.#resolve();
}
if (this.#blocking_pending > 0) { if (this.#blocking_pending > 0 || this.is_fork) {
this.#defer_effects(target.effects); this.#defer_effects(target.effects);
this.#defer_effects(target.render_effects); this.#defer_effects(target.render_effects);
this.#defer_effects(target.block_effects); this.#defer_effects(target.block_effects);
} else { } else {
// TODO append/detach blocks here, not in #commit
// If sources are written to, then work needs to happen in a separate batch, else prior sources would be mixed with // If sources are written to, then work needs to happen in a separate batch, else prior sources would be mixed with
// newly updated sources, which could lead to infinite loops when effects run over and over again. // newly updated sources, which could lead to infinite loops when effects run over and over again.
previous_batch = this; previous_batch = this;
@ -271,8 +290,8 @@ export class Batch {
* @param {any} value * @param {any} value
*/ */
capture(source, value) { capture(source, value) {
if (!this.#previous.has(source)) { if (!this.previous.has(source)) {
this.#previous.set(source, value); this.previous.set(source, value);
} }
this.current.set(source, source.v); this.current.set(source, source.v);
@ -289,16 +308,17 @@ export class Batch {
} }
flush() { flush() {
this.activate();
if (queued_root_effects.length > 0) { if (queued_root_effects.length > 0) {
this.activate();
flush_effects(); flush_effects();
if (current_batch !== null && current_batch !== this) { if (current_batch !== null && current_batch !== this) {
// this can happen if a new batch was created during `flush_effects()` // this can happen if a new batch was created during `flush_effects()`
return; return;
} }
} else { } else if (this.#pending === 0) {
this.#resolve(); this.process([]); // TODO this feels awkward
} }
this.deactivate(); this.deactivate();
@ -314,11 +334,16 @@ export class Batch {
} }
} }
discard() {
for (const fn of this.#discard_callbacks) fn(this);
this.#discard_callbacks.clear();
}
#resolve() { #resolve() {
if (this.#blocking_pending === 0) { if (this.#blocking_pending === 0) {
// append/remove branches // append/remove branches
for (const fn of this.#callbacks) fn(); for (const fn of this.#commit_callbacks) fn();
this.#callbacks.clear(); this.#commit_callbacks.clear();
} }
if (this.#pending === 0) { if (this.#pending === 0) {
@ -332,7 +357,7 @@ export class Batch {
// committed state, unless the batch in question has a more // committed state, unless the batch in question has a more
// recent value for a given source // recent value for a given source
if (batches.size > 1) { if (batches.size > 1) {
this.#previous.clear(); this.previous.clear();
var previous_batch_values = batch_values; var previous_batch_values = batch_values;
var is_earlier = true; var is_earlier = true;
@ -428,6 +453,10 @@ export class Batch {
this.#pending -= 1; this.#pending -= 1;
if (blocking) this.#blocking_pending -= 1; if (blocking) this.#blocking_pending -= 1;
this.revive();
}
revive() {
for (const e of this.#dirty_effects) { for (const e of this.#dirty_effects) {
set_signal_status(e, DIRTY); set_signal_status(e, DIRTY);
schedule_effect(e); schedule_effect(e);
@ -445,8 +474,13 @@ export class Batch {
} }
/** @param {() => void} fn */ /** @param {() => void} fn */
add_callback(fn) { oncommit(fn) {
this.#callbacks.add(fn); this.#commit_callbacks.add(fn);
}
/** @param {(batch: Batch) => void} fn */
ondiscard(fn) {
this.#discard_callbacks.add(fn);
} }
settled() { settled() {
@ -489,7 +523,7 @@ export class Batch {
for (const batch of batches) { for (const batch of batches) {
if (batch === this) continue; if (batch === this) continue;
for (const [source, previous] of batch.#previous) { for (const [source, previous] of batch.previous) {
if (!batch_values.has(source)) { if (!batch_values.has(source)) {
batch_values.set(source, previous); batch_values.set(source, previous);
} }
@ -717,6 +751,28 @@ function mark_effects(value, sources, marked, checked) {
} }
} }
/**
* When committing a fork, we need to trigger eager effects so that
* any `$state.eager(...)` expressions update immediately. This
* function allows us to discover them
* @param {Value} value
* @param {Set<Effect>} effects
*/
function mark_eager_effects(value, effects) {
if (value.reactions === null) return;
for (const reaction of value.reactions) {
const flags = reaction.f;
if ((flags & DERIVED) !== 0) {
mark_eager_effects(/** @type {Derived} */ (reaction), effects);
} else if ((flags & EAGER_EFFECT) !== 0) {
set_signal_status(reaction, DIRTY);
effects.add(/** @type {Effect} */ (reaction));
}
}
}
/** /**
* @param {Reaction} reaction * @param {Reaction} reaction
* @param {Source[]} sources * @param {Source[]} sources
@ -798,9 +854,9 @@ export function eager(fn) {
get(version); get(version);
inspect_effect(() => { eager_effect(() => {
if (initial) { if (initial) {
// the first time this runs, we create an inspect effect // the first time this runs, we create an eager effect
// that will run eagerly whenever the expression changes // that will run eagerly whenever the expression changes
var previous_batch_values = batch_values; var previous_batch_values = batch_values;
@ -829,6 +885,88 @@ export function eager(fn) {
return value; return value;
} }
/**
* Creates a 'fork', in which state changes are evaluated but not applied to the DOM.
* This is useful for speculatively loading data (for example) when you suspect that
* the user is about to take some action.
*
* Frameworks like SvelteKit can use this to preload data when the user touches or
* hovers over a link, making any subsequent navigation feel instantaneous.
*
* The `fn` parameter is a synchronous function that modifies some state. The
* state changes will be reverted after the fork is initialised, then reapplied
* if and when the fork is eventually committed.
*
* When it becomes clear that a fork will _not_ be committed (e.g. because the
* user navigated elsewhere), it must be discarded to avoid leaking memory.
*
* @param {() => void} fn
* @returns {Fork}
* @since 5.42
*/
export function fork(fn) {
if (!async_mode_flag) {
e.experimental_async_fork();
}
if (current_batch !== null) {
e.fork_timing();
}
const batch = Batch.ensure();
batch.is_fork = true;
const settled = batch.settled();
flushSync(fn);
// revert state changes
for (const [source, value] of batch.previous) {
source.v = value;
}
return {
commit: async () => {
if (!batches.has(batch)) {
e.fork_discarded();
}
batch.is_fork = false;
// apply changes
for (const [source, value] of batch.current) {
source.v = value;
}
// trigger any `$state.eager(...)` expressions with the new state.
// eager effects don't get scheduled like other effects, so we
// can't just encounter them during traversal, we need to
// proactively flush them
// TODO maybe there's a better implementation?
flushSync(() => {
/** @type {Set<Effect>} */
const eager_effects = new Set();
for (const source of batch.current.keys()) {
mark_eager_effects(source, eager_effects);
}
set_eager_effects(eager_effects);
flush_eager_effects();
});
batch.revive();
await settled;
},
discard: () => {
if (batches.has(batch)) {
batches.delete(batch);
batch.discard();
}
}
};
}
/** /**
* Forcibly remove all current batches, to prevent cross-talk between tests * Forcibly remove all current batches, to prevent cross-talk between tests
*/ */

@ -28,7 +28,7 @@ import { equals, safe_equals } from './equality.js';
import * as e from '../errors.js'; import * as e from '../errors.js';
import * as w from '../warnings.js'; import * as w from '../warnings.js';
import { async_effect, destroy_effect, teardown } from './effects.js'; import { async_effect, destroy_effect, teardown } from './effects.js';
import { inspect_effects, internal_set, set_inspect_effects, source } from './sources.js'; import { eager_effects, internal_set, set_eager_effects, source } from './sources.js';
import { get_stack } from '../dev/tracing.js'; import { get_stack } from '../dev/tracing.js';
import { async_mode_flag, tracing_mode_flag } from '../../flags/index.js'; import { async_mode_flag, tracing_mode_flag } from '../../flags/index.js';
import { Boundary } from '../dom/blocks/boundary.js'; import { Boundary } from '../dom/blocks/boundary.js';
@ -318,8 +318,8 @@ export function execute_derived(derived) {
set_active_effect(get_derived_parent_effect(derived)); set_active_effect(get_derived_parent_effect(derived));
if (DEV) { if (DEV) {
let prev_inspect_effects = inspect_effects; let prev_eager_effects = eager_effects;
set_inspect_effects(new Set()); set_eager_effects(new Set());
try { try {
if (stack.includes(derived)) { if (stack.includes(derived)) {
e.derived_references_self(); e.derived_references_self();
@ -332,7 +332,7 @@ export function execute_derived(derived) {
value = update_reaction(derived); value = update_reaction(derived);
} finally { } finally {
set_active_effect(prev_active_effect); set_active_effect(prev_active_effect);
set_inspect_effects(prev_inspect_effects); set_eager_effects(prev_eager_effects);
stack.pop(); stack.pop();
} }
} else { } else {

@ -27,7 +27,7 @@ import {
DERIVED, DERIVED,
UNOWNED, UNOWNED,
CLEAN, CLEAN,
INSPECT_EFFECT, EAGER_EFFECT,
HEAD_EFFECT, HEAD_EFFECT,
MAYBE_DIRTY, MAYBE_DIRTY,
EFFECT_PRESERVED, EFFECT_PRESERVED,
@ -88,7 +88,7 @@ function create_effect(type, fn, sync, push = true) {
if (DEV) { if (DEV) {
// Ensure the parent is never an inspect effect // Ensure the parent is never an inspect effect
while (parent !== null && (parent.f & INSPECT_EFFECT) !== 0) { while (parent !== null && (parent.f & EAGER_EFFECT) !== 0) {
parent = parent.parent; parent = parent.parent;
} }
} }
@ -245,8 +245,8 @@ export function user_pre_effect(fn) {
} }
/** @param {() => void | (() => void)} fn */ /** @param {() => void | (() => void)} fn */
export function inspect_effect(fn) { export function eager_effect(fn) {
return create_effect(INSPECT_EFFECT, fn, true); return create_effect(EAGER_EFFECT, fn, true);
} }
/** /**

@ -22,7 +22,7 @@ import {
DERIVED, DERIVED,
DIRTY, DIRTY,
BRANCH_EFFECT, BRANCH_EFFECT,
INSPECT_EFFECT, EAGER_EFFECT,
UNOWNED, UNOWNED,
MAYBE_DIRTY, MAYBE_DIRTY,
BLOCK_EFFECT, BLOCK_EFFECT,
@ -39,7 +39,7 @@ import { proxy } from '../proxy.js';
import { execute_derived } from './deriveds.js'; import { execute_derived } from './deriveds.js';
/** @type {Set<any>} */ /** @type {Set<any>} */
export let inspect_effects = new Set(); export let eager_effects = new Set();
/** @type {Map<Source, any>} */ /** @type {Map<Source, any>} */
export const old_values = new Map(); export const old_values = new Map();
@ -47,14 +47,14 @@ export const old_values = new Map();
/** /**
* @param {Set<any>} v * @param {Set<any>} v
*/ */
export function set_inspect_effects(v) { export function set_eager_effects(v) {
inspect_effects = v; eager_effects = v;
} }
let inspect_effects_deferred = false; let eager_effects_deferred = false;
export function set_inspect_effects_deferred() { export function set_eager_effects_deferred() {
inspect_effects_deferred = true; eager_effects_deferred = true;
} }
/** /**
@ -146,9 +146,9 @@ export function set(source, value, should_proxy = false) {
active_reaction !== null && active_reaction !== null &&
// since we are untracking the function inside `$inspect.with` we need to add this check // since we are untracking the function inside `$inspect.with` we need to add this check
// to ensure we error if state is set inside an inspect effect // to ensure we error if state is set inside an inspect effect
(!untracking || (active_reaction.f & INSPECT_EFFECT) !== 0) && (!untracking || (active_reaction.f & EAGER_EFFECT) !== 0) &&
is_runes() && is_runes() &&
(active_reaction.f & (DERIVED | BLOCK_EFFECT | ASYNC | INSPECT_EFFECT)) !== 0 && (active_reaction.f & (DERIVED | BLOCK_EFFECT | ASYNC | EAGER_EFFECT)) !== 0 &&
!current_sources?.includes(source) !current_sources?.includes(source)
) { ) {
e.state_unsafe_mutation(); e.state_unsafe_mutation();
@ -235,18 +235,18 @@ export function internal_set(source, value) {
} }
} }
if (DEV && inspect_effects.size > 0 && !inspect_effects_deferred) { if (!batch.is_fork && eager_effects.size > 0 && !eager_effects_deferred) {
flush_inspect_effects(); flush_eager_effects();
} }
} }
return value; return value;
} }
export function flush_inspect_effects() { export function flush_eager_effects() {
inspect_effects_deferred = false; eager_effects_deferred = false;
const inspects = Array.from(inspect_effects); const inspects = Array.from(eager_effects);
for (const effect of inspects) { for (const effect of inspects) {
// Mark clean inspect-effects as maybe dirty and then check their dirtiness // Mark clean inspect-effects as maybe dirty and then check their dirtiness
@ -260,7 +260,7 @@ export function flush_inspect_effects() {
} }
} }
inspect_effects.clear(); eager_effects.clear();
} }
/** /**
@ -320,8 +320,8 @@ function mark_reactions(signal, status) {
if (!runes && reaction === active_effect) continue; if (!runes && reaction === active_effect) continue;
// Inspect effects need to run immediately, so that the stack trace makes sense // Inspect effects need to run immediately, so that the stack trace makes sense
if (DEV && (flags & INSPECT_EFFECT) !== 0) { if (DEV && (flags & EAGER_EFFECT) !== 0) {
inspect_effects.add(reaction); eager_effects.add(reaction);
continue; continue;
} }

@ -137,7 +137,7 @@ const DELEGATED_EVENTS = [
* Returns `true` if `event_name` is a delegated event * Returns `true` if `event_name` is a delegated event
* @param {string} event_name * @param {string} event_name
*/ */
export function is_delegated(event_name) { export function can_delegate_event(event_name) {
return DELEGATED_EVENTS.includes(event_name); return DELEGATED_EVENTS.includes(event_name);
} }

@ -0,0 +1,92 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
async test({ assert, target, raf }) {
const [shift, increment, commit] = target.querySelectorAll('button');
shift.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`
<button>shift</button>
<button>increment</button>
<button>commit</button>
<p>count: 0</p>
<p>eager: 0</p>
<p>even</p>
`
);
increment.click();
await tick();
shift.click();
await tick();
// nothing updates until commit
assert.htmlEqual(
target.innerHTML,
`
<button>shift</button>
<button>increment</button>
<button>commit</button>
<p>count: 0</p>
<p>eager: 0</p>
<p>even</p>
`
);
commit.click();
await tick();
// nothing updates until commit
assert.htmlEqual(
target.innerHTML,
`
<button>shift</button>
<button>increment</button>
<button>commit</button>
<p>count: 1</p>
<p>eager: 1</p>
<p>odd</p>
`
);
increment.click();
await tick();
commit.click();
await tick();
// eager state updates on commit
assert.htmlEqual(
target.innerHTML,
`
<button>shift</button>
<button>increment</button>
<button>commit</button>
<p>count: 1</p>
<p>eager: 2</p>
<p>odd</p>
`
);
shift.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`
<button>shift</button>
<button>increment</button>
<button>commit</button>
<p>count: 2</p>
<p>eager: 2</p>
<p>even</p>
`
);
}
});

@ -0,0 +1,37 @@
<script>
import { fork } from 'svelte';
let count = $state(0);
const resolvers = [];
let f = null;
function push(value) {
const { promise, resolve } = Promise.withResolvers();
resolvers.push(() => resolve(value));
return promise;
}
</script>
<button onclick={() => resolvers.shift()?.()}>shift</button>
<button onclick={async () => {
f = await fork(() => {
count += 1;
});
}}>increment</button>
<button onclick={() => f?.commit()}>commit</button>
<p>count: {count}</p>
<p>eager: {$state.eager(count)}</p>
<svelte:boundary>
{#if await push(count) % 2 === 0}
<p>even</p>
{:else}
<p>odd</p>
{/if}
{#snippet pending()}
<p>loading...</p>
{/snippet}
</svelte:boundary>

@ -15,9 +15,9 @@ export default test({
{}, {},
[], [],
{ x: 'hello' }, { x: 'hello' },
'at HTMLButtonElement.on_click', 'at HTMLButtonElement.Main.button.__click',
['hello'], ['hello'],
'at HTMLButtonElement.on_click' 'at HTMLButtonElement.Main.button.__click'
]); ]);
} }
}); });

@ -15,9 +15,9 @@ export default test({
assert.deepEqual(normalise_inspect_logs(logs), [ assert.deepEqual(normalise_inspect_logs(logs), [
[], [],
[{}], [{}],
'at HTMLButtonElement.on_click', 'at HTMLButtonElement.Main.button.__click',
[{}, {}], [{}, {}],
'at HTMLButtonElement.on_click' 'at HTMLButtonElement.Main.button.__click'
]); ]);
} }
}); });

@ -1,19 +1,20 @@
import 'svelte/internal/disclose-version'; import 'svelte/internal/disclose-version';
import * as $ from 'svelte/internal/client'; import * as $ from 'svelte/internal/client';
function increment(_, counter) {
counter.count += 1;
}
var root = $.from_html(`<button> </button> <!> `, 1); var root = $.from_html(`<button> </button> <!> `, 1);
export default function Await_block_scope($$anchor) { export default function Await_block_scope($$anchor) {
let counter = $.proxy({ count: 0 }); let counter = $.proxy({ count: 0 });
const promise = $.derived(() => Promise.resolve(counter)); const promise = $.derived(() => Promise.resolve(counter));
function increment() {
counter.count += 1;
}
var fragment = root(); var fragment = root();
var button = $.first_child(fragment); var button = $.first_child(fragment);
button.__click = [increment, counter]; button.__click = increment;
var text = $.child(button); var text = $.child(button);

@ -2,12 +2,6 @@ import 'svelte/internal/disclose-version';
import 'svelte/internal/flags/legacy'; import 'svelte/internal/flags/legacy';
import * as $ from 'svelte/internal/client'; import * as $ from 'svelte/internal/client';
var on_click = (e) => {
const index = Number(e.currentTarget.dataset.index);
console.log(index);
};
var root_1 = $.from_html(`<button type="button">B</button>`); var root_1 = $.from_html(`<button type="button">B</button>`);
export default function Delegated_locally_declared_shadowed($$anchor) { export default function Delegated_locally_declared_shadowed($$anchor) {
@ -18,7 +12,13 @@ export default function Delegated_locally_declared_shadowed($$anchor) {
var button = root_1(); var button = root_1();
$.set_attribute(button, 'data-index', index); $.set_attribute(button, 'data-index', index);
button.__click = [on_click];
button.__click = (e) => {
const index = Number(e.currentTarget.dataset.index);
console.log(index);
};
$.append($$anchor, button); $.append($$anchor, button);
}); });

@ -1,7 +1,6 @@
import 'svelte/internal/disclose-version'; import 'svelte/internal/disclose-version';
import * as $ from 'svelte/internal/client'; import * as $ from 'svelte/internal/client';
var on_click = (_, count) => $.update(count);
var root = $.from_html(`<h1></h1> <b></b> <button> </button> <h1></h1>`, 1); var root = $.from_html(`<h1></h1> <b></b> <button> </button> <h1></h1>`, 1);
export default function Nullish_coallescence_omittance($$anchor) { export default function Nullish_coallescence_omittance($$anchor) {
@ -18,7 +17,7 @@ export default function Nullish_coallescence_omittance($$anchor) {
var button = $.sibling(b, 2); var button = $.sibling(b, 2);
button.__click = [on_click, count]; button.__click = () => $.update(count);
var text = $.child(button); var text = $.child(button);

@ -1,18 +1,19 @@
import 'svelte/internal/disclose-version'; import 'svelte/internal/disclose-version';
import * as $ from 'svelte/internal/client'; import * as $ from 'svelte/internal/client';
function reset(_, str, tpl) {
$.set(str, '');
$.set(str, ``);
$.set(tpl, '');
$.set(tpl, ``);
}
var root = $.from_html(`<input/> <input/> <button>reset</button>`, 1); var root = $.from_html(`<input/> <input/> <button>reset</button>`, 1);
export default function State_proxy_literal($$anchor) { export default function State_proxy_literal($$anchor) {
let str = $.state(''); let str = $.state('');
let tpl = $.state(``); let tpl = $.state(``);
function reset() {
$.set(str, '');
$.set(str, ``);
$.set(tpl, '');
$.set(tpl, ``);
}
var fragment = root(); var fragment = root();
var input = $.first_child(fragment); var input = $.first_child(fragment);
@ -24,7 +25,7 @@ export default function State_proxy_literal($$anchor) {
var button = $.sibling(input_1, 2); var button = $.sibling(input_1, 2);
button.__click = [reset, str, tpl]; button.__click = reset;
$.bind_value(input, () => $.get(str), ($$value) => $.set(str, $$value)); $.bind_value(input, () => $.get(str), ($$value) => $.set(str, $$value));
$.bind_value(input_1, () => $.get(tpl), ($$value) => $.set(tpl, $$value)); $.bind_value(input_1, () => $.get(tpl), ($$value) => $.set(tpl, $$value));
$.append($$anchor, fragment); $.append($$anchor, fragment);

@ -348,6 +348,22 @@ declare module 'svelte' {
*/ */
props: Props; props: Props;
}); });
/**
* Represents work that is happening off-screen, such as data being preloaded
* in anticipation of the user navigating
* @since 5.42
*/
export interface Fork {
/**
* Commit the fork. The promise will resolve once the state change has been applied
*/
commit(): Promise<void>;
/**
* Discard the fork
*/
discard(): void;
}
/** /**
* Returns an [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) that aborts when the current [derived](https://svelte.dev/docs/svelte/$derived) or [effect](https://svelte.dev/docs/svelte/$effect) re-runs or is destroyed. * Returns an [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) that aborts when the current [derived](https://svelte.dev/docs/svelte/$derived) or [effect](https://svelte.dev/docs/svelte/$effect) re-runs or is destroyed.
* *
@ -434,11 +450,6 @@ declare module 'svelte' {
* @deprecated Use [`$effect`](https://svelte.dev/docs/svelte/$effect) instead * @deprecated Use [`$effect`](https://svelte.dev/docs/svelte/$effect) instead
* */ * */
export function afterUpdate(fn: () => void): void; export function afterUpdate(fn: () => void): void;
/**
* Synchronously flush any pending updates.
* Returns void if no callback is provided, otherwise returns the result of calling the callback.
* */
export function flushSync<T = void>(fn?: (() => T) | undefined): T;
/** /**
* Create a snippet programmatically * Create a snippet programmatically
* */ * */
@ -448,6 +459,29 @@ declare module 'svelte' {
}): Snippet<Params>; }): Snippet<Params>;
/** Anything except a function */ /** Anything except a function */
type NotFunction<T> = T extends Function ? never : T; type NotFunction<T> = T extends Function ? never : T;
/**
* Synchronously flush any pending updates.
* Returns void if no callback is provided, otherwise returns the result of calling the callback.
* */
export function flushSync<T = void>(fn?: (() => T) | undefined): T;
/**
* Creates a 'fork', in which state changes are evaluated but not applied to the DOM.
* This is useful for speculatively loading data (for example) when you suspect that
* the user is about to take some action.
*
* Frameworks like SvelteKit can use this to preload data when the user touches or
* hovers over a link, making any subsequent navigation feel instantaneous.
*
* The `fn` parameter is a synchronous function that modifies some state. The
* state changes will be reverted after the fork is initialised, then reapplied
* if and when the fork is eventually committed.
*
* When it becomes clear that a fork will _not_ be committed (e.g. because the
* user navigated elsewhere), it must be discarded to avoid leaking memory.
*
* @since 5.42
*/
export function fork(fn: () => void): Fork;
/** /**
* Returns a `[get, set]` pair of functions for working with context in a type-safe way. * Returns a `[get, set]` pair of functions for working with context in a type-safe way.
* *

Loading…
Cancel
Save