sygnal 6.5.0 → 7.0.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 (104) hide show
  1. package/CHANGELOG.md +156 -0
  2. package/README.md +2 -0
  3. package/dist/astro/index.cjs.js +423 -27
  4. package/dist/astro/index.cjs.js.map +1 -1
  5. package/dist/astro/index.mjs +423 -27
  6. package/dist/astro/index.mjs.map +1 -1
  7. package/dist/astro/server.cjs.js.map +1 -1
  8. package/dist/astro/server.mjs.map +1 -1
  9. package/dist/devtools.cjs.js +141 -3
  10. package/dist/devtools.cjs.js.map +1 -1
  11. package/dist/devtools.esm.js +141 -4
  12. package/dist/devtools.esm.js.map +1 -1
  13. package/dist/diagnostics.cjs.js +169 -14
  14. package/dist/diagnostics.cjs.js.map +1 -1
  15. package/dist/diagnostics.esm.js +168 -15
  16. package/dist/diagnostics.esm.js.map +1 -1
  17. package/dist/guide/accessibility.md +2 -0
  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/ui/tabs.md +5 -4
  28. package/dist/guide/ui/tooltip.md +24 -0
  29. package/dist/index.cjs.js +781 -320
  30. package/dist/index.cjs.js.map +1 -1
  31. package/dist/index.d.ts +319 -43
  32. package/dist/index.esm.js +781 -320
  33. package/dist/index.esm.js.map +1 -1
  34. package/dist/sygnal.min.js +1 -1
  35. package/dist/sygnal.min.js.map +1 -1
  36. package/dist/ui.cjs.js +96 -11
  37. package/dist/ui.cjs.js.map +1 -1
  38. package/dist/ui.esm.js +96 -12
  39. package/dist/ui.esm.js.map +1 -1
  40. package/dist/vike/config/package.json +1 -1
  41. package/dist/vike/onRenderClient.cjs.js +63 -43
  42. package/dist/vike/onRenderClient.cjs.js.map +1 -1
  43. package/dist/vike/onRenderClient.mjs +63 -43
  44. package/dist/vike/onRenderClient.mjs.map +1 -1
  45. package/dist/vike/onRenderHtml.cjs.js +21 -25
  46. package/dist/vike/onRenderHtml.cjs.js.map +1 -1
  47. package/dist/vike/onRenderHtml.mjs +21 -25
  48. package/dist/vike/onRenderHtml.mjs.map +1 -1
  49. package/dist/vite/plugin.cjs.js +410 -27
  50. package/dist/vite/plugin.cjs.js.map +1 -1
  51. package/dist/vite/plugin.mjs +410 -27
  52. package/dist/vite/plugin.mjs.map +1 -1
  53. package/llms.txt +17 -16
  54. package/package.json +1 -1
  55. package/src/astro/index.d.ts +8 -0
  56. package/src/astro/index.ts +18 -0
  57. package/src/core/actions.ts +28 -21
  58. package/src/core/define.ts +2 -0
  59. package/src/core/hooks.ts +8 -2
  60. package/src/core/hosts/collection.ts +4 -3
  61. package/src/core/instance.ts +60 -16
  62. package/src/core/runtime.ts +5 -1
  63. package/src/core/view.ts +2 -0
  64. package/src/cycle/dom/utils.ts +4 -2
  65. package/src/devtools.d.ts +13 -0
  66. package/src/devtools.ts +3 -0
  67. package/src/extra/ai/chat/behavior.ts +3 -3
  68. package/src/extra/ai/commandBar.ts +3 -3
  69. package/src/extra/ai/compose/behavior.ts +3 -3
  70. package/src/extra/ai/transports/shared.ts +8 -2
  71. package/src/extra/behaviors.ts +21 -0
  72. package/src/extra/devGraph.ts +111 -0
  73. package/src/extra/devMcp.ts +1 -1
  74. package/src/extra/devtools.ts +18 -0
  75. package/src/extra/diagnostics/checks/fetch.ts +21 -1
  76. package/src/extra/diagnostics/checks/forms.ts +4 -3
  77. package/src/extra/diagnostics/checks/index.ts +2 -1
  78. package/src/extra/diagnostics/checks/inspect.ts +68 -6
  79. package/src/extra/diagnostics/checks/next.ts +3 -0
  80. package/src/extra/diagnostics/checks/public.d.ts +133 -12
  81. package/src/extra/diagnostics/checks/timingAudit.ts +47 -0
  82. package/src/extra/diagnostics/codes.ts +19 -0
  83. package/src/extra/fetchDriver.ts +112 -7
  84. package/src/extra/fetchEventSource.ts +113 -0
  85. package/src/extra/form.ts +68 -26
  86. package/src/extra/formHelpers.ts +3 -1
  87. package/src/extra/lens.ts +3 -2
  88. package/src/extra/queryCache.ts +4 -3
  89. package/src/extra/socketDriver.ts +10 -4
  90. package/src/extra/testing.ts +68 -11
  91. package/src/index.d.ts +199 -30
  92. package/src/index.ts +1 -0
  93. package/src/shared.ts +3 -0
  94. package/src/ui/dialog.ts +3 -2
  95. package/src/ui/tooltips.ts +92 -0
  96. package/src/ui.d.ts +35 -0
  97. package/src/ui.ts +1 -0
  98. package/src/vike/onRenderClient.ts +44 -46
  99. package/src/vike/onRenderHtml.ts +5 -26
  100. package/src/vike/shell.ts +33 -0
  101. package/src/vite/graph.ts +255 -0
  102. package/src/vite/mcp.ts +3 -3
  103. package/src/vite/plugin.d.ts +19 -0
  104. package/src/vite/plugin.ts +144 -25
