diff --git a/packages/svelte/src/index-client.js b/packages/svelte/src/index-client.js index 942a7b6bce..464bef85b7 100644 --- a/packages/svelte/src/index-client.js +++ b/packages/svelte/src/index-client.js @@ -238,7 +238,7 @@ function init_update_callbacks(context) { return (l.u ??= { a: [], b: [], m: [] }); } -export { flushSync } from './internal/client/runtime.js'; +export { flushSync } from './internal/client/reactivity/batch.js'; export { getContext, getAllContexts, hasContext, setContext } from './internal/client/context.js'; export { hydrate, mount, unmount } from './internal/client/render.js'; export { tick, untrack, settled } from './internal/client/runtime.js'; diff --git a/packages/svelte/src/internal/client/dom/blocks/await.js b/packages/svelte/src/internal/client/dom/blocks/await.js index 325224fff2..4f68db57b1 100644 --- a/packages/svelte/src/internal/client/dom/blocks/await.js +++ b/packages/svelte/src/internal/client/dom/blocks/await.js @@ -3,7 +3,7 @@ import { DEV } from 'esm-env'; import { is_promise } from '../../../shared/utils.js'; import { block, branch, pause_effect, resume_effect } from '../../reactivity/effects.js'; import { internal_set, mutable_source, source } from '../../reactivity/sources.js'; -import { flushSync, set_active_effect, set_active_reaction } from '../../runtime.js'; +import { set_active_effect, set_active_reaction } from '../../runtime.js'; import { hydrate_next, hydrate_node, @@ -22,6 +22,7 @@ import { set_dev_current_component_function, set_dev_stack } from '../../context.js'; +import { flushSync } from '../../reactivity/batch.js'; const PENDING = 0; const THEN = 1; diff --git a/packages/svelte/src/internal/client/index.js b/packages/svelte/src/internal/client/index.js index 25ef788604..04bad60c76 100644 --- a/packages/svelte/src/internal/client/index.js +++ b/packages/svelte/src/internal/client/index.js @@ -98,7 +98,7 @@ export { props_id, with_script } from './dom/template.js'; -export { suspend } from './reactivity/batch.js'; +export { flushSync as flush, suspend } from './reactivity/batch.js'; export { async_derived, user_derived as derived, @@ -142,7 +142,6 @@ export { get, safe_get, invalidate_inner_signals, - flushSync as flush, tick, untrack, exclude_from_object, diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 4e8015bde6..6ea395936b 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -1,17 +1,36 @@ /** @import { Derived, Effect, Source } from '#client' */ -import { CLEAN, DIRTY } from '#client/constants'; -import { deferred } from '../../shared/utils.js'; +import { + BLOCK_EFFECT, + BRANCH_EFFECT, + CLEAN, + DESTROYED, + DIRTY, + EFFECT, + EFFECT_ASYNC, + INERT, + RENDER_EFFECT, + ROOT_EFFECT +} from '#client/constants'; +import { async_mode_flag } from '../../flags/index.js'; +import { deferred, define_property } from '../../shared/utils.js'; import { get_pending_boundary } from '../dom/blocks/boundary.js'; import { - flush_queued_effects, - flush_queued_root_effects, - process_effects, + active_effect, + check_dirtiness, + dev_effect_stack, + is_updating_effect, queued_root_effects, - schedule_effect, + set_is_updating_effect, set_queued_root_effects, set_signal_status, update_effect } from '../runtime.js'; +import * as e from '../errors.js'; +import { flush_tasks } from '../dom/task.js'; +import { DEV } from 'esm-env'; +import { invoke_error_boundary } from '../error-handling.js'; +import { old_values } from './sources.js'; +import { unlink_effect } from './effects.js'; /** @type {Set} */ const batches = new Set(); @@ -22,6 +41,9 @@ export let current_batch = null; /** @type {Map | null} */ export let batch_deriveds = null; +/** @type {Effect | null} */ +let last_scheduled_effect = null; + /** TODO handy for debugging, but we should probably eventually delete it */ let uid = 1; @@ -329,6 +351,242 @@ export class Batch { } } +/** + * Synchronously flush any pending updates. + * Returns void if no callback is provided, otherwise returns the result of calling the callback. + * @template [T=void] + * @param {(() => T) | undefined} [fn] + * @returns {T} + */ +export function flushSync(fn) { + if (async_mode_flag && active_effect !== null) { + e.flush_sync_in_effect(); + } + + var result; + + const batch = Batch.ensure(); + + if (fn) { + flush_queued_root_effects(); + + result = fn(); + } + + while (true) { + flush_tasks(); + + if (queued_root_effects.length === 0) { + if (batch === current_batch) { + batch.flush(); + } + + // this would be reset in `flush_queued_root_effects` but since we are early returning here, + // we need to reset it here as well in case the first time there's 0 queued root effects + last_scheduled_effect = null; + + if (DEV) { + dev_effect_stack.length = 0; + } + + return /** @type {T} */ (result); + } + + flush_queued_root_effects(); + } +} + +function log_effect_stack() { + // eslint-disable-next-line no-console + console.error( + 'Last ten effects were: ', + dev_effect_stack.slice(-10).map((d) => d.fn) + ); + dev_effect_stack.length = 0; +} + +function infinite_loop_guard() { + try { + e.effect_update_depth_exceeded(); + } catch (error) { + if (DEV) { + // stack is garbage, ignore. Instead add a console.error message. + define_property(error, 'stack', { + value: '' + }); + } + // Try and handle the error so it can be caught at a boundary, that's + // if there's an effect available from when it was last scheduled + if (last_scheduled_effect !== null) { + if (DEV) { + try { + invoke_error_boundary(error, last_scheduled_effect); + } catch (e) { + // Only log the effect stack if the error is re-thrown + log_effect_stack(); + throw e; + } + } else { + invoke_error_boundary(error, last_scheduled_effect); + } + } else { + if (DEV) { + log_effect_stack(); + } + throw error; + } + } +} + +export function flush_queued_root_effects() { + var was_updating_effect = is_updating_effect; + var batch = /** @type {Batch} */ (current_batch); + + try { + var flush_count = 0; + set_is_updating_effect(true); + + while (queued_root_effects.length > 0) { + if (flush_count++ > 1000) { + infinite_loop_guard(); + } + + batch.process(queued_root_effects); + + old_values.clear(); + } + } finally { + set_is_updating_effect(was_updating_effect); + + last_scheduled_effect = null; + if (DEV) { + dev_effect_stack.length = 0; + } + } +} + +/** + * @param {Array} effects + * @returns {void} + */ +export function flush_queued_effects(effects) { + var length = effects.length; + if (length === 0) return; + + for (var i = 0; i < length; i++) { + var effect = effects[i]; + + if ((effect.f & (DESTROYED | INERT)) === 0) { + if (check_dirtiness(effect)) { + update_effect(effect); + + // Effects with no dependencies or teardown do not get added to the effect tree. + // Deferred effects (e.g. `$effect(...)`) _are_ added to the tree because we + // don't know if we need to keep them until they are executed. Doing the check + // here (rather than in `update_effect`) allows us to skip the work for + // immediate effects. + if (effect.deps === null && effect.first === null && effect.nodes_start === null) { + if (effect.teardown === null) { + // remove this effect from the graph + unlink_effect(effect); + } else { + // keep the effect in the graph, but free up some memory + effect.fn = null; + } + } + } + } + } +} + +/** + * @param {Effect} signal + * @returns {void} + */ +export function schedule_effect(signal) { + var effect = (last_scheduled_effect = signal); + + while (effect.parent !== null) { + effect = effect.parent; + var flags = effect.f; + + if ((flags & (ROOT_EFFECT | BRANCH_EFFECT)) !== 0) { + if ((flags & CLEAN) === 0) return; + effect.f ^= CLEAN; + } + } + + queued_root_effects.push(effect); +} + +/** + * + * This function both runs render effects and collects user effects in topological order + * from the starting effect passed in. Effects will be collected when they match the filtered + * bitwise flag passed in only. The collected effects array will be populated with all the user + * effects to be flushed. + * + * @param {Batch} batch + * @param {Effect} root + */ +export function process_effects(batch, root) { + root.f ^= CLEAN; + + var effect = root.first; + + while (effect !== null) { + var flags = effect.f; + var is_branch = (flags & (BRANCH_EFFECT | ROOT_EFFECT)) !== 0; + var is_skippable_branch = is_branch && (flags & CLEAN) !== 0; + + var skip = is_skippable_branch || (flags & INERT) !== 0 || batch.skipped_effects.has(effect); + + if (!skip && effect.fn !== null) { + if ((flags & EFFECT_ASYNC) !== 0) { + const boundary = effect.b; + + if (check_dirtiness(effect)) { + var effects = boundary?.pending ? batch.boundary_async_effects : batch.async_effects; + effects.push(effect); + } + } else if ((flags & BLOCK_EFFECT) !== 0) { + if (check_dirtiness(effect)) { + update_effect(effect); + } + } else if (is_branch) { + effect.f ^= CLEAN; + } else if ((flags & RENDER_EFFECT) !== 0) { + // we need to branch here because in legacy mode we run render effects + // before running block effects + if (async_mode_flag) { + batch.render_effects.push(effect); + } else { + if (check_dirtiness(effect)) { + update_effect(effect); + } + } + } else if ((flags & EFFECT) !== 0) { + batch.effects.push(effect); + } + + var child = effect.first; + + if (child !== null) { + effect = child; + continue; + } + } + + var parent = effect.parent; + effect = effect.next; + + while (effect === null && parent !== null) { + effect = parent.next; + parent = parent.parent; + } + } +} + export function suspend() { var boundary = get_pending_boundary(); var batch = /** @type {Batch} */ (current_batch); diff --git a/packages/svelte/src/internal/client/reactivity/effects.js b/packages/svelte/src/internal/client/reactivity/effects.js index b7fe3d86f1..a6b3c8f91a 100644 --- a/packages/svelte/src/internal/client/reactivity/effects.js +++ b/packages/svelte/src/internal/client/reactivity/effects.js @@ -7,7 +7,6 @@ import { get, is_destroying_effect, remove_reactions, - schedule_effect, set_active_reaction, set_is_destroying_effect, set_signal_status, @@ -40,7 +39,7 @@ import { DEV } from 'esm-env'; import { define_property } from '../../shared/utils.js'; import { get_next_sibling } from '../dom/operations.js'; import { component_context, dev_current_component_function, dev_stack } from '../context.js'; -import { Batch } from './batch.js'; +import { Batch, schedule_effect } from './batch.js'; import { flatten } from './async.js'; /** diff --git a/packages/svelte/src/internal/client/reactivity/sources.js b/packages/svelte/src/internal/client/reactivity/sources.js index aa0e3660bd..fec8fc6b42 100644 --- a/packages/svelte/src/internal/client/reactivity/sources.js +++ b/packages/svelte/src/internal/client/reactivity/sources.js @@ -5,7 +5,6 @@ import { active_effect, untracked_writes, get, - schedule_effect, set_untracked_writes, set_signal_status, untrack, @@ -34,7 +33,7 @@ import * as e from '../errors.js'; import { legacy_mode_flag, tracing_mode_flag } from '../../flags/index.js'; import { get_stack, tag_proxy } from '../dev/tracing.js'; import { component_context, is_runes } from '../context.js'; -import { Batch } from './batch.js'; +import { Batch, schedule_effect } from './batch.js'; import { proxy } from '../proxy.js'; import { execute_derived } from './deriveds.js'; diff --git a/packages/svelte/src/internal/client/runtime.js b/packages/svelte/src/internal/client/runtime.js index 75c6bda2dc..94e329cb72 100644 --- a/packages/svelte/src/internal/client/runtime.js +++ b/packages/svelte/src/internal/client/runtime.js @@ -1,21 +1,18 @@ /** @import { Derived, Effect, Reaction, Signal, Source, Value } from '#client' */ import { DEV } from 'esm-env'; -import { define_property, get_descriptors, get_prototype_of, index_of } from '../shared/utils.js'; +import { get_descriptors, get_prototype_of, index_of } from '../shared/utils.js'; import { destroy_block_effect_children, destroy_effect_children, - execute_effect_teardown, - unlink_effect + execute_effect_teardown } from './reactivity/effects.js'; import { - EFFECT, DIRTY, MAYBE_DIRTY, CLEAN, DERIVED, UNOWNED, DESTROYED, - INERT, BRANCH_EFFECT, STATE_SYMBOL, BLOCK_EFFECT, @@ -23,12 +20,9 @@ import { DISCONNECTED, REACTION_IS_UPDATING, EFFECT_IS_UPDATING, - EFFECT_ASYNC, - RENDER_EFFECT, STALE_REACTION, ERROR_VALUE } from './constants.js'; -import { flush_tasks } from './dom/task.js'; import { internal_set, old_values } from './reactivity/sources.js'; import { destroy_derived_effects, @@ -37,7 +31,6 @@ import { recent_async_deriveds, update_derived } from './reactivity/deriveds.js'; -import * as e from './errors.js'; import { async_mode_flag, tracing_mode_flag } from '../flags/index.js'; import { tracing_expressions, get_stack } from './dev/tracing.js'; import { @@ -50,13 +43,15 @@ import { set_dev_stack } from './context.js'; 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 { Batch, batch_deriveds, flushSync, schedule_effect } from './reactivity/batch.js'; +import { handle_error } from './error-handling.js'; -/** @type {Effect | null} */ -let last_scheduled_effect = null; +export let is_updating_effect = false; -let is_updating_effect = false; +/** @param {boolean} value */ +export function set_is_updating_effect(value) { + is_updating_effect = value; +} export let is_destroying_effect = false; @@ -65,8 +60,6 @@ export function set_is_destroying_effect(value) { is_destroying_effect = value; } -// Handle effect queues - /** @type {Effect[]} */ export let queued_root_effects = []; @@ -76,8 +69,7 @@ export function set_queued_root_effects(v) { } /** @type {Effect[]} Stack of effects, dev only */ -let dev_effect_stack = []; -// Handle signal reactivity tree dependencies and reactions +export let dev_effect_stack = []; /** @type {null | Reaction} */ export let active_reaction = null; @@ -522,242 +514,6 @@ export function update_effect(effect) { } } -function log_effect_stack() { - // eslint-disable-next-line no-console - console.error( - 'Last ten effects were: ', - dev_effect_stack.slice(-10).map((d) => d.fn) - ); - dev_effect_stack = []; -} - -function infinite_loop_guard() { - try { - e.effect_update_depth_exceeded(); - } catch (error) { - if (DEV) { - // stack is garbage, ignore. Instead add a console.error message. - define_property(error, 'stack', { - value: '' - }); - } - // Try and handle the error so it can be caught at a boundary, that's - // if there's an effect available from when it was last scheduled - if (last_scheduled_effect !== null) { - if (DEV) { - try { - invoke_error_boundary(error, last_scheduled_effect); - } catch (e) { - // Only log the effect stack if the error is re-thrown - log_effect_stack(); - throw e; - } - } else { - invoke_error_boundary(error, last_scheduled_effect); - } - } else { - if (DEV) { - log_effect_stack(); - } - throw error; - } - } -} - -export function flush_queued_root_effects() { - var was_updating_effect = is_updating_effect; - var batch = /** @type {Batch} */ (current_batch); - - try { - var flush_count = 0; - is_updating_effect = true; - - while (queued_root_effects.length > 0) { - if (flush_count++ > 1000) { - infinite_loop_guard(); - } - - batch.process(queued_root_effects); - - old_values.clear(); - } - } finally { - is_updating_effect = was_updating_effect; - - last_scheduled_effect = null; - if (DEV) { - dev_effect_stack = []; - } - } -} - -/** - * @param {Array} effects - * @returns {void} - */ -export function flush_queued_effects(effects) { - var length = effects.length; - if (length === 0) return; - - for (var i = 0; i < length; i++) { - var effect = effects[i]; - - if ((effect.f & (DESTROYED | INERT)) === 0) { - if (check_dirtiness(effect)) { - update_effect(effect); - - // Effects with no dependencies or teardown do not get added to the effect tree. - // Deferred effects (e.g. `$effect(...)`) _are_ added to the tree because we - // don't know if we need to keep them until they are executed. Doing the check - // here (rather than in `update_effect`) allows us to skip the work for - // immediate effects. - if (effect.deps === null && effect.first === null && effect.nodes_start === null) { - if (effect.teardown === null) { - // remove this effect from the graph - unlink_effect(effect); - } else { - // keep the effect in the graph, but free up some memory - effect.fn = null; - } - } - } - } - } -} - -/** - * @param {Effect} signal - * @returns {void} - */ -export function schedule_effect(signal) { - var effect = (last_scheduled_effect = signal); - - while (effect.parent !== null) { - effect = effect.parent; - var flags = effect.f; - - if ((flags & (ROOT_EFFECT | BRANCH_EFFECT)) !== 0) { - if ((flags & CLEAN) === 0) return; - effect.f ^= CLEAN; - } - } - - queued_root_effects.push(effect); -} - -/** - * - * This function both runs render effects and collects user effects in topological order - * from the starting effect passed in. Effects will be collected when they match the filtered - * bitwise flag passed in only. The collected effects array will be populated with all the user - * effects to be flushed. - * - * @param {Batch} batch - * @param {Effect} root - */ -export function process_effects(batch, root) { - root.f ^= CLEAN; - - var effect = root.first; - - while (effect !== null) { - var flags = effect.f; - var is_branch = (flags & (BRANCH_EFFECT | ROOT_EFFECT)) !== 0; - var is_skippable_branch = is_branch && (flags & CLEAN) !== 0; - - var skip = is_skippable_branch || (flags & INERT) !== 0 || batch.skipped_effects.has(effect); - - if (!skip && effect.fn !== null) { - if ((flags & EFFECT_ASYNC) !== 0) { - const boundary = effect.b; - - if (check_dirtiness(effect)) { - var effects = boundary?.pending ? batch.boundary_async_effects : batch.async_effects; - effects.push(effect); - } - } else if ((flags & BLOCK_EFFECT) !== 0) { - if (check_dirtiness(effect)) { - update_effect(effect); - } - } else if (is_branch) { - effect.f ^= CLEAN; - } else if ((flags & RENDER_EFFECT) !== 0) { - // we need to branch here because in legacy mode we run render effects - // before running block effects - if (async_mode_flag) { - batch.render_effects.push(effect); - } else { - if (check_dirtiness(effect)) { - update_effect(effect); - } - } - } else if ((flags & EFFECT) !== 0) { - batch.effects.push(effect); - } - - var child = effect.first; - - if (child !== null) { - effect = child; - continue; - } - } - - var parent = effect.parent; - effect = effect.next; - - while (effect === null && parent !== null) { - effect = parent.next; - parent = parent.parent; - } - } -} - -/** - * Synchronously flush any pending updates. - * Returns void if no callback is provided, otherwise returns the result of calling the callback. - * @template [T=void] - * @param {(() => T) | undefined} [fn] - * @returns {T} - */ -export function flushSync(fn) { - if (async_mode_flag && active_effect !== null) { - e.flush_sync_in_effect(); - } - - var result; - - const batch = Batch.ensure(); - - if (fn) { - flush_queued_root_effects(); - - result = fn(); - } - - while (true) { - flush_tasks(); - - if (queued_root_effects.length === 0) { - if (batch === current_batch) { - batch.flush(); - } - - // this would be reset in `flush_queued_root_effects` but since we are early returning here, - // we need to reset it here as well in case the first time there's 0 queued root effects - last_scheduled_effect = null; - - if (DEV) { - dev_effect_stack = []; - } - - return /** @type {T} */ (result); - } - - flush_queued_root_effects(); - } -} - /** * Returns a promise that resolves once any pending state changes have been applied. * @returns {Promise} @@ -768,6 +524,7 @@ export async function tick() { } await Promise.resolve(); + // By calling flushSync we guarantee that any pending state changes are applied after one tick. // TODO look into whether we can make flushing subsequent updates synchronously in the future. flushSync(); diff --git a/packages/svelte/src/legacy/legacy-client.js b/packages/svelte/src/legacy/legacy-client.js index 4ff1e619d5..6397cffe9f 100644 --- a/packages/svelte/src/legacy/legacy-client.js +++ b/packages/svelte/src/legacy/legacy-client.js @@ -3,7 +3,8 @@ import { DIRTY, LEGACY_PROPS, MAYBE_DIRTY } from '../internal/client/constants.j import { user_pre_effect } from '../internal/client/reactivity/effects.js'; import { mutable_source, set } from '../internal/client/reactivity/sources.js'; import { hydrate, mount, unmount } from '../internal/client/render.js'; -import { active_effect, flushSync, get, set_signal_status } from '../internal/client/runtime.js'; +import { active_effect, get, set_signal_status } from '../internal/client/runtime.js'; +import { flushSync } from '../internal/client/reactivity/batch.js'; import { lifecycle_outside_component } from '../internal/shared/errors.js'; import { define_property, is_array } from '../internal/shared/utils.js'; import * as w from '../internal/client/warnings.js'; diff --git a/packages/svelte/tests/store/test.ts b/packages/svelte/tests/store/test.ts index 77cecca7e5..ecb22c1be6 100644 --- a/packages/svelte/tests/store/test.ts +++ b/packages/svelte/tests/store/test.ts @@ -11,6 +11,7 @@ import { } from 'svelte/store'; import { source, set } from '../../src/internal/client/reactivity/sources'; import * as $ from '../../src/internal/client/runtime'; +import { flushSync } from '../../src/internal/client/reactivity/batch'; import { effect_root, render_effect } from 'svelte/internal/client'; describe('writable', () => { @@ -602,7 +603,7 @@ describe('toStore', () => { assert.deepEqual(log, [0]); set(count, 1); - $.flushSync(); + flushSync(); assert.deepEqual(log, [0, 1]); unsubscribe(); @@ -625,7 +626,7 @@ describe('toStore', () => { assert.deepEqual(log, [0]); set(count, 1); - $.flushSync(); + flushSync(); assert.deepEqual(log, [0, 1]); store.set(2); @@ -654,11 +655,11 @@ describe('fromStore', () => { assert.deepEqual(log, [0]); store.set(1); - $.flushSync(); + flushSync(); assert.deepEqual(log, [0, 1]); count.current = 2; - $.flushSync(); + flushSync(); assert.deepEqual(log, [0, 1, 2]); assert.equal(get(store), 2);