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
@@ -266,6 +266,8 @@ Star.model = { STAR: (state) => ({ ...state, starred: !state.starred }) }
266
266
 
267
267
  Use `aria-selected` for a tab, `aria-expanded` for a disclosure, `aria-checked` with `role="switch"`, and `aria-current="page"` for the current link. Native controls that carry their own state (a checkbox, `select`, `details`) aren't reported, and neither is a busy class (`{ saving: state.saving }`) that the click sets rather than toggles.
268
268
 
269
+ A change announced another way isn't reported either: the element's text or `aria-label` follows a field of the class condition (a Copy button whose text becomes `{state.status === 'copied' ? 'Copied!' : 'Copy'}`), or it has `aria-live` or `role="status"` / `role="alert"`. `aria-pressed` is only for an on/off toggle; for any other change, let the text or label say it.
270
+
269
271
  ## ARIA values in JSX
270
272
 
271
273
  An `aria-*` attribute set to `true` renders `"true"`. Set to `false`, it renders `"false"` on the ARIA states that have a false value (`aria-expanded`, `aria-hidden`, `aria-pressed`, `aria-checked`, `aria-selected`, `aria-disabled`, `aria-invalid`, `aria-current`, `aria-haspopup`, `aria-busy`, `aria-modal`, `aria-required`, `aria-readonly`, `aria-multiline`, `aria-multiselectable`, `aria-atomic`, `aria-grabbed`); on any other `aria-*` (a label, an id reference, a number) `false` removes the attribute, so `aria-describedby={hasError && uid('error')}` doesn't announce "false". `null` and `undefined` remove it. Passed to a component, `role`, `for`, `tabindex` and `aria-*` are ordinary props: the component gets the value as written.
@@ -92,8 +92,8 @@ Chat.model = {
92
92
  STATE: (state) => (state.prompt.trim()
93
93
  ? { ...state, messages: [...state.messages, { role: 'user', content: state.prompt }], prompt: '', status: 'streaming', error: null }
94
94
  : ABORT),
95
- // sinks see the state before the action: the conversation plus the prompt being sent
96
- LLM: (state) => (state.prompt.trim() ? ask([...state.messages, { role: 'user', content: state.prompt }]) : ABORT),
95
+ // STATE has stored the new message; the prompt from before it decides whether to send
96
+ LLM: (state, data, next, { previousState }) => (previousState.prompt.trim() ? ask(state.messages) : ABORT),
97
97
  },
98
98
  STOP: {
99
99
  // keep the text that arrived; nothing arrives after the abort
@@ -114,7 +114,7 @@ Chat.model = {
114
114
  What the component does:
115
115
 
116
116
  - **The conversation is state.** `messages` holds the user's messages and the model's replies. Every request sends the whole conversation: models are stateless, so the history goes with each turn. A message you write yourself can be `{ role: 'user', content: text }`: the driver, `messageText()` and every transport accept that short form.
117
- - **`SEND` has two sinks.** `STATE` adds the user's message, `LLM` sends the request. Both see the state from before the action ([Model](https://sygnal.js.org/guide/model/#sinks-see-the-state-before-the-action)), so the `LLM` sink builds the message list with the prompt itself.
117
+ - **`SEND` has two sinks.** `STATE` adds the user's message, `LLM` sends the request. STATE runs first, so the `LLM` sink sends the message list STATE just stored; STATE has cleared `prompt`, so the empty-submit guard reads `previousState.prompt` ([Model](https://sygnal.js.org/guide/model/#what-state-each-sink-sees)).
118
118
  - **`DELTA` shows the reply so far.** Its `text` is all the text received, not just the new piece, so the reducer only replaces `draft`.
119
119
  - **`DONE` gets the finished message** and appends it to the conversation as is.
120
120
  - **`STOP` aborts the request** by its key. Nothing arrives after an abort, not even `FAILED`.
@@ -388,7 +388,7 @@ Shopping.model = {
388
388
  STATE: (state) => (state.prompt.trim()
389
389
  ? { ...state, messages: [...state.messages, { role: 'user', content: state.prompt }], prompt: '', status: 'streaming', error: null }
390
390
  : ABORT),
391
- LLM: (state) => (state.prompt.trim() ? ask([...state.messages, { role: 'user', content: state.prompt }]) : ABORT),
391
+ LLM: (state, data, next, { previousState }) => (previousState.prompt.trim() ? ask(state.messages) : ABORT),
392
392
  },
393
393
  DELTA: (state, { text }) => ({ ...state, draft: text }),
394
394
  // runs the call as it arrives; the model's input is unchecked, so check it
@@ -402,8 +402,8 @@ Shopping.model = {
402
402
  draft: '',
403
403
  status: toolCalls.length ? 'streaming' : 'ready',
404
404
  }),
405
- // after tool calls, send the conversation again with their outputs
406
- LLM: (state, { message, toolCalls }) => (toolCalls.length ? ask([...state.messages, answered(message, state.items)]) : ABORT),
405
+ // after tool calls, send the conversation again: STATE has added the answered message
406
+ LLM: (state, { toolCalls }) => (toolCalls.length ? ask(state.messages) : ABORT),
407
407
  },
408
408
  FAILED: (state, { error }) => ({ ...state, draft: '', status: 'ready', error: error.message }),
409
409
  }
