@zerotal/flow 1.1.0 → 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.
package/CHANGELOG.md CHANGED
@@ -10,6 +10,56 @@ between minor versions.
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [1.3.0] — 2026-08-09
14
+
15
+ ### Fixed
16
+
17
+ - **An `@expose`d action on a shared page base no longer disappears from the allowlist.** Bun
18
+ 1.3.x does not reliably run a method decorator's `addInitializer` callbacks: when a subclass
19
+ declared a decorated field, the base class's method initializers never ran at all. So a page
20
+ base carrying `@expose guard()` lost that action the moment any subclass added one `@expose`
21
+ field — and since the un-`@expose`d action check is fatal, clicking a perfectly legitimate
22
+ button was rejected at runtime with a message pointing at the wrong cause. It reproduced with a
23
+ bare `class Sub extends Base` and no mixin anywhere. `@expose` (methods), `@task`,
24
+ `@renderless`, `@on` and `@computed` were all affected.
25
+
26
+ Method and getter decorators now tag the function in the decorator body — which always runs,
27
+ with the right name — and the readers scan the prototype chain for tagged members, registering
28
+ against the **declaring** prototype so inheritance resolves through the existing chain walk.
29
+ Registration no longer requires an instance to have been constructed, so `getExposedMethods()`
30
+ is correct on a class that has never been instantiated.
31
+
32
+ ### Changed — BREAKING
33
+
34
+ - **`ComponentWith(...)` is replaced by the `Component.using(...)` static.** Mixin composition is
35
+ now a property of the base class rather than a helper shipped alongside it, so there is one
36
+ idiom to learn and nothing extra to import.
37
+
38
+ ```tsx
39
+ // before
40
+ import { ComponentWith, Pagination } from "@zerotal/flow";
41
+ class PostsPage extends ComponentWith(Pagination, FileUploads) {}
42
+
43
+ // after
44
+ import { Component, Pagination } from "@zerotal/flow";
45
+ class PostsPage extends Component.using(Pagination, FileUploads) {}
46
+ ```
47
+
48
+ Run `bun run scripts/codemod-mixin-composition.ts` to rewrite call sites and imports.
49
+
50
+ How mixins are **authored** is unchanged — `<T extends Constructor<Component>>(Base: T) => …`
51
+ still works exactly as before, and every shipped mixin keeps its signature. The `Constructor`
52
+ and `Mixin` types are still exported; `Compose` (the type of `Component.using`) joins them.
53
+
54
+ ### Added
55
+
56
+ - **`using` composes onto any class in the chain, not just `Component`.** An app-level base can
57
+ now carry mixins without being flattened out of the prototype chain — `AdminPage.using(Pagination)`
58
+ keeps `AdminPage` in the lineage. `ComponentWith` hardcoded `Component`, so this previously
59
+ required hand-nesting (`Pagination(AdminPage)`).
60
+ - **Composition chains.** The composed class carries `using` itself, so
61
+ `Component.using(a, b).using(c, d)` works past the 8-mixin overload set.
62
+
13
63
  ## [1.1.0] — 2026-08-08
14
64
 
15
65
  ### Fixed
