Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
7c1408d
chore: run boundary async effects in the context of the current batch
Rich-Harris Oct 16, 2025
c3db9ac
WIP
Rich-Harris Oct 16, 2025
1e2958e
reinstate kludge
Rich-Harris Oct 16, 2025
ae0038c
fix test
Rich-Harris Oct 16, 2025
18ecf01
WIP
Rich-Harris Oct 16, 2025
739f5fc
WIP
Rich-Harris Oct 16, 2025
d8df737
WIP
Rich-Harris Oct 16, 2025
c054cd4
remove kludge
Rich-Harris Oct 17, 2025
bba5314
restore batch_values after commit
Rich-Harris Oct 17, 2025
09f4d75
merge main
Rich-Harris Oct 17, 2025
46d61ee
make private
Rich-Harris Oct 17, 2025
90e3148
tidy up
Rich-Harris Oct 17, 2025
fc9699a
Merge branch 'main' into run-batch-until-complete
Rich-Harris Oct 20, 2025
e31956d
fix tests
Rich-Harris Oct 20, 2025
fdc5114
update test
Rich-Harris Oct 21, 2025
db41cc4
reset #dirty_effects and #maybe_dirty_effects
Rich-Harris Oct 21, 2025
cfa87a9
add test
Rich-Harris Oct 21, 2025
ee1b114
WIP
Rich-Harris Oct 21, 2025
925dbeb
add test, fix block resolution
Rich-Harris Oct 21, 2025
f225c63
Merge branch 'main' into run-batch-until-complete
Rich-Harris Oct 21, 2025
bf90654
bring async-effect-after-await test from defer-effects-in-pending-bou…
Rich-Harris Oct 21, 2025
14ba41e
avoid reawakening committed batches
Rich-Harris Oct 22, 2025
edea101
Merge branch 'main' into run-batch-until-complete
Rich-Harris Oct 22, 2025
c2c3fec
changeset
Rich-Harris Oct 22, 2025
d377df7
cheat
Rich-Harris Oct 22, 2025
54cd91f
merge/fix
Rich-Harris Oct 22, 2025
1aa3db4
better API
Rich-Harris Oct 22, 2025
c5f17c5
regenerate
Rich-Harris Oct 22, 2025
63fa530
Merge branch 'main' into forking-hell
dummdidumm Oct 22, 2025
4469cc5
slightly better approach
Rich-Harris Oct 22, 2025
7f5f35a
lint
Rich-Harris Oct 22, 2025
1a1bcde
revert this whatever it is
Rich-Harris Oct 22, 2025
53f4fea
Merge branch 'main' into forking-hell
Rich-Harris Oct 23, 2025
9c605b8
add test
Rich-Harris Oct 23, 2025
a2b892b
Update feature description for fork API
Rich-Harris Oct 24, 2025
53cc91d
error if missing experimental flag
Rich-Harris Oct 24, 2025
8285fc6
rename inspect effects to eager effects, run them in prod
Rich-Harris Oct 24, 2025
de02329
regenerate
Rich-Harris Oct 24, 2025
4bb399b
Apply suggestions from code review
Rich-Harris Oct 24, 2025
69f74cf
tidy up
Rich-Harris Oct 24, 2025
1d026b9
add some minimal prose. probably don't need to go super deep here as …
Rich-Harris Oct 24, 2025
b213aea
bit more detail
Rich-Harris Oct 24, 2025
7a66321
add a fork_timing error, regenerate
Rich-Harris Oct 24, 2025
60ea2b2
unused
Rich-Harris Oct 24, 2025
ae95b5f
add note
Rich-Harris Oct 24, 2025
4f35c01
add fork_discarded error
Rich-Harris Oct 24, 2025
2664bb4
require users to discard forks
Rich-Harris Oct 24, 2025
cd4da0e
add docs
Rich-Harris Oct 24, 2025
bc56dbd
regenerate
Rich-Harris Oct 24, 2025
c8cc5e6
Merge branch 'main' into forking-hell
Rich-Harris Oct 24, 2025
d043b3c
tweak docs
Rich-Harris Oct 24, 2025
0d53d6d
fix leak
Rich-Harris Oct 24, 2025
1b5ff6b
fix
Rich-Harris Oct 24, 2025
f811633
preload on focusin as well
Rich-Harris Oct 24, 2025
1d58137
missed a spot
Rich-Harris Oct 24, 2025
3a27726
reduce nesting
Rich-Harris Oct 24, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/small-geckos-camp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"svelte": patch
---

feat: experimental `fork` API
48 changes: 48 additions & 0 deletions documentation/docs/03-template-syntax/19-await-expressions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
18 changes: 18 additions & 0 deletions documentation/docs/98-reference/.generated/client-errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

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

```
Expand Down
12 changes: 12 additions & 0 deletions packages/svelte/messages/client-errors/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/svelte/src/index-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions packages/svelte/src/index-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {}
Expand Down
16 changes: 16 additions & 0 deletions packages/svelte/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
2 changes: 1 addition & 1 deletion packages/svelte/src/internal/client/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions packages/svelte/src/internal/client/dev/inspect.js
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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) {
Expand Down
21 changes: 18 additions & 3 deletions packages/svelte/src/internal/client/dom/blocks/branches.js
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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';

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion packages/svelte/src/internal/client/dom/blocks/each.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
48 changes: 48 additions & 0 deletions packages/svelte/src/internal/client/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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}
Expand Down
8 changes: 4 additions & 4 deletions packages/svelte/src/internal/client/proxy.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
};
}
Expand Down
Loading
Loading