sygnal 7.0.0 → 7.1.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.
@@ -218,7 +218,7 @@ Sizes.model = { SIZE: (state, size) => ({ ...state, size }) }
218
218
  An intent action comes only from `mouseenter` or `mouseover` (also `pointerenter` / `pointerover`), nothing else sends it, and it changes the state. Keyboard users never hover, and touch screens hover only on a tap, so a tooltip, preview or menu shown this way is out of their reach. Trigger it from focus too, and close it on blur as on mouse leave:
219
219
 
220
220
  ```jsx
221
- import xs from 'xstream'
221
+ import { xs } from 'sygnal'
222
222
 
223
223
  // Flagged: SHOW: DOM.mouseenter('.info'), with nothing else sending SHOW
224
224
 
@@ -56,6 +56,7 @@ Quote.model = {
56
56
  - **A refetch of the same request keeps what is shown.** `{ refresh: 'quote' }`, an [invalidation](#invalidation), a [focus or reconnect refetch](#focus-reconnect-and-polling), polling, or the same request declared again after a pause keep `status`, `data` and `error`, and set `refreshing: true` until the reply lands. Show a spinner next to the data, or treat `refreshing` like loading when the old data must not be visible.
57
57
  - **A new request clears it.** When the derived request changes (another id), the resource goes to `'loading'` with no data, and the earlier request is aborted: its reply never lands. For pagination, where the previous page should stay visible, set `keepPrevious: true` on the request: the old `data` stays (with `refreshing: true`) until the new page arrives.
58
58
  - **Change detection is by value.** A state change that derives an equal request (by JSON) sends nothing.
59
+ - **An entry gets the context too**: `(state, context)`. A routed page reads `route` from context (`(state, { route }) => `/api/tasks/${route.params.id}``) instead of keeping a copy of it; the entries are computed again when the context changes ([Router](https://sygnal.js.org/guide/router/)).
59
60
  - `ok` / `error` on the request also dispatch those actions after the write, for side work such as appending pages.
60
61
  - A Collection item, or any component instance, has its own resources. Removing it aborts them.
61
62
  - In a hidden [Switchable](https://sygnal.js.org/guide/switchable/#hidden-pages-pause-connections-and-resources) page, resources pause: the request in flight is aborted and the resource keeps its last result (`refreshing` off; `'idle'` if nothing had arrived yet). When the page is shown again, the same request is refetched, keeping data. Mark an entry `background: true` to keep it live while hidden.
@@ -207,6 +208,7 @@ QuoteEditor.model = {
207
208
  - The value is a tag (`'quotes'`), a URL prefix of the request's `url` (a string starting with `/`: `'/api/quotes'`), an array of them, or a predicate `(request) => boolean`. Tags are explicit: nothing is derived from the URL.
208
209
  - Matching **mounted** resources refetch, keeping their data (`refreshing: true`); matching cache entries are marked stale, so the next use refetches. Invalidation works with or without the cache.
209
210
  - **Invalidation aborts older reads.** A refetch aborts the resource's read already in flight, so a reply sent before the save never lands after it: no request ids or sequence numbers. A shared cache fetch in flight for a matching entry still answers the requests waiting on it, but never writes the cache.
211
+ - **An `ok` action that navigates is fine.** When the `ok` action changes the resource's request (a `ROUTER` move to the next record), the new request is sent and the invalidation never resends the old one after it.
210
212
  - An `invalidate` that matches nothing is reported as [SYG632](https://sygnal.js.org/reference/errors/#syg632) (info).
211
213
 
212
214
  ## Writing a reply into the cache
@@ -24,7 +24,7 @@ The key is yours to choose (`TIMER` by convention): Sygnal finds the driver by t
24
24
 
25
25
  ## The timers static
26
26
 
27
- `timers` takes the state (with its calculated fields) and returns an object of named timers. Each value is a timer spec, or a falsy value for "not now":
27
+ `timers` takes the state (with its calculated fields) and the component's context (its second argument, as for every declaration static) and returns an object of named timers. Each value is a timer spec, or a falsy value for "not now":
28
28
 
29
29
  ```jsx