@@ -15,7 +15,7 @@
15
15
  */
16
16
  import xs from '../extra/xstreamCompat'
17
17
  import {StateSource} from '../cycle/state/StateSource'
18
- import {uidPart} from '../shared'
18
+ import {uidPart, BINDING} from '../shared'
19
19
  import {warn, error as logError, fail} from '../extra/diagnostics/legacy'
20
20
  import type {App} from './runtime'
21
21
  import {tearDown, INST, SEED} from './teardown'
@@ -29,6 +29,20 @@ import {attach, detach} from './statics'
29
29
  import {dbg} from './debug'
30
30
  import {sameData} from '../cycle/dom/utils'
31
31
 
32
+ // v7: a known binding target joins key/id/path in the lifetime identity. Fresh equivalent
33
+ // byId helpers retain identity; opaque lenses need an explicit key for a new target.
34
+ function bindingChanged(a: any, b: any): boolean {
35
+ if (a === b) return false
36
+ const x = a?.[BINDING], y = b?.[BINDING]
37
+ return x && y ? !x.every((v: any, i: number) => Object.is(v, y[i]))
38
+ : !!(x || y || typeof a == 'string' || typeof b == 'string' || a === undefined || b === undefined)
39
+ }
40
+
41
+ function bindingState(k: any): any {
42
+ const c = k.base || k.cell || k.arr
43
+ return c && (c.has?.() ?? c.get() !== undefined) ? (k.cell || k.arr).get() : undefined
44
+ }
45
+
32
46
  const ERR_FIX = 'See the attached error'
33
47
 
34
48
  // 4-I G-557: a fragment root (a Collection's): each of its top-level elements is marked.
@@ -65,8 +79,8 @@ const idOf = (n: any, path: string) => {
65
79
  }
66
80
 
67
81
  /**
68
- * a component vnode's props, without `resetState` (D174: read at creation, like `state`, never a
69
- * prop of the child)
82
+ * a component vnode's props, without `resetState` (D174: read at creation, never a
83
+ * prop of the child). Known `state` target changes start a new lifetime in v7.
70
84
  */
