@zerotal/flow 1.0.4 → 1.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 +56 -27
- package/README.md +2 -0
- package/package.json +7 -6
- package/src/Component.ts +29 -3
- package/src/client/bridge.ts +95 -6
- package/src/compiler/index.ts +31 -0
- package/src/compiler/transform.ts +341 -2
- package/src/jsx-runtime.ts +51 -8
- package/src/provider/FlowProvider.ts +16 -0
- package/src/testing.ts +112 -9
- package/src/utils.ts +31 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,13 +1,42 @@
|
|
|
1
|
-
# Changelog
|
|
1
|
+
# Changelog — @zerotal/flow
|
|
2
2
|
|
|
3
3
|
All notable changes to this package are documented here. The format is
|
|
4
4
|
based on [Keep a Changelog](https://keepachangelog.com/); this package
|
|
5
5
|
follows the Zerotal monorepo's unified versioning.
|
|
6
6
|
|
|
7
|
-
**Maturity: `
|
|
7
|
+
**Maturity: `experimental`** — matching this package's `maturity` field. Flow shares the
|
|
8
|
+
monorepo's version line but not the stable packages' API guarantees; it may change
|
|
9
|
+
between minor versions.
|
|
8
10
|
|
|
9
11
|
## [Unreleased]
|
|
10
12
|
|
|
13
|
+
## [1.1.0] — 2026-08-08
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- **A radio group can be bound again.** `_readModelValue` special-cased checkboxes but not radios, so every radio in a group contributed its `value` whether or not it was checked and the last one in DOM order won — a leisure enquiry silently became a corporate one, with no error anywhere. A radio now reports a value only when checked, and the flush, `input` and `flow:model.blur` paths all skip the `undefined` an unchecked radio returns. The blur path was additionally reading `.value` raw, so it sent `"on"` for checkboxes. Server-to-client sync no longer assigns `el.value` on a radio either, which was rewriting every option in a group to the group's current value and destroying the choices.
|
|
18
|
+
- **A reactive sibling attribute no longer suppresses a `value` binding.** The getter capture is a single slot and JSX evaluates every prop before `jsx()` runs, so `value={this.destination} disabled={this.notSure}` left the capture on `notSure`, the freshness check failed, and the element rendered with no `flow:model` at all — the field accepted typing and nothing reached the server. The capture now retains the element's reads, so a binding finds the key matching its own value. Only keys genuinely read through a `this.` getter qualify, so a literal that happens to equal state still binds nothing.
|
|
19
|
+
- **A radio no longer acquires a binding by coincidence.** Value-identity inference matched whichever single option currently equalled the bound property, so that one radio claimed the whole group's model. Radios are now excluded from binding inference, as `<option>` already was; bind a group explicitly with `{...this.bind('type', optionValue)}`.
|
|
20
|
+
- **`flow:click` no longer breaks the control it sits on.** `preventDefault()` was unconditional, which cancels the element's activation behaviour — a radio or checkbox carrying `onClick` never became checked, and no handler could compensate, because the browser restores the pre-click checkedness after listeners run. The default is now cancelled only where it is in the way (anchors, submit buttons and submit inputs), with `.prevent` to force it and `.passive` to opt out.
|
|
21
|
+
- **Click handlers carrying a modifier are no longer dead.** Modifiers are emitted into the attribute name (`flow:click.stop`), but the listener looked handlers up with an exact attribute selector — which cannot match that name — so `onClick={flow(this.save).stop}` did nothing at all, silently. Handler lookup now reads both forms and applies `.stop`.
|
|
22
|
+
- **`request().ip()` answers inside an action.** The WebSocket dispatcher builds its own `HttpContext`, which has no Bun `Server` behind it, so `ip()` resolved to `null` for every action even though the peer address was captured at upgrade. It is now supplied.
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- **A server action can take arguments from a loop.** `() => this.archive(row.id)` compiles to `flow:click="archive"` plus `data-args`, with the arguments evaluated server-side during the render where `row` exists. Previously the arrow was emitted verbatim, so `row` was undefined in the browser and the click threw a `ReferenceError` into the console and nothing else. An argument that reads `this` still stays a live client expression so it re-evaluates against reactive state.
|
|
27
|
+
- `Component.bind(key, optionValue)` — the explicit way to bind one member of a radio group.
|
|
28
|
+
- `FlowTest.tolerateErrors()`, `assertErrored()`, `lastError()`, `seed()` and `render()`.
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
|
|
32
|
+
- The members `Component` reserves are documented, and pinned by a test so the list cannot drift. A page property colliding with one is a compile error with a clear message, but `title` — taken by the page-title accessor — is an obvious name for a field on a row representing a media item or a review, and there was no list to check.
|
|
33
|
+
- **`FlowTest.call()` rethrows.** A `ValidationError` is still an expected outcome, but any other error is routed to `onError()` (as in production) and then rethrown, so a broken action fails its test instead of looking like one that ran and did nothing. Previously it was swallowed — and a throwing `onError` was swallowed too. Call `tolerateErrors()` when the error path is what you are testing.
|
|
34
|
+
- **`FlowTest.set()` re-renders.** A test that set a property and then read `html()` was silently asserting against the previous render. `seed()` is the batching form that does not render.
|
|
35
|
+
- **A handler pointing at an un-`@expose`d method is a build error.** The allowlist is the exposed methods, so an undecorated method was absent from it — the compiler emitted `flow:click="submit"`, the button rendered enabled, and the click sent a frame the server refused, reporting the refusal only over the WebSocket to `console.error`. Nothing reached the server log and the page did not change. The member table needed to catch this was already built; it is now used.
|
|
36
|
+
- **A client expression referencing a name the browser will not have is reported at boot**, naming the identifier and pointing at `data-args`. The page falls back to the runtime renderer rather than failing the build: the check works from a hand-maintained list of globals, and a false positive should not stop a server starting. In CSP-safe mode, where there is no runtime fallback, it is fatal.
|
|
37
|
+
- The compiler warns when more than half of a page set falls back to the runtime renderer, naming `ZT_FLOW_COMPILE_LOG=1`. Falling back for a page or two is normal; an entire app doing it silently is not.
|
|
38
|
+
- Maturity is stated in the README and npm metadata. It was only ever a `package.json` field npm does not render, and this changelog said `stable` while the manifest said `experimental`.
|
|
39
|
+
|
|
11
40
|
## [1.0.3] — 2026-08-07
|
|
12
41
|
|
|
13
42
|
### Changed
|
|
@@ -27,40 +56,40 @@ _First public release._
|
|
|
27
56
|
|
|
28
57
|
### Added
|
|
29
58
|
|
|
30
|
-
- **`make:flow` scaffolding command.** The flagship server-driven layer previously had no generator
|
|
31
|
-
- **HTTP fallback for WebSocket-blocked networks (Tier 1).** A client circuit breaker: after `HTTP_FALLBACK_AFTER` (3) failed socket handshakes the bridge stops waiting on the WebSocket and sends action frames as plain `POST /__flow/http` requests, which run the _exact same_ server pipeline (hydrate
|
|
32
|
-
- **Reactive lists (`<For>`) + optimistic collections.** `<For each={this.items} keyBy="id">{(item) =>
|
|
33
|
-
- **Dev error overlay.** Under `serve --dev`, an unexpected throw
|
|
34
|
-
- **Type-safe real-time events (Tier 1).** An app-augmentable `FlowEvents` contract maps event names to payload types; `dispatch`/`dispatchTo`/`dispatchSelf` are now generic over it, so a known event's payload is type-checked at every dispatch site (server actions and client expressions alike)
|
|
35
|
-
|
|
36
|
-
- **`@presence`
|
|
37
|
-
- **Time-travel devtools (Tier 1).** Under `serve --dev`, the client records the signed snapshot stream it already receives
|
|
38
|
-
- **Durable / resumable snapshots (Tier 1).** Opt a component in with `static durable = true` (or `{ ttl, scope }`) and its signed snapshot is persisted server-side after every request, keyed by user (or session) + route. On a fresh GET a valid stored snapshot is restored and `onHydrate()` runs instead of `onMount()`, so an in-progress flow resumes exactly across a full reload, tab close/reopen, device switch, or reconnect after the held client snapshot is gone
|
|
39
|
-
- **`@task`
|
|
40
|
-
- **`@shared`
|
|
59
|
+
- **`make:flow` scaffolding command.** The flagship server-driven layer previously had no generator — every `Component` was hand-written (the audit's #1 DX gap). `bun zt make:flow Name` emits a ready-to-run class with the JSX pragma, imports, and an `@expose`/`render` skeleton: `--child` scaffolds a child component (with `setup(props)`), `--crud` a resourceful page (list + create/edit/delete + `@validate` + a form), and `--layout Foo` wraps a page in a layout (with the correct relative import depth for nested names like `Users/Index`). The target directory is auto-detected (`app/flow/pages` for pages, `app/flow/components` for children) or set with `--dir`; since Flow uses file-based routing the page is served automatically, and the command also prints the explicit `Router.flow(...)` line. Registered lazily by `FlowProvider` in console mode, so it's available in any app that registers the provider.
|
|
60
|
+
- **HTTP fallback for WebSocket-blocked networks (Tier 1).** A client circuit breaker: after `HTTP_FALLBACK_AFTER` (3) failed socket handshakes the bridge stops waiting on the WebSocket and sends action frames as plain `POST /__flow/http` requests, which run the _exact same_ server pipeline (hydrate → dispatch → render → patch) over a collecting fake connection and return the frames as a JSON array the client applies — so actions, validation, flashes, redirects, events, and offline-queue replay all keep working behind strict proxies/firewalls that drop WS upgrades, with no component change. It's a graceful degrade, not a configured mode: WS reconnection keeps running and the client upgrades back automatically the moment the socket succeeds (`onopen` resets the failure count and exits HTTP mode). `this.cancel()` POSTs an out-of-band `$cancel` in HTTP mode too. Trade-offs while degraded: no server-pushed frames (so `@task` arrives as one batched update, not token-by-token) and no broadcast-driven `@presence`/`@shared`/`@on("echo:…")` (those ride the separate broadcasting socket); everything driven by the user's own actions still works. Nothing is sent over HTTP until the socket has actually failed.
|
|
61
|
+
- **Reactive lists (`<For>`) + optimistic collections.** `<For each={this.items} keyBy="id">{(item) => …}</For>` compiles to an Alpine `<template x-for>`, so any client change to the array re-renders the list instantly (server patches still keep it authoritative) — the missing piece that made optimistic list ops impossible while `.map()` renders to static HTML. The item template supports element structure, static attrs, `class`/`className` (→ `:class`), reactive attrs, `on*` arrow handlers (→ Alpine `@event`, `this.`→`$flow.`), and `{item.field}` text (→ `x-text`); anything else is a clear compile error pointing at a raw `x-for` escape hatch. On top of it, `this.appendOptimistic(prop, item)` / `this.removeOptimistic(prop, match)` mutate the array before the server confirms: the change shows at once, **survives interim (broadcast/event) patches** (pending ops are re-applied over each server merge), and is **reconciled when the owning action's patch resolves** — server list on success, automatic rollback on failure. Pure reconciliation core in `client/optimistic.ts`; new `For`/`ForProps` exports.
|
|
62
|
+
- **Dev error overlay.** Under `serve --dev`, an unexpected throw — in a server action **or** the initial `GET` render (`onMount`/`render`) — pops a full-screen overlay (Vite/Next style) with the error class, message, and stack, naming the action + component and dimming framework/`node_modules` frames so app frames stand out. Dismiss with Esc or a backdrop click; after an action error the patch still reconciles underneath, so you return to a live page (pairs with the time-travel timeline). The detail is attached **only under the dev worker** — production sends none (an action flashes; an initial-render error returns a normal 500). Only unexpected throws overlay: validation errors and **intended HTTP errors** (auth `401`/`403`, `404`, redirects — anything carrying a numeric `status`) pass straight through to their normal handling. New module `client/errorOverlay.ts`; the error frame gained optional dev-only `name`/`stack`/`action` fields; the initial-render error rides an embedded `flow-boot-error` script the bridge reads on load.
|
|
63
|
+
- **Type-safe real-time events (Tier 1).** An app-augmentable `FlowEvents` contract maps event names to payload types; `dispatch`/`dispatchTo`/`dispatchSelf` are now generic over it, so a known event's payload is type-checked at every dispatch site (server actions and client expressions alike) — wrong shape, missing payload, or a payload on a `void` event all fail to compile — while unknown names stay untyped (a `string` fallback keeps `echo:…` and gradual adoption compiling, no breakage). `@on` autocompletes to known names; annotate handlers with `EventPayload<K>`. No codegen. An optional runtime guard (`registerFlowEvent(name, guard)`) throws from `dispatch` on a malformed payload — for the untrusted client-originated path the types can't cover. New module `events.ts`; exports `registerFlowEvent`, `FlowEvents`, `EventName`, `EventPayload`.
|
|
64
|
+
|
|
65
|
+
- **`@presence` — multiplayer "who's here" (Tier 1).** Binds a property to a broadcast presence channel (static or a `(self) => string` resolver) and keeps it filled with the live member list, refreshed on join/leave via a new `$presence` built-in. The resolved channel is server-derived and carried signed in the snapshot; the prop is server-controlled (locked). Client-only cursor/typing whispers ride the same channel via `this.whisper(event, data)` / `this.onWhisper(event, cb)`. Broadcasting is an optional peer — resolved through a dynamic import, a no-op when absent — so flow takes no new hard dependency.
|
|
66
|
+
- **Time-travel devtools (Tier 1).** Under `serve --dev`, the client records the signed snapshot stream it already receives — one frame per applied patch plus the initial mount — into a capped ring buffer, and can scrub back to any frame: a jump re-applies that frame's snapshot + HTML to the live component (client-only, no server round-trip), restoring state and DOM exactly. It surfaces as a **Timeline tab in the `@zerotal/devtools` panel** (registered via that package's new `window.__zerotalDevtools` extension API) so all the framework's tooling is unified; when devtools isn't present it falls back to a standalone ⏱ panel. Frames list by action + changed fields + time with click-to-jump and a ⏵ Resume-live button; a `window.__flow.timeline` console API (`frames`/`framesFor`/`jump`/`live`) mirrors it. Dev-gated via the `dev` flag on the WS `ready` frame — production ships none of it. The recording core is a DOM-free module (`client/timeline.ts`); the DOM apply step is injected from the bridge, and the patch morph was extracted to a shared `_morphComponent` reused by both a patch and a jump.
|
|
67
|
+
- **Durable / resumable snapshots (Tier 1).** Opt a component in with `static durable = true` (or `{ ttl, scope }`) and its signed snapshot is persisted server-side after every request, keyed by user (or session) + route. On a fresh GET a valid stored snapshot is restored and `onHydrate()` runs instead of `onMount()`, so an in-progress flow resumes exactly across a full reload, tab close/reopen, device switch, or reconnect after the held client snapshot is gone — nothing stored in the browser. HMAC verification + identity keying isolate users and reject tampered/rotated-key entries (graceful fresh mount). `this.clearDurable()` forgets the entry on flow completion. The store is pluggable via `setDurableStore()` (default in-process TTL store; swap a `@zerotal/cache`/Redis-backed `{ get, set, delete }` for redeploy survival). `scope: "user"` keys by the authenticated user (session fallback when anonymous); `scope: "session"` keys per-device.
|
|
68
|
+
- **`@task` — streaming, cancellable actions (Tier 1).** Mark an async method `@task` (implicitly `@expose`) and write a field in a loop — while the action runs the framework flushes throttled **field-level** snapshot diffs (the changed fields only, no HTML, no per-chunk re-render), so a reactive binding of the field (`text={this.answer}`, `x-text`, a reactive `:attr`) streams to the browser live as `this.answer += token` runs. The field stays the source of truth (no `flow:stream` element, no double-write); the final patch re-renders once to reconcile any static template positions. The triggering control keeps its loading state for the whole run (partial patches don't clear it; only the final patch does). `this.signal` (a standard `AbortSignal`) + `this.cancelled` give cooperative cancellation; the client's `this.cancel()` sends an out-of-band `$cancel` frame (bypassing the per-component send queue the running task occupies), which the server handles before dispatch to abort the task's controller. Implemented via a `partial` patch flag and a chained delta base (each streaming patch is delta-encoded against the previous one the client received).
|
|
69
|
+
- **`@shared` — convergent multiplayer state (Tier 1).** Binds a property to server-authoritative shared state on a channel: mutating it in an action writes to a per-channel **room store** and broadcasts to the channel, so every other subscriber re-reads and converges via a new `$shared` built-in. Before each action the prop is refilled from the store (read-latest); after, changed props are written back and broadcast (`toOthers`). The resolved channel is signed in the snapshot (`SnapshotMemo.shared`) and the prop is server-controlled (locked). The store is pluggable via `setSharedStore()` (default in-process; swap a Redis-backed `{ get, set, has }` for multi-instance). Same optional-peer broadcasting as `@presence` — without it, state still converges within a single window's round-trips. v1 semantics: last-write-wins.
|
|
41
70
|
- **Delta transport.** WebSocket patches carry only the changed snapshot fields (rebuilt client-side against the copy it holds, still HMAC-verified) via `encodeSnapshotDelta`/`applySnapshotDelta`, and omit `html` when the re-render is byte-identical to the last patch on that connection. `ZT_FLOW_TRANSPORT_LOG=1` logs delta-vs-full sizes.
|
|
42
|
-
- **JSX-native layouts.** Override `Component.layout(page)` to wrap the rendered root in any component
|
|
43
|
-
- **Named slots on child components.** `slots={{
|
|
71
|
+
- **JSX-native layouts.** Override `Component.layout(page)` to wrap the rendered root in any component — named regions are plain props. `static layout` still works; the method wins when both are present. Nav-persistence uses a `data-flow-layout` marker (declared on the shell, else derived from the wrapper source).
|
|
72
|
+
- **Named slots on child components.** `slots={{ … }}` + `this.slot(name)` / `this.hasSlot(name)`; slot HTML is carried, signed, in the child snapshot (`SnapshotMemo.slots`).
|
|
44
73
|
- **Compiler prop parity.** `transition`, the `flow:offline` family (`showOnOffline`/`hideOnOffline`/`offlineClass`/`offlineAttr`), `sortItem`, and `loadingTarget`/`dirtyTarget` (`flow:target`) now compile to their directives; the AOT `directives.ts` map and the runtime `PULSE_PROP_MAP` were reconciled.
|
|
45
|
-
- **Stateful fast refresh in dev.** Editing a component under `serve --dev` now re-renders each mounted component from its held, still-valid snapshot with the newly-compiled code
|
|
46
|
-
- **Tier 2 interaction polish.** `<Skeleton>` pulsing placeholder (+ `<Loading skeleton>` and lazy `placeholder()` use); **hover-prefetch** for `navigate hover` links (fetch-on-hover cache so the click swaps instantly
|
|
74
|
+
- **Stateful fast refresh in dev.** Editing a component under `serve --dev` now re-renders each mounted component from its held, still-valid snapshot with the newly-compiled code — **preserving state** — instead of a full reload, and refetches the stylesheet so new classes apply. Implemented as a `$rerender` built-in (a no-op re-render that skips `onMount`), gated to the dev worker via a `dev` flag on the WS `ready` frame, triggered by the flow WS reconnect; falls back to a one-shot full reload if the held snapshot is incompatible with the new code.
|
|
75
|
+
- **Tier 2 interaction polish.** `<Skeleton>` pulsing placeholder (+ `<Loading skeleton>` and lazy `placeholder()` use); **hover-prefetch** for `navigate hover` links (fetch-on-hover cache so the click swaps instantly — the prop existed but was unimplemented); and **optimistic-UI failed state** via `showOnError`/`hideOnError` (driven by an `actionError` flag the server sets when an action or a rejected `onUpdating` write throws). Rollback itself was already a property of snapshot reconciliation. `lazy`/`defer` are now typed on component JSX props.
|
|
47
76
|
|
|
48
77
|
### Changed
|
|
49
78
|
|
|
50
|
-
- **Loading indicators wait out a short delay by default (no flash on fast actions).** `showOnLoading`, `hideOnLoading`, `loadingClass`, and `<Loading>` now reveal only after ~200ms in flight, so an action that finishes inside that window shows nothing
|
|
51
|
-
- **`live` text inputs debounce their server sync (~150ms) by default.** A `live` text/textarea input no longer fires a WebSocket `$set` on every keystroke
|
|
52
|
-
- **WebSocket actions serialize per component.** At most one action per component is in flight; the next waits for the previous patch to apply. Required by delta transport (deltas are order-dependent
|
|
79
|
+
- **Loading indicators wait out a short delay by default (no flash on fast actions).** `showOnLoading`, `hideOnLoading`, `loadingClass`, and `<Loading>` now reveal only after ~200ms in flight, so an action that finishes inside that window shows nothing — no spinner flash. `loadingAttr` (e.g. `loadingAttr="disabled"`) stays **immediate**, so a submit button still guards against a double-click even on a sub-100ms action. Previously only the opt-in `<Loading delay>` / `flow:loading.delay` was delayed; `delay` is now the default and kept for back-compat. Cleared instantly on resolve, so a just-over-threshold action barely blips.
|
|
80
|
+
- **`live` text inputs debounce their server sync (~150ms) by default.** A `live` text/textarea input no longer fires a WebSocket `$set` on every keystroke — it debounces ~150ms (matching Livewire's `wire:model.live`), so real-time validation and reactive server state update when you pause, not per character. Discrete controls (checkbox, radio, `<select>`, range) still sync immediately. The DOM and client-reactive bindings update on every keystroke regardless; only the server round-trip waits, and no value is ever lost (local state is current and any action flushes the full snapshot).
|
|
81
|
+
- **WebSocket actions serialize per component.** At most one action per component is in flight; the next waits for the previous patch to apply. Required by delta transport (deltas are order-dependent — a pipelined patch computed against a stale base would corrupt the client snapshot and trip the invalid-request rate limit). Offline replay runs through the same serial queue; a 15s ack timeout prevents wedging.
|
|
53
82
|
|
|
54
83
|
### Fixed
|
|
55
84
|
|
|
56
|
-
- **`@expose`/`@locked` on a getter now fails loudly.** A getter (a `@computed`, or any accessor) has no writable snapshot storage, so exposing or locking one would serialize a value the framework then tries to write back on the next update
|
|
57
|
-
- **`@computed` getters compile in a text child.** The AOT compiler only knew about `@expose`/`@locked`, so `{this.uptime}` for a `@computed` getter failed validation and the whole page silently fell back to the runtime renderer. The compiler now recognises `@computed` (it sits on a getter, which the member scan previously skipped) and allows it wherever a text child emits a **static** value (`{this.x}`, `{String(this.x)}`)
|
|
58
|
-
- **Removed two dead binding-resolution defences.** The runtime resolver's "brute-force own-property scan" and the twin `hasOwnProperty(hint)` fallback existed to recover `@locked` props "whose decorator registration failed to land"
|
|
59
|
-
- **Component bindings resolve statically (compile-time bind-name injection).** The AOT string compiler bails on any function component, so component-heavy pages render through the runtime `jsx()` path, where TSC's jsx transform has already discarded the `show
|
|
60
|
-
- **`text={this.x}` binds to its own property, not a sibling binding's.** On the runtime (non-AOT) render path, `_resolveBindName` trusted a single-slot getter capture
|
|
61
|
-
- **`<Head>` content survives SPA navigation.** On a same-layout `navigate`, `_processHead` only ever re-read the live `document`
|
|
85
|
+
- **`@expose`/`@locked` on a getter now fails loudly.** A getter (a `@computed`, or any accessor) has no writable snapshot storage, so exposing or locking one would serialize a value the framework then tries to write back on the next update — clobbering the getter. TypeScript already rejects it (the decorators' Stage 3 context types exclude getters), but Bun transpiles without typechecking, so the decorator would still run and silently misregister. The `@expose`/`@locked`/`@url`/`@session`/`@transient`/`@reactive`/`@modelable` decorators now throw at class definition on a getter/setter/accessor, with a message pointing to `@computed` (for a derived value) or an `@expose` field (for client-writable state).
|
|
86
|
+
- **`@computed` getters compile in a text child.** The AOT compiler only knew about `@expose`/`@locked`, so `{this.uptime}` for a `@computed` getter failed validation and the whole page silently fell back to the runtime renderer. The compiler now recognises `@computed` (it sits on a getter, which the member scan previously skipped) and allows it wherever a text child emits a **static** value (`{this.x}`, `{String(this.x)}`) — matching how `@expose`/`@locked` text children already render (static, updates on the next server patch). Reactive sites still reject it correctly (a `@computed` isn't in the snapshot, so `text={this.x}` / reactive `:class` can't bind to it) — now with a message that says so and points to `{this.x}` or `@expose`. Also corrected the docs claim that `@computed` is "reactive on the client": it renders a static server-evaluated value, not a client-reactive binding.
|
|
87
|
+
- **Removed two dead binding-resolution defences.** The runtime resolver's "brute-force own-property scan" and the twin `hasOwnProperty(hint)` fallback existed to recover `@locked` props "whose decorator registration failed to land" — but `getExposedProps`/`getLockedProps` now drain the prototype chain first, so every decorated prop is reliably registered before the value-scan. Verified across the full suite + the showcase that the brute-force branch never fired; it could only ever match a _non-decorated_ field (a false positive), so removing it also removes that footgun.
|
|
88
|
+
- **Component bindings resolve statically (compile-time bind-name injection).** The AOT string compiler bails on any function component, so component-heavy pages render through the runtime `jsx()` path, where TSC's jsx transform has already discarded the `show`→`sheetOpen` prop→key mapping. That made value-based bind resolution fragile: a bound child clobbered the getter capture, and props sharing a value (two `false`s) couldn't be told apart, so an overlay-with-a-bound-form could render **unbound** (with only a `console.warn`). A new pass (`buildBindInjectedRender`) reprints a bailed page's `render()` with `__flowBinds={{ show: "sheetOpen" }}` added to every component that binds `show`/`bind`/`query` to `this.<key>`; the component reads it via `_injectedBindKey(props, attr)` (before `name=`/value resolution), so the bound prop is resolved from the source **statically and unambiguously**. The reprint is otherwise identical to the source (behaviour unchanged apart from robust binding), runs through the existing cache as a `.tsx` entry, and falls back to the untouched runtime render on any failure. Wired for every overlay (`Modal`/`Drawer`/`Dialog`/`Sheet`), form control (`Switch`/`Checkbox`/`Select`/`RadioGroup`/`Listbox`), and `Combobox` (`bind` + `query`). `__flowBinds` is stripped by `jsx()` and never emitted as an HTML attribute. (Applies to pages the compiler sees — file-based routes automatically; `Router.flow` pages once their `__sourceFile` is set.) Audit of the other seams: `_resolveValueBind` (`value=`/`checked=`) is already value-identity-guarded and only applies to leaf inputs (no children to clobber the capture); `error=`/`<ErrorMessage>` carries a self-describing sentinel — neither is ambiguous.
|
|
89
|
+
- **`text={this.x}` binds to its own property, not a sibling binding's.** On the runtime (non-AOT) render path, `_resolveBindName` trusted a single-slot getter capture — but every prop of a JSX element is evaluated before `jsx()` runs, so a later binding like `class={"row " + this.accent}` overwrote the capture that `text={this.count}` had set. The element then emitted `flow:text="accent"` and rendered the accent string in place of the count. The resolver now value-guards the capture (only trusts it when the captured property's current value is the one being resolved) and falls back to value-identity + the hint, so `text` (and `show`) always resolve to their own property regardless of other bindings on the same element. `value=`/`checked=` were already value-checked and unaffected.
|
|
90
|
+
- **`<Head>` content survives SPA navigation.** On a same-layout `navigate`, `_processHead` only ever re-read the live `document` — but the initial hoist removes each `<template data-flow-head>` from the DOM, and a persistent-layout swap carries only the page root (not the shell's `<Head>`). So the managed head was cleared and never restored: a stylesheet or fonts declared in a JSX-native layout's `<Head>` vanished on the first navigation (the page rendered unstyled), and page-level `<title>`/`<meta>` were dropped too. `navigate` now re-applies head from the **incoming** parsed document (which still carries every head template — shell stylesheet and page meta alike), deduping by identity so an unchanged stylesheet is left in place rather than removed-and-re-added (no unstyled flash between visits).
|
|
62
91
|
- **`lazy`/`defer` on a child component in JSX now actually defer.** `<Widget lazy />` passed `lazy` through as a component _prop_ instead of a `child()` option, so the child mounted eagerly (blocking on its `onMount`) and never showed its placeholder. The JSX runtime now routes `lazy`/`defer` to the child-loading options.
|
|
63
|
-
- **Browser Back to the first page did nothing.** SPA `navigate` only re-rendered on `popstate` when the history entry carried the `flowNavigate` marker, which a fresh page load's initial entry lacked
|
|
92
|
+
- **Browser Back to the first page did nothing.** SPA `navigate` only re-rendered on `popstate` when the history entry carried the `flowNavigate` marker, which a fresh page load's initial entry lacked — so pressing Back to the first visited page changed the URL but left the page in place. The initial entry is now stamped at startup.
|
|
64
93
|
- **`transition` prop.** Was typed but absent from `PULSE_PROP_MAP`, so it emitted an inert `transition` attribute instead of `flow:transition`.
|
|
65
94
|
- **`children`/`slots` on components type-check.** Added to `JSX.IntrinsicAttributes` so passing children or a `slots` prop to a `Component` no longer errors.
|
|
66
95
|
|
package/README.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
> Reactive server-side rendering for Zerotal — server-rendered components, streamed over WebSocket, morphed into the DOM by Alpine.
|
|
4
4
|
|
|
5
|
+
**Maturity: experimental.** Flow ships on the same version line as the rest of Zerotal, but it is not at the same maturity as the stable packages. Its API may change between minor versions, and it has had far less production exposure than the core, ORM, and HTTP layers. The version number alone doesn't tell you that, which is why it is said here — build on it deliberately.
|
|
6
|
+
|
|
5
7
|
Flow is Zerotal's server-driven UI layer. Each page is a TypeScript `Component` class whose state lives on the server. You write plain JSX and bind handlers like `onClick={this.save}`; on each interaction the server hydrates the component from a signed snapshot, runs the method, re-renders, and streams only the changed HTML back, which Alpine.js morphs into the page. There is no API layer, no client-side store, and no hand-written client reactivity — the server is the single source of truth.
|
|
6
8
|
|
|
7
9
|
Flow began life as **Geleza** — isiZulu for _"to flow"_ — and the name turned out to describe the architecture better than anything we could invent. Settling on English package names, we translated it rather than replaced it.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zerotal/flow",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"maturity": "experimental",
|
|
6
6
|
"private": false,
|
|
@@ -45,14 +45,14 @@
|
|
|
45
45
|
"@alpinejs/morph": "^3.14.9",
|
|
46
46
|
"@alpinejs/persist": "^3.14.9",
|
|
47
47
|
"@alpinejs/resize": "^3.14.9",
|
|
48
|
-
"@zerotal/core": "1.0
|
|
49
|
-
"@zerotal/orm": "1.0
|
|
50
|
-
"@zerotal/validator": "1.0
|
|
48
|
+
"@zerotal/core": "1.1.0",
|
|
49
|
+
"@zerotal/orm": "1.1.0",
|
|
50
|
+
"@zerotal/validator": "1.1.0",
|
|
51
51
|
"alpinejs": "^3.14.9",
|
|
52
52
|
"typescript": "^5.8.0"
|
|
53
53
|
},
|
|
54
54
|
"devDependencies": {
|
|
55
|
-
"@zerotal/broadcasting": "1.0
|
|
55
|
+
"@zerotal/broadcasting": "1.1.0",
|
|
56
56
|
"@types/alpinejs": "^3.13.10",
|
|
57
57
|
"@types/alpinejs__anchor": "^3.13.1",
|
|
58
58
|
"@types/alpinejs__collapse": "^3.13.4",
|
|
@@ -62,8 +62,9 @@
|
|
|
62
62
|
"@types/alpinejs__resize": "^3.14.0",
|
|
63
63
|
"bun-types": "^1.3.14"
|
|
64
64
|
},
|
|
65
|
-
"description": "Flow — server-driven reactive UI for Zerotal with signed snapshots, delta transport, and real-time primitives.",
|
|
65
|
+
"description": "Flow (experimental) — server-driven reactive UI for Zerotal with signed snapshots, delta transport, and real-time primitives.",
|
|
66
66
|
"keywords": [
|
|
67
|
+
"experimental",
|
|
67
68
|
"zerotal",
|
|
68
69
|
"bun",
|
|
69
70
|
"typescript",
|
package/src/Component.ts
CHANGED
|
@@ -1181,18 +1181,44 @@ export abstract class Component {
|
|
|
1181
1181
|
/**
|
|
1182
1182
|
* Emit the HTML attributes needed for `flow:model` two-way binding.
|
|
1183
1183
|
*
|
|
1184
|
+
* Pass `optionValue` for one member of a radio group: the radio carries that
|
|
1185
|
+
* fixed value and is `checked` only when the bound property currently equals it.
|
|
1186
|
+
* A radio group is addressed as a unit — every option binds the same property —
|
|
1187
|
+
* so `value={…}`/`checked={…}` on a radio never infers a binding by itself and
|
|
1188
|
+
* this is the way to bind one.
|
|
1189
|
+
*
|
|
1184
1190
|
* @example
|
|
1185
1191
|
* <input {...this.bind('name')} />
|
|
1186
1192
|
* // → <input flow:model="name" value="Alice" />
|
|
1187
1193
|
*
|
|
1194
|
+
* @example
|
|
1195
|
+
* // A radio group over a `type` property:
|
|
1196
|
+
* {['CUSTOM', 'ROUTE', 'TEAMS'].map((t) => (
|
|
1197
|
+
* <label>
|
|
1198
|
+
* <input type="radio" name="type" {...this.bind('type', t)} /> {t}
|
|
1199
|
+
* </label>
|
|
1200
|
+
* ))}
|
|
1201
|
+
* // → <input type="radio" name="type" flow:model="type" value="ROUTE" checked>
|
|
1202
|
+
*
|
|
1188
1203
|
* @param key the `@expose` property to two-way bind.
|
|
1189
|
-
* @
|
|
1204
|
+
* @param optionValue this radio's own value; omit for text/checkbox/select inputs.
|
|
1205
|
+
* @returns the `flow:model` + `value` (+ `checked`) attributes to spread onto an input.
|
|
1190
1206
|
* @category State & exposure
|
|
1191
1207
|
*/
|
|
1192
|
-
bind(key: string): Record<string, unknown> {
|
|
1208
|
+
bind(key: string, optionValue?: string): Record<string, unknown> {
|
|
1209
|
+
const current = (this as Record<string, unknown>)[key];
|
|
1210
|
+
if (optionValue !== undefined) {
|
|
1211
|
+
return {
|
|
1212
|
+
"flow:model": key,
|
|
1213
|
+
value: optionValue,
|
|
1214
|
+
// String-compare so a numeric property still matches its string option value —
|
|
1215
|
+
// the DOM only ever gives a radio's value back as a string.
|
|
1216
|
+
checked: String(current ?? "") === optionValue,
|
|
1217
|
+
};
|
|
1218
|
+
}
|
|
1193
1219
|
return {
|
|
1194
1220
|
"flow:model": key,
|
|
1195
|
-
value:
|
|
1221
|
+
value: current ?? "",
|
|
1196
1222
|
};
|
|
1197
1223
|
}
|
|
1198
1224
|
|
package/src/client/bridge.ts
CHANGED
|
@@ -1605,6 +1605,14 @@ function _syncModelInputs(comp: FlowComponent): void {
|
|
|
1605
1605
|
el.checked = Boolean(serverVal);
|
|
1606
1606
|
return;
|
|
1607
1607
|
}
|
|
1608
|
+
// A radio's `value` is its immutable option identifier — the group's state selects
|
|
1609
|
+
// among those, it does not replace them. Assigning `el.value` here (as the generic
|
|
1610
|
+
// path below does) would rewrite every option in the group to the current value and
|
|
1611
|
+
// destroy the choices.
|
|
1612
|
+
if (el instanceof HTMLInputElement && el.type === "radio") {
|
|
1613
|
+
el.checked = el.value === String(serverVal ?? "");
|
|
1614
|
+
return;
|
|
1615
|
+
}
|
|
1608
1616
|
const str = String(serverVal ?? "");
|
|
1609
1617
|
if (el.value !== str) el.value = str;
|
|
1610
1618
|
// A draft-backed field the server just emptied (e.g. after a successful submit) → drop the draft.
|
|
@@ -1844,6 +1852,62 @@ function debounce<T extends unknown[]>(fn: (...args: T) => void, ms: number): (.
|
|
|
1844
1852
|
};
|
|
1845
1853
|
}
|
|
1846
1854
|
|
|
1855
|
+
/**
|
|
1856
|
+
* Find the nearest ancestor carrying `flow:<event>`, with or without modifiers.
|
|
1857
|
+
*
|
|
1858
|
+
* The compiler emits modifiers into the attribute *name* (`onClick={flow(this.save).stop}`
|
|
1859
|
+
* → `flow:click.stop="save"`), but a `[flow\:click]` attribute selector matches that name
|
|
1860
|
+
* exactly — so every handler that carried a modifier was invisible to the bridge and did
|
|
1861
|
+
* nothing at all, silently. Walking the ancestors and reading the attribute list finds
|
|
1862
|
+
* both forms.
|
|
1863
|
+
*/
|
|
1864
|
+
export function _findHandler(
|
|
1865
|
+
target: Element | null,
|
|
1866
|
+
event: string,
|
|
1867
|
+
): { el: Element; value: string; modifiers: Set<string> } | null {
|
|
1868
|
+
const prefix = `flow:${event}`;
|
|
1869
|
+
for (let el = target; el; el = el.parentElement) {
|
|
1870
|
+
for (const attr of Array.from(el.attributes)) {
|
|
1871
|
+
if (attr.name !== prefix && !attr.name.startsWith(`${prefix}.`)) continue;
|
|
1872
|
+
if (!attr.value) continue;
|
|
1873
|
+
const modifiers = new Set(
|
|
1874
|
+
attr.name.length > prefix.length ? attr.name.slice(prefix.length + 1).split(".") : [],
|
|
1875
|
+
);
|
|
1876
|
+
return { el, value: attr.value, modifiers };
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
return null;
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
/**
|
|
1883
|
+
* Whether a click handler should cancel the element's default action.
|
|
1884
|
+
*
|
|
1885
|
+
* `preventDefault()` used to be unconditional, which cancels the *activation behaviour*
|
|
1886
|
+
* of whatever it is placed on — so a radio or checkbox carrying `onClick` never became
|
|
1887
|
+
* checked, and no handler could fix it: the browser restores the control's pre-click
|
|
1888
|
+
* checkedness after listeners run, so assigning `.checked` inside the handler is reverted
|
|
1889
|
+
* a moment later. `onClick` and a form control were effectively mutually exclusive.
|
|
1890
|
+
*
|
|
1891
|
+
* Default only where the default action is the thing in the way — following a link, or
|
|
1892
|
+
* submitting a form. `.prevent` forces it anywhere; `.passive` opts out anywhere.
|
|
1893
|
+
*/
|
|
1894
|
+
export function _shouldPreventClickDefault(el: Element, modifiers: Set<string>): boolean {
|
|
1895
|
+
if (modifiers.has("passive")) return false;
|
|
1896
|
+
if (modifiers.has("prevent")) return true;
|
|
1897
|
+
// tagName rather than instanceof: an element adopted from another document (an iframe,
|
|
1898
|
+
// a template) fails `instanceof HTMLAnchorElement` against this window's constructors,
|
|
1899
|
+
// and silently taking the wrong branch here is the whole class of bug being fixed.
|
|
1900
|
+
const tag = el.tagName?.toUpperCase();
|
|
1901
|
+
if (tag === "A") return true;
|
|
1902
|
+
const type = (el.getAttribute?.("type") ?? "").toLowerCase();
|
|
1903
|
+
// A <button> with no type attribute is type="submit" per the HTML spec.
|
|
1904
|
+
if (tag === "BUTTON") return type === "" || type === "submit";
|
|
1905
|
+
// Inputs of type submit/image submit a form; every other control (checkbox, radio,
|
|
1906
|
+
// file, …) needs its activation behaviour left alone.
|
|
1907
|
+
if (tag === "INPUT") return type === "submit" || type === "image";
|
|
1908
|
+
return false;
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1847
1911
|
function _parseArgs(el: Element): unknown[] {
|
|
1848
1912
|
const raw = el.getAttribute("data-args") ?? el.getAttribute("flow:args");
|
|
1849
1913
|
if (!raw) return [];
|
|
@@ -1989,9 +2053,18 @@ function _modelKey(el: Element): string | null {
|
|
|
1989
2053
|
* coercion for `type="number"` inputs whose canonical value is already numeric. A checkbox
|
|
1990
2054
|
* returns its boolean `checked`. `existing` (the current canonical value) guards the implicit
|
|
1991
2055
|
* number coercion so a string-typed field isn't turned into a number behind the author's back.
|
|
2056
|
+
*
|
|
2057
|
+
* A radio returns its value only when it is the checked member of its group, and
|
|
2058
|
+
* `undefined` otherwise — callers must skip `undefined` rather than write it. Every
|
|
2059
|
+
* radio in a group carries the same `flow:model`, so returning a bare `.value` would
|
|
2060
|
+
* let each unchecked option overwrite the group's state in turn and leave whichever
|
|
2061
|
+
* one happens to be last in DOM order as the winner.
|
|
1992
2062
|
*/
|
|
1993
2063
|
function _readModelValue(el: ModelEl, existing?: unknown): unknown {
|
|
1994
2064
|
if (el instanceof HTMLInputElement && el.type === "checkbox") return el.checked;
|
|
2065
|
+
if (el instanceof HTMLInputElement && el.type === "radio") {
|
|
2066
|
+
return el.checked ? el.value : undefined;
|
|
2067
|
+
}
|
|
1995
2068
|
let v = (el as HTMLInputElement).value;
|
|
1996
2069
|
if (el.hasAttribute("data-flow-trim")) v = v.trim();
|
|
1997
2070
|
const wantNumber =
|
|
@@ -2010,6 +2083,9 @@ function _flushModelInputs(comp: FlowComponent): void {
|
|
|
2010
2083
|
if (!key) return;
|
|
2011
2084
|
// Coerce (see _readModelValue) so a number field doesn't send "0" as a string, etc.
|
|
2012
2085
|
const value = _readModelValue(el, _getPath(comp.reactive as Record<string, unknown>, key));
|
|
2086
|
+
// An unchecked radio reports undefined — skip it, or iterating the group would
|
|
2087
|
+
// clear the value its checked sibling just contributed.
|
|
2088
|
+
if (value === undefined) return;
|
|
2013
2089
|
// reactive first (see set-trap note): ephemeral is the proxy's raw target.
|
|
2014
2090
|
_setPath(comp.reactive, key, value);
|
|
2015
2091
|
_setPath(comp.ephemeral, key, value);
|
|
@@ -2072,13 +2148,14 @@ function _setupEventDelegation(): void {
|
|
|
2072
2148
|
});
|
|
2073
2149
|
|
|
2074
2150
|
document.addEventListener("click", async (e) => {
|
|
2075
|
-
const
|
|
2076
|
-
if (!
|
|
2077
|
-
const method =
|
|
2078
|
-
if (!method) return;
|
|
2151
|
+
const found = _findHandler(e.target as Element, "click");
|
|
2152
|
+
if (!found) return;
|
|
2153
|
+
const { el, value: method, modifiers } = found;
|
|
2079
2154
|
const comp = findComponentByEl(el);
|
|
2080
2155
|
if (!comp) return;
|
|
2081
|
-
|
|
2156
|
+
|
|
2157
|
+
if (modifiers.has("stop")) e.stopPropagation();
|
|
2158
|
+
if (_shouldPreventClickDefault(el, modifiers)) e.preventDefault();
|
|
2082
2159
|
|
|
2083
2160
|
// Async: a confirm prompt shows the styled dialog and resolves on Confirm/Cancel.
|
|
2084
2161
|
if (!(await _requireConfirm(el))) return;
|
|
@@ -2130,6 +2207,9 @@ function _setupEventDelegation(): void {
|
|
|
2130
2207
|
// Apply the trim/number modifiers on the way in, so client-reactive bindings see the coerced
|
|
2131
2208
|
// value immediately (not just the server on the next action).
|
|
2132
2209
|
const value = _readModelValue(el, _getPath(comp.reactive as Record<string, unknown>, key));
|
|
2210
|
+
// Only the newly-checked radio in a group reports a value; the input event that
|
|
2211
|
+
// deselects its sibling carries nothing to write.
|
|
2212
|
+
if (value === undefined) return;
|
|
2133
2213
|
|
|
2134
2214
|
// reactive first (see set-trap note): ephemeral is the proxy's raw target, so writing it
|
|
2135
2215
|
// first would suppress the reactive trigger and leave x-text/:class bindings stale.
|
|
@@ -2168,7 +2248,16 @@ function _setupEventDelegation(): void {
|
|
|
2168
2248
|
if (!comp) return;
|
|
2169
2249
|
|
|
2170
2250
|
const key = el.getAttribute("flow:model.blur");
|
|
2171
|
-
if (key)
|
|
2251
|
+
if (key) {
|
|
2252
|
+
// Go through _readModelValue so the blur path applies the same coercions and
|
|
2253
|
+
// the same radio/checkbox rules as input and flush do — a raw `.value` here
|
|
2254
|
+
// sends "on" for a checkbox and an unchecked radio's value for a radio.
|
|
2255
|
+
const blurValue = _readModelValue(
|
|
2256
|
+
el as ModelEl,
|
|
2257
|
+
_getPath(comp.reactive as Record<string, unknown>, key),
|
|
2258
|
+
);
|
|
2259
|
+
if (blurValue !== undefined) _callAction(comp, "$set", [key, blurValue]);
|
|
2260
|
+
}
|
|
2172
2261
|
|
|
2173
2262
|
// `onBlur={this.method}` — the JSX runtime emits `flow:blur` for it like any
|
|
2174
2263
|
// other `on*` prop, so without this the attribute renders and nothing ever
|
package/src/compiler/index.ts
CHANGED
|
@@ -169,4 +169,35 @@ export async function compileRegisteredPages(appRoot: string, cspSafe = false):
|
|
|
169
169
|
{ compiled, cached, injected, runtime: skipped, ms },
|
|
170
170
|
);
|
|
171
171
|
}
|
|
172
|
+
|
|
173
|
+
_warnIfMostlyRuntime(compiled + cached + injected + skipped, skipped);
|
|
172
174
|
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Falling back to the runtime renderer for a page or two is normal and correct.
|
|
178
|
+
* A whole app doing it is not: the runtime path resolves `value=`/`checked=`
|
|
179
|
+
* bindings by observing property reads at render time, which is necessarily
|
|
180
|
+
* weaker than the compiler reading the same intent out of the AST. An app that
|
|
181
|
+
* never compiles is relying on that weaker path everywhere without being told.
|
|
182
|
+
*
|
|
183
|
+
* So the per-page reasons stay behind ZT_FLOW_COMPILE_LOG (they are noise at a
|
|
184
|
+
* handful of pages), but the *rate* is reported unconditionally once it passes
|
|
185
|
+
* half. The message names the flag, because knowing which construct blocks each
|
|
186
|
+
* page is the only actionable next step.
|
|
187
|
+
*/
|
|
188
|
+
function _warnIfMostlyRuntime(total: number, runtime: number): void {
|
|
189
|
+
if (total < MIN_PAGES_FOR_FALLBACK_WARNING) return;
|
|
190
|
+
if (runtime * 2 <= total) return;
|
|
191
|
+
if (_explainFallbacks()) return; // already printing a reason per page
|
|
192
|
+
|
|
193
|
+
const pct = Math.round((runtime / total) * 100);
|
|
194
|
+
frameworkLog("flow").warn(
|
|
195
|
+
`${runtime} of ${total} Flow pages (${pct}%) render through the runtime fallback ` +
|
|
196
|
+
`instead of compiled output.\n` +
|
|
197
|
+
` Set ZT_FLOW_COMPILE_LOG=1 to print what blocks each page.`,
|
|
198
|
+
{ total, runtime, pct },
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Below this, a high percentage is not yet evidence of anything. */
|
|
203
|
+
const MIN_PAGES_FOR_FALLBACK_WARNING = 4;
|
|
@@ -448,14 +448,39 @@ interface MemberInfo {
|
|
|
448
448
|
locked: Set<string>; // @locked only — readable from client, not writable
|
|
449
449
|
computed: Set<string>; // @computed getters — derived, NOT in the snapshot
|
|
450
450
|
readable: Set<string>; // exposed ∪ locked — any prop in the snapshot (client-reactive)
|
|
451
|
+
/** Every method declared on this class, decorated or not — see `_assertActionExposed`. */
|
|
452
|
+
declaredMethods: Set<string>;
|
|
453
|
+
/**
|
|
454
|
+
* Methods the client may call. `@expose` is the usual one, but `@task` and `@on` both
|
|
455
|
+
* register into the same allowlist (`_exposedMethods`) — a `@task` streams field writes
|
|
456
|
+
* and is dispatched exactly like any other action. Checking only for `@expose` rejected
|
|
457
|
+
* perfectly good pages.
|
|
458
|
+
*/
|
|
459
|
+
callableMethods: Set<string>;
|
|
451
460
|
}
|
|
452
461
|
|
|
462
|
+
/** Decorators that put a method in the client-callable allowlist. */
|
|
463
|
+
const ACTION_DECORATORS = new Set(["expose", "task", "on"]);
|
|
464
|
+
|
|
453
465
|
/** Collect @expose, @locked, and @computed decorated members from the Component class AST. */
|
|
454
466
|
function _getMemberInfo(cls: ts.ClassDeclaration): MemberInfo {
|
|
455
467
|
const exposed = new Set<string>();
|
|
456
468
|
const locked = new Set<string>();
|
|
457
469
|
const computed = new Set<string>();
|
|
470
|
+
const declaredMethods = new Set<string>();
|
|
471
|
+
const callableMethods = new Set<string>();
|
|
458
472
|
for (const member of cls.members) {
|
|
473
|
+
if (ts.isMethodDeclaration(member) && ts.isIdentifier(member.name)) {
|
|
474
|
+
declaredMethods.add(member.name.text);
|
|
475
|
+
for (const m of member.modifiers ?? []) {
|
|
476
|
+
if (!ts.isDecorator(m)) continue;
|
|
477
|
+
// `@on("evt")` is a call; `@expose` is a bare identifier.
|
|
478
|
+
const e = ts.isCallExpression(m.expression) ? m.expression.expression : m.expression;
|
|
479
|
+
if (ts.isIdentifier(e) && ACTION_DECORATORS.has(e.text)) {
|
|
480
|
+
callableMethods.add(member.name.text);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
459
484
|
// @computed sits on a getter (GetAccessorDeclaration); @expose/@locked on fields/methods.
|
|
460
485
|
if (
|
|
461
486
|
!ts.isPropertyDeclaration(member) &&
|
|
@@ -475,7 +500,55 @@ function _getMemberInfo(cls: ts.ClassDeclaration): MemberInfo {
|
|
|
475
500
|
if (has("locked")) locked.add(name);
|
|
476
501
|
if (has("computed")) computed.add(name);
|
|
477
502
|
}
|
|
478
|
-
return {
|
|
503
|
+
return {
|
|
504
|
+
exposed,
|
|
505
|
+
locked,
|
|
506
|
+
computed,
|
|
507
|
+
readable: new Set([...exposed, ...locked]),
|
|
508
|
+
declaredMethods,
|
|
509
|
+
callableMethods,
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Reject `onClick={this.method}` when `method` is declared on the page but not `@expose`d.
|
|
515
|
+
*
|
|
516
|
+
* `getAllowedMethods()` is `getExposedMethods()`, so an undecorated method is simply absent
|
|
517
|
+
* from the allowlist. Everything else about the page passes — `tsc` is clean, the compiler
|
|
518
|
+
* emits `flow:click="submit"`, the button renders enabled — and the click sends a frame the
|
|
519
|
+
* server refuses, reporting it *only* over the WebSocket to `console.error`:
|
|
520
|
+
*
|
|
521
|
+
* [Flow] Server error on …: Method "submit" is not allowed
|
|
522
|
+
*
|
|
523
|
+
* Nothing reaches the server log, nothing throws, the page does not change. To a developer,
|
|
524
|
+
* and to every test that is not a real browser with the console open, the button simply does
|
|
525
|
+
* nothing — indistinguishable from the binding bugs, so the instinct is to go hunting for one.
|
|
526
|
+
*
|
|
527
|
+
* The member table needed to catch this is already built, so catch it here.
|
|
528
|
+
*
|
|
529
|
+
* Only fires for a method *declared on this class*: an inherited handler (a base-class action,
|
|
530
|
+
* a mixin) is invisible to this AST and must not be second-guessed.
|
|
531
|
+
*/
|
|
532
|
+
function _assertActionExposed(
|
|
533
|
+
method: string,
|
|
534
|
+
members: MemberInfo,
|
|
535
|
+
filename: string,
|
|
536
|
+
node: ts.Node,
|
|
537
|
+
): void {
|
|
538
|
+
if (!members.declaredMethods.has(method)) return; // inherited or a magic — not ours to judge
|
|
539
|
+
if (members.callableMethods.has(method)) return;
|
|
540
|
+
if (CLIENT_CALLBACK_MAGICS.has(method) || CLIENT_MAGICS.has(method)) return;
|
|
541
|
+
|
|
542
|
+
const sf = node.getSourceFile();
|
|
543
|
+
const { line, character } = sf.getLineAndCharacterOfPosition(node.getStart(sf));
|
|
544
|
+
throw new FlowValidationError(
|
|
545
|
+
`[Flow] \`${method}\` is used as a server action but is not @expose'd.\n` +
|
|
546
|
+
` ${_at(filename, line + 1, character + 1)}\n` +
|
|
547
|
+
` → Add @expose to ${method}() (or @task / @on, which also make it callable). ` +
|
|
548
|
+
`Without it the method is not in the action ` +
|
|
549
|
+
`allowlist, so the click sends a frame the server refuses — and the refusal is ` +
|
|
550
|
+
`reported only to the browser console, which makes the button look inert.`,
|
|
551
|
+
);
|
|
479
552
|
}
|
|
480
553
|
|
|
481
554
|
// ── this.xxx reference extraction (read vs write) ─────────────────────────────
|
|
@@ -685,6 +758,18 @@ function _validateFlowCallbacks(sf: ts.SourceFile, members: MemberInfo, filename
|
|
|
685
758
|
// its this.xxx refs must be @expose (writes) / @expose|@locked (reads).
|
|
686
759
|
_validateArrowRefs(expr, members, filename);
|
|
687
760
|
}
|
|
761
|
+
|
|
762
|
+
// onClick={this.method} / onSubmit={flow(this.method)} — the method must be
|
|
763
|
+
// @expose'd or it is not in the action allowlist. Checked here, in the pass that
|
|
764
|
+
// runs unconditionally and whose errors are fatal, rather than during transform:
|
|
765
|
+
// a transform error is caught and downgraded to a runtime fallback, which would
|
|
766
|
+
// leave the page rendering and the button still silently dead.
|
|
767
|
+
if (attrName.startsWith("on") || attrName === "submit") {
|
|
768
|
+
const ref = _isThisProp(expr)
|
|
769
|
+
? (expr as ts.PropertyAccessExpression)
|
|
770
|
+
: _extractFlowProp(expr);
|
|
771
|
+
if (ref) _assertActionExposed(ref.name.text, members, filename, ref);
|
|
772
|
+
}
|
|
688
773
|
}
|
|
689
774
|
ts.forEachChild(node, scan);
|
|
690
775
|
}
|
|
@@ -1671,7 +1756,8 @@ function _compileRenderMethod(
|
|
|
1671
1756
|
const event = eventName;
|
|
1672
1757
|
// onClick={this.method} — server action (named method reference).
|
|
1673
1758
|
if (_isThisProp(expr)) {
|
|
1674
|
-
|
|
1759
|
+
const method = (expr as ts.PropertyAccessExpression).name.text;
|
|
1760
|
+
emitStatic(` flow:${event}="${method}"`);
|
|
1675
1761
|
continue;
|
|
1676
1762
|
}
|
|
1677
1763
|
// onClick={(e) => this.x = 0} — bare arrow → client expression, emitted verbatim
|
|
@@ -1680,6 +1766,20 @@ function _compileRenderMethod(
|
|
|
1680
1766
|
// key off it). The <For> path below is the one exception — a loop handler must be an
|
|
1681
1767
|
// Alpine `@event`, because the loop variable only exists in Alpine's x-for scope.
|
|
1682
1768
|
if (ts.isArrowFunction(expr)) {
|
|
1769
|
+
// `() => this.method(row.id)` — a server action that wants an argument. The
|
|
1770
|
+
// arrow itself cannot ship: it would be emitted verbatim and `row` does not
|
|
1771
|
+
// exist in the browser. But this render function DOES have `row` in scope at
|
|
1772
|
+
// request time, so emit the action by name and serialise the arguments beside
|
|
1773
|
+
// it as data-args, which the bridge already reads and forwards. Matches what
|
|
1774
|
+
// the runtime renderer achieves by invoking the arrow with a recorder.
|
|
1775
|
+
const argCall = _asServerActionCall(expr, members);
|
|
1776
|
+
if (argCall) {
|
|
1777
|
+
emitStatic(` flow:${event}="${argCall.method}"`);
|
|
1778
|
+
emitStatic(` data-args="`);
|
|
1779
|
+
emitDynamic(`__escAttr(JSON.stringify([${argCall.argsSrc}]))`);
|
|
1780
|
+
emitStatic(`"`);
|
|
1781
|
+
continue;
|
|
1782
|
+
}
|
|
1683
1783
|
if (_cspSafe) {
|
|
1684
1784
|
// CSP-safe: emit the bare expression body (no arrow wrapper). An event
|
|
1685
1785
|
// param or a block body has no CSP-safe inline form → compile error.
|
|
@@ -1695,11 +1795,13 @@ function _compileRenderMethod(
|
|
|
1695
1795
|
` Use a single expression or an @expose action.\n File: ${filename}`,
|
|
1696
1796
|
);
|
|
1697
1797
|
}
|
|
1798
|
+
_assertClientExprScope(expr, members, filename);
|
|
1698
1799
|
const body = _rewriteClientExpr(expr.body.getText());
|
|
1699
1800
|
_assertCspExpr(body, filename);
|
|
1700
1801
|
emitStatic(` flow:${event}="${_escAttr(body)}"`);
|
|
1701
1802
|
continue;
|
|
1702
1803
|
}
|
|
1804
|
+
_assertClientExprScope(expr, members, filename);
|
|
1703
1805
|
const src = _rewriteClientExpr(expr.getText());
|
|
1704
1806
|
emitStatic(` flow:${event}="${_escAttr(src)}"`);
|
|
1705
1807
|
continue;
|
|
@@ -2047,9 +2149,246 @@ function _clientMagicSrc(expr: ts.Expression, members: MemberInfo, filename: str
|
|
|
2047
2149
|
);
|
|
2048
2150
|
}
|
|
2049
2151
|
}
|
|
2152
|
+
// Same verbatim-emission hazard as an arrow handler: this expression re-evaluates in
|
|
2153
|
+
// the browser, so a captured server-side name would silently evaluate to undefined.
|
|
2154
|
+
_assertClientExprScope(expr, members, filename);
|
|
2050
2155
|
return _rewriteClientExpr(expr.getText());
|
|
2051
2156
|
}
|
|
2052
2157
|
|
|
2158
|
+
/**
|
|
2159
|
+
* Recognise `() => this.method(a, b)` — a no-parameter arrow whose whole body is one
|
|
2160
|
+
* call to a component action with arguments — and return the pieces needed to emit it
|
|
2161
|
+
* as `flow:<event>="method"` + `data-args`.
|
|
2162
|
+
*
|
|
2163
|
+
* Returns null (leaving the caller on the verbatim client-expression path) when:
|
|
2164
|
+
* - the arrow takes parameters (it wants the DOM event; that's genuinely client-side);
|
|
2165
|
+
* - the body isn't a single call, or isn't a call to `this.something`;
|
|
2166
|
+
* - the callee isn't an @expose action (a client magic like `this.refresh()` must stay
|
|
2167
|
+
* a client expression);
|
|
2168
|
+
* - **any argument reads `this`** — `this.count + 1` must re-evaluate on the client
|
|
2169
|
+
* against live reactive state, not be frozen into the markup at render time.
|
|
2170
|
+
*/
|
|
2171
|
+
function _asServerActionCall(
|
|
2172
|
+
arrow: ts.ArrowFunction,
|
|
2173
|
+
members: MemberInfo,
|
|
2174
|
+
): { method: string; argsSrc: string } | null {
|
|
2175
|
+
if (arrow.parameters.length > 0) return null;
|
|
2176
|
+
|
|
2177
|
+
const body = ts.isBlock(arrow.body)
|
|
2178
|
+
? arrow.body.statements.length === 1 && ts.isExpressionStatement(arrow.body.statements[0]!)
|
|
2179
|
+
? (arrow.body.statements[0] as ts.ExpressionStatement).expression
|
|
2180
|
+
: null
|
|
2181
|
+
: arrow.body;
|
|
2182
|
+
if (!body || !ts.isCallExpression(body)) return null;
|
|
2183
|
+
if (!ts.isPropertyAccessExpression(body.expression)) return null;
|
|
2184
|
+
if (body.expression.expression.kind !== ts.SyntaxKind.ThisKeyword) return null;
|
|
2185
|
+
|
|
2186
|
+
const method = body.expression.name.text;
|
|
2187
|
+
if (CLIENT_CALLBACK_MAGICS.has(method) || CLIENT_MAGICS.has(method)) return null;
|
|
2188
|
+
// Actions are @expose methods, so `exposed` is the whole allowlist.
|
|
2189
|
+
if (!members.exposed.has(method)) return null;
|
|
2190
|
+
if (body.arguments.length === 0) return null; // no args → plain flow:click="method"
|
|
2191
|
+
|
|
2192
|
+
for (const arg of body.arguments) {
|
|
2193
|
+
if (_referencesThis(arg)) return null;
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2196
|
+
return { method, argsSrc: body.arguments.map((a) => a.getText()).join(", ") };
|
|
2197
|
+
}
|
|
2198
|
+
|
|
2199
|
+
/** True when `node` reads `this` anywhere (so it must stay a live client expression). */
|
|
2200
|
+
function _referencesThis(node: ts.Node): boolean {
|
|
2201
|
+
if (node.kind === ts.SyntaxKind.ThisKeyword) return true;
|
|
2202
|
+
let found = false;
|
|
2203
|
+
ts.forEachChild(node, (child) => {
|
|
2204
|
+
if (!found && _referencesThis(child)) found = true;
|
|
2205
|
+
});
|
|
2206
|
+
return found;
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
// ── Client-expression scope validation ────────────────────────────────────────
|
|
2210
|
+
|
|
2211
|
+
/**
|
|
2212
|
+
* Names a client expression may reference besides `$flow`, its own parameters, and
|
|
2213
|
+
* its own locals: Alpine's magics (the expression is evaluated by Alpine) and the
|
|
2214
|
+
* browser globals it is reasonable to reach for inline.
|
|
2215
|
+
*/
|
|
2216
|
+
const CLIENT_EXPR_GLOBALS = new Set([
|
|
2217
|
+
// Alpine magics
|
|
2218
|
+
"$el",
|
|
2219
|
+
"$event",
|
|
2220
|
+
"$refs",
|
|
2221
|
+
"$store",
|
|
2222
|
+
"$dispatch",
|
|
2223
|
+
"$watch",
|
|
2224
|
+
"$nextTick",
|
|
2225
|
+
"$id",
|
|
2226
|
+
"$root",
|
|
2227
|
+
"$data",
|
|
2228
|
+
"$flow",
|
|
2229
|
+
// Language / literals
|
|
2230
|
+
"undefined",
|
|
2231
|
+
"NaN",
|
|
2232
|
+
"Infinity",
|
|
2233
|
+
"globalThis",
|
|
2234
|
+
"arguments",
|
|
2235
|
+
// Standard built-ins
|
|
2236
|
+
"Object",
|
|
2237
|
+
"Array",
|
|
2238
|
+
"String",
|
|
2239
|
+
"Number",
|
|
2240
|
+
"Boolean",
|
|
2241
|
+
"Math",
|
|
2242
|
+
"JSON",
|
|
2243
|
+
"Date",
|
|
2244
|
+
"RegExp",
|
|
2245
|
+
"Map",
|
|
2246
|
+
"Set",
|
|
2247
|
+
"Promise",
|
|
2248
|
+
"Error",
|
|
2249
|
+
"Symbol",
|
|
2250
|
+
"BigInt",
|
|
2251
|
+
"Intl",
|
|
2252
|
+
"parseInt",
|
|
2253
|
+
"parseFloat",
|
|
2254
|
+
"isNaN",
|
|
2255
|
+
"isFinite",
|
|
2256
|
+
"encodeURIComponent",
|
|
2257
|
+
"decodeURIComponent",
|
|
2258
|
+
"encodeURI",
|
|
2259
|
+
"decodeURI",
|
|
2260
|
+
"structuredClone",
|
|
2261
|
+
// Browser
|
|
2262
|
+
"window",
|
|
2263
|
+
"document",
|
|
2264
|
+
"console",
|
|
2265
|
+
"navigator",
|
|
2266
|
+
"location",
|
|
2267
|
+
"history",
|
|
2268
|
+
"localStorage",
|
|
2269
|
+
"sessionStorage",
|
|
2270
|
+
"fetch",
|
|
2271
|
+
"URL",
|
|
2272
|
+
"URLSearchParams",
|
|
2273
|
+
"FormData",
|
|
2274
|
+
"Event",
|
|
2275
|
+
"CustomEvent",
|
|
2276
|
+
"alert",
|
|
2277
|
+
"confirm",
|
|
2278
|
+
"prompt",
|
|
2279
|
+
"setTimeout",
|
|
2280
|
+
"clearTimeout",
|
|
2281
|
+
"setInterval",
|
|
2282
|
+
"clearInterval",
|
|
2283
|
+
"requestAnimationFrame",
|
|
2284
|
+
"cancelAnimationFrame",
|
|
2285
|
+
"matchMedia",
|
|
2286
|
+
"getComputedStyle",
|
|
2287
|
+
]);
|
|
2288
|
+
|
|
2289
|
+
/** Collect every name a binding pattern introduces (`{a, b: [c]}`, `...rest`). */
|
|
2290
|
+
function _collectBoundNames(name: ts.BindingName, into: Set<string>): void {
|
|
2291
|
+
if (ts.isIdentifier(name)) {
|
|
2292
|
+
into.add(name.text);
|
|
2293
|
+
return;
|
|
2294
|
+
}
|
|
2295
|
+
for (const element of name.elements) {
|
|
2296
|
+
if (ts.isOmittedExpression(element)) continue;
|
|
2297
|
+
_collectBoundNames(element.name, into);
|
|
2298
|
+
}
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
/**
|
|
2302
|
+
* Reject a client expression that references a name the browser will not have.
|
|
2303
|
+
*
|
|
2304
|
+
* Client expressions are emitted as source text with `this.` rewritten to `$flow.`;
|
|
2305
|
+
* every other identifier is emitted verbatim. So a handler that closes over an
|
|
2306
|
+
* enclosing scope — the loop variable in
|
|
2307
|
+
* `{items.map((item) => <button onClick={() => this.select(item.id)}>)}` — compiles
|
|
2308
|
+
* to `$flow.select(item.id)`, where `item` does not exist. At runtime that throws a
|
|
2309
|
+
* ReferenceError inside the bridge's evaluator, which logs to the browser console
|
|
2310
|
+
* and returns: the click does nothing, the server never hears about it, and the page
|
|
2311
|
+
* looks perfect. Catching it here turns a silent dead button into a build error that
|
|
2312
|
+
* names the identifier.
|
|
2313
|
+
*
|
|
2314
|
+
* Server actions that need a row id take arguments instead — `onClick={this.remove}`
|
|
2315
|
+
* with `data-args={JSON.stringify([row.id])}`.
|
|
2316
|
+
*/
|
|
2317
|
+
function _assertClientExprScope(expr: ts.Expression, members: MemberInfo, filename: string): void {
|
|
2318
|
+
// Names bound *within* the expression (arrow params, block locals, catch bindings).
|
|
2319
|
+
const scoped = new Set<string>();
|
|
2320
|
+
|
|
2321
|
+
const collectScope = (node: ts.Node): void => {
|
|
2322
|
+
if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) {
|
|
2323
|
+
for (const param of node.parameters) _collectBoundNames(param.name, scoped);
|
|
2324
|
+
} else if (ts.isVariableDeclaration(node)) {
|
|
2325
|
+
_collectBoundNames(node.name, scoped);
|
|
2326
|
+
} else if (ts.isCatchClause(node) && node.variableDeclaration) {
|
|
2327
|
+
_collectBoundNames(node.variableDeclaration.name, scoped);
|
|
2328
|
+
}
|
|
2329
|
+
ts.forEachChild(node, collectScope);
|
|
2330
|
+
};
|
|
2331
|
+
collectScope(expr);
|
|
2332
|
+
|
|
2333
|
+
const check = (node: ts.Node): void => {
|
|
2334
|
+
// `a.b` — only `a` is a free identifier; `b` is a property name.
|
|
2335
|
+
if (ts.isPropertyAccessExpression(node)) {
|
|
2336
|
+
check(node.expression);
|
|
2337
|
+
return;
|
|
2338
|
+
}
|
|
2339
|
+
// `{ key: value }` — `key` is not a reference.
|
|
2340
|
+
if (ts.isPropertyAssignment(node)) {
|
|
2341
|
+
check(node.initializer);
|
|
2342
|
+
return;
|
|
2343
|
+
}
|
|
2344
|
+
if (ts.isShorthandPropertyAssignment(node)) {
|
|
2345
|
+
// `{ x }` — `x` IS a reference.
|
|
2346
|
+
_checkIdentifier(node.name, scoped, members, filename, expr);
|
|
2347
|
+
return;
|
|
2348
|
+
}
|
|
2349
|
+
if (ts.isIdentifier(node)) {
|
|
2350
|
+
_checkIdentifier(node, scoped, members, filename, expr);
|
|
2351
|
+
return;
|
|
2352
|
+
}
|
|
2353
|
+
ts.forEachChild(node, check);
|
|
2354
|
+
};
|
|
2355
|
+
// Enter through `check`, not forEachChild: when the whole expression IS a property
|
|
2356
|
+
// access (`$flow.store.ui.open`), forEachChild would hand us its trailing name as a
|
|
2357
|
+
// bare identifier and we would reject the root of the chain's own property.
|
|
2358
|
+
check(expr);
|
|
2359
|
+
}
|
|
2360
|
+
|
|
2361
|
+
function _checkIdentifier(
|
|
2362
|
+
id: ts.Identifier,
|
|
2363
|
+
scoped: Set<string>,
|
|
2364
|
+
members: MemberInfo,
|
|
2365
|
+
filename: string,
|
|
2366
|
+
expr: ts.Expression,
|
|
2367
|
+
): void {
|
|
2368
|
+
const name = id.text;
|
|
2369
|
+
if (scoped.has(name)) return;
|
|
2370
|
+
if (CLIENT_EXPR_GLOBALS.has(name)) return;
|
|
2371
|
+
if (name.startsWith("$")) return; // an Alpine plugin magic we don't know about
|
|
2372
|
+
|
|
2373
|
+
const sf = id.getSourceFile();
|
|
2374
|
+
const { line, character } = sf.getLineAndCharacterOfPosition(id.getStart(sf));
|
|
2375
|
+
|
|
2376
|
+
// A member of the component is the most likely intent, and has a precise fix.
|
|
2377
|
+
const isMember = members.readable.has(name) || members.exposed.has(name);
|
|
2378
|
+
const hint = isMember
|
|
2379
|
+
? `Did you mean this.${name}? A bare \`${name}\` is not in scope in the browser.`
|
|
2380
|
+
: `\`${name}\` comes from an enclosing server-side scope, and client expressions ` +
|
|
2381
|
+
`are emitted verbatim — it will not exist in the browser.\n` +
|
|
2382
|
+
` To pass a value to a server action, use data-args:\n` +
|
|
2383
|
+
` <button onClick={this.method} data-args={JSON.stringify([${name}])}>`;
|
|
2384
|
+
|
|
2385
|
+
throw new FlowValidationError(
|
|
2386
|
+
`[Flow] client expression references \`${name}\`, which will be undefined in the browser.\n` +
|
|
2387
|
+
` ${_at(filename, line + 1, character + 1)} in \`${_snippet(expr, 60)}\`\n` +
|
|
2388
|
+
` → ${hint}`,
|
|
2389
|
+
);
|
|
2390
|
+
}
|
|
2391
|
+
|
|
2053
2392
|
function _isComponent(tag: string): boolean {
|
|
2054
2393
|
const c = tag.charCodeAt(0);
|
|
2055
2394
|
return c >= 65 && c <= 90; // A–Z
|
package/src/jsx-runtime.ts
CHANGED
|
@@ -10,7 +10,11 @@
|
|
|
10
10
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
11
11
|
import { escapeHtml } from "@zerotal/core/helpers";
|
|
12
12
|
import { getExposedProps, getLockedProps } from "./decorators.ts";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
_setExposedKeyCapture,
|
|
15
|
+
_consumeExposedKeyCapture,
|
|
16
|
+
_consumeExposedKeyReads,
|
|
17
|
+
} from "./utils.ts";
|
|
14
18
|
import { URL_ATTRIBUTES, sanitizeUrl } from "./urlSafety.ts";
|
|
15
19
|
|
|
16
20
|
/** The rendered output of a JSX element: an object carrying its serialized HTML string. */
|
|
@@ -557,18 +561,27 @@ export function _resolveBindName(
|
|
|
557
561
|
* capture AND a value-identity match, so a literal or server value never false-positives),
|
|
558
562
|
* or null when the attribute is just an ordinary server-rendered value.
|
|
559
563
|
* `locked` distinguishes @locked (read-only, reactive) from @expose (two-way).
|
|
564
|
+
*
|
|
565
|
+
* @remarks
|
|
566
|
+
* The getter capture is a single slot and *all* of an element's props are evaluated
|
|
567
|
+
* before `jsx()` runs, so any later prop that reads an exposed property clobbers it —
|
|
568
|
+
* `value={this.destination} disabled={this.notSure}` leaves the capture pointing at
|
|
569
|
+
* `notSure`. The freshness check then fails and the element renders with no
|
|
570
|
+
* `flow:model` at all: the field accepts typing and nothing ever reaches the server.
|
|
571
|
+
* When the capture is unusable we therefore fall back to a value-identity scan over
|
|
572
|
+
* the exposed props, exactly as `_resolveBindName` does — binding correctly whenever
|
|
573
|
+
* the answer is unambiguous, rather than silently dropping the binding.
|
|
560
574
|
*/
|
|
561
575
|
function _resolveValueBind(value: unknown): { name: string; locked: boolean } | null {
|
|
562
576
|
const page = _getRenderCtx()?.page ?? null;
|
|
563
577
|
if (!page) return null;
|
|
564
578
|
const cap = _consumeExposedKeyCapture();
|
|
565
|
-
if (!cap) return null;
|
|
566
579
|
const exposed = getExposedProps(page); // includes @locked
|
|
567
580
|
|
|
568
581
|
// Dotted capture (e.g. "form.email") — a Form field. The root must be an exposed
|
|
569
582
|
// prop and the nested value must identity-match (freshness guard). Form fields
|
|
570
583
|
// are always writable (two-way), so locked is false.
|
|
571
|
-
if (cap.includes(".")) {
|
|
584
|
+
if (cap && cap.includes(".")) {
|
|
572
585
|
const root = cap.slice(0, cap.indexOf("."));
|
|
573
586
|
if (!exposed.has(root)) return null;
|
|
574
587
|
const nested = cap
|
|
@@ -578,11 +591,29 @@ function _resolveValueBind(value: unknown): { name: string; locked: boolean } |
|
|
|
578
591
|
return { name: cap, locked: false };
|
|
579
592
|
}
|
|
580
593
|
|
|
581
|
-
|
|
582
|
-
//
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
594
|
+
// Capture fast path: trust it only when it is fresh for THIS value (guards against
|
|
595
|
+
// a stale capture lingering from a previous element that didn't consume it).
|
|
596
|
+
if (cap && exposed.has(cap) && Object.is((page as Record<string, unknown>)[cap], value)) {
|
|
597
|
+
_consumeExposedKeyReads(); // this element is resolved; don't leak reads to the next
|
|
598
|
+
return { name: cap, locked: getLockedProps(page).has(cap) };
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// The single slot was clobbered by a later prop on the same element (the B2 case:
|
|
602
|
+
// `value={this.destination} disabled={this.notSure}`). Fall back to the keys actually
|
|
603
|
+
// read while this element's props were evaluated, most recent first, and take the one
|
|
604
|
+
// whose current value IS this value.
|
|
605
|
+
//
|
|
606
|
+
// Crucially this is NOT a scan of every exposed prop: a key only qualifies if it was
|
|
607
|
+
// genuinely read through a `this.` getter, so a literal `value="CUSTOM"` that happens
|
|
608
|
+
// to equal some property's current value still binds nothing.
|
|
609
|
+
const reads = _consumeExposedKeyReads();
|
|
610
|
+
for (const key of reads) {
|
|
611
|
+
if (key === cap) continue; // already rejected above
|
|
612
|
+
if (!exposed.has(key)) continue;
|
|
613
|
+
if (!Object.is((page as Record<string, unknown>)[key], value)) continue;
|
|
614
|
+
return { name: key, locked: getLockedProps(page).has(key) };
|
|
615
|
+
}
|
|
616
|
+
return null;
|
|
586
617
|
}
|
|
587
618
|
|
|
588
619
|
/**
|
|
@@ -1061,6 +1092,18 @@ export function jsx(
|
|
|
1061
1092
|
attrs.push(`value="${escapeAttr(String(v ?? ""))}"`);
|
|
1062
1093
|
continue;
|
|
1063
1094
|
}
|
|
1095
|
+
// A radio's `value` is the option's identifier and its `checked` is a per-option
|
|
1096
|
+
// boolean — neither is a two-way binding target, and inferring one is actively
|
|
1097
|
+
// wrong. In a group rendered from a .map() the inference lands on whichever single
|
|
1098
|
+
// option currently equals the bound property, so that one radio claims the whole
|
|
1099
|
+
// group's model and overwrites the user's pick on every flush. Bind a radio group
|
|
1100
|
+
// explicitly with {...this.bind('prop', 'optionValue')}, which knows the group is
|
|
1101
|
+
// addressed as a unit.
|
|
1102
|
+
if (type === "input" && String(p["type"] ?? "") === "radio") {
|
|
1103
|
+
if (k === "value") attrs.push(`value="${escapeAttr(String(v ?? ""))}"`);
|
|
1104
|
+
else if (v) attrs.push("checked");
|
|
1105
|
+
continue;
|
|
1106
|
+
}
|
|
1064
1107
|
const bound = _resolveValueBind(v);
|
|
1065
1108
|
if (bound) {
|
|
1066
1109
|
if (bound.locked) {
|
|
@@ -274,6 +274,22 @@ async function _handleFlowMessage(ws: FlowBunWs, raw: string, deps: FlowWsDeps):
|
|
|
274
274
|
await deps.container.runScoped(async (scoped) => {
|
|
275
275
|
const ctx = new HttpContext(request, scoped);
|
|
276
276
|
|
|
277
|
+
// A WebSocket action has no Bun `Server` behind it, so `ctx.ip()` — which resolves
|
|
278
|
+
// through `server.requestIP(request)` — would return null for every action even
|
|
279
|
+
// though the socket's peer address is known here and was captured at upgrade. Supply
|
|
280
|
+
// a minimal provider so `request().ip()` answers the same inside an action as it does
|
|
281
|
+
// during the initial HTTP render, instead of silently degrading to null.
|
|
282
|
+
const socketIp = ws.data.remoteIp;
|
|
283
|
+
if (socketIp && socketIp !== "unknown") {
|
|
284
|
+
ctx._server = {
|
|
285
|
+
requestIP: () => ({
|
|
286
|
+
address: socketIp,
|
|
287
|
+
family: socketIp.includes(":") ? "IPv6" : "IPv4",
|
|
288
|
+
port: 0, // not carried across the upgrade; nothing reads it
|
|
289
|
+
}),
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
277
293
|
await RequestContext.run(ctx, async () => {
|
|
278
294
|
let passed = false;
|
|
279
295
|
let redirectUrl: string | undefined;
|
package/src/testing.ts
CHANGED
|
@@ -54,6 +54,10 @@ export class FlowTest<T extends Component> {
|
|
|
54
54
|
private _effects: FlowEffects | null = null;
|
|
55
55
|
private _snapshot: Snapshot | null = null;
|
|
56
56
|
private _name: string;
|
|
57
|
+
/** Set by {@link tolerateErrors}: capture action errors instead of rethrowing them. */
|
|
58
|
+
private _tolerateErrors = false;
|
|
59
|
+
/** The last non-validation error an action threw, when {@link tolerateErrors} is on. */
|
|
60
|
+
private _lastError: Error | null = null;
|
|
57
61
|
|
|
58
62
|
private constructor(page: T, name: string) {
|
|
59
63
|
this._page = page;
|
|
@@ -97,13 +101,43 @@ export class FlowTest<T extends Component> {
|
|
|
97
101
|
// ── Actions ────────────────────────────────────────────────────────────────
|
|
98
102
|
|
|
99
103
|
/**
|
|
100
|
-
* Set a property value directly (state seeding), then return for
|
|
101
|
-
* Does NOT fire the updating/updated hooks — use `update()` to
|
|
102
|
-
* client-driven property change.
|
|
104
|
+
* Set a property value directly (state seeding) and re-render, then return for
|
|
105
|
+
* chaining. Does NOT fire the updating/updated hooks — use `update()` to
|
|
106
|
+
* simulate a client-driven property change.
|
|
107
|
+
*
|
|
108
|
+
* The re-render is the point: without it `html()` and every assertion that reads
|
|
109
|
+
* it would still describe the render from *before* the assignment, so a test
|
|
110
|
+
* would silently assert against stale markup. Use {@link seed} when you are
|
|
111
|
+
* assigning several properties and only want to pay for one render.
|
|
103
112
|
* @category Actions
|
|
104
113
|
*/
|
|
105
114
|
async set(prop: keyof T, value: unknown): Promise<this> {
|
|
106
115
|
(this._page as Record<string, unknown>)[prop as string] = value;
|
|
116
|
+
await this._render();
|
|
117
|
+
return this;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Assign a property without re-rendering — the batching form of {@link set}.
|
|
122
|
+
* Nothing reads the new value until the next `set`/`update`/`call` (or an
|
|
123
|
+
* explicit {@link render}) produces a fresh render.
|
|
124
|
+
* @category Actions
|
|
125
|
+
*
|
|
126
|
+
* @example
|
|
127
|
+
* ```ts
|
|
128
|
+
* await t.seed('step', 3);
|
|
129
|
+
* await t.seed('mode', 'advanced');
|
|
130
|
+
* await t.render(); // one render for both
|
|
131
|
+
* ```
|
|
132
|
+
*/
|
|
133
|
+
async seed(prop: keyof T, value: unknown): Promise<this> {
|
|
134
|
+
(this._page as Record<string, unknown>)[prop as string] = value;
|
|
135
|
+
return this;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Re-render the page without changing state (pairs with {@link seed}). @category Actions */
|
|
139
|
+
async render(): Promise<this> {
|
|
140
|
+
await this._render();
|
|
107
141
|
return this;
|
|
108
142
|
}
|
|
109
143
|
|
|
@@ -120,15 +154,71 @@ export class FlowTest<T extends Component> {
|
|
|
120
154
|
return this;
|
|
121
155
|
}
|
|
122
156
|
|
|
157
|
+
/**
|
|
158
|
+
* Opt out of the rethrow in {@link call}: a throwing action is captured on the
|
|
159
|
+
* harness instead of failing the test, so the error path itself can be asserted.
|
|
160
|
+
*
|
|
161
|
+
* Only for tests *about* error handling. Leaving it off is what makes a broken
|
|
162
|
+
* action fail its test rather than look like a no-op.
|
|
163
|
+
* @category Actions
|
|
164
|
+
*
|
|
165
|
+
* @example
|
|
166
|
+
* ```ts
|
|
167
|
+
* const t = (await FlowTest.mount(CheckoutPage)).tolerateErrors();
|
|
168
|
+
* await t.call('submit');
|
|
169
|
+
* t.assertErrored(/payment gateway/);
|
|
170
|
+
* ```
|
|
171
|
+
*/
|
|
172
|
+
tolerateErrors(): this {
|
|
173
|
+
this._tolerateErrors = true;
|
|
174
|
+
return this;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The last non-validation error an action threw, or null. @category Actions */
|
|
178
|
+
lastError(): Error | null {
|
|
179
|
+
return this._lastError;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Assert the last action threw, optionally matching its message.
|
|
184
|
+
* Requires {@link tolerateErrors} — without it a throwing action fails the test outright.
|
|
185
|
+
* @category Assertions
|
|
186
|
+
*/
|
|
187
|
+
assertErrored(match?: string | RegExp): this {
|
|
188
|
+
if (!this._lastError) {
|
|
189
|
+
throw new Error(
|
|
190
|
+
`[FlowTest] Expected the last action on ${this._name} to throw, but it did not.`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
if (match !== undefined) {
|
|
194
|
+
const message = this._lastError.message;
|
|
195
|
+
const ok = typeof match === "string" ? message.includes(match) : match.test(message);
|
|
196
|
+
if (!ok) {
|
|
197
|
+
throw new Error(
|
|
198
|
+
`[FlowTest] Expected the error message to match ${String(match)}, but got: ${message}`,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return this;
|
|
203
|
+
}
|
|
204
|
+
|
|
123
205
|
/**
|
|
124
206
|
* Call an action method and re-render. Simulates a subsequent (WebSocket) request:
|
|
125
207
|
* runs `onBoot()` + `onHydrate()`, invokes the method, then `onUpdate()` and re-render.
|
|
126
|
-
*
|
|
127
|
-
*
|
|
208
|
+
*
|
|
209
|
+
* A `ValidationError` is an expected outcome: the errors are stored and the page
|
|
210
|
+
* re-renders with the error bag populated, exactly as in production.
|
|
211
|
+
*
|
|
212
|
+
* Any other error is routed to `onError()` (mirroring production) and then
|
|
213
|
+
* **rethrown**, so a broken action fails its test instead of silently rendering
|
|
214
|
+
* an unchanged page. Call {@link tolerateErrors} first when the error path is
|
|
215
|
+
* what you are testing.
|
|
128
216
|
*
|
|
129
217
|
* @param method - Name of the method to invoke on the page.
|
|
130
218
|
* @param args - Arguments forwarded to the method.
|
|
131
219
|
* @throws If `method` is not a function on the page instance.
|
|
220
|
+
* @throws Whatever the action threw, unless it was a `ValidationError` or
|
|
221
|
+
* {@link tolerateErrors} is on.
|
|
132
222
|
* @category Actions
|
|
133
223
|
*/
|
|
134
224
|
async call(method: string, ...args: unknown[]): Promise<this> {
|
|
@@ -141,16 +231,25 @@ export class FlowTest<T extends Component> {
|
|
|
141
231
|
await this._page.onBoot();
|
|
142
232
|
await this._page.onHydrate();
|
|
143
233
|
|
|
234
|
+
this._lastError = null;
|
|
235
|
+
let thrown: Error | null = null;
|
|
236
|
+
|
|
144
237
|
try {
|
|
145
238
|
await (fn as (...a: unknown[]) => unknown).apply(this._page, args);
|
|
146
239
|
} catch (error) {
|
|
147
240
|
if (error instanceof ValidationError) {
|
|
148
|
-
// Errors already stored on page._errors
|
|
241
|
+
// Errors already stored on page._errors — a normal, assertable outcome.
|
|
149
242
|
} else {
|
|
243
|
+
thrown = error instanceof Error ? error : new Error(String(error));
|
|
244
|
+
this._lastError = thrown;
|
|
245
|
+
// Run the production error hook so its side effects (the default flashes
|
|
246
|
+
// the message) are observable either way. An onError that itself throws
|
|
247
|
+
// replaces the original — surfacing the deeper bug rather than hiding it.
|
|
150
248
|
try {
|
|
151
|
-
await this._page.onError(
|
|
152
|
-
} catch {
|
|
153
|
-
|
|
249
|
+
await this._page.onError(thrown);
|
|
250
|
+
} catch (hookError) {
|
|
251
|
+
thrown = hookError instanceof Error ? hookError : new Error(String(hookError));
|
|
252
|
+
this._lastError = thrown;
|
|
154
253
|
}
|
|
155
254
|
}
|
|
156
255
|
}
|
|
@@ -158,6 +257,10 @@ export class FlowTest<T extends Component> {
|
|
|
158
257
|
await this._page.onUpdate();
|
|
159
258
|
this._effects = this._page._drainEffects();
|
|
160
259
|
await this._render();
|
|
260
|
+
|
|
261
|
+
// Rethrow after the re-render, so an opted-in test still sees the rendered
|
|
262
|
+
// error state and a non-opted-in test gets the real stack.
|
|
263
|
+
if (thrown && !this._tolerateErrors) throw thrown;
|
|
161
264
|
return this;
|
|
162
265
|
}
|
|
163
266
|
|
package/src/utils.ts
CHANGED
|
@@ -176,15 +176,46 @@ export interface FlowChain<T> {
|
|
|
176
176
|
// Shared capture variable: set by instrumented @expose getters in jsx-runtime,
|
|
177
177
|
// consumed immediately by flow() to embed __key in the chain.
|
|
178
178
|
let _exposedKeyCapture: string | undefined;
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Every exposed key read since the last consume, in read order.
|
|
182
|
+
*
|
|
183
|
+
* `_exposedKeyCapture` is a single slot, but JSX evaluates *all* of an element's
|
|
184
|
+
* props before `jsx()` runs — so `value={this.destination} disabled={this.notSure}`
|
|
185
|
+
* leaves the slot pointing at `notSure` and the `value` binding resolves to nothing.
|
|
186
|
+
* Keeping the full read list lets a binding find the key that actually matches its
|
|
187
|
+
* own value, while still refusing to bind a literal that was never read through a
|
|
188
|
+
* `this.` getter at all. Bounded so a pathological render cannot grow it without limit.
|
|
189
|
+
*/
|
|
190
|
+
const _exposedKeyReads: string[] = [];
|
|
191
|
+
const _MAX_KEY_READS = 64;
|
|
192
|
+
|
|
179
193
|
export function _setExposedKeyCapture(k: string | undefined): void {
|
|
180
194
|
_exposedKeyCapture = k;
|
|
195
|
+
if (k === undefined) return;
|
|
196
|
+
// Keep the most recent read last; drop an earlier duplicate so order stays meaningful.
|
|
197
|
+
const at = _exposedKeyReads.indexOf(k);
|
|
198
|
+
if (at !== -1) _exposedKeyReads.splice(at, 1);
|
|
199
|
+
_exposedKeyReads.push(k);
|
|
200
|
+
if (_exposedKeyReads.length > _MAX_KEY_READS) _exposedKeyReads.shift();
|
|
181
201
|
}
|
|
202
|
+
|
|
182
203
|
export function _consumeExposedKeyCapture(): string | undefined {
|
|
183
204
|
const k = _exposedKeyCapture;
|
|
184
205
|
_exposedKeyCapture = undefined;
|
|
185
206
|
return k;
|
|
186
207
|
}
|
|
187
208
|
|
|
209
|
+
/**
|
|
210
|
+
* The keys read since the last call, most recent first, clearing the list.
|
|
211
|
+
* Used by the value/checked binding resolver to survive a clobbered single slot.
|
|
212
|
+
*/
|
|
213
|
+
export function _consumeExposedKeyReads(): string[] {
|
|
214
|
+
const reads = _exposedKeyReads.slice().reverse();
|
|
215
|
+
_exposedKeyReads.length = 0;
|
|
216
|
+
return reads;
|
|
217
|
+
}
|
|
218
|
+
|
|
188
219
|
export function flow<T>(target: T): FlowChain<T> {
|
|
189
220
|
const modifiers: string[] = [];
|
|
190
221
|
// Capture immediately so subsequent prop evaluations cannot clobber it.
|