sygnal 6.6.0 → 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/CHANGELOG.md +132 -0
  2. package/README.md +2 -0
  3. package/dist/astro/index.cjs.js +32 -9
  4. package/dist/astro/index.cjs.js.map +1 -1
  5. package/dist/astro/index.mjs +32 -9
  6. package/dist/astro/index.mjs.map +1 -1
  7. package/dist/astro/server.cjs.js.map +1 -1
  8. package/dist/astro/server.mjs.map +1 -1
  9. package/dist/devtools.cjs.js.map +1 -1
  10. package/dist/devtools.esm.js.map +1 -1
  11. package/dist/diagnostics.cjs.js +87 -4
  12. package/dist/diagnostics.cjs.js.map +1 -1
  13. package/dist/diagnostics.esm.js +86 -5
  14. package/dist/diagnostics.esm.js.map +1 -1
  15. package/dist/guide/accessibility.md +2 -0
  16. package/dist/guide/ai-chat.md +6 -6
  17. package/dist/guide/behaviors.md +1 -1
  18. package/dist/guide/element-commands.md +4 -2
  19. package/dist/guide/forms-reference.md +89 -3
  20. package/dist/guide/forms.md +8 -8
  21. package/dist/guide/http.md +77 -7
  22. package/dist/guide/migrating-to-7.md +314 -0
  23. package/dist/guide/recipes/ai-summarize.md +2 -1
  24. package/dist/guide/recipes/i18n.md +1 -1
  25. package/dist/guide/ui/tabs.md +5 -4
  26. package/dist/guide/ui/tooltip.md +24 -0
  27. package/dist/index.cjs.js +772 -318
  28. package/dist/index.cjs.js.map +1 -1
  29. package/dist/index.d.ts +198 -31
  30. package/dist/index.esm.js +772 -318
  31. package/dist/index.esm.js.map +1 -1
  32. package/dist/sygnal.min.js +1 -1
  33. package/dist/sygnal.min.js.map +1 -1
  34. package/dist/ui.cjs.js +96 -11
  35. package/dist/ui.cjs.js.map +1 -1
  36. package/dist/ui.esm.js +96 -12
  37. package/dist/ui.esm.js.map +1 -1
  38. package/dist/vike/config/package.json +1 -1
  39. package/dist/vite/plugin.cjs.js +19 -9
  40. package/dist/vite/plugin.cjs.js.map +1 -1
  41. package/dist/vite/plugin.mjs +19 -9
  42. package/dist/vite/plugin.mjs.map +1 -1
  43. package/llms.txt +17 -16
  44. package/package.json +1 -1
  45. package/src/astro/index.d.ts +8 -0
  46. package/src/astro/index.ts +18 -0
  47. package/src/core/actions.ts +28 -21
  48. package/src/core/define.ts +2 -0
  49. package/src/core/hooks.ts +2 -2
  50. package/src/core/hosts/collection.ts +4 -3
  51. package/src/core/instance.ts +60 -16
  52. package/src/core/runtime.ts +5 -1
  53. package/src/extra/ai/chat/behavior.ts +3 -3
  54. package/src/extra/ai/commandBar.ts +3 -3
  55. package/src/extra/ai/compose/behavior.ts +3 -3
  56. package/src/extra/ai/transports/shared.ts +8 -2
  57. package/src/extra/behaviors.ts +21 -0
  58. package/src/extra/diagnostics/checks/fetch.ts +21 -1
  59. package/src/extra/diagnostics/checks/forms.ts +4 -3
  60. package/src/extra/diagnostics/checks/index.ts +1 -0
  61. package/src/extra/diagnostics/checks/next.ts +3 -0
  62. package/src/extra/diagnostics/checks/public.d.ts +12 -0
  63. package/src/extra/diagnostics/checks/timingAudit.ts +47 -0
  64. package/src/extra/diagnostics/codes.ts +13 -0
  65. package/src/extra/fetchDriver.ts +112 -7
  66. package/src/extra/fetchEventSource.ts +113 -0
  67. package/src/extra/form.ts +68 -26
  68. package/src/extra/formHelpers.ts +3 -1
  69. package/src/extra/lens.ts +3 -2
  70. package/src/extra/queryCache.ts +4 -3
  71. package/src/extra/socketDriver.ts +10 -4
  72. package/src/extra/testing.ts +68 -11
  73. package/src/index.d.ts +197 -30
  74. package/src/index.ts +1 -0
  75. package/src/shared.ts +3 -0
  76. package/src/ui/dialog.ts +3 -2
  77. package/src/ui/tooltips.ts +92 -0
  78. package/src/ui.d.ts +35 -0
  79. package/src/ui.ts +1 -0
  80. package/src/vite/graph.ts +11 -3
  81. package/src/vite/plugin.d.ts +7 -0
  82. package/src/vite/plugin.ts +10 -5
