sygnal 6.6.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.
Files changed (91) hide show
  1. package/CHANGELOG.md +258 -0
  2. package/README.md +2 -0
  3. package/dist/astro/index.cjs.js +32 -9
  4. package/dist/astro/index.cjs.js.map +1 -1
  5. package/dist/astro/index.mjs +32 -9
  6. package/dist/astro/index.mjs.map +1 -1
  7. package/dist/astro/server.cjs.js +32 -43
  8. package/dist/astro/server.cjs.js.map +1 -1
  9. package/dist/astro/server.mjs +32 -43
  10. package/dist/astro/server.mjs.map +1 -1
  11. package/dist/devtools.cjs.js.map +1 -1
  12. package/dist/devtools.esm.js.map +1 -1
  13. package/dist/diagnostics.cjs.js +154 -6
  14. package/dist/diagnostics.cjs.js.map +1 -1
  15. package/dist/diagnostics.esm.js +153 -7
  16. package/dist/diagnostics.esm.js.map +1 -1
  17. package/dist/guide/accessibility.md +3 -1
  18. package/dist/guide/ai-chat.md +6 -6
  19. package/dist/guide/behaviors.md +1 -1
  20. package/dist/guide/element-commands.md +4 -2
  21. package/dist/guide/forms-reference.md +89 -3
  22. package/dist/guide/forms.md +8 -8
  23. package/dist/guide/http.md +77 -7
  24. package/dist/guide/migrating-to-7.md +314 -0
  25. package/dist/guide/recipes/ai-summarize.md +2 -1
  26. package/dist/guide/recipes/i18n.md +1 -1
  27. package/dist/guide/resources.md +2 -0
  28. package/dist/guide/timers.md +3 -3
  29. package/dist/guide/ui/tabs.md +5 -4
  30. package/dist/guide/ui/tooltip.md +24 -0
  31. package/dist/index.cjs.js +954 -388
  32. package/dist/index.cjs.js.map +1 -1
  33. package/dist/index.d.ts +300 -46
  34. package/dist/index.esm.js +953 -389
  35. package/dist/index.esm.js.map +1 -1
  36. package/dist/sygnal.min.js +1 -1
  37. package/dist/sygnal.min.js.map +1 -1
  38. package/dist/ui.cjs.js +96 -11
  39. package/dist/ui.cjs.js.map +1 -1
  40. package/dist/ui.esm.js +96 -12
  41. package/dist/ui.esm.js.map +1 -1
  42. package/dist/vike/config/package.json +1 -1
  43. package/dist/vite/plugin.cjs.js +19 -9
  44. package/dist/vite/plugin.cjs.js.map +1 -1
  45. package/dist/vite/plugin.mjs +19 -9
  46. package/dist/vite/plugin.mjs.map +1 -1
  47. package/llms.txt +23 -22
  48. package/package.json +1 -1
  49. package/src/astro/index.d.ts +8 -0
  50. package/src/astro/index.ts +18 -0
  51. package/src/core/actions.ts +28 -21
  52. package/src/core/define.ts +2 -0
  53. package/src/core/hooks.ts +2 -2
  54. package/src/core/hosts/collection.ts +4 -3
  55. package/src/core/hosts/switchable.ts +26 -8
  56. package/src/core/instance.ts +66 -17
  57. package/src/core/runtime.ts +5 -1
  58. package/src/core/statics.ts +5 -5
  59. package/src/extra/ai/chat/behavior.ts +3 -3
  60. package/src/extra/ai/commandBar.ts +3 -3
  61. package/src/extra/ai/compose/behavior.ts +3 -3
  62. package/src/extra/ai/transports/shared.ts +10 -2
  63. package/src/extra/behaviors.ts +24 -3
  64. package/src/extra/diagnostics/checks/fetch.ts +21 -1
  65. package/src/extra/diagnostics/checks/forms.ts +4 -3
  66. package/src/extra/diagnostics/checks/index.ts +1 -0
  67. package/src/extra/diagnostics/checks/next.ts +49 -3
  68. package/src/extra/diagnostics/checks/props.ts +6 -1
  69. package/src/extra/diagnostics/checks/public.d.ts +12 -0
  70. package/src/extra/diagnostics/checks/timingAudit.ts +47 -0
  71. package/src/extra/diagnostics/codes.ts +24 -0
  72. package/src/extra/fetchDriver.ts +115 -7
  73. package/src/extra/fetchEventSource.ts +117 -0
  74. package/src/extra/form.ts +68 -26
  75. package/src/extra/formHelpers.ts +3 -1
  76. package/src/extra/lens.ts +3 -2
  77. package/src/extra/queryCache.ts +4 -3
  78. package/src/extra/socketDriver.ts +60 -9
  79. package/src/extra/ssr.ts +30 -41
  80. package/src/extra/testing.ts +100 -18
  81. package/src/extra/timers.ts +5 -2
  82. package/src/index.d.ts +299 -45
  83. package/src/index.ts +3 -0
  84. package/src/shared.ts +3 -0
  85. package/src/ui/dialog.ts +3 -2
  86. package/src/ui/tooltips.ts +92 -0
  87. package/src/ui.d.ts +35 -0
  88. package/src/ui.ts +1 -0
  89. package/src/vite/graph.ts +11 -3
  90. package/src/vite/plugin.d.ts +7 -0
  91. package/src/vite/plugin.ts +10 -5
package/src/extra/lens.ts CHANGED
@@ -5,11 +5,12 @@
5
5
  * is gone); set: the array with that element replaced (the state unchanged when it is gone).
6
6
  * Side-effect free.
7
7
  */
