@cplieger/reactive 2.0.0 → 2.1.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 CHANGED
@@ -117,7 +117,8 @@ isComputed(doubled); // true
117
117
  - `effect(fn: () => Cleanup): () => void`: auto-tracking side-effect with cleanup support. `fn` must return `undefined` or a cleanup function.
118
118
  - `batch(fn)`: coalesce signal writes; effects flush synchronously at end of outermost batch. The only deferral — see [Flush model](#flush-model)
119
119
  - `untracked<T>(fn): T`: run fn without tracking signal reads (like Preact `untracked()` / Solid `untrack()`)
120
- - `on(deps, fn, options?): () => U`: explicit dependency declaration helper (like Solid `on()`); pass into `effect()` or `computed()`. With `{ defer: true }` the accessor returns `() => U | undefined`: the deferred first call yields `undefined`.
120
+ - `touch(...signals): void`: read signals for their tracking effect alone, discarding the values — the complement of `peek()` (`peek` reads without subscribing, `touch` subscribes without keeping). `undefined` entries are skipped, so a per-key lookup that may miss (`Collection.signalFor(id)`, `SignalMap.get(id)`) can be passed straight in. Use it where a scope must re-run on a signal whose value it has no use for: most often a two-tier read, where a computed or effect takes per-entity values through `signalFor` and so must depend on the structure signal (`Collection.ids`) separately to re-derive when the id set changes. Unlike `on()` it ADDS a dependency and leaves every other read in the scope tracked
121
+ - `on(deps, fn, options?): () => U`: explicit dependency declaration helper (like Solid `on()`); pass into `effect()` or `computed()`. Its body runs untracked, so the declared deps are the scope's ONLY dependencies — reach for `touch()` instead when the rest of the scope should stay tracked. Passed to `effect()`, the body owes an explicit `return undefined` (or a cleanup function) like any effect callback, since a body with no return statement infers `() => void`, which the `Cleanup` contract rejects. With `{ defer: true }` the accessor returns `() => U | undefined`: the deferred first call yields `undefined`.
121
122
  - `subscribe(signal, cb): () => void`: subscribe to a signal, calling cb immediately and on every change. The callback runs untracked: signals read inside it do not become dependencies of the subscription
122
123
  - `isSignal(value): boolean`: type guard for signals created by `signal()`
123
124
  - `isComputed(value): boolean`: type guard for computed signals
package/jsr.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/reactive",
3
- "version": "2.0.0",
3
+ "version": "2.1.1",
4
4
  "license": "Apache-2.0",
5
5
  "exports": {
6
6
  ".": "./src/index.ts"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/reactive",
3
- "version": "2.0.0",
3
+ "version": "2.1.1",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "exports": {
@@ -17,14 +17,16 @@
17
17
  "@stryker-mutator/typescript-checker": "10.0.0",
18
18
  "@stryker-mutator/vitest-runner": "10.0.0",
19
19
  "@typescript/native": "npm:typescript@7.0.2",
20
- "@vitest/coverage-v8": "4.1.11",
21
- "eslint": "10.8.1",
22
- "fast-check": "4.9.0",
23
- "happy-dom": "20.11.6",
20
+ "@vitest/browser": "5.0.0",
21
+ "@vitest/browser-playwright": "5.0.0",
22
+ "@vitest/coverage-v8": "5.0.0",
23
+ "eslint": "10.10.0",
24
+ "fast-check": "4.10.0",
25
+ "playwright": "1.63.0",
24
26
  "prettier": "3.9.6",
25
27
  "typescript": "npm:@typescript/typescript6@6.0.2",
26
- "typescript-eslint": "8.67.0",
27
- "vitest": "4.1.11"
28
+ "typescript-eslint": "8.70.0",
29
+ "vitest": "5.0.0"
28
30
  },
29
31
  "overrides": {
30
32
  "qs": "^6.15.2"
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@ export {
5
5
  setEffectErrorHandler,
6
6
  computed,
7
7
  untracked,
8
+ touch,
8
9
  subscribe,
9
10
  isSignal,
10
11
  isComputed,
@@ -149,8 +149,8 @@ export function reconcileChildren(parent: Node, newChildren: Node[]): void {
149
149
  // requested node is a template and is not inserted.
150
150
  //
151
151
  // Re-inserting a node that is already in place still detaches and
152
- // reattaches it, which blurs a focused input and drops a text selection
153
- // inside it, so move only when the position actually differs.
152
+ // reattaches it, which blurs a focused input, so move only when the
153
+ // position actually differs.
154
154
  if (ref !== matched) {
155
155
  parent.insertBefore(matched, ref);
156
156
  }
@@ -246,12 +246,13 @@ function patchAttrs(oldEl: HTMLElement, newEl: HTMLElement): void {
246
246
  // live value. Without this, a keyed re-patch could not turn a checkbox off,
247
247
  // move a selection, or replace the text in a field, ever.
248
248
  //
249
- // Syncing `value` overwrites whatever a user has typed, and it moves the caret
250
- // to the end. That is correct only where a re-render cannot land mid-keystroke,
251
- // which is a property of the CONSUMER, not of this function: a form re-rendered
252
- // by a click (open, save, reset) is safe, one re-rendered by a timer or a server
253
- // push is not. The README records the obligation; a consumer in the second shape
254
- // must drive the field from an effect instead of re-rendering over it.
249
+ // Syncing `value` overwrites whatever a user has typed, and a write that
250
+ // changes it also moves the caret to the end. That is correct only where a
251
+ // re-render cannot land mid-keystroke, which is a property of the CONSUMER, not
252
+ // of this function: a form re-rendered by a click (open, save, reset) is safe,
253
+ // one re-rendered by a timer or a server push is not. The README records the
254
+ // obligation; a consumer in the second shape must drive the field from an effect
255
+ // instead of re-rendering over it.
255
256
  //
256
257
  // Not covered here: `value` on an <option>/<progress>/<li> DOES reflect, so the
257
258
  // loops above already carry it. A <select>'s value is derived from its options,
@@ -261,13 +262,17 @@ function patchAttrs(oldEl: HTMLElement, newEl: HTMLElement): void {
261
262
  // canPatch has already established that both nodes share a nodeName, so one
262
263
  // side decides the branch.
263
264
  //
264
- // Each `!==` guard is deliberate: assigning the same string to `.value` is a
265
- // no-op through the DOM, but in a real browser it sets the dirty flag and moves
266
- // the caret to the end, so an unconditional write would jump the caret on every
267
- // re-render even when nothing changed. happy-dom 20.11.6 models the dirty-value,
268
- // dirty-checkedness and dirtiness flags, so "the re-patch did not write" is
269
- // observable as "the element still follows its content attribute" — all four
270
- // guards are pinned behaviourally, with no spy and no caret.
265
+ // Each `!==` guard is deliberate: assigning to `.value` sets the element's
266
+ // dirty-value flag unconditionally, which severs the property from its content
267
+ // attribute for the rest of the element's life, and on a field holding raw
268
+ // content the value getter cannot see (`type=date`, `number`, `time`, `month`,
269
+ // `week`, `datetime-local`) it destroys what the user is half-way through
270
+ // typing. The caret is NOT the reason: per the HTML spec the text entry cursor
271
+ // moves only when the value actually changes, so a same-value write leaves it
272
+ // where it was. The dirty-value, dirty-checkedness and dirtiness flags are what
273
+ // make "the re-patch did not write" observable as "the element still follows
274
+ // its content attribute" — all four guards are pinned behaviourally, with no
275
+ // spy and no caret.
271
276
  if (oldEl.nodeName === "INPUT") {
272
277
  const oldInput = oldEl as HTMLInputElement;
273
278
  const newInput = newEl as HTMLInputElement;
package/src/reconcile.ts CHANGED
@@ -30,10 +30,31 @@ export function reconcile<T>(
30
30
  }
31
31
  }
32
32
 
33
- let target: Node | null = null;
34
- for (let i = items.length - 1; i >= 0; i--) {
35
- const item = items[i] as T;
33
+ // DEPARTING ELEMENTS GO FIRST, before anything is placed. The walk below runs
34
+ // backwards and skips an element already sitting before the last one it placed; a
35
+ // departing element still in the tree makes every PREDECESSOR's `nextSibling` point at
36
+ // a node about to vanish, so the guard sees a mismatch and re-seats a row nothing
37
+ // changed — which restarts its animations and drops `:hover` and focus. So `onRemove`
38
+ // fires before the survivors are placed, holding its own intact element; only the
39
+ // siblings' positions are not yet final. `key` is still called exactly once per item,
40
+ // and the pairs carry it into the walk so neither loop indexes an array.
41
+ const pairs: [T, string][] = [];
42
+ const wanted = new Set<string>();
43
+ for (const item of items) {
36
44
  const k = spec.key(item);
45
+ pairs.push([item, k]);
46
+ wanted.add(k);
47
+ }
48
+ for (const [k, el] of existing) {
49
+ if (!wanted.has(k)) {
50
+ spec.onRemove?.(el, k);
51
+ el.remove();
52
+ existing.delete(k);
53
+ }
54
+ }
55
+
56
+ let target: Node | null = null;
57
+ for (const [item, k] of pairs.reverse()) {
37
58
  let el = existing.get(k);
38
59
  if (el === undefined) {
39
60
  el = spec.mount(item);
@@ -52,9 +73,4 @@ export function reconcile<T>(
52
73
  }
53
74
  target = el;
54
75
  }
55
-
56
- for (const [k, el] of existing) {
57
- spec.onRemove?.(el, k);
58
- el.remove();
59
- }
60
76
  }
package/src/signal.ts CHANGED
@@ -673,6 +673,37 @@ export function untracked<T>(fn: () => T): T {
673
673
  }
674
674
  }
675
675
 
676
+ /** Read a signal for its tracking effect alone, discarding the value.
677
+ *
678
+ * The complement of `peek()`: `peek` reads without subscribing, `touch`
679
+ * subscribes without keeping. Outside a reactive scope it does nothing
680
+ * observable.
681
+ *
682
+ * This exists because the two-tier shape `createCollection` and `bindList` are
683
+ * built on produces it: a scope reads per-entity values through `signalFor`,
684
+ * so it must depend on the structure signal separately to re-run when the id
685
+ * set changes, and that dependency has no value the body wants. Spelling it as
686
+ * a discarded expression left the intent to a comment, and `undefined` entries
687
+ * are accepted so a per-key lookup that may miss (`signalFor(id)`,
688
+ * `SignalMap.get(id)`) can be passed straight in.
689
+ *
690
+ * Not a substitute for `on()`, which declares an exhaustive dependency list
691
+ * and runs its body untracked. `touch` ADDS a dependency and leaves every
692
+ * other read in the scope tracked as usual. */
693
+ export function touch(
694
+ ...signals: readonly (Signal<unknown> | ReadonlySignal<unknown> | undefined)[]
695
+ ): void {
696
+ for (const sig of signals) {
697
+ readForDependency(sig);
698
+ }
699
+ }
700
+
701
+ // Named so the read is a call rather than a discarded expression: the value is
702
+ // deliberately unused, and returning it is what keeps that legible.
703
+ function readForDependency(sig: Signal<unknown> | ReadonlySignal<unknown> | undefined): unknown {
704
+ return sig?.value;
705
+ }
706
+
676
707
  /** Subscribe to a signal, calling cb on every change. Returns dispose function.
677
708
  * The callback runs untracked: reading other signals inside it does not add
678
709
  * them as dependencies of the subscription (matches upstream