sygnal 6.4.0 → 6.6.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 (65) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/dist/astro/index.cjs.js +398 -25
  3. package/dist/astro/index.cjs.js.map +1 -1
  4. package/dist/astro/index.mjs +398 -25
  5. package/dist/astro/index.mjs.map +1 -1
  6. package/dist/astro/server.cjs.js +3 -2
  7. package/dist/astro/server.cjs.js.map +1 -1
  8. package/dist/astro/server.mjs +3 -2
  9. package/dist/astro/server.mjs.map +1 -1
  10. package/dist/devtools.cjs.js +141 -3
  11. package/dist/devtools.cjs.js.map +1 -1
  12. package/dist/devtools.esm.js +141 -4
  13. package/dist/devtools.esm.js.map +1 -1
  14. package/dist/diagnostics.cjs.js +140 -21
  15. package/dist/diagnostics.cjs.js.map +1 -1
  16. package/dist/diagnostics.esm.js +140 -21
  17. package/dist/diagnostics.esm.js.map +1 -1
  18. package/dist/index.cjs.js +54 -24
  19. package/dist/index.cjs.js.map +1 -1
  20. package/dist/index.d.ts +139 -16
  21. package/dist/index.esm.js +54 -24
  22. package/dist/index.esm.js.map +1 -1
  23. package/dist/sygnal.min.js +1 -1
  24. package/dist/sygnal.min.js.map +1 -1
  25. package/dist/vike/config/package.json +1 -1
  26. package/dist/vike/onRenderClient.cjs.js +63 -43
  27. package/dist/vike/onRenderClient.cjs.js.map +1 -1
  28. package/dist/vike/onRenderClient.mjs +63 -43
  29. package/dist/vike/onRenderClient.mjs.map +1 -1
  30. package/dist/vike/onRenderHtml.cjs.js +21 -25
  31. package/dist/vike/onRenderHtml.cjs.js.map +1 -1
  32. package/dist/vike/onRenderHtml.mjs +21 -25
  33. package/dist/vike/onRenderHtml.mjs.map +1 -1
  34. package/dist/vite/plugin.cjs.js +398 -25
  35. package/dist/vite/plugin.cjs.js.map +1 -1
  36. package/dist/vite/plugin.mjs +398 -25
  37. package/dist/vite/plugin.mjs.map +1 -1
  38. package/llms.txt +3 -3
  39. package/package.json +1 -1
  40. package/src/core/cell.ts +25 -7
  41. package/src/core/hooks.ts +6 -0
  42. package/src/core/hosts/collection.ts +6 -11
  43. package/src/core/hosts/switchable.ts +2 -2
  44. package/src/core/instance.ts +2 -2
  45. package/src/core/view.ts +2 -0
  46. package/src/cycle/dom/utils.ts +4 -2
  47. package/src/devtools.d.ts +13 -0
  48. package/src/devtools.ts +3 -0
  49. package/src/extra/devGraph.ts +111 -0
  50. package/src/extra/devMcp.ts +1 -1
  51. package/src/extra/devtools.ts +18 -0
  52. package/src/extra/diagnostics/checks/collections.ts +31 -25
  53. package/src/extra/diagnostics/checks/index.ts +1 -1
  54. package/src/extra/diagnostics/checks/inspect.ts +68 -6
  55. package/src/extra/diagnostics/checks/next.ts +22 -0
  56. package/src/extra/diagnostics/checks/public.d.ts +121 -12
  57. package/src/extra/diagnostics/codes.ts +10 -0
  58. package/src/index.d.ts +19 -3
  59. package/src/vike/onRenderClient.ts +44 -46
  60. package/src/vike/onRenderHtml.ts +5 -26
  61. package/src/vike/shell.ts +33 -0
  62. package/src/vite/graph.ts +247 -0
  63. package/src/vite/mcp.ts +3 -3
  64. package/src/vite/plugin.d.ts +12 -0
  65. package/src/vite/plugin.ts +136 -22
package/llms.txt CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  ## 1. Mental model
6
6
  - A component is a pure view function plus static properties: `.intent` (WHEN: sources → named action streams), `.model` (WHAT: action → one reducer per sink), `.initialState`.
7
- - There is one state tree. A child sees its parent's whole state, or a slice with `state="key"`. A Collection item sees one array element.
7
+ - There is one state tree. A child sees its parent's whole state, or a slice with `state="key"` (or a path: `state="settings.rules"`, `"boards[0].title"`). A Collection item sees one array element.
8
8
  - Every side effect is a sink of a model entry (STATE, EVENTS, PARENT, EFFECT, custom drivers). JSX never has event handlers (`onClick`).
