@xmachines/play-signals 2.2.0 → 4.0.0

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
@@ -2,7 +2,7 @@
2
2
 
3
3
  TC39 Signals polyfill for XMachines. It gives the Play Architecture fine-grained reactive state primitives. The primitives propagate state without a glitch and without a subscription.
4
4
 
5
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Version](https://img.shields.io/badge/version-2.2.0-blue)](https://www.npmjs.com/package/@xmachines/play-signals)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Version](https://img.shields.io/badge/version-4.0.0-blue)](https://www.npmjs.com/package/@xmachines/play-signals)
6
6
 
7
7
  ## Installation
8
8
 
@@ -115,39 +115,18 @@ person.set({ name: "Alice", age: 30 });
115
115
  | Export | Kind | Description |
116
116
  | ------------------------------ | --------- | ------------------------------------------------------------------------------------------------------ |
117
117
  | `Signal` | namespace | Full TC39 Signals namespace (`State`, `Computed`, `subtle.Watcher`) re-exported from `signal-polyfill` |
118
- | `watchSignal(signal, onValue)` | function | The memory-safe subscription helper. It returns a cleanup function |
118
+ | `watchSignal(signal, onValue)` | function | The memory-safe subscription helper. It returns a `Cleanup` |
119
119
  | `SignalState<T>` | interface | Shape of `Signal.State<T>` (`.get()`, `.set()`) |
120
120
  | `SignalComputed<T>` | interface | Shape of `Signal.Computed<T>` (`.get()`) |
121
121
  | `SignalWatcher` | interface | Shape of `Signal.subtle.Watcher` (`.watch()`, `.unwatch()`, `.getPending()`) |
122
122
  | `SignalOptions<T>` | interface | The options object for the `Signal.State` constructor (`equals?`) |
123
123
  | `ComputedOptions<T>` | interface | The options object for the `Signal.Computed` constructor (`equals?`) |
124
124
  | `WatcherNotify` | type | The callback signature of the `Signal.subtle.Watcher` notify function |
125
-
126
- ## Testing
127
-
128
- Run tests for this package in isolation:
129
-
130
- ```bash
131
- # From this package directory
132
- pnpm test
133
-
134
- # Watch mode
135
- pnpm test -- --watch
136
- ```
137
-
138
- From the monorepo root:
139
-
140
- ```bash
141
- # Run tests for this package
142
- pnpm --filter @xmachines/play-signals test
143
-
144
- # Run with coverage (lines ≥ 90 %, functions ≥ 90 %, branches ≥ 85 %, statements ≥ 90 %)
145
- pnpm run test:coverage
146
- ```
125
+ | `Cleanup` | type | The release that `watchSignal` returns, re-exported from `@xmachines/play` |
147
126
 
148
127
  ## Requirements
149
128
 
150
- - **Node.js** `>= 22.0.0`
129
+ - **Node.js** `>= 24.0.0`
151
130
  - **TypeScript** `5.7+` (for a consumer that uses TypeScript)
152
131
 
153
132
  ## License
package/dist/index.d.ts CHANGED
@@ -52,4 +52,5 @@
52
52
  export { Signal } from "signal-polyfill";
53
53
  export { watchSignal } from "./watch-signal.js";
54
54
  export type { SignalState, SignalComputed, SignalWatcher, SignalOptions, ComputedOptions, WatcherNotify, } from "./types.js";
55
+ export { type Cleanup } from "@xmachines/play";
55
56
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkDG;AAGH,OAAO,EAAE,MAAM,EAAE,MAAM,iBAAiB,CAAC;AACzC,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAGhD,YAAY,EACX,WAAW,EACX,cAAc,EACd,aAAa,EACb,aAAa,EACb,eAAe,EACf,aAAa,GACb,MAAM,YAAY,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkDG;AAGH,OAAO,EAAE,MAAM,EAAE,MAAM,iBAAiB,CAAC;AACzC,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAGhD,YAAY,EACX,WAAW,EACX,cAAc,EACd,aAAa,EACb,aAAa,EACb,eAAe,EACf,aAAa,GACb,MAAM,YAAY,CAAC;AAKpB,OAAO,EAAE,KAAK,OAAO,EAAE,MAAM,iBAAiB,CAAC"}
package/dist/index.js CHANGED
@@ -52,4 +52,9 @@
52
52
  // Re-export the complete Signal namespace of the official polyfill
53
53
  export { Signal } from "signal-polyfill";
54
54
  export { watchSignal } from "./watch-signal.js";
55
+ // The release protocol of @xmachines/play. This package's own published `.d.ts` names
56
+ // exactly these, so a consumer reads them from here and needs no second manifest
57
+ // entry. It names `asCleanup` nowhere: a consumer of this package RECEIVES a
58
+ // release, and builds one only with @xmachines/play itself.
59
+ export {} from "@xmachines/play";
55
60
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkDG;AAEH,mEAAmE;AACnE,OAAO,EAAE,MAAM,EAAE,MAAM,iBAAiB,CAAC;AACzC,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkDG;AAEH,mEAAmE;AACnE,OAAO,EAAE,MAAM,EAAE,MAAM,iBAAiB,CAAC;AACzC,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAWhD,sFAAsF;AACtF,iFAAiF;AACjF,6EAA6E;AAC7E,4DAA4D;AAC5D,OAAO,EAAgB,MAAM,iBAAiB,CAAC"}
@@ -1,3 +1,4 @@
1
+ import { type Cleanup } from "@xmachines/play";
1
2
  import { Signal } from "signal-polyfill";
2
3
  /**
3
4
  * Subscribes to one signal, with the canonical one-shot watcher lifecycle.
@@ -12,10 +13,33 @@ import { Signal } from "signal-polyfill";
12
13
  * once when the cleanup runs before the microtask fires.
13
14
  * - The `needsEnqueue` guard removes the duplicates of rapid synchronous signal
14
15
  * changes: one batch of synchronous changes queues one microtask only.
16
+ * - The watcher arms again BEFORE the callback: a callback that THROWS keeps the watch, a
17
+ * callback that RELEASES the watch leaves no watcher attached to the signal, and a
18
+ * callback that WRITES the signal reaches the caller with the value that it wrote.
19
+ * - The watcher arms with NO argument: `watch(signal)` records the edge a second time on
20
+ * every emission, so the edge list of the watcher and the consumer list of the signal
21
+ * grow by one for each change, for the life of the watch.
22
+ *
23
+ * A CAUTION for a callback that writes the signal that it watches: the write reaches the
24
+ * callback, so a callback that writes a NEW value on every call runs again for ever, and
25
+ * the microtask queue drains never. That is the loop of the caller, and this function
26
+ * bounds it not — a bound here would drop the write of a caller that repairs its state
27
+ * one time, which is the case that the order above exists to serve. A caller that repairs
28
+ * from a failure must repair to a state that succeeds, or it must stop after one attempt.
29
+ * A write of the SAME value ends the sequence by itself, because a signal notifies for a
30
+ * value that it holds already never.
31
+ *
32
+ * The return is a {@link Cleanup}: a function, and a `Disposable`. A caller that keeps
33
+ * the release in a field and runs it from a teardown keeps that code. A caller inside one
34
+ * scope writes `using` and writes no teardown:
35
+ *
36
+ * ```ts
37
+ * using stop = watchSignal(count, render);
38
+ * ```
15
39
  *
16
40
  * @param signal - The `Signal.State` or `Signal.Computed` to subscribe to.
17
41
  * @param onValue - The watcher calls it with the current signal value after each change.
18
- * @returns The cleanup function. It removes the watcher.
42
+ * @returns The release. It removes the watcher.
19
43
  */
20
- export declare function watchSignal<T>(signal: Signal.State<T> | Signal.Computed<T>, onValue: (value: T) => void): () => void;
44
+ export declare function watchSignal<T>(signal: Signal.State<T> | Signal.Computed<T>, onValue: (value: T) => void): Cleanup;
21
45
  //# sourceMappingURL=watch-signal.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"watch-signal.d.ts","sourceRoot":"","sources":["../src/watch-signal.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,iBAAiB,CAAC;AAEzC;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,WAAW,CAAC,CAAC,EAC5B,MAAM,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,GAAG,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,EAC5C,OAAO,EAAE,CAAC,KAAK,EAAE,CAAC,KAAK,IAAI,GACzB,MAAM,IAAI,CA0BZ"}
1
+ {"version":3,"file":"watch-signal.d.ts","sourceRoot":"","sources":["../src/watch-signal.ts"],"names":[],"mappings":"AAAA,OAAO,EAAa,KAAK,OAAO,EAAE,MAAM,iBAAiB,CAAC;AAC1D,OAAO,EAAE,MAAM,EAAE,MAAM,iBAAiB,CAAC;AAEzC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AACH,wBAAgB,WAAW,CAAC,CAAC,EAC5B,MAAM,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,GAAG,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,EAC5C,OAAO,EAAE,CAAC,KAAK,EAAE,CAAC,KAAK,IAAI,GACzB,OAAO,CAqDT"}
@@ -1,3 +1,4 @@
1
+ import { asCleanup } from "@xmachines/play";
1
2
  import { Signal } from "signal-polyfill";
2
3
  /**
3
4
  * Subscribes to one signal, with the canonical one-shot watcher lifecycle.
@@ -12,10 +13,33 @@ import { Signal } from "signal-polyfill";
12
13
  * once when the cleanup runs before the microtask fires.
13
14
  * - The `needsEnqueue` guard removes the duplicates of rapid synchronous signal
14
15
  * changes: one batch of synchronous changes queues one microtask only.
16
+ * - The watcher arms again BEFORE the callback: a callback that THROWS keeps the watch, a
17
+ * callback that RELEASES the watch leaves no watcher attached to the signal, and a
18
+ * callback that WRITES the signal reaches the caller with the value that it wrote.
19
+ * - The watcher arms with NO argument: `watch(signal)` records the edge a second time on
20
+ * every emission, so the edge list of the watcher and the consumer list of the signal
21
+ * grow by one for each change, for the life of the watch.
22
+ *
23
+ * A CAUTION for a callback that writes the signal that it watches: the write reaches the
24
+ * callback, so a callback that writes a NEW value on every call runs again for ever, and
25
+ * the microtask queue drains never. That is the loop of the caller, and this function
26
+ * bounds it not — a bound here would drop the write of a caller that repairs its state
27
+ * one time, which is the case that the order above exists to serve. A caller that repairs
28
+ * from a failure must repair to a state that succeeds, or it must stop after one attempt.
29
+ * A write of the SAME value ends the sequence by itself, because a signal notifies for a
30
+ * value that it holds already never.
31
+ *
32
+ * The return is a {@link Cleanup}: a function, and a `Disposable`. A caller that keeps
33
+ * the release in a field and runs it from a teardown keeps that code. A caller inside one
34
+ * scope writes `using` and writes no teardown:
35
+ *
36
+ * ```ts
37
+ * using stop = watchSignal(count, render);
38
+ * ```
15
39
  *
16
40
  * @param signal - The `Signal.State` or `Signal.Computed` to subscribe to.
17
41
  * @param onValue - The watcher calls it with the current signal value after each change.
18
- * @returns The cleanup function. It removes the watcher.
42
+ * @returns The release. It removes the watcher.
19
43
  */
20
44
  export function watchSignal(signal, onValue) {
21
45
  let disposed = false;
@@ -29,19 +53,46 @@ export function watchSignal(signal, onValue) {
29
53
  return;
30
54
  needsEnqueue = true;
31
55
  watcher.getPending();
56
+ // Arm the watcher BEFORE the callback, and not after it. Three cases need this
57
+ // order, and no case needs the other one.
58
+ //
59
+ // A callback that WRITES the signal notifies the watcher on that write — a
60
+ // renderer that gives its host an error handler, and the host repairs the actor
61
+ // from that handler. The watcher must accept the notification at that moment. A
62
+ // line after the callback arms it too late: the write finds the watcher still
63
+ // notified, the polyfill drops the notification, and the line then clears the
64
+ // flag for a change that reaches nobody. Measured: a callback that reads 1 and
65
+ // writes 2 reads 3 next, and the value 2 reaches the caller never.
66
+ //
67
+ // A callback that THROWS keeps the watch, because this line ran already. Before
68
+ // it, such a callback left the watcher attached to the signal and never armed:
69
+ // no later change reached the callback, the caller followed the signal no more,
70
+ // in silence and for the life of the signal.
71
+ //
72
+ // A callback that RELEASES the watch from inside itself leaves no watcher on the
73
+ // signal: the release detaches the edge, and this line adds none.
74
+ //
75
+ // NO argument. `watch()` with no signal resets the notified state alone, which is
76
+ // what this line needs: the edge to the signal stands from the `watch(signal)`
77
+ // below. `watch(signal)` records the edge a SECOND time — the watcher appends the
78
+ // producer at the next free index, and it resets that index never — so the edge
79
+ // list of the watcher and the live-consumer list of the signal grow by one for
80
+ // EVERY emission. Measured with the polyfill: 50 emissions leave 51 entries in
81
+ // each list. A view that streams therefore leaks one entry for each chunk, and
82
+ // each later change walks the whole list.
83
+ watcher.watch();
32
84
  onValue(signal.get());
33
- watcher.watch(signal);
34
85
  });
35
86
  });
