Skip to content
Draft
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
23 changes: 22 additions & 1 deletion packages/solid-signals/src/core/async.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
STATUS_PENDING,
STATUS_UNINITIALIZED
} from "./constants.js";
import { attrHooks } from "./attribution-hooks.js";
import { context, setSignal, untrack } from "./core.js";
import { devTrackHeldPending } from "./invariants.js";
import { emitDiagnostic } from "./dev.js";
Expand Down Expand Up @@ -364,6 +365,10 @@ export function handleAsync<T>(
clearStatus(el);
const lane = resolveLane(el as any);
if (lane) lane._pendingAsync.delete(el);
// Attribution hook: lets the engine snapshot state before the landing
// branches, so it can tell whether the plain path's setSignal committed a
// change (and only then classify it as an async landing).
if (__DEV__ && attrHooks !== null) attrHooks.asyncStart(el);
if (setter) {
setter(value);
if (wasUninitialized) clearStatus(el, true);
Expand All @@ -388,15 +393,24 @@ export function handleAsync<T>(
// override every reader sees the override (A17), so waking subs would
// re-show an unchanged view — the revert is the notification point.
GlobalQueue._syncCompanions !== null && GlobalQueue._syncCompanions(el, value);
if (!hasActiveOverride(el)) insertSubs(el);
if (!hasActiveOverride(el)) {
if (__DEV__ && attrHooks !== null) attrHooks.asyncEnd(el, undefined, value, true);
insertSubs(el);
}
el._time = clock;
} else if (lane) {
// Route through lane's effect queue for independent flushing
const isEffect = (el as any)._type;
const prevValue = el._value;
const equals = el._equals;
// Attribution flag only — the stamp itself must stay OUTSIDE the try:
// rollup's tryCatchDeoptimization retains any function referenced
// inside a try block even behind a folded __DEV__ guard, which would
// re-couple the dev-only attribution module into prod bundles (#2883).
let devChanged = false;
try {
if ((!isEffect && wasUninitialized) || !equals || !equals(value, prevValue)) {
if (__DEV__) devChanged = true;
el._value = value;
el._time = clock;
// The latest() shadow write gives latest() effects independent lanes; the
Expand All @@ -412,6 +426,8 @@ export function handleAsync<T>(
// rejection (#2837).
notifyStatus(el, STATUS_ERROR, e);
}
if (__DEV__ && attrHooks !== null && devChanged)
attrHooks.asyncEnd(el, prevValue, value, true);
} else {
try {
setSignal(el, () => value);
Expand All @@ -420,6 +436,11 @@ export function handleAsync<T>(
// pre-commit failure here, and there is no user callsite to throw to.
notifyStatus(el, STATUS_ERROR, e);
}
// Attribution hook: this path landed through setSignal, whose write
// hook already saw any committed change — direct=false lets the engine
// reclassify that write as an async landing iff it actually committed.
// Outside the try (#2883 — see attribution-hooks.ts).
if (__DEV__ && attrHooks !== null) attrHooks.asyncEnd(el, undefined, value, false);
}
// First real answer landing: the window closes when the answer becomes
// OBSERVABLE. A direct commit is observable now; a transition-held write
Expand Down
61 changes: 61 additions & 0 deletions packages/solid-signals/src/core/attribution-hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import type { Computed, Signal } from "./types.js";

/**
* Dev-only observability hook points for the reactive core.
*
* Core's obligation is to call these with true facts at the moments they
* happen; ALL attribution semantics (stamps, cause chains, timings, warnings)
* live in the engine that installs them (attribution.ts — same pattern as the
* GlobalQueue._* feature slots). `attrHooks` is null unless an engine is
* installed, so the disabled cost is one null check per site, and prod builds
* fold every site out behind __DEV__.
*
* IMPORTANT for implementers of call sites: a hook call must never sit inside
* a `try` block — rollup's tryCatchDeoptimization retains functions referenced
* inside `try` even behind a folded __DEV__ guard, which re-couples the dev
* engine into prod bundles (#2883 harness). Set a local flag inside the try
* and call the hook after the catch.
*/
export interface AttributionHooks {
/**
* A recompute is starting; `el._deps` still holds the previous run's links.
* Always paired with `recomputeEnd` (recompute has no early returns).
*/
recomputeStart(el: Computed<any>, create: boolean): void;
/**
* The recompute finished. `changed` = committed a changed value (false for
* errored runs); `optimistic` = ran under an optimistic lane / lane-dirty
* posture; `transition` = a transition was active or owns this node;
* `held` = the value went to `_pendingValue` (a transition hold) rather
* than committing directly — its reveal happens later on the transition's
* own schedule.
*/
recomputeEnd(
el: Computed<any>,
create: boolean,
changed: boolean,
optimistic: boolean,
transition: boolean,
held: boolean
): void;
/** A non-effect computed committed a changed value during a re-run. */
derivedChanged(el: Computed<any>): void;
/** A signal write committed (value passed the equality gate). */
write(el: Signal<any> | Computed<any>, prev: unknown, value: unknown): void;
/** refresh() invalidated this node (self-invalidation, no dep changed). */
refreshed(el: Computed<any>): void;
/** An async landing is about to apply its value (before any branch). */
asyncStart(el: Computed<any>): void;
/**
* The async landing applied. `direct` = the value was committed by this
* landing itself (lane/override paths); false = it went through setSignal,
* whose own `write` hook already saw any committed change.
*/
asyncEnd(el: Computed<any>, prev: unknown, value: unknown, direct: boolean): void;
}

export let attrHooks: AttributionHooks | null = null;

export function setAttributionHooks(hooks: AttributionHooks | null): void {
attrHooks = hooks;
}
Loading
Loading