diff --git a/.changeset/small-geckos-camp.md b/.changeset/small-geckos-camp.md new file mode 100644 index 000000000000..622cbbbfa053 --- /dev/null +++ b/.changeset/small-geckos-camp.md @@ -0,0 +1,5 @@ +--- +"svelte": patch +--- + +feat: experimental `fork` API diff --git a/documentation/docs/03-template-syntax/19-await-expressions.md b/documentation/docs/03-template-syntax/19-await-expressions.md index 1c613af87091..2f73f6a47c20 100644 --- a/documentation/docs/03-template-syntax/19-await-expressions.md +++ b/documentation/docs/03-template-syntax/19-await-expressions.md @@ -135,6 +135,54 @@ If a `` with a `pending` snippet is encountered during SSR, tha > [!NOTE] In the future, we plan to add a streaming implementation that renders the content in the background. +## Forking + +The [`fork(...)`](svelte#fork) API, added in 5.42, makes it possible to run `await` expressions that you _expect_ to happen in the near future. This is mainly intended for frameworks like SvelteKit to implement preloading when (for example) users signal an intent to navigate. + +```svelte + + + + +{#if open} + + open = false} /> +{/if} +``` + ## Caveats As an experimental feature, the details of how `await` is handled (and related APIs like `$effect.pending()`) are subject to breaking changes outside of a semver major release, though we intend to keep such changes to a bare minimum. diff --git a/documentation/docs/98-reference/.generated/client-errors.md b/documentation/docs/98-reference/.generated/client-errors.md index 8fdb7770aa9b..74a0674dbab2 100644 --- a/documentation/docs/98-reference/.generated/client-errors.md +++ b/documentation/docs/98-reference/.generated/client-errors.md @@ -130,6 +130,12 @@ $effect(() => { Often when encountering this issue, the value in question shouldn't be state (for example, if you are pushing to a `logs` array in an effect, make `logs` a normal array rather than `$state([])`). In the rare cases where you really _do_ need to write to state in an effect — [which you should avoid]($effect#When-not-to-use-$effect) — you can read the state with [untrack](svelte#untrack) to avoid adding it as a dependency. +### experimental_async_fork + +``` +Cannot use `fork(...)` unless the `experimental.async` compiler option is `true` +``` + ### flush_sync_in_effect ``` @@ -140,6 +146,18 @@ The `flushSync()` function can be used to flush any pending effects synchronousl This restriction only applies when using the `experimental.async` option, which will be active by default in Svelte 6. +### fork_discarded + +``` +Cannot commit a fork that was already committed or discarded +``` + +### fork_timing + +``` +Cannot create a fork inside an effect or when state changes are pending +``` + ### get_abort_signal_outside_reaction ``` diff --git a/packages/svelte/messages/client-errors/errors.md b/packages/svelte/messages/client-errors/errors.md index 57ecca048977..b5fe51539d86 100644 --- a/packages/svelte/messages/client-errors/errors.md +++ b/packages/svelte/messages/client-errors/errors.md @@ -100,6 +100,10 @@ $effect(() => { Often when encountering this issue, the value in question shouldn't be state (for example, if you are pushing to a `logs` array in an effect, make `logs` a normal array rather than `$state([])`). In the rare cases where you really _do_ need to write to state in an effect — [which you should avoid]($effect#When-not-to-use-$effect) — you can read the state with [untrack](svelte#untrack) to avoid adding it as a dependency. +## experimental_async_fork + +> Cannot use `fork(...)` unless the `experimental.async` compiler option is `true` + ## flush_sync_in_effect > Cannot use `flushSync` inside an effect @@ -108,6 +112,14 @@ The `flushSync()` function can be used to flush any pending effects synchronousl This restriction only applies when using the `experimental.async` option, which will be active by default in Svelte 6. +## fork_discarded + +> Cannot commit a fork that was already committed or discarded + +## fork_timing + +> Cannot create a fork inside an effect or when state changes are pending + ## get_abort_signal_outside_reaction > `getAbortSignal()` can only be called inside an effect or derived diff --git a/packages/svelte/src/index-client.js b/packages/svelte/src/index-client.js index 337cbb500b39..4fcfff980dd8 100644 --- a/packages/svelte/src/index-client.js +++ b/packages/svelte/src/index-client.js @@ -241,7 +241,7 @@ function init_update_callbacks(context) { return (l.u ??= { a: [], b: [], m: [] }); } -export { flushSync } from './internal/client/reactivity/batch.js'; +export { flushSync, fork } from './internal/client/reactivity/batch.js'; export { createContext, getContext, diff --git a/packages/svelte/src/index-server.js b/packages/svelte/src/index-server.js index 223ce6a4cde1..61b0d98c0650 100644 --- a/packages/svelte/src/index-server.js +++ b/packages/svelte/src/index-server.js @@ -33,6 +33,10 @@ export function unmount() { e.lifecycle_function_unavailable('unmount'); } +export function fork() { + e.lifecycle_function_unavailable('fork'); +} + export async function tick() {} export async function settled() {} diff --git a/packages/svelte/src/index.d.ts b/packages/svelte/src/index.d.ts index 38e60866898f..a1782f5b61a5 100644 --- a/packages/svelte/src/index.d.ts +++ b/packages/svelte/src/index.d.ts @@ -352,4 +352,20 @@ export type MountOptions = Record props: Props; }); +/** + * Represents work that is happening off-screen, such as data being preloaded + * in anticipation of the user navigating + * @since 5.42 + */ +export interface Fork { + /** + * Commit the fork. The promise will resolve once the state change has been applied + */ + commit(): Promise; + /** + * Discard the fork + */ + discard(): void; +} + export * from './index-client.js'; diff --git a/packages/svelte/src/internal/client/constants.js b/packages/svelte/src/internal/client/constants.js index 6818fd9d3039..24dc9e4fb8d4 100644 --- a/packages/svelte/src/internal/client/constants.js +++ b/packages/svelte/src/internal/client/constants.js @@ -19,7 +19,7 @@ export const EFFECT_RAN = 1 << 15; * This is on a block effect 99% of the time but may also be on a branch effect if its parent block effect was pruned */ export const EFFECT_TRANSPARENT = 1 << 16; -export const INSPECT_EFFECT = 1 << 17; +export const EAGER_EFFECT = 1 << 17; export const HEAD_EFFECT = 1 << 18; export const EFFECT_PRESERVED = 1 << 19; export const USER_EFFECT = 1 << 20; diff --git a/packages/svelte/src/internal/client/dev/inspect.js b/packages/svelte/src/internal/client/dev/inspect.js index db7ab0d976b7..09150d6ee4e7 100644 --- a/packages/svelte/src/internal/client/dev/inspect.js +++ b/packages/svelte/src/internal/client/dev/inspect.js @@ -1,6 +1,6 @@ import { UNINITIALIZED } from '../../../constants.js'; import { snapshot } from '../../shared/clone.js'; -import { inspect_effect, render_effect, validate_effect } from '../reactivity/effects.js'; +import { eager_effect, render_effect, validate_effect } from '../reactivity/effects.js'; import { untrack } from '../runtime.js'; import { get_stack } from './tracing.js'; @@ -19,7 +19,7 @@ export function inspect(get_value, inspector, show_stack = false) { // stack traces. As a consequence, reading the value might result // in an error (an `$inspect(object.property)` will run before the // `{#if object}...{/if}` that contains it) - inspect_effect(() => { + eager_effect(() => { try { var value = get_value(); } catch (e) { diff --git a/packages/svelte/src/internal/client/dom/blocks/branches.js b/packages/svelte/src/internal/client/dom/blocks/branches.js index 827f9f44faf4..f1b9baf6f633 100644 --- a/packages/svelte/src/internal/client/dom/blocks/branches.js +++ b/packages/svelte/src/internal/client/dom/blocks/branches.js @@ -1,5 +1,4 @@ /** @import { Effect, TemplateNode } from '#client' */ -import { is_runes } from '../../context.js'; import { Batch, current_batch } from '../../reactivity/batch.js'; import { branch, @@ -8,7 +7,6 @@ import { pause_effect, resume_effect } from '../../reactivity/effects.js'; -import { set_should_intro, should_intro } from '../../render.js'; import { hydrate_node, hydrating } from '../hydration.js'; import { create_text, should_defer_append } from '../operations.js'; @@ -126,6 +124,22 @@ export class BranchManager { } }; + /** + * @param {Batch} batch + */ + #discard = (batch) => { + this.#batches.delete(batch); + + const keys = Array.from(this.#batches.values()); + + for (const [k, branch] of this.#offscreen) { + if (!keys.includes(k)) { + destroy_effect(branch.effect); + this.#offscreen.delete(k); + } + } + }; + /** * * @param {any} key @@ -173,7 +187,8 @@ export class BranchManager { } } - batch.add_callback(this.#commit); + batch.oncommit(this.#commit); + batch.ondiscard(this.#discard); } else { if (hydrating) { this.anchor = hydrate_node; diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js index a6369a721133..a0fae3713305 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -310,7 +310,7 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f } } - batch.add_callback(commit); + batch.oncommit(commit); } else { commit(); } diff --git a/packages/svelte/src/internal/client/errors.js b/packages/svelte/src/internal/client/errors.js index 937971da5e0b..2a433ed8f9b7 100644 --- a/packages/svelte/src/internal/client/errors.js +++ b/packages/svelte/src/internal/client/errors.js @@ -229,6 +229,22 @@ export function effect_update_depth_exceeded() { } } +/** + * Cannot use `fork(...)` unless the `experimental.async` compiler option is `true` + * @returns {never} + */ +export function experimental_async_fork() { + if (DEV) { + const error = new Error(`experimental_async_fork\nCannot use \`fork(...)\` unless the \`experimental.async\` compiler option is \`true\`\nhttps://svelte.dev/e/experimental_async_fork`); + + error.name = 'Svelte error'; + + throw error; + } else { + throw new Error(`https://svelte.dev/e/experimental_async_fork`); + } +} + /** * Cannot use `flushSync` inside an effect * @returns {never} @@ -245,6 +261,38 @@ export function flush_sync_in_effect() { } } +/** + * Cannot commit a fork that was already committed or discarded + * @returns {never} + */ +export function fork_discarded() { + if (DEV) { + const error = new Error(`fork_discarded\nCannot commit a fork that was already committed or discarded\nhttps://svelte.dev/e/fork_discarded`); + + error.name = 'Svelte error'; + + throw error; + } else { + throw new Error(`https://svelte.dev/e/fork_discarded`); + } +} + +/** + * Cannot create a fork inside an effect or when state changes are pending + * @returns {never} + */ +export function fork_timing() { + if (DEV) { + const error = new Error(`fork_timing\nCannot create a fork inside an effect or when state changes are pending\nhttps://svelte.dev/e/fork_timing`); + + error.name = 'Svelte error'; + + throw error; + } else { + throw new Error(`https://svelte.dev/e/fork_timing`); + } +} + /** * `getAbortSignal()` can only be called inside an effect or derived * @returns {never} diff --git a/packages/svelte/src/internal/client/proxy.js b/packages/svelte/src/internal/client/proxy.js index dae3791eb0c7..9baacacd0df7 100644 --- a/packages/svelte/src/internal/client/proxy.js +++ b/packages/svelte/src/internal/client/proxy.js @@ -19,8 +19,8 @@ import { state as source, set, increment, - flush_inspect_effects, - set_inspect_effects_deferred + flush_eager_effects, + set_eager_effects_deferred } from './reactivity/sources.js'; import { PROXY_PATH_SYMBOL, STATE_SYMBOL } from '#client/constants'; import { UNINITIALIZED } from '../../constants.js'; @@ -421,9 +421,9 @@ function inspectable_array(array) { * @param {any[]} args */ return function (...args) { - set_inspect_effects_deferred(); + set_eager_effects_deferred(); var result = value.apply(this, args); - flush_inspect_effects(); + flush_eager_effects(); return result; }; } diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 2cef562ac9ef..fdeb111a4dfc 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -1,3 +1,4 @@ +/** @import { Fork } from 'svelte' */ /** @import { Derived, Effect, Reaction, Source, Value } from '#client' */ import { BLOCK_EFFECT, @@ -12,25 +13,35 @@ import { ROOT_EFFECT, MAYBE_DIRTY, DERIVED, - BOUNDARY_EFFECT + BOUNDARY_EFFECT, + EAGER_EFFECT } from '#client/constants'; import { async_mode_flag } from '../../flags/index.js'; import { deferred, define_property } from '../../shared/utils.js'; import { active_effect, get, + increment_write_version, is_dirty, is_updating_effect, set_is_updating_effect, set_signal_status, + tick, update_effect } from '../runtime.js'; import * as e from '../errors.js'; import { flush_tasks, queue_micro_task } from '../dom/task.js'; import { DEV } from 'esm-env'; import { invoke_error_boundary } from '../error-handling.js'; -import { old_values, source, update } from './sources.js'; -import { inspect_effect, unlink_effect } from './effects.js'; +import { + flush_eager_effects, + eager_effects, + old_values, + set_eager_effects, + source, + update +} from './sources.js'; +import { eager_effect, unlink_effect } from './effects.js'; /** * @typedef {{ @@ -90,14 +101,20 @@ export class Batch { * They keys of this map are identical to `this.#current` * @type {Map} */ - #previous = new Map(); + previous = new Map(); /** * When the batch is committed (and the DOM is updated), we need to remove old branches * and append new ones by calling the functions added inside (if/each/key/etc) blocks * @type {Set<() => void>} */ - #callbacks = new Set(); + #commit_callbacks = new Set(); + + /** + * If a fork is discarded, we need to destroy any effects that are no longer needed + * @type {Set<(batch: Batch) => void>} + */ + #discard_callbacks = new Set(); /** * The number of async effects that are currently in flight @@ -135,6 +152,8 @@ export class Batch { */ skipped_effects = new Set(); + is_fork = false; + /** * * @param {Effect[]} root_effects @@ -159,15 +178,15 @@ export class Batch { this.#traverse_effect_tree(root, target); } - this.#resolve(); + if (!this.is_fork) { + this.#resolve(); + } - if (this.#blocking_pending > 0) { + if (this.#blocking_pending > 0 || this.is_fork) { this.#defer_effects(target.effects); this.#defer_effects(target.render_effects); this.#defer_effects(target.block_effects); } else { - // TODO append/detach blocks here, not in #commit - // If sources are written to, then work needs to happen in a separate batch, else prior sources would be mixed with // newly updated sources, which could lead to infinite loops when effects run over and over again. previous_batch = this; @@ -271,8 +290,8 @@ export class Batch { * @param {any} value */ capture(source, value) { - if (!this.#previous.has(source)) { - this.#previous.set(source, value); + if (!this.previous.has(source)) { + this.previous.set(source, value); } this.current.set(source, source.v); @@ -289,16 +308,17 @@ export class Batch { } flush() { + this.activate(); + if (queued_root_effects.length > 0) { - this.activate(); flush_effects(); if (current_batch !== null && current_batch !== this) { // this can happen if a new batch was created during `flush_effects()` return; } - } else { - this.#resolve(); + } else if (this.#pending === 0) { + this.process([]); // TODO this feels awkward } this.deactivate(); @@ -314,11 +334,16 @@ export class Batch { } } + discard() { + for (const fn of this.#discard_callbacks) fn(this); + this.#discard_callbacks.clear(); + } + #resolve() { if (this.#blocking_pending === 0) { // append/remove branches - for (const fn of this.#callbacks) fn(); - this.#callbacks.clear(); + for (const fn of this.#commit_callbacks) fn(); + this.#commit_callbacks.clear(); } if (this.#pending === 0) { @@ -332,7 +357,7 @@ export class Batch { // committed state, unless the batch in question has a more // recent value for a given source if (batches.size > 1) { - this.#previous.clear(); + this.previous.clear(); var previous_batch_values = batch_values; var is_earlier = true; @@ -428,6 +453,10 @@ export class Batch { this.#pending -= 1; if (blocking) this.#blocking_pending -= 1; + this.revive(); + } + + revive() { for (const e of this.#dirty_effects) { set_signal_status(e, DIRTY); schedule_effect(e); @@ -445,8 +474,13 @@ export class Batch { } /** @param {() => void} fn */ - add_callback(fn) { - this.#callbacks.add(fn); + oncommit(fn) { + this.#commit_callbacks.add(fn); + } + + /** @param {(batch: Batch) => void} fn */ + ondiscard(fn) { + this.#discard_callbacks.add(fn); } settled() { @@ -489,7 +523,7 @@ export class Batch { for (const batch of batches) { if (batch === this) continue; - for (const [source, previous] of batch.#previous) { + for (const [source, previous] of batch.previous) { if (!batch_values.has(source)) { batch_values.set(source, previous); } @@ -717,6 +751,28 @@ function mark_effects(value, sources, marked, checked) { } } +/** + * When committing a fork, we need to trigger eager effects so that + * any `$state.eager(...)` expressions update immediately. This + * function allows us to discover them + * @param {Value} value + * @param {Set} effects + */ +function mark_eager_effects(value, effects) { + if (value.reactions === null) return; + + for (const reaction of value.reactions) { + const flags = reaction.f; + + if ((flags & DERIVED) !== 0) { + mark_eager_effects(/** @type {Derived} */ (reaction), effects); + } else if ((flags & EAGER_EFFECT) !== 0) { + set_signal_status(reaction, DIRTY); + effects.add(/** @type {Effect} */ (reaction)); + } + } +} + /** * @param {Reaction} reaction * @param {Source[]} sources @@ -798,9 +854,9 @@ export function eager(fn) { get(version); - inspect_effect(() => { + eager_effect(() => { if (initial) { - // the first time this runs, we create an inspect effect + // the first time this runs, we create an eager effect // that will run eagerly whenever the expression changes var previous_batch_values = batch_values; @@ -829,6 +885,88 @@ export function eager(fn) { return value; } +/** + * Creates a 'fork', in which state changes are evaluated but not applied to the DOM. + * This is useful for speculatively loading data (for example) when you suspect that + * the user is about to take some action. + * + * Frameworks like SvelteKit can use this to preload data when the user touches or + * hovers over a link, making any subsequent navigation feel instantaneous. + * + * The `fn` parameter is a synchronous function that modifies some state. The + * state changes will be reverted after the fork is initialised, then reapplied + * if and when the fork is eventually committed. + * + * When it becomes clear that a fork will _not_ be committed (e.g. because the + * user navigated elsewhere), it must be discarded to avoid leaking memory. + * + * @param {() => void} fn + * @returns {Fork} + * @since 5.42 + */ +export function fork(fn) { + if (!async_mode_flag) { + e.experimental_async_fork(); + } + + if (current_batch !== null) { + e.fork_timing(); + } + + const batch = Batch.ensure(); + batch.is_fork = true; + + const settled = batch.settled(); + + flushSync(fn); + + // revert state changes + for (const [source, value] of batch.previous) { + source.v = value; + } + + return { + commit: async () => { + if (!batches.has(batch)) { + e.fork_discarded(); + } + + batch.is_fork = false; + + // apply changes + for (const [source, value] of batch.current) { + source.v = value; + } + + // trigger any `$state.eager(...)` expressions with the new state. + // eager effects don't get scheduled like other effects, so we + // can't just encounter them during traversal, we need to + // proactively flush them + // TODO maybe there's a better implementation? + flushSync(() => { + /** @type {Set} */ + const eager_effects = new Set(); + + for (const source of batch.current.keys()) { + mark_eager_effects(source, eager_effects); + } + + set_eager_effects(eager_effects); + flush_eager_effects(); + }); + + batch.revive(); + await settled; + }, + discard: () => { + if (batches.has(batch)) { + batches.delete(batch); + batch.discard(); + } + } + }; +} + /** * Forcibly remove all current batches, to prevent cross-talk between tests */ diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js index 5a3dee4b7feb..b6a50acc4d42 100644 --- a/packages/svelte/src/internal/client/reactivity/deriveds.js +++ b/packages/svelte/src/internal/client/reactivity/deriveds.js @@ -28,7 +28,7 @@ import { equals, safe_equals } from './equality.js'; import * as e from '../errors.js'; import * as w from '../warnings.js'; import { async_effect, destroy_effect, teardown } from './effects.js'; -import { inspect_effects, internal_set, set_inspect_effects, source } from './sources.js'; +import { eager_effects, internal_set, set_eager_effects, source } from './sources.js'; import { get_stack } from '../dev/tracing.js'; import { async_mode_flag, tracing_mode_flag } from '../../flags/index.js'; import { Boundary } from '../dom/blocks/boundary.js'; @@ -318,8 +318,8 @@ export function execute_derived(derived) { set_active_effect(get_derived_parent_effect(derived)); if (DEV) { - let prev_inspect_effects = inspect_effects; - set_inspect_effects(new Set()); + let prev_eager_effects = eager_effects; + set_eager_effects(new Set()); try { if (stack.includes(derived)) { e.derived_references_self(); @@ -332,7 +332,7 @@ export function execute_derived(derived) { value = update_reaction(derived); } finally { set_active_effect(prev_active_effect); - set_inspect_effects(prev_inspect_effects); + set_eager_effects(prev_eager_effects); stack.pop(); } } else { diff --git a/packages/svelte/src/internal/client/reactivity/effects.js b/packages/svelte/src/internal/client/reactivity/effects.js index 9b54598f9e2b..4235e9cb241a 100644 --- a/packages/svelte/src/internal/client/reactivity/effects.js +++ b/packages/svelte/src/internal/client/reactivity/effects.js @@ -27,7 +27,7 @@ import { DERIVED, UNOWNED, CLEAN, - INSPECT_EFFECT, + EAGER_EFFECT, HEAD_EFFECT, MAYBE_DIRTY, EFFECT_PRESERVED, @@ -88,7 +88,7 @@ function create_effect(type, fn, sync, push = true) { if (DEV) { // Ensure the parent is never an inspect effect - while (parent !== null && (parent.f & INSPECT_EFFECT) !== 0) { + while (parent !== null && (parent.f & EAGER_EFFECT) !== 0) { parent = parent.parent; } } @@ -245,8 +245,8 @@ export function user_pre_effect(fn) { } /** @param {() => void | (() => void)} fn */ -export function inspect_effect(fn) { - return create_effect(INSPECT_EFFECT, fn, true); +export function eager_effect(fn) { + return create_effect(EAGER_EFFECT, fn, true); } /** diff --git a/packages/svelte/src/internal/client/reactivity/sources.js b/packages/svelte/src/internal/client/reactivity/sources.js index 2fe8c4f75de1..9534e718a5a9 100644 --- a/packages/svelte/src/internal/client/reactivity/sources.js +++ b/packages/svelte/src/internal/client/reactivity/sources.js @@ -22,7 +22,7 @@ import { DERIVED, DIRTY, BRANCH_EFFECT, - INSPECT_EFFECT, + EAGER_EFFECT, UNOWNED, MAYBE_DIRTY, BLOCK_EFFECT, @@ -39,7 +39,7 @@ import { proxy } from '../proxy.js'; import { execute_derived } from './deriveds.js'; /** @type {Set} */ -export let inspect_effects = new Set(); +export let eager_effects = new Set(); /** @type {Map} */ export const old_values = new Map(); @@ -47,14 +47,14 @@ export const old_values = new Map(); /** * @param {Set} v */ -export function set_inspect_effects(v) { - inspect_effects = v; +export function set_eager_effects(v) { + eager_effects = v; } -let inspect_effects_deferred = false; +let eager_effects_deferred = false; -export function set_inspect_effects_deferred() { - inspect_effects_deferred = true; +export function set_eager_effects_deferred() { + eager_effects_deferred = true; } /** @@ -146,9 +146,9 @@ export function set(source, value, should_proxy = false) { active_reaction !== null && // since we are untracking the function inside `$inspect.with` we need to add this check // to ensure we error if state is set inside an inspect effect - (!untracking || (active_reaction.f & INSPECT_EFFECT) !== 0) && + (!untracking || (active_reaction.f & EAGER_EFFECT) !== 0) && is_runes() && - (active_reaction.f & (DERIVED | BLOCK_EFFECT | ASYNC | INSPECT_EFFECT)) !== 0 && + (active_reaction.f & (DERIVED | BLOCK_EFFECT | ASYNC | EAGER_EFFECT)) !== 0 && !current_sources?.includes(source) ) { e.state_unsafe_mutation(); @@ -235,18 +235,18 @@ export function internal_set(source, value) { } } - if (DEV && inspect_effects.size > 0 && !inspect_effects_deferred) { - flush_inspect_effects(); + if (!batch.is_fork && eager_effects.size > 0 && !eager_effects_deferred) { + flush_eager_effects(); } } return value; } -export function flush_inspect_effects() { - inspect_effects_deferred = false; +export function flush_eager_effects() { + eager_effects_deferred = false; - const inspects = Array.from(inspect_effects); + const inspects = Array.from(eager_effects); for (const effect of inspects) { // Mark clean inspect-effects as maybe dirty and then check their dirtiness @@ -260,7 +260,7 @@ export function flush_inspect_effects() { } } - inspect_effects.clear(); + eager_effects.clear(); } /** @@ -320,8 +320,8 @@ function mark_reactions(signal, status) { if (!runes && reaction === active_effect) continue; // Inspect effects need to run immediately, so that the stack trace makes sense - if (DEV && (flags & INSPECT_EFFECT) !== 0) { - inspect_effects.add(reaction); + if (DEV && (flags & EAGER_EFFECT) !== 0) { + eager_effects.add(reaction); continue; } diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork/_config.js b/packages/svelte/tests/runtime-runes/samples/async-fork/_config.js new file mode 100644 index 000000000000..35b47525a263 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork/_config.js @@ -0,0 +1,92 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target, raf }) { + const [shift, increment, commit] = target.querySelectorAll('button'); + + shift.click(); + await tick(); + + assert.htmlEqual( + target.innerHTML, + ` + + + +

