Skip to content

Commit 23454cf

Browse files
committed
chore: make $derived property accessing fine-grain reactive
add destructuring derived support add destructuring derived support add destructuring derived support more fixes better approach fix runtime bug cleanup Completely new take remove old code tweaks handle deep alternative approach alternative approach improves simplify revert other code revert other code revert other code simplify add bigint fix issue fix other issue better way add changeset add tests add more tests better gc better gc simplift cleardown
1 parent fb61f4e commit 23454cf

File tree

20 files changed

+428
-23
lines changed

20 files changed

+428
-23
lines changed

.changeset/polite-clocks-speak.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+
chore: make $derived property accessing fine-grain reactive

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -278,15 +278,15 @@ export const javascript_visitors_runes = {
278278

279279
if (rune === '$derived') {
280280
if (declarator.id.type === 'Identifier') {
281-
declarations.push(b.declarator(declarator.id, b.call('$.derived', b.thunk(value))));
281+
declarations.push(b.declarator(declarator.id, b.call('$.derived_proxy', b.thunk(value))));
282282
} else {
283283
const bindings = state.scope.get_bindings(declarator);
284284
const id = state.scope.generate('derived_value');
285285
declarations.push(
286286
b.declarator(
287287
b.id(id),
288288
b.call(
289-
'$.derived',
289+
'$.derived_proxy',
290290
b.thunk(
291291
b.block([
292292
b.let(declarator.id, value),

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

Lines changed: 175 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@ let current_queued_pre_and_render_effects = [];
5959
/** @type {import('./types.js').EffectSignal[]} */
6060
let current_queued_effects = [];
6161

62+
/** @type {{t: import('./types.js').ComputationSignal, p: Array<string | symbol>, r: import('./types.js').ComputationSignal } | null} */
63+
let current_path = null;
64+
6265
/** @type {Array<() => void>} */
6366
let current_queued_tasks = [];
6467
/** @type {Array<() => void>} */
@@ -79,6 +82,7 @@ let current_dependencies_index = 0;
7982
let current_untracked_writes = null;
8083
/** @type {null | import('./types.js').SignalDebug} */
8184
let last_inspected_signal = null;
85+
8286
/** If `true`, `get`ting the signal should not register it as a dependency */
8387
export let current_untracking = false;
8488
/** Exists to opt out of the mutation validation for stores which may be set for the first time during a derivation */
@@ -327,23 +331,26 @@ function is_signal_dirty(signal) {
327331
*/
328332
function execute_signal_fn(signal) {
329333
const init = signal.i;
334+
const flags = signal.f;
330335
const previous_dependencies = current_dependencies;
331336
const previous_dependencies_index = current_dependencies_index;
332337
const previous_untracked_writes = current_untracked_writes;
333338
const previous_consumer = current_consumer;
334339
const previous_block = current_block;
335340
const previous_component_context = current_component_context;
336341
const previous_skip_consumer = current_skip_consumer;
337-
const is_render_effect = (signal.f & RENDER_EFFECT) !== 0;
342+
const is_render_effect = (flags & RENDER_EFFECT) !== 0;
338343
const previous_untracking = current_untracking;
344+
const previous_path = current_path;
339345
current_dependencies = /** @type {null | import('./types.js').Signal[]} */ (null);
340346
current_dependencies_index = 0;
341347
current_untracked_writes = null;
342348
current_consumer = signal;
343349
current_block = signal.b;
344350
current_component_context = signal.x;
345-
current_skip_consumer = !is_flushing_effect && (signal.f & UNOWNED) !== 0;
351+
current_skip_consumer = !is_flushing_effect && (flags & UNOWNED) !== 0;
346352
current_untracking = false;
353+
current_path = null;
347354

348355
// Render effects are invoked when the UI is about to be updated - run beforeUpdate at that point
349356
if (is_render_effect && current_component_context?.u != null) {
@@ -364,6 +371,9 @@ function execute_signal_fn(signal) {
364371
} else {
365372
res = /** @type {() => V} */ (init)();
366373
}
374+
if (current_path !== null) {
375+
push_derived_path();
376+
}
367377
let dependencies = /** @type {import('./types.js').Signal<unknown>[]} **/ (signal.d);
368378
if (current_dependencies !== null) {
369379
let i;
@@ -412,6 +422,10 @@ function execute_signal_fn(signal) {
412422
if (consumers === null) {
413423
dependency.c = [signal];
414424
} else if (consumers[consumers.length - 1] !== signal) {
425+
// TODO: should this be:
426+
//
427+
// } else if (!consumers.includes(signal)) {
428+
//
415429
consumers.push(signal);
416430
}
417431
}
@@ -430,6 +444,7 @@ function execute_signal_fn(signal) {
430444
current_component_context = previous_component_context;
431445
current_skip_consumer = previous_skip_consumer;
432446
current_untracking = previous_untracking;
447+
current_path = previous_path;
433448
}
434449
}
435450

@@ -495,13 +510,7 @@ function destroy_references(signal) {
495510
let i;
496511
for (i = 0; i < references.length; i++) {
497512
const reference = references[i];
498-
if ((reference.f & IS_EFFECT) !== 0) {
499-
destroy_signal(reference);
500-
} else {
501-
destroy_references(reference);
502-
remove_consumers(reference, 0);
503-
reference.d = null;
504-
}
513+
destroy_signal(reference);
505514
}
506515
}
507516
}
@@ -931,6 +940,30 @@ export function unsubscribe_on_destroy(stores) {
931940
});
932941
}
933942

943+
function push_derived_path() {
944+
const path =
945+
/** @type {{t: import('./types.js').ComputationSignal, p: Array<string | symbol>, r: import('./types.js').ComputationSignal }} */ (
946+
current_path
947+
);
948+
if (is_last_current_dependency(path.r)) {
949+
if (current_dependencies === null) {
950+
current_dependencies_index--;
951+
} else {
952+
current_dependencies.pop();
953+
}
954+
}
955+
const derived_prop = derived(() => {
956+
let value = /** @type {any} */ (get(path.t));
957+
const property_path = path.p;
958+
for (let i = 0; i < property_path.length; i++) {
959+
value = value?.[property_path[i]];
960+
}
961+
return value;
962+
});
963+
current_path = null;
964+
get(derived_prop);
965+
}
966+
934967
/**
935968
* @template V
936969
* @param {import('./types.js').Signal<V>} signal
@@ -949,6 +982,10 @@ export function get(signal) {
949982
return signal.v;
950983
}
951984

985+
if (current_path !== null) {
986+
push_derived_path();
987+
}
988+
952989
if (is_signals_recorded) {
953990
captured_signals.add(signal);
954991
}
@@ -971,7 +1008,7 @@ export function get(signal) {
9711008
) {
9721009
if (current_dependencies === null) {
9731010
current_dependencies = [signal];
974-
} else if (signal !== current_dependencies[current_dependencies.length - 1]) {
1011+
} else {
9751012
current_dependencies.push(signal);
9761013
}
9771014
}
@@ -1268,16 +1305,7 @@ export function destroy_signal(signal) {
12681305
const flags = signal.f;
12691306
destroy_references(signal);
12701307
remove_consumers(signal, 0);
1271-
signal.i =
1272-
signal.r =
1273-
signal.y =
1274-
signal.x =
1275-
signal.b =
1276-
// @ts-expect-error - this is fine, since we're assigning to null to clear out a destroyed signal
1277-
signal.v =
1278-
signal.d =
1279-
signal.c =
1280-
null;
1308+
signal.i = signal.r = signal.y = signal.x = signal.b = signal.d = signal.c = null;
12811309
set_signal_status(signal, DESTROYED);
12821310
if (destroy !== null) {
12831311
if (is_array(destroy)) {
@@ -1312,6 +1340,126 @@ export function derived(init) {
13121340
return signal;
13131341
}
13141342

1343+
/**
1344+
* @param {any} value
1345+
*/
1346+
function should_proxy_derived_value(value) {
1347+
let prototype;
1348+
return (
1349+
(typeof value === 'object' &&
1350+
value !== null &&
1351+
(prototype = get_prototype_of(value)) === object_prototype) ||
1352+
prototype === array_prototype
1353+
);
1354+
}
1355+
1356+
/**
1357+
* @param {import("./types.js").SourceSignal<unknown>} signal
1358+
*/
1359+
function is_last_current_dependency(signal) {
1360+
if (current_dependencies !== null) {
1361+
return current_dependencies[current_dependencies.length - 1] === signal;
1362+
} else if (current_consumer !== null && current_dependencies_index > 0) {
1363+
return current_consumer.d?.[current_dependencies_index - 1] === signal;
1364+
}
1365+
return false;
1366+
}
1367+
1368+
/**
1369+
* @template V
1370+
* @param {() => any} init
1371+
* @returns {import('./types.js').ComputationSignal<V>}
1372+
*/
1373+
/*#__NO_SIDE_EFFECTS__*/
1374+
export function derived_proxy(init) {
1375+
const derived_object = derived(init);
1376+
const proxied_objects = new Map();
1377+
1378+
/**
1379+
* @param {V} value
1380+
* @param {(string | symbol)[]} path
1381+
* @returns {V}
1382+
*/
1383+
function proxify_object(value, path) {
1384+
const keys = new Set(Reflect.ownKeys(/** @type {object} */ (value)));
1385+
const proxy = new Proxy(value, handler);
1386+
proxied_objects.set(value, {
1387+
x: proxy,
1388+
k: keys,
1389+
p: path
1390+
});
1391+
return proxy;
1392+
}
1393+
1394+
const handler = {
1395+
/**
1396+
* @param {any} target
1397+
* @param {string | symbol} prop
1398+
* @param {any} receiver
1399+
*/
1400+
get(target, prop, receiver) {
1401+
const value = Reflect.get(target, prop, receiver);
1402+
const { k: keys, p: path } = proxied_objects.get(target);
1403+
1404+
if (
1405+
(effect_active_and_not_render_effect() || updating_derived) &&
1406+
keys.has(prop) &&
1407+
is_last_current_dependency(proxied_derived)
1408+
) {
1409+
const type = typeof value;
1410+
let new_path;
1411+
// We only track paths for primitives or state objects, to avoid tracking objects
1412+
// that likely change all the time.
1413+
if (
1414+
value === void 0 ||
1415+
type === 'string' ||
1416+
type === 'number' ||
1417+
type === 'boolean' ||
1418+
type === 'symbol' ||
1419+
type === 'bigint' ||
1420+
type === null ||
1421+
STATE_SYMBOL in value
1422+
) {
1423+
new_path = [...path, prop];
1424+
if (current_path !== null) {
1425+
push_derived_path();
1426+
} else {
1427+
current_path = { t: derived_object, p: new_path, r: proxied_derived };
1428+
}
1429+
}
1430+
if (should_proxy_derived_value(value)) {
1431+
const possible_proxy = proxied_objects.get(value);
1432+
if (possible_proxy !== undefined) {
1433+
return possible_proxy.x;
1434+
}
1435+
if (!new_path) {
1436+
new_path = [...path, prop];
1437+
}
1438+
return proxify_object(value, new_path);
1439+
}
1440+
}
1441+
return value;
1442+
}
1443+
};
1444+
1445+
const proxied_derived = derived(() => {
1446+
const value = get(derived_object);
1447+
if (should_proxy_derived_value(value)) {
1448+
return proxify_object(value, []);
1449+
} else if (proxied_objects.size > 0) {
1450+
proxied_objects.clear();
1451+
}
1452+
return value;
1453+
});
1454+
1455+
// Cleanup when the derived is destroyed
1456+
proxied_derived.y = () => {
1457+
proxied_objects.clear();
1458+
};
1459+
1460+
return proxied_derived;
1461+
}
1462+
13151463
/**
13161464
* @template V
13171465
* @param {() => V} init
@@ -1397,6 +1545,13 @@ export function effect_active() {
13971545
return current_effect ? (current_effect.f & MANAGED) === 0 : false;
13981546
}
13991547

1548+
/**
1549+
* @returns {boolean}
1550+
*/
1551+
function effect_active_and_not_render_effect() {
1552+
return current_effect ? (current_effect.f & (MANAGED | RENDER_EFFECT)) === 0 : false;
1553+
}
1554+
14001555
/**
14011556
* @param {() => void | (() => void)} init
14021557
* @returns {import('./types.js').EffectSignal}

packages/svelte/src/internal/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export {
77
source,
88
mutable_source,
99
derived,
10+
derived_proxy,
1011
derived_safe_equal,
1112
prop,
1213
user_effect,
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { test } from '../../test';
2+
import { log } from './log';
3+
4+
export default test({
5+
before_test() {
6+
log.length = 0;
7+
},
8+
9+
async test({ assert, target }) {
10+
const [btn1, btn2] = target.querySelectorAll('button');
11+
12+
log.length = 0;
13+
14+
await btn1?.click();
15+
assert.deepEqual(log, ['c', [1, 0], 'a', 1]);
16+
17+
log.length = 0;
18+
19+
await btn2?.click();
20+
assert.deepEqual(log, ['c', [1, 1], 'b', 1]);
21+
}
22+
});
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
/** @type {any[]} */
2+
export const log = [];
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<script>
2+
import { log } from './log.js';
3+
4+
let a = $state(0);
5+
let b = $state(0);
6+
let c = $derived([ a, b ]);
7+
8+
$effect(() => {
9+
log.push('c', c);
10+
})
11+
12+
$effect(() => {
13+
log.push('a', c[0]);
14+
})
15+
16+
$effect(() => {
17+
log.push('b', c[1]);
18+
})
19+
</script>
20+
21+
<button onclick={() => a++}>a</button>
22+
<button onclick={() => b++}>b</button>

0 commit comments

Comments
 (0)