@webjsdev/core 0.7.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 +55 -0
- package/index.d.ts +28 -0
- package/index.js +39 -0
- package/package.json +46 -0
- package/src/component.d.ts +120 -0
- package/src/component.js +1061 -0
- package/src/context.js +327 -0
- package/src/css.js +54 -0
- package/src/directives.js +454 -0
- package/src/escape.js +15 -0
- package/src/expose.js +90 -0
- package/src/html.js +30 -0
- package/src/lazy-loader.js +107 -0
- package/src/nav.js +45 -0
- package/src/registry.js +190 -0
- package/src/render-client.js +1717 -0
- package/src/render-server.js +1128 -0
- package/src/repeat.js +37 -0
- package/src/rich-fetch.js +70 -0
- package/src/router-client.js +1434 -0
- package/src/serialize.js +452 -0
- package/src/signal.js +684 -0
- package/src/slot.js +860 -0
- package/src/suspense.js +46 -0
- package/src/task.js +386 -0
- package/src/testing.js +157 -0
- package/src/webjs-frame.js +65 -0
- package/src/websocket-client.js +111 -0
package/src/signal.js
ADDED
|
@@ -0,0 +1,684 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signals, reactive scalar values that re-render dependents when they
|
|
3
|
+
* change.
|
|
4
|
+
*
|
|
5
|
+
* The shape mirrors the TC39 Signals proposal (Stage 1). When the
|
|
6
|
+
* proposal lands in browsers this module becomes a one-line re-export.
|
|
7
|
+
*
|
|
8
|
+
* import { signal, computed, effect } from '@webjsdev/core';
|
|
9
|
+
*
|
|
10
|
+
* const count = signal(0);
|
|
11
|
+
* const doubled = computed(() => count.get() * 2);
|
|
12
|
+
* count.set(count.get() + 1);
|
|
13
|
+
*
|
|
14
|
+
* Inside a WebComponent render(), any signal read is tracked
|
|
15
|
+
* automatically. The component re-renders when any read signal changes.
|
|
16
|
+
* For fine-grained reactivity (only one template hole updates), use the
|
|
17
|
+
* `watch()` directive from `@webjsdev/core/directives`.
|
|
18
|
+
*
|
|
19
|
+
* Cross-request safety note (SSR): a module-scope signal lives for the
|
|
20
|
+
* lifetime of the Node process, so `let count = signal(0)` at the top
|
|
21
|
+
* of a server module would leak state between requests. Keep
|
|
22
|
+
* module-scope signals in browser-only modules. For request-scoped
|
|
23
|
+
* server state, use per-instance signals or the request context
|
|
24
|
+
* primitives in `@webjsdev/server`.
|
|
25
|
+
*
|
|
26
|
+
* Algorithm: push-pull hybrid (matches signal-polyfill).
|
|
27
|
+
*
|
|
28
|
+
* - Each producer (State or Computed) carries a `version`. A State
|
|
29
|
+
* bumps its version on a real `set()` (where the new value is NOT
|
|
30
|
+
* `.equal` to the old). A Computed bumps its version after recompute
|
|
31
|
+
* only when the recomputed output is not equal to the prior.
|
|
32
|
+
*
|
|
33
|
+
* - A consumer (Computed or Watcher) records, per producer it reads,
|
|
34
|
+
* the version it saw at read time. On the next read, the consumer
|
|
35
|
+
* polls each producer: if the stored version still matches, that
|
|
36
|
+
* producer hasn't actually changed and recomputation can be skipped.
|
|
37
|
+
*
|
|
38
|
+
* - On `set()`, a producer pushes a dirty mark down to live consumers
|
|
39
|
+
* (consumers that need notification because they're watched). Push
|
|
40
|
+
* marks the consumer dirty cheaply; the version check on read is
|
|
41
|
+
* what determines whether a recompute happens. This is what makes
|
|
42
|
+
* diamond dependencies glitch-free and memoizes the common case
|
|
43
|
+
* where a computed's output doesn't actually change.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/* ============================================================
|
|
47
|
+
* Graph primitives
|
|
48
|
+
* ============================================================ */
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The currently-active consumer (Computed or Watcher) whose dependency
|
|
52
|
+
* set is being recorded. `null` at top-level / outside any reactive
|
|
53
|
+
* context. Reads of producers while this is non-null register an edge.
|
|
54
|
+
* @type {ReactiveNode | null}
|
|
55
|
+
*/
|
|
56
|
+
let activeConsumer = null;
|
|
57
|
+
|
|
58
|
+
/** Are we currently inside a Watcher notify callback? Reads + writes throw. */
|
|
59
|
+
let inNotificationPhase = false;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Global epoch counter. Bumped on every `State.set` that produces a
|
|
63
|
+
* value change. Lets clean consumers skip producer polling entirely
|
|
64
|
+
* when no source has changed since their last read.
|
|
65
|
+
*/
|
|
66
|
+
let epoch = 0;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Batched set: pending watcher notifications, drained at the outermost
|
|
70
|
+
* batch close. Inside a batch, computed reads see the new values
|
|
71
|
+
* immediately; only Watcher notify firing is deferred.
|
|
72
|
+
* @type {Set<ReactiveNode> | null}
|
|
73
|
+
*/
|
|
74
|
+
let batchPending = null;
|
|
75
|
+
let batchDepth = 0;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @typedef {Object} ReactiveNode
|
|
79
|
+
* @property {number} version
|
|
80
|
+
* @property {number} lastCleanEpoch
|
|
81
|
+
* @property {boolean} dirty
|
|
82
|
+
* @property {ReactiveNode[]} producers
|
|
83
|
+
* @property {number[]} producerLastReadVersions
|
|
84
|
+
* @property {Set<ReactiveNode>=} liveConsumers
|
|
85
|
+
* @property {boolean} consumerAllowSignalWrites
|
|
86
|
+
* @property {boolean} consumerIsAlwaysLive
|
|
87
|
+
* @property {(this: ReactiveNode) => boolean} producerMustRecompute
|
|
88
|
+
* @property {(this: ReactiveNode) => void} producerRecomputeValue
|
|
89
|
+
* @property {(this: ReactiveNode) => void} consumerMarkedDirty
|
|
90
|
+
* @property {any} wrapper
|
|
91
|
+
* @property {(this: any) => void=} watched
|
|
92
|
+
* @property {(this: any) => void=} unwatched
|
|
93
|
+
*/
|
|
94
|
+
|
|
95
|
+
/** Default equality check, identical to TC39 spec default. */
|
|
96
|
+
const defaultEquals = (a, b) => Object.is(a, b);
|
|
97
|
+
|
|
98
|
+
const NOOP = () => {};
|
|
99
|
+
const FALSE_FN = () => false;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Add `consumer` as a live consumer of `node`. If `node` transitions
|
|
103
|
+
* from 0 to 1+ live consumers AND is itself a consumer (a Computed),
|
|
104
|
+
* recursively register `node` as a live consumer of its own producers.
|
|
105
|
+
* This is how source `set()` calls reach watchers across computed
|
|
106
|
+
* chains: the push side of the graph propagates upward only when
|
|
107
|
+
* something downstream is actually live.
|
|
108
|
+
*/
|
|
109
|
+
function producerAddLiveConsumer(node, consumer) {
|
|
110
|
+
if (!node.liveConsumers) node.liveConsumers = new Set();
|
|
111
|
+
const wasEmpty = node.liveConsumers.size === 0;
|
|
112
|
+
node.liveConsumers.add(consumer);
|
|
113
|
+
if (wasEmpty) {
|
|
114
|
+
if (node.producers && node.producers.length > 0) {
|
|
115
|
+
for (const p of node.producers) producerAddLiveConsumer(p, node);
|
|
116
|
+
}
|
|
117
|
+
if (node.watched) {
|
|
118
|
+
try { node.watched.call(node.wrapper); } catch (e) { console.error(e); }
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Inverse of producerAddLiveConsumer. Cascades teardown upward. */
|
|
124
|
+
function producerRemoveLiveConsumer(node, consumer) {
|
|
125
|
+
if (!node.liveConsumers) return;
|
|
126
|
+
node.liveConsumers.delete(consumer);
|
|
127
|
+
if (node.liveConsumers.size === 0) {
|
|
128
|
+
if (node.producers && node.producers.length > 0) {
|
|
129
|
+
for (const p of node.producers) producerRemoveLiveConsumer(p, node);
|
|
130
|
+
}
|
|
131
|
+
if (node.unwatched) {
|
|
132
|
+
try { node.unwatched.call(node.wrapper); } catch (e) { console.error(e); }
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Called whenever a producer is read (during a Computed body or a
|
|
139
|
+
* Watcher's tracked region). Records the edge.
|
|
140
|
+
* @param {ReactiveNode} node
|
|
141
|
+
*/
|
|
142
|
+
function producerAccessed(node) {
|
|
143
|
+
if (inNotificationPhase) {
|
|
144
|
+
throw new Error('Signal read during notification phase');
|
|
145
|
+
}
|
|
146
|
+
if (activeConsumer === null) return;
|
|
147
|
+
|
|
148
|
+
const consumer = activeConsumer;
|
|
149
|
+
const idx = consumer.nextProducerIndex++;
|
|
150
|
+
|
|
151
|
+
if (idx < consumer.producers.length && consumer.producers[idx] !== node) {
|
|
152
|
+
// The producer at this slot from the previous evaluation isn't the
|
|
153
|
+
// same as the one being read now. Drop the stale live edge.
|
|
154
|
+
if (consumerIsLive(consumer)) {
|
|
155
|
+
producerRemoveLiveConsumer(consumer.producers[idx], consumer);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (consumer.producers[idx] !== node) {
|
|
160
|
+
consumer.producers[idx] = node;
|
|
161
|
+
if (consumerIsLive(consumer)) {
|
|
162
|
+
producerAddLiveConsumer(node, consumer);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
consumer.producerLastReadVersions[idx] = node.version;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** True if `node` has any live consumer (watched or under a live computed). */
|
|
169
|
+
function consumerIsLive(node) {
|
|
170
|
+
return node.consumerIsAlwaysLive || (node.liveConsumers && node.liveConsumers.size > 0);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Walk live consumers, mark them dirty, cascade. This is the push side
|
|
175
|
+
* of push-pull: it does NOT pre-emptively recompute computeds, only
|
|
176
|
+
* marks them so the next read knows to check.
|
|
177
|
+
* @param {ReactiveNode} node
|
|
178
|
+
*/
|
|
179
|
+
function producerNotifyConsumers(node) {
|
|
180
|
+
if (!node.liveConsumers || node.liveConsumers.size === 0) return;
|
|
181
|
+
const prev = inNotificationPhase;
|
|
182
|
+
inNotificationPhase = true;
|
|
183
|
+
try {
|
|
184
|
+
for (const consumer of node.liveConsumers) {
|
|
185
|
+
if (!consumer.dirty) {
|
|
186
|
+
consumerMarkDirty(consumer);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
} finally {
|
|
190
|
+
inNotificationPhase = prev;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Mark a consumer dirty, cascade dirty to its own live consumers, fire its callback. */
|
|
195
|
+
function consumerMarkDirty(node) {
|
|
196
|
+
node.dirty = true;
|
|
197
|
+
producerNotifyConsumers(node);
|
|
198
|
+
node.consumerMarkedDirty.call(node.wrapper || node);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Refresh `node.version` to reflect the current state of its inputs.
|
|
203
|
+
* For a Computed, this re-runs the computation if any input version
|
|
204
|
+
* has changed since the last evaluation. Memoizes via the `equal` check:
|
|
205
|
+
* if recomputed output equals prior, `version` is not bumped.
|
|
206
|
+
* @param {ReactiveNode} node
|
|
207
|
+
*/
|
|
208
|
+
function producerUpdateValueVersion(node) {
|
|
209
|
+
if (!node.dirty && node.lastCleanEpoch === epoch) return;
|
|
210
|
+
if (!node.producerMustRecompute() && !consumerPollProducersForChange(node)) {
|
|
211
|
+
node.dirty = false;
|
|
212
|
+
node.lastCleanEpoch = epoch;
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
node.producerRecomputeValue();
|
|
216
|
+
node.dirty = false;
|
|
217
|
+
node.lastCleanEpoch = epoch;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Walk producers, recursively refresh each, check if any version moved. */
|
|
221
|
+
function consumerPollProducersForChange(node) {
|
|
222
|
+
for (let i = 0; i < node.producers.length; i++) {
|
|
223
|
+
const producer = node.producers[i];
|
|
224
|
+
const seenVersion = node.producerLastReadVersions[i];
|
|
225
|
+
if (seenVersion !== producer.version) return true;
|
|
226
|
+
producerUpdateValueVersion(producer);
|
|
227
|
+
if (seenVersion !== producer.version) return true;
|
|
228
|
+
}
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Begin a tracked region: this node's producers will be recorded. */
|
|
233
|
+
function consumerBeforeComputation(node) {
|
|
234
|
+
if (node) node.nextProducerIndex = 0;
|
|
235
|
+
const prev = activeConsumer;
|
|
236
|
+
activeConsumer = node;
|
|
237
|
+
return prev;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** End a tracked region: prune stale edges, restore previous consumer. */
|
|
241
|
+
function consumerAfterComputation(node, prev) {
|
|
242
|
+
activeConsumer = prev;
|
|
243
|
+
if (!node) return;
|
|
244
|
+
if (consumerIsLive(node)) {
|
|
245
|
+
for (let i = node.nextProducerIndex; i < node.producers.length; i++) {
|
|
246
|
+
producerRemoveLiveConsumer(node.producers[i], node);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
while (node.producers.length > node.nextProducerIndex) {
|
|
250
|
+
node.producers.pop();
|
|
251
|
+
node.producerLastReadVersions.pop();
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/* ============================================================
|
|
256
|
+
* State signal
|
|
257
|
+
* ============================================================ */
|
|
258
|
+
|
|
259
|
+
class SignalState {
|
|
260
|
+
/**
|
|
261
|
+
* @param {unknown} initial
|
|
262
|
+
* @param {{equals?: (a:unknown,b:unknown)=>boolean, [k:symbol]: any}=} options
|
|
263
|
+
*/
|
|
264
|
+
constructor(initial, options) {
|
|
265
|
+
this.__value = initial;
|
|
266
|
+
this.version = 0;
|
|
267
|
+
this.lastCleanEpoch = epoch;
|
|
268
|
+
this.dirty = false;
|
|
269
|
+
this.producers = [];
|
|
270
|
+
this.producerLastReadVersions = [];
|
|
271
|
+
this.liveConsumers = undefined;
|
|
272
|
+
this.consumerAllowSignalWrites = true;
|
|
273
|
+
this.consumerIsAlwaysLive = false;
|
|
274
|
+
this.producerMustRecompute = FALSE_FN;
|
|
275
|
+
this.producerRecomputeValue = NOOP;
|
|
276
|
+
this.consumerMarkedDirty = NOOP;
|
|
277
|
+
this.wrapper = this;
|
|
278
|
+
this.__isSignal = true;
|
|
279
|
+
this.__equal = options?.equals || defaultEquals;
|
|
280
|
+
this.watched = options?.[WATCHED];
|
|
281
|
+
this.unwatched = options?.[UNWATCHED];
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
get() {
|
|
285
|
+
producerAccessed(this);
|
|
286
|
+
return this.__value;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
peek() { return this.__value; }
|
|
290
|
+
|
|
291
|
+
set(v) {
|
|
292
|
+
if (inNotificationPhase) {
|
|
293
|
+
throw new Error('Signal write during notification phase');
|
|
294
|
+
}
|
|
295
|
+
if (this.__equal.call(this, this.__value, v)) return;
|
|
296
|
+
this.__value = v;
|
|
297
|
+
this.version++;
|
|
298
|
+
epoch++;
|
|
299
|
+
if (batchPending !== null) {
|
|
300
|
+
// Collect watchers whose notify is deferred; computeds still see
|
|
301
|
+
// the new value via versioning.
|
|
302
|
+
collectBatched(this);
|
|
303
|
+
} else {
|
|
304
|
+
producerNotifyConsumers(this);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* During a batch, walk the live-consumer tree once and stash watchers
|
|
311
|
+
* for deferred firing. Computeds get their dirty flag set the same way;
|
|
312
|
+
* only the Watcher notify fires later.
|
|
313
|
+
*/
|
|
314
|
+
function collectBatched(node) {
|
|
315
|
+
const prev = inNotificationPhase;
|
|
316
|
+
inNotificationPhase = true;
|
|
317
|
+
try {
|
|
318
|
+
if (!node.liveConsumers) return;
|
|
319
|
+
for (const consumer of node.liveConsumers) {
|
|
320
|
+
if (consumer.dirty) continue;
|
|
321
|
+
consumer.dirty = true;
|
|
322
|
+
if (consumer.__isWatcher) {
|
|
323
|
+
batchPending.add(consumer);
|
|
324
|
+
} else {
|
|
325
|
+
collectBatched(consumer);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
} finally {
|
|
329
|
+
inNotificationPhase = prev;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/* ============================================================
|
|
334
|
+
* Computed signal
|
|
335
|
+
* ============================================================ */
|
|
336
|
+
|
|
337
|
+
const UNSET = Symbol('unset');
|
|
338
|
+
const COMPUTING = Symbol('computing');
|
|
339
|
+
const ERRORED = Symbol('errored');
|
|
340
|
+
|
|
341
|
+
class SignalComputed {
|
|
342
|
+
/**
|
|
343
|
+
* @param {() => unknown} fn
|
|
344
|
+
* @param {{equals?: (a:unknown,b:unknown)=>boolean, [k:symbol]: any}=} options
|
|
345
|
+
*/
|
|
346
|
+
constructor(fn, options) {
|
|
347
|
+
this.__fn = fn;
|
|
348
|
+
this.__value = UNSET;
|
|
349
|
+
this.__error = null;
|
|
350
|
+
this.version = 0;
|
|
351
|
+
this.lastCleanEpoch = -1;
|
|
352
|
+
this.dirty = true;
|
|
353
|
+
this.producers = [];
|
|
354
|
+
this.producerLastReadVersions = [];
|
|
355
|
+
this.nextProducerIndex = 0;
|
|
356
|
+
this.liveConsumers = undefined;
|
|
357
|
+
this.consumerAllowSignalWrites = false;
|
|
358
|
+
this.consumerIsAlwaysLive = false;
|
|
359
|
+
this.wrapper = this;
|
|
360
|
+
this.__isSignal = true;
|
|
361
|
+
this.__isComputed = true;
|
|
362
|
+
this.__equal = options?.equals || defaultEquals;
|
|
363
|
+
this.watched = options?.[WATCHED];
|
|
364
|
+
this.unwatched = options?.[UNWATCHED];
|
|
365
|
+
|
|
366
|
+
const self = this;
|
|
367
|
+
this.producerMustRecompute = function() {
|
|
368
|
+
return self.__value === UNSET || self.__value === COMPUTING;
|
|
369
|
+
};
|
|
370
|
+
this.producerRecomputeValue = function() {
|
|
371
|
+
if (self.__value === COMPUTING) {
|
|
372
|
+
throw new Error('Detected cycle in computations.');
|
|
373
|
+
}
|
|
374
|
+
const oldValue = self.__value;
|
|
375
|
+
self.__value = COMPUTING;
|
|
376
|
+
const prev = consumerBeforeComputation(self);
|
|
377
|
+
let newValue;
|
|
378
|
+
let isError = false;
|
|
379
|
+
try {
|
|
380
|
+
newValue = self.__fn.call(self.wrapper);
|
|
381
|
+
} catch (err) {
|
|
382
|
+
newValue = ERRORED;
|
|
383
|
+
self.__error = err;
|
|
384
|
+
isError = true;
|
|
385
|
+
} finally {
|
|
386
|
+
consumerAfterComputation(self, prev);
|
|
387
|
+
}
|
|
388
|
+
const oldOk = oldValue !== UNSET && oldValue !== ERRORED;
|
|
389
|
+
if (!isError && oldOk && self.__equal.call(self.wrapper, oldValue, newValue)) {
|
|
390
|
+
// Output unchanged. Restore prior value, don't bump version,
|
|
391
|
+
// so downstream consumers can short-circuit on the version check.
|
|
392
|
+
self.__value = oldValue;
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
self.__value = newValue;
|
|
396
|
+
self.version++;
|
|
397
|
+
};
|
|
398
|
+
this.consumerMarkedDirty = NOOP;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
get() {
|
|
402
|
+
producerUpdateValueVersion(this);
|
|
403
|
+
producerAccessed(this);
|
|
404
|
+
if (this.__value === ERRORED) throw this.__error;
|
|
405
|
+
return this.__value;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
peek() {
|
|
409
|
+
producerUpdateValueVersion(this);
|
|
410
|
+
if (this.__value === ERRORED) throw this.__error;
|
|
411
|
+
return this.__value;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/* ============================================================
|
|
416
|
+
* Watcher
|
|
417
|
+
* ============================================================ */
|
|
418
|
+
|
|
419
|
+
class SignalWatcher {
|
|
420
|
+
/** @param {() => void} notifyCb */
|
|
421
|
+
constructor(notifyCb) {
|
|
422
|
+
this.__notify = notifyCb;
|
|
423
|
+
this.version = 0;
|
|
424
|
+
this.lastCleanEpoch = epoch;
|
|
425
|
+
this.dirty = false;
|
|
426
|
+
this.producers = [];
|
|
427
|
+
this.producerLastReadVersions = [];
|
|
428
|
+
this.nextProducerIndex = 0;
|
|
429
|
+
this.liveConsumers = undefined;
|
|
430
|
+
this.consumerAllowSignalWrites = false;
|
|
431
|
+
this.consumerIsAlwaysLive = true;
|
|
432
|
+
this.producerMustRecompute = FALSE_FN;
|
|
433
|
+
this.producerRecomputeValue = NOOP;
|
|
434
|
+
this.wrapper = this;
|
|
435
|
+
this.__isWatcher = true;
|
|
436
|
+
const self = this;
|
|
437
|
+
this.consumerMarkedDirty = function() { self.__fire(); };
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** Fire the user's notify cb once; dirty stays until `watch()` re-arms. */
|
|
441
|
+
__fire() {
|
|
442
|
+
if (this.__notify) this.__notify.call(this);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Spec API: register signals to watch. Without args, re-arms (clears
|
|
447
|
+
* the dirty mark so the next change will fire notify again).
|
|
448
|
+
* @param {...(SignalState|SignalComputed)} signals
|
|
449
|
+
*/
|
|
450
|
+
watch(...signals) {
|
|
451
|
+
if (signals.length === 0) {
|
|
452
|
+
// Re-arm.
|
|
453
|
+
this.dirty = false;
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
const prev = consumerBeforeComputation(this);
|
|
457
|
+
try {
|
|
458
|
+
for (const sig of signals) {
|
|
459
|
+
producerAccessed(sig);
|
|
460
|
+
if (sig.__isComputed) {
|
|
461
|
+
producerUpdateValueVersion(sig);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
} finally {
|
|
465
|
+
consumerAfterComputation(this, prev);
|
|
466
|
+
}
|
|
467
|
+
this.dirty = false;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Spec API: stop watching the given signals.
|
|
472
|
+
* @param {...(SignalState|SignalComputed)} signals
|
|
473
|
+
*/
|
|
474
|
+
unwatch(...signals) {
|
|
475
|
+
for (let i = this.producers.length - 1; i >= 0; i--) {
|
|
476
|
+
if (signals.includes(this.producers[i])) {
|
|
477
|
+
producerRemoveLiveConsumer(this.producers[i], this);
|
|
478
|
+
this.producers.splice(i, 1);
|
|
479
|
+
this.producerLastReadVersions.splice(i, 1);
|
|
480
|
+
this.nextProducerIndex = Math.min(this.nextProducerIndex, this.producers.length);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** Spec API: list watched Computeds that are currently dirty. */
|
|
486
|
+
getPending() {
|
|
487
|
+
return this.producers.filter((p) => p.__isComputed && p.dirty);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Convenience extension (not in TC39 spec). Runs `fn` with this
|
|
492
|
+
* watcher as the active consumer, so any signal `fn` reads is added
|
|
493
|
+
* to the watcher's producer set automatically. Re-arms on every call.
|
|
494
|
+
*
|
|
495
|
+
* Used by the WebComponent integration and the `watch()` directive
|
|
496
|
+
* because the natural webjs pattern is "watch everything this render
|
|
497
|
+
* touches" rather than enumerating signals up front.
|
|
498
|
+
*
|
|
499
|
+
* @param {() => unknown} fn
|
|
500
|
+
* @returns {unknown}
|
|
501
|
+
*/
|
|
502
|
+
observe(fn) {
|
|
503
|
+
const prev = consumerBeforeComputation(this);
|
|
504
|
+
let result;
|
|
505
|
+
try {
|
|
506
|
+
result = fn();
|
|
507
|
+
} finally {
|
|
508
|
+
consumerAfterComputation(this, prev);
|
|
509
|
+
}
|
|
510
|
+
this.dirty = false;
|
|
511
|
+
return result;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** Convenience extension: tear down all subscriptions. */
|
|
515
|
+
dispose() {
|
|
516
|
+
for (const producer of this.producers) {
|
|
517
|
+
producerRemoveLiveConsumer(producer, this);
|
|
518
|
+
}
|
|
519
|
+
this.producers = [];
|
|
520
|
+
this.producerLastReadVersions = [];
|
|
521
|
+
this.__notify = null;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/* ============================================================
|
|
526
|
+
* Subtle helpers
|
|
527
|
+
* ============================================================ */
|
|
528
|
+
|
|
529
|
+
const WATCHED = Symbol('watched');
|
|
530
|
+
const UNWATCHED = Symbol('unwatched');
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Run `fn` with dependency tracking suppressed. Reads inside see
|
|
534
|
+
* current values but are not recorded as edges of the surrounding
|
|
535
|
+
* consumer.
|
|
536
|
+
* @template T
|
|
537
|
+
* @param {() => T} fn
|
|
538
|
+
* @returns {T}
|
|
539
|
+
*/
|
|
540
|
+
function untrack(fn) {
|
|
541
|
+
const prev = activeConsumer;
|
|
542
|
+
activeConsumer = null;
|
|
543
|
+
try { return fn(); } finally { activeConsumer = prev; }
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/** @param {SignalComputed | SignalWatcher} sink */
|
|
547
|
+
function introspectSources(sink) {
|
|
548
|
+
return sink.producers.slice();
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** @param {SignalState | SignalComputed} producer */
|
|
552
|
+
function introspectSinks(producer) {
|
|
553
|
+
return producer.liveConsumers ? [...producer.liveConsumers] : [];
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** @param {SignalState | SignalComputed} producer */
|
|
557
|
+
function hasSinks(producer) {
|
|
558
|
+
return !!producer.liveConsumers && producer.liveConsumers.size > 0;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** @param {SignalComputed | SignalWatcher} sink */
|
|
562
|
+
function hasSources(sink) {
|
|
563
|
+
return sink.producers.length > 0;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/** The innermost active Computed, or null. */
|
|
567
|
+
function currentComputed() {
|
|
568
|
+
return activeConsumer && activeConsumer.__isComputed ? activeConsumer : null;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/* ============================================================
|
|
572
|
+
* Batch
|
|
573
|
+
* ============================================================ */
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Open a batch. All `set()` calls inside `fn` defer Watcher notify
|
|
577
|
+
* firing until the outermost batch closes. Inside the batch,
|
|
578
|
+
* subsequent `get()` reads see the new values immediately; only the
|
|
579
|
+
* Watcher notify callback is deferred.
|
|
580
|
+
*
|
|
581
|
+
* @param {() => void} fn
|
|
582
|
+
*/
|
|
583
|
+
export function batch(fn) {
|
|
584
|
+
batchDepth++;
|
|
585
|
+
if (batchPending === null) batchPending = new Set();
|
|
586
|
+
try {
|
|
587
|
+
fn();
|
|
588
|
+
} finally {
|
|
589
|
+
batchDepth--;
|
|
590
|
+
if (batchDepth === 0) {
|
|
591
|
+
const pending = batchPending;
|
|
592
|
+
batchPending = null;
|
|
593
|
+
for (const w of pending) w.__fire();
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/* ============================================================
|
|
599
|
+
* Public surface
|
|
600
|
+
* ============================================================ */
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* TC39-shaped namespace. `Signal.State`, `Signal.Computed`, plus
|
|
604
|
+
* `Signal.subtle.*` for advanced use (Watcher, untrack, introspection,
|
|
605
|
+
* the watched/unwatched symbols).
|
|
606
|
+
*/
|
|
607
|
+
export const Signal = {
|
|
608
|
+
State: SignalState,
|
|
609
|
+
Computed: SignalComputed,
|
|
610
|
+
subtle: {
|
|
611
|
+
Watcher: SignalWatcher,
|
|
612
|
+
untrack,
|
|
613
|
+
introspectSources,
|
|
614
|
+
introspectSinks,
|
|
615
|
+
hasSinks,
|
|
616
|
+
hasSources,
|
|
617
|
+
currentComputed,
|
|
618
|
+
watched: WATCHED,
|
|
619
|
+
unwatched: UNWATCHED,
|
|
620
|
+
},
|
|
621
|
+
};
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Convenience constructor for a writable signal.
|
|
625
|
+
* @template T
|
|
626
|
+
* @param {T} initial
|
|
627
|
+
* @param {{equals?: (a:T,b:T)=>boolean}=} options
|
|
628
|
+
* @returns {SignalState}
|
|
629
|
+
*/
|
|
630
|
+
export function signal(initial, options) { return new SignalState(initial, options); }
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Convenience constructor for a computed signal.
|
|
634
|
+
* @template T
|
|
635
|
+
* @param {() => T} fn
|
|
636
|
+
* @param {{equals?: (a:T,b:T)=>boolean}=} options
|
|
637
|
+
* @returns {SignalComputed}
|
|
638
|
+
*/
|
|
639
|
+
export function computed(fn, options) { return new SignalComputed(fn, options); }
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Predicate: discriminate a signal from a plain value.
|
|
643
|
+
* @param {unknown} v
|
|
644
|
+
*/
|
|
645
|
+
export function isSignal(v) {
|
|
646
|
+
return !!(v && typeof v === 'object' && /** @type any */ (v).__isSignal === true);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* Run `fn` once, then re-run it whenever any signal it read changes.
|
|
651
|
+
* Returns a dispose function.
|
|
652
|
+
*
|
|
653
|
+
* Effects are useful for browser-only side effects such as DOM
|
|
654
|
+
* mutations, timers, or fetch calls. Avoid in render paths; the
|
|
655
|
+
* component's built-in SignalWatcher already covers re-render.
|
|
656
|
+
*
|
|
657
|
+
* The spec forbids reads/writes inside a Watcher notify, so effects
|
|
658
|
+
* defer the re-run to a microtask.
|
|
659
|
+
*
|
|
660
|
+
* @param {() => void} fn
|
|
661
|
+
* @returns {() => void}
|
|
662
|
+
*/
|
|
663
|
+
export function effect(fn) {
|
|
664
|
+
let scheduled = false;
|
|
665
|
+
let disposed = false;
|
|
666
|
+
const computed_ = new SignalComputed(fn);
|
|
667
|
+
const w = new SignalWatcher(() => {
|
|
668
|
+
if (scheduled || disposed) return;
|
|
669
|
+
scheduled = true;
|
|
670
|
+
queueMicrotask(() => {
|
|
671
|
+
scheduled = false;
|
|
672
|
+
if (disposed) return;
|
|
673
|
+
try { computed_.get(); } catch (e) { console.error(e); }
|
|
674
|
+
w.watch();
|
|
675
|
+
});
|
|
676
|
+
});
|
|
677
|
+
w.watch(computed_);
|
|
678
|
+
try { computed_.get(); } catch (e) { console.error(e); }
|
|
679
|
+
return () => {
|
|
680
|
+
if (disposed) return;
|
|
681
|
+
disposed = true;
|
|
682
|
+
w.dispose();
|
|
683
|
+
};
|
|
684
|
+
}
|