sygnal 7.0.0 → 7.1.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 +126 -0
- package/dist/astro/server.cjs.js +32 -43
- package/dist/astro/server.cjs.js.map +1 -1
- package/dist/astro/server.mjs +32 -43
- package/dist/astro/server.mjs.map +1 -1
- package/dist/diagnostics.cjs.js +67 -2
- package/dist/diagnostics.cjs.js.map +1 -1
- package/dist/diagnostics.esm.js +67 -2
- package/dist/diagnostics.esm.js.map +1 -1
- package/dist/guide/accessibility.md +1 -1
- package/dist/guide/resources.md +2 -0
- package/dist/guide/timers.md +3 -3
- package/dist/index.cjs.js +189 -77
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +103 -16
- package/dist/index.esm.js +188 -78
- 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/llms.txt +7 -7
- package/package.json +1 -1
- package/src/core/hosts/switchable.ts +26 -8
- package/src/core/instance.ts +12 -7
- package/src/core/statics.ts +5 -5
- package/src/extra/ai/transports/shared.ts +3 -1
- package/src/extra/behaviors.ts +3 -3
- package/src/extra/diagnostics/checks/next.ts +46 -3
- package/src/extra/diagnostics/checks/props.ts +6 -1
- package/src/extra/diagnostics/codes.ts +11 -0
- package/src/extra/fetchDriver.ts +3 -0
- package/src/extra/fetchEventSource.ts +6 -2
- package/src/extra/socketDriver.ts +51 -6
- package/src/extra/ssr.ts +30 -41
- package/src/extra/testing.ts +32 -7
- package/src/extra/timers.ts +5 -2
- package/src/index.d.ts +102 -15
- package/src/index.ts +2 -0
package/llms.txt
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
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
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).
|
|
9
9
|
- Every side effect is a sink of a model entry (STATE, EVENTS, PARENT, EFFECT, custom drivers). JSX never has event handlers (`onClick`).
|
|
10
|
-
- Streams are xstream, not RxJS. An intent's DOM selectors only see the component's own JSX, never a child component's.
|
|
10
|
+
- Streams are xstream, not RxJS: `import { xs } from 'sygnal'` (not `import xs from 'xstream'`, the module object under Node ESM / Vite 8: SYG302). An intent's DOM selectors only see the component's own JSX, never a child component's.
|
|
11
11
|
|
|
12
12
|
## 2. Component anatomy
|
|
13
13
|
```jsx
|
|
@@ -35,7 +35,7 @@ Counter.model = {
|
|
|
35
35
|
Counter.onError = (error, { componentName }) => <div className="error">{componentName} failed</div>
|
|
36
36
|
export default Counter
|
|
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
|
+
- Reducer `(state, data, next, props)`. `data` is the action stream's value. `next('ACTION', data?, delayMs = 10)` dispatches another action of this component only (a child's `next('X')` never reaches a parent's `X`: SYG117; send it up with `PARENT` / `EVENTS`) `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`.
|
|
39
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.
|
|
40
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`
|
|
41
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).
|
|
@@ -167,7 +167,7 @@ Search.model = {
|
|
|
167
167
|
```
|
|
168
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
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`).
|
|
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
|
|
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`; to answer with reply actions (several per request: progress, then ok), `const { replies, reply } = makeReplies(onStop)`, `reply(senderOf(req), req.ok, value)`, return `{ ...replies }` (custom-drivers.md). Page-wide events: `DOM.select('document' | 'body').events(type)`, CSS-filtered with `DOM.select('document').select('.overlay').events('click')`.
|
|
171
171
|
### WebSocket / SSE (makeSocketDriver + connections)
|
|
172
172
|
```jsx
|
|
173
173
|
import { ABORT } from 'sygnal'
|
|
@@ -192,7 +192,7 @@ Chat.model = {
|
|
|
192
192
|
DROPPED: (state, { willReconnect }) => ({ ...state, status: willReconnect ? 'reconnecting' : 'offline' }), // { code, reason, willReconnect }
|
|
193
193
|
}
|
|
194
194
|
```
|
|
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
|
+
- 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); SSE `meta: true` (actions get `{ data, id, event }`), `resume: true | 'param'` (a driver reconnect resumes from the last event id), `staleAfterMs` (silence = a drop that reconnects; `close` gets `stale: true`; heartbeat comments count over fetch). `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/
|
|
196
196
|
### Reads that follow state: `resources`
|
|
197
197
|
```jsx
|
|
198
198
|
function Quote({ state }) {
|
|
@@ -215,7 +215,7 @@ Quote.model = {
|
|
|
215
215
|
- `updates: 'item'` on a write (React Query's `setQueryData`): after a 2xx, before the `ok` action and `invalidates`, the reply becomes `state.item.data` and its cache entry at once, and the item's read in flight is aborted; `updates: { items: (list, reply) => newList }` derives the data. With `invalidates` the read then refetches keeping the written data (`refreshing`). An idle resource is skipped; the `ok` action still gets the reply.
|
|
216
216
|
- Query cache (opt-in, its own export): `run(App, { HTTP: makeFetchDriver({ cache: queryCache({ staleTime: 30000, gcTime: 300000 }) }) })`. A request fetched before shows its cached data at once (`'success'`, `refreshing: true` while it revalidates; nothing is sent while younger than `staleTime`, default 0), identical requests in flight share one fetch, and stale mounted resources refetch on window focus and reconnect. Reply-action GETs use it with `cache: true` or `staleTime`. Per request: `retry: 3` or `{ count, delayMs }` (default 0; network errors, 408, 429, 5xx), `validate: schema` (Standard Schema: zod, valibot), `refetchEvery: 10000` (polling). `{ prefetch: '/api/quotes/2' }` on the sink warms the cache. SSR: `cache.set(request, data)`, `renderToString(App, { cache })`, `cache.dehydrate()` → `queryCache({ initial: snapshot })` (Vike: `pageContext.queryCache` from `+data`). Guide and recipes (list/detail + save, optimistic update, pagination, infinite list, save status): `node_modules/sygnal/dist/guide/resources.md` (online: https://sygnal.js.org/guide/resources/)
|
|
217
217
|
- List/detail + save, cached: `items: (s) => s.view === 'list' && { url: '/api/items', staleTime: 2000 }`, `item: (s) => s.view === 'detail' && { url: '/api/items/' + s.id, staleTime: 2000 }` (declared only while shown; shown again: cached data at once, refetched once stale), `makeFetchDriver({ cache: queryCache() })` in main.js **and** `renderComponent(App, { http: { cache: queryCache() } })` in every test (a `staleTime` without a queryCache is SYG635), the save `{ url, method: 'PUT', json, ok: 'SAVED', error: 'SAVE_FAILED', updates: 'item', invalidates: '/api/items' }`, status `r?.status === 'loading' ? 'Loading…' : r?.refreshing ? 'Updating…' : ''`.
|
|
218
|
-
- In a hidden Switchable page, resources and `connections` pause (fetches abort, sockets close) and resume on show; an entry with `background: true` (`{ url, background: true }`, `{ socket, background: true }`) stays live.
|
|
218
|
+
- In a hidden Switchable page, resources and `connections` pause (fetches abort, sockets close) and resume on show; an entry with `background: true` (`{ url, background: true }`, `{ socket, background: true }`) stays live. Per-page slices: `state={{ projects: 'pages.projects', settings: byId('teams', state.teamId) }}` (key, path, lens or byId per page; each page follows its own binding: another target remakes only that page; unlisted pages share the owner's state; a key that names no page is SYG429).
|
|
219
219
|
### Router (makeRouter + route) and HEAD
|
|
220
220
|
```jsx
|
|
221
221
|
// src/routes.js: one module the views import
|
|
@@ -246,7 +246,7 @@ TaskPage.model = { // a page shares the parent's state: no initialState (SYG40
|
|
|
246
246
|
DONE: { ROUTER: () => ({ to: 'home', block: false }) },
|
|
247
247
|
}
|
|
248
248
|
```
|
|
249
|
-
- Links are plain `<a href={href(...)}>`, never click handlers: the driver intercepts same-origin left clicks at the document (not modified clicks, `target`, `download`, `rel="external"`, `data-router-ignore`, other origins). `params` and `query` values are strings; `href()` is typed from the route table (an unknown name or a missing param is a type error; SYG130/SYG131 at run time). `instance={state.route.path}` re-creates the page for `/tasks/1` → `/tasks/2`. Pages derive `resources` from `state.route.params`.
|
|
249
|
+
- Links are plain `<a href={href(...)}>`, never click handlers: the driver intercepts same-origin left clicks at the document (not modified clicks, `target`, `download`, `rel="external"`, `data-router-ignore`, other origins). `params` and `query` values are strings; `href()` is typed from the route table (an unknown name or a missing param is a type error; SYG130/SYG131 at run time). `instance={state.route.path}` re-creates the page for `/tasks/1` → `/tasks/2`. Pages derive `resources` from `state.route.params`. Pages read the route from context (`App.context = { route: (s) => s.route }`), and every declaration static (`resources timers connections head browser`) gets `(state, context)`: `TaskPage.resources = { task: (s, { route }) => `/api/tasks/${route.params.id}` }` (recomputed when the context changes).
|
|
250
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.
|
|
251
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/
|
|
252
252
|
### AI (`sygnal/ai`): chat, agents, decisions, composed UI
|
|
@@ -316,7 +316,7 @@ it('picks, then removes a task', async () => {
|
|
|
316
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.
|
|
317
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
318
|
- Resources: each fetch is listed as `{ url, ...request, resource: 'quote' }` and answered by resource name, URL or partial request: `await t.respond('HTTP', { text: 'Hi' }, 'quote')`, `await t.fail('HTTP', 500, 'quote')`. A resource request is sent after the state changes; `t.respond` by resource name right after the `simulate*` call that changes it waits for that fetch. Cache: `renderComponent(C, { http: { cache: queryCache({ staleTime: 2000 }) } })` (any driver options); `t.cache('HTTP')` lists `{ key, age, stale, subscribers, data, tags }`; `t.focus()` / `t.online()` fire the refetch triggers; retries and `refetchEvery` run on the test's timers.
|
|
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
|
+
- Router: `renderComponent(App, { router, url: '/tasks/2' })` (the first render is already on `url`: its route goes through the root's route STATE reducer first) 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).
|
|
320
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.
|
|
321
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).
|
|
322
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
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import {hosts} from '../registry'
|
|
19
19
|
import {resolve} from './collection'
|
|
20
|
-
import {Inst, shallowEq} from '../instance'
|
|
20
|
+
import {Inst, shallowEq, bindingChanged, known, watch} from '../instance'
|
|
21
21
|
import {Cell, keyCell, lensCell, localCell, pathOf} from '../cell'
|
|
22
22
|
import {isObj} from '../define'
|
|
23
23
|
import {uidPart} from '../../shared'
|
|
@@ -39,6 +39,9 @@ function check(owner: Inst, props: Record<string, any>) {
|
|
|
39
39
|
if (!(cur in of)) fail('SYG416', owner, `Switchable 'current' '${cur}' is not a key of 'of'`, CUR_FIX)
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/** F-3: a per-page map (an object that is not a lens or a byId selection: no `get`), or null */
|
|
43
|
+
const mapOf = (st: any) => isObj(st) && typeof st.get != 'function' ? st : null
|
|
44
|
+
|
|
42
45
|
/** a page's props: the Switchable's, kept by identity while equal (`of` compared shallowly) */
|
|
43
46
|
function sameProps(a: any, b: any) {
|
|
44
47
|
if (!a || !b) return false
|
|
@@ -51,6 +54,7 @@ function sameProps(a: any, b: any) {
|
|
|
51
54
|
export class SwitchableHost {
|
|
52
55
|
pages: Record<string, Page> = {}
|
|
53
56
|
cell: Cell
|
|
57
|
+
map: any
|
|
54
58
|
cur!: string
|
|
55
59
|
ready = true
|
|
56
60
|
disposed = false
|
|
@@ -61,10 +65,11 @@ export class SwitchableHost {
|
|
|
61
65
|
|
|
62
66
|
constructor(public owner: Inst, props: Record<string, any>, children: any[], id: string) {
|
|
63
67
|
check(owner, props)
|
|
64
|
-
const st = props.state
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
+
const st = props.state
|
|
69
|
+
// F-3: `state={{ page: key | lens | byId }}` (not a lens, not a byId selection): each listed
|
|
70
|
+
// page binds its own target, with a tag child's lifetime rules; the others share the owner's state
|
|
71
|
+
this.map = mapOf(st)
|
|
72
|
+
this.cell = this.bind(this.map ? undefined : st)
|
|
68
73
|
this.uidBase = owner.uid(uidPart(id.replace(/.*::(r\.)?/, '')))
|
|
69
74
|
this.props = props
|
|
70
75
|
this.kids = children
|
|
@@ -75,9 +80,17 @@ export class SwitchableHost {
|
|
|
75
80
|
this.setProps(props, children)
|
|
76
81
|
}
|
|
77
82
|
|
|
83
|
+
bind(st: any): Cell {
|
|
84
|
+
const owner = this.owner, calc = typeof st == 'string' && owner.def.calcNames?.has(pathOf(st)[0])
|
|
85
|
+
return typeof st == 'string' ? keyCell(owner.cell, st, calc && owner.def.name, undefined, 'Switchable sub-component')
|
|
86
|
+
: st !== undefined ? lensCell(owner.cell, st, owner.def.name, (e) => owner.app.appError(owner, e, 'view'))
|
|
87
|
+
: owner.cell
|
|
88
|
+
}
|
|
89
|
+
|
|
78
90
|
make(name: string, view: any): Inst {
|
|
79
91
|
const o = this.owner, app = o.app, def = app.def(view), scope = app.scope()
|
|
80
|
-
const
|
|
92
|
+
const c = this.map ? this.bind(this.map[name]) : this.cell
|
|
93
|
+
const inst = new Inst(app, def, o, def.isolated ? localCell(app, c) : c, o.dom && o.dom.isolateSource(o.dom, scope),
|
|
81
94
|
this.props, this.kids, scope, this.uidBase + '-' + uidPart(name), 'page')
|
|
82
95
|
inst.shown = false
|
|
83
96
|
return inst
|
|
@@ -95,15 +108,20 @@ export class SwitchableHost {
|
|
|
95
108
|
this.kids = children
|
|
96
109
|
for (const n in this.pages) this.pages[n].inst.setProps(props, children)
|
|
97
110
|
}
|
|
111
|
+
// F-3: a page whose own binding target changed (7.0's rule, per page), or that the owner
|
|
112
|
+
// disposed because its target was deleted, is made again
|
|
113
|
+
const m = this.map, nm = this.map = mapOf(props.state)
|
|
98
114
|
// G-317: a lazy() page that has loaded is made again with the loaded component
|
|
99
115
|
for (const n in this.pages) {
|
|
100
116
|
const p = this.pages[n], fn = resolve(p.view, this.owner)
|
|
101
|
-
if (fn !== p.fn) {
|
|
117
|
+
if (fn !== p.fn || p.inst.disposed || m && nm && bindingChanged(m[n], nm[n])) {
|
|
102
118
|
const shown = p.inst.shown
|
|
103
|
-
p.inst.dispose()
|
|
119
|
+
p.inst.disposed || p.inst.dispose()
|
|
104
120
|
p.inst = this.make(n, p.fn = fn)
|
|
105
121
|
p.inst.shown = shown
|
|
106
122
|
}
|
|
123
|
+
// the owner disposes a page whose target is deleted, as a tag child; it is made again above
|
|
124
|
+
if (nm && known(nm[n])) watch(this.owner, p.inst)
|
|
107
125
|
}
|
|
108
126
|
const page = this.pages[cur]
|
|
109
127
|
if (page) {
|
package/src/core/instance.ts
CHANGED
|
@@ -31,13 +31,22 @@ import {sameData} from '../cycle/dom/utils'
|
|
|
31
31
|
|
|
32
32
|
// v7: a known binding target joins key/id/path in the lifetime identity. Fresh equivalent
|
|
33
33
|
// byId helpers retain identity; opaque lenses need an explicit key for a new target.
|
|
34
|
-
function bindingChanged(a: any, b: any): boolean {
|
|
34
|
+
export function bindingChanged(a: any, b: any): boolean {
|
|
35
35
|
if (a === b) return false
|
|
36
36
|
const x = a?.[BINDING], y = b?.[BINDING]
|
|
37
37
|
return x && y ? !x.every((v: any, i: number) => Object.is(v, y[i]))
|
|
38
38
|
: !!(x || y || typeof a == 'string' || typeof b == 'string' || a === undefined || b === undefined)
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/** a binding whose target is known (a key, a byId selection): its deletion ends the child */
|
|
42
|
+
export const known = (b: any) => typeof b == 'string' || b?.[BINDING]
|
|
43
|
+
/** F-3: `owner` watches child (or Switchable page) `k`'s target: notify() disposes it when deleted */
|
|
44
|
+
export function watch(owner: Inst, k: any) {
|
|
45
|
+
k.bs = bindingState(k)
|
|
46
|
+
;(owner.bound ||= new Set()).add(k)
|
|
47
|
+
owner.app.watchers.add(owner)
|
|
48
|
+
}
|
|
49
|
+
|
|
41
50
|
function bindingState(k: any): any {
|
|
42
51
|
const c = k.base || k.cell || k.arr
|
|
43
52
|
return c && (c.has?.() ?? c.get() !== undefined) ? (k.cell || k.arr).get() : undefined
|
|
@@ -192,7 +201,7 @@ export class Inst {
|
|
|
192
201
|
// flag they were computed from; its reply streams
|
|
193
202
|
st: Array<[string, string]> | null = null
|
|
194
203
|
sv: Record<string, any> | null = null
|
|
195
|
-
sS: any; sH: any
|
|
204
|
+
sS: any; sH: any; sC: any
|
|
196
205
|
rep: any[] | null = null
|
|
197
206
|
|
|
198
207
|
constructor(
|
|
@@ -537,11 +546,7 @@ export class Inst {
|
|
|
537
546
|
if (k && (k.disposed || (view && k.def && k.def.view !== view) || bindingChanged(k.props?.[binding], props[binding]))) { k.dispose(); this.kids.delete(id); k = undefined }
|
|
538
547
|
if (k) k.setProps(props, children, n, id)
|
|
539
548
|
else this.kids.set(id, k = host ? this.host(host, props, children, id, n) : this.child(view, props, children, id, !!data.props?.resetState))
|
|
540
|
-
if (
|
|
541
|
-
k.bs = bindingState(k)
|
|
542
|
-
;(this.bound ||= new Set()).add(k)
|
|
543
|
-
this.app.watchers.add(this)
|
|
544
|
-
}
|
|
549
|
+
if (known(props[binding])) watch(this, k)
|
|
545
550
|
return
|
|
546
551
|
}
|
|
547
552
|
if (posts[sel] && !(ps ||= []).includes(sel)) ps.push(sel)
|
package/src/core/statics.ts
CHANGED
|
@@ -76,17 +76,17 @@ const shownOf = (inst: Inst) => {
|
|
|
76
76
|
/** recompute the instance's declarations when its state or shown flag changed; send the changed ones */
|
|
77
77
|
export function checkStatics(inst: Inst) {
|
|
78
78
|
if (inst.disposed) return
|
|
79
|
-
const s = inst.cell.get(), shown = shownOf(inst)
|
|
79
|
+
const s = inst.cell.get(), shown = shownOf(inst), c = inst.context()
|
|
80
80
|
// no state yet: nothing declared (as the state stream, which skips undefined)
|
|
81
|
-
if (s === undefined || (s === inst.sS && shown === inst.sH)) return
|
|
82
|
-
inst.sS = s; inst.sH = shown
|
|
81
|
+
if (s === undefined || (s === inst.sS && shown === inst.sH && c === inst.sC)) return
|
|
82
|
+
inst.sS = s; inst.sH = shown; inst.sC = c
|
|
83
83
|
const view = inst.def.view, sv = inst.sv!
|
|
84
84
|
for (const [n, k] of inst.st!) {
|
|
85
85
|
const f = view[k]
|
|
86
86
|
let v: any = f
|
|
87
87
|
try {
|
|
88
|
-
if (typeof f == 'function') v = f(s)
|
|
89
|
-
else if (f && typeof f == 'object') { v = {}; for (const r in f) v[r] = f[r](s) }
|
|
88
|
+
if (typeof f == 'function') v = f(s, c)
|
|
89
|
+
else if (f && typeof f == 'object') { v = {}; for (const r in f) v[r] = f[r](s, c) }
|
|
90
90
|
} catch (e) {
|
|
91
91
|
// as today: nothing sent; the next value is sent even when equal to the last one
|
|
92
92
|
inst.app.caught(inst, 'SYG216', `${k} threw; nothing sent`, e, 'declaration')
|
|
@@ -65,12 +65,14 @@ export async function post(url: string, body: any, req: any, signal: AbortSignal
|
|
|
65
65
|
* E-1: `meta` (optional) follows the stream's `id` (the last event id, kept across frames) and
|
|
66
66
|
* `retry` (ms) fields, as EventSource does, for a reconnect
|
|
67
67
|
*/
|
|
68
|
-
export async function* sse(res: any, meta?: {id?: string; retry?: number}): AsyncGenerator<{event: string; data: string}> {
|
|
68
|
+
export async function* sse(res: any, meta?: {id?: string; retry?: number; beat?: () => void}): AsyncGenerator<{event: string; data: string}> {
|
|
69
69
|
const reader = res.body.getReader(), dec = new TextDecoder();
|
|
70
70
|
let buf = '', cr = '', done = false;
|
|
71
71
|
try {
|
|
72
72
|
for (;;) {
|
|
73
73
|
const r = await reader.read();
|
|
74
|
+
// PLAN-13 S-1: any bytes (a heartbeat comment too) show the stream is alive
|
|
75
|
+
if (!r.done) meta?.beat?.();
|
|
74
76
|
// line ends -> \n (a \r at a chunk's end waits: it may be half of a \r\n)
|
|
75
77
|
let s = cr + (r.done ? '\n\n' : dec.decode(r.value, {stream: true}));
|
|
76
78
|
cr = !r.done && s.endsWith('\r') ? '\r' : '';
|
package/src/extra/behaviors.ts
CHANGED
|
@@ -96,10 +96,10 @@ const addTimers = (v: any): void => {
|
|
|
96
96
|
if (timed.has(v)) return
|
|
97
97
|
timed.add(v)
|
|
98
98
|
let own = v.timers
|
|
99
|
-
const all = (st: any) => {
|
|
99
|
+
const all = (st: any, c?: any) => {
|
|
100
100
|
const o: any = {}, f = own
|
|
101
|
-
if (typeof f == 'function') Object.assign(o, f(st))
|
|
102
|
-
else if (f && typeof f == 'object') for (const r in f) o[r] = f[r](st)
|
|
101
|
+
if (typeof f == 'function') Object.assign(o, f(st, c))
|
|
102
|
+
else if (f && typeof f == 'object') for (const r in f) o[r] = f[r](st, c)
|
|
103
103
|
const u = v.uses
|
|
104
104
|
for (const k in u) {
|
|
105
105
|
const b = u[k], t = b?.timers?.(st?.[k] ?? b.state, b.options, k), rn = (a: any) => a in (b.model || {}) ? k + '.' + a : a
|
|
@@ -18,11 +18,12 @@
|
|
|
18
18
|
* page after a patch, G-460: on rendered elements), SYG612 (D173: a form
|
|
19
19
|
* 6.0 removed, met at runtime; once per form and component, with a link to the migration
|
|
20
20
|
* guide), SYG453 (PLAN-9, D470: two children with one identity share one instance), SYG426
|
|
21
|
-
* (PLAN-9, D471: a lowercase <collection> / <switchable> tag, deprecated in 6.3.0)
|
|
21
|
+
* (PLAN-9, D471: a lowercase <collection> / <switchable> tag, deprecated in 6.3.0), SYG117
|
|
22
|
+
* (PLAN-13 N-1: next() to an action the component has no model entry for).
|
|
22
23
|
*
|
|
23
24
|
* Only while diagnostics are on (the checks' own rule); production builds never load this.
|
|
24
25
|
*/
|
|
25
|
-
import {bridge, devReport, reportSafely, once, onReset, nameOf, isPlainObject} from './shared'
|
|
26
|
+
import {bridge, devReport, reportSafely, once, onReset, nameOf, isPlainObject, didYouMean} from './shared'
|
|
26
27
|
import {DEV_CODE_SEVERITY} from '../codes'
|
|
27
28
|
import {watchMutation} from './state'
|
|
28
29
|
import {checkRequest} from './replies'
|
|
@@ -202,6 +203,31 @@ function lowercaseTags(iv: any): void {
|
|
|
202
203
|
walk(raw.tmpl, 0)
|
|
203
204
|
}
|
|
204
205
|
|
|
206
|
+
/**
|
|
207
|
+
* PLAN-13 F-3 SYG429: a Switchable's per-page `state={{ page: … }}` with a key that names no page
|
|
208
|
+
* (read from the owner's template, as SYG426; 0 production bytes). Once per component and key
|
|
209
|
+
*/
|
|
210
|
+
function switchableMaps(iv: any): void {
|
|
211
|
+
const raw = iv.i
|
|
212
|
+
if (!raw?.kids?.size || !raw.tmpl) return
|
|
213
|
+
const walk = (n: any, d: number): void => {
|
|
214
|
+
if (!n || typeof n != 'object' || n.$p || d > 300) return
|
|
215
|
+
const p = n.sel == 'switchable' && n.data?.props, st = p?.state
|
|
216
|
+
if (st && isPlainObject(st) && typeof st.get != 'function' && isPlainObject(p.of)) {
|
|
217
|
+
const pages = Object.keys(p.of)
|
|
218
|
+
for (const k of Object.keys(st)) if (!(k in p.of) && once(`SYG429:${iv.name}:${k}`)) devReport('SYG429', {
|
|
219
|
+
component: iv.name,
|
|
220
|
+
message: `${iv.name}'s Switchable state map binds '${k}', which is not one of its pages (${pages.join(', ')}), so that binding is never used${didYouMean(k, pages)}`,
|
|
221
|
+
fix: 'Use the page name from of={{ … }} as the key, or remove the entry',
|
|
222
|
+
data: {key: k, pages},
|
|
223
|
+
})
|
|
224
|
+
}
|
|
225
|
+
const kids = n.children
|
|
226
|
+
if (Array.isArray(kids)) for (const c of kids) walk(c, d + 1)
|
|
227
|
+
}
|
|
228
|
+
walk(raw.tmpl, 0)
|
|
229
|
+
}
|
|
230
|
+
|
|
205
231
|
/** the instance fields the checks read from today's Component instances */
|
|
206
232
|
class Facade {
|
|
207
233
|
_so: any
|
|
@@ -430,7 +456,7 @@ export function nextHooks(_api: any): any {
|
|
|
430
456
|
H.onRender(f, vnode)
|
|
431
457
|
if (!on()) return
|
|
432
458
|
removedTags(f, vnode)
|
|
433
|
-
try { lowercaseTags(iv) } catch (_) { /* a check never breaks the app */ }
|
|
459
|
+
try { lowercaseTags(iv); switchableMaps(iv) } catch (_) { /* a check never breaks the app */ }
|
|
434
460
|
// SYG453: only a view with children can render one twice
|
|
435
461
|
if (iv.children().length) try { sharedIdentity(iv, vnode) } catch (_) { /* a check never breaks the app */ }
|
|
436
462
|
},
|
|
@@ -460,6 +486,23 @@ export function nextHooks(_api: any): any {
|
|
|
460
486
|
if (src && src.__sygnalStatic === 'resources') checkFetch(f, sink, v)
|
|
461
487
|
} catch (_) { /* a check never breaks the app */ }
|
|
462
488
|
},
|
|
489
|
+
// SYG117 (PLAN-13 N-1): next('X') goes to the component's own model only; with no entry
|
|
490
|
+
// for X it does nothing (an ancestor's entry of that name never runs)
|
|
491
|
+
onNext(iv: any, type: string) {
|
|
492
|
+
if (!on() || iv.def.handlers.has(type)) return
|
|
493
|
+
const name = iv.name
|
|
494
|
+
if (!once(`SYG117:${name}:${type}`)) return
|
|
495
|
+
let up: any = live.get(iv.parentId)
|
|
496
|
+
while (up && !up.def.handlers.has(type)) up = live.get(up.parentId)
|
|
497
|
+
devReport('SYG117', {
|
|
498
|
+
component: name,
|
|
499
|
+
message: `${name} calls next('${type}'), but ${name} has no model entry '${type}', so the action does nothing${up ? `: next() never reaches ${up.name}, which handles '${type}'` : ''}${didYouMean(type, iv.def.handlers.keys())}`,
|
|
500
|
+
fix: up
|
|
501
|
+
? `next() stays in its own component. Send it up instead: give the entry that calls it a PARENT sink (PARENT: (state) => ({ … })) and add ${type}: CHILD.select(${name}) to ${up.name}.intent, or emit EVENTS: event('${type}', …) and select it there`
|
|
502
|
+
: `Add the entry to ${name}.model (${type}: (state, data) => …), or fix the name to match an existing one`,
|
|
503
|
+
data: {action: type, ...(up ? {handledBy: up.name} : {})},
|
|
504
|
+
})
|
|
505
|
+
},
|
|
463
506
|
onElementCommand(iv: any, cmd: any) {
|
|
464
507
|
if (!on()) return
|
|
465
508
|
const f = fac(iv)
|
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
* lens (a state field name or a { get, set } object), so it is only reported
|
|
9
9
|
* when the value can't be a lens.
|
|
10
10
|
*
|
|
11
|
+
* A Switchable page is exempt from the `state` part: it gets the Switchable's props, and their
|
|
12
|
+
* `state` is the host's binding (PLAN-13 F-3's per-page map is not a lens, and isn't the page's).
|
|
13
|
+
*
|
|
11
14
|
* Mechanism: onRender reads the component's current (sanitized) props.
|
|
12
15
|
* Reported once per component name and prop.
|
|
13
16
|
*/
|
|
@@ -39,7 +42,9 @@ export const propsCheck: DiagnosticCheck = {
|
|
|
39
42
|
})
|
|
40
43
|
}
|
|
41
44
|
|
|
42
|
-
|
|
45
|
+
// PLAN-13 F-3 follow-up: a Switchable page gets the Switchable's props, whose `state` is the
|
|
46
|
+
// host's binding (a per-page map too), consumed by the host: not a prop meant for the page
|
|
47
|
+
if ('state' in props && component.__next?.kind !== 'page') {
|
|
43
48
|
const value = props.state
|
|
44
49
|
const isLens = typeof value === 'string' || (value && typeof value === 'object' && typeof value.get === 'function')
|
|
45
50
|
if (!isLens && value !== undefined && once(`SYG106:${name}:state`)) {
|
|
@@ -146,6 +146,8 @@ export const CODE_TITLES: Record<string, string> = {
|
|
|
146
146
|
SYG111: 'Controlled input has no input listener',
|
|
147
147
|
SYG115: 'Unknown DOM event shorthand',
|
|
148
148
|
SYG116: 'EVENTS value has no string type',
|
|
149
|
+
// PLAN-13 N-1: dev entry (checks/next.ts onNext) and sygnal-check
|
|
150
|
+
SYG117: 'next() names an action with no model entry',
|
|
149
151
|
SYG124: 'Component used as a control or selector',
|
|
150
152
|
SYG125: 'Control given .intent, .model or .initialState',
|
|
151
153
|
SYG126: 'Control rendered but never listened to',
|
|
@@ -181,6 +183,8 @@ export const CODE_TITLES: Record<string, string> = {
|
|
|
181
183
|
// PLAN-5 V-1: VirtualCollection (checks/virtual.ts)
|
|
182
184
|
SYG430: 'VirtualCollection has no bounded height',
|
|
183
185
|
SYG431: 'VirtualCollection items without ids',
|
|
186
|
+
// PLAN-13 F-3: dev entry (checks/next.ts onRender) and sygnal-check
|
|
187
|
+
SYG429: 'Switchable state map names no page',
|
|
184
188
|
SYG432: 'VirtualCollection item does not render one element',
|
|
185
189
|
SYG433: 'VirtualCollection scroll target not in the list',
|
|
186
190
|
SYG434: 'Invalid VirtualCollection estimateSize or overscan',
|
|
@@ -196,6 +200,8 @@ export const CODE_TITLES: Record<string, string> = {
|
|
|
196
200
|
SYG202: 'STATE reducer returned undefined',
|
|
197
201
|
// SYG3xx streams & intent construction (1A)
|
|
198
202
|
SYG301: 'RxJS operator used on an xstream stream',
|
|
203
|
+
// PLAN-13 X-1: static only (sygnal-check)
|
|
204
|
+
SYG302: "Default import from 'xstream'",
|
|
199
205
|
// SYG4xx collections / switchable / sub-components / context (1A)
|
|
200
206
|
SYG401: "Collection 'from' field is missing or not an array",
|
|
201
207
|
// SYG5xx strict mode / canonical forms (2A; dev-plans/PLAN-1-canonical-forms.md)
|
|
@@ -426,6 +432,7 @@ export const DEV_CODE_SEVERITY: Record<string, DiagnosticSeverity> = {
|
|
|
426
432
|
SYG112: 'error',
|
|
427
433
|
SYG115: 'warn',
|
|
428
434
|
SYG116: 'error',
|
|
435
|
+
SYG117: 'warn',
|
|
429
436
|
// PLAN-4 1-D: controls, static (sygnal-check)
|
|
430
437
|
SYG124: 'error',
|
|
431
438
|
SYG125: 'error',
|
|
@@ -437,6 +444,10 @@ export const DEV_CODE_SEVERITY: Record<string, DiagnosticSeverity> = {
|
|
|
437
444
|
SYG129: 'warn',
|
|
438
445
|
// PLAN-10 X-1: static only (sygnal-check)
|
|
439
446
|
SYG170: 'warn',
|
|
447
|
+
// PLAN-13 X-1: static only (sygnal-check)
|
|
448
|
+
SYG302: 'info',
|
|
449
|
+
// PLAN-13 F-3 (checks/next.ts)
|
|
450
|
+
SYG429: 'warn',
|
|
440
451
|
SYG171: 'warn',
|
|
441
452
|
SYG221: 'error',
|
|
442
453
|
// PLAN-4 GS-4 (state.ts)
|
package/src/extra/fetchDriver.ts
CHANGED
|
@@ -431,6 +431,9 @@ export function makeFetchDriver(options: any = {}) {
|
|
|
431
431
|
if (E && 'v' in E && !q.validate) write(s, n, R, 'success', E.v, undefined, go);
|
|
432
432
|
else if ((same || q.keepPrevious) && L && SETTLED.test(L.status)) write(s, n, R, L.status, L.data, L.error, true);
|
|
433
433
|
else write(s, n, R, 'loading');
|
|
434
|
+
// R-1: the write is dispatched synchronously; when it changed the request (a navigation
|
|
435
|
+
// recomputed the statics and a nested load sent the new one) or paused it, q is stale
|
|
436
|
+
if (R.q !== q || R.p) return;
|
|
434
437
|
if (go || q.validate) R.i = send(q, s, n, after, R);
|
|
435
438
|
};
|
|
436
439
|
|
|
@@ -13,6 +13,8 @@ import {sse} from './ai/transports/shared';
|
|
|
13
13
|
* - a non-2xx status or another content type: `error` with readyState CLOSED (no retry; the
|
|
14
14
|
* driver's reconnect policy decides);
|
|
15
15
|
* - `close()` aborts the request and stops retrying.
|
|
16
|
+
* - PLAN-13 S-1: `init.lastEventId` seeds the id sent as `Last-Event-ID` (a driver reconnect that
|
|
17
|
+
* resumes); `onbeat` (not EventSource's) is called for any bytes read, heartbeat comments too.
|
|
16
18
|
*/
|
|
17
19
|
|
|
18
20
|
const NAMES = ['CONNECTING', 'OPEN', 'CLOSED'];
|
|
@@ -27,16 +29,18 @@ export class FetchEventSource {
|
|
|
27
29
|
onopen: any = null;
|
|
28
30
|
onmessage: any = null;
|
|
29
31
|
onerror: any = null;
|
|
32
|
+
onbeat: any = null;
|
|
30
33
|
private ls = new Map<string, Set<(e: any) => void>>();
|
|
31
|
-
private meta: {id?: string; retry?: number} = {id: '', retry: 3000};
|
|
34
|
+
private meta: {id?: string; retry?: number; beat?: () => void} = {id: '', retry: 3000, beat: () => this.onbeat?.()};
|
|
32
35
|
private ctl: any;
|
|
33
36
|
private timer: any;
|
|
34
37
|
private init: any;
|
|
35
38
|
|
|
36
|
-
constructor(url: string, init: {withCredentials?: boolean; headers?: Record<string, string>; fetch?: any} = {}) {
|
|
39
|
+
constructor(url: string, init: {withCredentials?: boolean; headers?: Record<string, string>; fetch?: any; lastEventId?: string} = {}) {
|
|
37
40
|
this.url = url;
|
|
38
41
|
this.withCredentials = !!init.withCredentials;
|
|
39
42
|
this.init = init;
|
|
43
|
+
if (init.lastEventId) this.meta.id = init.lastEventId;
|
|
40
44
|
queueMicrotask(() => this.connect());
|
|
41
45
|
}
|
|
42
46
|
|
|
@@ -41,6 +41,12 @@ import {FetchEventSource} from './fetchEventSource';
|
|
|
41
41
|
* changed header reconnects) is read with FetchEventSource (fetch, `options.fetch` if given),
|
|
42
42
|
* since EventSource can't send headers. An `options.EventSource` (a polyfill, the test fake)
|
|
43
43
|
* still wins and gets the headers in its init.
|
|
44
|
+
* - PLAN-13 S-1: SSE `meta: true`: message and `events` actions get `{ data, id, event }`. `resume:
|
|
45
|
+
* true | 'param'`: the transport keeps the last event id, and a reconnect the driver makes (after
|
|
46
|
+
* EventSource gave up) asks to resume from it: `?lastEventId=` (or `?param=`) on the URL, or
|
|
47
|
+
* `Last-Event-ID` on the fetch path (`init.lastEventId`). `staleAfterMs` (SSE and WebSocket): no
|
|
48
|
+
* event for that long (over fetch: no bytes, heartbeat comments count) closes the connection as a
|
|
49
|
+
* drop (`close` gets `stale: true`), so the reconnect policy applies.
|
|
44
50
|
*/
|
|
45
51
|
|
|
46
52
|
const SCOPE = '__sygnalScope';
|
|
@@ -96,19 +102,40 @@ export function makeSocketDriver(options: any = {}) {
|
|
|
96
102
|
const out = (t: any, data: any, e: any) => {
|
|
97
103
|
try { t.s.send(data); } catch (error) { deliver(e, 'error', {error}); }
|
|
98
104
|
};
|
|
105
|
+
// S-1: an SSE event as a subscriber gets it (`meta`: with its id and type); the transport
|
|
106
|
+
// keeps the last id (resume) and counts it as life (staleAfterMs)
|
|
107
|
+
const got = (t: any, m: any, e: any) => e.sse && e.spec.meta ? {data: parse(m.data), id: m.lastEventId ?? '', event: m.type || 'message'} : parse(m.data);
|
|
108
|
+
const seen = (t: any, m: any) => { if (m.lastEventId) t.id = m.lastEventId; alive(t); };
|
|
99
109
|
// SSE named events: one listener per name across the subscribers
|
|
100
110
|
const listen = (t: any) => t.s && t.sse && each(t, e => Object.keys(e.spec.events || {}).forEach(name => {
|
|
101
111
|
if (t.on.has(name)) return;
|
|
102
112
|
t.on.add(name);
|
|
103
113
|
const s = t.s;
|
|
104
|
-
s.addEventListener(name, (m: any) =>
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
114
|
+
s.addEventListener(name, (m: any) => {
|
|
115
|
+
if (t.s !== s) return;
|
|
116
|
+
seen(t, m);
|
|
117
|
+
each(t, x => {
|
|
118
|
+
const a = x.spec.events?.[name];
|
|
119
|
+
if (typeof a == 'string' && x.sender !== undefined) reply(x.sender, a, got(t, m, x));
|
|
120
|
+
});
|
|
121
|
+
});
|
|
108
122
|
}));
|
|
123
|
+
// S-1: staleAfterMs (the smallest of the subscribers'): re-armed by every event; when it fires
|
|
124
|
+
// the connection is dropped (as if the server had gone) and the reconnect policy applies
|
|
125
|
+
const alive = (t: any) => {
|
|
126
|
+
clearTimeout(t.st);
|
|
127
|
+
const ms = Math.min(...[...t.subs].map(e => e.spec.staleAfterMs).filter(x => x > 0));
|
|
128
|
+
const s = t.s;
|
|
129
|
+
if (s && ms < 1 / 0) t.st = setTimeout(() => {
|
|
130
|
+
if (t.s !== s) return;
|
|
131
|
+
try { s.close(); } catch (_) {}
|
|
132
|
+
gone(t, t.sse ? {stale: true} : {code: 1006, reason: 'stale', stale: true});
|
|
133
|
+
}, ms);
|
|
134
|
+
};
|
|
109
135
|
|
|
110
136
|
// the socket closed (or failed to open) without the driver closing it
|
|
111
137
|
const gone = (t: any, info: any) => {
|
|
138
|
+
clearTimeout(t.st);
|
|
112
139
|
t.s = null;
|
|
113
140
|
t.open = false;
|
|
114
141
|
let again: any;
|
|
@@ -126,9 +153,19 @@ export function makeSocketDriver(options: any = {}) {
|
|
|
126
153
|
const hdr = t.sse && t.spec.headers && typeof t.spec.headers == 'object' ? t.spec.headers : undefined;
|
|
127
154
|
const C = t.sse ? options.EventSource ?? (hdr && typeof g.fetch == 'function' ? FetchEventSource : g.EventSource) : options.WebSocket ?? g.WebSocket;
|
|
128
155
|
if (typeof C != 'function') return;
|
|
156
|
+
// S-1: resume from the last event id: Last-Event-ID on the fetch path (init.lastEventId, which
|
|
157
|
+
// a polyfill or the test fake gets too), else a URL parameter
|
|
158
|
+
// (a parameter the URL already has is replaced, not added again)
|
|
159
|
+
const r = t.sse && t.id && [...t.subs].map(e => e.spec.resume).find(x => x);
|
|
160
|
+
let url = t.url;
|
|
161
|
+
if (r && !hdr) {
|
|
162
|
+
const p = encodeURIComponent(r === true ? 'lastEventId' : r) + '=', v = p + encodeURIComponent(t.id);
|
|
163
|
+
const re = new RegExp('([?&])' + p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '[^&#]*');
|
|
164
|
+
url = re.test(url) ? url.replace(re, '$1' + v) : url + (url.includes('?') ? '&' : '?') + v;
|
|
165
|
+
}
|
|
129
166
|
let s: any;
|
|
130
167
|
try {
|
|
131
|
-
s = t.sse ? new C(
|
|
168
|
+
s = t.sse ? new C(url, {withCredentials: !!t.spec.withCredentials, ...(hdr && {headers: hdr, fetch: options.fetch}), ...(r && {lastEventId: t.id})}) : new C(t.url, t.spec.protocols);
|
|
132
169
|
} catch (error) {
|
|
133
170
|
// a URL the constructor rejects never opens: no retry
|
|
134
171
|
each(t, e => deliver(e, 'error', {error}));
|
|
@@ -141,12 +178,19 @@ export function makeSocketDriver(options: any = {}) {
|
|
|
141
178
|
s.onopen = mine(() => {
|
|
142
179
|
t.n = 0;
|
|
143
180
|
t.open = true;
|
|
181
|
+
alive(t);
|
|
144
182
|
each(t, e => { deliver(e, 'open', {reconnected: !!e.opened}); e.opened = true; });
|
|
145
183
|
const q = t.queue;
|
|
146
184
|
t.queue = [];
|
|
147
185
|
q.forEach((x: any) => t.s === s && out(t, x.data, x.e));
|
|
148
186
|
});
|
|
149
|
-
s.onmessage = mine((m: any) => {
|
|
187
|
+
s.onmessage = mine((m: any) => {
|
|
188
|
+
if (t.sse) seen(t, m); else alive(t);
|
|
189
|
+
const d = parse(m.data);
|
|
190
|
+
each(t, e => deliver(e, 'message', e.spec.meta && t.sse ? got(t, m, e) : d));
|
|
191
|
+
});
|
|
192
|
+
// S-1: FetchEventSource's bytes (heartbeat comments) count as life
|
|
193
|
+
s.onbeat = mine(() => alive(t));
|
|
150
194
|
s.onerror = mine((error: any) => {
|
|
151
195
|
each(t, e => deliver(e, 'error', {error}));
|
|
152
196
|
if (t.sse) {
|
|
@@ -162,6 +206,7 @@ export function makeSocketDriver(options: any = {}) {
|
|
|
162
206
|
// closes the driver makes: no `close` action, late events ignored, retry cancelled
|
|
163
207
|
const end = (t: any) => {
|
|
164
208
|
clearTimeout(t.timer);
|
|
209
|
+
clearTimeout(t.st);
|
|
165
210
|
transports.delete(t.key);
|
|
166
211
|
const s = t.s;
|
|
167
212
|
t.s = null;
|