solid-deps 0.0.5 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,16 +7,16 @@ Track additional dependencies for an existing **Solid 2 computation**, including
7
7
  ## Install
8
8
 
9
9
  ```sh
10
- npm install solid-deps solid-js@2.0.0-rc.17 @solidjs/signals@2.0.0-rc.17
10
+ npm install solid-deps solid-js@2.0.0-rc.18 @solidjs/signals@2.0.0-rc.18
11
11
  ```
12
12
 
13
13
  Or with Bun:
14
14
 
15
15
  ```sh
16
- bun add solid-deps solid-js@2.0.0-rc.17 @solidjs/signals@2.0.0-rc.17
16
+ bun add solid-deps solid-js@2.0.0-rc.18 @solidjs/signals@2.0.0-rc.18
17
17
  ```
18
18
 
19
- **Compatibility:** Tested with Solid 2.0.0-rc.17 production, development and observe builds. `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.
19
+ **Compatibility:** 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.
20
20
 
21
21
  ## Quick start: `createScope()`
22
22
 
@@ -102,7 +102,9 @@ createEffect(() => {
102
102
 
103
103
  **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.
104
104
 
105
- `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; complex Loading/transition semantics and error-state recovery are not guaranteed.
105
+ 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.
106
+
107
+ `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.
106
108
 
107
109
  ## Alternative: `createOwnerScope()`
108
110
 
@@ -226,6 +228,30 @@ console.log(result.added, result.duplicate, result.promoted);
226
228
 
227
229
  `attachRead()` returns status rather than throwing for stale/disposed targets. Reader exceptions are propagated; a Promise/thenable callback result is rejected.
228
230
 
231
+ ### `attachReads(target, callbacks)` — batched dependency attachment (experimental)
232
+
233
+ 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.
234
+
235
+ ```ts
236
+ import { attachReads } from 'solid-deps';
237
+
238
+ const result = attachReads(owner, [
239
+ () => firstName(),
240
+ () => lastName(),
241
+ () => profile().age
242
+ ]);
243
+ // result.values contains [firstName, lastName, age] in callback order.
244
+ // result.added / duplicate / promoted report reactive sources, not callback count.
245
+ ```
246
+
247
+ 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.
248
+
249
+ 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()`.
250
+
251
+ 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.
252
+
253
+ **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.
254
+
229
255
  ### `inspectDeps(owner)`
230
256
 
231
257
  Return the dependency source **node objects** currently linked to the supplied computation, for diagnostics:
@@ -273,14 +299,248 @@ export declare class DisposedScopeError extends Error {}
273
299
 
274
300
  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.
275
301
 
