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/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
|
package/dist/astro/server.cjs.js
CHANGED
|
@@ -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
|
|
3177
|
-
|
|
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 =
|
|
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
|
|
3649
|
-
const
|
|
3650
|
-
const mergedContext =
|
|
3651
|
-
|
|
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({
|