Skip to content

fix: make effects depend on state created inside them #16198

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Jun 18, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,23 @@ jobs:
- run: pnpm test
env:
CI: true
TestNoAsync:
permissions: {}
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm playwright install chromium
- run: pnpm test runtime-runes
env:
CI: true
SVELTE_NO_ASYNC: true
Lint:
permissions: {}
runs-on: ubuntu-latest
Expand Down
20 changes: 15 additions & 5 deletions packages/svelte/src/internal/client/runtime.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,6 @@ import {
import * as w from './warnings.js';
import { current_batch, Batch, batch_deriveds } from './reactivity/batch.js';
import { handle_error, invoke_error_boundary } from './error-handling.js';
import { snapshot } from '../shared/clone.js';

/** @type {Effect | null} */
let last_scheduled_effect = null;
Expand Down Expand Up @@ -259,7 +258,12 @@ function schedule_possible_effect_self_invalidation(signal, effect, root = true)
for (var i = 0; i < reactions.length; i++) {
var reaction = reactions[i];

if (reaction_sources?.[1].includes(signal) && reaction_sources[0] === active_reaction) continue;
if (
!async_mode_flag &&
reaction_sources?.[1].includes(signal) &&
reaction_sources[0] === active_reaction
)
continue;

if ((reaction.f & DERIVED) !== 0) {
schedule_possible_effect_self_invalidation(/** @type {Derived} */ (reaction), effect, false);
Expand Down Expand Up @@ -299,7 +303,9 @@ export function update_reaction(reaction) {
untracking = false;
read_version++;

reaction.f |= EFFECT_IS_UPDATING;
if (!async_mode_flag || (reaction.f & DERIVED) !== 0) {
reaction.f |= EFFECT_IS_UPDATING;
}

if (reaction.ac !== null) {
reaction.ac?.abort(STALE_REACTION);
Expand Down Expand Up @@ -383,7 +389,9 @@ export function update_reaction(reaction) {
set_component_context(previous_component_context);
untracking = previous_untracking;

reaction.f ^= EFFECT_IS_UPDATING;
if (!async_mode_flag || (reaction.f & DERIVED) !== 0) {
reaction.f ^= EFFECT_IS_UPDATING;
}
}
}

Expand Down Expand Up @@ -776,7 +784,9 @@ export function get(signal) {

if (
!destroyed &&
(!reaction_sources?.[1].includes(signal) || reaction_sources[0] !== active_reaction)
((async_mode_flag && (active_reaction.f & DERIVED) === 0) ||
!reaction_sources?.[1].includes(signal) ||
reaction_sources[0] !== active_reaction)
) {
var deps = active_reaction.deps;

Expand Down
5 changes: 5 additions & 0 deletions packages/svelte/src/internal/flags/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ export function enable_async_mode_flag() {
async_mode_flag = true;
}

/** ONLY USE THIS DURING TESTING */
export function disable_async_mode_flag() {
async_mode_flag = false;
}

export function enable_legacy_mode_flag() {
legacy_mode_flag = true;
}
Expand Down
2 changes: 2 additions & 0 deletions packages/svelte/tests/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,8 @@ if (typeof window !== 'undefined') {

export const fragments = /** @type {'html' | 'tree'} */ (process.env.FRAGMENTS) ?? 'html';

export const async_mode = process.env.SVELTE_NO_ASYNC !== 'true';

/**
* @param {any[]} logs
*/
Expand Down
20 changes: 16 additions & 4 deletions packages/svelte/tests/runtime-legacy/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ import { setImmediate } from 'node:timers/promises';
import { globSync } from 'tinyglobby';
import { createClassComponent } from 'svelte/legacy';
import { proxy } from 'svelte/internal/client';
import { flushSync, hydrate, mount, unmount, untrack } from 'svelte';
import { flushSync, hydrate, mount, unmount } from 'svelte';
import { render } from 'svelte/server';
import { afterAll, assert, beforeAll } from 'vitest';
import { compile_directory, fragments } from '../helpers.js';
import { async_mode, compile_directory, fragments } from '../helpers.js';
import { assert_html_equal, assert_html_equal_with_options } from '../html_equal.js';
import { raf } from '../animation-helpers.js';
import type { CompileOptions } from '#compiler';
Expand Down Expand Up @@ -45,6 +45,10 @@ export interface RuntimeTest<Props extends Record<string, any> = Record<string,
mode?: Array<'server' | 'client' | 'hydrate'>;
/** Temporarily skip specific modes, without skipping the entire test */
skip_mode?: Array<'server' | 'client' | 'hydrate'>;
/** Skip if running with process.env.NO_ASYNC */
skip_no_async?: boolean;
/** Skip if running without process.env.NO_ASYNC */
skip_async?: boolean;
html?: string;
ssrHtml?: string;
compileOptions?: Partial<CompileOptions>;
Expand Down Expand Up @@ -121,7 +125,15 @@ let console_error = console.error;
export function runtime_suite(runes: boolean) {
return suite_with_variants<RuntimeTest, 'hydrate' | 'ssr' | 'dom', CompileOptions>(
['dom', 'hydrate', 'ssr'],
(variant, config) => {
(variant, config, test_name) => {
if (!async_mode && (config.skip_no_async || test_name.startsWith('async-'))) {
return true;
}

if (async_mode && config.skip_async) {
return true;
}

if (variant === 'hydrate') {
if (config.mode && !config.mode.includes('hydrate')) return 'no-test';
if (config.skip_mode?.includes('hydrate')) return true;
Expand Down Expand Up @@ -169,7 +181,7 @@ async function common_setup(cwd: string, runes: boolean | undefined, config: Run
dev: force_hmr ? true : undefined,
hmr: force_hmr ? true : undefined,
experimental: {
async: runes
async: runes && async_mode
},
fragments,
...config.compileOptions,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { async_mode } from '../../../helpers';
import { test } from '../../test';
import { flushSync } from 'svelte';

Expand All @@ -10,6 +11,12 @@ export default test({
flushSync(() => {
b1.click();
});
assert.deepEqual(logs, ['init 0']);

// With async mode (which is on by default for runtime-runes) this works as expected, without it
// it works differently: https://github.com/sveltejs/svelte/pull/15564
assert.deepEqual(
logs,
async_mode ? ['init 0', 'cleanup 2', null, 'init 2', 'cleanup 4', null, 'init 4'] : ['init 0']
);
}
});
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,4 @@
})
</script>

<button on:click={() => count++ }>Click</button>
<button onclick={() => count++ }>Click</button>
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { test } from '../../test';

export default test({
skip_no_async: true,
async test({ assert, logs }) {
await Promise.resolve();
await Promise.resolve();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import { flushSync } from 'svelte';
import { test } from '../../test';
import { async_mode } from '../../../helpers';

export default test({
async test({ target, assert, logs }) {
const button = target.querySelector('button');

flushSync(() => button?.click());
assert.ok(logs[0].startsWith('set_context_after_init'));
assert.ok(
async_mode
? logs[0].startsWith('set_context_after_init')
: logs[0] === 'works without experimental async but really shouldnt'
);
}
});
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
if (condition) {
try {
setContext('potato', {});
console.log('works without experimental async but really shouldnt')
} catch (e) {
console.log(e.message);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { flushSync } from 'svelte';
import { test } from '../../test';

export default test({
// In async mode we _do_ want to run effects that react to their own state changing
skip_async: true,
test({ assert, target, logs }) {
const button = target.querySelector('button');

Expand Down
36 changes: 31 additions & 5 deletions packages/svelte/tests/signals/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { snapshot } from '../../src/internal/shared/clone.js';
import { SvelteSet } from '../../src/reactivity/set';
import { DESTROYED } from '../../src/internal/client/constants';
import { noop } from 'svelte/internal/client';
import { disable_async_mode_flag, enable_async_mode_flag } from '../../src/internal/flags';

/**
* @param runes runes mode
Expand Down Expand Up @@ -1010,14 +1011,39 @@ describe('signals', () => {
};
});

test('effects do not depend on state they own', () => {
user_effect(() => {
const value = state(0);
set(value, $.get(value) + 1);
test('effects do depend on state they own', (runes) => {
// This behavior is important for use cases like a Resource class
// which shares its instance between multiple effects and triggers
// rerenders by self-invalidating its state.
const log: number[] = [];

let count: any;

if (runes) {
// We will make this the new default behavior once it's stable but until then
// we need to keep the old behavior to not break existing code.
enable_async_mode_flag();
}

effect(() => {
if (!count || $.get<number>(count) < 2) {
count ||= state(0);
log.push($.get(count));
set(count, $.get<number>(count) + 1);
}
});

return () => {
flushSync();
try {
flushSync();
if (runes) {
assert.deepEqual(log, [0, 1]);
} else {
assert.deepEqual(log, [0]);
}
} finally {
disable_async_mode_flag();
}
};
});

Expand Down
6 changes: 3 additions & 3 deletions packages/svelte/tests/suite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export function suite<Test extends BaseTest>(fn: (config: Test, test_dir: string

export function suite_with_variants<Test extends BaseTest, Variants extends string, Common>(
variants: Variants[],
should_skip_variant: (variant: Variants, config: Test) => boolean | 'no-test',
should_skip_variant: (variant: Variants, config: Test, test_name: string) => boolean | 'no-test',
common_setup: (config: Test, test_dir: string) => Promise<Common> | Common,
fn: (config: Test, test_dir: string, variant: Variants, common: Common) => void
) {
Expand All @@ -46,11 +46,11 @@ export function suite_with_variants<Test extends BaseTest, Variants extends stri
let called_common = false;
let common: any = undefined;
for (const variant of variants) {
if (should_skip_variant(variant, config) === 'no-test') {
if (should_skip_variant(variant, config, dir) === 'no-test') {
continue;
}
// TODO unify test interfaces
const skip = config.skip || should_skip_variant(variant, config);
const skip = config.skip || should_skip_variant(variant, config, dir);
const solo = config.solo;
let it_fn = skip ? it.skip : solo ? it.only : it;

Expand Down