8
- export const byId = (field: string, id: any, idKey = 'id') => ({
8
+ import {BINDING} from '../shared'
9
+ export const byId = (field: string, id: any, idKey = 'id') => Object.defineProperty({
9
10
  get: (s: any) => (Array.isArray(s?.[field]) ? s[field].find((x: any) => x?.[idKey] === id) : undefined),
10
11
  set: (s: any, v: any) => {
11
12
  const a = s?.[field]
12
13
  if (!Array.isArray(a) || !a.some((x: any) => x?.[idKey] === id)) return s
13
14
  return {...s, [field]: a.map((x: any) => (x?.[idKey] === id ? v : x))}
14
15
  },
15
- })
16
+ }, BINDING, {value: [field, id, idKey]})
@@ -43,9 +43,10 @@ export function queryCache(o: any = {}): any {
43
43
  // the key of a cacheable request (else undefined); `res`: a resource's
44
44
  const key = (q: any, res?: any) => {
45
45
  if (typeof q == 'string') q = {url: q};
46
- const {method, json, body, parse, query} = q;
47
- const m = (method || (json !== undefined || body !== undefined ? 'POST' : 'GET')).toUpperCase();
48
- const b = json !== undefined ? JSON.stringify(json) : body ?? '';
46
+ const {method, json, body, parse, query, multipart} = q;
47
+ const m = (method || (json !== undefined || body !== undefined || multipart !== undefined ? 'POST' : 'GET')).toUpperCase();
48
+ // PLAN-11 U-5: a multipart request is never cached (its body isn't a string)
49
+ const b = json !== undefined ? JSON.stringify(json) : multipart !== undefined ? multipart : body ?? '';
49
50
  if (q.cache === false || typeof b != 'string' || typeof parse == 'function' || parse == 'response' ||
50
51
  !(q.cache || q.staleTime != null || (res && IDEMPOTENT.test(m)))) return;
51
52
  return [m, withQuery(q.url ?? '', query, 1), b, parse].join(' ').trim();
@@ -2,6 +2,7 @@ import xs, {Stream} from 'xstream';
2
2
  import {senderOf, keepSender, allowed, makeReplies} from './replies';
3
3
  import {error as logError} from './diagnostics/legacy';
4
4
  import {backoff} from './backoff';
5
+ import {FetchEventSource} from './fetchEventSource';
5
6
 
6
7
  /*
7
8
  * makeSocketDriver(options?) (PLAN-3 §1.3): WebSocket and server-sent events, declared per
@@ -11,7 +12,7 @@ import {backoff} from './backoff';
11
12
  * - Sink values:
12
13
  * - `{ connections: { [name]: spec | falsy } }`: the sender's whole set of connections. Diffed
13
14
  * per (sender, name): a new name opens, a removed or falsy one closes, a changed URL /
14
- * protocols / withCredentials / share closes and reopens, anything else (action names,
15
+ * protocols / withCredentials / headers / share closes and reopens, anything else (action names,
15
16
  * reconnect) only rebinds.
16
17
  * - `{ to: name, json? | text? | binary? }`: send on the sender's connection `name` (json is
17
18
  * stringified). Queued while (re)connecting, bounded (`queueLimit`, oldest dropped). A send
@@ -36,6 +37,16 @@ import {backoff} from './backoff';
36
37
  * - A disposed sender's connections close (and their retries stop); app dispose closes all.
37
38
  * - No WebSocket / EventSource (SSR): nothing opens, nothing is reported.
38
39
  * - `WebSocket` / `EventSource` are read at connect time: options.WebSocket ?? globalThis.WebSocket.
40
+ * - PLAN-11 E-1: an `sse` connection with `headers` (data: a token in it comes from state, and a
41
+ * changed header reconnects) is read with FetchEventSource (fetch, `options.fetch` if given),
42
+ * since EventSource can't send headers. An `options.EventSource` (a polyfill, the test fake)
43
+ * still wins and gets the headers in its init.
44
+ * - PLAN-13 S-1: SSE `meta: true`: message and `events` actions get `{ data, id, event }`. `resume:
45
+ * true | 'param'`: the transport keeps the last event id, and a reconnect the driver makes (after
46
+ * EventSource gave up) asks to resume from it: `?lastEventId=` (or `?param=`) on the URL, or
47
+ * `Last-Event-ID` on the fetch path (`init.lastEventId`). `staleAfterMs` (SSE and WebSocket): no
48
+ * event for that long (over fetch: no bytes, heartbeat comments count) closes the connection as a
49
+ * drop (`close` gets `stale: true`), so the reconnect policy applies.
39
50
  */
40
51
 
41
52
  const SCOPE = '__sygnalScope';
@@ -91,19 +102,40 @@ export function makeSocketDriver(options: any = {}) {
91
102
  const out = (t: any, data: any, e: any) => {
92
103
  try { t.s.send(data); } catch (error) { deliver(e, 'error', {error}); }
93
104
  };
105
+ // S-1: an SSE event as a subscriber gets it (`meta`: with its id and type); the transport
106
+ // keeps the last id (resume) and counts it as life (staleAfterMs)
107
+ const got = (t: any, m: any, e: any) => e.sse && e.spec.meta ? {data: parse(m.data), id: m.lastEventId ?? '', event: m.type || 'message'} : parse(m.data);
108
+ const seen = (t: any, m: any) => { if (m.lastEventId) t.id = m.lastEventId; alive(t); };
94
109
  // SSE named events: one listener per name across the subscribers
95
110
  const listen = (t: any) => t.s && t.sse && each(t, e => Object.keys(e.spec.events || {}).forEach(name => {
96
111
  if (t.on.has(name)) return;
97
112
  t.on.add(name);
98
113
  const s = t.s;
99
- s.addEventListener(name, (m: any) => t.s === s && each(t, x => {
100
- const a = x.spec.events?.[name];
101
- if (typeof a == 'string' && x.sender !== undefined) reply(x.sender, a, parse(m.data));
102
- }));
114
+ s.addEventListener(name, (m: any) => {
115
+ if (t.s !== s) return;
116
+ seen(t, m);
117
+ each(t, x => {
118
+ const a = x.spec.events?.[name];
119
+ if (typeof a == 'string' && x.sender !== undefined) reply(x.sender, a, got(t, m, x));
120
+ });
121
+ });
103
122
  }));
123
+ // S-1: staleAfterMs (the smallest of the subscribers'): re-armed by every event; when it fires
124
+ // the connection is dropped (as if the server had gone) and the reconnect policy applies
125
+ const alive = (t: any) => {
126
+ clearTimeout(t.st);
127
+ const ms = Math.min(...[...t.subs].map(e => e.spec.staleAfterMs).filter(x => x > 0));
128
+ const s = t.s;
129
+ if (s && ms < 1 / 0) t.st = setTimeout(() => {
130
+ if (t.s !== s) return;
131
+ try { s.close(); } catch (_) {}
132
+ gone(t, t.sse ? {stale: true} : {code: 1006, reason: 'stale', stale: true});
133
+ }, ms);
134
+ };
104
135
 
105
136
  // the socket closed (or failed to open) without the driver closing it
106
137
  const gone = (t: any, info: any) => {
138
+ clearTimeout(t.st);
107
139
  t.s = null;
108
140
  t.open = false;
109
141
  let again: any;
@@ -118,11 +150,22 @@ export function makeSocketDriver(options: any = {}) {
118
150
 
119
151
  const connect = (t: any) => {
120
152
  t.timer = 0;
121
- const C = t.sse ? options.EventSource ?? g.EventSource : options.WebSocket ?? g.WebSocket;
153
+ const hdr = t.sse && t.spec.headers && typeof t.spec.headers == 'object' ? t.spec.headers : undefined;
154
+ const C = t.sse ? options.EventSource ?? (hdr && typeof g.fetch == 'function' ? FetchEventSource : g.EventSource) : options.WebSocket ?? g.WebSocket;
122
155
  if (typeof C != 'function') return;
156
+ // S-1: resume from the last event id: Last-Event-ID on the fetch path (init.lastEventId, which
157
+ // a polyfill or the test fake gets too), else a URL parameter
158
+ // (a parameter the URL already has is replaced, not added again)
159
+ const r = t.sse && t.id && [...t.subs].map(e => e.spec.resume).find(x => x);
160
+ let url = t.url;
161
+ if (r && !hdr) {
162
+ const p = encodeURIComponent(r === true ? 'lastEventId' : r) + '=', v = p + encodeURIComponent(t.id);
163
+ const re = new RegExp('([?&])' + p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '[^&#]*');
164
+ url = re.test(url) ? url.replace(re, '$1' + v) : url + (url.includes('?') ? '&' : '?') + v;
165
+ }
123
166
  let s: any;
124
167
  try {
125
- s = t.sse ? new C(t.url, {withCredentials: !!t.spec.withCredentials}) : new C(t.url, t.spec.protocols);
168
+ 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);
126
169
  } catch (error) {
127
170
  // a URL the constructor rejects never opens: no retry
128
171
  each(t, e => deliver(e, 'error', {error}));
@@ -135,12 +178,19 @@ export function makeSocketDriver(options: any = {}) {
135
178
  s.onopen = mine(() => {
136
179
  t.n = 0;
137
180
  t.open = true;
181
+ alive(t);
138
182
  each(t, e => { deliver(e, 'open', {reconnected: !!e.opened}); e.opened = true; });
139
183
  const q = t.queue;
140
184
  t.queue = [];
141
185
  q.forEach((x: any) => t.s === s && out(t, x.data, x.e));
142
186
  });
143
- s.onmessage = mine((m: any) => { const d = parse(m.data); each(t, e => deliver(e, 'message', d)); });
187
+ s.onmessage = mine((m: any) => {
188
+ if (t.sse) seen(t, m); else alive(t);
189
+ const d = parse(m.data);
190
+ each(t, e => deliver(e, 'message', e.spec.meta && t.sse ? got(t, m, e) : d));
191
+ });
192
+ // S-1: FetchEventSource's bytes (heartbeat comments) count as life
193
+ s.onbeat = mine(() => alive(t));
144
194
  s.onerror = mine((error: any) => {
145
195
  each(t, e => deliver(e, 'error', {error}));
146
196
  if (t.sse) {
@@ -156,6 +206,7 @@ export function makeSocketDriver(options: any = {}) {
156
206
  // closes the driver makes: no `close` action, late events ignored, retry cancelled
157
207
  const end = (t: any) => {
158
208
  clearTimeout(t.timer);
209
+ clearTimeout(t.st);
159
210
  transports.delete(t.key);
160
211
  const s = t.s;
161
212
  t.s = null;
@@ -201,7 +252,7 @@ export function makeSocketDriver(options: any = {}) {
201
252
  const sse = typeof spec.sse == 'string';
202
253
  const url = resolve(sse ? spec.sse : spec.socket, sse);
203
254
  // the connection's identity: a change reconnects, anything else only rebinds
204
- const key = (spec.share === false ? 'u' : '') + (sse ? 'e' + !!spec.withCredentials : 's' + JSON.stringify(spec.protocols)) + url;
255
+ const key = (spec.share === false ? 'u' : '') + (sse ? 'e' + !!spec.withCredentials + (spec.headers ? JSON.stringify(spec.headers) : '') : 's' + JSON.stringify(spec.protocols)) + url;
205
256
  if (old && old.key === key) {
206
257
  old.spec = spec;
207
258
  if (old.t) listen(old.t);
package/src/extra/ssr.ts CHANGED
@@ -147,7 +147,7 @@ function withUses(def: any, state: any): any {
147
147
  * state reads from the cache ('success' with the cached data), else 'loading' ('idle' for a
148
148
  * falsy request), as the client's first paint does with the same (hydrated) cache
149
149
  */
150
- function withResources(def: any, state: any): any {
150
+ function withResources(def: any, state: any, ctx?: any): any {
151
151
  state = withUses(def, state)
152
152
  const res = def && def.resources
153
153
  if (!res || !state || typeof state !== 'object' || Array.isArray(state)) return state
@@ -155,7 +155,7 @@ function withResources(def: any, state: any): any {
155
155
  for (const n of Object.keys(res)) {
156
156
  if (out[n] !== undefined) continue
157
157
  let q: any
158
- try { q = typeof res[n] === 'function' ? res[n](state) : res[n] } catch (_) {}
158
+ try { q = typeof res[n] === 'function' ? res[n](state, ctx) : res[n] } catch (_) {}
159
159
  const E = q && ssrCache ? ssrCache.get(ssrCache.key(q, 1)) : undefined
160
160
  out[n] = E && E.at > 0 ? {status: 'success', data: E.v} : {status: q ? 'loading' : 'idle'}
161
161
  }
@@ -198,10 +198,26 @@ function viewFailed(def: any, err: any, name: string): any {
198
198
  return vnode
199
199
  }
200
200
 
201
- function collectHead(def: any, state: any): void {
201
+ function collectHead(def: any, state: any, ctx?: any): void {
202
202
  const h = def && def.head
203
203
  if (!heads || !h) return
204
- try { heads.push(typeof h === 'function' ? h(state) : h) } catch (_) {}
204
+ try { heads.push(typeof h === 'function' ? h(state, ctx) : h) } catch (_) {}
205
+ }
206
+
207
+ /**
208
+ * PLAN-13 F-1: the context a component sees (its parent's, plus its own entries computed on
209
+ * `state`). The statics get it as on the client; for `resources` it is computed on the state
210
+ * before the resources are filled in (a context entry can't read the component's own resource)
211
+ */
212
+ function contextOf(def: any, state: any, parent: Record<string, any>): Record<string, any> {
213
+ const own = def.context || {}, out: Record<string, any> = {...parent}
214
+ for (const key of Object.keys(own)) {
215
+ const f = own[key]
216
+ if (typeof f === 'function' && state != null) {
217
+ try { out[key] = f(state) } catch (_) {}
218
+ }
219
+ }
220
+ return out
205
221
  }
206
222
 
207
223
  /**
@@ -245,20 +261,11 @@ function renderRoot(componentDef: any, options: RenderToStringOptions): string {
245
261
 
246
262
  const ownState = state !== undefined ? state : componentDef.initialState
247
263
  // 5-5: the view sees its resources; the hydration script keeps the state as it was given
248
- const resolvedState = withResources(componentDef, ownState)
249
- collectHead(componentDef, resolvedState)
264
+ const resolvedState = withResources(componentDef, ownState, contextOf(componentDef, ownState, context))
250
265
 
251
266
  // Build context: merge parent context with component's own context definitions
252
- const componentContext = componentDef.context || {}
253
- const mergedContext: Record<string, any> = {...context}
254
- for (const key of Object.keys(componentContext)) {
255
- const contextFn = componentContext[key]
256
- if (typeof contextFn === 'function' && resolvedState != null) {
257
- try {
258
- mergedContext[key] = contextFn(resolvedState)
259
- } catch (_) {}
260
- }
261
- }
267
+ const mergedContext = contextOf(componentDef, resolvedState, context)
268
+ collectHead(componentDef, resolvedState, mergedContext)
262
269
 
263
270
  // Call the view function
264
271
  let vnode: any
@@ -474,19 +481,10 @@ function renderSubComponent(vnode: any, context: Record<string, any>, parentStat
474
481
  // State passed directly
475
482
  childState = stateProp
476
483
  }
477
- childState = withResources(componentDef, childState)
484
+ childState = withResources(componentDef, childState, contextOf(componentDef, childState, context))
478
485
 
479
486
  // Build child context
480
- const childContext: Record<string, any> = {...context}
481
- const componentContext = componentDef.context || {}
482
- for (const key of Object.keys(componentContext)) {
483
- const contextFn = componentContext[key]
484
- if (typeof contextFn === 'function' && childState != null) {
485
- try {
486
- childContext[key] = contextFn(childState)
487
- } catch (_) {}
488
- }
489
- }
487
+ const childContext = contextOf(componentDef, childState, context)
490
488
 
491
489
  // Extract slots from children
492
490
  const slots: Record<string, any[]> = {}
@@ -507,7 +505,7 @@ function renderSubComponent(vnode: any, context: Record<string, any>, parentStat
507
505
  slots.default.push(...defaultSlotChildren)
508
506
  }
509
507
 
510
- collectHead(componentDef, childState)
508
+ collectHead(componentDef, childState, childContext)
511
509
 
512
510
  // Call the view function
513
511
  let result: any
@@ -713,20 +711,11 @@ function renderSwitchable(vnode: any, context: Record<string, any>, parentState:
713
711
  * Internal helper: render a component def to a VNode (not HTML string).
714
712
  */
715
713
  function renderToStringInternal(componentDef: any, state: any, context: Record<string, any>, uid: string): any {
716
- const resolvedState = withResources(componentDef, state !== undefined ? state : componentDef.initialState)
717
-
718
- const componentContext = componentDef.context || {}
719
- const mergedContext: Record<string, any> = {...context}
720
- for (const key of Object.keys(componentContext)) {
721
- const contextFn = componentContext[key]
722
- if (typeof contextFn === 'function' && resolvedState != null) {
723
- try {
724
- mergedContext[key] = contextFn(resolvedState)
725
- } catch (_) {}
726
- }
727
- }
714
+ const own = state !== undefined ? state : componentDef.initialState
715
+ const resolvedState = withResources(componentDef, own, contextOf(componentDef, own, context))
716
+ const mergedContext = contextOf(componentDef, resolvedState, context)
728
717
 
729
- collectHead(componentDef, resolvedState)
718
+ collectHead(componentDef, resolvedState, mergedContext)
730
719
  let vnode: any
731
720
  try {
732
721
  vnode = componentDef({
@@ -5,6 +5,7 @@ import {enrichEventStream} from '../cycle/dom/enrichEventStream';
5
5
  import eventBusDriver from './eventDriver';
6
6
  import logDriver from './logDriver';
7
7
  import {ownedCopy} from './owned';
8
+ import {isAbort} from '../shared';
8
9
  import {renderToInnerHtml} from './ssr';
9
10
  import {_getDiagnosticsConfig, configureDiagnostics, getDiagnosticsMode, isDiagnosticsEnabled, onDiagnostic, report} from './diagnostics/index';
10
11
  import xs from './xstreamCompat';
@@ -252,6 +253,13 @@ export interface RenderOptions {
252
253
  * Default: unchanged (off unless configureStrict(true) was called).
253
254
  */
254
255
  strict?: boolean;
256
+ /**
257
+ * 7.0 timing audit (SYG257, info) while the component is rendered; restored on dispose():
258
+ * each output sink that reads a top-level state field its action's STATE changed is reported
259
+ * (it gets the new value since 7.0; 6.x gave it the old one). Requires 'sygnal/diagnostics'.
260
+ * Default: unchanged (off unless configureTimingAudit(true) was called).
261
+ */
262
+ timingAudit?: boolean;
255
263
  /**
256
264
  * settle()'s quiet window in ms (default 20): settle() resolves once nothing in the tree has
257
265
  * rendered, reduced or changed state for this long. A model `next('X', data, ms)` with a
@@ -454,6 +462,11 @@ export interface RenderResult {
454
462
  * `status`/`body`), or an Error / message (a network failure: the fetch rejects with it).
455
463
  */
456
464
  fail: (sinkName: string, error: any, target?: FakeReplyTarget) => Promise<void>;
465
+ /**
466
+ * PLAN-11 U-2: one progress step `{ loaded, total?, phase? }` of a pending request sent with
467
+ * `progress` (chosen as in respond()); it stays pending until respond()/fail().
468
+ */
469
+ progress: (sinkName: string, step: {loaded: number; total?: number; phase?: 'upload' | 'download'}, target?: FakeReplyTarget) => Promise<void>;
457
470
  /**
458
471
  * PLAN-6 L-4: stream chunks into a pending request of the LLM fake (the sink named by `llmSink`,
459
472
  * default 'LLM': the real chat driver over an in-memory transport). Chunks: strings (text),
@@ -484,9 +497,10 @@ export interface RenderResult {
484
497
  open: (sinkName: string, target?: FakeConnectionTarget) => Promise<void>;
485
498
  /**
486
499
  * PLAN-3 2-C: the server sends `data` (objects as JSON text) on the open connection(s): `message`
487
- * fires (the data JSON-parsed when it parses). `{ event, connection? }`: an SSE named event.
500
+ * fires (the data JSON-parsed when it parses). `{ event, connection? }`: an SSE named event;
501
+ * `{ id }` (PLAN-13 S-1): its SSE event id (`meta`, `resume`).
488
502
  */
489
- push: (sinkName: string, data: any, target?: FakeConnectionTarget | {event?: string; connection?: FakeConnectionTarget}) => Promise<void>;
503
+ push: (sinkName: string, data: any, target?: FakeConnectionTarget | {event?: string; id?: string | number; connection?: FakeConnectionTarget}) => Promise<void>;
490
504
  /**
491
505
  * PLAN-3 2-C: the connection(s) close without the app closing them (a connecting one fails to
492
506
  * open: `error` first): `close` fires with `{ code, reason, willReconnect }` and the fake
@@ -550,9 +564,9 @@ export interface RenderResult {
550
564
  * render (await t.ready() first). 4-A1: after `await` of any wait, it shows the state the wait
551
565
  * resolved with (a later render is held back until the next macrotask).
552
566
  */
553
- query: (selector: string) => Element | null;
567
+ query: <E extends Element = Element>(selector: string) => E | null;
554
568
  /** Every element matching a selector in the rendered tree (Portals included), as query() */
555
- queryAll: (selector: string) => Element[];
569
+ queryAll: <E extends Element = Element>(selector: string) => E[];
556
570
  /**
557
571
  * PLAN-5 W-1: a widget's host by selector or control: `.props` (what the view passed it),
558
572
  * `.instance` (`dom: 'real'`: what mount returned) and `.dispatch(name, detail)` (the event
@@ -1197,6 +1211,13 @@ const validMs = (v: any) => typeof v == 'number' && Number.isFinite(v) && v >= 0
1197
1211
  // too, so its time is the clock's (Date may be left real by `toFake`), and its waits drive it.
1198
1212
  const fakeClock = (): any => (setTimeout as any).clock;
1199
1213
  const clockNow = (): number => { const c = fakeClock(); return c ? c.now : Date.now(); };
1214
+ /** PLAN-11 Q-3: ms until the next pending fake timer (0: none, or real timers) */
1215
+ const nextFakeTimer = (): number => {
1216
+ const c = fakeClock();
1217
+ let due = Infinity;
1218
+ try { Object.values(c?.timers || {}).forEach((t: any) => { if (t && t.callAt > c.now && t.callAt < due) due = t.callAt; }); } catch (_) {}
1219
+ return due < Infinity ? due - c.now : 0;
1220
+ };
1200
1221
  /**
1201
1222
  * Under fake timers, advance the clock timer by timer (nextAsync flushes promises around each
1202
1223
  * one) until `p` settles. Every pending wait has a timer (its timeout, a quiet-window tick), so
@@ -1254,17 +1275,27 @@ const sockKey = (sse: boolean, url: string, arg: any) => (sse ? 'e' + !!(arg &&
1254
1275
  * PLAN-3 2-C: the in-memory WebSocket / EventSource the fake's makeSocketDriver opens. The
1255
1276
  * harness drives it (t.open / t.push / t.drop); the driver's own close() is the app's close.
1256
1277
  */
1278
+ /** `url` without its query parameter `name` (PLAN-13 S-1: a resumed reconnect's id parameter) */
1279
+ const withoutParam = (url: string, name: string) =>
1280
+ url.replace(new RegExp('([?&])' + name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '=[^&#]*&?'), '$1').replace(/[?&](#|$)/, '$1');
1257
1281
  const fakeSocketClass = (sse: boolean, made: (s: any) => void) => class {
1258
1282
  readyState = 0;
1259
1283
  sse = sse;
1260
1284
  byApp = false;
1261
1285
  k: string;
1286
+ rn?: string;
1262
1287
  url: string;
1263
1288
  ls: Record<string, any[]> = {};
1264
1289
  onopen: any; onmessage: any; onerror: any; onclose: any;
1265
1290
  constructor(url: string, arg?: any) {
1266
1291
  this.url = String(url);
1267
- this.k = sockKey(sse, this.url, arg);
1292
+ // PLAN-13 S-1: a resumed SSE reconnect's URL has the id parameter (added, or replacing the
1293
+ // declared one): its key leaves that parameter out (`rn`: its name; made() compares the
1294
+ // connections' URLs without it too)
1295
+ const id = sse && arg?.lastEventId;
1296
+ const m = id && new RegExp('[?&]([^?&=]+)=' + encodeURIComponent(id).replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(?=&|#|$)').exec(this.url);
1297
+ if (m) this.rn = m[1];
1298
+ this.k = sockKey(sse, m ? withoutParam(this.url, m[1]) : this.url, arg);
1268
1299
  made(this);
1269
1300
  }
1270
1301
  send() {}
@@ -1369,6 +1400,7 @@ const headFake = () => {
1369
1400
  };
1370
1401
  let savedConfig: ReturnType<typeof _getDiagnosticsConfig>;
1371
1402
  let savedStrict: any;
1403
+ let savedAudit: any;
1372
1404
 
1373
1405
  /*
1374
1406
  * PLAN-4 GS-2, mock DOM: could the element a command targets have this method? The core runs any
@@ -1468,7 +1500,7 @@ export function renderComponent(
1468
1500
  componentDef: any,
1469
1501
  options: RenderOptions = {}
1470
1502
  ): RenderResult {
1471
- const {initialState, mockConfig = {}, drivers = {}, diagnostics, strict, dom = 'mock', autoConnect = true, socketSink = 'WS', resourceSink = 'HTTP', llmSink = 'LLM', http: httpOptions, context: ancestors} = options;
1503
+ const {initialState, mockConfig = {}, drivers = {}, diagnostics, strict, timingAudit, dom = 'mock', autoConnect = true, socketSink = 'WS', resourceSink = 'HTTP', llmSink = 'LLM', http: httpOptions, context: ancestors} = options;
1472
1504
  const {intent, model = {}} = componentDef;
1473
1505
  // E4: real DOM mode
1474
1506
  const real = dom == 'real';
@@ -1511,6 +1543,7 @@ export function renderComponent(
1511
1543
  if (!active++) {
1512
1544
  savedConfig = _getDiagnosticsConfig();
1513
1545
  savedStrict = core.strict;
1546
+ savedAudit = core.timingAudit;
1514
1547
  // G-051: forget the checks' report dedupe (once()), so a finding from an earlier test is
1515
1548
  // reported again. Only that set: resetChecks() would also drop inspect()'s records of
1516
1549
  // other live instances / apps.
@@ -1518,6 +1551,8 @@ export function renderComponent(
1518
1551
  }
1519
1552
  configureDiagnostics({mode: diagnostics || (prevMode == 'off' ? 'collect' : prevMode)});
1520
1553
  if (strict !== undefined) core.strict = strict;
1554
+ // 7.0 timing audit (SYG257, 'sygnal/diagnostics'): same bridge flag as configureTimingAudit()
1555
+ if (timingAudit !== undefined) core.timingAudit = timingAudit;
1521
1556
  // PLAN-4 GS-2: element commands run on the real DOM (jsdom gets the missing methods), and their
1522
1557
  // SYG640/SYG641 are reported without the dev entry too
1523
1558
  if (real && !domFakes) domFakes = fakeElementMethods(document.defaultView || globalThis);
@@ -1683,6 +1718,7 @@ export function renderComponent(
1683
1718
  if (!--active) {
1684
1719
  configureDiagnostics(savedConfig);
1685
1720
  core.strict = savedStrict;
1721
+ core.timingAudit = savedAudit;
1686
1722
  domFakes?.();
1687
1723
  domFakes = undefined;
1688
1724
  }
@@ -1716,7 +1752,8 @@ export function renderComponent(
1716
1752
  // value: what t.requests lists (normalised); req: the request the driver got; res: the resource;
1717
1753
  // pf: a { prefetch } fetch (5-5: answered into the cache, no reply)
1718
1754
  // llm: the in-memory stream handle of an LLM fake request (PLAN-6 L-4)
1719
- type Pending = {value: any; req: any; category: any; res?: string; pf?: any; live: boolean; settle: (ok: boolean, v: any) => void; llm?: any};
1755
+ // step: PLAN-11 U-2, the driver's progress callback of a request sent with `progress` (t.progress)
1756
+ type Pending = {value: any; req: any; category: any; res?: string; pf?: any; live: boolean; settle: (ok: boolean, v: any) => void; llm?: any; step?: (phase: string, loaded: number, total: number) => void};
1720
1757
  type Fake = {select: any; errors: any; subs: Set<FakeSub>; at: (ns: any[]) => any; pending: Pending[]; in$: any; http: any; ws: Sock; llm?: any};
1721
1758
  const fakes = new Map<string, Fake>();
1722
1759
  // 5-3: the fake drivers' focus / online listeners (t.focus, t.online)
@@ -1749,14 +1786,22 @@ export function renderComponent(
1749
1786
  const lin$ = xs.create();
1750
1787
  llm = {in$: lin$, src: makeChatDriver({transport: tr, _frame: (f: () => void) => { frames.push(f); }})(lin$)};
1751
1788
  }
1789
+ let memFetch: any;
1752
1790
  const http = makeFetchDriver({
1753
1791
  ...httpOptions,
1792
+ // PLAN-11 U-2: a progress request is answered like any other (t.respond / t.fail); its
1793
+ // pending entry keeps the driver's progress callback for t.progress
1794
+ _progress: (url: string, init: any, step: any) => {
1795
+ const r = memFetch(url, init);
1796
+ pending[pending.length - 1].step = step;
1797
+ return r;
1798
+ },
1754
1799
  // 5-3: focus / online come from t.focus() / t.online() only
1755
1800
  _on: (f: any) => { signals.add(f); return () => signals.delete(f); },
1756
1801
  _tap: (req: any, res?: string, pf?: any) => { tapped = [req, res, pf]; },
1757
1802
  // D472: a batch / split request (a large decide()) is one fetch here, answered as one
1758
1803
  _whole: true,
1759
- fetch: (url: string, init: any) => new Promise((resolve, reject) => {
1804
+ fetch: memFetch = (url: string, init: any) => new Promise((resolve, reject) => {
1760
1805
  const [req, res, pf] = tapped || [{url}];
1761
1806
  tapped = undefined;
1762
1807
  // G-171(1): a resource fetch is listed as { url, ...request, resource: name }; 5-5: a
@@ -1788,6 +1833,7 @@ export function renderComponent(
1788
1833
  return {
1789
1834
  select: (sel?: any) => xs.merge(hs.select(sel), own(false)(sel), sock.select(sel)),
1790
1835
  errors: (sel?: any) => xs.merge(hs.errors(sel), own(true)(sel)),
1836
+ progress: (sel?: any) => hs.progress(sel),
1791
1837
  subs, at, pending, in$, http, ws, llm,
1792
1838
  // 5-3: the driver's matcher / inspection (the dev checks' SYG632, t.cache)
1793
1839
  __matches: http.__matches, __inspect: http.__inspect,
@@ -1817,7 +1863,7 @@ export function renderComponent(
1817
1863
  const w: Sock = {src: null, in$: xs.create(), conns: new Map(), sockets: [], sent: [], declaring: null};
1818
1864
  const made = (s: any) => {
1819
1865
  w.sockets.push(s);
1820
- const all = [...w.conns.values()].flatMap(m => [...m.values()]).filter(c => c.k == s.k);
1866
+ 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);
1821
1867
  if (w.declaring) {
1822
1868
  // a declaration opened it: the first new connection with its key (and, shared, the
1823
1869
  // other new shared ones)
@@ -1918,7 +1964,7 @@ export function renderComponent(
1918
1964
  for (const k in uses) for (const a in uses[k]?.model || {}) names.push(k + '.' + a);
1919
1965
  // G-275: the caller's initialState is owned (the dev statics freeze leaves it alone). G-289: a
1920
1966
  // copy is marked, not the caller's object
1921
- const init = initialState !== undefined ? ownedCopy(initialState) : componentDef.initialState;
1967
+ let init = initialState !== undefined ? ownedCopy(initialState) : componentDef.initialState;
1922
1968
  // G-028: with no intent, model or initialState nothing would ever emit state, so the view
1923
1969
  // never renders. Leave intent/model unset so the component falls back to the same no-op
1924
1970
  // model run() uses, and renders.
@@ -2179,6 +2225,17 @@ export function renderComponent(
2179
2225
  },
2180
2226
  };
2181
2227
  };
2228
+ // PLAN-13 F-2: a root that declares `route` starts from the test URL's route: its initialState's
2229
+ // router.current() ran at module load, before the test's URL existed. The route entry's STATE
2230
+ // reducer is applied to the initial state (its other sinks, a guard's redirect, run once when the
2231
+ // router answers the declaration), so the first render and the statics see the page the test
2232
+ // starts on: no render of '/' and no request for it
2233
+ const re = rt && typeof componentDef.route == 'string' && model[componentDef.route]
2234
+ const seed = typeof re == 'function' ? re : re && typeof re == 'object' && typeof re.STATE == 'function' && re.STATE
2235
+ if (seed && init !== undefined) {
2236
+ const v = seed(init, rt!.r.current(), noop, {state: init, previousState: init})
2237
+ if (v !== undefined && !isAbort(v)) init = v
2238
+ }
2182
2239
  let sources: any, sinks: any;
2183
2240
  try {
2184
2241
  // the root runs with the test intent, model, initial state and name, and the drivers above.
@@ -2473,7 +2530,7 @@ export function renderComponent(
2473
2530
  if (tg === undefined) return {match: cat, desc: catDesc, o};
2474
2531
  if (typeof tg == 'string') {
2475
2532
  // an ok/error action name, key, category, resource name or URL
2476
- return {match: r => { const v = r.value || {}; return cat(r) && [v.ok, v.error, v.key, v.category, r.res, v.url].includes(tg); }, desc: ` matching '${tg}'${catDesc}`, o};
2533
+ return {match: r => { const v = r.value || {}; return cat(r) && [v.ok, v.error, v.progress, v.key, v.category, r.res, v.url].includes(tg); }, desc: ` matching '${tg}'${catDesc}`, o};
2477
2534
  }
2478
2535
  if (typeof tg == 'function') {
2479
2536
  return {match: r => { try { return cat(r) && !!tg(r.value); } catch (_) { return false; } }, desc: ` matching the predicate${catDesc}`, o};
@@ -2706,6 +2763,23 @@ export function renderComponent(
2706
2763
  return {error: err, category, request: e?.req, status: status ?? x?.status, body};
2707
2764
  });
2708
2765
 
2766
+ // PLAN-11 U-2: t.progress. One progress step of a pending request sent with `progress` (its
2767
+ // reply action, or progress() for `progress: true`); the request stays pending. Targets as
2768
+ // t.respond's; the phase defaults to 'upload' for a request with a body, else 'download'
2769
+ const progress = (name: string, step: any, opts?: FakeReplyTarget) => {
2770
+ throwFailure();
2771
+ if (drivers[name]) throw new Error(`[Sygnal] t.progress('${name}'): ${name} has a real driver (passed in drivers), so there is nothing to script`);
2772
+ if (!step || typeof step != 'object' || typeof step.loaded != 'number') throw new Error(`[Sygnal] t.progress('${name}', step): step is { loaded, total?, phase? } (got ${brief(step)})`);
2773
+ const tg = targetOf(opts);
2774
+ const what = `t.progress('${name}'${typeof opts == 'string' ? `, …, '${opts}'` : ''})`;
2775
+ const now = isReady && !inputs.length ? pick(name, tg) : undefined;
2776
+ if (now && !now.step) throw new Error(`[Sygnal] ${what}: the request ${brief(now.value)} has no progress: send it with progress: 'UPLOADING' (a reply action) or progress: true`);
2777
+ return scripted(() => pick(name, tg), w => noPending(what, name, tg, w), (e: Pending) => {
2778
+ if (!e.step) return new Error(`[Sygnal] ${what}: the request ${brief(e.value)} has no progress: send it with progress: 'UPLOADING' (a reply action) or progress: true`);
2779
+ const r = e.req || {};
2780
+ e.step(step.phase || (r.body !== undefined || r.json !== undefined || r.multipart !== undefined ? 'upload' : 'download'), step.loaded, step.total ?? 0);
2781
+ }, sendDue);
2782
+ };
2709
2783
  // PLAN-6 L-4: t.stream. Targets as t.respond's (an action name or key, a partial request, a
2710
2784
  // predicate, { request, nth }); `end: false` (the last argument, or in the target options)
2711
2785
  // leaves the stream open. Chunks become ChatEvents pushed into the in-memory stream; the fake's
@@ -2797,11 +2871,13 @@ export function renderComponent(
2797
2871
  };
2798
2872
  const open = (name: string, target?: FakeConnectionTarget) => sockCall('open', name, target, [0], sockOpen);
2799
2873
  const push = (name: string, data: any, target?: any) => {
2800
- 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};
2874
+ 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};
2801
2875
  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);
2802
2876
  const ev = o.event;
2803
2877
  return sockCall('push', name, o.connection, [1], s => {
2804
- const m = {type: ev || 'message', data: raw};
2878
+ // PLAN-13 S-1: `id` sets the event id; as in EventSource, lastEventId keeps the last one
2879
+ if (s.sse && o.id != null) s.lastId = String(o.id);
2880
+ const m: any = {type: ev || 'message', data: raw, ...(s.sse && {lastEventId: s.lastId ?? ''})};
2805
2881
  if (!ev || ev == 'message') sockFire(s, 'message', m);
2806
2882
  if (s.sse) (s.ls[ev || 'message'] || []).forEach((f: any) => { try { f(m); } catch (e) { console.error(e); } });
2807
2883
  });
@@ -2904,11 +2980,11 @@ export function renderComponent(
2904
2980
  };
2905
2981
  // 6-B (G-185): on the mock DOM, MockElement snapshots of the latest rendered tree
2906
2982
  const mockAll = (s: string): any[] => findAll(vtree, parse(norm(s)), [], []).map(c => mockOf(c, htmlOf));
2907
- const queryAll = (s: string): Element[] => {
2983
+ const queryAll = <E extends Element = Element>(s: string): E[] => {
2908
2984
  s = selOf(s);
2909
2985
  notYet(`t.queryAll('${s}')`);
2910
2986
  if (!real) return mockAll(s);
2911
- return roots().flatMap(r => Array.from(r.querySelectorAll(s)));
2987
+ return roots().flatMap(r => Array.from(r.querySelectorAll<E>(s)));
2912
2988
  };
2913
2989
  const queryIn = (s: string): Element | null => {
2914
2990
  for (const r of roots()) {
@@ -2917,11 +2993,11 @@ export function renderComponent(
2917
2993
  }
2918
2994
  return null;
2919
2995
  };
2920
- const query = (s: string): Element | null => {
2996
+ const query = <E extends Element = Element>(s: string): E | null => {
2921
2997
  s = selOf(s);
2922
2998
  notYet(`t.query('${s}')`);
2923
2999
  if (!real) return mockAll(s)[0] ?? null;
2924
- return queryIn(s);
3000
+ return queryIn(s) as E | null;
2925
3001
  };
2926
3002
 
2927
3003
  // PLAN-5 W-1: a widget host (selector or control): the props it was rendered with (the mock
@@ -3134,7 +3210,12 @@ export function renderComponent(
3134
3210
  let msg = `${name} timed out after ${timeoutMs}ms.`;
3135
3211
  const [why, pending] = explainNext(start);
3136
3212
  msg += why;
3137
- if (pending) msg += ` Wait longer: t.${name}(pred, ms), or the renderComponent timeoutMs option (default for every wait).`;
3213
+ if (inputs.length) msg += ` ${inputs.length} simulated input(s) still pending.`;
3214
+ // PLAN-11 Q-3: on the fake clock a state a timer (TIMER driver, periodic, delay) brings
3215
+ // later than the timeout can't arrive in time: name the next pending timer
3216
+ const later = pending ? 0 : nextFakeTimer();
3217
+ if (later) msg += ` A timer is still pending on the fake clock (it fires ${later}ms after this timeout): if the state comes from it, this wait ended first.`;
3218
+ if (pending || later) msg += ` Wait longer: t.${name}(pred, ms), or the renderComponent timeoutMs option (default for every wait).`;
3138
3219
  // G-065: a state from before the call matched; next() only looks at new ones
3139
3220
  for (let i = from - 1; i >= 0; i--) {
3140
3221
  if (test(i)) {
@@ -3317,6 +3398,7 @@ export function renderComponent(
3317
3398
  requests,
3318
3399
  respond,
3319
3400
  fail,
3401
+ progress,
3320
3402
  stream,
3321
3403
  connections,
3322
3404
  cache,
@@ -13,7 +13,8 @@ import {makeReplies} from './replies';
13
13
  * falsy one stops, a changed spec restarts (an equal one keeps running).
14
14
  * - `every`: drift-free; tick n is due at start + n * every (each delay is computed from the
15
15
  * start, not chained). A late tick (a throttled background tab) coalesces the missed ones:
16
- * n jumps. Data `{ n, t }`: the tick number from 1, `t` = Date.now().
16
+ * n jumps. A delay is at most `every`, so a frozen Date.now() (a test clock that fixes the time
17
+ * while timers run) ticks once per interval. Data `{ n, t }`: the tick number from 1, `t` = Date.now().
17
18
  * - `after`: fires once, data `{ t }`. It doesn't fire again while the same spec stays declared.
18
19
  * - `frame`: every animation frame (requestAnimationFrame; setTimeout 16 ms without it), data
19
20
  * `{ t, dt }`: Date.now() and the ms since the previous frame (0 on the first).
@@ -47,7 +48,9 @@ const start = (s: any, send: (type: string, data: any) => void, e: any): (() =>
47
48
  last = ts;
48
49
  } else if (every) {
49
50
  n = Math.max(n + 1, Math.floor((t - t0) / every));
50
- later(t0 + (n + 1) * every - t);
51
+ // never more than one interval: under a frozen Date.now() (Playwright's setFixedTime: Date is
52
+ // fixed, timers run) the drift-free delay grew by one interval per tick
53
+ later(Math.min(every, t0 + (n + 1) * every - t));
51
54
  send(s.action, {n, t});
52
55
  } else {
53
56
  e.d = 1;