Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,13 @@ and this project adheres to Semantic Versioning.

- **`@bquery/bquery/view`** — declarative enter/leave/move transitions ([#137](https://github.com/bQuery/bQuery/issues/137)). New companion attributes `bq-transition`, `bq-in`, `bq-out`, `bq-transition-duration`, `bq-transition-easing` drive enter/leave animations on `bq-if` / `bq-show`, and `bq-animate="flip"` drives FLIP move animations when `bq-for` items reorder. The layer delegates to the existing `motion` engine (Web Animations + FLIP), skips the initial paint, defers removal until the leave finishes, is race-safe on rapid toggles, and honours `prefers-reduced-motion`.
- **`@bquery/bquery/view/compiler`** — optional, build-tool-agnostic compiler ([#138](https://github.com/bQuery/bQuery/issues/138)). `compileViews()`, `compileToModule()`, `compileExpression()`, `emitModule()`, and the dependency-free CLI (`runCompileCli` / `compileFiles`, `bquery-view-compile`) pre-parse `bq-*` expressions into optimized, `with`-free update functions. New runtime hooks `registerCompiledExpressions()` / `clearCompiledExpressions()` (exported from `@bquery/bquery/view`) let the runtime use the precompiled functions, skipping the `new Function()` evaluator (and its `'unsafe-eval'` requirement). The runtime evaluator stays the default; un-compilable expressions transparently fall back to it, so both paths are behaviourally identical.
- **`@bquery/bquery/forms`** — progressive-enhancement form actions + optimistic updates ([#140](https://github.com/bQuery/bQuery/issues/140)). New `formAction(target, options)` binds a form to a server action that POSTs natively without JS and progressively enhances to a `fetch`-based submit with reactive `pending` / `error` / `result` state when JS is present (`enhance(form)` sets the native `action`/`method` and an optional hidden CSRF field, then intercepts `submit`). `useFormStatus(action)` exposes read-only status signals (mirroring React 19), and `optimistic(base, reducer)` is an optimistic-update primitive whose reactive `value` folds pending drafts over the base and reverts automatically (`add` / `run` / `clear`). Composes with the validation pipeline and the `server` module's `csrf()`. A non-OK response throws `FormActionError` (carrying `status` / `response`).
- **`@bquery/bquery/forms`** — `createFieldArray()` gains an optional `getKey` for keyed list reconciliation ([#139](https://github.com/bQuery/bQuery/issues/139)), plus `keys()` / `keyAt(index)`. When supplied, the stable-key contract (present, unique keys) is validated on every structural mutation and a descriptive error names the offending key. Without `getKey` the array stays positional (unchanged behaviour).

### Changed (Unreleased)

- **`@bquery/bquery/view`** — `view` is now **targeting Stable in 1.15.0** ([#136](https://github.com/bQuery/bQuery/issues/136)). The directive set and expression grammar are frozen for one minor cycle, and a per-directive SSR support matrix is published in the [View guide](https://bquery.js.org/guide/view).
- **`@bquery/bquery/forms`** — `forms` is now **targeting Stable in 1.15.0** ([#139](https://github.com/bQuery/bQuery/issues/139)). The 1.13 batteries-included surface is frozen for one minor cycle; the `'manual'` `validationStrategy` default is documented as a deliberate contract (`handleSubmit()` always runs the full validation pass; the strategy gates only *automatic* per-change/per-blur validation); the SSR serialization boundary is now a guaranteed contract (`serializeFormState()` deterministically drops functions, `File` / `Blob` / `FileList`, `bigint`, and `symbol`); and the `createFieldArray()` stable-key requirement is validated with clear errors. See the [Forms guide](https://bquery.js.org/guide/forms).

### Fixed (Unreleased)

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ import { storyHtml, when } from '@bquery/bquery/storybook';
| **Router** | Stable | SPA routing, constrained params, redirects, guards, `useRoute()`, and `<bq-link>` |
| **Store** | Stable | Signal-based state management, persistence, migrations, action hooks, and plugin lifecycle helpers |
| **View** | Beta | Declarative DOM bindings with `bq-*` directives, declarative enter/leave/move transitions, and an optional expression precompiler (targeting Stable in 1.15.0) |
| **Forms** | Beta | Reactive form state with sync/async validation and submit handling |
| **Forms** | Beta | Reactive form state, validation, field arrays, progressive-enhancement form actions, and optimistic updates (targeting Stable in 1.15.0) |
| **i18n** | Beta | Reactive locales, interpolation, pluralization, lazy loading, and Intl formatting |
| **A11y** | Beta | Focus traps, live-region announcements, roving tabindex, skip links, and audits |
| **DnD** | Beta | Draggable elements, droppable zones, and sortable lists |
Expand Down
157 changes: 153 additions & 4 deletions docs/guide/forms.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,30 @@ The forms module provides reactive form state, sync/async validation, cross-fiel
import { createForm, required, email, minLength } from '@bquery/bquery/forms';
```

## Stability: targeting Stable in 1.15.0

`forms` has been **Beta**, and its surface expanded materially in 1.13.0 (validators + combinators, a schema builder, field arrays, `bindForm`/`bindField`, scope composables, and SSR helpers all arrived roughly one minor ago). A surface that large and that new — with defaults that surprise — is not yet a stable contract. The work to graduate it is tracked in [#139](https://github.com/bQuery/bQuery/issues/139): freeze the public surface for one minor cycle, settle the surprising defaults and serialization boundaries as documented guarantees, and validate the field-array key contract with clear errors. Promotion to **Stable** then follows one full minor cycle with the surface frozen.

### Exit criteria

- [x] **Public surface frozen for one minor** — see [Frozen surface reference](#frozen-surface-reference-1150) below; no additive breaking changes land during the freeze. The progressive-enhancement action model ([#140](https://github.com/bQuery/bQuery/issues/140)) is additive and ships alongside.
- [x] **`validationStrategy` default reviewed and clearly documented** ([#139](https://github.com/bQuery/bQuery/issues/139)) — the default stays `'manual'` (the least-surprising choice for "validate on submit"), and the contract is now explicit: **`handleSubmit()` always runs the full validation pass**, regardless of strategy. `validationStrategy` only controls *automatic* per-change / per-blur validation. See [Validation timing](#validation-timing-validationstrategy).
- [x] **SSR serialization boundary documented as guaranteed** ([#139](https://github.com/bQuery/bQuery/issues/139)) — `serializeFormState()` deterministically drops functions, `File` / `Blob` / `FileList` handles, `bigint`, and `symbol`. This is a stable contract, not an incidental `JSON.stringify` side effect. See [SSR serialization boundary](#ssr-serialization-boundary).
- [x] **`createFieldArray()` key contract validated with clear errors** ([#139](https://github.com/bQuery/bQuery/issues/139)) — supplying `getKey` enforces present, unique, stable keys on every structural mutation and throws a descriptive error naming the offending key. See [Field arrays and the stable-key contract](#field-arrays-and-the-stable-key-contract).
- [ ] **Surface frozen for one full minor** (no breaking changes) — demonstrated across the 1.15 cycle.

### Frozen surface reference (1.15.0)

The frozen public surface of `@bquery/bquery/forms`:

- **Entry points:** `createForm`, `useFormField`, `createFieldArray`, `useForm`, `useField`, `useFieldArray`.
- **Validators:** `required`, `minLength`, `maxLength`, `pattern`, `email`, `url`, `min`, `max`, `integer`, `numeric`, `between`, `length`, `oneOf`, `notOneOf`, `arrayOf`, `requiredIf`, `requiredUnless`, `validDate`, `dateAfter`, `dateBefore`, `fileSize`, `fileType`, `custom`, `customAsync`, `matchField`.
- **Combinators:** `compose`, `all`, `not`, `withMessage`.
- **Schema builder:** `field`, `schema`.
- **DOM bindings:** `bindField`, `bindForm`.
- **SSR helpers:** `serializeFormState`, `readSerializedFormState`, `hydrateForm`.
- **Progressive-enhancement actions (new in 1.15.0, additive):** `formAction`, `useFormStatus`, `optimistic`.

## Concepts

A bQuery form is a **graph of signals**. Each field owns its own `value`, `error`, `isTouched`, `isDirty`, `isFocused`, and `isValidating` signal; the parent form derives aggregate signals (`isValid`, `isDirty`, `isSubmitting`, `submitCount`, …) from those. Because everything is signal-based:
Expand Down Expand Up @@ -421,15 +445,139 @@ if (snapshot) form.restore(snapshot);
hydrateForm(form, 'login');
```

## What's new in 1.15.0 — Stable-track surface

The 1.15.0 cycle freezes the `forms` surface and settles its documented sharp
edges into guaranteed contracts ([#139](https://github.com/bQuery/bQuery/issues/139)),
and adds a progressive-enhancement action model
([#140](https://github.com/bQuery/bQuery/issues/140)). All additions are
backwards-compatible.

### Validation timing (`validationStrategy`)

`validationStrategy` controls only **automatic** validation as the user
interacts with the form. It does **not** gate submit:

```ts
const form = createForm({
fields: { email: { initialValue: '', validators: [required(), email()] } },
// default — no automatic per-keystroke/blur validation
validationStrategy: 'manual',
});

// typing leaves `error` untouched under 'manual'…
form.fields.email.value.value = 'nope';
console.log(form.errors.email.value); // ''

// …but handleSubmit() ALWAYS runs the full validation pass first
await form.handleSubmit();
console.log(form.errors.email.value); // 'Invalid email address' — onSubmit was skipped
```

- `'manual'` (**default**): errors surface on `handleSubmit()` or an explicit
`validate()` / `validateField()` / `setValue(v, { validate: true })`. This is
the default because it is the least surprising for the common "validate on
submit" flow and avoids flagging fields the user hasn't finished editing.
- `'onChange'` / `'onBlur'`: validate each field live as it changes / blurs.
- `'onSubmit'`: a self-documenting alias — automatic feedback behaves like
`'manual'`; submit validates either way.

Per-field `validateOn` overrides the form-wide strategy for one field.

### Field arrays and the stable-key contract

`createFieldArray()` is positional by default. For **keyed** list reconciliation
(e.g. rendering with `bq-for`), supply `getKey` — the array then enforces the
"stable item ids" contract on every structural mutation and throws a descriptive
error the moment it is violated, instead of letting duplicate keys cause silent
DOM-reuse bugs downstream:

```ts
const rows = createFieldArray<{ id: string; text: string }>({
initial: [{ id: 'a', text: 'first' }],
factory: (value) => useFormField(value),
getKey: (value) => value.id, // must be present, unique, and stable
});

rows.keys(); // ['a']
rows.keyAt(0); // 'a'
rows.add({ id: 'a', text: 'dup' });
// → Error: createFieldArray() requires stable, unique item keys, but getKey
// returned "a" for both index 0 and index 1.
```

Keys must be a non-empty `string` or a finite `number`. When `getKey` is omitted,
the array stays positional and `keys()` / `keyAt()` return `[]` / `undefined`
(unchanged behaviour).

### SSR serialization boundary

`serializeFormState()` guarantees a stable serialization boundary: values that
cannot meaningfully cross the server → HTML → client boundary as JSON are
**deterministically dropped** (the key is omitted), rather than emitted as
`null`, `{}`, or `"[object File]"`:

- **functions** — not transferable.
- **`File` / `Blob` / `FileList`** — binary handles; re-attach file inputs on the
client after hydration.
- **`bigint`** — would otherwise throw in `JSON.stringify`.
- **`symbol` / `undefined`** — already omitted by JSON.

This is a tested contract, not an incidental side effect — rely on it, and
hydrate large blobs separately.

### Progressive-enhancement form actions

`formAction()` binds a form to a server action that **posts natively when JS is
unavailable** and progressively enhances to a `fetch`-based submit with reactive
pending state when JS is present. It composes with the validation pipeline and
with the `server` module's `csrf()` middleware.

```ts
import { formAction, useFormStatus, optimistic } from '@bquery/bquery/forms';
import { signal } from '@bquery/bquery/reactive';

// Binds to a server action; falls back to a native POST without JS.
const submit = formAction('/todos', { method: 'POST', csrf: () => csrfToken });
const { pending, error } = useFormStatus(submit);

// Optimistic instant feedback, reconciled on response.
const todos = signal<string[]>([]);
const list = optimistic(todos, (current, draft: string) => [...current, draft]);

// Progressive enhancement: sets native action/method + a hidden _csrf field,
// then intercepts submit for a fetch-based, optimistic-aware POST.
const cleanup = submit.enhance(document.querySelector('form')!);
```

- **`formAction(target, options)`** → a handle with reactive `pending`, `error`,
`result`, `submitCount`, `submittedAt`; an `enhance(form)` method (PE attach,
returns cleanup); a programmatic `submit(formData)`; and `reset()`. `target` is
an endpoint URL (native-fallback capable) or a function `(formData) => result`.
Non-OK responses throw a `FormActionError` carrying `status` and `response`.
- **`useFormStatus(action)`** → read-only (`readonly()`) views of the action's
status signals, mirroring React 19's `useFormStatus`.
- **`optimistic(base, reducer)`** → a controller whose reactive `value` folds the
base state through every pending draft. `add(draft)` returns a handle with
`remove()`; `run(draft, task)` applies the overlay for the duration of an async
task and removes it on settle; `pending` / `drafts` are reactive; `clear()`
drops all overlays.

Native fallback requires the server-rendered `<form>` to carry the `action`
(and a hidden CSRF field) — `enhance()` fills these in when missing. Native forms
only support `GET`/`POST`, so `PUT`/`PATCH`/`DELETE` degrade to a native `POST`
(the enhanced fetch still uses the real verb).

<!-- uniform-template-footer -->

## Pitfalls and gotchas

- `handleSubmit()` runs field validation before the handler — throw inside `onSubmit` to populate `submitError`, do not return an error string.
- `createFieldArray()` requires stable item ids for keyed list reconciliation; supply `getKey` if items lack `id`.
- `handleSubmit()` runs the full validation pass before the handler — throw inside `onSubmit` to populate `submitError`, do not return an error string.
- `validationStrategy` defaults to `'manual'`: submit always validates, but there is **no** automatic per-keystroke/blur feedback until you opt into `'onChange'` / `'onBlur'`. See [Validation timing](#validation-timing-validationstrategy).
- For **keyed** field arrays, pass `getKey` to `createFieldArray()`; it validates the stable-key contract with clear errors. Without `getKey` the array is positional. See [the stable-key contract](#field-arrays-and-the-stable-key-contract).
- `bindField` / `bindForm` install delegated listeners — unmount them when the form leaves the DOM.
- `validationStrategy` defaults to `'manual'`; use `'onChange'`, `'onBlur'`, or `'onSubmit'` when you want automatic validation.
- SSR helpers (`serializeFormState` / `readSerializedFormState`) intentionally drop functions and `File` references — hydrate large blobs separately.
- `serializeFormState()` drops functions, `File` / `Blob` / `FileList`, `bigint`, and `symbol` as a [guaranteed boundary](#ssr-serialization-boundary) — hydrate large blobs separately.
- `formAction()` native fallback needs a server-rendered `<form action>` (and a hidden CSRF field) to POST without JS; `enhance()` fills these in when missing.

## Performance notes

Expand All @@ -450,4 +598,5 @@ hydrateForm(form, 'login');

## Version history

- **1.15.0** — **targeting Stable**: surface frozen for one minor cycle ([#139](https://github.com/bQuery/bQuery/issues/139)); `validationStrategy` default and SSR serialization boundary documented as guaranteed contracts; `createFieldArray()` `getKey` stable-key contract validated with clear errors (plus `keys()` / `keyAt()`). New progressive-enhancement actions ([#140](https://github.com/bQuery/bQuery/issues/140)): `formAction`, `useFormStatus`, `optimistic`.
- **1.13.0** — new validators (`integer`, `numeric`, `between`, `length`, `oneOf`, `notOneOf`, `arrayOf`, `requiredIf`, `requiredUnless`, `dateAfter`, `dateBefore`, `validDate`, `fileSize`, `fileType`), combinators (`compose`, `all`, `not`, `withMessage`), field arrays, schema-style config, `bindField` / `bindForm`, scope-aware composables, SSR helpers.
2 changes: 2 additions & 0 deletions docs/introduction.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ Stable modules will not introduce breaking changes between minor releases. Beta

`view` is also **targeting Stable in 1.15.0**: the directive set and expression grammar are frozen, the documented `bq-for` duplicate-key and object-expression edge cases are resolved, and a per-directive SSR support matrix is published. It also gains declarative enter/leave/move transitions (binding the `motion` engine to `bq-if`/`bq-show`/`bq-for`) and an optional `@bquery/bquery/view/compiler` build step that precompiles `bq-*` expressions without `'unsafe-eval'`. See the [View Stability section](/guide/view) for the exit-criteria checklist, frozen directive reference, and per-directive SSR matrix.

`forms` is also **targeting Stable in 1.15.0**: the 1.13 batteries-included surface (validators + combinators, schema builder, field arrays, `bindForm`/`bindField`, scope composables, SSR helpers) is frozen for one minor cycle, the surprising `'manual'` `validationStrategy` default and the SSR serialization boundary (functions / `File` are dropped) are documented as guaranteed contracts, and `createFieldArray()`'s stable-key requirement is now validated with clear errors. It also gains progressive-enhancement form actions — `formAction()` (native POST without JS, fetch-enhanced with pending state when JS is present), `useFormStatus()`, and an `optimistic()` update primitive — composing with validation and the `server` module's CSRF. See the [Forms Stability section](/guide/forms) for the exit-criteria checklist and frozen surface reference.

## When to use bQuery

bQuery is a good fit when you want:
Expand Down
Loading