@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.
- package/CHANGELOG.md +106 -27
- package/README.md +3 -1
- package/package.json +7 -6
- package/src/Component.ts +79 -3
- package/src/client/bridge.ts +95 -6
- package/src/compiler/index.ts +31 -0
- package/src/compiler/transform.ts +341 -2
- package/src/decorators.ts +99 -5
- package/src/index.ts +5 -5
- package/src/jsx-runtime.ts +51 -8
- package/src/mixins.ts +121 -151
- package/src/pagination.ts +2 -2
- package/src/provider/FlowProvider.ts +16 -0
- package/src/testing.ts +112 -9
- package/src/uploads/FileUploads.ts +2 -2
- package/src/uploads/TemporaryUploadedFile.ts +1 -1
- package/src/utils.ts +31 -0
|
@@ -448,14 +448,39 @@ interface MemberInfo {
|
|
|
448
448
|
locked: Set<string>; // @locked only — readable from client, not writable
|
|
449
449
|
computed: Set<string>; // @computed getters — derived, NOT in the snapshot
|
|
450
450
|
readable: Set<string>; // exposed ∪ locked — any prop in the snapshot (client-reactive)
|
|
451
|
+
/** Every method declared on this class, decorated or not — see `_assertActionExposed`. */
|
|
452
|
+
declaredMethods: Set<string>;
|
|
453
|
+
/**
|
|
454
|
+
* Methods the client may call. `@expose` is the usual one, but `@task` and `@on` both
|
|
455
|
+
* register into the same allowlist (`_exposedMethods`) — a `@task` streams field writes
|
|
456
|
+
* and is dispatched exactly like any other action. Checking only for `@expose` rejected
|
|
457
|
+
* perfectly good pages.
|
|
458
|
+
*/
|
|
459
|
+
callableMethods: Set<string>;
|
|
451
460
|
}
|
|
452
461
|
|
|
462
|
+
/** Decorators that put a method in the client-callable allowlist. */
|
|
463
|
+
const ACTION_DECORATORS = new Set(["expose", "task", "on"]);
|
|
464
|
+
|
|
453
465
|
/** Collect @expose, @locked, and @computed decorated members from the Component class AST. */
|
|
454
466
|
function _getMemberInfo(cls: ts.ClassDeclaration): MemberInfo {
|
|
455
467
|
const exposed = new Set<string>();
|
|
456
468
|
const locked = new Set<string>();
|
|
457
469
|
const computed = new Set<string>();
|
|
470
|
+
const declaredMethods = new Set<string>();
|
|
471
|
+
const callableMethods = new Set<string>();
|
|
458
472
|
for (const member of cls.members) {
|
|
473
|
+
if (ts.isMethodDeclaration(member) && ts.isIdentifier(member.name)) {
|
|
474
|
+
declaredMethods.add(member.name.text);
|
|
475
|
+
for (const m of member.modifiers ?? []) {
|
|
476
|
+
if (!ts.isDecorator(m)) continue;
|
|
477
|
+
// `@on("evt")` is a call; `@expose` is a bare identifier.
|
|
478
|
+
const e = ts.isCallExpression(m.expression) ? m.expression.expression : m.expression;
|
|
479
|
+
if (ts.isIdentifier(e) && ACTION_DECORATORS.has(e.text)) {
|
|
480
|
+
callableMethods.add(member.name.text);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
459
484
|
// @computed sits on a getter (GetAccessorDeclaration); @expose/@locked on fields/methods.
|
|
460
485
|
if (
|
|
461
486
|
!ts.isPropertyDeclaration(member) &&
|
|
@@ -475,7 +500,55 @@ function _getMemberInfo(cls: ts.ClassDeclaration): MemberInfo {
|
|
|
475
500
|
if (has("locked")) locked.add(name);
|
|
476
501
|
if (has("computed")) computed.add(name);
|
|
477
502
|
}
|
|
478
|
-
return {
|
|
503
|
+
return {
|
|
504
|
+
exposed,
|
|
505
|
+
locked,
|
|
506
|
+
computed,
|
|
507
|
+
readable: new Set([...exposed, ...locked]),
|
|
508
|
+
declaredMethods,
|
|
509
|
+
callableMethods,
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Reject `onClick={this.method}` when `method` is declared on the page but not `@expose`d.
|
|
515
|
+
*
|
|
516
|
+
* `getAllowedMethods()` is `getExposedMethods()`, so an undecorated method is simply absent
|
|
517
|
+
* from the allowlist. Everything else about the page passes — `tsc` is clean, the compiler
|
|
518
|
+
* emits `flow:click="submit"`, the button renders enabled — and the click sends a frame the
|
|
519
|
+
* server refuses, reporting it *only* over the WebSocket to `console.error`:
|
|
520
|
+
*
|
|
521
|
+
* [Flow] Server error on …: Method "submit" is not allowed
|
|
522
|
+
*
|
|
523
|
+
* Nothing reaches the server log, nothing throws, the page does not change. To a developer,
|
|
524
|
+
* and to every test that is not a real browser with the console open, the button simply does
|
|
525
|
+
* nothing — indistinguishable from the binding bugs, so the instinct is to go hunting for one.
|
|
526
|
+
*
|
|
527
|
+
* The member table needed to catch this is already built, so catch it here.
|
|
528
|
+
*
|
|
529
|
+
* Only fires for a method *declared on this class*: an inherited handler (a base-class action,
|
|
530
|
+
* a mixin) is invisible to this AST and must not be second-guessed.
|
|
531
|
+
*/
|
|
532
|
+
function _assertActionExposed(
|
|
533
|
+
method: string,
|
|
534
|
+
members: MemberInfo,
|
|
535
|
+
filename: string,
|
|
536
|
+
node: ts.Node,
|
|
537
|
+
): void {
|
|
538
|
+
if (!members.declaredMethods.has(method)) return; // inherited or a magic — not ours to judge
|
|
539
|
+
if (members.callableMethods.has(method)) return;
|
|
540
|
+
if (CLIENT_CALLBACK_MAGICS.has(method) || CLIENT_MAGICS.has(method)) return;
|
|
541
|
+
|
|
542
|
+
const sf = node.getSourceFile();
|
|
543
|
+
const { line, character } = sf.getLineAndCharacterOfPosition(node.getStart(sf));
|
|
544
|
+
throw new FlowValidationError(
|
|
545
|
+
`[Flow] \`${method}\` is used as a server action but is not @expose'd.\n` +
|
|
546
|
+
` ${_at(filename, line + 1, character + 1)}\n` +
|
|
547
|
+
` → Add @expose to ${method}() (or @task / @on, which also make it callable). ` +
|
|
548
|
+
`Without it the method is not in the action ` +
|
|
549
|
+
`allowlist, so the click sends a frame the server refuses — and the refusal is ` +
|
|
550
|
+
`reported only to the browser console, which makes the button look inert.`,
|
|
551
|
+
);
|
|
479
552
|
}
|
|
480
553
|
|
|
481
554
|
// ── this.xxx reference extraction (read vs write) ─────────────────────────────
|
|
@@ -685,6 +758,18 @@ function _validateFlowCallbacks(sf: ts.SourceFile, members: MemberInfo, filename
|
|
|
685
758
|
// its this.xxx refs must be @expose (writes) / @expose|@locked (reads).
|
|
686
759
|
_validateArrowRefs(expr, members, filename);
|
|
687
760
|
}
|
|
761
|
+
|
|
762
|
+
// onClick={this.method} / onSubmit={flow(this.method)} — the method must be
|
|
763
|
+
// @expose'd or it is not in the action allowlist. Checked here, in the pass that
|
|
764
|
+
// runs unconditionally and whose errors are fatal, rather than during transform:
|
|
765
|
+
// a transform error is caught and downgraded to a runtime fallback, which would
|
|
766
|
+
// leave the page rendering and the button still silently dead.
|
|
767
|
+
if (attrName.startsWith("on") || attrName === "submit") {
|
|
768
|
+
const ref = _isThisProp(expr)
|
|
769
|
+
? (expr as ts.PropertyAccessExpression)
|
|
770
|
+
: _extractFlowProp(expr);
|
|
771
|
+
if (ref) _assertActionExposed(ref.name.text, members, filename, ref);
|
|
772
|
+
}
|
|
688
773
|
}
|
|
689
774
|
ts.forEachChild(node, scan);
|
|
690
775
|
}
|
|
@@ -1671,7 +1756,8 @@ function _compileRenderMethod(
|
|
|
1671
1756
|
const event = eventName;
|
|
1672
1757
|
// onClick={this.method} — server action (named method reference).
|
|
1673
1758
|
if (_isThisProp(expr)) {
|
|
1674
|
-
|
|
1759
|
+
const method = (expr as ts.PropertyAccessExpression).name.text;
|
|
1760
|
+
emitStatic(` flow:${event}="${method}"`);
|
|
1675
1761
|
continue;
|
|
1676
1762
|
}
|
|
1677
1763
|
// onClick={(e) => this.x = 0} — bare arrow → client expression, emitted verbatim
|
|
@@ -1680,6 +1766,20 @@ function _compileRenderMethod(
|
|
|
1680
1766
|
// key off it). The <For> path below is the one exception — a loop handler must be an
|
|
1681
1767
|
// Alpine `@event`, because the loop variable only exists in Alpine's x-for scope.
|
|
1682
1768
|
if (ts.isArrowFunction(expr)) {
|
|
1769
|
+
// `() => this.method(row.id)` — a server action that wants an argument. The
|
|
1770
|
+
// arrow itself cannot ship: it would be emitted verbatim and `row` does not
|
|
1771
|
+
// exist in the browser. But this render function DOES have `row` in scope at
|
|
1772
|
+
// request time, so emit the action by name and serialise the arguments beside
|
|
1773
|
+
// it as data-args, which the bridge already reads and forwards. Matches what
|
|
1774
|
+
// the runtime renderer achieves by invoking the arrow with a recorder.
|
|
1775
|
+
const argCall = _asServerActionCall(expr, members);
|
|
1776
|
+
if (argCall) {
|
|
1777
|
+
emitStatic(` flow:${event}="${argCall.method}"`);
|
|
1778
|
+
emitStatic(` data-args="`);
|
|
1779
|
+
emitDynamic(`__escAttr(JSON.stringify([${argCall.argsSrc}]))`);
|
|
1780
|
+
emitStatic(`"`);
|
|
1781
|
+
continue;
|
|
1782
|
+
}
|
|
1683
1783
|
if (_cspSafe) {
|
|
1684
1784
|
// CSP-safe: emit the bare expression body (no arrow wrapper). An event
|
|
1685
1785
|
// param or a block body has no CSP-safe inline form → compile error.
|
|
@@ -1695,11 +1795,13 @@ function _compileRenderMethod(
|
|
|
1695
1795
|
` Use a single expression or an @expose action.\n File: ${filename}`,
|
|
1696
1796
|
);
|
|
1697
1797
|
}
|
|
1798
|
+
_assertClientExprScope(expr, members, filename);
|
|
1698
1799
|
const body = _rewriteClientExpr(expr.body.getText());
|
|
1699
1800
|
_assertCspExpr(body, filename);
|
|
1700
1801
|
emitStatic(` flow:${event}="${_escAttr(body)}"`);
|
|
1701
1802
|
continue;
|
|
1702
1803
|
}
|
|
1804
|
+
_assertClientExprScope(expr, members, filename);
|
|
1703
1805
|
const src = _rewriteClientExpr(expr.getText());
|
|
1704
1806
|
emitStatic(` flow:${event}="${_escAttr(src)}"`);
|
|
1705
1807
|
continue;
|
|
@@ -2047,9 +2149,246 @@ function _clientMagicSrc(expr: ts.Expression, members: MemberInfo, filename: str
|
|
|
2047
2149
|
);
|
|
2048
2150
|
}
|
|
2049
2151
|
}
|
|
2152
|
+
// Same verbatim-emission hazard as an arrow handler: this expression re-evaluates in
|
|
2153
|
+
// the browser, so a captured server-side name would silently evaluate to undefined.
|
|
2154
|
+
_assertClientExprScope(expr, members, filename);
|
|
2050
2155
|
return _rewriteClientExpr(expr.getText());
|
|
2051
2156
|
}
|
|
2052
2157
|
|
|
2158
|
+
/**
|
|
2159
|
+
* Recognise `() => this.method(a, b)` — a no-parameter arrow whose whole body is one
|
|
2160
|
+
* call to a component action with arguments — and return the pieces needed to emit it
|
|
2161
|
+
* as `flow:<event>="method"` + `data-args`.
|
|
2162
|
+
*
|
|
2163
|
+
* Returns null (leaving the caller on the verbatim client-expression path) when:
|
|
2164
|
+
* - the arrow takes parameters (it wants the DOM event; that's genuinely client-side);
|
|
2165
|
+
* - the body isn't a single call, or isn't a call to `this.something`;
|
|
2166
|
+
* - the callee isn't an @expose action (a client magic like `this.refresh()` must stay
|
|
2167
|
+
* a client expression);
|
|
2168
|
+
* - **any argument reads `this`** — `this.count + 1` must re-evaluate on the client
|
|
2169
|
+
* against live reactive state, not be frozen into the markup at render time.
|
|
2170
|
+
*/
|
|
2171
|
+
function _asServerActionCall(
|
|
2172
|
+
arrow: ts.ArrowFunction,
|
|
2173
|
+
members: MemberInfo,
|
|
2174
|
+
): { method: string; argsSrc: string } | null {
|
|
2175
|
+
if (arrow.parameters.length > 0) return null;
|
|
2176
|
+
|
|
2177
|
+
const body = ts.isBlock(arrow.body)
|
|
2178
|
+
? arrow.body.statements.length === 1 && ts.isExpressionStatement(arrow.body.statements[0]!)
|
|
2179
|
+
? (arrow.body.statements[0] as ts.ExpressionStatement).expression
|
|
2180
|
+
: null
|
|
2181
|
+
: arrow.body;
|
|
2182
|
+
if (!body || !ts.isCallExpression(body)) return null;
|
|
2183
|
+
if (!ts.isPropertyAccessExpression(body.expression)) return null;
|
|
2184
|
+
if (body.expression.expression.kind !== ts.SyntaxKind.ThisKeyword) return null;
|
|
2185
|
+
|
|
2186
|
+
const method = body.expression.name.text;
|
|
2187
|
+
if (CLIENT_CALLBACK_MAGICS.has(method) || CLIENT_MAGICS.has(method)) return null;
|
|
2188
|
+
// Actions are @expose methods, so `exposed` is the whole allowlist.
|
|
2189
|
+
if (!members.exposed.has(method)) return null;
|
|
2190
|
+
if (body.arguments.length === 0) return null; // no args → plain flow:click="method"
|
|
2191
|
+
|
|
2192
|
+
for (const arg of body.arguments) {
|
|
2193
|
+
if (_referencesThis(arg)) return null;
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2196
|
+
return { method, argsSrc: body.arguments.map((a) => a.getText()).join(", ") };
|
|
2197
|
+
}
|
|
2198
|
+
|
|
2199
|
+
/** True when `node` reads `this` anywhere (so it must stay a live client expression). */
|
|
2200
|
+
function _referencesThis(node: ts.Node): boolean {
|
|
2201
|
+
if (node.kind === ts.SyntaxKind.ThisKeyword) return true;
|
|
2202
|
+
let found = false;
|
|
2203
|
+
ts.forEachChild(node, (child) => {
|
|
2204
|
+
if (!found && _referencesThis(child)) found = true;
|
|
2205
|
+
});
|
|
2206
|
+
return found;
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
// ── Client-expression scope validation ────────────────────────────────────────
|
|
2210
|
+
|
|
2211
|
+
/**
|
|
2212
|
+
* Names a client expression may reference besides `$flow`, its own parameters, and
|
|
2213
|
+
* its own locals: Alpine's magics (the expression is evaluated by Alpine) and the
|
|
2214
|
+
* browser globals it is reasonable to reach for inline.
|
|
2215
|
+
*/
|
|
2216
|
+
const CLIENT_EXPR_GLOBALS = new Set([
|
|
2217
|
+
// Alpine magics
|
|
2218
|
+
"$el",
|
|
2219
|
+
"$event",
|
|
2220
|
+
"$refs",
|
|
2221
|
+
"$store",
|
|
2222
|
+
"$dispatch",
|
|
2223
|
+
"$watch",
|
|
2224
|
+
"$nextTick",
|
|
2225
|
+
"$id",
|
|
2226
|
+
"$root",
|
|
2227
|
+
"$data",
|
|
2228
|
+
"$flow",
|
|
2229
|
+
// Language / literals
|
|
2230
|
+
"undefined",
|
|
2231
|
+
"NaN",
|
|
2232
|
+
"Infinity",
|
|
2233
|
+
"globalThis",
|
|
2234
|
+
"arguments",
|
|
2235
|
+
// Standard built-ins
|
|
2236
|
+
"Object",
|
|
2237
|
+
"Array",
|
|
2238
|
+
"String",
|
|
2239
|
+
"Number",
|
|
2240
|
+
"Boolean",
|
|
2241
|
+
"Math",
|
|
2242
|
+
"JSON",
|
|
2243
|
+
"Date",
|
|
2244
|
+
"RegExp",
|
|
2245
|
+
"Map",
|
|
2246
|
+
"Set",
|
|
2247
|
+
"Promise",
|
|
2248
|
+
"Error",
|
|
2249
|
+
"Symbol",
|
|
2250
|
+
"BigInt",
|
|
2251
|
+
"Intl",
|
|
2252
|
+
"parseInt",
|
|
2253
|
+
"parseFloat",
|
|
2254
|
+
"isNaN",
|
|
2255
|
+
"isFinite",
|
|
2256
|
+
"encodeURIComponent",
|
|
2257
|
+
"decodeURIComponent",
|
|
2258
|
+
"encodeURI",
|
|
2259
|
+
"decodeURI",
|
|
2260
|
+
"structuredClone",
|
|
2261
|
+
// Browser
|
|
2262
|
+
"window",
|
|
2263
|
+
"document",
|
|
2264
|
+
"console",
|
|
2265
|
+
"navigator",
|
|
2266
|
+
"location",
|
|
2267
|
+
"history",
|
|
2268
|
+
"localStorage",
|
|
2269
|
+
"sessionStorage",
|
|
2270
|
+
"fetch",
|
|
2271
|
+
"URL",
|
|
2272
|
+
"URLSearchParams",
|
|
2273
|
+
"FormData",
|
|
2274
|
+
"Event",
|
|
2275
|
+
"CustomEvent",
|
|
2276
|
+
"alert",
|
|
2277
|
+
"confirm",
|
|
2278
|
+
"prompt",
|
|
2279
|
+
"setTimeout",
|
|
2280
|
+
"clearTimeout",
|
|
2281
|
+
"setInterval",
|
|
2282
|
+
"clearInterval",
|
|
2283
|
+
"requestAnimationFrame",
|
|
2284
|
+
"cancelAnimationFrame",
|
|
2285
|
+
"matchMedia",
|
|
2286
|
+
"getComputedStyle",
|
|
2287
|
+
]);
|
|
2288
|
+
|
|
2289
|
+
/** Collect every name a binding pattern introduces (`{a, b: [c]}`, `...rest`). */
|
|
2290
|
+
function _collectBoundNames(name: ts.BindingName, into: Set<string>): void {
|
|
2291
|
+
if (ts.isIdentifier(name)) {
|
|
2292
|
+
into.add(name.text);
|
|
2293
|
+
return;
|
|
2294
|
+
}
|
|
2295
|
+
for (const element of name.elements) {
|
|
2296
|
+
if (ts.isOmittedExpression(element)) continue;
|
|
2297
|
+
_collectBoundNames(element.name, into);
|
|
2298
|
+
}
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
/**
|
|
2302
|
+
* Reject a client expression that references a name the browser will not have.
|
|
2303
|
+
*
|
|
2304
|
+
* Client expressions are emitted as source text with `this.` rewritten to `$flow.`;
|
|
2305
|
+
* every other identifier is emitted verbatim. So a handler that closes over an
|
|
2306
|
+
* enclosing scope — the loop variable in
|
|
2307
|
+
* `{items.map((item) => <button onClick={() => this.select(item.id)}>)}` — compiles
|
|
2308
|
+
* to `$flow.select(item.id)`, where `item` does not exist. At runtime that throws a
|
|
2309
|
+
* ReferenceError inside the bridge's evaluator, which logs to the browser console
|
|
2310
|
+
* and returns: the click does nothing, the server never hears about it, and the page
|
|
2311
|
+
* looks perfect. Catching it here turns a silent dead button into a build error that
|
|
2312
|
+
* names the identifier.
|
|
2313
|
+
*
|
|
2314
|
+
* Server actions that need a row id take arguments instead — `onClick={this.remove}`
|
|
2315
|
+
* with `data-args={JSON.stringify([row.id])}`.
|
|
2316
|
+
*/
|
|
2317
|
+
function _assertClientExprScope(expr: ts.Expression, members: MemberInfo, filename: string): void {
|
|
2318
|
+
// Names bound *within* the expression (arrow params, block locals, catch bindings).
|
|
2319
|
+
const scoped = new Set<string>();
|
|
2320
|
+
|
|
2321
|
+
const collectScope = (node: ts.Node): void => {
|
|
2322
|
+
if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) {
|
|
2323
|
+
for (const param of node.parameters) _collectBoundNames(param.name, scoped);
|
|
2324
|
+
} else if (ts.isVariableDeclaration(node)) {
|
|
2325
|
+
_collectBoundNames(node.name, scoped);
|
|
2326
|
+
} else if (ts.isCatchClause(node) && node.variableDeclaration) {
|
|
2327
|
+
_collectBoundNames(node.variableDeclaration.name, scoped);
|
|
2328
|
+
}
|
|
2329
|
+
ts.forEachChild(node, collectScope);
|
|
2330
|
+
};
|
|
2331
|
+
collectScope(expr);
|
|
2332
|
+
|
|
2333
|
+
const check = (node: ts.Node): void => {
|
|
2334
|
+
// `a.b` — only `a` is a free identifier; `b` is a property name.
|
|
2335
|
+
if (ts.isPropertyAccessExpression(node)) {
|
|
2336
|
+
check(node.expression);
|
|
2337
|
+
return;
|
|
2338
|
+
}
|
|
2339
|
+
// `{ key: value }` — `key` is not a reference.
|
|
2340
|
+
if (ts.isPropertyAssignment(node)) {
|
|
2341
|
+
check(node.initializer);
|
|
2342
|
+
return;
|
|
2343
|
+
}
|
|
2344
|
+
if (ts.isShorthandPropertyAssignment(node)) {
|
|
2345
|
+
// `{ x }` — `x` IS a reference.
|
|
2346
|
+
_checkIdentifier(node.name, scoped, members, filename, expr);
|
|
2347
|
+
return;
|
|
2348
|
+
}
|
|
2349
|
+
if (ts.isIdentifier(node)) {
|
|
2350
|
+
_checkIdentifier(node, scoped, members, filename, expr);
|
|
2351
|
+
return;
|
|
2352
|
+
}
|
|
2353
|
+
ts.forEachChild(node, check);
|
|
2354
|
+
};
|
|
2355
|
+
// Enter through `check`, not forEachChild: when the whole expression IS a property
|
|
2356
|
+
// access (`$flow.store.ui.open`), forEachChild would hand us its trailing name as a
|
|
2357
|
+
// bare identifier and we would reject the root of the chain's own property.
|
|
2358
|
+
check(expr);
|
|
2359
|
+
}
|
|
2360
|
+
|
|
2361
|
+
function _checkIdentifier(
|
|
2362
|
+
id: ts.Identifier,
|
|
2363
|
+
scoped: Set<string>,
|
|
2364
|
+
members: MemberInfo,
|
|
2365
|
+
filename: string,
|
|
2366
|
+
expr: ts.Expression,
|
|
2367
|
+
): void {
|
|
2368
|
+
const name = id.text;
|
|
2369
|
+
if (scoped.has(name)) return;
|
|
2370
|
+
if (CLIENT_EXPR_GLOBALS.has(name)) return;
|
|
2371
|
+
if (name.startsWith("$")) return; // an Alpine plugin magic we don't know about
|
|
2372
|
+
|
|
2373
|
+
const sf = id.getSourceFile();
|
|
2374
|
+
const { line, character } = sf.getLineAndCharacterOfPosition(id.getStart(sf));
|
|
2375
|
+
|
|
2376
|
+
// A member of the component is the most likely intent, and has a precise fix.
|
|
2377
|
+
const isMember = members.readable.has(name) || members.exposed.has(name);
|
|
2378
|
+
const hint = isMember
|
|
2379
|
+
? `Did you mean this.${name}? A bare \`${name}\` is not in scope in the browser.`
|
|
2380
|
+
: `\`${name}\` comes from an enclosing server-side scope, and client expressions ` +
|
|
2381
|
+
`are emitted verbatim — it will not exist in the browser.\n` +
|
|
2382
|
+
` To pass a value to a server action, use data-args:\n` +
|
|
2383
|
+
` <button onClick={this.method} data-args={JSON.stringify([${name}])}>`;
|
|
2384
|
+
|
|
2385
|
+
throw new FlowValidationError(
|
|
2386
|
+
`[Flow] client expression references \`${name}\`, which will be undefined in the browser.\n` +
|
|
2387
|
+
` ${_at(filename, line + 1, character + 1)} in \`${_snippet(expr, 60)}\`\n` +
|
|
2388
|
+
` → ${hint}`,
|
|
2389
|
+
);
|
|
2390
|
+
}
|
|
2391
|
+
|
|
2053
2392
|
function _isComponent(tag: string): boolean {
|
|
2054
2393
|
const c = tag.charCodeAt(0);
|
|
2055
2394
|
return c >= 65 && c <= 90; // A–Z
|
package/src/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)
|
|
7
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
//
|
|
74
|
-
|
|
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 `
|
|
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 `
|
|
230
|
+
// File-upload management mixin (compose with `Component.using(FileUploads)`).
|
|
231
231
|
export { FileUploads } from "./uploads/FileUploads.ts";
|
|
232
232
|
|
|
233
233
|
// Validation
|