Skip to content

Commit 53f2693

Browse files
Rich-Harrisdummdidummgithub-actions[bot]hyunbinseobenmccann
authored
feat: $state.eager(value) (#16849)
* WIP implement `$effect.pending(...)` * feat: `$state.eager(value)` (#16926) * runtime-first approach * revert these * type safety, lint * fix: better input cursor restoration for `bind:value` (#16925) If cursor was at end and new input is longer, move cursor to new end No test because not possible to reproduce using our test setup. Follow-up to #14649, helps with #16577 * Version Packages (#16920) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * docs: await no longer need pending (#16900) * docs: link to custom renderer issue in Svelte Native discussion (#16896) * fix code block (#16937) Updated code block syntax from Svelte to JavaScript for clarity. * fix: unset context on stale promises (#16935) * fix: unset context on stale promises When a stale promise is rejected in `async_derived`, and the promise eventually resolves, `d.resolve` will be noop and `d.promise.then(handler, ...)` will never run. That in turns means any restored context (via `(await save(..))()`) will never be unset. We have to handle this case and unset the context to prevent errors such as false-positive state mutation errors * fix: unset context on stale promises (slightly different approach) (#16936) * slightly different approach to #16935 * move unset_context call * get rid of logs --------- Co-authored-by: Rich Harris <[email protected]> * fix: svg `radialGradient` `fr` attribute missing in types (#16943) * fix(svg radialGradient): fr attribute missing in types * chore: add changeset * Version Packages (#16940) * Version Packages * Update packages/svelte/CHANGELOG.md --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Rich Harris <[email protected]> * chore: simplify `batch.apply()` (#16945) * chore: simplify `batch.apply()` * belt and braces * note to self * unused * fix: don't rerun async effects unnecessarily (#16944) Since #16866, when an async effect runs multiple times, we rebase older batches and rerun those effects. This can have unintended consequences: In a case where an async effect only depends on a single source, and that single source was updated in a later batch, we know that we don't need to / should not rerun the older batch. This PR makes it so: We collect all the sources of older batches that are not part of the current batch that just committed, and then only mark those async effects as dirty which depend on one of those other sources. Fixes the bug I noticed while working on #16935 * fix: ensure map iteration order is correct (#16947) quick follow-up to #16944 Resetting a map entry does not change its position in the map when iterating. We need to make sure that reset makes that batch jump "to the front" for the "reject all stale batches" logic below. Edge case for which I can't come up with a test case but it _is_ a possibility. * feat: add `createContext` utility for type-safe context (#16948) * feat: add `createContext` utility for type-safe context * regenerate * Version Packages (#16946) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * chore: Remove annoying sync-async warning (#16949) * fix * use `$state.eager(value)` instead of `$effect.pending(value)` --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Hyunbin Seo <[email protected]> Co-authored-by: Ben McCann <[email protected]> Co-authored-by: Rich Harris <[email protected]> Co-authored-by: Hannes Rüger <[email protected]> Co-authored-by: Elliott Johnson <[email protected]> * decouple from boundaries * use queue_micro_task * add test * fix * changeset * revert * tidy up * update docs * Update packages/svelte/src/internal/client/reactivity/batch.js Co-authored-by: Simon H <[email protected]> * minor tweak --------- Co-authored-by: Simon H <[email protected]> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Hyunbin Seo <[email protected]> Co-authored-by: Ben McCann <[email protected]> Co-authored-by: Hannes Rüger <[email protected]> Co-authored-by: Elliott Johnson <[email protected]>
1 parent ee093e4 commit 53f2693

File tree

12 files changed

+181
-3
lines changed

12 files changed

+181
-3
lines changed

.changeset/shy-boats-protect.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'svelte': minor
3+
---
4+
5+
feat: add `$state.eager(value)` rune

documentation/docs/02-runes/02-$state.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,21 @@ To take a static snapshot of a deeply reactive `$state` proxy, use `$state.snaps
166166

167167
This is handy when you want to pass some state to an external library or API that doesn't expect a proxy, such as `structuredClone`.
168168

169+
## `$state.eager`
170+
171+
When state changes, it may not be reflected in the UI immediately if it is used by an `await` expression, because [updates are synchronized](await-expressions#Synchronized-updates).
172+
173+
In some cases, you may want to update the UI as soon as the state changes. For example, you might want to update a navigation bar when the user clicks on a link, so that they get visual feedback while waiting for the new page to load. To do this, use `$state.eager(value)`:
174+
175+
```svelte
176+
<nav>
177+
<a href="/" aria-current={$state.eager(pathname) === '/' ? 'page' : null}>home</a>
178+
<a href="/about" aria-current={$state.eager(pathname) === '/about' ? 'page' : null}>about</a>
179+
</nav>
180+
```
181+
182+
Use this feature sparingly, and only to provide feedback in response to user action — in general, allowing Svelte to coordinate updates will provide a better user experience.
183+
169184
## Passing state into functions
170185

171186
JavaScript is a _pass-by-value_ language — when you call a function, the arguments are the _values_ rather than the _variables_. In other words:

packages/svelte/src/ambient.d.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,18 @@ declare namespace $state {
9595
: never
9696
: never;
9797

98+
/**
99+
* Returns the latest `value`, even if the rest of the UI is suspending
100+
* while async work (such as data loading) completes.
101+
*
102+
* ```svelte
103+
* <nav>
104+
* <a href="/" aria-current={$state.eager(pathname) === '/' ? 'page' : null}>home</a>
105+
* <a href="/about" aria-current={$state.eager(pathname) === '/about' ? 'page' : null}>about</a>
106+
* </nav>
107+
* ```
108+
*/
109+
export function eager<T>(value: T): T;
98110
/**
99111
* Declares state that is _not_ made deeply reactive — instead of mutating it,
100112
* you must reassign it.

packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,13 @@ export function CallExpression(node, context) {
226226
break;
227227
}
228228

229+
case '$state.eager':
230+
if (node.arguments.length !== 1) {
231+
e.rune_invalid_arguments_length(node, rune, 'exactly one argument');
232+
}
233+
234+
break;
235+
229236
case '$state.snapshot':
230237
if (node.arguments.length !== 1) {
231238
e.rune_invalid_arguments_length(node, rune, 'exactly one argument');

packages/svelte/src/compiler/phases/3-transform/client/visitors/CallExpression.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,12 @@ export function CallExpression(node, context) {
4949
return b.call('$.derived', rune === '$derived' ? b.thunk(fn) : fn);
5050
}
5151

52+
case '$state.eager':
53+
return b.call(
54+
'$.eager',
55+
b.thunk(/** @type {Expression} */ (context.visit(node.arguments[0])))
56+
);
57+
5258
case '$state.snapshot':
5359
return b.call(
5460
'$.snapshot',

packages/svelte/src/compiler/phases/3-transform/server/visitors/CallExpression.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ export function CallExpression(node, context) {
3838
return b.call('$.derived', rune === '$derived' ? b.thunk(fn) : fn);
3939
}
4040

41+
if (rune === '$state.eager') {
42+
return node.arguments[0];
43+
}
44+
4145
if (rune === '$state.snapshot') {
4246
return b.call(
4347
'$.snapshot',

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ export {
103103
save,
104104
track_reactivity_loss
105105
} from './reactivity/async.js';
106-
export { flushSync as flush } from './reactivity/batch.js';
106+
export { eager, flushSync as flush } from './reactivity/batch.js';
107107
export {
108108
async_derived,
109109
user_derived as derived,

packages/svelte/src/internal/client/reactivity/batch.js

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { async_mode_flag } from '../../flags/index.js';
1717
import { deferred, define_property } from '../../shared/utils.js';
1818
import {
1919
active_effect,
20+
get,
2021
is_dirty,
2122
is_updating_effect,
2223
set_is_updating_effect,
@@ -27,8 +28,8 @@ import * as e from '../errors.js';
2728
import { flush_tasks, queue_micro_task } from '../dom/task.js';
2829
import { DEV } from 'esm-env';
2930
import { invoke_error_boundary } from '../error-handling.js';
30-
import { old_values } from './sources.js';
31-
import { unlink_effect } from './effects.js';
31+
import { old_values, source, update } from './sources.js';
32+
import { inspect_effect, unlink_effect } from './effects.js';
3233

3334
/** @type {Set<Batch>} */
3435
const batches = new Set();
@@ -684,6 +685,65 @@ export function schedule_effect(signal) {
684685
queued_root_effects.push(effect);
685686
}
686687

688+
/** @type {Source<number>[]} */
689+
let eager_versions = [];
690+
691+
function eager_flush() {
692+
try {
693+
flushSync(() => {
694+
for (const version of eager_versions) {
695+
update(version);
696+
}
697+
});
698+
} finally {
699+
eager_versions = [];
700+
}
701+
}
702+
703+
/**
704+
* Implementation of `$state.eager(fn())`
705+
* @template T
706+
* @param {() => T} fn
707+
* @returns {T}
708+
*/
709+
export function eager(fn) {
710+
var version = source(0);
711+
var initial = true;
712+
var value = /** @type {T} */ (undefined);
713+
714+
get(version);
715+
716+
inspect_effect(() => {
717+
if (initial) {
718+
// the first time this runs, we create an inspect effect
719+
// that will run eagerly whenever the expression changes
720+
var previous_batch_values = batch_values;
721+
722+
try {
723+
batch_values = null;
724+
value = fn();
725+
} finally {
726+
batch_values = previous_batch_values;
727+
}
728+
729+
return;
730+
}
731+
732+
// the second time this effect runs, it's to schedule a
733+
// `version` update. since this will recreate the effect,
734+
// we don't need to evaluate the expression here
735+
if (eager_versions.length === 0) {
736+
queue_micro_task(eager_flush);
737+
}
738+
739+
eager_versions.push(version);
740+
});
741+
742+
initial = false;
743+
744+
return value;
745+
}
746+
687747
/**
688748
* Forcibly remove all current batches, to prevent cross-talk between tests
689749
*/

packages/svelte/src/utils.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,7 @@ const STATE_CREATION_RUNES = /** @type {const} */ ([
436436

437437
const RUNES = /** @type {const} */ ([
438438
...STATE_CREATION_RUNES,
439+
'$state.eager',
439440
'$state.snapshot',
440441
'$props',
441442
'$props.id',
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { tick } from 'svelte';
2+
import { test } from '../../test';
3+
4+
export default test({
5+
async test({ assert, target }) {
6+
const [count, shift] = target.querySelectorAll('button');
7+
8+
shift.click();
9+
await tick();
10+
assert.htmlEqual(target.innerHTML, `<button>0</button><button>shift</button><p>0</p>`);
11+
12+
count.click();
13+
await tick();
14+
assert.htmlEqual(target.innerHTML, `<button>1</button><button>shift</button><p>0</p>`);
15+
16+
count.click();
17+
await tick();
18+
assert.htmlEqual(target.innerHTML, `<button>2</button><button>shift</button><p>0</p>`);
19+
20+
count.click();
21+
await tick();
22+
assert.htmlEqual(target.innerHTML, `<button>3</button><button>shift</button><p>0</p>`);
23+
24+
shift.click();
25+
await tick();
26+
assert.htmlEqual(target.innerHTML, `<button>3</button><button>shift</button><p>1</p>`);
27+
28+
shift.click();
29+
await tick();
30+
assert.htmlEqual(target.innerHTML, `<button>3</button><button>shift</button><p>2</p>`);
31+
32+
shift.click();
33+
await tick();
34+
assert.htmlEqual(target.innerHTML, `<button>3</button><button>shift</button><p>3</p>`);
35+
}
36+
});

0 commit comments

Comments
 (0)