Skip to content

Commit c08ecba

Browse files
feat: forking (#17004)
* chore: run boundary async effects in the context of the current batch * WIP * reinstate kludge * fix test * WIP * WIP * WIP * remove kludge * restore batch_values after commit * make private * tidy up * fix tests * update test * reset #dirty_effects and #maybe_dirty_effects * add test * WIP * add test, fix block resolution * bring async-effect-after-await test from defer-effects-in-pending-boundary branch * avoid reawakening committed batches * changeset * cheat * better API * regenerate * slightly better approach * lint * revert this whatever it is * add test * Update feature description for fork API * error if missing experimental flag * rename inspect effects to eager effects, run them in prod * regenerate * Apply suggestions from code review Co-authored-by: Simon H <[email protected]> * tidy up * add some minimal prose. probably don't need to go super deep here as it's not really meant for non-framework authors * bit more detail * add a fork_timing error, regenerate * unused * add note * add fork_discarded error * require users to discard forks * add docs * regenerate * tweak docs * fix leak * fix * preload on focusin as well * missed a spot * reduce nesting --------- Co-authored-by: Simon H <[email protected]>
1 parent 7434f21 commit c08ecba

File tree

20 files changed

+531
-64
lines changed

20 files changed

+531
-64
lines changed

.changeset/small-geckos-camp.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"svelte": patch
3+
---
4+
5+
feat: experimental `fork` API

documentation/docs/03-template-syntax/19-await-expressions.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,54 @@ If a `<svelte:boundary>` with a `pending` snippet is encountered during SSR, tha
135135

136136
> [!NOTE] In the future, we plan to add a streaming implementation that renders the content in the background.
137137
138+
## Forking
139+
140+
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.
141+
142+
```svelte
143+
<script>
144+
import { fork } from 'svelte';
145+
import Menu from './Menu.svelte';
146+
147+
let open = $state(false);
148+
149+
/** @type {import('svelte').Fork | null} */
150+
let pending = null;
151+
152+
function preload() {
153+
pending ??= fork(() => {
154+
open = true;
155+
});
156+
}
157+
158+
function discard() {
159+
pending?.discard();
160+
pending = null;
161+
}
162+
</script>
163+
164+
<button
165+
onfocusin={preload}
166+
onfocusout={discard}
167+
onpointerenter={preload}
168+
onpointerleave={discard}
169+
onclick={() => {
170+
pending?.commit();
171+
pending = null;
172+
173+
// in case `pending` didn't exist
174+
// (if it did, this is a no-op)
175+
open = true;
176+
}}
177+
>open menu</button>
178+
179+
{#if open}
180+
<!-- any async work inside this component will start
181+
as soon as the fork is created -->
182+
<Menu onclose={() => open = false} />
183+
{/if}
184+
```
185+
138186
## Caveats
139187

140188
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.

documentation/docs/98-reference/.generated/client-errors.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,12 @@ $effect(() => {
130130

131131
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.
132132

133+
### experimental_async_fork
134+
135+
```
136+
Cannot use `fork(...)` unless the `experimental.async` compiler option is `true`
137+
```
138+
133139
### flush_sync_in_effect
134140

135141
```
@@ -140,6 +146,18 @@ The `flushSync()` function can be used to flush any pending effects synchronousl
140146

141147
This restriction only applies when using the `experimental.async` option, which will be active by default in Svelte 6.
142148

149+
### fork_discarded
150+
151+
```
152+
Cannot commit a fork that was already committed or discarded
153+
```
154+
155+
### fork_timing
156+
157+
```
158+
Cannot create a fork inside an effect or when state changes are pending
159+
```
160+
143161
### get_abort_signal_outside_reaction
144162

145163
```

packages/svelte/messages/client-errors/errors.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,10 @@ $effect(() => {
100100

101101
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.
102102

103+
## experimental_async_fork
104+
105+
> Cannot use `fork(...)` unless the `experimental.async` compiler option is `true`
106+
103107
## flush_sync_in_effect
104108

105109
> Cannot use `flushSync` inside an effect
@@ -108,6 +112,14 @@ The `flushSync()` function can be used to flush any pending effects synchronousl
108112

109113
This restriction only applies when using the `experimental.async` option, which will be active by default in Svelte 6.
110114

115+
## fork_discarded
116+
117+
> Cannot commit a fork that was already committed or discarded
118+
119+
## fork_timing
120+
121+
> Cannot create a fork inside an effect or when state changes are pending
122+
111123
## get_abort_signal_outside_reaction
112124

113125
> `getAbortSignal()` can only be called inside an effect or derived

packages/svelte/src/index-client.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,7 @@ function init_update_callbacks(context) {
241241
return (l.u ??= { a: [], b: [], m: [] });
242242
}
243243

244-
export { flushSync } from './internal/client/reactivity/batch.js';
244+
export { flushSync, fork } from './internal/client/reactivity/batch.js';
245245
export {
246246
createContext,
247247
getContext,

packages/svelte/src/index-server.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ export function unmount() {
3333
e.lifecycle_function_unavailable('unmount');
3434
}
3535

36+
export function fork() {
37+
e.lifecycle_function_unavailable('fork');
38+
}
39+
3640
export async function tick() {}
3741

3842
export async function settled() {}

packages/svelte/src/index.d.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,4 +352,20 @@ export type MountOptions<Props extends Record<string, any> = Record<string, any>
352352
props: Props;
353353
});
354354

355+
/**
356+
* Represents work that is happening off-screen, such as data being preloaded
357+
* in anticipation of the user navigating
358+
* @since 5.42
359+
*/
360+
export interface Fork {
361+
/**
362+
* Commit the fork. The promise will resolve once the state change has been applied
363+
*/
364+
commit(): Promise<void>;
365+
/**
366+
* Discard the fork
367+
*/
368+
discard(): void;
369+
}
370+
355371
export * from './index-client.js';

packages/svelte/src/internal/client/constants.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export const EFFECT_RAN = 1 << 15;
1919
* 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
2020
*/
2121
export const EFFECT_TRANSPARENT = 1 << 16;
22-
export const INSPECT_EFFECT = 1 << 17;
22+
export const EAGER_EFFECT = 1 << 17;
2323
export const HEAD_EFFECT = 1 << 18;
2424
export const EFFECT_PRESERVED = 1 << 19;
2525
export const USER_EFFECT = 1 << 20;

packages/svelte/src/internal/client/dev/inspect.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { UNINITIALIZED } from '../../../constants.js';
22
import { snapshot } from '../../shared/clone.js';
3-
import { inspect_effect, render_effect, validate_effect } from '../reactivity/effects.js';
3+
import { eager_effect, render_effect, validate_effect } from '../reactivity/effects.js';
44
import { untrack } from '../runtime.js';
55
import { get_stack } from './tracing.js';
66

@@ -19,7 +19,7 @@ export function inspect(get_value, inspector, show_stack = false) {
1919
// stack traces. As a consequence, reading the value might result
2020
// in an error (an `$inspect(object.property)` will run before the
2121
// `{#if object}...{/if}` that contains it)
22-
inspect_effect(() => {
22+
eager_effect(() => {
2323
try {
2424
var value = get_value();
2525
} catch (e) {

packages/svelte/src/internal/client/dom/blocks/branches.js

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
/** @import { Effect, TemplateNode } from '#client' */
2-
import { is_runes } from '../../context.js';
32
import { Batch, current_batch } from '../../reactivity/batch.js';
43
import {
54
branch,
@@ -8,7 +7,6 @@ import {
87
pause_effect,
98
resume_effect
109
} from '../../reactivity/effects.js';
11-
import { set_should_intro, should_intro } from '../../render.js';
1210
import { hydrate_node, hydrating } from '../hydration.js';
1311
import { create_text, should_defer_append } from '../operations.js';
1412

@@ -126,6 +124,22 @@ export class BranchManager {
126124
}
127125
};
128126

127+
/**
128+
* @param {Batch} batch
129+
*/
130+
#discard = (batch) => {
131+
this.#batches.delete(batch);
132+
133+
const keys = Array.from(this.#batches.values());
134+
135+
for (const [k, branch] of this.#offscreen) {
136+
if (!keys.includes(k)) {
137+
destroy_effect(branch.effect);
138+
this.#offscreen.delete(k);
139+
}
140+
}
141+
};
142+
129143
/**
130144
*
131145
* @param {any} key
@@ -173,7 +187,8 @@ export class BranchManager {
173187
}
174188
}
175189

176-
batch.add_callback(this.#commit);
190+
batch.oncommit(this.#commit);
191+
batch.ondiscard(this.#discard);
177192
} else {
178193
if (hydrating) {
179194
this.anchor = hydrate_node;

0 commit comments

Comments
 (0)