@zerotal/flow 1.4.0 → 1.5.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 +278 -4
- package/README.md +1 -1
- package/package.json +8 -9
- package/src/Component.ts +214 -41
- package/src/browser/FlowBrowser.ts +270 -0
- package/src/browser/cdp.ts +273 -0
- package/src/browser/index.ts +19 -0
- package/src/client/FlowComponent.ts +1 -1
- package/src/client/bridge.ts +160 -24
- package/src/client/headless.ts +2 -1
- package/src/components.ts +276 -3
- package/src/config.ts +2 -2
- package/src/decorators.ts +122 -35
- package/src/dehydrate.ts +2 -2
- package/src/events.ts +1 -1
- package/src/index.ts +8 -2
- package/src/jsx-runtime.ts +77 -5
- package/src/pagination.ts +5 -7
- package/src/provider/FlowProvider.ts +46 -13
- package/src/router.ts +186 -116
- package/src/sections.ts +121 -0
- package/src/store.ts +0 -1
- package/src/streaming.ts +92 -0
- package/src/synths/index.ts +2 -2
- package/src/testing.ts +1 -1
- package/src/types.ts +1 -1
- package/src/uploads/FileUploads.ts +3 -4
- package/src/uploads/TemporaryUploadedFile.ts +1 -1
package/src/decorators.ts
CHANGED
|
@@ -189,13 +189,26 @@ function _scanChain(startProto: object | null): void {
|
|
|
189
189
|
interface PendingField {
|
|
190
190
|
name: string;
|
|
191
191
|
apply: (proto: object) => void;
|
|
192
|
+
/**
|
|
193
|
+
* `context.metadata` — one object per **file**, not per class (Bun shares it across
|
|
194
|
+
* every class defined in the same module). It is the only link back to where a
|
|
195
|
+
* buffered entry came from, because `Symbol.metadata` never reaches the class, so
|
|
196
|
+
* there is nothing to read at drain time.
|
|
197
|
+
*
|
|
198
|
+
* It is enough for the case that matters: a class may only claim registrations
|
|
199
|
+
* enqueued by its own file. Without it, a component that declares `count` and is
|
|
200
|
+
* never read leaves that entry in the buffer forever, and the next class in a
|
|
201
|
+
* *different* file with an own field called `count` claims it — silently getting
|
|
202
|
+
* the wrong decorator, and never getting its own.
|
|
203
|
+
*/
|
|
204
|
+
group: object | undefined;
|
|
192
205
|
}
|
|
193
206
|
const _pendingFields: PendingField[] = [];
|
|
194
207
|
const _drainedProtos = new WeakSet<object>();
|
|
195
208
|
|
|
196
209
|
/** Enqueue a field registration captured (with the correct name) in the decorator body. */
|
|
197
|
-
function _enqueueField(name: string, apply: (proto: object) => void): void {
|
|
198
|
-
_pendingFields.push({ name, apply });
|
|
210
|
+
function _enqueueField(name: string, apply: (proto: object) => void, group?: object): void {
|
|
211
|
+
_pendingFields.push({ name, apply, group });
|
|
199
212
|
}
|
|
200
213
|
|
|
201
214
|
/**
|
|
@@ -206,6 +219,11 @@ function _enqueueField(name: string, apply: (proto: object) => void): void {
|
|
|
206
219
|
* decorators run consecutively at definition time (including a subclass's mixin-supplied fields)
|
|
207
220
|
* and in declaration order, so its block is exactly such a run.
|
|
208
221
|
*
|
|
222
|
+
* The search is scoped to one FILE's entries at a time, newest first — see
|
|
223
|
+
* {@link PendingField.group}. Scanning the whole buffer let a class claim a same-named
|
|
224
|
+
* entry left behind by an unrelated file, which is invisible: it gets the wrong
|
|
225
|
+
* decorator and never gets its own.
|
|
226
|
+
*
|
|
209
227
|
* The strictly-increasing constraint is what makes this robust when two classes share field
|
|
210
228
|
* names AND their blocks are adjacent in the buffer (e.g. AddTransactionModal and TransferModal
|
|
211
229
|
* both declare `accountId`/`open`/`fAmount`). A plain "longest run ⊆ own" would greedily extend
|
|
@@ -222,40 +240,109 @@ function _drainFields(ctor: unknown): void {
|
|
|
222
240
|
_drainedProtos.add(proto); // mark first to stay re-entrancy-safe during the probe
|
|
223
241
|
if (_pendingFields.length === 0) return;
|
|
224
242
|
|
|
243
|
+
// The fields THIS class declares — not the ones it inherits. Instance fields all
|
|
244
|
+
// land on the instance whoever declared them, so `Object.keys(new Derived())` also
|
|
245
|
+
// lists the base's; subtracting the base's own probe leaves this class's block.
|
|
246
|
+
//
|
|
247
|
+
// The difference is load-bearing, not tidiness. `PaginatedPage extends
|
|
248
|
+
// Pagination(Component)` inherits `page`, so an undrained `[page, page]` group left
|
|
249
|
+
// by *another* `Pagination(...)` application matched it — and being two entries long
|
|
250
|
+
// it beat `PaginatedPage`'s own one-entry `[rows]`. The subclass silently ended up
|
|
251
|
+
// with the mixin's decorators and none of its own.
|
|
252
|
+
//
|
|
253
|
+
// If the base cannot be constructed without arguments there is nothing to subtract,
|
|
254
|
+
// and the full key set is the same answer this used to give.
|
|
225
255
|
let ownIndex: Map<string, number>;
|
|
226
256
|
try {
|
|
227
257
|
const keys = Object.keys(new (ctor as new () => object)());
|
|
228
|
-
|
|
258
|
+
const base = Object.getPrototypeOf(ctor) as unknown;
|
|
259
|
+
let inherited: Set<string> | undefined;
|
|
260
|
+
if (typeof base === "function" && base !== Function.prototype) {
|
|
261
|
+
try {
|
|
262
|
+
inherited = new Set(Object.keys(new (base as new () => object)()));
|
|
263
|
+
} catch {
|
|
264
|
+
inherited = undefined;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
const declared = inherited ? keys.filter((k) => !inherited.has(k)) : keys;
|
|
268
|
+
ownIndex = new Map(declared.map((k, i) => [k, i]));
|
|
229
269
|
} catch {
|
|
230
270
|
return; // not constructible without args — nothing we can match
|
|
231
271
|
}
|
|
232
272
|
|
|
233
|
-
//
|
|
234
|
-
//
|
|
235
|
-
let
|
|
236
|
-
|
|
273
|
+
// Match within one class's entries at a time — see {@link PendingField.group}.
|
|
274
|
+
//
|
|
275
|
+
// Scanning the buffer as one flat list let a class claim a same-named entry left
|
|
276
|
+
// behind by an unrelated class, and that is invisible when it happens: the class
|
|
277
|
+
// silently gets the wrong decorator and never gets its own. It is not
|
|
278
|
+
// hypothetical — a component that declares a field and is never read leaves its
|
|
279
|
+
// entry buffered for the life of the process, and names like `count`, `open` and
|
|
280
|
+
// `page` are declared all over a codebase.
|
|
281
|
+
//
|
|
282
|
+
// Groups are keyed by identity rather than by adjacency, because a class's entries
|
|
283
|
+
// need not be contiguous: an import evaluated part-way through a module interleaves
|
|
284
|
+
// another file's.
|
|
285
|
+
const groups: object[] = [];
|
|
286
|
+
const byGroup = new Map<object, number[]>();
|
|
287
|
+
const NO_GROUP = {}; // stand-in key, so entries without metadata still group together
|
|
237
288
|
for (let i = 0; i < _pendingFields.length; i++) {
|
|
238
|
-
|
|
239
|
-
let
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
const idx = ownIndex.get(name);
|
|
245
|
-
if (idx === undefined) break;
|
|
246
|
-
if (idx < last || (idx === last && name !== lastName)) break;
|
|
247
|
-
last = idx;
|
|
248
|
-
lastName = name;
|
|
249
|
-
j++;
|
|
289
|
+
const key = _pendingFields[i]!.group ?? NO_GROUP;
|
|
290
|
+
let list = byGroup.get(key);
|
|
291
|
+
if (!list) {
|
|
292
|
+
list = [];
|
|
293
|
+
byGroup.set(key, list);
|
|
294
|
+
groups.push(key);
|
|
250
295
|
}
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
296
|
+
list.push(i);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Best run across every group, NOT the first group that matches anything. A class
|
|
300
|
+
// whose own group is a two-field block must beat an unrelated group that happens to
|
|
301
|
+
// share one of those names, and only the length says so. Ties go to the most
|
|
302
|
+
// recently enqueued group: a class is drained after its own definition, so among
|
|
303
|
+
// equal candidates the newest is the one that just defined it, and older equals are
|
|
304
|
+
// leftovers by construction.
|
|
305
|
+
let bestIdxs: number[] | null = null;
|
|
306
|
+
let bestLen = 0;
|
|
307
|
+
let bestGroup = -1;
|
|
308
|
+
for (let g = 0; g < groups.length; g++) {
|
|
309
|
+
const idxs = byGroup.get(groups[g]!)!;
|
|
310
|
+
|
|
311
|
+
// Longest contiguous run whose names map to strictly-increasing declaration-order
|
|
312
|
+
// indices (equal allowed only for an identical repeated name — multiple decorators
|
|
313
|
+
// on one field). "Contiguous" is within this group's entries.
|
|
314
|
+
for (let i = 0; i < idxs.length; i++) {
|
|
315
|
+
if (!ownIndex.has(_pendingFields[idxs[i]!]!.name)) continue;
|
|
316
|
+
let last = -1;
|
|
317
|
+
let lastName = "";
|
|
318
|
+
let j = i;
|
|
319
|
+
while (j < idxs.length) {
|
|
320
|
+
const name = _pendingFields[idxs[j]!]!.name;
|
|
321
|
+
const idx = ownIndex.get(name);
|
|
322
|
+
if (idx === undefined) break;
|
|
323
|
+
if (idx < last || (idx === last && name !== lastName)) break;
|
|
324
|
+
last = idx;
|
|
325
|
+
lastName = name;
|
|
326
|
+
j++;
|
|
327
|
+
}
|
|
328
|
+
// Longer always wins. On a tie, a LATER group wins (newest definition), but an
|
|
329
|
+
// earlier run within the SAME group is kept — which is the previous behaviour.
|
|
330
|
+
const len = j - i;
|
|
331
|
+
if (len > bestLen || (len === bestLen && len > 0 && g > bestGroup)) {
|
|
332
|
+
bestLen = len;
|
|
333
|
+
bestIdxs = idxs.slice(i, j);
|
|
334
|
+
bestGroup = g;
|
|
335
|
+
}
|
|
254
336
|
}
|
|
255
337
|
}
|
|
256
|
-
if (
|
|
257
|
-
|
|
258
|
-
|
|
338
|
+
if (!bestIdxs || bestLen === 0) return;
|
|
339
|
+
|
|
340
|
+
// Apply, then remove the claimed entries. Removal is by descending buffer index so
|
|
341
|
+
// the earlier positions stay valid while splicing.
|
|
342
|
+
for (const bufIdx of bestIdxs) _pendingFields[bufIdx]!.apply(proto);
|
|
343
|
+
for (let k = bestIdxs.length - 1; k >= 0; k--) {
|
|
344
|
+
_pendingFields.splice(bestIdxs[k]!, 1);
|
|
345
|
+
}
|
|
259
346
|
}
|
|
260
347
|
|
|
261
348
|
/** Drain via an instance or a prototype before reading. */
|
|
@@ -291,7 +378,7 @@ function _fieldDecorator(
|
|
|
291
378
|
return (_value, context) => {
|
|
292
379
|
const name = String(context.name);
|
|
293
380
|
_assertField(name, context);
|
|
294
|
-
_enqueueField(name, (proto) => register(proto, name));
|
|
381
|
+
_enqueueField(name, (proto) => register(proto, name), context.metadata);
|
|
295
382
|
};
|
|
296
383
|
}
|
|
297
384
|
|
|
@@ -415,7 +502,7 @@ function _memoizeGetter(orig: () => unknown, name: string): () => unknown {
|
|
|
415
502
|
* @remarks
|
|
416
503
|
* A computed getter is not serialized and is not client-writable; it renders as static
|
|
417
504
|
* server-produced text. The result is memoized for the duration of a single render pass
|
|
418
|
-
*
|
|
505
|
+
* — an expensive getter read several times in one template
|
|
419
506
|
* runs once. The cache is scoped to the active render (see `_renderFlowPage`), so reads
|
|
420
507
|
* outside rendering always recompute and never go stale. Apply only to a getter — a field-style
|
|
421
508
|
* decorator on it is rejected; conversely `@computed` on a non-getter is a TypeScript error.
|
|
@@ -512,7 +599,7 @@ export function expose(_value: unknown, context: ExposeContext): void {
|
|
|
512
599
|
}
|
|
513
600
|
if (context.kind === "field") {
|
|
514
601
|
// Field: capture in the body and enqueue (see _fieldDecorator / _drainFields).
|
|
515
|
-
_enqueueField(name, (proto) => _register(_exposedProps, proto, name));
|
|
602
|
+
_enqueueField(name, (proto) => _register(_exposedProps, proto, name), context.metadata);
|
|
516
603
|
return;
|
|
517
604
|
}
|
|
518
605
|
// Method: tag the function so `_scanProto` finds it on the DECLARING prototype (see header);
|
|
@@ -671,7 +758,7 @@ export function resolveSharedChannel(channel: SharedChannel, instance: object):
|
|
|
671
758
|
// A child-component prop the PARENT can push new values into on every re-render.
|
|
672
759
|
// Included in the snapshot and client-writable (so the framework can deliver
|
|
673
760
|
// parent updates), and re-emitted by the parent so the child re-renders when the
|
|
674
|
-
// bound value changes.
|
|
761
|
+
// bound value changes.
|
|
675
762
|
|
|
676
763
|
/**
|
|
677
764
|
* Marks a child-component prop as reactive — the parent re-pushes new values into it on every
|
|
@@ -680,8 +767,8 @@ export function resolveSharedChannel(channel: SharedChannel, instance: object):
|
|
|
680
767
|
* @remarks
|
|
681
768
|
* The prop is included in the snapshot and is client-writable (so the framework can deliver the
|
|
682
769
|
* parent's updates), and the parent re-emits it so the child re-renders when the bound value
|
|
683
|
-
* changes. This is a one-way parent→child binding; use `@modelable` for two-way.
|
|
684
|
-
*
|
|
770
|
+
* changes. This is a one-way parent→child binding; use `@modelable` for two-way.
|
|
771
|
+
* Applies to a field only.
|
|
685
772
|
*
|
|
686
773
|
* @example
|
|
687
774
|
* ```tsx
|
|
@@ -700,8 +787,8 @@ export const reactive = _fieldDecorator((proto, name) => {
|
|
|
700
787
|
|
|
701
788
|
// ── @modelable ────────────────────────────────────────────────────────────────
|
|
702
789
|
// A reactive prop that ALSO syncs back to the parent: `value={this.x}` on a child
|
|
703
|
-
// component binds the parent's `x` to the child's modelable prop both ways.
|
|
704
|
-
//
|
|
790
|
+
// component binds the parent's `x` to the child's modelable prop both ways.
|
|
791
|
+
// A modelable prop is implicitly reactive.
|
|
705
792
|
|
|
706
793
|
/**
|
|
707
794
|
* Marks a child-component prop as two-way bound to the parent (parent↔child).
|
|
@@ -709,8 +796,8 @@ export const reactive = _fieldDecorator((proto, name) => {
|
|
|
709
796
|
* @remarks
|
|
710
797
|
* A modelable prop is reactive (the parent pushes new values in on re-render) AND syncs back:
|
|
711
798
|
* `value={this.x}` on a child component binds the parent's `x` to the child's modelable prop in
|
|
712
|
-
* both directions. A modelable prop is implicitly reactive (it registers as both).
|
|
713
|
-
*
|
|
799
|
+
* both directions. A modelable prop is implicitly reactive (it registers as both).
|
|
800
|
+
* Applies to a field only.
|
|
714
801
|
*
|
|
715
802
|
* @example
|
|
716
803
|
* ```tsx
|
package/src/dehydrate.ts
CHANGED
|
@@ -73,7 +73,7 @@ function currentSubject(): string | undefined {
|
|
|
73
73
|
|
|
74
74
|
// ── Signing payload ───────────────────────────────────────────────────────────
|
|
75
75
|
// children and listeners are excluded from signing:
|
|
76
|
-
// - children:
|
|
76
|
+
// - children: ids are derived server-side, never trusted from the client
|
|
77
77
|
// - listeners: server-derived from class decorators, cannot meaningfully be tampered
|
|
78
78
|
// with (@on methods are still validated against @expose allowlist on every call)
|
|
79
79
|
|
|
@@ -155,7 +155,7 @@ export function dehydrate(
|
|
|
155
155
|
: undefined;
|
|
156
156
|
|
|
157
157
|
// Children collected during the render that preceded this dehydrate call.
|
|
158
|
-
// Excluded from HMAC
|
|
158
|
+
// Excluded from HMAC — see the signing-payload note above.
|
|
159
159
|
const snapshotMemo: Snapshot["memo"] = {
|
|
160
160
|
...memo,
|
|
161
161
|
children: [...page._childIds],
|
package/src/events.ts
CHANGED
package/src/index.ts
CHANGED
|
@@ -105,6 +105,10 @@ export {
|
|
|
105
105
|
Alert,
|
|
106
106
|
Table,
|
|
107
107
|
Drawer,
|
|
108
|
+
ErrorBoundary,
|
|
109
|
+
SectionContent,
|
|
110
|
+
SectionOutlet,
|
|
111
|
+
Virtualize,
|
|
108
112
|
} from "./components.ts";
|
|
109
113
|
export type {
|
|
110
114
|
LinkProps,
|
|
@@ -126,6 +130,10 @@ export type {
|
|
|
126
130
|
TableProps,
|
|
127
131
|
TableColumn,
|
|
128
132
|
DrawerProps,
|
|
133
|
+
ErrorBoundaryProps,
|
|
134
|
+
SectionContentProps,
|
|
135
|
+
SectionOutletProps,
|
|
136
|
+
VirtualizeProps,
|
|
129
137
|
} from "./components.ts";
|
|
130
138
|
|
|
131
139
|
// Headless (unstyled, accessible) primitives — data-* state, Tailwind-friendly.
|
|
@@ -221,8 +229,6 @@ import "./synths/CarbonIntervalSynth.ts";
|
|
|
221
229
|
import "./synths/CollectionSynth.ts";
|
|
222
230
|
import "./synths/DateSynth.ts";
|
|
223
231
|
import "./synths/TemporaryUploadedFileSynth.ts";
|
|
224
|
-
import type { MiddlewareClass, RouteRegistration } from "@zerotal/core";
|
|
225
|
-
import type { PageClassWithMeta } from "./registry.ts";
|
|
226
232
|
|
|
227
233
|
// File uploads
|
|
228
234
|
export { TemporaryUploadedFile } from "./uploads/TemporaryUploadedFile.ts";
|
package/src/jsx-runtime.ts
CHANGED
|
@@ -163,6 +163,9 @@ interface FlowDirectives {
|
|
|
163
163
|
[key: `flow:${string}`]: any;
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
+
// TypeScript resolves JSX element/attribute types by looking up a JSX namespace —
|
|
167
|
+
// there is no module-shaped way to provide it.
|
|
168
|
+
// eslint-disable-next-line @typescript-eslint/no-namespace
|
|
166
169
|
export namespace JSX {
|
|
167
170
|
export type Element = HtmlNode;
|
|
168
171
|
|
|
@@ -184,6 +187,12 @@ export namespace JSX {
|
|
|
184
187
|
lazy?: boolean;
|
|
185
188
|
/** Mount a child component right after the first paint (non-blocking). */
|
|
186
189
|
defer?: boolean;
|
|
190
|
+
/**
|
|
191
|
+
* Render a child component's placeholder now and its real markup later on the
|
|
192
|
+
* same response — no second round trip. For content that is definitely needed
|
|
193
|
+
* and merely slow; use `lazy`/`defer` for content that may never be needed.
|
|
194
|
+
*/
|
|
195
|
+
stream?: boolean;
|
|
187
196
|
/**
|
|
188
197
|
* Named slot content for a child component: `slots={{ header: <h2>…</h2>, footer: … }}`.
|
|
189
198
|
* The child places each with `this.slot("header")`; its plain children are the default slot.
|
|
@@ -266,9 +275,25 @@ function escapeAttr(s: string): string {
|
|
|
266
275
|
// (e.g. two simultaneous GET requests each awaiting DB calls) cannot
|
|
267
276
|
// cross-contaminate their page reference or child-thunk lists.
|
|
268
277
|
|
|
278
|
+
/**
|
|
279
|
+
* How a failed child render is rendered instead of propagating.
|
|
280
|
+
*
|
|
281
|
+
* Installed by `<ErrorBoundary>` onto the thunks whose tokens appear in its
|
|
282
|
+
* children — see `_protectThunks`.
|
|
283
|
+
*/
|
|
284
|
+
export interface _ThunkBoundary {
|
|
285
|
+
fallback: (error: unknown) => string;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
interface _Thunk {
|
|
289
|
+
token: string;
|
|
290
|
+
run: () => Promise<HtmlNode>;
|
|
291
|
+
boundary?: _ThunkBoundary;
|
|
292
|
+
}
|
|
293
|
+
|
|
269
294
|
interface _RenderCtx {
|
|
270
295
|
page: Record<string, unknown>;
|
|
271
|
-
thunks:
|
|
296
|
+
thunks: _Thunk[];
|
|
272
297
|
seq: number;
|
|
273
298
|
/**
|
|
274
299
|
* Per-render random suffix on the child-placeholder token.
|
|
@@ -438,9 +463,17 @@ export async function _renderFlowPage(
|
|
|
438
463
|
try {
|
|
439
464
|
const node = await render();
|
|
440
465
|
let html = node.html;
|
|
441
|
-
for (const { token, run } of ctx.thunks) {
|
|
442
|
-
|
|
443
|
-
|
|
466
|
+
for (const { token, run, boundary } of ctx.thunks) {
|
|
467
|
+
let childHtml: string;
|
|
468
|
+
try {
|
|
469
|
+
childHtml = (await run()).html;
|
|
470
|
+
} catch (error) {
|
|
471
|
+
// Unprotected children still take the page down — a boundary is the
|
|
472
|
+
// opt-in, and swallowing errors by default would hide real bugs.
|
|
473
|
+
if (!boundary) throw error;
|
|
474
|
+
childHtml = boundary.fallback(error);
|
|
475
|
+
}
|
|
476
|
+
html = html.split(token).join(childHtml);
|
|
444
477
|
}
|
|
445
478
|
return html;
|
|
446
479
|
} finally {
|
|
@@ -759,6 +792,42 @@ function toFlowHtmlAttr(k: string): string {
|
|
|
759
792
|
return "flow:" + local.replace(/-/g, ".");
|
|
760
793
|
}
|
|
761
794
|
|
|
795
|
+
/**
|
|
796
|
+
* Render a JSX child to its HTML string, escaping anything that is not already
|
|
797
|
+
* an {@link HtmlNode}.
|
|
798
|
+
*
|
|
799
|
+
* @internal Exported for built-in components that need their children as markup
|
|
800
|
+
* before the wrapper element is built — `<ErrorBoundary>` inspects the result
|
|
801
|
+
* for child tokens, `<SectionContent>` publishes it elsewhere entirely.
|
|
802
|
+
*/
|
|
803
|
+
export function _renderChild(child: unknown): string {
|
|
804
|
+
return renderChild(child);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* Route failures of the child components in `html` through `fallback`.
|
|
809
|
+
*
|
|
810
|
+
* `jsx()` has already queued a thunk for every `<Component />` encountered while
|
|
811
|
+
* building `html`, but none of them have run yet — they resolve after the parent
|
|
812
|
+
* render finishes. Matching on the tokens embedded in `html` is therefore enough
|
|
813
|
+
* to say which pending children sit inside this boundary, without the boundary
|
|
814
|
+
* needing to be told about them.
|
|
815
|
+
*
|
|
816
|
+
* A thunk already claimed by an inner boundary keeps it, so the nearest boundary
|
|
817
|
+
* wins.
|
|
818
|
+
*
|
|
819
|
+
* @internal
|
|
820
|
+
*/
|
|
821
|
+
export function _protectThunks(html: string, fallback: (error: unknown) => string): void {
|
|
822
|
+
const ctx = _getRenderCtx();
|
|
823
|
+
if (!ctx) return;
|
|
824
|
+
for (const thunk of ctx.thunks) {
|
|
825
|
+
if (thunk.boundary === undefined && html.includes(thunk.token)) {
|
|
826
|
+
thunk.boundary = { fallback };
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
762
831
|
function renderChild(child: unknown): string {
|
|
763
832
|
if (child === null || child === undefined || child === false) return "";
|
|
764
833
|
if (child === true) return "";
|
|
@@ -882,6 +951,7 @@ export function jsx(
|
|
|
882
951
|
slots?: Record<string, string>;
|
|
883
952
|
lazy?: boolean;
|
|
884
953
|
defer?: boolean;
|
|
954
|
+
stream?: boolean;
|
|
885
955
|
},
|
|
886
956
|
): Promise<HtmlNode>;
|
|
887
957
|
}
|
|
@@ -902,6 +972,7 @@ export function jsx(
|
|
|
902
972
|
slots: _namedSlots,
|
|
903
973
|
lazy: _lazy,
|
|
904
974
|
defer: _defer,
|
|
975
|
+
stream: _stream,
|
|
905
976
|
...componentProps
|
|
906
977
|
} = p;
|
|
907
978
|
const slots: Record<string, string> = {};
|
|
@@ -924,6 +995,7 @@ export function jsx(
|
|
|
924
995
|
props: componentProps,
|
|
925
996
|
...(_lazy ? { lazy: true } : {}),
|
|
926
997
|
...(_defer ? { defer: true } : {}),
|
|
998
|
+
...(_stream ? { stream: true } : {}),
|
|
927
999
|
...(Object.keys(slots).length ? { slots } : {}),
|
|
928
1000
|
}),
|
|
929
1001
|
});
|
|
@@ -1016,7 +1088,7 @@ export function jsx(
|
|
|
1016
1088
|
// Emitted as flow:{event} so the bridge detects the $flow prefix and
|
|
1017
1089
|
// evaluates it via Alpine instead of sending a WebSocket action.
|
|
1018
1090
|
if (typeof target === "function" && !target.name && k.startsWith("on")) {
|
|
1019
|
-
const body = _clientExprFromFn(target
|
|
1091
|
+
const body = _clientExprFromFn(target);
|
|
1020
1092
|
const event = k.slice(2).toLowerCase();
|
|
1021
1093
|
const base = `flow:${event}`;
|
|
1022
1094
|
const attr = modifiers ? `${base}.${modifiers}` : base;
|
package/src/pagination.ts
CHANGED
|
@@ -128,8 +128,7 @@ export function paginate<T>(items: T[], page = 1, perPage = 15): Paginator<T> {
|
|
|
128
128
|
|
|
129
129
|
// ── Pagination mixin ──────────────────────────────────────────────────────────
|
|
130
130
|
//
|
|
131
|
-
//
|
|
132
|
-
// component to add a URL-synced `page` plus page-navigation methods (`nextPage`,
|
|
131
|
+
// A mixin: compose it onto a component to add a URL-synced `page` plus page-navigation methods (`nextPage`,
|
|
133
132
|
// `previousPage`, `gotoPage`, `resetPage`), WITHOUT putting any of that on
|
|
134
133
|
// the base Component.
|
|
135
134
|
//
|
|
@@ -147,12 +146,12 @@ export function paginate<T>(items: T[], page = 1, perPage = 15): Paginator<T> {
|
|
|
147
146
|
// applied here register on the mixin's prototype, which the component's prototype-chain
|
|
148
147
|
// walk already traverses — so it "just works".
|
|
149
148
|
//
|
|
150
|
-
// Named paginators
|
|
149
|
+
// Named paginators: every nav method accepts an optional page
|
|
151
150
|
// name so a single page can drive more than one independent paginator —
|
|
152
151
|
// `this.nextPage("invoices")`, `Invoice.paginate(10, undefined, "invoices")`. The default
|
|
153
152
|
// paginator ("page") is the URL-synced one; named paginators live in the snapshot.
|
|
154
153
|
//
|
|
155
|
-
// Optional update hooks fire around a page change
|
|
154
|
+
// Optional update hooks fire around a page change — define any of
|
|
156
155
|
// `updatingPage(page, name)` / `updatedPage(page, name)` (default paginator) or the
|
|
157
156
|
// generic `updatingPaginators(page, name)` / `updatedPaginators(page, name)`.
|
|
158
157
|
|
|
@@ -174,8 +173,7 @@ interface PaginationHooks {
|
|
|
174
173
|
const DEFAULT_PAGE = "page";
|
|
175
174
|
|
|
176
175
|
/**
|
|
177
|
-
* Component mixin that adds URL-synced pagination state and navigation actions
|
|
178
|
-
* flow's equivalent of Livewire's `WithPagination` trait.
|
|
176
|
+
* Component mixin that adds URL-synced pagination state and navigation actions.
|
|
179
177
|
*
|
|
180
178
|
* @remarks
|
|
181
179
|
* Composing this onto a component adds:
|
|
@@ -221,7 +219,7 @@ export function Pagination<TBase extends AbstractComponentCtor>(Base: TBase) {
|
|
|
221
219
|
/** Current page of the default paginator, synced to the `?page=` query string. */
|
|
222
220
|
@expose @url page = 1;
|
|
223
221
|
|
|
224
|
-
/** Current page of each NAMED paginator
|
|
222
|
+
/** Current page of each NAMED paginator, keyed by name. Snapshot-only. */
|
|
225
223
|
@expose paginators: Record<string, number> = {};
|
|
226
224
|
|
|
227
225
|
/**
|
|
@@ -97,7 +97,7 @@ export function _runtimeJs(): string | null {
|
|
|
97
97
|
return _runtimeBundle;
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
// ── Rate limiter for HMAC failures
|
|
100
|
+
// ── Rate limiter for HMAC failures ───────────────────────────────────────────
|
|
101
101
|
|
|
102
102
|
const _checksumFailures = new Map<string, { count: number; since: number }>();
|
|
103
103
|
const CHECKSUM_MAX = 10;
|
|
@@ -260,7 +260,7 @@ async function _handleFlowMessage(ws: FlowBunWs, raw: string, deps: FlowWsDeps):
|
|
|
260
260
|
// with no user at all, so every action on an authenticated page is rejected
|
|
261
261
|
// as somebody else's.
|
|
262
262
|
|
|
263
|
-
// ── Persistent middleware
|
|
263
|
+
// ── Persistent middleware ───────────────────────────────────────────────────
|
|
264
264
|
const pagePath = frame.snapshot.memo.path;
|
|
265
265
|
const middleware = [
|
|
266
266
|
...deps.globalMiddleware().filter(_isPersistentMiddleware),
|
|
@@ -785,7 +785,7 @@ async function _dispatchFrame(
|
|
|
785
785
|
ws.send(JSON.stringify({ type: "flash", component: canonicalId, ...f }));
|
|
786
786
|
}
|
|
787
787
|
|
|
788
|
-
// Dispatch cross-component events (to/self targeting
|
|
788
|
+
// Dispatch cross-component events (to/self targeting).
|
|
789
789
|
for (const event of effects.events) {
|
|
790
790
|
ws.send(
|
|
791
791
|
JSON.stringify({
|
|
@@ -922,10 +922,9 @@ export class FlowProvider extends ServiceProvider {
|
|
|
922
922
|
}
|
|
923
923
|
|
|
924
924
|
/**
|
|
925
|
-
* Global middleware re-applied on every WebSocket update
|
|
926
|
-
*
|
|
927
|
-
*
|
|
928
|
-
* not need to be listed here.
|
|
925
|
+
* Global middleware re-applied on every WebSocket update. Matched against the
|
|
926
|
+
* app's global pipeline by class reference or class name. Route middleware
|
|
927
|
+
* always re-runs and does not need to be listed here.
|
|
929
928
|
*/
|
|
930
929
|
static persistentMiddleware: (string | MiddlewareClass)[] = [...DEFAULT_PERSISTENT_MIDDLEWARE];
|
|
931
930
|
|
|
@@ -966,6 +965,11 @@ export class FlowProvider extends ServiceProvider {
|
|
|
966
965
|
this._applyPersistentMiddlewareConfig();
|
|
967
966
|
Router.macro("flow", flowRoute);
|
|
968
967
|
|
|
968
|
+
// Declared here rather than alongside the handlers below, because the handlers are
|
|
969
|
+
// only wired in the web runtime and `bun zt doctor --url=…` runs in a console one.
|
|
970
|
+
// The socket is the whole application, so a CLI that cannot name it cannot check it.
|
|
971
|
+
this.app.declareWebSocketPath("/__flow/ws");
|
|
972
|
+
|
|
969
973
|
registerFileRouteResolver(({ urlPath, module, middleware, name, filePath }) => {
|
|
970
974
|
const PageClass = _findPageExport(module);
|
|
971
975
|
if (!PageClass) return false;
|
|
@@ -1244,8 +1248,13 @@ export class FlowProvider extends ServiceProvider {
|
|
|
1244
1248
|
|
|
1245
1249
|
if (!cssExists && !jsExists) return;
|
|
1246
1250
|
|
|
1247
|
-
const {
|
|
1248
|
-
|
|
1251
|
+
const {
|
|
1252
|
+
registerDevBuildHook,
|
|
1253
|
+
buildCssBundle,
|
|
1254
|
+
buildJsBundle,
|
|
1255
|
+
isDevOrchestrated,
|
|
1256
|
+
bootBuildDecision,
|
|
1257
|
+
} = await import("@zerotal/core/dev");
|
|
1249
1258
|
|
|
1250
1259
|
// Combined hook — DevOrchestrator calls this on any frontend file change.
|
|
1251
1260
|
// Registered under Flow's own name so it sits alongside, rather than on
|
|
@@ -1256,16 +1265,40 @@ export class FlowProvider extends ServiceProvider {
|
|
|
1256
1265
|
const results = await Promise.all([
|
|
1257
1266
|
cssExists
|
|
1258
1267
|
? buildCssBundle(cssEntry, cssOutdir, false)
|
|
1259
|
-
: Promise.resolve({ success: true, logs: [] as unknown[] }),
|
|
1268
|
+
: Promise.resolve({ success: true, logs: [] as unknown[], skipped: true }),
|
|
1260
1269
|
jsExists
|
|
1261
1270
|
? buildJsBundle(jsEntry, jsOutdir, false)
|
|
1262
|
-
: Promise.resolve({ success: true, logs: [] as unknown[] }),
|
|
1271
|
+
: Promise.resolve({ success: true, logs: [] as unknown[], skipped: true }),
|
|
1263
1272
|
]);
|
|
1264
1273
|
const failed = results.flatMap((r) => (r.success ? [] : (r.logs ?? [])));
|
|
1265
|
-
return {
|
|
1274
|
+
return {
|
|
1275
|
+
success: failed.length === 0,
|
|
1276
|
+
logs: failed,
|
|
1277
|
+
// A bundle with no entry point counts as skipped: it did no work either.
|
|
1278
|
+
skipped: results.every((r) => r.skipped === true),
|
|
1279
|
+
};
|
|
1266
1280
|
});
|
|
1267
1281
|
|
|
1268
|
-
// Build once at startup so assets are ready before the first request
|
|
1282
|
+
// Build once at startup so assets are ready before the first request —
|
|
1283
|
+
// except under `serve --dev`, where the orchestrator owns builds and has
|
|
1284
|
+
// already run the hook above before this process could serve anything.
|
|
1285
|
+
// Without this guard a Flow app built its bundles three times per boot (once
|
|
1286
|
+
// here in the orchestrator process, once in the orchestrator's own initial
|
|
1287
|
+
// build, once here again in the spawned worker) and twice per backend save.
|
|
1288
|
+
if (isDevOrchestrated()) return;
|
|
1289
|
+
|
|
1290
|
+
// Nor in a production deployment whose output directory is read-only: those assets
|
|
1291
|
+
// were built at deploy time, and rebuilding them is what turns a hardened systemd
|
|
1292
|
+
// unit into a restart loop. See @zerotal/core/dev/bootBuild.ts.
|
|
1293
|
+
const decision = await bootBuildDecision(
|
|
1294
|
+
[...(cssExists ? [cssOutdir] : []), ...(jsExists ? [jsOutdir] : [])],
|
|
1295
|
+
config.safe<string>("app.env", Bun.env["APP_ENV"] ?? "development"),
|
|
1296
|
+
);
|
|
1297
|
+
if (!decision.build) {
|
|
1298
|
+
console.info(`[Flow] ${decision.reason}`);
|
|
1299
|
+
return;
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1269
1302
|
const [cssResult, jsResult] = await Promise.all([
|
|
1270
1303
|
cssExists
|
|
1271
1304
|
? buildCssBundle(cssEntry, cssOutdir, false)
|