count: 0

+

eager: 0

+

even

+ ` + ); + + increment.click(); + await tick(); + + shift.click(); + await tick(); + + // nothing updates until commit + assert.htmlEqual( + target.innerHTML, + ` + + + +

count: 0

+

eager: 0

+

even

+ ` + ); + + commit.click(); + await tick(); + + // nothing updates until commit + assert.htmlEqual( + target.innerHTML, + ` + + + +

count: 1

+

eager: 1

+

odd

+ ` + ); + + increment.click(); + await tick(); + + commit.click(); + await tick(); + + // eager state updates on commit + assert.htmlEqual( + target.innerHTML, + ` + + + +

count: 1

+

eager: 2

+

odd

+ ` + ); + + shift.click(); + await tick(); + + assert.htmlEqual( + target.innerHTML, + ` + + + +

count: 2

+

eager: 2

+

even

+ ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-fork/main.svelte new file mode 100644 index 000000000000..9b1f433f2d06 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork/main.svelte @@ -0,0 +1,37 @@ + + + + + + +

count: {count}

+

eager: {$state.eager(count)}

+ + + {#if await push(count) % 2 === 0} +

even

+ {:else} +

odd

+ {/if} + + {#snippet pending()} +

loading...

+ {/snippet} +
diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index d260b738c3cf..5e3ca77eb5cd 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -348,6 +348,22 @@ declare module 'svelte' { */ props: Props; }); + + /** + * Represents work that is happening off-screen, such as data being preloaded + * in anticipation of the user navigating + * @since 5.42 + */ + export interface Fork { + /** + * Commit the fork. The promise will resolve once the state change has been applied + */ + commit(): Promise; + /** + * Discard the fork + */ + discard(): void; + } /** * Returns an [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) that aborts when the current [derived](https://svelte.dev/docs/svelte/$derived) or [effect](https://svelte.dev/docs/svelte/$effect) re-runs or is destroyed. * @@ -434,11 +450,6 @@ declare module 'svelte' { * @deprecated Use [`$effect`](https://svelte.dev/docs/svelte/$effect) instead * */ export function afterUpdate(fn: () => void): void; - /** - * Synchronously flush any pending updates. - * Returns void if no callback is provided, otherwise returns the result of calling the callback. - * */ - export function flushSync(fn?: (() => T) | undefined): T; /** * Create a snippet programmatically * */ @@ -448,6 +459,29 @@ declare module 'svelte' { }): Snippet; /** Anything except a function */ type NotFunction = T extends Function ? never : T; + /** + * Synchronously flush any pending updates. + * Returns void if no callback is provided, otherwise returns the result of calling the callback. + * */ + export function flushSync(fn?: (() => T) | undefined): T; + /** + * Creates a 'fork', in which state changes are evaluated but not applied to the DOM. + * This is useful for speculatively loading data (for example) when you suspect that + * the user is about to take some action. + * + * Frameworks like SvelteKit can use this to preload data when the user touches or + * hovers over a link, making any subsequent navigation feel instantaneous. + * + * The `fn` parameter is a synchronous function that modifies some state. The + * state changes will be reverted after the fork is initialised, then reapplied + * if and when the fork is eventually committed. + * + * When it becomes clear that a fork will _not_ be committed (e.g. because the + * user navigated elsewhere), it must be discarded to avoid leaking memory. + * + * @since 5.42 + */ + export function fork(fn: () => void): Fork; /** * Returns a `[get, set]` pair of functions for working with context in a type-safe way. *