Skip to content

Commit 6b33dd2

Browse files
authored
fix: group sync statements (#17977)
We were just putting each statement into its own promise. Besides this being bad for perf, it also introduces subtle timing issues - the execution order of the code could change in bad ways. Fixes #17940
1 parent 425fba3 commit 6b33dd2

10 files changed

Lines changed: 184 additions & 74 deletions

File tree

.changeset/puny-masks-run.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+
fix: group sync statements

packages/svelte/src/compiler/phases/2-analyze/index.js

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1074,6 +1074,9 @@ function calculate_blockers(instance, analysis) {
10741074

10751075
let awaited = false;
10761076

1077+
/** @type {Array<ESTree.Statement | ESTree.VariableDeclarator>} */
1078+
let sync_group = [];
1079+
10771080
// TODO this should probably be attached to the scope?
10781081
const promises = b.id('$$promises');
10791082

@@ -1088,6 +1091,13 @@ function calculate_blockers(instance, analysis) {
10881091
binding.blocker = blocker;
10891092
}
10901093

1094+
function flush_sync_group() {
1095+
if (sync_group.length === 0) return;
1096+
1097+
analysis.instance_body.async.push({ nodes: sync_group, has_await: false });
1098+
sync_group = [];
1099+
}
1100+
10911101
/**
10921102
* Analysis of blockers for functions is deferred until we know which statements are async/blockers
10931103
* @type {Array<ESTree.FunctionDeclaration | ESTree.VariableDeclarator>}
@@ -1149,6 +1159,9 @@ function calculate_blockers(instance, analysis) {
11491159

11501160
trace_references(declarator, reads, writes, instance.scope);
11511161

1162+
// Needs to happen before blocker computation
1163+
if (has_await) flush_sync_group();
1164+
11521165
const blocker = /** @type {NonNullable<Binding['blocker']>} */ (
11531166
b.member(promises, b.literal(analysis.instance_body.async.length), true)
11541167
);
@@ -1161,11 +1174,12 @@ function calculate_blockers(instance, analysis) {
11611174
push_declaration(id, blocker);
11621175
}
11631176

1164-
// one declarator per declaration, makes things simpler
1165-
analysis.instance_body.async.push({
1166-
node: declarator,
1167-
has_await
1168-
});
1177+
if (has_await) {
1178+
// one declarator per declaration, makes things simpler
1179+
analysis.instance_body.async.push({ nodes: [declarator], has_await: true });
1180+
} else {
1181+
sync_group.push(declarator);
1182+
}
11691183
}
11701184
}
11711185
} else if (awaited) {
@@ -1177,6 +1191,9 @@ function calculate_blockers(instance, analysis) {
11771191

11781192
trace_references(node, reads, writes, instance.scope);
11791193

1194+
// Needs to happen before blocker computation
1195+
if (has_await) flush_sync_group();
1196+
11801197
const blocker = /** @type {NonNullable<Binding['blocker']>} */ (
11811198
b.member(promises, b.literal(analysis.instance_body.async.length), true)
11821199
);
@@ -1187,15 +1204,20 @@ function calculate_blockers(instance, analysis) {
11871204

11881205
if (node.type === 'ClassDeclaration') {
11891206
push_declaration(node.id, blocker);
1190-
analysis.instance_body.async.push({ node, has_await });
1207+
}
1208+
1209+
if (has_await) {
1210+
analysis.instance_body.async.push({ nodes: [node], has_await: true });
11911211
} else {
1192-
analysis.instance_body.async.push({ node, has_await });
1212+
sync_group.push(node);
11931213
}
11941214
} else {
11951215
analysis.instance_body.sync.push(node);
11961216
}
11971217
}
11981218

1219+
flush_sync_group();
1220+
11991221
for (const fn of functions) {
12001222
/** @type {Set<Binding>} */
12011223
const reads_writes = new Set();

packages/svelte/src/compiler/phases/3-transform/shared/transform-async.js

Lines changed: 72 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -47,64 +47,24 @@ export function transform_body(instance_body, runner, transform) {
4747

4848
// Thunks for the await expressions
4949
if (instance_body.async.length > 0) {
50-
const thunks = instance_body.async.map((s) => {
51-
if (s.node.type === 'VariableDeclarator') {
52-
const visited = /** @type {ESTree.VariableDeclaration | ESTree.EmptyStatement} */ (
53-
transform(b.var(s.node.id, s.node.init))
54-
);
55-
56-
const statements =
57-
visited.type === 'VariableDeclaration'
58-
? visited.declarations.map((node) => {
59-
if (
60-
node.id.type === 'Identifier' &&
61-
(node.id.name.startsWith('$$d') || node.id.name.startsWith('$$array'))
62-
) {
63-
// this is an intermediate declaration created in VariableDeclaration.js;
64-
// subsequent statements depend on it
65-
return b.var(node.id, node.init);
66-
}
67-
68-
return b.stmt(b.assignment('=', node.id, node.init ?? b.void0));
69-
})
70-
: [];
71-
72-
if (statements.length === 1) {
73-
const statement = /** @type {ESTree.ExpressionStatement} */ (statements[0]);
74-
return b.thunk(statement.expression, s.has_await);
75-
}
76-
77-
return b.thunk(b.block(statements), s.has_await);
78-
}
50+
const thunks = instance_body.async.map((entry) => {
51+
/** @type {ESTree.Statement[]} */
52+
const entry_statements = [];
7953

80-
if (s.node.type === 'ClassDeclaration') {
81-
return b.thunk(
82-
b.assignment(
83-
'=',
84-
s.node.id,
85-
/** @type {ESTree.ClassExpression} */ ({ ...s.node, type: 'ClassExpression' })
86-
),
87-
s.has_await
88-
);
54+
for (const node of entry.nodes) {
55+
entry_statements.push(...transform_async_node(node, transform));
8956
}
9057

91-
if (s.node.type === 'ExpressionStatement') {
92-
// the expression may be a $inspect call, which will be transformed into an empty statement
93-
const expression = /** @type {ESTree.Expression | ESTree.EmptyStatement} */ (
94-
transform(s.node.expression)
95-
);
96-
97-
if (expression.type === 'EmptyStatement') {
98-
// Keep indices stable for async sequencing while avoiding array holes in run([...]).
99-
return b.thunk(b.void0, false);
100-
}
58+
if (entry_statements.length === 0) {
59+
// Keep indices stable for async sequencing while avoiding array holes in run([...]).
60+
return b.thunk(b.void0, false);
61+
}
10162

102-
return expression.type === 'AwaitExpression'
103-
? b.thunk(expression, true)
104-
: b.thunk(b.unary('void', expression), s.has_await);
63+
if (entry_statements.length === 1 && entry_statements[0].type === 'ExpressionStatement') {
64+
return b.thunk(entry_statements[0].expression, entry.has_await);
10565
}
10666

107-
return b.thunk(b.block([/** @type {ESTree.Statement} */ (transform(s.node))]), s.has_await);
67+
return b.thunk(b.block(entry_statements), entry.has_await);
10868
});
10969

11070
// TODO get the `$$promises` ID from scope
@@ -113,3 +73,63 @@ export function transform_body(instance_body, runner, transform) {
11373

11474
return statements;
11575
}
76+
77+
/**
78+
* @param {ESTree.Statement | ESTree.VariableDeclarator} node
79+
* @param {(node: ESTree.Node) => ESTree.Node} transform
80+
* @returns {ESTree.Statement[]}
81+
*/
82+
function transform_async_node(node, transform) {
83+
if (node.type === 'VariableDeclarator') {
84+
const visited = /** @type {ESTree.VariableDeclaration | ESTree.EmptyStatement} */ (
85+
transform(b.var(node.id, node.init))
86+
);
87+
88+
return visited.type === 'VariableDeclaration'
89+
? visited.declarations.map((node) => {
90+
if (
91+
node.id.type === 'Identifier' &&
92+
(node.id.name.startsWith('$$d') || node.id.name.startsWith('$$array'))
93+
) {
94+
// This intermediate declaration is created in VariableDeclaration.js;
95+
// subsequent statements may depend on it.
96+
return b.var(node.id, node.init);
97+
}
98+
99+
return b.stmt(b.assignment('=', node.id, node.init ?? b.void0));
100+
})
101+
: [];
102+
}
103+
104+
if (node.type === 'ClassDeclaration') {
105+
return [
106+
b.stmt(
107+
b.assignment(
108+
'=',
109+
node.id,
110+
/** @type {ESTree.ClassExpression} */ ({ ...node, type: 'ClassExpression' })
111+
)
112+
)
113+
];
114+
}
115+
116+
if (node.type === 'ExpressionStatement') {
117+
// The expression may be a $inspect call, which will be transformed into an empty statement.
118+
const expression = /** @type {ESTree.Expression | ESTree.EmptyStatement} */ (
119+
transform(node.expression)
120+
);
121+
122+
if (expression.type === 'EmptyStatement') {
123+
return [];
124+
}
125+
126+
if (expression.type === 'AwaitExpression') {
127+
return [b.stmt(expression)];
128+
}
129+
130+
return [b.stmt(b.unary('void', expression))];
131+
}
132+
133+
const statement = /** @type {ESTree.Statement | ESTree.EmptyStatement} */ (transform(node));
134+
return statement.type === 'EmptyStatement' ? [] : [statement];
135+
}

packages/svelte/src/compiler/phases/types.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ export interface ComponentAnalysis extends Analysis {
131131
instance_body: {
132132
hoisted: Array<Statement | ModuleDeclaration>;
133133
sync: Array<Statement | ModuleDeclaration | VariableDeclaration>;
134-
async: Array<{ node: Statement | VariableDeclarator; has_await: boolean }>;
134+
async: Array<{ nodes: Array<Statement | VariableDeclarator>; has_await: boolean }>;
135135
declarations: Array<Identifier>;
136136
};
137137
}

packages/svelte/tests/snapshot/samples/async-in-derived/_expected/client/index.svelte.js

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,15 @@ export default function Async_in_derived($$anchor, $$props) {
1010
var $$promises = $.run([
1111
async () => yes1 = await $.async_derived(() => 1),
1212
async () => yes2 = await $.async_derived(async () => foo(await 1)),
13-
() => no1 = $.derived(async () => {
14-
return await 1;
15-
}),
16-
17-
() => no2 = $.derived(() => async () => {
18-
return await 1;
19-
})
13+
() => {
14+
no1 = $.derived(async () => {
15+
return await 1;
16+
});
17+
18+
no2 = $.derived(() => async () => {
19+
return await 1;
20+
});
21+
}
2022
]);
2123

2224
var fragment = $.comment();

packages/svelte/tests/snapshot/samples/async-in-derived/_expected/server/index.svelte.js

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,15 @@ export default function Async_in_derived($$renderer, $$props) {
88
var $$promises = $$renderer.run([
99
async () => yes1 = await $.async_derived(() => 1),
1010
async () => yes2 = await $.async_derived(async () => foo(await 1)),
11-
() => no1 = $.derived(async () => {
12-
return await 1;
13-
}),
14-
15-
() => no2 = $.derived(() => async () => {
16-
return await 1;
17-
})
11+
() => {
12+
no1 = $.derived(async () => {
13+
return await 1;
14+
});
15+
16+
no2 = $.derived(() => async () => {
17+
return await 1;
18+
});
19+
}
1820
]);
1921

2022
if (true) {
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { test } from '../../test';
2+
3+
export default test({ compileOptions: { experimental: { async: true } } });
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import 'svelte/internal/disclose-version';
2+
import 'svelte/internal/flags/async';
3+
import * as $ from 'svelte/internal/client';
4+
5+
export default function Async_top_level_group_sync_run($$anchor) {
6+
var a,
7+
// these should be grouped into one, having an async tick inbetween
8+
// would change how the code runs and could introduce subtle timing bugs
9+
b,
10+
c;
11+
12+
var $$promises = $.run([
13+
async () => a = await Promise.resolve(1),
14+
() => {
15+
b = a + 1;
16+
c = b + 1;
17+
}
18+
]);
19+
20+
$.next();
21+
22+
var text = $.text();
23+
24+
$.template_effect(() => $.set_text(text, c), void 0, void 0, [$$promises[1]]);
25+
$.append($$anchor, text);
26+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import 'svelte/internal/flags/async';
2+
import * as $ from 'svelte/internal/server';
3+
4+
export default function Async_top_level_group_sync_run($$renderer) {
5+
var a,
6+
// these should be grouped into one, having an async tick inbetween
7+
// would change how the code runs and could introduce subtle timing bugs
8+
b,
9+
c;
10+
11+
var $$promises = $$renderer.run([
12+
async () => a = await Promise.resolve(1),
13+
() => {
14+
b = a + 1;
15+
c = b + 1;
16+
}
17+
]);
18+
19+
$$renderer.push(`<!---->`);
20+
$$renderer.async([$$promises[1]], ($$renderer) => $$renderer.push(() => $.escape(c)));
21+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<script>
2+
let a = await Promise.resolve(1);
3+
// these should be grouped into one, having an async tick inbetween
4+
// would change how the code runs and could introduce subtle timing bugs
5+
let b = a + 1;
6+
let c = b + 1;
7+
</script>
8+
9+
{c}

0 commit comments

Comments
 (0)