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 CHANGED
@@ -2,6 +2,132 @@
2
2
 
3
3
  All notable changes to Sygnal are listed here. Versions follow [semantic versioning](https://semver.org). Releases before 5.4.0 are described in the [GitHub releases](https://github.com/tpresley/sygnal/releases) and tags.
4
4
 
5
+ ## 7.1.0 — 2026-10-20
6
+
7
+ Fixes and additions from building an app (Temper's Tune UI) on 7.0 ([PLAN-13](dev-plans/PLAN-13.md)):
8
+ an invalidation / navigation race, declaration statics that see the context, per-page Switchable
9
+ state, SSE resume and dead-stream detection, reply helpers for hand-written drivers, and more
10
+ precise `sygnal-check` findings. All additive.
11
+
12
+ **Measured impact.** Size: the kanban gate is (a) 42,982 B with `nativeGlobalThis: false` (7.0.0:
13
+ 42,844 B; budget 43,000 B, 18 B headroom) and (b) 38,987 B by default. Statics with context cost
14
+ 19 B and the per-page Switchable map 126 B (it reuses the owner's binding watch for a page whose
15
+ record is deleted); everything else is in drivers, the dev diagnostics entry, the test helper or
16
+ `sygnal-check`, and adds nothing to an app that doesn't use it. The perf count gate is within its
17
+ limits.
18
+
19
+ **Companion packages.**
20
+ - **`sygnal-check` 0.12.0:** SYG117 (`next()` to an action with no entry here), SYG302 (`import xs
21
+ from 'xstream'`, with `--fix`), SYG429 (a Switchable state map key that names no page); reply names
22
+ through request helpers and consts; SYG256 sees past helper calls in other fields; `next` followed
23
+ into helpers, imported ones included; context reads in the statics count for SYG170; `replyKeys`
24
+ in the config file. Its caret range doesn't reach 0.12.0 from `^0.11.0`, so update the dev
25
+ dependency.
26
+ - **`create-sygnal-app` 2.10.0:** templates depend on `sygnal` ^7.1.0 and `sygnal-check` ^0.12.0, use
27
+ a system font stack (no Google Fonts) and scaffold a `.gitignore`.
28
+ - **VS Code extension 0.9.0:** bundles `sygnal-check` 0.12.0.
29
+ - **Claude Code plugin 0.2.1:** the `sygnal-dev` skill covers 7.1 (statics with context, per-page
30
+ Switchable state, SSE resume, reply helpers, `import { xs } from 'sygnal'`).
31
+
32
+ ### Added
33
+
34
+ - **SYG117: `next()` to an action the component has no entry for (PLAN-13 N-1).** `next('X')`
35
+ reaches only its own component's model, so a child's `next('ENTER')` for an action only its
36
+ parent handles silently did nothing. The dev checks (`sygnal/diagnostics`, 0 production bytes)
37
+ warn on the first such `next()` per component and name, naming the ancestor that handles it and
38
+ the fix (send it up with `PARENT` or `EVENTS`), or the closest entry for a typo. `sygnal-check`
39
+ reports a literal `next('X')` with no entry `X` in the same component (with a rename fix for a typo).
40
+
41
+ - **sygnal-check reads reply names through request helpers and consts (PLAN-13 C-1).** An object
42
+ argument with a reply key of a call a sink returns is a request (`req('/a', { ok: 'GOT_A' })`,
43
+ `api.get_run(args, { ok: 'RUN' })`, any callee), a returned const is followed (`HTTP: () => SNAP`),
44
+ and so are `connections` specs and SSE `events` maps held in consts. SYG102 no longer reports
45
+ their reply actions as unreachable, SYG112 checks the names, SYG152 and `--graph` see the requests.
46
+
47
+ - **SYG256 sees past helper calls in other fields (PLAN-13 C-2).** A call in the STATE reducer
48
+ stopped the proof, so a leave-guard like `EDIT: { STATE: (s, d) => ({ ...s, dirty: true, edit:
49
+ { ...s.edit, [keyOf(s)]: d } }), ROUTER: (s) => s.dirty ? ABORT : … }` went unreported. A call
50
+ (or a function) inside a plain field's value now only makes that field unknown; calls in a test,
51
+ a spread or a computed key of the written object, and any assignment, still keep the check out.
52
+
53
+ - **create-sygnal-app: no Google Fonts, and a `.gitignore` (PLAN-13 T-1).** The vite, vite-pwa and
54
+ astro templates loaded Inter from Google Fonts; every template now uses a system font stack and
55
+ loads nothing remote. No template wrote a `.gitignore` (npm leaves it out of the package): each
56
+ ships `gitignore`, scaffolded as `.gitignore` (`node_modules`, `dist`, `.vite`, `.astro`,
57
+ `*.local`, `dev-dist` for the PWA). The release smoke checks both.
58
+
59
+ - **SYG302: `import xs from 'xstream'` (PLAN-13 X-1).** Under Node ESM and Vite 8 (a `"type":
60
+ "module"` package) xstream's CommonJS default import is the module object, so a custom driver's
61
+ `xs.create` is undefined. The docs, llms.txt and the skill use `import { xs } from 'sygnal'`
62
+ (the accessibility guide's sample was the last default import), the custom-drivers guide says
63
+ why, and `sygnal-check` reports the default import (info) with a `--fix`.
64
+
65
+ - **`makeReplies()` and `senderOf()` for drivers you write (PLAN-13 D-1).** The reply-action
66
+ protocol `makeFetchDriver`, `driverFromAsync` and the socket driver use is exported and typed
67
+ (`ReplySender`, `ReplyChannel`): spread `replies` into the source, `reply(senderOf(req), type,
68
+ data)` as often as needed (progress, then a result), and abort a disposed sender's work in
69
+ `onStop`. The custom-drivers guide has an upload driver that replies more than once.
70
+
71
+ - **SSE event ids, resuming and dead streams (PLAN-13 S-1).** On a `connections` spec: `meta:
72
+ true` gives SSE `message` / `events` actions `{ data, id, event }`; `resume: true | 'param'`
73
+ keeps the last event id and makes a reconnect the driver makes (after EventSource gave up, e.g. a
74
+ proxy 502, or a non-2xx on the `headers` path) resume from it, as `Last-Event-ID` over fetch or
75
+ `?lastEventId=` (or `?param=`) on the EventSource URL; `staleAfterMs` (SSE and WebSocket) closes
76
+ a connection that stays silent that long as a drop (`close` gets `stale: true`) so the reconnect
77
+ policy applies; over fetch, heartbeat comments count. `t.push(sink, data, { id })` gives a test
78
+ event its id. No bytes for apps without a socket driver.
79
+
80
+ - **Declaration statics see the context (PLAN-13 F-1).** `resources`, `timers`, `connections`,
81
+ `head` and `browser` get `(state, context)` and are computed again when the component's context
82
+ changes (by identity, D603; an unrelated write re-runs nothing). A routed page reads `route` from
83
+ context (`App.context = { route: (s) => s.route }`, `TaskPage.resources = { task: (s, { route })
84
+ => … }`) instead of keeping a copy of the params in its own state. Server rendering passes the
85
+ context to `resources` and `head` too; the types take it as the second parameter; `sygnal-check`
86
+ counts context reads in the statics for SYG170. Core: +19 B (kanban gzip).
87
+
88
+ - **Per-page Switchable state (PLAN-13 F-3).** `state` takes a map from page names to bindings
89
+ (`state={{ projects: 'pages.projects', settings: byId('teams', state.teamId) }}`): each listed
90
+ page binds its own key, path, lens or byId selection with a tag child's lifetime rules (another
91
+ target makes only that page again; a deleted target disposes it on its last state), and the
92
+ pages not listed share the owner's state. A key that names no page is the new **SYG429** (dev
93
+ checks and `sygnal-check`, with a rename fix). Types infer the page names from `of`. Core:
94
+ +126 B (kanban gzip), leaving 18 B of the 43,000 B budget with F-1.
95
+
96
+ - **Deep-link tests start on their route (PLAN-13 F-2).** `renderComponent(App, { router, url })`
97
+ applies the root's `route` entry's STATE reducer to the initial state with the `url`'s route
98
+ before the first render: `initialState`'s `router.current()` ran at module load, so a test used
99
+ to render `/` first and send its requests. The entry's other sinks (a guard's redirect) still run
100
+ once, when the router answers. Tests only; no core change.
101
+
102
+ - **sygnal-check follows `next` into helpers (PLAN-13 C-3).** A model that passes `next` to a
103
+ function (`advance(state, next)`), imported or in the same file and with any parameter name, has
104
+ that function's `next('X')` calls counted as its own, 3 calls deep: SYG102 sees the targets, and
105
+ SYG117 reports a bad one in the helper's file. A helper it can't read (a package) makes the
106
+ targets unknown, so SYG102 drops to info. The language server re-checks the component when the
107
+ helper changes.
108
+
109
+ - **`replyKeys` in sygnal-check's config (PLAN-13 C-4).** A custom driver's own reply keys
110
+ (`cancelled: 'UP_CANCELLED'`) can be listed in `sygnal-check.config.json`, for every sink
111
+ (`["cancelled"]`) or per sink (`{ "UPLOAD": ["progress", "cancelled"] }`): their actions count for
112
+ SYG102 and are checked by SYG112. Without the setting, a request key the checker doesn't know
113
+ whose value names a model entry makes that entry's SYG102 info, pointing at `replyKeys`.
114
+
115
+ ### Fixed
116
+
117
+ - **Follow-ups from trying the PLAN-13 build in Temper.** A Switchable page with a per-page
118
+ `state` map no longer reports SYG106 for the Switchable's own `state` prop (every page did, so
119
+ `expectNoDiagnostics()` failed). The `sygnal-check` command line reads `replyKeys` (check,
120
+ `--fix`, `--graph`, `--base`; the API and the language server already did). SSE `resume` replaces
121
+ an id parameter the URL already has instead of adding a second one. An `every` timer ticks once
122
+ per interval under a test clock that freezes `Date.now()` while timers run (Playwright's
123
+ `setFixedTime()`): its drift-free delays had grown by one interval per tick (1, 3, 6, 10 s).
124
+ - **An invalidation no longer resends a resource's old request after a navigation (PLAN-13 R-1).**
125
+ When a write's `ok` action navigated (`ROUTER`) and its `invalidates` (a URL prefix or a tag)
126
+ matched a resource of a lens- or key-bound page, the page's new request was sent and then the
127
+ invalidation sent the old one in the same lane, cancelling the new one: the new page showed the
128
+ old record. A load now stops when the resource's request changed (or it was paused) while it
129
+ wrote its loading state; refresh, polling and focus refetches go through the same path.
130
+
5
131
  ## 7.0.0 — 2026-10-19