30
30
  Game.timers = (state) => ({
@@ -42,7 +42,7 @@ Game.timers = (state) => ({
42
42
 
43
43
  Any spec can add `background: true` (see [hidden pages](#hidden-pages)). The action is one of the component's own actions: it goes to the instance that declared the timer, and every sink of its model entry runs, as for an intent action.
44
44
 
45
- Sygnal compares the result with the previous one, name by name, whenever the state changes:
45
+ Sygnal compares the result with the previous one, name by name, whenever the state or the context changes:
46
46
 
47
47
  - a new name starts its timer;
48
48
  - a name that is gone, or now falsy, stops its timer;
@@ -55,7 +55,7 @@ So `timers` is a description, not a command: return what should be running now.
55
55
 
56
56
  An `every` timer schedules each tick from the moment it started, not from the previous tick: tick `n` is due at start + n × `every`. A late tick doesn't push the later ones back, so 1,000 ticks of 100 ms end 100 s after the start. Use the `t` in the data (or `Date.now()` in the intent) for elapsed time rather than counting ticks.
57
57
 
58
- When a tick comes very late (a browser throttles timers in a background tab), the missed ticks are not delivered one by one: they are coalesced into the next one, and `n` jumps over them.
58
+ When a tick comes very late (a browser throttles timers in a background tab), the missed ticks are not delivered one by one: they are coalesced into the next one, and `n` jumps over them. A delay is never longer than `every`, so a test clock that fixes `Date.now()` while timers keep running (Playwright's `clock.setFixedTime()`) still gets one tick per interval.
59
59
 
60
60
  ## A stopwatch
61
61
 
package/dist/index.cjs.js CHANGED
@@ -714,6 +714,7 @@ const DEV_CODE_SEVERITY = {
714
714
  SYG112: 'error',
715
715
  SYG115: 'warn',
716
716
  SYG116: 'error',
717
+ SYG117: 'warn',
717
718
  // PLAN-4 1-D: controls, static (sygnal-check)
718
719
  SYG124: 'error',
719
720
  SYG125: 'error',
@@ -725,6 +726,10 @@ const DEV_CODE_SEVERITY = {
725
726
  SYG129: 'warn',
726
727
  // PLAN-10 X-1: static only (sygnal-check)
727
728
  SYG170: 'warn',
729
+ // PLAN-13 X-1: static only (sygnal-check)
730
+ SYG302: 'info',
731
+ // PLAN-13 F-3 (checks/next.ts)
732
+ SYG429: 'warn',
728
733
  SYG171: 'warn',
729
734
  SYG221: 'error',
730
735
  // PLAN-4 GS-4 (state.ts)
@@ -1811,23 +1816,24 @@ const shownOf = (inst) => {
1811
1816
  function checkStatics(inst) {
1812
1817
  if (inst.disposed)
1813
1818
  return;
1814
- const s = inst.cell.get(), shown = shownOf(inst);
1819
+ const s = inst.cell.get(), shown = shownOf(inst), c = inst.context();
1815
1820
  // no state yet: nothing declared (as the state stream, which skips undefined)
1816
- if (s === undefined || (s === inst.sS && shown === inst.sH))
1821
+ if (s === undefined || (s === inst.sS && shown === inst.sH && c === inst.sC))
1817
1822
  return;
1818
1823
  inst.sS = s;
1819
1824
  inst.sH = shown;
1825
+ inst.sC = c;
1820
1826
  const view = inst.def.view, sv = inst.sv;
1821
1827
  for (const [n, k] of inst.st) {
1822
1828
  const f = view[k];
1823
1829
  let v = f;
1824
1830
  try {
1825
1831
  if (typeof f == 'function')
1826
- v = f(s);
1832
+ v = f(s, c);
1827
1833
  else if (f && typeof f == 'object') {
1828
1834
  v = {};
1829
1835
  for (const r in f)
1830
- v[r] = f[r](s);
1836
+ v[r] = f[r](s, c);
1831
1837
  }
1832
1838
  }
1833
1839
  catch (e) {
@@ -2238,6 +2244,14 @@ function bindingChanged(a, b) {
2238
2244
  return x && y ? !x.every((v, i) => Object.is(v, y[i]))
2239
2245
  : !!(x || y || typeof a == 'string' || typeof b == 'string' || a === undefined || b === undefined);
2240
2246
  }
2247
+ /** a binding whose target is known (a key, a byId selection): its deletion ends the child */
2248
+ const known$1 = (b) => typeof b == 'string' || b?.[BINDING];
2249
+ /** F-3: `owner` watches child (or Switchable page) `k`'s target: notify() disposes it when deleted */
2250
+ function watch(owner, k) {
2251
+ k.bs = bindingState(k);
2252
+ (owner.bound || (owner.bound = new Set())).add(k);
2253
+ owner.app.watchers.add(owner);
2254
+ }
2241
2255
  function bindingState(k) {
2242
2256
  const c = k.base || k.cell || k.arr;
2243
2257
  return c && (c.has?.() ?? c.get() !== undefined) ? (k.cell || k.arr).get() : undefined;
@@ -2824,11 +2838,8 @@ class Inst {
2824
2838
  k.setProps(props, children, n, id);
2825
2839
  else
2826
2840
  this.kids.set(id, k = host ? this.host(host, props, children, id, n) : this.child(view, props, children, id, !!data.props?.resetState));
2827
- if (typeof props[binding] == 'string' || props[binding]?.[BINDING]) {
2828
- k.bs = bindingState(k);
2829
- (this.bound || (this.bound = new Set())).add(k);
2830
- this.app.watchers.add(this);
2831
- }
2841
+ if (known$1(props[binding]))
2842
+ watch(this, k);
2832
2843
  return;
2833
2844
  }
2834
2845
  if (posts[sel] && !(ps || (ps = [])).includes(sel))
@@ -4237,6 +4248,8 @@ function check$3(owner, props) {
4237
4248
  if (!(cur in of))
4238
4249
  fail$2('SYG416', owner, `Switchable 'current' '${cur}' is not a key of 'of'`, CUR_FIX);
4239
4250
  }
4251
+ /** F-3: a per-page map (an object that is not a lens or a byId selection: no `get`), or null */
4252
+ const mapOf = (st) => isObj$2(st) && typeof st.get != 'function' ? st : null;
4240
4253
  /** a page's props: the Switchable's, kept by identity while equal (`of` compared shallowly) */
4241
4254
  function sameProps(a, b) {
4242
4255
  if (!a || !b)
@@ -4258,10 +4271,11 @@ class SwitchableHost {
4258
4271
  this.lr = true;
4259
4272
  this.kids = [];
4260
4273
  check$3(owner, props);
4261
- const st = props.state, calc = typeof st == 'string' && owner.def.calcNames?.has(pathOf$1(st)[0]);
4262
- this.cell = typeof st == 'string' ? keyCell(owner.cell, st, calc && owner.def.name, undefined, 'Switchable sub-component')
4263
- : st !== undefined ? lensCell(owner.cell, st, owner.def.name, (e) => owner.app.appError(owner, e, 'view'))
4264
- : owner.cell;
4274
+ const st = props.state;
4275
+ // F-3: `state={{ page: key | lens | byId }}` (not a lens, not a byId selection): each listed
4276
+ // page binds its own target, with a tag child's lifetime rules; the others share the owner's state
4277
+ this.map = mapOf(st);
4278
+ this.cell = this.bind(this.map ? undefined : st);
4265
4279
  this.uidBase = owner.uid(uidPart(id.replace(/.*::(r\.)?/, '')));
4266
4280
  this.props = props;
4267
4281
  this.kids = children;
@@ -4271,9 +4285,16 @@ class SwitchableHost {
4271
4285
  }
4272
4286
  this.setProps(props, children);
4273
4287
  }
4288
+ bind(st) {
4289
+ const owner = this.owner, calc = typeof st == 'string' && owner.def.calcNames?.has(pathOf$1(st)[0]);
4290
+ return typeof st == 'string' ? keyCell(owner.cell, st, calc && owner.def.name, undefined, 'Switchable sub-component')
4291
+ : st !== undefined ? lensCell(owner.cell, st, owner.def.name, (e) => owner.app.appError(owner, e, 'view'))
4292
+ : owner.cell;
4293
+ }
4274
4294
  make(name, view) {
4275
4295
  const o = this.owner, app = o.app, def = app.def(view), scope = app.scope();
4276
- const inst = new Inst(app, def, o, def.isolated ? localCell(app, this.cell) : this.cell, o.dom && o.dom.isolateSource(o.dom, scope), this.props, this.kids, scope, this.uidBase + '-' + uidPart(name), 'page');
4296
+ const c = this.map ? this.bind(this.map[name]) : this.cell;
4297
+ const inst = new Inst(app, def, o, def.isolated ? localCell(app, c) : c, o.dom && o.dom.isolateSource(o.dom, scope), this.props, this.kids, scope, this.uidBase + '-' + uidPart(name), 'page');
4277
4298
  inst.shown = false;
4278
4299
  return inst;
4279
4300
  }
@@ -4290,15 +4311,21 @@ class SwitchableHost {
4290
4311
  for (const n in this.pages)
4291
4312
  this.pages[n].inst.setProps(props, children);
4292
4313
  }
4314
+ // F-3: a page whose own binding target changed (7.0's rule, per page), or that the owner
4315
+ // disposed because its target was deleted, is made again
4316
+ const m = this.map, nm = this.map = mapOf(props.state);
4293
4317
  // G-317: a lazy() page that has loaded is made again with the loaded component
4294
4318
  for (const n in this.pages) {
4295
4319
  const p = this.pages[n], fn = resolve$2(p.view, this.owner);
4296
- if (fn !== p.fn) {
4320
+ if (fn !== p.fn || p.inst.disposed || m && nm && bindingChanged(m[n], nm[n])) {
4297
4321
  const shown = p.inst.shown;
4298
- p.inst.dispose();
4322
+ p.inst.disposed || p.inst.dispose();
4299
4323
  p.inst = this.make(n, p.fn = fn);
4300
4324
  p.inst.shown = shown;
4301
4325
  }
4326
+ // the owner disposes a page whose target is deleted, as a tag child; it is made again above
4327
+ if (nm && known$1(nm[n]))
4328
+ watch(this.owner, p.inst);
4302
4329
  }
4303
4330
  const page = this.pages[cur];
4304
4331
  if (page) {
@@ -5910,6 +5937,10 @@ function makeFetchDriver(options = {}) {
5910
5937
  write(s, n, R, L.status, L.data, L.error, true);
5911
5938
  else
5912
5939
  write(s, n, R, 'loading');
5940
+ // R-1: the write is dispatched synchronously; when it changed the request (a navigation
5941
+ // recomputed the statics and a nested load sent the new one) or paused it, q is stale
5942
+ if (R.q !== q || R.p)
5943
+ return;
5913
5944
  if (go || q.validate)
5914
5945
  R.i = send(q, s, n, after, R);
5915
5946
  };
@@ -6374,6 +6405,9 @@ async function* sse(res, meta) {
6374
6405
  try {
6375
6406
  for (;;) {
6376
6407
  const r = await reader.read();
6408
+ // PLAN-13 S-1: any bytes (a heartbeat comment too) show the stream is alive
6409
+ if (!r.done)
6410
+ meta?.beat?.();
6377
6411
  // line ends -> \n (a \r at a chunk's end waits: it may be half of a \r\n)
6378
6412
  let s = cr + (r.done ? '\n\n' : dec.decode(r.value, { stream: true }));
6379
6413
  cr = !r.done && s.endsWith('\r') ? '\r' : '';
@@ -6455,6 +6489,8 @@ const dev$8 = (...a) => g$6.__SYGNAL_DIAGNOSTICS__?.chat?.(...a);
6455
6489
  * - a non-2xx status or another content type: `error` with readyState CLOSED (no retry; the
6456
6490
  * driver's reconnect policy decides);
6457
6491
  * - `close()` aborts the request and stops retrying.
6492
+ * - PLAN-13 S-1: `init.lastEventId` seeds the id sent as `Last-Event-ID` (a driver reconnect that
6493
+ * resumes); `onbeat` (not EventSource's) is called for any bytes read, heartbeat comments too.
6458
6494
  */
6459
6495
  const NAMES = ['CONNECTING', 'OPEN', 'CLOSED'];
6460
6496
  class FetchEventSource {
@@ -6463,11 +6499,14 @@ class FetchEventSource {
6463
6499
  this.onopen = null;
6464
6500
  this.onmessage = null;
6465
6501
  this.onerror = null;
6502
+ this.onbeat = null;
6466
6503
  this.ls = new Map();
6467
- this.meta = { id: '', retry: 3000 };
6504
+ this.meta = { id: '', retry: 3000, beat: () => this.onbeat?.() };
6468
6505
  this.url = url;
6469
6506
  this.withCredentials = !!init.withCredentials;
6470
6507
  this.init = init;
6508
+ if (init.lastEventId)
6509
+ this.meta.id = init.lastEventId;
6471
6510
  queueMicrotask(() => this.connect());
6472
6511
  }
6473
6512
  addEventListener(type, f) {
@@ -6603,6 +6642,12 @@ FetchEventSource.CLOSED = 2;
6603
6642
  * changed header reconnects) is read with FetchEventSource (fetch, `options.fetch` if given),
6604
6643
  * since EventSource can't send headers. An `options.EventSource` (a polyfill, the test fake)
6605
6644
  * still wins and gets the headers in its init.
6645
+ * - PLAN-13 S-1: SSE `meta: true`: message and `events` actions get `{ data, id, event }`. `resume:
6646
+ * true | 'param'`: the transport keeps the last event id, and a reconnect the driver makes (after
6647
+ * EventSource gave up) asks to resume from it: `?lastEventId=` (or `?param=`) on the URL, or
6648
+ * `Last-Event-ID` on the fetch path (`init.lastEventId`). `staleAfterMs` (SSE and WebSocket): no
6649
+ * event for that long (over fetch: no bytes, heartbeat comments count) closes the connection as a
6650
+ * drop (`close` gets `stale: true`), so the reconnect policy applies.
6606
6651
  */
6607
6652
  const SCOPE$1 = '__sygnalScope';
6608
6653
  const scopeOf = (v) => v[SCOPE$1] || [];
@@ -6672,20 +6717,48 @@ function makeSocketDriver(options = {}) {
6672
6717
  deliver(e, 'error', { error });
6673
6718
  }
6674
6719
  };
6720
+ // S-1: an SSE event as a subscriber gets it (`meta`: with its id and type); the transport
6721
+ // keeps the last id (resume) and counts it as life (staleAfterMs)
6722
+ const got = (t, m, e) => e.sse && e.spec.meta ? { data: parse(m.data), id: m.lastEventId ?? '', event: m.type || 'message' } : parse(m.data);
6723
+ const seen = (t, m) => { if (m.lastEventId)
6724
+ t.id = m.lastEventId; alive(t); };
6675
6725
  // SSE named events: one listener per name across the subscribers
6676
6726
  const listen = (t) => t.s && t.sse && each(t, e => Object.keys(e.spec.events || {}).forEach(name => {
6677
6727
  if (t.on.has(name))
6678
6728
  return;
6679
6729
  t.on.add(name);
6680
6730
  const s = t.s;
6681
- s.addEventListener(name, (m) => t.s === s && each(t, x => {
6682
- const a = x.spec.events?.[name];
6683
- if (typeof a == 'string' && x.sender !== undefined)
6684
- reply(x.sender, a, parse(m.data));
6685
- }));
6731
+ s.addEventListener(name, (m) => {
6732
+ if (t.s !== s)
6733
+ return;
6734
+ seen(t, m);
6735
+ each(t, x => {
6736
+ const a = x.spec.events?.[name];
6737
+ if (typeof a == 'string' && x.sender !== undefined)
6738
+ reply(x.sender, a, got(t, m, x));
6739
+ });
6740
+ });
6686
6741
  }));
6742
+ // S-1: staleAfterMs (the smallest of the subscribers'): re-armed by every event; when it fires
6743
+ // the connection is dropped (as if the server had gone) and the reconnect policy applies
6744
+ const alive = (t) => {
6745
+ clearTimeout(t.st);
6746
+ const ms = Math.min(...[...t.subs].map(e => e.spec.staleAfterMs).filter(x => x > 0));
6747
+ const s = t.s;
6748
+ if (s && ms < 1 / 0)
6749
+ t.st = setTimeout(() => {
6750
+ if (t.s !== s)
6751
+ return;
6752
+ try {
6753
+ s.close();
6754
+ }
6755
+ catch (_) { }
6756
+ gone(t, t.sse ? { stale: true } : { code: 1006, reason: 'stale', stale: true });
6757
+ }, ms);
6758
+ };
6687
6759
  // the socket closed (or failed to open) without the driver closing it
6688
6760
  const gone = (t, info) => {
6761
+ clearTimeout(t.st);
6689
6762
  t.s = null;
6690
6763
  t.open = false;
6691
6764
  let again;
@@ -6706,9 +6779,19 @@ function makeSocketDriver(options = {}) {
6706
6779
  const C = t.sse ? options.EventSource ?? (hdr && typeof g.fetch == 'function' ? FetchEventSource : g.EventSource) : options.WebSocket ?? g.WebSocket;
6707
6780
  if (typeof C != 'function')
6708
6781
  return;
6782
+ // S-1: resume from the last event id: Last-Event-ID on the fetch path (init.lastEventId, which
6783
+ // a polyfill or the test fake gets too), else a URL parameter
6784
+ // (a parameter the URL already has is replaced, not added again)
6785
+ const r = t.sse && t.id && [...t.subs].map(e => e.spec.resume).find(x => x);
6786
+ let url = t.url;
6787
+ if (r && !hdr) {
6788
+ const p = encodeURIComponent(r === true ? 'lastEventId' : r) + '=', v = p + encodeURIComponent(t.id);
6789
+ const re = new RegExp('([?&])' + p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '[^&#]*');
6790
+ url = re.test(url) ? url.replace(re, '$1' + v) : url + (url.includes('?') ? '&' : '?') + v;
6791
+ }
6709
6792
  let s;
6710
6793
  try {
6711
- s = t.sse ? new C(t.url, { withCredentials: !!t.spec.withCredentials, ...(hdr && { headers: hdr, fetch: options.fetch }) }) : new C(t.url, t.spec.protocols);
6794
+ s = t.sse ? new C(url, { withCredentials: !!t.spec.withCredentials, ...(hdr && { headers: hdr, fetch: options.fetch }), ...(r && { lastEventId: t.id }) }) : new C(t.url, t.spec.protocols);
6712
6795
  }
6713
6796
  catch (error) {
6714
6797
  // a URL the constructor rejects never opens: no retry
@@ -6723,12 +6806,22 @@ function makeSocketDriver(options = {}) {
6723
6806
  s.onopen = mine(() => {
6724
6807
  t.n = 0;
6725
6808
  t.open = true;
6809
+ alive(t);
6726
6810
  each(t, e => { deliver(e, 'open', { reconnected: !!e.opened }); e.opened = true; });
6727
6811
  const q = t.queue;
6728
6812
  t.queue = [];
6729
6813
  q.forEach((x) => t.s === s && out(t, x.data, x.e));
6730
6814
  });
6731
- s.onmessage = mine((m) => { const d = parse(m.data); each(t, e => deliver(e, 'message', d)); });
6815
+ s.onmessage = mine((m) => {
6816
+ if (t.sse)
6817
+ seen(t, m);
6818
+ else
6819
+ alive(t);
6820
+ const d = parse(m.data);
6821
+ each(t, e => deliver(e, 'message', e.spec.meta && t.sse ? got(t, m, e) : d));
6822
+ });
6823
+ // S-1: FetchEventSource's bytes (heartbeat comments) count as life
6824
+ s.onbeat = mine(() => alive(t));
6732
6825
  s.onerror = mine((error) => {
6733
6826
  each(t, e => deliver(e, 'error', { error }));
6734
6827
  if (t.sse) {
@@ -6750,6 +6843,7 @@ function makeSocketDriver(options = {}) {
6750
6843
  // closes the driver makes: no `close` action, late events ignored, retry cancelled
6751
6844
  const end = (t) => {
6752
6845
  clearTimeout(t.timer);
6846
+ clearTimeout(t.st);
6753
6847
  transports.delete(t.key);
6754
6848
  const s = t.s;
6755
6849
  t.s = null;
@@ -8548,7 +8642,8 @@ function makeHeadDriver(options = {}) {
8548
8642
  * falsy one stops, a changed spec restarts (an equal one keeps running).
8549
8643
  * - `every`: drift-free; tick n is due at start + n * every (each delay is computed from the
8550
8644
  * start, not chained). A late tick (a throttled background tab) coalesces the missed ones:
8551
- * n jumps. Data `{ n, t }`: the tick number from 1, `t` = Date.now().
8645
+ * n jumps. A delay is at most `every`, so a frozen Date.now() (a test clock that fixes the time
8646
+ * while timers run) ticks once per interval. Data `{ n, t }`: the tick number from 1, `t` = Date.now().
8552
8647
  * - `after`: fires once, data `{ t }`. It doesn't fire again while the same spec stays declared.
8553
8648
  * - `frame`: every animation frame (requestAnimationFrame; setTimeout 16 ms without it), data
8554
8649
  * `{ t, dt }`: Date.now() and the ms since the previous frame (0 on the first).
@@ -8580,7 +8675,9 @@ const start$1 = (s, send, e) => {
8580
8675
  }
8581
8676
  else if (every) {
8582
8677
  n = Math.max(n + 1, Math.floor((t - t0) / every));
8583
- later(t0 + (n + 1) * every - t);
8678
+ // never more than one interval: under a frozen Date.now() (Playwright's setFixedTime: Date is
8679
+ // fixed, timers run) the drift-free delay grew by one interval per tick
8680
+ later(Math.min(every, t0 + (n + 1) * every - t));
8584
8681
  send(s.action, { n, t });
8585
8682
  }
8586
8683
  else {
@@ -10653,13 +10750,13 @@ const addTimers = (v) => {
10653
10750
  return;
10654
10751
  timed.add(v);
10655
10752
  let own = v.timers;
10656
- const all = (st) => {
10753
+ const all = (st, c) => {
10657
10754
  const o = {}, f = own;
10658
10755
  if (typeof f == 'function')
10659
- Object.assign(o, f(st));
10756
+ Object.assign(o, f(st, c));
10660
10757
  else if (f && typeof f == 'object')
10661
10758
  for (const r in f)
10662
- o[r] = f[r](st);
10759
+ o[r] = f[r](st, c);
10663
10760
  const u = v.uses;
10664
10761
  for (const k in u) {
10665
10762
  const b = u[k], t = b?.timers?.(st?.[k] ?? b.state, b.options, k), rn = (a) => a in (b.model || {}) ? k + '.' + a : a;
@@ -12293,7 +12390,7 @@ function withUses(def, state) {
12293
12390
  * state reads from the cache ('success' with the cached data), else 'loading' ('idle' for a
12294
12391
  * falsy request), as the client's first paint does with the same (hydrated) cache
12295
12392
  */
12296
- function withResources(def, state) {
12393
+ function withResources(def, state, ctx) {
12297
12394
  state = withUses(def, state);
12298
12395
  const res = def && def.resources;
12299
12396
  if (!res || !state || typeof state !== 'object' || Array.isArray(state))
@@ -12304,7 +12401,7 @@ function withResources(def, state) {
12304
12401
  continue;
12305
12402
  let q;
12306
12403
  try {
12307
- q = typeof res[n] === 'function' ? res[n](state) : res[n];
12404
+ q = typeof res[n] === 'function' ? res[n](state, ctx) : res[n];
12308
12405
  }
12309
12406
  catch (_) { }
12310
12407
  const E = q && ssrCache ? ssrCache.get(ssrCache.key(q, 1)) : undefined;
@@ -12351,15 +12448,33 @@ function viewFailed(def, err, name) {
12351
12448
  }
12352
12449
  return vnode;
12353
12450
  }
12354
- function collectHead(def, state) {
12451
+ function collectHead(def, state, ctx) {
12355
12452
  const h = def && def.head;
12356
12453
  if (!heads || !h)
12357
12454
  return;
12358
12455
  try {
12359
- heads.push(typeof h === 'function' ? h(state) : h);
12456
+ heads.push(typeof h === 'function' ? h(state, ctx) : h);
12360
12457
  }
12361
12458
  catch (_) { }
12362
12459
  }
12460
+ /**
12461
+ * PLAN-13 F-1: the context a component sees (its parent's, plus its own entries computed on
12462
+ * `state`). The statics get it as on the client; for `resources` it is computed on the state
12463
+ * before the resources are filled in (a context entry can't read the component's own resource)
12464
+ */
12465
+ function contextOf(def, state, parent) {
12466
+ const own = def.context || {}, out = { ...parent };
12467
+ for (const key of Object.keys(own)) {
12468
+ const f = own[key];
12469
+ if (typeof f === 'function' && state != null) {
12470
+ try {
12471
+ out[key] = f(state);
12472
+ }
12473
+ catch (_) { }
12474
+ }
12475
+ }
12476
+ return out;
12477
+ }
12363
12478
  /**
12364
12479
  * Render a Sygnal component to an HTML string.
12365
12480
  *
@@ -12400,20 +12515,10 @@ function renderRoot(componentDef, options) {
12400
12515
  const uid = (options.uid || 'u').replace(/[^\w-]+/g, '_');
12401
12516
  const ownState = state !== undefined ? state : componentDef.initialState;
12402
12517
  // 5-5: the view sees its resources; the hydration script keeps the state as it was given
12403
- const resolvedState = withResources(componentDef, ownState);
12404
- collectHead(componentDef, resolvedState);
12518
+ const resolvedState = withResources(componentDef, ownState, contextOf(componentDef, ownState, context));
12405
12519
  // Build context: merge parent context with component's own context definitions
12406
- const componentContext = componentDef.context || {};
12407
- const mergedContext = { ...context };
12408
- for (const key of Object.keys(componentContext)) {
12409
- const contextFn = componentContext[key];
12410
- if (typeof contextFn === 'function' && resolvedState != null) {
12411
- try {
12412
- mergedContext[key] = contextFn(resolvedState);
12413
- }
12414
- catch (_) { }
12415
- }
12416
- }
12520
+ const mergedContext = contextOf(componentDef, resolvedState, context);
12521
+ collectHead(componentDef, resolvedState, mergedContext);
12417
12522
  // Call the view function
12418
12523
  let vnode;
12419
12524
  try {
@@ -12624,19 +12729,9 @@ function renderSubComponent(vnode, context, parentState, uid) {
12624
12729
  // State passed directly
12625
12730
  childState = stateProp;
12626
12731
  }
12627
- childState = withResources(componentDef, childState);
12732
+ childState = withResources(componentDef, childState, contextOf(componentDef, childState, context));
12628
12733
  // Build child context
12629
- const childContext = { ...context };
12630
- const componentContext = componentDef.context || {};
12631
- for (const key of Object.keys(componentContext)) {
12632
- const contextFn = componentContext[key];
12633
- if (typeof contextFn === 'function' && childState != null) {
12634
- try {
12635
- childContext[key] = contextFn(childState);
12636
- }
12637
- catch (_) { }
12638
- }
12639
- }
12734
+ const childContext = contextOf(componentDef, childState, context);
12640
12735
  // Extract slots from children
12641
12736
  const slots = {};
12642
12737
  const defaultSlotChildren = [];
@@ -12658,7 +12753,7 @@ function renderSubComponent(vnode, context, parentState, uid) {
12658
12753
  slots.default = [];
12659
12754
  slots.default.push(...defaultSlotChildren);
12660
12755
  }
12661
- collectHead(componentDef, childState);
12756
+ collectHead(componentDef, childState, childContext);
12662
12757
  // Call the view function
12663
12758
  let result;
12664
12759
  try {
@@ -12875,19 +12970,10 @@ function renderSwitchable(vnode, context, parentState, uid) {
12875
12970
  * Internal helper: render a component def to a VNode (not HTML string).
12876
12971
  */
12877
12972
  function renderToStringInternal(componentDef, state, context, uid) {
12878
- const resolvedState = withResources(componentDef, state !== undefined ? state : componentDef.initialState);
12879
- const componentContext = componentDef.context || {};
12880
- const mergedContext = { ...context };
12881
- for (const key of Object.keys(componentContext)) {
12882
- const contextFn = componentContext[key];
12883
- if (typeof contextFn === 'function' && resolvedState != null) {
12884
- try {
12885
- mergedContext[key] = contextFn(resolvedState);
12886
- }
12887
- catch (_) { }
12888
- }
12889
- }
12890
- collectHead(componentDef, resolvedState);
12973
+ const own = state !== undefined ? state : componentDef.initialState;
12974
+ const resolvedState = withResources(componentDef, own, contextOf(componentDef, own, context));
12975
+ const mergedContext = contextOf(componentDef, resolvedState, context);
12976
+ collectHead(componentDef, resolvedState, mergedContext);
12891
12977
  let vnode;
12892
12978
  try {
12893
12979
  vnode = componentDef({
@@ -15659,6 +15745,8 @@ const sockKey = (sse, url, arg) => (sse ? 'e' + !!(arg && arg.withCredentials) :
15659
15745
  * PLAN-3 2-C: the in-memory WebSocket / EventSource the fake's makeSocketDriver opens. The
15660
15746
  * harness drives it (t.open / t.push / t.drop); the driver's own close() is the app's close.
15661
15747
  */
15748
+ /** `url` without its query parameter `name` (PLAN-13 S-1: a resumed reconnect's id parameter) */
15749
+ const withoutParam = (url, name) => url.replace(new RegExp('([?&])' + name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '=[^&#]*&?'), '$1').replace(/[?&](#|$)/, '$1');
15662
15750
  const fakeSocketClass = (sse, made) => class {
15663
15751
  constructor(url, arg) {
15664
15752
  this.readyState = 0;
@@ -15666,7 +15754,14 @@ const fakeSocketClass = (sse, made) => class {
15666
15754
  this.byApp = false;
15667
15755
  this.ls = {};
15668
15756
  this.url = String(url);
15669
- this.k = sockKey(sse, this.url, arg);
15757
+ // PLAN-13 S-1: a resumed SSE reconnect's URL has the id parameter (added, or replacing the
15758
+ // declared one): its key leaves that parameter out (`rn`: its name; made() compares the
15759
+ // connections' URLs without it too)
15760
+ const id = sse && arg?.lastEventId;
15761
+ const m = id && new RegExp('[?&]([^?&=]+)=' + encodeURIComponent(id).replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(?=&|#|$)').exec(this.url);
15762
+ if (m)
15763
+ this.rn = m[1];
15764
+ this.k = sockKey(sse, m ? withoutParam(this.url, m[1]) : this.url, arg);
15670
15765
  made(this);
15671
15766
  }
15672
15767
  send() { }
@@ -16265,7 +16360,7 @@ function renderComponent(componentDef, options = {}) {
16265
16360
  const w = { src: null, in$: xs$1.create(), conns: new Map(), sockets: [], sent: [], declaring: null };
16266
16361
  const made = (s) => {
16267
16362
  w.sockets.push(s);
16268
- const all = [...w.conns.values()].flatMap(m => [...m.values()]).filter(c => c.k == s.k);
16363
+ const all = [...w.conns.values()].flatMap(m => [...m.values()]).filter(c => (s.rn ? sockKey(true, withoutParam(c.url, s.rn), c.spec) : c.k) == s.k);
16269
16364
  if (w.declaring) {
16270
16365
  // a declaration opened it: the first new connection with its key (and, shared, the
16271
16366
  // other new shared ones)
@@ -16402,7 +16497,7 @@ function renderComponent(componentDef, options = {}) {
16402
16497
  names.push(k + '.' + a);
16403
16498
  // G-275: the caller's initialState is owned (the dev statics freeze leaves it alone). G-289: a
16404
16499
  // copy is marked, not the caller's object
16405
- const init = initialState !== undefined ? ownedCopy(initialState) : componentDef.initialState;
16500
+ let init = initialState !== undefined ? ownedCopy(initialState) : componentDef.initialState;
16406
16501
  // G-028: with no intent, model or initialState nothing would ever emit state, so the view
16407
16502
  // never renders. Leave intent/model unset so the component falls back to the same no-op
16408
16503
  // model run() uses, and renders.
@@ -16694,6 +16789,18 @@ function renderComponent(componentDef, options = {}) {
16694
16789
  },
16695
16790
  };
16696
16791
  };
16792
+ // PLAN-13 F-2: a root that declares `route` starts from the test URL's route: its initialState's
16793
+ // router.current() ran at module load, before the test's URL existed. The route entry's STATE
16794
+ // reducer is applied to the initial state (its other sinks, a guard's redirect, run once when the
16795
+ // router answers the declaration), so the first render and the statics see the page the test
16796
+ // starts on: no render of '/' and no request for it
16797
+ const re = rt && typeof componentDef.route == 'string' && model[componentDef.route];
16798
+ const seed = typeof re == 'function' ? re : re && typeof re == 'object' && typeof re.STATE == 'function' && re.STATE;
16799
+ if (seed && init !== undefined) {
16800
+ const v = seed(init, rt.r.current(), noop, { state: init, previousState: init });
16801
+ if (v !== undefined && !isAbort(v))
16802
+ init = v;
16803
+ }
16697
16804
  let sources, sinks;
16698
16805
  try {
16699
16806
  // the root runs with the test intent, model, initial state and name, and the drivers above.
@@ -17402,11 +17509,14 @@ function renderComponent(componentDef, options = {}) {
17402
17509
  };
17403
17510
  const open = (name, target) => sockCall('open', name, target, [0], sockOpen);
17404
17511
  const push = (name, data, target) => {
17405
- const o = target && typeof target == 'object' && !Array.isArray(target) && Object.keys(target).length && Object.keys(target).every(k => k == 'event' || k == 'connection') ? target : { connection: target };
17512
+ const o = target && typeof target == 'object' && !Array.isArray(target) && Object.keys(target).length && Object.keys(target).every(k => k == 'event' || k == 'connection' || k == 'id') ? target : { connection: target };
17406
17513
  const raw = typeof data == 'string' || (data && typeof data == 'object' && (data instanceof ArrayBuffer || ArrayBuffer.isView(data) || (typeof Blob != 'undefined' && data instanceof Blob))) ? data : JSON.stringify(data);
17407
17514
  const ev = o.event;
17408
17515
  return sockCall('push', name, o.connection, [1], s => {
17409
- const m = { type: ev || 'message', data: raw };
17516
+ // PLAN-13 S-1: `id` sets the event id; as in EventSource, lastEventId keeps the last one
17517
+ if (s.sse && o.id != null)
17518
+ s.lastId = String(o.id);
17519
+ const m = { type: ev || 'message', data: raw, ...(s.sse && { lastEventId: s.lastId ?? '' }) };
17410
17520
  if (!ev || ev == 'message')
17411
17521
  sockFire(s, 'message', m);
17412
17522
  if (s.sse)
@@ -26488,6 +26598,7 @@ exports.makeDragDriver = makeDragDriver;
26488
26598
  exports.makeFetchDriver = makeFetchDriver;
26489
26599
  exports.makeHeadDriver = makeHeadDriver;
26490
26600
  exports.makeMcpAppDriver = makeMcpAppDriver;
26601
+ exports.makeReplies = makeReplies;
26491
26602
  exports.makeRouter = makeRouter;
26492
26603
  exports.makeRouterDriver = makeRouterDriver;
26493
26604
  exports.makeServiceWorkerDriver = makeServiceWorkerDriver;
@@ -26521,6 +26632,7 @@ exports.run = run;
26521
26632
  exports.sampleCombine = sampleCombine;
26522
26633
  exports.score = score;
26523
26634
  exports.selection = selection;
26635
+ exports.senderOf = senderOf;
26524
26636
  exports.set = set;
26525
26637
  exports.setField = setField;
26526
26638
  exports.sortable = sortable;