package/CHANGELOG.md CHANGED
@@ -2,6 +2,138 @@
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.0.0 — 2026-10-19
6
+
7
+ **Read [Migrating to 7.0](https://sygnal.js.org/guide/migrating-to-7/) before upgrading.** 7.0
8
+ has one reducer timing contract and no option to switch back; apps that aren't ready stay on 6.x.
9
+
10
+ **Measured impact.** Size: the kanban gate is (a) 42,844 B with `nativeGlobalThis: false` (6.6.0:
11
+ 42,495 B; budget raised from 42,700 B to 43,000 B for the binding lifetimes) and (b) 38,834 B by
12
+ default. Most of the growth is the binding-lifetime bookkeeping; the single timing contract is
13
+ smaller than the opt-in switch it replaced (−181 B against the 7.0 candidate). The timing audit is
14
+ in the dev diagnostics entry (0 production bytes), and the upload, SSE, form and `tooltips()`
15
+ features add nothing to an app that doesn't use them. The perf count gate is within its limits.
16
+
17
+ **Companion packages.**
18
+ - **`sygnal-check` 0.11.0:** checks reducer timing and binding changes against the Sygnal version
19
+ installed for each source package (7: every output gets the committed state, changed known
20
+ targets remount; 6: the 6.x contracts). New **SYG256** (an output guard that rejects the state
21
+ its own action writes, in a Sygnal 7 project); **SYG454** (an unkeyed dynamic `byId` selection)
22
+ only for an installed Sygnal 6. SYG731 accepts state announced through text, `aria-label`,
23
+ `aria-live` or `role="status"`; SYG609 also reports intent sources with no driver; `progress`
24
+ counts as a reply-action key (SYG102 / SYG112). Explanations for SYG257 and SYG636. Its caret
25
+ range doesn't reach 0.11.0 from `^0.10.0`, so update the dev dependency.
26
+ - **`create-sygnal-app` 2.9.0:** every template depends on `sygnal` ^7.0.0 and `sygnal-check`
27
+ ^0.11.0 and has `npm run check` (TypeScript templates: sygnal-check and `tsc --noEmit`).
28
+ Scaffolding into a directory that holds only `.git` and/or `.DS_Store` keeps that metadata, and
29
+ `.` gets a valid package name from the directory.
30
+ - **VS Code extension 0.8.0:** bundles `sygnal-check` 0.11.0 for Sygnal 7 and existing Sygnal 6
31
+ apps; workspace checkers need 0.11.0 or later (earlier ones fall back to the bundled checker).
32
+ - **Claude Code plugin 0.2.0:** the `sygnal-dev` skill describes Sygnal 7; a project's
33
+ `sygnal-check` older than 0.11.0 is refused only when the project has Sygnal 7.
34
+
35
+ ### Breaking: STATE runs first, and output sinks see its result
36
+
37
+ In every model entry STATE now runs first, whatever the key order, and every other sink (EFFECT,
38
+ EVENTS, PARENT, ELEMENT, HTTP and other drivers) gets the committed state, calculated fields
39
+ included, as `state` and `props.state`. The new `props.previousState` (fourth argument) is the
40
+ state from before the action; a STATE reducer gets the old state in both. 6.x gave every reducer
41
+ the state from before the action.
42
+
43
+ - An output that computed the update itself now applies it twice: in
44
+ `INC: { STATE: s => ({ ...s, n: s.n + 1 }), PARENT: s => ({ n: s.n + 1 }) }`, PARENT becomes
45
+ `s => ({ n: s.n })`.
46
+ - A value STATE clears or deletes (a submitted draft, a removed record) is read from
47
+ `previousState`.
48
+ - A guard that admits the action (a loading or status flag STATE sets) tests `previousState`.
49
+ Keep the output guard: STATE returning `ABORT` doesn't cancel the other sinks.
50
+ - When STATE returns `ABORT` or the same state, or throws, the other sinks still run, with
51
+ `state === previousState`.
52
+ - When STATE removes the component's own state (`STATE: () => undefined` in a Collection item, or
53
+ a child bound with `state="x"` / `byId()` deleting its record), the other sinks get its last
54
+ state, so `REMOVE: { STATE: () => undefined, EVENTS: event('REMOVED', (s) => s.id) }` works as
55
+ before.
56
+ - Behaviors: the first argument is the slice after STATE, `props.state` the host state after
57
+ STATE, `props.previousState` the host state before the action; the old slice is
58
+ `props.previousState[key]`. Every built-in behavior (form, chat, compose, commandBar, undo,
59
+ pager, selection, sortable, and the `sygnal/ui` dialog, popover, tooltip, tabs, accordion and
60
+ disclosure) is on this contract.
61
+ - Libraries that support 6 and 7 read old values as `props.previousState ?? state`.
62
+
63
+ ### New: the timing audit (SYG257)
64
+
65
+ An opt-in dev check for upgrading 6.x code: with `renderComponent(C, { timingAudit: true })`,
66
+ `sygnal({ diagnostics: { timingAudit: true } })` in `vite.config`, `configureTimingAudit(true)`
67
+ from `'sygnal/diagnostics'` or `globalThis.__SYGNAL_TIMING_AUDIT__ = true`, every output sink that
68
+ reads a top-level state field its action's STATE changed reports **SYG257** (info, once per
69
+ component, action, sink and field). Keep `state` where the new value is meant; read
70
+ `previousState` where the old one was. It is off by default because in code written for 7.0
71
+ reading the new value is the intent, and it adds nothing to production builds.
72
+
73
+ sygnal-check's **SYG256** (warning) reports an output guard that contradicts the same action's
74
+ literal STATE write, the loading/admission pitfall above. sygnal-check reads the installed
75
+ Sygnal version per package; **SYG454** (a `byId()` selection without a key) applies only to
76
+ Sygnal 6.
77
+
78
+ ### Breaking: a child follows the record its state binding points at
79
+
80
+ When a child's `state` path, or its `byId()` field, id or idKey, changes, Sygnal disposes the old
81
+ child and mounts a new one: `DISPOSE` runs with the last state, pending requests, delayed `next()`
82
+ actions and subscriptions are cancelled, initialization reruns and local state starts from its
83
+ defaults. The child's DOM is replaced, so focus, caret, scroll position and in-progress input
84
+ inside it are lost. The same applies to Collection/VirtualCollection `from` and Switchable
85
+ `state` (all items or pages, hidden ones included, even when ids overlap). Deleting the bound
86
+ record disposes the child too, after the deleting action's outputs; re-adding the same id gets a
87
+ new child, never the old pending work. A new `byId()` call with the same arguments, field edits
88
+ and reordering keep the child. `key={selected.id}` is no longer needed for a selection.
89
+
90
+ **A stable explicit key no longer keeps a child across a target change**: lift state that must
91
+ survive the change into the parent. A child with a custom lens keeps its binding until its key
92
+ changes.
93
+
94
+ ### New: uploads, SSE auth, form files and types, `tooltips()`
95
+
96
+ - **Upload and download progress** on `makeFetchDriver` requests: `progress: 'UPLOADING'` (a reply
97
+ action) or `progress: true` (`HTTP.progress(category)`) sends `{ phase, loaded, total, fraction }`
98
+ steps, throttled to one every 50 ms per phase. Such a request goes over XMLHttpRequest, the only
99
+ way to see upload progress in every browser; fetch-only `init` options it can't honour are
100
+ SYG636. Tests: `t.progress('HTTP', { loaded, total }, 'UPLOADING')`.
101
+ - **`multipart: { file, title }`** on a request: the multipart/form-data body as data, the way
102
+ `json` is; a model never builds a `FormData`.
103
+ - **The `form` behavior** takes file inputs (a field's value is its `File`, `multiple` an array,
104
+ validated by the schema), keeps the submit's progress in `state.form.progress`
105
+ (`progress: 'form.PROGRESS'`) and counts start-overs in `state.form.resets` (key a file input
106
+ with it). `request: { url }` (or `(data, state) => request`) sends a valid submit itself, as
107
+ JSON or multipart, with `form.DONE` / `form.ERRORS` wired: the common save needs no model entry.
108
+ `form.RESET` from a click goes back to the start values (it used to take the event as values).
109
+ - **Form types from the schema**: a typed Standard Schema (zod, valibot, arktype) types
110
+ `state.form.values` and `fields.x.value`; `FormOutput<typeof uses.form>` is the submit data.
111
+ - **SSE connections take `headers`** (`{ authorization: 'Bearer ' + state.token }`): such a stream
112
+ is read with fetch (`Last-Event-ID`, `retry`), since EventSource can't send headers.
113
+ - **`tooltips()`** in `sygnal/ui`: one behavior and one state slice for every tooltip of a
114
+ component (glossary terms, icon toolbars), one tip at a time.
115
+ - `Component<State, any>` keeps `state` typed; SYG731 accepts state announced through text,
116
+ `aria-label`, `aria-live` or `role="status"`; sygnal-check's SYG609 also reports intent sources
117
+ with no driver; a test wait that times out on the fake clock names the pending timer; every
118
+ create-sygnal-app template has `npm run check` (TypeScript: sygnal-check and `tsc --noEmit`).
119
+
120
+ ### Also included
121
+
122
+ - A child that fails to instantiate is no longer retried on every parent render.
123
+ - State-wait timeout messages report queued simulated inputs; the testing guide separates
124
+ serialized simulated input from synchronous native bursts.
125
+ - TypeScript tests can use `t.query<HTMLInputElement>(selector)` and `t.queryAll<…>(selector)`
126
+ (type assertions, not runtime checks); typed control tokens keep their inference.
127
+ - The combined Rollup build exits normally (compiler watcher lifecycle fix).
128
+ - The release workflow checks the CHANGELOG headings of the new versions before the gates and
129
+ any publish.
130
+
131
+ ### Changed
132
+
133
+ - **The app graph's banner line says why it's missing.** When the dev server's graph route is on but the project has no `sygnal-check` 0.10.0 or later, `sygnal/vite` prints `➜ Sygnal graph: needs sygnal-check 0.10.0 or later in this project (npm i -D sygnal-check)` instead of nothing (the URL already answered 404 with the same hint).
134
+ - **`astro dev` prints the graph page's address** under Astro's own banner (Astro never runs Vite's URL printing), and the Astro integration takes the `graph` option (`sygnal({ graph: false })`).
135
+ - **The repository's examples link `sygnal-check`** (`file:../../sygnal-check`, as they link `sygnal`), so `npm run dev` in any of them serves the app graph; `examples/mcp-app` moves off `sygnal-check` ^0.7.0.
136
+
5
137
  ## 6.6.0 — 2026-10-16
6
138
 
7
139
  The app graph as an interactive page: `sygnal-check --graph=html`, live with `--watch` or at `/__sygnal/graph` in the Vite dev server, in a VS Code panel and in Sygnal DevTools. See [The App Graph](https://sygnal.js.org/guide/app-graph/).
package/README.md CHANGED
@@ -9,6 +9,8 @@ A reactive component framework with pure functions, zero side effects, and autom
9
9
 
10
10
  ---
11
11
 
12
+ > **Upgrading from 6.x?** Sygnal 7.0 changes reducer timing: output sinks (EFFECT, EVENTS, PARENT, HTTP, ...) now see the state after the action's STATE update. See [Migrating to 7.0](https://sygnal.js.org/guide/migrating-to-7/).
13
+
12
14
  ## Why Sygnal?
13
15
 
14
16
  - **Pure components** — Views are plain functions. All side effects are handled by drivers, outside your code.
@@ -867,17 +867,25 @@ function graphMiddleware(server, root, engine, ping = 25000) {
867
867
  res.end(req.method === 'HEAD' ? undefined : body);
868
868
  };
869
869
  }
870
- /** Adds the page's URL under Vite's own when the dev server prints them. */
871
- function printGraphUrl(server) {
870
+ /** The banner line when the project has no sygnal-check with the graph page (it used to print nothing). */
871
+ const GRAPH_MISSING_LINE = ' ➜ Sygnal graph: needs sygnal-check 0.10.0 or later in this project (npm i -D sygnal-check)';
872
+ /**
873
+ * Adds the page's URL under Vite's own when the dev server prints them, or, when the project's
874
+ * sygnal-check is missing or too old for the page (`available` false), a line saying what to install.
875
+ */
876
+ function printGraphUrl(server, available = true) {
872
877
  const print = server?.printUrls;
873
878
  if (typeof print !== 'function')
874
879
  return;
875
880
  server.printUrls = function (...args) {
876
881
  const out = print.apply(this, args);
877
882
  try {
883
+ const logger = server.config?.logger || console;
878
884
  const local = server.resolvedUrls?.local?.[0];
879
- if (local)
880
- (server.config?.logger || console).info(` ➜ Sygnal graph: ${new URL(local).origin}${GRAPH_PATH}`);
885
+ if (!available)
886
+ logger.info(GRAPH_MISSING_LINE);
887
+ else if (local)
888
+ logger.info(` ➜ Sygnal graph: ${new URL(local).origin}${GRAPH_PATH}`);
881
889
  }
882
890
  catch (_) { }
883
891
  return out;
@@ -1016,7 +1024,8 @@ function printGraphUrl(server) {
1016
1024
  * skipped silently when missing; a sygnal-check without the page keeps the checker as in 5)
1017
1025
  * builds it with createLiveProject on start and after each change: one build gives the page,
1018
1026
  * the check overlay / console findings of 5, and the component stamps. Its URL is printed
1019
- * under Vite's ("➜ Sygnal graph: http://localhost:5173/__sygnal/graph"). The modules of the
1027
+ * under Vite's ("➜ Sygnal graph: http://localhost:5173/__sygnal/graph"; without a sygnal-check
1028
+ * 0.10.0+ in the project, a line saying so instead). The modules of the
1020
1029
  * scanned files get a tail setting a non-enumerable `__sygnalKey` (the static 'file#name'
1021
1030
  * key, D521) on each top-level component function, which the runtime inspect() reports as
1022
1031
  * `key`; with DevTools on, the dev entries also import 'virtual:sygnal/graph'
@@ -1084,7 +1093,7 @@ function sygnal(options = {}) {
1084
1093
  // Files whose `import ... from 'sygnal'` resolves to the run() wrapper
1085
1094
  const runtimeImporters = new Set();
1086
1095
  // Statements run before the app starts: dev flag (+ strict flag)
1087
- const flags = DEV_FLAG + (diagnostics.strict ? STRICT_FLAG : '');
1096
+ const flags = DEV_FLAG + (diagnostics.strict ? STRICT_FLAG : '') + (diagnostics.timingAudit ? AUDIT_FLAG : '');
1088
1097
  const devImports = `import 'sygnal/diagnostics';import '${DEV_CLIENT}';`;
1089
1098
  // D77: the DevTools bridge, dev only. First, so the diagnostics entry finds it.
1090
1099
  // E-1: the MCP endpoint needs the bridge, so `mcp` turns it on
@@ -1282,8 +1291,7 @@ function sygnal(options = {}) {
1282
1291
  // W-2 (D543): the page and its stream, behind the MCP endpoint's guard; its URL in the banner
1283
1292
  if (graphOn && server?.middlewares) {
1284
1293
  server.middlewares.use(graphMiddleware(server, root, engine));
1285
- if (graphAvailable(root))
1286
- printGraphUrl(server);
1294
+ printGraphUrl(server, graphAvailable(root));
1287
1295
  }
1288
1296
  },
1289
1297
  transform(code, id, opts) {
@@ -1568,7 +1576,7 @@ function normalizeDiagnostics(value) {
1568
1576
  if (!MODES.includes(mode)) {
1569
1577
  throw new Error(`[vite-plugin-sygnal] diagnostics mode must be one of ${MODES.map(m => `'${m}'`).join(', ')}; got ${JSON.stringify(mode)}`);
1570
1578
  }
1571
- return { mode, strict: !!o.strict, ignore: Array.isArray(o.ignore) ? o.ignore.map(String) : [] };
1579
+ return { mode, strict: !!o.strict, timingAudit: !!o.timingAudit, ignore: Array.isArray(o.ignore) ? o.ignore.map(String) : [] };
1572
1580
  }
1573
1581
  /** Module id without a query or hash. */
1574
1582
  function cleanId(id) {
@@ -1946,6 +1954,8 @@ function startChecker(server, root, mod, setup, opts, engine) {
1946
1954
  }
1947
1955
  const DEV_FLAG = 'if (globalThis.__SYGNAL_DEV__ === undefined) globalThis.__SYGNAL_DEV__ = true;';
1948
1956
  const STRICT_FLAG = 'if (globalThis.__SYGNAL_STRICT__ === undefined) globalThis.__SYGNAL_STRICT__ = true;';
1957
+ // 7.0: the opt-in timing audit (SYG257, 'sygnal/diagnostics' checks/timingAudit.ts)
1958
+ const AUDIT_FLAG = 'if (globalThis.__SYGNAL_TIMING_AUDIT__ === undefined) globalThis.__SYGNAL_TIMING_AUDIT__ = true;';
1949
1959
  // A top-level (column 0) run(Component, ...) statement: bare, or declaring a
1950
1960
  // new binding. Assignments to existing variables, nested calls and indented
1951
1961
  // calls (e.g. inside a function or test callback) do not match (B-007). It is
@@ -2084,10 +2094,12 @@ const rootDir = (root) =>
2084
2094
  // @ts-ignore — Node's process
2085
2095
  !root ? process.cwd() : typeof root === 'string' ? root : node_url.fileURLToPath(root);
2086
2096
  function sygnalAstroIntegration(options = {}) {
2097
+ let projectRoot;
2087
2098
  return {
2088
2099
  name: SYGNAL_RENDERER_NAME,
2089
2100
  hooks: {
2090
2101
  'astro:config:setup': ({ addRenderer, updateConfig, command, config }) => {
2102
+ projectRoot = rootDir(config?.root);
2091
2103
  addRenderer({
2092
2104
  name: SYGNAL_RENDERER_NAME,
2093
2105
  clientEntrypoint: 'sygnal/astro/client',
@@ -2117,6 +2129,7 @@ function sygnalAstroIntegration(options = {}) {
2117
2129
  check: options.check,
2118
2130
  nativeGlobalThis: options.nativeGlobalThis,
2119
2131
  devtools: options.devtools,
2132
+ graph: options.graph,
2120
2133
  })];
2121
2134
  }
2122
2135
  else if (options.nativeGlobalThis !== false) {
@@ -2129,6 +2142,16 @@ function sygnalAstroIntegration(options = {}) {
2129
2142
  vite.plugins = [...(vite.plugins || []), onErrorPlugin(hook)];
2130
2143
  updateConfig({ vite });
2131
2144
  },
2145
+ // W-2: Astro prints its own banner (Vite's printUrls never runs), so the graph page's line goes here
2146
+ 'astro:server:start': ({ address, logger }) => {
2147
+ if (options.graph === false || !projectRoot)
2148
+ return;
2149
+ const print = (msg) => (logger ? logger.info(msg.replace(/^\s*➜\s*/, '')) : console.log(msg));
2150
+ if (!graphAvailable(projectRoot))
2151
+ print(GRAPH_MISSING_LINE);
2152
+ else if (address?.port)
2153
+ print(` ➜ Sygnal graph: http://localhost:${address.port}${GRAPH_PATH}`);
2154
+ },
2132
2155
  },
2133
2156
  };
2134
2157
  }