6
132
 
7
133
  **Read [Migrating to 7.0](https://sygnal.js.org/guide/migrating-to-7/) before upgrading.** 7.0
@@ -3063,7 +3063,7 @@ function withUses(def, state) {
3063
3063
  * state reads from the cache ('success' with the cached data), else 'loading' ('idle' for a
3064
3064
  * falsy request), as the client's first paint does with the same (hydrated) cache
3065
3065
  */
3066
- function withResources(def, state) {
3066
+ function withResources(def, state, ctx) {
3067
3067
  state = withUses(def, state);
3068
3068
  const res = def && def.resources;
3069
3069
  if (!res || !state || typeof state !== 'object' || Array.isArray(state))
@@ -3074,7 +3074,7 @@ function withResources(def, state) {
3074
3074
  continue;
3075
3075
  let q;
3076
3076
  try {
3077
- q = typeof res[n] === 'function' ? res[n](state) : res[n];
3077
+ q = typeof res[n] === 'function' ? res[n](state, ctx) : res[n];
3078
3078
  }
3079
3079
  catch (_) { }
3080
3080
  const E = q && ssrCache ? ssrCache.get(ssrCache.key(q, 1)) : undefined;
@@ -3121,15 +3121,33 @@ function viewFailed(def, err, name) {
3121
3121
  }
3122
3122
  return vnode;
3123
3123
  }
3124
- function collectHead(def, state) {
3124
+ function collectHead(def, state, ctx) {
3125
3125
  const h = def && def.head;
3126
3126
  if (!heads || !h)
3127
3127
  return;
3128
3128
  try {
3129
- heads.push(typeof h === 'function' ? h(state) : h);
3129
+ heads.push(typeof h === 'function' ? h(state, ctx) : h);
3130
3130
  }
3131
3131
  catch (_) { }
3132
3132
  }
3133
+ /**
3134
+ * PLAN-13 F-1: the context a component sees (its parent's, plus its own entries computed on
3135
+ * `state`). The statics get it as on the client; for `resources` it is computed on the state
3136
+ * before the resources are filled in (a context entry can't read the component's own resource)
3137
+ */
3138
+ function contextOf(def, state, parent) {
3139
+ const own = def.context || {}, out = { ...parent };
3140
+ for (const key of Object.keys(own)) {
3141
+ const f = own[key];
3142
+ if (typeof f === 'function' && state != null) {
3143
+ try {
3144
+ out[key] = f(state);
3145
+ }
3146
+ catch (_) { }
3147
+ }
3148
+ }
3149
+ return out;
3150
+ }
3133
3151
  /**
3134
3152
  * Render a Sygnal component to an HTML string.
3135
3153
  *
@@ -3170,20 +3188,10 @@ function renderRoot(componentDef, options) {
3170
3188
  const uid = (options.uid || 'u').replace(/[^\w-]+/g, '_');
3171
3189
  const ownState = state !== undefined ? state : componentDef.initialState;
3172
3190
  // 5-5: the view sees its resources; the hydration script keeps the state as it was given
3173
- const resolvedState = withResources(componentDef, ownState);
3174
- collectHead(componentDef, resolvedState);
3191
+ const resolvedState = withResources(componentDef, ownState, contextOf(componentDef, ownState, context));
3175
3192
  // Build context: merge parent context with component's own context definitions
3176
- const componentContext = componentDef.context || {};
3177
- const mergedContext = { ...context };
3178
- for (const key of Object.keys(componentContext)) {
3179
- const contextFn = componentContext[key];
3180
- if (typeof contextFn === 'function' && resolvedState != null) {
3181
- try {
3182
- mergedContext[key] = contextFn(resolvedState);
3183
- }
3184
- catch (_) { }
3185
- }
3186
- }
3193
+ const mergedContext = contextOf(componentDef, resolvedState, context);
3194
+ collectHead(componentDef, resolvedState, mergedContext);
3187
3195
  // Call the view function
3188
3196
  let vnode;
3189
3197
  try {
@@ -3394,19 +3402,9 @@ function renderSubComponent(vnode, context, parentState, uid) {
3394
3402
  // State passed directly
3395
3403
  childState = stateProp;
3396
3404
  }
3397
- childState = withResources(componentDef, childState);
3405
+ childState = withResources(componentDef, childState, contextOf(componentDef, childState, context));
3398
3406
  // Build child context
3399
- const childContext = { ...context };
3400
- const componentContext = componentDef.context || {};
3401
- for (const key of Object.keys(componentContext)) {
3402
- const contextFn = componentContext[key];
3403
- if (typeof contextFn === 'function' && childState != null) {
3404
- try {
3405
- childContext[key] = contextFn(childState);
3406
- }
3407
- catch (_) { }
3408
- }
3409
- }
3407
+ const childContext = contextOf(componentDef, childState, context);
3410
3408
  // Extract slots from children
3411
3409
  const slots = {};
3412
3410
  const defaultSlotChildren = [];
@@ -3428,7 +3426,7 @@ function renderSubComponent(vnode, context, parentState, uid) {
3428
3426
  slots.default = [];
3429
3427
  slots.default.push(...defaultSlotChildren);
3430
3428
  }
3431
- collectHead(componentDef, childState);
3429
+ collectHead(componentDef, childState, childContext);
3432
3430
  // Call the view function
3433
3431
  let result;
3434
3432
  try {
@@ -3645,19 +3643,10 @@ function renderSwitchable(vnode, context, parentState, uid) {
3645
3643
  * Internal helper: render a component def to a VNode (not HTML string).
3646
3644
  */
3647
3645
  function renderToStringInternal(componentDef, state, context, uid) {
3648
- const resolvedState = withResources(componentDef, state !== undefined ? state : componentDef.initialState);
3649
- const componentContext = componentDef.context || {};
3650
- const mergedContext = { ...context };
3651
- for (const key of Object.keys(componentContext)) {
3652
- const contextFn = componentContext[key];
3653
- if (typeof contextFn === 'function' && resolvedState != null) {
3654
- try {
3655
- mergedContext[key] = contextFn(resolvedState);
3656
- }
3657
- catch (_) { }
3658
- }
3659
- }
3660
- collectHead(componentDef, resolvedState);
3646
+ const own = state !== undefined ? state : componentDef.initialState;
3647
+ const resolvedState = withResources(componentDef, own, contextOf(componentDef, own, context));
3648
+ const mergedContext = contextOf(componentDef, resolvedState, context);
3649
+ collectHead(componentDef, resolvedState, mergedContext);
3661
3650
  let vnode;
3662
3651
  try {
3663
3652
  vnode = componentDef({