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.
- package/CHANGELOG.md +91 -0
- package/dist/astro/index.cjs.js +69 -3
- package/dist/astro/index.cjs.js.map +1 -1
- package/dist/astro/index.mjs +69 -3
- 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/diagnostics.cjs.js +105 -19
- package/dist/diagnostics.cjs.js.map +1 -1
- package/dist/diagnostics.esm.js +105 -19
- package/dist/diagnostics.esm.js.map +1 -1
- package/dist/guide/mcp-apps.md +59 -4
- package/dist/index.cjs.js +54 -25
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +27 -6
- package/dist/index.esm.js +54 -25
- 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/vite/plugin.cjs.js +69 -3
- package/dist/vite/plugin.cjs.js.map +1 -1
- package/dist/vite/plugin.mjs +69 -3
- package/dist/vite/plugin.mjs.map +1 -1
- package/llms.txt +4 -4
- package/package.json +1 -1
- package/src/core/cell.ts +25 -7
- 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/extra/diagnostics/checks/collections.ts +31 -25
- package/src/extra/diagnostics/checks/dom.ts +10 -2
- package/src/extra/diagnostics/checks/next.ts +39 -1
- package/src/extra/diagnostics/checks/persist.ts +4 -2
- package/src/extra/diagnostics/checks/wiring.ts +14 -6
- package/src/extra/diagnostics/codes.ts +9 -0
- package/src/extra/pwa.ts +3 -1
- package/src/extra/reducers.ts +10 -4
- package/src/index.d.ts +26 -5
- 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"
|
|
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` (
|
|
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
|
|
|
@@ -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()
|
|
@@ -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 = {
|
|
@@ -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
|
|
29
|
-
*
|
|
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[]) => {
|
|
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
|
|
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
|
-
|
|
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,
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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) => {
|
package/src/extra/reducers.ts
CHANGED
|
@@ -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) =>
|
|
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"`)
|
|
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
|
|
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
|
-
:
|
|
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(
|