@qorejs/qore 0.7.3 → 1.0.0-rc.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.
Files changed (44) hide show
  1. package/README.md +275 -5
  2. package/dist/src/core/owner.d.ts +14 -0
  3. package/dist/src/core/owner.js +66 -0
  4. package/dist/src/core/signal-context.js +19 -9
  5. package/dist/src/core/signal-nodes.d.ts +5 -0
  6. package/dist/src/core/signal-nodes.js +51 -21
  7. package/dist/src/core/signal-types.d.ts +2 -0
  8. package/dist/src/core/signal.d.ts +1 -0
  9. package/dist/src/core/signal.js +2 -0
  10. package/dist/src/core/stream-types.d.ts +12 -0
  11. package/dist/src/core/stream.d.ts +1 -1
  12. package/dist/src/core/stream.js +144 -0
  13. package/dist/src/dom/app.js +2 -3
  14. package/dist/src/dom/dom.d.ts +1 -0
  15. package/dist/src/dom/dom.js +96 -9
  16. package/dist/src/dom/scope.d.ts +3 -1
  17. package/dist/src/dom/scope.js +10 -3
  18. package/dist/src/index.d.ts +10 -4
  19. package/dist/src/index.js +7 -2
  20. package/dist/src/providers/anthropic.js +6 -4
  21. package/dist/src/providers/deepseek.d.ts +2 -0
  22. package/dist/src/providers/deepseek.js +90 -0
  23. package/dist/src/providers/line-adapter.d.ts +2 -0
  24. package/dist/src/providers/line-adapter.js +83 -0
  25. package/dist/src/providers/line-parser.d.ts +5 -0
  26. package/dist/src/providers/line-parser.js +103 -0
  27. package/dist/src/providers/metadata.d.ts +8 -0
  28. package/dist/src/providers/metadata.js +193 -0
  29. package/dist/src/providers/ollama.d.ts +2 -0
  30. package/dist/src/providers/ollama.js +82 -0
  31. package/dist/src/providers/openai.js +6 -4
  32. package/dist/src/providers/openrouter.d.ts +2 -0
  33. package/dist/src/providers/openrouter.js +90 -0
  34. package/dist/src/providers/sse-adapter.js +129 -44
  35. package/dist/src/providers/sse-parser.d.ts +1 -1
  36. package/dist/src/providers/sse-parser.js +74 -38
  37. package/dist/src/providers/sse.d.ts +7 -1
  38. package/dist/src/providers/sse.js +6 -0
  39. package/dist/src/providers/types.d.ts +145 -0
  40. package/dist/src/server/sse-response.d.ts +16 -0
  41. package/dist/src/server/sse-response.js +102 -0
  42. package/dist/src/shared/utils.d.ts +1 -0
  43. package/dist/src/shared/utils.js +8 -1
  44. package/package.json +11 -2