36
87
  watcher.watch(signal);
37
- return () => {
88
+ return asCleanup(() => {
38
89
  disposed = true;
39
90
  try {
40
91
  watcher.unwatch(signal);
41
92
  }
42
93
  catch {
43
- // Ignore an error of a detached watcher. The cleanup therefore stays idempotent.
94
+ // Ignore an error of a detached watcher. The release therefore stays idempotent.
44
95
  }
45
- };
96
+ });
46
97
  }
47
98
  //# sourceMappingURL=watch-signal.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"watch-signal.js","sourceRoot":"","sources":["../src/watch-signal.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,iBAAiB,CAAC;AAEzC;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,UAAU,WAAW,CAC1B,MAA4C,EAC5C,OAA2B;IAE3B,IAAI,QAAQ,GAAG,KAAK,CAAC;IACrB,IAAI,YAAY,GAAG,IAAI,CAAC;IAExB,MAAM,OAAO,GAAG,IAAI,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC,GAAG,EAAE;QAC9C,IAAI,QAAQ,IAAI,CAAC,YAAY;YAAE,OAAO;QACtC,YAAY,GAAG,KAAK,CAAC;QACrB,cAAc,CAAC,GAAG,EAAE;YACnB,IAAI,QAAQ;gBAAE,OAAO;YACrB,YAAY,GAAG,IAAI,CAAC;YACpB,OAAO,CAAC,UAAU,EAAE,CAAC;YACrB,OAAO,CAAC,MAAM,CAAC,GAAG,EAAE,CAAC,CAAC;YACtB,OAAO,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC;QACvB,CAAC,CAAC,CAAC;IACJ,CAAC,CAAC,CAAC;IAEH,OAAO,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC;IAEtB,OAAO,GAAG,EAAE;QACX,QAAQ,GAAG,IAAI,CAAC;QAChB,IAAI,CAAC;YACJ,OAAO,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;QACzB,CAAC;QAAC,MAAM,CAAC;YACR,iFAAiF;QAClF,CAAC;IACF,CAAC,CAAC;AACH,CAAC"}
