feat: onchange kernel for deep proxied state via verified parent links

state-onchange-verified-links
Nic 4 days ago
parent 4bf15ae6f3
commit 62b01097f6

@ -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');

@ -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<any, Source<any>>;
* 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<ProxyMeta>} 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;
}
/**

@ -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);
});
});
});
Loading…
Cancel
Save