diff --git a/.changeset/easy-paths-take.md b/.changeset/easy-paths-take.md deleted file mode 100644 index 1378322abe..0000000000 --- a/.changeset/easy-paths-take.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: take into account static blocks when determining transition locality diff --git a/.changeset/slimy-turtles-yell.md b/.changeset/slimy-turtles-yell.md deleted file mode 100644 index e3f3a66264..0000000000 --- a/.changeset/slimy-turtles-yell.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: flush pending changes after rendering `failed` snippet diff --git a/.changeset/small-geckos-camp.md b/.changeset/small-geckos-camp.md new file mode 100644 index 0000000000..622cbbbfa0 --- /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 1c613af870..2f73f6a47c 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 8fdb7770aa..74a0674dba 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/CHANGELOG.md b/packages/svelte/CHANGELOG.md index 7692383aed..d63548d3e1 100644 --- a/packages/svelte/CHANGELOG.md +++ b/packages/svelte/CHANGELOG.md @@ -1,5 +1,17 @@ # svelte +## 5.41.4 + +### Patch Changes + +- fix: take into account static blocks when determining transition locality ([#17018](https://github.com/sveltejs/svelte/pull/17018)) + +- fix: coordinate mount of snippets with await expressions ([#17021](https://github.com/sveltejs/svelte/pull/17021)) + +- fix: better optimization of await expressions ([#17025](https://github.com/sveltejs/svelte/pull/17025)) + +- fix: flush pending changes after rendering `failed` snippet ([#16995](https://github.com/sveltejs/svelte/pull/16995)) + ## 5.41.3 ### Patch Changes diff --git a/packages/svelte/messages/client-errors/errors.md b/packages/svelte/messages/client-errors/errors.md index 57ecca0489..b5fe51539d 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/package.json b/packages/svelte/package.json index 2cb3bf4ab3..1d50920d4f 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -2,7 +2,7 @@ "name": "svelte", "description": "Cybernetically enhanced web apps", "license": "MIT", - "version": "5.41.3", + "version": "5.41.4", "type": "module", "types": "./types/index.d.ts", "engines": { diff --git a/packages/svelte/src/compiler/phases/2-analyze/index.js b/packages/svelte/src/compiler/phases/2-analyze/index.js index 52be997374..b4c704c34d 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/index.js +++ b/packages/svelte/src/compiler/phases/2-analyze/index.js @@ -6,7 +6,7 @@ import { walk } from 'zimmerframe'; import { parse } from '../1-parse/acorn.js'; import * as e from '../../errors.js'; import * as w from '../../warnings.js'; -import { extract_identifiers } from '../../utils/ast.js'; +import { extract_identifiers, has_await_expression } from '../../utils/ast.js'; import * as b from '#compiler/builders'; import { Scope, ScopeRoot, create_scopes, get_rune, set_scope } from '../scope.js'; import check_graph_for_cycles from './utils/check_graph_for_cycles.js'; diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/utils.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/utils.js index f5132c1cf8..92653ed73c 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/utils.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/utils.js @@ -12,7 +12,7 @@ import { import * as b from '#compiler/builders'; import { sanitize_template_string } from '../../../../../utils/sanitize_template_string.js'; import { regex_whitespaces_strict } from '../../../../patterns.js'; -import { has_await } from '../../../../../utils/ast.js'; +import { has_await_expression } from '../../../../../utils/ast.js'; /** Opens an if/each block, so that we can remove nodes in the case of a mismatch */ export const block_open = b.literal(BLOCK_OPEN); @@ -315,7 +315,7 @@ export class PromiseOptimiser { const promises = b.array( this.expressions.map((expression) => { - return expression.type === 'AwaitExpression' && !has_await(expression.argument) + return expression.type === 'AwaitExpression' && !has_await_expression(expression.argument) ? expression.argument : b.call(b.thunk(expression, true)); }) diff --git a/packages/svelte/src/compiler/utils/ast.js b/packages/svelte/src/compiler/utils/ast.js index 541921befb..bd92dda5d9 100644 --- a/packages/svelte/src/compiler/utils/ast.js +++ b/packages/svelte/src/compiler/utils/ast.js @@ -611,16 +611,20 @@ export function build_assignment_value(operator, left, right) { } /** - * @param {ESTree.Expression} expression + * @param {ESTree.Node} node */ -export function has_await(expression) { +export function has_await_expression(node) { let has_await = false; - walk(expression, null, { + walk(node, null, { AwaitExpression(_node, context) { has_await = true; context.stop(); - } + }, + // don't traverse into these + FunctionDeclaration() {}, + FunctionExpression() {}, + ArrowFunctionExpression() {} }); return has_await; diff --git a/packages/svelte/src/compiler/utils/builders.js b/packages/svelte/src/compiler/utils/builders.js index f53ecedb76..b42319797c 100644 --- a/packages/svelte/src/compiler/utils/builders.js +++ b/packages/svelte/src/compiler/utils/builders.js @@ -2,7 +2,7 @@ import { walk } from 'zimmerframe'; import { regex_is_valid_identifier } from '../phases/patterns.js'; import { sanitize_template_string } from './sanitize_template_string.js'; -import { has_await } from './ast.js'; +import { has_await_expression } from './ast.js'; /** * @param {Array} elements @@ -451,7 +451,7 @@ export function thunk(expression, async = false) { export function unthunk(expression) { // optimize `async () => await x()`, but not `async () => await x(await y)` if (expression.async && expression.body.type === 'AwaitExpression') { - if (!has_await(expression.body.argument)) { + if (!has_await_expression(expression.body.argument)) { return unthunk(arrow(expression.params, expression.body.argument)); } } diff --git a/packages/svelte/src/index-client.js b/packages/svelte/src/index-client.js index 337cbb500b..4fcfff980d 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 223ce6a4cd..61b0d98c06 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 38e6086689..a1782f5b61 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 6818fd9d30..24dc9e4fb8 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 db7ab0d976..09150d6ee4 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/boundary.js b/packages/svelte/src/internal/client/dom/blocks/boundary.js index 72e64b1a3a..febbc00898 100644 --- a/packages/svelte/src/internal/client/dom/blocks/boundary.js +++ b/packages/svelte/src/internal/client/dom/blocks/boundary.js @@ -38,6 +38,7 @@ import { Batch, effect_pending_updates } from '../../reactivity/batch.js'; import { internal_set, source } from '../../reactivity/sources.js'; import { tag } from '../../dev/tracing.js'; import { createSubscriber } from '../../../../reactivity/create-subscriber.js'; +import { create_text } from '../operations.js'; /** * @typedef {{ @@ -92,6 +93,9 @@ export class Boundary { /** @type {DocumentFragment | null} */ #offscreen_fragment = null; + /** @type {TemplateNode | null} */ + #pending_anchor = null; + #local_pending_count = 0; #pending_count = 0; @@ -155,8 +159,10 @@ export class Boundary { this.#hydrate_resolved_content(); } } else { + var anchor = this.#get_anchor(); + try { - this.#main_effect = branch(() => children(this.#anchor)); + this.#main_effect = branch(() => children(anchor)); } catch (error) { this.error(error); } @@ -167,6 +173,10 @@ export class Boundary { this.#pending = false; } } + + return () => { + this.#pending_anchor?.remove(); + }; }, flags); if (hydrating) { @@ -194,9 +204,11 @@ export class Boundary { this.#pending_effect = branch(() => pending(this.#anchor)); Batch.enqueue(() => { + var anchor = this.#get_anchor(); + this.#main_effect = this.#run(() => { Batch.ensure(); - return branch(() => this.#children(this.#anchor)); + return branch(() => this.#children(anchor)); }); if (this.#pending_count > 0) { @@ -211,6 +223,19 @@ export class Boundary { }); } + #get_anchor() { + var anchor = this.#anchor; + + if (this.#pending) { + this.#pending_anchor = create_text(); + this.#anchor.before(this.#pending_anchor); + + anchor = this.#pending_anchor; + } + + return anchor; + } + /** * Returns `true` if the effect exists inside a boundary whose pending snippet is shown * @returns {boolean} @@ -252,6 +277,7 @@ export class Boundary { if (this.#main_effect !== null) { this.#offscreen_fragment = document.createDocumentFragment(); + this.#offscreen_fragment.append(/** @type {TemplateNode} */ (this.#pending_anchor)); move_effect(this.#main_effect, this.#offscreen_fragment); } diff --git a/packages/svelte/src/internal/client/dom/blocks/branches.js b/packages/svelte/src/internal/client/dom/blocks/branches.js index 827f9f44fa..f1b9baf6f6 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 a6369a7211..a0fae37133 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 937971da5e..2a433ed8f9 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 dae3791eb0..9baacacd0d 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 2cef562ac9..fdeb111a4d 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 5a3dee4b7f..b6a50acc4d 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 9b54598f9e..4235e9cb24 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 2fe8c4f75d..9534e718a5 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/src/version.js b/packages/svelte/src/version.js index 81ead49fca..f5f47c6056 100644 --- a/packages/svelte/src/version.js +++ b/packages/svelte/src/version.js @@ -4,5 +4,5 @@ * The current version, as set in package.json. * @type {string} */ -export const VERSION = '5.41.3'; +export const VERSION = '5.41.4'; export const PUBLIC_VERSION = '5'; 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 0000000000..35b47525a2 --- /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 0000000000..9b1f433f2d --- /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/tests/runtime-runes/samples/async-snippet-coordinated-mount/Child.svelte b/packages/svelte/tests/runtime-runes/samples/async-snippet-coordinated-mount/Child.svelte new file mode 100644 index 0000000000..7085219a5a --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-snippet-coordinated-mount/Child.svelte @@ -0,0 +1,7 @@ + + +

