Skip to content

Commit 489ccc0

Browse files
committed
fix: unset context on stale promises
When a stale promise is rejected in `async_derived`, and the promise eventually resolves, `d.resolve` will be noop and `d.promise.then(handler, ...)` will never run. That in turns means any restored context (via `(await save(..))()`) will never be unset. We have to handle this case and unset the context to prevent errors such as false-positive state mutation errors
1 parent 93012e1 commit 489ccc0

File tree

4 files changed

+87
-4
lines changed

4 files changed

+87
-4
lines changed

.changeset/major-beans-fry.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: unset context on stale promises

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

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -113,20 +113,30 @@ export function async_derived(fn, location) {
113113
// only suspend in async deriveds created on initialisation
114114
var should_suspend = !active_reaction;
115115

116-
/** @type {Map<Batch, ReturnType<typeof deferred<V>>>} */
116+
/** @type {Map<Batch, ReturnType<typeof deferred<V>> & { rejected?: boolean }>} */
117117
var deferreds = new Map();
118118

119119
async_effect(() => {
120120
if (DEV) current_async_effect = active_effect;
121121

122-
/** @type {ReturnType<typeof deferred<V>>} */
122+
/** @type {ReturnType<typeof deferred<V>> & { rejected?: boolean }} */
123123
var d = deferred();
124124
promise = d.promise;
125125

126126
try {
127127
// If this code is changed at some point, make sure to still access the then property
128128
// of fn() to read any signals it might access, so that we track them as dependencies.
129-
Promise.resolve(fn()).then(d.resolve, d.reject);
129+
Promise.resolve(fn()).then((v) => {
130+
if (d.rejected) {
131+
// If we rejected this stale promise, d.resolve
132+
// is a noop (d.promise.then(handler) below will never run).
133+
// In this case we need to unset the restored context here
134+
// to avoid leaking it (and e.g. cause false-positive mutation errors).
135+
unset_context();
136+
} else {
137+
d.resolve(v);
138+
}
139+
}, d.reject);
130140
} catch (error) {
131141
d.reject(error);
132142
}
@@ -141,7 +151,11 @@ export function async_derived(fn, location) {
141151
if (!pending) {
142152
batch.increment();
143153

144-
deferreds.get(batch)?.reject(STALE_REACTION);
154+
var previous_deferred = deferreds.get(batch);
155+
if (previous_deferred) {
156+
previous_deferred.rejected = true;
157+
previous_deferred.reject(STALE_REACTION);
158+
}
145159
deferreds.set(batch, d);
146160
}
147161
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { test } from '../../test';
2+
3+
export default test({
4+
async test({ assert, target }) {
5+
// We gotta wait a bit more in this test because of the macrotasks in App.svelte
6+
function macrotask(t = 3) {
7+
return new Promise((r) => setTimeout(r, t));
8+
}
9+
10+
await macrotask();
11+
assert.htmlEqual(target.innerHTML, '<input> 1 | ');
12+
13+
const [input] = target.querySelectorAll('input');
14+
15+
input.value = '1';
16+
input.dispatchEvent(new Event('input', { bubbles: true }));
17+
await macrotask();
18+
assert.htmlEqual(target.innerHTML, '<input> 1 | ');
19+
20+
input.value = '12';
21+
input.dispatchEvent(new Event('input', { bubbles: true }));
22+
await macrotask(6);
23+
// TODO this is wrong (separate bug), this should be 3 | 12
24+
assert.htmlEqual(target.innerHTML, '<input> 5 | 12');
25+
}
26+
});
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<script>
2+
let count = $state(0);
3+
let value = $state('');
4+
let prev;
5+
6+
function asd(v) {
7+
const r = Promise.withResolvers();
8+
9+
if (prev || v === '') {
10+
console.log('hello', !!prev)
11+
Promise.resolve().then(async () => {
12+
console.log('count++')
13+
count++;
14+
r.resolve(v);
15+
await new Promise(r => setTimeout(r, 0));
16+
// TODO with a microtask like below it still throws a mutation error
17+
// await Promise.resolve();
18+
prev?.resolve();
19+
})
20+
} else {
21+
console.log('other')
22+
prev = Promise.withResolvers();
23+
prev.promise.then(() => {
24+
console.log('other coun++')
25+
count++;
26+
r.resolve(v)
27+
})
28+
}
29+
30+
return r.promise;
31+
}
32+
33+
const x = $derived(await asd(value))
34+
</script>
35+
36+
<input bind:value />
37+
38+
{count} | {x}

0 commit comments

Comments
 (0)