Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

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

fix: end a restored reaction context at the end of its synchronous segment
4 changes: 2 additions & 2 deletions packages/svelte/src/compiler/phases/2-analyze/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,7 @@ export function analyze_module(source, options) {
runes: true,
immutable: true,
tracing: false,
async_deriveds: new Set(),
async_deriveds: new Map(),
comments,
classes: new Map(),
pickled_awaits: new Set()
Expand Down Expand Up @@ -557,7 +557,7 @@ export function analyze_component(root, source, options) {
source,
snippet_renderers: new Map(),
snippets: new Set(),
async_deriveds: new Set(),
async_deriveds: new Map(),
pickled_awaits: new Set(),
instance_body: {
sync: [],
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/** @import { AwaitExpression, Expression, SpreadElement, Property } from 'estree' */
/** @import { Context } from '../types' */
/** @import { AST } from '#compiler' */
/** @import { ExpressionMetadata } from '../../nodes.js' */
import * as e from '../../../errors.js';

/**
Expand All @@ -10,15 +11,20 @@ import * as e from '../../../errors.js';
export function AwaitExpression(node, context) {
const tla = context.state.ast_type === 'instance' && context.state.function_depth === 1;

// preserve context for awaits that precede other expressions in template or `$derived(...)`
if (
is_reactive_expression(
context.path,
context.state.derived_function_depth === context.state.function_depth
) &&
!is_last_evaluated_expression(context.path, node)
)
) {
context.state.analysis.pickled_awaits.add(node);
const expression = /** @type {ExpressionMetadata} */ (context.state.expression);

// preserve context for awaits that precede other expressions in template or `$derived(...)`,
// and for any await that follows one, so the restored context ends at the next suspension
if (expression.has_pickled_await || !is_last_evaluated_expression(context.path, node)) {
context.state.analysis.pickled_awaits.add(node);
expression.has_pickled_await = true;
}
}

let suspend = tla;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ export function CallExpression(node, context) {
});

if (expression.has_await) {
context.state.analysis.async_deriveds.add(node);
context.state.analysis.async_deriveds.set(node, expression);
}

// Tell surrounding declaration tag about metadata for correct calculation of blockers etc
Expand Down
46 changes: 38 additions & 8 deletions packages/svelte/src/compiler/phases/3-transform/client/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
/** @import { Binding } from '#compiler' */
/** @import { ClientTransformState, ComponentClientTransformState } from './types.js' */
/** @import { Analysis } from '../../types.js' */
/** @import { ExpressionMetadata } from '../../nodes.js' */
/** @import { Scope } from '../../scope.js' */
import * as b from '#compiler/builders';
import { is_simple_expression, save } from '../../../utils/ast.js';
Expand Down Expand Up @@ -164,20 +165,49 @@ export function should_proxy(node, scope) {
return true;
}

/**
* An async thunk. If an `await` inside restores the reaction context via `$.save`,
* the body ends with `$.unsave` so the context cannot leak into foreign microtasks
* that run before the returned promise settles. If the body throws instead, the
* context is unset by the consumer's `finally`, as before
* @param {Expression | BlockStatement} body
* @param {ExpressionMetadata} metadata
*/
export function async_thunk(body, metadata) {
if (!metadata.has_pickled_await) {
return b.arrow([], body, true);
}

if (body.type !== 'BlockStatement') {
return b.arrow([], b.call('$.unsave', body), true);
}

return b.arrow(
[],
b.block([
{
type: 'TryStatement',
block: body,
handler: null,
finalizer: b.block([b.stmt(b.call('$.unsave'))])
}
]),
true
);
}

/**
* Svelte legacy mode should use safe equals in most places, runes mode shouldn't
* @param {ComponentClientTransformState} state
* @param {Expression | BlockStatement} expression
* @param {boolean} [async]
* @param {ExpressionMetadata} [metadata]
*/
export function create_derived(state, expression, async = false) {
const thunk = b.thunk(expression, async);

if (async) {
return save(b.call('$.async_derived', thunk));
} else {
return b.call(state.analysis.runes ? '$.derived' : '$.derived_safe_equal', thunk);
export function create_derived(state, expression, metadata) {
if (metadata?.has_await) {
return save(b.call('$.async_derived', async_thunk(expression, metadata)));
}

return b.call(state.analysis.runes ? '$.derived' : '$.derived_safe_equal', b.thunk(expression));
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export function ConstTag(node, context) {
if (declaration.id.type === 'Identifier') {
const init = build_expression(context, declaration.init, node.metadata.expression);

let expression = create_derived(context.state, init, node.metadata.expression.has_await);
let expression = create_derived(context.state, init, node.metadata.expression);

if (dev) {
expression = b.call('$.tag', expression, b.literal(declaration.id.name));
Expand Down Expand Up @@ -69,7 +69,7 @@ export function ConstTag(node, context) {
b.return(b.object(identifiers.map((node) => b.prop('init', node, node))))
]);

let expression = create_derived(context.state, block, node.metadata.expression.has_await);
let expression = create_derived(context.state, block, node.metadata.expression);

if (dev) {
expression = b.call('$.tag', expression, b.literal('[@const]'));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
/** @import { ComponentContext } from '../types' */
import { extract_identifiers, has_await_expression } from '../../../../utils/ast.js';
import * as b from '#compiler/builders';
import { async_thunk } from '../utils.js';
import { add_state_transformers } from './shared/declarations.js';

/**
Expand Down Expand Up @@ -85,5 +86,5 @@ export function add_async_declaration(context, metadata, ids, assignments, kind
metadata.expression.has_await ||
assignments.some((assignment) => has_await_expression(assignment));
const body = assignments.length === 1 ? assignments[0].expression : b.block(assignments);
run.thunks.push(b.thunk(body, has_await));
run.thunks.push(has_await ? async_thunk(body, metadata.expression) : b.thunk(body));
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
import { dev } from '../../../../state.js';
import { extract_paths, object } from '../../../../utils/ast.js';
import * as b from '#compiler/builders';
import { async_thunk } from '../utils.js';
import { get_value } from './shared/declarations.js';
import { build_expression, add_svelte_meta } from './shared/utils.js';

Expand Down Expand Up @@ -313,7 +314,9 @@ export function EachBlock(node, context) {

const has_await = node.metadata.expression.has_await;

const get_collection = b.thunk(collection, has_await);
const get_collection = has_await
? async_thunk(collection, node.metadata.expression)
: b.thunk(collection);
const thunk = has_await ? b.thunk(b.call('$.get', b.id('$$collection'))) : get_collection;

const render_args = [b.id('$$anchor'), item];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
/** @import { ComponentContext } from '../types' */
import { is_ignored } from '../../../../state.js';
import * as b from '#compiler/builders';
import { async_thunk } from '../utils.js';
import { build_expression } from './shared/utils.js';

/**
Expand Down Expand Up @@ -46,7 +47,7 @@ export function HtmlTag(node, context) {
'$.async',
context.state.node,
node.metadata.expression.blockers(),
has_await ? b.array([b.thunk(expression, true)]) : b.void0,
has_await ? b.array([async_thunk(expression, node.metadata.expression)]) : b.void0,
b.arrow(
has_await ? [context.state.node, b.id('$$html')] : [context.state.node],
b.block([statement])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
/** @import { AST } from '#compiler' */
/** @import { ComponentContext } from '../types' */
import * as b from '#compiler/builders';
import { async_thunk } from '../utils.js';
import { build_expression, add_svelte_meta } from './shared/utils.js';

/**
Expand Down Expand Up @@ -117,7 +118,7 @@ export function IfBlock(node, context) {
'$.async',
context.state.node,
node.metadata.expression.blockers(),
has_await ? b.array([b.thunk(expression, true)]) : b.void0,
has_await ? b.array([async_thunk(expression, node.metadata.expression)]) : b.void0,
b.arrow(
has_await ? [context.state.node, b.id('$$condition')] : [context.state.node],
b.block(statements)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
/** @import { AST } from '#compiler' */
/** @import { ComponentContext } from '../types' */
import * as b from '#compiler/builders';
import { async_thunk } from '../utils.js';
import { build_expression, add_svelte_meta } from './shared/utils.js';

/**
Expand Down Expand Up @@ -31,7 +32,7 @@ export function KeyBlock(node, context) {
'$.async',
context.state.node,
node.metadata.expression.blockers(),
has_await ? b.array([b.thunk(expression, true)]) : b.void0,
has_await ? b.array([async_thunk(expression, node.metadata.expression)]) : b.void0,
b.arrow(
has_await ? [context.state.node, b.id('$$key')] : [context.state.node],
b.block([statement])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import { dev, locator } from '../../../../state.js';
import { is_text_attribute } from '../../../../utils/ast.js';
import * as b from '#compiler/builders';
import { async_thunk } from '../utils.js';
import { determine_namespace_for_children } from '../../utils.js';
import {
build_attribute_value,
Expand Down Expand Up @@ -147,7 +148,7 @@ export function SvelteElement(node, context) {
'$.async',
context.state.node,
node.metadata.expression.blockers(),
has_await ? b.array([b.thunk(expression, true)]) : b.void0,
has_await ? b.array([async_thunk(expression, node.metadata.expression)]) : b.void0,
b.arrow(
has_await ? [context.state.node, b.id('$$tag')] : [context.state.node],
b.block(statements)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@ import { extract_paths, save } from '../../../../utils/ast.js';
import * as b from '#compiler/builders';
import * as assert from '../../../../utils/assert.js';
import { get_rune } from '../../../scope.js';
import { get_prop_source, is_prop_source, is_state_source, should_proxy } from '../utils.js';
import {
async_thunk,
get_prop_source,
is_prop_source,
is_state_source,
should_proxy
} from '../utils.js';
import { get_value } from './shared/declarations.js';

/**
Expand Down Expand Up @@ -200,9 +206,10 @@ export function VariableDeclaration(node, context) {
}

if (rune === '$derived' || rune === '$derived.by') {
const is_async = context.state.analysis.async_deriveds.has(
const metadata = context.state.analysis.async_deriveds.get(
/** @type {CallExpression} */ (init)
);
const is_async = metadata !== undefined;

if (declarator.id.type === 'Identifier') {
let expression = /** @type {Expression} */ (context.visit(value));
Expand All @@ -213,7 +220,7 @@ export function VariableDeclaration(node, context) {
/** @type {Expression} */
let call = b.call(
'$.async_derived',
b.thunk(expression, true),
async_thunk(expression, metadata),
dev && b.literal(declarator.id.name),
location ? b.literal(location) : undefined
);
Expand Down Expand Up @@ -246,7 +253,7 @@ export function VariableDeclaration(node, context) {

call = b.call(
'$.async_derived',
b.thunk(expression, true),
async_thunk(expression, metadata),
dev &&
b.literal(
`[$derived ${declarator.id.type === 'ArrayPattern' ? 'iterable' : 'object'}]`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,18 @@ import { sanitize_template_string } from '../../../../../utils/sanitize_template
import { regex_is_valid_identifier } from '../../../../patterns.js';
import is_reference from 'is-reference';
import { dev, is_ignored, locator, component_name } from '../../../../../state.js';
import { build_getter, is_state_source } from '../../utils.js';
import { async_thunk, build_getter, is_state_source } from '../../utils.js';
import { ExpressionMetadata } from '../../../../nodes.js';

/**
* A utility for extracting complex expressions (such as call expressions)
* from templates and replacing them with `$0`, `$1` etc
*/
export class Memoizer {
/** @type {Array<{ id: Identifier, expression: Expression }>} */
/** @type {Array<{ id: Identifier, expression: Expression, metadata: ExpressionMetadata }>} */
#sync = [];

/** @type {Array<{ id: Identifier, expression: Expression }>} */
/** @type {Array<{ id: Identifier, expression: Expression, metadata: ExpressionMetadata }>} */
#async = [];

/** @type {Set<Expression>} */
Expand All @@ -43,7 +43,7 @@ export class Memoizer {

const id = b.id('#'); // filled in later

(metadata.has_await ? this.#async : this.#sync).push({ id, expression });
(metadata.has_await ? this.#async : this.#sync).push({ id, expression, metadata });

return id;
}
Expand Down Expand Up @@ -84,7 +84,7 @@ export class Memoizer {
if (this.#async.length === 0) return;
// use `b.arrow` rather than `b.thunk` so that deferred async/template effects
// always read live bindings rather than a possibly stale snapshot.
return b.array(this.#async.map((memo) => b.arrow([], memo.expression, true)));
return b.array(this.#async.map((memo) => async_thunk(memo.expression, memo.metadata)));
}

sync_values() {
Expand Down
4 changes: 4 additions & 0 deletions packages/svelte/src/compiler/phases/nodes.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ export class ExpressionMetadata {
/** True if the expression contains `await` */
has_await = false;

/** True if an `await` restores the reaction context afterwards, so the thunk must end it */
has_pickled_await = false;

/** True if the expression includes a member expression */
has_member_expression = false;

Expand Down Expand Up @@ -142,6 +145,7 @@ export class ExpressionMetadata {
this.has_state ||= source.has_state;
this.has_call ||= source.has_call;
this.has_await ||= source.has_await;
this.has_pickled_await ||= source.has_pickled_await;
this.has_member_expression ||= source.has_member_expression;
this.has_assignment ||= source.has_assignment;
this.#blockers = null; // so that blockers are recalculated
Expand Down
2 changes: 1 addition & 1 deletion packages/svelte/src/compiler/phases/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export interface Analysis {
accessors: boolean;

/** A set of deriveds that contain `await` expressions */
async_deriveds: Set<CallExpression>;
async_deriveds: Map<CallExpression, ExpressionMetadata>;
/** Awaits needing context preservation */
pickled_awaits: Set<AwaitExpression>;
}
Expand Down
1 change: 1 addition & 0 deletions packages/svelte/src/internal/client/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ export {
run,
save,
track_reactivity_loss,
unsave,
run_after_blockers,
wait
} from './reactivity/async.js';
Expand Down
Loading