Skip to content

Commit b181c45

Browse files
chore: emit await_reactivity_loss in for await loops (#16521)
* chore: emit `await_reactivity_loss` in `for await` loops * oops * fix lint * fix import order * input is an iterable, not an iterator * handle non-iterables * add test * typescript. shrug --------- Co-authored-by: Rich Harris <[email protected]>
1 parent bbd0b1e commit b181c45

File tree

8 files changed

+142
-1
lines changed

8 files changed

+142
-1
lines changed

.changeset/quiet-donuts-wonder.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'svelte': patch
3+
---
4+
5+
chore: emit `await_reactivity_loss` in `for await` loops

packages/svelte/src/compiler/phases/3-transform/client/transform-client.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { DebugTag } from './visitors/DebugTag.js';
2626
import { EachBlock } from './visitors/EachBlock.js';
2727
import { ExportNamedDeclaration } from './visitors/ExportNamedDeclaration.js';
2828
import { ExpressionStatement } from './visitors/ExpressionStatement.js';
29+
import { ForOfStatement } from './visitors/ForOfStatement.js';
2930
import { Fragment } from './visitors/Fragment.js';
3031
import { FunctionDeclaration } from './visitors/FunctionDeclaration.js';
3132
import { FunctionExpression } from './visitors/FunctionExpression.js';
@@ -103,6 +104,7 @@ const visitors = {
103104
EachBlock,
104105
ExportNamedDeclaration,
105106
ExpressionStatement,
107+
ForOfStatement,
106108
Fragment,
107109
FunctionDeclaration,
108110
FunctionExpression,
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/** @import { Expression, ForOfStatement, Pattern, Statement, VariableDeclaration } from 'estree' */
2+
/** @import { ComponentContext } from '../types' */
3+
import * as b from '#compiler/builders';
4+
import { dev, is_ignored } from '../../../../state.js';
5+
6+
/**
7+
* @param {ForOfStatement} node
8+
* @param {ComponentContext} context
9+
*/
10+
export function ForOfStatement(node, context) {
11+
if (node.await && dev && !is_ignored(node, 'await_reactivity_loss')) {
12+
const left = /** @type {VariableDeclaration | Pattern} */ (context.visit(node.left));
13+
const argument = /** @type {Expression} */ (context.visit(node.right));
14+
const body = /** @type {Statement} */ (context.visit(node.body));
15+
const right = b.call('$.for_await_track_reactivity_loss', argument);
16+
return b.for_of(left, right, body, true);
17+
}
18+
19+
context.next();
20+
}

packages/svelte/src/compiler/utils/builders.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,23 @@ export function export_default(declaration) {
214214
return { type: 'ExportDefaultDeclaration', declaration };
215215
}
216216

217+
/**
218+
* @param {ESTree.VariableDeclaration | ESTree.Pattern} left
219+
* @param {ESTree.Expression} right
220+
* @param {ESTree.Statement} body
221+
* @param {boolean} [_await]
222+
* @returns {ESTree.ForOfStatement}
223+
*/
224+
export function for_of(left, right, body, _await = false) {
225+
return {
226+
type: 'ForOfStatement',
227+
left,
228+
right,
229+
body,
230+
await: _await
231+
};
232+
}
233+
217234
/**
218235
* @param {ESTree.Identifier} id
219236
* @param {ESTree.Pattern[]} params

packages/svelte/src/internal/client/index.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,11 @@ export {
9898
props_id,
9999
with_script
100100
} from './dom/template.js';
101-
export { save, track_reactivity_loss } from './reactivity/async.js';
101+
export {
102+
for_await_track_reactivity_loss,
103+
save,
104+
track_reactivity_loss
105+
} from './reactivity/async.js';
102106
export { flushSync as flush, suspend } from './reactivity/batch.js';
103107
export {
104108
async_derived,

packages/svelte/src/internal/client/reactivity/async.js

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,51 @@ export async function track_reactivity_loss(promise) {
119119
};
120120
}
121121

122+
/**
123+
* Used in `for await` loops in DEV, so
124+
* that we can emit `await_reactivity_loss` warnings
125+
* after each `async_iterator` result resolves and
126+
* after the `async_iterator` return resolves (if it runs)
127+
* @template T
128+
* @template TReturn
129+
* @param {Iterable<T> | AsyncIterable<T>} iterable
130+
* @returns {AsyncGenerator<T, TReturn | undefined>}
131+
*/
132+
export async function* for_await_track_reactivity_loss(iterable) {
133+
// This is based on the algorithms described in ECMA-262:
134+
// ForIn/OfBodyEvaluation
135+
// https://tc39.es/ecma262/multipage/ecmascript-language-statements-and-declarations.html#sec-runtime-semantics-forin-div-ofbodyevaluation-lhs-stmt-iterator-lhskind-labelset
136+
// AsyncIteratorClose
137+
// https://tc39.es/ecma262/multipage/abstract-operations.html#sec-asynciteratorclose
138+
139+
/** @type {AsyncIterator<T, TReturn>} */
140+
// @ts-ignore
141+
const iterator = iterable[Symbol.asyncIterator]?.() ?? iterable[Symbol.iterator]?.();
142+
143+
if (iterator === undefined) {
144+
throw new TypeError('value is not async iterable');
145+
}
146+
147+
/** Whether the completion of the iterator was "normal", meaning it wasn't ended via `break` or a similar method */
148+
let normal_completion = false;
149+
try {
150+
while (true) {
151+
const { done, value } = (await track_reactivity_loss(iterator.next()))();
152+
if (done) {
153+
normal_completion = true;
154+
break;
155+
}
156+
yield value;
157+
}
158+
} finally {
159+
// If the iterator had a normal completion and `return` is defined on the iterator, call it and return the value
160+
if (normal_completion && iterator.return !== undefined) {
161+
// eslint-disable-next-line no-unsafe-finally
162+
return /** @type {TReturn} */ ((await track_reactivity_loss(iterator.return()))().value);
163+
}
164+
}
165+
}
166+
122167
export function unset_context() {
123168
set_active_effect(null);
124169
set_active_reaction(null);
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { tick } from 'svelte';
2+
import { test } from '../../test';
3+
4+
export default test({
5+
compileOptions: {
6+
dev: true
7+
},
8+
9+
html: `<button>a</button><button>b</button><p>pending</p>`,
10+
11+
async test({ assert, target, warnings }) {
12+
await tick();
13+
assert.htmlEqual(target.innerHTML, '<button>a</button><button>b</button><h1>3</h1>');
14+
15+
assert.equal(
16+
warnings[0],
17+
'Detected reactivity loss when reading `values[1]`. This happens when state is read in an async function after an earlier `await`'
18+
);
19+
20+
assert.equal(warnings[1].name, 'TracedAtError');
21+
22+
assert.equal(warnings.length, 2);
23+
}
24+
});
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<script>
2+
let values = $state([1, 2]);
3+
4+
async function get_total() {
5+
let total = 0;
6+
7+
for await (const n of values) {
8+
total += n;
9+
}
10+
11+
return total;
12+
}
13+
</script>
14+
15+
<button onclick={() => values[0]++}>a</button>
16+
<button onclick={() => values[1]++}>b</button>
17+
18+
<svelte:boundary>
19+
<h1>{await get_total()}</h1>
20+
21+
{#snippet pending()}
22+
<p>pending</p>
23+
{/snippet}
24+
</svelte:boundary>

0 commit comments

Comments
 (0)