1
+ {"version":3,"file":"watch-signal.js","sourceRoot":"","sources":["../src/watch-signal.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAgB,MAAM,iBAAiB,CAAC;AAC1D,OAAO,EAAE,MAAM,EAAE,MAAM,iBAAiB,CAAC;AAEzC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AACH,MAAM,UAAU,WAAW,CAC1B,MAA4C,EAC5C,OAA2B;IAE3B,IAAI,QAAQ,GAAG,KAAK,CAAC;IACrB,IAAI,YAAY,GAAG,IAAI,CAAC;IAExB,MAAM,OAAO,GAAG,IAAI,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC,GAAG,EAAE;QAC9C,IAAI,QAAQ,IAAI,CAAC,YAAY;YAAE,OAAO;QACtC,YAAY,GAAG,KAAK,CAAC;QACrB,cAAc,CAAC,GAAG,EAAE;YACnB,IAAI,QAAQ;gBAAE,OAAO;YACrB,YAAY,GAAG,IAAI,CAAC;YACpB,OAAO,CAAC,UAAU,EAAE,CAAC;YACrB,+EAA+E;YAC/E,0CAA0C;YAC1C,EAAE;YACF,2EAA2E;YAC3E,gFAAgF;YAChF,gFAAgF;YAChF,8EAA8E;YAC9E,8EAA8E;YAC9E,+EAA+E;YAC/E,mEAAmE;YACnE,EAAE;YACF,gFAAgF;YAChF,+EAA+E;YAC/E,gFAAgF;YAChF,6CAA6C;YAC7C,EAAE;YACF,iFAAiF;YACjF,kEAAkE;YAClE,EAAE;YACF,kFAAkF;YAClF,+EAA+E;YAC/E,kFAAkF;YAClF,gFAAgF;YAChF,+EAA+E;YAC/E,+EAA+E;YAC/E,+EAA+E;YAC/E,0CAA0C;YAC1C,OAAO,CAAC,KAAK,EAAE,CAAC;YAChB,OAAO,CAAC,MAAM,CAAC,GAAG,EAAE,CAAC,CAAC;QACvB,CAAC,CAAC,CAAC;IACJ,CAAC,CAAC,CAAC;IAEH,OAAO,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC;IAEtB,OAAO,SAAS,CAAC,GAAG,EAAE;QACrB,QAAQ,GAAG,IAAI,CAAC;QAChB,IAAI,CAAC;YACJ,OAAO,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;QACzB,CAAC;QAAC,MAAM,CAAC;YACR,iFAAiF;QAClF,CAAC;IACF,CAAC,CAAC,CAAC;AACJ,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmachines/play-signals",
3
- "version": "2.2.0",
3
+ "version": "4.0.0",
4
4
  "private": false,
5
5
  "description": "TC39 Signals polyfill for XMachines - Fine-grained reactive state primitives",
6
6
  "keywords": [
@@ -43,6 +43,7 @@
43
43
  "test": "vitest",
44
44
  "test:coverage": "vitest run --coverage",
45
45
  "lint": "oxlint .",
46
+ "lint:security": "node ../../scripts/semgrep-scan.mjs",
46
47
  "lint:fix": "oxlint --fix .",
47
48
  "format": "oxfmt .",
48
49
  "format:check": "oxfmt --check ."
@@ -51,15 +52,19 @@
51
52
  "signal-polyfill": "^0.2.2"
52
53
  },
53
54
  "devDependencies": {
54
- "@testing-library/jest-dom": "^6.9.1",
55
- "@types/node": "^26.2.0",
56
- "@vitest/browser-playwright": "^4.1.11",
57
- "oxfmt": "^0.64.0",
58
- "oxlint": "^1.79.0",
59
- "vite": "^8.0.10",
60
- "vitest": "^4.1.11"
55
+ "@testing-library/jest-dom": "^7.0.1",
56
+ "@types/node": "^26.6.2",
57
+ "@vitest/browser-playwright": "^5.0.1",
58
+ "@xmachines/play": "4.0.0",
59
+ "oxfmt": "^0.68.0",
60
+ "oxlint": "^1.83.0",
61
+ "vite": "^8.3.0",
62
+ "vitest": "^5.0.1"
63
+ },
64
+ "peerDependencies": {
65
+ "@xmachines/play": "4.0.0"
61
66
  },
62
67
  "engines": {
63
- "node": ">=22.0.0"
68
+ "node": ">=24.0.0"
64
69
  }
65
70
  }