sygnal 6.5.0 → 7.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +156 -0
- package/README.md +2 -0
- package/dist/astro/index.cjs.js +423 -27
- package/dist/astro/index.cjs.js.map +1 -1
- package/dist/astro/index.mjs +423 -27
- package/dist/astro/index.mjs.map +1 -1
- package/dist/astro/server.cjs.js.map +1 -1
- 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 +169 -14
- package/dist/diagnostics.cjs.js.map +1 -1
- package/dist/diagnostics.esm.js +168 -15
- package/dist/diagnostics.esm.js.map +1 -1
- package/dist/guide/accessibility.md +2 -0
- package/dist/guide/ai-chat.md +6 -6
- package/dist/guide/behaviors.md +1 -1
- package/dist/guide/element-commands.md +4 -2
- package/dist/guide/forms-reference.md +89 -3
- package/dist/guide/forms.md +8 -8
- package/dist/guide/http.md +77 -7
- package/dist/guide/migrating-to-7.md +314 -0
- package/dist/guide/recipes/ai-summarize.md +2 -1
- package/dist/guide/recipes/i18n.md +1 -1
- package/dist/guide/ui/tabs.md +5 -4
- package/dist/guide/ui/tooltip.md +24 -0
- package/dist/index.cjs.js +781 -320
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +319 -43
- package/dist/index.esm.js +781 -320
- 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/ui.cjs.js +96 -11
- package/dist/ui.cjs.js.map +1 -1
- package/dist/ui.esm.js +96 -12
- package/dist/ui.esm.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 +410 -27
- package/dist/vite/plugin.cjs.js.map +1 -1
- package/dist/vite/plugin.mjs +410 -27
- package/dist/vite/plugin.mjs.map +1 -1
- package/llms.txt +17 -16
- package/package.json +1 -1
- package/src/astro/index.d.ts +8 -0
- package/src/astro/index.ts +18 -0
- package/src/core/actions.ts +28 -21
- package/src/core/define.ts +2 -0
- package/src/core/hooks.ts +8 -2
- package/src/core/hosts/collection.ts +4 -3
- package/src/core/instance.ts +60 -16
- package/src/core/runtime.ts +5 -1
- 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/ai/chat/behavior.ts +3 -3
- package/src/extra/ai/commandBar.ts +3 -3
- package/src/extra/ai/compose/behavior.ts +3 -3
- package/src/extra/ai/transports/shared.ts +8 -2
- package/src/extra/behaviors.ts +21 -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/fetch.ts +21 -1
- package/src/extra/diagnostics/checks/forms.ts +4 -3
- package/src/extra/diagnostics/checks/index.ts +2 -1
- package/src/extra/diagnostics/checks/inspect.ts +68 -6
- package/src/extra/diagnostics/checks/next.ts +3 -0
- package/src/extra/diagnostics/checks/public.d.ts +133 -12
- package/src/extra/diagnostics/checks/timingAudit.ts +47 -0
- package/src/extra/diagnostics/codes.ts +19 -0
- package/src/extra/fetchDriver.ts +112 -7
- package/src/extra/fetchEventSource.ts +113 -0
- package/src/extra/form.ts +68 -26
- package/src/extra/formHelpers.ts +3 -1
- package/src/extra/lens.ts +3 -2
- package/src/extra/queryCache.ts +4 -3
- package/src/extra/socketDriver.ts +10 -4
- package/src/extra/testing.ts +68 -11
- package/src/index.d.ts +199 -30
- package/src/index.ts +1 -0
- package/src/shared.ts +3 -0
- package/src/ui/dialog.ts +3 -2
- package/src/ui/tooltips.ts +92 -0
- package/src/ui.d.ts +35 -0
- package/src/ui.ts +1 -0
- 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 +255 -0
- package/src/vite/mcp.ts +3 -3
- package/src/vite/plugin.d.ts +19 -0
- package/src/vite/plugin.ts +144 -25
package/llms.txt
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
# Sygnal
|
|
2
2
|
|
|
3
|
-
> Reactive component framework (Cycle.js Model-View-Intent, JSX views, xstream streams). `npm i sygnal`; Vite: `import sygnal from 'sygnal/vite'`, `plugins: [sygnal()]`; entry: `run(App)`. This file shows only the canonical forms. Strict mode (`sygnal-check --strict`, runtime `strict: true`) flags other forms as SYG5xx; forms 6.0 removed (positional views, `'A | SINK'` keys, `CHILD.select('Name')`, `.components`, `.peers`, `component()`) are SYG612: https://sygnal.js.org/guide/migrating-to-6/
|
|
3
|
+
> Reactive component framework (Cycle.js Model-View-Intent, JSX views, xstream streams). `npm i sygnal`; Vite: `import sygnal from 'sygnal/vite'`, `plugins: [sygnal()]`; entry: `run(App)`. This file shows only the canonical forms. Strict mode (`sygnal-check --strict`, runtime `strict: true`) flags other forms as SYG5xx; forms 6.0 removed (positional views, `'A | SINK'` keys, `CHILD.select('Name')`, `.components`, `.peers`, `component()`) are SYG612: https://sygnal.js.org/guide/migrating-to-6/. 7.0 changes reducer timing (outputs see the state after STATE) and child lifetimes: https://sygnal.js.org/guide/migrating-to-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
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
|
+
- A child belongs to the record its `state` points at (Sygnal 7): when a path or `byId()` target changes, or its record is deleted, the old child is disposed (its DISPOSE gets its last state; its requests, timers and subscriptions are cancelled) and a new one mounts with fresh local state and new DOM (focus and typing inside it are lost). So `<Workspace state={byId('projects', selected.id)} />` needs no key. Same for Collection/VirtualCollection `from` and Switchable `state`. A stable `key` doesn't keep a child across a target change (keep that state in the parent); a custom lens needs `key={record.id}`. Sygnal 6 fixes bindings at mount: use `key={selected.id}` there (SYG454).
|
|
8
9
|
- Every side effect is a sink of a model entry (STATE, EVENTS, PARENT, EFFECT, custom drivers). JSX never has event handlers (`onClick`).
|
|
9
10
|
- Streams are xstream, not RxJS. An intent's DOM selectors only see the component's own JSX, never a child component's.
|
|
10
11
|
|
|
@@ -36,7 +37,7 @@ export default Counter
|
|
|
36
37
|
```
|
|
37
38
|
- Reducer `(state, data, next, props)`. `data` is the action stream's value. `next('ACTION', data?, delayMs = 10)` dispatches another action of this component `delayMs` after the call, whenever it is called: also later, from a timer or callback in an EFFECT (HTTP goes through `makeFetchDriver`, not `fetch` + `next()`). `props` holds the parent's props plus `state`, `context`, `children`, `slots`.
|
|
38
39
|
- Model entry: a function is the STATE reducer. An object `{ STATE, EVENTS, PARENT, EFFECT, LOG, <DRIVER>: fn }` maps each sink to a `(state, data, next, props)` function whose return value goes to that sink. `<SINK>: true` forwards `data` as-is.
|
|
39
|
-
- **
|
|
40
|
+
- **STATE runs first in every entry (Sygnal 7)**; every other sink gets the committed state, calculated fields included: `INC: { STATE: s => ({ ...s, n: s.n + 1 }), PARENT: s => ({ n: s.n }) }` (`s.n + 1` would count twice). The state from before the action is `props.previousState` (4th argument): read it for a value STATE cleared or deleted, and in a guard that admits the action: `HTTP: (s, d, next, { previousState }) => (previousState.status === 'working' ? ABORT : { url: '/api/work', ok: 'DONE' })` (`s.status` is already `'working'` there; keep the guard, since STATE's `ABORT` doesn't cancel other sinks; SYG256). The tradeoff: payloads read `state`, guards read `previousState`. When STATE removes the component's own state (`() => undefined`), its other sinks get its last state. Behaviors: the slice and `props.state` are after STATE, the old slice is `props.previousState[key]`. Code for 6 and 7 reads old values as `props.previousState ?? state`. Upgrading 6.x code (every reducer saw the old state): turn on the timing audit, `renderComponent(C, { timingAudit: true })` or `sygnal({ diagnostics: { timingAudit: true } })`, and decide each SYG257. Guide: `node_modules/sygnal/dist/guide/migrating-to-7.md`
|
|
40
41
|
- Built-in actions (model only): `BOOTSTRAP` (once, just after mount), `INITIALIZE` (automatic: sets initialState), `DISPOSE` (unmount). `HYDRATE` is not built in (6.0): SSR data comes from Vike `+data` / `hydrateState`. Declaration statics (`connections`, `resources`, `route`, `head`) are computed from state, with or without a model; a root that declares one needs `initialState` (SYG132). Also reserved as statics: `timers`, `uses`, `persist`, `viewTransitions` (§3).
|
|
41
42
|
|
|
42
43
|
## 3. Canonical examples
|
|
@@ -123,19 +124,19 @@ TaskList.model = { PICKED: (state, { taskId }) => ({ ...state, picked: taskId })
|
|
|
123
124
|
- 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).
|
|
124
125
|
- The host names behavior actions with the key: an intent action `'more.TOGGLE': DOM.keydown(…)` replaces the behavior's trigger; a model entry `'more.TOGGLE': (state) => …` runs after the behavior's, on the full state; tests use `t.simulateAction('more.TOGGLE')`. Guide: `node_modules/sygnal/dist/guide/behaviors.md`
|
|
125
126
|
### Forms with validation (the `form` behavior)
|
|
126
|
-
- `Signup.uses = { form: form(schema, { values: { email: '', addresses: [{ id: 1, city: '' }] },
|
|
127
|
+
- `Signup.uses = { form: form(schema, { values: { email: '', addresses: [{ id: 1, city: '' }] }, request: { url: '/api/signup' } }) }` (`form` from `'sygnal'`), any Standard Schema (zod, valibot, or `{ '~standard': { version: 1, vendor, validate } }`). Fields inside the `<form>` are matched by `name`, a path in `values` (array rows by id: `addresses.7.city`): no intent per field (SYG230: a name not in `values`). TypeScript: `type State = { … } & UsesState<typeof uses>`, `type Actions = UsesActions<typeof uses>` (a `submit` action's data: `FormOutput<typeof uses.form>`) (behaviors' slices and namespaced actions); a typed schema (zod, valibot, arktype) types `state.form.values` and `fields.email.value` and checks `values`. File fields: `<input type="file" name="avatar" key={'avatar' + state.form.resets} />` (never bind `value`) with `avatar: null` in values gives the `File` (`multiple`: File[]), validated by the schema (`z.file()`); `request: { url }` sends it as multipart, `state.form.progress?.fraction`.
|
|
127
128
|
- View: `const f = state.form.fields`; `<input id={uid('email')} name="email" value={f.email.value} aria-invalid={f.email.invalid} aria-describedby={uid('email-error')} />`, `<p id={uid('email-error')}>{f.email.error}</p>`, a `<label for={uid('email')}>`. `f.x.error` is what to show (after blur or a submit); also `state.form.submitting`, `state.form.valid`, `state.form.error` (form-level).
|
|
128
|
-
- An invalid submit shows every error and focuses the first invalid field; a valid one
|
|
129
|
+
- An invalid submit shows every error and focuses the first invalid field; a valid one sends `request` with the schema's output (the form adds `json: data`, or `multipart` when a value is a File, `progress: 'form.PROGRESS'`, `ok: 'form.DONE'`, `error: 'form.ERRORS'`; `request: (data, state) => ({ url: '/api/users/' + state.id, method: 'PUT' })` for state); `state.form.submitting` is `true` until the reply (a second submit meanwhile is dropped); a submit that sends nothing names a host action instead, `submit: 'NEXT'` with `NEXT: { PARENT: … }` (a wizard step), done at once, no `form.DONE` needed; `form.ERRORS` puts a 422's `{ errors: { field: msg } }` or `{ errors: [{ path: ['items', 0, 'qty'], message }] }` (an index is the row's position in what was sent) on the fields and focuses the first; a reply with no field message shows in `state.form.error` (`Request failed (500)`); a host entry `'form.DONE': (state, body) => …` runs after the form's. Two forms in one component: `form: '.login'` on each (SYG237).
|
|
129
130
|
- **The `form` slice lives in the host's state**, and a Switchable page (no `state=`) shares its parent's, so the values survive leaving the page and coming back, even when `instance=` re-creates it. When the form must start empty on each visit, say so: `form(schema, { values: EMPTY, submit: 'SAVE', resetOnShow: true })` starts over (start values; touched, errors and the submit cleared) each time the form is shown: the page shown again, the component mounted again. Off by default (a wizard step keeps its values). A start value that comes from state (a default category from the settings) goes in a `values` function of the host's state, called at each start: `values: (state) => ({ ...EMPTY, category: state.settings.defaultCategory })`. When removing a feature that fed a start value, keep `resetOnShow`.
|
|
130
131
|
- Rows, inline (no child component): `state.form.values.addresses.map((row, i) => …)` with `const c = f['addresses.' + row.id + '.city']`, `name={c.name} value={c.value}`, ids `uid('city-' + row.id)`, `<button type="button" className="remove" data-id={row.id}>`; `'form.ADD': DOM.click('.add').mapTo({ field: 'addresses', value: { city: '' } })`, `'form.REMOVE': DOM.click('.remove').map((e) => ({ field: 'addresses', id: e.target.dataset.id }))`. Rows as components: `<Collection of={Row} from={{ get: (s) => s.form.values.addresses }} fields={f} />`, Row `REMOVE: { PARENT: (s) => ({ field: 'addresses', id: s.id }) }` + host `'form.REMOVE': CHILD.select(Row)`.
|
|
131
132
|
- Async check: `check: { email: { request: (email) => ({ url: '/api/email-free', query: { email } }), error: (body) => !body.free && 'Taken' } }` (on blur; a submit waits for it; `f.email.pending`). Tests: `t.simulateEvent('[name="email"]', 'input', { value })`, `'focusout'`, `'submit'` on the form; `t.state.form.fields.email.error`. Guide (recipe first): `node_modules/sygnal/dist/guide/forms.md`; details `forms-reference.md`
|
|
132
133
|
### Commands (parent → child), element commands, timers, persistence
|
|
133
134
|
- `const player = createCommand()` (from `'sygnal'`); the parent passes it as a prop, `<Player commands={player} state="player" />`, and calls it from an EFFECT (side effect only, returns nothing): `PLAY: { EFFECT: () => player.send('play', data) }`. The child's intent listens with `PLAY: commands$.select('play')`, which emits `send()`'s data.
|
|
134
|
-
- **ELEMENT** (built in, no driver) calls a method on an element of the sending instance's own view, after the render the action causes: `SUBMIT: { STATE: (s) => ({ ...s, errors: validate(s) }), ELEMENT: (s) => (validate(s).email ? { focus: '.email' } : ABORT) }`. The first key is the method (`focus blur select click scrollIntoView showModal show close showPopover hidePopover togglePopover`) and its value the target (a selector); other keys are options (`{ scrollIntoView: '.row', block: 'nearest' }`, `{ close: '.help', returnValue: 'done' }`); an array sends several; `{ focus: focusWithin('.title') }` (from `'sygnal'`) reaches into children and Collection items. `DOM.close('.help')` hears a dialog close. SYG640: no target; SYG641: unknown method. Tests: `t.commands('ELEMENT')`; `dom: 'real'` also runs them. Guide: `node_modules/sygnal/dist/guide/element-commands.md`
|
|
135
|
+
- **ELEMENT** (built in, no driver) calls a method on an element of the sending instance's own view, after the render the action causes: `SUBMIT: { STATE: (s) => ({ ...s, errors: validate(s) }), ELEMENT: (s) => (validate(s).email ? { focus: '.email' } : ABORT) }`. The first key is the method (`focus blur select click scrollIntoView showModal show close showPopover hidePopover togglePopover`) and its value the target (a selector); other keys are options (`{ scrollIntoView: '.row', block: 'nearest' }`, `{ close: '.help', returnValue: 'done' }`); an array sends several; `{ focus: focusWithin('.title') }` (from `'sygnal'`) reaches into children and Collection items. `DOM.close('.help')` hears a dialog close. SYG640: no target; SYG641: unknown method. Tests: `t.commands('ELEMENT')`; `dom: 'real'` also runs them. TypeScript: a command built outside the return (`const top = { scrollIntoView: '.page', block: 'start' }`) widens `'start'` to `string`: annotate it `const top: ElementCommand = …` (or `satisfies ElementCommand`). Guide: `node_modules/sygnal/dist/guide/element-commands.md`
|
|
135
136
|
- **Timers**, from state like `connections` (falsy stops, a changed spec restarts): `Clock.timers = (state) => ({ tick: state.running && { every: 1000, action: 'TICK' } })` (also `{ after: ms, action }`, `{ frame: 'FRAME' }`); `TICK` gets `{ n, t }`. Needs `run(App, { TIMER: makeTimerDriver() })` (SYG643 without it); no `setInterval`. Tests need no driver: fake timers, `await vi.advanceTimersByTimeAsync(ms)`, `t.timers()`. Guide: `node_modules/sygnal/dist/guide/timers.md`
|
|
136
137
|
- **Persistence** (root only, SYG224): `App.persist = persist({ key: 'todo-app', pick: ['todos', 'filter'] })` (also `version`, `migrate: (old, fromVersion) => keys`, `omit`, `storage: 'local' | 'session' | { getItem, setItem, removeItem }`, `sync: true`, `debounceMs`) restores those keys into `initialState` before the first render and saves them after each change (100 ms debounce) as JSON `{ version, state }` (`format: 'plain'`: the picked keys alone, no `version`/`migrate`); `PERSIST: { clear: true }` in an entry removes the copy. Tests: `renderComponent(App, { storage: { 'todo-app': { version: 1, state: { todos: [] } } } })`, `await t.settle()`, `t.storage('todo-app')`. Guide: `node_modules/sygnal/dist/guide/persistence.md`
|
|
137
138
|
### Third-party widgets (`defineWidget`) and web components
|
|
138
|
-
- `defineWidget({ tag, mount(el, props, dispatch), update(instance, props), unmount(instance), events, commands })` (from `'sygnal'`) makes a JSX tag: `const DatePicker = defineWidget({ tag: 'input', mount: (el, props, dispatch) => flatpickr(el, { defaultDate: props.value, onChange: ([d]) => dispatch('pick', d) }), update: (fp, props) => fp.setDate(props.value, false), unmount: (fp) => fp.destroy(), events: ['pick'], commands: { open: (fp) => fp.open() } })`. Also `fallback` (SSR content), `hostProps` / `ownProps` (props on / kept off the host), `mount`'s 4th argument `error(e)` (a failure the library reports later).
|
|
139
|
+
- `defineWidget({ tag, mount(el, props, dispatch), update(instance, props), unmount(instance), events, commands })` (from `'sygnal'`) makes a JSX tag: `const DatePicker = defineWidget({ tag: 'input', mount: (el, props, dispatch) => flatpickr(el, { defaultDate: props.value, onChange: ([d]) => dispatch('pick', d) }), update: (fp, props) => fp.setDate(props.value, false), unmount: (fp) => fp.destroy(), events: ['pick'], commands: { open: (fp) => fp.open() } })`. Also `fallback` (SSR content), `hostProps` / `ownProps` (props on / kept off the host), `mount`'s 4th argument `error(e)` (a failure the library reports later). `update` runs only when a prop is a different value (shallow `===`): give a chart a new array (`points: [...s.points, p]`), never the same one changed in place, or it won't redraw.
|
|
139
140
|
- View: `<label>Due <DatePicker className="due" value={state.due} /></label>` (the host element; the widget owns its content and keeps its instance across renders). Intent: `DUE: DOM.select('.due').events('pick').detail()` (`.detail(fn?)` = e.detail). Model: `OPEN: { ELEMENT: { open: '.due' } }` runs the widget's command (it wins over a native method of the same name).
|
|
140
141
|
- List every dispatched name in `events` and name them yourself (`'pick'`, not `'change'`: SYG140/144); `update` gets the newest props when they change (without it, a change remounts); a throwing `mount`/`update` renders the component's `onError` fallback in its place (`onError` phase `'widget'`). Never `DOM.select(DatePicker)`: select its class (SYG143).
|
|
141
142
|
- Tests: `t.widget('.due').dispatch('pick', date)`, `t.widget('.due').props.value`, `t.commands('ELEMENT')`; `dom: 'real'` mounts it (`.instance`). Guide: `node_modules/sygnal/dist/guide/widgets.md`
|
|
@@ -164,8 +165,8 @@ Search.model = {
|
|
|
164
165
|
FAILED: (state, { status, error }) => ({ ...state, status: status === 404 ? 'Not found.' : 'Search failed.', results: [] }),
|
|
165
166
|
}
|
|
166
167
|
```
|
|
167
|
-
- main.js: `run(Search, { HTTP: makeFetchDriver() })` (import both from `'sygnal'`; options `baseUrl headers init timeoutMs`). Request: `{ url, ok, error, key, query, json, body, method (POST with json/body, else GET), headers, latest, timeoutMs, parse: 'auto' | 'json' | 'text' | 'response', init: { credentials, mode, cache, ... } }`; other keys (an id) aren't sent and come back on `request`. The `ok` action gets the parsed body; the `error` action gets `{ error, status, body, request }` (non-2xx: `status` + parsed `body`; network error or timeout: `status` undefined, a timeout has `error.name === 'TimeoutError'`). The reply reaches exactly the instance that sent it (two `<Search>`es, every Collection item), so no request ids. Never `HTTP.select`/`HTTP.errors` for your own request (SYG508), never `then`/`catch` keys (SYG610: not sent); an `ok`/`error` name with no model entry is SYG112. Don't call `fetch` in a component, intent or EFFECT. Guide: `node_modules/sygnal/dist/guide/http.md` (online: https://sygnal.js.org/guide/http/)
|
|
168
|
-
- `latest: true`: a newer request in the same lane (`key`: a constant name, never an id or URL; default the `ok` action) from the same instance aborts the older ones once it is sent: their replies never arrive after that, but one that lands before it (while the newer input still waits out a debounce) does. `{ abort: 'RESULTS' }` (it names the lane: with a custom `key`, abort that key; or `{ abort: true, key }`) cancels without sending; a removed instance's requests are aborted. **
|
|
168
|
+
- main.js: `run(Search, { HTTP: makeFetchDriver() })` (import both from `'sygnal'`; options `baseUrl headers init timeoutMs`). Request: `{ url, ok, error, key, query, json, multipart, body, method (POST with json/multipart/body, else GET), headers, latest, timeoutMs, parse: 'auto' | 'json' | 'text' | 'response', init: { credentials, mode, cache, ... } }`; other keys (an id) aren't sent and come back on `request`. The `ok` action gets the parsed body; the `error` action gets `{ error, status, body, request }` (non-2xx: `status` + parsed `body`; network error or timeout: `status` undefined, a timeout has `error.name === 'TimeoutError'`). The reply reaches exactly the instance that sent it (two `<Search>`es, every Collection item), so no request ids. Never `HTTP.select`/`HTTP.errors` for your own request (SYG508), never `then`/`catch` keys (SYG610: not sent); an `ok`/`error` name with no model entry is SYG112. Don't call `fetch` in a component, intent or EFFECT. Upload/download progress: `progress: 'UPLOADING'` on the request (`multipart: { file: state.file, title }` builds the multipart/form-data body, never `new FormData()` in a model; the file from `DOM.select('.file').events('change').map(e => e.target.files[0])`) sends `{ phase: 'upload' | 'download', loaded, total, fraction }` steps as that action (`true`: `HTTP.progress(category)`); it goes over XMLHttpRequest; tests: `await t.progress('HTTP', { loaded: 50, total: 100 }, 'UPLOADING')`. Guide: `node_modules/sygnal/dist/guide/http.md` (online: https://sygnal.js.org/guide/http/)
|
|
169
|
+
- `latest: true`: a newer request in the same lane (`key`: a constant name, never an id or URL; default the `ok` action) from the same instance aborts the older ones once it is sent: their replies never arrive after that, but one that lands before it (while the newer input still waits out a debounce) does. `{ abort: 'RESULTS' }` (it names the lane: with a custom `key`, abort that key; or `{ abort: true, key }`) cancels without sending; a removed instance's requests are aborted. **The request sees what STATE stored**: `SHOW: { STATE: (s, id) => ({ ...s, id }), HTTP: (s) => ({ url: '/api/q/' + s.id, ok: 'LOADED' }) }` (or build it from `id`).
|
|
169
170
|
- Other promise APIs: `driverFromAsync(fn)` takes the same reply actions: `{ value, ok: 'DONE', error: 'FAILED' }` calls `fn(value)`; `DONE` gets the result, `FAILED` `{ error, request }` (no `latest`/abort). One-off async work: `EFFECT: async (state, data, next, { signal }) => { ...; next('DONE', result) }` (rejection: SYG214; `signal` aborts on unmount; no concurrency control). A driver is any function `sink$ => source`. Page-wide events: `DOM.select('document' | 'body').events(type)`, CSS-filtered with `DOM.select('document').select('.overlay').events('click')`.
|
|
170
171
|
### WebSocket / SSE (makeSocketDriver + connections)
|
|
171
172
|
```jsx
|
|
@@ -183,7 +184,7 @@ Chat.model = {
|
|
|
183
184
|
DRAFT: (state, draft) => ({ ...state, draft }),
|
|
184
185
|
SEND: {
|
|
185
186
|
STATE: (state) => (state.draft.trim() ? { ...state, draft: '' } : ABORT),
|
|
186
|
-
WS: (state) => (
|
|
187
|
+
WS: (state, data, next, { previousState }) => (previousState.draft.trim() ? { to: 'room', json: { text: previousState.draft } } : ABORT), // queued while (re)connecting
|
|
187
188
|
},
|
|
188
189
|
LEAVE: (state) => ({ ...state, room: null, status: 'offline', messages: [] }), // closes it: no DROPPED
|
|
189
190
|
RECEIVED: (state, msg) => ({ ...state, messages: [...state.messages, msg] }), // the JSON-parsed frame
|
|
@@ -191,7 +192,7 @@ Chat.model = {
|
|
|
191
192
|
DROPPED: (state, { willReconnect }) => ({ ...state, status: willReconnect ? 'reconnecting' : 'offline' }), // { code, reason, willReconnect }
|
|
192
193
|
}
|
|
193
194
|
```
|
|
194
|
-
- main.js: `run(Chat, { WS: makeSocketDriver() })` (options `baseUrl reconnect queueLimit`). Without it the static opens nothing and nothing reports it. Spec keys: `socket` (a path gets ws:/wss: from the page) or `sse` (read-only; `events: { 'price-update': 'PRICE' }` for named events), `message open close error` (all optional reply actions, to this instance only), `reconnect` (`false` = never), `share` (default true: one socket per URL, ref-counted), `protocols`, `withCredentials
|
|
195
|
+
- main.js: `run(Chat, { WS: makeSocketDriver() })` (options `baseUrl reconnect queueLimit`). Without it the static opens nothing and nothing reports it. Spec keys: `socket` (a path gets ws:/wss: from the page) or `sse` (read-only; `events: { 'price-update': 'PRICE' }` for named events), `message open close error` (all optional reply actions, to this instance only), `reconnect` (`false` = never), `share` (default true: one socket per URL, ref-counted), `protocols`, `withCredentials`, SSE `headers: { authorization: `Bearer ${state.token}` }` (EventSource can't send headers, so the driver reads such a stream with fetch; keep the token in state: a changed header reconnects). `close` fires only for drops the app didn't cause; leaving, a URL change, unmount and dispose close silently, so no connection-generation ids. A room switch must `ABORT` when the room is unchanged (an unchanged connection fires no new `open`). `error` gets `{ error }`. `{ to, json | text | binary }` on a name this instance didn't declare, a closed-for-good or an SSE connection is SYG611. Use the static OR a model-sent `{ connections }`, never both (each is the whole set). Guide: https://sygnal.js.org/guide/sockets/
|
|
195
196
|
### Reads that follow state: `resources`
|
|
196
197
|
```jsx
|
|
197
198
|
function Quote({ state }) {
|
|
@@ -241,7 +242,7 @@ App.model = {
|
|
|
241
242
|
TaskPage.model = { // a page shares the parent's state: no initialState (SYG405)
|
|
242
243
|
EDIT: { STATE: (state, draft) => ({ ...state, draft }), ROUTER: () => ({ block: 'CONFIRM_LEAVE' }) }, // unsaved changes
|
|
243
244
|
CONFIRM_LEAVE: (state, { proceed }) => ({ ...state, leaving: proceed }), // a blocked attempt: { to, route, proceed }
|
|
244
|
-
LEAVE: { STATE: (state) => ({ ...state, leaving: null }), ROUTER: (state) => ({ ...
|
|
245
|
+
LEAVE: { STATE: (state) => ({ ...state, leaving: null }), ROUTER: (state, data, next, { previousState }) => ({ ...previousState.leaving, block: false }) },
|
|
245
246
|
DONE: { ROUTER: () => ({ to: 'home', block: false }) },
|
|
246
247
|
}
|
|
247
248
|
```
|
|
@@ -249,7 +250,7 @@ TaskPage.model = { // a page shares the parent's state: no initialState (SYG40
|
|
|
249
250
|
- ROUTER commands: `{ to, params, query, hash, replace, scroll }`, `{ url }`, `{ back: true }`, `{ forward: true }`, `{ go: n }`, `{ block: 'ACTION' | false }` (also stops back/forward; `proceed` navigates anyway), `{ prefetch: 'task', params }` (calls `makeRouter({ prefetch })`). `route` is reserved for the declaration. A bad command is SYG620. The first declarer (the root) gets each route first, so keep guards there.
|
|
250
251
|
- Options: `makeRouter({ routes, base: '/app', mode: 'hash', scroll, focus })`. SSR: `router.current(req.url)` in the seeded state; `renderToString(App, { head: list })` + `renderHead(list)`. Vike: `makeRouter({ routes, navigate })` with `navigate` from `'vike/client/router'`, registered in `pages/+drivers.js`. HEAD: a later-mounted component's title wins; `meta`/`link` merge; a model entry can also send `HEAD: (state, data) => ({ title })`. Guides: https://sygnal.js.org/guide/router/, https://sygnal.js.org/guide/head/
|
|
251
252
|
### AI (`sygnal/ai`): chat, agents, decisions, composed UI
|
|
252
|
-
- **A chat conversation** (send, streaming draft, Stop keeping the partial reply, Retry) is the `chat` behavior with no tools; don't hand-roll it: `Chat.uses = { chat: chat({ agent: false, form: '.ask', prompt: '.prompt', stop: '.stop', regenerate: '.retry' }) }` → `state.chat` (`messages`, `prompt` (bind `<input className="prompt" value={state.chat.prompt} />`), `draft`, `status`: `'ready' | 'submitted' | 'streaming' | 'error'`, `error`), the markup in the host's own view (SYG104), `next('chat.SEND', text)` to send from code, a host entry `'chat.DONE'` after a reply; options and slice: agent.md "Options and state". **One-off requests** (a summary, structured `output`, or your own state shape) are a driver request, like HTTP: `SEND: { STATE: (s) => ({ ...s, messages: [...s.messages, { role: 'user', content: s.prompt }], prompt: '' }), LLM: (s) => ({ messages:
|
|
253
|
+
- **A chat conversation** (send, streaming draft, Stop keeping the partial reply, Retry) is the `chat` behavior with no tools; don't hand-roll it: `Chat.uses = { chat: chat({ agent: false, form: '.ask', prompt: '.prompt', stop: '.stop', regenerate: '.retry' }) }` → `state.chat` (`messages`, `prompt` (bind `<input className="prompt" value={state.chat.prompt} />`), `draft`, `status`: `'ready' | 'submitted' | 'streaming' | 'error'`, `error`), the markup in the host's own view (SYG104), `next('chat.SEND', text)` to send from code, a host entry `'chat.DONE'` after a reply; options and slice: agent.md "Options and state". **One-off requests** (a summary, structured `output`, or your own state shape) are a driver request, like HTTP: `SEND: { STATE: (s) => ({ ...s, messages: [...s.messages, { role: 'user', content: s.prompt }], prompt: '' }), LLM: (s) => ({ messages: s.messages, key: 'reply', delta: 'DELTA', ok: 'DONE', error: 'FAILED' }) }` (STATE runs first: the LLM sink sends the list it stored), `DELTA: (s, { text }) => ({ ...s, draft: text })` (all the text so far), `DONE: (s, { message }) => ({ ...s, messages: [...s.messages, message], draft: '' })` (also `text`, `toolCalls`, and `value` for `output: schema`), `FAILED` gets `{ error }`; `{ abort: 'reply' }` stops it (nothing more arrives; `latest` is on: a new request with the same key aborts the old); render with `messageText(m)`. main.js, a local Ollama: `run(App, { LLM: makeChatDriver({ transport: openResponses({ baseURL: 'http://localhost:11434/v1', model: 'llama3.2' }) }) })`; production: `transport: uiMessageStream('/api/chat')` (an AI SDK route on your server; the JSON it POSTs: ai-chat.md, "What uiMessageStream sends"). Tests need no driver: `await t.stream('LLM', ['Hel', 'lo'])` (then `ok`), `t.respond('LLM', text)`, `t.fail('LLM', 429)`, `t.requests('LLM')`.
|
|
253
254
|
- **`agent` static**: what an LLM may do in the app: `TodoApp.agent = { name: 'todos', description: 'The todo list', read: (state) => ({ todos: state.todos.map(({ id, text, done }) => ({ id, text, done })) }), actions: { ADD: { description: 'Add a todo', input: z.string().min(1) }, CLEAR_DONE: { description: 'Delete the done todos', consequential: true } } }` → tools `todos_add`, `todos_clear_done`, `todos_read`. **Only declared actions are tools, and `read` is all agents see** (a projection: keep ids, leave out secrets; `untrusted: true` when it holds text users typed). Per action: `input` (a Standard Schema with JSON Schema: zod 4.2+, or `jsonSchema({ type: 'string' })`), `consequential` (the user confirms), `when: (state) => bool`, `idempotent`, `until: 'search'` (the call answers once that resource has loaded, with the `read`, not `loading`; or `until: (state) => bool` for a reply action; `timeout` → `{ ok: true, pending: true }`); an entry counts as a trigger (SYG102); a Collection item's `agent` (+ `label: (s) => s.text`) gives one tool per action with an `id` parameter. Data too large for `read` (a long list, one record): `queries: { find: { description, input, run: (state, input) => state.todos.filter(…) } }` → read-only tool `todos_find`, `{ ok: true, result }`, nothing dispatched; `run` is pure and synchronous (async is SYG246: load data with an action + `until`). A reducer refuses with `abort('reason')` (from `'sygnal'`). Tests: `t.tools()`, `await t.callTool('todos_add', { value: 'milk' })` → `{ ok: true, state }` (a consequential tool needs `{ confirm: true }`), `t.agentContext()`.
|
|
254
255
|
- **Decisions** (typed answers from a decision model; a fetch-driver request, `HTTP: makeFetchDriver()`): `const questions = { topic: choice('What is this ticket about?', { billing: 'Payments, invoices', bug: 'Something is broken' }), urgent: noul('Does it need handling today?'), mood: score('How upset is the customer?', ['calm', 'annoyed', 'angry']) }`, as a resource: `Ticket.resources = { triage: (state) => state.text && decide({ model: 'jev-latest', state: state.text, questions }) }` → `state.triage.data.answers.topic.choice` (`.confidence`: below ~0.6, ask a chat model), `answers.urgent.noul` (0–1), `answers.mood.score`; a click-triggered one is an `HTTP` sink `decide({ …, ok, error })`. Tests: `await t.respond('HTTP', answers(questions, { topic: 'bug', urgent: true }), 'triage')`.
|
|
255
256
|
- **A page a model composes from your parts** (a dashboard the user describes in words; the model only picks parts, params and places, never markup): `const catalog = defineCatalog({ parts: { revenue: { component: Kpi, props: { metric: 'revenue' }, description: 'Revenue (KPI card: the total this month)' }, orders: { component: OrdersTable, description: 'Orders (table of recent orders)', params: { status: { default: 'any', options: { any: 'orders of every status', pending: 'pending orders (not paid yet)' } } } }, tasks: { component: Checklist, state: 'tasks', description: "Tasks (the user's checklist)" } } })` (`description` is all a model sees: "Noun (what it shows)", no overlap between parts; params arrive as props; `state` is a key, a lens, or for a record param (`{ from: (state) => records }`) `(node) => byId('customers', node.params.id)` with `byId` from `'sygnal'`; `containers: { tabs: { slots, render: ({ slots, control }) => … } }` are view functions, never components (SYG161)), `Dashboard.uses = { page: compose(catalog, { backend: compose.decisions({ model: 'jev-latest' }), form: '.ask', prompt: '.prompt' }) }` (or `compose.llm()` on the `LLM` sink: same app, same tests) → `state.page` (`status`: `'composing' | 'preview' | 'done' | 'unavailable' | 'error'`, `layout`, `decisions`, `error`), rendered in the host's view as `{catalog.render(state.page, state)}` or `<Composed catalog={catalog} from="page" />`: the parts are the host's children (lenses, `CHILD.select`) keyed by identity, so a follow-up `next('page.COMPOSE', 'Move the orders to the top')` edits the page and keeps the untouched parts' instances (`{ prompt, fresh: true }` starts over; `persist` the slice to keep the page); for an agent: `actions: { ...compose.action('page') }`, `read: (s) => ({ page: compose.summary(s.page) })`. Tests (either backend): `t.simulateAction('page.COMPOSE', 'Pending orders')`, then `await composeWith(t, catalog, [{ part: 'orders', params: { status: 'pending' } }])`.
|
|
@@ -272,7 +273,7 @@ TaskPage.model = { // a page shares the parent's state: no initialState (SYG40
|
|
|
272
273
|
- Browser sources: `Card.browser = (state) => ({ seen: !state.seen && { intersection: '.cover', action: 'SEEN' }, dark: { media: '(prefers-color-scheme: dark)', action: 'DARK' } })` + `run(App, { BROWSER: makeBrowserDriver() })` (also `resize`, `storage`, `visibility`, `online`, `geolocation`; clipboard: `{ BROWSER: (s) => ({ copy: s.url, ok: 'COPIED', error: 'COPY_FAILED' }) }`; tests need no driver: `t.browser.intersect('.cover', true)`, `t.browser.media(query, true)`); `lazy(() => import('./Chart'), { when: 'visible' })` (or `'idle'`) defers the import. Guide: `node_modules/sygnal/dist/guide/browser-sources.md`
|
|
273
274
|
- Long lists (from ~1,000 rows): `<VirtualCollection of={Row} from="rows" className="rows" estimateSize={32} aria-label="Rows" />` (Collection's props; it is itself the scroll container, a `div` with `role="list"`: give `.rows` a bounded height in CSS and put `className`, `role`, `aria-label` on it, never wrap it in your own scrolling `<ul>`; SYG430 means fix that height; only rows in view have components, so keep row state in the array); jump with `ELEMENT: (s, index) => ({ scrollToIndex: '.rows', index })` or `{ scrollToId: '.rows', id }`. Guide: `node_modules/sygnal/dist/guide/virtual-collections.md`
|
|
274
275
|
- Reorderable list: `List.uses = { sort: sortable({ from: 'tasks', item: '.task', handle: '.grip' }) }` (mouse, touch, keyboard); items `<li className="task" data-id={state.id}><button className="grip" aria-label=… aria-describedby={context.sort.helpId}>` (context `sort: (s) => s.sort`; style from `context.sort.dragging` / `over` / `after`), host `<p id={state.sort.helpId} hidden>` instructions and `<p role="status" aria-live="assertive">{state.sort.message}</p>` (English defaults: "Dropped X at position 2 of 5.", "Reorder cancelled. X is back at position…"; a spec's exact wording: `messages: { lift, move, drop, cancel, stay }`, each `(label, position, count) => text`), save in `'sort.DROPPED': (state, { id, index, fromIndex }) => …` (the list is already reordered); two lists `from: ['todo', 'done']` + `data-list="todo"` on each container. HTML5 / file drops: `makeDragDriver`. Guide: `node_modules/sygnal/dist/guide/drag-and-drop.md`
|
|
275
|
-
- UI parts (`import { dialog, popover, tooltip, tabs, tabsAttrs, accordion, accordionAttrs, disclosure, disclosureAttrs, Toaster } from 'sygnal/ui'`; native HTML, unstyled): behaviors over your own markup in `uses` (`Profile.uses = { profile: dialog({ dialog: '.profile', trigger: '.edit', close: '.cancel' }) }` → `state.profile.open`, `'profile.CLOSED'`; for tabs/accordion/disclosure spread `tabsAttrs(state.tabs, uid).tab('general')` etc. for roles, ids and ARIA), and `<Toaster />` once + `EVENTS: event('TOAST', { text, kind, timeoutMs })` from anywhere (needs `makeTimerDriver()`). Guides: `node_modules/sygnal/dist/guide/ui/overview.md` (and `ui/dialog.md`, `popover`, `tooltip`, `tabs`, `accordion`, `disclosure`, `toaster`)
|
|
276
|
+
- UI parts (`import { dialog, popover, tooltip, tooltips, tabs, tabsAttrs, accordion, accordionAttrs, disclosure, disclosureAttrs, Toaster } from 'sygnal/ui'`; native HTML, unstyled): behaviors over your own markup in `uses` (`Profile.uses = { profile: dialog({ dialog: '.profile', trigger: '.edit', close: '.cancel' }) }` → `state.profile.open`, `'profile.CLOSED'`; for tabs/accordion/disclosure spread `tabsAttrs(state.tabs, uid).tab('general')` etc. for roles, ids and ARIA), `tooltips({ trigger: '.term', tip: '.term-tip' })` serves many triggers with one slice (each trigger's `aria-describedby` names its tip), and `<Toaster />` once + `EVENTS: event('TOAST', { text, kind, timeoutMs })` from anywhere (needs `makeTimerDriver()`). Guides: `node_modules/sygnal/dist/guide/ui/overview.md` (and `ui/dialog.md`, `popover`, `tooltip`, `tabs`, `accordion`, `disclosure`, `toaster`)
|
|
276
277
|
- Menu / Select / Combobox: `import { Select } from 'sygnal/ui/select'` (`sygnal/ui/menu`, `sygnal/ui/combobox`; each needs `@zag-js/vanilla` + its machine, e.g. `@zag-js/select@~1.45.0`): widget tags, `<Select className="size" label="Size" items={['S', 'M']} value={state.size} />` + `SIZE: DOM.select('.size').events('value-change').detail()` (Menu: `'select'`; Combobox also `'input-change'`), commands `{ open: '.size' }`. Escape hatch for one React/Preact component or a Zag machine: `fromReact(Comp, { events: { rate: 'onChange' } })` (`sygnal/react`) / `fromZag(menu, (api, props) => …, { events })` (`sygnal/zag`, `import * as menu from '@zag-js/menu'`) return widget tags; test them with `dom: 'real'`. Guides: `node_modules/sygnal/dist/guide/ui/menu.md` (`ui/select.md`, `ui/combobox.md`), `node_modules/sygnal/dist/guide/adapters.md`
|
|
277
278
|
- Third-party libraries (tested recipes): charts (Chart.js, ECharts), rich text (Tiptap), code editor (CodeMirror), carousel (Embla), data grid (AG Grid) are `defineWidget` tags; TanStack Table v9 runs in the view as a pure function of state (`constructTable` per render, settings in state); icons: `lucide` data → SVG vnodes with a helper called in the view (`{icon(Plus)}`, not a component); i18n: i18next `getFixedT(locale)` once per locale in `App.context = { t }`, the locale in state saved with `persist()` (never `changeLanguage()`). Recipes: `node_modules/sygnal/dist/guide/recipes/overview.md` (and `recipes/charts.md`, `code-editor`, `rich-text`, `carousel`, `data-table`, `data-grid`, `icons`, `i18n`)
|
|
278
279
|
|
|
@@ -309,8 +310,8 @@ it('picks, then removes a task', async () => {
|
|
|
309
310
|
t.expectNoDiagnostics() // throws on any warn/error diagnostic
|
|
310
311
|
})
|
|
311
312
|
```
|
|
312
|
-
- `simulateEvent(sel, type, init?)`: init `{ value }`, `{ checked }`, `{ key: 'Enter' }`, `{ data: { id: 2 } }`; `'document'` targets `DOM.select('document')` listeners. Calls made before the component is ready are buffered (`await t.ready()` is optional). `sel` matches the rendered tree like the real DOM: tag, `.class`, `#id`, `[attr="v"]`, `:first-child`, `:last-child`, `:nth-child(n)`, `:nth-of-type(n)`, `:not()`, descendant and `>` (e.g. `.list:nth-child(2) .card:first-child .next`). If it matches nothing (after waiting up to 300ms for a render), the test fails with an error naming the selector (check `t.html()`, or select by attribute: `[data-id="3"]`); `{ allowMissing: true }` drops the event instead. `:has()`, `+`, `~` throw "unsupported selector syntax". `sel` is always a selector string, never an element (`t.query()` returns snapshots: select by attribute, `'.remove[data-id="2"]'`).
|
|
313
|
-
- `t.next(pred)` matches only future states (right after `await t.ready()`, also those of the buffered calls). `t.waitForState(pred)` also matches states already recorded (e.g. the initial one), so use it only for a state that can't already exist. Both resolve after the whole tree has rendered. `await t.respond(…)` / `t.fail(…)` / `t.settle()` resolve once the state is there: assert on `t.state` next; a `t.next(pred)` after them waits for a further state and times out ("a state recorded before this next() call already matches"). `t.settle()` doesn't wait out a model `next('X', data, ms)` longer than its 20ms window: `await t.next(pred)` instead (timeout errors name the pending `next()`). `t.state` is the latest state (`t.states.at(-1)`), with current calculated fields (also after a child or Collection item writes); `t.states`, `t.emitted` (EVENTS sent) and `t.sinkValues('PARENT')` / `t.sinkValues('API')` (any sink, children's too) are live arrays. `t.html()` is serialised like `innerHTML` (text `Couldn't`, not `'`) and throws before the first render: `await t.ready()` (or a `t.next`) first. Timer tests (debounce/delay): `vi.useFakeTimers()` before `renderComponent`, then `await t.ready()`; `await vi.advanceTimersByTimeAsync(ms)` moves time (e.g. 299 ms: nothing sent yet), and `t.next`/`t.waitForState`/`t.settle`/`t.ready` advance the fake clock themselves until they resolve, so no real waiting. Use it instead of sleeping out a real debounce. `vi.useRealTimers()` in `afterEach`.
|
|
313
|
+
- `simulateEvent(sel, type, init?)`: init `{ value }`, `{ checked }`, `{ key: 'Enter' }`, `{ data: { id: 2 } }`; `'document'` targets `DOM.select('document')` listeners. Calls made before the component is ready are buffered (`await t.ready()` is optional). `sel` matches the rendered tree like the real DOM: tag, `.class`, `#id`, `[attr="v"]`, `:first-child`, `:last-child`, `:nth-child(n)`, `:nth-of-type(n)`, `:not()`, descendant and `>` (e.g. `.list:nth-child(2) .card:first-child .next`). If it matches nothing (after waiting up to 300ms for a render), the test fails with an error naming the selector (check `t.html()`, or select by attribute: `[data-id="3"]`); `{ allowMissing: true }` drops the event instead. `:has()`, `+`, `~` throw "unsupported selector syntax". `sel` is always a selector string, never an element (`t.query()` returns snapshots: select by attribute, `'.remove[data-id="2"]'`). With `dom: 'real'`, simulated inputs wait for a quiet, patched tree between deliveries (10 ms), so a large queued batch may exceed `timeoutMs` without losing writes. Use native button `.click()` calls for a synchronous burst test, or give serialized tests a larger timeout. `eventWaitMs` is the target-readiness budget, not a quiet-window switch; wait timeouts report queued input counts.
|
|
314
|
+
- `t.next(pred)` matches only future states (right after `await t.ready()`, also those of the buffered calls). `t.waitForState(pred)` also matches states already recorded (e.g. the initial one), so use it only for a state that can't already exist. Both resolve after the whole tree has rendered. `await t.respond(…)` / `t.fail(…)` / `t.settle()` resolve once the state is there: assert on `t.state` next; a `t.next(pred)` after them waits for a further state and times out ("a state recorded before this next() call already matches"). `t.settle()` doesn't wait out a model `next('X', data, ms)` longer than its 20ms window: `await t.next(pred)` instead (timeout errors name the pending `next()`). `t.state` is the latest state (`t.states.at(-1)`), with current calculated fields (also after a child or Collection item writes); `t.states`, `t.emitted` (EVENTS sent) and `t.sinkValues('PARENT')` / `t.sinkValues('API')` (any sink, children's too) are live arrays. `t.html()` is serialised like `innerHTML` (text `Couldn't`, not `'`) and throws before the first render: `await t.ready()` (or a `t.next`) first. Timer tests (debounce/delay): `vi.useFakeTimers()` before `renderComponent`, then `await t.ready()`; `await vi.advanceTimersByTimeAsync(ms)` moves time (e.g. 299 ms: nothing sent yet), and `t.next`/`t.waitForState`/`t.settle`/`t.ready` advance the fake clock themselves until they resolve, so no real waiting. Use it instead of sleeping out a real debounce. Each wait fails after 2000 ms of clock time (fake time too): for a state a longer timer brings, pass more, `t.next(pred, 6000)` (or `renderComponent(C, { timeoutMs })`). `vi.useRealTimers()` in `afterEach`.
|
|
314
315
|
- `t.actions` logs every action of the tree, `{ type, data, component, instance, sinks, cause, at }` (`cause`: `'intent' | 'next' | 'reply' | 'built-in' | 'simulateAction' | 'behavior' | 'agent'`; `sinks`: those that produced a value): when "nothing happened", it shows whether the action ran and what it produced. `t.explain(s => s.count === 5)` returns the action that produced a state.
|
|
315
316
|
- `t.simulateAction('LOADED', data)` pushes an action straight into intent → model (all sinks run). Use it for actions without a DOM trigger. **Drivers need no wiring in tests**: the HTTP fake is the real `makeFetchDriver` over an in-memory fetch, so every request stays pending until the test answers it. `t.requests('HTTP')` lists the requests as objects (a string URL is `{ url }`; never `{ abort }`/`{ refresh }`/`{ resources }` values). `await t.respond('HTTP', body, 'RESULTS')` delivers `body` as that request's `ok` action and resolves once it is reduced and rendered; `await t.fail('HTTP', 404 | error, target?)` sends `{ error, status, body, request }` to its `error` action. Target: an action or key name, a resource name, a URL, a partial request (`{ url: '/items/2' }`), or a predicate; none = the newest pending. A request superseded by `latest: true`, aborted or already answered isn't pending, and answering it **throws at the call**: `expect(() => t.respond('HTTP', {}, { query: { q: 'du' } })).toThrow()`. Identical requests (a resource refetch, a second click) match the same targets: `{ nth: 0 }` answers exactly `t.requests('HTTP')[0]` (`-1` the newest; with `request`, counting only its matches), and throws if it isn't pending. A call made while earlier input is still queued (a debounce) waits up to 1s for its request instead. Each send is its own request. The fake can't see `makeFetchDriver({ latest: true })` from main.js: write `latest: true` on the request.
|
|
316
317
|
- Sockets need no driver either: a component with `connections` gets a fake `WS` sink running the real driver on in-memory sockets (`{ socketSink: 'SOCKET' }` for another name). Connections open by themselves; `renderComponent(C, { autoConnect: false })` holds them in `'connecting'` until `await t.open('WS')`. `await t.push('WS', { text: 'hi' })` is a server frame (SSE named event: `t.push('WS', data, { event: 'price-update' })`); `await t.drop('WS', { code: 1011 })` is a close the app didn't make (`close` fires, the retry runs on the test's timers: `await vi.advanceTimersByTimeAsync(1000)`); `t.sent('WS')` lists the `{ to, json }` values; `t.connections('WS')` the declared connections (`name`, `url`, `state`). They throw too when nothing matches.
|
|
@@ -318,4 +319,4 @@ it('picks, then removes a task', async () => {
|
|
|
318
319
|
- Router: `renderComponent(App, { router, url: '/tasks/2' })` runs the router's real driver over an in-memory history (a component that declares `route` needs the `router` option). `await t.navigate('/admin')` (as a link click) or `await t.navigate({ to: 'task', params: { id: 1 } })`, `await t.back()` / `t.forward()`, `t.location` (`{ path, search, hash, href }`), `t.sent('ROUTER')` (the commands, not the declarations); a `simulateEvent` click on an `<a>` goes through the router's link handling; `{ block }` stops all of them. HEAD: `t.head()` returns the merged `{ title, meta, link }` (`{ titleTemplate }` option).
|
|
319
320
|
- TypeScript tests: `renderComponent` infers the state type; for a handle assigned in `beforeEach`, declare `let t: RenderResult<State>` (import the type from `'sygnal'`), not `any`. With the Vite plugin, Vitest gets `sygnal/diagnostics` in its setupFiles automatically; otherwise `import 'sygnal/diagnostics'` in the test.
|
|
320
321
|
- Real DOM (state the user typed or clicked, focus, refs, any CSS selector): `renderComponent(C, { dom: 'real' })` (`'real'`, not `'jsdom'`) in a jsdom test (`// @vitest-environment jsdom`, `npm i -D jsdom`), same `t.*` API in ONE suite (no separate `run()` + jsdom suite). `simulateEvent` then dispatches real events on any CSS selector (`{ value }` types, `'click'` toggles a checkbox/radio and skips disabled buttons, `'focus'`/`'blur'` move focus).
|
|
321
|
-
- Assert on elements: `t.query(sel)` / `t.queryAll(sel)` also work on the default mock DOM, as read-only snapshots of what the view rendered (`textContent`, `value`, `checked`, `disabled`, `getAttribute`, `classList`, `dataset`, `querySelector`, `closest`; `focus()` throws): `expect(t.query('input[name="plan"]:checked').value).toBe('team')`, `t.query('.save').disabled`, `t.queryAll('.row')`; with `dom: 'real'` they are real elements (`document.activeElement === t.query('input[name="city"]')`). Every wait (`ready`, `next`, `waitForState`, `settle`) resolves once its state is rendered, so query right after the `await` (before the first render, `t.query` throws: `await t.ready()`). `await t.next(a); await t.next(b)` also sees a `b` that arrived while the DOM showed `a`.
|
|
322
|
+
- Assert on elements: `t.query<HTMLInputElement>(sel)` / `t.queryAll<HTMLInputElement>(sel)` (or typed control tokens; generic types are assertions, and a single query is nullable) also work on the default mock DOM, as read-only snapshots of what the view rendered (`textContent`, `value`, `checked`, `disabled`, `getAttribute`, `classList`, `dataset`, `querySelector`, `closest`; `focus()` throws): `expect(t.query('input[name="plan"]:checked').value).toBe('team')`, `t.query('.save').disabled`, `t.queryAll('.row')`; with `dom: 'real'` they are real elements (`document.activeElement === t.query('input[name="city"]')`). Every wait (`ready`, `next`, `waitForState`, `settle`) resolves once its state is rendered, so query right after the `await` (before the first render, `t.query` throws: `await t.ready()`). `await t.next(a); await t.next(b)` also sees a `b` that arrived while the DOM showed `a`.
|
package/package.json
CHANGED
package/src/astro/index.d.ts
CHANGED
|
@@ -14,6 +14,7 @@ declare type SygnalAstroIntegration = {
|
|
|
14
14
|
logger?: { warn: (message: string) => void };
|
|
15
15
|
config?: { root?: URL | string; vite?: any };
|
|
16
16
|
}) => void;
|
|
17
|
+
'astro:server:start': (args: { address?: { port?: number }; logger?: { info: (message: string) => void } }) => void;
|
|
17
18
|
};
|
|
18
19
|
};
|
|
19
20
|
|
|
@@ -43,6 +44,13 @@ export interface SygnalAstroOptions {
|
|
|
43
44
|
* @default true
|
|
44
45
|
*/
|
|
45
46
|
devtools?: boolean;
|
|
47
|
+
/**
|
|
48
|
+
* Serve the live app graph page at `/__sygnal/graph` in `astro dev` (see the sygnal/vite `graph`
|
|
49
|
+
* option); its address is printed when the dev server starts. Needs sygnal-check 0.10.0 or later
|
|
50
|
+
* in the project. Never in `astro build`.
|
|
51
|
+
* @default true
|
|
52
|
+
*/
|
|
53
|
+
graph?: boolean;
|
|
46
54
|
/**
|
|
47
55
|
* The islands' app-level error hook (PLAN-4 GS-11): the path of a module, relative to the
|
|
48
56
|
* Astro project root, whose default export is called as run()'s and renderToString's
|
package/src/astro/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import sygnalVite from '../vite/plugin'
|
|
2
2
|
import { globalThisAlias } from '../vite/globalthis'
|
|
3
|
+
import { GRAPH_MISSING_LINE, GRAPH_PATH, graphAvailable } from '../vite/graph'
|
|
3
4
|
import type { DiagnosticsMode, DiagnosticsPluginOptions, CheckPluginOptions } from '../vite/plugin'
|
|
4
5
|
// Node built-ins (the integration runs in Node). The package has no @types/node.
|
|
5
6
|
// @ts-ignore
|
|
@@ -70,6 +71,13 @@ export interface SygnalAstroOptions {
|
|
|
70
71
|
* @default true
|
|
71
72
|
*/
|
|
72
73
|
devtools?: boolean;
|
|
74
|
+
/**
|
|
75
|
+
* Serve the live app graph page at `/__sygnal/graph` in `astro dev` (see the sygnal/vite `graph`
|
|
76
|
+
* option); its address is printed when the dev server starts. Needs sygnal-check 0.10.0 or later
|
|
77
|
+
* in the project. Never in `astro build`.
|
|
78
|
+
* @default true
|
|
79
|
+
*/
|
|
80
|
+
graph?: boolean;
|
|
73
81
|
/**
|
|
74
82
|
* PLAN-4 GS-11: the islands' app-level error hook: the path of a module (relative to the Astro
|
|
75
83
|
* project root) whose default export is called as run()'s / renderToString's `onError`
|
|
@@ -81,10 +89,12 @@ export interface SygnalAstroOptions {
|
|
|
81
89
|
}
|
|
82
90
|
|
|
83
91
|
export default function sygnalAstroIntegration(options: SygnalAstroOptions = {}) {
|
|
92
|
+
let projectRoot: string | undefined
|
|
84
93
|
return {
|
|
85
94
|
name: SYGNAL_RENDERER_NAME,
|
|
86
95
|
hooks: {
|
|
87
96
|
'astro:config:setup': ({ addRenderer, updateConfig, command, config }: AstroConfigSetupArgs) => {
|
|
97
|
+
projectRoot = rootDir(config?.root)
|
|
88
98
|
addRenderer({
|
|
89
99
|
name: SYGNAL_RENDERER_NAME,
|
|
90
100
|
clientEntrypoint: 'sygnal/astro/client',
|
|
@@ -116,6 +126,7 @@ export default function sygnalAstroIntegration(options: SygnalAstroOptions = {})
|
|
|
116
126
|
check: options.check,
|
|
117
127
|
nativeGlobalThis: options.nativeGlobalThis,
|
|
118
128
|
devtools: options.devtools,
|
|
129
|
+
graph: options.graph,
|
|
119
130
|
})]
|
|
120
131
|
} else if (options.nativeGlobalThis !== false) {
|
|
121
132
|
// G-099: the same `globalthis` alias the plugin adds in dev (R2-7: unless the
|
|
@@ -128,6 +139,13 @@ export default function sygnalAstroIntegration(options: SygnalAstroOptions = {})
|
|
|
128
139
|
|
|
129
140
|
updateConfig({ vite })
|
|
130
141
|
},
|
|
142
|
+
// W-2: Astro prints its own banner (Vite's printUrls never runs), so the graph page's line goes here
|
|
143
|
+
'astro:server:start': ({ address, logger }: { address?: { port?: number }, logger?: { info(msg: string): void } }) => {
|
|
144
|
+
if (options.graph === false || !projectRoot) return
|
|
145
|
+
const print = (msg: string) => (logger ? logger.info(msg.replace(/^\s*➜\s*/, '')) : console.log(msg))
|
|
146
|
+
if (!graphAvailable(projectRoot)) print(GRAPH_MISSING_LINE)
|
|
147
|
+
else if (address?.port) print(` ➜ Sygnal graph: http://localhost:${address.port}${GRAPH_PATH}`)
|
|
148
|
+
},
|
|
131
149
|
},
|
|
132
150
|
}
|
|
133
151
|
}
|
package/src/core/actions.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* PLAN-4.6 core: running one action (03-proposal §3, 04 §2.4.1).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* state
|
|
4
|
+
* STATE runs first (normalize puts it first in each entry); every other handler then gets the
|
|
5
|
+
* state it committed (7.0), with the state from before the action as `props.previousState`.
|
|
6
|
+
* Calculated fields are included.
|
|
6
7
|
* - STATE: applied at once (D165). ABORT, or the object it was given (GS-4), is no change.
|
|
7
8
|
* D205: during an input / change event (an action triggered by typing in a field), no change
|
|
8
9
|
* still re-renders the instance, so a controlled field shows the model's value again
|
|
@@ -32,7 +33,7 @@ export function handle(inst: Inst, type: string, data: any, cause: any, src?: st
|
|
|
32
33
|
if (!hs) return
|
|
33
34
|
const def = inst.def
|
|
34
35
|
const pre = inst.cell.get()
|
|
35
|
-
let props: any, outs: any[] | undefined
|
|
36
|
+
let props: any, afterProps: any, post = pre, outs: any[] | undefined
|
|
36
37
|
const next = (t: string, d?: any, ms: any = 10, effect?: boolean) => {
|
|
37
38
|
if (typeof ms !== 'number') fail('SYG215', inst, `next() delay in '${type}' must be a number`, "Use next('ACTION', data, ms)")
|
|
38
39
|
// G-300: none for an instance being disposed; dispose() clears the pending ones
|
|
@@ -45,24 +46,20 @@ export function handle(inst: Inst, type: string, data: any, cause: any, src?: st
|
|
|
45
46
|
}
|
|
46
47
|
for (const [sink, h0] of hs) {
|
|
47
48
|
const h = H.wrapHandler ? H.wrapHandler(viewOf(inst), type, sink, h0) || h0 : h0
|
|
48
|
-
|
|
49
|
-
if (typeof h != 'function') continue
|
|
50
|
-
const failed = (e: any) => app.caught(inst, 'SYG214', `EFFECT handler '${type}' threw`, e, 'effect', type)
|
|
51
|
-
try {
|
|
52
|
-
const p = {...(props ||= inst_props(inst, pre)), signal: (inst.ac ||= globalThis.AbortController && new AbortController())?.signal}
|
|
53
|
-
const r = h(pre, data, (t: string, d?: any, ms?: any) => next(t, d, ms, true), p)
|
|
54
|
-
if (r?.then) r.then(null, failed)
|
|
55
|
-
else if (r !== undefined && !isAbort(r)) warn('SYG219', inst, `EFFECT handler '${type}' returned a value, which is ignored`, 'Use a STATE or driver sink')
|
|
56
|
-
} catch (e) { failed(e) }
|
|
57
|
-
continue
|
|
58
|
-
}
|
|
49
|
+
const effect = sink == 'EFFECT'
|
|
59
50
|
let v: any
|
|
60
51
|
if (typeof h == 'function') {
|
|
61
|
-
try {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
52
|
+
try {
|
|
53
|
+
const p = sink == 'STATE' ? props ||= inst_props(inst, pre, pre) : afterProps ||= inst_props(inst, post, pre)
|
|
54
|
+
v = h(p.state, data, effect ? (t: string, d?: any, ms?: any) => next(t, d, ms, true) : next,
|
|
55
|
+
effect ? {...p, signal: (inst.ac ||= globalThis.AbortController && new AbortController())?.signal} : p)
|
|
56
|
+
if (effect) {
|
|
57
|
+
if (v?.then) v.then(null, (e: any) => handlerFailed(inst, type, sink, e))
|
|
58
|
+
else if (v !== undefined && !isAbort(v)) warn('SYG219', inst, `EFFECT handler '${type}' returned a value, which is ignored`, 'Use a STATE or driver sink')
|
|
59
|
+
}
|
|
60
|
+
} catch (e) { handlerFailed(inst, type, sink, e); continue }
|
|
65
61
|
} else v = h === undefined || h === true ? data : h
|
|
62
|
+
if (effect) continue
|
|
66
63
|
if (sink == 'STATE' && (isAbort(v) || v === pre)) {
|
|
67
64
|
// D205: the DOM event being dispatched (window.event) is the one this action came from
|
|
68
65
|
if (/^(input|change)$/.test((globalThis as any).event?.type)) inst.refresh()
|
|
@@ -73,6 +70,10 @@ export function handle(inst: Inst, type: string, data: any, cause: any, src?: st
|
|
|
73
70
|
H.onReducer?.(viewOf(inst), type, pre, v)
|
|
74
71
|
dbg(inst, () => `<${type}> State reducer added`)
|
|
75
72
|
inst.cell.set(v)
|
|
73
|
+
// a STATE that removed the component's own state (a deleted record it is bound to): the
|
|
74
|
+
// outputs get its last state, as a Collection item's do
|
|
75
|
+
post = inst.cell.get()
|
|
76
|
+
if (post === undefined) post = pre
|
|
76
77
|
// GS-12: this app's DOM driver patches inside a View Transition (makeViewTransitionDOMDriver
|
|
77
78
|
// reads the flag on its IsolateModule); includes?.: a non-array static lists nothing (G-228).
|
|
78
79
|
// Only for a state that changed structurally: today an equal one renders nothing (its
|
|
@@ -103,9 +104,15 @@ export function handle(inst: Inst, type: string, data: any, cause: any, src?: st
|
|
|
103
104
|
}
|
|
104
105
|
}
|
|
105
106
|
|
|
106
|
-
/** the
|
|
107
|
-
function
|
|
108
|
-
|
|
107
|
+
/** Keep the same failure boundary for sync handlers and rejected EFFECT promises. */
|
|
108
|
+
function handlerFailed(inst: Inst, type: string, sink: string, e: any) {
|
|
109
|
+
const effect = sink == 'EFFECT'
|
|
110
|
+
inst.app.caught(inst, effect ? 'SYG214' : 'SYG216', effect ? `EFFECT handler '${type}' threw` : `Reducer for '${type}' threw; ${sink == 'STATE' ? 'state unchanged' : 'nothing sent'}`, e, effect ? 'effect' : 'reducer', type)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** the handlers' 4th argument: the props, children, slots, context, uid, the state and the pre-action state */
|
|
114
|
+
function inst_props(inst: Inst, state: any, previousState: any) {
|
|
115
|
+
return {...inst.props, children: inst.children, slots: inst.slots, context: inst.context(), uid: inst.uid, state, previousState}
|
|
109
116
|
}
|
|
110
117
|
|
|
111
118
|
/**
|
package/src/core/define.ts
CHANGED
|
@@ -141,6 +141,8 @@ export function normalize(view: ComponentFn, src: DefSource): CoreDef {
|
|
|
141
141
|
}
|
|
142
142
|
const list: Handler[] = []
|
|
143
143
|
for (const sink in e) list.push([sink, e[sink]]), sinks.add(sink)
|
|
144
|
+
// 7.0: STATE first, so the other sinks get the state it committed
|
|
145
|
+
list.sort((a, b) => +(a[0] != 'STATE') - +(b[0] != 'STATE'))
|
|
144
146
|
handlers.set(key, list)
|
|
145
147
|
}
|
|
146
148
|
const c = normCalc(name, src.calculated, src.initialState)
|
package/src/core/hooks.ts
CHANGED
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
* - Actions drain run-to-completion, FIFO: an action dispatched while another is processed (a
|
|
19
19
|
* driver emitting synchronously during sink delivery, an EFFECT, a PARENT, a hook) is appended
|
|
20
20
|
* to the queue, never run nested.
|
|
21
|
-
* - A STATE reducer is applied when its action is processed
|
|
22
|
-
* the state from before it.
|
|
21
|
+
* - A STATE reducer is applied when its action is processed, before the action's other sinks;
|
|
22
|
+
* they get the state it committed, with the state from before it as `props.previousState` (7.0).
|
|
23
23
|
* - A flush (one microtask after the first commit of a batch) repeats { render top-down, recompute
|
|
24
24
|
* statics, drain the queue } until nothing changed, then emits ONE root vnode (one patch), then
|
|
25
25
|
* runs ELEMENT commands and dispatches BOOTSTRAP for the instances created in it.
|
|
@@ -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'
|
|
@@ -81,7 +81,7 @@ const OWN = new Set(['of', 'from', 'filter', 'sort', 'idfield', 'className', 'vi
|
|
|
81
81
|
const NONE: any[] = []
|
|
82
82
|
|
|
83
83
|
/** the array cell for `from` (D178: a missing key renders once it appears), or null for an invalid `from` (renders nothing) */
|
|
84
|
-
function arrayCell(owner: Inst, from: any): Cell | null {
|
|
84
|
+
function arrayCell(owner: Inst, from: any): (Cell & {has?(): boolean}) | null {
|
|
85
85
|
const oc = owner.cell, name = owner.def.name
|
|
86
86
|
if (from === undefined) {
|
|
87
87
|
// the owner's state is the array (or its `value`, as today)
|
|
@@ -93,7 +93,7 @@ function arrayCell(owner: Inst, from: any): Cell | null {
|
|
|
93
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
94
|
if (typeof from == 'string') return keyCell(oc, from, owner.def.calcNames?.has(pathOf(from)[0]) && name, undefined, 'Collection')
|
|
95
95
|
if (isObj(from) && typeof from.get == 'function') {
|
|
96
|
-
let lp: any = {}, lv: any
|
|
96
|
+
let lp: any = {}, lv: any, raw: any
|
|
97
97
|
return {
|
|
98
98
|
get() {
|
|
99
99
|
const p = oc.get()
|
|
@@ -106,11 +106,12 @@ function arrayCell(owner: Inst, from: any): Cell | null {
|
|
|
106
106
|
owner.app.appError(owner, e, 'view')
|
|
107
107
|
return lv
|
|
108
108
|
}
|
|
109
|
-
lv = g
|
|
109
|
+
raw = lv = g
|
|
110
110
|
if (!Array.isArray(lv)) { warn('SYG401', name, "Collection 'from' getter returned a non-array; it renders nothing", 'Return an array from get()', lv); lv = NONE }
|
|
111
111
|
}
|
|
112
112
|
return lv
|
|
113
113
|
},
|
|
114
|
+
has() { this.get(); return raw !== undefined },
|
|
114
115
|
set(v) {
|
|
115
116
|
if (typeof from.set != 'function') return
|
|
116
117
|
const p = oc.get(), n = from.set(p, v)
|