solid-deps
v0.0.8
Published
Experimental Solid 2 late-dependency tracking and hybrid owner fallback
Maintainers
Readme
solid-deps
Restore Solid 2's reactive tracking or owner context for synchronous callbacks after an await.
By default, createScope() chooses one of two behaviors at capture time:
- Tracking observer present (effect or memo): capture the observer cycle and attach late reactive reads to it. Stale and disposed scopes reject calls.
- Plain owner present (component/owner body without tracking): restore that owner using
runWithOwner()for synchronous callbacks. This restores context and owned cleanups/children but does not subscribe signals.
For a strictly observer-only factory, use createObserverScope(). The explicit owner argument to createScope(owner) continues to require a tracking Computed for backwards compatibility. createOwnerScope() is unchanged.
Install
npm install solid-deps [email protected] @solidjs/[email protected]Or with Bun:
bun add solid-deps [email protected] @solidjs/[email protected]Compatibility: This hybrid feature is tested with Solid 2.0.0-rc.17 and 2.0.0-rc.18; rc.20 is pending verification. The preexisting package was tested with Solid 2.0.0-rc.17 and 2.0.0-rc.18 production, development and observe builds (Node/ESM regression and package consumer tests, plus real Chromium owner-bound bridge scenarios). solid-deps recognizes validated descriptive development fields directly and discovers unknown or minified graph fields at runtime. Other Solid 2 versions may work if they retain compatible internal graph semantics, but are not yet verified. Your app and solid-deps must share one @solidjs/signals runtime instance. SSR is not supported.
Quick start: hybrid createScope()
Component body (plain-owner fallback):
import { getOwner, onCleanup } from 'solid-js';
import { createScope } from 'solid-deps';
function Component() {
const scope = createScope(); // No tracking observer; captures component owner
async function load() {
await fetch('/api/items');
scope(() => {
console.log(getOwner() !== null); // restored owner
onCleanup(() => console.log('component disposed'));
});
}
void load();
return <div>Loaded asynchronously</div>;
}Read accessors under this plain-owner fallback do not create subscriptions in the component. runWithOwner() deliberately clears tracking. Children and cleanups created inside the callback belong to the captured owner and are disposed with it. The callback itself must be synchronous; if it contains await, context is not restored after the await. Promise-returning callbacks are rejected.
Tracked effect or memo:
Capture a dependency scope inside the tracked part of your computation. Call it after await to attach signal reads to that same computation:
import { createEffect, createSignal } from 'solid-js';
import { createScope, StaleScopeError, DisposedScopeError } from 'solid-deps';
export default function Counter() {
const [count, setCount] = createSignal(0);
createEffect(async () => {
const scope = createScope(); // Capture the current observer cycle BEFORE await
await Promise.resolve();
try {
const value = scope(() => count()); // Returns the count directly
console.log('Count:', value);
} catch (error) {
// An older async invocation must not attach reads to a newer cycle.
if (error instanceof StaleScopeError || error instanceof DisposedScopeError) return;
throw error;
}
}, () => {});
return (
<button onClick={() => setCount(v => v + 1)}>
Count: {count()}
</button>
);
}scope(callback) is a callable function whose result is the callback's return value. Only in the tracked-observer branch, it grafts reactive sources read synchronously by the callback into the captured observer. It does not restore that observer's original owner context: reads run under a temporary tracking observer and their subscriptions are transferred.
In the plain-owner branch, it uses runWithOwner() and does not graft dependencies. The branch is fixed when createScope() is called, even if the wrapper is invoked from a different context later.
A scope can be called multiple times while its captured observer cycle is still current:
const scope = createScope();
await loadData();
const name = scope(() => userName());
const summary = scope(() => ({ count: count(), enabled: enabled() }));The callback must be synchronous. Await asynchronous work before calling scope(), not inside it.
Pending async memos and NotReadyError
A synchronous callback may read an existing async memo that is not ready yet. In that case, Solid 2 throws NotReadyError as part of its pending-value protocol. solid-deps preserves the dependencies read up to that point before rethrowing the same error:
import { createMemo, createEffect, NotReadyError } from 'solid-js';
import { createOwnerScope } from 'solid-deps';
const memo = createMemo(async () => {
return await loadData();
});
createEffect(() => {
const scope = createOwnerScope();
// Example: an externally scheduled callback after the effect starts.
queueMicrotask(() => {
try {
console.log(scope(() => memo()));
} catch (error) {
if (error instanceof NotReadyError) {
// memo() has been subscribed to the original effect.
// Its successful settlement can invalidate that effect.
return;
}
throw error;
}
});
return 0;
}, () => {});What this means: the pending memo becomes a direct dependency of the original computation, even when scope() cannot return a value. If the memo later fulfills, Solid can schedule the original effect; its next normal recomputation may remove the late subscription if the effect no longer reads it. Only sources actually read before NotReadyError are attached. Other thrown errors still leave the original dependency graph unchanged.
A later pending transition behaves differently from an initial pending read. If an already-settled memo is attached and then starts another async request, Solid can propagate its pending status through the existing link, delaying the target effect’s apply phase until settlement. This does not make late reads persistent: if the target recomputes without reading the memo again, Solid may remove its subscription. With multiple pending memos, the target can therefore apply after one settles while another remains pending. A native effect that reads both memos in its own compute function can keep waiting. solid-deps does not replay the scoped callback or automatically repeat its pending reads on subsequent effect passes.
NotReadyError is re-thrown, not converted to undefined or an AttachReadResult. createScope() and createOwnerScope() share this behavior. When using a delayed callback outside Solid's tracked execution, catch or handle the error; simply propagating it does not automatically restore the original <Loading> boundary context. Fulfillment-based wakeup and basic suspended-effect behavior have been tested on rc.17 and rc.18; complex Loading/transition semantics and error-state recovery are not guaranteed.
Alternative: createOwnerScope()
createScope() is cycle-bound when an observer is captured: when the effect recomputes, an earlier scope becomes stale. Use createOwnerScope() when an older asynchronous callback deliberately needs to attach reads to the same still-live effect, even after that effect has rerun.
import { createEffect, createSignal } from 'solid-js';
import { createOwnerScope, DisposedScopeError } from 'solid-deps';
const [key, setKey] = createSignal(0);
const [late, setLate] = createSignal(0);
createEffect(() => {
const ownerScope = createOwnerScope();
const current = key(); // Native dependency
setTimeout(() => {
try {
console.log(ownerScope(() => late()));
} catch (error) {
if (!(error instanceof DisposedScopeError)) throw error;
}
}, 2000);
return current;
}, () => {});
setKey(1); // The old ownerScope still targets the same live computation.This example deliberately leaves the timeout running to demonstrate owner-bound behavior. For async work that belongs only to a particular effect execution, prefer createScope() and cancel obsolete work using the effect's cleanup mechanism.
Comparison:
| | createScope() (observer branch) | createOwnerScope() |
| --- | --- | --- |
| Captures | Computation and dependency generation | Computation identity only |
| Reusable after a normal effect rerun | No — throws StaleScopeError | Yes, while the effect is alive |
| After final disposal | Throws DisposedScopeError | Throws DisposedScopeError |
| Callback return | Direct value | Direct value |
| Extra dependencies survive every rerun | No | No |
Owner-bound callbacks do not restore Solid ownership context, transfer to a replacement effect, or make attached dependencies permanent. An older callback may alter the currently running effect's dependency graph; use this behavior intentionally.
API
createScope(owner?) / createHybridScope(owner?)
The default and explicit hybrid alias are the same function. Without arguments, capture the current tracking observer if present; otherwise capture the current plain owner. If neither exists, throw immediately. With an explicit owner argument, preserve the preexisting strict observer-only behavior: the supplied owner must be a tracking Computed.
When an observer is captured, calls attach reads to its captured dependency generation. When only a plain owner is captured, calls restore owner/context synchronously, without tracking reactive dependencies. A disposed owner throws DisposedScopeError before the callback executes.
createObserverScope(owner?)
The strict observer-only equivalent of the pre-hybrid createScope(). Use it when a missing tracking computation must be an error even inside a component owner. It has the same stale-cycle and disposed behavior as the original graph attachment factory.
const scope = createScope(); // Capture current tracked observer implicitly
const scope2 = createScope(owner); // Explicit existing computation owner
const value = scope(() => signal());The implicit form must be invoked before await, while the intended observer or owner is active. The explicit owner parameter remains strictly observer-only; it must be a tracking computation, not a plain root/component owner.
A scope call:
- Returns the exact synchronous callback value, including
0,false,null,undefined, or an object reference. - Throws
StaleScopeErrorif its captured computation has begun a newer cycle. The callback is not executed. - Throws
DisposedScopeErrorif the computation has been disposed. The callback is not executed. - Propagates errors thrown by the callback or the graph-validation mechanism.
createOwnerScope(owner?)
Create a callable function bound to the lifetime of the current tracked computation, rather than its current execution cycle.
const scope = createOwnerScope(); // While an observer is tracking
const other = createOwnerScope(owner); // Explicit existing computation owner
await loadData();
const value = scope(() => signal()); // Exact callback valueThe implicit form requires an active observer at creation time. An explicit owner must be a tracking computation rather than a plain Solid owner/root. The handle keeps a reference to that same computation across normal reruns; it does not discover or switch to any newly created effect. Final disposal causes DisposedScopeError, and the callback is skipped. Other reader/graph errors propagate normally.
Do not call it to perform tracked reads inside an effect cleanup callback: the temporary tracking computation required for grafting can be rejected by Solid in development mode. Use cleanup to cancel external timers or requests instead.
captureOwnerCycle(owner?)
Capture an opaque owner-cycle token with the same implicit-or-explicit owner behavior as createScope:
import { captureOwnerCycle, attachRead } from 'solid-deps';
const cycle = captureOwnerCycle();
await something();
const result = attachRead(cycle, () => count());Tokens retain the observer identity and dependency generation at capture time. They do not create a new reactive computation.
attachRead(target, callback)
The lower-level API runs a synchronous callback and returns a single object containing both the callback value and attachment metadata.
const result = attachRead(cycle, () => {
const first = firstName();
const last = lastName();
return `${first} ${last}`;
});
console.log(result.value);
console.log(result.added, result.duplicate, result.promoted);target may be a captured OwnerCycle or a raw, validated effect/computation owner. A raw owner is not generation-guarded; use a captured cycle after await when executions can overlap.
| Field | Type | Description |
| --- | --- | --- |
| value | T \| undefined | Exact callback return; undefined when skipped |
| added | number | Newly validated/attached sources, including promotions |
| duplicate | number | Already-valid sources encountered again |
| promoted | number | Reused links from the previous generation (included in added) |
| stale | boolean | Captured cycle superseded; callback skipped |
| disposed | boolean | Target disposed; callback skipped |
attachRead() returns status rather than throwing for stale/disposed targets. Reader exceptions are propagated; a Promise/thenable callback result is rejected.
attachReads(target, callbacks) — batched dependency attachment (experimental)
Use this when several independent synchronous callbacks need to attach reactive reads to the same existing observer. A single attachRead() callback may already read many signals; this API avoids repeatedly setting up a temporary observer and repeatedly reconciling the target's entire dependency list.
import { attachReads } from 'solid-deps';
const result = attachReads(owner, [
() => firstName(),
() => lastName(),
() => profile().age
]);
// result.values contains [firstName, lastName, age] in callback order.
// result.added / duplicate / promoted report reactive sources, not callback count.Each callback can itself read multiple signals. All encountered dependencies, including nested direct signal reads, are collected through one temporary tracking computation; reading an existing memo ordinarily attaches the memo source, not all of its own transitive dependencies.
If a callback throws NotReadyError, subsequent callbacks are still attempted so all independently pending sources can be attached. After successful graph transfer, the first NotReadyError is rethrown. There is no values result in this case. A non-NotReadyError exception, an async/thenable callback return, an owned child computation, or an invalid temporary source rejects the batch without grafting its captured links. The target remains subject to the same callback and ownership restrictions as attachRead().
On success, attachReads() returns { values, added, duplicate, promoted, disposed, stale }. values is the ordered array of callback return values, or undefined if a stale/disposed target caused the callbacks to be skipped. target accepts a live observer or captureOwnerCycle() token. An empty array is a no-op.
Important: batching only improves the cost of late attachment. It does not retroactively trigger native NotReady status or Loading boundary bookkeeping. A normal computation can still prune grafted links if its own compute function doesn't read those sources. For ongoing native pending behavior use explicit pure retained accessors and normal tracked recomputation where appropriate.
inspectDeps(owner)
Return the dependency source node objects currently linked to the supplied computation, for diagnostics:
const nodes = inspectDeps(owner);
console.log(nodes.length);Do not mutate these private nodes.
TypeScript
Declarations ship with the package; no external @types dependency is needed.
import type { Owner } from '@solidjs/signals';
export interface OwnerCycle {
readonly __solidDepsOwnerCycle: unique symbol;
}
export type Scope = <T>(callback: () => T) => T;
export interface AttachReadResult<T> {
readonly value: T | undefined;
readonly added: number;
readonly duplicate: number;
readonly promoted: number;
readonly stale: boolean;
readonly disposed: boolean;
}
export declare function createScope(owner?: Owner | null): Scope;
export declare function createHybridScope(owner?: Owner | null): Scope;
export declare function createObserverScope(owner?: Owner | null): Scope;
export declare function createOwnerScope(owner?: Owner | null): Scope;
export declare function captureOwnerCycle(owner?: Owner | null): OwnerCycle;
export declare function attachRead<T>(
target: Owner | OwnerCycle | null,
callback: () => T
): AttachReadResult<T>;
export declare function inspectDeps(owner: Owner): unknown[];
export declare class StaleScopeError extends Error {}
export declare class DisposedScopeError extends Error {}With createScope(), a callback returning number gives a number directly. With attachRead(), result.value is number | undefined because a captured cycle can be stale or disposed.
Delegated scopes: solid-deps/delegated
Attach new reactive dependencies to an existing Solid 2 effect or memo, including after an asynchronous boundary, without attaching those dependencies directly to its observer graph.
The delegated scope tracks its readers externally. The original effect or memo subscribes to one pending-aware reactive bridge. That bridge notifies the parent when registered dependencies change and can hold its next application while an async dependency is not ready.
This is an experimental Solid 2 API. It uses Solid's reactive primitives rather than private graph-link manipulation.
Install and import
npm install solid-depsimport { createScope } from 'solid-deps/delegated';solid-deps/delegated exports two runtime values (createScope, DisposedScopeError) and the DelegatedScope TypeScript interface. It is separate from the graph-based solid-deps entry point.
Quick start: add a late dependency
import { createEffect, createSignal } from 'solid-js';
import { createScope } from 'solid-deps/delegated';
const [count, setCount] = createSignal(0);
const [late, setLate] = createSignal(10);
let scope!: ReturnType<typeof createScope>;
createEffect(
() => {
scope = createScope();
return count();
},
value => console.log('effect applied:', value)
);
// Later: an event handler, promise continuation, or other callback.
const current = scope(() => late()); // 10; also attaches late()
setLate(11); // Notifies the original effectscope(read) reads immediately, returns the accessor's value, and registers its reactive dependencies for future invalidation. It does not change the meaning of the original effect's already completed application.
By default, these late registrations are per-pass, just like native dependency reconciliation: when the parent recomputes, readers not registered again in that pass are removed. Use { retain: true } for one-time registrations that should continue to participate in subsequent passes.
One reader or several
const value = scope(() => signalA());
const sum = scope(() => signalA() + signalB());
const values = scope.batch([() => signalA(), () => signalB()]);
// values is a typed tuple [A, B]A single callback may read several dependencies. batch() is useful when readers are independent: if A is initially pending, B is still visited and registered. If a pending accessor throws NotReadyError, scope() or batch() can throw after capturing the reached subscriptions. Ordinary errors and returned Promises/thenables do not commit a failed registration.
Readers must be pure synchronous callbacks. Read existing signals or memos; do not create computations, register cleanups, perform writes, or return promises inside readers. Readers may be evaluated again as part of change, pending, and error propagation.
Subscription lifetime
Default: native-style per-pass pruning
const scope = createScope(); // same as { retain: false }A late registration stays active through the next applicable parent pass; it is removed if that pass does not re-register it. The cached scope function itself remains stable and reusable while its parent observer stays alive. Late work can register again on that latest live owner.
Opt-in: retain registrations
const scope = createScope({ retain: true });
// Later, normally only once:
scope(() => signalA());
scope(() => signalB());Registrations survive subsequent parent recomputations until removed or disposed. Retention preserves reader callbacks, not every signal those callbacks have ever touched. For a conditional reader, dependencies are reconciled inside the reader:
scope.subscribe(() => enabled() ? asyncMemoA() : undefined);When enabled() becomes false, the retained callback remains registered but no longer needs A. Switching back to true can track A again, including if A is pending.
Choose the same options on every createScope() call in a given observer. Changing them for a live observer throws.
Remove a specific registration or clear all
const removeA = scope.subscribe(() => signalA());
const removeB = scope.subscribe(() => signalB());
removeA(); // Only this registration is removed
removeA(); // Safe to call more than once
scope.clear(); // Remove all current registrations
// The scope stays reusable:
scope(() => signalC());subscribe(read) returns an unsubscribe function, not the current read value. It still evaluates and registers the reader. To obtain the value directly, use scope(read); that form returns the value, not a removal handle. There is currently no scope.remove(read) by callback identity.
Both subscribe() and clear() work with either retention policy. clear() does not dispose the scope or its parent observer.
Pending, refetches and errors
The parent effect/memo tracks the bridge, rather than the registered sources directly. The bridge observes the selected readers and conveys their changes, pending state and ordinary errors to Solid on the next parent computation.
- Initial pending: an async memo without a value can throw
NotReadyErrorand hold the parent's next application. - Multiple pending readers: A and B are both visited; one resolving does not make the parent ready while the other is pending.
- Refetch with an old value: the derived bridge carries Solid's pending verdict even when the async source can return a previous value.
- Ordinary errors: errors from registered readers can propagate through the bridge to the parent / Solid error handling. A registration that synchronously throws an ordinary error rolls back rather than leaving partial subscriptions.
- Once pending ends: a subsequent successful parent computation can apply normally.
Pending delegation is not retroactive: registering a pending dependency after an effect's apply callback has already executed cannot undo that callback. This is still experimental pending/transition integration; full scheduler-lane and Loading-boundary parity with ordinary in-effect reads is not guaranteed.
Advanced: defer a pending check until after conditional cleanup
Normally createScope() checks the bridge during the parent's computation. If the bridge is already pending, createScope() can throw before later lines in the same computation run.
When your computation must first clear a retained registration, opt into deferPending:
import { createEffect, createSignal } from 'solid-js';
import { createScope } from 'solid-deps/delegated';
const [enabled] = createSignal(true);
createEffect(
() => {
const active = enabled();
const scope = createScope({ retain: true, deferPending: true });
if (!active) scope.clear();
return scope.finish(active); // mandatory with deferPending: true
},
value => console.log('applied:', value)
);finish(value) reads the bridge and returns value unchanged if ready. It can throw pending/error status just as the normal check would. deferPending is advanced and opt-in: if enabled, call scope.finish(...) on every computation pass. Without it, pending propagation is not guaranteed. Neither finish() nor clear() is required in the ordinary usage shown above.
Owner lifetime and safety
- Call
createScope()inside a tracked computation of a Solid 2 effect or memo, not at module scope, outside tracking, or inside an effect's application callback. - The same owner gets the same cached scope on normal recomputations. A late callback can register through that cached scope while the owner is alive.
- If the parent stops requesting its scope, the delegated root is retired. Disposal also releases its subscriptions. A subsequent call against a disposed scope raises
DisposedScopeError. - A stale async continuation can register on the newest pass of the same live owner; it is not automatically cancelled. Use your own cancellation/token guards if older async work must not attach.
- A callback executes under the external delegation mechanism, not under the original observer's ownership. Treat it as a pure read, not a way to create owned children or cleanups.
- The scope uses a single bridge for the parent; its internal implementation may use other reactive observers to capture late subscriptions.
API reference
| API | Result | Role |
| --- | --- | --- |
| createScope(options?) | DelegatedScope | Get a stable scope for the currently tracked observer |
| scope(read) | T | Read and register one synchronous callback |
| scope.batch(reads) | Typed tuple | Read and register several callbacks together |
| scope.subscribe(read) | () => void | Add a removable registration |
| unsubscribe() | void | Remove only that registration; idempotent |
| scope.clear() | void | Remove all registrations without disposing the scope |
| scope.finish(value) | T | Perform the deferred bridge pending check; return the input |
| scope.stats() | DelegatedScopeStats-shaped object | Get diagnostic state/counters |
| new DisposedScopeError() | Error | Error class used for disposed/retired scopes |
createScope options
| Option | Type | Default | Purpose |
| --- | --- | --- | --- |
| retain | boolean | false | Preserve registrations across parent passes |
| deferPending | boolean | false | Move bridge check from createScope() to scope.finish(value) |
scope.stats() diagnostics
alive (boolean), retain (boolean), readers (number of current registered callbacks), arms (reaction re-arming count), probes (callback evaluations), notifications (source-event notification count), and activations (scope capture invocations). Counters are diagnostics, not a performance API or guaranteed number of effect applications.
Full TypeScript declarations
/** Callable delegated scope bound to one Solid 2 tracking observer. */
export interface DelegatedScope {
/** Register a pure synchronous reader; may throw NotReadyError after capture. */
<T>(read: () => T): T;
/** Register independent readers as a typed tuple. */
batch<const T extends readonly (() => unknown)[]>(
reads: T
): { [K in keyof T]: ReturnType<T[K]> };
/** Return an idempotent unsubscribe function for this registration. */
subscribe(read: () => unknown): () => void;
/** Remove all registered readers without disposing this scope. */
clear(): void;
/** Required each pass when using { deferPending: true }. */
finish<T>(value: T): T;
/** Diagnostic state and implementation-specific counters. */
stats(): {
readonly alive: boolean;
readonly retain: boolean;
readonly readers: number;
readonly arms: number;
readonly probes: number;
readonly notifications: number;
readonly activations: number;
};
}
export declare class DisposedScopeError extends Error {
constructor();
}
export declare function createScope(options?: {
retain?: boolean;
deferPending?: boolean;
}): DelegatedScope;The module has no default export. The declared interface can be imported via import type { DelegatedScope } from 'solid-deps/delegated'.
Compatibility and limitations
Target: Solid 2 with @solidjs/signals compatible with >=2.0.0-rc.17 <3.0.0. Tested against rc.17 and rc.18 in production, development and observe builds; selected scenarios have also run in Chromium. Browser bundler behavior, complete Loading/transition parity, and future Solid internals are not guaranteed. Because registration can occur after a computation has completed, delegation is not always observably identical to having read every source synchronously inside the original effect.
Graph-based behavior and limitations
- An attached read affects future invalidations. Writes that occurred before registration are not replayed.
- Extra dependencies can be removed on the target's next normal recomputation. Reattach them on each async effect execution when needed.
scope()/attachRead()callbacks must be synchronous. They may read existing pending memos:NotReadyErroris propagated after transferring previously read dependencies. Ordinary thrown errors leave the target unchanged. Creating owned computations, registering cleanups, or mutating signals from inside callbacks is not supported.- A callback that allocates owned children/cleanups, or reads a newly allocated probe-owned memo, is rejected. Access existing independently owned memos instead.
- Reads happen under a temporary observer;
getOwner(),getObserver(), pending resource behavior, transitions, Loading boundaries, and ownership-sensitive operations are not equivalent to running under the original effect. - When using
Loading, its content computation must also read the pending memo under native tracking. The scope only subscribes its target observer. In narrow rc.17 tests, explicitly mirroring the memo accessor inside Loading or separately subscribing the boundary's content observer after its tracked pass allows it to recover. The latter is advanced, experimental behavior and is not an automatic feature. Reading the accessor again is not the same as replaying the entire scoped callback. - A late graft of a pending memo does not put an already-running target effect into Solid's native pending state, and does not retroactively defer an effect phase that has already executed. If a normal target recomputation prunes that late subscription before settlement, the memo will no longer wake it. Only live subscriptions can receive the settlement notification.
- A pending memo can throw
NotReadyError, and the attached observer will keep the dependency for successful settlement. This does not recreate the original Loading boundary or guarantee retry after rejection. In particular, if aLoadingboundary only reads a pending memo throughattachRead()orscope(), it may show fallback indefinitely even after the memo resolves: the pending dependency belongs to the target effect, not necessarily to the boundary. Make sure the boundary itself performs its own normal tracked read of the pending memo, or manage boundary state separately. Advanced suspense/transition and held-transaction behavior is not broadly supported. - Cycle-bound
createScope()rejects an older execution after rerun;createOwnerScope()stays valid for the same live observer, but old callbacks can change the new execution's dependencies. Neither automatically cancels work. - Do not graft reads from inside effect-phase cleanup; use cleanup to cancel obsolete external work. Final disposal invalidates both scope types.
- Uses private observer graph internals discovered by inspecting disposable runtime probes. This supports field renaming when graph relationships remain compatible, but cannot guarantee future graph algorithms, scheduler semantics, or minifier property restructuring. Discovery fails with an error if required fields are ambiguous or missing.
- Calibrates only once per loaded package instance. In an ambiguous numeric-generation case, a field-position clue is used after checking link-generation equality; reordered internal fields may therefore cause calibration to fail or be misidentified. Test on new versions before shipping.
- Other Solid versions, SSR, and multiple separately instantiated signal runtimes are not guaranteed.
Online Solid playground check
A copy-pasteable main.tsx is available in the source ZIP at examples/online-playground/main.tsx. Install [email protected], ensure the playground runs a Solid 2 browser build with a shared signals runtime, and use Run runtime checks. It reports successful automatic layout discovery, async dependency attachment, deduplication, future updates, stale cycles, and disposal.
Opt-in native pending parity: createRetainedScope()
A late dependency attached using createScope() or attachRead() can be pruned when the original computation reruns without reading it. For pending async memos, this may allow the effect to apply before every pending dependency has resolved.
createRetainedScope() offers a different approach: declare pure accessors that Solid should read during every normal tracking pass. The original effect executes those reads itself, letting Solid manage NotReadyError, Loading, pending sources, async settlement and dependency reconciliation normally. No private graph-link surgery is used by this API.
import { createEffect, createMemo } from 'solid-js';
import { createRetainedScope } from 'solid-deps';
const profile = createMemo(async () => loadProfile());
let retained;
createEffect(
() => {
// IMPORTANT: execute on every effect computation, not just once.
retained = createRetainedScope();
return 1;
},
() => { console.log('Effect applied after dependencies become ready'); }
);
// In a later event handler or async continuation, outside a tracked scope:
const unsubscribe = retained.subscribe(() => profile());
// The callback is NOT executed here. Solid recomputes the original effect,
// reads profile() natively, and suspends/resumes normally if it is NotReady.
// Later, to remove this retained read:
unsubscribe();Usage requirements:
- Call
createRetainedScope()once on every execution of the original tracked compute function. It returns a stable registration handle for that same computation. - Call
retained.subscribe(accessor)outside an owned or tracking context, for example in an event handler or after anawait(not inside an effect cleanup). It schedules native recomputation and returns an idempotent unsubscribe function. - Accessors must be pure and synchronous: they may read signals, stores and memos, but must not write state, register cleanup, create effects, or start async work. Don't pass an
asyncfunction or a callback returning a Promise. - The accessor is invoked on future effect computations, potentially many times. It is not invoked during registration. Disposal removes native subscriptions, and registering after disposal throws.
- Unlike
createScope()andcreateOwnerScope(), registration does not return the memo's immediate value. It's a subscription operation that asks the original computation to read the value naturally.
This API is opt-in and experimental. It requires cooperation from the original effect's compute function. It cannot retroactively undo an effect phase that already applied before registration, but it can trigger a new normal computation whose pending state is handled by Solid itself. The existing scope APIs remain unchanged.
License
MIT