71
85
  function propsOf(p: any) {
72
86
  if (!p) return {}
@@ -118,12 +132,15 @@ const ALL = '\u0000all'
118
132
 
119
133
  let IDS = 0
120
134
 
121
- /** a child that failed to instantiate: renders the owner's error fallback (SYG408) */
135
+ /**
136
+ * a child that failed to instantiate: renders the owner's error fallback (SYG408). It keeps its
137
+ * props, so only a changed binding target makes another attempt (not every owner render)
138
+ */
122
139
  class Failed {
123
140
  last: any; lr = true; ready = true; def: any = null; disposed = false; shown = true
124
- constructor(public outv: any) {}
141
+ constructor(public outv: any, public props?: any) {}
125
142
  render() { return this.outv }
126
- setProps() {}
143
+ setProps(p: any) { this.props = p }
127
144
  dispose() {}
128
145
  }
129
146
 
@@ -144,6 +161,8 @@ export class Inst {
144
161
  raw!: any[]
145
162
  slots: Record<string, any[]> = {}
146
163
  kids = new Map<string, any>()
164
+ /** the children with a string / byId binding: the presence scan's (notify) */
165
+ bound?: Set<any>
147
166
  uidBase: string
148
167
  /** the parent's view of this instance's output (last vnode / READY it injected) */
149
168
  last: any; lr = true
@@ -177,7 +196,7 @@ export class Inst {
177
196
  rep: any[] | null = null
178
197
 
179
198
  constructor(
180
- public app: App, public def: CoreDef, public parent: Inst | null, base: Cell,
199
+ public app: App, public def: CoreDef, public parent: Inst | null, public base: Cell,
181
200
  public dom: any, props: Record<string, any>, children: any[], public scope: string | undefined,
182
201
  uidBase: string, public kind: 'root' | 'child' | 'item' | 'page' = parent ? 'child' : 'root', reset = false,
183
202
  ) {
@@ -285,8 +304,26 @@ export class Inst {
285
304
  }
286
305
  /** called after each action and before each render pass: STATE.stream emits a new state */
287
306
  notify() {
288
- const v = this.cell.raw()
289
- if (v !== this.stLast && v !== undefined) { this.stLast = v; this.st$.shamefullySendNext(v) }
307
+ // A completed action can remove and reinsert a target before the next render. Owners
308
+ // observe each transition, including hidden pages; the current action finishes first.
309
+ // only the bound children (a string / byId binding), not every child
310
+ if (this.bound) for (const k of this.bound) {
311
+ if (k.disposed) { this.bound.delete(k); continue }
312
+ const previous = k.bs, state = k.bs = bindingState(k)
313
+ if (previous !== undefined && state === undefined) {
314
+ // Cleanup reads its last owned snapshot. A dying path must not recreate the
315
+ // deleted target; the fresh lifetime receives a new writable cell at reconciliation.
316
+ const cell = k.cell || k.arr
317
+ cell.get = () => previous
318
+ cell.set = () => {}
319
+ k.dispose()
320
+ this.forced = true
321
+ }
322
+ }
323
+ if (this.st$) {
324
+ const v = this.cell.raw()
325
+ if (v !== this.stLast && v !== undefined) { this.stLast = v; this.st$.shamefullySendNext(v) }
326
+ }
290
327
  }
291
328
  dispose$() {
292
329
  return this.disp$ ||= xs.create()
@@ -495,9 +532,16 @@ export class Inst {
495
532
  let view = data.c
496
533
  if (view) for (const r of resolvers) view = r(view, this) || view
497
534
  let k = this.kids.get(id)
498
- if (k && view && k.def && k.def.view !== view) { k.dispose(); this.kids.delete(id); k = undefined }
535
+ // Collection and VirtualCollection share an array cell; Switchable binds `state`.
536
+ const binding = host && sel != 'switchable' ? 'from' : 'state'
537
+ if (k && (k.disposed || (view && k.def && k.def.view !== view) || bindingChanged(k.props?.[binding], props[binding]))) { k.dispose(); this.kids.delete(id); k = undefined }
499
538
  if (k) k.setProps(props, children, n, id)
500
- else this.kids.set(id, host ? this.host(host, props, children, id, n) : this.child(view, props, children, id, !!data.props?.resetState))
539
+ else this.kids.set(id, k = host ? this.host(host, props, children, id, n) : this.child(view, props, children, id, !!data.props?.resetState))
540
+ if (typeof props[binding] == 'string' || props[binding]?.[BINDING]) {
541
+ k.bs = bindingState(k)
542
+ ;(this.bound ||= new Set()).add(k)
543
+ this.app.watchers.add(this)
544
+ }
501
545
  return
502
546
  }
503
547
  if (posts[sel] && !(ps ||= []).includes(sel)) ps.push(sel)
@@ -511,11 +555,11 @@ export class Inst {
511
555
 
512
556
  /** a host (Collection, Switchable); one that throws renders the owner's error fallback (SYG408) */
513
557
  host(make: any, props: Record<string, any>, children: any[], id: string, n: any): any {
514
- try { return make(this, props, children, id, n) } catch (err) { return this.failed(err) }
558
+ try { return make(this, props, children, id, n) } catch (err) { return this.failed(err, props) }
515
559
  }
516
560
 
517
561
  /** the owner's error fallback for a child that failed to instantiate (SYG408) */
518
- failed(err: any): Failed {
562
+ failed(err: any, props?: any): Failed {
519
563
  const e = err instanceof Error ? err : new Error(String(err))
520
564
  let out: any = errorDiv(this.def.name)
521
565
  if (this.def.onError) {
@@ -523,7 +567,7 @@ export class Inst {
523
567
  catch (fe) { logError('SYG407', this, 'onError threw; rendering an empty error <div>', 'Make onError return a vnode', fe) }
524
568
  }
525
569
  this.app.caught(this, 'SYG408', 'Sub-component threw; rendering the error fallback', e, 'instantiate')
526
- return new Failed(out)
570
+ return new Failed(out, props)
527
571
  }
528
572
 
529
573
  /** a tag child: state="key" | state={lens} | none (the parent's state) | isolatedState (D174: `resetState`) */
@@ -543,7 +587,7 @@ export class Inst {
543
587
  return new Inst(app, def, this, cell, this.dom && this.dom.isolateSource(this.dom, scope), props, children, scope,
544
588
  this.uid(uidPart(id.replace(/.*::(r\.)?/, ''))), 'child', reset)
545
589
  } catch (err) {
546
- return this.failed(err)
590
+ return this.failed(err, props)
547
591
  }
548
592
  }
549
593
 
@@ -589,7 +633,7 @@ export class Inst {
589
633
  const subs = this.subs
590
634
  if (subs.length) tearDown(() => { for (const s of subs) s.unsubscribe() }, app.dq, this)
591
635
  this.subs = []
592
- if (this.st$) app.watchers.delete(this)
636
+ app.watchers.delete(this)
593
637
  this.hub = null
594
638
  }
595
639
  }
@@ -96,6 +96,8 @@ export class App {
96
96
  /** run()'s hooks, then each addHooks() layer (G-297) */
97
97
  layers: Hooks[]
98
98
  born: Inst[] = []
99
+ /** Last state version delivered to stream listeners and binding owners. */
100
+ nv?: number
99
101
  watchers = new Set<Inst>()
100
102
  /** streams the disposed instances left without listeners, stopped when the drain / flush ends */
101
103
  dq: any[] = []
@@ -185,6 +187,8 @@ export class App {
185
187
  }
186
188
  /** STATE.stream: the instances whose state changed emit it */
187
189
  notify() {
190
+ if (this.nv === this.ver) return
191
+ this.nv = this.ver
188
192
  for (const i of this.watchers) i.notify()
189
193
  }
190
194
 
@@ -280,7 +284,7 @@ export class App {
280
284
  this.ran = false
281
285
  this.last = v
282
286
  this.hooks.onPatch?.(v)
283
- this.vdomL?.next(v)
287
+ this.vdomL?.next(v ?? null)
284
288
  }
285
289
  if (this.dq.length) this.stopLater()
286
290
  } finally {
package/src/core/view.ts CHANGED
@@ -14,6 +14,8 @@ class View implements InstanceView {
14
14
  get state() { return this.i.cell.get() }
15
15
  get props() { return this.i.props }
16
16
  get context() { return this.i.context() }
17
+ /** D530: the context keys the last render's view read (null: none rendered, or no context) */
18
+ get reads() { return this.i.keys }
17
19
  get isRoot() { return !this.i.parent }
18
20
  get kind() { return this.i.kind }
19
21
  /** false on a hidden Switchable page and below it */
@@ -88,9 +88,11 @@ export function makeInsert(
88
88
  }
89
89
 
90
90
  // G-261: a DOM change Sygnal makes outside a patch (a Transition's leave, a Portal mounted late)
91
- // re-emits the DOM source of the app whose root contains `el` (makeDOMDriver listens on its root)
91
+ // re-emits the DOM source of the app whose root contains `el` (makeDOMDriver listens on its root).
92
+ // The event comes from the element's own window: jsdom refuses an `Event` from another realm (a
93
+ // Transition's leave timer that fires after a jsdom test environment was torn down)
92
94
  export const POKE = 'sygnal-dom';
93
- export const pokeDOM = (el: any): any => el?.dispatchEvent(new Event(POKE, {bubbles: true}));
95
+ export const pokeDOM = (el: any): any => el?.dispatchEvent(new (el.ownerDocument?.defaultView?.Event || Event)(POKE, {bubbles: true}));
94
96
 
95
97
  const obj = (o: any) => o !== null && typeof o == 'object' && !Array.isArray(o);
96
98
  /**
package/src/devtools.d.ts CHANGED
@@ -168,3 +168,16 @@ export declare function installMcpBridge(
168
168
  hot: { on(event: string, cb: (data: any) => void): void; send(event: string, data?: any): void } | undefined,
169
169
  options?: McpBridgeOptions
170
170
  ): void
171
+
172
+ // ── PLAN-10 P-2: the app side of sygnal/vite's graph page (/__sygnal/graph) ──
173
+
174
+ export interface GraphBridgeOptions {
175
+ /** The BroadcastChannel the graph page listens on (default 'sygnal:graph') */
176
+ channel?: string
177
+ }
178
+ /**
179
+ * Answer the dev server's graph page ("Live data": instances, action flashes, state) over a
180
+ * same-origin BroadcastChannel. sygnal/vite calls it in dev while the graph route is on (the
181
+ * default; `graph: false` turns it off); you don't call it yourself.
182
+ */
183
+ export declare function installGraphBridge(options?: GraphBridgeOptions): void
package/src/devtools.ts CHANGED
@@ -35,3 +35,6 @@ export function copyAsTest(target?: any, options?: CopyAsTestOptions): string {
35
35
 
36
36
  // PLAN-6 E-1: the page side of sygnal/vite's dev MCP endpoint (`sygnal({ mcp: true })`)
37
37
  export {installMcpBridge} from './extra/devMcp'
38
+
39
+ // PLAN-10 P-2 (D544): the app side of the graph page's "Live data" (sygnal/vite's /__sygnal/graph)
40
+ export {installGraphBridge} from './extra/devGraph'
@@ -92,7 +92,7 @@
92
92
  * closed as not run, an app confirm dropped (a server approval stays answerable), and a card that
93
93
  * was composing composed again; open client calls stay answerable.
94
94
  */
95
- import {defineBehavior} from '../../behaviors'
95
+ import {defineBehavior, fromPrevious} from '../../behaviors'
96
96
  import {ABORT} from '../../../shared'
97
97
  import {report} from '../../diagnostics/index'
98
98
  import xs from '../../xstreamCompat'
@@ -529,7 +529,7 @@ export const chat = (options: any = {}): any => {
529
529
  ...(c.ask?.controls && DOM && {CONTROL: DOM.select('[data-compose-control]').events('click').map(controlOf).filter((x: any) => !!x)}),
530
530
  }
531
531
  },
532
- model: {
532
+ model: fromPrevious({
533
533
  PROMPT: (s: any, p: any) => typeof p == 'string' && p !== s.prompt ? {...s, prompt: p} : ABORT,
534
534
  SEND: {
535
535
  STATE: (s: any, d: any) => sendStep(s, d) || ABORT,
@@ -609,7 +609,7 @@ export const chat = (options: any = {}): any => {
609
609
  ANSWER: {STATE: stateOf(S.answerCard), ...sinks(S.answerCard)},
610
610
  CONTROL: (s: any, d: any) => S.controlStep(s, d) || ABORT,
611
611
  }),
612
- },
612
+ }),
613
613
  })(options)
614
614
  // the `prompt` option is a selector, not the slice's start value (defineBehavior's option override)
615
615
  b.state = {...b.state, prompt: ''}
@@ -48,7 +48,7 @@
48
48
  * It reaches the runtime through the shared link (./link.ts, D283), as `chat` does.
49
49
  */
50
50
  import xs from 'xstream'
51
- import {defineBehavior} from '../behaviors'
51
+ import {defineBehavior, fromPrevious} from '../behaviors'
52
52
  import {ABORT} from '../../shared'
53
53
  import {agentTools, labelsUntrusted} from './agent/index'
54
54
  import {decide as decideRequest} from './decide'
@@ -242,7 +242,7 @@ export const commandBar = (options: any = {}): any => {
242
242
  ...(deny && {DENY: on(DOM, deny)}),
243
243
  }
244
244
  },
245
- model: {
245
+ model: fromPrevious({
246
246
  INPUT: (s: any, v: any) => typeof v == 'string' && v !== s.text ? {...s, text: v} : ABORT,
247
247
  RUN: {
248
248
  STATE: (s: any, d: any, _n: any, p: any, o: any, k: string) => runStep(s, d, E(p, k, o))?.s || ABORT,
@@ -275,7 +275,7 @@ export const commandBar = (options: any = {}): any => {
275
275
  // a command ran (or was refused): the result; the input is cleared when it was this command
276
276
  DONE: (s: any, d: any) => s.status != 'running' ? ABORT
277
277
  : {...s, status: 'ready', command: null, pending: null, result: d, text: d?.ok && s.text.trim() === d.command ? '' : s.text},
278
- },
278
+ }),
279
279
  })(options)
280
280
  return linked(b)
281
281
  }
@@ -34,7 +34,7 @@
34
34
  * reads it as `props.state` (the pre-action state), and one memo per behavior value makes STATE,
35
35
  * the sink and EFFECT of one action compute the same step.
36
36
  */
37
- import {defineBehavior} from '../../behaviors'
37
+ import {defineBehavior, fromPrevious} from '../../behaviors'
38
38
  import {ABORT} from '../../../shared'
39
39
  import {fail} from '../../diagnostics/legacy'
40
40
  import {finish, fromTree, removeNode, toTree} from './layout'
@@ -144,7 +144,7 @@ export function composeBehavior(catalog: Catalog, options: any = {}): any {
144
144
  .map(({key, name, value}: any) => ({key, name, value}))}),
145
145
  }
146
146
  },
