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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sygnal",
3
- "version": "7.0.0",
3
+ "version": "7.1.0",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }
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`. Page-wide events: `DOM.select('document' | 'body').events(type)`, CSS-filtered with `DOM.select('document').select('.overlay').events('click')`.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sygnal",
3
- "version": "7.0.0",
3
+ "version": "7.1.0",
4
4
  "description": "An intuitive framework for building fast and small components or applications based on Cycle.js",
5
5
  "main": "./dist/index.cjs.js",
6
6
  "types": "./dist/index.d.ts",
@@ -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, calc = typeof st == 'string' && owner.def.calcNames?.has(pathOf(st)[0])
65
- this.cell = typeof st == 'string' ? keyCell(owner.cell, st, calc && owner.def.name, undefined, 'Switchable sub-component')
66
- : st !== undefined ? lensCell(owner.cell, st, owner.def.name, (e) => owner.app.appError(owner, e, 'view'))
67
- : owner.cell
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 inst = new Inst(app, def, o, def.isolated ? localCell(app, this.cell) : this.cell, o.dom && o.dom.isolateSource(o.dom, scope),
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) {
@@ -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 (typeof props[binding] == 'string' || props[binding]?.[BINDING]) {
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)
@@ -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' : '';
@@ -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
- if ('state' in props) {
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)
@@ -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) => t.s === s && each(t, x => {
105
- const a = x.spec.events?.[name];
106
- if (typeof a == 'string' && x.sender !== undefined) reply(x.sender, a, parse(m.data));
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(t.url, {withCredentials: !!t.spec.withCredentials, ...(hdr && {headers: hdr, fetch: options.fetch})}) : new C(t.url, t.spec.protocols);
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) => { const d = parse(m.data); each(t, e => deliver(e, 'message', d)); });
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;