sygnal 6.3.0 → 6.5.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 (42) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/dist/astro/index.cjs.js +69 -3
  3. package/dist/astro/index.cjs.js.map +1 -1
  4. package/dist/astro/index.mjs +69 -3
  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/diagnostics.cjs.js +105 -19
  11. package/dist/diagnostics.cjs.js.map +1 -1
  12. package/dist/diagnostics.esm.js +105 -19
  13. package/dist/diagnostics.esm.js.map +1 -1
  14. package/dist/guide/mcp-apps.md +59 -4
  15. package/dist/index.cjs.js +54 -25
  16. package/dist/index.cjs.js.map +1 -1
  17. package/dist/index.d.ts +27 -6
  18. package/dist/index.esm.js +54 -25
  19. package/dist/index.esm.js.map +1 -1
  20. package/dist/sygnal.min.js +1 -1
  21. package/dist/sygnal.min.js.map +1 -1
  22. package/dist/vike/config/package.json +1 -1
  23. package/dist/vite/plugin.cjs.js +69 -3
  24. package/dist/vite/plugin.cjs.js.map +1 -1
  25. package/dist/vite/plugin.mjs +69 -3
  26. package/dist/vite/plugin.mjs.map +1 -1
  27. package/llms.txt +4 -4
  28. package/package.json +1 -1
  29. package/src/core/cell.ts +25 -7
  30. package/src/core/hosts/collection.ts +6 -11
  31. package/src/core/hosts/switchable.ts +2 -2
  32. package/src/core/instance.ts +2 -2
  33. package/src/extra/diagnostics/checks/collections.ts +31 -25
  34. package/src/extra/diagnostics/checks/dom.ts +10 -2
  35. package/src/extra/diagnostics/checks/next.ts +39 -1
  36. package/src/extra/diagnostics/checks/persist.ts +4 -2
  37. package/src/extra/diagnostics/checks/wiring.ts +14 -6
  38. package/src/extra/diagnostics/codes.ts +9 -0
  39. package/src/extra/pwa.ts +3 -1
  40. package/src/extra/reducers.ts +10 -4
  41. package/src/index.d.ts +26 -5
  42. package/src/vite/plugin.ts +63 -2
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
 
@@ -79,7 +79,7 @@ SaveButton.intent = ({ DOM }) => ({ SAVE: DOM.click('.save') })
79
79
  SaveButton.model = {
80
80
  SAVE: {
81
81
  STATE: (state) => ({ ...state, saving: true }),
82
- EVENTS: event('DOC_SAVED', (state) => ({ id: state.id })), // or event('RESET'), event('MODE', 'dark')
82
+ EVENTS: event('DOC_SAVED', (state) => ({ id: state.id })), // or event('RESET'), event('MODE', 'dark'); return ABORT to send nothing
83
83
  },
84
84
  }