147
- model: {
147
+ model: fromPrevious({
148
148
  PROMPT: (s: any, p: any) => typeof p == 'string' && p !== s.prompt ? {...s, prompt: p} : ABORT,
149
149
  COMPOSE: {HOST: host(start), [S]: sink(start)},
150
150
  REPLY: {
@@ -178,6 +178,6 @@ export function composeBehavior(catalog: Catalog, options: any = {}): any {
178
178
  CONTROL: (s: any, c: any) => c && typeof c.key == 'string' && typeof c.name == 'string'
179
179
  ? {...s, ui: {...s.ui, [c.key]: {...s.ui?.[c.key], [c.name]: c.value}}} : ABORT,
180
180
  DONE: () => ABORT,
181
- },
181
+ }),
182
182
  })(rest)
183
183
  }
@@ -60,8 +60,12 @@ export async function post(url: string, body: any, req: any, signal: AbortSignal
60
60
  return res;
61
61
  }
62
62
 
63
- /** the SSE frames of a response body: `{ event, data }` (data: the joined data lines) */
64
- export async function* sse(res: any): AsyncGenerator<{event: string; data: string}> {
63
+ /**
64
+ * the SSE frames of a response body: `{ event, data }` (data: the joined data lines). PLAN-11
65
+ * E-1: `meta` (optional) follows the stream's `id` (the last event id, kept across frames) and
66
+ * `retry` (ms) fields, as EventSource does, for a reconnect
67
+ */
68
+ export async function* sse(res: any, meta?: {id?: string; retry?: number}): AsyncGenerator<{event: string; data: string}> {
65
69
  const reader = res.body.getReader(), dec = new TextDecoder();
66
70
  let buf = '', cr = '', done = false;
67
71
  try {
@@ -84,6 +88,8 @@ export async function* sse(res: any): AsyncGenerator<{event: string; data: strin
84
88
  const v = c < 0 ? '' : line.slice(c + 1).replace(/^ /, '');
85
89
  if (field == 'event') event = v;
86
90
  else if (field == 'data') data.push(v);
91
+ else if (meta && field == 'id' && !v.includes('\0')) meta.id = v;
92
+ else if (meta && field == 'retry' && /^\d+$/.test(v)) meta.retry = +v;
87
93
  }
88
94
  if (data.length) yield {event, data: data.join('\n')};
89
95
  }
@@ -45,6 +45,27 @@
45
45
  */
46
46
  import xs from './xstreamCompat'
47
47
  import {isAbort} from '../shared'
48
+ /** A behavior output's slice from before the action (outputs get the updated one): for a transition shared with STATE */
49
+ export const was = (s: any, p: any, k: string) => p?.previousState ? p.previousState[k] : s
50
+ /**
51
+ * A behavior model whose outputs compute from the state before the action (its slice and
52
+ * `props.state`), for behaviors whose STATE and outputs share one transition; STATE / HOST as is
53
+ */
54
+ export const fromPrevious = (model: any) => {
55
+ const out: any = {}
56
+ for (const a in model) {
57
+ const e = model[a]
58
+ if (!e || typeof e != 'object') { out[a] = e; continue }
59
+ const m: any = {}
60
+ for (const s in e) {
61
+ const f = e[s]
62
+ m[s] = s == 'STATE' || s == 'HOST' || typeof f != 'function' ? f
63
+ : (x: any, d: any, n: any, p: any, o: any, k: string) => f(was(x, p, k), d, n, p?.previousState ? {...p, state: p.previousState} : p, o, k)
64
+ }
65
+ out[a] = m
66
+ }
67
+ return out
68
+ }
48
69
  // a model entry as { sink: fn }: a constant is sent as is, `true` sends the action's data, an
49
70
  // EFFECT constant does nothing (as the core treats them)
50
71
  const sinksOf = (e: any, S: string): any => {
@@ -0,0 +1,111 @@
1
+ /**
2
+ * PLAN-10 P-2 (D544): the app side of the graph page's "Live data" (sygnal/vite serves the page at
3
+ * `/__sygnal/graph` in dev, W-2 / D543). Dev only: it is part of the 'sygnal/devtools' entry, and
4
+ * sygnal/vite's 'virtual:sygnal/graph' module (served by the dev server, never in a build) calls
5
+ * installGraphBridge().
6
+ *
7
+ * The page is served by the dev server on the app's own origin, so the two talk over a
8
+ * BroadcastChannel ('sygnal:graph'): no server round trip, no HMR traffic, nothing a page on
9
+ * another origin can join. Every message carries `v: 1`.
10
+ *
11
+ * page → apps { type: 'who', watch? } every 2 s while the page is open; `watch` names
12
+ * the tab the page follows (it keeps that tab
13
+ * sending for 6 s)
14
+ * app → page { type: 'here', tab, url, title, at, ok } the answer, and on load / focus
15
+ * (`at`: when the tab was last loaded or focused;
16
+ * `ok`: inspect() is there, i.e. diagnostics on)
17
+ * app → page { type: 'graph', tab, graph } inspect({ actions: 200 }) of the page's apps, JSON
18
+ * safe: when it starts being watched, then 150 ms
19
+ * after the last action or component change
20
+ * app → page { type: 'action', tab, record } { type, instance, component } at once (a flash)
21
+ * page → app { type: 'get-state', tab, req, id } an instance's state
22
+ * app → page { type: 'state', tab, req, id, value?, error? }
23
+ * app → page { type: 'bye', tab } the tab is going away (pagehide)
24
+ *
25
+ * State values go through the DevTools preview (JSON safe, as the MCP tools send them). The static
26
+ * keys the page joins on are on the component functions (sygnal/vite's dev transform) and come out
27
+ * of inspect() as `key`.
28
+ */
29
+ import {getDevTools} from './devtools'
30
+ import {onAction} from './devtoolsActions'
31
+ import {addLayer} from './devtoolsNext'
32
+ import {pageTools, trackApps} from './devMcp'
33
+
34
+ export const GRAPH_CHANNEL = 'sygnal:graph'
35
+ const WATCH_MS = 6000
36
+ const SETTLE_MS = 150
37
+
38
+ export interface GraphBridgeOptions {
39
+ /** the BroadcastChannel name (default 'sygnal:graph') */
40
+ channel?: string
41
+ }
42
+
43
+ let installed = false
44
+
45
+ /** Connect this page to the graph page's "Live data" (sygnal/vite calls it in dev). Once per page. */
46
+ export function installGraphBridge(options: GraphBridgeOptions = {}): void {
47
+ const g: any = globalThis
48
+ if (installed || typeof g.BroadcastChannel != 'function' || !g.window) return
49
+ installed = true
50
+ trackApps()
51
+ const bc = new g.BroadcastChannel(options.channel || GRAPH_CHANNEL)
52
+ const tab = Math.random().toString(36).slice(2, 10) + Date.now().toString(36)
53
+ const tools = pageTools({})
54
+ let at = Date.now()
55
+ let watchedUntil = 0
56
+ let timer: any = 0
57
+
58
+ const inspect = () => {
59
+ const f = g.__SYGNAL_DIAGNOSTICS__?.inspect || (getDevTools() as any)?.inspect
60
+ return typeof f == 'function' ? f : null
61
+ }
62
+ const post = (m: any) => { try { bc.postMessage({v: 1, tab, ...m}) } catch (_) { /* closed */ } }
63
+ const here = () => post({type: 'here', url: String(g.location?.href || ''), title: String(g.document?.title || ''), at, ok: !!inspect()})
64
+ const watched = () => Date.now() < watchedUntil
65
+
66
+ const sendGraph = () => {
67
+ clearTimeout(timer)
68
+ timer = 0
69
+ const f = inspect()
70
+ if (!watched() || !f) return
71
+ let graph: any
72
+ try { graph = JSON.parse(JSON.stringify(f({actions: 200}))) } catch (_) { return }
73
+ post({type: 'graph', graph})
74
+ }
75
+ const settle = () => {
76
+ if (!watched()) return
77
+ clearTimeout(timer)
78
+ timer = setTimeout(sendGraph, SETTLE_MS)
79
+ }
80
+ const watch = () => {
81
+ const was = watched()
82
+ watchedUntil = Date.now() + WATCH_MS
83
+ if (!was) sendGraph()
84
+ }
85
+
86
+ bc.onmessage = (e: any) => {
87
+ const m = e && e.data
88
+ if (!m || m.v !== 1 || typeof m.type != 'string') return
89
+ if (m.type == 'who') {
90
+ if (m.watch === tab) watch()
91
+ here()
92
+ } else if (m.type == 'get-state' && m.tab === tab) {
93
+ let value: any, error: string | undefined
94
+ try { value = (tools.get_state({component: String(m.id)}, m.req) as any).state } catch (err: any) { error = String(err?.message || err) }
95
+ post({type: 'state', req: m.req, id: m.id, ...(error ? {error} : {value})})
96
+ }
97
+ }
98
+
99
+ onAction((a: any, kind) => {
100
+ if (!watched()) return
101
+ if (kind == 'add' && a) post({type: 'action', record: {type: a.type, instance: String(a.instance), component: a.component}})
102
+ settle()
103
+ })
104
+ addLayer(() => ({onCreate: settle, onDispose: settle}))
105
+
106
+ const seen = () => { at = Date.now(); here() }
107
+ g.addEventListener?.('focus', seen)
108
+ g.document?.addEventListener?.('visibilitychange', () => { if (g.document.visibilityState == 'visible') seen() })
109
+ g.addEventListener?.('pagehide', () => post({type: 'bye'}))
110
+ here()
111
+ }
@@ -91,7 +91,7 @@ function appLayer(api: any) {
91
91
  }
92
92
  }
93
93
 
94
- function trackApps() {
94
+ export function trackApps(): void {
95
95
  if (tracking) return
96
96
  tracking = true
97
97
  addLayer(appLayer)
@@ -146,6 +146,24 @@ export class SygnalDevTools {
146
146
  case 'GET_STATE':
147
147
  this._sendComponentState(msg.payload.componentId);
148
148
  break;
149
+ case 'GET_GRAPH':
150
+ this._post('GRAPH', this._graph(msg.payload));
151
+ break;
152
+ }
153
+ }
154
+
155
+ /**
156
+ * PLAN-10 P-1: the runtime app graph for the panel's App graph view: inspect() with the recent
157
+ * actions, JSON-safe. inspect() is on this object only when 'sygnal/diagnostics' is loaded
158
+ * (sygnal/vite loads it in dev); without it the panel shows why.
159
+ */
160
+ _graph(opts?: {actions?: number}): {graph: any; reason?: string; message?: string} {
161
+ const inspect = (this as any).inspect;
162
+ if (typeof inspect !== 'function') return {graph: null, reason: 'no-diagnostics'};
163
+ try {
164
+ return {graph: JSON.parse(JSON.stringify(inspect({actions: opts?.actions ?? 200})))};
165
+ } catch (e: any) {
166
+ return {graph: null, reason: 'error', message: String(e?.message ?? e)};
149
167
  }
150
168
  }
151
169
 
@@ -12,6 +12,9 @@
12
12
  * SYG635 — `cache: true` / `staleTime` / `{ prefetch }` sent to a driver without a
13
13
  * `queryCache()` (D88): nothing is cached (warn). The source's `__inspect().cache`
14
14
  * is undefined without one
15
+ * SYG636 — PLAN-11 U-1: `progress` that can't be reported as asked: on a resource or a
16
+ * `batch` request (ignored), or with fetch-only `init` options that XMLHttpRequest
17
+ * doesn't support (ignored for that request) (warn)
15
18
  *
16
19
  * Mechanism: onModel wraps the component's own model stream for every sink whose source
17
20
  * takes the `resources` static (makeFetchDriver and renderComponent's HTTP fake) and checks
@@ -24,6 +27,12 @@ import {devReport, once, nameOf} from './shared'
24
27
  import {isStandardSchema} from '../../standardSchema'
25
28
 
26
29
  const IDEMPOTENT = /^(GET|HEAD)$/i
30
+ // PLAN-11 U-1: fetch() init options a progress request (XMLHttpRequest) can't honour
31
+ const XHR_IGNORES = ['keepalive', 'priority', 'cache', 'redirect', 'integrity', 'mode', 'referrer', 'referrerPolicy', 'window', 'duplex']
32
+ const reportProgress = (component: any, sink: string, url: string, why: string, fix: string) => {
33
+ if (!once(`SYG636:${nameOf(component)}:${url}:${why}`)) return
34
+ devReport('SYG636', {component, message: `${sink} ${url ? url + ' ' : ''}has progress, but ${why}`, fix, data: {sink, url}})
35
+ }
27
36
 
28
37
  /** instance → reply key lanes it has sent, and ok name → the other key it was sent under */
29
38
  const lanes = new WeakMap<object, {used: Set<string>; keyed: Map<string, string>}>()
@@ -48,7 +57,7 @@ function checkOne(component: any, sink: string, req: any, where: string) {
48
57
  if (!req || typeof req !== 'object') return
49
58
  const name = nameOf(component)
50
59
  const url = typeof req.url === 'string' ? req.url : ''
51
- const method = String(req.method || (req.json !== undefined || req.body !== undefined ? 'POST' : 'GET')).toUpperCase()
60
+ const method = String(req.method || (req.json !== undefined || req.body !== undefined || req.multipart !== undefined ? 'POST' : 'GET')).toUpperCase()
52
61
  if ((req.cache === true || req.staleTime != null) && !IDEMPOTENT.test(method) && once(`SYG630:${name}:${method}:${url}`)) {
53
62
  devReport('SYG630', {
54
63
  component,
@@ -58,6 +67,17 @@ function checkOne(component: any, sink: string, req: any, where: string) {
58
67
  })
59
68
  }
60
69
  if (req.cache === true || req.staleTime != null) reportNoCache(component, sink, req.cache === true ? 'cache: true' : 'staleTime', url)
70
+ if (req.progress) {
71
+ if (where.includes('.resources.'))
72
+ reportProgress(component, sink, url, 'resources report no progress (it is ignored)', 'Send the download as a request with progress and reply actions instead of a resource, or remove progress')
73
+ else if (req.batch)
74
+ reportProgress(component, sink, url, 'a batch request reports no progress (it is ignored)', 'Send the parts as separate requests with progress, or remove progress')
75
+ else {
76
+ const bad = XHR_IGNORES.filter(k => req.init && req.init[k] !== undefined)
77
+ if (bad.length) reportProgress(component, sink, url, `it goes over XMLHttpRequest, which ignores init.${bad.join(', init.')}`,
78
+ `Remove ${bad.join(', ')} from init for this request, or drop progress if the request needs ${bad.length > 1 ? 'them' : 'it'}`)
79
+ }
80
+ }
61
81
  if ('validate' in req && !isStandardSchema(req.validate) && once(`SYG631:${name}:${url}`)) {
62
82
  devReport('SYG631', {
63
83
  component,
@@ -133,14 +133,15 @@ export const formsCheck: DiagnosticCheck = {
133
133
  })
134
134
  const model = Object.keys(view.model || {}).map(a => a.split('|')[0].trim()), own = ownActions(b)
135
135
  const clash = own.includes(o.submit)
136
- if (typeof o.submit != 'string' || !model.includes(o.submit) || clash) {
136
+ // PLAN-11 F-3: with `request`, the form sends the submit itself and `submit` is optional
137
+ if (o.request ? o.submit !== undefined && (typeof o.submit != 'string' || !model.includes(o.submit) || clash) : typeof o.submit != 'string' || !model.includes(o.submit) || clash) {
137
138
  devReport('SYG234', {
138
139
  component,
139
- message: typeof o.submit != 'string' ? `${name}'s form '${key}' has no submit action, so a valid submit dispatches nothing`
140
+ message: typeof o.submit != 'string' ? `${name}'s form '${key}' has no submit action and no request, so a valid submit dispatches nothing`
140
141
  : clash ? `${name}'s form '${key}' names submit: '${o.submit}', one of the form's own actions; a valid submit dispatches the form's '${key}.${o.submit}'${model.includes(o.submit) ? `, never ${name}'s '${o.submit}' entry` : ''}`
141
142
  : `${name}'s form '${key}' dispatches '${o.submit}' on a valid submit, but ${name} has no model entry '${o.submit}'`,
142
143
  fix: clash ? SUBMIT_FIX(key, o.submit, own)
143
- : `Name a host action and add it to the model: submit: 'SIGN_UP', model: { SIGN_UP: { HTTP: (state, values) => ({ url, json: values, ok: '${key}.DONE', error: '${key}.ERRORS' }) } }`,
144
+ : `Give the form a request, form(schema, { values, request: { url: '/api/signup' } }) (it sends the valid submit itself), or name a host action and add it to the model: submit: 'SIGN_UP', model: { SIGN_UP: { HTTP: (state, values) => ({ url, json: values, ok: '${key}.DONE', error: '${key}.ERRORS' }) } }`,
144
145
  data: {key, submit: o.submit},
145
146
  })
146
147
  }
@@ -107,10 +107,11 @@ import {nextHooks} from './next'
107
107
 
108
108
  export {checkEventBus} from './events'
109
109
  export {configureStrict, isStrictEnabled} from './strict'
110
+ export {configureTimingAudit, isTimingAuditEnabled} from './timingAudit'
110
111
  export type {EventBusSummary} from './events'
111
112
  export {RXJS_HINTS} from './rxjsHints'
112
113
  export {inspect} from './inspect'
113
- export type {InspectResource, InspectCacheEntry, InspectGraph, InspectComponent, InspectAction, InspectActionTrigger, InspectChild, InspectSelector, InspectControl, InspectDiagnostic, InspectRelatedLocation, InspectFix, InspectTextEdit, InspectOptions, InspectRecentAction} from './public'
114
+ export type {InspectResource, InspectCacheEntry, InspectGraph, InspectComponent, InspectAction, InspectActionTrigger, InspectChild, InspectSelector, InspectControl, InspectDiagnostic, InspectRelatedLocation, InspectFix, InspectTextEdit, InspectOptions, InspectRecentAction, InspectLocation, InspectChildSelect} from './public'
114
115
  export {listCodes, getCodeInfo} from '../codes'
115
116
  export type {DiagnosticCodeInfo} from '../codes'
116
117