diff --git a/packages/svelte/src/internal/client/constants.js b/packages/svelte/src/internal/client/constants.js index e0b7a8779b..94c94edcaf 100644 --- a/packages/svelte/src/internal/client/constants.js +++ b/packages/svelte/src/internal/client/constants.js @@ -72,6 +72,7 @@ export const COMPONENT_SYMBOL = Symbol('component'); export const LEGACY_PROPS = Symbol('legacy props'); export const LOADING_ATTR_SYMBOL = Symbol(''); export const PROXY_PATH_SYMBOL = Symbol('proxy path'); +export const PROXY_META_SYMBOL = Symbol('proxy meta'); export const ATTRIBUTES_CACHE = Symbol('attributes'); export const CLASS_CACHE = Symbol('class'); export const STYLE_CACHE = Symbol('style'); diff --git a/packages/svelte/src/internal/client/proxy.js b/packages/svelte/src/internal/client/proxy.js index 91d82f8903..d36fef3f1f 100644 --- a/packages/svelte/src/internal/client/proxy.js +++ b/packages/svelte/src/internal/client/proxy.js @@ -1,4 +1,4 @@ -/** @import { Source } from '#client' */ +/** @import { Effect, Source } from '#client' */ import { DEV } from 'esm-env'; import { get, @@ -6,8 +6,10 @@ import { update_version, active_reaction, set_update_version, - set_active_reaction + set_active_reaction, + untrack } from './runtime.js'; +import { destroy_effect, eager_effect } from './reactivity/effects.js'; import { array_prototype, get_descriptor, @@ -22,7 +24,7 @@ import { flush_eager_effects, set_eager_effects_deferred } from './reactivity/sources.js'; -import { COMPONENT_SYMBOL, PROXY_PATH_SYMBOL, STATE_SYMBOL } from '#client/constants'; +import { COMPONENT_SYMBOL, PROXY_META_SYMBOL, PROXY_PATH_SYMBOL, STATE_SYMBOL } from '#client/constants'; import { UNINITIALIZED } from '../../constants.js'; import * as e from './errors.js'; import { tag } from './dev/tracing.js'; @@ -32,19 +34,205 @@ import { tracing_mode_flag } from '../flags/index.js'; // TODO move all regexes into shared module? const regex_is_valid_identifier = /^[a-zA-Z_$][a-zA-Z_$0-9]*$/; +/** + * @typedef {{ + * self: any; + * sources: Map>; + * links: Array<{ pm: ProxyMeta, k: any }>; + * fires: Array<{ cb: () => void, fire: () => void, e: Effect }>; + * observed: boolean; + * }} ProxyMeta + */ + +/** + * Creates the dispatch half of an `onchange` root: a notifier source paired with an + * eager effect, so callbacks run synchronously inside `set()` and inherit the existing + * fork gating and deferral behaviour. The original callback is kept alongside so it + * can be detached again by identity + * @param {() => void} onchange + * @returns {{ cb: () => void, fire: () => void, e: Effect }} + */ +function create_fire(onchange) { + var notifier = source(0); + var initial = true; + var running = false; + + var effect = eager_effect(() => { + get(notifier); + + if (initial) { + initial = false; + return; + } + + // guard against the callback synchronously mutating its own tree + if (running) return; + running = true; + + try { + untrack(onchange); + } finally { + running = false; + } + }); + + return { cb: onchange, fire: () => increment(notifier), e: effect }; +} + +/** + * Registers `parent_meta[key]` as a (possibly stale) way to reach a root from `child`. + * Links are never eagerly removed — they are verified and pruned during `collect_roots` + * @param {any} child + * @param {ProxyMeta} parent_meta + * @param {any} key + */ +function link_child(child, parent_meta, key) { + if (child === null || typeof child !== 'object' || !(STATE_SYMBOL in child)) return; + + var meta = /** @type {ProxyMeta | undefined} */ (child[PROXY_META_SYMBOL]); + if (meta === undefined) return; + + var links = meta.links; + + for (var i = 0; i < links.length; i += 1) { + if (links[i].pm === parent_meta && links[i].k === key) return; + } + + links.push({ pm: parent_meta, k: key }); + observe(meta); +} + +/** + * Marks a node observed and links its already-materialized children, so references + * captured before the node joined an observed tree still reach a root + * @param {ProxyMeta} meta + */ +function observe(meta) { + if (meta.observed) return; + meta.observed = true; + + for (var [key, s] of meta.sources) { + if (s.v !== UNINITIALIZED) { + link_child(s.v, meta, key); + } + } +} + +/** + * Walks rootward from `meta`, verifying each link against the parent's backing source + * (`parent.sources.get(key).v === child`). Dead links are pruned in place; live chains + * contribute their root callbacks to `fires` + * @param {ProxyMeta} meta + * @param {Set} visited + * @param {Set<() => void>} fires + */ +function collect_roots(meta, visited, fires) { + if (visited.has(meta)) return; + visited.add(meta); + + for (var i = 0; i < meta.fires.length; i += 1) { + fires.add(meta.fires[i].fire); + } + + var links = meta.links; + + for (var j = links.length - 1; j >= 0; j -= 1) { + var link = links[j]; + var s = link.pm.sources.get(link.k); + + if ( + s !== undefined && + (s.v === meta.self || + // the slot may hold a user wrapper around this proxy, which forwards the meta lookup + (s.v !== null && + typeof s.v === 'object' && + STATE_SYMBOL in s.v && + /** @type {any} */ (s.v)[PROXY_META_SYMBOL] === meta)) + ) { + collect_roots(link.pm, visited, fires); + } else { + links.splice(j, 1); + } + } + + if (links.length === 0 && meta.fires.length === 0) { + meta.observed = false; + } +} + +/** @param {ProxyMeta} meta */ +function notify_onchange(meta) { + /** @type {Set<() => void>} */ + var fires = new Set(); + collect_roots(meta, new Set(), fires); + + for (var fire of fires) fire(); +} + +/** + * Detaches a callback previously attached with `proxy(value, onchange)`, used by the + * `$state` shell so a reassigned variable's old tree stops firing its callback + * @param {any} value + * @param {() => void} onchange + */ +export function remove_onchange(value, onchange) { + if (value === null || typeof value !== 'object' || !(STATE_SYMBOL in value)) return; + + var meta = /** @type {ProxyMeta | undefined} */ (value[PROXY_META_SYMBOL]); + if (meta === undefined) return; + + var fires = meta.fires; + + for (var i = 0; i < fires.length; i += 1) { + if (fires[i].cb === onchange) { + destroy_effect(fires[i].e); + fires.splice(i, 1); + break; + } + } + + if (fires.length === 0 && meta.links.length === 0) { + meta.observed = false; + } +} + +/** + * Wraps an array mutating method so onchange roots fire once per method call + * rather than once per internal `set` (e.g. `push` writes an element and `length`) + * @param {Function} fn + */ +function batch_eager_method(fn) { + return function (/** @type {any[]} */ ...args) { + set_eager_effects_deferred(); + + try { + // @ts-ignore + return fn.apply(this, args); + } finally { + flush_eager_effects(); + } + }; +} + /** * @template T * @param {T} value + * @param {() => void} [onchange] fires synchronously when anything in the proxied tree changes. Only plain objects and arrays are proxied, so writes inside class instances or reactive built-ins like `SvelteMap` do not notify * @returns {T} */ -export function proxy(value) { - // if non-proxyable, a component instance, or already a proxy, return `value` - if ( - typeof value !== 'object' || - value === null || - STATE_SYMBOL in value || - COMPONENT_SYMBOL in value - ) { +export function proxy(value, onchange) { + // if non-proxyable or a component instance, return `value` + if (typeof value !== 'object' || value === null || COMPONENT_SYMBOL in value) { + return value; + } + + if (STATE_SYMBOL in value) { + if (onchange !== undefined) { + // attach an additional root callback to an existing proxy + var m = /** @type {ProxyMeta} */ (/** @type {any} */ (value)[PROXY_META_SYMBOL]); + m.fires.push(create_fire(onchange)); + observe(m); + } return value; } @@ -59,6 +247,13 @@ export function proxy(value) { var is_proxied_array = is_array(value); var version = source(0); + /** + * Only allocated once this proxy participates in an observed tree — + * proxies outside onchange trees never pay for this beyond a null check + * @type {ProxyMeta | null} + */ + var meta = null; + var stack = DEV && tracing_mode_flag ? get_error('created at') : null; var parent_version = update_version; @@ -115,7 +310,7 @@ export function proxy(value) { updating = false; } - return new Proxy(/** @type {any} */ (value), { + var p = new Proxy(/** @type {any} */ (value), { defineProperty(_, prop, descriptor) { if ( !('value' in descriptor) || @@ -148,22 +343,29 @@ export function proxy(value) { deleteProperty(target, prop) { var s = sources.get(prop); + var changed = false; if (s === undefined) { if (prop in target) { const s = with_parent(() => source(UNINITIALIZED, stack)); sources.set(prop, s); increment(version); + changed = true; if (DEV) { tag(s, get_label(path, prop)); } } } else { + if (s.v !== UNINITIALIZED) changed = true; set(s, UNINITIALIZED); increment(version); } + if (changed && meta !== null && meta.observed) { + notify_onchange(/** @type {ProxyMeta} */ (meta)); + } + return true; }, @@ -179,6 +381,11 @@ export function proxy(value) { var s = sources.get(prop); var exists = prop in target; + // symbols are never own properties, so this check can live off the hot path + if (s === undefined && prop === PROXY_META_SYMBOL) { + return (meta ??= { self: p, sources, links: [], fires: [], observed: false }); + } + // create a source, but only if it's an own property and not a prototype property if (s === undefined && (!exists || get_descriptor(target, prop)?.writable)) { s = with_parent(() => { @@ -197,10 +404,30 @@ export function proxy(value) { if (s !== undefined) { var v = get(s); + + // reads through an observed proxy establish (or refresh) the child's rootward link + if (meta !== null && meta.observed && v !== UNINITIALIZED) { + link_child(v, meta, prop); + } + return v === UNINITIALIZED ? undefined : v; } - return Reflect.get(target, prop, receiver); + var reflected = Reflect.get(target, prop, receiver); + + if ( + meta !== null && + meta.observed && + is_proxied_array && + typeof prop === 'string' && + typeof reflected === 'function' && + ARRAY_MUTATING_METHODS.has(prop) + ) { + // batch array methods so e.g. `push` (element + length) fires roots once + return batch_eager_method(reflected); + } + + return reflected; }, getOwnPropertyDescriptor(target, prop) { @@ -208,12 +435,22 @@ export function proxy(value) { if (descriptor && 'value' in descriptor) { var s = sources.get(prop); - if (s) descriptor.value = get(s); + if (s) { + descriptor.value = get(s); + + if (meta !== null && meta.observed) { + link_child(descriptor.value, meta, prop); + } + } } else if (descriptor === undefined) { var source = sources.get(prop); var value = source?.v; if (source !== undefined && value !== UNINITIALIZED) { + if (meta !== null && meta.observed) { + link_child(value, meta, prop); + } + return { enumerable: true, configurable: true, @@ -257,6 +494,10 @@ export function proxy(value) { if (value === UNINITIALIZED) { return false; } + + if (meta !== null && meta.observed) { + link_child(value, meta, prop); + } } return has; @@ -265,6 +506,7 @@ export function proxy(value) { set(target, prop, value, receiver) { var s = sources.get(prop); var has = prop in target; + var changed = false; // variable.length = value -> clear all signals with index >= value if (is_proxied_array && prop === 'length') { @@ -297,7 +539,14 @@ export function proxy(value) { if (DEV) { tag(s, get_label(path, prop)); } - set(s, proxy(value)); + + var np = proxy(value); + set(s, np); + changed = !has || target[prop] !== value; + + if (meta !== null && meta.observed) { + link_child(np, /** @type {ProxyMeta} */ (meta), prop); + } sources.set(prop, s); } @@ -305,7 +554,14 @@ export function proxy(value) { has = s.v !== UNINITIALIZED; var p = with_parent(() => proxy(value)); - set(s, p); + + if (meta !== null && meta.observed) { + if (s.v !== p) changed = true; + set(s, p); + link_child(p, meta, prop); + } else { + set(s, p); + } } var descriptor = Reflect.getOwnPropertyDescriptor(target, prop); @@ -330,6 +586,11 @@ export function proxy(value) { } increment(version); + changed = true; + } + + if (changed && meta !== null && meta.observed) { + notify_onchange(/** @type {ProxyMeta} */ (meta)); } return true; @@ -356,6 +617,12 @@ export function proxy(value) { e.state_prototype_fixed(); } }); + + if (onchange !== undefined) { + meta = { self: p, sources, links: [], fires: [create_fire(onchange)], observed: true }; + } + + return p; } /** diff --git a/packages/svelte/tests/signals/onchange.test.ts b/packages/svelte/tests/signals/onchange.test.ts new file mode 100644 index 0000000000..18c9dd4aae --- /dev/null +++ b/packages/svelte/tests/signals/onchange.test.ts @@ -0,0 +1,298 @@ +import { assert, describe, it } from 'vitest'; +import { effect_root } from '../../src/internal/client/reactivity/effects'; +import { push, pop } from '../../src/internal/client/context'; +import { proxy, remove_onchange } from '../../src/internal/client/proxy'; + +function run(fn: () => void) { + push({}, true); + const destroy = effect_root(() => { + fn(); + }); + pop(); + destroy(); +} + +describe('proxy onchange kernel', () => { + it('fires synchronously on deep mutation', () => { + run(() => { + let count = 0; + const state = proxy({ a: { b: 1 } } as any, () => count++); + + state.a.b = 2; + assert.equal(count, 1); + + state.a.b = 3; + assert.equal(count, 2); + }); + }); + + it('does not fire when a write does not change the value', () => { + run(() => { + let count = 0; + const state = proxy({ x: 1 } as any, () => count++); + + state.x = 2; + assert.equal(count, 1); + + state.x = 2; + assert.equal(count, 1); + }); + }); + + it('veto case 1 (shared-array flaw): detached children go silent', () => { + run(() => { + let count = 0; + const items = proxy([{ foo: 'a' }, { foo: 'b' }, { foo: 'c' }] as any[], () => count++); + + const last = items[2]; + assert.equal(count, 0); + + items.pop(); + assert.equal(count, 1); + + // the popped item is no longer in the tree — mutating it must not fire + last.foo = 'blah'; + assert.equal(count, 1); + }); + }); + + it('veto case 2 (parent-chain dealbreaker): existing proxies can join observed trees', () => { + run(() => { + let foo = 0; + let bar = 0; + const inner = proxy({ x: 0 } as any, () => bar++); + const tree = proxy({} as any, () => foo++); + + tree.slot = inner; + assert.equal(foo, 1); + assert.equal(bar, 0); + + // mutating inner through its own reference notifies BOTH trees + inner.x = 1; + assert.equal(foo, 2); + assert.equal(bar, 1); + + // and through the tree as well + tree.slot.x = 2; + assert.equal(foo, 3); + assert.equal(bar, 2); + + delete tree.slot; + assert.equal(foo, 4); + assert.equal(bar, 2); + + // detached again — only its own root fires + inner.x = 3; + assert.equal(foo, 4); + assert.equal(bar, 3); + }); + }); + + it('veto case 3 (Set aliasing bug): same child under two keys survives one deletion', () => { + run(() => { + let count = 0; + const tree = proxy({} as any, () => count++); + + tree.a = { z: 0 }; + assert.equal(count, 1); + + tree.b = tree.a; + assert.equal(count, 2); + + delete tree.a; + assert.equal(count, 3); + + // still reachable via `b` — must still fire + tree.b.z = 1; + assert.equal(count, 4); + + const child = tree.b; + delete tree.b; + assert.equal(count, 5); + + // now fully detached — silent + child.z = 2; + assert.equal(count, 5); + }); + }); + + it('array methods fire once per call', () => { + run(() => { + let count = 0; + const arr = proxy([] as any[], () => count++); + + arr.push(1); + assert.equal(count, 1); + + arr.push(2, 3); + assert.equal(count, 2); + + arr.splice(0, 1); + assert.equal(count, 3); + }); + }); + + it('works without a parent effect (module-level state)', () => { + let count = 0; + const state = proxy({ n: 0 } as any, () => count++); + + state.n = 1; + assert.equal(count, 1); + }); + + it('lazily-read nested objects are covered once traversed', () => { + run(() => { + let count = 0; + const state = proxy({ nested: { deep: { v: 0 } } } as any, () => count++); + + // mutation requires traversal, traversal establishes links + state.nested.deep.v = 1; + assert.equal(count, 1); + + const deep = state.nested.deep; + deep.v = 2; + assert.equal(count, 2); + }); + }); + + it('callback reads see the post-mutation tree', () => { + run(() => { + let seen = -1; + const state: any = proxy({ x: 0 } as any, () => (seen = state.x)); + + state.x = 42; + assert.equal(seen, 42); + }); + }); + + it('does not fire when the first write of a property does not change it', () => { + run(() => { + let count = 0; + const state = proxy({ x: 1 } as any, () => count++); + + state.x = 1; + assert.equal(count, 0); + + state.x = 2; + assert.equal(count, 1); + }); + }); + + it('attached callbacks can be detached again (#15069 reassignment path)', () => { + run(() => { + const logs: string[] = []; + const b = proxy({ count: 0 } as any, () => logs.push('b')); + const c = () => logs.push('c'); + proxy(b, c); + + b.count = 1; + assert.deepEqual(logs, ['b', 'c']); + + remove_onchange(b, c); + + b.count = 2; + assert.deepEqual(logs, ['b', 'c', 'b']); + }); + }); + + it('attaching to an existing proxy covers already-materialized children', () => { + run(() => { + let count = 0; + const state = proxy({ a: { b: 0 } } as any); + const a = state.a; + + proxy(state, () => count++); + + a.b = 1; + assert.equal(count, 1); + }); + }); + + it('references captured before a subtree joins an observed tree still notify', () => { + run(() => { + let count = 0; + const inner = proxy({ nested: { v: 0 } } as any); + const captured = inner.nested; + + const tree = proxy({} as any, () => count++); + tree.slot = inner; + assert.equal(count, 1); + + captured.v = 1; + assert.equal(count, 2); + }); + }); + + it('children created while detached are covered after re-attachment', () => { + run(() => { + let count = 0; + const state = proxy({ child: { v: 0 } } as any, () => count++); + + const child = state.child; + state.child = null; + assert.equal(count, 1); + + child.v = 1; + child.deep = { z: 0 }; + const deep = child.deep; + assert.equal(count, 1); + + state.child = child; + assert.equal(count, 2); + + deep.z = 1; + assert.equal(count, 3); + }); + }); + + it('replacing a subtree detaches the old one', () => { + run(() => { + let count = 0; + const state = proxy({ child: { v: 0 } } as any, () => count++); + + const old = state.child; + old.v = 1; + assert.equal(count, 1); + + state.child = { v: 100 }; + assert.equal(count, 2); + + old.v = 2; + assert.equal(count, 2); + + state.child.v = 101; + assert.equal(count, 3); + }); + }); + + it('fires through a user wrapper around a state proxy', () => { + run(() => { + let count = 0; + const inner = proxy({ x: 1 } as any); + const wrapped = new Proxy(inner, {}); + + const state = proxy({ slot: null } as any, () => count++); + state.slot = wrapped; + assert.equal(count, 1); + + state.slot.x = 2; + assert.equal(count, 2); + + // verification must accept the wrapper — a later write still fires + inner.x = 3; + assert.equal(count, 3); + }); + }); + + it('links children handed out via the has trap and property descriptors', () => { + run(() => { + let count = 0; + const state = proxy({ child: { x: 1 } } as any, () => count++); + + void ('child' in state); + const descriptor = Object.getOwnPropertyDescriptor(state, 'child'); + descriptor!.value.x = 2; + assert.equal(count, 1); + }); + }); +});