message: {message}

+{@render children()} diff --git a/packages/svelte/tests/runtime-runes/samples/async-snippet-coordinated-mount/_config.js b/packages/svelte/tests/runtime-runes/samples/async-snippet-coordinated-mount/_config.js new file mode 100644 index 0000000000..b6ca2ae3d2 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-snippet-coordinated-mount/_config.js @@ -0,0 +1,25 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + const [shift] = target.querySelectorAll('button'); + + shift.click(); + await tick(); + + assert.htmlEqual(target.innerHTML, `

loading...

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

message: hello from child

+

hello from parent

+ ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-snippet-coordinated-mount/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-snippet-coordinated-mount/main.svelte new file mode 100644 index 0000000000..3ad2c9572a --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-snippet-coordinated-mount/main.svelte @@ -0,0 +1,21 @@ + + + + + + +

{await push('hello from parent')}

+
+ + {#snippet pending()} +

loading...

+ {/snippet} +
diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index 942478e19a..a98397735b 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. * diff --git a/playgrounds/sandbox/run.js b/playgrounds/sandbox/run.js index 639b755020..7ff9f7c4cd 100644 --- a/playgrounds/sandbox/run.js +++ b/playgrounds/sandbox/run.js @@ -5,6 +5,13 @@ import { parseArgs } from 'node:util'; import { globSync } from 'tinyglobby'; import { compile, compileModule, parse, migrate } from 'svelte/compiler'; +// toggle these to change what gets written to sandbox/output +const AST = false; +const MIGRATE = false; +const FROM_HTML = true; +const FROM_TREE = false; +const DEV = false; + const argv = parseArgs({ options: { runes: { type: 'boolean' } }, args: process.argv.slice(2) }); const cwd = fileURLToPath(new URL('.', import.meta.url)).slice(0, -1); @@ -51,48 +58,52 @@ for (const generate of /** @type {const} */ (['client', 'server'])) { mkdirp(path.dirname(output_js)); if (generate === 'client') { - const ast = parse(source, { - modern: true - }); + if (AST) { + const ast = parse(source, { + modern: true + }); + + write( + `${cwd}/output/ast/${file}.json`, + JSON.stringify( + ast, + (key, value) => (typeof value === 'bigint' ? ['BigInt', value.toString()] : value), + '\t' + ) + ); + } - write( - `${cwd}/output/ast/${file}.json`, - JSON.stringify( - ast, - (key, value) => (typeof value === 'bigint' ? ['BigInt', value.toString()] : value), - '\t' - ) - ); - - try { - const migrated = migrate(source); - write(`${cwd}/output/migrated/${file}`, migrated.code); - } catch (e) { - console.warn(`Error migrating ${file}`, e); + if (MIGRATE) { + try { + const migrated = migrate(source); + write(`${cwd}/output/migrated/${file}`, migrated.code); + } catch (e) { + console.warn(`Error migrating ${file}`, e); + } } } - const compiled = compile(source, { - dev: false, - filename: input, - generate, - runes: argv.values.runes, - experimental: { - async: true - } - }); + let from_html; + let from_tree; - for (const warning of compiled.warnings) { - console.warn(warning.code); - console.warn(warning.frame); - } + if (generate === 'server' || FROM_HTML) { + from_html = compile(source, { + dev: DEV, + filename: input, + generate, + runes: argv.values.runes, + experimental: { + async: true + } + }); - write(output_js, compiled.js.code + '\n//# sourceMappingURL=' + path.basename(output_map)); - write(output_map, compiled.js.map.toString()); + write(output_js, from_html.js.code + '\n//# sourceMappingURL=' + path.basename(output_map)); + write(output_map, from_html.js.map.toString()); + } // generate with fragments: 'tree' - if (generate === 'client') { - const compiled = compile(source, { + if (generate === 'client' && FROM_TREE) { + from_tree = compile(source, { dev: false, filename: input, generate, @@ -106,12 +117,21 @@ for (const generate of /** @type {const} */ (['client', 'server'])) { const output_js = `${cwd}/output/${generate}/${file}.tree.js`; const output_map = `${cwd}/output/${generate}/${file}.tree.js.map`; - write(output_js, compiled.js.code + '\n//# sourceMappingURL=' + path.basename(output_map)); - write(output_map, compiled.js.map.toString()); + write(output_js, from_tree.js.code + '\n//# sourceMappingURL=' + path.basename(output_map)); + write(output_map, from_tree.js.map.toString()); } - if (compiled.css) { - write(output_css, compiled.css.code); + const compiled = from_html ?? from_tree; + + if (compiled) { + for (const warning of compiled.warnings) { + console.warn(warning.code); + console.warn(warning.frame); + } + + if (compiled.css) { + write(output_css, compiled.css.code); + } } }