@@ -185,7 +185,7 @@ Two components can use the same behavior, and so can every item of a [Collection
185
185
 
186
186
  ### Options, the key, host state and timers
187
187
 
188
- Every model handler of a behavior gets the use's options and its key after the usual arguments: `(slice, data, next, props, options, key)`, and the intent gets `(sources, options, key)`. `props.state` is the host's whole state.
188
+ Every model handler of a behavior gets the use's options and its key after the usual arguments: `(slice, data, next, props, options, key)`, and the intent gets `(sources, options, key)`. `props.state` is the host's whole state. As in a component, STATE runs first: an output handler gets the slice and `props.state` after it, and `props.previousState` is the host's state from before the action, so the slice from before it is `props.previousState[key]` ([Model](https://sygnal.js.org/guide/model/#what-state-each-sink-sees)).
189
189
 
190
190
  Two more parts are for behaviors that reach past their slice:
191
191
 
@@ -68,6 +68,8 @@ declare module 'sygnal' {
68
68
  }
69
69
  ```
70
70
 
71
+ A command built outside the model's return, in a variable or a helper, needs a type in TypeScript: in `const top = { scrollIntoView: '.page', block: 'start' }` the `block` widens to `string`, which isn't a `ScrollLogicalPosition`. Annotate it, `const top: ElementCommand = { … }` (or write `satisfies ElementCommand`); `ElementCommand` is exported from `sygnal`. A command written inside the return needs nothing.
72
+
71
73
  Methods that change the DOM Sygnal renders (`remove`, `append`, `setAttribute` and the like) are reported as [SYG641](https://sygnal.js.org/reference/errors/#syg641): the next render would undo them or trip over them. Change the state instead.
72
74
 
73
75
  ## Which element
@@ -105,12 +107,12 @@ Checklist.model = {
105
107
  ADD: {
106
108
  STATE: (state) => ({ ...state, rows: [...state.rows, { id: state.next, title: '' }], next: state.next + 1 }),
107
109
  // the new row renders in the same patch, before the command runs
108
- ELEMENT: (state) => ({ focus: focusWithin(`[data-id="${state.next}"] .title`) }),
110
+ ELEMENT: (state, data, next, { previousState }) => ({ focus: focusWithin(`[data-id="${previousState.next}"] .title`) }),
109
111
  },
110
112
  }
111
113
  ```
112
114
 
113
- The reducer for `ELEMENT` sees the state before the action, so `state.next` is the new row's id. Prefer the child's own model when the child decides; `focusWithin` is for a parent that does (adding a row, submitting a form whose fields are child components). If nothing matches, nothing happens; `renderComponent()` reports [SYG640](https://sygnal.js.org/reference/errors/#syg640) when nothing in the view, children included, matches.
115
+ STATE has already incremented `next` when `ELEMENT` runs, so the new row's id is `previousState.next`. Prefer the child's own model when the child decides; `focusWithin` is for a parent that does (adding a row, submitting a form whose fields are child components). If nothing matches, nothing happens; `renderComponent()` reports [SYG640](https://sygnal.js.org/reference/errors/#syg640) when nothing in the view, children included, matches.
114
116
 
115
117
  ## When it runs
116
118
 
@@ -13,6 +13,7 @@ The details of the [`form` behavior](./forms.md). Start with the recipe on [Form
13
13
  | `errors` | Every current schema error by field name, shown or not |
14
14
  | `touched`, `server`, `remote`, `pending` | Blurred fields; [server errors](./forms.md#server-errors-and-failed-submits); [check](#async-checks) results; checks running |
15
15
  | `submitting`, `submitted`, `submitCount`, `queued`, `validating`, `validated` | Submit state: the request sent and not answered yet; `form.DONE` arrived (or a submit without a request was dispatched); attempts; a submit waits for a check or an async schema; an async schema runs; the schema has answered since the start or the last reset |
16
+ | `progress`, `resets` | The submit request's last [progress](#file-fields) step (`{ phase, loaded, total, fraction }`, or `null`); how many times the form started over (`form.RESET`, `resetOnShow`), for keying a [file input](#file-fields) |
16
17
  | `fields`, `valid`, `dirty`, `error` | Calculated: per-field view data; no errors as of the schema's last answer (`false` before the first); values differ from `initial`; the form-level message |
17
18
 
18
19
  The options are on [Forms](./forms.md#options).
@@ -23,7 +24,7 @@ Named after the `uses` key (`form.CHANGE` for `uses = { form: … }`):
23
24
 
24
25
  | Action | Data |
25
26
  |---|---|
26
- | `form.CHANGE` | `{ name, value }`: from the form element's `input` events ([field types](./forms.md#field-types); a checkbox gives `checked`, and its `value` as `item`) |
27
+ | `form.CHANGE` | `{ name, value }`: from the form element's `input` events ([field types](./forms.md#field-types); a checkbox gives `checked`, and its `value` as `item`; a [file input](#file-fields) its `File`) |
27
28
  | `form.SET` | `{ values }`: several fields at once, by field name (`{ name: 'Dana', 'addresses.7.city': 'Hilo' }`), as if the user had typed them: one validation, the fields count as changed, `touched` as for `form.CHANGE` (so errors show by the [`show`](./forms.md#options) rule), their server errors and check results cleared. Fields not named keep their values; a name that isn't a field is skipped ([SYG230](https://sygnal.js.org/reference/errors/#syg230) in development). For a fill from elsewhere: [an AI extraction](./recipes/ai-form-fill.md), a saved draft, an address lookup |
28
29
  | `form.BLUR` | The field name, from `focusout` |
29
30
  | `form.SUBMIT` | From the form element's `submit` (default prevented) |
@@ -31,10 +32,50 @@ Named after the `uses` key (`form.CHANGE` for `uses = { form: … }`):
31
32
  | `form.ERRORS` | Server errors: an error reply, a map or a list of issues |
32
33
  | `form.DONE` | The submit's request was saved: `initial` becomes `values`, `submitted` turns on ([a submit without a request](./forms.md#submit-without-a-request) needs none) |
33
34
  | `form.RESET` | Back to `initial`, or to the values given |
35
+ | `form.PROGRESS` | A progress step of the submit's request, sent with `progress: 'form.PROGRESS'`: kept as `progress` until `form.DONE` or `form.ERRORS` ([file fields](#file-fields)) |
34
36
  | `form.VALIDATE` | Internal: the first validation when the host starts; with `resetOnShow`, the start over (also each time the form element appears again) |
35
37
 
36
38
  As for any behavior, a host model entry with the same name runs after the form's: `'form.DONE': (state) => ({ ...state, done: true })` shows a confirmation. Trigger an action from elsewhere with an intent action of that name, or `t.simulateAction('form.RESET')` in a test.
37
39
 
40
+ ## File fields
41
+
42
+ A file input whose `name` is in `values` is a field like any other: its value is the `File` the user picked (an array of them with `multiple`, `null` when none), the schema validates it, its error shows on `fields.avatar.error`, and the submit's data carries it, so `request` sends it as [`multipart`](./http.md#upload-and-download-progress):
43
+
44
+ ```jsx
45
+ import { form } from 'sygnal'
46
+ import { z } from 'zod'
47
+
48
+ const profileSchema = z.object({
49
+ name: z.string().min(1, 'Enter your name'),
50
+ avatar: z.file('Choose a photo').max(5_000_000, 'At most 5 MB').mime(['image/png', 'image/jpeg'], 'A PNG or JPEG photo'),
51
+ })
52
+
53
+ function Profile({ state }) {
54
+ const f = state.form.fields
55
+ const percent = Math.round((state.form.progress?.fraction ?? 0) * 100)
56
+ return (
57
+ <form>
58
+ <label>Name <input name="name" value={f.name.value} /></label>
59
+ <label>Photo <input type="file" name="avatar" accept="image/*" key={'avatar' + state.form.resets} /></label>
60
+ <p className="chosen">{f.avatar.value?.name}</p>
61
+ <p className="error">{f.avatar.error}</p>
62
+ {state.form.submitting && <progress max="100" value={percent} aria-label="Upload progress" />}
63
+ <button type="submit" disabled={state.form.submitting}>Save</button>
64
+ </form>
65
+ )
66
+ }
67
+
68
+ // the data has a File, so the form sends it as multipart, with progress in state.form.progress
69
+ Profile.uses = { form: form(profileSchema, { values: { name: '', avatar: null }, request: { url: '/api/profile' } }) }
70
+ ```
71
+
72
+ - **Progress**: the form's `request` asks for progress (`progress: 'form.PROGRESS'`; a `submit` entry's request can name it too), and the form keeps the latest step in `state.form.progress` (`{ phase, loaded, total, fraction }`) while it is submitting, and clears it on `form.DONE`, `form.ERRORS` and `form.RESET`.
73
+ - **Don't bind `value`**: the browser doesn't let a page set a file input's file. So a reset can't clear it through state; instead the form counts its start overs in `state.form.resets`, and `key={'avatar' + state.form.resets}` makes a new, empty input after each one. For the same reason, show the chosen file's name yourself (`f.avatar.value?.name`): after a reset to saved values (`form.DONE` makes the saved values the start values) the value is still the file while the new input is empty.
74
+ - **Validation** uses your schema's file checks: zod's `z.file()` (`.max()`, `.mime()`), valibot's `v.file()`, or `instanceof File` in a hand-written schema. Required means rejecting `null`.
75
+ - **Tests** pick a file with `t.simulateEvent('[name="avatar"]', 'input', { target: { files: [new File(['…'], 'me.png')] } })`, submit, then `t.progress('HTTP', { loaded: 1, total: 2 }, 'form.PROGRESS')` and `t.respond('HTTP', reply, 'form.DONE')`.
76
+ - A file input whose `name` isn't in `values` is left alone, as before 6.7, for an app that reads its files in its own intent.
77
+ - A `File` can't be serialized, so `persist()` and server-rendered state don't carry file fields.
78
+
38
79
  ## Any Standard Schema
39
80
 
40
81
  `form()` takes any object with `~standard.validate`: zod (3.24 and later), valibot (1.0 and later), arktype, or one you write. A schema's issue paths become field names, and an issue without a path is a form-level message (`state.form.error`, shown after a submit):
@@ -263,11 +304,15 @@ Signup.model = {
263
304
 
264
305
  `form.DONE` marks the submit saved: `submitting` turns off, `submitted` on, and the saved values become `initial` (so `dirty` is `false` again).
265
306
 
266
- Whether a valid submit waits for `form.DONE` depends on the host's entry for the `submit` action, checked when the host starts:
307
+ Whether a valid submit waits for `form.DONE` depends on how it is sent:
308
+
309
+ - **With `request`**: the form sends it on its `http` sink and waits. `request` is an object or `(data, state) => request`, called with the schema's output and the host's state (as committed: the outputs' state since 7.0); `ABORT` or a falsy result sends nothing and leaves the submit pending, as below. The form adds `json: data` (or `multipart: data` when a top-level value is a `File`, `Blob` or an array of them; a file nested deeper isn't sent as a file) unless the request sets `json`, `multipart` or `body`, adds `progress: 'form.PROGRESS'` unless it sets `progress`, and always sets `ok: 'form.DONE'` and `error: 'form.ERRORS'`. With `submit` as well, that host action is dispatched too (to set state); its entry shouldn't send another request.
310
+
311
+ Otherwise it depends on the host's entry for the `submit` action, checked when the host starts:
267
312
 
268
313
  - **With the `http` sink** (`SIGN_UP: { HTTP: … }`, or the sink the `http` option names, such as `http: 'API'`): `submitting` is `true` until `form.DONE` or `form.ERRORS`, and a submit meanwhile is dropped ([SYG232](https://sygnal.js.org/reference/errors/#syg232)). Even when the entry returns `ABORT` this time, the form waits.
269
314
  - **Anything else** (`STATE`, `PARENT`, `EVENTS`, `EFFECT`, `ROUTER`, a reducer function): no request, so the submit is done at once, as if `form.DONE` had arrived (no host `form.DONE` entry runs). An `EFFECT` that calls `next('form.DONE')` itself still works; the second `DONE` changes nothing. Work that answers later without the `http` sink (a promise in an `EFFECT`) gets no pending state: send it through a driver as a request instead.
270
- - **A host that unmounts** keeps its slice in the parent's state when it has a `state` prop. When it mounts again, the form clears `submitting`, `queued` and running checks (their replies went to the old host); the values, errors and `submitted` stay. `form.RESET` goes back to `initial`, or to the values it is given (`t.simulateAction('form.RESET', values)`, or an intent action `'form.RESET': DOM.click('.reset')`); it clears errors, touched fields and the submit count.
315
+ - **A host that unmounts** keeps its slice in the parent's state when it has a `state` prop. When it mounts again, the form clears `submitting`, `queued` and running checks (their replies went to the old host); the values, errors and `submitted` stay. `form.RESET` goes back to `initial`, or to the values it is given (`t.simulateAction('form.RESET', values)`, or an intent action `'form.RESET': DOM.click('.reset')`: data that isn't a plain object, such as the click event, means `initial`); it clears errors, touched fields and the submit count.
271
316
  - **Starting over when shown** (`resetOnShow: true`): the form goes back to its start values, as a new form (`initial` too; touched fields, errors, server errors, checks and the submit state cleared), when its host starts (mounted again, a Switchable page made again for another `instance`) and each time its form element appears in the host's DOM after being absent (a Switchable page shown again: hidden pages stay alive and keep their state; a form rendered conditionally). Re-renders while the form is shown keep what the user typed. With the mock DOM (and in SSR) only the host's start counts. A `values` function gets the host's whole state and is called at each start over; without `resetOnShow`, once, when the host starts and the slice is still untouched. Before its first call (the first render of a page that reads its parent's state) the fields come from calling it with a blank state, every value read from it `''`.
272
317
 
273
318
  ## Without the behavior: the helpers
@@ -344,6 +389,47 @@ Signup.uses = {
344
389
  - A user's own submit is unchanged.
345
390
  - `formTool` comes from `sygnal/ai` so that an app using `form` without a tool doesn't carry the tool code. A plain object as `tool` does nothing but report [SYG245](https://sygnal.js.org/reference/errors/#syg245) in development.
346
391
 
392
+ ## TypeScript
393
+
394
+ With a schema that declares its [Standard Schema](https://standardschema.dev) types (zod, valibot and arktype do), `form()` types the values from the schema's input and checks `values` against it. `UsesState<typeof uses>` gives the component's state the slice: `state.form.values.email` and `state.form.fields.email.value` are strings, and a top-level field name that isn't in the values is a type error (nested paths such as `` f[`addresses.${id}.city`] `` stay open). `FormOutput<typeof uses.form>` (or `SchemaOutput<typeof signupSchema>`) is the schema's output, the data of the host's submit action:
395
+
396
+ ```tsx
397
+ import { form } from 'sygnal'
398
+ import type { Component, UsesState, UsesActions, FormOutput } from 'sygnal'
399
+ import { z } from 'zod'
400
+
401
+ const signupSchema = z.object({
402
+ email: z.string().trim().email('Enter a valid email address'),
403
+ age: z.coerce.number().int().min(18, 'You must be 18 or older'),
404
+ })
405
+
406
+ const uses = {
407
+ form: form(signupSchema, { values: { email: '', age: '' }, submit: 'SIGN_UP' }),
408
+ }
409
+
410
+ type State = UsesState<typeof uses>
411
+ type Actions = UsesActions<typeof uses> & { SIGN_UP: FormOutput<typeof uses.form> }
412
+
413
+ const Signup: Component<State, {}, {}, Actions> = ({ state }) => {
414
+ const f = state.form.fields
415
+ return (
416
+ <form noValidate>
417
+ <label>Email <input name="email" value={f.email.value} aria-invalid={f.email.invalid} /></label>
418
+ <label>Age <input name="age" inputMode="numeric" value={f.age.value} aria-invalid={f.age.invalid} /></label>
419
+ <button type="submit" disabled={state.form.submitting}>Sign up</button>
420
+ </form>
421
+ )
422
+ }
423
+
424
+ Signup.uses = uses
425
+ Signup.model = {
426
+ // values: { email: string; age: number }, the schema's output
427
+ SIGN_UP: { HTTP: (state, values) => ({ url: '/api/signup', method: 'POST', json: values, ok: 'form.DONE', error: 'form.ERRORS' }) },
428
+ }
429
+ ```
430
+
431
+ A schema without `types` (a hand-written one) types the values from `values` and the output as `any`. The reply actions are typed too: `form.ERRORS` takes an error reply, a map `{ name: message }` or a list of issues (`FormServerErrors`), and `form.RESET` the whole values (or nothing, for `initial`).
432
+
347
433
  ## Diagnostics
348
434
 
349
435
  With the dev checks on (the Vite plugin in dev, `renderComponent` in tests): [SYG230](https://sygnal.js.org/reference/errors/#syg230) a field inside the form whose name isn't in `values`, [SYG231](https://sygnal.js.org/reference/errors/#syg231) a schema that isn't a Standard Schema, [SYG232](https://sygnal.js.org/reference/errors/#syg232) a submit dropped while one is in progress (info), [SYG233](https://sygnal.js.org/reference/errors/#syg233) a value the schema strips from its output, [SYG234](https://sygnal.js.org/reference/errors/#syg234) a `submit` action the model doesn't have, [SYG235](https://sygnal.js.org/reference/errors/#syg235) a `check` for an unknown field, or a check request that sets `ok`/`error`/`latest`, [SYG236](https://sygnal.js.org/reference/errors/#syg236) array rows without an `id`, and [SYG237](https://sygnal.js.org/reference/errors/#syg237) two forms in one component on the same selector. `sygnal-check` knows `form`: an option typo is [SYG127](https://sygnal.js.org/reference/errors/#syg127), and fields inside the form element are not reported as uncontrolled ([SYG111](https://sygnal.js.org/reference/errors/#syg111)).
@@ -69,7 +69,7 @@ function Signup({ state, uid }) {
69
69
  Signup.initialState = { accountId: null }
70
70
 
71
71
  Signup.uses = {
72
- form: form(signupSchema, { values: { name: '', email: '', addresses: [{ id: 1, city: '' }] }, submit: 'SIGN_UP' }),
72
+ form: form(signupSchema, { values: { name: '', email: '', addresses: [{ id: 1, city: '' }] }, request: { url: '/api/signup' } }),
73
73
  }
74
74
 
75
75
  Signup.intent = ({ DOM }) => ({
@@ -78,7 +78,6 @@ Signup.intent = ({ DOM }) => ({
78
78
  })
79
79
 
80
80
  Signup.model = {
81
- SIGN_UP: { HTTP: (state, values) => ({ url: '/api/signup', method: 'POST', json: values, ok: 'form.DONE', error: 'form.ERRORS' }) },
82
81
  'form.DONE': (state, account) => ({ ...state, accountId: account.id }),
83
82
  }
84
83
  ```
@@ -91,8 +90,8 @@ Signup.model = {
91
90
  - **`state.form.fields[name]`** is what the view needs for each field: `value`, `error` (the message to show, `''` for none), `invalid` (`!!error`, for `aria-invalid`), `touched`, `dirty` and `pending` (an [async check](./forms-reference.md#async-checks) is running), plus `name`.
92
91
  - **When errors show**: every change is validated, but a field's schema error shows once the field has lost focus (`show: 'blur'`, the default), or while typing (`show: 'input'`), or only after a submit (`show: 'submit'`). After the first submit every error shows, and each follows the typing. Server errors show at once.
93
92
  - **An invalid submit** shows every error, focuses the first invalid field in page order (rows included) and sends nothing.
94
- - **A valid submit** dispatches the `submit` action (`SIGN_UP`) with the schema's output: the trimmed, transformed values (`email` lower-cased here; the rows without their `id`, because `z.object` strips keys it doesn't declare), not the raw ones. Your model entry for it sends the request, with `ok: 'form.DONE'` and `error: 'form.ERRORS'` as its reply actions.
95
- - **Pending**: when the submit entry sends a request (an `HTTP` sink), `state.form.submitting` is `true` from the valid submit until `form.DONE` or `form.ERRORS` arrives. Use it for the button's text and `disabled`. A second submit meanwhile is dropped, so a double click sends once.
93
+ - **A valid submit** sends the `request` with the schema's output as its body: the trimmed, transformed values (`email` lower-cased here; the rows without their `id`, because `z.object` strips keys it doesn't declare), not the raw ones. The form adds `json: data` (`multipart` when a value is a [file](./forms-reference.md#file-fields)), `progress: 'form.PROGRESS'` and its reply actions `ok: 'form.DONE'`, `error: 'form.ERRORS'`. Other request fields (`method`, `headers`) are sent as written.
94
+ - **Pending**: `state.form.submitting` is `true` from the valid submit until `form.DONE` or `form.ERRORS` arrives. Use it for the button's text and `disabled`. A second submit meanwhile is dropped, so a double click sends once.
96
95
  - **`form.DONE`** marks the submit saved: `submitting` turns off, `submitted` on, and the saved values become the new start values (`dirty` is `false` again). A host model entry with the same name runs after the form's and gets the reply body: `'form.DONE': (state, account) => …` above shows the new account's id.
97
96
  - **Labels and errors**: each field has a label and its error text is linked with `aria-describedby`, with ids from [`uid()`](./inputs.md#labels-and-ids-uid) (a row's ids include its `id`, so they stay unique), so the form passes the [accessibility checks](./accessibility.md). `aria-invalid={f.email.invalid}` renders `"true"` or `"false"`.
98
97
 
@@ -104,7 +103,7 @@ Reserve the height of the error lines in your CSS (`min-height`). A field's erro
104
103
 
105
104
  ## Submit without a request
106
105
 
107
- A submit that sends nothing (a wizard step's "Next", a dialog's "OK") is an entry with `STATE`, `PARENT` or `EVENTS` and no `HTTP`. It is done at once: `submitting` stays `false`, `submitted` turns on, and you send no `form.DONE`.
106
+ A submit that sends nothing (a wizard step's "Next", a dialog's "OK") names a host action with `submit` instead of `request`: an entry with `STATE`, `PARENT` or `EVENTS` and no `HTTP`. It is done at once: `submitting` stays `false`, `submitted` turns on, and you send no `form.DONE`. (One with an `HTTP` sink is pending like a `request`.)
108
107
 
109
108
  ```jsx
110
109
  AccountStep.uses = { form: form(accountSchema, { values: { email: '', password: '' }, submit: 'NEXT' }) }
@@ -185,18 +184,19 @@ What `state.form.values[name]` gets from each kind of field, and how to bind it:
185
184
  | `<select multiple>` | The array of selected `value`s | `selected={f.colors.value.includes('red')}` on each `<option>` |
186
185
  | A form-associated custom element (`<wa-input>`, `<wa-select>`) | Its `value` | `value={f.nick.value}` |
187
186
  | A form-associated custom checkbox or switch (`<wa-checkbox>`, `<wa-switch>`: a hyphenated tag with a boolean `checked`) | `checked` (as a group on an array, like checkboxes) | `checked={f.news.value}` |
188
- | `<input type="file">` | Not handled: the form ignores it | Leave `value` unbound; read `e.target.files` in your own intent and keep the files out of `values` |
187
+ | `<input type="file">` | Its `File` (`multiple`: an array), or `null` | Don't bind `value`: [file fields](./forms-reference.md#file-fields) |
189
188
 
190
189
  ## Options
191
190
 
192
191
  | Option | |
193
192
  |---|---|
194
193
  | `values` | The start values (or a function of the host's state). Field names are paths in it: `email`, `address.city`, and `addresses.7.city` for the row with `id` 7 |
195
- | `submit` | The host action a valid submit dispatches with the schema's output ([SYG234](https://sygnal.js.org/reference/errors/#syg234) when the model has no such entry). An entry with an `HTTP` sink keeps the submit pending until its reply; any other is done at once ([submit without a request](#submit-without-a-request)) |
194
+ | `request` | What a valid submit sends: `{ url, … }`, or `(data, state) => request` (an id from state) |
195
+ | `submit` | Instead of `request`: the host action a valid submit dispatches with the schema's output ([SYG234](https://sygnal.js.org/reference/errors/#syg234) when the model has no such entry; [submit without a request](#submit-without-a-request)) |
196
196
  | `show` | `'blur'` (default), `'input'` or `'submit'`: when a schema error shows |
197
197
  | `form` | The form element's selector, default `'form'`. Give each form its own when a component has two: `form: '.login'` and `form: '.news'` ([two forms](./forms-reference.md#two-forms-in-one-component), [SYG237](https://sygnal.js.org/reference/errors/#syg237)) |
198
198
  | `check` | [Async checks](./forms-reference.md#async-checks) by field name |
199
- | `http` | The driver sink of the requests, default `'HTTP'`: the checks' requests go to it, and a submit entry with this sink waits for its reply |
199
+ | `http` | The driver sink of the requests, default `'HTTP'` |
200
200
  | `resetOnShow` | `true`: start over each time the form is shown ([start empty on each visit](#start-empty-on-each-visit)) |
201
201
  | `tool` | Experimental: `formTool({ name, description, autosubmit? })` from `sygnal/ai` offers the form to the browser's agent ([WebMCP](./forms-reference.md#offer-the-form-to-the-browsers-agent)) |
202
202
 
@@ -45,7 +45,7 @@ Quote.intent = ({ DOM }) => ({
45
45
  Quote.model = {
46
46
  SHOW: {
47
47
  STATE: (state, id) => ({ ...state, id, status: 'loading', error: '' }),
48
- HTTP: (state, id) => ({ url: `/api/quotes/${id}`, ok: 'LOADED', error: 'FAILED', latest: true }), // id from data: state.id is still the old one here
48
+ HTTP: (state, id) => ({ url: `/api/quotes/${id}`, ok: 'LOADED', error: 'FAILED', latest: true }), // or state.id: STATE has stored it
49
49
  },
50
50
  REFRESH: {
51
51
  STATE: (state) => (state.id === null ? ABORT : { ...state, status: 'loading', error: '' }),
@@ -66,7 +66,7 @@ Click First, then Second before quote 1 arrives: with `latest: true` the first r
66
66
 
67
67
  ### Build the request from (state, data)
68
68
 
69
- Every sink of one action sees the state from **before** that action ([Model](https://sygnal.js.org/guide/model/#sinks-see-the-state-before-the-action)). In `SHOW` above, `STATE` sets `id`, but the `HTTP` sink still sees the previous `state.id`, so it builds the URL from `data`. `REFRESH` doesn't change `id`, so it can read it from `state`.
69
+ STATE runs first, so the HTTP sink of `SHOW` sees the `id` STATE just stored; the action's data has it too. `REFRESH` doesn't change `id` and reads it from `state`. A request that needs a value STATE replaced (a cleared field, a guard on a flag STATE sets) reads it from the fourth argument's `previousState` ([Model](https://sygnal.js.org/guide/model/#what-state-each-sink-sees)). Sygnal 6.x gave every sink the state from before the action; see [Migrating to 7.0](./migrating-to-7.md).
70
70
 
71
71
  ## Only the Latest Response
72
72
 
@@ -123,10 +123,12 @@ A request is an object (or a URL string, for a plain GET with no reply actions):
123
123
  | `ok` | — | Action that receives the parsed body of a 2xx response |
124
124
  | `error` | — | Action that receives `{ error, status, body, request }` |
125
125
  | `key` | the `ok` action, else `error` | The group for `latest` and `abort` |
126
- | `method` | `'POST'` with `json`/`body`, else `'GET'` | HTTP method |
126
+ | `method` | `'POST'` with `json`/`multipart`/`body`, else `'GET'` | HTTP method |
127
127
  | `query` | — | Object appended as a query string, before any `#fragment`: `{ q: 'dune' }` → `?q=dune` (URL-encoded; `null`/`undefined` values skipped; an array repeats the key: `{ tag: ['a', 'b'] }` → `?tag=a&tag=b`) |
128
128
  | `json` | — | Body sent as `JSON.stringify(json)` with `Content-Type: application/json` |
129
+ | `multipart` | — | A `multipart/form-data` body written as data: `{ file: state.file, title: state.title }` ([uploads](#upload-and-download-progress)) |
129
130
  | `body` | — | Raw body (string, `FormData`, `Blob`, …) |
131
+ | `progress` | — | Report upload and download progress: an action name (`'UPLOADING'`) or `true` for `HTTP.progress()` ([Upload and download progress](#upload-and-download-progress)) |
130
132
  | `headers` | — | Object or `Headers`, merged over the driver's `headers` case-insensitively (sent with lowercase names) |
131
133
  | `latest` | driver's `latest` (false) | Abort this instance's requests with the same key still in flight |
132
134
  | `timeoutMs` | driver's `timeoutMs` (none) | Fail with a `TimeoutError` after this many ms |
@@ -143,6 +145,72 @@ Any other field is yours (an id, the query text): it isn't sent, and it comes ba
143
145
 
144
146
  A request that names only `ok` sends its failures to `HTTP.errors()` (logged when nothing listens), and one that names only `error` sends its successes to `HTTP.select()`. Name both.
145
147
 
148
+ ## Upload and Download Progress
149
+
150
+ Add `progress` to a request to hear how far it has got. With an action name, each step arrives as that action of the component that sent it, like `ok` and `error`:
151
+
152
+ ```js live-server
153
+ export default {
154
+ 'POST /api/files': ({ body }) => {
155
+ const file = body.get('file')
156
+ return { status: 201, json: { name: file.name, size: file.size }, delayMs: 300 }
157
+ },
158
+ }
159
+ ```
160
+
161
+ ```jsx live title="Upload.jsx"
162
+ import { ABORT } from 'sygnal'
163
+
164
+ function Upload({ state }) {
165
+ const percent = Math.round(state.fraction * 100)
166
+ return (
167
+ <form className="upload">
168
+ <input type="file" className="file" aria-label="File to upload" />
169
+ <button type="button" className="sample">Use a 3 MB sample</button>
170
+ <button type="submit" disabled={!state.file || state.status === 'uploading'}>Upload</button>
171
+ <progress max="100" value={percent} aria-label="Upload progress">{percent}%</progress>
172
+ <p className="status" role="status">{state.message}</p>
173
+ </form>
174
+ )
175
+ }
176
+ Upload.initialState = { file: null, fraction: 0, status: 'idle', message: '' }
177
+ Upload.intent = ({ DOM }) => ({
178
+ PICK: DOM.select('.file').events('change').map((e) => e.target.files[0] ?? null),
179
+ SAMPLE: DOM.click('.sample'),
180
+ SEND: DOM.select('.upload').events('submit', { preventDefault: true }),
181
+ })
182
+ const busy = (state) => !state.file || state.status === 'uploading'
183
+ Upload.model = {
184
+ PICK: (state, file) => ({ ...state, file, fraction: 0, status: 'idle', message: file ? file.name : '' }),
185
+ SAMPLE: (state) => ({ ...state, file: new File([new Uint8Array(3 * 1024 * 1024)], 'sample.bin'), fraction: 0, status: 'idle', message: 'sample.bin' }),
186
+ SEND: {
187
+ STATE: (state) => (busy(state) ? ABORT : { ...state, status: 'uploading', fraction: 0, message: 'Uploading…' }),
188
+ // STATE has already set status 'uploading': admit the upload by the state before this SEND
189
+ HTTP: (state, data, next, { previousState }) => (busy(previousState) ? ABORT : { url: '/api/files', multipart: { file: state.file }, progress: 'UPLOADING', ok: 'UPLOADED', error: 'FAILED' }),
190
+ },
191
+ UPLOADING: (state, { phase, fraction }) => (phase === 'upload' ? { ...state, fraction } : ABORT),
192
+ UPLOADED: (state, saved) => ({ ...state, status: 'done', fraction: 1, message: `Uploaded ${saved.name} (${saved.size} bytes).` }),
193
+ FAILED: (state) => ({ ...state, status: 'failed', message: 'Upload failed.' }),
194
+ }
195
+ ```
196
+
197
+ - **Each step** is `{ phase, loaded, total, fraction, category, request }`. `phase` is `'upload'` while the body is sent, then `'download'` while the reply arrives (a GET reports only `'download'`). `total` and `fraction` (0 to 1) are `null` when the size isn't known, such as a download without a `Content-Length`.
198
+ - **How often:** at most one step every 50 ms per phase, always including the first and the last (`fraction: 1`), so a fast connection doesn't render hundreds of times. A retry starts again at 0.
199
+ - **`progress: true`** sends the steps to the source instead: `HTTP.progress('upload')` in the intent, filtered by category like `select()`.
200
+ - **The file** comes from the input's `change` event (`e.target.files`) and is kept in state. `multipart` turns the request's parts into a `multipart/form-data` body, the encoding upload endpoints expect: a `File` or `Blob` is a file part, an array repeats the name (`files: [a, b]`), `null` and `undefined` are left out, an object is sent as JSON text and anything else as a string. The browser writes the `Content-Type` with its boundary, so one set in `headers` is dropped. In a test, `t.requests('HTTP')` shows the parts as you wrote them (`multipart.file.name`). An endpoint that takes the raw bytes needs no encoding: `body: state.file` (with `method: 'PUT'`). With the [`form` behavior](./forms.md), a file input is a field like any other, and `request: { url }` sends the submit as multipart with progress in `state.form.progress` ([file fields](./forms-reference.md#file-fields)).
201
+ - **XMLHttpRequest:** a request with `progress` goes over `XMLHttpRequest`, because it is the only way to see upload progress in every browser (streamed `fetch` uploads work only in Chromium over HTTP/2). The reply is the same as with `fetch`: parsing, statuses, `latest`, `abort`, `timeoutMs` and `retry` all apply. Options `fetch` has and `XMLHttpRequest` doesn't (`init.keepalive`, `priority`, `cache`, `redirect`, `integrity`, `mode`) are ignored ([SYG636](https://sygnal.js.org/reference/errors/#syg636)), and the driver's `fetch` option isn't used: put auth in `headers`, which apply to both. `progress` does nothing on resources and `batch` requests. Where there is no `XMLHttpRequest` (Node, a service worker), the request is sent with `fetch` and reports no steps.
202
+
203
+ In a test, `t.progress` sends a step of a pending request, which stays pending until `t.respond` or `t.fail`:
204
+
205
+ ```js
206
+ t.simulateEvent('.sample', 'click')
207
+ t.simulateEvent('.upload', 'submit')
208
+ await t.progress('HTTP', { loaded: 50, total: 100 }, 'UPLOADING') // phase: 'upload' for a request with a body
209
+ expect(t.state.fraction).toBe(0.5)
210
+ await t.respond('HTTP', { name: 'sample.bin', size: 3145728 }, 'UPLOADED')
211
+ expect(t.state.status).toBe('done')
212
+ ```
213
+
146
214
  ## Driver Options
147
215
 
148
216
  ```javascript
@@ -156,6 +224,7 @@ makeFetchDriver({
156
224
  fetch: myFetch, // default: globalThis.fetch, read at each request
157
225
  cache: queryCache(), // the query cache (default: none), see Resources and Caching
158
226
  retry: 2, // default for GET/HEAD requests (default: 0)
227
+ XMLHttpRequest: MyXhr, // for progress requests (default: the global one)
159
228
  })
160
229
  ```
161
230
 
@@ -234,9 +303,10 @@ Todos.intent = ({ DOM }) => ({ TOGGLE: DOM.click('.toggle').data('id', Number) }
234
303
  Todos.model = {
235
304
  TOGGLE: {
236
305
  STATE: (state, id) => ({ ...state, error: '', todos: state.todos.map(t => (t.id === id ? { ...t, done: !t.done } : t)) }),
237
- HTTP: (state, id) => {
238
- const before = state.todos.find(t => t.id === id) // the state before this action
239
- return { url: `/api/todos/${id}`, method: 'PATCH', json: { done: !before.done }, ok: 'SAVED', error: 'SAVE_FAILED', before }
306
+ HTTP: (state, id, next, { previousState }) => {
307
+ const before = previousState.todos.find(t => t.id === id) // rollback value
308
+ const updated = state.todos.find(t => t.id === id)
309
+ return { url: `/api/todos/${id}`, method: 'PATCH', json: { done: updated.done }, ok: 'SAVED', error: 'SAVE_FAILED', before }
240
310
  },
241
311
  },
242
312
  SAVED: (state, saved) => ({ ...state, todos: state.todos.map(t => (t.id === saved.id ? saved : t)) }),
@@ -281,7 +351,7 @@ Profile.model = {
281
351
  NAME: (state, name) => ({ ...state, name, save: 'idle' }),
282
352
  SAVE: {
283
353
  STATE: (state) => (state.save === 'saving' ? ABORT : { ...state, save: 'saving' }),
284
- HTTP: (state) => (state.save === 'saving' ? ABORT : { url: '/api/profile', method: 'PUT', json: { name: state.name }, ok: 'SAVED', error: 'SAVE_FAILED' }),
354
+ HTTP: (state, data, next, { previousState }) => (previousState.save === 'saving' ? ABORT : { url: '/api/profile', method: 'PUT', json: { name: state.name }, ok: 'SAVED', error: 'SAVE_FAILED' }),
285
355
  },
286
356
  SAVED: (state) => ({ ...state, save: 'saved' }),
287
357
  SAVE_FAILED: (state) => ({ ...state, save: 'failed' }),