solid22 0.0.1

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 ADDED
@@ -0,0 +1,1299 @@
1
+ # solid2
2
+
3
+ Experimental Solid 2 primitives and helpers, organized as focused subpath modules.
4
+
5
+ The first module is `solid2/effects`, which provides an explicit-dependency effect API built on `@solidjs/signals`.
6
+
7
+ ```ts
8
+ import { createEffect, on } from "solid2/effects";
9
+ ```
10
+
11
+ > Because the npm package is named `solid2`, the subpath is `solid2/effects`. An import such as `solid/effects` would refer to a different npm package named `solid`.
12
+
13
+ ## Goals
14
+
15
+ `solid2` is structured as a collection of independent primitive families rather than one large flat module.
16
+
17
+ ```text
18
+ solid2/
19
+ src/
20
+ effects/
21
+ createEffect.ts
22
+ index.ts
23
+ index.ts
24
+ ```
25
+
26
+ That structure is intended to grow naturally:
27
+
28
+ ```text
29
+ src/
30
+ effects/
31
+ index.ts
32
+ loading/
33
+ index.ts
34
+ signals/
35
+ index.ts
36
+ helpers/
37
+ index.ts
38
+ ```
39
+
40
+ The package uses a wildcard subpath export, so adding `src/loading/index.ts` produces the public import `solid2/loading` after build without requiring another `package.json` export entry.
41
+
42
+ The root `solid2` entry is a convenience barrel. Focused subpath imports such as `solid2/effects` are preferred because they make module ownership and future API growth clearer.
43
+
44
+ ---
45
+
46
+ # `solid2/effects`
47
+
48
+ `solid2/effects` wraps Solid 2's two-phase effect model with:
49
+
50
+ - explicit dependencies,
51
+ - accessor-array dependency syntax,
52
+ - dependency callback syntax,
53
+ - tuple inference,
54
+ - async dependency callbacks,
55
+ - async iterable dependencies,
56
+ - per-invocation ownership,
57
+ - async owner restoration through `context.scope`,
58
+ - synchronous and asynchronous returned cleanup functions,
59
+ - `once`,
60
+ - `defer`,
61
+ - explicit untracking control,
62
+ - optional ownerless callbacks,
63
+ - an `on(...)` DSL,
64
+ - and the effect-first `createEffect2(...)` compatibility form.
65
+
66
+ ## Installation
67
+
68
+ ```sh
69
+ npm install solid2 @solidjs/signals
70
+ ```
71
+
72
+ The package declares `@solidjs/signals` as a peer dependency so the application and `solid2` share the same reactive runtime.
73
+
74
+ Current peer range:
75
+
76
+ ```text
77
+ @solidjs/signals >=2.0.0-rc.8 <3.0.0
78
+ ```
79
+
80
+ ## Import
81
+
82
+ ```ts
83
+ import {
84
+ createEffect,
85
+ createEffect2,
86
+ on,
87
+ type Cleanup,
88
+ type CreateEffectOptions,
89
+ type DeferredEffectOptions,
90
+ type EffectContext,
91
+ type EffectResult,
92
+ type EffectScope,
93
+ type OnBuilder
94
+ } from "solid2/effects";
95
+ ```
96
+
97
+ The same exports are currently available from the root convenience barrel:
98
+
99
+ ```ts
100
+ import { createEffect, on } from "solid2";
101
+ ```
102
+
103
+ The canonical import for this primitive family is still:
104
+
105
+ ```ts
106
+ import { createEffect, on } from "solid2/effects";
107
+ ```
108
+
109
+ ---
110
+
111
+ # Quick start
112
+
113
+ ```ts
114
+ import { createSignal, onCleanup } from "solid-js";
115
+ import { createEffect } from "solid2/effects";
116
+
117
+ const [count, setCount] = createSignal(0);
118
+
119
+ createEffect(
120
+ [count],
121
+ ([value]) => {
122
+ console.log("count", value);
123
+
124
+ onCleanup(() => {
125
+ console.log("cleanup", value);
126
+ });
127
+ }
128
+ );
129
+
130
+ setCount(1);
131
+ ```
132
+
133
+ The dependency list is explicit. Reads made in the effect body do not become new dependencies.
134
+
135
+ ---
136
+
137
+ # API surface
138
+
139
+ ## `createEffect(dependencies, effect, options?)`
140
+
141
+ The primary API is dependency-first:
142
+
143
+ ```ts
144
+ createEffect(dependencies, effect, options?);
145
+ ```
146
+
147
+ It accepts two dependency forms.
148
+
149
+ ### Accessor-array dependencies
150
+
151
+ ```ts
152
+ createEffect(
153
+ [count, name, enabled],
154
+ ([countValue, nameValue, enabledValue]) => {
155
+ // number, string, boolean
156
+ }
157
+ );
158
+ ```
159
+
160
+ Each entry must be an accessor function. The callback receives a tuple containing the corresponding accessor values.
161
+
162
+ Derived accessors can be mixed into the array:
163
+
164
+ ```ts
165
+ createEffect(
166
+ [count, name, () => count() * 2],
167
+ ([countValue, nameValue, doubled]) => {
168
+ // number, string, number
169
+ }
170
+ );
171
+ ```
172
+
173
+ Do not pass already-read values:
174
+
175
+ ```ts
176
+ // Wrong: count() and name() execute before createEffect sees them.
177
+ createEffect([count(), name()], () => {});
178
+ ```
179
+
180
+ Use accessors instead:
181
+
182
+ ```ts
183
+ createEffect([count, name], ([countValue, nameValue]) => {
184
+ // ...
185
+ });
186
+ ```
187
+
188
+ ### Dependency callback
189
+
190
+ The dependency callback form is useful when dependencies need to be derived together:
191
+
192
+ ```ts
193
+ createEffect(
194
+ () => [count(), user()?.id, count() * 2],
195
+ ([countValue, userId, doubled]) => {
196
+ // tuple values are inferred from the dependency callback
197
+ }
198
+ );
199
+ ```
200
+
201
+ This form can return:
202
+
203
+ ```ts
204
+ readonly unknown[]
205
+ PromiseLike<readonly unknown[]>
206
+ AsyncIterable<readonly unknown[]>
207
+ ```
208
+
209
+ The callback dependency path is materialized through Solid's computed/async signal path. That lets Solid handle pending values, stale async result suppression, and repeated async-iterable publications before the effect callback receives a ready tuple.
210
+
211
+ ---
212
+
213
+ # Effect callback
214
+
215
+ The callback receives two arguments:
216
+
217
+ ```ts
218
+ (values, context) => result
219
+ ```
220
+
221
+ Example:
222
+
223
+ ```ts
224
+ createEffect(
225
+ [count],
226
+ ([value], context) => {
227
+ console.log(value);
228
+ console.log(context.scope.active);
229
+ }
230
+ );
231
+ ```
232
+
233
+ The common form destructures `scope`:
234
+
235
+ ```ts
236
+ createEffect(
237
+ [count],
238
+ ([value], { scope }) => {
239
+ scope(() => {
240
+ // run under this invocation's owner
241
+ });
242
+ }
243
+ );
244
+ ```
245
+
246
+ The second parameter is an `EffectContext`; it is not directly callable.
247
+
248
+ Incorrect:
249
+
250
+ ```ts
251
+ createEffect([count], ([value], scope) => {
252
+ scope(() => {}); // EffectContext is not callable
253
+ });
254
+ ```
255
+
256
+ Correct:
257
+
258
+ ```ts
259
+ createEffect([count], ([value], { scope }) => {
260
+ scope(() => {});
261
+ });
262
+ ```
263
+
264
+ ---
265
+
266
+ # `EffectContext`
267
+
268
+ ```ts
269
+ interface EffectContext {
270
+ readonly scope: EffectScope;
271
+ }
272
+ ```
273
+
274
+ `EffectContext` is created once for each effect invocation.
275
+
276
+ `context.scope` is lazy and memoized. The `EffectScope` function is only allocated if user code actually reads the property, and repeated reads during the same invocation return the same scope object.
277
+
278
+ ```ts
279
+ createEffect([count], (_values, context) => {
280
+ const first = context.scope;
281
+ const second = context.scope;
282
+
283
+ console.log(first === second); // true
284
+ });
285
+ ```
286
+
287
+ This allows ordinary effects that never use `scope` to avoid allocating the scope closure.
288
+
289
+ ---
290
+
291
+ # `EffectScope`
292
+
293
+ ```ts
294
+ interface EffectScope {
295
+ <T>(fn: () => T): T | undefined;
296
+ readonly active: boolean;
297
+ }
298
+ ```
299
+
300
+ An `EffectScope` represents one specific invocation of an effect.
301
+
302
+ ## `scope(fn)`
303
+
304
+ `scope(fn)` re-enters the owner belonging to that invocation.
305
+
306
+ ```ts
307
+ createEffect([count], ([value], { scope }) => {
308
+ scope(() => {
309
+ onCleanup(() => console.log("cleanup", value));
310
+ });
311
+ });
312
+ ```
313
+
314
+ The callback result is returned:
315
+
316
+ ```ts
317
+ createEffect([count], (_values, { scope }) => {
318
+ const result = scope(() => 42);
319
+ // result: number | undefined
320
+ });
321
+ ```
322
+
323
+ `undefined` is possible because the invocation may already have been disposed.
324
+
325
+ ## `scope.active`
326
+
327
+ ```ts
328
+ createEffect([count], (_values, { scope }) => {
329
+ console.log(scope.active);
330
+ });
331
+ ```
332
+
333
+ `scope.active` is `true` while that exact invocation's owner is live.
334
+
335
+ When an invocation is replaced or disposed, its previously captured scope becomes inactive:
336
+
337
+ ```ts
338
+ let previousScope: EffectScope | undefined;
339
+
340
+ createEffect([count], (_values, { scope }) => {
341
+ if (previousScope) {
342
+ console.log(previousScope.active); // false after replacement
343
+ }
344
+
345
+ previousScope = scope;
346
+ });
347
+ ```
348
+
349
+ Calling an inactive scope is a no-op and returns `undefined`.
350
+
351
+ ---
352
+
353
+ # Ownership
354
+
355
+ Each effect execution gets its own disposable invocation root.
356
+
357
+ By default, the user callback executes under that invocation owner:
358
+
359
+ ```ts
360
+ createEffect([count], ([value]) => {
361
+ onCleanup(() => {
362
+ console.log("cleanup", value);
363
+ });
364
+
365
+ createEffect([other], ([otherValue]) => {
366
+ console.log(value, otherValue);
367
+ });
368
+ });
369
+ ```
370
+
371
+ When `count` changes:
372
+
373
+ 1. the previous invocation root is disposed,
374
+ 2. its cleanup functions run,
375
+ 3. its nested reactive work is disposed,
376
+ 4. a new invocation root is created,
377
+ 5. the callback executes for the new dependency value.
378
+
379
+ This prevents nested effects from accumulating across parent reruns.
380
+
381
+ ---
382
+
383
+ # Async effect callbacks
384
+
385
+ An effect callback may return a promise:
386
+
387
+ ```ts
388
+ createEffect(
389
+ [userId],
390
+ async ([id], { scope }) => {
391
+ const user = await loadUser(id);
392
+
393
+ scope(() => {
394
+ console.log("resolved user", user);
395
+ });
396
+ }
397
+ );
398
+ ```
399
+
400
+ Do not rely on ambient ownership surviving an `await`. Capture `scope` and explicitly re-enter the invocation owner afterward.
401
+
402
+ ```ts
403
+ createEffect(
404
+ [userId],
405
+ async ([id], { scope }) => {
406
+ const resource = await openResource(id);
407
+
408
+ scope(() => {
409
+ onCleanup(() => resource.close());
410
+ });
411
+ }
412
+ );
413
+ ```
414
+
415
+ If the dependency changes while the operation is pending, the old invocation is disposed. When its asynchronous work later resumes:
416
+
417
+ ```ts
418
+ scope.active === false
419
+ ```
420
+
421
+ and:
422
+
423
+ ```ts
424
+ scope(() => {
425
+ // this will not execute for a stale invocation
426
+ });
427
+ ```
428
+
429
+ This makes it possible to prevent stale async work from attaching new owned resources to an obsolete invocation.
430
+
431
+ ---
432
+
433
+ # Cleanup behavior
434
+
435
+ There are three supported cleanup patterns.
436
+
437
+ ## `onCleanup()`
438
+
439
+ With the default `owned: true`, native Solid cleanup works directly inside the callback:
440
+
441
+ ```ts
442
+ createEffect([count], ([value]) => {
443
+ const resource = open(value);
444
+
445
+ onCleanup(() => {
446
+ resource.close();
447
+ });
448
+ });
449
+ ```
450
+
451
+ ## Return a cleanup function
452
+
453
+ ```ts
454
+ createEffect([count], ([value]) => {
455
+ const resource = open(value);
456
+
457
+ return () => {
458
+ resource.close();
459
+ };
460
+ });
461
+ ```
462
+
463
+ The wrapper registers the returned cleanup on the current invocation.
464
+
465
+ ## Return a promise of a cleanup function
466
+
467
+ ```ts
468
+ createEffect([count], async ([value]) => {
469
+ const resource = await openAsync(value);
470
+
471
+ return () => {
472
+ resource.close();
473
+ };
474
+ });
475
+ ```
476
+
477
+ If the invocation is still active when the promise resolves, the cleanup is registered normally.
478
+
479
+ If the invocation was already replaced, the late cleanup is executed immediately because there is no longer a valid invocation owner on which to register it.
480
+
481
+ This prevents resources created by stale async work from leaking.
482
+
483
+ ## Combining cleanup styles
484
+
485
+ `onCleanup()` and a returned cleanup may both be used:
486
+
487
+ ```ts
488
+ createEffect([count], ([value]) => {
489
+ onCleanup(() => console.log("hook cleanup", value));
490
+
491
+ return () => {
492
+ console.log("returned cleanup", value);
493
+ };
494
+ });
495
+ ```
496
+
497
+ Both belong to the same invocation lifecycle.
498
+
499
+ ---
500
+
501
+ # Dependency selection and snapshot reads
502
+
503
+ Dependencies are determined only by the explicit dependency input.
504
+
505
+ ```ts
506
+ createEffect(
507
+ [count],
508
+ ([value]) => {
509
+ console.log(value, other());
510
+ }
511
+ );
512
+ ```
513
+
514
+ `count` is a dependency.
515
+
516
+ `other()` is a snapshot read inside the effect body and does not become another dependency.
517
+
518
+ Changing `other` by itself does not rerun the effect. The newest `other()` value will be observed the next time `count` causes the effect to run.
519
+
520
+ ---
521
+
522
+ # Options
523
+
524
+ ```ts
525
+ export type CreateEffectOptions = EffectOptions & {
526
+ untrack?: boolean;
527
+ owned?: boolean;
528
+ once?: boolean;
529
+ };
530
+ ```
531
+
532
+ All native `EffectOptions` supported by the underlying Solid effect may be passed through, plus the options documented below.
533
+
534
+ ## `defer`
535
+
536
+ Inherited from Solid's effect options.
537
+
538
+ ```ts
539
+ createEffect(
540
+ [count],
541
+ ([value]) => {
542
+ console.log(value);
543
+ },
544
+ { defer: true }
545
+ );
546
+ ```
547
+
548
+ With `defer: true`, the initial callback execution is skipped. The effect begins applying on a dependency change.
549
+
550
+ ## `once`
551
+
552
+ Default:
553
+
554
+ ```ts
555
+ once: false
556
+ ```
557
+
558
+ Without `defer`:
559
+
560
+ ```ts
561
+ createEffect(
562
+ [count],
563
+ ([value]) => console.log(value),
564
+ { once: true }
565
+ );
566
+ ```
567
+
568
+ Behavior:
569
+
570
+ ```text
571
+ initial execution
572
+ first changed execution
573
+ unsubscribe
574
+ ```
575
+
576
+ So `{ once: true }` means **initial + one change**.
577
+
578
+ With `defer`:
579
+
580
+ ```ts
581
+ createEffect(
582
+ [count],
583
+ ([value]) => console.log(value),
584
+ { defer: true, once: true }
585
+ );
586
+ ```
587
+
588
+ Behavior:
589
+
590
+ ```text
591
+ skip initial execution
592
+ first changed execution
593
+ unsubscribe
594
+ ```
595
+
596
+ So `{ defer: true, once: true }` means **one changed execution only**.
597
+
598
+ For a terminal async invocation, dependency observation is removed immediately, but that final invocation stays alive until the returned promise settles. This lets its captured `scope` remain usable during the final async operation.
599
+
600
+ ## `untrack`
601
+
602
+ Default:
603
+
604
+ ```ts
605
+ untrack: true
606
+ ```
607
+
608
+ The user callback is explicitly wrapped in `untrack()` by default.
609
+
610
+ ```ts
611
+ createEffect(
612
+ [count],
613
+ ([value]) => {
614
+ console.log(value, other());
615
+ },
616
+ { untrack: true }
617
+ );
618
+ ```
619
+
620
+ This makes effect-body reads explicit snapshot reads and suppresses Solid 2's strict untracked-read diagnostic for them.
621
+
622
+ Set it to `false` to preserve the native diagnostic behavior:
623
+
624
+ ```ts
625
+ createEffect(
626
+ [count],
627
+ ([value]) => {
628
+ console.log(value, other());
629
+ },
630
+ { untrack: false }
631
+ );
632
+ ```
633
+
634
+ Dependency selection does not change: body reads still do not become dependencies. The option controls the read/diagnostic policy of the callback body.
635
+
636
+ `scope()` follows the same `untrack` choice when it restores the invocation owner.
637
+
638
+ ## `owned`
639
+
640
+ Default:
641
+
642
+ ```ts
643
+ owned: true
644
+ ```
645
+
646
+ With the default, the callback runs under the invocation owner:
647
+
648
+ ```ts
649
+ createEffect(
650
+ [count],
651
+ ([value]) => {
652
+ onCleanup(() => console.log("cleanup"));
653
+
654
+ createEffect([other], () => {
655
+ // nested effect belongs to this invocation
656
+ });
657
+ },
658
+ { owned: true }
659
+ );
660
+ ```
661
+
662
+ Set `owned: false` to run the callback with no owner:
663
+
664
+ ```ts
665
+ createEffect(
666
+ [count],
667
+ ([value], { scope }) => {
668
+ // callback body itself has no owner
669
+
670
+ scope(() => {
671
+ // exact invocation owner restored here
672
+ onCleanup(() => console.log("cleanup"));
673
+ });
674
+ },
675
+ { owned: false }
676
+ );
677
+ ```
678
+
679
+ The invocation root still exists when `owned: false`; otherwise `context.scope()` would have nothing to restore.
680
+
681
+ Returned cleanup functions continue to work with `owned: false` because returned cleanup registration is managed by the wrapper:
682
+
683
+ ```ts
684
+ createEffect(
685
+ [count],
686
+ ([value]) => {
687
+ const resource = open(value);
688
+ return () => resource.close();
689
+ },
690
+ { owned: false }
691
+ );
692
+ ```
693
+
694
+ Direct `onCleanup()` and direct nested owned primitives should not be used in an ownerless callback. Put that work inside `scope()` instead.
695
+
696
+ ## `owned` and `untrack` are independent
697
+
698
+ | `owned` | `untrack` | callback owner | callback read policy |
699
+ | --- | --- | --- | --- |
700
+ | `true` | `true` | invocation owner | explicit snapshot/untracked reads |
701
+ | `true` | `false` | invocation owner | native strict-read diagnostics |
702
+ | `false` | `true` | no owner | explicit snapshot/untracked reads |
703
+ | `false` | `false` | no owner | native strict-read diagnostics |
704
+
705
+ ---
706
+
707
+ # `on(dependencies)` DSL
708
+
709
+ `on()` captures dependencies first and returns an `OnBuilder`.
710
+
711
+ ```ts
712
+ const builder = on([count, name]);
713
+ ```
714
+
715
+ Most code uses it inline:
716
+
717
+ ```ts
718
+ on([count, name]).effect(([countValue, nameValue]) => {
719
+ console.log(countValue, nameValue);
720
+ });
721
+ ```
722
+
723
+ It supports both dependency forms accepted by `createEffect`.
724
+
725
+ ## Accessor arrays
726
+
727
+ ```ts
728
+ on([count, name]).effect(([countValue, nameValue]) => {
729
+ // ...
730
+ });
731
+ ```
732
+
733
+ ## Dependency callback
734
+
735
+ ```ts
736
+ on(() => [count(), name()]).effect(([countValue, nameValue]) => {
737
+ // ...
738
+ });
739
+ ```
740
+
741
+ ## `.effect(effect, options?)`
742
+
743
+ Equivalent to the primary `createEffect` behavior for the captured dependencies:
744
+
745
+ ```ts
746
+ on([count]).effect(
747
+ ([value]) => {
748
+ console.log(value);
749
+ },
750
+ {
751
+ once: true,
752
+ untrack: true,
753
+ owned: true
754
+ }
755
+ );
756
+ ```
757
+
758
+ The callback receives the same `EffectContext`:
759
+
760
+ ```ts
761
+ on([count]).effect(([value], { scope }) => {
762
+ scope(() => {
763
+ // ...
764
+ });
765
+ });
766
+ ```
767
+
768
+ ## `.deferEffect(effect, options?)`
769
+
770
+ ```ts
771
+ on([count]).deferEffect(([value]) => {
772
+ console.log(value);
773
+ });
774
+ ```
775
+
776
+ This is equivalent to:
777
+
778
+ ```ts
779
+ on([count]).effect(
780
+ ([value]) => {
781
+ console.log(value);
782
+ },
783
+ { defer: true }
784
+ );
785
+ ```
786
+
787
+ `deferEffect()` uses `DeferredEffectOptions`, which omits the `defer` property so callers cannot accidentally provide a conflicting value.
788
+
789
+ It composes with `once`:
790
+
791
+ ```ts
792
+ on([count]).deferEffect(
793
+ ([value]) => {
794
+ console.log(value);
795
+ },
796
+ { once: true }
797
+ );
798
+ ```
799
+
800
+ That runs exactly once, on the first dependency change.
801
+
802
+ ---
803
+
804
+ # `createEffect2(effect, dependencies, options?)`
805
+
806
+ `createEffect2` provides the earlier effect-first argument order:
807
+
808
+ ```ts
809
+ createEffect2(
810
+ ([value]) => {
811
+ console.log(value);
812
+ },
813
+ [count]
814
+ );
815
+ ```
816
+
817
+ It has the same semantics as `createEffect`; only argument order differs.
818
+
819
+ Dependency callback form:
820
+
821
+ ```ts
822
+ createEffect2(
823
+ ([doubled]) => {
824
+ console.log(doubled);
825
+ },
826
+ () => [count() * 2]
827
+ );
828
+ ```
829
+
830
+ Options work identically:
831
+
832
+ ```ts
833
+ createEffect2(
834
+ ([value], { scope }) => {
835
+ scope(() => {
836
+ // ...
837
+ });
838
+ },
839
+ [count],
840
+ {
841
+ defer: true,
842
+ once: true
843
+ }
844
+ );
845
+ ```
846
+
847
+ `createEffect` is the preferred public form for new code.
848
+
849
+ ---
850
+
851
+ # Async dependencies
852
+
853
+ Dependency callbacks can be asynchronous.
854
+
855
+ ## Promise dependency
856
+
857
+ ```ts
858
+ createEffect(
859
+ async () => {
860
+ const id = userId();
861
+ const user = await loadUser(id);
862
+ return [id, user] as const;
863
+ },
864
+ ([id, user]) => {
865
+ console.log(id, user);
866
+ }
867
+ );
868
+ ```
869
+
870
+ The effect callback does not run until the dependency tuple is ready.
871
+
872
+ When the dependency callback recomputes, stale promise results are handled by the underlying Solid async signal path rather than manually applied by this wrapper.
873
+
874
+ ## Async iterable dependency
875
+
876
+ ```ts
877
+ createEffect(
878
+ async function* () {
879
+ yield [1] as const;
880
+ yield [2] as const;
881
+ yield [3] as const;
882
+ },
883
+ ([value]) => {
884
+ console.log(value);
885
+ }
886
+ );
887
+ ```
888
+
889
+ Each published tuple can produce another effect invocation.
890
+
891
+ ## Pending / NotReady dependencies
892
+
893
+ Accessor dependencies can also be pending Solid async signals.
894
+
895
+ ```ts
896
+ createEffect(
897
+ [asyncValue],
898
+ ([value]) => {
899
+ console.log(value);
900
+ }
901
+ );
902
+ ```
903
+
904
+ The effect body is not invoked with a partial tuple while one of its dependency reads is NotReady. Once the dependency is ready, the coherent tuple is applied.
905
+
906
+ ---
907
+
908
+ # Nested effects
909
+
910
+ Nested `solid2/effects` effects belong to the current invocation by default:
911
+
912
+ ```ts
913
+ createEffect([outer], ([outerValue]) => {
914
+ createEffect([inner], ([innerValue]) => {
915
+ console.log(outerValue, innerValue);
916
+ });
917
+ });
918
+ ```
919
+
920
+ When `outer` changes, the old outer invocation is disposed, which disposes the old nested effect. A replacement nested effect is then created under the new invocation.
921
+
922
+ This works recursively for deeper nested trees.
923
+
924
+ ---
925
+
926
+ # Exported types
927
+
928
+ ## `Cleanup`
929
+
930
+ ```ts
931
+ export type Cleanup = () => void;
932
+ ```
933
+
934
+ A synchronous disposer.
935
+
936
+ ## `EffectScope`
937
+
938
+ ```ts
939
+ export interface EffectScope {
940
+ <T>(fn: () => T): T | undefined;
941
+ readonly active: boolean;
942
+ }
943
+ ```
944
+
945
+ A callable handle for re-entering one specific invocation owner.
946
+
947
+ ## `EffectContext`
948
+
949
+ ```ts
950
+ export interface EffectContext {
951
+ readonly scope: EffectScope;
952
+ }
953
+ ```
954
+
955
+ The context passed as the second effect callback argument.
956
+
957
+ ## `EffectResult`
958
+
959
+ ```ts
960
+ export type EffectResult =
961
+ | void
962
+ | Cleanup
963
+ | PromiseLike<void | Cleanup>;
964
+ ```
965
+
966
+ The supported result of an effect callback.
967
+
968
+ ## `CreateEffectOptions`
969
+
970
+ Conceptually:
971
+
972
+ ```ts
973
+ export type CreateEffectOptions = EffectOptions & {
974
+ untrack?: boolean;
975
+ owned?: boolean;
976
+ once?: boolean;
977
+ };
978
+ ```
979
+
980
+ The native Solid effect options are forwarded along with `solid2/effects` options.
981
+
982
+ ## `DeferredEffectOptions`
983
+
984
+ ```ts
985
+ export type DeferredEffectOptions = Omit<CreateEffectOptions, "defer">;
986
+ ```
987
+
988
+ Used by `OnBuilder.deferEffect()` because that method always sets `defer: true` itself.
989
+
990
+ ## `OnBuilder<T>`
991
+
992
+ ```ts
993
+ export interface OnBuilder<T extends readonly unknown[]> {
994
+ effect(
995
+ effect: (values: T, context: EffectContext) => EffectResult,
996
+ options?: CreateEffectOptions
997
+ ): void;
998
+
999
+ deferEffect(
1000
+ effect: (values: T, context: EffectContext) => EffectResult,
1001
+ options?: DeferredEffectOptions
1002
+ ): void;
1003
+ }
1004
+ ```
1005
+
1006
+ The actual source uses the package's internal effect function type, but this is the public shape exposed by the builder.
1007
+
1008
+ ---
1009
+
1010
+ # Practical examples
1011
+
1012
+ ## Watch selected state but snapshot other state
1013
+
1014
+ ```ts
1015
+ createEffect([count], ([countValue]) => {
1016
+ console.log({
1017
+ count: countValue,
1018
+ currentTheme: theme()
1019
+ });
1020
+ });
1021
+ ```
1022
+
1023
+ Only `count` causes reruns.
1024
+
1025
+ ## One changed execution only
1026
+
1027
+ ```ts
1028
+ createEffect(
1029
+ [count],
1030
+ ([value]) => {
1031
+ console.log("first change", value);
1032
+ },
1033
+ {
1034
+ defer: true,
1035
+ once: true
1036
+ }
1037
+ );
1038
+ ```
1039
+
1040
+ ## Initial execution plus one changed execution
1041
+
1042
+ ```ts
1043
+ createEffect(
1044
+ [count],
1045
+ ([value]) => {
1046
+ console.log(value);
1047
+ },
1048
+ {
1049
+ once: true
1050
+ }
1051
+ );
1052
+ ```
1053
+
1054
+ ## Restore ownership after `await`
1055
+
1056
+ ```ts
1057
+ createEffect(
1058
+ [userId],
1059
+ async ([id], { scope }) => {
1060
+ const controller = new AbortController();
1061
+ const response = await fetch(`/users/${id}`, {
1062
+ signal: controller.signal
1063
+ });
1064
+
1065
+ scope(() => {
1066
+ onCleanup(() => controller.abort());
1067
+ });
1068
+
1069
+ console.log(await response.json());
1070
+ }
1071
+ );
1072
+ ```
1073
+
1074
+ ## Explicitly ownerless callback
1075
+
1076
+ ```ts
1077
+ createEffect(
1078
+ [count],
1079
+ ([value], { scope }) => {
1080
+ console.log("ownerless work", value);
1081
+
1082
+ scope(() => {
1083
+ onCleanup(() => {
1084
+ console.log("owned cleanup");
1085
+ });
1086
+ });
1087
+ },
1088
+ {
1089
+ owned: false
1090
+ }
1091
+ );
1092
+ ```
1093
+
1094
+ ## Return cleanup instead of using `onCleanup`
1095
+
1096
+ ```ts
1097
+ createEffect([roomId], ([id]) => {
1098
+ const socket = connect(id);
1099
+ return () => socket.close();
1100
+ });
1101
+ ```
1102
+
1103
+ ## Async resource with returned cleanup
1104
+
1105
+ ```ts
1106
+ createEffect([roomId], async ([id]) => {
1107
+ const socket = await connectAsync(id);
1108
+ return () => socket.close();
1109
+ });
1110
+ ```
1111
+
1112
+ ---
1113
+
1114
+ # Package layout
1115
+
1116
+ ```text
1117
+ solid2/
1118
+ ├─ src/
1119
+ │ ├─ index.ts
1120
+ │ └─ effects/
1121
+ │ ├─ createEffect.ts
1122
+ │ └─ index.ts
1123
+ ├─ tests/
1124
+ │ ├─ effects.test.ts
1125
+ │ ├─ test-utils.ts
1126
+ │ └─ runner.ts
1127
+ ├─ examples/
1128
+ │ ├─ basic.tsx
1129
+ │ └─ async-scope.ts
1130
+ ├─ package.json
1131
+ ├─ tsconfig.json
1132
+ ├─ tsconfig.build.json
1133
+ ├─ CHANGELOG.md
1134
+ └─ README.md
1135
+ ```
1136
+
1137
+ The npm package only publishes the compiled `dist` tree plus documentation files.
1138
+
1139
+ ---
1140
+
1141
+ # Adding another primitive family
1142
+
1143
+ Suppose you add loading helpers.
1144
+
1145
+ Create:
1146
+
1147
+ ```text
1148
+ src/loading/index.ts
1149
+ src/loading/createLoadingBoundary.ts
1150
+ ```
1151
+
1152
+ Then export from the module barrel:
1153
+
1154
+ ```ts
1155
+ // src/loading/index.ts
1156
+ export * from "./createLoadingBoundary.js";
1157
+ ```
1158
+
1159
+ After `npm run build`, the wildcard package export makes this available automatically:
1160
+
1161
+ ```ts
1162
+ import {
1163
+ createLoadingBoundary
1164
+ } from "solid2/loading";
1165
+ ```
1166
+
1167
+ No new `package.json` export entry is required.
1168
+
1169
+ If you also want the primitive available from the root convenience barrel, add it manually:
1170
+
1171
+ ```ts
1172
+ // src/index.ts
1173
+ export * from "./effects/index.js";
1174
+ export * from "./loading/index.js";
1175
+ ```
1176
+
1177
+ Keeping the root barrel manual prevents new module families from unexpectedly changing the flat root namespace.
1178
+
1179
+ ---
1180
+
1181
+ # Build
1182
+
1183
+ ```sh
1184
+ npm install
1185
+ npm run build
1186
+ ```
1187
+
1188
+ The build uses TypeScript directly and preserves the module folder structure:
1189
+
1190
+ ```text
1191
+ dist/
1192
+ index.js
1193
+ index.d.ts
1194
+ effects/
1195
+ index.js
1196
+ index.d.ts
1197
+ createEffect.js
1198
+ createEffect.d.ts
1199
+ ```
1200
+
1201
+ That structure maps directly to the package subpath exports.
1202
+
1203
+ ## Typecheck
1204
+
1205
+ ```sh
1206
+ npm run typecheck
1207
+ ```
1208
+
1209
+ ## Tests
1210
+
1211
+ ```sh
1212
+ npm test
1213
+ ```
1214
+
1215
+ The repository retains the behavioral test suite used while developing the effect helper. Tests are not part of the published npm `files` list.
1216
+
1217
+ ## Pack locally
1218
+
1219
+ ```sh
1220
+ npm pack
1221
+ ```
1222
+
1223
+ `prepack` automatically runs the build before npm creates the tarball.
1224
+
1225
+ ---
1226
+
1227
+ # Design notes
1228
+
1229
+ ## Why explicit dependencies?
1230
+
1231
+ The dependency computation and side-effect body have different jobs:
1232
+
1233
+ ```ts
1234
+ createEffect(
1235
+ () => [a(), b()],
1236
+ ([aValue, bValue]) => {
1237
+ // side effects
1238
+ }
1239
+ );
1240
+ ```
1241
+
1242
+ The first phase explicitly describes what should cause the effect to update. The second phase receives a stable snapshot of those values and performs side effects without accidentally widening the dependency set.
1243
+
1244
+ ## Why a context object instead of passing `scope` directly?
1245
+
1246
+ The callback shape is:
1247
+
1248
+ ```ts
1249
+ (values, context)
1250
+ ```
1251
+
1252
+ rather than:
1253
+
1254
+ ```ts
1255
+ (values, scope)
1256
+ ```
1257
+
1258
+ This leaves room for invocation-scoped helpers to be added later without changing callback arity or turning the second argument into an overloaded callable object.
1259
+
1260
+ It also lets `scope` be lazy. Effects that never read `context.scope` do not allocate the `EffectScope` closure.
1261
+
1262
+ ## Why is `scope` invocation-specific?
1263
+
1264
+ A scope from an old invocation must not be able to attach new resources after that invocation has been replaced.
1265
+
1266
+ That is especially important for asynchronous work:
1267
+
1268
+ ```ts
1269
+ createEffect([id], async ([id], { scope }) => {
1270
+ const result = await load(id);
1271
+
1272
+ scope(() => {
1273
+ // only runs if this exact invocation is still alive
1274
+ });
1275
+ });
1276
+ ```
1277
+
1278
+ The scope therefore captures one invocation owner rather than merely re-entering whichever owner happens to be current later.
1279
+
1280
+ ## Why keep returned cleanup support when `onCleanup` exists?
1281
+
1282
+ Returned cleanup gives the effect callback a resource-oriented style:
1283
+
1284
+ ```ts
1285
+ createEffect([id], ([id]) => {
1286
+ const resource = open(id);
1287
+ return () => resource.close();
1288
+ });
1289
+ ```
1290
+
1291
+ It is also useful for `owned: false`, because the wrapper can register returned cleanups on the invocation owner without requiring the callback itself to run under that owner.
1292
+
1293
+ Async returned cleanup additionally gives the wrapper a deterministic way to immediately release a resource that resolves after its invocation has already become stale.
1294
+
1295
+ ---
1296
+
1297
+ # Status
1298
+
1299
+ `solid2/effects` is experimental and targets the Solid 2 signals APIs. Its semantics are deliberately explicit and are covered by the included regression suite, but the package should be versioned conservatively while Solid 2 APIs are still evolving.