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.
- package/CHANGELOG.md +126 -0
- package/dist/astro/server.cjs.js +32 -43
- package/dist/astro/server.cjs.js.map +1 -1
- package/dist/astro/server.mjs +32 -43
- package/dist/astro/server.mjs.map +1 -1
- package/dist/diagnostics.cjs.js +67 -2
- package/dist/diagnostics.cjs.js.map +1 -1
- package/dist/diagnostics.esm.js +67 -2
- package/dist/diagnostics.esm.js.map +1 -1
- package/dist/guide/accessibility.md +1 -1
- package/dist/guide/resources.md +2 -0
- package/dist/guide/timers.md +3 -3
- package/dist/index.cjs.js +189 -77
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +103 -16
- package/dist/index.esm.js +188 -78
- package/dist/index.esm.js.map +1 -1
- package/dist/sygnal.min.js +1 -1
- package/dist/sygnal.min.js.map +1 -1
- package/dist/vike/config/package.json +1 -1
- package/llms.txt +7 -7
- package/package.json +1 -1
- package/src/core/hosts/switchable.ts +26 -8
- package/src/core/instance.ts +12 -7
- package/src/core/statics.ts +5 -5
- package/src/extra/ai/transports/shared.ts +3 -1
- package/src/extra/behaviors.ts +3 -3
- package/src/extra/diagnostics/checks/next.ts +46 -3
- package/src/extra/diagnostics/checks/props.ts +6 -1
- package/src/extra/diagnostics/codes.ts +11 -0
- package/src/extra/fetchDriver.ts +3 -0
- package/src/extra/fetchEventSource.ts +6 -2
- package/src/extra/socketDriver.ts +51 -6
- package/src/extra/ssr.ts +30 -41
- package/src/extra/testing.ts +32 -7
- package/src/extra/timers.ts +5 -2
- package/src/index.d.ts +102 -15
- package/src/index.ts +2 -0
|
@@ -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 '
|
|
221
|
+
import { xs } from 'sygnal'
|
|
222
222
|
|
|
223
223
|
// Flagged: SHOW: DOM.mouseenter('.info'), with nothing else sending SHOW
|
|
224
224
|
|
package/dist/guide/resources.md
CHANGED
|
@@ -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
|
package/dist/guide/timers.md
CHANGED
|
@@ -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 (
|
|
2828
|
-
|
|
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
|
|
4262
|
-
|
|
4263
|
-
|
|
4264
|
-
|
|
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
|
|
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) =>
|
|
6682
|
-
|
|
6683
|
-
|
|
6684
|
-
|
|
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(
|
|
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) => {
|
|
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.
|
|
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
|
-
|
|
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
|
|
12407
|
-
|
|
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 =
|
|
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
|
|
12879
|
-
const
|
|
12880
|
-
const mergedContext =
|
|
12881
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|