85
85
  function Toast({ state }) { // anywhere in the tree: EVENTS is a global bus
@@ -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.3.0",
3
+ "version": "6.5.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
 
@@ -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()
@@ -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 = {
@@ -25,8 +25,10 @@
25
25
  * that matches in one Collection item is fine for all of them.
26
26
  *
27
27
  * SYG103 noise control (conditional rendering is normal): a selector whose
28
- * class/id/tag tokens all appear in the component's view source is assumed
29
- * to be conditionally rendered and is not reported. A selector that does not
28
+ * class/id/tag tokens all appear in the component's view source (or in the
29
+ * words sygnal/vite registered for it: markup drawn by view helpers in its
30
+ * module and its relative imports) is assumed to be conditionally rendered
31
+ * and is not reported. A selector that does not
30
32
  * appear in the view (typo, or an element rendered elsewhere) is reported as
31
33
  * info on the first sweep, and escalated per the rule above.
32
34
  *
@@ -71,12 +73,18 @@ onReset(() => {
71
73
 
72
74
  const unref = (t: any) => { if (t && typeof t.unref === 'function') t.unref(); return t }
73
75
 
76
+ /**
77
+ * The view's source, plus the words sygnal/vite registered for the component (its module's and
78
+ * its relative imports' string literals: markup a view helper draws), when it did
79
+ */
74
80
  function viewSource(component: any): string {
75
81
  const view = component && component.view
76
82
  if (typeof view !== 'function') return ''
77
83
  let src = viewTokens.get(view)
78
84
  if (src === undefined) {
79
85
  try { src = Function.prototype.toString.call(view) } catch (_) { src = '' }
86
+ const registered = (globalThis as any).__SYGNAL_VIEW_TOKENS__?.get?.(view)
87
+ if (typeof registered == 'string') src += '\n' + registered
80
88
  viewTokens.set(view, src as string)
81
89
  }
82
90
  return src as string
@@ -66,6 +66,33 @@ function removed(component: any, name: string, anchor: string, detail?: string):
66
66
  })
67
67
  }
68
68
 
69
+ /**
70
+ * SYG428 (6.5.0): `state="a.b"` / `from="a.b"` is a path, read as `state.a.b`. Before 6.5.0 it was
71
+ * one key named "a.b"; when the owner's state still has such a key, the designator now misses it.
72
+ */
73
+ function dottedKey(f: any, what: string, designator: any, ownerState: any): void {
74
+ if (typeof designator != 'string' || !/[.[]/.test(designator) || !isPlainObject(ownerState)) return
75
+ if (!Object.prototype.hasOwnProperty.call(ownerState, designator) || !once(`SYG428:${f.name}:${designator}`)) return
76
+ devReport('SYG428', {
77
+ component: f.name,
78
+ message: `${what}="${designator}" is a path (since 6.5.0), read as ${designator.replace(/\[(\d+)\]/g, '.$1').split('.').map((k: string) => `[${JSON.stringify(k)}]`).join('')}, but the state has a key named "${designator}"`,
79
+ fix: `Rename the key so it has no dot (and point ${what} at it), or use a lens: ${what}={{ get: (s) => s['${designator}'], set: (s, v) => ({ ...s, '${designator}': v }) }}`,
80
+ data: {designator, what},
81
+ })
82
+ }
83
+
84
+ /** SYG427: CHILD.select(x) with no component (an import of a name the module doesn't export) */
85
+ function notComponent(f: any, x: any): void {
86
+ if (!once(`SYG427:${f.name}`)) return
87
+ const got = x === null ? 'null' : typeof x == 'object' ? 'an object' : String(x)
88
+ devReport('SYG427', {
89
+ component: f.name,
90
+ message: `${f.name}'s intent calls CHILD.select(${got}): that is not a component, so it never emits`,
91
+ fix: "Pass the child component itself, and check its import: a name its module doesn't export is undefined (Vitest runs on; the browser fails to link the module). Export it: export function Child() { … }",
92
+ data: {got},
93
+ })
94
+ }
95
+
69
96
  /** the removed forms a definition shows (statics, view arity, model keys), once per function */
70
97
  let seenDefs = new WeakSet<object>()
71
98
  // G-332: resetChecks() forgets them (as the once() dedupe)
@@ -361,11 +388,16 @@ export function nextHooks(_api: any): any {
361
388
  // G-527: one chain of SYG149 checks over the latest patches (G-501: it stops once every instance
362
389
  // that renders a named Collection is disposed, the app's dispose included)
363
390
  const vtCheck = vtChecker(() => vtp.size && on())
391
+ // SYG428: the live instances by id, to read a child's parent state
392
+ const live = new Map<any, any>()
364
393
  return {
365
394
  onCreate(iv: any) {
395
+ live.set(iv.id, iv)
366
396
  if (!on()) return
367
397
  log.onCreate(iv)
368
398
  const f = fac(iv)
399
+ const parent = live.get(iv.parentId)
400
+ if (parent) dottedKey(f, `<${f.name} state`, iv.props?.state, parent.state)
369
401
  removedStatics(f)
370
402
  if (!iv.def.intent) wire(iv, [])
371
403
  },
@@ -381,7 +413,11 @@ export function nextHooks(_api: any): any {
381
413
  get(t: any, k: any) {
382
414
  const v = t[k]
383
415
  if (k !== 'CHILD' || !v || typeof v.select != 'function') return v
384
- return {...v, select: (x: any, ...r: any[]) => { if (typeof x == 'string') removed(f, f.name, 'child-select-name', `CHILD.select('${x}')`); return v.select(x, ...r) }}
416
+ return {...v, select: (x: any, ...r: any[]) => {
417
+ if (typeof x == 'string') removed(f, f.name, 'child-select-name', `CHILD.select('${x}')`)
418
+ else if (typeof x != 'function') notComponent(f, x)
419
+ return v.select(x, ...r)
420
+ }}
385
421
  },
386
422
  })
387
423
  },
@@ -402,6 +438,7 @@ export function nextHooks(_api: any): any {
402
438
  },
403
439
  onDispose(iv: any) {
404
440
  vtp.delete(iv)
441
+ live.delete(iv.id)
405
442
  H.onDispose(fac(iv))
406
443
  },
407
444
  onAction(iv: any, a: any) { log.onAction(iv, a) },
@@ -435,6 +472,7 @@ export function nextHooks(_api: any): any {
435
472
  if (w.length) removed(f, f.name, 'collection-wrapper', w.map(k => k == 'dataset' ? 'data-*' : k == 'hook' ? 'ref / hook' : k).join(', '))
436
473
  }
437
474
  try { checkCollection(f, {data: {props}}) } catch (_) { /* ignore */ }
475
+ dottedKey(f, `<${sel == 'collection' ? 'Collection' : 'VirtualCollection'} from`, props.from, owner.state)
438
476
  // G-419: SYG148 for a prefix that isn't an identifier; a valid one is checked for
439
477
  // duplicate names after each patch (SYG149). G-460: a falsy one names nothing (as the host)
440
478
  const p = props.viewTransitionName
@@ -6,7 +6,8 @@
6
6
  * or restored. Under Vike's Layout/Wrapper shell (the `__vike` source) the message says
7
7
  * persist isn't supported on Vike pages, layouts or wrappers yet.
8
8
  * SYG223 (warn) — a `pick` / `omit` key of persist() that isn't a key of the component's
9
- * `initialState` static (a typo: that key is never saved, or never left out).
9
+ * `initialState` static or of its `uses` (a behavior's slice, such as a chat's), so it is
10
+ * a typo: that key is never saved, or never left out.
10
11
  * Once per component name. Mechanism: onModel reads the static `view.persist` (the persist()
11
12
  * value carries its options) and `view.initialState`.
12
13
  */
@@ -38,7 +39,8 @@ export const persistCheck: DiagnosticCheck = {
38
39
  }
39
40
  const o = p.options, init = view.initialState
40
41
  if (!o || !init || typeof init != 'object') return
41
- const keys = Object.keys(init)
42
+ // a behavior's slice (`uses = { chat: chat() }`) is a state key too
43
+ const keys = [...Object.keys(init), ...Object.keys(view.uses || {})]
42
44
  for (const opt of ['pick', 'omit']) {
43
45
  for (const k of Array.isArray(o[opt]) ? o[opt] : []) {
44
46
  if (keys.includes(k) || !once(`SYG223:${name}:${opt}:${k}`)) continue
@@ -36,12 +36,20 @@ const underTest = (c: any) => {
36
36
  const dom = c?.sources?.[c.DOMSourceName || 'DOM']
37
37
  return !!dom && typeof dom == 'object' && '_hub' in dom
38
38
  }
39
- // G-620: an LLM / chat sink gets the chat driver's fix, not makeFetchDriver's
40
- const driverFix = (sink: string) =>
41
- `Pass a driver named ${sink} to run(): ` + (/^(LLM|CHAT|AI|ASSISTANT)$/.test(sink)
42
- ? `run(App, { ${sink}: makeChatDriver({ transport: openResponses({ baseURL: 'http://localhost:11434/v1', model: 'llama3.2' }) }) }) (from 'sygnal/ai'; uiMessageStream('/api/chat') for an AI SDK server)`
43
- : `run(App, { ${sink}: makeFetchDriver() }) for HTTP, or driverFromAsync(fn) / your own driver`) +
44
- `. Check the spelling against the drivers you pass`
39
+ // G-620: the usual driver for a known sink name (an LLM / chat sink gets the chat driver's fix,
40
+ // SW the service worker's), the generic text for any other name
41
+ const DRIVER_FOR: Array<[RegExp, (sink: string) => string]> = [
42
+ [/^(LLM|CHAT|AI|ASSISTANT)$/, (s) => `run(App, { ${s}: makeChatDriver({ transport: openResponses({ baseURL: 'http://localhost:11434/v1', model: 'llama3.2' }) }) }) (from 'sygnal/ai'; uiMessageStream('/api/chat') for an AI SDK server)`],
43
+ [/^(SW|SERVICE_WORKER)$/, (s) => `run(App, { ${s}: makeServiceWorkerDriver('/sw.js', { enabled: import.meta.env.PROD }) }) (enabled: false keeps the driver, with no service worker, on a dev server)`],
44
+ [/^(HTTP|API|FETCH)$/, (s) => `run(App, { ${s}: makeFetchDriver() })`],
45
+ [/^(WS|SOCKET)$/, (s) => `run(App, { ${s}: makeSocketDriver() })`],
46
+ ]
47
+ const driverFix = (sink: string) => {
48
+ const known = DRIVER_FOR.find(([re]) => re.test(sink))
49
+ return `Pass a driver named ${sink} to run(): ` +
50
+ (known ? known[1](sink) : `run(App, { ${sink}: makeFetchDriver() }) for HTTP, or driverFromAsync(fn) / your own driver`) +
51
+ `. Check the spelling against the drivers you pass`
52
+ }
45
53
 
46
54
  export const wiringCheck: DiagnosticCheck = {
47
55
  id: 'wiring',
@@ -278,6 +278,11 @@ export const CODE_TITLES: Record<string, string> = {
278
278
  SYG425: 'isolatedState slice lacks initialState keys',
279
279
  // PLAN-9 (D471): checks/next.ts; dev entry and sygnal-check
280
280
  SYG426: 'Deprecated lowercase marker tag',
281
+ // checks/next.ts and sygnal-check: a component reference that is not a function (an import of
282
+ // a name the module doesn't export is undefined under Vitest)
283
+ SYG427: 'CHILD.select() of something that is not a component',
284
+ // 6.5.0 (checks/next.ts): state="a.b" / from="a.b" is a path; the parent state has a literal "a.b" key
285
+ SYG428: 'A state path that names a key containing a dot',
281
286
  // PLAN-9 (D470): checks/next.ts; dev entry
282
287
  SYG453: 'Two children with the same identity share one instance',
283
288
  SYG601: "Retired in 6.0: Missing or invalid sources",
@@ -486,6 +491,10 @@ export const DEV_CODE_SEVERITY: Record<string, DiagnosticSeverity> = {
486
491
  SYG425: 'warn',
487
492
  // PLAN-9 (checks/next.ts): D471 a lowercase <collection> / <switchable> tag (deprecated in 6.3.0)
488
493
  SYG426: 'warn',
494
+ // checks/next.ts: CHILD.select(undefined), usually a missing export
495
+ SYG427: 'error',
496
+ // checks/next.ts: a path designator that 6.4 read as one key with a dot
497
+ SYG428: 'warn',
489
498
  // PLAN-9 (checks/next.ts): D470 key identity, two children with one identity
490
499
  SYG453: 'warn',
491
500
  SYG612: 'error',
package/src/extra/pwa.ts CHANGED
@@ -18,6 +18,8 @@ export interface ServiceWorkerCommand {
18
18
 
19
19
  export interface ServiceWorkerOptions {
20
20
  scope?: string;
21
+ /** false: register nothing, ignore commands, emit nothing (a dev server: `enabled: import.meta.env.PROD`) */
22
+ enabled?: boolean;
21
23
  }
22
24
 
23
25
  export interface InstallPrompt {
@@ -47,7 +49,7 @@ export function makeServiceWorkerDriver(
47
49
  return function serviceWorkerDriver(sink$: Stream<ServiceWorkerCommand>): ServiceWorkerSource {
48
50
  const events = new EventTarget();
49
51
 
50
- if (typeof navigator !== 'undefined' && 'serviceWorker' in navigator) {
52
+ if (options.enabled !== false && typeof navigator !== 'undefined' && 'serviceWorker' in navigator) {
51
53
  navigator.serviceWorker
52
54
  .register(scriptUrl, {scope: options.scope})
53
55
  .then((reg) => {
@@ -5,6 +5,8 @@
5
5
  * shorthand factories for the most frequent reducer shapes.
6
6
  */
7
7
 
8
+ import {ABORT, isAbort} from '../shared'
9
+
8
10
  // ── set() ──────────────────────────────────────────────────────────
9
11
  /**
10
12
  * Create a reducer that merges a partial update into state.
@@ -40,13 +42,17 @@ export function toggle<S = any>(field: keyof S & string): (state: S) => S {
40
42
  }
41
43
 
42
44
  // event(type, payload?) → EVENTS sink fn for the object form:
43
- // `ACTION: { EVENTS: event('TYPE', (state, data) => payload) }` (static payloads allowed)
45
+ // `ACTION: { EVENTS: event('TYPE', (state, data) => payload) }` (static payloads allowed).
46
+ // A payload function that returns ABORT sends nothing, as ABORT does from any other sink.
44
47
  export function event(
45
48
  type: string,
46
49
  payload?: any | ((state: any, data: any, next: Function, props: any) => any)
47
- ): (state: any, data: any, next: Function, props: any) => { type: string; data: any } {
50
+ ): (state: any, data: any, next: Function, props: any) => { type: string; data: any } | typeof ABORT {
48
51
  return typeof payload === 'function'
49
- ? (state, data, next, props) => ({ type, data: payload(state, data, next, props) })
52
+ ? (state, data, next, props) => {
53
+ const d = payload(state, data, next, props)
54
+ return isAbort(d) ? ABORT : { type, data: d }
55
+ }
50
56
  : () => ({ type, data: payload })
51
57
  }
52
58
 
@@ -66,6 +72,6 @@ export function event(
66
72
  export function emit(
67
73
  type: string,
68
74
  data?: any | ((state: any, actionData: any, next: Function, props: any) => any)
69
- ): { EVENTS: (state: any, actionData: any, next: Function, props: any) => { type: string; data: any } } {
75
+ ): { EVENTS: (state: any, actionData: any, next: Function, props: any) => { type: string; data: any } | typeof ABORT } {
70
76
  return { EVENTS: event(type, data) }
71
77
  }
package/src/index.d.ts CHANGED
@@ -59,7 +59,9 @@ export type ViewProps<STATE = any, PROPS = {}, CONTEXT = {}> =
59
59
 
60
60
  /**
61
61
  * The `state` prop a parent passes to a sub-component in JSX: the name of a field of the
62
- * parent's state (`state="editor"`) or a lens. Without it the child shares the parent's state.
62
+ * parent's state (`state="editor"`), a path to nested state (`state="settings.rules"`,
63
+ * `state="boards[0].title"`; since 6.5.0: a write copies only the levels on the path), or a
64
+ * lens. Without it the child shares the parent's state.
63
65
  */
64
66
  export type StateProp = string | Lense<any, any>
65
67
 
@@ -156,7 +158,7 @@ type EventNextFunction = (action: string, data?: any, delay?: number) => void
156
158
 
157
159
  /** Payload function for `event()` / `emit()`: receives the reducer arguments, returns the event data. */
158
160
  export type EventPayloadFunction<TYPE extends string = string, STATE = any, DATA = any> =
159
- (state: STATE, data: DATA, next: EventNextFunction, props: any) => EventPayload<TYPE>
161
+ (state: STATE, data: DATA, next: EventNextFunction, props: any) => EventPayload<TYPE> | ABORT
160
162
 
161
163
  /**
162
164
  * Remaining arguments of `event()` / `emit()` when the payload is a static value.
@@ -1574,13 +1576,25 @@ export type ArrayKeysOf<STATE> = {
1574
1576
  [KEY in keyof STATE]-?: NonNullable<STATE[KEY]> extends ReadonlyArray<any> ? KEY : never
1575
1577
  }[keyof STATE] & string
1576
1578
 
1579
+ /**
1580
+ * Array-valued keys of STATE and dotted paths to arrays in its nested objects, up to three levels
1581
+ * (`'tasks' | 'board.cards'`; since 6.5.0). Paths through array positions (`'boards.0.cards'`)
1582
+ * aren't listed: for a list item, use `byId`.
1583
+ */
1584
+ export type ArrayPathsOf<STATE, DEPTH extends unknown[] = []> = DEPTH['length'] extends 3 ? never : {
1585
+ [KEY in keyof STATE & string]-?: NonNullable<STATE[KEY]> extends ReadonlyArray<any> ? KEY
1586
+ : NonNullable<STATE[KEY]> extends Record<string, any> ? `${KEY}.${ArrayPathsOf<NonNullable<STATE[KEY]>, [...DEPTH, 1]>}`
1587
+ : never
1588
+ }[keyof STATE & string]
1589
+
1577
1590
  /**
1578
1591
  * Valid `from` values for a Collection: any string or Lense while the parent STATE is unknown
1579
- * (`any`); otherwise an array-valued key of STATE or a Lense over STATE.
1592
+ * (`any`); otherwise an array-valued key of STATE, a dotted path to one (`'board.cards'`), or a
1593
+ * Lense over STATE.
1580
1594
  */
1581
1595
  export type CollectionFrom<STATE = any> = 0 extends (1 & STATE)
1582
1596
  ? string | Lense
1583
- : ArrayKeysOf<STATE> | Lense<STATE, any>
1597
+ : ArrayPathsOf<STATE> | Lense<STATE, any>
1584
1598
 
1585
1599
  /**
1586
1600
  * Collection props. Pass the parent component's state type as STATE to type-check `from`:
@@ -1977,7 +1991,8 @@ export function emit<TYPE extends EventName>(
1977
1991
  * }
1978
1992
  *
1979
1993
  * The payload is either a function `(state, data, next, props) => payload` or a static value:
1980
- * `event('RESET')`, `event('SET_MODE', 'dark')`.
1994
+ * `event('RESET')`, `event('SET_MODE', 'dark')`. A payload function that returns `ABORT` sends
1995
+ * nothing: `event('SAVED', (state) => state.dirty ? { id: state.id } : ABORT)`.
1981
1996
  *
1982
1997
  * With a `SygnalEvents` registry, `type` must be a registered name and the payload must match
1983
1998
  * its type. When used inside a model, the payload function's `state` and `data` parameters are
@@ -3005,6 +3020,12 @@ export interface ServiceWorkerCommand {
3005
3020
 
3006
3021
  export interface ServiceWorkerOptions {
3007
3022
  scope?: string;
3023
+ /**
3024
+ * `false`: the driver registers no service worker, ignores commands and emits nothing, so the
3025
+ * app keeps its `SW` wiring where a worker must not run: `enabled: import.meta.env.PROD`.
3026
+ * Default `true`.
3027
+ */
3028
+ enabled?: boolean;
3008
3029
  }
3009
3030
 
3010
3031
  export function makeServiceWorkerDriver(