@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 +2 -1
- package/jsr.json +1 -1
- package/package.json +9 -7
- package/src/index.ts +1 -0
- package/src/reconcile-tree.ts +20 -15
- package/src/reconcile.ts +24 -8
- package/src/signal.ts +31 -0
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
|
-
- `
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/reactive",
|
|
3
|
-
"version": "2.
|
|
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/
|
|
21
|
-
"
|
|
22
|
-
"
|
|
23
|
-
"
|
|
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.
|
|
27
|
-
"vitest": "
|
|
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
package/src/reconcile-tree.ts
CHANGED
|
@@ -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
|
|
153
|
-
//
|
|
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
|
|
250
|
-
// to the end. That is correct only where a
|
|
251
|
-
// which is a property of the CONSUMER, not
|
|
252
|
-
// by a click (open, save, reset) is safe,
|
|
253
|
-
// push is not. The README records the
|
|
254
|
-
// must drive the field from an effect
|
|
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
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
//
|
|
270
|
-
//
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|