package/README.md CHANGED
@@ -122,7 +122,7 @@ Action helpers available inside any exposed method include `this.flash()`, `this
122
122
 
123
123
  **Headless (unstyled, accessible) primitives:** `Switch`, `Checkbox`, `Select`, `RadioGroup`, `Listbox`, `Combobox`, `Disclosure`, `Accordion`, `Popover`, `Field`, `Label`, `Description`, `Fieldset`, `Legend`.
124
124
 
125
- **Mixins & helpers:** compose features with `ComponentWith(Pagination, FileUploads)` — the `Pagination` mixin (`page` state + `nextPage`/`gotoPage`/…, paired with `Model.paginate()` and rendered by `<Pager>`) and the `FileUploads` mixin (`removeUpload`, paired with `<FileUpload>` + `TemporaryUploadedFile`). Also `Form` + `registerForm`, the `paginate` helper + `Paginator` type, and `Layout` for shared page shells.
125
+ **Mixins & helpers:** compose features with `Component.using(Pagination, FileUploads)` — the `Pagination` mixin (`page` state + `nextPage`/`gotoPage`/…, paired with `Model.paginate()` and rendered by `<Pager>`) and the `FileUploads` mixin (`removeUpload`, paired with `<FileUpload>` + `TemporaryUploadedFile`). Also `Form` + `registerForm`, the `paginate` helper + `Paginator` type, and `Layout` for shared page shells.
126
126
 
127
127
  **Slots:** a child component's plain children are its default slot; a `slots={{ header, footer }}` prop supplies named slots. The child places them with `this.slot(name)` / `this.slot()` and branches on `this.hasSlot(name)` — ideal for reusable shells (cards, modals, panels). Slot markup renders in the parent's scope and is carried, signed, in the child's snapshot.
128
128
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/flow",
3
- "version": "1.1.0",
3
+ "version": "1.3.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.1.0",
49
- "@zerotal/orm": "1.1.0",
50
- "@zerotal/validator": "1.1.0",
48
+ "@zerotal/core": "1.3.0",
49
+ "@zerotal/orm": "1.3.0",
50
+ "@zerotal/validator": "1.3.0",
51
51
  "alpinejs": "^3.14.9",
52
52
  "typescript": "^5.8.0"
53
53
  },
54
54
  "devDependencies": {
55
- "@zerotal/broadcasting": "1.1.0",
55
+ "@zerotal/broadcasting": "1.3.0",
56
56
  "@types/alpinejs": "^3.13.10",
57
57
  "@types/alpinejs__anchor": "^3.13.1",
58
58
  "@types/alpinejs__collapse": "^3.13.4",
package/src/Component.ts CHANGED
@@ -23,6 +23,8 @@ import { ValidationError } from "./validation.ts";
23
23
  import type { ValidationRules } from "./validation.ts";
24
24
  import { resolveUploadValue } from "./uploads/TemporaryUploadedFile.ts";
25
25
  import { toScriptJson } from "./utils.ts";
26
+ import { _compose } from "./mixins.ts";
27
+ import type { Compose } from "./mixins.ts";
26
28
  import { _validateEventPayload } from "./events.ts";
27
29
  import type { FlowEvents, EventName, EventArgs } from "./events.ts";
28
30
  import { route, request, safeRedirectPath, HttpContext } from "@zerotal/core";
@@ -343,6 +345,54 @@ export abstract class Component {
343
345
  */
344
346
  static readonly __isFlowPage = true as const;
345
347
 
348
+ /**
349
+ * Compose one or more feature mixins onto this class, folding them left-to-right, so a page can
350
+ * be built from focused, reusable behaviours instead of one bloated class.
351
+ *
352
+ * @remarks
353
+ * Each mixin receives the accumulated base and returns an extended class, so `Component`'s full
354
+ * surface (`flash()`, `redirect()`, `validate()`, the client magics, …) and every mixin's
355
+ * `@expose`/`@locked` members flow through to the final page — fully type-checked. Mixin props
356
+ * register on the mixin prototype, which sits in the page's prototype chain, so snapshot,
357
+ * reactivity, client writes, and `@url` sync all work exactly as for props declared on the page.
358
+ *
359
+ * `using` composes onto whatever class it is called on, so it also works on an intermediate base
360
+ * (`AdminPage.using(Pagination)` keeps `AdminPage` in the chain), and the composed class carries
361
+ * `using` itself, so `Component.using(a, b).using(c)` chains past the 8-mixin overload set.
362
+ *
363
+ * Pages composed this way render via the runtime path rather than the AOT compiler (which only
364
+ * statically sees a page's own `extends Component` plus locally-declared members) — the same
365
+ * fallback complex pages already use; behaviour is identical, you just don't get the
366
+ * ahead-of-time compile step for that page.
367
+ *
368
+ * @param mixins - Mixin factories applied left-to-right (each takes the prior result as its base).
369
+ * @returns A class extending this one with every mixin's members mixed in.
370
+ *
371
+ * @example
372
+ * ```tsx
373
+ * // app/flow/mixins/pagination.ts
374
+ * import { Component, expose, type Constructor } from "@zerotal/flow";
375
+ *
376
+ * export function Pagination<T extends Constructor<Component>>(Base: T) {
377
+ * abstract class WithPagination extends Base {
378
+ * @expose page = 1;
379
+ * @expose next() { this.page++; }
380
+ * }
381
+ * return WithPagination;
382
+ * }
383
+ *
384
+ * // app/flow/UsersPage.tsx
385
+ * class UsersPage extends Component.using(Pagination) {
386
+ * override async render() {
387
+ * return <div data-flow-root>Page {this.page}</div>;
388
+ * }
389
+ * }
390
+ * ```
391
+ *
392
+ * @category Composition
393
+ */
394
+ static using: Compose = _compose;
395
+
346
396
  // ── @internal: framework bookkeeping, not part of the developer API ──────────
347
397
 
348
398
  /** Set to true by hydrate() so the framework skips calling onMount(). @internal */
package/src/decorators.ts CHANGED
@@ -3,8 +3,20 @@
3
3
  // Standard TC39 decorators, keyed by the class prototype; the `get*` readers walk the
4
4
  // prototype chain so subclasses inherit.
5
5
  //
6
- // METHOD / GETTER decorators (@computed, @expose on a method, @renderless, @on) register
7
- // via `addInitializer`, which Bun compiles correctly.
6
+ // METHOD / GETTER decorators (@computed, @expose on a method, @renderless, @task, @on) CANNOT
7
+ // rely on `addInitializer` either. Bun 1.3.x does not reliably run a method decorator's
8
+ // initializers on construction: a class whose SUBCLASS declares a decorated field never runs the
9
+ // base's method initializers at all, so a shared page base's `@expose` action silently vanished
10
+ // from the allowlist the moment a subclass added one `@expose` field — and since the un-@exposed
11
+ // action check is fatal, a legitimate action was then rejected at runtime. It reproduces with no
12
+ // mixin and no framework code (a bare `class Sub extends Base` on Bun 1.3.14).
13
+ //
14
+ // So method/getter decorators TAG the function object itself in the decorator body — which runs
15
+ // synchronously, with the right name, always — and the `get*` readers scan the prototype chain
16
+ // for tagged members on first read (`_scanProto`). Registration lands on the DECLARING prototype,
17
+ // which is what makes inheritance work through `_collect`'s existing chain walk. `addInitializer`
18
+ // is still called as a belt-and-braces fallback: it is harmless when it fires (the registries are
19
+ // Sets keyed by prototype) and it covers members a prototype scan cannot see.
8
20
  //
9
21
  // FIELD decorators (@expose on a field, @locked, @reactive, @modelable, @transient,
10
22
  // @validate, @url, @session) CANNOT defer to a field initializer or to `addInitializer`:
@@ -106,6 +118,72 @@ function _registerOn(map: WeakMap<object, Set<string>>, self: unknown, key: stri
106
118
  _register(map, Object.getPrototypeOf(self as object) as object, key);
107
119
  }
108
120
 
121
+ // ── Method/getter tagging (Bun addInitializer workaround) ─────────────────────
122
+ //
123
+ // See the header note. A method or getter decorator marks the function object it is handed
124
+ // (or, for @computed, the wrapper it returns); `_scanProto` later reads those marks off the
125
+ // prototype and populates the registries. Symbols so a mark can never collide with a user
126
+ // property, and non-enumerable so nothing serializes or copies them.
127
+
128
+ /** Which registry a tagged member belongs in. `on` also carries its event name. */
129
+ interface MemberTag {
130
+ exposed?: boolean;
131
+ renderless?: boolean;
132
+ task?: boolean;
133
+ computed?: boolean;
134
+ on?: string;
135
+ }
136
+
137
+ // `unique symbol` (not a plain `symbol`) so the computed-key types below resolve.
138
+ const TAG: unique symbol = Symbol.for("zerotal.flow.memberTag");
139
+ const SCANNED: unique symbol = Symbol.for("zerotal.flow.scanned");
140
+
141
+ /** Attach/merge a tag on a decorated method or getter function. */
142
+ function _tag(fn: unknown, patch: MemberTag): void {
143
+ if (typeof fn !== "function") return;
144
+ const target = fn as { [TAG]?: MemberTag };
145
+ if (!Object.prototype.hasOwnProperty.call(fn, TAG)) {
146
+ // Own, non-enumerable slot — an inherited tag from an overridden base method must not leak.
147
+ Object.defineProperty(fn, TAG, { value: {}, writable: true, configurable: true });
148
+ }
149
+ Object.assign(target[TAG] as MemberTag, patch);
150
+ }
151
+
152
+ /**
153
+ * Populate the method/getter registries for one prototype from the tags its own members carry.
154
+ * Idempotent and cheap: marked with a non-enumerable flag so it runs once per prototype.
155
+ */
156
+ function _scanProto(proto: object): void {
157
+ if (!proto || proto === Object.prototype) return;
158
+ if (Object.prototype.hasOwnProperty.call(proto, SCANNED)) return;
159
+ Object.defineProperty(proto, SCANNED, { value: true, configurable: true });
160
+
161
+ for (const name of Object.getOwnPropertyNames(proto)) {
162
+ if (name === "constructor") continue;
163
+ const desc = Object.getOwnPropertyDescriptor(proto, name);
164
+ if (!desc) continue;
165
+ // A method lives on `value`; a @computed getter on `get`.
166
+ const fn: unknown = desc.value ?? desc.get;
167
+ if (typeof fn !== "function") continue;
168
+ if (!Object.prototype.hasOwnProperty.call(fn, TAG)) continue;
169
+ const tag = (fn as { [TAG]?: MemberTag })[TAG];
170
+ if (!tag) continue;
171
+
172
+ if (tag.exposed) _register(_exposedMethods, proto, name);
173
+ if (tag.renderless) _register(_renderlessMethods, proto, name);
174
+ if (tag.task) _register(_taskMethods, proto, name);
175
+ if (tag.computed) _register(_computedProtos, proto, name);
176
+ if (tag.on !== undefined) _registerMap(_onListeners, proto, tag.on, name);
177
+ }
178
+ }
179
+
180
+ /** Scan `startProto` and every prototype above it, so inherited members register too. */
181
+ function _scanChain(startProto: object | null): void {
182
+ for (let p = startProto; p && p !== Object.prototype; p = Object.getPrototypeOf(p) as object) {
183
+ _scanProto(p);
184
+ }
185
+ }
186
+
109
187
  // ── Field-decorator buffer (Bun standard-decorator workaround) ────────────────
110
188
 
111
189
  interface PendingField {
@@ -235,7 +313,9 @@ function _assertField(name: string, context: { kind: string }): void {
235
313
  }
236
314
 
237
315
  export function getComputedKeys(instance: object): Set<string> {
238
- return _collect(_computedProtos, Object.getPrototypeOf(instance) as object);
316
+ const proto = Object.getPrototypeOf(instance) as object;
317
+ _scanChain(proto);
318
+ return _collect(_computedProtos, proto);
239
319
  }
240
320
  export function getTransientKeys(instance: object): Set<string> {
241
321
  _ensureDrained(instance);
@@ -250,13 +330,16 @@ export function getLockedProps(instance: object): Set<string> {
250
330
  return _collect(_lockedProps, Object.getPrototypeOf(instance) as object);
251
331
  }
252
332
  export function getExposedMethods(PageClass: { prototype: object }): Set<string> {
333
+ _scanChain(PageClass.prototype as object);
253
334
  return _collect(_exposedMethods, PageClass.prototype as object);
254
335
  }
255
336
  /** `@task` method names — streaming, cancellable long-running actions. */
256
337
  export function getTaskMethods(PageClass: { prototype: object }): Set<string> {
338
+ _scanChain(PageClass.prototype as object);
257
339
  return _collect(_taskMethods, PageClass.prototype as object);
258
340
  }
259
341
  export function getRenderlessMethods(PageClass: { prototype: object }): Set<string> {
342
+ _scanChain(PageClass.prototype as object);
260
343
  return _collect(_renderlessMethods, PageClass.prototype as object);
261
344
  }
262
345
  export function getValidateRules(instance: object): Map<string, ValidateBuilder> {
@@ -264,6 +347,7 @@ export function getValidateRules(instance: object): Map<string, ValidateBuilder>
264
347
  return _collectMap(_validateRules, Object.getPrototypeOf(instance) as object);
265
348
  }
266
349
  export function getListeners(PageClass: { prototype: object }): Map<string, string> {
350
+ _scanChain(PageClass.prototype as object);
267
351
  return _collectMap(_onListeners, PageClass.prototype as object);
268
352
  }
269
353
  export function getUrlProps(instance: object): Map<string, UrlOptions> {
@@ -358,7 +442,10 @@ export function computed<This, Return>(
358
442
  context.addInitializer(function (this: This) {
359
443
  _registerOn(_computedProtos, this, name);
360
444
  });
361
- return _memoizeGetter(getter, name) as (this: This) => Return;
445
+ const memoized = _memoizeGetter(getter, name);
446
+ // Tag the wrapper we return — that is what lands on the prototype, not `getter`.
447
+ _tag(memoized, { computed: true });
448
+ return memoized as (this: This) => Return;
362
449
  }
363
450
 
364
451
  // ── @transient ────────────────────────────────────────────────────────────────
@@ -428,7 +515,9 @@ export function expose(_value: unknown, context: ExposeContext): void {
428
515
  _enqueueField(name, (proto) => _register(_exposedProps, proto, name));
429
516
  return;
430
517
  }
431
- // Method: addInitializer is available (and reliable) on method contexts.
518
+ // Method: tag the function so `_scanProto` finds it on the DECLARING prototype (see header);
519
+ // addInitializer stays as a fallback for anything a prototype scan cannot reach.
520
+ _tag(_value, { exposed: true });
432
521
  context.addInitializer(function (this: unknown) {
433
522
  _registerOn(_exposedMethods, this, name);
434
523
  });
@@ -664,6 +753,7 @@ export const modelable = _fieldDecorator((proto, name) => {
664
753
  * @category Actions
665
754
  */
666
755
  export function renderless(_fn: unknown, context: ClassMethodDecoratorContext): void {
756
+ _tag(_fn, { renderless: true });
667
757
  context.addInitializer(function (this: unknown) {
668
758
  _registerOn(_renderlessMethods, this, String(context.name));
669
759
  });
@@ -703,6 +793,8 @@ export function renderless(_fn: unknown, context: ClassMethodDecoratorContext):
703
793
  * @category Actions
704
794
  */
705
795
  export function task(_fn: unknown, context: ClassMethodDecoratorContext): void {
796
+ // `@task` implies `@expose` — it registers into the same allowlist.
797
+ _tag(_fn, { exposed: true, task: true });
706
798
  context.addInitializer(function (this: unknown) {
707
799
  const proto = Object.getPrototypeOf(this as object) as object;
708
800
  const name = String(context.name);
@@ -776,6 +868,8 @@ export function on(
776
868
  eventName: LooseEventName,
777
869
  ): (fn: unknown, context: ClassMethodDecoratorContext) => void {
778
870
  return (_fn: unknown, context: ClassMethodDecoratorContext): void => {
871
+ // `@on` implies `@expose`; the tag carries the event name so the scan can rebuild the map.
872
+ _tag(_fn, { exposed: true, on: eventName });
779
873
  context.addInitializer(function (this: unknown) {
780
874
  const proto = Object.getPrototypeOf(this as object) as object;
781
875
  const name = String(context.name);
package/src/index.ts CHANGED
@@ -70,9 +70,9 @@ export type {
70
70
  FlashBuilder,
71
71
  FlashOptions,
72
72
  } from "./Component.ts";
73
- // Compose focused feature mixins onto Component — `class extends ComponentWith(Pagination, …)`.
74
- export { ComponentWith } from "./mixins.ts";
75
- export type { Constructor, Mixin } from "./mixins.ts";
73
+ // Mixin authoring types. Compose them onto a page with the `Component.using(...)` static —
74
+ // `class PostsPage extends Component.using(Pagination, …)`.
75
+ export type { Constructor, Mixin, Compose } from "./mixins.ts";
76
76
  export { Layout } from "./Layout.ts";
77
77
  export { Form, registerForm } from "./Form.ts";
78
78
  // Global client store — app-wide, client-only reactive UI state, read in JSX as
@@ -80,7 +80,7 @@ export { Form, registerForm } from "./Form.ts";
80
80
  // `FlowStore` to type it.
81
81
  export { defineStore } from "./store.ts";
82
82
  export type { FlowStore } from "./store.ts";
83
- // Pagination state mixin (compose with `ComponentWith(Pagination)`); `paginate`/`Paginator`
83
+ // Pagination state mixin (compose with `Component.using(Pagination)`); `paginate`/`Paginator`
84
84
  // are the in-memory paginator.
85
85
  export { paginate, Pagination } from "./pagination.ts";
86
86
  export type { Paginator } from "./pagination.ts";
@@ -227,7 +227,7 @@ import type { PageClassWithMeta } from "./registry.ts";
227
227
  // File uploads
228
228
  export { TemporaryUploadedFile } from "./uploads/TemporaryUploadedFile.ts";
229
229
  export type { UploadRef } from "./uploads/TemporaryUploadedFile.ts";
230
- // File-upload management mixin (compose with `ComponentWith(FileUploads)`).
230
+ // File-upload management mixin (compose with `Component.using(FileUploads)`).
231
231
  export { FileUploads } from "./uploads/FileUploads.ts";
232
232
 
233
233
  // Validation
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() {
@@ -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
  *