diff --git a/.changeset/quiet-donuts-wonder.md b/.changeset/quiet-donuts-wonder.md new file mode 100644 index 000000000000..a0ee39f4e2b0 --- /dev/null +++ b/.changeset/quiet-donuts-wonder.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +chore: emit `await_reactivity_loss` in `for await` loops diff --git a/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js b/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js index a56aca9c5f0b..0737f531a5b4 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js @@ -26,6 +26,7 @@ import { DebugTag } from './visitors/DebugTag.js'; import { EachBlock } from './visitors/EachBlock.js'; import { ExportNamedDeclaration } from './visitors/ExportNamedDeclaration.js'; import { ExpressionStatement } from './visitors/ExpressionStatement.js'; +import { ForOfStatement } from './visitors/ForOfStatement.js'; import { Fragment } from './visitors/Fragment.js'; import { FunctionDeclaration } from './visitors/FunctionDeclaration.js'; import { FunctionExpression } from './visitors/FunctionExpression.js'; @@ -103,6 +104,7 @@ const visitors = { EachBlock, ExportNamedDeclaration, ExpressionStatement, + ForOfStatement, Fragment, FunctionDeclaration, FunctionExpression, diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/ForOfStatement.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/ForOfStatement.js new file mode 100644 index 000000000000..a5d2751812cc --- /dev/null +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/ForOfStatement.js @@ -0,0 +1,20 @@ +/** @import { Expression, ForOfStatement, Pattern, Statement, VariableDeclaration } from 'estree' */ +/** @import { ComponentContext } from '../types' */ +import * as b from '#compiler/builders'; +import { dev, is_ignored } from '../../../../state.js'; + +/** + * @param {ForOfStatement} node + * @param {ComponentContext} context + */ +export function ForOfStatement(node, context) { + if (node.await && dev && !is_ignored(node, 'await_reactivity_loss')) { + const left = /** @type {VariableDeclaration | Pattern} */ (context.visit(node.left)); + const argument = /** @type {Expression} */ (context.visit(node.right)); + const body = /** @type {Statement} */ (context.visit(node.body)); + const right = b.call('$.for_await_track_reactivity_loss', argument); + return b.for_of(left, right, body, true); + } + + context.next(); +} diff --git a/packages/svelte/src/compiler/utils/builders.js b/packages/svelte/src/compiler/utils/builders.js index c241cdb44532..81c4e6b8e0c5 100644 --- a/packages/svelte/src/compiler/utils/builders.js +++ b/packages/svelte/src/compiler/utils/builders.js @@ -214,6 +214,23 @@ export function export_default(declaration) { return { type: 'ExportDefaultDeclaration', declaration }; } +/** + * @param {ESTree.VariableDeclaration | ESTree.Pattern} left + * @param {ESTree.Expression} right + * @param {ESTree.Statement} body + * @param {boolean} [_await] + * @returns {ESTree.ForOfStatement} + */ +export function for_of(left, right, body, _await = false) { + return { + type: 'ForOfStatement', + left, + right, + body, + await: _await + }; +} + /** * @param {ESTree.Identifier} id * @param {ESTree.Pattern[]} params diff --git a/packages/svelte/src/internal/client/index.js b/packages/svelte/src/internal/client/index.js index 90f0f9baaccb..c094c9e04449 100644 --- a/packages/svelte/src/internal/client/index.js +++ b/packages/svelte/src/internal/client/index.js @@ -98,7 +98,11 @@ export { props_id, with_script } from './dom/template.js'; -export { save, track_reactivity_loss } from './reactivity/async.js'; +export { + for_await_track_reactivity_loss, + save, + track_reactivity_loss +} from './reactivity/async.js'; export { flushSync as flush, suspend } from './reactivity/batch.js'; export { async_derived, diff --git a/packages/svelte/src/internal/client/reactivity/async.js b/packages/svelte/src/internal/client/reactivity/async.js index c200f10dba8f..2b133e5f4492 100644 --- a/packages/svelte/src/internal/client/reactivity/async.js +++ b/packages/svelte/src/internal/client/reactivity/async.js @@ -119,6 +119,51 @@ export async function track_reactivity_loss(promise) { }; } +/** + * Used in `for await` loops in DEV, so + * that we can emit `await_reactivity_loss` warnings + * after each `async_iterator` result resolves and + * after the `async_iterator` return resolves (if it runs) + * @template T + * @template TReturn + * @param {Iterable | AsyncIterable} iterable + * @returns {AsyncGenerator} + */ +export async function* for_await_track_reactivity_loss(iterable) { + // This is based on the algorithms described in ECMA-262: + // ForIn/OfBodyEvaluation + // https://tc39.es/ecma262/multipage/ecmascript-language-statements-and-declarations.html#sec-runtime-semantics-forin-div-ofbodyevaluation-lhs-stmt-iterator-lhskind-labelset + // AsyncIteratorClose + // https://tc39.es/ecma262/multipage/abstract-operations.html#sec-asynciteratorclose + + /** @type {AsyncIterator} */ + // @ts-ignore + const iterator = iterable[Symbol.asyncIterator]?.() ?? iterable[Symbol.iterator]?.(); + + if (iterator === undefined) { + throw new TypeError('value is not async iterable'); + } + + /** Whether the completion of the iterator was "normal", meaning it wasn't ended via `break` or a similar method */ + let normal_completion = false; + try { + while (true) { + const { done, value } = (await track_reactivity_loss(iterator.next()))(); + if (done) { + normal_completion = true; + break; + } + yield value; + } + } finally { + // If the iterator had a normal completion and `return` is defined on the iterator, call it and return the value + if (normal_completion && iterator.return !== undefined) { + // eslint-disable-next-line no-unsafe-finally + return /** @type {TReturn} */ ((await track_reactivity_loss(iterator.return()))().value); + } + } +} + export function unset_context() { set_active_effect(null); set_active_reaction(null); diff --git a/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await/_config.js b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await/_config.js new file mode 100644 index 000000000000..bde65a499f3b --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await/_config.js @@ -0,0 +1,24 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + compileOptions: { + dev: true + }, + + html: `

pending

`, + + async test({ assert, target, warnings }) { + await tick(); + assert.htmlEqual(target.innerHTML, '

3

'); + + assert.equal( + warnings[0], + 'Detected reactivity loss when reading `values[1]`. This happens when state is read in an async function after an earlier `await`' + ); + + assert.equal(warnings[1].name, 'TracedAtError'); + + assert.equal(warnings.length, 2); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await/main.svelte new file mode 100644 index 000000000000..92a6ec18bcb2 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await/main.svelte @@ -0,0 +1,24 @@ + + + + + + +

{await get_total()}

+ + {#snippet pending()} +

pending

+ {/snippet} +