@jxsuite/runtime 3.1.0 → 4.0.1
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 +1460 -516
- package/dist/runtime.js.map +8 -8
- package/package.json +3 -3
- package/src/css.ts +767 -3
- package/src/expression.ts +34 -2
- package/src/runtime.ts +1917 -330
- package/src/statements.ts +42 -5
- package/src/types.ts +73 -1
package/src/runtime.ts
CHANGED
|
@@ -17,16 +17,36 @@ import {
|
|
|
17
17
|
computed,
|
|
18
18
|
effect,
|
|
19
19
|
effectScope,
|
|
20
|
+
isReactive,
|
|
20
21
|
isRef,
|
|
21
22
|
onEffectCleanup,
|
|
23
|
+
onScopeDispose,
|
|
24
|
+
pauseTracking,
|
|
22
25
|
reactive,
|
|
23
26
|
ref,
|
|
27
|
+
resetTracking,
|
|
28
|
+
shallowReactive,
|
|
24
29
|
toRaw,
|
|
25
30
|
} from "@vue/reactivity";
|
|
26
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
buildStyleRules,
|
|
33
|
+
cssPropertyName,
|
|
34
|
+
hashCss,
|
|
35
|
+
isNestedSelectorKey,
|
|
36
|
+
transposeCanvasOverlaySelector,
|
|
37
|
+
} from "./css.ts";
|
|
27
38
|
import { evaluateExpression, evaluateOperand, isMutating } from "./expression.ts";
|
|
28
39
|
import { readPath } from "./pointer.ts";
|
|
29
|
-
import type {
|
|
40
|
+
import type {
|
|
41
|
+
DynamicClass,
|
|
42
|
+
JxContext,
|
|
43
|
+
JxEventHandler,
|
|
44
|
+
JxMount,
|
|
45
|
+
JxMountOptions,
|
|
46
|
+
JxPath,
|
|
47
|
+
JxRenderOptions,
|
|
48
|
+
JxScope,
|
|
49
|
+
} from "./types.ts";
|
|
30
50
|
import {
|
|
31
51
|
bodyReturnsValue,
|
|
32
52
|
hasStructuredBody,
|
|
@@ -59,7 +79,7 @@ import type {
|
|
|
59
79
|
JxServerFnDef,
|
|
60
80
|
JxStyle,
|
|
61
81
|
} from "@jxsuite/schema/types";
|
|
62
|
-
import type { Ref } from "@vue/reactivity";
|
|
82
|
+
import type { EffectScope, Ref } from "@vue/reactivity";
|
|
63
83
|
|
|
64
84
|
// ─── Public API ───────────────────────────────────────────────────────────────
|
|
65
85
|
|
|
@@ -110,11 +130,76 @@ export async function Jx(
|
|
|
110
130
|
? new URL(source, location.href).href
|
|
111
131
|
: location.href;
|
|
112
132
|
const doc = await resolve(source);
|
|
133
|
+
|
|
134
|
+
/* The module-level fallback is still written here, on purpose: a component defined LATER with
|
|
135
|
+
`@--name` blocks and no own `$media` reads it (setRootMedia). `mount()` never writes it — a
|
|
136
|
+
mount's `$media` travels in its context, which is the whole point of having one. */
|
|
137
|
+
if (doc.$media) {
|
|
138
|
+
_rootMedia = doc.$media;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const mounted = await mount(doc, target, {
|
|
142
|
+
base,
|
|
143
|
+
...(options?.onNodeCreated ? { onNodeCreated: options.onNodeCreated } : {}),
|
|
144
|
+
...(options?.signal ? { signal: options.signal } : {}),
|
|
145
|
+
});
|
|
146
|
+
return mounted.scope;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Host-scope keys the runtime owns: `$media`, `$map`, the `$`-locals and `#`-private entries. */
|
|
150
|
+
const HOST_SCOPE_RESERVED = /^[$#]/;
|
|
151
|
+
|
|
152
|
+
function assertHostScope(scope: Record<string, unknown>): void {
|
|
153
|
+
for (const key of Object.keys(scope)) {
|
|
154
|
+
if (HOST_SCOPE_RESERVED.test(key)) {
|
|
155
|
+
throw new TypeError(
|
|
156
|
+
`Jx mount: host scope key "${key}" is reserved — names beginning with "$" or "#" are ` +
|
|
157
|
+
`runtime vocabulary`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Mount a document into a host-owned node and hand back the live scope with a disposer.
|
|
165
|
+
*
|
|
166
|
+
* This is the embedding contract (specs/embedding.md): the host supplies its own reactive records
|
|
167
|
+
* and functions as `options.scope`, a per-mount base, `$media` and document resolver, and gets a
|
|
168
|
+
* root it can remove and a `dispose()` that stops every effect the render created. Nothing here
|
|
169
|
+
* writes a module global, so a shell can hold many roots with different needs at once.
|
|
170
|
+
*
|
|
171
|
+
* @param {JxDocument} doc - The document to render (already an object; use {@link resolve} for a
|
|
172
|
+
* URL)
|
|
173
|
+
* @param {ParentNode} target - Where the rendered root is appended
|
|
174
|
+
* @param {JxMountOptions} [options]
|
|
175
|
+
* @returns {Promise<JxMount>}
|
|
176
|
+
* @docs extending/embedding/runtime-host
|
|
177
|
+
*/
|
|
178
|
+
export async function mount(
|
|
179
|
+
doc: JxDocument,
|
|
180
|
+
target: ParentNode,
|
|
181
|
+
options: JxMountOptions = {},
|
|
182
|
+
): Promise<JxMount> {
|
|
183
|
+
const hostScope = options.scope ?? {};
|
|
184
|
+
assertHostScope(hostScope);
|
|
185
|
+
const base = options.base ? new URL(options.base, location.href).href : location.href;
|
|
186
|
+
const ctx: JxContext = {
|
|
187
|
+
base,
|
|
188
|
+
media: options.media ?? doc.$media ?? null,
|
|
189
|
+
root: null,
|
|
190
|
+
...(options.resolver ? { resolver: options.resolver } : {}),
|
|
191
|
+
...(options.skipServerFunctions === undefined
|
|
192
|
+
? {}
|
|
193
|
+
: { skipServerFunctions: options.skipServerFunctions }),
|
|
194
|
+
...(options.skipAutoRequests === undefined
|
|
195
|
+
? {}
|
|
196
|
+
: { skipAutoRequests: options.skipAutoRequests }),
|
|
197
|
+
};
|
|
113
198
|
checkSchemaVersion(doc.$schema);
|
|
114
199
|
|
|
115
200
|
// Register custom elements declared in $elements (depth-first)
|
|
116
201
|
if (doc.$elements) {
|
|
117
|
-
await registerElements(doc.$elements, base);
|
|
202
|
+
await registerElements(doc.$elements, base, ctx);
|
|
118
203
|
}
|
|
119
204
|
|
|
120
205
|
// Inject <head> elements declared in $head (link, meta, script, etc.)
|
|
@@ -122,26 +207,86 @@ export async function Jx(
|
|
|
122
207
|
injectHead(doc.$head, base);
|
|
123
208
|
}
|
|
124
209
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
210
|
+
const scope = await buildScope(doc, hostScope, base, ctx);
|
|
211
|
+
|
|
212
|
+
const elements = new Set<string>();
|
|
213
|
+
const renderOptions: JxRenderOptions = {
|
|
214
|
+
_ctx: ctx,
|
|
215
|
+
onNodeCreated: (el, path, def, state) => {
|
|
216
|
+
if (el instanceof Element && el.tagName.includes("-")) {
|
|
217
|
+
elements.add(el.tagName.toLowerCase());
|
|
218
|
+
}
|
|
219
|
+
options.onNodeCreated?.(el, path, def, state);
|
|
220
|
+
},
|
|
221
|
+
...(options.onNodeMoved ? { onNodeMoved: options.onNodeMoved } : {}),
|
|
222
|
+
};
|
|
223
|
+
const { result: root, stop } = runScoped(() => renderNode(doc, scope, renderOptions));
|
|
224
|
+
ctx.root = root;
|
|
225
|
+
|
|
226
|
+
let mounted = false;
|
|
227
|
+
let disposed = false;
|
|
228
|
+
const dispose = (): void => {
|
|
229
|
+
if (disposed) {
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
disposed = true;
|
|
233
|
+
options.signal?.removeEventListener("abort", dispose);
|
|
234
|
+
stop();
|
|
235
|
+
if (mounted && typeof scope.onUnmount === "function") {
|
|
236
|
+
(scope.onUnmount as (s: JxScope) => unknown)(scope);
|
|
237
|
+
}
|
|
238
|
+
root.remove();
|
|
239
|
+
};
|
|
128
240
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
241
|
+
if (options.signal?.aborted) {
|
|
242
|
+
// Aborted before it could be attached: tear the effects down and attach nothing.
|
|
243
|
+
dispose();
|
|
244
|
+
} else {
|
|
245
|
+
options.signal?.addEventListener("abort", dispose, { once: true });
|
|
246
|
+
target.append(root);
|
|
247
|
+
mounted = true;
|
|
248
|
+
if (typeof scope.onMount === "function") {
|
|
249
|
+
(scope.onMount as (s: JxScope) => unknown)(scope);
|
|
250
|
+
}
|
|
133
251
|
}
|
|
134
|
-
return
|
|
252
|
+
return { scope, root, elements, dispose };
|
|
135
253
|
}
|
|
136
254
|
|
|
137
255
|
// ─── Step 1: Resolve ──────────────────────────────────────────────────────────
|
|
138
256
|
|
|
139
257
|
const _resolveCache = new Map<string, Promise<JxDocument>>();
|
|
140
258
|
|
|
141
|
-
|
|
259
|
+
/**
|
|
260
|
+
* Seed the resolve cache with a document a host already holds, under the URL its references use.
|
|
261
|
+
*
|
|
262
|
+
* A bundled document — one imported as JSON rather than fetched — still names its dependencies by
|
|
263
|
+
* URL: an `$elements` entry, a `$switch` case. Preloading those under the same URLs lets every such
|
|
264
|
+
* reference resolve with no network, and lets `defineElement("<url>")` dedupe on the source it
|
|
265
|
+
* would otherwise have fetched. The key is stored as given and, when it parses as a URL, in its
|
|
266
|
+
* absolute form too, because the two lookup paths spell it differently.
|
|
267
|
+
*
|
|
268
|
+
* @param {string} url - The URL the document is known by
|
|
269
|
+
* @param {JxDocument} doc
|
|
270
|
+
*/
|
|
271
|
+
export function preloadDocument(url: string, doc: JxDocument): void {
|
|
272
|
+
const settled = Promise.resolve(doc);
|
|
273
|
+
_resolveCache.set(url, settled);
|
|
274
|
+
try {
|
|
275
|
+
_resolveCache.set(new URL(url, location.href).href, settled);
|
|
276
|
+
} catch {
|
|
277
|
+
// Not a URL at all — the raw key still serves the lookup that uses it.
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export async function resolve(source: string | JxDocument, ctx?: JxContext): Promise<JxDocument> {
|
|
142
282
|
if (typeof source !== "string") {
|
|
143
283
|
return source;
|
|
144
284
|
}
|
|
285
|
+
// A mount's resolver answers for THIS mount and its hit never enters the shared cache.
|
|
286
|
+
const served = ctx?.resolver?.(source);
|
|
287
|
+
if (served) {
|
|
288
|
+
return served;
|
|
289
|
+
}
|
|
145
290
|
if (_resolveCache.has(source)) {
|
|
146
291
|
return _resolveCache.get(source)!;
|
|
147
292
|
}
|
|
@@ -329,11 +474,124 @@ export function setCanvasDelinkAnchors(on: boolean) {
|
|
|
329
474
|
_canvasDelinkAnchors = on;
|
|
330
475
|
}
|
|
331
476
|
|
|
477
|
+
/**
|
|
478
|
+
* Studio-canvas popover de-linking — `setCanvasDelinkAnchors`'s rule applied to `popover`.
|
|
479
|
+
*
|
|
480
|
+
* An OPEN popover is in the top layer, and CSS Position 4 §3.1 gives a top-layer element the
|
|
481
|
+
* VIEWPORT as its containing block whatever its ancestors say. In the editor canvas that viewport
|
|
482
|
+
* is a fiction: the frame is sized to its own content height, so a drawer pinned with `inset: 0`
|
|
483
|
+
* lands halfway down a 6000px page, and a panel taller than a short component frame is clipped by
|
|
484
|
+
* the `overflow: hidden` the canvas document needs. Worse, a top-layer box contributes nothing to
|
|
485
|
+
* any ancestor's scrollable overflow, so the artboard can never grow to fit one.
|
|
486
|
+
*
|
|
487
|
+
* When this is on, the runtime stamps `popover` on `data-jx-popover` for nodes the studio can
|
|
488
|
+
* ADDRESS — those carrying a stamped `data-jx-path`. That drops every `[popover]` UA rule at once,
|
|
489
|
+
* and the one that matters most is `position: fixed`: with it gone the panel lays out in NORMAL
|
|
490
|
+
* FLOW at its document position, which is what makes it contribute to `#jx-canvas-root`'s
|
|
491
|
+
* scrollHeight so the host can grow the artboard to fit it. Leaving the top layer is necessary but
|
|
492
|
+
* not sufficient — a fixed box contributes nothing to an ancestor's overflow either.
|
|
493
|
+
*
|
|
494
|
+
* It is worth saying what does NOT rescue this, because it is the intuitive answer and it is wrong:
|
|
495
|
+
* `container-type: size` on the canvas's query container does NOT make it a containing block for
|
|
496
|
+
* fixed descendants. Measured in Chrome 151 — `getComputedStyle(container).contain` is `none`, and
|
|
497
|
+
* a `position: fixed` child inside it measures the WINDOW, not the container. So a panel that stays
|
|
498
|
+
* fixed stays laid out against the frame's own viewport, which in an editable mode is the
|
|
499
|
+
* document's full height. `iframe-render.ts` therefore FORCES `position` on the open panel rather
|
|
500
|
+
* than relying on any containment.
|
|
501
|
+
*
|
|
502
|
+
* The studio re-supplies the one UA rule that matters — `display: none` while closed — inside a
|
|
503
|
+
* cascade LAYER, so author declarations still beat it exactly as they beat the real UA rule on the
|
|
504
|
+
* shipped page. That is deliberate: a popover whose base rule sets `display` is broken in
|
|
505
|
+
* production, and the canvas has to show that rather than paper over it.
|
|
506
|
+
*
|
|
507
|
+
* The gate on `data-jx-path` is what keeps this out of documents the studio does not own: a popover
|
|
508
|
+
* rendered inside a component's own template is never stamped, so it keeps its native behaviour.
|
|
509
|
+
*
|
|
510
|
+
* It also drives {@link transposeCanvasPopoverSelector}. The two are one transform — an attribute
|
|
511
|
+
* renamed without its selectors transposed is a popover that can never be styled open.
|
|
512
|
+
*
|
|
513
|
+
* @docs framework/concepts/overlays
|
|
514
|
+
*/
|
|
515
|
+
let _canvasDelinkPopovers = false;
|
|
516
|
+
export function setCanvasDelinkPopovers(on: boolean) {
|
|
517
|
+
_canvasDelinkPopovers = on;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Studio-canvas de-linking of invoker commands, `inert` and a dialog's `open` — the dialog half of
|
|
522
|
+
* {@link setCanvasDelinkPopovers}.
|
|
523
|
+
*
|
|
524
|
+
* A `<button command="show-modal" commandfor="…">` is the platform's own invoker: clicking it calls
|
|
525
|
+
* `showModal()` on its target, which puts the dialog in the top layer and makes the rest of the
|
|
526
|
+
* page INERT — the two things an editable canvas can least afford, since the first is the geometry
|
|
527
|
+
* the popover comment above describes and the second makes every other element unclickable. So on a
|
|
528
|
+
* stamped node `commandfor` is renamed to `data-jx-commandfor`: a button with `command` and no
|
|
529
|
+
* target does nothing, and the studio's frame reports the click instead, so the host's single
|
|
530
|
+
* writer of open state can answer it. `popovertarget` is left alone on purpose — its target has no
|
|
531
|
+
* `popover` attribute on the canvas, so the platform already does nothing with it.
|
|
532
|
+
*
|
|
533
|
+
* `inert` becomes `data-jx-inert` for the same reason: an author's inert region is a region the
|
|
534
|
+
* editor could not select into. And a `<dialog open>` becomes `<dialog data-jx-open>`, so the UA's
|
|
535
|
+
* `dialog:not([open]) { display: none }` keeps every dialog closed until the canvas opens ONE with
|
|
536
|
+
* `data-jx-dialog-open`, exactly as it opens one popover. `<details open>` is untouched: its `open`
|
|
537
|
+
* is content, not an overlay state.
|
|
538
|
+
*
|
|
539
|
+
* Gated on `data-jx-path` like the popover rewrite, and driving
|
|
540
|
+
* {@link transposeCanvasOverlaySelector} with it: an attribute renamed without its selectors
|
|
541
|
+
* transposed is a dialog that can never be styled open, or a region that can never be styled inert.
|
|
542
|
+
* Both renames travel with their selectors there — `[open]` on a dialog's own rules, `[inert]`
|
|
543
|
+
* wherever it appears.
|
|
544
|
+
*
|
|
545
|
+
* @docs framework/concepts/overlays
|
|
546
|
+
*/
|
|
547
|
+
let _canvasDelinkCommands = false;
|
|
548
|
+
export function setCanvasDelinkCommands(on: boolean) {
|
|
549
|
+
_canvasDelinkCommands = on;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Raised while a defined element renders its own children, so the canvas de-link reaches them.
|
|
554
|
+
*
|
|
555
|
+
* The de-link rules gate on `data-jx-path`, which a document node carries and an element's INTERNAL
|
|
556
|
+
* node never can: the internals belong to the definition, not to the page being edited, so the
|
|
557
|
+
* studio's stamper never sees them. Without this a kit element that declares `popover` on a panel
|
|
558
|
+
* inside itself opens a genuine top-layer popover inside an editable canvas, while Studio's single
|
|
559
|
+
* writer of open state learns nothing about it.
|
|
560
|
+
*
|
|
561
|
+
* A module-scoped flag is sound here because `canvasAttrName` is called once per attribute OUTSIDE
|
|
562
|
+
* the effect that binds it, and a definition renders its children synchronously — so the flag is
|
|
563
|
+
* never read across a suspension point.
|
|
564
|
+
*/
|
|
565
|
+
let _canvasInsideStampedHost = false;
|
|
566
|
+
|
|
567
|
+
/** Whether the node being rendered is inside a stamped host, and so part of the edited page. */
|
|
568
|
+
function canvasStamped(el: HTMLElement): boolean {
|
|
569
|
+
return _canvasInsideStampedHost || el.dataset.jxPath !== undefined;
|
|
570
|
+
}
|
|
571
|
+
|
|
332
572
|
/** The attribute name to stamp `key` on `el` under — `href` → `data-jx-href` on de-linked anchors. */
|
|
333
573
|
function canvasAttrName(el: HTMLElement, key: string): string {
|
|
334
574
|
if (_canvasDelinkAnchors && key === "href" && (el.tagName === "A" || el.tagName === "AREA")) {
|
|
335
575
|
return "data-jx-href";
|
|
336
576
|
}
|
|
577
|
+
/* `dataset.jxPath` is already stamped here: `onNodeCreated` fires before `applyAttributes` in
|
|
578
|
+
`renderNode`, and the studio's stamper writes the attribute synchronously inside it. That
|
|
579
|
+
ordering is an unwritten contract between two packages, so `runtime-canvas.test.ts` asserts it
|
|
580
|
+
directly rather than trusting it. */
|
|
581
|
+
if (_canvasDelinkPopovers && key === "popover" && canvasStamped(el)) {
|
|
582
|
+
return "data-jx-popover";
|
|
583
|
+
}
|
|
584
|
+
if (_canvasDelinkCommands && canvasStamped(el)) {
|
|
585
|
+
if (key === "commandfor") {
|
|
586
|
+
return "data-jx-commandfor";
|
|
587
|
+
}
|
|
588
|
+
if (key === "inert") {
|
|
589
|
+
return "data-jx-inert";
|
|
590
|
+
}
|
|
591
|
+
if (key === "open" && el.tagName === "DIALOG") {
|
|
592
|
+
return "data-jx-open";
|
|
593
|
+
}
|
|
594
|
+
}
|
|
337
595
|
return key;
|
|
338
596
|
}
|
|
339
597
|
|
|
@@ -505,22 +763,41 @@ function boundPropKey(key: string, val: unknown, state: JxScope): string | null
|
|
|
505
763
|
* @param {JxDocument} doc
|
|
506
764
|
* @param {JxScope} [parentScope] Default is `{}`
|
|
507
765
|
* @param {string} [base] Base URL for resolving $src imports. Default is `location.href`
|
|
766
|
+
* @param {JxContext} [ctx] The mount context; absent on the legacy direct path
|
|
508
767
|
* @returns {Promise<JxScope>} Reactive proxy (state)
|
|
509
768
|
*/
|
|
510
769
|
export async function buildScope(
|
|
511
770
|
doc: JxDocument,
|
|
512
771
|
parentScope: JxScope = {},
|
|
513
772
|
base: string = location.href,
|
|
773
|
+
ctx?: JxContext,
|
|
514
774
|
) {
|
|
775
|
+
// A body run without an event dispatches from the mount's root, read when it fires.
|
|
776
|
+
const dispatchRoot = (): EventTarget | null => ctx?.root ?? null;
|
|
515
777
|
const raw: JxScope = {};
|
|
778
|
+
const defs = doc.state ?? {};
|
|
516
779
|
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
780
|
+
/* Merge parent scope properties. A REACTIVE parent stays live: its members are read through
|
|
781
|
+
accessors rather than copied, so a host that hands in a reactive record as the scope sees the
|
|
782
|
+
document follow every field of it, not a snapshot of the primitives it held at mount time. A
|
|
783
|
+
member the document declares itself is copied instead, so the document's own entry can still
|
|
784
|
+
overwrite it — the document wins (embedding.md §3). */
|
|
785
|
+
const linked = isReactive(parentScope);
|
|
786
|
+
for (const key of Object.keys(parentScope)) {
|
|
787
|
+
if (linked && !(key in defs)) {
|
|
788
|
+
Object.defineProperty(raw, key, {
|
|
789
|
+
configurable: true,
|
|
790
|
+
enumerable: true,
|
|
791
|
+
get: () => parentScope[key],
|
|
792
|
+
set: (value: unknown) => {
|
|
793
|
+
parentScope[key] = value;
|
|
794
|
+
},
|
|
795
|
+
});
|
|
796
|
+
} else {
|
|
797
|
+
raw[key] = parentScope[key];
|
|
798
|
+
}
|
|
520
799
|
}
|
|
521
800
|
|
|
522
|
-
const defs = doc.state ?? {};
|
|
523
|
-
|
|
524
801
|
// Pass 0: resolve bare $prototype names via import map
|
|
525
802
|
const imports = doc.imports ?? {};
|
|
526
803
|
for (const [, def] of Object.entries(defs)) {
|
|
@@ -642,11 +919,11 @@ export async function buildScope(
|
|
|
642
919
|
? p.default
|
|
643
920
|
: argValues[i];
|
|
644
921
|
}
|
|
645
|
-
return runStatements(body, state, null, { args });
|
|
922
|
+
return runStatements(body, state, null, { args, target: dispatchRoot });
|
|
646
923
|
};
|
|
647
924
|
} else {
|
|
648
925
|
const handler: JxEventHandler = (s, event) => {
|
|
649
|
-
void runStatements(body, s, event ?? null);
|
|
926
|
+
void runStatements(body, s, event ?? null, { target: dispatchRoot });
|
|
650
927
|
};
|
|
651
928
|
state[key] = handler;
|
|
652
929
|
}
|
|
@@ -658,12 +935,12 @@ export async function buildScope(
|
|
|
658
935
|
// Fourth pass: other $prototype entries (Request, Set, Map, etc.)
|
|
659
936
|
for (const [key, def] of Object.entries(defs)) {
|
|
660
937
|
if (isPrototypeDef(def)) {
|
|
661
|
-
state[key] = await resolvePrototype(def, state, key, base);
|
|
938
|
+
state[key] = await resolvePrototype(def, state, key, base, ctx);
|
|
662
939
|
}
|
|
663
940
|
}
|
|
664
941
|
|
|
665
942
|
// Fifth pass: timing: "server" entries (dev mode — execute client-side, boundary unenforced)
|
|
666
|
-
if (!_serverFnConfig.skip) {
|
|
943
|
+
if (!(ctx?.skipServerFunctions ?? _serverFnConfig.skip)) {
|
|
667
944
|
for (const [key, def] of Object.entries(defs)) {
|
|
668
945
|
if (isServerFnDef(def)) {
|
|
669
946
|
state[key] = await resolveServerFunction(def, state, key, base);
|
|
@@ -671,10 +948,11 @@ export async function buildScope(
|
|
|
671
948
|
}
|
|
672
949
|
}
|
|
673
950
|
|
|
951
|
+
const rootMedia = ctx?.media ?? _rootMedia;
|
|
674
952
|
if (doc.$media) {
|
|
675
953
|
state["$media"] = doc.$media;
|
|
676
|
-
} else if (!state["$media"] && Object.keys(
|
|
677
|
-
state["$media"] =
|
|
954
|
+
} else if (!state["$media"] && Object.keys(rootMedia).length > 0) {
|
|
955
|
+
state["$media"] = rootMedia;
|
|
678
956
|
}
|
|
679
957
|
|
|
680
958
|
return state;
|
|
@@ -796,6 +1074,88 @@ interface ExternalClassInstance {
|
|
|
796
1074
|
/** Module cache for $src imports (shared with external class resolution). */
|
|
797
1075
|
const _moduleCache = new Map<string, ImportedModule>();
|
|
798
1076
|
|
|
1077
|
+
/** A module the host will import on first use, keyed as the document spells its `$src`. */
|
|
1078
|
+
type ModuleLoader = () => Promise<Record<string, unknown>>;
|
|
1079
|
+
|
|
1080
|
+
/**
|
|
1081
|
+
* Lazy registrations: a loader until it runs, then the in-flight load, so two entries resolving the
|
|
1082
|
+
* same sidecar in one tick share one import rather than racing two.
|
|
1083
|
+
*/
|
|
1084
|
+
const _moduleLoaders = new Map<string, ModuleLoader | Promise<ImportedModule>>();
|
|
1085
|
+
|
|
1086
|
+
/**
|
|
1087
|
+
* Seed the `$src` module cache with a module the host already imported, under the specifier the
|
|
1088
|
+
* document spells. A bundled document cannot have its sidecar fetched by URL — the bundler saw
|
|
1089
|
+
* neither the string nor the file — so the host imports the sidecar itself and registers it here,
|
|
1090
|
+
* and `$src` then resolves exactly as it would have from the network.
|
|
1091
|
+
*
|
|
1092
|
+
* Given a FUNCTION instead of a namespace, the registration is lazy: nothing is imported until a
|
|
1093
|
+
* document names the specifier, and the loader runs once. That is the shape for a host that can
|
|
1094
|
+
* bundle a sidecar as its own chunk but should not pay for it until something renders — Studio's
|
|
1095
|
+
* canvas frame registers every kit behaviour this way, so a page that uses no kit element loads
|
|
1096
|
+
* none of them (embedding.md §6).
|
|
1097
|
+
*
|
|
1098
|
+
* @param {string} specifier - The `$src` value as written in the document
|
|
1099
|
+
* @param {Record<string, unknown> | (() => Promise<Record<string, unknown>>)} mod - The imported
|
|
1100
|
+
* module namespace, or a loader that imports it on first use
|
|
1101
|
+
*/
|
|
1102
|
+
export function preloadModule(
|
|
1103
|
+
specifier: string,
|
|
1104
|
+
mod: Record<string, unknown> | ModuleLoader,
|
|
1105
|
+
): void {
|
|
1106
|
+
if (typeof mod === "function") {
|
|
1107
|
+
_moduleCache.delete(specifier);
|
|
1108
|
+
_moduleLoaders.set(specifier, mod);
|
|
1109
|
+
return;
|
|
1110
|
+
}
|
|
1111
|
+
_moduleLoaders.delete(specifier);
|
|
1112
|
+
_moduleCache.set(specifier, mod as ImportedModule);
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
/**
|
|
1116
|
+
* The module a specifier was seeded with — imported, or lazily registered — or undefined when the
|
|
1117
|
+
* host said nothing about it and the network is the answer.
|
|
1118
|
+
*
|
|
1119
|
+
* SYNCHRONOUS for a namespace the host imported, and that is load-bearing rather than tidy: a cache
|
|
1120
|
+
* hit used to be a `Map#has` on the way through `resolveFunction`, and an `await` there — even of a
|
|
1121
|
+
* value that is not a promise — defers one microtask, which reorders every element's scope
|
|
1122
|
+
* construction against its siblings'. The kit's field naming its slotted control is one thing that
|
|
1123
|
+
* order decides. Only a loader is a promise, because only a loader has to be.
|
|
1124
|
+
*
|
|
1125
|
+
* @param {string} src
|
|
1126
|
+
* @returns {ImportedModule | Promise<ImportedModule> | undefined}
|
|
1127
|
+
*/
|
|
1128
|
+
function seededModule(src: string): ImportedModule | Promise<ImportedModule> | undefined {
|
|
1129
|
+
const cached = _moduleCache.get(src);
|
|
1130
|
+
if (cached) {
|
|
1131
|
+
return cached;
|
|
1132
|
+
}
|
|
1133
|
+
const pending = _moduleLoaders.get(src);
|
|
1134
|
+
if (!pending) {
|
|
1135
|
+
return undefined;
|
|
1136
|
+
}
|
|
1137
|
+
if (typeof pending !== "function") {
|
|
1138
|
+
return pending;
|
|
1139
|
+
}
|
|
1140
|
+
/* `Promise.resolve` so a thenable from a host's loader is still a Promise the call sites can
|
|
1141
|
+
tell from a namespace. */
|
|
1142
|
+
const load = Promise.resolve(pending()).then(
|
|
1143
|
+
(mod) => {
|
|
1144
|
+
_moduleCache.set(src, mod as ImportedModule);
|
|
1145
|
+
_moduleLoaders.delete(src);
|
|
1146
|
+
return mod as ImportedModule;
|
|
1147
|
+
},
|
|
1148
|
+
(error: unknown) => {
|
|
1149
|
+
/* A failed load — a chunk the network dropped — puts the loader back, so the next document to
|
|
1150
|
+
name the specifier tries again rather than inheriting this rejection for good. */
|
|
1151
|
+
_moduleLoaders.set(src, pending);
|
|
1152
|
+
throw error;
|
|
1153
|
+
},
|
|
1154
|
+
);
|
|
1155
|
+
_moduleLoaders.set(src, load);
|
|
1156
|
+
return load;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
799
1159
|
/**
|
|
800
1160
|
* Resolve a $prototype: "Function" entry into a function or computed.
|
|
801
1161
|
*
|
|
@@ -839,8 +1199,11 @@ async function resolveFunction(def: JxFunctionDef, state: JxScope, key: string,
|
|
|
839
1199
|
}
|
|
840
1200
|
const exportName = def.$export ?? key;
|
|
841
1201
|
let mod: ImportedModule;
|
|
842
|
-
|
|
843
|
-
|
|
1202
|
+
const seeded = seededModule(src);
|
|
1203
|
+
if (seeded instanceof Promise) {
|
|
1204
|
+
mod = await seeded;
|
|
1205
|
+
} else if (seeded) {
|
|
1206
|
+
mod = seeded;
|
|
844
1207
|
} else {
|
|
845
1208
|
if (base) {
|
|
846
1209
|
const resolvedSrc = new URL(src, base).href;
|
|
@@ -1072,6 +1435,12 @@ export function renderNode(
|
|
|
1072
1435
|
}
|
|
1073
1436
|
|
|
1074
1437
|
applyProperties(el, def, localState);
|
|
1438
|
+
/* A custom element's own definition applies its style at connection, through the same interning
|
|
1439
|
+
path — which releases whatever is already on the element. So the call site's style is
|
|
1440
|
+
remembered here and merged there; see {@link callSiteStyles}. */
|
|
1441
|
+
if (def.style && tagName.includes("-")) {
|
|
1442
|
+
callSiteStyles.set(el, def.style);
|
|
1443
|
+
}
|
|
1075
1444
|
applyStyle(
|
|
1076
1445
|
el,
|
|
1077
1446
|
def.style ?? {},
|
|
@@ -1130,47 +1499,75 @@ function applyProperties(el: HTMLElement, def: JxElement, state: JxScope) {
|
|
|
1130
1499
|
continue;
|
|
1131
1500
|
} // Scope bindings — handled in renderNode
|
|
1132
1501
|
|
|
1133
|
-
if (key.startsWith("on")) {
|
|
1134
|
-
|
|
1135
|
-
if (isRefObj(val)) {
|
|
1136
|
-
const handler = resolveRef(val.$ref, state);
|
|
1137
|
-
if (typeof handler === "function") {
|
|
1138
|
-
const scope = state;
|
|
1139
|
-
const handlerFn = handler as (s: JxScope, e: Event) => unknown;
|
|
1140
|
-
el.addEventListener(key.slice(2), (e) => handlerFn(scope, e));
|
|
1141
|
-
}
|
|
1142
|
-
continue;
|
|
1143
|
-
}
|
|
1144
|
-
// Event handler: inline $prototype: "Function" with a structured body (spec §20)
|
|
1145
|
-
if (hasStructuredBody(val)) {
|
|
1146
|
-
const { body } = val;
|
|
1147
|
-
const scope = state;
|
|
1148
|
-
el.addEventListener(key.slice(2), (e) => {
|
|
1149
|
-
void runStatements(body, scope, e);
|
|
1150
|
-
});
|
|
1151
|
-
continue;
|
|
1152
|
-
}
|
|
1153
|
-
// Event handler: inline $prototype: "Function"
|
|
1154
|
-
if (isFunctionDef(val) && typeof val.body === "string") {
|
|
1155
|
-
const params = resolveParamNames(val);
|
|
1156
|
-
const fn = new Function(...params, val.body) as (s: JxScope, e: Event) => unknown;
|
|
1157
|
-
const scope = state;
|
|
1158
|
-
el.addEventListener(key.slice(2), (e) => fn(scope, e));
|
|
1159
|
-
continue;
|
|
1160
|
-
}
|
|
1161
|
-
// Event handler: inline $expression
|
|
1162
|
-
if (isExpressionDef(val)) {
|
|
1163
|
-
const node = val.$expression;
|
|
1164
|
-
const scope = state;
|
|
1165
|
-
el.addEventListener(key.slice(2), (e) => evaluateExpression(node, scope, e));
|
|
1166
|
-
continue;
|
|
1167
|
-
}
|
|
1502
|
+
if (key.startsWith("on") && bindHandler(el, key, val, state)) {
|
|
1503
|
+
continue;
|
|
1168
1504
|
}
|
|
1169
1505
|
|
|
1170
1506
|
bindProperty(el, key, val, state);
|
|
1171
1507
|
}
|
|
1172
1508
|
}
|
|
1173
1509
|
|
|
1510
|
+
/**
|
|
1511
|
+
* Attach an `on*` key as an event listener, in any of the four spellings a handler has: a `$ref` to
|
|
1512
|
+
* a function, a structured body (spec §20), a string body, or an `$expression`. Returns false for a
|
|
1513
|
+
* value that is none of those, which the caller then writes as a plain property.
|
|
1514
|
+
*
|
|
1515
|
+
* @param {HTMLElement} el
|
|
1516
|
+
* @param {string} key The `on`-prefixed key
|
|
1517
|
+
* @param {unknown} val
|
|
1518
|
+
* @param {JxScope} state
|
|
1519
|
+
* @returns {boolean} Whether a listener was attached
|
|
1520
|
+
*/
|
|
1521
|
+
function bindHandler(el: HTMLElement, key: string, val: unknown, state: JxScope): boolean {
|
|
1522
|
+
const type = key.slice(2);
|
|
1523
|
+
const scope = state;
|
|
1524
|
+
if (isRefObj(val)) {
|
|
1525
|
+
const handler = resolveRef(val.$ref, state);
|
|
1526
|
+
if (typeof handler === "function") {
|
|
1527
|
+
const handlerFn = handler as (s: JxScope, e: Event) => unknown;
|
|
1528
|
+
el.addEventListener(type, (e) => handlerFn(scope, e));
|
|
1529
|
+
}
|
|
1530
|
+
return true;
|
|
1531
|
+
}
|
|
1532
|
+
if (hasStructuredBody(val)) {
|
|
1533
|
+
const { body } = val;
|
|
1534
|
+
el.addEventListener(type, (e) => {
|
|
1535
|
+
void runStatements(body, scope, e);
|
|
1536
|
+
});
|
|
1537
|
+
return true;
|
|
1538
|
+
}
|
|
1539
|
+
if (isFunctionDef(val) && typeof val.body === "string") {
|
|
1540
|
+
const params = resolveParamNames(val);
|
|
1541
|
+
const fn = new Function(...params, val.body) as (s: JxScope, e: Event) => unknown;
|
|
1542
|
+
el.addEventListener(type, (e) => fn(scope, e));
|
|
1543
|
+
return true;
|
|
1544
|
+
}
|
|
1545
|
+
if (isExpressionDef(val)) {
|
|
1546
|
+
const node = val.$expression;
|
|
1547
|
+
el.addEventListener(type, (e) => evaluateExpression(node, scope, e));
|
|
1548
|
+
return true;
|
|
1549
|
+
}
|
|
1550
|
+
return false;
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
/**
|
|
1554
|
+
* A definition's root-level `on*` keys listen on the HOST element, exactly as they would on an
|
|
1555
|
+
* element in a document. Only handler keys are read here: the rest of a definition's root —
|
|
1556
|
+
* `observedAttributes`, `emits`, `description` — describes the element rather than styling an
|
|
1557
|
+
* instance, and is not written onto it.
|
|
1558
|
+
*
|
|
1559
|
+
* @param {HTMLElement} host
|
|
1560
|
+
* @param {JxElement} def
|
|
1561
|
+
* @param {JxScope} state
|
|
1562
|
+
*/
|
|
1563
|
+
function bindDefinitionHandlers(host: HTMLElement, def: JxElement, state: JxScope) {
|
|
1564
|
+
for (const [key, val] of Object.entries(def)) {
|
|
1565
|
+
if (key.startsWith("on") && !RESERVED_KEYS.has(key)) {
|
|
1566
|
+
bindHandler(host, key, val, state);
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1174
1571
|
/**
|
|
1175
1572
|
* @param {HTMLElement} el
|
|
1176
1573
|
* @param {string} key
|
|
@@ -1199,6 +1596,13 @@ function bindProperty(el: HTMLElement, key: string, val: unknown, state: JxScope
|
|
|
1199
1596
|
node.setAttribute("class", resolved == null ? "" : String(resolved));
|
|
1200
1597
|
return;
|
|
1201
1598
|
}
|
|
1599
|
+
/* An equal write is skipped. A binding re-runs whenever anything it read changes, not only
|
|
1600
|
+
when its own result does, and re-setting `value` on a focused control is not free: it can
|
|
1601
|
+
move the caret and collapse the selection. A control that already holds the value has
|
|
1602
|
+
nothing to learn from being told again. */
|
|
1603
|
+
if (target[key] === resolved) {
|
|
1604
|
+
return;
|
|
1605
|
+
}
|
|
1202
1606
|
target[key] = resolved;
|
|
1203
1607
|
};
|
|
1204
1608
|
if (isRefObj(val)) {
|
|
@@ -1224,193 +1628,543 @@ function bindProperty(el: HTMLElement, key: string, val: unknown, state: JxScope
|
|
|
1224
1628
|
write(asAsset(val));
|
|
1225
1629
|
}
|
|
1226
1630
|
|
|
1631
|
+
/*
|
|
1632
|
+
* ─── The Stylesheet Engine ───────────────────────────────────────────────────
|
|
1633
|
+
*
|
|
1634
|
+
* An authored `style` object becomes REAL CSS RULES, delivered through the document's
|
|
1635
|
+
* `adoptedStyleSheets`. Everything below exists to make that true, and the reason it has to be
|
|
1636
|
+
* true is a cascade inversion rather than a missing feature.
|
|
1637
|
+
*
|
|
1638
|
+
* `applyStyle` used to split one style object across two cascade ORIGINS: a top-level scalar went
|
|
1639
|
+
* inline (`el.style.color = …`) while an object-valued key became a per-element `<style>` tag. An
|
|
1640
|
+
* inline declaration beats any non-`!important` rule, so
|
|
1641
|
+
*
|
|
1642
|
+
* { "backgroundColor": "#6e0303", ":hover": { "backgroundColor": "#15164a" } }
|
|
1643
|
+
*
|
|
1644
|
+
* emitted a perfectly correct `:hover` rule that could never win. Nesting was not missing; it was
|
|
1645
|
+
* outranked. The one existing defence, `mediaOverriddenProps`, moved a base property out of inline
|
|
1646
|
+
* when an `@`-block overrode it — one level deep, `@`-keys only, never `:hover` — which is the
|
|
1647
|
+
* shape of the bug rather than a fix for it. Every style assertion in the suite read the `<style>`
|
|
1648
|
+
* tag's text, so the inversion was invisible to all of them.
|
|
1649
|
+
*
|
|
1650
|
+
* With every declaration in a rule, `:hover` versus base is an ordinary equal-specificity pair and
|
|
1651
|
+
* source order decides, exactly as it does on the compiled page.
|
|
1652
|
+
*
|
|
1653
|
+
* **Three sheet roles, because mutation cost scales with sheet size.** Measured in Chrome 152 over
|
|
1654
|
+
* 500 styled elements: 500 `<style>` tags cost 86.0ms to build, one adopted sheet with hash dedup
|
|
1655
|
+
* costs 1.7ms, and 500 separate adopted sheets cost 6.2ms to build but 84.1ms per style recalc
|
|
1656
|
+
* because each one is walked. On the update path (300 value edits, the Style-panel hot path)
|
|
1657
|
+
* mutating a rule inside one big shared sheet costs 295.7ms, because Chrome rebuilds the whole
|
|
1658
|
+
* sheet's RuleSet on any mutation. So:
|
|
1659
|
+
*
|
|
1660
|
+
* - the SHARED sheet holds every static, interned rule set and is grown but never rewritten;
|
|
1661
|
+
* - a reactive declaration is indirected through a custom property, so the rule is static text
|
|
1662
|
+
* and the update writes `el.style.setProperty("--jx-r0-0", v)` — 12.4ms, and independent of
|
|
1663
|
+
* sheet size (the same mechanism that makes `${…}` work inside a nested block at all);
|
|
1664
|
+
* - the SCRATCH sheet takes the elements under live Studio edit and is rewritten with
|
|
1665
|
+
* `replaceSync`, 7.5ms per 100 structural edits against 76.9ms for delete+insert in the big one.
|
|
1666
|
+
*
|
|
1667
|
+
* **The handle stays `data-jx`, and its value becomes a content hash.** Not a generated class:
|
|
1668
|
+
* `applyAttributes` runs after `applyStyle`, so a static `attributes: { class: … }` would wipe a
|
|
1669
|
+
* class handle. `data-jx` is already there, is specificity (0,1,0), and is not author-writable.
|
|
1670
|
+
* Hashing the built rule text rather than calling `Math.random()` is what gives dedup and SSR
|
|
1671
|
+
* stability, and it covers everything that varies per call site by construction — the authored
|
|
1672
|
+
* object, the resolved `$media` map and the canvas transposer state are all inputs to that text.
|
|
1673
|
+
*
|
|
1674
|
+
* **Jx emits no cascade layer of its own.** Third-party CSS arrives through `$head` unlayered, and
|
|
1675
|
+
* an unlayered rule beats a layered one at any specificity — so layering Jx's own rules would hand
|
|
1676
|
+
* every page's `$head` a win it does not have today. It is also what keeps the canvas's
|
|
1677
|
+
* `@layer jx-canvas-ua` UA emulation working now that base declarations are unlayered rules rather
|
|
1678
|
+
* than inline styles: unlayered still beats layered, which is the same direction author origin
|
|
1679
|
+
* beats UA origin on the shipped page.
|
|
1680
|
+
*/
|
|
1681
|
+
|
|
1682
|
+
/** A rule set as it was written into a sheet: the texts, plus the live rules when there are any. */
|
|
1683
|
+
interface InsertedRules {
|
|
1684
|
+
texts: readonly string[];
|
|
1685
|
+
rules: CSSRule[];
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
/** A place rules can be written. Two implementations: the CSSOM, and `<style>` text. */
|
|
1689
|
+
interface StyleSink {
|
|
1690
|
+
insert: (texts: readonly string[]) => InsertedRules;
|
|
1691
|
+
remove: (inserted: InsertedRules) => void;
|
|
1692
|
+
/** Rewrite the whole sink. Used only by the scratch sheet. */
|
|
1693
|
+
replaceAll: (texts: readonly string[]) => void;
|
|
1694
|
+
/** Everything written, in sheet order. See {@link documentStyleText}. */
|
|
1695
|
+
text: () => string;
|
|
1696
|
+
detach: () => void;
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1227
1699
|
/**
|
|
1228
|
-
*
|
|
1229
|
-
* breakpoint rules.
|
|
1700
|
+
* A sink over a real `CSSStyleSheet` — constructable and adopted, or a `<style>` element's own.
|
|
1230
1701
|
*
|
|
1231
|
-
* @param {
|
|
1232
|
-
* @param {
|
|
1233
|
-
* @
|
|
1234
|
-
* `{}`
|
|
1235
|
-
* @param {JxScope} [state] Component scope for template string evaluation. Default is `{}`
|
|
1702
|
+
* @param {CSSStyleSheet} sheet
|
|
1703
|
+
* @param {() => void} detach
|
|
1704
|
+
* @returns {StyleSink}
|
|
1236
1705
|
*/
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
!k.startsWith("[") &&
|
|
1257
|
-
!k.startsWith("@")
|
|
1258
|
-
) {
|
|
1259
|
-
mediaOverriddenProps.add(k);
|
|
1706
|
+
function cssomSink(sheet: CSSStyleSheet, detach: () => void): StyleSink {
|
|
1707
|
+
const entries: InsertedRules[] = [];
|
|
1708
|
+
return {
|
|
1709
|
+
detach,
|
|
1710
|
+
insert(texts) {
|
|
1711
|
+
const rules: CSSRule[] = [];
|
|
1712
|
+
for (const text of texts) {
|
|
1713
|
+
const index = sheet.cssRules.length;
|
|
1714
|
+
try {
|
|
1715
|
+
sheet.insertRule(text, index);
|
|
1716
|
+
} catch {
|
|
1717
|
+
/* One unparseable rule must not take the other twenty with it. A `<style>` element drops
|
|
1718
|
+
exactly the offending rule and keeps going; `insertRule` throws, so this restores the
|
|
1719
|
+
tolerance an author's stylesheet has always had. */
|
|
1720
|
+
continue;
|
|
1721
|
+
}
|
|
1722
|
+
const rule = sheet.cssRules[index];
|
|
1723
|
+
if (rule) {
|
|
1724
|
+
rules.push(rule);
|
|
1260
1725
|
}
|
|
1261
1726
|
}
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
nested[prop] = val;
|
|
1727
|
+
const inserted: InsertedRules = { rules, texts };
|
|
1728
|
+
entries.push(inserted);
|
|
1729
|
+
return inserted;
|
|
1730
|
+
},
|
|
1731
|
+
remove(inserted) {
|
|
1732
|
+
const at = entries.indexOf(inserted);
|
|
1733
|
+
if (at !== -1) {
|
|
1734
|
+
entries.splice(at, 1);
|
|
1271
1735
|
}
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
if (prop.startsWith("@") || isNestedSelector(prop)) {
|
|
1275
|
-
// Scalar under a selector/media key — invalid style shape, drop it.
|
|
1276
|
-
continue;
|
|
1277
|
-
}
|
|
1278
|
-
if (val === undefined) {
|
|
1279
|
-
continue;
|
|
1280
|
-
}
|
|
1281
|
-
const scalar = String(val);
|
|
1282
|
-
if (prop.startsWith("--")) {
|
|
1283
|
-
if (isTemplateString(val)) {
|
|
1284
|
-
effect(() => {
|
|
1285
|
-
el.style.setProperty(prop, canvasStyleValue(evaluateTemplate(val, state)));
|
|
1286
|
-
});
|
|
1287
|
-
} else {
|
|
1288
|
-
el.style.setProperty(prop, canvasStyleValue(scalar));
|
|
1736
|
+
if (inserted.rules.length === 0) {
|
|
1737
|
+
return;
|
|
1289
1738
|
}
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1739
|
+
/* An index scan of a 302-rule sheet measures 0.04ms, so finding the rules costs nothing next
|
|
1740
|
+
to the RuleSet rebuild the deletion itself forces. Descending, or each deletion shifts the
|
|
1741
|
+
indices still to be removed. */
|
|
1742
|
+
const list = [...sheet.cssRules];
|
|
1743
|
+
const indices = inserted.rules
|
|
1744
|
+
.map((rule) => list.indexOf(rule))
|
|
1745
|
+
.filter((index) => index >= 0)
|
|
1746
|
+
.toSorted((a, b) => b - a);
|
|
1747
|
+
for (const index of indices) {
|
|
1748
|
+
sheet.deleteRule(index);
|
|
1749
|
+
}
|
|
1750
|
+
inserted.rules.length = 0;
|
|
1751
|
+
},
|
|
1752
|
+
replaceAll(texts) {
|
|
1753
|
+
entries.length = 0;
|
|
1754
|
+
entries.push({ rules: [], texts });
|
|
1755
|
+
sheet.replaceSync(texts.join("\n"));
|
|
1756
|
+
},
|
|
1757
|
+
text() {
|
|
1758
|
+
return entries.flatMap((entry) => entry.texts.join("\n")).join("\n");
|
|
1759
|
+
},
|
|
1760
|
+
};
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
/**
|
|
1764
|
+
* The last resort: a `<style>` element whose text is rewritten. No CSSOM required.
|
|
1765
|
+
*
|
|
1766
|
+
* @param {HTMLStyleElement} tag
|
|
1767
|
+
* @returns {StyleSink}
|
|
1768
|
+
*/
|
|
1769
|
+
function textSink(tag: HTMLStyleElement): StyleSink {
|
|
1770
|
+
const entries: InsertedRules[] = [];
|
|
1771
|
+
const write = () => {
|
|
1772
|
+
tag.textContent = entries.map((entry) => entry.texts.join("\n")).join("\n");
|
|
1773
|
+
};
|
|
1774
|
+
return {
|
|
1775
|
+
detach() {
|
|
1776
|
+
tag.remove();
|
|
1777
|
+
},
|
|
1778
|
+
insert(texts) {
|
|
1779
|
+
const inserted: InsertedRules = { rules: [], texts };
|
|
1780
|
+
entries.push(inserted);
|
|
1781
|
+
write();
|
|
1782
|
+
return inserted;
|
|
1783
|
+
},
|
|
1784
|
+
remove(inserted) {
|
|
1785
|
+
const index = entries.indexOf(inserted);
|
|
1786
|
+
if (index !== -1) {
|
|
1787
|
+
entries.splice(index, 1);
|
|
1788
|
+
write();
|
|
1789
|
+
}
|
|
1790
|
+
},
|
|
1791
|
+
replaceAll(texts) {
|
|
1792
|
+
entries.length = 0;
|
|
1793
|
+
entries.push({ rules: [], texts });
|
|
1794
|
+
write();
|
|
1795
|
+
},
|
|
1796
|
+
text() {
|
|
1797
|
+
return tag.textContent ?? "";
|
|
1798
|
+
},
|
|
1799
|
+
};
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
/**
|
|
1803
|
+
* Open a sheet on `doc`, preferring a constructable one.
|
|
1804
|
+
*
|
|
1805
|
+
* A constructable stylesheet cannot be adopted into another document, which is why the registry
|
|
1806
|
+
* below is keyed on the document and never cached at module scope: the Studio iframe, the live
|
|
1807
|
+
* preview tab and the parent page each open their own.
|
|
1808
|
+
*
|
|
1809
|
+
* @param {Document} doc
|
|
1810
|
+
* @returns {StyleSink}
|
|
1811
|
+
*/
|
|
1812
|
+
function openSink(doc: Document): StyleSink {
|
|
1813
|
+
const view = doc.defaultView as (Window & { CSSStyleSheet?: typeof CSSStyleSheet }) | null;
|
|
1814
|
+
const SheetConstructor = view?.CSSStyleSheet;
|
|
1815
|
+
if (typeof SheetConstructor === "function" && Array.isArray(doc.adoptedStyleSheets)) {
|
|
1816
|
+
try {
|
|
1817
|
+
const sheet = new SheetConstructor();
|
|
1818
|
+
doc.adoptedStyleSheets = [...doc.adoptedStyleSheets, sheet];
|
|
1819
|
+
return cssomSink(sheet, () => {
|
|
1820
|
+
doc.adoptedStyleSheets = doc.adoptedStyleSheets.filter((s) => s !== sheet);
|
|
1295
1821
|
});
|
|
1296
|
-
}
|
|
1297
|
-
//
|
|
1298
|
-
baseDecls[prop] = scalar;
|
|
1299
|
-
} else {
|
|
1300
|
-
(el.style as unknown as Record<string, string>)[prop] = canvasStyleValue(scalar);
|
|
1822
|
+
} catch {
|
|
1823
|
+
// No constructable stylesheets, or a frozen adoptedStyleSheets. Fall through to a tag.
|
|
1301
1824
|
}
|
|
1302
1825
|
}
|
|
1826
|
+
const tag = doc.createElement("style");
|
|
1827
|
+
tag.dataset.jxSheet = "";
|
|
1828
|
+
(doc.head ?? doc.documentElement).append(tag);
|
|
1829
|
+
return tag.sheet ? cssomSink(tag.sheet, () => tag.remove()) : textSink(tag);
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
/** One interned rule set: the rules as written, and how many elements point at them. */
|
|
1833
|
+
interface InternEntry {
|
|
1834
|
+
inserted: InsertedRules;
|
|
1835
|
+
refs: number;
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
/** Everything one document's rules are kept in. */
|
|
1839
|
+
interface SheetState {
|
|
1840
|
+
shared: StyleSink;
|
|
1841
|
+
/** Opened on the first live edit, and only then — most documents never have one. */
|
|
1842
|
+
scratch: StyleSink | null;
|
|
1843
|
+
/** Elements living on the scratch sheet, in the order their rules are written. */
|
|
1844
|
+
scratchOwners: Map<HTMLElement, readonly string[]>;
|
|
1845
|
+
/** Scope handle to the rule set it names. */
|
|
1846
|
+
interned: Map<string, InternEntry>;
|
|
1847
|
+
/** Declaration-body at-rule text to the one copy of it. */
|
|
1848
|
+
hoisted: Map<string, InternEntry>;
|
|
1849
|
+
/** Distinguishes `--jx-r<serial>-<n>` variables between elements. */
|
|
1850
|
+
serial: number;
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
/**
|
|
1854
|
+
* The per-document registry. A `WeakMap` rather than a module-level singleton because one process
|
|
1855
|
+
* renders into several documents (canvas iframe, preview tab, parent) and a constructable sheet
|
|
1856
|
+
* belongs to exactly one of them.
|
|
1857
|
+
*/
|
|
1858
|
+
const sheetStates = new WeakMap<Document, SheetState>();
|
|
1859
|
+
|
|
1860
|
+
/**
|
|
1861
|
+
* @param {Document} doc
|
|
1862
|
+
* @returns {SheetState}
|
|
1863
|
+
*/
|
|
1864
|
+
function sheetStateFor(doc: Document): SheetState {
|
|
1865
|
+
const existing = sheetStates.get(doc);
|
|
1866
|
+
if (existing) {
|
|
1867
|
+
return existing;
|
|
1868
|
+
}
|
|
1869
|
+
const created: SheetState = {
|
|
1870
|
+
hoisted: new Map(),
|
|
1871
|
+
interned: new Map(),
|
|
1872
|
+
scratch: null,
|
|
1873
|
+
scratchOwners: new Map(),
|
|
1874
|
+
serial: 0,
|
|
1875
|
+
shared: openSink(doc),
|
|
1876
|
+
};
|
|
1877
|
+
sheetStates.set(doc, created);
|
|
1878
|
+
return created;
|
|
1879
|
+
}
|
|
1303
1880
|
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1881
|
+
/** What one element holds, so that releasing it is exact rather than approximate. */
|
|
1882
|
+
interface ElementStyleEntry {
|
|
1883
|
+
doc: Document;
|
|
1884
|
+
/** The state this entry's handles belong to; a reset replaces it and orphans them. */
|
|
1885
|
+
state: SheetState;
|
|
1886
|
+
uid: string;
|
|
1887
|
+
/** The interning key in the shared sheet, or null when the element is on the scratch sheet. */
|
|
1888
|
+
interned: string | null;
|
|
1889
|
+
/** Hoisted declaration at-rules this element holds a reference to. */
|
|
1890
|
+
hoisted: readonly string[];
|
|
1891
|
+
/** The `--jx-r…` custom properties its effects write inline. */
|
|
1892
|
+
vars: readonly string[];
|
|
1893
|
+
/** Stops those effects. */
|
|
1894
|
+
stop: (() => void) | null;
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
/**
|
|
1898
|
+
* Per-element style bookkeeping. The replacement for the old `elementStyleTags`, which exported a
|
|
1899
|
+
* `<style>` element and let its importers remove it by hand; {@link releaseElementStyles} is the
|
|
1900
|
+
* documented teardown in its place, because a rule set is now refcounted and dropping one by hand
|
|
1901
|
+
* would either leak it or take it away from another element that shares it.
|
|
1902
|
+
*/
|
|
1903
|
+
const elementStyles = new WeakMap<HTMLElement, ElementStyleEntry>();
|
|
1904
|
+
|
|
1905
|
+
/** The scope handle `buildStyleRules` writes, before the hash of its own output replaces it. */
|
|
1906
|
+
const SCOPE_TOKEN = "jx-scope";
|
|
1907
|
+
|
|
1908
|
+
/**
|
|
1909
|
+
* Rewrite the scratch sheet from its owners.
|
|
1910
|
+
*
|
|
1911
|
+
* `scratchOwners` is a STRONG map — it has to be, since the sheet's whole text is rebuilt from it —
|
|
1912
|
+
* so an element only leaves it through {@link releaseElementStyles}. That is not a hole: every way
|
|
1913
|
+
* an edited element can go away calls it. `applyStyle` registers it with the enclosing reactive
|
|
1914
|
+
* scope, Studio's `disposeSubtree` calls it per element, and `resetDocumentStyles` drops the map
|
|
1915
|
+
* wholesale. Sweeping on `isConnected` instead would be wrong rather than merely redundant: an
|
|
1916
|
+
* element is routinely styled before it is inserted.
|
|
1917
|
+
*
|
|
1918
|
+
* @param {SheetState} state
|
|
1919
|
+
*/
|
|
1920
|
+
function rewriteScratch(state: SheetState) {
|
|
1921
|
+
if (!state.scratch) {
|
|
1308
1922
|
return;
|
|
1309
1923
|
}
|
|
1924
|
+
state.scratch.replaceAll([...state.scratchOwners.values()].flat());
|
|
1925
|
+
}
|
|
1310
1926
|
|
|
1311
|
-
|
|
1312
|
-
|
|
1927
|
+
/**
|
|
1928
|
+
* Drop one reference to an interned rule set, removing its rules when the last one goes.
|
|
1929
|
+
*
|
|
1930
|
+
* @param {SheetState} state
|
|
1931
|
+
* @param {Map<string, InternEntry>} table
|
|
1932
|
+
* @param {string} key
|
|
1933
|
+
*/
|
|
1934
|
+
function releaseInterned(state: SheetState, table: Map<string, InternEntry>, key: string) {
|
|
1935
|
+
const entry = table.get(key);
|
|
1936
|
+
if (!entry) {
|
|
1937
|
+
return;
|
|
1938
|
+
}
|
|
1939
|
+
entry.refs -= 1;
|
|
1940
|
+
if (entry.refs <= 0) {
|
|
1941
|
+
table.delete(key);
|
|
1942
|
+
state.shared.remove(entry.inserted);
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1313
1945
|
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1946
|
+
/**
|
|
1947
|
+
* Take one reference to a rule set, inserting it the first time it is asked for.
|
|
1948
|
+
*
|
|
1949
|
+
* @param {SheetState} state
|
|
1950
|
+
* @param {Map<string, InternEntry>} table
|
|
1951
|
+
* @param {string} key
|
|
1952
|
+
* @param {readonly string[]} texts
|
|
1953
|
+
*/
|
|
1954
|
+
function retainInterned(
|
|
1955
|
+
state: SheetState,
|
|
1956
|
+
table: Map<string, InternEntry>,
|
|
1957
|
+
key: string,
|
|
1958
|
+
texts: readonly string[],
|
|
1959
|
+
) {
|
|
1960
|
+
const existing = table.get(key);
|
|
1961
|
+
if (existing) {
|
|
1962
|
+
existing.refs += 1;
|
|
1963
|
+
return;
|
|
1318
1964
|
}
|
|
1965
|
+
table.set(key, { inserted: state.shared.insert(texts), refs: 1 });
|
|
1966
|
+
}
|
|
1319
1967
|
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
for (const [sel, sub] of Object.entries(obj)) {
|
|
1356
|
-
if (sub === null || typeof sub !== "object" || Array.isArray(sub)) {
|
|
1357
|
-
continue;
|
|
1358
|
-
}
|
|
1359
|
-
if (sel.startsWith("@")) {
|
|
1360
|
-
continue;
|
|
1361
|
-
}
|
|
1362
|
-
const resolved = sel.startsWith("&")
|
|
1363
|
-
? sel.replace("&", parentSel)
|
|
1364
|
-
: sel.startsWith("[")
|
|
1365
|
-
? `${parentSel}${sel}`
|
|
1366
|
-
: sel.startsWith(":") || sel.startsWith(".")
|
|
1367
|
-
? `${parentSel}${sel}`
|
|
1368
|
-
: `${parentSel} ${sel}`;
|
|
1369
|
-
const props = toCSSText(sub);
|
|
1370
|
-
if (props) {
|
|
1371
|
-
css += `${atRule} { ${resolved} { ${props} } }\n`;
|
|
1372
|
-
}
|
|
1373
|
-
emitMediaNested(atRule, resolved, sub);
|
|
1968
|
+
/**
|
|
1969
|
+
* Release everything {@link applyStyle} gave an element: its rules, its scope handle, and the inline
|
|
1970
|
+
* custom properties and effects behind its reactive declarations.
|
|
1971
|
+
*
|
|
1972
|
+
* This is the teardown API `elementStyleTags` used to stand in for, and it is exported because
|
|
1973
|
+
* removing a rule set by hand is no longer possible: sets are shared between elements that style
|
|
1974
|
+
* alike, so the only safe removal is a refcounted one. Calling it on an element that has no styles
|
|
1975
|
+
* is a no-op, which is what makes `applyStyle` safe to call twice.
|
|
1976
|
+
*
|
|
1977
|
+
* Callers rarely need it. `applyStyle` registers it with the enclosing reactive scope, so an
|
|
1978
|
+
* element discarded by a re-rendering mapped array or a disposed Studio subtree releases itself.
|
|
1979
|
+
*
|
|
1980
|
+
* @param {HTMLElement} el
|
|
1981
|
+
* @param {ElementStyleEntry} [only] - Release only if this is still the element's current entry
|
|
1982
|
+
*/
|
|
1983
|
+
export function releaseElementStyles(el: HTMLElement, only?: ElementStyleEntry): void {
|
|
1984
|
+
const entry = elementStyles.get(el);
|
|
1985
|
+
if (!entry || (only !== undefined && entry !== only)) {
|
|
1986
|
+
return;
|
|
1987
|
+
}
|
|
1988
|
+
elementStyles.delete(el);
|
|
1989
|
+
entry.stop?.();
|
|
1990
|
+
for (const name of entry.vars) {
|
|
1991
|
+
el.style.removeProperty(name);
|
|
1992
|
+
}
|
|
1993
|
+
delete el.dataset.jx;
|
|
1994
|
+
/* A `resetDocumentStyles` between the two replaces the state, and its sheets are already gone —
|
|
1995
|
+
so the element-local half above always runs and the sheet half is skipped as already done. */
|
|
1996
|
+
if (sheetStates.get(entry.doc) !== entry.state) {
|
|
1997
|
+
return;
|
|
1998
|
+
}
|
|
1999
|
+
const { state } = entry;
|
|
2000
|
+
if (entry.interned === null) {
|
|
2001
|
+
if (state.scratchOwners.delete(el)) {
|
|
2002
|
+
rewriteScratch(state);
|
|
1374
2003
|
}
|
|
2004
|
+
} else {
|
|
2005
|
+
releaseInterned(state, state.interned, entry.interned);
|
|
2006
|
+
}
|
|
2007
|
+
for (const key of entry.hoisted) {
|
|
2008
|
+
releaseInterned(state, state.hoisted, key);
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
/**
|
|
2013
|
+
* Discard every sheet this runtime opened on a document, and orphan the element entries that point
|
|
2014
|
+
* into them.
|
|
2015
|
+
*
|
|
2016
|
+
* A full re-render replaces the whole tree, so the alternative is a shared sheet that only ever
|
|
2017
|
+
* grows. The element `WeakMap` cannot be enumerated to be cleared, so entries are invalidated
|
|
2018
|
+
* instead: each one records the `SheetState` it was written into, and a release that finds a
|
|
2019
|
+
* different state registered for the document does the element-local half only. That is what stops
|
|
2020
|
+
* a surviving element from later handing `deleteRule` an index into a sheet nobody is reading.
|
|
2021
|
+
*
|
|
2022
|
+
* @param {Document} [doc] - Defaults to the ambient document
|
|
2023
|
+
*/
|
|
2024
|
+
export function resetDocumentStyles(doc: Document = document): void {
|
|
2025
|
+
const state = sheetStates.get(doc);
|
|
2026
|
+
if (!state) {
|
|
2027
|
+
return;
|
|
2028
|
+
}
|
|
2029
|
+
sheetStates.delete(doc);
|
|
2030
|
+
for (const el of state.scratchOwners.keys()) {
|
|
2031
|
+
elementStyles.delete(el);
|
|
2032
|
+
delete el.dataset.jx;
|
|
1375
2033
|
}
|
|
2034
|
+
state.scratchOwners.clear();
|
|
2035
|
+
state.interned.clear();
|
|
2036
|
+
state.hoisted.clear();
|
|
2037
|
+
state.shared.detach();
|
|
2038
|
+
state.scratch?.detach();
|
|
2039
|
+
}
|
|
1376
2040
|
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
2041
|
+
/**
|
|
2042
|
+
* The CSS this engine has written into a document, in sheet order.
|
|
2043
|
+
*
|
|
2044
|
+
* It reports what the engine ASKED the document to adopt, not what the document managed to parse,
|
|
2045
|
+
* and the difference is the point: `insertRule` throws on a rule the host cannot parse, and the
|
|
2046
|
+
* sink swallows that so one bad rule cannot take the rest of an element's set with it. The test DOM
|
|
2047
|
+
* rejects `@position-try` outright, so reading it back off `cssRules` there would report a block
|
|
2048
|
+
* that a browser adopts perfectly well as missing.
|
|
2049
|
+
*
|
|
2050
|
+
* It is also the honest answer to "what CSS did my style object become", which is a question the
|
|
2051
|
+
* Studio style inspector asks and, before this, nothing could answer without scraping `<style>`
|
|
2052
|
+
* tags out of the head.
|
|
2053
|
+
*
|
|
2054
|
+
* @param {Document} [doc] - Defaults to the ambient document
|
|
2055
|
+
* @returns {string} One rule per line, shared sheet first
|
|
2056
|
+
* @docs framework/concepts/styling
|
|
2057
|
+
*/
|
|
2058
|
+
export function documentStyleText(doc: Document = document): string {
|
|
2059
|
+
const state = sheetStates.get(doc);
|
|
2060
|
+
if (!state) {
|
|
2061
|
+
return "";
|
|
1396
2062
|
}
|
|
2063
|
+
return [state.shared.text(), state.scratch?.text() ?? ""].filter(Boolean).join("\n");
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
/**
|
|
2067
|
+
* Whether a style object declares `display` anywhere in it.
|
|
2068
|
+
*
|
|
2069
|
+
* `defineElement` gives a custom element `display: block` because a custom element defaults to
|
|
2070
|
+
* `inline` and Jx containers behave like `<div>`. It used to test `this.style.display`, which was a
|
|
2071
|
+
* correct test only while the author's own `display` was written inline — the moment it became a
|
|
2072
|
+
* rule the guard stopped seeing it and the default started overriding the author. The test is now
|
|
2073
|
+
* on the source rather than on the element, and it is a deep one on purpose: an inline default
|
|
2074
|
+
* would beat a `:hover` or `@media` `display` just as surely as a base one.
|
|
2075
|
+
*
|
|
2076
|
+
* A `@keyframes` block is the one thing the walk does NOT descend into. A `display` in a keyframe
|
|
2077
|
+
* stop is a point on a timeline, not a declaration on this element — animating `display` is the
|
|
2078
|
+
* ordinary shape of an `allow-discrete` reveal — so counting one as the author's own left the
|
|
2079
|
+
* element with no `display` at all, and a custom element with no `display` is `inline`. The element
|
|
2080
|
+
* laid out wrongly whenever it was still, which is most of the time.
|
|
2081
|
+
*
|
|
2082
|
+
* @param {JxStyle | undefined} style
|
|
2083
|
+
* @returns {boolean}
|
|
2084
|
+
*/
|
|
2085
|
+
function declaresDisplay(style: JxStyle | undefined): boolean {
|
|
2086
|
+
/* The BASE block only. A deep walk read a `display` that exists solely inside `&:hover` or an
|
|
2087
|
+
`@media` block as "the author supplied one", so the element stayed `inline` at rest and
|
|
2088
|
+
became a block on hover — a live trap rather than a subtlety. An author who wants the
|
|
2089
|
+
platform's own default writes `display: revert` in the base block, which this sees. */
|
|
2090
|
+
return Boolean(style) && typeof style === "object" && "display" in style;
|
|
2091
|
+
}
|
|
1397
2092
|
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
2093
|
+
/**
|
|
2094
|
+
* The `style` object a USAGE SITE wrote on a custom element, kept until the element connects.
|
|
2095
|
+
*
|
|
2096
|
+
* Weak in the element, so an instance that is never connected is collected with its entry.
|
|
2097
|
+
*/
|
|
2098
|
+
const callSiteStyles = new WeakMap<HTMLElement, JxStyle>();
|
|
2099
|
+
|
|
2100
|
+
/**
|
|
2101
|
+
* Merge two style objects, `over` winning.
|
|
2102
|
+
*
|
|
2103
|
+
* Deep, because a nested selector block is a rule of its own: a call site that declares `&:hover`
|
|
2104
|
+
* must not delete the definition's `&:hover`, only the declarations it repeats inside it. A scalar
|
|
2105
|
+
* always replaces, which is what "the call site wins" means.
|
|
2106
|
+
*
|
|
2107
|
+
* @param base The definition's style.
|
|
2108
|
+
* @param over The usage site's, if any.
|
|
2109
|
+
* @returns {JxStyle} A new object; neither argument is mutated.
|
|
2110
|
+
*/
|
|
2111
|
+
function mergeStyle(base: JxStyle, over: JxStyle | undefined): JxStyle {
|
|
2112
|
+
if (!over) {
|
|
2113
|
+
return base;
|
|
2114
|
+
}
|
|
2115
|
+
const out: Record<string, unknown> = { ...(base as Record<string, unknown>) };
|
|
2116
|
+
for (const [key, value] of Object.entries(over as Record<string, unknown>)) {
|
|
2117
|
+
const prior = out[key];
|
|
2118
|
+
out[key] =
|
|
2119
|
+
prior !== null &&
|
|
2120
|
+
typeof prior === "object" &&
|
|
2121
|
+
value !== null &&
|
|
2122
|
+
typeof value === "object" &&
|
|
2123
|
+
!Array.isArray(value)
|
|
2124
|
+
? mergeStyle(prior as JxStyle, value as JxStyle)
|
|
2125
|
+
: value;
|
|
2126
|
+
}
|
|
2127
|
+
return out as JxStyle;
|
|
1403
2128
|
}
|
|
1404
2129
|
|
|
1405
2130
|
/**
|
|
1406
|
-
*
|
|
1407
|
-
*
|
|
2131
|
+
* Turn an element's `style` object into CSS rules and adopt them.
|
|
2132
|
+
*
|
|
2133
|
+
* Nothing is written inline except the `--jx-r…` custom properties that carry reactive values, so
|
|
2134
|
+
* an authored `:hover`, `@media` or `@supports` block overrides its own base declaration by
|
|
2135
|
+
* ordinary cascade rules — which is what the emitted CSS always said and the inline write always
|
|
2136
|
+
* prevented. See the engine essay above for the sheet roles and the measurements behind them.
|
|
2137
|
+
*
|
|
2138
|
+
* Calling it twice on one element is safe: the first call's rules are released first.
|
|
2139
|
+
*
|
|
2140
|
+
* @param {HTMLElement} el
|
|
2141
|
+
* @param {JxStyle} styleDef
|
|
2142
|
+
* @param {Record<string, string>} [mediaQueries] Named breakpoints from root $media. Default is
|
|
2143
|
+
* `{}`
|
|
2144
|
+
* @param {JxScope} [state] Component scope for template string evaluation. Default is `{}`
|
|
2145
|
+
* @docs framework/concepts/styling
|
|
1408
2146
|
*/
|
|
1409
|
-
export
|
|
2147
|
+
export function applyStyle(
|
|
2148
|
+
el: HTMLElement,
|
|
2149
|
+
styleDef: JxStyle,
|
|
2150
|
+
mediaQueries: Record<string, string> = {},
|
|
2151
|
+
state: JxScope = {},
|
|
2152
|
+
) {
|
|
2153
|
+
applyStyleInto(el, styleDef, mediaQueries, state, false);
|
|
2154
|
+
}
|
|
1410
2155
|
|
|
1411
2156
|
/**
|
|
1412
|
-
* Re-apply an element's style definition in place
|
|
1413
|
-
*
|
|
2157
|
+
* Re-apply an element's style definition in place.
|
|
2158
|
+
*
|
|
2159
|
+
* The Studio Style panel calls this per keystroke, so its rules go to the SCRATCH sheet, which is
|
|
2160
|
+
* rewritten whole with `replaceSync` rather than edited rule by rule inside the shared one: 7.5ms
|
|
2161
|
+
* per 100 structural edits against 76.9ms. The previous rule set is released first — without that
|
|
2162
|
+
* this would leak a set into the shared sheet on every keypress, which is the opposite of the
|
|
2163
|
+
* `<style>`-tag leak it replaced.
|
|
2164
|
+
*
|
|
2165
|
+
* Inline styles are NOT cleared wholesale any more. `el.style.cssText = ""` also destroyed an
|
|
2166
|
+
* author's own `attributes: { style: "…" }` and would now take the custom-element `display` default
|
|
2167
|
+
* with it; only the `--jx-r…` properties this engine wrote are removed.
|
|
1414
2168
|
*
|
|
1415
2169
|
* @param {HTMLElement} el
|
|
1416
2170
|
* @param {JxStyle | undefined} styleDef
|
|
@@ -1423,14 +2177,144 @@ export function reapplyStyle(
|
|
|
1423
2177
|
mediaQueries: Record<string, string> = {},
|
|
1424
2178
|
state: JxScope = {},
|
|
1425
2179
|
) {
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
2180
|
+
applyStyleInto(el, styleDef ?? {}, mediaQueries, state, true);
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
/**
|
|
2184
|
+
* @param {HTMLElement} el
|
|
2185
|
+
* @param {JxStyle} styleDef
|
|
2186
|
+
* @param {Record<string, string>} mediaQueries
|
|
2187
|
+
* @param {JxScope} state
|
|
2188
|
+
* @param {boolean} live - Write to the scratch sheet (the Studio edit path) rather than intern
|
|
2189
|
+
*/
|
|
2190
|
+
function applyStyleInto(
|
|
2191
|
+
el: HTMLElement,
|
|
2192
|
+
styleDef: JxStyle,
|
|
2193
|
+
mediaQueries: Record<string, string>,
|
|
2194
|
+
state: JxScope,
|
|
2195
|
+
live: boolean,
|
|
2196
|
+
) {
|
|
2197
|
+
releaseElementStyles(el);
|
|
2198
|
+
const doc = el.ownerDocument ?? document;
|
|
2199
|
+
const sheetState = sheetStateFor(doc);
|
|
2200
|
+
|
|
2201
|
+
/* One gate for the whole call, so every recursion answers alike. Off in production and in
|
|
2202
|
+
preview, where the selector is written exactly as authored.
|
|
2203
|
+
|
|
2204
|
+
The SAME predicate the attribute rename uses, and it has to be: an attribute renamed without
|
|
2205
|
+
its selectors transposed is a panel that can never be styled open. They disagreed while the
|
|
2206
|
+
rename learned to reach inside a defined element and this did not, so a kit popover rendered
|
|
2207
|
+
within another definition's template would have been de-linked and then left with a
|
|
2208
|
+
`:popover-open` rule that could no longer match. */
|
|
2209
|
+
const transposeSelector =
|
|
2210
|
+
(_canvasDelinkPopovers || _canvasDelinkCommands) && canvasStamped(el)
|
|
2211
|
+
? (selector: string) =>
|
|
2212
|
+
transposeCanvasOverlaySelector(selector, {
|
|
2213
|
+
dialog: el.tagName === "DIALOG",
|
|
2214
|
+
})
|
|
2215
|
+
: (selector: string) => selector;
|
|
2216
|
+
|
|
2217
|
+
/* Reactive declarations become `var()` reads of a custom property this element sets inline. The
|
|
2218
|
+
variable inherits, which is correct, and the declaration stays in the rule where `:hover` can
|
|
2219
|
+
override it. The serial keeps two elements' variables apart: a descendant rule reads `var()`
|
|
2220
|
+
from the nearest ancestor that set it, and without it a styled child would shadow its parent's
|
|
2221
|
+
`--jx-r0` with its own. It also makes the rule text — and so the scope handle hashed from it —
|
|
2222
|
+
unique per element, which is exactly the sharing a reactive rule set must not have. */
|
|
2223
|
+
let serial = -1;
|
|
2224
|
+
let indirect = 0;
|
|
2225
|
+
const sources: { name: string; value: string | JxRef }[] = [];
|
|
2226
|
+
const rules = buildStyleRules(styleDef, {
|
|
2227
|
+
mediaQueries,
|
|
2228
|
+
resolveValue: (property, value, target) => {
|
|
2229
|
+
/* A reactive value on a CUSTOM PROPERTY, in a rule targeting the element itself, is written
|
|
2230
|
+
inline under the AUTHOR'S OWN NAME and contributes no declaration. That keeps the rule
|
|
2231
|
+
text free of anything per-element, so every row of a repeater interns ONE rule instead of
|
|
2232
|
+
one each: the serial below is in the rule text, the interning handle is a hash of that
|
|
2233
|
+
text, and so no two reactive elements could ever share a rule set. Measured before this:
|
|
2234
|
+
three font rows produced three handles and three rules.
|
|
2235
|
+
The declaration that READS the variable — `font-family: var(--row-face, inherit)` — lives
|
|
2236
|
+
in the owning element's own style, is identical across rows, and interns once.
|
|
2237
|
+
Only a SELF-target rule qualifies. A descendant rule keeps the indirection, because its
|
|
2238
|
+
variable must not be set on the element that carries the rule: a `var()` resolves from the
|
|
2239
|
+
nearest ancestor that set it, and a shared descendant rule would read the wrong one. */
|
|
2240
|
+
if (target === "self" && property.startsWith("--")) {
|
|
2241
|
+
sources.push({ name: property, value });
|
|
2242
|
+
return null;
|
|
2243
|
+
}
|
|
2244
|
+
if (serial < 0) {
|
|
2245
|
+
({ serial } = sheetState);
|
|
2246
|
+
sheetState.serial += 1;
|
|
2247
|
+
}
|
|
2248
|
+
/* Counted separately from `sources`, so an inline custom property before it cannot shift the
|
|
2249
|
+
name of an indirected one. The name is IN the rule text, and the interning handle is a
|
|
2250
|
+
hash of that text — a number that moved with unrelated declarations would give two
|
|
2251
|
+
otherwise identical styles two rules. */
|
|
2252
|
+
const name = `--jx-r${serial}-${indirect}`;
|
|
2253
|
+
indirect += 1;
|
|
2254
|
+
sources.push({ name, value });
|
|
2255
|
+
return `var(${name})`;
|
|
2256
|
+
},
|
|
2257
|
+
scope: SCOPE_TOKEN,
|
|
2258
|
+
transposeSelector,
|
|
2259
|
+
transposeValue: canvasStyleValue,
|
|
2260
|
+
});
|
|
2261
|
+
/* `sources.length` too, not `rules.length` alone: a style whose ONLY reactive declaration is a
|
|
2262
|
+
custom property on the element itself emits no rule at all, and returning here would install
|
|
2263
|
+
no effect, so the variable would be written once and never track its source again. */
|
|
2264
|
+
if (rules.length === 0 && sources.length === 0) {
|
|
2265
|
+
return;
|
|
1430
2266
|
}
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
2267
|
+
|
|
2268
|
+
/* Declaration-body at-rules declare a document-global NAME — `@position-try --flip`,
|
|
2269
|
+
`@font-face` — so they are written once for the whole document and refcounted, rather than
|
|
2270
|
+
once per element that mentions them. */
|
|
2271
|
+
const scoped = rules.filter((rule) => rule.target !== "unscoped");
|
|
2272
|
+
const unscoped = rules.filter((rule) => rule.target === "unscoped");
|
|
2273
|
+
for (const rule of unscoped) {
|
|
2274
|
+
retainInterned(sheetState, sheetState.hoisted, rule.text, [rule.text]);
|
|
2275
|
+
}
|
|
2276
|
+
|
|
2277
|
+
const uid = `jx-${hashCss(scoped.map((rule) => rule.key).join("|"))}`;
|
|
2278
|
+
const texts = scoped.map((rule) => rule.text.replaceAll(SCOPE_TOKEN, `[data-jx="${uid}"]`));
|
|
2279
|
+
if (texts.length > 0) {
|
|
2280
|
+
el.dataset.jx = uid;
|
|
2281
|
+
if (live) {
|
|
2282
|
+
sheetState.scratch ??= openSink(doc);
|
|
2283
|
+
sheetState.scratchOwners.set(el, texts);
|
|
2284
|
+
rewriteScratch(sheetState);
|
|
2285
|
+
} else {
|
|
2286
|
+
retainInterned(sheetState, sheetState.interned, uid, texts);
|
|
2287
|
+
}
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
const entry: ElementStyleEntry = {
|
|
2291
|
+
doc,
|
|
2292
|
+
hoisted: unscoped.map((rule) => rule.text),
|
|
2293
|
+
interned: texts.length > 0 && !live ? uid : null,
|
|
2294
|
+
state: sheetState,
|
|
2295
|
+
stop: null,
|
|
2296
|
+
uid,
|
|
2297
|
+
vars: sources.map((source) => source.name),
|
|
2298
|
+
};
|
|
2299
|
+
if (sources.length > 0) {
|
|
2300
|
+
const scope = effectScope();
|
|
2301
|
+
scope.run(() => {
|
|
2302
|
+
for (const { name, value } of sources) {
|
|
2303
|
+
effect(() => {
|
|
2304
|
+
const resolved = isRefObj(value)
|
|
2305
|
+
? resolveRef(value.$ref, state)
|
|
2306
|
+
: evaluateTemplate(value, state);
|
|
2307
|
+
el.style.setProperty(name, canvasStyleValue(String(resolved ?? "")));
|
|
2308
|
+
});
|
|
2309
|
+
}
|
|
2310
|
+
});
|
|
2311
|
+
entry.stop = () => scope.stop();
|
|
2312
|
+
}
|
|
2313
|
+
elementStyles.set(el, entry);
|
|
2314
|
+
/* Ties the rules to the render that made them: a mapped array's discarded generation, or a
|
|
2315
|
+
Studio subtree being replaced, stops its scope and the rule set goes with it. Guarded on the
|
|
2316
|
+
entry so a later `reapplyStyle` is not undone by an older scope disposing after it. */
|
|
2317
|
+
onScopeDispose(() => releaseElementStyles(el, entry), true);
|
|
1434
2318
|
}
|
|
1435
2319
|
|
|
1436
2320
|
/*
|
|
@@ -1445,10 +2329,38 @@ export function reapplyStyle(
|
|
|
1445
2329
|
* `contenteditable` means "inherit from the parent" rather than `false`. Every `aria-*` is in this
|
|
1446
2330
|
* family — ARIA has no presence attributes at all — along with the three HTML attributes below.
|
|
1447
2331
|
*
|
|
2332
|
+
* **`popover` is enumerated and is deliberately NOT in this set**, which looks like an oversight
|
|
2333
|
+
* and is not. Its keywords are `auto`, `manual` and `hint`; its MISSING-value default is `auto`, so
|
|
2334
|
+
* a bare `popover` and `popover=""` are both auto popovers — which is what the presence branch
|
|
2335
|
+
* below already produces for `true`, correctly. Its INVALID-value default is `manual`, so moving it
|
|
2336
|
+
* here would emit `popover="true"`, which is not a keyword, and every popover written as a boolean
|
|
2337
|
+
* would silently stop light-dismissing and stop answering Escape. The right answer to a boolean
|
|
2338
|
+
* `popover` is not a different emission, it is not writing one: `@jxsuite/schema/overlays` reports
|
|
2339
|
+
* it as `invalid-mode`, and the house spelling is `"auto"`.
|
|
2340
|
+
*
|
|
2341
|
+
* That also corrects the paragraph above for this one attribute: a presence attribute counts any
|
|
2342
|
+
* value as true, but `popover="false"` is a *manual* popover rather than a true-ish one, because it
|
|
2343
|
+
* is enumerated even though it is emitted through the presence branch.
|
|
2344
|
+
*
|
|
1448
2345
|
* @docs framework/concepts/elements
|
|
1449
2346
|
*/
|
|
1450
2347
|
const ENUMERATED_ATTRS = new Set(["contenteditable", "draggable", "spellcheck"]);
|
|
1451
2348
|
|
|
2349
|
+
/**
|
|
2350
|
+
* The enumerated-attribute names, for a code generator that cannot import this module.
|
|
2351
|
+
*
|
|
2352
|
+
* The compiler's element and client targets emit standalone ES modules that a built site loads
|
|
2353
|
+
* without `@jxsuite/runtime`, so they inline the boolean decision instead of calling it. They
|
|
2354
|
+
* serialize THIS array into that inlined helper, and `compile-element.test.ts` asserts the emitted
|
|
2355
|
+
* literal still equals it — which is what keeps the four writers of the rule one decision rather
|
|
2356
|
+
* than four, in the one place a shared function could not reach.
|
|
2357
|
+
*
|
|
2358
|
+
* @returns {string[]} The names, sorted, so the emitted output is stable.
|
|
2359
|
+
*/
|
|
2360
|
+
export function enumeratedAttrNames(): string[] {
|
|
2361
|
+
return [...ENUMERATED_ATTRS].toSorted();
|
|
2362
|
+
}
|
|
2363
|
+
|
|
1452
2364
|
/**
|
|
1453
2365
|
* The text an attribute's boolean value must be written with, or `null` when saying it means
|
|
1454
2366
|
* removing the attribute instead.
|
|
@@ -1483,6 +2395,13 @@ function applyAttributes(el: HTMLElement, attrs: Record<string, JxAttributeValue
|
|
|
1483
2395
|
/* Inside the effects, not before them. A `$ref` or `${…}` value is not a string until the
|
|
1484
2396
|
effect runs — which is exactly why the document walk this replaced could never see it. */
|
|
1485
2397
|
const write = (resolved: unknown) => {
|
|
2398
|
+
if (resolved === null || resolved === undefined) {
|
|
2399
|
+
/* Nothing is an attribute the element does not have. A binding that resolves to `null`
|
|
2400
|
+
has to take the attribute WITH it, for the same reason a `false` does below — and an
|
|
2401
|
+
`aria-checked=""` or `title=""` left behind is not absence, it is a wrong value. */
|
|
2402
|
+
el.removeAttribute(attr);
|
|
2403
|
+
return;
|
|
2404
|
+
}
|
|
1486
2405
|
if (typeof resolved === "boolean") {
|
|
1487
2406
|
const text = booleanAttrValue(attr, resolved);
|
|
1488
2407
|
/* `removeAttribute`, not `setAttribute(attr, "false")`. A binding that flips back has to
|
|
@@ -1496,7 +2415,7 @@ function applyAttributes(el: HTMLElement, attrs: Record<string, JxAttributeValue
|
|
|
1496
2415
|
}
|
|
1497
2416
|
return;
|
|
1498
2417
|
}
|
|
1499
|
-
el.setAttribute(attr, canvasAssetValue(el.tagName, k, String(resolved
|
|
2418
|
+
el.setAttribute(attr, canvasAssetValue(el.tagName, k, String(resolved)));
|
|
1500
2419
|
};
|
|
1501
2420
|
if (isRefObj(v)) {
|
|
1502
2421
|
effect(() => write(resolveRef(v.$ref, state)));
|
|
@@ -1513,12 +2432,24 @@ function applyAttributes(el: HTMLElement, attrs: Record<string, JxAttributeValue
|
|
|
1513
2432
|
/**
|
|
1514
2433
|
* Render a mapped array (repeater) wrapper-less: its item instances are inserted directly into
|
|
1515
2434
|
* `parentEl`, in place, ahead of an anchor comment that marks the array's position among the
|
|
1516
|
-
* parent's other children.
|
|
1517
|
-
*
|
|
1518
|
-
*
|
|
2435
|
+
* parent's other children.
|
|
2436
|
+
*
|
|
2437
|
+
* Rows are RECONCILED, not rebuilt. Each row is keyed — by the `key` pointer the array declares,
|
|
2438
|
+
* else by its index — and owns a detached effect scope and a reactive `$map` context. When `items`
|
|
2439
|
+
* (or the filter/sort sources, or a key) change, a row whose key survives keeps its node and its
|
|
2440
|
+
* effects: a reorder moves the node, an insertion creates only the new rows, a removal stops only
|
|
2441
|
+
* the removed ones, and a moved row's `$map.index` is updated in place. That is what lets focus,
|
|
2442
|
+
* scroll position and a half-typed value survive a change to the list.
|
|
2443
|
+
*
|
|
2444
|
+
* Reads made while a row is constructed are made with tracking PAUSED. `effectScope.run()` does not
|
|
2445
|
+
* reset the active subscriber, so without this every top-level read a row performs — a `$props`
|
|
2446
|
+
* write, a tag discriminant, a `$`-local — subscribed the LIST effect, and one row's data changing
|
|
2447
|
+
* rebuilt the whole list. The bindings inside a row are effects of their own and re-enable tracking
|
|
2448
|
+
* when they run, so a row still updates itself.
|
|
1519
2449
|
*
|
|
1520
2450
|
* `options._path` is the array node's own document path (`[…, "children", i]`, or `[…, "children"]`
|
|
1521
|
-
* for a legacy whole-children repeater); item instances render at `[…that…, "map", index]
|
|
2451
|
+
* for a legacy whole-children repeater); item instances render at `[…that…, "map", index]`, and a
|
|
2452
|
+
* row that moves reports its new path through `options.onNodeMoved`.
|
|
1522
2453
|
*
|
|
1523
2454
|
* @param {HTMLElement} parentEl
|
|
1524
2455
|
* @param {import("@jxsuite/schema/types").JxMappedArray} arrayDef
|
|
@@ -1534,9 +2465,87 @@ function renderMappedArrayInto(
|
|
|
1534
2465
|
const path = options?._path ?? [];
|
|
1535
2466
|
const anchor = document.createComment("jx-array");
|
|
1536
2467
|
parentEl.append(anchor);
|
|
1537
|
-
const { items: itemsSrc, map: mapDef, filter: filterRef, sort: sortRef } = arrayDef;
|
|
2468
|
+
const { items: itemsSrc, map: mapDef, filter: filterRef, sort: sortRef, key: keyRef } = arrayDef;
|
|
2469
|
+
const keyPointer = isRefObj(keyRef) ? keyRef.$ref : null;
|
|
2470
|
+
/* The instantiation chain the list belongs to (spec.md §16.9), for the same reason
|
|
2471
|
+
{@link renderSwitch} keeps one: a row that arrives with new data is inserted from an effect
|
|
2472
|
+
re-run, and the instance it holds must connect under the chain of the definition that drew
|
|
2473
|
+
the list. */
|
|
2474
|
+
const instancePath = _instancePath;
|
|
2475
|
+
|
|
2476
|
+
let warned = false;
|
|
2477
|
+
const warnOnce = (message: string): void => {
|
|
2478
|
+
if (!warned) {
|
|
2479
|
+
warned = true;
|
|
2480
|
+
console.warn(`Jx $map: ${message}`);
|
|
2481
|
+
}
|
|
2482
|
+
};
|
|
1538
2483
|
|
|
1539
|
-
|
|
2484
|
+
/** The rows currently rendered, in DOM order. */
|
|
2485
|
+
let rows: MappedRow[] = [];
|
|
2486
|
+
|
|
2487
|
+
const teardown = (row: MappedRow): void => {
|
|
2488
|
+
row.scope.stop();
|
|
2489
|
+
row.node.remove();
|
|
2490
|
+
};
|
|
2491
|
+
|
|
2492
|
+
const createRow = (key: unknown, item: unknown, index: number): MappedRow => {
|
|
2493
|
+
const map = shallowReactive({ index, item });
|
|
2494
|
+
const child = Object.create(state) as JxScope;
|
|
2495
|
+
child.$map = map;
|
|
2496
|
+
// The flat aliases stay readable by legacy readers, and follow the reactive context.
|
|
2497
|
+
Object.defineProperty(child, "$map/item", { enumerable: true, get: () => map.item });
|
|
2498
|
+
Object.defineProperty(child, "$map/index", { enumerable: true, get: () => map.index });
|
|
2499
|
+
const scope = effectScope(true);
|
|
2500
|
+
const childOpts = options ? { ...options, _path: [...path, "map", index] } : undefined;
|
|
2501
|
+
const node = scope.run(() => renderNode(mapDef!, child, childOpts))!;
|
|
2502
|
+
return { key, map, node, scope };
|
|
2503
|
+
};
|
|
2504
|
+
|
|
2505
|
+
const reconcile = (list: unknown[], keys: unknown[]): void => {
|
|
2506
|
+
const previous = new Map<unknown, MappedRow>();
|
|
2507
|
+
for (const row of rows) {
|
|
2508
|
+
previous.set(row.key, row);
|
|
2509
|
+
}
|
|
2510
|
+
const next: MappedRow[] = [];
|
|
2511
|
+
for (let index = 0; index < list.length; index++) {
|
|
2512
|
+
const key = keys[index];
|
|
2513
|
+
const item = list[index];
|
|
2514
|
+
const existing = previous.get(key);
|
|
2515
|
+
if (existing) {
|
|
2516
|
+
previous.delete(key);
|
|
2517
|
+
if (existing.map.item !== item) {
|
|
2518
|
+
existing.map.item = item;
|
|
2519
|
+
}
|
|
2520
|
+
if (existing.map.index !== index) {
|
|
2521
|
+
existing.map.index = index;
|
|
2522
|
+
options?.onNodeMoved?.(existing.node, [...path, "map", index]);
|
|
2523
|
+
}
|
|
2524
|
+
next.push(existing);
|
|
2525
|
+
} else {
|
|
2526
|
+
next.push(createRow(key, item, index));
|
|
2527
|
+
}
|
|
2528
|
+
}
|
|
2529
|
+
// Whatever was not claimed is gone.
|
|
2530
|
+
for (const row of previous.values()) {
|
|
2531
|
+
teardown(row);
|
|
2532
|
+
}
|
|
2533
|
+
/* Order in one forward pass. The cursor walks the kept nodes in their current order; a row
|
|
2534
|
+
already under the cursor is in place, anything else is inserted before it — a new node, or
|
|
2535
|
+
a kept node moved forward. Only rows that are out of place move, and a move is the one thing
|
|
2536
|
+
that can cost a focused element its focus. */
|
|
2537
|
+
let cursor: ChildNode = rows.find((row) => !previous.has(row.key))?.node ?? anchor;
|
|
2538
|
+
for (const row of next) {
|
|
2539
|
+
if (row.node === cursor) {
|
|
2540
|
+
cursor = cursor.nextSibling ?? anchor;
|
|
2541
|
+
continue;
|
|
2542
|
+
}
|
|
2543
|
+
cursor.before(row.node);
|
|
2544
|
+
}
|
|
2545
|
+
rows = next;
|
|
2546
|
+
};
|
|
2547
|
+
|
|
2548
|
+
const update = (): void => {
|
|
1540
2549
|
let items: unknown = isRefObj(itemsSrc) ? resolveRef(itemsSrc.$ref, state) : itemsSrc;
|
|
1541
2550
|
if (Array.isArray(items) && isRefObj(filterRef)) {
|
|
1542
2551
|
const fn = resolveRef(filterRef.$ref, state);
|
|
@@ -1550,33 +2559,98 @@ function renderMappedArrayInto(
|
|
|
1550
2559
|
items = [...(items as unknown[])].toSorted(fn as (a: unknown, b: unknown) => number);
|
|
1551
2560
|
}
|
|
1552
2561
|
}
|
|
1553
|
-
|
|
1554
|
-
|
|
2562
|
+
const list = Array.isArray(items) && mapDef ? (items as unknown[]) : [];
|
|
2563
|
+
|
|
2564
|
+
// Keys are read HERE, tracked, so a key that changes reconciles the list.
|
|
2565
|
+
const keys = list.map((item, index) => mappedRowKey(keyPointer, item, index, warnOnce));
|
|
2566
|
+
const seen = new Set<unknown>();
|
|
2567
|
+
for (let i = 0; i < keys.length; i++) {
|
|
2568
|
+
if (seen.has(keys[i])) {
|
|
2569
|
+
warnOnce(
|
|
2570
|
+
`duplicate key ${String(keys[i])} — every occurrence after the first is rebuilt on each change`,
|
|
2571
|
+
);
|
|
2572
|
+
keys[i] = { duplicate: i };
|
|
2573
|
+
} else {
|
|
2574
|
+
seen.add(keys[i]);
|
|
2575
|
+
}
|
|
1555
2576
|
}
|
|
1556
2577
|
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
2578
|
+
pauseTracking();
|
|
2579
|
+
try {
|
|
2580
|
+
underInstancePath(reexpand(instancePath), () => reconcile(list, keys));
|
|
2581
|
+
} finally {
|
|
2582
|
+
resetTracking();
|
|
2583
|
+
}
|
|
2584
|
+
};
|
|
2585
|
+
|
|
2586
|
+
/* The first render is synchronous; every re-render is coalesced into ONE microtask. A reactive
|
|
2587
|
+
array mutated in place — `reverse()`, `sort()`, an index write — triggers once per element it
|
|
2588
|
+
touches, and an effect run between two of those writes sees an array that is half of each
|
|
2589
|
+
state: a row that is transiently absent would be torn down and then rebuilt, which is exactly
|
|
2590
|
+
the identity loss keys exist to prevent. Deferring to a microtask is what a framework scheduler
|
|
2591
|
+
does for the same reason; a row's own bindings stay synchronous. */
|
|
2592
|
+
let queued = false;
|
|
2593
|
+
let stopped = false;
|
|
2594
|
+
const runner = effect(update, {
|
|
2595
|
+
scheduler: () => {
|
|
2596
|
+
if (queued) {
|
|
2597
|
+
return;
|
|
1577
2598
|
}
|
|
1578
|
-
|
|
2599
|
+
queued = true;
|
|
2600
|
+
queueMicrotask(() => {
|
|
2601
|
+
queued = false;
|
|
2602
|
+
if (!stopped) {
|
|
2603
|
+
runner();
|
|
2604
|
+
}
|
|
2605
|
+
});
|
|
2606
|
+
},
|
|
1579
2607
|
});
|
|
2608
|
+
|
|
2609
|
+
onScopeDispose(() => {
|
|
2610
|
+
stopped = true;
|
|
2611
|
+
for (const row of rows) {
|
|
2612
|
+
teardown(row);
|
|
2613
|
+
}
|
|
2614
|
+
rows = [];
|
|
2615
|
+
}, true);
|
|
2616
|
+
}
|
|
2617
|
+
|
|
2618
|
+
/** A rendered row of a mapped array. */
|
|
2619
|
+
interface MappedRow {
|
|
2620
|
+
key: unknown;
|
|
2621
|
+
/** The row's `$map` context; `item` and `index` are written in place when the row is reused. */
|
|
2622
|
+
map: { index: number; item: unknown };
|
|
2623
|
+
node: HTMLElement | Text;
|
|
2624
|
+
scope: EffectScope;
|
|
2625
|
+
}
|
|
2626
|
+
|
|
2627
|
+
/**
|
|
2628
|
+
* The identity a `key` pointer names for one item: the item itself for `$map/item`, a value read
|
|
2629
|
+
* off it for `$map/item/<path>`. Anything else is not a key — an index names a position, not a row
|
|
2630
|
+
* — and a key that evaluates to nothing cannot tell rows apart, so both fall back to the index.
|
|
2631
|
+
*/
|
|
2632
|
+
function mappedRowKey(
|
|
2633
|
+
pointer: string | null,
|
|
2634
|
+
item: unknown,
|
|
2635
|
+
index: number,
|
|
2636
|
+
warnOnce: (message: string) => void,
|
|
2637
|
+
): unknown {
|
|
2638
|
+
if (pointer === null) {
|
|
2639
|
+
return index;
|
|
2640
|
+
}
|
|
2641
|
+
if (pointer === "$map/item") {
|
|
2642
|
+
return item;
|
|
2643
|
+
}
|
|
2644
|
+
if (pointer.startsWith("$map/item/")) {
|
|
2645
|
+
const key = readPath(item, pointer.slice("$map/item/".length));
|
|
2646
|
+
if (key === undefined || key === null) {
|
|
2647
|
+
warnOnce(`key "${pointer}" is empty for some items — those rows fall back to their index`);
|
|
2648
|
+
return index;
|
|
2649
|
+
}
|
|
2650
|
+
return key;
|
|
2651
|
+
}
|
|
2652
|
+
warnOnce(`key "${pointer}" is not a $map/item pointer — rows fall back to their index`);
|
|
2653
|
+
return index;
|
|
1580
2654
|
}
|
|
1581
2655
|
|
|
1582
2656
|
/**
|
|
@@ -1618,6 +2692,11 @@ export function resolveTagName(tagName: unknown, scope: JxScope): string {
|
|
|
1618
2692
|
function renderSwitch(def: JxElement, state: JxScope, options?: JxRenderOptions) {
|
|
1619
2693
|
const path = options?._path ?? [];
|
|
1620
2694
|
const container = document.createElement(resolveTagName(def.tagName, state));
|
|
2695
|
+
/* The instantiation chain this switch belongs to (spec.md §16.9), read now because the effect
|
|
2696
|
+
below re-runs with nothing on the stack. A case that flips after the first paint puts it back
|
|
2697
|
+
around the insertion, so an instance the new case creates connects under the chain of the
|
|
2698
|
+
definition that drew the switch rather than under none. See {@link _instancePath}. */
|
|
2699
|
+
const instancePath = _instancePath;
|
|
1621
2700
|
|
|
1622
2701
|
if (options?.onNodeCreated) {
|
|
1623
2702
|
options.onNodeCreated(container, path, def, state);
|
|
@@ -1626,40 +2705,67 @@ function renderSwitch(def: JxElement, state: JxScope, options?: JxRenderOptions)
|
|
|
1626
2705
|
applyProperties(container, def, state);
|
|
1627
2706
|
applyStyle(container, def.style ?? {}, (state["$media"] as Record<string, string>) ?? {}, state);
|
|
1628
2707
|
applyAttributes(container, def.attributes ?? {}, state);
|
|
2708
|
+
/* Every case renders in a detached scope of its own, stopped before the next case renders and
|
|
2709
|
+
when the container's own scope stops. A generation counter names the case a pending external
|
|
2710
|
+
load belongs to; it moves on EVERY change — an inline case included — so a load that resolves
|
|
2711
|
+
after the discriminant moved on cannot paint over what replaced it. */
|
|
1629
2712
|
let generation = 0;
|
|
2713
|
+
let live: EffectScope | null = null;
|
|
2714
|
+
/* The key the rendered case was chosen by. A discriminant that re-resolves to the SAME key keeps
|
|
2715
|
+
its case: the subtree, its effects and its state — an open submenu, a half-typed value —
|
|
2716
|
+
survive. Without this a mapped row whose item was replaced by an equal one (a host rebuilding
|
|
2717
|
+
its projection) rebuilt every switch inside it, and the popover the case rendered closed. */
|
|
2718
|
+
let renderedKey: string | null = null;
|
|
2719
|
+
const retire = (): void => {
|
|
2720
|
+
live?.stop();
|
|
2721
|
+
live = null;
|
|
2722
|
+
};
|
|
1630
2723
|
|
|
1631
2724
|
effect(() => {
|
|
2725
|
+
if (!isRefObj(def.$switch)) {
|
|
2726
|
+
retire();
|
|
2727
|
+
container.replaceChildren();
|
|
2728
|
+
generation += 1;
|
|
2729
|
+
return;
|
|
2730
|
+
}
|
|
2731
|
+
const key = String(resolveRef(def.$switch.$ref, state));
|
|
2732
|
+
if (key === renderedKey) {
|
|
2733
|
+
return;
|
|
2734
|
+
}
|
|
2735
|
+
renderedKey = key;
|
|
2736
|
+
retire();
|
|
1632
2737
|
/* `replaceChildren()` rather than `innerHTML = ""`: identical semantics, and it is not a
|
|
1633
2738
|
Trusted Types injection sink — under `require-trusted-types-for 'script'` an innerHTML write
|
|
1634
2739
|
needs a policy even when the string is empty. Four sinks that were never injecting anything
|
|
1635
2740
|
is four fewer things a policy has to be permissive about. */
|
|
1636
2741
|
container.replaceChildren();
|
|
1637
|
-
|
|
1638
|
-
return;
|
|
1639
|
-
}
|
|
1640
|
-
const key = resolveRef(def.$switch.$ref, state) as string;
|
|
2742
|
+
generation += 1;
|
|
1641
2743
|
const caseDef = def.cases?.[key];
|
|
1642
2744
|
if (!caseDef) {
|
|
1643
2745
|
return;
|
|
1644
2746
|
}
|
|
2747
|
+
const gen = generation;
|
|
2748
|
+
const scope = effectScope(true);
|
|
2749
|
+
live = scope;
|
|
2750
|
+
const childOpts = options ? { ...options, _path: [...path, "cases", key] } : undefined;
|
|
1645
2751
|
|
|
1646
2752
|
if (isRefObj(caseDef)) {
|
|
1647
|
-
// External $ref — fetch and render asynchronously
|
|
1648
|
-
|
|
1649
|
-
const
|
|
1650
|
-
|
|
1651
|
-
resolve(href)
|
|
2753
|
+
// External $ref — fetch and render asynchronously, against the mount's base so a bundled
|
|
2754
|
+
// Document need not live at the page URL.
|
|
2755
|
+
const { href } = new URL(caseDef.$ref, options?._ctx?.base ?? location.href);
|
|
2756
|
+
resolve(href, options?._ctx)
|
|
1652
2757
|
.then(async (doc) => {
|
|
1653
2758
|
if (gen !== generation) {
|
|
1654
2759
|
return;
|
|
1655
2760
|
}
|
|
1656
|
-
const childScope = await buildScope(doc, {}, href);
|
|
2761
|
+
const childScope = await buildScope(doc, {}, href, options?._ctx);
|
|
1657
2762
|
if (gen !== generation) {
|
|
1658
2763
|
return;
|
|
1659
2764
|
}
|
|
1660
2765
|
container.replaceChildren();
|
|
1661
|
-
|
|
1662
|
-
|
|
2766
|
+
underInstancePath(reexpand(instancePath), () =>
|
|
2767
|
+
scope.run(() => container.append(renderNode(doc, childScope, childOpts))),
|
|
2768
|
+
);
|
|
1663
2769
|
})
|
|
1664
2770
|
.catch((error: unknown) =>
|
|
1665
2771
|
console.error("Jx $switch: failed to load external case", caseDef.$ref, error),
|
|
@@ -1667,9 +2773,19 @@ function renderSwitch(def: JxElement, state: JxScope, options?: JxRenderOptions)
|
|
|
1667
2773
|
return;
|
|
1668
2774
|
}
|
|
1669
2775
|
|
|
1670
|
-
|
|
1671
|
-
|
|
2776
|
+
// Tracking is paused for the same reason it is for a mapped row: a top-level read the case
|
|
2777
|
+
// Makes while it renders must not subscribe THIS effect, or the case re-renders on every
|
|
2778
|
+
// Change to anything it read. The bindings inside it track for themselves.
|
|
2779
|
+
pauseTracking();
|
|
2780
|
+
try {
|
|
2781
|
+
underInstancePath(reexpand(instancePath), () =>
|
|
2782
|
+
scope.run(() => container.append(renderNode(caseDef, state, childOpts))),
|
|
2783
|
+
);
|
|
2784
|
+
} finally {
|
|
2785
|
+
resetTracking();
|
|
2786
|
+
}
|
|
1672
2787
|
});
|
|
2788
|
+
onScopeDispose(retire, true);
|
|
1673
2789
|
|
|
1674
2790
|
return container;
|
|
1675
2791
|
}
|
|
@@ -1693,6 +2809,7 @@ export async function resolvePrototype(
|
|
|
1693
2809
|
state: JxScope,
|
|
1694
2810
|
key: string,
|
|
1695
2811
|
base?: string,
|
|
2812
|
+
ctx?: JxContext,
|
|
1696
2813
|
) {
|
|
1697
2814
|
// ── External class via $src ─────────────────────────────────────────────────
|
|
1698
2815
|
if (def.$src) {
|
|
@@ -1705,7 +2822,7 @@ export async function resolvePrototype(
|
|
|
1705
2822
|
const debounceMs = def.debounce ?? 0;
|
|
1706
2823
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
1707
2824
|
|
|
1708
|
-
if (!def.manual && !_autoRequestConfig.skip) {
|
|
2825
|
+
if (!def.manual && !(ctx?.skipAutoRequests ?? _autoRequestConfig.skip)) {
|
|
1709
2826
|
effect(() => {
|
|
1710
2827
|
let url: string | undefined;
|
|
1711
2828
|
if (isTemplateString(def.url)) {
|
|
@@ -1954,8 +3071,11 @@ async function resolveExternalPrototype(
|
|
|
1954
3071
|
*/
|
|
1955
3072
|
async function importAndInstantiate(def: JxScope, src: string, exportName: string, base?: string) {
|
|
1956
3073
|
let mod: ImportedModule;
|
|
1957
|
-
|
|
1958
|
-
|
|
3074
|
+
const seeded = seededModule(src);
|
|
3075
|
+
if (seeded instanceof Promise) {
|
|
3076
|
+
mod = await seeded;
|
|
3077
|
+
} else if (seeded) {
|
|
3078
|
+
mod = seeded;
|
|
1959
3079
|
} else {
|
|
1960
3080
|
try {
|
|
1961
3081
|
mod = (await import(src)) as ImportedModule;
|
|
@@ -2267,8 +3387,11 @@ async function resolveServerFunction(
|
|
|
2267
3387
|
const exportName = def.$export;
|
|
2268
3388
|
|
|
2269
3389
|
let mod: ImportedModule;
|
|
2270
|
-
|
|
2271
|
-
|
|
3390
|
+
const seeded = seededModule(src);
|
|
3391
|
+
if (seeded instanceof Promise) {
|
|
3392
|
+
mod = await seeded;
|
|
3393
|
+
} else if (seeded) {
|
|
3394
|
+
mod = seeded;
|
|
2272
3395
|
} else {
|
|
2273
3396
|
try {
|
|
2274
3397
|
mod = (await import(src)) as ImportedModule;
|
|
@@ -2407,8 +3530,11 @@ export function resolveRef(refPath: string, state: JxScope) {
|
|
|
2407
3530
|
if (typeof refPath !== "string") {
|
|
2408
3531
|
return refPath;
|
|
2409
3532
|
}
|
|
2410
|
-
if (refPath.startsWith("$map/")) {
|
|
2411
|
-
|
|
3533
|
+
if (refPath.startsWith("$map/") || refPath.startsWith("#/$map/")) {
|
|
3534
|
+
/* Both spellings. `#/` is the documented JSON Pointer prefix everywhere else in the schema,
|
|
3535
|
+
so a `#/$map/item/x` fell through to `readPath`, returned null, and a `$map` over it
|
|
3536
|
+
rendered ZERO rows with no warning. */
|
|
3537
|
+
const parts = (refPath.startsWith("#/") ? refPath.slice(2) : refPath).split("/");
|
|
2412
3538
|
const [, key] = parts; // "item" or "index"
|
|
2413
3539
|
const map = state.$map as Record<string, unknown> | undefined;
|
|
2414
3540
|
const base = map?.[key!] ?? state[`$map/${key}`];
|
|
@@ -2455,14 +3581,6 @@ function isRefObj(v: unknown): v is JxRef {
|
|
|
2455
3581
|
return isRefValue(v);
|
|
2456
3582
|
}
|
|
2457
3583
|
|
|
2458
|
-
/**
|
|
2459
|
-
* @param {string} k
|
|
2460
|
-
* @returns {boolean}
|
|
2461
|
-
*/
|
|
2462
|
-
function isNestedSelector(k: string) {
|
|
2463
|
-
return k.startsWith(":") || k.startsWith(".") || k.startsWith("&") || k.startsWith("[");
|
|
2464
|
-
}
|
|
2465
|
-
|
|
2466
3584
|
/**
|
|
2467
3585
|
* Walk a path off a value. Delegates to the one tokenizer (`pointer.ts`) — this used to split on
|
|
2468
3586
|
* `/[./]/`, which no other path in the codebase agreed with.
|
|
@@ -2475,6 +3593,71 @@ function getPath(obj: unknown, path: string) {
|
|
|
2475
3593
|
return readPath(obj, path);
|
|
2476
3594
|
}
|
|
2477
3595
|
|
|
3596
|
+
/**
|
|
3597
|
+
* Write an observed attribute into element state, coerced by the type the state entry already holds
|
|
3598
|
+
* (spec §16.5): a number parses, a boolean is presence (`"false"` counts as absent), and anything
|
|
3599
|
+
* else is the string. Shared by connection and `attributeChangedCallback` so the two cannot
|
|
3600
|
+
* disagree.
|
|
3601
|
+
*
|
|
3602
|
+
* REMOVING an attribute restores the entry's DECLARED DEFAULT rather than writing the absence
|
|
3603
|
+
* through. `attributeChangedCallback` reports a removal as `null`, and writing that through gave a
|
|
3604
|
+
* `type: "string"` entry the value `null` and a `type: "number"` entry `Number(null)`, which is 0 —
|
|
3605
|
+
* so a numeric prop could never express "unset" and a string prop stopped matching its own declared
|
|
3606
|
+
* type. The default is the value the entry had before anyone set the attribute, which is what the
|
|
3607
|
+
* removal is asking to go back to.
|
|
3608
|
+
*
|
|
3609
|
+
* @param defaults The declared defaults by state key; a key absent from it has none.
|
|
3610
|
+
*/
|
|
3611
|
+
function absorbAttribute(
|
|
3612
|
+
state: JxScope,
|
|
3613
|
+
name: string,
|
|
3614
|
+
value: string | null,
|
|
3615
|
+
defaults?: ReadonlyMap<string, unknown>,
|
|
3616
|
+
): void {
|
|
3617
|
+
const camelKey = name.replaceAll(/-([a-z])/g, (_: string, c: string) => c.toUpperCase());
|
|
3618
|
+
const current = state[camelKey];
|
|
3619
|
+
if (typeof current === "boolean") {
|
|
3620
|
+
// Presence IS the value for a boolean, so a removal is already spelled by the same rule.
|
|
3621
|
+
state[camelKey] = value !== null && value !== "false";
|
|
3622
|
+
return;
|
|
3623
|
+
}
|
|
3624
|
+
if (value === null) {
|
|
3625
|
+
const declared = defaults?.get(camelKey);
|
|
3626
|
+
state[camelKey] = declared === undefined ? (typeof current === "number" ? 0 : "") : declared;
|
|
3627
|
+
return;
|
|
3628
|
+
}
|
|
3629
|
+
state[camelKey] = typeof current === "number" ? Number(value) : value;
|
|
3630
|
+
}
|
|
3631
|
+
|
|
3632
|
+
/**
|
|
3633
|
+
* The declared default of every state entry that has one, by state key.
|
|
3634
|
+
*
|
|
3635
|
+
* @param def The element definition
|
|
3636
|
+
* @returns {Map<string, unknown>} Key → default
|
|
3637
|
+
*/
|
|
3638
|
+
function declaredDefaults(def: JxElement): Map<string, unknown> {
|
|
3639
|
+
const out = new Map<string, unknown>();
|
|
3640
|
+
for (const [key, entry] of Object.entries(def.state ?? {})) {
|
|
3641
|
+
if (entry !== null && typeof entry === "object") {
|
|
3642
|
+
const obj = entry as Record<string, unknown>;
|
|
3643
|
+
if ("default" in obj) {
|
|
3644
|
+
out.set(key, obj["default"]);
|
|
3645
|
+
continue;
|
|
3646
|
+
}
|
|
3647
|
+
// A computed entry has no default: its value is produced, and a removal leaves it alone.
|
|
3648
|
+
if (COMPUTED_STATE_KEYS.some((marker) => marker in obj)) {
|
|
3649
|
+
continue;
|
|
3650
|
+
}
|
|
3651
|
+
}
|
|
3652
|
+
// The shorthand `{ "label": "none" }` IS the default, the same way `buildScope` reads it.
|
|
3653
|
+
out.set(key, entry);
|
|
3654
|
+
}
|
|
3655
|
+
return out;
|
|
3656
|
+
}
|
|
3657
|
+
|
|
3658
|
+
/** The markers that make a state entry produced rather than authored. */
|
|
3659
|
+
const COMPUTED_STATE_KEYS = ["$expression", "$prototype", "$ref", "$src"] as const;
|
|
3660
|
+
|
|
2478
3661
|
/** Keys already reported, so a component rendered in a loop warns once rather than per instance. */
|
|
2479
3662
|
const _privatePropWarned = new Set<string>();
|
|
2480
3663
|
|
|
@@ -2561,36 +3744,104 @@ function mergeProps(def: JxElement, parentState: JxScope): JxScope {
|
|
|
2561
3744
|
}
|
|
2562
3745
|
|
|
2563
3746
|
/*
|
|
2564
|
-
* The CSS-authoring rules live in `./css.ts`,
|
|
3747
|
+
* The CSS-authoring rules live in `./css.ts`, whose only import is a pair of type guards. They are
|
|
3748
|
+
* re-exported
|
|
2565
3749
|
* here because they always were part of this module's surface and moving them must not break a
|
|
2566
3750
|
* consumer; a host that cannot afford the DOM runtime imports `@jxsuite/runtime/css` instead.
|
|
2567
3751
|
*/
|
|
2568
3752
|
export {
|
|
3753
|
+
buildStyleRules,
|
|
2569
3754
|
camelToKebab,
|
|
2570
3755
|
COLOR_SCHEME_ATTR,
|
|
2571
3756
|
COLOR_SCHEME_STORAGE_KEY,
|
|
3757
|
+
cssPropertyName,
|
|
3758
|
+
cssRuleText,
|
|
3759
|
+
hashCss,
|
|
3760
|
+
isDeclarationAtRule,
|
|
3761
|
+
isKeyframesAtRule,
|
|
3762
|
+
isNestedSelectorKey,
|
|
2572
3763
|
pureSchemeOf,
|
|
2573
3764
|
resolveAtQuery,
|
|
3765
|
+
resolveNestedSelector,
|
|
2574
3766
|
schemeSelectors,
|
|
3767
|
+
splitSelectorList,
|
|
3768
|
+
transposeCanvasOverlaySelector,
|
|
3769
|
+
transposeCanvasPopoverSelector,
|
|
2575
3770
|
} from "./css.ts";
|
|
3771
|
+
export type { CssBuildOptions, CssKeyframeBlock, CssRule, CssRuleTarget } from "./css.ts";
|
|
2576
3772
|
|
|
2577
3773
|
/**
|
|
2578
3774
|
* Convert a style rules object to a CSS text string (skipping nested selectors).
|
|
2579
3775
|
*
|
|
3776
|
+
* Kept for consumers; nothing in this repository composes a rule this way any more, because
|
|
3777
|
+
* `buildStyleRules` answers the same question for a whole style object rather than one flat level
|
|
3778
|
+
* of it. It shares that function's property spelling — `cssPropertyName`, not `camelToKebab` — so a
|
|
3779
|
+
* custom property survives it: `--fooBar` and `--foo-bar` are two different properties, and
|
|
3780
|
+
* kebab-casing one renames it out from under the `var()` that reads it.
|
|
3781
|
+
*
|
|
2580
3782
|
* @param {Record<string, unknown> | object} rules
|
|
2581
3783
|
* @returns {string}
|
|
2582
3784
|
*/
|
|
2583
3785
|
export function toCSSText(rules: Record<string, unknown> | object) {
|
|
2584
3786
|
return Object.entries(rules)
|
|
2585
|
-
.filter(([k, v]) => !
|
|
2586
|
-
.map(([p, v]) => `${
|
|
3787
|
+
.filter(([k, v]) => !isNestedSelectorKey(k) && (v === null || typeof v !== "object"))
|
|
3788
|
+
.map(([p, v]) => `${cssPropertyName(p)}: ${canvasStyleValue(String(v))}`)
|
|
2587
3789
|
.join("; ");
|
|
2588
3790
|
}
|
|
2589
3791
|
|
|
2590
3792
|
// ─── Custom Element Registration ──────────────────────────────────────────────
|
|
2591
3793
|
|
|
2592
3794
|
let _rootMedia: Record<string, string> = {};
|
|
2593
|
-
|
|
3795
|
+
/**
|
|
3796
|
+
* Every defined element's CURRENT definition, read at connection — which is what lets a definition
|
|
3797
|
+
* be replaced.
|
|
3798
|
+
*/
|
|
3799
|
+
const _elementDefs = new Map<string, { base: string; doc: JxDocument }>();
|
|
3800
|
+
|
|
3801
|
+
/**
|
|
3802
|
+
* The definition an element tag renders from, as most recently defined or redefined.
|
|
3803
|
+
*
|
|
3804
|
+
* @param {string} tagName
|
|
3805
|
+
* @returns {{ base: string; doc: JxDocument } | undefined}
|
|
3806
|
+
*/
|
|
3807
|
+
export function elementDefinition(tagName: string): { base: string; doc: JxDocument } | undefined {
|
|
3808
|
+
return _elementDefs.get(tagName);
|
|
3809
|
+
}
|
|
3810
|
+
|
|
3811
|
+
/**
|
|
3812
|
+
* Replace an element's definition (embedding.md §7). `customElements.define` is one-shot, so the
|
|
3813
|
+
* generated class reads its definition through the registry at connection time: an instance
|
|
3814
|
+
* connected after this call renders the new document; one already on the page keeps the definition
|
|
3815
|
+
* it rendered until it is re-mounted. `observedAttributes` is the one thing the platform freezes at
|
|
3816
|
+
* first definition, so a changed list is reported and takes effect only in a fresh realm. A tag not
|
|
3817
|
+
* yet defined is simply defined.
|
|
3818
|
+
*
|
|
3819
|
+
* @param {JxDocument} doc
|
|
3820
|
+
* @param {string} [baseUrl]
|
|
3821
|
+
* @returns {Promise<void>}
|
|
3822
|
+
*/
|
|
3823
|
+
export async function redefineElement(doc: JxDocument, baseUrl?: string): Promise<void> {
|
|
3824
|
+
const base = baseUrl ?? location.href;
|
|
3825
|
+
const { tagName } = doc;
|
|
3826
|
+
if (!tagName || !tagName.includes("-")) {
|
|
3827
|
+
throw new Error(`Jx redefineElement: tagName "${tagName}" must contain a hyphen`);
|
|
3828
|
+
}
|
|
3829
|
+
if (!customElements.get(tagName)) {
|
|
3830
|
+
await defineElement(doc, base);
|
|
3831
|
+
return;
|
|
3832
|
+
}
|
|
3833
|
+
if (doc.$elements) {
|
|
3834
|
+
await registerElements(doc.$elements, base);
|
|
3835
|
+
}
|
|
3836
|
+
const before = _elementDefs.get(tagName)?.doc.observedAttributes ?? [];
|
|
3837
|
+
const after = doc.observedAttributes ?? [];
|
|
3838
|
+
if (JSON.stringify(before) !== JSON.stringify(after)) {
|
|
3839
|
+
console.warn(
|
|
3840
|
+
`Jx redefineElement: <${tagName}> keeps observedAttributes [${before.join(", ")}] as first defined; [${after.join(", ")}] takes effect only in a fresh realm`,
|
|
3841
|
+
);
|
|
3842
|
+
}
|
|
3843
|
+
_elementDefs.set(tagName, { base, doc });
|
|
3844
|
+
}
|
|
2594
3845
|
|
|
2595
3846
|
/**
|
|
2596
3847
|
* Seed the module-level root `$media` map used as the fallback for components that declare their
|
|
@@ -2614,7 +3865,11 @@ export function setRootMedia(map: Record<string, string>): void {
|
|
|
2614
3865
|
* @param {string} base
|
|
2615
3866
|
* @returns {Promise<void>}
|
|
2616
3867
|
*/
|
|
2617
|
-
async function registerElements(
|
|
3868
|
+
async function registerElements(
|
|
3869
|
+
elements: NonNullable<JxDocument["$elements"]>,
|
|
3870
|
+
base: string,
|
|
3871
|
+
ctx?: JxContext,
|
|
3872
|
+
) {
|
|
2618
3873
|
for (const entry of elements) {
|
|
2619
3874
|
// Bare string: npm package side-effect import (registers custom elements)
|
|
2620
3875
|
if (typeof entry === "string") {
|
|
@@ -2633,7 +3888,7 @@ async function registerElements(elements: NonNullable<JxDocument["$elements"]>,
|
|
|
2633
3888
|
continue;
|
|
2634
3889
|
}
|
|
2635
3890
|
const { href } = new URL(entry.$ref, base);
|
|
2636
|
-
const doc = await resolve(href);
|
|
3891
|
+
const doc = await resolve(href, ctx);
|
|
2637
3892
|
if (!doc.tagName || !doc.tagName.includes("-")) {
|
|
2638
3893
|
continue;
|
|
2639
3894
|
}
|
|
@@ -2643,7 +3898,7 @@ async function registerElements(elements: NonNullable<JxDocument["$elements"]>,
|
|
|
2643
3898
|
|
|
2644
3899
|
// Depth-first: register sub-dependencies first
|
|
2645
3900
|
if (doc.$elements) {
|
|
2646
|
-
await registerElements(doc.$elements, href);
|
|
3901
|
+
await registerElements(doc.$elements, href, ctx);
|
|
2647
3902
|
}
|
|
2648
3903
|
|
|
2649
3904
|
await defineElement(doc, href);
|
|
@@ -2702,6 +3957,219 @@ function injectHead(entries: JxHeadEntry[], _base: string) {
|
|
|
2702
3957
|
}
|
|
2703
3958
|
}
|
|
2704
3959
|
|
|
3960
|
+
// ─── Instantiation chain (spec.md §16.9) ─────────────────────────────────────
|
|
3961
|
+
|
|
3962
|
+
/**
|
|
3963
|
+
* How deep component nesting may go before an instance refuses to render.
|
|
3964
|
+
*
|
|
3965
|
+
* The same number the compiler holds in `MAX_COMPONENT_NESTING` (`@jxsuite/compiler`, `shared.ts`),
|
|
3966
|
+
* and for the same reason: the cycle check in {@link extendInstancePath} catches a definition that
|
|
3967
|
+
* renders ITSELF — same tag, same props — which is the only shape that can never terminate, while a
|
|
3968
|
+
* self-reference whose props change at every level is data-driven recursion that a `$switch` may or
|
|
3969
|
+
* may not bottom out. The seen-set cannot tell those apart, so this cap is what turns a forgotten
|
|
3970
|
+
* stop into a diagnostic instead of a page that never settles. It is written twice because the
|
|
3971
|
+
* compiler depends on the runtime and not the other way round, and the runtime's browser bundle
|
|
3972
|
+
* cannot reach into the build tool; spec.md §16.9 is the one statement both are held to, and the
|
|
3973
|
+
* compiler's `nesting-cap-agreement.test.ts` asserts its copy against this export, so the two
|
|
3974
|
+
* numbers cannot part without a red test in the workspace that owns the copy.
|
|
3975
|
+
*/
|
|
3976
|
+
export const MAX_COMPONENT_NESTING = 32;
|
|
3977
|
+
|
|
3978
|
+
/** One instance on the chain: its tag, and the props it was given, as an identity. */
|
|
3979
|
+
interface InstanceFrame {
|
|
3980
|
+
tag: string;
|
|
3981
|
+
/** Every value the instance supplied that its definition's state read (§16.9). */
|
|
3982
|
+
props: Record<string, unknown>;
|
|
3983
|
+
}
|
|
3984
|
+
|
|
3985
|
+
/**
|
|
3986
|
+
* The chain an instance is created under, and where on it the current expansion began.
|
|
3987
|
+
*
|
|
3988
|
+
* `frames` is every instance from the page-level one down, outermost first. `expansion` is the
|
|
3989
|
+
* index of the first frame that belongs to the SAME synchronous pass as the instance being created:
|
|
3990
|
+
* the cycle check (§16.9 rule 1) looks back no further than that, while the cap (rule 2) counts
|
|
3991
|
+
* every frame. The distinction is what an effect re-run does to certainty. Along one pass a
|
|
3992
|
+
* repeated frame is a render that repeats itself, deterministically, forever. Across a re-run it is
|
|
3993
|
+
* not: the frames above the boundary rendered to completion once, and whether the page asks for one
|
|
3994
|
+
* of them again is its data — an `onMount` that flips a `$switch` into the same tag every time, or
|
|
3995
|
+
* a click that opens one more level of a node that starts closed — which the runtime cannot tell
|
|
3996
|
+
* apart. So the cycle check is not asked across the boundary, and the loop that lives in re-runs is
|
|
3997
|
+
* ended by the cap instead: thirty-three empty hosts and a diagnostic, not a page that never
|
|
3998
|
+
* settles.
|
|
3999
|
+
*/
|
|
4000
|
+
interface InstancePath {
|
|
4001
|
+
frames: readonly InstanceFrame[];
|
|
4002
|
+
expansion: number;
|
|
4003
|
+
}
|
|
4004
|
+
|
|
4005
|
+
/** The chain a page-level render runs under: nothing above it. */
|
|
4006
|
+
const ROOT_INSTANCE_PATH: InstancePath = { expansion: 0, frames: [] };
|
|
4007
|
+
|
|
4008
|
+
/**
|
|
4009
|
+
* The chain of instances whose definitions are being rendered RIGHT NOW.
|
|
4010
|
+
*
|
|
4011
|
+
* A module-level stack rather than a WeakMap from element to chain, and it is worth saying why that
|
|
4012
|
+
* survives `connectedCallback` being async. The callback awaits `buildScope` before it touches the
|
|
4013
|
+
* DOM, and by the time it resumes, whatever the enclosing instance pushed here has long been popped
|
|
4014
|
+
* — so the stack alone would be empty for every instance and catch nothing. What saves it is WHEN
|
|
4015
|
+
* an instance connects: `this.append(child)` in the enclosing render runs the child's
|
|
4016
|
+
* `connectedCallback` synchronously, and every way a definition brings an instance into being —
|
|
4017
|
+
* `renderCustomElementWithProps`, the bare-tag branch of `renderNode`, a nested element inside
|
|
4018
|
+
* either, and a prerendered host drawing its children again as it upgrades — appends it inside that
|
|
4019
|
+
* same synchronous stretch. So the callback reads the stack ONCE, before its first `await`, and
|
|
4020
|
+
* carries the copy across the gap. A WeakMap would need a write at each of those creation sites to
|
|
4021
|
+
* say the same thing.
|
|
4022
|
+
*
|
|
4023
|
+
* A subtree an effect renders LATER — a `$switch` case that flips after the first paint, a `$map`
|
|
4024
|
+
* row that arrives with new data — connects with nothing on the stack, because no enclosing render
|
|
4025
|
+
* is running when the effect re-runs; a write from `onMount` or a click is what triggered it. So
|
|
4026
|
+
* the two renderers that create nodes from an effect each capture the stack when they are first
|
|
4027
|
+
* rendered (which is inside the enclosing pass) and put it back, re-expanded, around every later
|
|
4028
|
+
* insertion ({@link underInstancePath}). That is the one thing this stack needs beyond the
|
|
4029
|
+
* synchronous stretch, and it is what makes the `onMount`-flip shape a diagnostic rather than a
|
|
4030
|
+
* hang. Reading `@vue/reactivity`'s `getCurrentScope()` there instead would find nothing: an effect
|
|
4031
|
+
* re-run restores no scope, and a case or a row renders in a DETACHED scope with no parent.
|
|
4032
|
+
*/
|
|
4033
|
+
let _instancePath: InstancePath = ROOT_INSTANCE_PATH;
|
|
4034
|
+
|
|
4035
|
+
/**
|
|
4036
|
+
* Run `fn` with `path` as the chain, so every instance that CONNECTS during it reads that chain.
|
|
4037
|
+
*
|
|
4038
|
+
* Restored rather than cleared afterwards: the enclosing render's chain is what was there, and a
|
|
4039
|
+
* restore needs no argument about whether anything was.
|
|
4040
|
+
*
|
|
4041
|
+
* @param {InstancePath} path - The chain the instances `fn` connects are created under
|
|
4042
|
+
* @param {() => T} fn - The render, or the insertion that connects what a render made
|
|
4043
|
+
* @returns {T} What `fn` returned
|
|
4044
|
+
*/
|
|
4045
|
+
function underInstancePath<T>(path: InstancePath, fn: () => T): T {
|
|
4046
|
+
const was = _instancePath;
|
|
4047
|
+
_instancePath = path;
|
|
4048
|
+
try {
|
|
4049
|
+
return fn();
|
|
4050
|
+
} finally {
|
|
4051
|
+
_instancePath = was;
|
|
4052
|
+
}
|
|
4053
|
+
}
|
|
4054
|
+
|
|
4055
|
+
/**
|
|
4056
|
+
* The chain a later insertion runs under: the same frames, with the expansion beginning now.
|
|
4057
|
+
*
|
|
4058
|
+
* Called on EVERY run of a `$switch` case or a `$map` reconcile, the first one included — harmless
|
|
4059
|
+
* there, because a first run builds into a detached container and nothing connects until the
|
|
4060
|
+
* enclosing pass appends it, under the enclosing pass's own chain.
|
|
4061
|
+
*
|
|
4062
|
+
* @param {InstancePath} path - The chain captured when the renderer was first rendered
|
|
4063
|
+
* @returns {InstancePath}
|
|
4064
|
+
*/
|
|
4065
|
+
function reexpand(path: InstancePath): InstancePath {
|
|
4066
|
+
return { expansion: path.frames.length, frames: path.frames };
|
|
4067
|
+
}
|
|
4068
|
+
|
|
4069
|
+
/**
|
|
4070
|
+
* Whether two prop values are the same input to a definition.
|
|
4071
|
+
*
|
|
4072
|
+
* Reference equality first — a `$ref` into the parent's state forwards the same primitive, the same
|
|
4073
|
+
* reactive proxy or the same function to the next level — and structural equality for plain data
|
|
4074
|
+
* rebuilt at each level (`data-jx-props` parsed twice is two objects with one meaning). Plain data
|
|
4075
|
+
* is what JSON can describe: an array, or an object with no prototype but `Object`'s, reactive or
|
|
4076
|
+
* raw. Anything else — a `Map`, a `Set`, a class instance, a circular object — is different from
|
|
4077
|
+
* everything but itself, because its JSON form would not say what it holds and two different values
|
|
4078
|
+
* that serialise alike would close a cycle that was never there. The cost of that policy is a loop
|
|
4079
|
+
* the depth cap catches instead; the alternative is a false diagnostic on a page that would have
|
|
4080
|
+
* ended. Inside plain data the comparison IS the JSON form, so a member JSON cannot see (a
|
|
4081
|
+
* function, a `Map` two levels down) is not seen here either; §16.9 says so.
|
|
4082
|
+
*
|
|
4083
|
+
* @param {unknown} a
|
|
4084
|
+
* @param {unknown} b
|
|
4085
|
+
* @returns {boolean}
|
|
4086
|
+
*/
|
|
4087
|
+
function samePropValue(a: unknown, b: unknown): boolean {
|
|
4088
|
+
if (Object.is(a, b)) {
|
|
4089
|
+
return true;
|
|
4090
|
+
}
|
|
4091
|
+
if (!isPlainData(a) || !isPlainData(b)) {
|
|
4092
|
+
return false;
|
|
4093
|
+
}
|
|
4094
|
+
try {
|
|
4095
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
4096
|
+
} catch {
|
|
4097
|
+
return false;
|
|
4098
|
+
}
|
|
4099
|
+
}
|
|
4100
|
+
|
|
4101
|
+
/**
|
|
4102
|
+
* Whether a value is an array or a plain object — the shapes whose JSON form is the whole value.
|
|
4103
|
+
*
|
|
4104
|
+
* @param {unknown} value
|
|
4105
|
+
* @returns {value is object}
|
|
4106
|
+
*/
|
|
4107
|
+
function isPlainData(value: unknown): value is object {
|
|
4108
|
+
if (typeof value !== "object" || value === null) {
|
|
4109
|
+
return false;
|
|
4110
|
+
}
|
|
4111
|
+
const raw: unknown = toRaw(value);
|
|
4112
|
+
if (Array.isArray(raw)) {
|
|
4113
|
+
return true;
|
|
4114
|
+
}
|
|
4115
|
+
const proto: unknown = Object.getPrototypeOf(raw);
|
|
4116
|
+
return proto === Object.prototype || proto === null;
|
|
4117
|
+
}
|
|
4118
|
+
|
|
4119
|
+
/**
|
|
4120
|
+
* Whether two frames are the same instance: one tag, one set of prop names, equal values.
|
|
4121
|
+
*
|
|
4122
|
+
* @param {InstanceFrame} a
|
|
4123
|
+
* @param {InstanceFrame} b
|
|
4124
|
+
* @returns {boolean}
|
|
4125
|
+
*/
|
|
4126
|
+
function sameFrame(a: InstanceFrame, b: InstanceFrame): boolean {
|
|
4127
|
+
if (a.tag !== b.tag) {
|
|
4128
|
+
return false;
|
|
4129
|
+
}
|
|
4130
|
+
const keys = Object.keys(a.props);
|
|
4131
|
+
if (keys.length !== Object.keys(b.props).length) {
|
|
4132
|
+
return false;
|
|
4133
|
+
}
|
|
4134
|
+
return keys.every(
|
|
4135
|
+
(key) => Object.hasOwn(b.props, key) && samePropValue(a.props[key], b.props[key]),
|
|
4136
|
+
);
|
|
4137
|
+
}
|
|
4138
|
+
|
|
4139
|
+
/**
|
|
4140
|
+
* The chain an instance's own render runs under: the enclosing chain plus itself — or the
|
|
4141
|
+
* diagnostic that says why it must not render at all.
|
|
4142
|
+
*
|
|
4143
|
+
* The two messages are the compiler's, word for word (`renderComponentInstance` in
|
|
4144
|
+
* `@jxsuite/compiler`), so a page that fails to build and a canvas that fails to paint say the same
|
|
4145
|
+
* thing about the same definition. Meeting a frame already on the chain, within the current
|
|
4146
|
+
* expansion, is the instance rendering itself with the same props, which never ends; a chain at the
|
|
4147
|
+
* cap is a self-reference whose props change — or whose repeats live in re-runs, where the cycle
|
|
4148
|
+
* check is not asked ({@link InstancePath}) — and whose stop case never came.
|
|
4149
|
+
*
|
|
4150
|
+
* @param {InstancePath} path - The chain the instance was created under
|
|
4151
|
+
* @param {InstanceFrame} frame - The instance about to render
|
|
4152
|
+
* @returns {InstancePath} The chain its children render under
|
|
4153
|
+
* @throws {Error} The named diagnostic — never a stack overflow, never a hang
|
|
4154
|
+
* @docs framework/concepts/components
|
|
4155
|
+
*/
|
|
4156
|
+
function extendInstancePath(path: InstancePath, frame: InstanceFrame): InstancePath {
|
|
4157
|
+
const chain = [...path.frames.map((f) => f.tag), frame.tag].join(" → ");
|
|
4158
|
+
if (path.frames.slice(path.expansion).some((f) => sameFrame(f, frame))) {
|
|
4159
|
+
throw new Error(
|
|
4160
|
+
`Component <${frame.tag}> renders itself: ${chain}. A component cannot appear inside its own ` +
|
|
4161
|
+
`definition with the same props — the expansion would never end.`,
|
|
4162
|
+
);
|
|
4163
|
+
}
|
|
4164
|
+
if (path.frames.length >= MAX_COMPONENT_NESTING) {
|
|
4165
|
+
throw new Error(
|
|
4166
|
+
`Component nesting exceeds ${MAX_COMPONENT_NESTING} levels: ${chain}. A component that ` +
|
|
4167
|
+
`renders itself with changing props needs a case that stops.`,
|
|
4168
|
+
);
|
|
4169
|
+
}
|
|
4170
|
+
return { expansion: path.expansion, frames: [...path.frames, frame] };
|
|
4171
|
+
}
|
|
4172
|
+
|
|
2705
4173
|
/**
|
|
2706
4174
|
* Register a custom element from a Jx document.
|
|
2707
4175
|
*
|
|
@@ -2741,8 +4209,11 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
|
|
|
2741
4209
|
|
|
2742
4210
|
_elementDefs.set(tagName, { base, doc: source_ });
|
|
2743
4211
|
|
|
2744
|
-
|
|
2745
|
-
|
|
4212
|
+
// The definition as first given. The class reads the CURRENT one at connection (see
|
|
4213
|
+
// `redefineElement`); only `observedAttributes` is frozen here, because the platform freezes it.
|
|
4214
|
+
const initialDef = source_;
|
|
4215
|
+
const initialBase = base;
|
|
4216
|
+
const observedAttrs = initialDef.observedAttributes ?? [];
|
|
2746
4217
|
|
|
2747
4218
|
const ElementClass = class extends HTMLElement {
|
|
2748
4219
|
_jxInitialized = false;
|
|
@@ -2765,10 +4236,40 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
|
|
|
2765
4236
|
}
|
|
2766
4237
|
this._jxInitialized = true;
|
|
2767
4238
|
|
|
2768
|
-
|
|
4239
|
+
/* The chain this instance was created under, read BEFORE the first `await`: the enclosing
|
|
4240
|
+
render is still on the stack now and will not be when this callback resumes. See
|
|
4241
|
+
{@link _instancePath} for why that ordering is the whole mechanism. */
|
|
4242
|
+
const enclosingPath = _instancePath;
|
|
4243
|
+
/* What this instance was given, recorded beside each write into state below. It is the
|
|
4244
|
+
instance's identity on the chain (§16.9): two nested instances of one tag that were given
|
|
4245
|
+
the same values render the same thing, forever. Only a value the definition's state READS
|
|
4246
|
+
is recorded — a `$props` key no state entry declares changes nothing about the render, so
|
|
4247
|
+
a definition that "varies" one at each level is still rendering itself. */
|
|
4248
|
+
const supplied: Record<string, unknown> = {};
|
|
4249
|
+
|
|
4250
|
+
// The definition as it stands NOW — a redefinition since this class was made is honoured.
|
|
4251
|
+
const live = _elementDefs.get(tagName) ?? { base: initialBase, doc: initialDef };
|
|
4252
|
+
const def = live.doc;
|
|
4253
|
+
const defBase = live.base;
|
|
4254
|
+
|
|
4255
|
+
// The element is its own dispatch root: a body run without an event emits from the host.
|
|
4256
|
+
const state = await buildScope(def, {}, defBase, { base: defBase, media: null, root: this });
|
|
2769
4257
|
|
|
2770
4258
|
// Read properties from the data-jx-props payload the site build writes on a
|
|
2771
4259
|
// Non-static instance, so an upgrade re-renders with the authored props, not the defaults.
|
|
4260
|
+
/* Observed attributes already on the element are read now. `attributeChangedCallback` fires
|
|
4261
|
+
for an attribute set before connection too — but on an instance with no state yet, so it
|
|
4262
|
+
had nothing to write into, and `<jx-icon name="plus">` rendered with the default. Before
|
|
4263
|
+
the `$props` merges below, so a property a parent set still wins (spec §16.2). */
|
|
4264
|
+
for (const attr of observedAttrs) {
|
|
4265
|
+
if (this.hasAttribute(attr)) {
|
|
4266
|
+
absorbAttribute(state, attr, this.getAttribute(attr));
|
|
4267
|
+
// The raw string, not the coerced value: coercion is a function of it, so two equal
|
|
4268
|
+
// Strings are one input.
|
|
4269
|
+
supplied[attr] = this.getAttribute(attr);
|
|
4270
|
+
}
|
|
4271
|
+
}
|
|
4272
|
+
|
|
2772
4273
|
const propsAttr = this.dataset.jxProps;
|
|
2773
4274
|
if (propsAttr) {
|
|
2774
4275
|
try {
|
|
@@ -2779,6 +4280,7 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
|
|
|
2779
4280
|
}
|
|
2780
4281
|
if (key in (def.state ?? {})) {
|
|
2781
4282
|
state[key] = val;
|
|
4283
|
+
supplied[key] = val;
|
|
2782
4284
|
}
|
|
2783
4285
|
}
|
|
2784
4286
|
} catch {}
|
|
@@ -2800,6 +4302,7 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
|
|
|
2800
4302
|
}
|
|
2801
4303
|
if (key in (def.state ?? {})) {
|
|
2802
4304
|
state[key] = this.getAttribute(name);
|
|
4305
|
+
supplied[key] = this.getAttribute(name);
|
|
2803
4306
|
this.removeAttribute(name);
|
|
2804
4307
|
}
|
|
2805
4308
|
}
|
|
@@ -2815,7 +4318,48 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
|
|
|
2815
4318
|
instanceSupplies(this, key)
|
|
2816
4319
|
) {
|
|
2817
4320
|
state[key] = (this as Record<string, unknown>)[key];
|
|
4321
|
+
supplied[key] = (this as Record<string, unknown>)[key];
|
|
4322
|
+
}
|
|
4323
|
+
}
|
|
4324
|
+
|
|
4325
|
+
/* Every input is known now, so this is where an instance that would never finish is
|
|
4326
|
+
refused (§16.9): before it has a state, a style or a child, so a refused host is an empty
|
|
4327
|
+
element the page renders around — emptied of what the definition wrote into it for its
|
|
4328
|
+
slots too, since nothing will distribute that. The diagnostic is REPORTED rather than
|
|
4329
|
+
thrown onward: a throw from an async `connectedCallback` is an unhandled rejection that
|
|
4330
|
+
names no element and stops nothing. It goes two ways. The console is the runtime's
|
|
4331
|
+
surface for a render that fails after the page has settled (`$switch` external cases and
|
|
4332
|
+
the dev proxies report there too). The `jx-error` event is the surface a HOST can hold:
|
|
4333
|
+
it bubbles from the refused element with the error on it, so a page, or the studio canvas
|
|
4334
|
+
wrapping one, can collect it where a console line inside an iframe reaches nobody. The
|
|
4335
|
+
rest of the page keeps rendering, which is the point.
|
|
4336
|
+
|
|
4337
|
+
Neither surface is used for a host that is no longer in the document. A refused instance
|
|
4338
|
+
was connected when this callback began, and one that is not by the time the verdict is in
|
|
4339
|
+
belongs to a subtree the page discarded during the `await` above: a prerendered host of the
|
|
4340
|
+
same tag that was parsed INSIDE another and re-rendered from the definition by the outer
|
|
4341
|
+
one's upgrade, which captures it as slot content and empties itself. Its replacement is
|
|
4342
|
+
the instance that reports, on the chain the outer upgrade draws; reporting from the
|
|
4343
|
+
discarded copy too said one defect twice. The refusal itself still stands, so a host
|
|
4344
|
+
re-attached later is an empty element and not a hang. */
|
|
4345
|
+
let ownPath: InstancePath;
|
|
4346
|
+
try {
|
|
4347
|
+
ownPath = extendInstancePath(enclosingPath, { props: supplied, tag: tagName });
|
|
4348
|
+
} catch (error) {
|
|
4349
|
+
this.replaceChildren();
|
|
4350
|
+
if (!this.isConnected) {
|
|
4351
|
+
return;
|
|
2818
4352
|
}
|
|
4353
|
+
console.error(`Jx: <${tagName}> was not rendered:`, error);
|
|
4354
|
+
this.dispatchEvent(
|
|
4355
|
+
new ErrorEvent("jx-error", {
|
|
4356
|
+
bubbles: true,
|
|
4357
|
+
composed: true,
|
|
4358
|
+
error,
|
|
4359
|
+
message: error instanceof Error ? error.message : String(error),
|
|
4360
|
+
}),
|
|
4361
|
+
);
|
|
4362
|
+
return;
|
|
2819
4363
|
}
|
|
2820
4364
|
/*
|
|
2821
4365
|
* Set up property getters/setters that forward into reactive state. Private entries get NO
|
|
@@ -2843,16 +4387,22 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
|
|
|
2843
4387
|
const slottedChildren = [...this.childNodes];
|
|
2844
4388
|
this.replaceChildren();
|
|
2845
4389
|
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
4390
|
+
/* Custom elements default to `display: inline`; a Jx container behaves like a `<div>`, so
|
|
4391
|
+
one is supplied. It goes in the element's OWN RULE rather than inline: an inline
|
|
4392
|
+
declaration is beaten only by `!important`, so a consumer could not override the default
|
|
4393
|
+
without one. At (0,1,0) a consumer's rule, a cascade layer or a parent's descendant rule
|
|
4394
|
+
all win normally, and the definition's own `display` still wins because it is written into
|
|
4395
|
+
the same rule after this. */
|
|
4396
|
+
const defStyle = declaresDisplay(def.style) ? def.style : { display: "block", ...def.style };
|
|
4397
|
+
/* Definition first, call site second, so the call site wins at equal specificity by source
|
|
4398
|
+
order — the promise §9.2 already makes for base-before-nested. Merged into ONE call
|
|
4399
|
+
because each `applyStyle` releases what the last one interned. */
|
|
4400
|
+
const hostStyle = mergeStyle(defStyle as JxStyle, callSiteStyles.get(this));
|
|
2852
4401
|
|
|
2853
4402
|
// Render template into light DOM (once, not in effect — inner effects handle reactivity)
|
|
2854
|
-
applyStyle(this,
|
|
4403
|
+
applyStyle(this, hostStyle, (state["$media"] as Record<string, string>) ?? {}, state);
|
|
2855
4404
|
applyAttributes(this, def.attributes ?? {}, state);
|
|
4405
|
+
bindDefinitionHandlers(this, def, state);
|
|
2856
4406
|
|
|
2857
4407
|
/*
|
|
2858
4408
|
* Root-level `textContent` is a definition's content just as much as `children` is — it is
|
|
@@ -2863,17 +4413,34 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
|
|
|
2863
4413
|
bindProperty(this, "textContent", def.textContent, state);
|
|
2864
4414
|
}
|
|
2865
4415
|
const children = Array.isArray(def.children) ? def.children : [];
|
|
2866
|
-
|
|
2867
|
-
|
|
4416
|
+
/* An instance the studio stamped is part of the page being edited, so everything the
|
|
4417
|
+
definition draws inside it is too — and the de-link rules cannot see that from the nodes
|
|
4418
|
+
themselves. Restored rather than cleared, because one definition may render another. */
|
|
4419
|
+
const wasInside = _canvasInsideStampedHost;
|
|
4420
|
+
_canvasInsideStampedHost ||= this.dataset.jxPath !== undefined;
|
|
4421
|
+
/* And the chain, for the same span: an instance this definition draws connects inside
|
|
4422
|
+
`this.append` below and reads it there ({@link underInstancePath}). */
|
|
4423
|
+
try {
|
|
4424
|
+
underInstancePath(ownPath, () => {
|
|
4425
|
+
for (const childDef of children) {
|
|
4426
|
+
this.append(renderNode(childDef, state));
|
|
4427
|
+
}
|
|
4428
|
+
});
|
|
4429
|
+
} finally {
|
|
4430
|
+
_canvasInsideStampedHost = wasInside;
|
|
2868
4431
|
}
|
|
2869
4432
|
|
|
2870
4433
|
// Slot distribution (light DOM)
|
|
2871
4434
|
distributeSlots(this, slottedChildren);
|
|
2872
4435
|
|
|
2873
|
-
|
|
4436
|
+
/* Lifecycle: onMount, with the HOST as its second argument.
|
|
4437
|
+
A sidecar that must reach the element it belongs to had no way to: the runtime handed it
|
|
4438
|
+
the scope alone, so elements resorted to dispatching a `jx-ready` event at themselves and
|
|
4439
|
+
catching it with a root handler purely to learn `currentTarget`. The host is what the
|
|
4440
|
+
callback needed; the event round trip was the workaround. */
|
|
2874
4441
|
const { onMount } = state;
|
|
2875
4442
|
if (typeof onMount === "function") {
|
|
2876
|
-
queueMicrotask(() => (onMount as (s: JxScope) => unknown)(state));
|
|
4443
|
+
queueMicrotask(() => (onMount as (s: JxScope, host: HTMLElement) => unknown)(state, this));
|
|
2877
4444
|
}
|
|
2878
4445
|
}
|
|
2879
4446
|
|
|
@@ -2893,15 +4460,14 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
|
|
|
2893
4460
|
if (!this._state || oldVal === newVal) {
|
|
2894
4461
|
return;
|
|
2895
4462
|
}
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
this._state
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
}
|
|
4463
|
+
// The CURRENT definition's defaults, so a redefinition (embedding.md §7) that changes one
|
|
4464
|
+
// Reaches an instance that is already connected.
|
|
4465
|
+
absorbAttribute(
|
|
4466
|
+
this._state,
|
|
4467
|
+
name,
|
|
4468
|
+
newVal,
|
|
4469
|
+
declaredDefaults(_elementDefs.get(tagName)?.doc ?? initialDef),
|
|
4470
|
+
);
|
|
2905
4471
|
}
|
|
2906
4472
|
};
|
|
2907
4473
|
|
|
@@ -2958,15 +4524,33 @@ function renderCustomElementWithProps(
|
|
|
2958
4524
|
}
|
|
2959
4525
|
}
|
|
2960
4526
|
|
|
2961
|
-
|
|
4527
|
+
/* Apply host-level style and attributes from the usage site.
|
|
4528
|
+
Also REMEMBERED, because `connectedCallback` applies the definition's style through the same
|
|
4529
|
+
interning path and `applyStyleInto` releases the element's existing rules first — so without
|
|
4530
|
+
this the call site's declarations were written here and deleted a moment later, and a document
|
|
4531
|
+
could not style an element instance at all. See {@link callSiteStyles}. */
|
|
4532
|
+
if (def.style) {
|
|
4533
|
+
callSiteStyles.set(el, def.style);
|
|
4534
|
+
}
|
|
2962
4535
|
applyStyle(el, def.style ?? {}, (state["$media"] as Record<string, string>) ?? {}, state);
|
|
2963
4536
|
applyAttributes(el, def.attributes ?? {}, state);
|
|
2964
|
-
|
|
2965
|
-
|
|
4537
|
+
/* And its ordinary properties and handlers — `id`, `hidden`, `className`, an `onselect` — exactly
|
|
4538
|
+
as an ordinary element gets them. A custom element used in a document is still an element in
|
|
4539
|
+
that document; only `$props` are the definition's to absorb. */
|
|
4540
|
+
applyProperties(el, def, state);
|
|
4541
|
+
|
|
4542
|
+
// Append slotted children. A mapped array is not a node: its rows are rendered in place, ahead of
|
|
4543
|
+
// Their anchor, exactly as they are under an ordinary element — so a `$map` of menu rows inside a
|
|
4544
|
+
// `jx-menu` is a list of rows, not one `<div>` standing where the list should be.
|
|
2966
4545
|
const children = Array.isArray(def.children) ? def.children : [];
|
|
2967
4546
|
for (let i = 0; i < children.length; i++) {
|
|
4547
|
+
const child = children[i]!;
|
|
2968
4548
|
const childOpts = options && path ? { ...options, _path: [...path, "children", i] } : undefined;
|
|
2969
|
-
|
|
4549
|
+
if (isMappedArray(child)) {
|
|
4550
|
+
renderMappedArrayInto(el, child, state, childOpts);
|
|
4551
|
+
} else {
|
|
4552
|
+
el.append(renderNode(child, state, childOpts));
|
|
4553
|
+
}
|
|
2970
4554
|
}
|
|
2971
4555
|
|
|
2972
4556
|
return el;
|
|
@@ -2979,10 +4563,11 @@ function renderCustomElementWithProps(
|
|
|
2979
4563
|
* @param {ChildNode[]} slottedChildren
|
|
2980
4564
|
*/
|
|
2981
4565
|
function distributeSlots(host: HTMLElement, slottedChildren: ChildNode[]) {
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
4566
|
+
/* No early return when nothing was slotted. A slot leaves no node WHETHER OR NOT it received
|
|
4567
|
+
anything — an unmatched one unwraps to its own fallback children, and a host given nothing at
|
|
4568
|
+
all is just every slot unmatched. Returning early here left the slot standing in exactly the
|
|
4569
|
+
case an element most wants to detect: `[part="label"]:empty` answered false for a control with
|
|
4570
|
+
no label, because the surviving slot was still a child. */
|
|
2986
4571
|
const slots = host.querySelectorAll("slot");
|
|
2987
4572
|
if (slots.length === 0) {
|
|
2988
4573
|
return;
|
|
@@ -3003,14 +4588,16 @@ function distributeSlots(host: HTMLElement, slottedChildren: ChildNode[]) {
|
|
|
3003
4588
|
}
|
|
3004
4589
|
}
|
|
3005
4590
|
|
|
4591
|
+
/* A `<slot>` UNWRAPS: its matches stand in its place and the slot itself leaves no node.
|
|
4592
|
+
It used to survive, holding its matches as children. The box tree looked right, because a
|
|
4593
|
+
slot is `display: contents` — but the SELECTOR tree did not, so every `& > x` rule in a
|
|
4594
|
+
definition silently stopped matching a slotted child, which is now a grandchild. Measured on
|
|
4595
|
+
the field row: 68.40px where the class form gives 88.40px, and its help text 80px wide
|
|
4596
|
+
instead of 260px, with `[data-span]` dead. An unmatched slot unwraps to its own fallback
|
|
4597
|
+
children, which is what the platform's slot does when nothing is assigned to it. */
|
|
3006
4598
|
for (const slot of slots) {
|
|
3007
4599
|
const name = slot.getAttribute("name");
|
|
3008
4600
|
const matches = name ? (named.get(name) ?? []) : unnamed;
|
|
3009
|
-
|
|
3010
|
-
slot.replaceChildren();
|
|
3011
|
-
for (const child of matches) {
|
|
3012
|
-
slot.append(child);
|
|
3013
|
-
}
|
|
3014
|
-
}
|
|
4601
|
+
slot.replaceWith(...(matches.length > 0 ? matches : [...slot.childNodes]));
|
|
3015
4602
|
}
|
|
3016
4603
|
}
|