Skip to content
Merged
7 changes: 7 additions & 0 deletions .changeset/frames-dependency-edges.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@solidjs/signals": patch
"solid-js": patch
"@solidjs/web": patch
---

Frames: a signal or store write during deferred hydration mounts only the slot occurrences the server recorded as its readers, then applies, instead of mounting every pending occurrence first. The server names plain signals (`<owner id>~<n>`) and plain stores (by their root) in server-component renders and emits `sc:deps` records per frame; writes it cannot vouch for (unnamed, derived, optimistic, projection or wide sources) still mount everything first. Apps without server components keep their sizes and code paths.
10 changes: 6 additions & 4 deletions packages/signals/src/core/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1688,6 +1688,7 @@ export function pullComputed(owner: Computed<any>, c: Computed<any>): void {
}

export function read<T>(el: Signal<T> | Computed<T>): T {
if (__DEV__) DEV.hooks.onRead?.(el);
if (__DEV__ && asyncTailFlights !== 0 && !tracking && untrackDepth === 0)
checkPostAwaitRead(
el,
Expand Down Expand Up @@ -2029,20 +2030,21 @@ export function captureWriteSnapshot<T>(el: Signal<T> | Computed<T>, current: T)
}

/**
* A callback run before every signal or store write lands, while set. The
* A callback run before every signal or store write lands, while set, with
* the written signal or the store proxy whose setter runs. The
* server-components client sets it while slot occurrences are left
* unmounted after hydration (deferred hydration): a write may be one an
* unmounted component would have observed, so the client mounts what is
* left before the write applies, and clears the hook. `null` otherwise.
*/
export let writeHook: (() => void) | null = null;
export let writeHook: ((node?: object) => void) | null = null;
/** @internal */
export function setWriteHook(hook: (() => void) | null): void {
export function setWriteHook(hook: ((node?: object) => void) | null): void {
writeHook = hook;
}

export function setSignal<T>(el: Signal<T> | Computed<T>, v: T | ((prev: T) => T)): T {
if (writeHook !== null) writeHook();
if (writeHook !== null) writeHook(el);
if (
__DEV__ &&
!(el._config & CONFIG_OWNED_WRITE) &&
Expand Down
2 changes: 2 additions & 0 deletions packages/signals/src/core/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ export interface DevHooks {
onGraph?: (value: any, owner: Owner | null) => void;
onUpdate?: () => void;
onStoreNodeUpdate?: (state: any, property: PropertyKey, value: any, prev: any) => void;
/** @internal Every node read (the frames client's identity-drift check; `setReadWatch`). */
onRead?: (node: object) => void;
}

/**
Expand Down
64 changes: 64 additions & 0 deletions packages/signals/src/core/identities.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { CONFIG_TRANSPARENT } from "./constants.js";
import { DEV } from "./dev.js";
import { getOwner } from "./owner.js";
import type { Owner } from "./types.js";

// Deferred hydration, dependency edges: plain-signal identities.
// While on (`setSignalIdentities`; the server-components client turns it on
// before hydrating), a plain signal created under an id-bearing owner is
// named `<owner id>~<n>`, n counting the plain signals (and plain store
// roots: `createStore` over an object, store/edges.ts) created under that
// owner in its current pass (transparent owners defer to the nearest
// id-bearing one, as child ids do). The server names the same signal the
// same way and restarts the count on every re-run, so an edge it shipped (a
// slot fill read it) matches the client node; the write hook receives the
// node and looks its name up (`signalName`). The count restarts when the owner's pass
// generation (`_depGen`, bumped by every recompute) moves on. Only the
// frames client imports this module, so nothing here ships without it.
// Names live in a side table, not on the node: frames reads them across the
// package boundary, where a mangled field would not survive.
let counts: WeakMap<Owner, [gen: number | undefined, n: number]> | null = null;
const names = new WeakMap<object, string>();

/** @internal The identity of a plain signal named while identities were on. */
export function signalName(node: object | undefined): string | undefined {
return node === undefined ? undefined : names.get(node);
}

function identify(node: object): void {
let o = getOwner();
if (!o) return;
while (o._config & CONFIG_TRANSPARENT && o._parent) o = o._parent;
if (o.id == null) return;
const gen = (o as any)._depGen as number | undefined;
let c = counts!.get(o);
if (c === undefined || c[0] !== gen) counts!.set(o, (c = [gen, 0]));
const sid = o.id + "~" + c[1]++;
// A store root named twice (one object made into two stores, or a store
// handed to `createStore`) is one target under two server names: its
// writes cannot be attributed (an empty name).
names.set(node, names.has(node) ? "" : sid);
}

/** @internal Name plain signals from now on (`on`), or stop. */
export function setSignalIdentities(on: boolean): void {
counts = on ? counts || new WeakMap() : null;
signalHook = on ? identify : null;
}

/**
* @internal Called with every plain signal `createSignal` makes, and every
* plain store root `createStore` makes, while set (the namer above). Never
* set unless `setSignalIdentities` is imported, so a bundle without it keeps
* only a null check.
*/
export let signalHook: ((node: object) => void) | null = null;

/**
* @internal Dev only: `watch` is called with every node read while set (the
* dev hook `onRead`) — the frames client's identity-drift check watches the
* named signals a deferred occurrence reads as it mounts. A no-op in prod.
*/
export function setReadWatch(watch: ((node: object) => void) | null): void {
if (__DEV__) DEV.hooks.onRead = watch || undefined;
}
1 change: 1 addition & 0 deletions packages/signals/src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export {
cleanup,
peekNextChildId
} from "./owner.js";
export { setSignalIdentities, signalName, signalHook, setReadWatch } from "./identities.js";
export {
createContext,
getContext,
Expand Down
5 changes: 4 additions & 1 deletion packages/signals/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,10 @@ export {
*/
export { setConsoleFooter } from "./core/index.js";
/** @internal The pre-write hook (`setWriteHook` in core/core.ts); the server-components client's deferred hydration. */
export { setWriteHook } from "./core/index.js";
/** @internal Plain-signal identities and the dev read watch (dependency edges; the server-components client). */
export { setWriteHook, setSignalIdentities, setReadWatch, signalName } from "./core/index.js";
/** @internal Store dependency edges: the name a store proxy's writes go by (the server-components client). */
export { storeName } from "./store/edges.js";
import { DEV as _DEV, OBSERVE as _OBSERVE, type Dev, type Observe } from "./core/index.js";
/**
* Observe tier (diagnostics channel, attribution hook slot + interaction
Expand Down
2 changes: 2 additions & 0 deletions packages/signals/src/signals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
runWithOwner,
setMemo,
setSignal,
signalHook,
signal,
trackedEffect,
untrack,
Expand Down Expand Up @@ -416,6 +417,7 @@ export function createSignal<T>(
}
const node = signal<T>(first as any, second as SignalOptions<T>);
if (__OBSERVE__) registerGraph(node, getOwner());
if (signalHook !== null) signalHook(node);
return [accessor<T>(node), setSignal.bind(null, node as any) as Setter<T | undefined>];
}

Expand Down
38 changes: 38 additions & 0 deletions packages/signals/src/store/edges.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* Deferred hydration, dependency edges for stores. Only the frames client
* imports this module, so nothing here ships without it.
*
* A plain store root (`createStore` over an object) is named like a plain
* signal (core/identities.ts: `<owner id>~<n>`, on the same count), and the
* whole store goes by that one name: the server records it for any read of
* the store, at any depth, and a setter call on the store — on its root or
* on a proxy nested in it — is a write to it. Its readers mount at the
* setter's entry, before the draft runs, as a plain signal's do.
*/
import { CONFIG_SLOT_NODE } from "../core/constants.js";
import { signalName } from "../core/identities.js";
import type { StoreTarget } from "./target.js";
import { $TARGET } from "./types.js";

/**
* @internal The name of the store `x` belongs to — `x` a store proxy (a
* setter's entry) or one of its property nodes (the setter notifying it) —
* or undefined when it has none or it cannot be placed: a projection or
* optimistic family, a store chained over another store's proxy, a root
* named twice, or a proxy reached through a store other than the one it
* was named as the root of.
*/
export function storeName(x: object | undefined): string | undefined {
if (x === undefined) return undefined;
let t: StoreTarget | undefined =
(x as any)._config & CONFIG_SLOT_NODE ? (x as any)._host : (x as any)[$TARGET];
if (t === undefined) return undefined;
// Up to the root; a target on the way named as a root of its own is one
// object in two stores.
while (t.u !== null) {
if (t.fam !== null || t.ch || signalName(t) !== undefined) return undefined;
t = t.u;
}
if (t.fam !== null || t.ch) return undefined;
return signalName(t) || undefined;
}
5 changes: 4 additions & 1 deletion packages/signals/src/store/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ import {
registerGraph,
warnStrictReadUntracked
} from "../core/dev.js";
import { signalHook } from "../core/identities.js";
import { setSlotUnobserved } from "../core/graph.js";
import { getObserver, getOwner } from "../core/owner.js";
import { insertIntoHeap } from "../core/heap.js";
Expand Down Expand Up @@ -2317,7 +2318,7 @@ function devGuardStoreSetterResult(result: unknown): void {
}

export function storeSetter<T>(proxy: T, fn: (draft: T) => T | void, guard = true): void {
if (writeHook !== null) writeHook();
if (writeHook !== null) writeHook(proxy as object);
if (__DEV__ && guard) devGuardStoreSetterWrite();
const target: StoreTarget = (proxy as any)[$TARGET];
const prevScopes = writeScopes;
Expand Down Expand Up @@ -2389,6 +2390,8 @@ export function createStore<T extends Record<PropertyKey, any>>(
throw new Error("createStore({ shallow }): value is already a store proxy");
}
const proxy = wrap(initialValue);
// Dependency edges (store/edges.ts): the root is named like a plain signal.
if (signalHook !== null) signalHook((proxy as any)[$TARGET]);
if (shallow) {
((proxy as any)[$TARGET] as StoreTarget).s = true;
markRawIngest(initialValue);
Expand Down
8 changes: 7 additions & 1 deletion packages/signals/tests/attribution-interactions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -504,7 +504,13 @@ describe("a handler that returns a promise", () => {
await wait(20);
});
await until(() => interactionLog().length === 1, "the record to settle");
expect(interactionLog()[0].continuationMs).toBeGreaterThanOrEqual(15);
// The 20 ms timer starts inside the handler, before it returns, and
// `continuationMs` counts from the return: a slow return (coverage
// instrumentation on a loaded runner) shortens it. Measured from the
// frame's open, the record waited the whole timer.
const record = interactionLog()[0];
expect(record.continuationMs).toBeGreaterThan(0);
expect(record.handlerMs + record.continuationMs!).toBeGreaterThanOrEqual(15);
expect(findings).toHaveLength(0);
});
});
Expand Down
Loading
Loading