@zerotal/flow 1.0.4 → 1.3.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.
@@ -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 { _setExposedKeyCapture, _consumeExposedKeyCapture } from "./utils.ts";
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
- if (!exposed.has(cap)) return null;
582
- // Confirm the capture is fresh for THIS value (guards against a stale capture
583
- // lingering from a previous element that didn't consume it).
584
- if (!Object.is((page as Record<string, unknown>)[cap], value)) return null;
585
- return { name: cap, locked: getLockedProps(page).has(cap) };
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) {
package/src/mixins.ts CHANGED
@@ -1,17 +1,9 @@
1
1
  // ── Component mixins ────────────────────────────────────────────────────────
2
2
  //
3
- // `ComponentWith(...)` composes any number of feature mixins on top of `Component`,
4
- // so a page can be broken into focused, reusable behaviours (pagination, file uploads,
5
- // sortable tables, …) that stack left-to-right instead of bloating one class — the
6
- // Flow counterpart of the ORM's `BaseModelWith(...)`:
7
- //
8
- // // before — one class carrying every feature inline, base repeated per mixin
9
- // class UsersPage extends FileUploads(Pagination(Component)) { … }
10
- //
11
- // // after — flat, left-to-right, base baked in
12
- // class UsersPage extends ComponentWith(Pagination, FileUploads) {
13
- // async render() { … } // the page only owns its own markup + state
14
- // }
3
+ // Mixin composition machinery for Flow. The entry point is `Component.using(...)`
4
+ // (see Component.ts) — this module owns the types it is built from, and the fold
5
+ // itself. Nothing here imports `Component`: the fold seeds from its receiver, so
6
+ // this is a leaf module.
15
7
  //
16
8
  // A Component mixin is the canonical generic form below — note it extends an ABSTRACT
17
9
  // base and returns an `abstract class`, so it doesn't have to implement Component's
@@ -28,21 +20,7 @@
28
20
  // return WithUpload;
29
21
  // }
30
22
  //
31
- // Because each mixin returns `class extends Base`, Component's full surface (`flash()`,
32
- // `redirect()`, `validate()`, the `this.set`/`refresh`/… client magics, …) and every other
33
- // mixin's members flow through to the final page — fully type-checked. The shipped
34
- // `Pagination` mixin (see ./pagination.ts) is written exactly this way and composes here:
35
- // `ComponentWith(Pagination)`.
36
- //
37
- // Mixin `@expose`/`@locked` props register on the mixin's prototype, which sits in the page's
38
- // prototype chain — so they're discovered at runtime exactly like props declared on the page
39
- // itself (snapshot, reactivity, client writes, `@url` sync all work). Note: pages composed
40
- // with `ComponentWith(...)` render via the runtime path rather than the AOT compiler (which
41
- // only statically sees a page's own `extends Component` + locally-declared members) — the same
42
- // fallback complex pages already use; behaviour is identical, you just don't get the
43
- // ahead-of-time compile step for that page.
44
-
45
- import { Component } from "./Component.ts";
23
+ // Compose them onto a page with `Component.using(FileUpload, …)`.
46
24
 
47
25
  /**
48
26
  * A class constructor. Abstract-aware (`abstract new …`) so the abstract `Component` base —
@@ -65,135 +43,127 @@ export type Mixin<TIn extends Constructor = Constructor, TOut extends Constructo
65
43
  Base: TIn,
66
44
  ) => TOut;
67
45
 
68
- type Base = typeof Component;
69
-
70
46
  /**
71
- * Compose one or more feature {@link Mixin}s onto {@link Component}, folding them left-to-right
72
- * so a page can be built from focused, reusable behaviours instead of one bloated class.
73
- *
74
- * @remarks
75
- * Each mixin receives the accumulated base and returns an extended class, so `Component`'s full
76
- * surface (`flash()`, `redirect()`, `validate()`, the client magics, …) and every mixin's
77
- * `@expose`/`@locked` members flow through to the final page — fully type-checked. Mixin props
78
- * register on the mixin prototype, which sits in the page's prototype chain, so snapshot,
79
- * reactivity, and client writes all work. Overloads cover 1–8 mixins; nest another
80
- * `ComponentWith(...)` call for more. Pages composed this way render via the runtime path rather
81
- * than the AOT compiler.
82
- *
83
- * @param mixins mixin factories applied left-to-right (each takes the prior result as its base).
84
- * @returns a class extending `Component` with every mixin's members mixed in.
47
+ * The call signatures behind `Component.using(...)`.
85
48
  *
86
- * @example
87
- * ```tsx
88
- * // app/flow/mixins/pagination.ts
89
- * import { Component, expose, type Constructor } from "@zerotal/flow";
49
+ * Each overload threads the accumulated type through every step, so the composed class carries
50
+ * the base's full surface plus every mixin's members — fully type-checked. The `this: TBase`
51
+ * parameter is what makes the base polymorphic: `using` composes onto whatever class it is
52
+ * called on, not onto a hardcoded `Component`.
90
53
  *
91
- * export function Pagination<T extends Constructor<Component>>(Base: T) {
92
- * abstract class WithPagination extends Base {
93
- * @expose page = 1;
94
- * @expose next() { this.page++; }
95
- * }
96
- * return WithPagination;
97
- * }
98
- *
99
- * // app/flow/UsersPage.tsx
100
- * class UsersPage extends ComponentWith(Pagination) {
101
- * override async render() {
102
- * return <div data-flow-root>Page {this.page}</div>;
103
- * }
104
- * }
105
- * ```
54
+ * Overloads cover 1–8 mixins. There is no ceiling: the composed class carries `using` too, so
55
+ * `Base.using(a, b).using(c, d)` chains.
106
56
  */
107
- export function ComponentWith<A extends Constructor>(a: (base: Base) => A): A;
108
- export function ComponentWith<A extends Constructor, B extends Constructor>(
109
- a: (base: Base) => A,
110
- b: (base: A) => B,
111
- ): B;
112
- export function ComponentWith<A extends Constructor, B extends Constructor, C extends Constructor>(
113
- a: (base: Base) => A,
114
- b: (base: A) => B,
115
- c: (base: B) => C,
116
- ): C;
117
- export function ComponentWith<
118
- A extends Constructor,
119
- B extends Constructor,
120
- C extends Constructor,
121
- D extends Constructor,
122
- >(a: (base: Base) => A, b: (base: A) => B, c: (base: B) => C, d: (base: C) => D): D;
123
- export function ComponentWith<
124
- A extends Constructor,
125
- B extends Constructor,
126
- C extends Constructor,
127
- D extends Constructor,
128
- E extends Constructor,
129
- >(
130
- a: (base: Base) => A,
131
- b: (base: A) => B,
132
- c: (base: B) => C,
133
- d: (base: C) => D,
134
- e: (base: D) => E,
135
- ): E;
136
- export function ComponentWith<
137
- A extends Constructor,
138
- B extends Constructor,
139
- C extends Constructor,
140
- D extends Constructor,
141
- E extends Constructor,
142
- F extends Constructor,
143
- >(
144
- a: (base: Base) => A,
145
- b: (base: A) => B,
146
- c: (base: B) => C,
147
- d: (base: C) => D,
148
- e: (base: D) => E,
149
- f: (base: E) => F,
150
- ): F;
151
- export function ComponentWith<
152
- A extends Constructor,
153
- B extends Constructor,
154
- C extends Constructor,
155
- D extends Constructor,
156
- E extends Constructor,
157
- F extends Constructor,
158
- G extends Constructor,
159
- >(
160
- a: (base: Base) => A,
161
- b: (base: A) => B,
162
- c: (base: B) => C,
163
- d: (base: C) => D,
164
- e: (base: D) => E,
165
- f: (base: E) => F,
166
- g: (base: F) => G,
167
- ): G;
168
- export function ComponentWith<
169
- A extends Constructor,
170
- B extends Constructor,
171
- C extends Constructor,
172
- D extends Constructor,
173
- E extends Constructor,
174
- F extends Constructor,
175
- G extends Constructor,
176
- H extends Constructor,
177
- >(
178
- a: (base: Base) => A,
179
- b: (base: A) => B,
180
- c: (base: B) => C,
181
- d: (base: C) => D,
182
- e: (base: D) => E,
183
- f: (base: E) => F,
184
- g: (base: F) => G,
185
- h: (base: G) => H,
186
- ): H;
57
+ export interface Compose {
58
+ <TBase extends Constructor, A extends Constructor>(this: TBase, a: (base: TBase) => A): A;
59
+ <TBase extends Constructor, A extends Constructor, B extends Constructor>(
60
+ this: TBase,
61
+ a: (base: TBase) => A,
62
+ b: (base: A) => B,
63
+ ): B;
64
+ <TBase extends Constructor, A extends Constructor, B extends Constructor, C extends Constructor>(
65
+ this: TBase,
66
+ a: (base: TBase) => A,
67
+ b: (base: A) => B,
68
+ c: (base: B) => C,
69
+ ): C;
70
+ <
71
+ TBase extends Constructor,
72
+ A extends Constructor,
73
+ B extends Constructor,
74
+ C extends Constructor,
75
+ D extends Constructor,
76
+ >(
77
+ this: TBase,
78
+ a: (base: TBase) => A,
79
+ b: (base: A) => B,
80
+ c: (base: B) => C,
81
+ d: (base: C) => D,
82
+ ): D;
83
+ <
84
+ TBase extends Constructor,
85
+ A extends Constructor,
86
+ B extends Constructor,
87
+ C extends Constructor,
88
+ D extends Constructor,
89
+ E extends Constructor,
90
+ >(
91
+ this: TBase,
92
+ a: (base: TBase) => A,
93
+ b: (base: A) => B,
94
+ c: (base: B) => C,
95
+ d: (base: C) => D,
96
+ e: (base: D) => E,
97
+ ): E;
98
+ <
99
+ TBase extends Constructor,
100
+ A extends Constructor,
101
+ B extends Constructor,
102
+ C extends Constructor,
103
+ D extends Constructor,
104
+ E extends Constructor,
105
+ F extends Constructor,
106
+ >(
107
+ this: TBase,
108
+ a: (base: TBase) => A,
109
+ b: (base: A) => B,
110
+ c: (base: B) => C,
111
+ d: (base: C) => D,
112
+ e: (base: D) => E,
113
+ f: (base: E) => F,
114
+ ): F;
115
+ <
116
+ TBase extends Constructor,
117
+ A extends Constructor,
118
+ B extends Constructor,
119
+ C extends Constructor,
120
+ D extends Constructor,
121
+ E extends Constructor,
122
+ F extends Constructor,
123
+ G extends Constructor,
124
+ >(
125
+ this: TBase,
126
+ a: (base: TBase) => A,
127
+ b: (base: A) => B,
128
+ c: (base: B) => C,
129
+ d: (base: C) => D,
130
+ e: (base: D) => E,
131
+ f: (base: E) => F,
132
+ g: (base: F) => G,
133
+ ): G;
134
+ <
135
+ TBase extends Constructor,
136
+ A extends Constructor,
137
+ B extends Constructor,
138
+ C extends Constructor,
139
+ D extends Constructor,
140
+ E extends Constructor,
141
+ F extends Constructor,
142
+ G extends Constructor,
143
+ H extends Constructor,
144
+ >(
145
+ this: TBase,
146
+ a: (base: TBase) => A,
147
+ b: (base: A) => B,
148
+ c: (base: B) => C,
149
+ d: (base: C) => D,
150
+ e: (base: D) => E,
151
+ f: (base: E) => F,
152
+ g: (base: F) => G,
153
+ h: (base: G) => H,
154
+ ): H;
155
+ }
187
156
 
188
- // Implementation: fold the mixins over Component, left to right. The base param is `any` so
189
- // each typed overload above (whose base is the narrower `typeof Component` or a prior mixin's
190
- // result) stays assignable to this implementation signature.
191
- export function ComponentWith(
157
+ /**
158
+ * The fold behind {@link Compose}: apply each mixin left-to-right, seeding from `this` (the
159
+ * class `using` was called on). Assigned to `Component.using` — not exported publicly.
160
+ *
161
+ * @internal
162
+ */
163
+ export const _compose = function (
164
+ this: Constructor,
192
165
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- variadic mixin folder
193
166
  ...mixins: Array<(base: any) => Constructor>
194
167
  ): Constructor {
195
- return mixins.reduce<Constructor>(
196
- (acc, mixin) => mixin(acc),
197
- Component as unknown as Constructor,
198
- );
199
- }
168
+ return mixins.reduce<Constructor>((acc, mixin) => mixin(acc), this);
169
+ } as Compose;
package/src/pagination.ts CHANGED
@@ -133,7 +133,7 @@ export function paginate<T>(items: T[], page = 1, perPage = 15): Paginator<T> {
133
133
  // `previousPage`, `gotoPage`, `resetPage`), WITHOUT putting any of that on
134
134
  // the base Component.
135
135
  //
136
- // class PostsPage extends ComponentWith(Pagination) {
136
+ // class PostsPage extends Component.using(Pagination) {
137
137
  // @locked posts: Post[] = [];
138
138
  // async onMount() { this.posts = await Post.all(); }
139
139
  // async render() {
@@ -200,7 +200,7 @@ const DEFAULT_PAGE = "page";
200
200
  *
201
201
  * @example
202
202
  * ```tsx
203
- * class PostsPage extends ComponentWith(Pagination) {
203
+ * class PostsPage extends Component.using(Pagination) {
204
204
  * @locked posts: Post[] = [];
205
205
  * async onMount() { this.posts = await Post.all(); }
206
206
  * async render() {
@@ -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 chaining.
101
- * Does NOT fire the updating/updated hooks — use `update()` to simulate a
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
- * If the action throws ValidationError, the errors are stored and the page re-renders
127
- * with the error bag populated; any other thrown error is routed to `onError()`.
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(error instanceof Error ? error : new Error(String(error)));
152
- } catch {
153
- // ignore
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
 
@@ -7,7 +7,7 @@
7
7
  // This mixin adds the ergonomic server-side actions for managing those pending uploads —
8
8
  // chiefly removing a chosen file before it's stored — without bloating the base class.
9
9
  //
10
- // class Avatar extends ComponentWith(FileUploads) {
10
+ // class Avatar extends Component.using(FileUploads) {
11
11
  // @expose photo: TemporaryUploadedFile | null = null;
12
12
  // @expose async save() {
13
13
  // this.path = await this.photo?.store("avatars"); // persist permanently
@@ -48,7 +48,7 @@ type AbstractComponentCtor = abstract new (...args: any[]) => Component;
48
48
  *
49
49
  * @example
50
50
  * ```tsx
51
- * class Avatar extends ComponentWith(FileUploads) {
51
+ * class Avatar extends Component.using(FileUploads) {
52
52
  * @expose photo: TemporaryUploadedFile | null = null;
53
53
  * @expose path = "";
54
54
  *
@@ -98,7 +98,7 @@ async function disk(name?: string): Promise<Disk> {
98
98
  *
99
99
  * @example
100
100
  * ```tsx
101
- * class Avatar extends ComponentWith(FileUploads) {
101
+ * class Avatar extends Component.using(FileUploads) {
102
102
  * @expose photo: TemporaryUploadedFile | null = null;
103
103
  * @expose path = "";
104
104
  *