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.
- package/CHANGELOG.md +64 -0
- package/dist/astro/index.cjs.js +398 -25
- package/dist/astro/index.cjs.js.map +1 -1
- package/dist/astro/index.mjs +398 -25
- package/dist/astro/index.mjs.map +1 -1
- package/dist/astro/server.cjs.js +3 -2
- package/dist/astro/server.cjs.js.map +1 -1
- package/dist/astro/server.mjs +3 -2
- package/dist/astro/server.mjs.map +1 -1
- package/dist/devtools.cjs.js +141 -3
- package/dist/devtools.cjs.js.map +1 -1
- package/dist/devtools.esm.js +141 -4
- package/dist/devtools.esm.js.map +1 -1
- package/dist/diagnostics.cjs.js +140 -21
- package/dist/diagnostics.cjs.js.map +1 -1
- package/dist/diagnostics.esm.js +140 -21
- package/dist/diagnostics.esm.js.map +1 -1
- package/dist/index.cjs.js +54 -24
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +139 -16
- package/dist/index.esm.js +54 -24
- package/dist/index.esm.js.map +1 -1
- package/dist/sygnal.min.js +1 -1
- package/dist/sygnal.min.js.map +1 -1
- package/dist/vike/config/package.json +1 -1
- package/dist/vike/onRenderClient.cjs.js +63 -43
- package/dist/vike/onRenderClient.cjs.js.map +1 -1
- package/dist/vike/onRenderClient.mjs +63 -43
- package/dist/vike/onRenderClient.mjs.map +1 -1
- package/dist/vike/onRenderHtml.cjs.js +21 -25
- package/dist/vike/onRenderHtml.cjs.js.map +1 -1
- package/dist/vike/onRenderHtml.mjs +21 -25
- package/dist/vike/onRenderHtml.mjs.map +1 -1
- package/dist/vite/plugin.cjs.js +398 -25
- package/dist/vite/plugin.cjs.js.map +1 -1
- package/dist/vite/plugin.mjs +398 -25
- package/dist/vite/plugin.mjs.map +1 -1
- package/llms.txt +3 -3
- package/package.json +1 -1
- package/src/core/cell.ts +25 -7
- package/src/core/hooks.ts +6 -0
- package/src/core/hosts/collection.ts +6 -11
- package/src/core/hosts/switchable.ts +2 -2
- package/src/core/instance.ts +2 -2
- package/src/core/view.ts +2 -0
- package/src/cycle/dom/utils.ts +4 -2
- package/src/devtools.d.ts +13 -0
- package/src/devtools.ts +3 -0
- package/src/extra/devGraph.ts +111 -0
- package/src/extra/devMcp.ts +1 -1
- package/src/extra/devtools.ts +18 -0
- package/src/extra/diagnostics/checks/collections.ts +31 -25
- package/src/extra/diagnostics/checks/index.ts +1 -1
- package/src/extra/diagnostics/checks/inspect.ts +68 -6
- package/src/extra/diagnostics/checks/next.ts +22 -0
- package/src/extra/diagnostics/checks/public.d.ts +121 -12
- package/src/extra/diagnostics/codes.ts +10 -0
- package/src/index.d.ts +19 -3
- package/src/vike/onRenderClient.ts +44 -46
- package/src/vike/onRenderHtml.ts +5 -26
- package/src/vike/shell.ts +33 -0
- package/src/vite/graph.ts +247 -0
- package/src/vite/mcp.ts +3 -3
- package/src/vite/plugin.d.ts +12 -0
- 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"
|
|
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` (
|
|
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
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
|
|
42
|
-
*
|
|
43
|
-
*
|
|
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
|
|
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 '${
|
|
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
|
|
74
|
+
if (getIn(p, ks) !== v) parent.set(setIn(p, ks, 0, v))
|
|
57
75
|
},
|
|
58
|
-
has: () => parent.get()
|
|
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
|
|
8
|
-
*
|
|
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
|
-
|
|
93
|
-
|
|
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
|
package/src/core/instance.ts
CHANGED
|
@@ -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 */
|
package/src/cycle/dom/utils.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/extra/devMcp.ts
CHANGED
package/src/extra/devtools.ts
CHANGED
|
@@ -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>
|
|
3
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
34
|
-
|
|
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 (
|
|
40
|
-
|
|
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}}> ${
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
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)
|
|
375
|
-
for (const t of c.eventsSelected)
|
|
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)
|