Merge branch 'main' into gh-17012

gh-17012
ComputerGuy 4 weeks ago committed by GitHub
commit 307e42fe9b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -1,5 +0,0 @@
---
'svelte': patch
---
fix: take into account static blocks when determining transition locality

@ -1,5 +0,0 @@
---
'svelte': patch
---
fix: flush pending changes after rendering `failed` snippet

@ -0,0 +1,5 @@
---
"svelte": patch
---
feat: experimental `fork` API

@ -135,6 +135,54 @@ If a `<svelte:boundary>` 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
<script>
import { fork } from 'svelte';
import Menu from './Menu.svelte';
let open = $state(false);
/** @type {import('svelte').Fork | null} */
let pending = null;
function preload() {
pending ??= fork(() => {
open = true;
});
}
function discard() {
pending?.discard();
pending = null;
}
</script>
<button
onfocusin={preload}
onfocusout={discard}
onpointerenter={preload}
onpointerleave={discard}
onclick={() => {
pending?.commit();
pending = null;
// in case `pending` didn't exist
// (if it did, this is a no-op)
open = true;
}}
>open menu</button>
{#if open}
<!-- any async work inside this component will start
as soon as the fork is created -->
<Menu onclose={() => 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.

@ -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
```

@ -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

@ -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

@ -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": {

@ -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';

@ -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));
})

@ -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;

@ -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<ESTree.Expression | ESTree.SpreadElement | null>} 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));
}
}

@ -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,

@ -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() {}

@ -352,4 +352,20 @@ export type MountOptions<Props extends Record<string, any> = Record<string, any>
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<void>;
/**
* Discard the fork
*/
discard(): void;
}
export * from './index-client.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;

@ -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) {

@ -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);
}

@ -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;

@ -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();
}

@ -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}

@ -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;
};
}

@ -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<Source, any>}
*/
#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<Effect>} 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<Effect>} */
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
*/

@ -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 {

@ -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);
}
/**

@ -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<any>} */
export let inspect_effects = new Set();
export let eager_effects = new Set();
/** @type {Map<Source, any>} */
export const old_values = new Map();
@ -47,14 +47,14 @@ export const old_values = new Map();
/**
* @param {Set<any>} 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;
}

@ -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';

@ -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,
`
<button>shift</button>
<button>increment</button>
<button>commit</button>
<p>count: 0</p>
<p>eager: 0</p>
<p>even</p>
`
);
increment.click();
await tick();
shift.click();
await tick();
// nothing updates until commit
assert.htmlEqual(
target.innerHTML,
`
<button>shift</button>
<button>increment</button>
<button>commit</button>
<p>count: 0</p>
<p>eager: 0</p>
<p>even</p>
`
);
commit.click();
await tick();
// nothing updates until commit
assert.htmlEqual(
target.innerHTML,
`
<button>shift</button>
<button>increment</button>
<button>commit</button>
<p>count: 1</p>
<p>eager: 1</p>
<p>odd</p>
`
);
increment.click();
await tick();
commit.click();
await tick();
// eager state updates on commit
assert.htmlEqual(
target.innerHTML,
`
<button>shift</button>
<button>increment</button>
<button>commit</button>
<p>count: 1</p>
<p>eager: 2</p>
<p>odd</p>
`
);
shift.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`
<button>shift</button>
<button>increment</button>
<button>commit</button>
<p>count: 2</p>
<p>eager: 2</p>
<p>even</p>
`
);
}
});

@ -0,0 +1,37 @@
<script>
import { fork } from 'svelte';
let count = $state(0);
const resolvers = [];
let f = null;
function push(value) {
const { promise, resolve } = Promise.withResolvers();
resolvers.push(() => resolve(value));
return promise;
}
</script>
<button onclick={() => resolvers.shift()?.()}>shift</button>
<button onclick={async () => {
f = await fork(() => {
count += 1;
});
}}>increment</button>
<button onclick={() => f?.commit()}>commit</button>
<p>count: {count}</p>
<p>eager: {$state.eager(count)}</p>
<svelte:boundary>
{#if await push(count) % 2 === 0}
<p>even</p>
{:else}
<p>odd</p>
{/if}
{#snippet pending()}
<p>loading...</p>
{/snippet}
</svelte:boundary>

@ -0,0 +1,7 @@
<script>
let { children, push } = $props();
let message = await push('hello from child');
</script>
<p>message: {message}</p>
{@render children()}

@ -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, `<button>shift</button><p>loading...</p>`);
shift.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`
<button>shift</button>
<p>message: hello from child</p>
<p>hello from parent</p>
`
);
}
});

@ -0,0 +1,21 @@
<script>
import Child from './Child.svelte';
const resolvers = [];
function push(value) {
const { promise, resolve } = Promise.withResolvers();
resolvers.push(() => resolve(value));
return promise;
}
</script>
<button onclick={() => resolvers.shift()?.()}>shift</button>
<svelte:boundary>
<Child {push}>
<p>{await push('hello from parent')}</p>
</Child>
{#snippet pending()}
<p>loading...</p>
{/snippet}
</svelte:boundary>

@ -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<void>;
/**
* 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<T = void>(fn?: (() => T) | undefined): T;
/**
* Create a snippet programmatically
* */
@ -448,6 +459,29 @@ declare module 'svelte' {
}): Snippet<Params>;
/** Anything except a function */
type NotFunction<T> = 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<T = void>(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.
*

@ -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);
}
}
}

Loading…
Cancel
Save