package/README.md CHANGED
@@ -19,6 +19,7 @@ Quick links:
19
19
  - [npm package](https://www.npmjs.com/package/@qorejs/qore)
20
20
  - [latest release](https://github.com/qorejs/qore/releases/latest)
21
21
  - [GitHub Packages](https://github.com/qorejs/qore/packages)
22
+ - [migration notes](./MIGRATION.md)
22
23
  - [landing page source](https://github.com/qorejs/qore/blob/main/index.html)
23
24
  - [streaming demo source](https://github.com/qorejs/qore/blob/main/examples/streaming-response.html)
24
25
  - [benchmark page](https://github.com/qorejs/qore/blob/main/examples/benchmark.html)
@@ -44,6 +45,15 @@ npm i @qorejs/qore
44
45
 
45
46
  GitHub Packages installs require an authenticated session against `https://npm.pkg.github.com`.
46
47
 
48
+ For maintainers, the local npm release path now includes a fast preflight:
49
+
50
+ ```bash
51
+ npm run publish:preflight
52
+ npm run publish:npm
53
+ ```
54
+
55
+ The preflight checks npm auth, confirms the changelog matches `package.json`, and fails early if that exact version is already published.
56
+
47
57
  - Package name: `@qorejs/qore`
48
58
  - Module format: `ESM`
49
59
  - Supported runtime: `Node >= 18`
@@ -53,6 +63,19 @@ GitHub Packages installs require an authenticated session against `https://npm.p
53
63
  - npm: [npmjs.com/package/@qorejs/qore](https://www.npmjs.com/package/@qorejs/qore)
54
64
  - GitHub Packages: [github.com/qorejs/qore/packages](https://github.com/qorejs/qore/packages)
55
65
 
66
+ ## Compatibility Matrix
67
+
68
+ | Surface | Status | Notes |
69
+ | --- | --- | --- |
70
+ | Node runtime | Supported | `Node >= 18` |
71
+ | Browser runtime | Supported | browser DOM entrypoints require `document` |
72
+ | Reactive core | Supported | `signal`, `computed`, `effect`, `batch`, `createRoot`, `onCleanup` |
73
+ | Stream runtime | Supported | includes backpressure, retry, orchestration, async iteration, and abort |
74
+ | Provider adapters | Supported | OpenAI, Anthropic, OpenRouter, DeepSeek, Ollama, generic SSE, generic line-stream |
75
+ | Browser DOM layer | Supported | `h`, `text`, `dynamic`, `list`, `mount`, `createApp(...).mount(...)` |
76
+ | SSR | Not supported | explicit browser-only DOM boundary in `1.0.x` |
77
+ | Hydration | Not supported | deferred until a fully proven implementation exists |
78
+
56
79
  ## Core Idea
57
80
 
58
81
  `stream` is how data flows.
@@ -137,6 +160,57 @@ const anthropic = createAnthropic({
137
160
  const answer = stream(anthropic.chat('Why should stream be signal?'));
138
161
  ```
139
162
 
163
+ ### `createOpenRouter(options?)`
164
+
165
+ ```js
166
+ import { createOpenRouter, stream } from '@qorejs/qore';
167
+
168
+ const openrouter = createOpenRouter({
169
+ apiKey: process.env.OPENROUTER_API_KEY,
170
+ model: 'openai/gpt-4.1-mini'
171
+ });
172
+
173
+ const answer = stream(openrouter.chat('Why should stream be signal?'));
174
+ ```
175
+
176
+ ### `createDeepSeek(options?)`
177
+
178
+ ```js
179
+ import { createDeepSeek, stream } from '@qorejs/qore';
180
+
181
+ const deepseek = createDeepSeek({
182
+ apiKey: process.env.DEEPSEEK_API_KEY,
183
+ model: 'deepseek-chat'
184
+ });
185
+
186
+ const answer = stream(deepseek.chat('Why should stream be signal?'));
187
+ ```
188
+
189
+ Provider adapters also accept a request `signal` so you can cancel in-flight streams explicitly:
190
+
191
+ ```js
192
+ const controller = new AbortController();
193
+ const answer = stream(openrouter.chat('Keep streaming', {
194
+ signal: controller.signal
195
+ }));
196
+
197
+ controller.abort('user navigated away');
198
+ ```
199
+
200
+ ### `createOllama(options?)`
201
+
202
+ If you want a local-first provider path, Qore can stream directly from Ollama:
203
+
204
+ ```js
205
+ import { createOllama, stream } from '@qorejs/qore';
206
+
207
+ const ollama = createOllama({
208
+ model: 'llama3.2'
209
+ });
210
+
211
+ const answer = stream(ollama.chat('Why should stream be signal?'));
212
+ ```
213
+
140
214
  ### `createSSEAdapter(options?)`
141
215
 
142
216
  If your backend already streams `text/event-stream`, Qore can adopt it directly:
@@ -166,6 +240,63 @@ const answer = stream(provider.chat('hello'));
166
240
 
167
241
  That makes `stream(provider.chat(...))` a general entry point instead of something tied to a single SDK.
168
242
 
243
+ ### `createLineAdapter(options?)`
244
+
245
+ If your backend streams newline-delimited JSON instead of `text/event-stream`, Qore can adopt that too:
246
+
247
+ ```js
248
+ import { createLineAdapter, stream } from '@qorejs/qore';
249
+
250
+ const provider = createLineAdapter({
251
+ name: 'Local NDJSON Chat',
252
+ url: 'http://localhost:11434/api/chat',
253
+ buildRequest(request) {
254
+ return {
255
+ method: 'POST',
256
+ body: JSON.stringify(request)
257
+ };
258
+ },
259
+ buildChatRequest(input) {
260
+ return {
261
+ model: 'llama3.2',
262
+ messages: [{ role: 'user', content: input }]
263
+ };
264
+ },
265
+ lineToText(event) {
266
+ return typeof event.data?.message?.content === 'string'
267
+ ? event.data.message.content
268
+ : undefined;
269
+ }
270
+ });
271
+
272
+ const answer = stream(provider.chat('hello'));
273
+ ```
274
+
275
+ ## Provider Support Matrix
276
+
277
+ | Adapter | Transport | Status | Notes |
278
+ | --- | --- | --- | --- |
279
+ | `createOpenAI(...)` | SSE | Supported | OpenAI Responses streaming |
280
+ | `createAnthropic(...)` | SSE | Supported | Anthropic Messages streaming |
281
+ | `createOpenRouter(...)` | SSE | Supported | chat-completions style SSE |
282
+ | `createDeepSeek(...)` | SSE | Supported | chat-completions style SSE |
283
+ | `createOllama(...)` | line-stream / NDJSON | Supported | local-first model path |
284
+ | `createSSEAdapter(...)` | SSE | Supported | generic hosted or self-managed SSE |
285
+ | `createLineAdapter(...)` | line-stream / NDJSON | Supported | generic line-delimited transport |
286
+
287
+ Common guarantees across supported adapters:
288
+
289
+ - async iterable text streaming
290
+ - request `signal` support for cancellation
291
+ - typed event streaming on provider-specific surfaces
292
+ - package smoke coverage
293
+
294
+ Additional hosted SSE guarantees:
295
+
296
+ - retry contract support
297
+ - `Last-Event-ID` resume support
298
+ - normalized metadata helpers for usage, finish reason, response id, and model identity
299
+
169
300
  ## API Shape
170
301
 
171
302
  ### `stream(source, options?)`
@@ -189,6 +320,80 @@ const events = stream.list(eventSource);
189
320
  const latest = stream.latest(modelEvents);
190
321
  ```
191
322
 
323
+ For append-heavy DOM lists such as chat transcripts, pass a stable key:
324
+
325
+ ```js
326
+ list(messages, (message) => h('article', {}, message.body), {
327
+ key: (message) => message.id
328
+ });
329
+ ```
330
+
331
+ ## Server-Side SSE
332
+
333
+ If you want Qore to produce the server stream as well:
334
+
335
+ ```js
336
+ import { createSSEResponse } from '@qorejs/qore';
337
+
338
+ export function handler() {
339
+ return createSSEResponse(['hello', ' world']);
340
+ }
341
+ ```
342
+
343
+ If you need orchestration:
344
+
345
+ ```js
346
+ const merged = stream.merge([openai.chat('a'), anthropic.chat('b')]);
347
+ const scripted = stream.concat([retrieve.chat('a'), summarize.chat('a')]);
348
+ const pipeline = stream.pipe(openai.chat('hello'), [
349
+ (draft) => review.chat(draft),
350
+ (reviewed) => format.chat(reviewed)
351
+ ]);
352
+ const fastest = stream.race([openai.chat('hello'), openrouter.chat('hello')]);
353
+ const resilient = stream.retryable(() => openai.chat('retry me'), {
354
+ maxRetries: 2,
355
+ backoff: 'exponential'
356
+ });
357
+ const liveAnswer = stream.switchMap(promptChanges, (prompt) => openai.chat(prompt));
358
+ ```
359
+
360
+ Provider adapters can also retry dropped SSE connections and resume from the last event id:
361
+
362
+ ```js
363
+ const openai = createOpenAI({
364
+ apiKey: process.env.OPENAI_API_KEY,
365
+ retry: {
366
+ maxAttempts: 3,
367
+ backoff: 'exponential'
368
+ }
369
+ });
370
+ ```
371
+
372
+ Provider metadata can be normalized into one shared shape:
373
+
374
+ ```js
375
+ import {
376
+ collectProviderMetadata,
377
+ extractAnthropicMetadata,
378
+ extractOpenAIMetadata
379
+ } from '@qorejs/qore';
380
+
381
+ const openaiMetadata = await collectProviderMetadata(
382
+ 'OpenAI',
383
+ openai.responses.stream({ input: 'hello' }),
384
+ extractOpenAIMetadata
385
+ );
386
+
387
+ const anthropicMetadata = await collectProviderMetadata(
388
+ 'Anthropic',
389
+ anthropic.messages.stream({ messages: [{ role: 'user', content: 'hello' }] }),
390
+ extractAnthropicMetadata
391
+ );
392
+
393
+ openaiMetadata.usage?.totalTokens;
394
+ anthropicMetadata.finishReason;
395
+ ```
396
+
192
397
  ### Backpressure
193
398
 
194
399
  ```js
@@ -212,13 +417,24 @@ answer.buffered(); // how many chunks are queued right now
212
417
  answer.dropped(); // how many chunks were dropped by the overflow policy
213
418
  ```
214
419
 
215
- ### `signal`, `computed`, `effect`
420
+ ### `signal`, `computed`, `effect`, `createRoot`, `onCleanup`
216
421
 
217
422
  ```js
218
- import { computed, signal, stream } from '@qorejs/qore';
423
+ import { computed, createRoot, effect, onCleanup, signal, stream } from '@qorejs/qore';
219
424
 
220
425
  const answer = stream(openai.chat('hello'));
221
426
  const length = computed(() => answer().length);
427
+
428
+ const dispose = createRoot((dispose) => {
429
+ effect(() => {
430
+ console.log(length());
431
+ onCleanup(() => console.log('effect disposed'));
432
+ });
433
+
434
+ return dispose;
435
+ });
436
+
437
+ dispose();
222
438
  ```
223
439
 
224
440
  ### `response`
@@ -247,7 +463,7 @@ The repository includes a landing page and a focused streaming demo:
247
463
  src/
248
464
  core/ stream, signal, response, iterable
249
465
  dom/ app mounting and DOM bindings
250
- providers/ OpenAI, Anthropic, generic SSE adapters
466
+ providers/ OpenAI, Anthropic, OpenRouter, DeepSeek, Ollama, SSE, and line-stream adapters
251
467
  shared/ runtime utilities
252
468
  index.ts public entrypoint
253
469
 
@@ -269,6 +485,43 @@ python3 -m http.server 4173
269
485
 
270
486
  Then open [http://127.0.0.1:4173/](http://127.0.0.1:4173/).
271
487
 
488
+ ## Server And SSR
489
+
490
+ Qore's reactive core and stream runtime work in Node and browser environments today.
491
+
492
+ The DOM layer is intentionally browser-only right now:
493
+
494
+ - `signal`, `computed`, `effect`, `stream`, and provider adapters work in Node and the browser
495
+ - `h`, `text`, `mount`, and `createApp(...).mount(...)` require a browser-like `document`
496
+ - `canUseDOM()` is exported so integrations can branch cleanly before touching DOM APIs
497
+ - `assertCanUseDOM(name?)` is exported if you want to fail fast with the same browser-boundary error shape Qore uses internally
498
+ - the published entrypoint is checked against a frozen public API snapshot before release so accidental export drift fails CI early
499
+
500
+ If you call DOM helpers without a browser-like runtime, Qore throws an entrypoint-specific error instead of failing later with a generic reference error. For example:
501
+
502
+ - `h() requires a browser-like environment`
503
+ - `mount() requires a browser-like environment`
504
+ - `createApp(...).mount(...) requires a browser-like environment`
505
+
506
+ Example:
507
+
508
+ ```js
509
+ import { assertCanUseDOM, canUseDOM } from '@qorejs/qore';
510
+
511
+ if (canUseDOM()) {
512
+ // Safe to call mount(), h(), text(), and other DOM entrypoints.
513
+ }
514
+
515
+ assertCanUseDOM('chat shell hydration');
516
+ ```
517
+
518
+ That means the current `1.0.0` path is:
519
+
520
+ - stable reactive runtime
521
+ - stable streaming runtime
522
+ - explicit browser DOM boundary
523
+ - streaming SSR and hydration as a post-`1.0.0` expansion area unless the implementation is fully proven first
524
+
272
525
  ## Browser Regression
273
526
 
274
527
  Install the browser binary once:
@@ -291,7 +544,7 @@ It validates:
291
544
 
292
545
  The suite checks desktop and mobile layouts, watches for runtime console errors, exercises the primary interactions, and runs inside `release:check`.
293
546
 
294
- CI also uploads the browser regression evidence as workflow artifacts. The bundle includes viewport screenshots, focused page-surface screenshots, the Playwright HTML report, and a `benchmark-suite.json` attachment from the dedicated benchmark page.
547
+ CI also uploads the browser regression evidence as workflow artifacts. The bundle includes viewport screenshots, focused page-surface screenshots, the Playwright HTML report, a `benchmark-suite.json` attachment from the dedicated benchmark page, and a human-readable benchmark summary markdown file from the benchmark gate.
295
548
 
296
549
  If a locked-down local shell cannot launch a supported headless browser, the script will defer to CI unless you force a hard local failure with `QORE_BROWSER_SMOKE_REQUIRED=1`.
297
550
 
@@ -324,6 +577,23 @@ The repository includes GitHub Actions workflows for both release validation and
324
577
 
325
578
  Because the package already includes the correct `repository` field in `package.json`, GitHub Packages can link the package back to `qorejs/qore` when the workflow publishes it.
326
579
 
580
+ ## Release Checklist
581
+
582
+ The canonical release checklist lives in [`RELEASE.md`](./RELEASE.md). You can generate the current version's GitHub release body with `npm run release:notes`.
583
+
584
+ For every release candidate or stable release, the minimum local gate is:
585
+
586
+ ```bash
587
+ npm ci
588
+ npm run release:check
589
+ ```
590
+
591
+ For npm publishing from a local shell, run:
592
+
593
+ ```bash
594
+ npm run publish:preflight
595
+ ```
596
+
327
597
  ## Project Hygiene
328
598
 
329
599
  The repository also includes:
@@ -357,7 +627,7 @@ The current test suite covers:
357
627
  - `signal`, `computed`, and `effect`
358
628
  - The core `stream = signal` behavior
359
629
  - `response` interoperability with async iterables
360
- - OpenAI, Anthropic, and generic SSE adapters
630
+ - OpenAI, Anthropic, OpenRouter, DeepSeek, Ollama, and generic streaming adapters
361
631
 
362
632
  ## Roadmap
363
633
 
@@ -0,0 +1,14 @@
1
+ import type { Cleanup } from './signal-types.js';
2
+ interface OwnerNode {
3
+ parent: OwnerNode | null;
4
+ children: Set<OwnerNode>;
5
+ cleanups: Cleanup[];
6
+ active: boolean;
7
+ }
8
+ export declare function withOwner<T>(owner: OwnerNode | null, fn: () => T): T;
9
+ export declare function createOwnedScope(): OwnerNode;
10
+ export declare function resetOwner(owner: OwnerNode): void;
11
+ export declare function disposeOwner(owner: OwnerNode | null | undefined): void;
12
+ export declare function createRoot<T>(fn: (dispose: Cleanup) => T): T;
13
+ export declare function onCleanup(cleanup: Cleanup): void;
14
+ export {};
@@ -0,0 +1,66 @@
1
+ let activeOwner = null;
2
+ function createOwner(parent) {
3
+ const owner = {
4
+ parent,
5
+ children: new Set(),
6
+ cleanups: [],
7
+ active: true
8
+ };
9
+ parent?.children.add(owner);
10
+ return owner;
11
+ }
12
+ function disposeOwnerContents(owner) {
13
+ const children = Array.from(owner.children);
14
+ owner.children.clear();
15
+ for (let index = children.length - 1; index >= 0; index -= 1) {
16
+ disposeOwner(children[index]);
17
+ }
18
+ for (let index = owner.cleanups.length - 1; index >= 0; index -= 1) {
19
+ try {
20
+ const cleanup = owner.cleanups[index];
21
+ if (cleanup) {
22
+ cleanup();
23
+ }
24
+ }
25
+ catch {
26
+ // Keep unwinding the owner tree even if one cleanup throws.
27
+ }
28
+ }
29
+ owner.cleanups.length = 0;
30
+ }
31
+ export function withOwner(owner, fn) {
32
+ const previousOwner = activeOwner;
33
+ activeOwner = owner;
34
+ try {
35
+ return fn();
36
+ }
37
+ finally {
38
+ activeOwner = previousOwner;
39
+ }
40
+ }
41
+ export function createOwnedScope() {
42
+ return createOwner(activeOwner);
43
+ }
44
+ export function resetOwner(owner) {
45
+ if (!owner.active) {
46
+ return;
47
+ }
48
+ disposeOwnerContents(owner);
49
+ }
50
+ export function disposeOwner(owner) {
51
+ if (!owner || !owner.active) {
52
+ return;
53
+ }
54
+ owner.active = false;
55
+ disposeOwnerContents(owner);
56
+ owner.parent?.children.delete(owner);
57
+ owner.parent = null;
58
+ }
59
+ export function createRoot(fn) {
60
+ const owner = createOwner(activeOwner);
61
+ const dispose = () => disposeOwner(owner);
62
+ return withOwner(owner, () => fn(dispose));
63
+ }
64
+ export function onCleanup(cleanup) {
65
+ activeOwner?.cleanups.push(cleanup);
66
+ }
@@ -3,6 +3,7 @@ export const READ = Symbol('qore.signal.read');
3
3
  let activeObserver = null;
4
4
  let batchDepth = 0;
5
5
  const pendingObservers = new Set();
6
+ let flushingObservers = false;
6
7
  export function getActiveObserver() {
7
8
  return activeObserver;
8
9
  }
@@ -31,24 +32,33 @@ export function scheduleObserver(observer) {
31
32
  if (!observer.active) {
32
33
  return;
33
34
  }
34
- if (batchDepth > 0) {
35
- pendingObservers.add(observer);
35
+ pendingObservers.add(observer);
36
+ if (batchDepth > 0 || flushingObservers) {
36
37
  return;
37
38
  }
38
- observer.schedule();
39
+ flushObservers();
39
40
  }
40
41
  export function removePendingObserver(observer) {
41
42
  pendingObservers.delete(observer);
42
43
  }
43
44
  // Flush batched observer work in FIFO-like waves until the queue is empty.
44
45
  function flushObservers() {
45
- while (pendingObservers.size > 0) {
46
- const queue = Array.from(pendingObservers);
47
- pendingObservers.clear();
48
- for (const observer of queue) {
49
- observer.schedule();
46
+ flushingObservers = true;
47
+ try {
48
+ while (pendingObservers.size > 0) {
49
+ const queue = Array.from(pendingObservers);
50
+ pendingObservers.clear();
51
+ queue.sort((left, right) => left.level - right.level);
52
+ for (const observer of queue) {
53
+ if (observer.active) {
54
+ observer.schedule();
55
+ }
56
+ }
50
57
  }
51
58
  }
59
+ finally {
60
+ flushingObservers = false;
61
+ }
52
62
  }
53
63
  // Batch synchronous updates so dependent observers only re-run once afterward.
54
64
  export function batch(fn) {
@@ -58,7 +68,7 @@ export function batch(fn) {
58
68
  }
59
69
  finally {
60
70
  batchDepth -= 1;
61
- if (batchDepth === 0) {
71
+ if (batchDepth === 0 && !flushingObservers) {
62
72
  flushObservers();
63
73
  }
64
74
  }
@@ -1,6 +1,7 @@
1
1
  import type { Cleanup, EffectCallback, EffectOptions, ObserverDependency, ReactiveObserver, SignalListener, SubscribeOptions } from './signal-types.js';
2
2
  export declare class SignalNode<T> implements ObserverDependency {
3
3
  value: T;
4
+ level: number;
4
5
  subscribers: Set<ReactiveObserver>;
5
6
  listeners: Set<SignalListener<T>>;
6
7
  constructor(initialValue: T);
@@ -13,6 +14,8 @@ export declare class SignalNode<T> implements ObserverDependency {
13
14
  }
14
15
  export declare class ComputedNode<T> implements ObserverDependency, ReactiveObserver {
15
16
  getter: () => T;
17
+ private owner;
18
+ level: number;
16
19
  subscribers: Set<ReactiveObserver>;
17
20
  listeners: Set<SignalListener<T>>;
18
21
  deps: Set<ObserverDependency>;
@@ -31,6 +34,8 @@ export declare class ComputedNode<T> implements ObserverDependency, ReactiveObse
31
34
  export declare class EffectNode implements ReactiveObserver {
32
35
  fn: EffectCallback;
33
36
  scheduler: EffectOptions['scheduler'];
37
+ private owner;
38
+ level: number;
34
39
  deps: Set<ObserverDependency>;
35
40
  active: boolean;
36
41
  scheduled: boolean;
@@ -1,8 +1,10 @@
1
- import { cleanupObserver, getActiveObserver, removePendingObserver, scheduleObserver, withActiveObserver } from './signal-context.js';
1
+ import { batch, cleanupObserver, getActiveObserver, removePendingObserver, scheduleObserver, withActiveObserver } from './signal-context.js';
2
+ import { createOwnedScope, disposeOwner, onCleanup, resetOwner, withOwner } from './owner.js';
2
3
  import { scheduleEffectRun } from './signal-scheduler.js';
3
4
  // A mutable signal node stores a value and fan-outs updates to listeners and observers.
4
5
  export class SignalNode {
5
6
  value;
7
+ level = 0;
6
8
  subscribers = new Set();
7
9
  listeners = new Set();
8
10
  constructor(initialValue) {
@@ -45,14 +47,18 @@ export class SignalNode {
45
47
  for (const listener of Array.from(this.listeners)) {
46
48
  listener(this.value);
47
49
  }
48
- for (const observer of Array.from(this.subscribers)) {
49
- scheduleObserver(observer);
50
- }
50
+ batch(() => {
51
+ for (const observer of Array.from(this.subscribers)) {
52
+ scheduleObserver(observer);
53
+ }
54
+ });
51
55
  }
52
56
  }
53
57
  // A computed node re-runs its getter whenever one of its dependencies changes.
54
58
  export class ComputedNode {
55
59
  getter;
60
+ owner = createOwnedScope();
61
+ level = 1;
56
62
  subscribers = new Set();
57
63
  listeners = new Set();
58
64
  deps = new Set();
@@ -61,6 +67,7 @@ export class ComputedNode {
61
67
  value;
62
68
  constructor(getter) {
63
69
  this.getter = getter;
70
+ onCleanup(() => this.stop());
64
71
  this.recompute();
65
72
  }
66
73
  get() {
@@ -96,11 +103,16 @@ export class ComputedNode {
96
103
  return;
97
104
  }
98
105
  const previousValue = this.value;
106
+ resetOwner(this.owner);
99
107
  cleanupObserver(this);
100
- withActiveObserver(this, () => {
108
+ withOwner(this.owner, () => withActiveObserver(this, () => {
101
109
  const nextValue = this.getter();
102
110
  const changed = !this.initialized || !Object.is(previousValue, nextValue);
111
+ const nextLevel = this.deps.size > 0
112
+ ? Math.max(...Array.from(this.deps, (dependency) => dependency.level)) + 1
113
+ : 1;
103
114
  this.value = nextValue;
115
+ this.level = nextLevel;
104
116
  this.initialized = true;
105
117
  if (!changed) {
106
118
  return;
@@ -108,10 +120,12 @@ export class ComputedNode {
108
120
  for (const listener of Array.from(this.listeners)) {
109
121
  listener(this.value);
110
122
  }
111
- for (const observer of Array.from(this.subscribers)) {
112
- scheduleObserver(observer);
113
- }
114
- });
123
+ batch(() => {
124
+ for (const observer of Array.from(this.subscribers)) {
125
+ scheduleObserver(observer);
126
+ }
127
+ });
128
+ }));
115
129
  }
116
130
  stop() {
117
131
  if (!this.active) {
@@ -120,6 +134,7 @@ export class ComputedNode {
120
134
  this.active = false;
121
135
  cleanupObserver(this);
122
136
  removePendingObserver(this);
137
+ disposeOwner(this.owner);
123
138
  this.subscribers.clear();
124
139
  this.listeners.clear();
125
140
  }
@@ -128,6 +143,8 @@ export class ComputedNode {
128
143
  export class EffectNode {
129
144
  fn;
130
145
  scheduler;
146
+ owner = createOwnedScope();
147
+ level = 1;
131
148
  deps = new Set();
132
149
  active = true;
133
150
  scheduled = false;
@@ -137,6 +154,7 @@ export class EffectNode {
137
154
  constructor(fn, options = {}) {
138
155
  this.fn = fn;
139
156
  this.scheduler = options.scheduler ?? 'sync';
157
+ onCleanup(() => this.stop());
140
158
  this.run();
141
159
  }
142
160
  schedule() {
@@ -166,18 +184,29 @@ export class EffectNode {
166
184
  }
167
185
  this.running = true;
168
186
  this.needsRun = false;
169
- cleanupObserver(this);
170
- if (typeof this.cleanup === 'function') {
171
- this.cleanup();
172
- this.cleanup = null;
173
- }
174
- withActiveObserver(this, () => {
175
- const maybeCleanup = this.fn();
176
- this.cleanup = typeof maybeCleanup === 'function' ? maybeCleanup : null;
177
- });
178
- this.running = false;
179
- if (this.needsRun && this.active) {
180
- this.schedule();
187
+ const previousCleanup = this.cleanup;
188
+ this.cleanup = null;
189
+ try {
190
+ // Run the previous cleanup before dropping old dependencies so a thrown cleanup
191
+ // does not silently unsubscribe the effect from future source updates.
192
+ if (typeof previousCleanup === 'function') {
193
+ previousCleanup();
194
+ }
195
+ resetOwner(this.owner);
196
+ cleanupObserver(this);
197
+ withOwner(this.owner, () => withActiveObserver(this, () => {
198
+ const maybeCleanup = this.fn();
199
+ this.cleanup = typeof maybeCleanup === 'function' ? maybeCleanup : null;
200
+ }));
201
+ this.level = this.deps.size > 0
202
+ ? Math.max(...Array.from(this.deps, (dependency) => dependency.level)) + 1
203
+ : 1;
204
+ }
205
+ finally {
206
+ this.running = false;
207
+ if (this.needsRun && this.active) {
208
+ this.schedule();
209
+ }
181
210
  }
182
211
  }
183
212
  stop() {
@@ -189,6 +218,7 @@ export class EffectNode {
189
218
  this.needsRun = false;
190
219
  cleanupObserver(this);
191
220
  removePendingObserver(this);
221
+ disposeOwner(this.owner);
192
222
  if (typeof this.cleanup === 'function') {
193
223
  this.cleanup();
194
224
  this.cleanup = null;
@@ -9,11 +9,13 @@ export interface EffectOptions {
9
9
  scheduler?: EffectScheduler;
10
10
  }
11
11
  export interface ObserverDependency {
12
+ level: number;
12
13
  subscribers: Set<ReactiveObserver>;
13
14
  }
14
15
  export interface ReactiveObserver {
15
16
  deps: Set<ObserverDependency>;
16
17
  active: boolean;
18
+ level: number;
17
19
  schedule(): void;
18
20
  notify(): void;
19
21
  }