@jxsuite/runtime 6.2.2 → 7.0.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/dist/runtime.js +1046 -116
- package/dist/runtime.js.map +10 -7
- package/package.json +3 -2
- package/src/body-eraser.ts +265 -0
- package/src/css.ts +2 -1
- package/src/expression.ts +140 -7
- package/src/runtime.ts +1283 -160
- package/src/types.ts +58 -3
package/src/runtime.ts
CHANGED
|
@@ -36,7 +36,12 @@ import {
|
|
|
36
36
|
isNestedSelectorKey,
|
|
37
37
|
transposeCanvasOverlaySelector,
|
|
38
38
|
} from "./css.ts";
|
|
39
|
-
import {
|
|
39
|
+
import {
|
|
40
|
+
evaluateExpression,
|
|
41
|
+
evaluateOperand,
|
|
42
|
+
inheritableFormulas,
|
|
43
|
+
isMutating,
|
|
44
|
+
} from "./expression.ts";
|
|
40
45
|
import { readPath } from "./pointer.ts";
|
|
41
46
|
import type {
|
|
42
47
|
JxContext,
|
|
@@ -52,7 +57,6 @@ import type {
|
|
|
52
57
|
JxScopeValue,
|
|
53
58
|
} from "./types.ts";
|
|
54
59
|
import {
|
|
55
|
-
bodyReturnsValue,
|
|
56
60
|
hasStructuredBody,
|
|
57
61
|
isBoolean,
|
|
58
62
|
isExpressionDef,
|
|
@@ -71,14 +75,31 @@ import {
|
|
|
71
75
|
isString,
|
|
72
76
|
isTagExpression,
|
|
73
77
|
isTemplateString,
|
|
74
|
-
|
|
78
|
+
templateExpressions,
|
|
75
79
|
} from "@jxsuite/schema/guards";
|
|
76
80
|
import type { TagNameSlot } from "@jxsuite/schema/guards";
|
|
81
|
+
import {
|
|
82
|
+
classifyFunctionEntries,
|
|
83
|
+
declaresBodyAndSrc,
|
|
84
|
+
handlerParamNames,
|
|
85
|
+
} from "@jxsuite/schema/function-role";
|
|
86
|
+
import type { FunctionRole } from "@jxsuite/schema/function-role";
|
|
77
87
|
import { formatSrcset, parseSrcset } from "@jxsuite/schema/asset-paths";
|
|
78
88
|
import { runStatements } from "./statements.ts";
|
|
89
|
+
import { canLoadModules, compileBody, moduleLoaderFor, prepareBodies } from "./body-eraser.ts";
|
|
90
|
+
import {
|
|
91
|
+
isScriptSpecifier,
|
|
92
|
+
isTypeScriptPath,
|
|
93
|
+
parseScriptSpecifier,
|
|
94
|
+
qualifyScripts,
|
|
95
|
+
scriptRefSide,
|
|
96
|
+
scriptSpecifier,
|
|
97
|
+
} from "@jxsuite/schema/document-scripts";
|
|
98
|
+
|
|
79
99
|
import { readCookie, serializeCookie } from "./cookie.ts";
|
|
80
100
|
import type {
|
|
81
101
|
CemParameter,
|
|
102
|
+
JsonObject,
|
|
82
103
|
JsonValue,
|
|
83
104
|
JxAttributeValue,
|
|
84
105
|
JxClassCtorDef,
|
|
@@ -92,10 +113,26 @@ import type {
|
|
|
92
113
|
JxPrototypeDef,
|
|
93
114
|
JxRef,
|
|
94
115
|
JxServerFnDef,
|
|
116
|
+
JxStateDefinition,
|
|
95
117
|
JxStyle,
|
|
96
118
|
} from "@jxsuite/schema/types";
|
|
97
119
|
import type { EffectScope, Ref } from "@vue/reactivity";
|
|
98
120
|
|
|
121
|
+
export {
|
|
122
|
+
bodyNeedsEraser,
|
|
123
|
+
prepareBodies,
|
|
124
|
+
setBodyEraser,
|
|
125
|
+
setBodyEraserLoader,
|
|
126
|
+
setModuleLoader,
|
|
127
|
+
} from "./body-eraser.ts";
|
|
128
|
+
export type {
|
|
129
|
+
BodyEraser,
|
|
130
|
+
BodyEraserLoader,
|
|
131
|
+
BodyEraserModule,
|
|
132
|
+
ModuleLoader,
|
|
133
|
+
ModuleLoadOptions,
|
|
134
|
+
} from "./body-eraser.ts";
|
|
135
|
+
|
|
99
136
|
// ─── Public API ───────────────────────────────────────────────────────────────
|
|
100
137
|
|
|
101
138
|
/**
|
|
@@ -222,7 +259,7 @@ function assertHostScope(scope: JxScope): void {
|
|
|
222
259
|
* root it can remove and a `dispose()` that stops every effect the render created. Nothing here
|
|
223
260
|
* writes a module global, so a shell can hold many roots with different needs at once.
|
|
224
261
|
*
|
|
225
|
-
* @param {JxDocument}
|
|
262
|
+
* @param {JxDocument} source - The document to render (already an object; use {@link resolve} for a
|
|
226
263
|
* URL)
|
|
227
264
|
* @param {ParentNode} target - Where the rendered root is appended
|
|
228
265
|
* @param {JxMountOptions} [options]
|
|
@@ -230,13 +267,14 @@ function assertHostScope(scope: JxScope): void {
|
|
|
230
267
|
* @docs extending/embedding/runtime-host
|
|
231
268
|
*/
|
|
232
269
|
export async function mount<S extends JxHostScope<S> = JxScope>(
|
|
233
|
-
|
|
270
|
+
source: JxDocument,
|
|
234
271
|
target: ParentNode,
|
|
235
272
|
options: JxMountOptions<S> = {},
|
|
236
273
|
): Promise<JxMount> {
|
|
237
274
|
const hostScope: JxScope = options.scope ? hostScopeOf(options.scope) : {};
|
|
238
275
|
assertHostScope(hostScope);
|
|
239
276
|
const base = options.base ? new URL(options.base, location.href).href : location.href;
|
|
277
|
+
const doc = inlineDocument(source, base);
|
|
240
278
|
const ctx: JxContext = {
|
|
241
279
|
base,
|
|
242
280
|
media: options.media ?? doc.$media ?? null,
|
|
@@ -278,6 +316,16 @@ export async function mount<S extends JxHostScope<S> = JxScope>(
|
|
|
278
316
|
if (options.onNodeMoved) {
|
|
279
317
|
renderOptions.onNodeMoved = options.onNodeMoved;
|
|
280
318
|
}
|
|
319
|
+
// The authoring hooks (off unless a host names them): see `JxRenderOptions`.
|
|
320
|
+
if (options.rowLimit !== undefined) {
|
|
321
|
+
renderOptions.rowLimit = options.rowLimit;
|
|
322
|
+
}
|
|
323
|
+
if (options.emptyRow) {
|
|
324
|
+
renderOptions.emptyRow = options.emptyRow;
|
|
325
|
+
}
|
|
326
|
+
if (options.onListReconciled) {
|
|
327
|
+
renderOptions.onListReconciled = options.onListReconciled;
|
|
328
|
+
}
|
|
281
329
|
const { result: root, stop } = runScoped(() => renderNode(doc, scope, renderOptions));
|
|
282
330
|
ctx.root = root;
|
|
283
331
|
|
|
@@ -357,17 +405,18 @@ function responseContentType(res: Partial<Pick<Response, "headers">>): string {
|
|
|
357
405
|
|
|
358
406
|
export async function resolve(source: string | JxDocument, ctx?: JxContext): Promise<JxDocument> {
|
|
359
407
|
if (!isString(source)) {
|
|
408
|
+
await prepareBodies(source);
|
|
360
409
|
return source;
|
|
361
410
|
}
|
|
362
411
|
// A mount's resolver answers for THIS mount and its hit never enters the shared cache.
|
|
363
412
|
const served = ctx?.resolver?.(source);
|
|
364
413
|
if (served) {
|
|
365
|
-
return served;
|
|
414
|
+
return withOwnScripts(await served, source);
|
|
366
415
|
}
|
|
367
416
|
if (_resolveCache.has(source)) {
|
|
368
417
|
return _resolveCache.get(source)!;
|
|
369
418
|
}
|
|
370
|
-
const
|
|
419
|
+
const fetched = fetch(source).then(async (res) => {
|
|
371
420
|
if (!res.ok) {
|
|
372
421
|
throw new Error(`Jx: failed to fetch ${source} (${res.status})`);
|
|
373
422
|
}
|
|
@@ -406,6 +455,12 @@ export async function resolve(source: string | JxDocument, ctx?: JxContext): Pro
|
|
|
406
455
|
the JSON is that document; the runtime does not schema-validate documents by design. */
|
|
407
456
|
return (await res.json()) as JxDocument;
|
|
408
457
|
});
|
|
458
|
+
// A body that needs the eraser has it before anything compiles, which happens synchronously.
|
|
459
|
+
const p = fetched.then(async (read) => {
|
|
460
|
+
const doc = withOwnScripts(read, source);
|
|
461
|
+
await prepareBodies(doc);
|
|
462
|
+
return doc;
|
|
463
|
+
});
|
|
409
464
|
_resolveCache.set(source, p);
|
|
410
465
|
return p;
|
|
411
466
|
}
|
|
@@ -475,6 +530,64 @@ export function setSkipAutoRequests(v: boolean) {
|
|
|
475
530
|
_autoRequestConfig.skip = v;
|
|
476
531
|
}
|
|
477
532
|
|
|
533
|
+
/**
|
|
534
|
+
* One remote lookup the runtime is about to start: an automatic `Request` fetch, or a call of a
|
|
535
|
+
* `timing: "server"` function (embedding.md §5.2).
|
|
536
|
+
*/
|
|
537
|
+
export interface RemoteLookup {
|
|
538
|
+
kind: "request" | "server";
|
|
539
|
+
/** The state key the entry is declared under. */
|
|
540
|
+
key: string;
|
|
541
|
+
/** The scope the entry belongs to: a document's, or a component instance's. */
|
|
542
|
+
scope: JxScope;
|
|
543
|
+
/**
|
|
544
|
+
* What the lookup asks for, with every binding in it resolved. A request: `method`, `url`, and
|
|
545
|
+
* `headers` and `body` where the entry declares them. A server function: `$src`, `$export`,
|
|
546
|
+
* `arguments`, and `$base` where the document has one. Two lookups that ask for the same thing
|
|
547
|
+
* hold equal values here, which is what a host keys a cache on.
|
|
548
|
+
*/
|
|
549
|
+
request: JsonObject;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* A host's answer to a {@link RemoteLookup}. `{ value }` is written to the entry at once and nothing
|
|
554
|
+
* is in flight; a promise stands for the lookup (its answer is written when it settles, and a
|
|
555
|
+
* rejection is the entry's failure, as the lookup's own would be); `null` starts nothing, and the
|
|
556
|
+
* entry keeps the value it has.
|
|
557
|
+
*/
|
|
558
|
+
export type RemoteLookupAnswer = { value: JsonValue } | Promise<JsonValue> | null;
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* What a host installs with {@link setRemoteLookupHandler}. `run` starts the lookup itself and
|
|
562
|
+
* answers with its promise; a handler that never calls it has kept the lookup from happening.
|
|
563
|
+
*/
|
|
564
|
+
export type RemoteLookupHandler = (
|
|
565
|
+
lookup: RemoteLookup,
|
|
566
|
+
run: () => Promise<JsonValue>,
|
|
567
|
+
) => RemoteLookupAnswer;
|
|
568
|
+
|
|
569
|
+
let _remoteLookupHandler: RemoteLookupHandler | null = null;
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Put `handler` between the runtime and every remote lookup it starts, or take it away with `null`
|
|
573
|
+
* (embedding.md §5.2).
|
|
574
|
+
*
|
|
575
|
+
* The two skip switches above are all or nothing, and a host that draws a document over and over
|
|
576
|
+
* (Studio's canvas renders on every structural edit) needs a third answer: run this lookup once and
|
|
577
|
+
* hand every later render what it answered. The handler is asked each time a lookup would start,
|
|
578
|
+
* after the skip switches have had their say, and decides per lookup: let it run, answer it from
|
|
579
|
+
* what it holds, or leave it alone.
|
|
580
|
+
*
|
|
581
|
+
* A lookup started through `run` is the handler's. It is not aborted when the binding that asked
|
|
582
|
+
* for it is torn down, because the handler may be keeping the answer for the next one that asks;
|
|
583
|
+
* the runtime only declines to write an answer that arrives for a binding no longer there.
|
|
584
|
+
*
|
|
585
|
+
* @param {RemoteLookupHandler | null} handler
|
|
586
|
+
*/
|
|
587
|
+
export function setRemoteLookupHandler(handler: RemoteLookupHandler | null): void {
|
|
588
|
+
_remoteLookupHandler = handler;
|
|
589
|
+
}
|
|
590
|
+
|
|
478
591
|
// ─── Dev-proxy auth token (Studio cross-origin canvas) ────────────────────────
|
|
479
592
|
// The Studio canvas iframe is served from a token-gated loopback origin (createProjectServer): its
|
|
480
593
|
// POST /__jx_resolve__ + /__jx_server__ routes 403 without ?token=<rpcToken>. The iframe boot reads
|
|
@@ -680,9 +793,42 @@ export function setCanvasDelinkCommands(on: boolean) {
|
|
|
680
793
|
*/
|
|
681
794
|
let _canvasInsideStampedHost = false;
|
|
682
795
|
|
|
796
|
+
/**
|
|
797
|
+
* Run a later redraw (a `$switch` case that flips, a `$map` row that arrives) as inside a stamped
|
|
798
|
+
* host or not, the way its first render was.
|
|
799
|
+
*
|
|
800
|
+
* The flag is true only while a stamped instance draws, and a redraw comes from an effect with
|
|
801
|
+
* nothing on the stack (a list's from a microtask), so without this a case or a row drawn after the
|
|
802
|
+
* first paint kept its native `popover`, `inert` and `commandfor` inside the canvas.
|
|
803
|
+
*
|
|
804
|
+
* @param {boolean} inside - What the flag was when the list or switch was first rendered.
|
|
805
|
+
* @param {() => T} fn - The redraw.
|
|
806
|
+
* @returns {T}
|
|
807
|
+
*/
|
|
808
|
+
function insideStampedHost<T>(inside: boolean, fn: () => T): T {
|
|
809
|
+
const was = _canvasInsideStampedHost;
|
|
810
|
+
_canvasInsideStampedHost = inside;
|
|
811
|
+
try {
|
|
812
|
+
return fn();
|
|
813
|
+
} finally {
|
|
814
|
+
_canvasInsideStampedHost = was;
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
683
818
|
/** Whether the node being rendered is inside a stamped host, and so part of the edited page. */
|
|
684
819
|
function canvasStamped(el: HTMLElement): boolean {
|
|
685
|
-
return _canvasInsideStampedHost || el
|
|
820
|
+
return _canvasInsideStampedHost || carriesCanvasStamp(el);
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/**
|
|
824
|
+
* Whether the studio stamped this element as part of the page being edited: with its document path
|
|
825
|
+
* (`data-jx-path`), or, for a mapped row that is not the one being edited, with the path it echoes
|
|
826
|
+
* (`data-jx-echo-path`). An echo row draws the same document nodes as the active one, so it is
|
|
827
|
+
* de-linked like it: left out, every row but one would keep live popovers, dialogs and commands in
|
|
828
|
+
* an editable canvas.
|
|
829
|
+
*/
|
|
830
|
+
function carriesCanvasStamp(el: HTMLElement): boolean {
|
|
831
|
+
return el.dataset.jxPath !== undefined || el.dataset.jxEchoPath !== undefined;
|
|
686
832
|
}
|
|
687
833
|
|
|
688
834
|
/** The attribute name to stamp `key` on `el` under — `href` → `data-jx-href` on de-linked anchors. */
|
|
@@ -880,18 +1026,20 @@ function boundPropKey(
|
|
|
880
1026
|
/**
|
|
881
1027
|
* Build the reactive scope (state) from the document using the five-shape detection algorithm.
|
|
882
1028
|
*
|
|
883
|
-
* @param {JxDocument}
|
|
1029
|
+
* @param {JxDocument} source - The document
|
|
884
1030
|
* @param {JxScope} [parentScope] Default is `{}`
|
|
885
1031
|
* @param {string} [base] Base URL for resolving $src imports. Default is `location.href`
|
|
886
1032
|
* @param {JxContext} [ctx] The mount context; absent on the legacy direct path
|
|
887
1033
|
* @returns {Promise<JxScope>} Reactive proxy (state)
|
|
888
1034
|
*/
|
|
889
1035
|
export async function buildScope(
|
|
890
|
-
|
|
1036
|
+
source: JxDocument,
|
|
891
1037
|
parentScope: JxScope = {},
|
|
892
1038
|
base: string = location.href,
|
|
893
1039
|
ctx?: JxContext,
|
|
894
1040
|
) {
|
|
1041
|
+
// Already done for a document that was mounted or read from a URL; this is for one handed in raw.
|
|
1042
|
+
const doc = inlineDocument(source, base);
|
|
895
1043
|
// A body run without an event dispatches from the mount's root, read when it fires.
|
|
896
1044
|
const dispatchRoot = (): EventTarget | null => ctx?.root ?? null;
|
|
897
1045
|
const raw: JxScope = {};
|
|
@@ -1007,8 +1155,15 @@ export async function buildScope(
|
|
|
1007
1155
|
}
|
|
1008
1156
|
}
|
|
1009
1157
|
|
|
1010
|
-
// Third pass: $prototype: "Function" entries
|
|
1158
|
+
// Third pass: $prototype: "Function" entries. One verdict per entry, from the document: the same
|
|
1159
|
+
// One every compiled target reads (spec.md §5.3 4b, 4d).
|
|
1160
|
+
const roles = classifyFunctionEntries(doc);
|
|
1011
1161
|
for (const [key, def] of Object.entries(defs)) {
|
|
1162
|
+
/* Refused ahead of the structured-body branch, so a structured body beside a `$src` is caught
|
|
1163
|
+
too, and by presence, so `body: ""` is (spec.md §5.3 4d). */
|
|
1164
|
+
if (isFunctionDef(def) && declaresBodyAndSrc(def)) {
|
|
1165
|
+
throw new Error(`Jx: '${key}' declares both body and $src — these are mutually exclusive`);
|
|
1166
|
+
}
|
|
1012
1167
|
if (hasStructuredBody(def)) {
|
|
1013
1168
|
// Structured body (spec §20): with parameters → a callable mapping positional args onto
|
|
1014
1169
|
// $args names (invoked via the call operator); without → an event handler.
|
|
@@ -1029,7 +1184,7 @@ export async function buildScope(
|
|
|
1029
1184
|
state[key] = handler;
|
|
1030
1185
|
}
|
|
1031
1186
|
} else if (isFunctionDef(def)) {
|
|
1032
|
-
state[key] = await resolveFunction(def, state, key, base);
|
|
1187
|
+
state[key] = await resolveFunction(def, state, key, roles.get(key) ?? "callable", base);
|
|
1033
1188
|
}
|
|
1034
1189
|
}
|
|
1035
1190
|
|
|
@@ -1283,6 +1438,7 @@ function bindTemplate(source: string, run: () => void, fallback?: () => void): v
|
|
|
1283
1438
|
run();
|
|
1284
1439
|
heldTemplateErrors.delete(run);
|
|
1285
1440
|
} catch (error) {
|
|
1441
|
+
_templateThrows += 1;
|
|
1286
1442
|
const thrown = error instanceof Error ? error : new Error(String(error));
|
|
1287
1443
|
if (_templateErrorHandler) {
|
|
1288
1444
|
_templateErrorHandler(thrown, source);
|
|
@@ -1296,6 +1452,20 @@ function bindTemplate(source: string, run: () => void, fallback?: () => void): v
|
|
|
1296
1452
|
});
|
|
1297
1453
|
}
|
|
1298
1454
|
|
|
1455
|
+
/**
|
|
1456
|
+
* How many template evaluations have thrown and been survived. {@link updateInstanceProps} reads it
|
|
1457
|
+
* around its writes: a binding that throws keeps what it last drew, which a fresh render of the
|
|
1458
|
+
* same values never drew.
|
|
1459
|
+
*/
|
|
1460
|
+
let _templateThrows = 0;
|
|
1461
|
+
|
|
1462
|
+
/**
|
|
1463
|
+
* How many times a mapped list has been asked to redraw. A list redraws in a later microtask, so
|
|
1464
|
+
* {@link updateInstanceProps} cannot answer for what that draws (a row that throws, for one) and
|
|
1465
|
+
* reads this around its writes instead.
|
|
1466
|
+
*/
|
|
1467
|
+
let _listRedraws = 0;
|
|
1468
|
+
|
|
1299
1469
|
/** A compiled template: the scope, the row context, its item and index, and the page context. */
|
|
1300
1470
|
type TemplateFn<R> = (
|
|
1301
1471
|
state: JxScope,
|
|
@@ -1323,11 +1493,15 @@ function rowContext(state: JxScope): JxRowContext | undefined {
|
|
|
1323
1493
|
* Whether a template is ONE expression end to end — `"${a.b}"` rather than `"a ${b}"` or
|
|
1324
1494
|
* `"${a}${b}"`.
|
|
1325
1495
|
*
|
|
1326
|
-
*
|
|
1327
|
-
*
|
|
1328
|
-
*
|
|
1329
|
-
*
|
|
1330
|
-
*
|
|
1496
|
+
* Scanned rather than pattern-matched. A greedy `/^\$\{(.+)\}$/` also matched `"${a} / ${b}"`,
|
|
1497
|
+
* spliced the interior into `return (a} / ${b)`, and the SyntaxError became a silent null: the node
|
|
1498
|
+
* rendered empty and a `$head` entry shipped its own template text. A scan is exact where a
|
|
1499
|
+
* tightened regex is not — `"${`${a}-x`}"` is still one expression, and must keep its raw value.
|
|
1500
|
+
*
|
|
1501
|
+
* The scan is `templateExpressions`, the one every reader of a template shares, so a `}` inside a
|
|
1502
|
+
* string does not end the expression: a brace count read `"${a ? '}' : b}"` as text around an
|
|
1503
|
+
* expression, and its value arrived as a string where the same expression without the quoted brace
|
|
1504
|
+
* kept its type.
|
|
1331
1505
|
*
|
|
1332
1506
|
* Lives here rather than in the compiler because both renderers must draw the line in the same
|
|
1333
1507
|
* place. It is what decides whether a value keeps its own type, and a value that is a boolean in
|
|
@@ -1341,19 +1515,8 @@ export function isSingleExpression(str: string): boolean {
|
|
|
1341
1515
|
if (!str.startsWith("${") || !str.endsWith("}")) {
|
|
1342
1516
|
return false;
|
|
1343
1517
|
}
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
const c = str[i];
|
|
1347
|
-
if (c === "{") {
|
|
1348
|
-
depth += 1;
|
|
1349
|
-
} else if (c === "}") {
|
|
1350
|
-
depth -= 1;
|
|
1351
|
-
if (depth === 0) {
|
|
1352
|
-
return i === str.length - 1;
|
|
1353
|
-
}
|
|
1354
|
-
}
|
|
1355
|
-
}
|
|
1356
|
-
return false;
|
|
1518
|
+
const [only, ...more] = templateExpressions(str);
|
|
1519
|
+
return only !== undefined && more.length === 0 && only.start === 0 && only.end === str.length;
|
|
1357
1520
|
}
|
|
1358
1521
|
|
|
1359
1522
|
/**
|
|
@@ -1392,6 +1555,26 @@ function evaluateValueTemplate(str: string, state: JxScope): JxScopeValue {
|
|
|
1392
1555
|
return fn(...templateArgs(state));
|
|
1393
1556
|
}
|
|
1394
1557
|
|
|
1558
|
+
/**
|
|
1559
|
+
* Evaluate a template against a scope, for editor tooling: the value a binding would write.
|
|
1560
|
+
*
|
|
1561
|
+
* It is the runtime's own reading, so a tool that shows a template's live value cannot disagree
|
|
1562
|
+
* with the render beside it. A template that is one whole `${…}` answers the expression's own value
|
|
1563
|
+
* (an array, a boolean, a record); anything else answers the interpolated string. The expression
|
|
1564
|
+
* sees exactly the names a rendered template does: `state` (the scope), `$map`, `item` and `index`
|
|
1565
|
+
* (the scope's row context, undefined outside a mapped row), and `$site` and `$page` (read off the
|
|
1566
|
+
* scope). Nothing is caught: an expression that throws, or a source that does not compile, throws
|
|
1567
|
+
* here, and reads made during the call are tracked by whatever effect is running.
|
|
1568
|
+
*
|
|
1569
|
+
* @param {string} str - Template source
|
|
1570
|
+
* @param {JxScope} scope - The scope to read, a row's own scope for a template inside a `map`
|
|
1571
|
+
* @returns {JxScopeValue} The expression's value, or the interpolated string
|
|
1572
|
+
* @docs extending/embedding/runtime-host
|
|
1573
|
+
*/
|
|
1574
|
+
export function evaluateTemplateValue(str: string, scope: JxScope): JxScopeValue {
|
|
1575
|
+
return evaluateValueTemplate(str, scope);
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1395
1578
|
// ─── Step 2b: Function resolution (Shape 4) ─────────────────────────────────
|
|
1396
1579
|
|
|
1397
1580
|
/**
|
|
@@ -1451,6 +1634,14 @@ async function importSource(src: string, base?: string): Promise<ImportedModule>
|
|
|
1451
1634
|
const specifier = src.slice(NPM_SCHEME.length);
|
|
1452
1635
|
return importModule(_npmModuleResolver?.(specifier) ?? specifier);
|
|
1453
1636
|
}
|
|
1637
|
+
const typed = typeScriptSource(src, base);
|
|
1638
|
+
// Asked only where an answer is possible, so a page with no loader waits no longer than it did.
|
|
1639
|
+
if (typed !== null && (canLoadModules() || isScriptSpecifier(src))) {
|
|
1640
|
+
const loaded = await importTypeScript(typed, src, base);
|
|
1641
|
+
if (loaded) {
|
|
1642
|
+
return loaded;
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1454
1645
|
if (base) {
|
|
1455
1646
|
try {
|
|
1456
1647
|
return await importModule(new URL(src, base).href);
|
|
@@ -1461,6 +1652,145 @@ async function importSource(src: string, base?: string): Promise<ImportedModule>
|
|
|
1461
1652
|
return importModule(src);
|
|
1462
1653
|
}
|
|
1463
1654
|
|
|
1655
|
+
/** The schemes a page can fetch a module's text from. */
|
|
1656
|
+
const FETCHED_SCHEMES = new Set(["http:", "https:"]);
|
|
1657
|
+
|
|
1658
|
+
/**
|
|
1659
|
+
* What the module loader is asked for when a `$src` names TypeScript (spec.md §23.2): a document
|
|
1660
|
+
* script's specifier as it is, or the URL of a `.ts` file a page can fetch. Null for anything a
|
|
1661
|
+
* browser imports as it is.
|
|
1662
|
+
*
|
|
1663
|
+
* @param {string} src - The `$src`, qualified if it named its document's script
|
|
1664
|
+
* @param {string} [base] - The document's URL
|
|
1665
|
+
* @returns {string | null}
|
|
1666
|
+
* @throws {Error} For `#client` or `#server` in a document with no `$script`
|
|
1667
|
+
*/
|
|
1668
|
+
function typeScriptSource(src: string, base?: string): string | null {
|
|
1669
|
+
if (isScriptSpecifier(src)) {
|
|
1670
|
+
return src;
|
|
1671
|
+
}
|
|
1672
|
+
if (scriptRefSide(src) !== null) {
|
|
1673
|
+
throw new Error(
|
|
1674
|
+
`Jx: $src "${src}" names the script of the document it is written in, and that document ` +
|
|
1675
|
+
`has no $script`,
|
|
1676
|
+
);
|
|
1677
|
+
}
|
|
1678
|
+
const from = base ?? location.href;
|
|
1679
|
+
if (!URL.canParse(src, from)) {
|
|
1680
|
+
return null;
|
|
1681
|
+
}
|
|
1682
|
+
const url = new URL(src, from);
|
|
1683
|
+
// A `file:` path is a test runner's, which imports TypeScript itself; a seeded scheme never gets here.
|
|
1684
|
+
return FETCHED_SCHEMES.has(url.protocol) && isTypeScriptPath(url.pathname) ? url.href : null;
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
/**
|
|
1688
|
+
* Import a module written in TypeScript through the host's module loader. Null when the page has
|
|
1689
|
+
* none and the module is a `.ts` file, which is then imported as written: a host that transpiles
|
|
1690
|
+
* what it serves still answers that. A document's script has no URL to fall back to, so it says
|
|
1691
|
+
* what is missing.
|
|
1692
|
+
*
|
|
1693
|
+
* @param {string} specifier - What {@link typeScriptSource} gave
|
|
1694
|
+
* @param {string} src - The `$src` as the document holds it
|
|
1695
|
+
* @param {string} [base] - The document's URL
|
|
1696
|
+
* @returns {Promise<ImportedModule | null>}
|
|
1697
|
+
* @throws {Error} For a document's script on a page with no module loader
|
|
1698
|
+
*/
|
|
1699
|
+
async function importTypeScript(
|
|
1700
|
+
specifier: string,
|
|
1701
|
+
src: string,
|
|
1702
|
+
base?: string,
|
|
1703
|
+
): Promise<ImportedModule | null> {
|
|
1704
|
+
const load = await moduleLoaderFor();
|
|
1705
|
+
if (load) {
|
|
1706
|
+
const module = await load(specifier, {
|
|
1707
|
+
base,
|
|
1708
|
+
resolveNpm: _npmModuleResolver,
|
|
1709
|
+
text: _documentScripts.get(src),
|
|
1710
|
+
});
|
|
1711
|
+
/* SAFETY: trust boundary, the same one as `importModule` below: the loader hands back the
|
|
1712
|
+
module namespace `import()` gave it, read by export name and checked at each use. */
|
|
1713
|
+
return module as ImportedModule;
|
|
1714
|
+
}
|
|
1715
|
+
if (isScriptSpecifier(src)) {
|
|
1716
|
+
throw new Error(
|
|
1717
|
+
`Jx: ${parseScriptSpecifier(src)?.path ?? src} has a script, and this page has nothing ` +
|
|
1718
|
+
`that can load one. Give it a module loader with setModuleLoader(loadModule), loadModule ` +
|
|
1719
|
+
`from @jxsuite/erase/browser, or let setBodyEraserLoader fetch that file when it is needed.`,
|
|
1720
|
+
);
|
|
1721
|
+
}
|
|
1722
|
+
return null;
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
/**
|
|
1726
|
+
* The text of each document's client script the runtime has read, by the qualified specifier that
|
|
1727
|
+
* names it, so importing one fetches nothing.
|
|
1728
|
+
*/
|
|
1729
|
+
const _documentScripts = new Map<string, string>();
|
|
1730
|
+
|
|
1731
|
+
/**
|
|
1732
|
+
* A document as the runtime uses it: every `$src` naming its own script qualified with the
|
|
1733
|
+
* document's address (spec.md §23.2), and its client script kept for the module loader.
|
|
1734
|
+
*
|
|
1735
|
+
* @param {JxDocument} doc - The document as read
|
|
1736
|
+
* @param {string} source - Where it was read from: a URL, or a key a host serves it under
|
|
1737
|
+
* @returns {JxDocument}
|
|
1738
|
+
*/
|
|
1739
|
+
function withOwnScripts(doc: JxDocument, source: string): JxDocument {
|
|
1740
|
+
if (!doc.$script) {
|
|
1741
|
+
return doc;
|
|
1742
|
+
}
|
|
1743
|
+
// Its absolute URL, so one document is one module however each reference to it was spelled.
|
|
1744
|
+
const address = URL.canParse(source, location.href)
|
|
1745
|
+
? new URL(source, location.href).href
|
|
1746
|
+
: source;
|
|
1747
|
+
const { client } = doc.$script;
|
|
1748
|
+
const specifier = scriptSpecifier(address, "client");
|
|
1749
|
+
if (isString(client) && _documentScripts.get(specifier) !== client) {
|
|
1750
|
+
/* Read again with its script edited, which an editing host does on every change: the module
|
|
1751
|
+
made from the old text is not this document's any more. One the host seeded stays, because
|
|
1752
|
+
the host is then what says what the module is. */
|
|
1753
|
+
if (_documentScripts.has(specifier) && !_hostSeeded.has(specifier)) {
|
|
1754
|
+
_moduleCache.delete(specifier);
|
|
1755
|
+
}
|
|
1756
|
+
_documentScripts.set(specifier, client);
|
|
1757
|
+
}
|
|
1758
|
+
const own = qualifyScripts(doc, address);
|
|
1759
|
+
_addressed.set(own, own);
|
|
1760
|
+
return own;
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
/**
|
|
1764
|
+
* Each document that has a script, with the copy that names it: itself for one read from an
|
|
1765
|
+
* address, so mounting what `resolve()` gave does not address it a second time.
|
|
1766
|
+
*/
|
|
1767
|
+
const _addressed = new WeakMap<JxDocument, JxDocument>();
|
|
1768
|
+
let _inlineCount = 0;
|
|
1769
|
+
|
|
1770
|
+
/**
|
|
1771
|
+
* A document handed over as an object, with every `$src` naming its own script qualified. It has no
|
|
1772
|
+
* URL of its own, so it stands at its base, and under a name no other document on the page has: two
|
|
1773
|
+
* documents mounted from one page are still two modules, and one mounted twice is one.
|
|
1774
|
+
*
|
|
1775
|
+
* @param {JxDocument} doc - The document as given
|
|
1776
|
+
* @param {string} base - What its relative references resolve against
|
|
1777
|
+
* @returns {JxDocument}
|
|
1778
|
+
*/
|
|
1779
|
+
function inlineDocument(doc: JxDocument, base: string): JxDocument {
|
|
1780
|
+
if (!doc.$script) {
|
|
1781
|
+
return doc;
|
|
1782
|
+
}
|
|
1783
|
+
let own = _addressed.get(doc);
|
|
1784
|
+
if (!own) {
|
|
1785
|
+
_inlineCount += 1;
|
|
1786
|
+
const address = new URL(base, location.href);
|
|
1787
|
+
address.hash = `jx-inline-${_inlineCount}`;
|
|
1788
|
+
own = withOwnScripts(doc, address.href);
|
|
1789
|
+
_addressed.set(doc, own);
|
|
1790
|
+
}
|
|
1791
|
+
return own;
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1464
1794
|
function importModule(specifier: string): Promise<ImportedModule> {
|
|
1465
1795
|
/* SAFETY: trust boundary — a module exports whatever it exports. `import()` resolves to the
|
|
1466
1796
|
module namespace object, whose keys are its export names, and every export is typed as the scope
|
|
@@ -1586,24 +1916,95 @@ function seededModule(src: string): ImportedModule | Promise<ImportedModule> | u
|
|
|
1586
1916
|
*/
|
|
1587
1917
|
type ScopeFunction = (...args: JxScopeValue[]) => JxScopeValue;
|
|
1588
1918
|
|
|
1919
|
+
/**
|
|
1920
|
+
* The module a `$src` names: the host's seeded copy, the cached one, or a fetch that is cached when
|
|
1921
|
+
* it settles. Returned as it comes, a module or a promise of one, so an eager caller adds no
|
|
1922
|
+
* microtask of its own: a kit element's scope is ready a fixed number of ticks after it connects,
|
|
1923
|
+
* and `jx-field` names the control it slots at the end of them.
|
|
1924
|
+
*
|
|
1925
|
+
* @param {string} src - The specifier
|
|
1926
|
+
* @param {string} [base] - Base URL for resolving relative specifiers
|
|
1927
|
+
*/
|
|
1928
|
+
function acquireModule(src: string, base?: string): ImportedModule | Promise<ImportedModule> {
|
|
1929
|
+
const seeded = seededModule(src);
|
|
1930
|
+
if (seeded) {
|
|
1931
|
+
return seeded;
|
|
1932
|
+
}
|
|
1933
|
+
const loading = importSource(src, base);
|
|
1934
|
+
// A side branch: it caches the module without delaying what the caller awaits.
|
|
1935
|
+
loading.then(
|
|
1936
|
+
(mod) => {
|
|
1937
|
+
_moduleCache.set(src, mod);
|
|
1938
|
+
},
|
|
1939
|
+
() => {},
|
|
1940
|
+
);
|
|
1941
|
+
return loading;
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
/**
|
|
1945
|
+
* The function a `$src` Function entry names, out of its module.
|
|
1946
|
+
*
|
|
1947
|
+
* @param {JxFunctionDef} def
|
|
1948
|
+
* @param {string} key - Def key name
|
|
1949
|
+
* @param {string} src - The specifier the module came from
|
|
1950
|
+
* @param {ImportedModule} mod
|
|
1951
|
+
*/
|
|
1952
|
+
function exportedFunction(
|
|
1953
|
+
def: JxFunctionDef,
|
|
1954
|
+
key: string,
|
|
1955
|
+
src: string,
|
|
1956
|
+
mod: ImportedModule,
|
|
1957
|
+
): ScopeFunction {
|
|
1958
|
+
const exportName = def.$export ?? key;
|
|
1959
|
+
const candidate = mod[exportName] ?? mod.default?.[exportName];
|
|
1960
|
+
if (!isFunction(candidate)) {
|
|
1961
|
+
throw new TypeError(`Jx: export "${exportName}" not found or not a function in "${src}"`);
|
|
1962
|
+
}
|
|
1963
|
+
/* SAFETY: the guard proved a function, and a function accepts any call: the runtime passes the
|
|
1964
|
+
scope first, as a Function entry's contract says, and binds the result as a scope member. */
|
|
1965
|
+
return candidate as ScopeFunction;
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
/**
|
|
1969
|
+
* Import the function a `$src` Function entry names, for a caller that can wait for it: a `$lazy`
|
|
1970
|
+
* entry's first call. An eager entry does the same two steps inline, in `resolveFunction`.
|
|
1971
|
+
*
|
|
1972
|
+
* @param {JxFunctionDef} def
|
|
1973
|
+
* @param {string} key - Def key name
|
|
1974
|
+
* @param {string} src - The entry's `$src`
|
|
1975
|
+
* @param {string} [base] - Base URL for resolving relative specifiers
|
|
1976
|
+
*/
|
|
1977
|
+
async function importFunctionExport(
|
|
1978
|
+
def: JxFunctionDef,
|
|
1979
|
+
key: string,
|
|
1980
|
+
src: string,
|
|
1981
|
+
base?: string,
|
|
1982
|
+
): Promise<ScopeFunction> {
|
|
1983
|
+
return exportedFunction(def, key, src, await acquireModule(src, base));
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1589
1986
|
/**
|
|
1590
1987
|
* Resolve a $prototype: "Function" entry into a function or computed.
|
|
1591
1988
|
*
|
|
1592
|
-
* Functions receive state as their first parameter at call time.
|
|
1593
|
-
*
|
|
1989
|
+
* Functions receive state as their first parameter at call time. A `computed` role (decided by
|
|
1990
|
+
* `classifyFunctionEntries` from the document) is wrapped in computed() for reactive evaluation.
|
|
1594
1991
|
*
|
|
1595
1992
|
* @param {JxFunctionDef} def - State entry with $prototype: "Function"
|
|
1596
1993
|
* @param {JxScope} state - Reactive scope proxy
|
|
1597
1994
|
* @param {string} key - Def key name
|
|
1995
|
+
* @param {FunctionRole} role - The document's verdict for this entry
|
|
1598
1996
|
* @param {string} [base] - Base URL for resolving $src imports
|
|
1599
1997
|
* @returns {Promise<JxScopeValue>}
|
|
1600
1998
|
*/
|
|
1601
|
-
async function resolveFunction(
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1999
|
+
async function resolveFunction(
|
|
2000
|
+
def: JxFunctionDef,
|
|
2001
|
+
state: JxScope,
|
|
2002
|
+
key: string,
|
|
2003
|
+
role: FunctionRole,
|
|
2004
|
+
base?: string,
|
|
2005
|
+
) {
|
|
1605
2006
|
if (!def.body && !def.$src) {
|
|
1606
|
-
const params =
|
|
2007
|
+
const params = handlerParamNames(def);
|
|
1607
2008
|
const noop = new Function(...params, "");
|
|
1608
2009
|
Object.defineProperty(noop, "name", {
|
|
1609
2010
|
configurable: true,
|
|
@@ -1615,50 +2016,52 @@ async function resolveFunction(def: JxFunctionDef, state: JxScope, key: string,
|
|
|
1615
2016
|
let fn: ScopeFunction;
|
|
1616
2017
|
|
|
1617
2018
|
if (isString(def.body)) {
|
|
1618
|
-
const params =
|
|
2019
|
+
const params = handlerParamNames(def);
|
|
1619
2020
|
/* SAFETY: `new Function` returns an ordinary function of the named parameters, `state` first.
|
|
1620
2021
|
A call never checks its arguments, and what the body returns is one more scope member. */
|
|
1621
|
-
fn =
|
|
2022
|
+
fn = compileBody(params, def.body, `'${key}'`) as ScopeFunction;
|
|
1622
2023
|
Object.defineProperty(fn, "name", {
|
|
1623
2024
|
configurable: true,
|
|
1624
2025
|
value: def.name ?? key,
|
|
1625
2026
|
});
|
|
2027
|
+
} else if (def.$lazy === true && def.$src) {
|
|
2028
|
+
const { $src: src } = def;
|
|
2029
|
+
/* Loaded on the first call, not here (spec.md §5.3 4d), as a compiled page does. The first call
|
|
2030
|
+
starts the import and every call waits on it, so the module loads once and a call returns a
|
|
2031
|
+
promise from then on. A computed reads its value now, which a promise cannot give, so the
|
|
2032
|
+
document is refused at build rather than rendering one. */
|
|
2033
|
+
if (role === "computed") {
|
|
2034
|
+
throw new Error(
|
|
2035
|
+
`Jx: "${key}" declares "$lazy": true but is used as a computed value, not called. ` +
|
|
2036
|
+
`A lazily loaded function resolves to a promise; a computed must resolve now. ` +
|
|
2037
|
+
`Remove "$lazy", or call ${key}() from a handler instead of binding its value.`,
|
|
2038
|
+
);
|
|
2039
|
+
}
|
|
2040
|
+
let loaded: Promise<ScopeFunction> | undefined;
|
|
2041
|
+
const load = (): Promise<ScopeFunction> =>
|
|
2042
|
+
(loaded ??= importFunctionExport(def, key, src, base));
|
|
2043
|
+
fn = (...args: JxScopeValue[]) => load().then((target) => target(...args));
|
|
2044
|
+
Object.defineProperty(fn, "name", {
|
|
2045
|
+
configurable: true,
|
|
2046
|
+
value: def.name ?? key,
|
|
2047
|
+
});
|
|
2048
|
+
return fn;
|
|
1626
2049
|
} else {
|
|
1627
2050
|
// $src: dynamic import (the body/$src dichotomy was validated above)
|
|
1628
|
-
|
|
1629
|
-
if (!src) {
|
|
2051
|
+
if (!def.$src) {
|
|
1630
2052
|
throw new Error(`Jx: '${key}' has neither body nor $src`);
|
|
1631
2053
|
}
|
|
1632
|
-
const
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
} else {
|
|
1640
|
-
mod = await importSource(src, base);
|
|
1641
|
-
_moduleCache.set(src, mod);
|
|
1642
|
-
}
|
|
1643
|
-
const candidate = mod[exportName] ?? mod.default?.[exportName];
|
|
1644
|
-
if (!isFunction(candidate)) {
|
|
1645
|
-
throw new TypeError(`Jx: export "${exportName}" not found or not a function in "${src}"`);
|
|
1646
|
-
}
|
|
1647
|
-
/* SAFETY: the guard proved a function, and a function accepts any call: the runtime passes the
|
|
1648
|
-
scope first, as a Function entry's contract says, and binds the result as a scope member. */
|
|
1649
|
-
fn = candidate as ScopeFunction;
|
|
2054
|
+
const acquired = acquireModule(def.$src, base);
|
|
2055
|
+
fn = exportedFunction(
|
|
2056
|
+
def,
|
|
2057
|
+
key,
|
|
2058
|
+
def.$src,
|
|
2059
|
+
acquired instanceof Promise ? await acquired : acquired,
|
|
2060
|
+
);
|
|
1650
2061
|
}
|
|
1651
2062
|
|
|
1652
|
-
//
|
|
1653
|
-
|
|
1654
|
-
const hasParams = (def.parameters ?? def.arguments ?? []).length > 0;
|
|
1655
|
-
let isComputed = false;
|
|
1656
|
-
if (!hasParams) {
|
|
1657
|
-
isComputed = isString(def.body)
|
|
1658
|
-
? bodyReturnsValue(def.body)
|
|
1659
|
-
: fn.length <= 1 && bodyReturnsValue(fn.toString());
|
|
1660
|
-
}
|
|
1661
|
-
if (isComputed) {
|
|
2063
|
+
// The role comes from the document (spec.md §5.3 4b, 4d), never from the import's arity or source.
|
|
2064
|
+
if (role === "computed") {
|
|
1662
2065
|
return computed(() => fn(state));
|
|
1663
2066
|
}
|
|
1664
2067
|
|
|
@@ -1667,19 +2070,6 @@ async function resolveFunction(def: JxFunctionDef, state: JxScope, key: string,
|
|
|
1667
2070
|
|
|
1668
2071
|
// ─── Step 3: Render ───────────────────────────────────────────────────────────
|
|
1669
2072
|
|
|
1670
|
-
/**
|
|
1671
|
-
* Extract parameter names from a function definition. Supports both legacy "arguments" (string
|
|
1672
|
-
* array) and CEM-compatible "parameters" (object array). Always ensures "state" is the first
|
|
1673
|
-
* parameter.
|
|
1674
|
-
*
|
|
1675
|
-
* @param {JxFunctionDef | JxPrototypeDef} def
|
|
1676
|
-
* @returns {string[]}
|
|
1677
|
-
*/
|
|
1678
|
-
function resolveParamNames(def: JxFunctionDef | JxPrototypeDef) {
|
|
1679
|
-
const names = def.parameters ? paramNames(def.parameters) : (def.arguments ?? []);
|
|
1680
|
-
return names.length > 0 && names[0] === "state" ? names : ["state", ...names];
|
|
1681
|
-
}
|
|
1682
|
-
|
|
1683
2073
|
/**
|
|
1684
2074
|
* Reserved Jx keys — never set as DOM properties.
|
|
1685
2075
|
*
|
|
@@ -1699,6 +2089,7 @@ export const RESERVED_KEYS = new Set([
|
|
|
1699
2089
|
"$prototype",
|
|
1700
2090
|
"$src",
|
|
1701
2091
|
"$export",
|
|
2092
|
+
"$script",
|
|
1702
2093
|
"$media",
|
|
1703
2094
|
"$map",
|
|
1704
2095
|
"timing",
|
|
@@ -1991,6 +2382,8 @@ export function renderNode(
|
|
|
1991
2382
|
if (isMappedArray(child)) {
|
|
1992
2383
|
// Array pseudo-element among siblings: expand inline, no wrapper.
|
|
1993
2384
|
renderMappedArrayInto(el, child, localState, childOpts);
|
|
2385
|
+
} else if (isString(child) && isSingleExpression(child)) {
|
|
2386
|
+
renderTemplatedChild(el, child, localState, childOpts);
|
|
1994
2387
|
} else {
|
|
1995
2388
|
el.append(renderNode(child, localState, childOpts));
|
|
1996
2389
|
}
|
|
@@ -2009,7 +2402,7 @@ export function renderNode(
|
|
|
2009
2402
|
/**
|
|
2010
2403
|
* Draw a whole-`children` template, the way the site build's template pass expands one (spec.md
|
|
2011
2404
|
* §8.4). This is what an item's or an entry's parsed body looks like to something that renders the
|
|
2012
|
-
* source document rather than the build's output: Studio's
|
|
2405
|
+
* source document rather than the build's output: Studio's editing canvas, chiefly.
|
|
2013
2406
|
*
|
|
2014
2407
|
* A result that is not an array draws nothing, because a plain string is not a `children` value.
|
|
2015
2408
|
* The children are rendered in a detached scope of their own, replaced when the template's value
|
|
@@ -2027,7 +2420,6 @@ function renderComputedChildren(
|
|
|
2027
2420
|
state: JxScope,
|
|
2028
2421
|
options: JxRenderOptions,
|
|
2029
2422
|
): void {
|
|
2030
|
-
const path = options._path ?? [];
|
|
2031
2423
|
let live: EffectScope | null = null;
|
|
2032
2424
|
const retire = (): void => {
|
|
2033
2425
|
// Detached before it is stopped, for the reason `teardown` in `renderMappedArray` gives.
|
|
@@ -2043,26 +2435,138 @@ function renderComputedChildren(
|
|
|
2043
2435
|
if (!Array.isArray(nodes)) {
|
|
2044
2436
|
return;
|
|
2045
2437
|
}
|
|
2046
|
-
|
|
2047
|
-
live
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
scope.run(() => {
|
|
2051
|
-
/* SAFETY: a children template that yields an array stands for the child list itself, the
|
|
2052
|
-
same reading the site build's template pass gives it. */
|
|
2053
|
-
for (const [i, child] of (nodes as (JxElement | string)[]).entries()) {
|
|
2054
|
-
el.append(renderNode(child, state, { ...options, _path: [...path, i] }));
|
|
2055
|
-
}
|
|
2056
|
-
});
|
|
2057
|
-
} finally {
|
|
2058
|
-
resetTracking();
|
|
2059
|
-
}
|
|
2438
|
+
live = effectScope(true);
|
|
2439
|
+
drawTemplateNodes(live, nodes, state, options, (node) => {
|
|
2440
|
+
el.append(node);
|
|
2441
|
+
});
|
|
2060
2442
|
},
|
|
2061
2443
|
retire,
|
|
2062
2444
|
);
|
|
2063
2445
|
onScopeDispose(() => live?.stop(), true);
|
|
2064
2446
|
}
|
|
2065
2447
|
|
|
2448
|
+
/**
|
|
2449
|
+
* Render the members of a template's array value, each handed to `place` as it is made.
|
|
2450
|
+
*
|
|
2451
|
+
* The members render in `scope`, a detached scope the caller owns and stops when the value changes,
|
|
2452
|
+
* and with tracking paused for the reason {@link renderSwitch} gives: the bindings inside track for
|
|
2453
|
+
* themselves. A member is rendered as a child definition whatever it is, the reading the site
|
|
2454
|
+
* build's template pass gives it: an object is an element, anything else is text. A member that is
|
|
2455
|
+
* itself a template string is bound as text and never spliced a second time, which is also what the
|
|
2456
|
+
* build does (it steps over what it spliced in).
|
|
2457
|
+
*
|
|
2458
|
+
* Each member renders at `[…options._path, index]`: the template's own position, then the member's
|
|
2459
|
+
* place in the value. That is not a path into the document, which holds a string there; it is what
|
|
2460
|
+
* `onNodeCreated` reports for the member, and it cannot collide with a sibling's path.
|
|
2461
|
+
*
|
|
2462
|
+
* @param {EffectScope} scope - Owns every effect the members create
|
|
2463
|
+
* @param {readonly JxScopeValue[]} nodes - The template's value
|
|
2464
|
+
* @param {JxScope} state
|
|
2465
|
+
* @param {JxRenderOptions | undefined} options - `_path` is the template's own position
|
|
2466
|
+
* @param {(node: HTMLElement | Text) => void} place - Puts a rendered member in the DOM
|
|
2467
|
+
*/
|
|
2468
|
+
function drawTemplateNodes(
|
|
2469
|
+
scope: EffectScope,
|
|
2470
|
+
nodes: readonly JxScopeValue[],
|
|
2471
|
+
state: JxScope,
|
|
2472
|
+
options: JxRenderOptions | undefined,
|
|
2473
|
+
place: (node: HTMLElement | Text) => void,
|
|
2474
|
+
): void {
|
|
2475
|
+
const path = options?._path ?? [];
|
|
2476
|
+
/* Read HERE, tracked, before tracking is paused: the template's own read reaches the array and
|
|
2477
|
+
no further, so an array changed in place (a `push`, an index write, a `length` of zero) would
|
|
2478
|
+
otherwise leave what was drawn standing. The same string as text read every member when it
|
|
2479
|
+
joined them, and a list that stopped following its array would be a regression against that.
|
|
2480
|
+
SAFETY: a template that yields an array stands for the children themselves, the same reading
|
|
2481
|
+
the site build's template pass gives it. */
|
|
2482
|
+
const members = [...(nodes as readonly (JxElement | string)[])];
|
|
2483
|
+
pauseTracking();
|
|
2484
|
+
try {
|
|
2485
|
+
scope.run(() => {
|
|
2486
|
+
for (const [i, child] of members.entries()) {
|
|
2487
|
+
place(renderNode(child, state, options ? { ...options, _path: [...path, i] } : undefined));
|
|
2488
|
+
}
|
|
2489
|
+
});
|
|
2490
|
+
} finally {
|
|
2491
|
+
resetTracking();
|
|
2492
|
+
}
|
|
2493
|
+
}
|
|
2494
|
+
|
|
2495
|
+
/**
|
|
2496
|
+
* Render a string member of a `children` array that is one whole `${…}` (spec.md §8.4).
|
|
2497
|
+
*
|
|
2498
|
+
* Such a member is text unless its value is an array, and then it stands for the nodes the array
|
|
2499
|
+
* holds: the site build's template pass (`resolveDocTemplates`) splices them in where the string
|
|
2500
|
+
* stood, and this draws the same thing so a document reads alike prerendered and rendered live. The
|
|
2501
|
+
* build's rule is the whole rule: ANY array is spliced, every member of it (an object as an
|
|
2502
|
+
* element, anything else as text, mixed freely, an empty array as nothing), and a value that is not
|
|
2503
|
+
* an array leaves the string a text child. Only a single-expression template can yield an array at
|
|
2504
|
+
* all, so every other string child takes the plain text path in {@link renderNode} untouched.
|
|
2505
|
+
*
|
|
2506
|
+
* One Text node is always appended, exactly as before, and it is the stable anchor. While the value
|
|
2507
|
+
* is text it holds the text. While the value is an array it is empty and the spliced nodes stand
|
|
2508
|
+
* immediately before it, so a change replaces that range and nothing else: siblings on either side
|
|
2509
|
+
* are never touched, and a value that turns from an array into text (or back) needs no second
|
|
2510
|
+
* anchor. The spliced nodes own a detached scope that is stopped when the value changes.
|
|
2511
|
+
*
|
|
2512
|
+
* @param {Element} parent
|
|
2513
|
+
* @param {string} template - A single-expression template
|
|
2514
|
+
* @param {JxScope} state
|
|
2515
|
+
* @param {JxRenderOptions | undefined} options - `_path` is the string's own `children` position
|
|
2516
|
+
*/
|
|
2517
|
+
function renderTemplatedChild(
|
|
2518
|
+
parent: Element,
|
|
2519
|
+
template: string,
|
|
2520
|
+
state: JxScope,
|
|
2521
|
+
options: JxRenderOptions | undefined,
|
|
2522
|
+
): void {
|
|
2523
|
+
const anchor = document.createTextNode(template);
|
|
2524
|
+
parent.append(anchor);
|
|
2525
|
+
let live: EffectScope | null = null;
|
|
2526
|
+
let drawn: (HTMLElement | Text)[] = [];
|
|
2527
|
+
const retire = (): void => {
|
|
2528
|
+
// Detached before it is stopped, for the reason `teardown` in `renderMappedArray` gives.
|
|
2529
|
+
for (const node of drawn) {
|
|
2530
|
+
node.remove();
|
|
2531
|
+
}
|
|
2532
|
+
drawn = [];
|
|
2533
|
+
live?.stop();
|
|
2534
|
+
live = null;
|
|
2535
|
+
};
|
|
2536
|
+
bindTemplate(
|
|
2537
|
+
template,
|
|
2538
|
+
() => {
|
|
2539
|
+
const value = evaluateValueTemplate(template, state);
|
|
2540
|
+
retire();
|
|
2541
|
+
if (!Array.isArray(value)) {
|
|
2542
|
+
anchor.textContent = templateText(value);
|
|
2543
|
+
return;
|
|
2544
|
+
}
|
|
2545
|
+
anchor.textContent = "";
|
|
2546
|
+
live = effectScope(true);
|
|
2547
|
+
drawTemplateNodes(live, value, state, options, (node) => {
|
|
2548
|
+
drawn.push(node);
|
|
2549
|
+
anchor.before(node);
|
|
2550
|
+
});
|
|
2551
|
+
},
|
|
2552
|
+
() => {
|
|
2553
|
+
retire();
|
|
2554
|
+
anchor.textContent = "";
|
|
2555
|
+
},
|
|
2556
|
+
);
|
|
2557
|
+
onScopeDispose(() => live?.stop(), true);
|
|
2558
|
+
}
|
|
2559
|
+
|
|
2560
|
+
/**
|
|
2561
|
+
* A single expression's value as the text its template interpolates to: a template literal's own
|
|
2562
|
+
* conversion, so a Symbol still throws where `String()` would print it.
|
|
2563
|
+
*/
|
|
2564
|
+
function templateText(value: JxScopeValue): string {
|
|
2565
|
+
/* SAFETY: not a claim that the value is a string. A template literal converts whatever it is
|
|
2566
|
+
handed, and the cast only names a type the literal accepts without a lint exemption. */
|
|
2567
|
+
return `${value as string}`;
|
|
2568
|
+
}
|
|
2569
|
+
|
|
2066
2570
|
// ─── Template string utilities ────────────────────────────────────────────────
|
|
2067
2571
|
|
|
2068
2572
|
/**
|
|
@@ -2121,6 +2625,11 @@ function bindHandler(
|
|
|
2121
2625
|
}
|
|
2122
2626
|
return true;
|
|
2123
2627
|
}
|
|
2628
|
+
if (isFunctionDef(val) && declaresBodyAndSrc(val)) {
|
|
2629
|
+
throw new Error(
|
|
2630
|
+
`Jx: inline '${key}' handler declares both body and $src — these are mutually exclusive`,
|
|
2631
|
+
);
|
|
2632
|
+
}
|
|
2124
2633
|
if (hasStructuredBody(val)) {
|
|
2125
2634
|
const { body } = val;
|
|
2126
2635
|
el.addEventListener(type, (e) => {
|
|
@@ -2129,10 +2638,10 @@ function bindHandler(
|
|
|
2129
2638
|
return true;
|
|
2130
2639
|
}
|
|
2131
2640
|
if (isFunctionDef(val) && isString(val.body)) {
|
|
2132
|
-
const params =
|
|
2641
|
+
const params = handlerParamNames(val);
|
|
2133
2642
|
/* SAFETY: `new Function` returns an ordinary function of the named parameters, `state` first;
|
|
2134
2643
|
the listener calls it with the scope and the event, and discards what it returns. */
|
|
2135
|
-
const fn =
|
|
2644
|
+
const fn = compileBody(params, val.body, `the ${type} handler`) as JxEventHandler;
|
|
2136
2645
|
el.addEventListener(type, (e) => fn(scope, e));
|
|
2137
2646
|
return true;
|
|
2138
2647
|
}
|
|
@@ -2931,12 +3440,19 @@ function applyStyleInto(
|
|
|
2931
3440
|
const scope = effectScope();
|
|
2932
3441
|
scope.run(() => {
|
|
2933
3442
|
for (const { name, value } of sources) {
|
|
2934
|
-
|
|
2935
|
-
const resolved = isRefValue(value)
|
|
2936
|
-
? resolveRef(value.$ref, state)
|
|
2937
|
-
: evaluateTemplate(value, state);
|
|
3443
|
+
const write = (resolved: JxScopeValue): void => {
|
|
2938
3444
|
el.style.setProperty(name, canvasStyleValue(String(resolved ?? "")));
|
|
2939
|
-
}
|
|
3445
|
+
};
|
|
3446
|
+
if (isRefValue(value)) {
|
|
3447
|
+
effect(() => write(resolveRef(value.$ref, state)));
|
|
3448
|
+
} else {
|
|
3449
|
+
/* Survives its expression throwing, as every other template binding does (§6.1): the
|
|
3450
|
+
variable is left unwritten, so the declaration that reads it has no value and the
|
|
3451
|
+
cascade answers, and the binding re-runs when what it read changes. Unguarded, one
|
|
3452
|
+
`${state.theme.accent}` read before `theme` arrived, or one `${item.data.tint}` on a
|
|
3453
|
+
row with no `data`, threw out of the render and left the page blank. */
|
|
3454
|
+
bindTemplate(value, () => write(evaluateTemplate(value, state)));
|
|
3455
|
+
}
|
|
2940
3456
|
}
|
|
2941
3457
|
});
|
|
2942
3458
|
entry.stop = () => scope.stop();
|
|
@@ -3080,7 +3596,13 @@ function applyAttributes(el: HTMLElement, attrs: Record<string, JxAttributeValue
|
|
|
3080
3596
|
*
|
|
3081
3597
|
* `options._path` is the array node's own document path (`[…, "children", i]`, or `[…, "children"]`
|
|
3082
3598
|
* for a legacy whole-children repeater); item instances render at `[…that…, "map", index]`, and a
|
|
3083
|
-
* row that moves reports its new path through `options.onNodeMoved
|
|
3599
|
+
* row that moves reports its new path through `options.onNodeMoved`: once, for the row's root. The
|
|
3600
|
+
* nodes inside the row are not reported, though each one's path begins with the row's and so
|
|
3601
|
+
* changed with it.
|
|
3602
|
+
*
|
|
3603
|
+
* Three options exist for an editor and do nothing unless a host sets them (`JxRenderOptions`):
|
|
3604
|
+
* `rowLimit` caps the rows drawn, `emptyRow` supplies one row for a list with none, and
|
|
3605
|
+
* `onListReconciled` reports what each reconciliation drew.
|
|
3084
3606
|
*
|
|
3085
3607
|
* @param {HTMLElement} parentEl
|
|
3086
3608
|
* @param {import("@jxsuite/schema/types").JxMappedArray} arrayDef
|
|
@@ -3103,6 +3625,8 @@ function renderMappedArrayInto(
|
|
|
3103
3625
|
re-run, and the instance it holds must connect under the chain of the definition that drew
|
|
3104
3626
|
the list. */
|
|
3105
3627
|
const instancePath = _instancePath;
|
|
3628
|
+
// And whether it is drawn inside a stamped host, which a later row must be drawn as too.
|
|
3629
|
+
const insideStamped = _canvasInsideStampedHost;
|
|
3106
3630
|
|
|
3107
3631
|
let warned = false;
|
|
3108
3632
|
const warnOnce = (message: string): void => {
|
|
@@ -3138,7 +3662,9 @@ function renderMappedArrayInto(
|
|
|
3138
3662
|
const scope = effectScope(true);
|
|
3139
3663
|
const childOpts = options ? { ...options, _path: [...path, "map", index] } : undefined;
|
|
3140
3664
|
const node = scope.run(() => renderNode(mapDef!, child, childOpts))!;
|
|
3141
|
-
|
|
3665
|
+
const row: MappedRow = { key, map, node, scope };
|
|
3666
|
+
listRows.set(node, row);
|
|
3667
|
+
return row;
|
|
3142
3668
|
};
|
|
3143
3669
|
|
|
3144
3670
|
const reconcile = (list: readonly JxScopeValue[], keys: readonly MappedRow["key"][]): void => {
|
|
@@ -3204,10 +3730,27 @@ function renderMappedArrayInto(
|
|
|
3204
3730
|
items = [...unsorted].toSorted(compare);
|
|
3205
3731
|
}
|
|
3206
3732
|
}
|
|
3207
|
-
|
|
3733
|
+
let list: readonly JxScopeValue[] = Array.isArray(items) && mapDef ? items : [];
|
|
3734
|
+
const count = list.length;
|
|
3735
|
+
|
|
3736
|
+
/* The authoring hooks (`JxRenderOptions`), both absent unless a host names them. A list with
|
|
3737
|
+
nothing to draw may be given one row to stand for it, and a long one may be cut short. The
|
|
3738
|
+
placeholder is keyed by a value no item can be, so it is never taken for a real row: it is
|
|
3739
|
+
torn down when the list fills and rebuilt when it empties. */
|
|
3740
|
+
const placeholder = count === 0 && mapDef ? options?.emptyRow?.(arrayDef, path) : undefined;
|
|
3741
|
+
if (placeholder !== undefined) {
|
|
3742
|
+
list = [placeholder];
|
|
3743
|
+
}
|
|
3744
|
+
const limit = options?.rowLimit;
|
|
3745
|
+
if (isNumber(limit) && limit >= 0 && list.length > limit) {
|
|
3746
|
+
list = list.slice(0, Math.floor(limit));
|
|
3747
|
+
}
|
|
3208
3748
|
|
|
3209
3749
|
// Keys are read HERE, tracked, so a key that changes reconciles the list.
|
|
3210
|
-
const keys =
|
|
3750
|
+
const keys =
|
|
3751
|
+
placeholder === undefined
|
|
3752
|
+
? list.map((item, index) => mappedRowKey(keyPointer, item, index, warnOnce))
|
|
3753
|
+
: list.map(() => EMPTY_ROW_KEY);
|
|
3211
3754
|
const seen = new Set<MappedRow["key"]>();
|
|
3212
3755
|
for (let i = 0; i < keys.length; i++) {
|
|
3213
3756
|
if (seen.has(keys[i])) {
|
|
@@ -3222,7 +3765,15 @@ function renderMappedArrayInto(
|
|
|
3222
3765
|
|
|
3223
3766
|
pauseTracking();
|
|
3224
3767
|
try {
|
|
3225
|
-
underInstancePath(reexpand(instancePath), () =>
|
|
3768
|
+
underInstancePath(reexpand(instancePath), () =>
|
|
3769
|
+
insideStampedHost(insideStamped, () => reconcile(list, keys)),
|
|
3770
|
+
);
|
|
3771
|
+
options?.onListReconciled?.(path, {
|
|
3772
|
+
count,
|
|
3773
|
+
empty: count === 0,
|
|
3774
|
+
nodes: rows.map((row) => row.node),
|
|
3775
|
+
rendered: rows.length,
|
|
3776
|
+
});
|
|
3226
3777
|
} finally {
|
|
3227
3778
|
resetTracking();
|
|
3228
3779
|
}
|
|
@@ -3238,6 +3789,7 @@ function renderMappedArrayInto(
|
|
|
3238
3789
|
let stopped = false;
|
|
3239
3790
|
const runner = effect(update, {
|
|
3240
3791
|
scheduler: () => {
|
|
3792
|
+
_listRedraws += 1;
|
|
3241
3793
|
if (queued) {
|
|
3242
3794
|
return;
|
|
3243
3795
|
}
|
|
@@ -3260,13 +3812,58 @@ function renderMappedArrayInto(
|
|
|
3260
3812
|
}, true);
|
|
3261
3813
|
}
|
|
3262
3814
|
|
|
3815
|
+
/** The key of the one row `emptyRow` supplies: an object, so no item's key can equal it. */
|
|
3816
|
+
const EMPTY_ROW_KEY: MappedRow["key"] = Object.freeze({ emptyRow: true });
|
|
3817
|
+
|
|
3263
3818
|
/** A rendered row of a mapped array. */
|
|
3264
3819
|
interface MappedRow {
|
|
3265
3820
|
key: JxScopeValue;
|
|
3266
3821
|
/** The row's `$map` context; `item` and `index` are written in place when the row is reused. */
|
|
3267
3822
|
map: JxRowContext;
|
|
3268
3823
|
node: HTMLElement | Text;
|
|
3269
|
-
|
|
3824
|
+
/** What stops the row's effects: its own scope, or the disposer a host handed over with a node. */
|
|
3825
|
+
scope: Pick<EffectScope, "stop">;
|
|
3826
|
+
}
|
|
3827
|
+
|
|
3828
|
+
/** The row each rendered row node stands for, so a host can hand its list another node for it. */
|
|
3829
|
+
const listRows = new WeakMap<Node, MappedRow>();
|
|
3830
|
+
|
|
3831
|
+
/**
|
|
3832
|
+
* Put `next` where the mapped-list row `current` stands, and make it that row's node from now on.
|
|
3833
|
+
* Answers whether `current` was a row's node; when it was not, nothing is touched.
|
|
3834
|
+
*
|
|
3835
|
+
* An authoring interface, beside {@link updateInstanceProps}: a host that edits a document draws a
|
|
3836
|
+
* changed template again for a row that is already on screen, with the row's own scope (the one
|
|
3837
|
+
* `onNodeCreated` reported for the old node). The list holds each row by its node, moves that node
|
|
3838
|
+
* when the row is reused at another index and removes it when the row goes, so a node swapped in
|
|
3839
|
+
* behind its back would be left standing when the list next redrew and the old one put back. Told
|
|
3840
|
+
* here, the list moves and removes the new node instead. The row keeps its key and its `$map`
|
|
3841
|
+
* record, so nothing is reconciled and no other row is touched.
|
|
3842
|
+
*
|
|
3843
|
+
* The old node's effects are stopped, after it has left the document for the reason `teardown` in
|
|
3844
|
+
* {@link renderMappedArrayInto} gives. `stop` is what ends the new node's effects when the row is
|
|
3845
|
+
* dropped or the list is torn down; the host created them, so only it can say how.
|
|
3846
|
+
*
|
|
3847
|
+
* @param current A node the list drew as a row's root.
|
|
3848
|
+
* @param next The node to stand there instead, rendered by the host.
|
|
3849
|
+
* @param [stop] Called once when the row is removed.
|
|
3850
|
+
*/
|
|
3851
|
+
export function replaceListRow(
|
|
3852
|
+
current: Node,
|
|
3853
|
+
next: HTMLElement | Text,
|
|
3854
|
+
stop?: () => void,
|
|
3855
|
+
): boolean {
|
|
3856
|
+
const row = listRows.get(current);
|
|
3857
|
+
if (!row || row.node !== current) {
|
|
3858
|
+
return false;
|
|
3859
|
+
}
|
|
3860
|
+
row.node.replaceWith(next);
|
|
3861
|
+
row.scope.stop();
|
|
3862
|
+
listRows.delete(current);
|
|
3863
|
+
row.node = next;
|
|
3864
|
+
row.scope = { stop: () => stop?.() };
|
|
3865
|
+
listRows.set(next, row);
|
|
3866
|
+
return true;
|
|
3270
3867
|
}
|
|
3271
3868
|
|
|
3272
3869
|
/**
|
|
@@ -3342,6 +3939,8 @@ function renderSwitch(def: JxElement, state: JxScope, options?: JxRenderOptions)
|
|
|
3342
3939
|
around the insertion, so an instance the new case creates connects under the chain of the
|
|
3343
3940
|
definition that drew the switch rather than under none. See {@link _instancePath}. */
|
|
3344
3941
|
const instancePath = _instancePath;
|
|
3942
|
+
// And whether it is drawn inside a stamped host, which a later case must be drawn as too.
|
|
3943
|
+
const insideStamped = _canvasInsideStampedHost;
|
|
3345
3944
|
|
|
3346
3945
|
if (options?.onNodeCreated) {
|
|
3347
3946
|
options.onNodeCreated(container, path, def, state);
|
|
@@ -3405,13 +4004,16 @@ function renderSwitch(def: JxElement, state: JxScope, options?: JxRenderOptions)
|
|
|
3405
4004
|
if (gen !== generation) {
|
|
3406
4005
|
return;
|
|
3407
4006
|
}
|
|
3408
|
-
|
|
4007
|
+
// An external case is a component boundary, so it inherits what a component does (§13.4).
|
|
4008
|
+
const childScope = await buildScope(withProjectFormulas(doc), {}, href, options?._ctx);
|
|
3409
4009
|
if (gen !== generation) {
|
|
3410
4010
|
return;
|
|
3411
4011
|
}
|
|
3412
4012
|
container.replaceChildren();
|
|
3413
4013
|
underInstancePath(reexpand(instancePath), () =>
|
|
3414
|
-
|
|
4014
|
+
insideStampedHost(insideStamped, () =>
|
|
4015
|
+
scope.run(() => container.append(renderNode(doc, childScope, childOpts))),
|
|
4016
|
+
),
|
|
3415
4017
|
);
|
|
3416
4018
|
})
|
|
3417
4019
|
.catch((error) =>
|
|
@@ -3426,7 +4028,9 @@ function renderSwitch(def: JxElement, state: JxScope, options?: JxRenderOptions)
|
|
|
3426
4028
|
pauseTracking();
|
|
3427
4029
|
try {
|
|
3428
4030
|
underInstancePath(reexpand(instancePath), () =>
|
|
3429
|
-
|
|
4031
|
+
insideStampedHost(insideStamped, () =>
|
|
4032
|
+
scope.run(() => container.append(renderNode(caseDef, state, childOpts))),
|
|
4033
|
+
),
|
|
3430
4034
|
);
|
|
3431
4035
|
} finally {
|
|
3432
4036
|
resetTracking();
|
|
@@ -3498,22 +4102,59 @@ export async function resolvePrototype(
|
|
|
3498
4102
|
}
|
|
3499
4103
|
});
|
|
3500
4104
|
|
|
4105
|
+
/** The fetch itself. With a signal it dies with this binding; without, it is a host's. */
|
|
4106
|
+
const request = (signal?: AbortSignal): Promise<JsonValue> =>
|
|
4107
|
+
fetch(url, {
|
|
4108
|
+
method: def.method ?? "GET",
|
|
4109
|
+
...(signal && { signal }),
|
|
4110
|
+
...(def.headers && { headers: def.headers }),
|
|
4111
|
+
...(def.body && {
|
|
4112
|
+
body: isNonNullObject(def.body) ? JSON.stringify(def.body) : def.body,
|
|
4113
|
+
}),
|
|
4114
|
+
}).then((r): Promise<JsonValue> => (r.ok ? r.json() : Promise.reject(r.statusText)));
|
|
4115
|
+
|
|
3501
4116
|
const doFetch = () => {
|
|
4117
|
+
const handler = _remoteLookupHandler;
|
|
4118
|
+
let lookup: Promise<JsonValue>;
|
|
4119
|
+
if (handler) {
|
|
4120
|
+
const answer = handler(
|
|
4121
|
+
{
|
|
4122
|
+
key,
|
|
4123
|
+
kind: "request",
|
|
4124
|
+
request: {
|
|
4125
|
+
method: def.method ?? "GET",
|
|
4126
|
+
url,
|
|
4127
|
+
...(def.headers && { headers: def.headers }),
|
|
4128
|
+
...(def.body !== undefined && { body: def.body }),
|
|
4129
|
+
},
|
|
4130
|
+
scope: state,
|
|
4131
|
+
},
|
|
4132
|
+
() => request(),
|
|
4133
|
+
);
|
|
4134
|
+
if (answer === null) {
|
|
4135
|
+
return;
|
|
4136
|
+
}
|
|
4137
|
+
if (!(answer instanceof Promise)) {
|
|
4138
|
+
s.value = answer.value;
|
|
4139
|
+
return;
|
|
4140
|
+
}
|
|
4141
|
+
lookup = answer;
|
|
4142
|
+
} else {
|
|
4143
|
+
lookup = request(controller.signal);
|
|
4144
|
+
}
|
|
3502
4145
|
holdTemplateErrorsFor(
|
|
3503
|
-
|
|
3504
|
-
method: def.method ?? "GET",
|
|
3505
|
-
signal: controller.signal,
|
|
3506
|
-
...(def.headers && { headers: def.headers }),
|
|
3507
|
-
...(def.body && {
|
|
3508
|
-
body: isNonNullObject(def.body) ? JSON.stringify(def.body) : def.body,
|
|
3509
|
-
}),
|
|
3510
|
-
})
|
|
3511
|
-
.then((r): Promise<JsonValue> => (r.ok ? r.json() : Promise.reject(r.statusText)))
|
|
4146
|
+
lookup
|
|
3512
4147
|
.then((d) => {
|
|
3513
|
-
|
|
4148
|
+
// An answer a host kept may arrive for a binding that is gone: not written.
|
|
4149
|
+
if (!controller.signal.aborted) {
|
|
4150
|
+
s.value = d;
|
|
4151
|
+
}
|
|
3514
4152
|
})
|
|
3515
4153
|
.catch((error) => {
|
|
3516
|
-
if (
|
|
4154
|
+
if (
|
|
4155
|
+
!controller.signal.aborted &&
|
|
4156
|
+
!(error instanceof Error && error.name === "AbortError")
|
|
4157
|
+
) {
|
|
3517
4158
|
s.value = { error: String(error) };
|
|
3518
4159
|
}
|
|
3519
4160
|
}),
|
|
@@ -3867,6 +4508,7 @@ async function resolveClassJson(def: JxPrototypeDef, state: JxScope, key: string
|
|
|
3867
4508
|
// Fall back to dev proxy (server will handle .class.json resolution)
|
|
3868
4509
|
return resolveViaDevProxy(def, state, key, base);
|
|
3869
4510
|
}
|
|
4511
|
+
await prepareBodies(classDef);
|
|
3870
4512
|
|
|
3871
4513
|
// Hybrid mode: $implementation points to the real JS module
|
|
3872
4514
|
if (classDef.$implementation) {
|
|
@@ -3934,6 +4576,7 @@ function classFromSchema(classDef: JxClassDef) {
|
|
|
3934
4576
|
const rawCtor = classDef.$defs?.constructor;
|
|
3935
4577
|
const ctor = isCtorDef(rawCtor) ? rawCtor : undefined;
|
|
3936
4578
|
const methods = classDef.$defs?.methods ?? {};
|
|
4579
|
+
const className = classDef.title ?? "a class";
|
|
3937
4580
|
|
|
3938
4581
|
// oxlint-disable-next-line typescript/no-extraneous-class -- methods are attached to the prototype dynamically below
|
|
3939
4582
|
class DynClass {
|
|
@@ -3961,7 +4604,10 @@ function classFromSchema(classDef: JxClassDef) {
|
|
|
3961
4604
|
/* SAFETY: `new Function` returns an ordinary function of the one named parameter; `call`
|
|
3962
4605
|
runs it with the instance under construction as `this`, which is the constructor
|
|
3963
4606
|
body's contract. */
|
|
3964
|
-
const init =
|
|
4607
|
+
const init = compileBody(["config"], bodyStr, `the constructor of ${className}`) as (
|
|
4608
|
+
this: DynClass,
|
|
4609
|
+
config: JxScope,
|
|
4610
|
+
) => void;
|
|
3965
4611
|
init.call(this, config);
|
|
3966
4612
|
}
|
|
3967
4613
|
}
|
|
@@ -3986,7 +4632,11 @@ function classFromSchema(classDef: JxClassDef) {
|
|
|
3986
4632
|
if (typedMethod.getter) {
|
|
3987
4633
|
/* SAFETY: `new Function` returns an ordinary function; as a getter it is called with no
|
|
3988
4634
|
arguments and the instance as `this`, and what it returns is the property's value. */
|
|
3989
|
-
descriptor.get =
|
|
4635
|
+
descriptor.get = compileBody(
|
|
4636
|
+
[],
|
|
4637
|
+
typedMethod.getter.body ?? "",
|
|
4638
|
+
`the getter ${name} of ${className}`,
|
|
4639
|
+
) as () => JxScopeValue;
|
|
3990
4640
|
}
|
|
3991
4641
|
if (typedMethod.setter) {
|
|
3992
4642
|
const sp = (typedMethod.setter.parameters ?? []).map(
|
|
@@ -3994,18 +4644,20 @@ function classFromSchema(classDef: JxClassDef) {
|
|
|
3994
4644
|
);
|
|
3995
4645
|
/* SAFETY: `new Function` returns an ordinary function of the named parameters; as a setter
|
|
3996
4646
|
it receives the assigned value as its one argument, and its return is discarded. */
|
|
3997
|
-
descriptor.set =
|
|
3998
|
-
|
|
3999
|
-
|
|
4647
|
+
descriptor.set = compileBody(
|
|
4648
|
+
sp,
|
|
4649
|
+
typedMethod.setter.body ?? "",
|
|
4650
|
+
`the setter ${name} of ${className}`,
|
|
4651
|
+
) as (v: JxScopeValue) => void;
|
|
4000
4652
|
}
|
|
4001
4653
|
Object.defineProperty(DynClass.prototype, name, {
|
|
4002
4654
|
...descriptor,
|
|
4003
4655
|
configurable: true,
|
|
4004
4656
|
});
|
|
4005
4657
|
} else if (typedMethod.scope === "static") {
|
|
4006
|
-
DynClass[name] =
|
|
4658
|
+
DynClass[name] = compileBody(params, bodyStr, `${name} of ${className}`);
|
|
4007
4659
|
} else {
|
|
4008
|
-
DynClass.prototype[name] =
|
|
4660
|
+
DynClass.prototype[name] = compileBody(params, bodyStr, `${name} of ${className}`);
|
|
4009
4661
|
}
|
|
4010
4662
|
}
|
|
4011
4663
|
|
|
@@ -4098,6 +4750,7 @@ function latestServerCall(
|
|
|
4098
4750
|
s: Ref<JxScopeValue>,
|
|
4099
4751
|
invoke: ServerCall,
|
|
4100
4752
|
label: string,
|
|
4753
|
+
describe: (args: JxScope) => RemoteLookup,
|
|
4101
4754
|
): (args: JxScope) => void {
|
|
4102
4755
|
let seq = 0;
|
|
4103
4756
|
return (args: JxScope) => {
|
|
@@ -4105,17 +4758,29 @@ function latestServerCall(
|
|
|
4105
4758
|
const n = seq;
|
|
4106
4759
|
/* The executor runs before the constructor returns, so the call still starts synchronously,
|
|
4107
4760
|
and a throw inside it rejects this promise instead of escaping to the caller. */
|
|
4108
|
-
|
|
4761
|
+
const run = (): Promise<JsonValue> =>
|
|
4109
4762
|
new Promise<JsonValue>((settle) => {
|
|
4110
4763
|
settle(invoke(args));
|
|
4111
|
-
})
|
|
4764
|
+
});
|
|
4765
|
+
const answer = _remoteLookupHandler ? _remoteLookupHandler(describe(args), run) : run();
|
|
4766
|
+
if (answer === null) {
|
|
4767
|
+
return;
|
|
4768
|
+
}
|
|
4769
|
+
if (!(answer instanceof Promise)) {
|
|
4770
|
+
s.value = answer.value;
|
|
4771
|
+
return;
|
|
4772
|
+
}
|
|
4773
|
+
holdTemplateErrorsFor(
|
|
4774
|
+
answer
|
|
4112
4775
|
.then((result) => {
|
|
4113
4776
|
if (n === seq) {
|
|
4114
4777
|
s.value = result;
|
|
4115
4778
|
}
|
|
4116
4779
|
})
|
|
4117
4780
|
.catch((error) => {
|
|
4118
|
-
|
|
4781
|
+
/* An abort is not a failure: it is how a host that stood for the lookup says nobody is
|
|
4782
|
+
waiting for it any more (a wait it ended), as it is for a request (embedding.md §5.2). */
|
|
4783
|
+
if (n === seq && !(error instanceof Error && error.name === "AbortError")) {
|
|
4119
4784
|
console.error(label, error);
|
|
4120
4785
|
}
|
|
4121
4786
|
}),
|
|
@@ -4123,6 +4788,32 @@ function latestServerCall(
|
|
|
4123
4788
|
};
|
|
4124
4789
|
}
|
|
4125
4790
|
|
|
4791
|
+
/**
|
|
4792
|
+
* What one call of a `timing: "server"` entry asks for, as a host's {@link RemoteLookupHandler} is
|
|
4793
|
+
* told it.
|
|
4794
|
+
*/
|
|
4795
|
+
function serverLookup(
|
|
4796
|
+
def: JxServerFnDef,
|
|
4797
|
+
state: JxScope,
|
|
4798
|
+
key: string,
|
|
4799
|
+
args: JxScope,
|
|
4800
|
+
base?: string,
|
|
4801
|
+
): RemoteLookup {
|
|
4802
|
+
return {
|
|
4803
|
+
key,
|
|
4804
|
+
kind: "server",
|
|
4805
|
+
/* SAFETY: the arguments cross the proxy as JSON, so a server function's are JSON by contract;
|
|
4806
|
+
a value that is not is the call's own failure when it is serialized, as it always was. */
|
|
4807
|
+
request: {
|
|
4808
|
+
$export: def.$export,
|
|
4809
|
+
$src: def.$src,
|
|
4810
|
+
arguments: args,
|
|
4811
|
+
...(base !== undefined && { $base: base }),
|
|
4812
|
+
} as JsonObject,
|
|
4813
|
+
scope: state,
|
|
4814
|
+
};
|
|
4815
|
+
}
|
|
4816
|
+
|
|
4126
4817
|
/**
|
|
4127
4818
|
* One call of a `timing: "server"` function, with its resolved arguments. Its answer is JSON, or a
|
|
4128
4819
|
* promise of JSON: on the proxy path the answer crosses the wire as JSON, and a host-registered
|
|
@@ -4186,7 +4877,9 @@ async function resolveServerFunction(
|
|
|
4186
4877
|
// Always wrap in ref for reactivity
|
|
4187
4878
|
const s: Ref<JxScopeValue> = ref(null);
|
|
4188
4879
|
if (hasReactiveArg) {
|
|
4189
|
-
const call = latestServerCall(s, fn, "Jx server function:")
|
|
4880
|
+
const call = latestServerCall(s, fn, "Jx server function:", (args) =>
|
|
4881
|
+
serverLookup(def, state, key, args, base),
|
|
4882
|
+
);
|
|
4190
4883
|
effect(() => {
|
|
4191
4884
|
const args = resolveArgs();
|
|
4192
4885
|
onEffectCleanup(() => {});
|
|
@@ -4250,7 +4943,9 @@ async function resolveServerFunctionViaProxy(
|
|
|
4250
4943
|
|
|
4251
4944
|
// Always wrap in ref for reactivity
|
|
4252
4945
|
const s: Ref<JxScopeValue> = ref(null);
|
|
4253
|
-
const call = latestServerCall(s, doResolve, "Jx server proxy:")
|
|
4946
|
+
const call = latestServerCall(s, doResolve, "Jx server proxy:", (args) =>
|
|
4947
|
+
serverLookup(def, state, key, args, base),
|
|
4948
|
+
);
|
|
4254
4949
|
if (hasReactiveArg) {
|
|
4255
4950
|
effect(() => {
|
|
4256
4951
|
const args = resolveArgs();
|
|
@@ -4594,6 +5289,50 @@ export async function redefineElement(doc: JxDocument, baseUrl?: string): Promis
|
|
|
4594
5289
|
_elementDefs.set(tagName, { base, doc });
|
|
4595
5290
|
}
|
|
4596
5291
|
|
|
5292
|
+
/** The project formulas every component scope in this realm inherits (spec.md §13.4). */
|
|
5293
|
+
let _projectFormulas: Record<string, JxStateDefinition> = {};
|
|
5294
|
+
/** A definition with the inherited formulas merged in, built once per definition. */
|
|
5295
|
+
let _withFormulas = new WeakMap<JxDocument, JxDocument>();
|
|
5296
|
+
|
|
5297
|
+
/**
|
|
5298
|
+
* Give this realm its project's `state`, from which a component inherits the pure named formulas
|
|
5299
|
+
* (spec.md §13.4, §19.4c).
|
|
5300
|
+
*
|
|
5301
|
+
* A page receives project state merged into its own document, but a component's document is its own
|
|
5302
|
+
* scope and is fetched as written, so the host that knows the project says so once: the site shell
|
|
5303
|
+
* before it renders the page, Studio before it draws a canvas. Only closed pure formulas are taken
|
|
5304
|
+
* (`inheritableFormulas`); data state never crosses. Realm-wide because the custom element registry
|
|
5305
|
+
* is: one tag has one definition per realm, whatever mounted it.
|
|
5306
|
+
*
|
|
5307
|
+
* @param {Record<string, JxStateDefinition> | null} [projectState] - `project.json` `state`; absent
|
|
5308
|
+
* or null clears it, and a component is then exactly its own document
|
|
5309
|
+
*/
|
|
5310
|
+
export function setProjectFormulas(projectState?: Record<string, JxStateDefinition> | null): void {
|
|
5311
|
+
_projectFormulas = inheritableFormulas(projectState);
|
|
5312
|
+
_withFormulas = new WeakMap();
|
|
5313
|
+
}
|
|
5314
|
+
|
|
5315
|
+
/**
|
|
5316
|
+
* A component document as its scope is built: the inherited formulas first, then its own `state`,
|
|
5317
|
+
* so an entry the component declares shadows a project formula of that name. The document itself
|
|
5318
|
+
* when the project gives it nothing.
|
|
5319
|
+
*
|
|
5320
|
+
* @param {JxDocument} doc
|
|
5321
|
+
* @returns {JxDocument}
|
|
5322
|
+
*/
|
|
5323
|
+
function withProjectFormulas(doc: JxDocument): JxDocument {
|
|
5324
|
+
const cached = _withFormulas.get(doc);
|
|
5325
|
+
if (cached) {
|
|
5326
|
+
return cached;
|
|
5327
|
+
}
|
|
5328
|
+
const own = doc.state ?? {};
|
|
5329
|
+
const added = Object.entries(_projectFormulas).filter(([key]) => !(key in own));
|
|
5330
|
+
const merged =
|
|
5331
|
+
added.length > 0 ? { ...doc, state: { ...Object.fromEntries(added), ...own } } : doc;
|
|
5332
|
+
_withFormulas.set(doc, merged);
|
|
5333
|
+
return merged;
|
|
5334
|
+
}
|
|
5335
|
+
|
|
4597
5336
|
/**
|
|
4598
5337
|
* Seed the module-level root `$media` map used as the fallback for components that declare their
|
|
4599
5338
|
* own `@--name` style blocks but carry no own `$media` (buildScope ~279-280). `Jx()` sets this from
|
|
@@ -4964,7 +5703,7 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
|
|
|
4964
5703
|
_definedSources.add(base);
|
|
4965
5704
|
source_ = await resolve(source);
|
|
4966
5705
|
} else {
|
|
4967
|
-
source_ = source;
|
|
5706
|
+
source_ = inlineDocument(source, new URL(base ?? location.href, location.href).href);
|
|
4968
5707
|
}
|
|
4969
5708
|
base ??= location.href;
|
|
4970
5709
|
|
|
@@ -4988,6 +5727,7 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
|
|
|
4988
5727
|
const initialDef = source_;
|
|
4989
5728
|
const initialBase = base;
|
|
4990
5729
|
const observedAttrs = initialDef.observedAttributes ?? [];
|
|
5730
|
+
_observedAttributes.set(tagName, observedAttrs);
|
|
4991
5731
|
|
|
4992
5732
|
const ElementClass = class extends HTMLElement {
|
|
4993
5733
|
_jxInitialized = false;
|
|
@@ -5023,7 +5763,9 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
|
|
|
5023
5763
|
|
|
5024
5764
|
// The definition as it stands NOW — a redefinition since this class was made is honoured.
|
|
5025
5765
|
const live = _elementDefs.get(tagName) ?? { base: initialBase, doc: initialDef };
|
|
5026
|
-
|
|
5766
|
+
_connectedDefs.set(this, live.doc);
|
|
5767
|
+
// With the project's formulas under its own state (§13.4): the scope, and what a prop may set.
|
|
5768
|
+
const def = withProjectFormulas(live.doc);
|
|
5027
5769
|
const defBase = live.base;
|
|
5028
5770
|
|
|
5029
5771
|
// The element is its own dispatch root: a body run without an event emits from the host.
|
|
@@ -5131,10 +5873,20 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
|
|
|
5131
5873
|
* accessor: the property-first interface IS the props mechanism, so defining one would leave
|
|
5132
5874
|
* `el["#cache"] = x` writing straight through everything above.
|
|
5133
5875
|
*/
|
|
5876
|
+
const declared = live.doc.state ?? {};
|
|
5134
5877
|
for (const key of Object.keys(def.state ?? {})) {
|
|
5135
5878
|
if (isPrivateStateKey(key)) {
|
|
5136
5879
|
continue;
|
|
5137
5880
|
}
|
|
5881
|
+
/* An inherited project formula (§13.4) is a function in the scope, not part of the
|
|
5882
|
+
element's interface: the component's author never chose its name. An accessor for one
|
|
5883
|
+
would put a project formula called `append`, `remove` or `title` over the host's own
|
|
5884
|
+
member of that name, on every component in the realm, and the render below then calls
|
|
5885
|
+
the formula instead of the DOM method. Only an instance that was GIVEN the name as a prop
|
|
5886
|
+
gets the accessor, so a live `$props` binding of that name still reaches state. */
|
|
5887
|
+
if (!(key in declared) && !(key in supplied)) {
|
|
5888
|
+
continue;
|
|
5889
|
+
}
|
|
5138
5890
|
/* A name the platform already owns (`title`, `lang`, `hidden`) keeps its native setter, so
|
|
5139
5891
|
the attribute still reflects — and ALSO reaches state. Skipping it entirely meant a prop
|
|
5140
5892
|
delivered after connection (a template over data that resolves asynchronously) wrote
|
|
@@ -5194,7 +5946,7 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
|
|
|
5194
5946
|
definition draws inside it is too — and the de-link rules cannot see that from the nodes
|
|
5195
5947
|
themselves. Restored rather than cleared, because one definition may render another. */
|
|
5196
5948
|
const wasInside = _canvasInsideStampedHost;
|
|
5197
|
-
_canvasInsideStampedHost ||= this
|
|
5949
|
+
_canvasInsideStampedHost ||= carriesCanvasStamp(this);
|
|
5198
5950
|
/* And the chain, for the same span: an instance this definition draws connects inside
|
|
5199
5951
|
`this.append` below and reads it there ({@link underInstancePath}). */
|
|
5200
5952
|
let failure: Error | null = null;
|
|
@@ -5322,6 +6074,394 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
|
|
|
5322
6074
|
customElements.define(tagName, ElementClass);
|
|
5323
6075
|
}
|
|
5324
6076
|
|
|
6077
|
+
/**
|
|
6078
|
+
* Write one `$props` value onto an instance, before or after it connected.
|
|
6079
|
+
*
|
|
6080
|
+
* The same resolution `bindProperty` gives an ordinary element. A `$props: { src }` on a custom
|
|
6081
|
+
* element is an asset reference authored at the USAGE site, so it belongs to the document that
|
|
6082
|
+
* wrote it; a media prop the component forwards into its own `<img>` is resolved separately, inside
|
|
6083
|
+
* the component's render, against the same context.
|
|
6084
|
+
*
|
|
6085
|
+
* @param {HTMLElement} el - The instance.
|
|
6086
|
+
* @param {string} key - The prop's name.
|
|
6087
|
+
* @param {JxScopeValue} resolved - Its value, already resolved from any binding.
|
|
6088
|
+
*/
|
|
6089
|
+
function writeInstanceProp(el: HTMLElement, key: string, resolved: JxScopeValue): void {
|
|
6090
|
+
const value = isString(resolved) ? canvasAssetValue(el.tagName, key, resolved) : resolved;
|
|
6091
|
+
elementProperties(el)[key] = value;
|
|
6092
|
+
/* A declared prop that shares its name with an `HTMLElement` property (`title`, `lang`) gets
|
|
6093
|
+
no forwarding accessor (§16.2), so the write above lands on the element and never reaches
|
|
6094
|
+
the instance's state. Connection reads it once; a binding that changes AFTER connection
|
|
6095
|
+
(a row whose data arrives later) was lost, and the instance went on drawing the value it
|
|
6096
|
+
connected with. The binding is the parent's `$props`, addressed to the definition's prop,
|
|
6097
|
+
so it writes that prop's state. */
|
|
6098
|
+
const instanceState = instanceStateOf(el);
|
|
6099
|
+
if (instanceState && key in HTMLElement.prototype && Object.hasOwn(toRaw(instanceState), key)) {
|
|
6100
|
+
instanceState[key] = value;
|
|
6101
|
+
}
|
|
6102
|
+
}
|
|
6103
|
+
|
|
6104
|
+
/**
|
|
6105
|
+
* The attributes each tag observes, as first defined: the list the platform froze, which a
|
|
6106
|
+
* redefinition does not change ({@link redefineElement}).
|
|
6107
|
+
*/
|
|
6108
|
+
const _observedAttributes = new Map<string, readonly string[]>();
|
|
6109
|
+
|
|
6110
|
+
/**
|
|
6111
|
+
* The definition each instance connected with. A redefinition leaves a connected instance drawing
|
|
6112
|
+
* the old one (embedding.md §7), and nothing read from the new one describes it.
|
|
6113
|
+
*/
|
|
6114
|
+
const _connectedDefs = new WeakMap<HTMLElement, JxDocument>();
|
|
6115
|
+
|
|
6116
|
+
/** Whether an instance is still drawn by the definition its tag has now. */
|
|
6117
|
+
function drawnByCurrent(el: HTMLElement): boolean {
|
|
6118
|
+
const drawn = _connectedDefs.get(el);
|
|
6119
|
+
return drawn !== undefined && drawn === _elementDefs.get(el.localName)?.doc;
|
|
6120
|
+
}
|
|
6121
|
+
|
|
6122
|
+
/** The markers of a state entry whose value is produced asynchronously or kept outside the scope. */
|
|
6123
|
+
function producedElsewhere(entry: JxDocumentValue | undefined): boolean {
|
|
6124
|
+
if (!isJsonObject(entry)) {
|
|
6125
|
+
return false;
|
|
6126
|
+
}
|
|
6127
|
+
const { $prototype: prototype } = entry;
|
|
6128
|
+
if (prototype !== undefined) {
|
|
6129
|
+
return prototype !== "Function";
|
|
6130
|
+
}
|
|
6131
|
+
return "$src" in entry || "timing" in entry;
|
|
6132
|
+
}
|
|
6133
|
+
|
|
6134
|
+
/** Whether a `$props` value is a scalar written as it stands: no binding, no object, no `null`. */
|
|
6135
|
+
function isScalarProp(value: JxScopeValue | undefined): value is string | number | boolean {
|
|
6136
|
+
return (isString(value) && !isTemplateString(value)) || isNumber(value) || isBoolean(value);
|
|
6137
|
+
}
|
|
6138
|
+
|
|
6139
|
+
/**
|
|
6140
|
+
* The platform's own properties that reflect a string to an attribute and read it back unchanged,
|
|
6141
|
+
* so a declared prop of that name holds after a write exactly what connection would have read.
|
|
6142
|
+
*/
|
|
6143
|
+
const VERBATIM_PROPS: ReadonlySet<string> = new Set(["id", "lang", "role", "slot", "title"]);
|
|
6144
|
+
|
|
6145
|
+
/** Whether a document value is drawn through a binding (a `$ref` or a `${…}` template). */
|
|
6146
|
+
function isBoundProp(value: JxDocumentValue | undefined): boolean {
|
|
6147
|
+
return isRefValue(value) || isTemplateString(value);
|
|
6148
|
+
}
|
|
6149
|
+
|
|
6150
|
+
/**
|
|
6151
|
+
* Whether a definition, or one it draws, reads something ONCE when it renders, so that a later
|
|
6152
|
+
* write to its state would not reach what it drew, or would undo it:
|
|
6153
|
+
*
|
|
6154
|
+
* - A tag expression. The tag is chosen at creation and is not tracked (§19.6).
|
|
6155
|
+
* - A `$`-local bound to a `$ref`, which is a copy of the value taken at render, on a node or in the
|
|
6156
|
+
* `$props` of an element that is not a component; and an `id` bound to a `$ref`, written once.
|
|
6157
|
+
* - A `<slot>` a write can redraw (under a `$switch` case or a `$map` template, or with a `$switch`
|
|
6158
|
+
* of its own) or re-address (a bound `name`), or whose fallback is bound text. Slots are
|
|
6159
|
+
* distributed once, at connection (§8.5): the redrawn one is empty, and a replaced one is gone.
|
|
6160
|
+
* - A component it draws being handed a value by a route that component reads only when it connects
|
|
6161
|
+
* ({@link handsOverOnce}): bound `textContent`, slot content that is bound text, a mapped array
|
|
6162
|
+
* or a child with a bound `slot`, a bound `props.*` attribute, a bound attribute for a prop it
|
|
6163
|
+
* declares and does not observe (§16.5), or a bound prop or property the platform owns.
|
|
6164
|
+
* - An `onMount` in its state (§16.4), which runs once with the values it had then.
|
|
6165
|
+
* - A definition that can draw itself, whose instances are admitted against the props each connected
|
|
6166
|
+
* with (§16.9).
|
|
6167
|
+
* - Children or `innerHTML` drawn from a binding: what they draw is not in the document, so nothing
|
|
6168
|
+
* can be said about it.
|
|
6169
|
+
* - A state entry produced asynchronously or kept outside the scope (a `$prototype` other than
|
|
6170
|
+
* `Function`, a `$src`, a `timing`): a request, a class or a store answers for the value it was
|
|
6171
|
+
* built with, and a stored value outlives the write.
|
|
6172
|
+
*
|
|
6173
|
+
* The answer belongs to the definition, not to the instance: it is asked of the document, so a
|
|
6174
|
+
* `$switch` case that is not drawn yet counts. It is not remembered, because it depends on every
|
|
6175
|
+
* definition this one draws and any of them can be redefined (embedding.md §7).
|
|
6176
|
+
*
|
|
6177
|
+
* @param {JxDocument} def
|
|
6178
|
+
* @param {Set<JxDocument>} [drawing] - The definitions this walk is inside.
|
|
6179
|
+
* @returns {boolean}
|
|
6180
|
+
*/
|
|
6181
|
+
function rendersOnce(def: JxDocument, drawing = new Set<JxDocument>()): boolean {
|
|
6182
|
+
const declared = def.state ?? {};
|
|
6183
|
+
if ("onMount" in declared || Object.values(declared).some((entry) => producedElsewhere(entry))) {
|
|
6184
|
+
return true;
|
|
6185
|
+
}
|
|
6186
|
+
drawing.add(def);
|
|
6187
|
+
const walk = (value: JxDocumentValue | undefined, isRoot: boolean, redrawn: boolean): boolean => {
|
|
6188
|
+
if (Array.isArray(value)) {
|
|
6189
|
+
return value.some((item) => walk(item, false, redrawn));
|
|
6190
|
+
}
|
|
6191
|
+
if (!isNonNullObject(value)) {
|
|
6192
|
+
return false;
|
|
6193
|
+
}
|
|
6194
|
+
/* SAFETY: a definition is a JSON document, so every member of every object in it is a document
|
|
6195
|
+
value; `Object.entries` on the union of node shapes says `any` only because it cannot name
|
|
6196
|
+
one type for them all. */
|
|
6197
|
+
const node = Object.fromEntries(Object.entries(value)) as Record<string, JxDocumentValue>;
|
|
6198
|
+
const { attributes, tagName } = node;
|
|
6199
|
+
if (tagName !== undefined && !isString(tagName)) {
|
|
6200
|
+
return true;
|
|
6201
|
+
}
|
|
6202
|
+
const attrs: Record<string, JxDocumentValue> = isJsonObject(attributes) ? attributes : {};
|
|
6203
|
+
if (
|
|
6204
|
+
tagName === "slot" &&
|
|
6205
|
+
(redrawn ||
|
|
6206
|
+
isBoundProp(attrs.name) ||
|
|
6207
|
+
isBoundProp(node.name) ||
|
|
6208
|
+
isBoundProp(node.textContent) ||
|
|
6209
|
+
node.$switch !== undefined)
|
|
6210
|
+
) {
|
|
6211
|
+
return true;
|
|
6212
|
+
}
|
|
6213
|
+
if (isBoundProp(node.innerHTML) || (!isRoot && isBoundProp(node.children))) {
|
|
6214
|
+
return true;
|
|
6215
|
+
}
|
|
6216
|
+
const isInstance = !isRoot && isString(tagName) && tagName.includes("-");
|
|
6217
|
+
const inner = isInstance ? _elementDefs.get(tagName)?.doc : undefined;
|
|
6218
|
+
if (isInstance && handsOverOnce(node, attrs, inner)) {
|
|
6219
|
+
return true;
|
|
6220
|
+
}
|
|
6221
|
+
if (inner && (drawing.has(inner) || rendersOnce(inner, drawing))) {
|
|
6222
|
+
return true;
|
|
6223
|
+
}
|
|
6224
|
+
for (const [key, entry] of Object.entries(node)) {
|
|
6225
|
+
// State is the scope a write reaches, not something drawn; the root's `$props` are defaults.
|
|
6226
|
+
if (isRoot && (key === "state" || key === "$props")) {
|
|
6227
|
+
continue;
|
|
6228
|
+
}
|
|
6229
|
+
if (key === "$props") {
|
|
6230
|
+
if (!isInstance && isJsonObject(entry) && Object.values(entry).some((v) => isRefValue(v))) {
|
|
6231
|
+
return true;
|
|
6232
|
+
}
|
|
6233
|
+
continue;
|
|
6234
|
+
}
|
|
6235
|
+
if ((key === "id" || (key.startsWith("$") && !RESERVED_KEYS.has(key))) && isRefValue(entry)) {
|
|
6236
|
+
return true;
|
|
6237
|
+
}
|
|
6238
|
+
// `cases` is a map of nodes by name, not a node: a case may be called `state`.
|
|
6239
|
+
const drawn = key === "cases" && isJsonObject(entry) ? Object.values(entry) : entry;
|
|
6240
|
+
if (walk(drawn, false, redrawn || key === "cases" || key === "map")) {
|
|
6241
|
+
return true;
|
|
6242
|
+
}
|
|
6243
|
+
}
|
|
6244
|
+
return false;
|
|
6245
|
+
};
|
|
6246
|
+
const answer = walk(def, true, false);
|
|
6247
|
+
drawing.delete(def);
|
|
6248
|
+
return answer;
|
|
6249
|
+
}
|
|
6250
|
+
|
|
6251
|
+
/**
|
|
6252
|
+
* Whether a component a definition draws is handed a bound value by a route it reads only when it
|
|
6253
|
+
* connects ({@link rendersOnce}).
|
|
6254
|
+
*
|
|
6255
|
+
* @param node - The node that draws the component.
|
|
6256
|
+
* @param attrs - That node's `attributes`.
|
|
6257
|
+
* @param inner - The component's definition, when it is registered.
|
|
6258
|
+
* @returns {boolean}
|
|
6259
|
+
*/
|
|
6260
|
+
function handsOverOnce(
|
|
6261
|
+
node: Record<string, JxDocumentValue>,
|
|
6262
|
+
attrs: Record<string, JxDocumentValue>,
|
|
6263
|
+
inner: JxDocument | undefined,
|
|
6264
|
+
): boolean {
|
|
6265
|
+
if (isBoundProp(node.textContent)) {
|
|
6266
|
+
return true;
|
|
6267
|
+
}
|
|
6268
|
+
/* Its slot content is grouped, and tested for being empty, once (§8.5): text that fills in later,
|
|
6269
|
+
a row that arrives later and a child that changes group are not distributed again. */
|
|
6270
|
+
const kids = Array.isArray(node.children) ? node.children : [];
|
|
6271
|
+
if (
|
|
6272
|
+
kids.some(
|
|
6273
|
+
(kid) =>
|
|
6274
|
+
isBoundProp(kid) ||
|
|
6275
|
+
isMappedArray(kid) ||
|
|
6276
|
+
(isJsonObject(kid) &&
|
|
6277
|
+
(isBoundProp(kid.slot) ||
|
|
6278
|
+
(isJsonObject(kid.attributes) && isBoundProp(kid.attributes.slot)))),
|
|
6279
|
+
)
|
|
6280
|
+
) {
|
|
6281
|
+
return true;
|
|
6282
|
+
}
|
|
6283
|
+
/* A platform-named prop is read back through the platform's accessor at connection, which
|
|
6284
|
+
coerces it; forwarded later, as a prop or as a property of the node, it reaches the
|
|
6285
|
+
component's state as given. */
|
|
6286
|
+
const coerced = (given: Record<string, JxDocumentValue>): boolean =>
|
|
6287
|
+
Object.entries(given).some(
|
|
6288
|
+
([key, value]) => isBoundProp(value) && key in HTMLElement.prototype && key !== "textContent",
|
|
6289
|
+
);
|
|
6290
|
+
if (coerced(node) || (isJsonObject(node.$props) && coerced(node.$props))) {
|
|
6291
|
+
return true;
|
|
6292
|
+
}
|
|
6293
|
+
const observed = isString(node.tagName) ? (_observedAttributes.get(node.tagName) ?? []) : [];
|
|
6294
|
+
return Object.entries(attrs).some(([name, given]) => {
|
|
6295
|
+
if (!isBoundProp(given)) {
|
|
6296
|
+
return false;
|
|
6297
|
+
}
|
|
6298
|
+
if (name.startsWith("props.")) {
|
|
6299
|
+
return true;
|
|
6300
|
+
}
|
|
6301
|
+
const prop = name.replaceAll(/-([a-z])/g, (_: string, c: string) => c.toUpperCase());
|
|
6302
|
+
// An unregistered component is one whose declarations cannot be read: assume it reads this.
|
|
6303
|
+
return !inner || (prop in (inner.state ?? {}) && !observed.includes(name));
|
|
6304
|
+
});
|
|
6305
|
+
}
|
|
6306
|
+
|
|
6307
|
+
/**
|
|
6308
|
+
* Whether anything the instance's own definition drew has an `onMount` that already ran: the
|
|
6309
|
+
* instance itself, or an instance inside it that no document node stands between.
|
|
6310
|
+
*
|
|
6311
|
+
* `onMount` runs once (§16.4), and what it read then it does not read again. An instance the
|
|
6312
|
+
* DOCUMENT placed inside this one (slot content, stamped `data-jx-path` by the canvas) takes
|
|
6313
|
+
* nothing from this instance's props, so it does not count.
|
|
6314
|
+
*
|
|
6315
|
+
* @param {HTMLElement} host
|
|
6316
|
+
* @param {JxScope} state
|
|
6317
|
+
* @returns {boolean}
|
|
6318
|
+
*/
|
|
6319
|
+
function mountedFromProps(host: HTMLElement, state: JxScope): boolean {
|
|
6320
|
+
if (isScopeHook(state.onMount) || !drawnByCurrent(host)) {
|
|
6321
|
+
return true;
|
|
6322
|
+
}
|
|
6323
|
+
for (const inner of host.querySelectorAll<HTMLElement>("*")) {
|
|
6324
|
+
const innerState = instanceStateOf(inner);
|
|
6325
|
+
if (!innerState || (!isScopeHook(innerState.onMount) && drawnByCurrent(inner))) {
|
|
6326
|
+
continue;
|
|
6327
|
+
}
|
|
6328
|
+
let owner: HTMLElement | null = inner;
|
|
6329
|
+
while (owner && owner !== host && owner.dataset.jxPath === undefined) {
|
|
6330
|
+
owner = owner.parentElement;
|
|
6331
|
+
}
|
|
6332
|
+
if (owner === host) {
|
|
6333
|
+
return true;
|
|
6334
|
+
}
|
|
6335
|
+
}
|
|
6336
|
+
return false;
|
|
6337
|
+
}
|
|
6338
|
+
|
|
6339
|
+
/**
|
|
6340
|
+
* Write a changed `$props` block into an instance that is already connected, in place, and answer
|
|
6341
|
+
* whether that was done. `false` means the caller draws the instance again; nothing was written
|
|
6342
|
+
* unless the refusal is the last case below.
|
|
6343
|
+
*
|
|
6344
|
+
* This is the property-first interface (§16.2) used by an editor: the Studio canvas calls it when
|
|
6345
|
+
* an author edits a prop, so the instance's own bindings redraw what reads that prop and nothing
|
|
6346
|
+
* else in it is rebuilt. It refuses whenever writing the properties could leave the instance
|
|
6347
|
+
* showing something a fresh render of it would not:
|
|
6348
|
+
*
|
|
6349
|
+
* - The element is not a connected Jx instance, or its definition is gone.
|
|
6350
|
+
* - Nothing changed. A block equal to the last one is a request to draw again, not a write.
|
|
6351
|
+
* - A changed prop is not a scalar on both sides: a string, a number or a boolean, as it stands. A
|
|
6352
|
+
* `$ref` or a template is an effect the render that created the instance owns, and it would go on
|
|
6353
|
+
* writing the old source. An object, an array or `null` is refused because too much reads one
|
|
6354
|
+
* once: a list redraws its rows in a later microtask, where a throw can no longer be answered
|
|
6355
|
+
* for; a binding that reads a member survives `null` by keeping its last drawing; and the
|
|
6356
|
+
* canvas's inline-edit marker is stamped by the type the value had at render.
|
|
6357
|
+
* - A changed prop overrides a state entry that is produced elsewhere (a store, a request).
|
|
6358
|
+
* - A changed prop is named for a property the platform owns (`hidden`, `tabIndex`, `textContent`):
|
|
6359
|
+
* connection reads such a value back through the platform's accessor, which coerces it, reflects
|
|
6360
|
+
* it to an attribute, or for `textContent` makes it slot content, and a later write does none of
|
|
6361
|
+
* that the same way. The exception is a declared prop the platform reflects verbatim
|
|
6362
|
+
* ({@link VERBATIM_PROPS}) being given a string, which reads back as written.
|
|
6363
|
+
* - A removed prop has no scalar declared default to go back to, or its attribute is observed and on
|
|
6364
|
+
* the host, where a fresh instance would read it instead of the default.
|
|
6365
|
+
* - The definition, or one it draws, declares an `onMount`, or an instance already drawn has one: it
|
|
6366
|
+
* ran once with the old values.
|
|
6367
|
+
* - The instance, or one its definition drew, connected with a definition since replaced
|
|
6368
|
+
* (embedding.md §7), and still draws the old one.
|
|
6369
|
+
* - The definition, or one it draws, reads something once at render ({@link rendersOnce}): a tag
|
|
6370
|
+
* expression, a copied `$ref`, a slot a write would redraw, a nested component handed a value it
|
|
6371
|
+
* reads only at connection, or a definition that can draw itself.
|
|
6372
|
+
* - A write throws, a template binding throws on the new value and is survived (§6.1), or a write
|
|
6373
|
+
* reaches a mapped list. The first is a render that throws, which the fresh render reports
|
|
6374
|
+
* (§16.4); the second keeps what it last drew, which a fresh render never drew; the third redraws
|
|
6375
|
+
* in a later microtask, where nothing can answer for what it draws. All three are known only
|
|
6376
|
+
* after the fact: the props are written, and the caller's redraw discards them with the
|
|
6377
|
+
* instance.
|
|
6378
|
+
*
|
|
6379
|
+
* What it does not put back is state the instance changed itself (a counter its own handler moved):
|
|
6380
|
+
* the write changes the props that changed and leaves the rest of the scope as it stands.
|
|
6381
|
+
*
|
|
6382
|
+
* @param {HTMLElement} el - The instance host.
|
|
6383
|
+
* @param {JxElement["$props"]} before - The `$props` block it was drawn with.
|
|
6384
|
+
* @param {JxElement["$props"]} after - The block it should show now.
|
|
6385
|
+
* @returns {boolean}
|
|
6386
|
+
*/
|
|
6387
|
+
export function updateInstanceProps(
|
|
6388
|
+
el: HTMLElement,
|
|
6389
|
+
before: JxElement["$props"],
|
|
6390
|
+
after: JxElement["$props"],
|
|
6391
|
+
): boolean {
|
|
6392
|
+
const state = instanceStateOf(el);
|
|
6393
|
+
const registered = _elementDefs.get(el.localName)?.doc;
|
|
6394
|
+
if (!state || !registered) {
|
|
6395
|
+
return false;
|
|
6396
|
+
}
|
|
6397
|
+
// As the instance's scope was built: with the project's formulas under its own state (§13.4).
|
|
6398
|
+
const def = withProjectFormulas(registered);
|
|
6399
|
+
const defaults = declaredDefaults(def);
|
|
6400
|
+
const declared = def.state ?? {};
|
|
6401
|
+
const observed = _observedAttributes.get(el.localName) ?? [];
|
|
6402
|
+
const writes: (() => void)[] = [];
|
|
6403
|
+
for (const key of new Set([...Object.keys(before ?? {}), ...Object.keys(after ?? {})])) {
|
|
6404
|
+
const was = before?.[key];
|
|
6405
|
+
const now = after?.[key];
|
|
6406
|
+
// A private key was refused when the instance was drawn and is refused now: nothing to write.
|
|
6407
|
+
if (isPrivateStateKey(key) || JSON.stringify(was) === JSON.stringify(now)) {
|
|
6408
|
+
continue;
|
|
6409
|
+
}
|
|
6410
|
+
if (
|
|
6411
|
+
(was !== undefined && !isScalarProp(was)) ||
|
|
6412
|
+
(now !== undefined && !isScalarProp(now)) ||
|
|
6413
|
+
producedElsewhere(declared[key]) ||
|
|
6414
|
+
// What the instance holds now decided things at render too (a marker, a handler).
|
|
6415
|
+
(key in declared && !isScalarProp(toRaw(state)[key])) ||
|
|
6416
|
+
// The element class's own fields, which a property write would overwrite.
|
|
6417
|
+
key === "_state" ||
|
|
6418
|
+
key === "_jxInitialized"
|
|
6419
|
+
) {
|
|
6420
|
+
return false;
|
|
6421
|
+
}
|
|
6422
|
+
if (
|
|
6423
|
+
key in HTMLElement.prototype &&
|
|
6424
|
+
!(VERBATIM_PROPS.has(key) && key in declared && isString(now))
|
|
6425
|
+
) {
|
|
6426
|
+
return false;
|
|
6427
|
+
}
|
|
6428
|
+
if (now !== undefined) {
|
|
6429
|
+
writes.push(() => {
|
|
6430
|
+
writeInstanceProp(el, key, now);
|
|
6431
|
+
});
|
|
6432
|
+
continue;
|
|
6433
|
+
}
|
|
6434
|
+
if (!(key in declared)) {
|
|
6435
|
+
// Nothing reads an undeclared prop: a fresh instance simply would not carry it.
|
|
6436
|
+
writes.push(() => {
|
|
6437
|
+
Reflect.deleteProperty(el, key);
|
|
6438
|
+
});
|
|
6439
|
+
continue;
|
|
6440
|
+
}
|
|
6441
|
+
const fallback = defaults.get(key);
|
|
6442
|
+
const attribute = key.replaceAll(/[A-Z]/gu, (c) => `-${c.toLowerCase()}`);
|
|
6443
|
+
if (!isScalarProp(fallback) || (observed.includes(attribute) && el.hasAttribute(attribute))) {
|
|
6444
|
+
return false;
|
|
6445
|
+
}
|
|
6446
|
+
writes.push(() => {
|
|
6447
|
+
state[key] = fallback;
|
|
6448
|
+
});
|
|
6449
|
+
}
|
|
6450
|
+
if (writes.length === 0 || mountedFromProps(el, state) || rendersOnce(def)) {
|
|
6451
|
+
return false;
|
|
6452
|
+
}
|
|
6453
|
+
const thrown = _templateThrows;
|
|
6454
|
+
const redraws = _listRedraws;
|
|
6455
|
+
try {
|
|
6456
|
+
for (const write of writes) {
|
|
6457
|
+
write();
|
|
6458
|
+
}
|
|
6459
|
+
} catch {
|
|
6460
|
+
return false;
|
|
6461
|
+
}
|
|
6462
|
+
return _templateThrows === thrown && _listRedraws === redraws;
|
|
6463
|
+
}
|
|
6464
|
+
|
|
5325
6465
|
/**
|
|
5326
6466
|
* Render a registered custom element with $props (property-first interface).
|
|
5327
6467
|
*
|
|
@@ -5348,27 +6488,8 @@ function renderCustomElementWithProps(
|
|
|
5348
6488
|
if (refusePrivateProp(key, "$props")) {
|
|
5349
6489
|
continue;
|
|
5350
6490
|
}
|
|
5351
|
-
/* The same resolution `bindProperty` gives an ordinary element. A `$props: { src }` on a custom
|
|
5352
|
-
element is an asset reference authored at the USAGE site, so it belongs to the document that
|
|
5353
|
-
wrote it — a media prop the component forwards into its own `<img>` is resolved separately,
|
|
5354
|
-
inside the component's render, against the same context. */
|
|
5355
6491
|
const write = (resolved: JxScopeValue): void => {
|
|
5356
|
-
|
|
5357
|
-
elementProperties(el)[key] = value;
|
|
5358
|
-
/* A declared prop that shares its name with an `HTMLElement` property (`title`, `lang`) gets
|
|
5359
|
-
no forwarding accessor (§16.2), so the write above lands on the element and never reaches
|
|
5360
|
-
the instance's state. Connection reads it once; a binding that changes AFTER connection
|
|
5361
|
-
(a row whose data arrives later) was lost, and the instance went on drawing the value it
|
|
5362
|
-
connected with. The binding is the parent's `$props`, addressed to the definition's prop,
|
|
5363
|
-
so it writes that prop's state. */
|
|
5364
|
-
const instanceState = instanceStateOf(el);
|
|
5365
|
-
if (
|
|
5366
|
-
instanceState &&
|
|
5367
|
-
key in HTMLElement.prototype &&
|
|
5368
|
-
Object.hasOwn(toRaw(instanceState), key)
|
|
5369
|
-
) {
|
|
5370
|
-
instanceState[key] = value;
|
|
5371
|
-
}
|
|
6492
|
+
writeInstanceProp(el, key, resolved);
|
|
5372
6493
|
};
|
|
5373
6494
|
if (isRefValue(val)) {
|
|
5374
6495
|
const refVal = val;
|
|
@@ -5410,6 +6531,8 @@ function renderCustomElementWithProps(
|
|
|
5410
6531
|
const childOpts = options && path ? { ...options, _path: [...path, "children", i] } : undefined;
|
|
5411
6532
|
if (isMappedArray(child)) {
|
|
5412
6533
|
renderMappedArrayInto(el, child, state, childOpts);
|
|
6534
|
+
} else if (isString(child) && isSingleExpression(child)) {
|
|
6535
|
+
renderTemplatedChild(el, child, state, childOpts);
|
|
5413
6536
|
} else {
|
|
5414
6537
|
el.append(renderNode(child, state, childOpts));
|
|
5415
6538
|
}
|