Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/observe-exclude-interaction-writes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/signals": patch
---

`OBSERVE.exclude` now covers writes: a root write to an excluded subject (the observer's own store or signal) no longer counts toward the interaction that made it, and an interaction whose writes all went to excluded subjects with none of the app's work run — a click on a devtools panel's own button — is not recorded. Store nodes carry the owner their store was created under, so an excluded panel's store is an excluded subject like its signals.
2 changes: 1 addition & 1 deletion documentation/solid-2.0/08-dev-diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -609,7 +609,7 @@ createRoot(() => {

**Records and clocks.** Everything the engine hands out — `RerunEvent`, `InteractionEvent`, `HoldEvent`, `NavigationEvent` — is a record with an absolute `at` on the `performance.now()` clock (`RerunEvent.at` the run's start, `HoldEvent.at` the start of the wait, `NavigationEvent.at`/`InteractionEvent.at` the request/dispatch) plus durations from it (`holdMs`, `settledMs`, `selfMs`). Epoch time for an exporter is `performance.timeOrigin + at` (milliseconds). Without cross-origin isolation the browser quantizes `performance.now()` to 100µs, so a single run's `selfMs` is often `0`; the per-interaction `settledMs` is the wall-clock number to report. Records carry live graph references (`RerunEvent.node`) and the frame objects that join them (`origin`, `interaction`) — the same object across records, so join by identity, not by name. `subscribe(type, listener)` delivers each record synchronously at the moment it is complete (a re-run at recompute end; an interaction, hold or navigation when it settles), bottom-up: a hold before the navigation it held, before the interaction that performed it. A listener runs inside the engine and must not write signals; hand work off to a microtask.

**Excluding the observer.** `OBSERVE.exclude(owner)` marks an owner subtree as the observer's own: diagnostics whose subject sits under it are built (a throwing site still throws) but never delivered or printed, and the attribution engine records no run for its computations, charges none of them to an interaction, and does not spend a once-per-key slot (`IMMUTABLE_UPDATE_IN_STORE`'s per-path memory) on them. Mark the root as it is created, and make writes from outside the graph under it (`runWithOwner(owner, () => setPanel(…))`) so the writer's context is excluded too. `OBSERVE.isExcluded(subject)` answers the question for any owner or node.
**Excluding the observer.** `OBSERVE.exclude(owner)` marks an owner subtree as the observer's own: diagnostics whose subject sits under it are built (a throwing site still throws) but never delivered or printed, and the attribution engine records no run for its computations, charges none of them to an interaction, counts no write to its signals or stores toward an interaction, and does not spend a once-per-key slot (`IMMUTABLE_UPDATE_IN_STORE`'s per-path memory) on them. An interaction whose writes all went to excluded subjects, with none of the app's work run — a click on the observer's own panel — is not recorded at all. Mark the root as it is created (a store's nodes take the owner the store was created under, recorded only once the engine is enabled — enable before creating the panel's stores), and make writes from outside the graph under it (`runWithOwner(owner, () => setPanel(…))`) so the writer's context is excluded too. `OBSERVE.isExcluded(subject)` answers the question for any owner or node.

`costs()` aggregates since `enable()`: `scopes` ranked by self-time with `wastedMs` (time in runs whose value didn't change — the equality cutoff absorbed them), and `writes` ranked by the total downstream re-run time each root write caused. Overlay work (optimistic-lane and held runs — `phase: "optimistic" | "held"`) is accounted separately as `overlayMs` and never blamed as waste.

Expand Down
23 changes: 19 additions & 4 deletions packages/signals/src/core/attribution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,7 @@ interface AttributedNode {
* engine records nothing about the node. Cached per node once any exclusion
* exists; before that the answer is a flag read.
*/
function excludedNode(el: Computed<any>): boolean {
function excludedNode(el: Computed<any> | Signal<any>): boolean {
if (!anyExcluded()) return false;
const node = el as AttributedNode;
if (node._devExcluded === undefined) node._devExcluded = isExcluded(el);
Expand Down Expand Up @@ -713,7 +713,7 @@ function stampWrite(
const prior = (node as AttributedNode)._devChange;
(node as AttributedNode)._devChange = record;
noteNavigationWrite(prior, record);
noteInteractionWrite(record.origin);
noteInteractionWrite(record.origin, excludedNode(node));
if (kind === "write") trackEffectWrite(node, record, value);
// stampWrite is the single funnel for committed root invalidations (sync
// writes, refresh(), async landings), which makes it the one place the
Expand Down Expand Up @@ -2732,6 +2732,8 @@ interface InteractionState {
heldIn: Set<Transition>;
/** A flush parked its writes at some point (hold tracking on or off). */
held: boolean;
/** Root writes to excluded subjects (the observer's own store): not the app's. */
excludedWrites: number;
}
const interactionStates = new WeakMap<ChangeOrigin, InteractionState>();
/** Opened, not yet settled. */
Expand All @@ -2757,7 +2759,8 @@ function openInteraction(frame: ChangeOrigin): void {
open: true,
writeDrain: drainSeq,
heldIn: new Set(),
held: false
held: false,
excludedWrites: 0
};
interactionStates.set(frame, state);
openInteractions.add(state);
Expand All @@ -2784,9 +2787,13 @@ function openInteractionOf(origin: ChangeOrigin | undefined): InteractionState |
}

/** stampWrite: a root write stamped `origin`. */
function noteInteractionWrite(origin: ChangeOrigin): void {
function noteInteractionWrite(origin: ChangeOrigin, excluded: boolean): void {
const state = openInteractionOf(origin);
if (state === undefined) return;
if (excluded) {
state.excludedWrites++;
return;
}
state.event.writes++;
state.writeDrain = drainSeq;
}
Expand Down Expand Up @@ -2845,6 +2852,14 @@ function maybeSettleInteraction(state: InteractionState, end: number = now()): v
if (event.writes > 0 && drainSeq <= state.writeDrain) return;
for (const nav of event.navigations) if (nav.outcome === undefined) return;
openInteractions.delete(state);
// Every write went to an excluded subject and nothing of the app's ran: the
// click was on the observer's own UI (a devtools panel's button). Not a
// fact about the app — forget it rather than report a dead interaction.
if (event.writes === 0 && state.excludedWrites > 0 && event.runs === 0 && event.created === 0) {
const i = interactionLog.indexOf(event);
if (i !== -1) interactionLog.splice(i, 1);
return;
}
event.settledMs = end - event.at;
event.outcome = event.writes === 0 ? "idle" : state.held ? "held" : "committed";
emitRecord("interaction", event);
Expand Down
44 changes: 41 additions & 3 deletions packages/signals/src/store/next/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ import {
setProjectionWriteActive,
setStoreCommitHook
} from "../../core/scheduler.js";
import type { Signal } from "../../core/types.js";
import type { Owner, Signal } from "../../core/types.js";
import { pendingCheckActive, strictRead } from "../../core/core.js";
import {
DEV,
Expand Down Expand Up @@ -310,7 +310,10 @@ export function getNode(
// "signal". Gated on the engine being installed — node creation is
// the hottest store path, and the disabled cost must stay one null
// check (nodes created before enable() stay generically named).
if (__OBSERVE__ && attrHooks !== null) (created as any)._name = "store." + String(key);
if (__OBSERVE__ && attrHooks !== null) {
(created as any)._name = "store." + String(key);
stampNodeOwner(created, target);
}
// Optimistic families: arm the override slot — setSignal routes armed
// nodes through the core engine (lanes, ownership, reverts all native).
if (target.fam?.opt) {
Expand Down Expand Up @@ -364,6 +367,27 @@ function sameLogicalSlot(target: StoreNextTarget, a: any, b: any): boolean {
return at !== undefined && at === lookupTarget(b, target.fam);
}

/**
* Observe-tier: the owner each store root was created under. The proxy
* cannot carry `_owner` itself (`registerGraph`'s stamp is swallowed by the
* set trap outside a draft), so the root target keys it here and the store's
* nodes copy it into `_owner` as they are created — an `OBSERVE.exclude`d
* panel's store nodes are then excluded subjects, exactly like its signals.
* Both ends are gated with the naming on the engine being installed: node
* creation is the hottest store path, store creation is next, and the
* disabled cost of each stays one null check. A store created before
* `enable()` therefore has no recorded owner and its nodes are never excluded
* subjects — the same boundary the naming draws; a panel enables first.
*/
const storeOwners: WeakMap<StoreNextTarget, Owner | null> | null = __OBSERVE__
? new WeakMap()
: null;
function stampNodeOwner(created: Signal<any>, target: StoreNextTarget): void {
let root = target;
while (root.u !== null) root = root.u;
(created as any)._owner = storeOwners!.get(root) ?? null;
}

export function getHasNode(
target: StoreNextTarget,
key: PropertyKey,
Expand All @@ -387,6 +411,7 @@ export function getHasNode(
(target.fam?.node as any) ?? undefined
));
created._config |= CONFIG_OWNED_WRITE;
if (__OBSERVE__ && attrHooks !== null) stampNodeOwner(created, target);
if (target.fam?.opt) {
ext(created)._overrideValue = NOT_PENDING;
created._config |= CONFIG_OPTIMISTIC;
Expand Down Expand Up @@ -415,6 +440,7 @@ export function getKeySetNode(target: StoreNextTarget): Signal<number> {
(target.fam?.node as any) ?? undefined
));
created._config |= CONFIG_OWNED_WRITE;
if (__OBSERVE__ && attrHooks !== null) stampNodeOwner(created, target);
if (target.fam?.opt) {
ext(created)._overrideValue = NOT_PENDING;
created._config |= CONFIG_OPTIMISTIC;
Expand Down Expand Up @@ -442,6 +468,7 @@ function getDeepNode(target: StoreNextTarget): Signal<number> {
(target.fam?.node as any) ?? undefined
));
created._config |= CONFIG_OWNED_WRITE;
if (__OBSERVE__ && attrHooks !== null) stampNodeOwner(created, target);
if (target.fam?.opt) {
ext(created)._overrideValue = NOT_PENDING;
created._config |= CONFIG_OPTIMISTIC;
Expand Down Expand Up @@ -2265,7 +2292,18 @@ export function createStoreNext<T extends Record<PropertyKey, any>>(
((proxy as any)[$TARGET] as StoreNextTarget).s = true;
markRawIngest(initialValue);
}
if (__OBSERVE__) registerGraph(proxy, getOwner());
if (__OBSERVE__) {
const owner = getOwner();
// Dev-tier graph registration (owner signal lists, onGraph); the
// `_owner` write itself never reaches the proxy, see storeOwners.
registerGraph(proxy, owner);
// Only once the engine is installed, like the node stamping it feeds: a
// WeakMap.set per fresh store is a growing ephemeron table (~+35% on the
// 2000-store create+commit shape, CodSpeed −11.7% on #3380's first cut)
// and, disabled, buys nothing — a store created before enable() has no
// excluded owner to inherit either way.
if (attrHooks !== null) storeOwners!.set((proxy as any)[$TARGET] as StoreNextTarget, owner);
}
const setter: SetStoreNextFunction<T> = fn => storeSetterNext(proxy, fn);
return [proxy, setter];
}
Expand Down
37 changes: 37 additions & 0 deletions packages/signals/tests/observe-exclude.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,43 @@ describe("OBSERVE.exclude", () => {
expect(seen.filter(e => e.code === "IMMUTABLE_UPDATE_IN_STORE")).toHaveLength(1);
});

it("forgets an interaction whose only writes went to the panel's own store", () => {
arm();
const delivered: unknown[] = [];
attribution.subscribe("interaction", e => delivered.push(e));
const { owner, result: setPanel } = excludedRoot(() => {
const [panel, setPanel] = createStore<{ items: number[] }>({ items: [] });
// The panel renders its list, so the store has live nodes to write.
createEffect(
() => panel.items.length,
() => {},
{ name: "panelList" }
);
return setPanel;
});
flush();
// The panel's own "clear" button: a real DOM click the runtime stamps,
// whose handler writes nothing but the panel's store.
OBSERVE!.attribution.withInteraction({ type: "click", target: "button" }, () =>
runWithOwner(owner, () => setPanel(s => void s.items.push(1)))
);
flush();
expect(attribution.interactions()).toHaveLength(0);
expect(delivered).toHaveLength(0);

// A click that also writes the app is the app's: recorded, with the
// panel write not counted among its writes.
const [, setApp] = createSignal(0, { name: "app" });
OBSERVE!.attribution.withInteraction({ type: "click" }, () => {
runWithOwner(owner, () => setPanel(s => void s.items.push(2)));
setApp(1);
});
flush();
expect(attribution.interactions()).toHaveLength(1);
expect(delivered).toHaveLength(1);
expect(attribution.interactions()[0].writes).toBe(1);
});

it("records no runs for the panel's computations", () => {
arm();
const [tick, setTick] = createSignal(0, { name: "tick" });
Expand Down
12 changes: 11 additions & 1 deletion scripts/size/.size-limit.js
Original file line number Diff line number Diff line change
Expand Up @@ -1087,7 +1087,17 @@ module.exports = [
// measured at 26978 B against 26930 (+48); see the core floor note.
// #3372/#3377 (2026-09-12): 27.05 -> 27.10 KB, measured at 27066 B against 26978
// (+88); see the core floor note.
limit: "27.10 KB",
//
// Excluded writes (#3380, 2026-09-12): 27.10 -> 27.16 KB, measured at
// 27090 B against 27066 (+24; +56 on the pre-rebase base). A root write
// to an excluded subject (the observer's own store) no longer counts
// toward the interaction, and an interaction whose writes all went there
// with none of the app's work run is forgotten rather than reported idle.
// Store nodes now carry their root's creating owner (`_owner`) so they
// are excluded subjects like signals — that part lives in store.ts and
// no observe scenario bundles stores; the observe CSR scenario above did
// not move.
limit: "27.16 KB",
modifyEsbuildConfig: observeEsbuildConfig
},
{
Expand Down
Loading