9
9
  - Streams are xstream, not RxJS. An intent's DOM selectors only see the component's own JSX, never a child component's.
10
10
 
@@ -117,7 +117,7 @@ TaskList.initialState = { picked: null, tasks: [{ id: 1, title: 'Write' }, { id:
117
117
  TaskList.intent = ({ CHILD }) => ({ PICKED: CHILD.select(TaskItem) }) // the function, not a string
118
118
  TaskList.model = { PICKED: (state, { taskId }) => ({ ...state, picked: taskId }) }
119
119
  ```
120
- - `from` names an array field. Items are keyed by `.id` (by index if they have none). `filter={t => !t.done}`; `sort="title"`, `{ title: 'desc' }`, an array of those, or a compare function. Sorting and filtering only change what renders: the state array keeps its order, and an item's edit (`{ ...state, done: true }`) is written back to its element by key. Items render directly into the parent element, with no wrapper (`<Collection className>` is SYG612: put the class on your own `<ul>`). Switchable: `<Switchable of={{ home: Home, settings: Settings }} current={state.tab} />` (optional `state="slice"`); hidden pages stay alive, and `instance={key}` re-creates the current page with fresh state when the key changes (a router: `instance={state.route.path}`).
120
+ - `from` names an array field (or a path to one: `from="board.cards"`). Items are keyed by `.id` (by index if they have none). `filter={t => !t.done}`; `sort="title"`, `{ title: 'desc' }`, an array of those, or a compare function. Sorting and filtering only change what renders: the state array keeps its order, and an item's edit (`{ ...state, done: true }`) is written back to its element by key. Items render directly into the parent element, with no wrapper (`<Collection className>` is SYG612: put the class on your own `<ul>`). Switchable: `<Switchable of={{ home: Home, settings: Settings }} current={state.tab} />` (optional `state="slice"`); hidden pages stay alive, and `instance={key}` re-creates the current page with fresh state when the key changes (a router: `instance={state.route.path}`).
121
121
  ### Behaviors (`uses`): shared state + actions
122
122
  - `TaskList.uses = { pager: pager({ pageSize: 10, next: '.newer', prev: '.older' }) }` adds `state.pager` (`{ page, pageSize, total }` + calculated `offset`, `pages`, `hasPrev`, `hasNext`) and the actions `pager.NEXT`, `pager.PREV`, `pager.GOTO`, `pager.SET_TOTAL`; a click on what `next`/`prev` name triggers them. Options set the slice's start values; the key must not also be in `initialState` (SYG127). Also from `'sygnal'`: `selection({ multi: true, item: '.pick', from: 'mails' })` (`data-id` on each item; `isSelected(state.sel, id)`) and `undo({ key: 'doc', undo: '.undo', redo: '.redo' })` (`state.history.canUndo`/`canRedo`; `coalesce: ['TYPE'], coalesceMs: 500` groups only typing, each other action is its own step; `key: ['todo', 'done']` records several keys as one step; `node_modules/sygnal/dist/guide/undo.md`).
123
123
  - Your own: `const disclosure = defineBehavior({ initialState: { open: false }, intent: ({ DOM }, { toggle }) => ({ TOGGLE: DOM.click(toggle) }), model: { TOGGLE: (slice) => ({ ...slice, open: !slice.open }) } })`, used as `uses = { more: disclosure({ toggle: '.more' }) }`. Its reducers and `calculated` get and return the slice (`state.more`), its action names have no key, and any sink works. Options: `persist: false` (UI state `persist()` never saves), `undoStep: ['DONE']` (its other actions form one gesture: one undo entry when `DONE` completes it).
@@ -258,7 +258,7 @@ TaskPage.model = { // a page shares the parent's state: no initialState (SYG40
258
258
  - Guides: `node_modules/sygnal/dist/guide/ai-chat.md` (transports, structured `output`, tools, shipping), `ai-decisions.md` (escalation, `decide.openai()`), `ai-compose.md` (catalogs, containers, edits, `byId`), `ai-cards.md` (`chat({ ask })`, client tools), `agent.md` (the in-app assistant `uses = { assistant: chat({ form: '.ask', prompt: '.prompt', instructions }) }`, `commandBar`, security), `webmcp.md` (`experimentalExposeWebMcp(app)`, `form(schema, { tool: formTool({ name, description }) })`: experimental, outside semver), `mcp-apps.md` (`makeMcpAppDriver()`: the app inside Claude or ChatGPT); online https://sygnal.js.org/guide/ai-chat/ (and `/guide/ai-decisions/`, `/guide/ai-compose/`, `/guide/ai-cards/`, `/guide/agent/`, `/guide/webmcp/`, `/guide/mcp-apps/`)
259
259
 
260
260
  ## 4. API facts
261
- - **Child props**: `<Rating name="food" value={state.food} />` → `function Rating({ state, name, value })`; reducers read `props.name` (4th arg); intent gets the `props$` stream. Reserved: `state` (lens: `"key"` or `{ get, set }`), `children`, `slots`, `context`, `uid` (SYG106; an error in strict mode). `key={…}` is a child's identity (not a prop; else its `id` prop, else its place in the view): a new key re-creates it. Without `state=` a child shares its parent's whole state. A child rendered by tag (`<Stopwatch state="stopwatch" />`) has no `initialState`: its start values go in the parent's (`stopwatch: { ms: 0 }`); its own throws SYG405 and the parent renders its error fallback (a test rendering the child as the root doesn't show it). Test a child alone with `renderComponent(Child, { initialState, context: { lang: 'fr' } })` (the ancestors' context). `uid(name?)` (a view prop, also on reducers' `props`) is a stable per-instance id for `id`/`for`/`aria-*` pairs: `<label for={uid('email')}>`, `<input id={uid('email')} />`.
261
+ - **Child props**: `<Rating name="food" value={state.food} />` → `function Rating({ state, name, value })`; reducers read `props.name` (4th arg); intent gets the `props$` stream. Reserved: `state` (`"key"`, a path `"a.b"` / `"a[0].b"` (writes copy only the levels on it; for list items use `byId`), or a `{ get, set }` lens), `children`, `slots`, `context`, `uid` (SYG106; an error in strict mode). `key={…}` is a child's identity (not a prop; else its `id` prop, else its place in the view): a new key re-creates it. Without `state=` a child shares its parent's whole state. A child rendered by tag (`<Stopwatch state="stopwatch" />`) has no `initialState`: its start values go in the parent's (`stopwatch: { ms: 0 }`); its own throws SYG405 and the parent renders its error fallback (a test rendering the child as the root doesn't show it). Test a child alone with `renderComponent(Child, { initialState, context: { lang: 'fr' } })` (the ancestors' context). `uid(name?)` (a view prop, also on reducers' `props`) is a stable per-instance id for `id`/`for`/`aria-*` pairs: `<label for={uid('email')}>`, `<input id={uid('email')} />`.
262
262
  - **A sliced child with its own start values**: `Editor.isolatedState = true` lets `<Editor state="draft" />` keep an `initialState`, used only while `state.draft` is `undefined` (an existing slice is kept; SYG425 warns when it lacks keys the `initialState` has). To start it from its `initialState` on every mount: `<Editor state="draft" resetState />`.
263
263
  - **CHILD.select(Comp)** emits exactly what the child's `PARENT` function returned, for every instance (Collection items too). Put an id in the payload.
264
264
  - **Extract a component without changing markup**: snapshot `t.html()` on the unchanged code first; move the elements verbatim into the child (a component adds no wrapper element and no attributes, so the HTML stays identical); props in, `PARENT` out; the parent drops its old selectors and uses `CHILD.select(Child)`; the snapshot must still match. Recipe: https://sygnal.js.org/guide/parent-child/#recipe-extract-a-component-without-changing-the-markup
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sygnal",
3
- "version": "6.4.0",
3
+ "version": "6.6.0",
4
4
  "description": "An intuitive framework for building fast and small components or applications based on Cycle.js",
5
5
  "main": "./dist/index.cjs.js",
6
6
  "types": "./dist/index.d.ts",
package/src/core/cell.ts CHANGED
@@ -38,24 +38,42 @@ export function localCell(app: {commit(): void}, parent: Cell): Cell {
38
38
  }
39
39
 
40
40
  /**
41
- * state="key". `owner`: the parent's name when `k` is one of its calculated fields (SYG409:
42
- * writes ignored; `what` names the writer in the message). `dflt`: an isolated child's
43
- * initialState, read while the slice is undefined. `has()`: the slice itself is defined (D174).
41
+ * SPIKE (state paths): a state designator as segments. A plain key is one segment; 'a.b.0' and
42
+ * 'a[0].b' are paths into nested objects and arrays. No array form: an array `from` / `state` is
43
+ * the data passed by mistake (SYG410 / SYG412), and ['x', 'y'] would be ambiguous.
44
+ */
45
+ export const pathOf = (p: string): string[] => p.replace(/\[(\d+)\]/g, '.$1').split('.')
46
+ const getIn = (o: any, ks: any[]) => { for (const k of ks) { if (o == null) return undefined; o = o[k] } return o }
47
+ /** `o` with the value at `ks` replaced; only the levels on the path are copied (a missing level becomes {}) */
48
+ function setIn(o: any, ks: any[], i: number, v: any): any {
49
+ if (i == ks.length) return v
50
+ const k = ks[i], cur = o?.[k], n = setIn(cur, ks, i + 1, v)
51
+ if (n === cur && o != null) return o
52
+ if (Array.isArray(o)) { const c = o.slice(); c[k] = n; return c }
53
+ return {...o, [k]: n}
54
+ }
55
+
56
+ /**
57
+ * state="key" (SPIKE: or a path, 'a.b' / 'a[0].b'). `owner`: the parent's name when the first
58
+ * segment is one of its calculated fields (SYG409: writes ignored; `what` names the writer in
59
+ * the message). `dflt`: an isolated child's initialState, read while the slice is undefined.
60
+ * `has()`: the slice itself is defined (D174).
44
61
  */
45
62
  export function keyCell(parent: Cell, k: string, owner?: string | false, dflt?: any, what = 'Sub-component'): Cell & {has(): boolean} {
63
+ const ks = pathOf(k)
46
64
  let lp: any = {}, lv: any
47
65
  return {
48
66
  get() {
49
67
  const p = parent.get()
50
- if (p !== lp) { lp = p; lv = p == null ? undefined : p[k]; if (lv === undefined) lv = dflt }
68
+ if (p !== lp) { lp = p; lv = getIn(p, ks); if (lv === undefined) lv = dflt }
51
69
  return lv
52
70
  },
53
71
  set(v) {
54
- if (owner) return warn('SYG409', owner, `${what} tried to update calculated field '${k}'; ignored`, 'Bind it to a non-calculated field')
72
+ if (owner) return warn('SYG409', owner, `${what} tried to update calculated field '${ks[0]}'; ignored`, 'Bind it to a non-calculated field')
55
73
  const p = parent.get()
56
- if (p?.[k] !== v) parent.set({...p, [k]: v})
74
+ if (getIn(p, ks) !== v) parent.set(setIn(p, ks, 0, v))
57
75
  },
58
- has: () => parent.get()?.[k] !== undefined,
76
+ has: () => getIn(parent.get(), ks) !== undefined,
59
77
  }
60
78
  }
61
79
 
package/src/core/hooks.ts CHANGED
@@ -100,6 +100,12 @@ export interface InstanceView {
100
100
  readonly state: any
101
101
  readonly props: Record<string, any>
102
102
  readonly context: Record<string, any>
103
+ /**
104
+ * PLAN-10 D530: the context keys the latest render's view read (the D168 read tracking), or null
105
+ * (no render yet, or no context). Holds '\u0000all' when the view read every key (a spread,
106
+ * Object.keys). Live: read it, don't keep it
107
+ */
108
+ readonly reads: ReadonlySet<string> | null
103
109
  readonly isRoot: boolean
104
110
  /** 'item' (Collection), 'page' (Switchable), 'child' (tag), 'root' */
105
111
  readonly kind: 'root' | 'child' | 'item' | 'page'
@@ -4,8 +4,9 @@
4
4
  *
5
5
  * It stands where the `<Collection>` marker was in its owner's template and renders the items
6
6
  * inside the owner's render: no stream of its own, no pickCombine.
7
- * - `of`: a component function (D163: no names). `from`: a state key (SYG409 on a calculated
8
- * one), a `{ get, set }` lens (SYG412 without get(); SYG401 for a non-array) or none (the
7
+ * - `of`: a component function (D163: no names). `from`: a state key or path ('board.cards';
8
+ * SYG409 on a calculated first segment; SYG401 when it isn't an array: the dev entry's), a
9
+ * `{ get, set }` lens (SYG412 without get(); SYG401 for a non-array) or none (the
9
10
  * owner's state is the array).
10
11
  * - Items are keyed by `id`, an id-less item by its raw index (D169); one key -> index map per
11
12
  * array identity serves every item (cell.ts indexer). A duplicate key renders its first element
@@ -30,7 +31,7 @@
30
31
  */
31
32
  import {hosts, resolvers} from '../registry'
32
33
  import {Inst, shallowEq} from '../instance'
33
- import {Cell, Index, indexer, itemCell, keyCell, keyOf, keyName} from '../cell'
34
+ import {Cell, Index, indexer, itemCell, keyCell, keyOf, keyName, pathOf} from '../cell'
34
35
  import {CoreDef, isObj} from '../define'
35
36
  import {uidPart, vtStyle} from '../../shared'
36
37
  import {viewOf} from '../view'
@@ -89,14 +90,8 @@ function arrayCell(owner: Inst, from: any): Cell | null {
89
90
  set: (v) => oc.set(v),
90
91
  }
91
92
  }
92
- if (typeof from == 'string') {
93
- const cs = oc.get(), calc = owner.def.calcNames?.has(from)
94
- if (isObj(cs) && !(from in cs) && !calc) {
95
- const arrays = Object.keys(cs).filter(k => Array.isArray(cs[k]))
96
- warn('SYG401', name, `Collection from="${from}" is not in state${arrays.length ? ` (array fields: '${arrays.join("', '")}')` : ''}; it renders nothing until it exists`, 'Set it to an array in initialState')
97
- } else if (isObj(cs) && !Array.isArray(cs[from])) warn('SYG401', name, `Collection 'from' field '${from}' is not an array; it renders nothing`, 'Set it to an array in initialState')
98
- return keyCell(oc, from, calc && name, undefined, 'Collection')
99
- }
93
+ // a key or a path ('board.cards'); SYG401 for one that isn't an array is the dev entry's (checks/collections.ts)
94
+ if (typeof from == 'string') return keyCell(oc, from, owner.def.calcNames?.has(pathOf(from)[0]) && name, undefined, 'Collection')
100
95
  if (isObj(from) && typeof from.get == 'function') {
101
96
  let lp: any = {}, lv: any
102
97
  return {
@@ -18,7 +18,7 @@
18
18
  import {hosts} from '../registry'
19
19
  import {resolve} from './collection'
20
20
  import {Inst, shallowEq} from '../instance'
21
- import {Cell, keyCell, lensCell, localCell} from '../cell'
21
+ import {Cell, keyCell, lensCell, localCell, pathOf} from '../cell'
22
22
  import {isObj} from '../define'
23
23
  import {uidPart} from '../../shared'
24
24
  import {error as logError, fail} from '../../extra/diagnostics/legacy'
@@ -61,7 +61,7 @@ export class SwitchableHost {
61
61
 
62
62
  constructor(public owner: Inst, props: Record<string, any>, children: any[], id: string) {
63
63
  check(owner, props)
64
- const st = props.state, calc = typeof st == 'string' && owner.def.calcNames?.has(st)
64
+ const st = props.state, calc = typeof st == 'string' && owner.def.calcNames?.has(pathOf(st)[0])
65
65
  this.cell = typeof st == 'string' ? keyCell(owner.cell, st, calc && owner.def.name, undefined, 'Switchable sub-component')
66
66
  : st !== undefined ? lensCell(owner.cell, st, owner.def.name, (e) => owner.app.appError(owner, e, 'view'))
67
67
  : owner.cell
@@ -20,7 +20,7 @@ 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'
22
22
  import {CoreDef, defOf, isObj} from './define'
23
- import {Cell, CalcCell, calcCell, keyCell, lensCell, localCell} from './cell'
23
+ import {Cell, CalcCell, calcCell, keyCell, lensCell, localCell, pathOf} from './cell'
24
24
  import {hosts, posts, pres, resolvers} from './registry'
25
25
  import {handle} from './actions'
26
26
  import {viewOf} from './view'
@@ -536,7 +536,7 @@ export class Inst {
536
536
  fail('SYG405', def.name, 'Sub-component initialState replaces the state its parent passes in', 'Remove initialState, or set isolatedState = true')
537
537
  }
538
538
  const dflt = def.isolated ? def.initialState : undefined
539
- const cell = typeof st == 'string' ? keyCell(this.cell, st, calcOf?.has(st) && this.def.name, dflt)
539
+ const cell = typeof st == 'string' ? keyCell(this.cell, st, calcOf?.has(pathOf(st)[0]) && this.def.name, dflt)
540
540
  : st !== undefined ? lensCell(this.cell, st, this.def.name, (e) => app.appError(this, e, 'view'), dflt)
541
541
  : def.isolated ? localCell(app, this.cell) : this.cell
542
542
  const scope = app.scope()
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'
@@ -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
 
@@ -1,18 +1,13 @@
1
1
  /**
2
- * SYG401 — <Collection> has no `from` and the whole state is not an array (warn).
3
- * A defined `from` is checked by the core (D22).
2
+ * SYG401 — a <Collection> / <VirtualCollection> whose items aren't an array (warn):
3
+ * - no `from`, and the whole state is not an array
4
+ * - `from` a key or a path ('board.cards') that isn't in state, or isn't an array there
5
+ * A lens `from` (a getter that returns a non-array, SYG401) and an invalid `from` (SYG412) are
6
+ * the core's. The string `from` checks moved here from the core in 6.5.0 (production apps don't
7
+ * print them; dev and tests do: sygnal/vite and renderComponent load this entry).
4
8
  *
5
- * Reported through diagnostics in addition to the existing console output.
6
- *
7
- * Mechanism: the core has no hook at Collection instantiation, and the
8
- * Collection marker vnode is gone by the time onRender sees the tree. So, at
9
- * onModel (construction time, before the first render), this check wraps the
10
- * component INSTANCE's instantiateCollection method (an own property that
11
- * shadows the prototype method; Component calls `this.instantiateCollection`
12
- * for every new Collection). The wrapper inspects the marker's props and the
13
- * component's current state, then always calls the original. This only
14
- * happens when the dev-only 'sygnal/diagnostics' entry is loaded and
15
- * diagnostics are on. Reported once per component name and `from` value.
9
+ * Called by the dev layer (next.ts onHostProps) with the owner's facade and the host's props.
10
+ * Reported once per component name and `from` value.
16
11
  */
17
12
  import type {DiagnosticCheck} from '../index'
18
13
  import {reportSafely, once, nameOf, isPlainObject} from './shared'
@@ -30,25 +25,36 @@ export function checkCollection(component: any, el: any): void {
30
25
  let state = component.currentState
31
26
  try { if (typeof component.addCalculated === 'function') state = component.addCalculated(state) } catch (_) {}
32
27
 
33
- let problem: string | undefined
34
- let fix = `Pass the name of an array field in ${name}'s state, e.g. <Collection of={${ofName}} from="items" />, and initialize that field to [] in initialState`
35
- // Every defined `from` (string field, lens object, or an invalid value) is reported by
36
- // the core itself (SYG401/SYG412, retrofitted in 1E) in every diagnostics mode (D22), so
37
- // this check only covers a missing `from`.
28
+ if (typeof from === 'string') return checkFrom(component, name, from, ofName, state)
29
+ // a lens or an invalid value: the core's (SYG401 / SYG412)
38
30
  if (from !== undefined) return
39
- if (!Array.isArray(state) && !(state && Array.isArray(state.value))) {
40
- problem = `has no 'from' prop, so it uses ${name}'s whole state, which is ${describe(state)}, not an array`
41
- }
42
-
43
- if (!problem || !once(`SYG401:${name}:undefined`)) return
31
+ if (Array.isArray(state) || (state && Array.isArray(state.value))) return
32
+ if (!once(`SYG401:${name}:undefined`)) return
44
33
  reportSafely('SYG401', {
45
34
  component,
46
- message: `<Collection of={${ofName}}> ${problem}, so it renders no items`,
47
- fix,
35
+ message: `<Collection of={${ofName}}> has no 'from' prop, so it uses ${name}'s whole state, which is ${describe(state)}, not an array, so it renders no items`,
36
+ fix: `Pass the name of an array field in ${name}'s state, e.g. <Collection of={${ofName}} from="items" />, and initialize that field to [] in initialState`,
48
37
  data: {from: 'undefined', of: ofName},
49
38
  })
50
39
  }
51
40
 
41
+ /** `from` a key or a path ('board.cards', 'boards[0].cards'), as the core reads it (cell.ts pathOf) */
42
+ function checkFrom(component: any, name: string, from: string, ofName: string, state: any): void {
43
+ const ks = from.replace(/\[(\d+)\]/g, '.$1').split('.')
44
+ const calc = component._calculatedFieldNames
45
+ if (calc?.has?.(ks[0])) return
46
+ const at = ks.slice(0, -1).reduce((o: any, k: string) => (o == null ? o : o[k]), state), last = ks[ks.length - 1]
47
+ if (!isPlainObject(at) && !Array.isArray(at)) return // a level still missing: renders once it appears (D178)
48
+ let message: string
49
+ if (!(last in at)) {
50
+ const arrays = Object.keys(at).filter(k => Array.isArray(at[k]))
51
+ message = `Collection from="${from}" is not in state${arrays.length ? ` (array fields: '${arrays.join("', '")}')` : ''}; it renders nothing until it exists`
52
+ } else if (!Array.isArray(at[last])) message = `Collection 'from' field '${from}' is not an array; it renders nothing`
53
+ else return
54
+ if (!once(`SYG401:${name}:${from}`)) return
55
+ reportSafely('SYG401', {component, message, fix: 'Set it to an array in initialState', data: {from, of: ofName}})
56
+ }
57
+
52
58
  const WRAPPED = '__sygnalDiagWrapped'
53
59
 
54
60
  export const collectionsCheck: DiagnosticCheck = {
@@ -110,7 +110,7 @@ export {configureStrict, isStrictEnabled} from './strict'
110
110
  export type {EventBusSummary} from './events'
111
111
  export {RXJS_HINTS} from './rxjsHints'
112
112
  export {inspect} from './inspect'
113
- export type {InspectResource, InspectCacheEntry, InspectGraph, InspectComponent, InspectAction, InspectActionTrigger, InspectChild, InspectSelector, InspectControl, InspectDiagnostic, InspectRelatedLocation, InspectFix, InspectTextEdit, InspectOptions, InspectRecentAction} from './public'
113
+ export type {InspectResource, InspectCacheEntry, InspectGraph, InspectComponent, InspectAction, InspectActionTrigger, InspectChild, InspectSelector, InspectControl, InspectDiagnostic, InspectRelatedLocation, InspectFix, InspectTextEdit, InspectOptions, InspectRecentAction, InspectLocation, InspectChildSelect} from './public'
114
114
  export {listCodes, getCodeInfo} from '../codes'
115
115
  export type {DiagnosticCodeInfo} from '../codes'
116
116
 
@@ -17,6 +17,15 @@
17
17
  * onRender (PLAN-4 CT-1) the controls the instance renders: its own vtree, up to the
18
18
  * isolated child components, through ./controls' vnode -> control map
19
19
  * onDispose prunes the instance
20
+ * (PLAN-10 D522, read from the instance view at inspect() time, no core change) viewOnly (the
21
+ * component function has no statics), entry (an app's root), app (which run() the
22
+ * instance belongs to), shown: false (a hidden Switchable page and below), the
23
+ * instance ids on children entries and in events[type].emitterIds / selectorIds.
24
+ * D530: contextConsumes from InstanceView.reads (the keys the latest render read);
25
+ * diagnostics keep their `timestamp`.
26
+ * `key` (PLAN-10 P-2, D544): the static 'file#name' (D521) that sygnal/vite stamps on
27
+ * the component function in dev (a non-enumerable `__sygnalKey`), when it is there;
28
+ * the runtime itself has no source file
20
29
  * (PLAN-4 2-C) the action log (./actionLog: actionHooks, a layer of checks/next.ts) feeds
21
30
  * inspect({ actions }): the recent actions (the last RECENT_MAX, any instance)
22
31
  * Selectors come from the DOM check (real DOM) or from renderComponent (mock
@@ -205,6 +214,8 @@ const slim = (d: any): InspectDiagnostic => {
205
214
  if (d.component) out.component = d.component
206
215
  if (d.fix) out.fix = d.fix
207
216
  if (d.docsUrl) out.docsUrl = d.docsUrl
217
+ // PLAN-10 P-1: when it was reported (runtime diagnostics are reported once per process)
218
+ if (typeof d.timestamp === 'number') out.timestamp = d.timestamp
208
219
  if (d.data !== undefined) {
209
220
  // keep it JSON-safe (data can hold errors, vnodes, ...)
210
221
  try { out.data = JSON.parse(JSON.stringify(d.data)) } catch (_) { /* drop */ }
@@ -316,12 +327,55 @@ function childrenOf(r: Rec, all: Rec[]): InspectChild[] {
316
327
  if (c.parentId !== r.id) continue
317
328
  const key = `${c.name}\u0000${via[c.kind]}`
318
329
  const entry = out.get(key)
319
- if (entry) entry.count = (entry.count || 1) + 1
320
- else out.set(key, {name: c.name, via: via[c.kind], count: 1})
330
+ if (entry) entry.count = entry.ids!.push(c.id)
331
+ else out.set(key, {name: c.name, via: via[c.kind], count: 1, ids: [c.id]})
321
332
  }
322
333
  return [...out.values()]
323
334
  }
324
335
 
336
+ /** the statics sygnal-check reads (its STATIC_PROPS): a component function with none is view-only */
337
+ const STATICS = ['intent', 'model', 'initialState', 'isolatedState', 'context', 'calculated', 'connections', 'resources', 'route', 'head', 'uses', 'timers', 'persist', 'viewTransitions', 'browser', 'agent']
338
+
339
+ /** PLAN-10 D522: no statics and nothing the core added (renderComponent's test intent, a behavior's model) */
340
+ function isViewOnly(c: any): boolean {
341
+ const def = c && c.__next && c.__next.def
342
+ if (!def || def.intent || (def.handlers && def.handlers.size) || def.initialState !== undefined) return false
343
+ const fn = def.view
344
+ return !!fn && STATICS.every(k => fn[k] === undefined)
345
+ }
346
+
347
+ /** PLAN-10 P-2 (D544): the static key sygnal/vite's dev transform stamped on the component function */
348
+ function stampedKey(c: any): string | undefined {
349
+ const fn = c && c.__next && c.__next.def && c.__next.def.view
350
+ const k = fn && fn.__sygnalKey
351
+ return typeof k === 'string' ? k : undefined
352
+ }
353
+
354
+ /** PLAN-10 D530: the context keys the instance's latest render read (InstanceView.reads); every key when it read them all */
355
+ function contextReadsOf(c: any): string[] {
356
+ const v = c && c.__next
357
+ const reads: ReadonlySet<string> | null | undefined = v && v.reads
358
+ if (!reads) return []
359
+ const out = [...reads].filter(k => k !== '\u0000all')
360
+ if (reads.has('\u0000all')) { try { for (const k of Object.keys(v.context || {})) if (!out.includes(k)) out.push(k) } catch (_) { /* no context */ } }
361
+ return out
362
+ }
363
+
364
+ /** PLAN-10 D522: the app (run()) of each instance: the index of its root among the live roots, oldest first */
365
+ function appsOf(all: Rec[]): Map<Rec, number> {
366
+ const byId = new Map(all.map(r => [r.id, r]))
367
+ const roots = all.filter(r => r.parentId === null).sort((a, b) => Number(a.id) - Number(b.id))
368
+ const index = new Map(roots.map((r, i) => [r, i]))
369
+ const out = new Map<Rec, number>()
370
+ for (const r of all) {
371
+ let top: Rec | undefined = r
372
+ for (let n = 0; top && top.parentId !== null && n < 10000; n++) top = byId.get(top.parentId)
373
+ const i = top && index.get(top)
374
+ if (i !== undefined) out.set(r, i)
375
+ }
376
+ return out
377
+ }
378
+
325
379
  /** The app graph of the live component instances (see ./public.d.ts). */
326
380
  export function inspect(options: InspectOptions = {}): InspectGraph {
327
381
  // report SYG105 (info) for EVENTS types selected but never emitted so far
@@ -340,22 +394,29 @@ export function inspect(options: InspectOptions = {}): InspectGraph {
340
394
  const ids = options.ids && new Set(options.ids.map(String))
341
395
  const recs = ids ? all.filter(r => ids.has(r.id)) : all
342
396
  const names = new Set(recs.map(r => r.name))
397
+ const apps = appsOf(all)
343
398
 
344
399
  const components: InspectComponent[] = recs.map(r => {
345
400
  const c = r.instance
346
401
  const calculated = c && c.calculated && typeof c.calculated === 'object' ? Object.keys(c.calculated) : []
347
402
  const selectors = selectorsOf(r, options, diags)
348
403
  const controls = controlsOf(r, selectors)
404
+ const key = stampedKey(c)
349
405
  return {
350
406
  name: r.name,
351
407
  id: r.id,
408
+ ...(key ? {key} : {}),
352
409
  parentId: r.parentId,
353
410
  kind: r.kind,
411
+ ...(isViewOnly(c) ? {viewOnly: true as const} : {}),
412
+ ...(r.parentId === null ? {entry: true as const} : {}),
413
+ ...(apps.has(r) ? {app: apps.get(r)} : {}),
414
+ ...(c && c.__next && c.__next.shown === false ? {shown: false as const} : {}),
354
415
  actions: actionsOf(r),
355
416
  stateKeys: stateKeysOf(c, calculated),
356
417
  calculated,
357
418
  contextProvides: c && isPlainObject(c.context) ? Object.keys(c.context) : [],
358
- contextConsumes: null,
419
+ contextConsumes: contextReadsOf(c),
359
420
  eventsEmitted: [...r.eventsEmitted],
360
421
  eventsSelected: [...r.eventsSelected],
361
422
  children: childrenOf(r, all),
@@ -368,11 +429,12 @@ export function inspect(options: InspectOptions = {}): InspectGraph {
368
429
 
369
430
  const cache = cacheOf(recs) || {}
370
431
  const events: InspectGraph['events'] = {}
371
- const ev = (type: string) => events[type] || (events[type] = {emitters: [], selectors: []})
432
+ // names once each (as before); PLAN-10 D522: every instance's id in emitterIds / selectorIds
433
+ const ev = (type: string) => events[type] || (events[type] = {emitters: [], selectors: [], emitterIds: [], selectorIds: []})
372
434
  const add = (list: string[], name: string) => { if (!list.includes(name)) list.push(name) }
373
435
  for (const c of components) {
374
- for (const t of c.eventsEmitted) add(ev(t).emitters, c.name)
375
- for (const t of c.eventsSelected) add(ev(t).selectors, c.name)
436
+ for (const t of c.eventsEmitted) { const e = ev(t); add(e.emitters, c.name); e.emitterIds!.push(c.id) }
437
+ for (const t of c.eventsSelected) { const e = ev(t); add(e.selectors, c.name); e.selectorIds!.push(c.id) }
376
438
  }
377
439
  // emitted on the bus by something that isn't a listed component (e.g. a driver, a pruned component)
378
440
  if (!ids) for (const t of busTypes) ev(t)