276
- ## Behavior and limitations
302
+ ## Delegated scopes: `solid-deps/delegated`
303
+
304
+ 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**.
305
+
306
+ 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.
307
+
308
+ This is an experimental Solid 2 API. It uses Solid's reactive primitives rather than private graph-link manipulation.
309
+
310
+ ### Install and import
311
+
312
+ ```bash
313
+ npm install solid-deps
314
+ ```
315
+
316
+ ```ts
317
+ import { createScope } from 'solid-deps/delegated';
318
+ ```
319
+
320
+ `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.
321
+
322
+ ### Quick start: add a late dependency
323
+
324
+ ```ts
325
+ import { createEffect, createSignal } from 'solid-js';
326
+ import { createScope } from 'solid-deps/delegated';
327
+
328
+ const [count, setCount] = createSignal(0);
329
+ const [late, setLate] = createSignal(10);
330
+
331
+ let scope!: ReturnType<typeof createScope>;
332
+
333
+ createEffect(
334
+ () => {
335
+ scope = createScope();
336
+ return count();
337
+ },
338
+ value => console.log('effect applied:', value)
339
+ );
340
+
341
+ // Later: an event handler, promise continuation, or other callback.
342
+ const current = scope(() => late()); // 10; also attaches late()
343
+ setLate(11); // Notifies the original effect
344
+ ```
345
+
346
+ `scope(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.
347
+
348
+ 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.
349
+
350
+ ### One reader or several
351
+
352
+ ```ts
353
+ const value = scope(() => signalA());
354
+ const sum = scope(() => signalA() + signalB());
355
+ const values = scope.batch([() => signalA(), () => signalB()]);
356
+ // values is a typed tuple [A, B]
357
+ ```
358
+
359
+ 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.
360
+
361
+ 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.
362
+
363
+ ### Subscription lifetime
364
+
365
+ #### Default: native-style per-pass pruning
366
+
367
+ ```ts
368
+ const scope = createScope(); // same as { retain: false }
369
+ ```
370
+
371
+ 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.
372
+
373
+ #### Opt-in: retain registrations
374
+
375
+ ```ts
376
+ const scope = createScope({ retain: true });
377
+
378
+ // Later, normally only once:
379
+ scope(() => signalA());
380
+ scope(() => signalB());
381
+ ```
382
+
383
+ 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:
384
+
385
+ ```ts
386
+ scope.subscribe(() => enabled() ? asyncMemoA() : undefined);
387
+ ```
388
+
389
+ 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.
390
+
391
+ Choose the same options on every `createScope()` call in a given observer. Changing them for a live observer throws.
392
+
393
+ ### Remove a specific registration or clear all
394
+
395
+ ```ts
396
+ const removeA = scope.subscribe(() => signalA());
397
+ const removeB = scope.subscribe(() => signalB());
398
+
399
+ removeA(); // Only this registration is removed
400
+ removeA(); // Safe to call more than once
401
+ scope.clear(); // Remove all current registrations
402
+
403
+ // The scope stays reusable:
404
+ scope(() => signalC());
405
+ ```
406
+
407
+ `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.
408
+
409
+ Both `subscribe()` and `clear()` work with either retention policy. `clear()` does not dispose the scope or its parent observer.
410
+
411
+ ### Pending, refetches and errors
412
+
413
+ 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**.
414
+
415
+ - **Initial pending:** an async memo without a value can throw `NotReadyError` and hold the parent's next application.
416
+ - **Multiple pending readers:** A and B are both visited; one resolving does not make the parent ready while the other is pending.
417
+ - **Refetch with an old value:** the derived bridge carries Solid's pending verdict even when the async source can return a previous value.
418
+ - **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.
419
+ - **Once pending ends:** a subsequent successful parent computation can apply normally.
420
+
421
+ 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.
422
+
423
+ ### Advanced: defer a pending check until after conditional cleanup
424
+
425
+ 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.
426
+
427
+ When your computation must first clear a retained registration, opt into `deferPending`:
428
+
429
+ ```ts
430
+ import { createEffect, createSignal } from 'solid-js';
431
+ import { createScope } from 'solid-deps/delegated';
432
+
433
+ const [enabled] = createSignal(true);
434
+
435
+ createEffect(
436
+ () => {
437
+ const active = enabled();
438
+ const scope = createScope({ retain: true, deferPending: true });
439
+
440
+ if (!active) scope.clear();
441
+
442
+ return scope.finish(active); // mandatory with deferPending: true
443
+ },
444
+ value => console.log('applied:', value)
445
+ );
446
+ ```
447
+
448
+ `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.
449
+
450
+ ### Owner lifetime and safety
451
+
452
+ - 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.
453
+ - 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.
454
+ - 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`.
455
+ - 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.
456
+ - 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.
457
+ - The scope uses a single bridge for the parent; its internal implementation may use other reactive observers to capture late subscriptions.
458
+
459
+ ### API reference
460
+
461
+ | API | Result | Role |
462
+ | --- | --- | --- |
463
+ | `createScope(options?)` | `DelegatedScope` | Get a stable scope for the currently tracked observer |
464
+ | `scope(read)` | `T` | Read and register one synchronous callback |
465
+ | `scope.batch(reads)` | Typed tuple | Read and register several callbacks together |
466
+ | `scope.subscribe(read)` | `() => void` | Add a removable registration |
467
+ | `unsubscribe()` | `void` | Remove only that registration; idempotent |
468
+ | `scope.clear()` | `void` | Remove all registrations without disposing the scope |
469
+ | `scope.finish(value)` | `T` | Perform the deferred bridge pending check; return the input |
470
+ | `scope.stats()` | `DelegatedScopeStats`-shaped object | Get diagnostic state/counters |
471
+ | `new DisposedScopeError()` | `Error` | Error class used for disposed/retired scopes |
472
+
473
+ #### `createScope` options
474
+
475
+ | Option | Type | Default | Purpose |
476
+ | --- | --- | --- | --- |
477
+ | `retain` | `boolean` | `false` | Preserve registrations across parent passes |
478
+ | `deferPending` | `boolean` | `false` | Move bridge check from `createScope()` to `scope.finish(value)` |
479
+
480
+ #### `scope.stats()` diagnostics
481
+
482
+ `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**.
483
+
484
+ ### Full TypeScript declarations
485
+
486
+ ```ts
487
+ /** Callable delegated scope bound to one Solid 2 tracking observer. */
488
+ export interface DelegatedScope {
489
+ /** Register a pure synchronous reader; may throw NotReadyError after capture. */
490
+ <T>(read: () => T): T;
491
+
492
+ /** Register independent readers as a typed tuple. */
493
+ batch<const T extends readonly (() => unknown)[]>(
494
+ reads: T
495
+ ): { [K in keyof T]: ReturnType<T[K]> };
496
+
497
+ /** Return an idempotent unsubscribe function for this registration. */
498
+ subscribe(read: () => unknown): () => void;
499
+
500
+ /** Remove all registered readers without disposing this scope. */
501
+ clear(): void;
502
+
503
+ /** Required each pass when using { deferPending: true }. */
504
+ finish<T>(value: T): T;
505
+
506
+ /** Diagnostic state and implementation-specific counters. */
507
+ stats(): {
508
+ readonly alive: boolean;
509
+ readonly retain: boolean;
510
+ readonly readers: number;
511
+ readonly arms: number;
512
+ readonly probes: number;
513
+ readonly notifications: number;
514
+ readonly activations: number;
515
+ };
516
+ }
517
+
518
+ export declare class DisposedScopeError extends Error {
519
+ constructor();
520
+ }
521
+
522
+ export declare function createScope(options?: {
523
+ retain?: boolean;
524
+ deferPending?: boolean;
525
+ }): DelegatedScope;
526
+ ```
527
+
528
+ The module has no default export. The declared interface can be imported via `import type { DelegatedScope } from 'solid-deps/delegated'`.
529
+
530
+ ### Compatibility and limitations
531
+
532
+ 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.
533
+
534
+ ## Graph-based behavior and limitations
277
535
 
278
536
  - An attached read affects **future** invalidations. Writes that occurred before registration are not replayed.
279
537
  - Extra dependencies can be removed on the target's next normal recomputation. Reattach them on each async effect execution when needed.
280
538
  - `scope()` / `attachRead()` callbacks must be synchronous. They may read existing pending memos: `NotReadyError` is 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.
281
539
  - A callback that allocates owned children/cleanups, or reads a newly allocated probe-owned memo, is rejected. Access existing independently owned memos instead.
282
540
  - 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.
283
- - 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. Advanced suspense/transition and held-transaction behavior is not broadly supported.
541
+ - 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.
542
+ - 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.
543
+ - 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 a `Loading` boundary only reads a pending memo through `attachRead()` or `scope()`, 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.
284
544
  - 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.
285
545
  - Do not graft reads from inside effect-phase cleanup; use cleanup to cancel obsolete external work. Final disposal invalidates both scope types.
286
546
  - 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.
@@ -291,6 +551,47 @@ With `createScope()`, a callback returning `number` gives a `number` directly. W
291
551
 
292
552
  A copy-pasteable `main.tsx` is available in the source ZIP at `examples/online-playground/main.tsx`. Install `solid-deps@0.0.5`, 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.
293
553
 
554
+ ## Opt-in native pending parity: `createRetainedScope()`
555
+
556
+ 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.
557
+
558
+ `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.
559
+
560
+ ```ts
561
+ import { createEffect, createMemo } from 'solid-js';
562
+ import { createRetainedScope } from 'solid-deps';
563
+
564
+ const profile = createMemo(async () => loadProfile());
565
+ let retained;
566
+
567
+ createEffect(
568
+ () => {
569
+ // IMPORTANT: execute on every effect computation, not just once.
570
+ retained = createRetainedScope();
571
+ return 1;
572
+ },
573
+ () => { console.log('Effect applied after dependencies become ready'); }
574
+ );
575
+
576
+ // In a later event handler or async continuation, outside a tracked scope:
577
+ const unsubscribe = retained.subscribe(() => profile());
578
+ // The callback is NOT executed here. Solid recomputes the original effect,
579
+ // reads profile() natively, and suspends/resumes normally if it is NotReady.
580
+
581
+ // Later, to remove this retained read:
582
+ unsubscribe();
583
+ ```
584
+
585
+ **Usage requirements:**
586
+
587
+ - Call `createRetainedScope()` once on **every** execution of the original tracked compute function. It returns a stable registration handle for that same computation.
588
+ - Call `retained.subscribe(accessor)` outside an owned or tracking context, for example in an event handler or after an `await` (not inside an effect cleanup). It schedules native recomputation and returns an idempotent unsubscribe function.
589
+ - 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 `async` function or a callback returning a Promise.
590
+ - 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.
591
+ - Unlike `createScope()` and `createOwnerScope()`, registration does **not** return the memo's immediate value. It's a subscription operation that asks the original computation to read the value naturally.
592
+
593
+ 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.
594
+
294
595
  ## License
295
596
 
296
597
  MIT
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "solid-deps",
3
- "version": "0.0.5",
4
- "description": "Experimental direct dependency attachment for Solid 2 reactive observers after async boundaries",
3
+ "version": "0.0.6",
4
+ "description": "Experimental late dependency tracking for Solid 2 via graph attachment or delegated scopes",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "engines": {
@@ -11,16 +11,23 @@
11
11
  ".": {
12
12
  "types": "./types/index.d.mts",
13
13
  "default": "./src/index.mjs"
14
+ },
15
+ "./delegated": {
16
+ "types": "./types/delegated.d.mts",
17
+ "default": "./src/delegated-scope.mjs"
14
18
  }
15
19
  },
16
20
  "types": "./types/index.d.mts",
17
21
  "files": [
18
22
  "src/attach-read.mjs",
19
23
  "src/discover.mjs",
24
+ "src/retained-scope.mjs",
20
25
  "src/index.mjs",
21
26
  "types/index.d.mts",
22
27
  "README.md",
23
- "LICENSE"
28
+ "LICENSE",
29
+ "src/delegated-scope.mjs",
30
+ "types/delegated.d.mts"
24
31
  ],
25
32
  "sideEffects": false,
26
33
  "peerDependencies": {
@@ -37,13 +44,20 @@
37
44
  ],
38
45
  "scripts": {
39
46
  "setup": "node scripts/setup.mjs",
40
- "test": "node --conditions=browser --test test/test.mjs test/test-async.mjs test/test-return.mjs test/test-scope.mjs test/test-manifest.mjs test/test-graph-hardening.mjs test/test-async-transitions.mjs test/test-transaction-integrity.mjs test/test-scheduler-lifecycle.mjs test/test-release-hardening.mjs test/test-discovery.mjs test/test-fastpath.mjs test/test-owner-bound-research.mjs test/test-owner-scope.mjs test/test-notready-lifecycle.mjs",
41
- "test:dev": "node --conditions=browser --conditions=development --test test/test.mjs test/test-async.mjs test/test-return.mjs test/test-scope.mjs test/test-manifest.mjs test/test-graph-hardening.mjs test/test-async-transitions.mjs test/test-transaction-integrity.mjs test/test-scheduler-lifecycle.mjs test/test-release-hardening.mjs test/test-discovery.mjs test/test-fastpath.mjs test/test-owner-bound-research.mjs test/test-owner-scope.mjs test/test-notready-lifecycle.mjs",
42
- "test:observe": "node --conditions=browser --conditions=observe --test test/test.mjs test/test-async.mjs test/test-return.mjs test/test-scope.mjs test/test-manifest.mjs test/test-graph-hardening.mjs test/test-async-transitions.mjs test/test-transaction-integrity.mjs test/test-scheduler-lifecycle.mjs test/test-release-hardening.mjs test/test-discovery.mjs test/test-fastpath.mjs test/test-owner-bound-research.mjs test/test-owner-scope.mjs test/test-notready-lifecycle.mjs",
47
+ "test": "node --conditions=browser --test test/test.mjs test/test-async.mjs test/test-return.mjs test/test-scope.mjs test/test-manifest.mjs test/test-graph-hardening.mjs test/test-async-transitions.mjs test/test-transaction-integrity.mjs test/test-scheduler-lifecycle.mjs test/test-release-hardening.mjs test/test-discovery.mjs test/test-fastpath.mjs test/test-owner-bound-research.mjs test/test-owner-scope.mjs test/test-notready-lifecycle.mjs test/test-loading-suspension.mjs test/test-loading-recovery-research.mjs test/test-native-notready-parity.mjs test/test-pending-status-propagation.mjs test/test-retained-scope.mjs test/test-research-retained.mjs test/test-parallel-parity.mjs test/test-batched-reads.mjs test/test-direct-links-research.mjs test/test-delegated-scope-research.mjs test/test-owner-cached-delegated.mjs test/test-sharded-delegated.mjs test/test-memory-conformance.mjs test/test-optimized-delegated.mjs test/test-unified-scope-contract.mjs test/test-unified-advanced-contract.mjs test/test-delegated-advanced-contract.mjs test/test-delegated-retention-contract.mjs test/test-delegated-default-dev19.mjs test/test-delegated-pending-bridge-dev25.mjs",
48
+ "test:dev": "node --conditions=browser --conditions=development --test test/test.mjs test/test-async.mjs test/test-return.mjs test/test-scope.mjs test/test-manifest.mjs test/test-graph-hardening.mjs test/test-async-transitions.mjs test/test-transaction-integrity.mjs test/test-scheduler-lifecycle.mjs test/test-release-hardening.mjs test/test-discovery.mjs test/test-fastpath.mjs test/test-owner-bound-research.mjs test/test-owner-scope.mjs test/test-notready-lifecycle.mjs test/test-loading-suspension.mjs test/test-loading-recovery-research.mjs test/test-native-notready-parity.mjs test/test-pending-status-propagation.mjs test/test-retained-scope.mjs test/test-research-retained.mjs test/test-parallel-parity.mjs test/test-batched-reads.mjs test/test-direct-links-research.mjs test/test-delegated-scope-research.mjs test/test-owner-cached-delegated.mjs test/test-sharded-delegated.mjs test/test-memory-conformance.mjs test/test-optimized-delegated.mjs test/test-unified-scope-contract.mjs test/test-unified-advanced-contract.mjs test/test-delegated-advanced-contract.mjs test/test-delegated-retention-contract.mjs test/test-delegated-default-dev19.mjs test/test-delegated-pending-bridge-dev25.mjs",
49
+ "test:observe": "node --conditions=browser --conditions=observe --test test/test.mjs test/test-async.mjs test/test-return.mjs test/test-scope.mjs test/test-manifest.mjs test/test-graph-hardening.mjs test/test-async-transitions.mjs test/test-transaction-integrity.mjs test/test-scheduler-lifecycle.mjs test/test-release-hardening.mjs test/test-discovery.mjs test/test-fastpath.mjs test/test-owner-bound-research.mjs test/test-owner-scope.mjs test/test-notready-lifecycle.mjs test/test-loading-suspension.mjs test/test-loading-recovery-research.mjs test/test-native-notready-parity.mjs test/test-pending-status-propagation.mjs test/test-retained-scope.mjs test/test-research-retained.mjs test/test-parallel-parity.mjs test/test-batched-reads.mjs test/test-direct-links-research.mjs test/test-delegated-scope-research.mjs test/test-owner-cached-delegated.mjs test/test-sharded-delegated.mjs test/test-memory-conformance.mjs test/test-optimized-delegated.mjs test/test-unified-scope-contract.mjs test/test-unified-advanced-contract.mjs test/test-delegated-advanced-contract.mjs test/test-delegated-retention-contract.mjs test/test-delegated-default-dev19.mjs test/test-delegated-pending-bridge-dev25.mjs",
43
50
  "test:all": "node scripts/run-tests.mjs",
44
51
  "test:package": "node scripts/smoke-package.mjs",
45
52
  "test:browser": "node --test test/browser/playwright.test.mjs",
46
- "test:browser:preflight": "node scripts/browser-preflight.mjs"
53
+ "test:browser:preflight": "node scripts/browser-preflight.mjs",
54
+ "bench:memory": "node --conditions=browser research/benchmark-conformance-memory.mjs",
55
+ "bench:memory:dev": "node --conditions=browser --conditions=development research/benchmark-conformance-memory.mjs",
56
+ "bench:memory:observe": "node --conditions=browser --conditions=observe research/benchmark-conformance-memory.mjs",
57
+ "test:unified-contract": "node --conditions=browser --test test/test-unified-scope-contract.mjs test/test-unified-advanced-contract.mjs test/test-delegated-retention-contract.mjs test/test-delegated-default-dev19.mjs && node --conditions=browser --conditions=development --test test/test-unified-scope-contract.mjs test/test-unified-advanced-contract.mjs test/test-delegated-retention-contract.mjs test/test-delegated-default-dev19.mjs && node --conditions=browser --conditions=observe --test test/test-unified-scope-contract.mjs test/test-unified-advanced-contract.mjs test/test-delegated-retention-contract.mjs test/test-delegated-default-dev19.mjs",
58
+ "bench:realistic": "node --conditions=browser --expose-gc research/benchmark-realistic-scopes-dev17.mjs",
59
+ "test:matrix": "node scripts/test-matrix.mjs",
60
+ "setup:browser": "node scripts/setup-browser.mjs"
47
61
  },
48
62
  "devDependencies": {
49
63
  "@solidjs/signals": "2.0.0-rc.17",
@@ -174,6 +174,132 @@ export function attachRead(observerOrCycle, read) {
174
174
  disposeRoot?.();
175
175
  }
176
176
  }
177
+ /**
178
+ * One temporary observer + one target graph reconciliation for several reads.
179
+ * Each accessor is tried independently: NotReadyError preserves what it tracked
180
+ * and does not stop later readers. The first NotReadyError is rethrown AFTER
181
+ * all valid dependency links have been transferred. Any ordinary exception or
182
+ * thenable return aborts the whole batch before touching the target graph.
183
+ *
184
+ * This is NOT execution under the target's native tracking context; pending
185
+ * status and Loading semantics are unchanged from attachRead().
186
+ */
187
+ export function attachReads(observerOrCycle, readers) {
188
+ if (!Array.isArray(readers)) throw new TypeError('reads must be an array of synchronous functions');
189
+ const reads = readers.slice();
190
+ if (reads.some(read => typeof read !== 'function'))
191
+ throw new TypeError('each read must be a function');
192
+ const pass = observerOrCycle && ownerCycles.get(observerOrCycle);
193
+ const target = pass ? pass.observer : observerOrCycle;
194
+ const s = layoutOf(target);
195
+ const skipped = (disposed, stale) => ({
196
+ values: undefined, added: 0, duplicate: 0, promoted: 0, disposed, stale
197
+ });
198
+ if (isDisposed(target)) return skipped(true, false);
199
+ if (pass && target[s.gen] !== pass.generation) return skipped(false, true);
200
+ if (target[s.flags] & s.recomputingBit) throw new Error('Target observer is currently recomputing');
201
+ if (!reads.length) return {
202
+ values: [], added: 0, duplicate: 0, promoted: 0, disposed: false, stale: false
203
+ };
204
+
205
+ const existing = links(target, s);
206
+ const beforeTail = target[s.tail];
207
+ const beforeGen = target[s.gen];
208
+ const prefixEnd = beforeTail == null ? -1 : existing.indexOf(beforeTail);
209
+ if (beforeTail !== null && prefixEnd < 0)
210
+ throw new Error('Target tail is not reachable from its dependency head');
211
+ const valid = existing.slice(0, prefixEnd + 1);
212
+ const stale = existing.slice(prefixEnd + 1);
213
+ const values = new Array(reads.length);
214
+ let disposeRoot;
215
+ let probe;
216
+ let pendingError;
217
+ let fatalError;
218
+ let failed = false;
219
+ try {
220
+ createRoot(dispose => {
221
+ disposeRoot = dispose;
222
+ const track = createReaction(() => {});
223
+ track(() => {
224
+ probe = getObserver();
225
+ for (let i = 0; i < reads.length; i++) {
226
+ try {
227
+ const value = reads[i]();
228
+ if (value != null && typeof value.then === 'function')
229
+ throw new TypeError('attachReads requires synchronous reads, not an async callback');
230
+ values[i] = value;
231
+ } catch (error) {
232
+ if (error instanceof NotReadyError) pendingError ??= error;
233
+ else { fatalError = error; failed = true; break; }
234
+ }
235
+ }
236
+ });
237
+ });
238
+ if (failed) throw fatalError;
239
+ if (!probe) throw new Error('No temporary observer was captured');
240
+ const p = layoutOf(probe);
241
+ if (p !== s) throw new Error('Temporary observer and target use different Solid runtimes');
242
+ if (isDisposed(target)) return skipped(true, false);
243
+ if ((target[s.flags] & s.recomputingBit) || target[s.tail] !== beforeTail || target[s.gen] !== beforeGen ||
244
+ (target[s.deps] ?? null) !== (existing[0] ?? null))
245
+ throw new Error('Target changed during capture; refusing unsafe graft');
246
+ const after = links(target, s);
247
+ if (after.length !== existing.length || after.some((edge, i) => edge !== existing[i]))
248
+ throw new Error('Target dependency chain changed during capture');
249
+
250
+ const captured = links(probe, s);
251
+ if (probe[s.child] != null || probe[s.disposal] != null)
252
+ throw new Error('attachReads read() cannot create owned children or register cleanups');
253
+ for (const edge of captured) {
254
+ const source = edge[s.dep];
255
+ if (isDisposed(source) || ownedBy(source, probe))
256
+ throw new Error('Cannot attach a disposed or temporary-observer-owned source');
257
+ }
258
+ const seen = new Set(valid.map(edge => edge[s.dep]));
259
+ const staleBySource = new Map(stale.map(edge => [edge[s.dep], edge]));
260
+ const moved = [];
261
+ const promoted = [];
262
+ const kept = [];
263
+ for (const edge of captured) {
264
+ const source = edge[s.dep];
265
+ if (seen.has(source)) {
266
+ kept.push(edge);
267
+ } else {
268
+ seen.add(source);
269
+ const old = staleBySource.get(source);
270
+ if (old) {
271
+ promoted.push(old);
272
+ kept.push(edge);
273
+ } else moved.push(edge);
274
+ }
275
+ }
276
+ probe[s.deps] = kept[0] ?? null;
277
+ probe[s.tail] = kept.at(-1) ?? null;
278
+ for (let i = 0; i < kept.length; i++) kept[i][s.next] = kept[i + 1] ?? null;
279
+
280
+ const promotedSet = new Set(promoted);
281
+ const staleRemainder = stale.filter(edge => !promotedSet.has(edge));
282
+ const additions = [...promoted, ...moved];
283
+ const validated = [...valid, ...additions];
284
+ const nextList = [...validated, ...staleRemainder];
285
+ for (const edge of additions) {
286
+ edge[s.sub] = target;
287
+ edge[s.linkGen] = beforeGen;
288
+ }
289
+ for (let i = 0; i < nextList.length; i++)
290
+ nextList[i][s.next] = nextList[i + 1] ?? null;
291
+ target[s.deps] = nextList[0] ?? null;
292
+ target[s.tail] = validated.at(-1) ?? null;
293
+ if (pendingError) throw pendingError;
294
+ return {
295
+ values, added: additions.length, duplicate: kept.length - promoted.length,
296
+ promoted: promoted.length, disposed: false, stale: false
297
+ };
298
+ } finally {
299
+ disposeRoot?.();
300
+ }
301
+ }
302
+
177
303
  export function inspectDeps(owner) {
178
304
  const s = layoutOf(owner);
179
305
  return links(owner, s).map(link => link[s.dep]);
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Experimental public-Solid-API delegated scope: one derived bridge per owner.
3
+ * A reaction captures external subscriptions synchronously; the parent observes
4
+ * only one memo carrying changes, pending state and ordinary errors. Registration
5
+ * is not an event and cannot itself trigger an extra parent application.
6
+ * No internal Solid graph links are inspected or mutated.
7
+ */
8
+ import {
9
+ createRoot, createReaction, createMemo, createSignal, getObserver, isDisposed,
10
+ onCleanup, runWithOwner, untrack, NotReadyError
11
+ } from '@solidjs/signals';
12
+
13
+ const scopes = new WeakMap();
14
+ export class DisposedScopeError extends Error {
15
+ constructor() { super('The scope owner has been disposed'); this.name='DisposedScopeError'; }
16
+ }
17
+ const isThenable=x=>x!=null&&typeof x.then==='function';
18
+ function check(value){if(isThenable(value))throw new TypeError('Scope callbacks must be synchronous');return value;}
19
+ function guard(state){if(!state.alive||isDisposed(state.observer))throw new DisposedScopeError();}
20
+
21
+ function allocate(observer,retain,deferPending){
22
+ const state={observer,retain,deferPending,alive:true,activations:0,readers:[],arms:0,probes:0,notifications:0,
23
+ dispose:null,scope:null,bridge:null,markRegistration:null,arm:null,rearm:null,event:null};
24
+ runWithOwner(null,()=>createRoot(dispose=>{
25
+ state.dispose=dispose;
26
+ const [registration,bumpRegistration]=createSignal(0,{ownedWrite:true});
27
+ const [events,bumpEvents]=createSignal(0,{ownedWrite:true});
28
+ state.markRegistration=()=>bumpRegistration(n=>n+1);
29
+ state.event=()=>{state.notifications++;bumpEvents(n=>n+1);};
30
+ state.arm=createReaction(()=>{
31
+ if(!state.alive||isDisposed(observer))return;
32
+ state.rearm();
33
+ state.event();
34
+ });
35
+ state.rearm=()=>{
36
+ if(!state.alive)return;
37
+ state.arms++;
38
+ const copy=state.readers.slice();
39
+ state.arm(()=>{
40
+ for(const item of copy){
41
+ if(!item.active)continue;
42
+ state.probes++;
43
+ try{check(item.read());}catch(error){
44
+ // Continue capturing the remaining readers even if one suspends or
45
+ // fails. The coherent memo carries pending/errors to the parent.
46
+ item.error=error;
47
+ }
48
+ }
49
+ });
50
+ };
51
+ state.bridge=createMemo(()=>{
52
+ registration();
53
+ const version=events();
54
+ let pending;
55
+ for(const item of state.readers){
56
+ if(!item.active)continue;
57
+ state.probes++;
58
+ try{check(untrack(item.read));}
59
+ catch(error){if(error instanceof NotReadyError)pending??=error;else throw error;}
60
+ }
61
+ if(pending)throw pending;
62
+ return version;
63
+ });
64
+ }));
65
+ state.scope=makeScope(state);scopes.set(observer,state);
66
+ return state;
67
+ }
68
+ function destroy(state){
69
+ if(!state.alive)return;
70
+ state.alive=false;state.readers.length=0;scopes.delete(state.observer);state.dispose();
71
+ }
72
+ function retire(state,activation){
73
+ if(!state.alive||state.retain)return;
74
+ const before=state.readers.length;
75
+ state.readers=state.readers.filter(item=>{
76
+ if(item.activation>activation)return true;
77
+ item.active=false;return false;
78
+ });
79
+ if(state.readers.length!==before){state.rearm();state.markRegistration();}
80
+ }
81
+ function publishRegistration(state){state.rearm();state.markRegistration();}
82
+ function makeScope(state){
83
+ function insert(readers,throwPending,asSubscription=false){
84
+ guard(state);
85
+ if(readers.some(read=>typeof read!=='function'))throw new TypeError('Scope read must be a function');
86
+ // Stage and validate before subscribing, and never leak a partially registered batch.
87
+ const values=[];let pending;
88
+ for(const read of readers){
89
+ try{values.push(check(untrack(read)));}
90
+ catch(error){if(!(error instanceof NotReadyError))throw error;pending??=error;values.push(undefined);}
91
+ }
92
+ const items=readers.map(read=>({read,active:true,activation:state.activations}));
93
+ state.readers.push(...items);
94
+ publishRegistration(state);
95
+ if(throwPending&&pending)throw pending;
96
+ return asSubscription?items:values;
97
+ }
98
+ function scope(read){return insert([read],true)[0];}
99
+ scope.batch=(readers)=>{
100
+ guard(state);
101
+ if(!Array.isArray(readers))throw new TypeError('Scope batch requires an array of functions');
102
+ if(!readers.length)return [];
103
+ return insert(readers,true);
104
+ };
105
+ scope.subscribe=read=>{
106
+ const [item]=insert([read],false,true);
107
+ let stopped=false;
108
+ return ()=>{
109
+ if(stopped)return;stopped=true;
110
+ if(!state.alive)return;
111
+ item.active=false;
112
+ const idx=state.readers.indexOf(item);
113
+ if(idx>=0)state.readers.splice(idx,1);
114
+ publishRegistration(state);
115
+ };
116
+ };
117
+ // Optional end-of-computation check for branches that must clear a pending
118
+ // retained scope before reading its verdict.
119
+ scope.finish=value=>{guard(state);state.bridge();return value;};
120
+ scope.clear=()=>{
121
+ guard(state);
122
+ if(state.readers.length){
123
+ for(const item of state.readers)item.active=false;
124
+ state.readers.length=0;publishRegistration(state);
125
+ }
126
+ };
127
+ scope.stats=()=>({alive:state.alive,retain:state.retain,readers:state.readers.length,
128
+ arms:state.arms,probes:state.probes,notifications:state.notifications,activations:state.activations});
129
+ return scope;
130
+ }
131
+ export function createScope(options){
132
+ const retain=options?.retain??false;
133
+ const deferPending=options?.deferPending??false;
134
+ if(options!=null&&(typeof options!=='object'||Array.isArray(options)||typeof retain!=='boolean'||typeof deferPending!=='boolean'||Object.keys(options).some(k=>k!=='retain'&&k!=='deferPending')))
135
+ throw new TypeError('createScope options.retain and options.deferPending must be booleans');
136
+ const observer=getObserver();if(!observer)throw new Error('createScope() requires a tracking effect or memo');
137
+ let state=scopes.get(observer);
138
+ if(!state||!state.alive)state=allocate(observer,retain,deferPending);
139
+ else if(state.retain!==retain||state.deferPending!==deferPending)throw new Error('createScope retain/deferPending option cannot change for an existing observer');
140
+ const activation=++state.activations;
141
+ onCleanup(()=>{
142
+ if(isDisposed(observer)){destroy(state);return;}
143
+ retire(state,activation);
144
+ queueMicrotask(()=>{
145
+ if(state.alive&&!isDisposed(observer)&&state.activations===activation)destroy(state);
146
+ });
147
+ });
148
+ state.rearm();
149
+ state.markRegistration();
150
+ if(!state.deferPending)state.bridge();
151
+ return state.scope;
152
+ }
package/src/index.mjs CHANGED
@@ -1 +1,2 @@
1
- export { attachRead, captureOwnerCycle, createScope, createOwnerScope, inspectDeps, StaleScopeError, DisposedScopeError } from './attach-read.mjs';
1
+ export { createRetainedScope } from './retained-scope.mjs';
2
+ export { attachRead, attachReads, captureOwnerCycle, createScope, createOwnerScope, inspectDeps, StaleScopeError, DisposedScopeError } from './attach-read.mjs';
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Opt-in native replay of pure reactive accessors.
3
+ *
4
+ * Unlike attachRead(), this does not transfer graph links. It asks the
5
+ * original tracking function to read declared accessors on its next pass,
6
+ * letting Solid perform native NotReady, Loading, status and lane accounting.
7
+ *
8
+ * IMPORTANT: Call createRetainedScope() during EVERY run of the original
9
+ * tracked computation; accessor functions passed to subscribe() must be
10
+ * synchronous and free of writes, cleanup registration or other side effects.
11
+ */
12
+ import { createSignal, getObserver, getOwner, isDisposed } from '@solidjs/signals';
13
+
14
+ import { DisposedScopeError } from './attach-read.mjs';
15
+
16
+ const scopes = new WeakMap();
17
+
18
+ export function createRetainedScope() {
19
+ const owner = getObserver();
20
+ if (!owner) throw new Error('createRetainedScope() requires an active tracking computation');
21
+ let state = scopes.get(owner);
22
+ if (!state) {
23
+ const [revision, invalidate] = createSignal(0);
24
+ state = { reads: [], revision, invalidate };
25
+ const subscribe = read => {
26
+ if (typeof read !== 'function') throw new TypeError('subscribe() expects a synchronous accessor callback');
27
+ if (isDisposed(owner)) {
28
+ state.reads.length = 0;
29
+ throw new DisposedScopeError();
30
+ }
31
+ // An active foreign observer (including a nested effect) is not a safe
32
+ // place to write Solid signals, and the callback should not be replayed
33
+ // as part of another computation's setup.
34
+ if (getOwner()) throw new Error('subscribe() must be called outside an owned or tracking scope');
35
+ const item = { read };
36
+ state.reads.push(item);
37
+ // Trigger a normal compute pass. The callback does NOT execute here.
38
+ state.invalidate(n => n + 1);
39
+ let stopped = false;
40
+ return () => {
41
+ if (stopped) return;
42
+ const alive = !isDisposed(owner);
43
+ if (alive && getOwner()) throw new Error('Unsubscribe outside an owned or tracking scope');
44
+ stopped = true;
45
+ const index = state.reads.indexOf(item);
46
+ if (index !== -1) state.reads.splice(index, 1);
47
+ if (alive) state.invalidate(n => n + 1);
48
+ };
49
+ };
50
+ state.handle = Object.freeze({ subscribe });
51
+ scopes.set(owner, state);
52
+ }
53
+ // Read the revision in the effect's OWN active tracking pass.
54
+ state.revision();
55
+ // Snapshot avoids reentrant modifications changing iteration order.
56
+ // An exception (including NotReady) propagates to Solid's normal effect
57
+ // compute handler, preserving native suspension and error bookkeeping.
58
+ for (const item of [...state.reads]) {
59
+ const result = item.read();
60
+ if (result != null && typeof result.then === 'function')
61
+ throw new TypeError('Retained read callbacks must be synchronous (not return Promise/thenable)');
62
+ }
63
+ return state.handle;
64
+ }
@@ -0,0 +1,63 @@
1
+ /** Callable delegated scope bound to one Solid 2 tracking observer. */
2
+ export interface DelegatedScope {
3
+ /**
4
+ * Register a pure synchronous reader and return its current result.
5
+ * A pending async source may throw NotReadyError, after registration.
6
+ */
7
+ <T>(read: () => T): T;
8
+
9
+ /**
10
+ * Register independent readers together and return their values as a tuple.
11
+ * Visits all readers even when one throws NotReadyError; an ordinary error
12
+ * aborts registration without leaving a partially added batch.
13
+ */
14
+ batch<const T extends readonly (() => unknown)[]>(
15
+ reads: T
16
+ ): { [K in keyof T]: ReturnType<T[K]> };
17
+
18
+ /**
19
+ * Register a reader and return an idempotent unsubscribe function.
20
+ * Use when an individual registration must be removable.
21
+ */
22
+ subscribe(read: () => unknown): () => void;
23
+
24
+ /** Remove all current registrations; the scope itself stays reusable. */
25
+ clear(): void;
26
+
27
+ /**
28
+ * Read the pending-aware bridge and return value unchanged.
29
+ * Must be called during each tracked pass when deferPending is true.
30
+ */
31
+ finish<T>(value: T): T;
32
+
33
+ /** Diagnostics; counters are implementation-specific, not performance guarantees. */
34
+ stats(): {
35
+ readonly alive: boolean;
36
+ readonly retain: boolean;
37
+ readonly readers: number;
38
+ readonly arms: number;
39
+ readonly probes: number;
40
+ readonly notifications: number;
41
+ readonly activations: number;
42
+ };
43
+ }
44
+
45
+ /** Raised when a scope is used after its observer/scope has been disposed. */
46
+ export declare class DisposedScopeError extends Error {
47
+ constructor();
48
+ }
49
+
50
+ /**
51
+ * Create or retrieve the scope cached for the current tracked effect/memo.
52
+ *
53
+ * - retain: false (default): per-pass reconciliation.
54
+ * - retain: true: registrations persist until explicitly removed/disposed.
55
+ * - deferPending: false (default): read the bridge in createScope().
56
+ * - deferPending: true: caller must read it with scope.finish(...) each pass.
57
+ *
58
+ * Each observer must use the same options across its lifetime.
59
+ */
60
+ export declare function createScope(options?: {
61
+ retain?: boolean;
62
+ deferPending?: boolean;
63
+ }): DelegatedScope;
package/types/index.d.mts CHANGED
@@ -63,5 +63,43 @@ export declare function attachRead<T>(
63
63
  read: () => T
64
64
  ): AttachReadResult<T>;
65
65
 
66
+ /** Result of a batched synchronous attachment, with values in input order. */
67
+ export interface AttachReadsResult<T> extends Omit<AttachReadResult<T>, 'value'> {
68
+ /** Undefined when target is disposed or its captured cycle is stale. */
69
+ readonly values: T | undefined;
70
+ }
71
+
72
+ /**
73
+ * Graft reads in a single temporary tracking pass and one graph reconciliation.
74
+ * A NotReadyError from one read does not skip subsequent reads; the FIRST
75
+ * NotReadyError is thrown after grafting all encountered dependencies.
76
+ * Other errors abort without changing the target dependency list.
77
+ * Returned values are aligned with the callbacks (undefined for pending reads).
78
+ * Like attachRead, this does not restore native pending/Loading context.
79
+ */
80
+ export declare function attachReads<const T extends readonly (() => unknown)[]>(
81
+ observerOrCycle: Owner | OwnerCycle | null,
82
+ reads: T
83
+ ): AttachReadsResult<{ [K in keyof T]: ReturnType<T[K]> }>;
84
+
66
85
  /** Diagnostic only: get the raw dependency-source nodes for a computation. */
67
86
  export declare function inspectDeps(observer: Owner): unknown[];
87
+
88
+ /** Opt-in native tracking-pass replay of pure synchronous accessors. */
89
+ export interface RetainedScope {
90
+ /**
91
+ * Register a pure synchronous read to execute during each future native
92
+ * tracking pass of the original computation. Does not execute immediately.
93
+ * Trigger a native recompute; returned function removes the registration.
94
+ * Invoke outside Solid-owned/tracking callbacks (e.g. after await or event).
95
+ */
96
+ subscribe<T>(read: () => T): () => void;
97
+ }
98
+
99
+ /**
100
+ * Must be called during EVERY tracking pass of the original computation.
101
+ * Unlike createScope(), it retains registered accessors and replays them
102
+ * in the original active observer; this is opt-in and does not mutate links.
103
+ * The registered callbacks must be pure, synchronous and safe to reexecute.
104
+ */
105
+ export declare function createRetainedScope(): RetainedScope;