fix: abort and reschedule effect processing after state change in user effect
#16280
Closing issue
Describe the bug
I have a Modal Component that provides the style of Modal, content slot, and methods to open and close Modal.
The control method of opening and closing is implemented through the state of current,and current is set to undefiend when closing.
When calling this component, if there are child components within chilren, and first-level child component has an $effect, and second-level child component calls the clse methods, an exception whill be thrown.
Uncaught TypeError: Cannot read properties of undefined (reading 'model')
I have a #if check for the data usage, but the error still occurs. I suspect that the child components'rerendering executes earlier than the #if check.
test.svelte.ts(data model):
type State = 'init' | 'submitted';
export function createModal() {
let state = $state<State>('init');
return {
get state() {
return state;
},
submit() {
state = 'submitted';
}
};
}
export type Model = ReturnType<typeof createModal>;
Modal.svelte:
<script lang="ts" module>
let current = $state<{ model: Model }>();
export function open(model: Model) {
current = {
model
};
}
export function close() {
current = undefined;
}
</script>
<script lang="ts">
import { onDestroy, type Snippet } from 'svelte';
import type { Model } from './test.svelte';
let {
children
}: {
children: Snippet<[Model]>;
} = $props();
onDestroy(() => {
console.log('destroy test');
});
</script>
{#if current?.model}
{@render children(current.model)}
{/if}
+page.svelte
<script>
import { createModal } from './test.svelte';
import TestChildren1 from './TestChildren1.svelte';
import TestComponent, { open } from './Modal.svelte';
const model = createModal();
$effect(() => {
console.log('model', model);
});
</script>
<button
onclick={() => {
open(model);
}}>open</button
>
<TestComponent>
{#snippet children(model)}
<TestChildren1 {model} />
{/snippet}
</TestComponent>
TestChildren1:
<script lang="ts">
import { onDestroy } from 'svelte';
import TestChildren2 from './TestChildren2.svelte';
import type { Model } from './test.svelte';
let {
model
}: {
model: Model;
} = $props();
$effect(() => {
console.log('b', JSON.stringify(model));
});
onDestroy(() => {
console.log('destroy component 1');
});
</script>
<span>{model.state}</span>
<TestChildren2 {model} />
TestChildren2:
<script lang="ts">
import { onDestroy } from 'svelte';
import { close } from './Modal.svelte';
import type { Model } from './test.svelte';
let {
model
}: {
model: Model;
} = $props();
$effect(() => {
if (model.state === 'submitted') close();
});
setTimeout(() => {
model.submit();
}, 1000);
onDestroy(() => {
console.log('destroy component 2');
});
</script>
<span>{model.state}</span>
Reproduction
example:https://github.com/Sincenir/svelte-issues-effect-and-modal
localhost... /test2
Logs
System Info
chunk-ZPWZ3TV6.js?v=a4869f9a:1549 Uncaught TypeError: Cannot read properties of undefined (reading 'model')
in $effect
in TestChildren1.svelte
in Modal.svelte
in +page.svelte
in +layout.svelte
in root.svelte
at Modal.svelte:29:27
at get model (+page.svelte:19:26)
at $effect (TestChildren1.svelte:13:34)
(匿名) @ Modal.svelte:29
get model @ +page.svelte:19
$effect @ TestChildren1.svelte:13
setTimeout
TestChildren2 @ TestChildren2.svelte:16
TestChildren1 @ TestChildren1.svelte:18
(匿名) @ +page.svelte:19
consequent @ Modal.svelte:25
(匿名) @ Modal.svelte:28
Severity
blocking an upgrade
Pull request
Fixes #16072. It's a real edge case, hence the convoluted nature of the test, but it's possible for a state change in one effect to cause a subsequent already-scheduled effect to run even though the second effect would be destroyed if the effect tree was being processed from the root.
The solution, I think, is to abort the processing of effects if a state change occurs. We can schedule the remaining effects and start again from the top; this will ensure that any dirty branches (i.e. a now-falsy if containing an effect with a now-broken reference) are updated before their children. Within each flush of the effect tree, predictable order is preserved.
We need to distinguish between user effects and non-user effects, since element bindings can result in state changes, and these should not cause flushing to be aborted (they are guaranteed to run before the effects that depend on the changed state, so this is not a problem).
Before submitting the PR, please make sure you do the following
- It's really useful if your PR references an issue where it is discussed ahead of time. In many cases, features are absent for a reason. For large changes, please create an RFC: https://github.com/sveltejs/rfcs
- Prefix your PR title with
feat:,fix:,chore:, ordocs:. - This message body should clearly illustrate what problems it solves.
- Ideally, include a test that fails without this PR but passes with it.
- If this PR changes code within
packages/svelte/src, add a changeset (npx changeset).
Tests and linting
- Run the tests with
pnpm testand lint the project withpnpm lint
Info
🦋 Changeset detected
Latest commit: 8d82339
The changes in this PR will be included in the next version bump.
This PR includes changesets to release 1 package
| Name | Type |
|---|---|
| svelte | Patch |
Not sure what this means? Click here to learn what changesets are.
Click here if you're a maintainer who wants to add another changeset to this PR
pnpm add https://pkg.pr.new/svelte@16280This is a good solution IMO - it doesn't yet work for $effect.pre though, since that's executed in another part. If I change the reproducible to use $effect.pre
Adding similar logic to process_effects and returning an empty array seems to fix it.
Good catch. Rather than making changes to process_effects, which will create more merge headaches with the async branch, I think we should park this until that branch is merged. Adding a failing test in the meantime
I've skipped the $effect.pre for now in the interests of merging — we can revisit it once async is in
I was having a problem similar to #16072 and after pulling the latest update, it's still there. It happens only if there is an $inspect in the components inside an if directive.
I wasn't able to reproduce exactly that scenario, but I got another issue when trying to recreate it, which also is running code before the if directive executes. Here's a simple REPL, where when you click the button, it assigns undefined to activeTab, which should make the child component disappear, but some code is still running within the if directive, attempting to read from activeTab, and it throws an error (If you remove the $inspect inside the child, the error goes away):
As for my original problem, all I can provide are these screenshots since I couldn't reproduce, but this is just for reference, the REPL above is enough to trigger the issue:
Here's the error:
And here's where that error is thrown, which as you can see, that code shouldn't even be running given the condition of the if statement.:
@abtinturing please create a new issue.
fix: abort and reschedule effect processing after state change in user effect
• Jul 2, 2025, 12:41 AM