@webjsdev/core 0.7.50 → 0.7.51
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/webjs-core-browser.js +5 -5
- package/dist/webjs-core-browser.js.map +3 -3
- package/dist/webjs-core-testing.js +20 -20
- package/dist/webjs-core-testing.js.map +3 -3
- package/dist/webjs-core.js +7 -7
- package/dist/webjs-core.js.map +3 -3
- package/index-browser.js +1 -1
- package/index.d.ts +1 -1
- package/index.js +1 -1
- package/package.json +1 -1
- package/src/component/lifecycle.js +1481 -0
- package/src/component/reactive.js +171 -0
- package/src/component/server-element.js +248 -0
- package/src/component.js +2 -1896
- package/src/render-client/parts.js +2007 -0
- package/src/render-client/reconciler.js +388 -0
- package/src/render-client/template-compiler.js +525 -0
- package/src/render-client.js +1 -2938
- package/src/render-server/attrs.js +160 -0
- package/src/render-server/dsd.js +445 -0
- package/src/render-server/html-scan.js +402 -0
- package/src/render-server/slots.js +213 -0
- package/src/render-server/stream.js +163 -0
- package/src/render-server/template-renderer.js +923 -0
- package/src/render-server/text.js +163 -0
- package/src/render-server.js +6 -2405
- package/src/router-client/anchors.js +44 -0
- package/src/router-client/boundaries.js +221 -0
- package/src/router-client/constants.js +187 -0
- package/src/router-client/diagnostics.js +377 -0
- package/src/router-client/dom-differ.js +709 -0
- package/src/router-client/dom-parse.js +150 -0
- package/src/router-client/events.js +139 -0
- package/src/router-client/fetch-apply.js +387 -0
- package/src/router-client/form-encoder.js +135 -0
- package/src/router-client/frames.js +329 -0
- package/src/router-client/head-merge.js +344 -0
- package/src/router-client/nav-error.js +105 -0
- package/src/router-client/navigator.js +781 -0
- package/src/router-client/prefetch.js +824 -0
- package/src/router-client/scroll.js +259 -0
- package/src/router-client/snapshot-cache.js +97 -0
- package/src/router-client/state.js +92 -0
- package/src/router-client/stream.js +206 -0
- package/src/router-client/swap.js +451 -0
- package/src/router-client/upgrade.js +261 -0
- package/src/router-client/view-transition.js +223 -0
- package/src/router-client.d.ts +10 -1
- package/src/router-client.js +75 -5345
- package/src/slot/assignment.js +304 -0
- package/src/slot/interception.js +558 -0
- package/src/slot/polyfills.js +365 -0
- package/src/slot/project.js +432 -0
- package/src/slot/sensors.js +303 -0
- package/src/slot/state.js +245 -0
- package/src/slot/symbols.js +85 -0
- package/src/slot.js +7 -2199
package/src/render-client.js
CHANGED
|
@@ -1,2938 +1 @@
|
|
|
1
|
-
|
|
2
|
-
import { BINDING_PREFIXES, isBindingPrefix } from './binding-prefixes.js';
|
|
3
|
-
import { escapeAttr } from './escape.js';
|
|
4
|
-
import {
|
|
5
|
-
assertNotFunctionActionAttr, assertNotFunctionReflectedActionProp,
|
|
6
|
-
reconcileFormAction, isBoundFormAction, ABSENT, assertSubmitterHasNoName,
|
|
7
|
-
assertSubmitterType, assertSubmitterHasNoValue, assertSubmitterHasNoStaticFormAction,
|
|
8
|
-
assertSubmitterHasNoFormAttribute, assertSingleSubmitterAction,
|
|
9
|
-
resolveBoundSubmitterAttrs, applyResolvedAttr, releaseSubmitterAttrs,
|
|
10
|
-
assertConvergentSubmitter, isSubmitterReflectedProp,
|
|
11
|
-
formActionId, assertIdentifiableAction, FORM_ACTION_FIELD,
|
|
12
|
-
} from './form-action.js';
|
|
13
|
-
import { isRepeat } from './repeat.js';
|
|
14
|
-
import { isUnsafeHTML, isLive, isKeyed, isGuard, isTemplateContent, isRef, isCache, isUntil, isAsyncAppend, isAsyncReplace, isWatch } from './directives.js';
|
|
15
|
-
import { Signal } from './signal.js';
|
|
16
|
-
import {
|
|
17
|
-
LIGHT_SLOT_ATTR,
|
|
18
|
-
PROJECTION_ATTR,
|
|
19
|
-
PROJECTION_FALLBACK,
|
|
20
|
-
SLOT_FALLBACK_FRAG,
|
|
21
|
-
SLOT_STATE,
|
|
22
|
-
SLOT_OWNER,
|
|
23
|
-
RENDERING,
|
|
24
|
-
applySlotAssignments,
|
|
25
|
-
rescueAssignedNodes,
|
|
26
|
-
withRendererWrites,
|
|
27
|
-
drainRendererBackstop,
|
|
28
|
-
} from './slot.js';
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Open the renderer-write window on a light-DOM host while `fn` commits into
|
|
32
|
-
* it, so the host's patched slot-interception methods delegate to native and
|
|
33
|
-
* a renderer commit is never mistaken for authored content. A no-op (just runs
|
|
34
|
-
* `fn`) when `node` is not a slot host, so nested and non-host commits pay
|
|
35
|
-
* nothing. Covers the ASYNC commit paths (async directives, streaming) that
|
|
36
|
-
* run outside a synchronous render() call.
|
|
37
|
-
*/
|
|
38
|
-
function commitInto(node, fn) {
|
|
39
|
-
const host = node && /** @type {any} */ (node)[SLOT_STATE] ? node : null;
|
|
40
|
-
if (!host) return fn();
|
|
41
|
-
return withRendererWrites(host, fn);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Client-side renderer with **fine-grained** updates.
|
|
46
|
-
*
|
|
47
|
-
* Each TemplateResult is compiled once (keyed by the tagged-template's
|
|
48
|
-
* `strings` array identity, so reuse is free across renders) into:
|
|
49
|
-
* - a `<template>` element with static HTML + marker comments/attributes
|
|
50
|
-
* at each dynamic hole
|
|
51
|
-
* - a list of `Part` descriptors (kind + DOM location + attr/event name)
|
|
52
|
-
*
|
|
53
|
-
* On first render the template is cloned into the container and each Part
|
|
54
|
-
* is bound to the freshly-created node. Subsequent renders compare the new
|
|
55
|
-
* values to the last-applied values and only touch parts that changed.
|
|
56
|
-
* Text-position holes containing nested TemplateResults reuse the existing
|
|
57
|
-
* child instance when the inner `strings` match; they only rebuild when the
|
|
58
|
-
* template shape changes.
|
|
59
|
-
*
|
|
60
|
-
* Consequences worth knowing:
|
|
61
|
-
* - Input focus, cursor position, selection, and scroll inside components
|
|
62
|
-
* survive re-renders triggered by property assignments, signal changes,
|
|
63
|
-
* or `requestUpdate()`.
|
|
64
|
-
* - Event listeners are attached once and retargeted when the handler
|
|
65
|
-
* reference changes (swap-in-place via a dispatch closure, so `addEventListener`
|
|
66
|
-
* isn't churned every render).
|
|
67
|
-
* - A plain `.map()` array reconciles POSITIONALLY (non-keyed), matching
|
|
68
|
-
* lit-html: each index updates its item instance in place when the
|
|
69
|
-
* template shape is unchanged, so DOM node identity (and the focus,
|
|
70
|
-
* selection, scroll, and in-progress native drag it carries) survives an
|
|
71
|
-
* item-level update. See `reconcileArray`. Keyed reordering still needs
|
|
72
|
-
* the `repeat()` directive.
|
|
73
|
-
*/
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* One `method` / `enctype` hole on a candidate bound form. `statics` / `group`
|
|
77
|
-
* are carried for a mixed attribute, whose value is the concatenation of its
|
|
78
|
-
* static pieces and every one of its holes.
|
|
79
|
-
*
|
|
80
|
-
* @typedef {{ i: number, kind: string, statics?: string[], group?: number[] }} FormAttrPart
|
|
81
|
-
*/
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* What one `<form action=${...}>` in a template needs at reconcile time (#1155).
|
|
85
|
-
* Every field is a property of the TEMPLATE, computed once per template literal
|
|
86
|
-
* call site, so it cannot drift out of step with the live DOM.
|
|
87
|
-
*
|
|
88
|
-
* @typedef {{
|
|
89
|
-
* actionIdxs: number[],
|
|
90
|
-
* duplicateAction: boolean,
|
|
91
|
-
* staticAction: boolean,
|
|
92
|
-
* authoredName: boolean,
|
|
93
|
-
* authoredValue: boolean,
|
|
94
|
-
* authoredForm: boolean,
|
|
95
|
-
* nameParts: {i: number, kind: string}[],
|
|
96
|
-
* valueParts: {i: number, kind: string}[],
|
|
97
|
-
* propAttrs: string[],
|
|
98
|
-
* staticMethod: string | null,
|
|
99
|
-
* staticEnctype: string | null,
|
|
100
|
-
* methodParts: FormAttrPart[],
|
|
101
|
-
* enctypeParts: FormAttrPart[],
|
|
102
|
-
* }} FormActionRecord
|
|
103
|
-
*/
|
|
104
|
-
|
|
105
|
-
/** @type {WeakMap<TemplateStringsArray | string[], { templateEl: HTMLTemplateElement, parts: PartDescriptor[], formActions: FormActionRecord[] | null }>} */
|
|
106
|
-
const templateCache = new WeakMap();
|
|
107
|
-
/**
|
|
108
|
-
* Submitters this renderer stamped with an identity, so a later release removes
|
|
109
|
-
* only the framework's own `name` / `value` and never an author's.
|
|
110
|
-
* @type {WeakMap<Element, string>}
|
|
111
|
-
*/
|
|
112
|
-
const submitterActionBindings = new WeakMap();
|
|
113
|
-
const INSTANCE = Symbol.for('webjs.instance');
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
* @typedef {{
|
|
117
|
-
* kind: 'child' | 'attr' | 'attr-mixed' | 'event' | 'prop' | 'bool' | 'element' | 'slot' | 'noop',
|
|
118
|
-
* path: number[],
|
|
119
|
-
* name?: string,
|
|
120
|
-
* statics?: string[],
|
|
121
|
-
* group?: number[],
|
|
122
|
-
* }} PartDescriptor
|
|
123
|
-
*
|
|
124
|
-
* @typedef {{
|
|
125
|
-
* strings: TemplateStringsArray | string[],
|
|
126
|
-
* bound: BoundPart[],
|
|
127
|
-
* lastValues: unknown[],
|
|
128
|
-
* startNode: Comment,
|
|
129
|
-
* endNode: Comment,
|
|
130
|
-
* }} TemplateInstance
|
|
131
|
-
*
|
|
132
|
-
* @typedef {
|
|
133
|
-
* | { kind: 'child', marker: Comment, child?: TemplateInstance | ChildNode[] }
|
|
134
|
-
* | { kind: 'attr', el: Element, name: string }
|
|
135
|
-
* | { kind: 'attr-mixed', el: Element, name: string, statics: string[], group: number[] }
|
|
136
|
-
* | { kind: 'event', el: Element, name: string, handler: ((e: Event) => void) | null, dispatcher: (e: Event) => void }
|
|
137
|
-
* | { kind: 'prop', el: Element, name: string }
|
|
138
|
-
* | { kind: 'bool', el: Element, name: string }
|
|
139
|
-
* | { kind: 'element', el: Element, lastTarget?: any }
|
|
140
|
-
* | { kind: 'slot', slotEl: HTMLSlotElement, applied: boolean }
|
|
141
|
-
* | { kind: 'noop' }
|
|
142
|
-
* } BoundPart
|
|
143
|
-
*/
|
|
144
|
-
|
|
145
|
-
/**
|
|
146
|
-
* The container the in-progress `render()` is committing into, or null
|
|
147
|
-
* outside one. Every part created or applied during that render belongs to
|
|
148
|
-
* THIS container's component, which is what an out-of-band commit needs to
|
|
149
|
-
* know to reach the right error boundary. A structural parent walk cannot
|
|
150
|
-
* work it out: `html`<child-el>${watch(sig)}</child-el>`` puts the part in the
|
|
151
|
-
* PARENT's template but inside the child's tag, so the walk meets the child
|
|
152
|
-
* first. Same reason `SLOT_OWNER` exists.
|
|
153
|
-
* @type {any}
|
|
154
|
-
*/
|
|
155
|
-
let currentRenderRoot = null;
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* The element carrying the error boundary for a render root. A ShadowRoot has
|
|
159
|
-
* no boundary of its own; its `.host` is the component.
|
|
160
|
-
* @param {any} root
|
|
161
|
-
* @returns {any}
|
|
162
|
-
*/
|
|
163
|
-
function boundaryOwnerOf(root) {
|
|
164
|
-
if (!root) return null;
|
|
165
|
-
if (root.nodeType === 11 && root.host) return root.host;
|
|
166
|
-
return root;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
/**
|
|
170
|
-
* Run an OUT-OF-BAND commit (a `watch` notify microtask, an `until`
|
|
171
|
-
* resolution) with the part's owner installed as the current render root.
|
|
172
|
-
*
|
|
173
|
-
* Without this the commit runs with no owner in scope, so any `watch` /
|
|
174
|
-
* `until` nested INSIDE the template it commits is installed unstamped and
|
|
175
|
-
* its own later throw escapes to the window. A directive nested in that
|
|
176
|
-
* template belongs to the same component as the part committing it, which is
|
|
177
|
-
* exactly the owner already recorded here.
|
|
178
|
-
*
|
|
179
|
-
* @param {Extract<BoundPart, {kind:'child'}>} part
|
|
180
|
-
* @param {() => void} fn
|
|
181
|
-
*/
|
|
182
|
-
function commitOutOfBand(part, fn) {
|
|
183
|
-
const owner = /** @type any */ (part).__commitOwner;
|
|
184
|
-
const prev = currentRenderRoot;
|
|
185
|
-
if (owner) currentRenderRoot = owner;
|
|
186
|
-
try {
|
|
187
|
-
fn();
|
|
188
|
-
} finally {
|
|
189
|
-
currentRenderRoot = prev;
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/**
|
|
194
|
-
* Render a value into a container, reusing DOM where possible.
|
|
195
|
-
*
|
|
196
|
-
* @param {unknown} value
|
|
197
|
-
* @param {Element | DocumentFragment | ShadowRoot} container
|
|
198
|
-
*/
|
|
199
|
-
export function render(value, container) {
|
|
200
|
-
const host = /** @type any */ (container);
|
|
201
|
-
// Open the renderer-write window for the whole commit: every host-receiver
|
|
202
|
-
// write below (and in createInstance / updateInstance / clearInstance / all
|
|
203
|
-
// part commits they reach synchronously) then bypasses the slot interception
|
|
204
|
-
// that is patched onto a light host. This is the single discriminator
|
|
205
|
-
// between a renderer commit and an author write.
|
|
206
|
-
const prevRendering = host[RENDERING];
|
|
207
|
-
host[RENDERING] = true;
|
|
208
|
-
const prevRenderRoot = currentRenderRoot;
|
|
209
|
-
currentRenderRoot = container;
|
|
210
|
-
try {
|
|
211
|
-
const prev = host[INSTANCE];
|
|
212
|
-
|
|
213
|
-
if (isTemplate(value)) {
|
|
214
|
-
const tr = /** @type {import('./html.js').TemplateResult} */ (value);
|
|
215
|
-
if (prev && prev.strings === tr.strings) {
|
|
216
|
-
updateInstance(prev, tr.values);
|
|
217
|
-
return;
|
|
218
|
-
}
|
|
219
|
-
if (prev) clearInstance(prev, container);
|
|
220
|
-
|
|
221
|
-
// Light DOM hydration: if container has SSR content (marked by
|
|
222
|
-
// <!--webjs-hydrate-->), remove the marker and proceed with normal
|
|
223
|
-
// rendering. The content will be replaced with identical output -
|
|
224
|
-
// no visible flash because SSR and client render produce the same HTML.
|
|
225
|
-
const firstChild = container.firstChild;
|
|
226
|
-
if (firstChild && firstChild.nodeType === 8 && /** @type {Comment} */ (firstChild).data === 'webjs-hydrate') {
|
|
227
|
-
firstChild.remove();
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
// Pre-set the symbol to an explicit null BEFORE the commit,
|
|
231
|
-
// UNCONDITIONALLY: if createInstance throws after its replaceChildren
|
|
232
|
-
// (e.g. inside the slot-part apply loop), the finally-drain must see
|
|
233
|
-
// "rendered, no instance" (discard the commit's records), never
|
|
234
|
-
// "never rendered" (fold and corrupt) and never a STALE cleared prev
|
|
235
|
-
// instance on the template-swap path (whose bookends would misclassify
|
|
236
|
-
// the half-committed new roots as unowned and fold them).
|
|
237
|
-
host[INSTANCE] = null;
|
|
238
|
-
const inst = createInstance(tr, container);
|
|
239
|
-
host[INSTANCE] = inst;
|
|
240
|
-
return;
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
// Non-template value: treat as a single text child.
|
|
244
|
-
if (prev) clearInstance(prev, container);
|
|
245
|
-
host[INSTANCE] = null;
|
|
246
|
-
container.replaceChildren();
|
|
247
|
-
if (value == null || value === false || value === true) return;
|
|
248
|
-
if (Array.isArray(value)) {
|
|
249
|
-
for (const v of value) {
|
|
250
|
-
const text = document.createTextNode(String(v ?? ''));
|
|
251
|
-
container.appendChild(text);
|
|
252
|
-
}
|
|
253
|
-
return;
|
|
254
|
-
}
|
|
255
|
-
container.appendChild(document.createTextNode(String(value)));
|
|
256
|
-
} finally {
|
|
257
|
-
currentRenderRoot = prevRenderRoot;
|
|
258
|
-
host[RENDERING] = prevRendering;
|
|
259
|
-
// Outermost window closing: drain this commit's childList records off the
|
|
260
|
-
// slot backstop (drainRendererBackstop processes them with a renderer-output
|
|
261
|
-
// skip when an instance exists, else discards), so the backstop never folds
|
|
262
|
-
// renderer output. Mirrors withRendererWrites, used by the async paths.
|
|
263
|
-
if (!prevRendering) drainRendererBackstop(host);
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
/* ================================================================
|
|
268
|
-
* Template compilation
|
|
269
|
-
* ================================================================ */
|
|
270
|
-
|
|
271
|
-
/** @param {import('./html.js').TemplateResult} tr */
|
|
272
|
-
function compile(tr) {
|
|
273
|
-
const { strings } = tr;
|
|
274
|
-
let cached = templateCache.get(strings);
|
|
275
|
-
if (cached) return cached;
|
|
276
|
-
|
|
277
|
-
/** @type {PartDescriptor[]} */
|
|
278
|
-
const parts = [];
|
|
279
|
-
let html = '';
|
|
280
|
-
let state = 'text';
|
|
281
|
-
let attrName = '';
|
|
282
|
-
let attrStart = 0;
|
|
283
|
-
let attrQuote = '';
|
|
284
|
-
let commentDashes = 0;
|
|
285
|
-
/** @type {{ name: string, firstPartIdx: number } | null} */
|
|
286
|
-
let mixedAttr = null;
|
|
287
|
-
let currentTag = '';
|
|
288
|
-
let rawTail = '';
|
|
289
|
-
|
|
290
|
-
for (let i = 0; i < strings.length; i++) {
|
|
291
|
-
const s = strings[i];
|
|
292
|
-
for (let j = 0; j < s.length; j++) {
|
|
293
|
-
const c = s[j];
|
|
294
|
-
switch (state) {
|
|
295
|
-
case 'text':
|
|
296
|
-
html += c;
|
|
297
|
-
if (c === '<') state = 'tag-open';
|
|
298
|
-
break;
|
|
299
|
-
case 'tag-open':
|
|
300
|
-
html += c;
|
|
301
|
-
if (c === '!') state = 'bang-1';
|
|
302
|
-
else if (c === '/') { state = 'tag-name'; currentTag = ''; }
|
|
303
|
-
else if (/[a-zA-Z]/.test(c)) { state = 'tag-name'; currentTag = c.toLowerCase(); }
|
|
304
|
-
else state = 'text';
|
|
305
|
-
break;
|
|
306
|
-
case 'bang-1':
|
|
307
|
-
html += c;
|
|
308
|
-
state = c === '-' ? 'bang-dash' : 'tag-name';
|
|
309
|
-
break;
|
|
310
|
-
case 'bang-dash':
|
|
311
|
-
html += c;
|
|
312
|
-
if (c === '-') { state = 'comment'; commentDashes = 0; }
|
|
313
|
-
else state = 'tag-name';
|
|
314
|
-
break;
|
|
315
|
-
case 'comment':
|
|
316
|
-
html += c;
|
|
317
|
-
if (c === '-') commentDashes += 1;
|
|
318
|
-
else if (c === '>' && commentDashes >= 2) { state = 'text'; commentDashes = 0; }
|
|
319
|
-
else commentDashes = 0;
|
|
320
|
-
break;
|
|
321
|
-
case 'tag-name':
|
|
322
|
-
html += c;
|
|
323
|
-
if (c === '>') {
|
|
324
|
-
state = (currentTag === 'script' || currentTag === 'style') ? 'rawtext' : 'text';
|
|
325
|
-
if (state === 'rawtext') rawTail = '';
|
|
326
|
-
} else if (/\s/.test(c)) state = 'in-tag';
|
|
327
|
-
else currentTag += c.toLowerCase();
|
|
328
|
-
break;
|
|
329
|
-
case 'in-tag':
|
|
330
|
-
html += c;
|
|
331
|
-
if (c === '>') {
|
|
332
|
-
state = (currentTag === 'script' || currentTag === 'style') ? 'rawtext' : 'text';
|
|
333
|
-
if (state === 'rawtext') rawTail = '';
|
|
334
|
-
} else if (!/\s/.test(c) && c !== '/') {
|
|
335
|
-
state = 'attr-name';
|
|
336
|
-
attrName = c;
|
|
337
|
-
attrStart = html.length - 1;
|
|
338
|
-
}
|
|
339
|
-
break;
|
|
340
|
-
case 'rawtext':
|
|
341
|
-
html += c;
|
|
342
|
-
rawTail = (rawTail + c.toLowerCase()).slice(-9);
|
|
343
|
-
if (rawTail.endsWith('</script>') || rawTail.endsWith('</style>')) {
|
|
344
|
-
state = 'text';
|
|
345
|
-
rawTail = '';
|
|
346
|
-
currentTag = '';
|
|
347
|
-
}
|
|
348
|
-
break;
|
|
349
|
-
case 'attr-name':
|
|
350
|
-
if (c === '=') { state = 'after-eq'; html += c; }
|
|
351
|
-
else if (/\s/.test(c)) { state = 'in-tag'; attrName = ''; html += c; }
|
|
352
|
-
else if (c === '>') { state = 'text'; attrName = ''; html += c; }
|
|
353
|
-
else { attrName += c; html += c; }
|
|
354
|
-
break;
|
|
355
|
-
case 'after-eq':
|
|
356
|
-
if (c === '"' || c === "'") { state = 'attr-quoted'; attrQuote = c; html += c; }
|
|
357
|
-
else if (/\s/.test(c)) { state = 'in-tag'; attrName = ''; html += c; }
|
|
358
|
-
else if (c === '>') { state = 'text'; attrName = ''; html += c; }
|
|
359
|
-
else { state = 'attr-unquoted'; html += c; }
|
|
360
|
-
break;
|
|
361
|
-
case 'attr-unquoted':
|
|
362
|
-
if (/\s/.test(c)) { state = 'in-tag'; attrName = ''; html += c; }
|
|
363
|
-
else if (c === '>') { state = 'text'; attrName = ''; html += c; }
|
|
364
|
-
else html += c;
|
|
365
|
-
break;
|
|
366
|
-
case 'attr-quoted':
|
|
367
|
-
html += c;
|
|
368
|
-
if (c === attrQuote) { state = 'in-tag'; attrName = ''; }
|
|
369
|
-
break;
|
|
370
|
-
case 'skip-attr':
|
|
371
|
-
// Consume mixed-attribute chars without appending to html.
|
|
372
|
-
// The attribute was replaced with a sentinel on the first hole.
|
|
373
|
-
if (c === attrQuote) {
|
|
374
|
-
// Closing quote: finalize the attr-mixed part.
|
|
375
|
-
if (mixedAttr) {
|
|
376
|
-
const idx0 = mixedAttr.firstPartIdx;
|
|
377
|
-
const group = [];
|
|
378
|
-
for (let k = idx0; k < parts.length; k++) {
|
|
379
|
-
if (parts[k].kind === 'noop' || parts[k].kind === 'attr-mixed') group.push(k);
|
|
380
|
-
}
|
|
381
|
-
// Build statics from the template strings array.
|
|
382
|
-
// For `attr="a ${x} b ${y} c"`, group=[idx0,idx1].
|
|
383
|
-
// statics[0] = tail of strings[idx0] after the `="`
|
|
384
|
-
// statics[1] = strings[idx1] (between holes)
|
|
385
|
-
// statics[n] = prefix of strings[last+1] up to closing quote
|
|
386
|
-
const statics = [];
|
|
387
|
-
const s0 = strings[group[0]];
|
|
388
|
-
const qp = s0.lastIndexOf(attrQuote);
|
|
389
|
-
statics.push(qp >= 0 ? s0.slice(qp + 1) : s0);
|
|
390
|
-
for (let k = 1; k < group.length; k++) {
|
|
391
|
-
statics.push(strings[group[k]]);
|
|
392
|
-
}
|
|
393
|
-
const sLast = strings[group[group.length - 1] + 1];
|
|
394
|
-
const eq = sLast.indexOf(attrQuote);
|
|
395
|
-
statics.push(eq >= 0 ? sLast.slice(0, eq) : sLast);
|
|
396
|
-
|
|
397
|
-
parts[idx0] = {
|
|
398
|
-
kind: 'attr-mixed',
|
|
399
|
-
path: [],
|
|
400
|
-
name: mixedAttr.name,
|
|
401
|
-
statics,
|
|
402
|
-
group,
|
|
403
|
-
};
|
|
404
|
-
// The mixed attribute is rebuilt from ALL its holes' values, but
|
|
405
|
-
// it is anchored at a single part (group[0]). The later holes stay
|
|
406
|
-
// `noop`, so a change confined to one of them would be skipped by
|
|
407
|
-
// updateInstance's per-hole dirty-check and the attribute would go
|
|
408
|
-
// stale. Point every non-anchor member back at the anchor so a
|
|
409
|
-
// change to any hole re-applies the whole attribute.
|
|
410
|
-
for (let m = 1; m < group.length; m++) {
|
|
411
|
-
parts[group[m]] = { kind: 'noop', path: [], mixedAnchor: idx0 };
|
|
412
|
-
}
|
|
413
|
-
mixedAttr = null;
|
|
414
|
-
}
|
|
415
|
-
state = 'in-tag';
|
|
416
|
-
attrName = '';
|
|
417
|
-
}
|
|
418
|
-
break;
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
if (i < strings.length - 1) {
|
|
423
|
-
const partIdx = parts.length;
|
|
424
|
-
if (state === 'comment') {
|
|
425
|
-
// Holes inside <!-- ... --> are dropped. Comments are inert and
|
|
426
|
-
// the compile cache is keyed on `strings`, so per-render values
|
|
427
|
-
// can't be baked in anyway.
|
|
428
|
-
commentDashes = 0;
|
|
429
|
-
parts.push({ kind: 'noop', path: [] });
|
|
430
|
-
continue;
|
|
431
|
-
}
|
|
432
|
-
if (state === 'rawtext') {
|
|
433
|
-
// Inside <script>/<style>: per-render interpolation isn't supported;
|
|
434
|
-
// the compile cache would lock in whatever was first rendered. The
|
|
435
|
-
// hole is dropped and authors should set body text via a child part
|
|
436
|
-
// outside the raw-text container, or inline style/script directly.
|
|
437
|
-
rawTail = '';
|
|
438
|
-
parts.push({ kind: 'noop', path: [] });
|
|
439
|
-
continue;
|
|
440
|
-
}
|
|
441
|
-
if (state === 'text') {
|
|
442
|
-
// Child hole: insert a comment marker. Use bracketed markers so we can
|
|
443
|
-
// later walk all comments and find ours without ambiguity.
|
|
444
|
-
html += `<!--${MARKER}${partIdx}-->`;
|
|
445
|
-
parts.push({ kind: 'child', path: [] });
|
|
446
|
-
} else if (state === 'in-tag') {
|
|
447
|
-
// Element-position hole: `<tag ${expr}>`. Used by the `ref` directive
|
|
448
|
-
// (and any future element-bound directive). Emit a sentinel attribute
|
|
449
|
-
// on the current open tag; at bind time the attribute is stripped
|
|
450
|
-
// and the element is captured into the part.
|
|
451
|
-
const sentinel = `data-${MARKER}${partIdx}`;
|
|
452
|
-
html += `${sentinel}=""`;
|
|
453
|
-
parts.push({ kind: 'element', path: [] });
|
|
454
|
-
} else if (state === 'after-eq') {
|
|
455
|
-
const prefix = attrName[0];
|
|
456
|
-
const name = attrName.slice(1);
|
|
457
|
-
if (isBindingPrefix(prefix)) {
|
|
458
|
-
// Strip the attribute name+"=" from html and add a sentinel attr.
|
|
459
|
-
html = html.slice(0, attrStart);
|
|
460
|
-
const kind = BINDING_PREFIXES[prefix];
|
|
461
|
-
const sentinel = `data-${MARKER}${partIdx}`;
|
|
462
|
-
html += `${sentinel}=""`;
|
|
463
|
-
parts.push({ kind, path: [], name });
|
|
464
|
-
} else {
|
|
465
|
-
// Regular attribute: rewrite to `attrName="__MARKER__"` and parse as attr.
|
|
466
|
-
html = html.slice(0, attrStart);
|
|
467
|
-
const sentinel = `data-${MARKER}${partIdx}`;
|
|
468
|
-
html += `${sentinel}=""`;
|
|
469
|
-
parts.push({ kind: 'attr', path: [], name: attrName });
|
|
470
|
-
}
|
|
471
|
-
state = 'in-tag';
|
|
472
|
-
attrName = '';
|
|
473
|
-
} else if (state === 'attr-quoted' || state === 'attr-unquoted') {
|
|
474
|
-
// First hole inside a quoted attribute value: start mixed-attr tracking.
|
|
475
|
-
// Replace the entire attribute with a sentinel (same as regular attr).
|
|
476
|
-
html = html.slice(0, attrStart);
|
|
477
|
-
const sentinel = `data-${MARKER}${partIdx}`;
|
|
478
|
-
html += `${sentinel}=""`;
|
|
479
|
-
mixedAttr = { name: attrName, firstPartIdx: partIdx };
|
|
480
|
-
parts.push({ kind: 'noop', path: [] }); // patched to attr-mixed at close-quote
|
|
481
|
-
state = 'skip-attr';
|
|
482
|
-
} else if (state === 'skip-attr') {
|
|
483
|
-
// Subsequent hole in the same mixed attribute.
|
|
484
|
-
parts.push({ kind: 'noop', path: [] });
|
|
485
|
-
}
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
const templateEl = document.createElement('template');
|
|
490
|
-
templateEl.innerHTML = html;
|
|
491
|
-
|
|
492
|
-
// Mark every <slot> in the template for framework projection and
|
|
493
|
-
// register a SLOT part for each so the slot-apply step can find them on
|
|
494
|
-
// clones. This runs BEFORE assignPaths so the sentinel attributes the
|
|
495
|
-
// discovery step adds are picked up in the same path-recording walk.
|
|
496
|
-
discoverSlots(templateEl.content, parts);
|
|
497
|
-
|
|
498
|
-
// Walk the parsed fragment and record DOM paths for each part. It also
|
|
499
|
-
// returns the per-template form-action records (#1155), null when the
|
|
500
|
-
// template contains no bound-form candidate, which is the overwhelmingly
|
|
501
|
-
// common case and keeps the reconcile out of every other template's path.
|
|
502
|
-
const formActions = assignPaths(templateEl.content, parts);
|
|
503
|
-
|
|
504
|
-
cached = { templateEl, parts, formActions };
|
|
505
|
-
templateCache.set(strings, cached);
|
|
506
|
-
return cached;
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
/**
|
|
510
|
-
* Walk the compiled template content for <slot> elements (the static ones
|
|
511
|
-
* written into the template, not dynamically-inserted ones). For each:
|
|
512
|
-
* 1. Add the `data-webjs-light` attribute so slot.js's polyfilled APIs
|
|
513
|
-
* recognise it as a framework-managed light-DOM slot.
|
|
514
|
-
* 2. Add a sentinel attribute (`data-MARKER<idx>`) so the subsequent
|
|
515
|
-
* assignPaths walk records the slot's path into the new SLOT part.
|
|
516
|
-
* 3. Move the slot's authored children into a `fallbackTemplate`
|
|
517
|
-
* DocumentFragment stored on the PartDescriptor. The slot in the
|
|
518
|
-
* cached template becomes empty, so every clone starts empty too.
|
|
519
|
-
* bindPart clones a fresh fallback fragment per instance from this
|
|
520
|
-
* template, giving each instance an independent fallback supply
|
|
521
|
-
* that slot.js swaps in via the SLOT_FALLBACK_FRAG symbol.
|
|
522
|
-
*
|
|
523
|
-
* Fallback content with template holes (`<slot>fallback ${x}</slot>`)
|
|
524
|
-
* is captured as a static-HTML snapshot of the template state at
|
|
525
|
-
* compile time. Dynamic holes inside fallback content are not
|
|
526
|
-
* re-bound per instance in v1; authors should put dynamic content
|
|
527
|
-
* outside the slot.
|
|
528
|
-
*
|
|
529
|
-
* @param {DocumentFragment} root
|
|
530
|
-
* @param {PartDescriptor[]} parts
|
|
531
|
-
*/
|
|
532
|
-
function discoverSlots(root, parts) {
|
|
533
|
-
const slots = root.querySelectorAll('slot');
|
|
534
|
-
for (const slot of slots) {
|
|
535
|
-
slot.setAttribute(LIGHT_SLOT_ATTR, '');
|
|
536
|
-
const partIdx = parts.length;
|
|
537
|
-
slot.setAttribute(`data-${MARKER}${partIdx}`, '');
|
|
538
|
-
parts.push({ kind: 'slot', path: [] });
|
|
539
|
-
}
|
|
540
|
-
// NOTE: fallback content stays IN the slot's children in the cached
|
|
541
|
-
// template. Each clone gets its own copy. For shadow-DOM components,
|
|
542
|
-
// native browser projection uses those children as fallback content
|
|
543
|
-
// when no light child matches. For light-DOM components, the slot's
|
|
544
|
-
// apply step (run after the cloned template is in the live tree)
|
|
545
|
-
// moves the cloned fallback into a per-instance holding fragment
|
|
546
|
-
// owned by slot.js, so the slot is empty and ready to receive
|
|
547
|
-
// projected children. Doing the strip at apply time, not at compile
|
|
548
|
-
// time, lets a single cached template serve both DOM modes.
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
/**
|
|
552
|
-
* Walk the template fragment and record the path (chain of child indices) to
|
|
553
|
-
* each part's anchor node. We use marker comments for child parts and sentinel
|
|
554
|
-
* attributes for everything else.
|
|
555
|
-
*
|
|
556
|
-
* @param {DocumentFragment} root
|
|
557
|
-
* @param {PartDescriptor[]} parts
|
|
558
|
-
*/
|
|
559
|
-
function assignPaths(root, parts) {
|
|
560
|
-
/** @type {number[]} */
|
|
561
|
-
const path = [];
|
|
562
|
-
/** @type {FormActionRecord[]} */
|
|
563
|
-
const formActions = [];
|
|
564
|
-
/** @param {Node} node */
|
|
565
|
-
function visit(node) {
|
|
566
|
-
for (let i = 0; i < node.childNodes.length; i++) {
|
|
567
|
-
const child = node.childNodes[i];
|
|
568
|
-
path.push(i);
|
|
569
|
-
// Comment marker?
|
|
570
|
-
if (child.nodeType === 8) {
|
|
571
|
-
const txt = /** @type Comment */ (child).data;
|
|
572
|
-
if (txt.startsWith(MARKER)) {
|
|
573
|
-
const idx = Number(txt.slice(MARKER.length));
|
|
574
|
-
if (parts[idx] && parts[idx].kind === 'child') {
|
|
575
|
-
parts[idx].path = path.slice();
|
|
576
|
-
}
|
|
577
|
-
}
|
|
578
|
-
} else if (child.nodeType === 1) {
|
|
579
|
-
const el = /** @type Element */ (child);
|
|
580
|
-
// Sentinel attribute?
|
|
581
|
-
const toRemove = [];
|
|
582
|
-
/** Every part bound to THIS element, which only this walk can see. */
|
|
583
|
-
/** @type {{ idx: number, kind: string, name: string }[]} */
|
|
584
|
-
const onEl = [];
|
|
585
|
-
for (const attr of el.attributes) {
|
|
586
|
-
if (attr.name.startsWith(`data-${MARKER}`)) {
|
|
587
|
-
const idx = Number(attr.name.slice(`data-${MARKER}`.length));
|
|
588
|
-
if (parts[idx] && parts[idx].kind !== 'child') {
|
|
589
|
-
parts[idx].path = path.slice();
|
|
590
|
-
onEl.push({ idx, kind: parts[idx].kind, name: parts[idx].name || '' });
|
|
591
|
-
}
|
|
592
|
-
toRemove.push(attr.name);
|
|
593
|
-
}
|
|
594
|
-
}
|
|
595
|
-
// #1155 / #1207: record what a bound form or submitter needs, while the
|
|
596
|
-
// STATIC attributes and the element's part indices are both still
|
|
597
|
-
// visible. This is the only point in compilation with that whole view,
|
|
598
|
-
// and it runs once per template, so the record is a constant rather
|
|
599
|
-
// than runtime memory that can drift out of step with the DOM. It is
|
|
600
|
-
// also the only place a submitter's own `name` is still legible: by
|
|
601
|
-
// reconcile time the renderer has written the identity over it.
|
|
602
|
-
if (el.localName === 'form' || el.localName === 'button' || el.localName === 'input') {
|
|
603
|
-
const rec = buildFormActionRecord(el, onEl, parts);
|
|
604
|
-
if (rec) formActions.push(rec);
|
|
605
|
-
}
|
|
606
|
-
for (const a of toRemove) el.removeAttribute(a);
|
|
607
|
-
visit(child);
|
|
608
|
-
}
|
|
609
|
-
path.pop();
|
|
610
|
-
}
|
|
611
|
-
}
|
|
612
|
-
visit(root);
|
|
613
|
-
return formActions.length ? formActions : null;
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
/**
|
|
617
|
-
* Build the compile-time record for one `<form>` carrying an `action` hole, or
|
|
618
|
-
* one submitter carrying a `formaction` hole.
|
|
619
|
-
*
|
|
620
|
-
* A CANDIDATE, not a decision: `<form action=${maybeString}>` compiles exactly
|
|
621
|
-
* like a bound one, so whether the form is really bound is a runtime fact the
|
|
622
|
-
* reconcile decides from the value. Everything recorded here is a property of
|
|
623
|
-
* the TEMPLATE, which is why it cannot go stale the way remembering "did the
|
|
624
|
-
* bind supply this attribute" at runtime did.
|
|
625
|
-
*
|
|
626
|
-
* `encoding` folds into `enctype` for a PROPERTY binding only: it is a legacy
|
|
627
|
-
* IDL alias, so `.encoding=` writes the enctype content attribute. As a content
|
|
628
|
-
* attribute `encoding=` is inert (a browser's `form.encoding` reads back
|
|
629
|
-
* `enctype`), and SSR ignores it, so folding the attribute spelling too would
|
|
630
|
-
* make the client honour a value the server never saw.
|
|
631
|
-
*
|
|
632
|
-
* The submitter fields (`authoredName` / `authoredValue` / `authoredForm`) are
|
|
633
|
-
* read off the COMPILED template rather than the live element, for the same
|
|
634
|
-
* reason: by reconcile time the renderer has written its own `name` / `value`
|
|
635
|
-
* onto a bound submitter, so the live element can no longer say which of them
|
|
636
|
-
* the author wrote. A hole-provided `name=${n}` leaves no attribute on the
|
|
637
|
-
* compiled template at all, which is why `nameParts` is tracked separately.
|
|
638
|
-
*
|
|
639
|
-
* @param {Element} el
|
|
640
|
-
* @param {{ idx: number, kind: string, name: string }[]} onEl parts bound to `el`
|
|
641
|
-
* @param {PartDescriptor[]} parts
|
|
642
|
-
* @returns {FormActionRecord | null}
|
|
643
|
-
*/
|
|
644
|
-
function buildFormActionRecord(el, onEl, parts) {
|
|
645
|
-
const isForm = el.localName === 'form';
|
|
646
|
-
const targetAttr = isForm ? 'action' : 'formaction';
|
|
647
|
-
// The submission pair, named per element (#1307). A form declares `method` /
|
|
648
|
-
// `enctype`; a submitter declares `formmethod` / `formenctype`, and a bound
|
|
649
|
-
// one receives them from the renderer exactly as a bound form does.
|
|
650
|
-
const methodAttr = isForm ? 'method' : 'formmethod';
|
|
651
|
-
const enctypeAttr = isForm ? 'enctype' : 'formenctype';
|
|
652
|
-
const actionParts = onEl.filter((p) => p.kind === 'attr' && p.name.toLowerCase() === targetAttr);
|
|
653
|
-
if (!actionParts.length) return null;
|
|
654
|
-
|
|
655
|
-
/** @type {FormAttrPart[]} */
|
|
656
|
-
const methodParts = [];
|
|
657
|
-
/** @type {FormAttrPart[]} */
|
|
658
|
-
const enctypeParts = [];
|
|
659
|
-
/** Prop bindings that cannot converge with SSR; refused when actually bound. */
|
|
660
|
-
const propAttrs = [];
|
|
661
|
-
// `name` holes on a submitter, recorded WITH their kind. Whether one occupies
|
|
662
|
-
// the identity's channel is not a property of the hole alone: SSR emits
|
|
663
|
-
// `name=""` for an attribute hole whatever it resolved to, but emits nothing
|
|
664
|
-
// at all for a FALSY boolean hole and nothing for an `@name` listener. Asking
|
|
665
|
-
// only "is there a part called name" therefore refused templates SSR renders
|
|
666
|
-
// happily, which is the render-on-the-server-throw-on-hydration direction.
|
|
667
|
-
// The kinds travel with the record and `reconcileFormActions` resolves them.
|
|
668
|
-
const nameParts = isForm ? [] : onEl
|
|
669
|
-
.filter((p) => p.name.toLowerCase() === 'name'
|
|
670
|
-
&& (p.kind === 'attr' || p.kind === 'attr-mixed' || p.kind === 'bool'))
|
|
671
|
-
.map((p) => ({ i: p.idx, kind: p.kind }));
|
|
672
|
-
const authoredName = !isForm && el.hasAttribute('name');
|
|
673
|
-
const authoredForm = !isForm && el.hasAttribute('form');
|
|
674
|
-
const authoredValue = !isForm && el.hasAttribute('value');
|
|
675
|
-
// `value` holes, recorded with their kind for the same reason as `name`: a
|
|
676
|
-
// FALSY boolean hole emits nothing at SSR, so counting the part's mere
|
|
677
|
-
// presence refused a template the server renders happily.
|
|
678
|
-
const valueParts = isForm ? [] : onEl
|
|
679
|
-
.filter((p) => p.name.toLowerCase() === 'value'
|
|
680
|
-
&& (p.kind === 'attr' || p.kind === 'attr-mixed' || p.kind === 'bool'))
|
|
681
|
-
.map((p) => ({ i: p.idx, kind: p.kind }));
|
|
682
|
-
|
|
683
|
-
for (const p of onEl) {
|
|
684
|
-
const name = String(p.name).toLowerCase();
|
|
685
|
-
if (p.kind === 'prop') {
|
|
686
|
-
// On a FORM, the reflected attributes that decide submittability. On a
|
|
687
|
-
// SUBMITTER, the ones that carry the identity or override the submission
|
|
688
|
-
// (#1207). Either way a `.prop` is dropped at SSR and written to the
|
|
689
|
-
// attribute in the browser, so it can never converge.
|
|
690
|
-
if (isForm) {
|
|
691
|
-
if (name === 'method' || name === 'enctype' || name === 'encoding') propAttrs.push(p.name);
|
|
692
|
-
} else if (isSubmitterReflectedProp(name)) {
|
|
693
|
-
propAttrs.push(p.name);
|
|
694
|
-
}
|
|
695
|
-
continue;
|
|
696
|
-
}
|
|
697
|
-
// #1307: a bound submitter carries its OWN submission attributes, so the
|
|
698
|
-
// pair to capture depends on the element. `formmethod` / `formenctype` on a
|
|
699
|
-
// button are exactly what `method` / `enctype` are on a form, and they flow
|
|
700
|
-
// through the same resolution below untouched.
|
|
701
|
-
if (name !== methodAttr && name !== enctypeAttr) continue;
|
|
702
|
-
if (p.kind !== 'attr' && p.kind !== 'attr-mixed' && p.kind !== 'bool') continue;
|
|
703
|
-
const d = /** @type any */ (parts[p.idx]);
|
|
704
|
-
/** @type {FormAttrPart} */
|
|
705
|
-
const entry = { i: p.idx, kind: p.kind };
|
|
706
|
-
// A mixed attribute's value is `statics[0] + v0 + statics[1] + ...`, so the
|
|
707
|
-
// pieces have to travel with the record; reading the anchor's value alone
|
|
708
|
-
// is right only when the statics are empty.
|
|
709
|
-
if (p.kind === 'attr-mixed') { entry.statics = d.statics || []; entry.group = d.group || []; }
|
|
710
|
-
// Compared against `methodAttr`, NOT the literal 'method'. On a submitter
|
|
711
|
-
// the pair is `formmethod` / `formenctype`, so a literal comparison sent
|
|
712
|
-
// every hole-provided `formmethod` into `enctypeParts`: the client then
|
|
713
|
-
// resolved it as the enctype, refused `formenctype="post"`, and threw on
|
|
714
|
-
// hydration for a template SSR renders happily. That is the
|
|
715
|
-
// render-on-the-server, crash-in-the-browser direction this module treats
|
|
716
|
-
// as the one unacceptable failure.
|
|
717
|
-
(name === methodAttr ? methodParts : enctypeParts).push(entry);
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
return {
|
|
721
|
-
isForm,
|
|
722
|
-
tag: el.localName,
|
|
723
|
-
// EVERY action hole, not just the first. SSR refuses two holes whenever
|
|
724
|
-
// ANY of them resolves to a bound action, so recording only the first would
|
|
725
|
-
// let `<form action=${'/legacy'} action=${boundFn}>` slip through the
|
|
726
|
-
// client's release path while SSR throws on the same template.
|
|
727
|
-
actionIdxs: actionParts.map((p) => p.idx),
|
|
728
|
-
duplicateAction: actionParts.length > 1,
|
|
729
|
-
// A static `action="..."` / `formaction="..."` surviving alongside the hole.
|
|
730
|
-
// The compiled template holds the hole as a sentinel attribute, so anything
|
|
731
|
-
// read back under the real name here is the author's own second one.
|
|
732
|
-
staticAction: el.getAttribute(targetAttr) != null,
|
|
733
|
-
authoredName,
|
|
734
|
-
authoredValue,
|
|
735
|
-
authoredForm,
|
|
736
|
-
propAttrs,
|
|
737
|
-
nameParts,
|
|
738
|
-
valueParts,
|
|
739
|
-
staticMethod: el.getAttribute(methodAttr),
|
|
740
|
-
staticEnctype: el.getAttribute(enctypeAttr),
|
|
741
|
-
methodParts,
|
|
742
|
-
enctypeParts,
|
|
743
|
-
};
|
|
744
|
-
}
|
|
745
|
-
|
|
746
|
-
/**
|
|
747
|
-
* Converge every candidate bound form and submitter in this template, after all
|
|
748
|
-
* of its parts have committed. A no-op for the overwhelming majority of
|
|
749
|
-
* templates, which carry no `<form action=${...}>` at all and therefore no
|
|
750
|
-
* record.
|
|
751
|
-
*
|
|
752
|
-
* FORMS FIRST, submitters second, regardless of the order the records were
|
|
753
|
-
* collected in. Nothing reads an enclosing form's boundness any more (#1307
|
|
754
|
-
* made a bound submitter self-sufficient), but the ordering still makes a
|
|
755
|
-
* form's RELEASE run before its submitters reconcile, so it is kept rather
|
|
756
|
-
* than churned. See the note in the body.
|
|
757
|
-
*
|
|
758
|
-
* @param {FormActionRecord[] | null} formActions
|
|
759
|
-
* @param {BoundPart[]} bound
|
|
760
|
-
* @param {unknown[]} values
|
|
761
|
-
*/
|
|
762
|
-
function reconcileFormActions(formActions, bound, values) {
|
|
763
|
-
if (!formActions) return;
|
|
764
|
-
// Forms first, then submitters. The original motivation was a boundness read
|
|
765
|
-
// that is gone with #1307, but the ordering still makes a form's RELEASE run
|
|
766
|
-
// before its submitters reconcile, so it is kept rather than churned.
|
|
767
|
-
for (const pass of [true, false]) {
|
|
768
|
-
for (const rec of formActions) {
|
|
769
|
-
if (rec.isForm !== pass) continue;
|
|
770
|
-
// With more than one action hole, the BOUND one decides, whichever
|
|
771
|
-
// position it is written in. Picking `actionIdxs[0]` blindly would send
|
|
772
|
-
// `<form action=${'/url'} action=${boundFn}>` down the release path and
|
|
773
|
-
// ship the broken form SSR refuses outright.
|
|
774
|
-
let idx = rec.actionIdxs[0];
|
|
775
|
-
const targetAttr = rec.isForm ? 'action' : 'formaction';
|
|
776
|
-
for (const i of rec.actionIdxs) {
|
|
777
|
-
if (isBoundFormAction(resolveHoleValue(values[i]), targetAttr, rec.tag)) { idx = i; break; }
|
|
778
|
-
}
|
|
779
|
-
const part = bound[idx];
|
|
780
|
-
if (!part || !part.el) continue;
|
|
781
|
-
const val = resolveHoleValue(values[idx]);
|
|
782
|
-
if (rec.isForm) {
|
|
783
|
-
reconcileFormAction(
|
|
784
|
-
/** @type any */ (part.el),
|
|
785
|
-
val,
|
|
786
|
-
effectiveFormAttr(rec.methodParts, rec.staticMethod, values),
|
|
787
|
-
effectiveFormAttr(rec.enctypeParts, rec.staticEnctype, values),
|
|
788
|
-
rec,
|
|
789
|
-
);
|
|
790
|
-
} else {
|
|
791
|
-
// What SSR would have emitted for this pass. An attribute hole always
|
|
792
|
-
// emits (even `name=${null}`, as `name=""`); a boolean hole emits only
|
|
793
|
-
// when truthy. Both identity channels ask the same question, through
|
|
794
|
-
// one predicate, so they cannot drift apart again.
|
|
795
|
-
const emits = (parts) => parts.some((np) => (np.kind === 'bool'
|
|
796
|
-
? !!resolveHoleValue(values[np.i])
|
|
797
|
-
: true));
|
|
798
|
-
reconcileSubmitterAction(
|
|
799
|
-
/** @type any */ (part.el), val, rec,
|
|
800
|
-
emits(rec.nameParts), emits(rec.valueParts),
|
|
801
|
-
effectiveFormAttr(rec.methodParts, rec.staticMethod, values),
|
|
802
|
-
effectiveFormAttr(rec.enctypeParts, rec.staticEnctype, values),
|
|
803
|
-
);
|
|
804
|
-
}
|
|
805
|
-
}
|
|
806
|
-
}
|
|
807
|
-
}
|
|
808
|
-
|
|
809
|
-
/**
|
|
810
|
-
* Remove the identity channel previously injected into a submitter.
|
|
811
|
-
*
|
|
812
|
-
* Only ours is removed. `submitterActionBindings` remembers the elements this
|
|
813
|
-
* renderer stamped, and the `name` check covers an SSR'd button meeting its
|
|
814
|
-
* template for the first time on hydration. A button that never carried a
|
|
815
|
-
* binding is left alone, so an author's own `name` / `value` survives a
|
|
816
|
-
* re-render that happens to pass a non-action value.
|
|
817
|
-
*
|
|
818
|
-
* @param {Element} el
|
|
819
|
-
*/
|
|
820
|
-
function releaseSubmitterAction(el) {
|
|
821
|
-
const injected = submitterActionBindings.has(el) || el.getAttribute('name') === FORM_ACTION_FIELD;
|
|
822
|
-
if (!injected) return;
|
|
823
|
-
if (el.getAttribute('name') === FORM_ACTION_FIELD) el.removeAttribute('name');
|
|
824
|
-
el.removeAttribute('value');
|
|
825
|
-
submitterActionBindings.delete(el);
|
|
826
|
-
}
|
|
827
|
-
|
|
828
|
-
/**
|
|
829
|
-
* Converge a live submitter on what SSR emitted for `<button formaction=${fn}>`
|
|
830
|
-
* (#1207, #1307): the `formaction` attribute gone, the identity in the button's
|
|
831
|
-
* own `name` / `value` pair, and the submission the framework supplies in its
|
|
832
|
-
* own `formmethod` / `formenctype`.
|
|
833
|
-
*
|
|
834
|
-
* There is no enclosing-form question here any more. It used to be the hard
|
|
835
|
-
* part of this function, asked once per element and BEST EFFORT because a
|
|
836
|
-
* fragment reconciling detached (a submitter inside a `repeat()` or an array
|
|
837
|
-
* item, whose form lives in the parent template) has no answer to give. With
|
|
838
|
-
* the submission attributes now on the button itself (#1307) the button needs
|
|
839
|
-
* nothing from the form, so the question stopped mattering and the whole
|
|
840
|
-
* asymmetry with SSR went with it.
|
|
841
|
-
*
|
|
842
|
-
* @param {Element} el
|
|
843
|
-
* @param {unknown} value
|
|
844
|
-
* @param {FormActionRecord} rec
|
|
845
|
-
* @param {boolean} emitsName whether SSR would emit a `name` attribute this pass
|
|
846
|
-
* @param {boolean} emitsValue whether SSR would emit a `value` attribute this pass
|
|
847
|
-
* @param {string | typeof ABSENT} authoredFormMethod what the template supplies for `formmethod`
|
|
848
|
-
* @param {string | typeof ABSENT} authoredFormEnctype what the template supplies for `formenctype`
|
|
849
|
-
*/
|
|
850
|
-
function reconcileSubmitterAction(
|
|
851
|
-
el, value, rec, emitsName, emitsValue, authoredFormMethod, authoredFormEnctype,
|
|
852
|
-
) {
|
|
853
|
-
const id = typeof value === 'function' ? formActionId(value) : null;
|
|
854
|
-
if (!id) {
|
|
855
|
-
// A function that was MEANT as an action still refuses, so a button never
|
|
856
|
-
// silently submits the form's action instead of its own.
|
|
857
|
-
if (typeof value === 'function') assertIdentifiableAction(null, el.localName);
|
|
858
|
-
releaseSubmitterAction(el);
|
|
859
|
-
// The submission attributes the framework supplied go with the identity, or
|
|
860
|
-
// a released button keeps a `formmethod` / `formenctype` SSR does not emit
|
|
861
|
-
// for the same template (#1307).
|
|
862
|
-
releaseSubmitterAttrs(el, authoredFormMethod, authoredFormEnctype, rec.propAttrs);
|
|
863
|
-
return;
|
|
864
|
-
}
|
|
865
|
-
// Template-shaped refusals first, from the compiled record, because the live
|
|
866
|
-
// element may already carry this renderer's own `name` / `value`.
|
|
867
|
-
assertSingleSubmitterAction(rec.duplicateAction, el.localName);
|
|
868
|
-
assertConvergentSubmitter(rec.propAttrs, el.localName);
|
|
869
|
-
if (rec.staticAction) assertSubmitterHasNoStaticFormAction(el.localName);
|
|
870
|
-
if (rec.authoredValue || emitsValue) assertSubmitterHasNoValue(el.localName);
|
|
871
|
-
if (rec.authoredName || emitsName) {
|
|
872
|
-
// Judged on the PART, not on what it resolved to this pass. `name=${null}`
|
|
873
|
-
// leaves no attribute here while SSR emits `name=""` beside the identity,
|
|
874
|
-
// so reading the live value back returned '' and waved through a template
|
|
875
|
-
// SSR refuses. The template supplying a `name` channel at all is the
|
|
876
|
-
// conflict, whatever today's value happens to be.
|
|
877
|
-
assertSubmitterHasNoName(el.getAttribute('name') || FORM_ACTION_FIELD, el.localName, false);
|
|
878
|
-
}
|
|
879
|
-
if (rec.authoredForm || el.hasAttribute('form')) assertSubmitterHasNoFormAttribute(el.localName);
|
|
880
|
-
assertSubmitterType(el.localName, el.getAttribute('type'));
|
|
881
|
-
|
|
882
|
-
// The submission decision, made from the TEMPLATE rather than by reading the
|
|
883
|
-
// DOM back, exactly as `reconcileFormAction` makes it for a bound form. The
|
|
884
|
-
// distinction the DOM cannot preserve is the same one: `?formmethod=${false}`
|
|
885
|
-
// and `formmethod=${null}` both leave no attribute, and SSR resolves them to
|
|
886
|
-
// opposite answers.
|
|
887
|
-
const resolved = resolveBoundSubmitterAttrs(
|
|
888
|
-
el.localName, authoredFormMethod, authoredFormEnctype,
|
|
889
|
-
);
|
|
890
|
-
el.removeAttribute('formaction');
|
|
891
|
-
el.setAttribute('name', FORM_ACTION_FIELD);
|
|
892
|
-
el.setAttribute('value', id);
|
|
893
|
-
// AFTER the identity, matching SSR's byte order: the identity replaces the
|
|
894
|
-
// `formaction=` hole in place and the submission pair is appended at the `>`.
|
|
895
|
-
// The differential parity suite compares these two renderers byte for byte,
|
|
896
|
-
// so the order is part of the contract, not a detail.
|
|
897
|
-
applyResolvedAttr(el, 'formmethod', authoredFormMethod, resolved.formMethod);
|
|
898
|
-
applyResolvedAttr(el, 'formenctype', authoredFormEnctype, resolved.formEnctype);
|
|
899
|
-
submitterActionBindings.set(el, id);
|
|
900
|
-
}
|
|
901
|
-
|
|
902
|
-
/* ================================================================
|
|
903
|
-
* Instance lifecycle
|
|
904
|
-
* ================================================================ */
|
|
905
|
-
|
|
906
|
-
/**
|
|
907
|
-
* @param {import('./html.js').TemplateResult} tr
|
|
908
|
-
* @param {Element | DocumentFragment | ShadowRoot} container
|
|
909
|
-
*/
|
|
910
|
-
function createInstance(tr, container) {
|
|
911
|
-
const { templateEl, parts, formActions } = compile(tr);
|
|
912
|
-
const frag = /** @type DocumentFragment */ (templateEl.content.cloneNode(true));
|
|
913
|
-
|
|
914
|
-
// Bookend markers bound the instance so we can tear it down cleanly.
|
|
915
|
-
const startNode = document.createComment(`${MARKER}s`);
|
|
916
|
-
const endNode = document.createComment(`${MARKER}e`);
|
|
917
|
-
|
|
918
|
-
const bound = parts.map((p) => bindPart(p, frag));
|
|
919
|
-
const lastValues = [];
|
|
920
|
-
for (let i = 0; i < tr.values.length; i++) {
|
|
921
|
-
applyPart(bound[i], tr.values[i], undefined, tr.values);
|
|
922
|
-
lastValues.push(tr.values[i]);
|
|
923
|
-
}
|
|
924
|
-
reconcileFormActions(formActions, bound, tr.values);
|
|
925
|
-
|
|
926
|
-
/** @type any */ (container).replaceChildren(startNode, ...frag.childNodes, endNode);
|
|
927
|
-
|
|
928
|
-
// Slot parts have no value-hole to drive applyPart from the loop above.
|
|
929
|
-
// Apply them once now that the fragment is inserted into the live
|
|
930
|
-
// container, so each slot can locate its host by walking parents and
|
|
931
|
-
// schedule the first projection through slot.js. Stamp each slot with the
|
|
932
|
-
// host whose TEMPLATE produced it (this container), so a FORWARDED slot
|
|
933
|
-
// (rendered here but nested inside a child component) routes to this host
|
|
934
|
-
// rather than to the child the structural walk would pick. Only an element
|
|
935
|
-
// container is a host; a fragment/shadow container leaves the structural
|
|
936
|
-
// path in place.
|
|
937
|
-
const ownerHost =
|
|
938
|
-
/** @type {any} */ (container).nodeType === 1 && /** @type {any} */ (container)[SLOT_STATE]
|
|
939
|
-
? container
|
|
940
|
-
: null;
|
|
941
|
-
for (const part of bound) {
|
|
942
|
-
if (part.kind === 'slot') {
|
|
943
|
-
if (ownerHost && part.slotEl) /** @type {any} */ (part.slotEl)[SLOT_OWNER] = ownerHost;
|
|
944
|
-
applyPart(part, undefined, undefined, []);
|
|
945
|
-
}
|
|
946
|
-
}
|
|
947
|
-
|
|
948
|
-
return { strings: tr.strings, bound, lastValues, startNode, endNode };
|
|
949
|
-
}
|
|
950
|
-
|
|
951
|
-
/**
|
|
952
|
-
* @param {PartDescriptor} p
|
|
953
|
-
* @param {DocumentFragment | Element} root
|
|
954
|
-
* @returns {BoundPart}
|
|
955
|
-
*/
|
|
956
|
-
function bindPart(p, root) {
|
|
957
|
-
if (p.kind === 'noop') return /** @type any */ ({ kind: 'noop', mixedAnchor: /** @type any */ (p).mixedAnchor });
|
|
958
|
-
let node = /** @type Node */ (root);
|
|
959
|
-
for (const i of p.path) node = node.childNodes[i];
|
|
960
|
-
if (p.kind === 'child') {
|
|
961
|
-
return { kind: 'child', marker: /** @type Comment */ (node) };
|
|
962
|
-
}
|
|
963
|
-
const el = /** @type Element */ (node);
|
|
964
|
-
if (p.kind === 'event') {
|
|
965
|
-
/** @type {BoundPart} */
|
|
966
|
-
const part = {
|
|
967
|
-
kind: 'event',
|
|
968
|
-
el,
|
|
969
|
-
name: p.name || '',
|
|
970
|
-
handler: null,
|
|
971
|
-
// The dispatcher is the registered listener; handler swaps behind it.
|
|
972
|
-
dispatcher(ev) { part.handler?.(ev); },
|
|
973
|
-
};
|
|
974
|
-
el.addEventListener(part.name, part.dispatcher);
|
|
975
|
-
return part;
|
|
976
|
-
}
|
|
977
|
-
if (p.kind === 'attr') return { kind: 'attr', el, name: p.name || '' };
|
|
978
|
-
if (p.kind === 'attr-mixed') return { kind: 'attr-mixed', el, name: p.name || '', statics: p.statics || [], group: p.group || [] };
|
|
979
|
-
if (p.kind === 'prop') return { kind: 'prop', el, name: p.name || '' };
|
|
980
|
-
if (p.kind === 'bool') return { kind: 'bool', el, name: p.name || '' };
|
|
981
|
-
if (p.kind === 'element') return { kind: 'element', el };
|
|
982
|
-
if (p.kind === 'slot') {
|
|
983
|
-
const slotEl = /** @type {HTMLSlotElement} */ (el);
|
|
984
|
-
// Defer fallback-strip and SLOT_FALLBACK_FRAG installation to apply
|
|
985
|
-
// time so we know whether the slot is light or shadow at the point
|
|
986
|
-
// where the decision matters. At bind time the cloned slot still
|
|
987
|
-
// holds its fallback content from the template clone; we just
|
|
988
|
-
// record the slot ref.
|
|
989
|
-
return { kind: 'slot', slotEl, applied: false };
|
|
990
|
-
}
|
|
991
|
-
throw new Error(`unknown part kind ${/** @type any */(p).kind}`);
|
|
992
|
-
}
|
|
993
|
-
|
|
994
|
-
/**
|
|
995
|
-
* Sentinel parked in `lastValues` for a hole whose commit threw, so the next
|
|
996
|
-
* render cannot mistake the un-advanced entry for "already applied". Never
|
|
997
|
-
* equal (by `Object.is`) to anything an author can pass through a template.
|
|
998
|
-
*/
|
|
999
|
-
const COMMIT_FAILED = Symbol('webjs.commitFailed');
|
|
1000
|
-
|
|
1001
|
-
/**
|
|
1002
|
-
* @param {TemplateInstance} inst
|
|
1003
|
-
* @param {unknown[]} values
|
|
1004
|
-
*/
|
|
1005
|
-
function updateInstance(inst, values) {
|
|
1006
|
-
for (let i = 0; i < values.length; i++) {
|
|
1007
|
-
const next = values[i];
|
|
1008
|
-
if (Object.is(next, inst.lastValues[i])) continue;
|
|
1009
|
-
const bp = inst.bound[i];
|
|
1010
|
-
// A hole that belongs to a mixed attribute (`class="a ${x} b ${y}"`) is a
|
|
1011
|
-
// `noop` pointing at the attribute's anchor part; re-apply the anchor so the
|
|
1012
|
-
// whole attribute is rebuilt from every hole's current value, not just the
|
|
1013
|
-
// anchor hole's. Without this, a change confined to a non-anchor hole is
|
|
1014
|
-
// dropped (the attribute goes stale).
|
|
1015
|
-
const anchor = /** @type any */ (bp).mixedAnchor;
|
|
1016
|
-
try {
|
|
1017
|
-
if (bp.kind === 'noop' && anchor != null) {
|
|
1018
|
-
applyPart(inst.bound[anchor], values[anchor], inst.lastValues[anchor], values);
|
|
1019
|
-
} else {
|
|
1020
|
-
applyPart(bp, next, inst.lastValues[i], values);
|
|
1021
|
-
}
|
|
1022
|
-
} catch (err) {
|
|
1023
|
-
// A commit that throws leaves `lastValues` for THIS hole un-advanced,
|
|
1024
|
-
// still holding the value from before the throw. That is almost always
|
|
1025
|
-
// the value the recovering render supplies, so the `Object.is` skip at
|
|
1026
|
-
// the top of this loop would skip the hole FOREVER. Harmless for an
|
|
1027
|
-
// attribute (its commit stringifies before touching the DOM, so nothing
|
|
1028
|
-
// changed), and permanently destructive for a child position, whose
|
|
1029
|
-
// commit tears the old content down BEFORE the step that throws: the
|
|
1030
|
-
// region is left empty and never re-rendered.
|
|
1031
|
-
//
|
|
1032
|
-
// Poison the entry with a sentinel no author value can ever be, so the
|
|
1033
|
-
// next render always re-applies this hole. The value is deliberately
|
|
1034
|
-
// NOT advanced to `next` either, since `next` was never committed.
|
|
1035
|
-
inst.lastValues[i] = COMMIT_FAILED;
|
|
1036
|
-
if (bp.kind === 'noop' && anchor != null) inst.lastValues[anchor] = COMMIT_FAILED;
|
|
1037
|
-
throw err;
|
|
1038
|
-
}
|
|
1039
|
-
inst.lastValues[i] = next;
|
|
1040
|
-
}
|
|
1041
|
-
// Unconditional, and NOT inside the loop above: a form's correctness depends
|
|
1042
|
-
// on holes other than its own, and the `Object.is` skip means the action hole
|
|
1043
|
-
// may not have been re-applied at all this pass.
|
|
1044
|
-
reconcileFormActions(templateCache.get(inst.strings)?.formActions ?? null, inst.bound, values);
|
|
1045
|
-
}
|
|
1046
|
-
|
|
1047
|
-
/**
|
|
1048
|
-
* @param {TemplateInstance} inst
|
|
1049
|
-
* @param {Element | DocumentFragment | ShadowRoot} container
|
|
1050
|
-
*/
|
|
1051
|
-
function clearInstance(inst, container) {
|
|
1052
|
-
// Dispose event listeners on event parts, unbind active refs on
|
|
1053
|
-
// element parts, and rescue any projected children sitting inside
|
|
1054
|
-
// slot parts so they survive teardown of a collapsing conditional
|
|
1055
|
-
// fragment.
|
|
1056
|
-
for (const p of inst.bound) {
|
|
1057
|
-
if (p.kind === 'event') p.el.removeEventListener(p.name, p.dispatcher);
|
|
1058
|
-
if (p.kind === 'element') {
|
|
1059
|
-
// Guarded for the same reason as the sibling in `disposeInstance`, and
|
|
1060
|
-
// the stakes are higher here: this is the container-level teardown
|
|
1061
|
-
// `render()` runs before a template swap, so a throw skips the rest of
|
|
1062
|
-
// this loop AND the `replaceChildren()` below, leaving the old DOM in
|
|
1063
|
-
// place with `host[INSTANCE]` never reassigned. Since `lastTarget` is
|
|
1064
|
-
// cleared only after the write, every later swap of that container
|
|
1065
|
-
// then throws at the same part, permanently.
|
|
1066
|
-
const prev = /** @type any */ (p).lastTarget;
|
|
1067
|
-
if (prev) {
|
|
1068
|
-
if (typeof prev === 'function') {
|
|
1069
|
-
try { prev(undefined); } catch { /* swallow */ }
|
|
1070
|
-
} else if (typeof prev === 'object') {
|
|
1071
|
-
try { prev.value = undefined; } catch { /* swallow */ }
|
|
1072
|
-
}
|
|
1073
|
-
/** @type any */ (p).lastTarget = undefined;
|
|
1074
|
-
/** @type any */ (p).__lastEl = undefined;
|
|
1075
|
-
}
|
|
1076
|
-
}
|
|
1077
|
-
if (p.kind === 'slot') {
|
|
1078
|
-
// Detach record-owned children before the teardown disposes the
|
|
1079
|
-
// slot subtree; the record keeps the refs, so a re-created slot
|
|
1080
|
-
// re-places the SAME nodes (children are values, #1015).
|
|
1081
|
-
const host = findSlotHost(p.slotEl);
|
|
1082
|
-
if (host) rescueAssignedNodes(host, p.slotEl);
|
|
1083
|
-
}
|
|
1084
|
-
}
|
|
1085
|
-
/** @type any */ (container).replaceChildren();
|
|
1086
|
-
}
|
|
1087
|
-
|
|
1088
|
-
/* ================================================================
|
|
1089
|
-
* Part application
|
|
1090
|
-
* ================================================================ */
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
/**
|
|
1094
|
-
* Resolve what SSR would have emitted for one attribute of a candidate form.
|
|
1095
|
-
*
|
|
1096
|
-
* The per-kind rules mirror `render-server.js` exactly, which is the whole
|
|
1097
|
-
* point: a boolean hole emits nothing when falsy (`if (val) out += name+'=""'`)
|
|
1098
|
-
* while an attribute hole emits an EMPTY value for null (`String(val ?? '')`).
|
|
1099
|
-
* Those two produce the same DOM and opposite verdicts, so only the template
|
|
1100
|
-
* can tell them apart.
|
|
1101
|
-
*
|
|
1102
|
-
* @param {FormAttrPart[]} attrParts
|
|
1103
|
-
* @param {string | null} staticValue
|
|
1104
|
-
* @param {unknown[]} values
|
|
1105
|
-
* @returns {string | typeof ABSENT}
|
|
1106
|
-
*/
|
|
1107
|
-
function effectiveFormAttr(attrParts, staticValue, values) {
|
|
1108
|
-
for (const p of attrParts) {
|
|
1109
|
-
if (p.kind === 'bool') return resolveHoleValue(values[p.i]) ? '' : ABSENT;
|
|
1110
|
-
if (p.kind === 'attr') {
|
|
1111
|
-
const v = resolveHoleValue(values[p.i]);
|
|
1112
|
-
return v == null ? '' : String(v);
|
|
1113
|
-
}
|
|
1114
|
-
// A mixed attribute is the concatenation of its static pieces and EVERY one
|
|
1115
|
-
// of its holes, so the anchor's own value is only part of the answer.
|
|
1116
|
-
const statics = p.statics || [];
|
|
1117
|
-
const group = p.group || [];
|
|
1118
|
-
let out = statics[0] || '';
|
|
1119
|
-
for (let j = 0; j < group.length; j++) {
|
|
1120
|
-
out += String(resolveHoleValue(values[group[j]]) ?? '');
|
|
1121
|
-
out += statics[j + 1] || '';
|
|
1122
|
-
}
|
|
1123
|
-
return out;
|
|
1124
|
-
}
|
|
1125
|
-
return staticValue == null ? ABSENT : staticValue;
|
|
1126
|
-
}
|
|
1127
|
-
|
|
1128
|
-
/**
|
|
1129
|
-
* Unwrap a hole's value the same way `applyPart` does, so the reconcile judges
|
|
1130
|
-
* what was actually committed. `live()` in particular wraps its value, and
|
|
1131
|
-
* reading the wrapper would make a bound action look like a plain object.
|
|
1132
|
-
*
|
|
1133
|
-
* @param {unknown} v
|
|
1134
|
-
* @returns {unknown}
|
|
1135
|
-
*/
|
|
1136
|
-
function resolveHoleValue(v) {
|
|
1137
|
-
return isLive(v) ? /** @type any */ (v).value : v;
|
|
1138
|
-
}
|
|
1139
|
-
|
|
1140
|
-
/**
|
|
1141
|
-
* @param {BoundPart} part
|
|
1142
|
-
* @param {unknown} value
|
|
1143
|
-
* @param {unknown} _prev
|
|
1144
|
-
*/
|
|
1145
|
-
function applyPart(part, value, _prev, allValues) {
|
|
1146
|
-
// Unwrap live() to dirty-check against the live DOM value, not the
|
|
1147
|
-
// last rendered value. Essential for <input> two-way binding.
|
|
1148
|
-
if (isLive(value)) {
|
|
1149
|
-
const liveVal = /** @type any */ (value).value;
|
|
1150
|
-
if (part.kind === 'prop' && /** @type any */ (part.el)[part.name] === liveVal) return;
|
|
1151
|
-
if (part.kind === 'attr' && part.el.getAttribute(part.name) === String(liveVal)) return;
|
|
1152
|
-
if (part.kind === 'bool' && part.el.hasAttribute(part.name) === !!liveVal) return;
|
|
1153
|
-
value = liveVal;
|
|
1154
|
-
}
|
|
1155
|
-
|
|
1156
|
-
switch (part.kind) {
|
|
1157
|
-
case 'child':
|
|
1158
|
-
applyChild(part, value);
|
|
1159
|
-
break;
|
|
1160
|
-
case 'attr': {
|
|
1161
|
-
if (value == null || value === false) part.el.removeAttribute(part.name);
|
|
1162
|
-
else if (isBoundFormAction(value, part.name, part.el.localName)) {
|
|
1163
|
-
// #1155: the ONE supported form-action binding, applied to the live
|
|
1164
|
-
// form exactly as SSR wrote it. A component that ships re-renders its
|
|
1165
|
-
// whole template on hydration, so without this the SSR'd hidden field
|
|
1166
|
-
// would be replaced by an `action` attribute holding a stringified
|
|
1167
|
-
// function, and the form would post to a garbage url.
|
|
1168
|
-
//
|
|
1169
|
-
// Nothing happens here. The whole decision (identity, the submit
|
|
1170
|
-
// attributes, the hidden field) is made at the end of the pass by
|
|
1171
|
-
// `reconcileFormAction`, because it depends on holes that have not
|
|
1172
|
-
// committed yet when this one does.
|
|
1173
|
-
} else {
|
|
1174
|
-
// #1154: refuse to stringify a function into action=/formaction=
|
|
1175
|
-
// (mirrors the SSR guard, so a client re-render cannot write a
|
|
1176
|
-
// server action's source into the live DOM).
|
|
1177
|
-
assertNotFunctionActionAttr(value, part.name, part.el.localName);
|
|
1178
|
-
part.el.setAttribute(part.name, String(value));
|
|
1179
|
-
}
|
|
1180
|
-
break;
|
|
1181
|
-
}
|
|
1182
|
-
case 'prop':
|
|
1183
|
-
// `.action=${fn}` is a leak too, not just the attribute form, but only
|
|
1184
|
-
// where the property is a REFLECTED IDL attribute: assigning a function
|
|
1185
|
-
// to `form.action` (or `.formAction` on a button/input) stringifies it
|
|
1186
|
-
// into that element's own content attribute in a real browser. On any
|
|
1187
|
-
// other native tag it is a plain expando that reflects nothing, and on a
|
|
1188
|
-
// custom element it is an author-defined property, so a function is
|
|
1189
|
-
// legitimate in both. The helper owns that distinction.
|
|
1190
|
-
//
|
|
1191
|
-
// Not covered here, because it is not a commit: a custom element's prop
|
|
1192
|
-
// declared `reflect: true` reflects from its own setter. That path used
|
|
1193
|
-
// to write String(value) and leak the source. #1169 made it remove the
|
|
1194
|
-
// attribute instead (an array carrying one included, via the same
|
|
1195
|
-
// `carriesFunction` predicate this file's guard uses), so it is guarded
|
|
1196
|
-
// at the setter rather than here. One carve-out stays the author's call:
|
|
1197
|
-
// a prop supplying its own `converter.toAttribute` runs that converter
|
|
1198
|
-
// first and is left alone, so it still writes whatever the author
|
|
1199
|
-
// returns for a function.
|
|
1200
|
-
assertNotFunctionReflectedActionProp(value, part.name, part.el.localName);
|
|
1201
|
-
/** @type any */ (part.el)[part.name] = value;
|
|
1202
|
-
break;
|
|
1203
|
-
case 'bool':
|
|
1204
|
-
// #1154: never leaked (a boolean binding stringifies nothing), but
|
|
1205
|
-
// `?action=${fn}` is meaningless in every case, and refusing it keeps
|
|
1206
|
-
// this renderer agreeing with both SSR machines.
|
|
1207
|
-
assertNotFunctionActionAttr(value, part.name, part.el.localName);
|
|
1208
|
-
if (value) part.el.setAttribute(part.name, '');
|
|
1209
|
-
else part.el.removeAttribute(part.name);
|
|
1210
|
-
break;
|
|
1211
|
-
case 'event':
|
|
1212
|
-
// NOT guarded, deliberately. An event binding never stringifies its
|
|
1213
|
-
// value, and a function is the legitimate thing to pass one, so
|
|
1214
|
-
// `<my-el @action=${handler}>` has to keep working.
|
|
1215
|
-
part.handler = typeof value === 'function' ? /** @type any */ (value) : null;
|
|
1216
|
-
break;
|
|
1217
|
-
case 'element':
|
|
1218
|
-
applyElement(part, value);
|
|
1219
|
-
break;
|
|
1220
|
-
case 'attr-mixed': {
|
|
1221
|
-
// Reconstruct the attribute from static pieces + all dynamic values.
|
|
1222
|
-
const mp = /** @type {{ statics: string[], group: number[] }} */ (/** @type any */ (part));
|
|
1223
|
-
let val = mp.statics[0];
|
|
1224
|
-
for (let j = 0; j < mp.group.length; j++) {
|
|
1225
|
-
const piece = allValues ? allValues[mp.group[j]] : value;
|
|
1226
|
-
// #1154: same function guard for each piece of a mixed attribute.
|
|
1227
|
-
assertNotFunctionActionAttr(piece, part.name, part.el.localName);
|
|
1228
|
-
val += String(piece ?? '');
|
|
1229
|
-
val += mp.statics[j + 1] || '';
|
|
1230
|
-
}
|
|
1231
|
-
part.el.setAttribute(part.name, val);
|
|
1232
|
-
break;
|
|
1233
|
-
}
|
|
1234
|
-
case 'slot': {
|
|
1235
|
-
// Slot parts have no template-hole value to apply. The "apply" is
|
|
1236
|
-
// a one-shot trigger that runs after the template fragment has
|
|
1237
|
-
// been inserted into the host's render root. At this point the
|
|
1238
|
-
// slot's parent chain reveals whether it lives inside a shadow
|
|
1239
|
-
// root (browser native projection) or in light DOM (framework
|
|
1240
|
-
// projection). For shadow-DOM slots we leave the cloned fallback
|
|
1241
|
-
// in place. For light-DOM slots we move the fallback into a
|
|
1242
|
-
// per-slot holding fragment owned by slot.js (via the
|
|
1243
|
-
// SLOT_FALLBACK_FRAG symbol) so the slot can receive projected
|
|
1244
|
-
// children, and we kick off projection.
|
|
1245
|
-
//
|
|
1246
|
-
// For NESTED templates (a slot inside `${cond ? html`<slot/>` : ''}`),
|
|
1247
|
-
// the slot's parent chain at first apply may still lead through
|
|
1248
|
-
// an unattached fragment. findSlotHost returns null then; we
|
|
1249
|
-
// retry on the next microtask, by which point the outer's
|
|
1250
|
-
// replaceChildren has placed the entire tree into the host.
|
|
1251
|
-
if (part.applied) break;
|
|
1252
|
-
const slotEl = part.slotEl;
|
|
1253
|
-
const finalize = () => {
|
|
1254
|
-
const host = findSlotHost(slotEl);
|
|
1255
|
-
if (!host) {
|
|
1256
|
-
part.applied = true;
|
|
1257
|
-
return; // truly orphan slot (no owner, no structural host)
|
|
1258
|
-
}
|
|
1259
|
-
// A FORWARDED slot's owner is known immediately (the SLOT_OWNER
|
|
1260
|
-
// stamp) but the child component has not yet PLACED the slot into
|
|
1261
|
-
// the owner's subtree, so the owner's apply cannot find it yet.
|
|
1262
|
-
// Re-defer until the child places it (host.contains becomes true);
|
|
1263
|
-
// the child WILL place it, or the owner disconnects and the retries
|
|
1264
|
-
// stop. A normal own slot is already inside its host, so this passes
|
|
1265
|
-
// on the first call with no extra deferral.
|
|
1266
|
-
if (!host.contains(slotEl)) {
|
|
1267
|
-
if (host.isConnected) queueMicrotask(finalize);
|
|
1268
|
-
else part.applied = true;
|
|
1269
|
-
return;
|
|
1270
|
-
}
|
|
1271
|
-
part.applied = true;
|
|
1272
|
-
// Shadow DOM: native projection. Leave fallback in place.
|
|
1273
|
-
if (isInShadowRootEl(slotEl)) return;
|
|
1274
|
-
// Light DOM: harvest the cloned fallback into a holding
|
|
1275
|
-
// fragment, then place the host's slot record (#1015). The
|
|
1276
|
-
// application is deferred one microtask so the documented
|
|
1277
|
-
// lifecycle timing holds: firstUpdated() sees the <slot>
|
|
1278
|
-
// element itself, the populated content lands right after.
|
|
1279
|
-
const frag = document.createDocumentFragment();
|
|
1280
|
-
while (slotEl.firstChild) frag.appendChild(slotEl.firstChild);
|
|
1281
|
-
/** @type {any} */ (slotEl)[SLOT_FALLBACK_FRAG] = frag;
|
|
1282
|
-
queueMicrotask(() => applySlotAssignments(host));
|
|
1283
|
-
};
|
|
1284
|
-
const directHost = findSlotHost(slotEl);
|
|
1285
|
-
if (directHost && directHost.contains(slotEl)) {
|
|
1286
|
-
finalize();
|
|
1287
|
-
} else {
|
|
1288
|
-
queueMicrotask(finalize);
|
|
1289
|
-
}
|
|
1290
|
-
break;
|
|
1291
|
-
}
|
|
1292
|
-
case 'noop':
|
|
1293
|
-
// intentionally empty: used for holes inside HTML comments
|
|
1294
|
-
break;
|
|
1295
|
-
}
|
|
1296
|
-
}
|
|
1297
|
-
|
|
1298
|
-
/**
|
|
1299
|
-
* Walk a slot element's parent chain looking for a WebComponent host
|
|
1300
|
-
* (an element that has slot state initialised). Used by the slot-part's
|
|
1301
|
-
* apply and teardown steps to coordinate with slot.js.
|
|
1302
|
-
*
|
|
1303
|
-
* @param {HTMLSlotElement} slotEl
|
|
1304
|
-
* @returns {Element | null}
|
|
1305
|
-
*/
|
|
1306
|
-
function findSlotHost(slotEl) {
|
|
1307
|
-
// Template-owner stamp wins (a forwarded slot's true host), else the
|
|
1308
|
-
// nearest SLOT_STATE ancestor structurally.
|
|
1309
|
-
const owner = /** @type any */ (slotEl)[SLOT_OWNER];
|
|
1310
|
-
if (owner && owner.isConnected) return owner;
|
|
1311
|
-
let p = slotEl.parentElement;
|
|
1312
|
-
while (p) {
|
|
1313
|
-
if (/** @type any */ (p)[SLOT_STATE]) return p;
|
|
1314
|
-
p = p.parentElement;
|
|
1315
|
-
}
|
|
1316
|
-
return null;
|
|
1317
|
-
}
|
|
1318
|
-
|
|
1319
|
-
/**
|
|
1320
|
-
* True when an element is inside a shadow root (so native browser slot
|
|
1321
|
-
* projection applies). Mirrors slot.js's helper; duplicated here to
|
|
1322
|
-
* avoid the round trip through the slot.js public surface for this
|
|
1323
|
-
* hot path.
|
|
1324
|
-
* @param {Element} el
|
|
1325
|
-
* @returns {boolean}
|
|
1326
|
-
*/
|
|
1327
|
-
function isInShadowRootEl(el) {
|
|
1328
|
-
let n = /** @type {Node} */ (el);
|
|
1329
|
-
for (let depth = 0; depth < 128; depth++) {
|
|
1330
|
-
const parent = n.parentNode;
|
|
1331
|
-
if (!parent) return false;
|
|
1332
|
-
if (parent === n) return false;
|
|
1333
|
-
// A real ShadowRoot is a DocumentFragment (nodeType 11) exposing its
|
|
1334
|
-
// owner as `.host`. Checking `.host` truthiness ALONE misfires on
|
|
1335
|
-
// ordinary elements: HTMLAnchorElement/HTMLAreaElement expose a
|
|
1336
|
-
// URL-derived `.host` ('example.com'), so a slot nested inside an
|
|
1337
|
-
// <a> card was misread as shadow DOM and its light-DOM application
|
|
1338
|
-
// silently skipped (surfaced by #1015's removal of the redundant
|
|
1339
|
-
// observer repair paths that used to mask it).
|
|
1340
|
-
if (parent.nodeType === 11 && /** @type any */ (parent).host) return true;
|
|
1341
|
-
n = parent;
|
|
1342
|
-
}
|
|
1343
|
-
return false;
|
|
1344
|
-
}
|
|
1345
|
-
|
|
1346
|
-
/**
|
|
1347
|
-
* Child (text-position) part. Replace the marker's surrounding nodes with the
|
|
1348
|
-
* new value's rendered form. Nested TemplateResults get an instance with its
|
|
1349
|
-
* own parts; we reuse on `strings` identity.
|
|
1350
|
-
*
|
|
1351
|
-
* @param {Extract<BoundPart, {kind:'child'}>} part
|
|
1352
|
-
* @param {unknown} value
|
|
1353
|
-
*/
|
|
1354
|
-
/**
|
|
1355
|
-
* Apply a value at an element-position part (`<tag ${expr}>`). The
|
|
1356
|
-
* sole supported directive here is `ref(refOrCallback)` and
|
|
1357
|
-
* `createRef()`. Other values are ignored so a stray non-ref hole
|
|
1358
|
-
* doesn't crash. Tracks the prior target so a change from one ref to
|
|
1359
|
-
* another correctly unsets the old target before binding the new one.
|
|
1360
|
-
*
|
|
1361
|
-
* @param {Extract<BoundPart, {kind:'element'}>} part
|
|
1362
|
-
* @param {unknown} value
|
|
1363
|
-
*/
|
|
1364
|
-
function applyElement(part, value) {
|
|
1365
|
-
// Matches lit-html's RefDirective.update():
|
|
1366
|
-
// 1. If the ref target changed since last render, unbind the prior one.
|
|
1367
|
-
// 2. If the ref target OR the element identity changed, bind the new
|
|
1368
|
-
// (ref, element) pair. If both are stable, skip entirely.
|
|
1369
|
-
// For callback refs, an unset-before-bind cycle runs whenever the
|
|
1370
|
-
// same callback is now pointing at a different element.
|
|
1371
|
-
const partAny = /** @type any */ (part);
|
|
1372
|
-
const nextTarget = isRef(value) ? /** @type any */ (value).target : undefined;
|
|
1373
|
-
const prevTarget = partAny.__refTarget;
|
|
1374
|
-
const refChanged = nextTarget !== prevTarget;
|
|
1375
|
-
|
|
1376
|
-
if (refChanged && prevTarget) {
|
|
1377
|
-
if (typeof prevTarget === 'function') {
|
|
1378
|
-
try { prevTarget(undefined); } catch { /* swallow */ }
|
|
1379
|
-
} else if (typeof prevTarget === 'object') {
|
|
1380
|
-
prevTarget.value = undefined;
|
|
1381
|
-
}
|
|
1382
|
-
}
|
|
1383
|
-
|
|
1384
|
-
if (refChanged || partAny.__refElement !== part.el) {
|
|
1385
|
-
partAny.__refTarget = nextTarget;
|
|
1386
|
-
if (nextTarget) {
|
|
1387
|
-
if (typeof nextTarget === 'function') {
|
|
1388
|
-
// Same callback now pointing at a different element: deliver
|
|
1389
|
-
// an `undefined` cleanup for the prior element first.
|
|
1390
|
-
if (!refChanged && partAny.__refElement !== undefined) {
|
|
1391
|
-
try { nextTarget(undefined); } catch { /* swallow */ }
|
|
1392
|
-
}
|
|
1393
|
-
try { nextTarget(part.el); } catch { /* swallow */ }
|
|
1394
|
-
} else if (typeof nextTarget === 'object') {
|
|
1395
|
-
nextTarget.value = part.el;
|
|
1396
|
-
}
|
|
1397
|
-
}
|
|
1398
|
-
partAny.__refElement = part.el;
|
|
1399
|
-
// Keep the legacy `lastTarget` field in sync for clearInstance /
|
|
1400
|
-
// disposeInstance which read it for template-disposal cleanup.
|
|
1401
|
-
part.lastTarget = nextTarget;
|
|
1402
|
-
}
|
|
1403
|
-
}
|
|
1404
|
-
|
|
1405
|
-
function applyChild(part, value) {
|
|
1406
|
-
// Drop directive state from prior renders when the new value is for a
|
|
1407
|
-
// different directive (or no directive at all). Keeps __untilState
|
|
1408
|
-
// from leaking across replacements, __guardDeps from causing a stale
|
|
1409
|
-
// short-circuit, etc. Done once per outermost applyChild call; the
|
|
1410
|
-
// directive handlers recurse via applyChildInner (no re-clear) so
|
|
1411
|
-
// their own state survives the recursion.
|
|
1412
|
-
clearStaleDirectiveState(part, value);
|
|
1413
|
-
return applyChildInner(part, value);
|
|
1414
|
-
}
|
|
1415
|
-
|
|
1416
|
-
/**
|
|
1417
|
-
* Internal dispatch. Used both by `applyChild` (which first clears
|
|
1418
|
-
* stale per-part directive state) and by directive handlers that
|
|
1419
|
-
* recurse with a different value at the same part. Recursing via
|
|
1420
|
-
* `applyChild` would clear the directive state that was just set,
|
|
1421
|
-
* because the inner value almost always isn't itself a directive.
|
|
1422
|
-
*
|
|
1423
|
-
* @param {Extract<BoundPart, {kind:'child'}>} part
|
|
1424
|
-
* @param {unknown} value
|
|
1425
|
-
*/
|
|
1426
|
-
function applyChildInner(part, value) {
|
|
1427
|
-
// Open the renderer-write window around this commit. Most calls are
|
|
1428
|
-
// synchronous inside render() (the window is already open, this nests
|
|
1429
|
-
// harmlessly), but the async directive paths (until, watch, asyncAppend /
|
|
1430
|
-
// asyncReplace, streaming) re-enter here from a promise / microtask OUTSIDE
|
|
1431
|
-
// any render() window, so this is where those commits into a light host are
|
|
1432
|
-
// marked as renderer writes rather than authored content.
|
|
1433
|
-
return commitInto(part.marker && part.marker.parentNode, () =>
|
|
1434
|
-
applyChildInnerRaw(part, value),
|
|
1435
|
-
);
|
|
1436
|
-
}
|
|
1437
|
-
|
|
1438
|
-
/**
|
|
1439
|
-
* @param {Extract<BoundPart, {kind:'child'}>} part
|
|
1440
|
-
* @param {unknown} value
|
|
1441
|
-
*/
|
|
1442
|
-
function applyChildInnerRaw(part, value) {
|
|
1443
|
-
const marker = part.marker;
|
|
1444
|
-
|
|
1445
|
-
// unsafeHTML directive: inject raw HTML string as DOM nodes.
|
|
1446
|
-
if (isUnsafeHTML(value)) {
|
|
1447
|
-
teardownChild(part);
|
|
1448
|
-
const htmlStr = String(/** @type any */ (value).value ?? '');
|
|
1449
|
-
const template = document.createElement('template');
|
|
1450
|
-
template.innerHTML = htmlStr;
|
|
1451
|
-
const nodes = [...template.content.childNodes];
|
|
1452
|
-
const frag = document.createDocumentFragment();
|
|
1453
|
-
for (const n of nodes) frag.appendChild(n);
|
|
1454
|
-
marker.parentNode?.insertBefore(frag, marker);
|
|
1455
|
-
part.child = nodes;
|
|
1456
|
-
return;
|
|
1457
|
-
}
|
|
1458
|
-
|
|
1459
|
-
// keyed directive: when key changes, tear down and remount fresh DOM.
|
|
1460
|
-
// When the key matches, recurse so the standard template-reconciliation
|
|
1461
|
-
// path can update the existing DOM in place.
|
|
1462
|
-
if (isKeyed(value)) {
|
|
1463
|
-
const v = /** @type any */ (value);
|
|
1464
|
-
const prevKey = /** @type any */ (part).__keyedKey;
|
|
1465
|
-
if (prevKey !== undefined && !Object.is(prevKey, v.key)) {
|
|
1466
|
-
teardownChild(part);
|
|
1467
|
-
}
|
|
1468
|
-
/** @type any */ (part).__keyedKey = v.key;
|
|
1469
|
-
applyChildInner(part, v.value);
|
|
1470
|
-
return;
|
|
1471
|
-
}
|
|
1472
|
-
|
|
1473
|
-
// guard directive: skip re-evaluation when the deps array is shallow-
|
|
1474
|
-
// equal to the prior call. Stored deps live on the part so they
|
|
1475
|
-
// persist across renders that reuse the same template (and thus the
|
|
1476
|
-
// same part).
|
|
1477
|
-
if (isGuard(value)) {
|
|
1478
|
-
const v = /** @type any */ (value);
|
|
1479
|
-
const prevDeps = /** @type any */ (part).__guardDeps;
|
|
1480
|
-
const nextDeps = v.deps;
|
|
1481
|
-
// Accept any value for deps. When deps is an array, compare shallowly.
|
|
1482
|
-
// When it's a primitive (number, string, undefined), compare with
|
|
1483
|
-
// Object.is. Mirrors lit-html's tolerance for non-array deps so user
|
|
1484
|
-
// code like `guard(this.id, () => ...)` works without crashing.
|
|
1485
|
-
if (prevDeps !== undefined) {
|
|
1486
|
-
const equal = Array.isArray(prevDeps) && Array.isArray(nextDeps)
|
|
1487
|
-
? shallowEqualArray(prevDeps, nextDeps)
|
|
1488
|
-
: Object.is(prevDeps, nextDeps);
|
|
1489
|
-
if (equal) return;
|
|
1490
|
-
}
|
|
1491
|
-
// Snapshot the deps BEFORE running `fn` (so a fn that mutates the array
|
|
1492
|
-
// it was handed cannot rewrite what we record), but RECORD them only
|
|
1493
|
-
// after the commit succeeds. Recording first meant a throw part-way
|
|
1494
|
-
// through the commit left the region torn down with the NEW deps already
|
|
1495
|
-
// stored, so every later render with the same deps hit the `equal`
|
|
1496
|
-
// short-circuit above and skipped the region forever. Renders are always
|
|
1497
|
-
// microtask-scheduled (`_scheduleUpdate`), so `fn()` cannot re-enter this
|
|
1498
|
-
// part synchronously and see the stale deps.
|
|
1499
|
-
const depsSnapshot = Array.isArray(nextDeps) ? nextDeps.slice() : nextDeps;
|
|
1500
|
-
applyChildInner(part, v.fn());
|
|
1501
|
-
/** @type any */ (part).__guardDeps = depsSnapshot;
|
|
1502
|
-
return;
|
|
1503
|
-
}
|
|
1504
|
-
|
|
1505
|
-
// templateContent directive: clone the content of a <template> element.
|
|
1506
|
-
if (isTemplateContent(value)) {
|
|
1507
|
-
teardownChild(part);
|
|
1508
|
-
const tpl = /** @type any */ (value).template;
|
|
1509
|
-
if (tpl && tpl.content) {
|
|
1510
|
-
const frag = tpl.content.cloneNode(true);
|
|
1511
|
-
const nodes = [...frag.childNodes];
|
|
1512
|
-
marker.parentNode?.insertBefore(frag, marker);
|
|
1513
|
-
part.child = nodes;
|
|
1514
|
-
}
|
|
1515
|
-
return;
|
|
1516
|
-
}
|
|
1517
|
-
|
|
1518
|
-
// ref directive in a child position: no DOM produced. Element-position
|
|
1519
|
-
// refs are bound via element parts; a stray ref() in a child position
|
|
1520
|
-
// is a no-op for compatibility.
|
|
1521
|
-
if (isRef(value)) {
|
|
1522
|
-
return;
|
|
1523
|
-
}
|
|
1524
|
-
|
|
1525
|
-
// cache directive: real DOM retention. When the inner value changes
|
|
1526
|
-
// to a different template shape, detach (rather than destroy) the
|
|
1527
|
-
// current DOM and stash it keyed by its template strings. When a
|
|
1528
|
-
// previously-cached shape returns, re-attach it before the marker
|
|
1529
|
-
// and reconcile values. Preserves input state, scroll, focus across
|
|
1530
|
-
// toggles between sub-templates (e.g. tab interfaces).
|
|
1531
|
-
if (isCache(value)) {
|
|
1532
|
-
return applyCache(part, /** @type any */ (value).value);
|
|
1533
|
-
}
|
|
1534
|
-
|
|
1535
|
-
// until directive: render the highest-priority resolved value among
|
|
1536
|
-
// the candidates. Synchronous values are rendered immediately; Promises
|
|
1537
|
-
// are awaited in the background and applied if no higher-priority
|
|
1538
|
-
// candidate has resolved yet. When the marker is torn down, in-flight
|
|
1539
|
-
// priorities are cleared so late resolves cannot overwrite later DOM.
|
|
1540
|
-
if (isUntil(value)) {
|
|
1541
|
-
return applyUntil(part, /** @type any */ (value).args);
|
|
1542
|
-
}
|
|
1543
|
-
|
|
1544
|
-
// watch directive: bind a part to a signal. Reads the signal at
|
|
1545
|
-
// render time and subscribes the part to changes. When the signal
|
|
1546
|
-
// fires, only this part updates; the host component's render() does
|
|
1547
|
-
// not re-run. The signal read inside the watcher's observe is
|
|
1548
|
-
// tracked against the part's private Watcher, NOT the host's render
|
|
1549
|
-
// watcher (so the host doesn't subscribe to a full re-render too).
|
|
1550
|
-
if (isWatch(value)) {
|
|
1551
|
-
return applyWatch(part, /** @type any */ (value).signal);
|
|
1552
|
-
}
|
|
1553
|
-
|
|
1554
|
-
// asyncAppend / asyncReplace: subscribe to the AsyncIterable. Each
|
|
1555
|
-
// yielded value is mapped (optional) and appended (asyncAppend) or
|
|
1556
|
-
// replaces (asyncReplace) the prior content. Teardown aborts the
|
|
1557
|
-
// iteration so leaked iterators do not keep references to detached
|
|
1558
|
-
// DOM.
|
|
1559
|
-
if (isAsyncAppend(value)) {
|
|
1560
|
-
return applyAsyncAppend(part, /** @type any */ (value));
|
|
1561
|
-
}
|
|
1562
|
-
if (isAsyncReplace(value)) {
|
|
1563
|
-
return applyAsyncReplace(part, /** @type any */ (value));
|
|
1564
|
-
}
|
|
1565
|
-
|
|
1566
|
-
// Repeat directive: keyed reconciliation. Keep previous state when both
|
|
1567
|
-
// old and new are repeats; otherwise tear down and rebuild.
|
|
1568
|
-
if (isRepeat(value)) {
|
|
1569
|
-
if (part.child && /** @type any */ (part.child).kind === 'repeat') {
|
|
1570
|
-
reconcileRepeat(part, value);
|
|
1571
|
-
return;
|
|
1572
|
-
}
|
|
1573
|
-
teardownChild(part);
|
|
1574
|
-
const state = { kind: 'repeat', map: new Map() };
|
|
1575
|
-
part.child = state;
|
|
1576
|
-
applyRepeatFresh(marker, state, value);
|
|
1577
|
-
return;
|
|
1578
|
-
}
|
|
1579
|
-
|
|
1580
|
-
// Plain array (a `.map()` / list interpolation): positional, non-keyed
|
|
1581
|
-
// reconciliation. Update each position's instance IN PLACE when its
|
|
1582
|
-
// template shape is unchanged, so DOM node identity survives an item
|
|
1583
|
-
// update (focus, selection, scroll, and an in-progress native drag all
|
|
1584
|
-
// survive). Mirrors lit-html's non-keyed array child part. Without this,
|
|
1585
|
-
// flipping one item's binding rebuilt the WHOLE list and detached every
|
|
1586
|
-
// node, which cancels a native drag mid-gesture. Use `repeat()` for keyed
|
|
1587
|
-
// reordering.
|
|
1588
|
-
if (Array.isArray(value)) {
|
|
1589
|
-
if (part.child && /** @type any */ (part.child).kind === 'array') {
|
|
1590
|
-
reconcileArray(part, value);
|
|
1591
|
-
return;
|
|
1592
|
-
}
|
|
1593
|
-
teardownChild(part);
|
|
1594
|
-
const arrState = { kind: 'array', items: [] };
|
|
1595
|
-
part.child = arrState;
|
|
1596
|
-
applyArrayFresh(marker, /** @type any */ (arrState), value);
|
|
1597
|
-
return;
|
|
1598
|
-
}
|
|
1599
|
-
|
|
1600
|
-
// Remove previously rendered nodes between marker and its next sibling we own.
|
|
1601
|
-
if (part.child) {
|
|
1602
|
-
const c = /** @type any */ (part.child);
|
|
1603
|
-
if (c.kind === 'repeat') {
|
|
1604
|
-
teardownRepeat(c);
|
|
1605
|
-
part.child = undefined;
|
|
1606
|
-
} else if (c.kind === 'array') {
|
|
1607
|
-
teardownArray(/** @type any */ (c));
|
|
1608
|
-
part.child = undefined;
|
|
1609
|
-
} else if (c.kind === 'async-stream') {
|
|
1610
|
-
teardownAsyncStream(c);
|
|
1611
|
-
part.child = undefined;
|
|
1612
|
-
} else if ('strings' in /** @type any */ (part.child)) {
|
|
1613
|
-
// Previous was a TemplateInstance.
|
|
1614
|
-
const inst = /** @type TemplateInstance */ (part.child);
|
|
1615
|
-
if (isTemplate(value) && inst.strings === /** @type any */ (value).strings) {
|
|
1616
|
-
updateInstance(inst, /** @type any */ (value).values);
|
|
1617
|
-
return;
|
|
1618
|
-
}
|
|
1619
|
-
removeBetween(inst.startNode, inst.endNode);
|
|
1620
|
-
part.child = undefined;
|
|
1621
|
-
} else {
|
|
1622
|
-
// Previous was ChildNode[]: remove each node we inserted.
|
|
1623
|
-
for (const n of /** @type ChildNode[] */ (part.child)) {
|
|
1624
|
-
if (n.parentNode) n.parentNode.removeChild(n);
|
|
1625
|
-
}
|
|
1626
|
-
part.child = undefined;
|
|
1627
|
-
}
|
|
1628
|
-
}
|
|
1629
|
-
|
|
1630
|
-
if (value == null || value === false || value === true) return;
|
|
1631
|
-
|
|
1632
|
-
if (isTemplate(value)) {
|
|
1633
|
-
const tr = /** @type any */ (value);
|
|
1634
|
-
const { templateEl, parts, formActions } = compile(tr);
|
|
1635
|
-
const frag = /** @type DocumentFragment */ (templateEl.content.cloneNode(true));
|
|
1636
|
-
const startNode = document.createComment(`${MARKER}s`);
|
|
1637
|
-
const endNode = document.createComment(`${MARKER}e`);
|
|
1638
|
-
const bound = parts.map((p) => bindPart(p, frag));
|
|
1639
|
-
const lastValues = [];
|
|
1640
|
-
for (let i = 0; i < tr.values.length; i++) {
|
|
1641
|
-
applyPart(bound[i], tr.values[i], undefined, tr.values);
|
|
1642
|
-
lastValues.push(tr.values[i]);
|
|
1643
|
-
}
|
|
1644
|
-
reconcileFormActions(formActions, bound, tr.values);
|
|
1645
|
-
const nodes = [startNode, ...frag.childNodes, endNode];
|
|
1646
|
-
marker.parentNode?.insertBefore(nodesToFrag(nodes), marker);
|
|
1647
|
-
// Slot parts in this nested template need their one-shot apply just
|
|
1648
|
-
// like createInstance does for top-level templates. The slot is now
|
|
1649
|
-
// in the live tree (insertBefore above) so its parent walk can
|
|
1650
|
-
// reach the host. Without this loop, conditional / nested templates
|
|
1651
|
-
// with <slot> inside never trigger projection.
|
|
1652
|
-
for (const p of bound) {
|
|
1653
|
-
if (p.kind === 'slot') applyPart(p, undefined, undefined, []);
|
|
1654
|
-
}
|
|
1655
|
-
part.child = { strings: tr.strings, bound, lastValues, startNode, endNode };
|
|
1656
|
-
return;
|
|
1657
|
-
}
|
|
1658
|
-
|
|
1659
|
-
const node = document.createTextNode(String(value));
|
|
1660
|
-
marker.parentNode?.insertBefore(node, marker);
|
|
1661
|
-
part.child = [node];
|
|
1662
|
-
}
|
|
1663
|
-
|
|
1664
|
-
/** @param {ChildNode[]} nodes */
|
|
1665
|
-
function nodesToFrag(nodes) {
|
|
1666
|
-
const frag = document.createDocumentFragment();
|
|
1667
|
-
for (const n of nodes) frag.appendChild(n);
|
|
1668
|
-
return frag;
|
|
1669
|
-
}
|
|
1670
|
-
|
|
1671
|
-
/**
|
|
1672
|
-
* Remove a template instance's whole range, its bookend markers INCLUDED.
|
|
1673
|
-
*
|
|
1674
|
-
* Every caller discards the instance right after: the map entry or slot that
|
|
1675
|
-
* held it is dropped, and a replacement, where there is one, is built with
|
|
1676
|
-
* markers of its own. So this is a REMOVE, never lit's clear-and-reuse. A
|
|
1677
|
-
* caller that wants to keep the bookends and render into them again needs its
|
|
1678
|
-
* OWN function, because the two want opposite answers for the end marker.
|
|
1679
|
-
*
|
|
1680
|
-
* `parent` is read BEFORE the walk because the walk removes `start` on its
|
|
1681
|
-
* first iteration, which nulls `start.parentNode`. Reading it afterwards
|
|
1682
|
-
* compared the end marker's live parent against `null`, so the guard could
|
|
1683
|
-
* never fire and every teardown left one `wjm-e` comment in the document,
|
|
1684
|
-
* unbounded for the life of the region.
|
|
1685
|
-
*
|
|
1686
|
-
* The `end.parentNode === parent` comparison is a refusal, not a formality. A
|
|
1687
|
-
* marker moved under a different parent is not this region's to remove, and
|
|
1688
|
-
* `parent.removeChild(end)` on it throws NotFoundError from inside a teardown
|
|
1689
|
-
* that has to stay total.
|
|
1690
|
-
*
|
|
1691
|
-
* The walk assumes the range is INTACT: it steps `nextSibling` from `start`
|
|
1692
|
-
* and stops on `end`, so a range whose end no longer follows its start runs
|
|
1693
|
-
* off the child list and takes the part's own marker with it. That is not
|
|
1694
|
-
* something this function defends against, before or after the parent capture,
|
|
1695
|
-
* and the consequence is spelled out where it bites, on `reconcileRepeat`'s
|
|
1696
|
-
* catch below. The guard is the narrower promise: whatever the walk did, a
|
|
1697
|
-
* marker that is somewhere else is left alone.
|
|
1698
|
-
*
|
|
1699
|
-
* @param {Node} start @param {Node} end
|
|
1700
|
-
*/
|
|
1701
|
-
function removeBetween(start, end) {
|
|
1702
|
-
const parent = start.parentNode;
|
|
1703
|
-
if (!parent) return;
|
|
1704
|
-
let n = start;
|
|
1705
|
-
while (n && n !== end) {
|
|
1706
|
-
const next = n.nextSibling;
|
|
1707
|
-
n.parentNode?.removeChild(n);
|
|
1708
|
-
n = next;
|
|
1709
|
-
}
|
|
1710
|
-
if (end.parentNode === parent) parent.removeChild(end);
|
|
1711
|
-
}
|
|
1712
|
-
|
|
1713
|
-
/* ================================================================
|
|
1714
|
-
* Keyed list (repeat) support
|
|
1715
|
-
* ================================================================ */
|
|
1716
|
-
|
|
1717
|
-
/**
|
|
1718
|
-
* Build a TemplateInstance whose nodes (including bookends) live in a
|
|
1719
|
-
* document fragment that the caller will insert wherever it wants.
|
|
1720
|
-
* @param {import('./html.js').TemplateResult} tr
|
|
1721
|
-
* @returns {{ inst: TemplateInstance, frag: DocumentFragment }}
|
|
1722
|
-
*/
|
|
1723
|
-
function buildDetached(tr) {
|
|
1724
|
-
const { templateEl, parts, formActions } = compile(tr);
|
|
1725
|
-
const frag = /** @type DocumentFragment */ (templateEl.content.cloneNode(true));
|
|
1726
|
-
const startNode = document.createComment(`${MARKER}s`);
|
|
1727
|
-
const endNode = document.createComment(`${MARKER}e`);
|
|
1728
|
-
const bound = parts.map((p) => bindPart(p, frag));
|
|
1729
|
-
const lastValues = [];
|
|
1730
|
-
for (let i = 0; i < tr.values.length; i++) {
|
|
1731
|
-
applyPart(bound[i], tr.values[i], undefined, tr.values);
|
|
1732
|
-
lastValues.push(tr.values[i]);
|
|
1733
|
-
}
|
|
1734
|
-
reconcileFormActions(formActions, bound, tr.values);
|
|
1735
|
-
// Slot parts need their one-shot apply exactly like createInstance and the
|
|
1736
|
-
// nested-template path. The fragment is still detached here, so the
|
|
1737
|
-
// slot-part's own deferred finalize (a one-microtask retry when the parent
|
|
1738
|
-
// walk cannot reach a host yet) carries it: every caller inserts the
|
|
1739
|
-
// returned fragment synchronously in the same task, so the retry lands in
|
|
1740
|
-
// the live tree. Without this loop a <slot> inside a repeat() / array item
|
|
1741
|
-
// never finalizes and its content is never placeable.
|
|
1742
|
-
for (const p of bound) {
|
|
1743
|
-
if (p.kind === 'slot') applyPart(p, undefined, undefined, []);
|
|
1744
|
-
}
|
|
1745
|
-
const outFrag = document.createDocumentFragment();
|
|
1746
|
-
outFrag.appendChild(startNode);
|
|
1747
|
-
while (frag.firstChild) outFrag.appendChild(frag.firstChild);
|
|
1748
|
-
outFrag.appendChild(endNode);
|
|
1749
|
-
return {
|
|
1750
|
-
inst: { strings: tr.strings, bound, lastValues, startNode, endNode },
|
|
1751
|
-
frag: outFrag,
|
|
1752
|
-
};
|
|
1753
|
-
}
|
|
1754
|
-
|
|
1755
|
-
/** @param {TemplateInstance} inst */
|
|
1756
|
-
function disposeInstance(inst) {
|
|
1757
|
-
for (const p of inst.bound) {
|
|
1758
|
-
if (p.kind === 'event') p.el.removeEventListener(p.name, p.dispatcher);
|
|
1759
|
-
if (p.kind === 'element') {
|
|
1760
|
-
// Unbind any active ref so the user observes the element being
|
|
1761
|
-
// removed (callback receives undefined / Ref.value cleared).
|
|
1762
|
-
// Mirrors lit-html's cleanup-on-disconnect for element parts.
|
|
1763
|
-
//
|
|
1764
|
-
// BOTH branches swallow, and lit is not the reason: lit's ref directive
|
|
1765
|
-
// guards neither, so a throw there propagates. The reason is that a
|
|
1766
|
-
// teardown has to be TOTAL. `lastTarget` is cleared only AFTER these
|
|
1767
|
-
// writes, so a throw leaves the part still pointing at the ref and
|
|
1768
|
-
// every later teardown of the same instance throws at the same line
|
|
1769
|
-
// forever. It also aborts the rest of this loop, so the remaining
|
|
1770
|
-
// parts keep their listeners and their refs bound. A teardown has no
|
|
1771
|
-
// retry either (a commit has the COMMIT_FAILED sentinel and a next
|
|
1772
|
-
// render; this does not), so there is nothing a propagated error could
|
|
1773
|
-
// usefully repair.
|
|
1774
|
-
//
|
|
1775
|
-
// The object branch is the one this adds. The callback branch was
|
|
1776
|
-
// already guarded here AND on the commit path (`applyElement` wraps
|
|
1777
|
-
// every `nextTarget(...)` / `prevTarget(undefined)` call), so a
|
|
1778
|
-
// throwing ref CALLBACK has always been swallowed everywhere. What was
|
|
1779
|
-
// inconsistent is the object ref, guarded on neither. This makes the
|
|
1780
|
-
// two agree on TEARDOWN, which is where the totality argument bites.
|
|
1781
|
-
// It does NOT touch the commit path, so `applyElement`'s object-ref
|
|
1782
|
-
// writes still propagate to the component boundary, which has a route
|
|
1783
|
-
// for the error and a next render to repair it.
|
|
1784
|
-
const prev = /** @type any */ (p).lastTarget;
|
|
1785
|
-
if (prev) {
|
|
1786
|
-
if (typeof prev === 'function') {
|
|
1787
|
-
try { prev(undefined); } catch { /* swallow */ }
|
|
1788
|
-
} else if (typeof prev === 'object') {
|
|
1789
|
-
try { prev.value = undefined; } catch { /* swallow */ }
|
|
1790
|
-
}
|
|
1791
|
-
/** @type any */ (p).lastTarget = undefined;
|
|
1792
|
-
/** @type any */ (p).__lastEl = undefined;
|
|
1793
|
-
}
|
|
1794
|
-
}
|
|
1795
|
-
}
|
|
1796
|
-
}
|
|
1797
|
-
|
|
1798
|
-
/**
|
|
1799
|
-
* Initial fresh render of a repeat directive. Inserts all items' nodes
|
|
1800
|
-
* immediately before the part's marker comment.
|
|
1801
|
-
*
|
|
1802
|
-
* @param {Comment} marker
|
|
1803
|
-
* @param {{ kind: 'repeat', map: Map<any, TemplateInstance> }} state
|
|
1804
|
-
* @param {any} value
|
|
1805
|
-
*/
|
|
1806
|
-
function applyRepeatFresh(marker, state, value) {
|
|
1807
|
-
const { items, keyFn, templateFn } = value;
|
|
1808
|
-
const parent = marker.parentNode;
|
|
1809
|
-
if (!parent) return;
|
|
1810
|
-
const bulk = document.createDocumentFragment();
|
|
1811
|
-
for (let i = 0; i < items.length; i++) {
|
|
1812
|
-
const key = keyFn(items[i], i);
|
|
1813
|
-
const tr = templateFn(items[i], i);
|
|
1814
|
-
if (!isTemplate(tr)) continue;
|
|
1815
|
-
const { inst, frag } = buildDetached(/** @type any */ (tr));
|
|
1816
|
-
state.map.set(key, inst);
|
|
1817
|
-
bulk.appendChild(frag);
|
|
1818
|
-
}
|
|
1819
|
-
parent.insertBefore(bulk, marker);
|
|
1820
|
-
}
|
|
1821
|
-
|
|
1822
|
-
/**
|
|
1823
|
-
* Keyed reconciliation. For each key in the new list:
|
|
1824
|
-
* - hit: update the existing instance in place (if template shape matches),
|
|
1825
|
-
* then move its nodes into position
|
|
1826
|
-
* - miss: build a new instance and insert
|
|
1827
|
-
* Finally drop instances whose keys aren't in the new list.
|
|
1828
|
-
*
|
|
1829
|
-
* @param {Extract<BoundPart, {kind:'child'}>} part
|
|
1830
|
-
* @param {any} value
|
|
1831
|
-
*/
|
|
1832
|
-
function reconcileRepeat(part, value) {
|
|
1833
|
-
const marker = part.marker;
|
|
1834
|
-
const parent = marker.parentNode;
|
|
1835
|
-
if (!parent) return;
|
|
1836
|
-
const state = /** @type {{ kind: 'repeat', map: Map<any, TemplateInstance> }} */ (part.child);
|
|
1837
|
-
const { items, keyFn, templateFn } = value;
|
|
1838
|
-
|
|
1839
|
-
const newMap = new Map();
|
|
1840
|
-
|
|
1841
|
-
try {
|
|
1842
|
-
// Walk the new list and position each item's nodes immediately before the marker.
|
|
1843
|
-
for (let i = 0; i < items.length; i++) {
|
|
1844
|
-
const key = keyFn(items[i], i);
|
|
1845
|
-
const tr = templateFn(items[i], i);
|
|
1846
|
-
if (!isTemplate(tr)) continue;
|
|
1847
|
-
const existing = state.map.get(key);
|
|
1848
|
-
if (existing && existing.strings === /** @type any */ (tr).strings) {
|
|
1849
|
-
updateInstance(existing, /** @type any */ (tr).values);
|
|
1850
|
-
// Move nodes before marker preserving element identity.
|
|
1851
|
-
moveRange(existing.startNode, existing.endNode, parent, marker);
|
|
1852
|
-
newMap.set(key, existing);
|
|
1853
|
-
state.map.delete(key);
|
|
1854
|
-
} else {
|
|
1855
|
-
if (existing) {
|
|
1856
|
-
// Unmapped BEFORE the row is touched, for the reason spelled out
|
|
1857
|
-
// in the leftover loop below: a key kept across a refused removal
|
|
1858
|
-
// points at a half-removed row, and reusing that row later walks
|
|
1859
|
-
// the removal off the end of the region. Same ordering, same
|
|
1860
|
-
// trade, and the two have to agree or the invariant the catch
|
|
1861
|
-
// relies on holds on one branch and not the other.
|
|
1862
|
-
state.map.delete(key);
|
|
1863
|
-
disposeInstance(existing);
|
|
1864
|
-
removeBetween(existing.startNode, existing.endNode);
|
|
1865
|
-
}
|
|
1866
|
-
const { inst, frag } = buildDetached(/** @type any */ (tr));
|
|
1867
|
-
parent.insertBefore(frag, marker);
|
|
1868
|
-
newMap.set(key, inst);
|
|
1869
|
-
}
|
|
1870
|
-
}
|
|
1871
|
-
|
|
1872
|
-
// Remove any keys that remain in the old map. The key leaves the map
|
|
1873
|
-
// BEFORE its row is touched and the removal is in a `finally`, so at any
|
|
1874
|
-
// throw point `state.map` holds exactly the leftovers this pass has not
|
|
1875
|
-
// reached, and a row whose dispose threw still leaves the document.
|
|
1876
|
-
// Iterating a snapshot keeps the delete obviously safe rather than
|
|
1877
|
-
// relying on the reader knowing that deleting during a Map iteration is
|
|
1878
|
-
// legal.
|
|
1879
|
-
for (const [k, inst] of [...state.map]) {
|
|
1880
|
-
state.map.delete(k);
|
|
1881
|
-
try {
|
|
1882
|
-
disposeInstance(inst);
|
|
1883
|
-
} finally {
|
|
1884
|
-
removeBetween(inst.startNode, inst.endNode);
|
|
1885
|
-
}
|
|
1886
|
-
}
|
|
1887
|
-
state.map = newMap;
|
|
1888
|
-
} catch (err) {
|
|
1889
|
-
// The walk moves DOM and drains `state.map` incrementally while the
|
|
1890
|
-
// replacement map is accumulated locally, so a throw part-way through
|
|
1891
|
-
// leaves NEITHER map describing what is on screen: the already-processed
|
|
1892
|
-
// keys sit only in `newMap`, which is about to be discarded, while their
|
|
1893
|
-
// nodes stay in the document tracked by nothing. The next (perfectly
|
|
1894
|
-
// valid) render then rebuilds those keys from scratch and the orphaned
|
|
1895
|
-
// originals are never removed, so the list shows duplicated rows forever
|
|
1896
|
-
// with nothing logged after the first throw.
|
|
1897
|
-
//
|
|
1898
|
-
// Re-unite every instance still in the document under `state.map` (the
|
|
1899
|
-
// two maps are disjoint: a key lands in `newMap` only after being deleted
|
|
1900
|
-
// from `state.map`), so the map describes the DOM again and nothing is
|
|
1901
|
-
// orphaned. That is the whole repair. The next render is then an ORDINARY
|
|
1902
|
-
// reconcile against a truthful map, which repositions every row and
|
|
1903
|
-
// re-applies whatever the throw skipped.
|
|
1904
|
-
//
|
|
1905
|
-
// That claim covers the REMOVAL loop as well as the walk, and only
|
|
1906
|
-
// because the loop was written to earn it. It drops each key before
|
|
1907
|
-
// touching that row and removes the nodes in a `finally`, so a throw
|
|
1908
|
-
// mid-removal cannot merge `newMap` over a `state.map` still holding
|
|
1909
|
-
// disposed, detached rows. That was the failure: the row the app DELETED
|
|
1910
|
-
// stayed on screen, the survivors reordered, and a later render that
|
|
1911
|
-
// re-added that key reinserted the detached instance. The invariant, at
|
|
1912
|
-
// any throw point on either branch: every instance this pass has not
|
|
1913
|
-
// destructively touched is described by exactly one of the two maps,
|
|
1914
|
-
// `newMap` for the processed new keys and `state.map` for the leftovers
|
|
1915
|
-
// not reached yet, which is what makes the merge below correct. The
|
|
1916
|
-
// exception is the row named in the residual just below, whose removal
|
|
1917
|
-
// refused part-way; that one is in neither map, by choice.
|
|
1918
|
-
//
|
|
1919
|
-
// The residual is a throw from `removeBetween` ITSELF, which only calls
|
|
1920
|
-
// `removeChild` on nodes the renderer owns, so it takes a throwing DOM to
|
|
1921
|
-
// reach. That row is already unmapped, so its remaining nodes stay in the
|
|
1922
|
-
// document tracked by nothing and a later re-add of that key builds a
|
|
1923
|
-
// second row beside them. Unmapping AFTER the removal instead would keep
|
|
1924
|
-
// that key, and it is measurably worse rather than better: the row is
|
|
1925
|
-
// half removed, its start marker gone and its end marker still in place,
|
|
1926
|
-
// so the re-add hits the reuse branch and `moveRange` re-attaches the
|
|
1927
|
-
// lone start marker AFTER the end marker. The next removal of that key
|
|
1928
|
-
// then walks forward from a start that never reaches its end, taking the
|
|
1929
|
-
// repeat part's own marker and every following sibling with it, and the
|
|
1930
|
-
// region is dead for good. One untracked row beats a destroyed list.
|
|
1931
|
-
//
|
|
1932
|
-
// Deliberately NOT a teardown-and-rebuild of the region. Rebuilding is
|
|
1933
|
-
// the obvious defensive move and it is measurably worse: it discards node
|
|
1934
|
-
// identity for every row, which is the exact cost keyed reconciliation
|
|
1935
|
-
// exists to avoid (see the plain-array note below, a rebuild cancels an
|
|
1936
|
-
// in-progress native drag). It is also unnecessary, because this map is
|
|
1937
|
-
// restored to the truth rather than left describing a DOM that moved, so
|
|
1938
|
-
// there is nothing to guess and no partial DOM move to unwind.
|
|
1939
|
-
//
|
|
1940
|
-
// That is only half of it, and the other half does NOT live here: the row
|
|
1941
|
-
// whose own commit threw is repaired by the COMMIT_FAILED sentinel in
|
|
1942
|
-
// `updateInstance`, not by anything below. Without it the failed hole
|
|
1943
|
-
// compares EQUAL on the next render (its `lastValues` entry never
|
|
1944
|
-
// advanced past the throw, so it still holds the value that render
|
|
1945
|
-
// supplies) and is skipped forever, which for a child position means the
|
|
1946
|
-
// row stays permanently blank. Do not remove the sentinel on the theory
|
|
1947
|
-
// that a half-updated instance heals itself; it does not.
|
|
1948
|
-
for (const [k, inst] of newMap) state.map.set(k, inst);
|
|
1949
|
-
throw err;
|
|
1950
|
-
}
|
|
1951
|
-
}
|
|
1952
|
-
|
|
1953
|
-
/** @param {{ kind: 'repeat', map: Map<any, TemplateInstance> }} state */
|
|
1954
|
-
function teardownRepeat(state) {
|
|
1955
|
-
// Same delete-as-you-go shape as the leftover loop in `reconcileRepeat`,
|
|
1956
|
-
// for the same reason: a throw part-way must not leave already-removed
|
|
1957
|
-
// instances in the map. The trailing `clear()` stays as a no-op safety net.
|
|
1958
|
-
for (const [k, inst] of [...state.map]) {
|
|
1959
|
-
state.map.delete(k);
|
|
1960
|
-
try {
|
|
1961
|
-
disposeInstance(inst);
|
|
1962
|
-
} finally {
|
|
1963
|
-
removeBetween(inst.startNode, inst.endNode);
|
|
1964
|
-
}
|
|
1965
|
-
}
|
|
1966
|
-
state.map.clear();
|
|
1967
|
-
}
|
|
1968
|
-
|
|
1969
|
-
/* ================================================================
|
|
1970
|
-
* Plain array (.map) support: positional, non-keyed reconciliation
|
|
1971
|
-
* ================================================================ */
|
|
1972
|
-
|
|
1973
|
-
/**
|
|
1974
|
-
* One rendered slot of a plain array. A `tpl` carries a detached template
|
|
1975
|
-
* instance (bookended by its own markers), a `text` carries a single text
|
|
1976
|
-
* node, and an `empty` slot (a nullish / boolean element) renders nothing
|
|
1977
|
-
* but still holds the position so index-based reconciliation stays aligned.
|
|
1978
|
-
* @typedef {{ type: 'tpl', inst: TemplateInstance } | { type: 'text', node: Text } | { type: 'empty' }} ArrayItem
|
|
1979
|
-
*/
|
|
1980
|
-
|
|
1981
|
-
/** @param {ArrayItem} item @returns {ChildNode | null} */
|
|
1982
|
-
function arrayItemFirstNode(item) {
|
|
1983
|
-
if (item.type === 'tpl') return item.inst.startNode;
|
|
1984
|
-
if (item.type === 'text') return item.node;
|
|
1985
|
-
return null;
|
|
1986
|
-
}
|
|
1987
|
-
|
|
1988
|
-
/** @param {ArrayItem} item */
|
|
1989
|
-
function removeArrayItem(item) {
|
|
1990
|
-
if (item.type === 'tpl') {
|
|
1991
|
-
disposeInstance(item.inst);
|
|
1992
|
-
removeBetween(item.inst.startNode, item.inst.endNode);
|
|
1993
|
-
} else if (item.type === 'text') {
|
|
1994
|
-
item.node.parentNode?.removeChild(item.node);
|
|
1995
|
-
}
|
|
1996
|
-
}
|
|
1997
|
-
|
|
1998
|
-
/**
|
|
1999
|
-
* Build the slot for one array element, plus the fragment to insert (null
|
|
2000
|
-
* for an empty slot). A TemplateResult becomes a detached instance, a
|
|
2001
|
-
* primitive becomes a text node, and nullish / boolean renders nothing.
|
|
2002
|
-
* @param {unknown} v
|
|
2003
|
-
* @returns {{ item: ArrayItem, frag: Node | null }}
|
|
2004
|
-
*/
|
|
2005
|
-
function buildArrayItem(v) {
|
|
2006
|
-
if (isTemplate(v)) {
|
|
2007
|
-
const { inst, frag } = buildDetached(/** @type any */ (v));
|
|
2008
|
-
return { item: { type: 'tpl', inst }, frag };
|
|
2009
|
-
}
|
|
2010
|
-
if (v != null && v !== false && v !== true) {
|
|
2011
|
-
const node = document.createTextNode(String(v));
|
|
2012
|
-
return { item: { type: 'text', node }, frag: node };
|
|
2013
|
-
}
|
|
2014
|
-
return { item: { type: 'empty' }, frag: null };
|
|
2015
|
-
}
|
|
2016
|
-
|
|
2017
|
-
/**
|
|
2018
|
-
* Initial render of a plain array: insert every slot's nodes before the marker.
|
|
2019
|
-
* @param {Comment} marker
|
|
2020
|
-
* @param {{ kind: 'array', items: ArrayItem[] }} state
|
|
2021
|
-
* @param {unknown[]} value
|
|
2022
|
-
*/
|
|
2023
|
-
function applyArrayFresh(marker, state, value) {
|
|
2024
|
-
const parent = marker.parentNode;
|
|
2025
|
-
if (!parent) return;
|
|
2026
|
-
const bulk = document.createDocumentFragment();
|
|
2027
|
-
/** @type {ArrayItem[]} */
|
|
2028
|
-
const items = [];
|
|
2029
|
-
for (const v of value) {
|
|
2030
|
-
const { item, frag } = buildArrayItem(v);
|
|
2031
|
-
if (frag) bulk.appendChild(frag);
|
|
2032
|
-
items.push(item);
|
|
2033
|
-
}
|
|
2034
|
-
parent.insertBefore(bulk, marker);
|
|
2035
|
-
state.items = items;
|
|
2036
|
-
}
|
|
2037
|
-
|
|
2038
|
-
/**
|
|
2039
|
-
* Positional (non-keyed) reconciliation. For each index, update the slot
|
|
2040
|
-
* in place when its shape is unchanged (preserving DOM node identity),
|
|
2041
|
-
* otherwise replace it; grow or shrink at the tail. There is no key
|
|
2042
|
-
* matching, so a reorder reduces to a series of in-place value updates;
|
|
2043
|
-
* reach for `repeat()` when element identity must follow a moved key.
|
|
2044
|
-
* @param {Extract<BoundPart, {kind:'child'}>} part
|
|
2045
|
-
* @param {unknown[]} value
|
|
2046
|
-
*/
|
|
2047
|
-
function reconcileArray(part, value) {
|
|
2048
|
-
const marker = part.marker;
|
|
2049
|
-
const parent = marker.parentNode;
|
|
2050
|
-
if (!parent) return;
|
|
2051
|
-
const state = /** @type {{ kind: 'array', items: ArrayItem[] }} */ (part.child);
|
|
2052
|
-
const old = state.items;
|
|
2053
|
-
/** @type {ArrayItem[]} */
|
|
2054
|
-
const next = [];
|
|
2055
|
-
// How many slots of `old` are fully processed. Tracked rather than inferred
|
|
2056
|
-
// from `next.length`, because the shrink loop below advances through `old`
|
|
2057
|
-
// while `next` stops growing, so the two part company there. The catch is
|
|
2058
|
-
// the only reader.
|
|
2059
|
-
let consumed = 0;
|
|
2060
|
-
|
|
2061
|
-
try {
|
|
2062
|
-
for (let i = 0; i < value.length; i++) {
|
|
2063
|
-
const v = value[i];
|
|
2064
|
-
const o = old[i];
|
|
2065
|
-
if (isTemplate(v)) {
|
|
2066
|
-
const tr = /** @type any */ (v);
|
|
2067
|
-
if (o && o.type === 'tpl' && o.inst.strings === tr.strings) {
|
|
2068
|
-
updateInstance(o.inst, tr.values);
|
|
2069
|
-
next.push(o);
|
|
2070
|
-
consumed = i + 1;
|
|
2071
|
-
continue;
|
|
2072
|
-
}
|
|
2073
|
-
} else if (v != null && v !== false && v !== true) {
|
|
2074
|
-
if (o && o.type === 'text') {
|
|
2075
|
-
const str = String(v);
|
|
2076
|
-
if (o.node.data !== str) o.node.data = str;
|
|
2077
|
-
next.push(o);
|
|
2078
|
-
consumed = i + 1;
|
|
2079
|
-
continue;
|
|
2080
|
-
}
|
|
2081
|
-
} else {
|
|
2082
|
-
// Empty slot: drop any prior nodes that occupied this position.
|
|
2083
|
-
// Deliberately NOT reordered like the branch below. That reorder
|
|
2084
|
-
// exists to keep a slot that was already BUILT and INSERTED tracked,
|
|
2085
|
-
// and this branch builds and inserts nothing, so pushing first would
|
|
2086
|
-
// only mean a throw from the removal leaves a phantom empty slot at
|
|
2087
|
-
// this index AND `old[i]` spliced in at the next one, shifting every
|
|
2088
|
-
// later slot by one in a POSITIONAL reconciler. Removing first, a
|
|
2089
|
-
// throw here leaves `old[i]` describing its own position, which is
|
|
2090
|
-
// still exactly where its nodes are.
|
|
2091
|
-
if (o) removeArrayItem(o);
|
|
2092
|
-
next.push({ type: 'empty' });
|
|
2093
|
-
consumed = i + 1;
|
|
2094
|
-
continue;
|
|
2095
|
-
}
|
|
2096
|
-
// Shape changed, or the array grew past the old length. Build fresh,
|
|
2097
|
-
// insert at this position (before the current / next still-attached
|
|
2098
|
-
// old node, else the marker), then drop the old slot it replaced.
|
|
2099
|
-
// The push sits BEFORE the removal, which is a pure reordering on the
|
|
2100
|
-
// success path and means a slot that has already been built and
|
|
2101
|
-
// inserted is never untracked at any throw point.
|
|
2102
|
-
const { item, frag } = buildArrayItem(v);
|
|
2103
|
-
if (frag) parent.insertBefore(frag, nextArrayAnchor(old, i, marker));
|
|
2104
|
-
next.push(item);
|
|
2105
|
-
if (o) removeArrayItem(o);
|
|
2106
|
-
consumed = i + 1;
|
|
2107
|
-
}
|
|
2108
|
-
|
|
2109
|
-
// Shrink: remove slots beyond the new length.
|
|
2110
|
-
for (let i = value.length; i < old.length; i++) {
|
|
2111
|
-
removeArrayItem(old[i]);
|
|
2112
|
-
consumed = i + 1;
|
|
2113
|
-
}
|
|
2114
|
-
state.items = next;
|
|
2115
|
-
} catch (err) {
|
|
2116
|
-
// `state.items` is committed only after the whole walk, so a throw part
|
|
2117
|
-
// way through discards `next` entirely: the map of slots keeps describing
|
|
2118
|
-
// positions whose nodes were already removed, while the freshly built and
|
|
2119
|
-
// inserted ones are in the document tracked by nothing. Nothing is logged
|
|
2120
|
-
// after the first throw, and the orphan outlives even a render of an EMPTY
|
|
2121
|
-
// array, because the only code that could remove it walks `state.items`.
|
|
2122
|
-
//
|
|
2123
|
-
// Splice the untouched tail of `old` onto what `next` accumulated, so the
|
|
2124
|
-
// bookkeeping describes the DOM again. The invariant that holds at any
|
|
2125
|
-
// throw point: every live node is described by exactly one slot, the
|
|
2126
|
-
// slots below `next.length` being the rebuilt or reused ones and the rest
|
|
2127
|
-
// the part of `old` this pass never reached. Index alignment survives
|
|
2128
|
-
// because this reconciler is POSITIONAL, so a slot's index IS its
|
|
2129
|
-
// identity, which is also why the boundary has to come from `consumed`
|
|
2130
|
-
// rather than `next.length`: during the shrink loop those differ, and
|
|
2131
|
-
// splicing from `next.length` would re-describe slots already removed.
|
|
2132
|
-
// A later render that grew the array would then match a live value
|
|
2133
|
-
// against a DETACHED slot with the same `strings`, update it in place,
|
|
2134
|
-
// and that row would silently never appear.
|
|
2135
|
-
//
|
|
2136
|
-
// Deliberately NOT a teardown-and-rebuild of the region, for the reason
|
|
2137
|
-
// recorded on `reconcileRepeat`'s catch above: it discards node identity
|
|
2138
|
-
// for every row, which cancels an in-progress native drag and drops focus
|
|
2139
|
-
// and scroll.
|
|
2140
|
-
//
|
|
2141
|
-
// The residual is a throw from the removal step itself, which takes a
|
|
2142
|
-
// throwing DOM to reach (the teardown it calls is total, so only
|
|
2143
|
-
// `removeBetween` is left, and that calls `removeChild` solely on nodes
|
|
2144
|
-
// the renderer owns). State it rather than deny it: `removeBetween`
|
|
2145
|
-
// takes the start marker first and then early-returns for good once that
|
|
2146
|
-
// marker is gone, so a row whose removal refused part-way can never be
|
|
2147
|
-
// removed afterwards. Its remaining nodes stay in the document, and an
|
|
2148
|
-
// EMPTY render will not clear them. Tracked or not, they are there for
|
|
2149
|
-
// the life of the region, the same residual `reconcileRepeat`'s catch
|
|
2150
|
-
// names. What this repair buys is that there is only ONE such row and
|
|
2151
|
-
// every other slot still reconciles, where before the whole pass was
|
|
2152
|
-
// discarded.
|
|
2153
|
-
state.items = next.concat(old.slice(consumed));
|
|
2154
|
-
throw err;
|
|
2155
|
-
}
|
|
2156
|
-
}
|
|
2157
|
-
|
|
2158
|
-
/**
|
|
2159
|
-
* The node a freshly-built slot at index `i` inserts before: the first
|
|
2160
|
-
* node of the current or next still-attached old slot, else the part
|
|
2161
|
-
* marker (a tail append).
|
|
2162
|
-
* @param {ArrayItem[]} old @param {number} i @param {Comment} marker
|
|
2163
|
-
* @returns {ChildNode}
|
|
2164
|
-
*/
|
|
2165
|
-
function nextArrayAnchor(old, i, marker) {
|
|
2166
|
-
for (let j = i; j < old.length; j++) {
|
|
2167
|
-
const f = arrayItemFirstNode(old[j]);
|
|
2168
|
-
if (f && f.parentNode) return f;
|
|
2169
|
-
}
|
|
2170
|
-
return marker;
|
|
2171
|
-
}
|
|
2172
|
-
|
|
2173
|
-
/** @param {{ kind: 'array', items: ArrayItem[] }} state */
|
|
2174
|
-
function teardownArray(state) {
|
|
2175
|
-
for (const it of state.items) removeArrayItem(it);
|
|
2176
|
-
state.items = [];
|
|
2177
|
-
}
|
|
2178
|
-
|
|
2179
|
-
/**
|
|
2180
|
-
* Collect [start .. end] (inclusive) and insert immediately before `anchor`.
|
|
2181
|
-
* Browsers treat insertBefore of an already-connected node as a move and
|
|
2182
|
-
* preserve element identity + focus.
|
|
2183
|
-
*
|
|
2184
|
-
* @param {Node} start
|
|
2185
|
-
* @param {Node} end
|
|
2186
|
-
* @param {Node} parent
|
|
2187
|
-
* @param {Node} anchor
|
|
2188
|
-
*/
|
|
2189
|
-
function moveRange(start, end, parent, anchor) {
|
|
2190
|
-
// No-op if the range is already immediately before the anchor.
|
|
2191
|
-
if (end.nextSibling === anchor && start.parentNode === parent) return;
|
|
2192
|
-
const frag = document.createDocumentFragment();
|
|
2193
|
-
let n = start;
|
|
2194
|
-
while (n) {
|
|
2195
|
-
const next = n.nextSibling;
|
|
2196
|
-
frag.appendChild(n);
|
|
2197
|
-
if (n === end) break;
|
|
2198
|
-
n = next;
|
|
2199
|
-
}
|
|
2200
|
-
parent.insertBefore(frag, anchor);
|
|
2201
|
-
}
|
|
2202
|
-
|
|
2203
|
-
/**
|
|
2204
|
-
* Shallow array equality (Object.is on each element). Used by the
|
|
2205
|
-
* `guard` directive to skip re-evaluation when deps are unchanged.
|
|
2206
|
-
* @param {readonly unknown[]} a
|
|
2207
|
-
* @param {readonly unknown[]} b
|
|
2208
|
-
*/
|
|
2209
|
-
function shallowEqualArray(a, b) {
|
|
2210
|
-
if (a === b) return true;
|
|
2211
|
-
if (!a || !b || a.length !== b.length) return false;
|
|
2212
|
-
for (let i = 0; i < a.length; i++) {
|
|
2213
|
-
if (!Object.is(a[i], b[i])) return false;
|
|
2214
|
-
}
|
|
2215
|
-
return true;
|
|
2216
|
-
}
|
|
2217
|
-
|
|
2218
|
-
/** @param {Extract<BoundPart, {kind:'child'}>} part */
|
|
2219
|
-
function teardownChild(part) {
|
|
2220
|
-
// Always abort any in-flight directive state on the part, even if
|
|
2221
|
-
// `part.child` itself is something else (e.g. an `until` directive
|
|
2222
|
-
// installed __untilState but applyChild's recursion overwrote
|
|
2223
|
-
// part.child to the rendered fallback shape).
|
|
2224
|
-
const partAny = /** @type any */ (part);
|
|
2225
|
-
if (partAny.__untilState) {
|
|
2226
|
-
partAny.__untilState.aborted = true;
|
|
2227
|
-
partAny.__untilState = undefined;
|
|
2228
|
-
}
|
|
2229
|
-
if (partAny.__watchSub) {
|
|
2230
|
-
teardownWatch(partAny);
|
|
2231
|
-
}
|
|
2232
|
-
|
|
2233
|
-
if (!part.child) return;
|
|
2234
|
-
const c = /** @type any */ (part.child);
|
|
2235
|
-
if (c.kind === 'repeat') {
|
|
2236
|
-
teardownRepeat(c);
|
|
2237
|
-
} else if (c.kind === 'array') {
|
|
2238
|
-
teardownArray(c);
|
|
2239
|
-
} else if (c.kind === 'async-stream') {
|
|
2240
|
-
teardownAsyncStream(c);
|
|
2241
|
-
} else if ('strings' in c) {
|
|
2242
|
-
const inst = /** @type TemplateInstance */ (part.child);
|
|
2243
|
-
disposeInstance(inst);
|
|
2244
|
-
removeBetween(inst.startNode, inst.endNode);
|
|
2245
|
-
} else {
|
|
2246
|
-
for (const n of /** @type ChildNode[] */ (part.child)) {
|
|
2247
|
-
if (n.parentNode) n.parentNode.removeChild(n);
|
|
2248
|
-
}
|
|
2249
|
-
}
|
|
2250
|
-
part.child = undefined;
|
|
2251
|
-
}
|
|
2252
|
-
|
|
2253
|
-
/**
|
|
2254
|
-
* Clear per-part directive state slots that don't apply to the value
|
|
2255
|
-
* currently being rendered. Prevents stale `__guardDeps` from short-
|
|
2256
|
-
* circuiting a render when the directive at this position is no longer
|
|
2257
|
-
* a guard, stale `__cacheMap` from accumulating across non-cache
|
|
2258
|
-
* renders, and stale `__untilState` from letting a prior Promise
|
|
2259
|
-
* resolution overwrite newer DOM.
|
|
2260
|
-
*
|
|
2261
|
-
* @param {Extract<BoundPart, {kind:'child'}>} part
|
|
2262
|
-
* @param {unknown} value
|
|
2263
|
-
*/
|
|
2264
|
-
function clearStaleDirectiveState(part, value) {
|
|
2265
|
-
const partAny = /** @type any */ (part);
|
|
2266
|
-
if (partAny.__untilState && !isUntil(value)) {
|
|
2267
|
-
partAny.__untilState.aborted = true;
|
|
2268
|
-
partAny.__untilState = undefined;
|
|
2269
|
-
}
|
|
2270
|
-
if (partAny.__guardDeps !== undefined && !isGuard(value)) {
|
|
2271
|
-
partAny.__guardDeps = undefined;
|
|
2272
|
-
}
|
|
2273
|
-
if (partAny.__cacheMap && !isCache(value)) {
|
|
2274
|
-
partAny.__cacheMap = undefined;
|
|
2275
|
-
}
|
|
2276
|
-
if (partAny.__keyedKey !== undefined && !isKeyed(value)) {
|
|
2277
|
-
partAny.__keyedKey = undefined;
|
|
2278
|
-
}
|
|
2279
|
-
if (partAny.__watchSub && !isWatch(value)) {
|
|
2280
|
-
teardownWatch(partAny);
|
|
2281
|
-
}
|
|
2282
|
-
}
|
|
2283
|
-
|
|
2284
|
-
/* ================================================================
|
|
2285
|
-
* Cache directive: detach + retain prior template instances so that
|
|
2286
|
-
* toggling between sub-templates preserves their DOM state.
|
|
2287
|
-
* ================================================================ */
|
|
2288
|
-
|
|
2289
|
-
/**
|
|
2290
|
-
* Apply the `cache` directive at a child position. The cache is stored
|
|
2291
|
-
* on the part as `__cacheMap: Map<strings, { inst, holderFrag }>`.
|
|
2292
|
-
*
|
|
2293
|
-
* When the new inner value is a template whose `strings` already lives
|
|
2294
|
-
* in the cache map, re-attach the stashed nodes before the marker and
|
|
2295
|
-
* reconcile values against the new template. When the new inner is a
|
|
2296
|
-
* template whose strings aren't cached, stash the currently-attached
|
|
2297
|
-
* instance (if any) into the cache map before rendering the new one.
|
|
2298
|
-
*
|
|
2299
|
-
* Non-template inner values fall through to the generic applyChild path
|
|
2300
|
-
* (after first stashing any currently-attached cached instance).
|
|
2301
|
-
*
|
|
2302
|
-
* @param {Extract<BoundPart, {kind:'child'}>} part
|
|
2303
|
-
* @param {unknown} inner
|
|
2304
|
-
*/
|
|
2305
|
-
function applyCache(part, inner) {
|
|
2306
|
-
const marker = part.marker;
|
|
2307
|
-
const partAny = /** @type any */ (part);
|
|
2308
|
-
/** @type {Map<TemplateStringsArray, { inst: TemplateInstance, holder: DocumentFragment }>} */
|
|
2309
|
-
let cacheMap = partAny.__cacheMap;
|
|
2310
|
-
if (!cacheMap) {
|
|
2311
|
-
cacheMap = new Map();
|
|
2312
|
-
partAny.__cacheMap = cacheMap;
|
|
2313
|
-
}
|
|
2314
|
-
|
|
2315
|
-
const currentChild = /** @type any */ (part.child);
|
|
2316
|
-
const currentIsInstance = currentChild && 'strings' in currentChild;
|
|
2317
|
-
|
|
2318
|
-
// If the currently-attached child IS a template instance, decide
|
|
2319
|
-
// whether to update-in-place, stash for later, or destroy.
|
|
2320
|
-
if (currentIsInstance) {
|
|
2321
|
-
const currentInst = /** @type TemplateInstance */ (currentChild);
|
|
2322
|
-
|
|
2323
|
-
// Same template structure: reconcile values, no detach/re-attach.
|
|
2324
|
-
if (isTemplate(inner) && currentInst.strings === /** @type any */ (inner).strings) {
|
|
2325
|
-
updateInstance(currentInst, /** @type any */ (inner).values);
|
|
2326
|
-
return;
|
|
2327
|
-
}
|
|
2328
|
-
|
|
2329
|
-
// Different shape: detach the current instance into a holder fragment
|
|
2330
|
-
// and store it in the cache map. We keep the existing instance, slot
|
|
2331
|
-
// markers, and rendered nodes; only the parent changes. moveRange's
|
|
2332
|
-
// null anchor means "append to parent".
|
|
2333
|
-
const holder = document.createDocumentFragment();
|
|
2334
|
-
moveRange(currentInst.startNode, currentInst.endNode, holder, null);
|
|
2335
|
-
cacheMap.set(currentInst.strings, { inst: currentInst, holder });
|
|
2336
|
-
part.child = undefined;
|
|
2337
|
-
}
|
|
2338
|
-
|
|
2339
|
-
// Now part.child is either undefined or some non-instance shape (rare;
|
|
2340
|
-
// happens when prior render had a string / array / etc.). For non-
|
|
2341
|
-
// instance shapes, fall through to the generic teardown via applyChild.
|
|
2342
|
-
|
|
2343
|
-
// If the new inner is a template AND we've cached an instance for its
|
|
2344
|
-
// strings, re-attach it.
|
|
2345
|
-
if (isTemplate(inner)) {
|
|
2346
|
-
const tr = /** @type any */ (inner);
|
|
2347
|
-
const cached = cacheMap.get(tr.strings);
|
|
2348
|
-
if (cached) {
|
|
2349
|
-
cacheMap.delete(tr.strings);
|
|
2350
|
-
// Tear down any non-instance child currently attached (a string /
|
|
2351
|
-
// array of text nodes from a prior cache(non-template) render).
|
|
2352
|
-
// Without this the prior nodes remain in the DOM alongside the
|
|
2353
|
-
// re-attached cached template.
|
|
2354
|
-
if (part.child) {
|
|
2355
|
-
teardownChild(part);
|
|
2356
|
-
}
|
|
2357
|
-
// Move the cached nodes back before the marker.
|
|
2358
|
-
moveRange(cached.inst.startNode, cached.inst.endNode, /** @type Node */ (marker.parentNode), marker);
|
|
2359
|
-
// Reconcile values so any state changes since detachment apply.
|
|
2360
|
-
updateInstance(cached.inst, tr.values);
|
|
2361
|
-
part.child = cached.inst;
|
|
2362
|
-
// A re-attached instance may carry ALREADY-APPLIED slot parts, whose
|
|
2363
|
-
// finalize will never fire again, while the host's record moved on
|
|
2364
|
-
// during the stash (content for these slots was parked when an apply
|
|
2365
|
-
// ran with the slot unreachable). Re-run the apply for each owning
|
|
2366
|
-
// host so parked content is pulled back out. The collection walks the
|
|
2367
|
-
// re-attached DOM RANGE (not the instance tree): slot parts live on
|
|
2368
|
-
// whatever template level contains the <slot> (nested holes, repeat /
|
|
2369
|
-
// array items, streamed chunks), so a structural DOM walk is the only
|
|
2370
|
-
// shape that covers every composition uniformly. moveRange already
|
|
2371
|
-
// ran, so the range is live and each slot's parent walk reaches its
|
|
2372
|
-
// host.
|
|
2373
|
-
const hosts = new Set();
|
|
2374
|
-
for (
|
|
2375
|
-
let n = cached.inst.startNode.nextSibling;
|
|
2376
|
-
n && n !== cached.inst.endNode;
|
|
2377
|
-
n = n.nextSibling
|
|
2378
|
-
) {
|
|
2379
|
-
if (n.nodeType !== 1) continue;
|
|
2380
|
-
const el = /** @type {Element} */ (n);
|
|
2381
|
-
if (el.matches('slot[data-webjs-light]')) {
|
|
2382
|
-
const h = findSlotHost(el);
|
|
2383
|
-
if (h) hosts.add(h);
|
|
2384
|
-
}
|
|
2385
|
-
for (const s of el.querySelectorAll('slot[data-webjs-light]')) {
|
|
2386
|
-
const h = findSlotHost(s);
|
|
2387
|
-
if (h) hosts.add(h);
|
|
2388
|
-
}
|
|
2389
|
-
}
|
|
2390
|
-
for (const h of hosts) applySlotAssignments(h);
|
|
2391
|
-
return;
|
|
2392
|
-
}
|
|
2393
|
-
}
|
|
2394
|
-
|
|
2395
|
-
// No cached instance available. Render the new inner value via the
|
|
2396
|
-
// standard applyChild path. The currentIsInstance branch already
|
|
2397
|
-
// handled detaching the prior instance; if part.child still holds a
|
|
2398
|
-
// non-instance shape, applyChild will tear it down generically.
|
|
2399
|
-
applyChildInner(part, inner);
|
|
2400
|
-
}
|
|
2401
|
-
|
|
2402
|
-
/* ================================================================
|
|
2403
|
-
* Until directive: render highest-priority resolved candidate.
|
|
2404
|
-
* ================================================================ */
|
|
2405
|
-
|
|
2406
|
-
/**
|
|
2407
|
-
* Apply the `until` directive at a child position.
|
|
2408
|
-
*
|
|
2409
|
-
* Priority is left-to-right: args[0] has the highest priority. The
|
|
2410
|
-
* highest-priority synchronous candidate (if any) renders immediately.
|
|
2411
|
-
* Strictly-higher-priority Promises are awaited in the background; when
|
|
2412
|
-
* one resolves AND no higher-priority Promise has already resolved, its
|
|
2413
|
-
* result becomes the rendered value.
|
|
2414
|
-
*
|
|
2415
|
-
* The directive's state lives on `part.__untilState` (a stable slot
|
|
2416
|
-
* that survives `applyChild`'s overwrites of `part.child`). When a new
|
|
2417
|
-
* render replaces the directive, the prior state's `aborted` flag flips
|
|
2418
|
-
* to `true` so any in-flight Promise resolutions short-circuit instead
|
|
2419
|
-
* of overwriting newer DOM.
|
|
2420
|
-
*
|
|
2421
|
-
* @param {Extract<BoundPart, {kind:'child'}>} part
|
|
2422
|
-
* @param {readonly unknown[]} args
|
|
2423
|
-
*/
|
|
2424
|
-
function applyUntil(part, args) {
|
|
2425
|
-
// Carry forward the prior render's `highestResolved` ONLY when the
|
|
2426
|
-
// args list is unchanged. When any argument identity changes, prior
|
|
2427
|
-
// priorities no longer apply (a Promise that won at index 0 may now
|
|
2428
|
-
// sit at a different index, or have been replaced entirely); the
|
|
2429
|
-
// state must reset to Infinity so the new args' Promises can compete.
|
|
2430
|
-
//
|
|
2431
|
-
// For TemplateResult args, compare by `strings` array identity rather
|
|
2432
|
-
// than the wrapper object identity. `html\`loading...\`` evaluates to
|
|
2433
|
-
// a fresh TemplateResult on every call but the strings array is
|
|
2434
|
-
// interned per call site, so the conceptual value is unchanged.
|
|
2435
|
-
const partAny = /** @type any */ (part);
|
|
2436
|
-
// Same as applyWatch: the promise handlers below commit with no render on
|
|
2437
|
-
// the stack, so the owning component is recorded here while it is knowable.
|
|
2438
|
-
if (currentRenderRoot) partAny.__commitOwner = boundaryOwnerOf(currentRenderRoot);
|
|
2439
|
-
const prevState = partAny.__untilState;
|
|
2440
|
-
const prevArgs = partAny.__untilArgs;
|
|
2441
|
-
const argEq = (a, b) => {
|
|
2442
|
-
if (Object.is(a, b)) return true;
|
|
2443
|
-
if (isTemplate(a) && isTemplate(b)
|
|
2444
|
-
&& /** @type any */ (a).strings === /** @type any */ (b).strings) return true;
|
|
2445
|
-
return false;
|
|
2446
|
-
};
|
|
2447
|
-
const argsEqual = prevArgs && prevArgs.length === args.length
|
|
2448
|
-
&& prevArgs.every((a, i) => argEq(a, args[i]));
|
|
2449
|
-
const carriedHighest = argsEqual && prevState ? prevState.highestResolved : Infinity;
|
|
2450
|
-
if (prevState) prevState.aborted = true;
|
|
2451
|
-
partAny.__untilArgs = args.slice();
|
|
2452
|
-
|
|
2453
|
-
/** @type {{aborted:boolean, highestResolved:number}} */
|
|
2454
|
-
const state = { aborted: false, highestResolved: carriedHighest };
|
|
2455
|
-
partAny.__untilState = state;
|
|
2456
|
-
|
|
2457
|
-
// Highest-priority synchronous candidate. If found, render it now
|
|
2458
|
-
// and cap further Promise subscription to strictly-higher priorities.
|
|
2459
|
-
let firstSyncIdx = -1;
|
|
2460
|
-
let firstSyncVal = undefined;
|
|
2461
|
-
for (let i = 0; i < args.length; i++) {
|
|
2462
|
-
const a = args[i];
|
|
2463
|
-
if (!a || typeof (/** @type any */ (a).then) !== 'function') {
|
|
2464
|
-
firstSyncIdx = i;
|
|
2465
|
-
firstSyncVal = a;
|
|
2466
|
-
break;
|
|
2467
|
-
}
|
|
2468
|
-
}
|
|
2469
|
-
|
|
2470
|
-
if (firstSyncIdx !== -1 && firstSyncIdx <= state.highestResolved) {
|
|
2471
|
-
// The sync candidate beats any previously-rendered Promise value
|
|
2472
|
-
// (when firstSyncIdx < state.highestResolved) OR re-renders the
|
|
2473
|
-
// sync fallback at the same priority slot (when ===), in case its
|
|
2474
|
-
// value changed between renders.
|
|
2475
|
-
applyChildInner(part, firstSyncVal);
|
|
2476
|
-
state.highestResolved = firstSyncIdx;
|
|
2477
|
-
} else if (firstSyncIdx === -1 && !partAny.__untilEverRendered) {
|
|
2478
|
-
// First-ever render of this part with all-Promise args: render
|
|
2479
|
-
// empty as the initial fallback while Promises settle.
|
|
2480
|
-
applyChildInner(part, '');
|
|
2481
|
-
}
|
|
2482
|
-
// Else: either there is no sync candidate but the part has rendered
|
|
2483
|
-
// before (preserve existing DOM until a Promise resolves), OR the
|
|
2484
|
-
// sync candidate is lower-priority than what's already rendered.
|
|
2485
|
-
// Either way: leave the existing DOM in place. This prevents the
|
|
2486
|
-
// "all-Promises wipes prior content" flash on re-renders.
|
|
2487
|
-
partAny.__untilEverRendered = true;
|
|
2488
|
-
|
|
2489
|
-
// Subscribe to Promises with priority strictly less than what's
|
|
2490
|
-
// currently rendered. (Lower index = higher priority in lit's model.)
|
|
2491
|
-
// Each subscription wraps in Promise.resolve() so synchronous
|
|
2492
|
-
// thenables get a microtask boundary, matching lit's contract that
|
|
2493
|
-
// all Promise/thenable resolutions are deferred.
|
|
2494
|
-
const cap = firstSyncIdx === -1
|
|
2495
|
-
? Math.min(args.length, state.highestResolved)
|
|
2496
|
-
: Math.min(firstSyncIdx, state.highestResolved);
|
|
2497
|
-
for (let i = 0; i < cap; i++) {
|
|
2498
|
-
const a = args[i];
|
|
2499
|
-
if (!a || typeof (/** @type any */ (a).then) !== 'function') continue;
|
|
2500
|
-
|
|
2501
|
-
Promise.resolve(/** @type Promise<unknown> */ (a)).then(
|
|
2502
|
-
(resolved) => {
|
|
2503
|
-
if (state.aborted) return;
|
|
2504
|
-
if (i >= state.highestResolved) return;
|
|
2505
|
-
// Commit FIRST, record the new priority only once it succeeded.
|
|
2506
|
-
// Advancing `highestResolved` up front meant a commit throw left the
|
|
2507
|
-
// region torn down while the state claimed index `i` had won, so
|
|
2508
|
-
// every later resolution at a lower priority was refused and the
|
|
2509
|
-
// region stayed empty. Committing first also keeps the throw inside
|
|
2510
|
-
// the try, where it can reach the component boundary.
|
|
2511
|
-
try {
|
|
2512
|
-
commitOutOfBand(part, () => applyChildInner(part, resolved));
|
|
2513
|
-
} catch (err) {
|
|
2514
|
-
reportOutOfBandCommitError(part, err);
|
|
2515
|
-
return;
|
|
2516
|
-
}
|
|
2517
|
-
state.highestResolved = i;
|
|
2518
|
-
},
|
|
2519
|
-
() => {
|
|
2520
|
-
// Swallow rejection. A rejected Promise is treated as "no value";
|
|
2521
|
-
// the existing render stays in place.
|
|
2522
|
-
},
|
|
2523
|
-
);
|
|
2524
|
-
}
|
|
2525
|
-
}
|
|
2526
|
-
|
|
2527
|
-
/**
|
|
2528
|
-
* Route a throw from an OUT-OF-BAND commit to the owning component's
|
|
2529
|
-
* render-error boundary.
|
|
2530
|
-
*
|
|
2531
|
-
* `watch`'s notify microtask and `until`'s Promise resolution commit from
|
|
2532
|
-
* outside `component.js`'s update cycle, so none of the boundaries that wrap
|
|
2533
|
-
* every other render path are on the stack. Left alone, a commit throw there
|
|
2534
|
-
* escapes as a window-level `error` / unhandled rejection instead of the
|
|
2535
|
-
* per-component `renderError()` the sync and async render paths both route
|
|
2536
|
-
* to, which breaks per-component error isolation for exactly these two
|
|
2537
|
-
* directives.
|
|
2538
|
-
*
|
|
2539
|
-
* Reads the owner stamped on the part when the directive was installed. See
|
|
2540
|
-
* the note in the body for why this is not a walk up the parent chain.
|
|
2541
|
-
*
|
|
2542
|
-
* @param {Extract<BoundPart, {kind:'child'}>} part
|
|
2543
|
-
* @param {unknown} error
|
|
2544
|
-
*/
|
|
2545
|
-
function reportOutOfBandCommitError(part, error) {
|
|
2546
|
-
let err;
|
|
2547
|
-
try {
|
|
2548
|
-
err = error instanceof Error ? error : new Error(String(error));
|
|
2549
|
-
} catch {
|
|
2550
|
-
// The thrown value's own `toString` threw. That is the exact class of
|
|
2551
|
-
// value that makes a commit throw in the first place, so it is reachable
|
|
2552
|
-
// here; do not let stringifying it mask the original failure.
|
|
2553
|
-
err = new Error('render error (unstringifiable thrown value)');
|
|
2554
|
-
}
|
|
2555
|
-
// The owner stamped when the directive was installed, which is the
|
|
2556
|
-
// component whose TEMPLATE holds this part. Never walk the parent chain
|
|
2557
|
-
// for this: `_handleRenderError` lives on WebComponent's prototype, so
|
|
2558
|
-
// every upgraded element on the way up carries one, and the FIRST one a
|
|
2559
|
-
// structural walk meets is the innermost element the part happens to sit
|
|
2560
|
-
// inside, not the template that owns it. Routing there is not merely the
|
|
2561
|
-
// wrong log line: a light-DOM component's renderError() commits into the
|
|
2562
|
-
// component itself, which would replace the very children holding this
|
|
2563
|
-
// part's markers and silently kill every later update through it.
|
|
2564
|
-
const owner = /** @type any */ (part).__commitOwner;
|
|
2565
|
-
if (owner && typeof owner._handleRenderError === 'function') {
|
|
2566
|
-
owner._handleRenderError(err);
|
|
2567
|
-
return;
|
|
2568
|
-
}
|
|
2569
|
-
// No component boundary owns this part (a bare `render()` into a plain
|
|
2570
|
-
// container). Nothing can contain the error, so surface it rather than
|
|
2571
|
-
// swallow it.
|
|
2572
|
-
throw err;
|
|
2573
|
-
}
|
|
2574
|
-
|
|
2575
|
-
/**
|
|
2576
|
-
* Abort an `until` directive's in-flight Promise tracking. Called from
|
|
2577
|
-
* `teardownChild` when the part is being reset.
|
|
2578
|
-
* @param {{aborted:boolean}} state
|
|
2579
|
-
*/
|
|
2580
|
-
function teardownUntil(state) {
|
|
2581
|
-
state.aborted = true;
|
|
2582
|
-
}
|
|
2583
|
-
|
|
2584
|
-
/* ================================================================
|
|
2585
|
-
* watch (signal binding): fine-grained reactive part.
|
|
2586
|
-
* ================================================================ */
|
|
2587
|
-
|
|
2588
|
-
/**
|
|
2589
|
-
* Bind a child part to a signal. Reads the signal once and writes its
|
|
2590
|
-
* value into the part. Installs a per-part `Signal.subtle.Watcher`
|
|
2591
|
-
* that, on signal change, re-reads and re-applies the value WITHOUT
|
|
2592
|
-
* re-running the host component's render(). When the part is torn
|
|
2593
|
-
* down (teardownChild) the watcher is disposed.
|
|
2594
|
-
*
|
|
2595
|
-
* The signal read happens inside the watcher's `observe()`, so the
|
|
2596
|
-
* dependency edge connects the signal to THIS watcher. The host's
|
|
2597
|
-
* own render watcher is outside the active stack here, so the host
|
|
2598
|
-
* does not also subscribe to the signal (which would double-fire as
|
|
2599
|
-
* both a full re-render and a watch update).
|
|
2600
|
-
*
|
|
2601
|
-
* @param {Extract<BoundPart, {kind:'child'}>} part
|
|
2602
|
-
* @param {{ get: () => unknown, __isSignal: true }} sig
|
|
2603
|
-
*/
|
|
2604
|
-
function applyWatch(part, sig) {
|
|
2605
|
-
const partAny = /** @type any */ (part);
|
|
2606
|
-
// Record the owning component while we are still inside its render(), the
|
|
2607
|
-
// only moment it is knowable. The notify microtask below commits with no
|
|
2608
|
-
// render on the stack and no way to derive it. Keep a prior stamp if this
|
|
2609
|
-
// somehow runs outside a render, rather than clearing a good owner.
|
|
2610
|
-
if (currentRenderRoot) partAny.__commitOwner = boundaryOwnerOf(currentRenderRoot);
|
|
2611
|
-
// Same signal as last render: refresh dep tracking via observe (the
|
|
2612
|
-
// spec-aligned Watcher fires once per arm, so re-observing re-arms).
|
|
2613
|
-
if (partAny.__watchSig === sig && partAny.__watchSub) {
|
|
2614
|
-
let value;
|
|
2615
|
-
partAny.__watchSub.observe(() => { value = sig.get(); });
|
|
2616
|
-
applyChildInner(part, value);
|
|
2617
|
-
return;
|
|
2618
|
-
}
|
|
2619
|
-
// Signal changed (or first render). Tear down any prior watcher.
|
|
2620
|
-
if (partAny.__watchSub) {
|
|
2621
|
-
partAny.__watchSub.dispose();
|
|
2622
|
-
partAny.__watchSub = undefined;
|
|
2623
|
-
}
|
|
2624
|
-
partAny.__watchSig = sig;
|
|
2625
|
-
// Notify defers to a microtask because the spec forbids signal reads
|
|
2626
|
-
// inside the notify itself. The microtask re-observes (re-arms + re-
|
|
2627
|
-
// records the dep) and applies the new value to the part.
|
|
2628
|
-
const watcher = new Signal.subtle.Watcher(() => {
|
|
2629
|
-
queueMicrotask(() => {
|
|
2630
|
-
if (partAny.__watchSub !== watcher) return; // disposed mid-flight
|
|
2631
|
-
let v;
|
|
2632
|
-
// Nothing above this microtask catches: it runs outside the host's
|
|
2633
|
-
// update cycle, so a commit throw here would surface at the window
|
|
2634
|
-
// instead of the component's renderError(). Route it explicitly.
|
|
2635
|
-
try {
|
|
2636
|
-
watcher.observe(() => { v = sig.get(); });
|
|
2637
|
-
commitOutOfBand(part, () => applyChildInner(part, v));
|
|
2638
|
-
} catch (err) {
|
|
2639
|
-
reportOutOfBandCommitError(part, err);
|
|
2640
|
-
}
|
|
2641
|
-
});
|
|
2642
|
-
});
|
|
2643
|
-
partAny.__watchSub = watcher;
|
|
2644
|
-
let initial;
|
|
2645
|
-
watcher.observe(() => { initial = sig.get(); });
|
|
2646
|
-
applyChildInner(part, initial);
|
|
2647
|
-
}
|
|
2648
|
-
|
|
2649
|
-
/**
|
|
2650
|
-
* Dispose a `watch` directive's per-part watcher. Called from
|
|
2651
|
-
* `teardownChild` and from `clearStaleDirectiveState` when the value
|
|
2652
|
-
* at the part is no longer a watch.
|
|
2653
|
-
* @param {any} partAny
|
|
2654
|
-
*/
|
|
2655
|
-
function teardownWatch(partAny) {
|
|
2656
|
-
if (partAny.__watchSub) {
|
|
2657
|
-
partAny.__watchSub.dispose();
|
|
2658
|
-
partAny.__watchSub = undefined;
|
|
2659
|
-
partAny.__watchSig = undefined;
|
|
2660
|
-
}
|
|
2661
|
-
}
|
|
2662
|
-
|
|
2663
|
-
/* ================================================================
|
|
2664
|
-
* asyncAppend / asyncReplace: stream from AsyncIterable.
|
|
2665
|
-
* ================================================================ */
|
|
2666
|
-
|
|
2667
|
-
/**
|
|
2668
|
-
* Apply `asyncAppend(iterable, mapper?)` at a child position.
|
|
2669
|
-
*
|
|
2670
|
-
* Iterates the AsyncIterable in the background. Each yielded value is
|
|
2671
|
-
* mapped (optional) and rendered as a node group, appended before the
|
|
2672
|
-
* marker. The state is stored on `part.child` so `teardownChild` can
|
|
2673
|
-
* abort the iteration when the part is reset.
|
|
2674
|
-
*
|
|
2675
|
-
* @param {Extract<BoundPart, {kind:'child'}>} part
|
|
2676
|
-
* @param {{ iterable: AsyncIterable<unknown>, mapper?: (v: unknown, i: number) => unknown }} dir
|
|
2677
|
-
*/
|
|
2678
|
-
function applyAsyncAppend(part, dir) {
|
|
2679
|
-
const partAny = /** @type any */ (part);
|
|
2680
|
-
// Record the owning component while we are still inside its render(), the
|
|
2681
|
-
// only moment it is knowable, exactly as `applyWatch` / `applyUntil` do.
|
|
2682
|
-
// Chunks commit from an async loop with no render on the stack, so without
|
|
2683
|
-
// this both the chunk's own commit throw and any directive nested inside a
|
|
2684
|
-
// chunk have no owner to route to. Stamped ABOVE the short-circuit so a
|
|
2685
|
-
// re-render that returns early still refreshes the owner, and guarded so a
|
|
2686
|
-
// re-install outside a render keeps a previously good one.
|
|
2687
|
-
if (currentRenderRoot) partAny.__commitOwner = boundaryOwnerOf(currentRenderRoot);
|
|
2688
|
-
// Same-iterable short-circuit: if the prior render's iterable identity
|
|
2689
|
-
// matches, the existing iterator is still consuming it. Re-subscribing
|
|
2690
|
-
// would start a fresh iterator that misses already-yielded values.
|
|
2691
|
-
// Matches lit-html's behavior.
|
|
2692
|
-
const currentChild = /** @type any */ (part.child);
|
|
2693
|
-
if (currentChild && currentChild.kind === 'async-stream'
|
|
2694
|
-
&& currentChild.mode === 'append'
|
|
2695
|
-
&& currentChild.iterable === dir.iterable) {
|
|
2696
|
-
return;
|
|
2697
|
-
}
|
|
2698
|
-
|
|
2699
|
-
teardownChild(part);
|
|
2700
|
-
|
|
2701
|
-
const iterator = /** @type AsyncIterator<unknown> */ (
|
|
2702
|
-
dir.iterable[Symbol.asyncIterator]()
|
|
2703
|
-
);
|
|
2704
|
-
/** @type {AsyncStreamState} */
|
|
2705
|
-
const state = {
|
|
2706
|
-
kind: 'async-stream',
|
|
2707
|
-
mode: 'append',
|
|
2708
|
-
aborted: false,
|
|
2709
|
-
iterable: dir.iterable,
|
|
2710
|
-
iterator,
|
|
2711
|
-
/** @type {ChildNode[]} */ nodes: [],
|
|
2712
|
-
};
|
|
2713
|
-
part.child = state;
|
|
2714
|
-
|
|
2715
|
-
consumeAsyncStream(state, part, dir);
|
|
2716
|
-
}
|
|
2717
|
-
|
|
2718
|
-
/**
|
|
2719
|
-
* Apply `asyncReplace(iterable, mapper?)` at a child position. Same as
|
|
2720
|
-
* `applyAsyncAppend` but each new value replaces the previous content.
|
|
2721
|
-
*
|
|
2722
|
-
* @param {Extract<BoundPart, {kind:'child'}>} part
|
|
2723
|
-
* @param {{ iterable: AsyncIterable<unknown>, mapper?: (v: unknown, i: number) => unknown }} dir
|
|
2724
|
-
*/
|
|
2725
|
-
function applyAsyncReplace(part, dir) {
|
|
2726
|
-
const partAny = /** @type any */ (part);
|
|
2727
|
-
// Owner stamp: see comment in applyAsyncAppend. Above the short-circuit.
|
|
2728
|
-
if (currentRenderRoot) partAny.__commitOwner = boundaryOwnerOf(currentRenderRoot);
|
|
2729
|
-
// Same-iterable short-circuit: see comment in applyAsyncAppend.
|
|
2730
|
-
const currentChild = /** @type any */ (part.child);
|
|
2731
|
-
if (currentChild && currentChild.kind === 'async-stream'
|
|
2732
|
-
&& currentChild.mode === 'replace'
|
|
2733
|
-
&& currentChild.iterable === dir.iterable) {
|
|
2734
|
-
return;
|
|
2735
|
-
}
|
|
2736
|
-
|
|
2737
|
-
teardownChild(part);
|
|
2738
|
-
|
|
2739
|
-
const iterator = /** @type AsyncIterator<unknown> */ (
|
|
2740
|
-
dir.iterable[Symbol.asyncIterator]()
|
|
2741
|
-
);
|
|
2742
|
-
/** @type {AsyncStreamState} */
|
|
2743
|
-
const state = {
|
|
2744
|
-
kind: 'async-stream',
|
|
2745
|
-
mode: 'replace',
|
|
2746
|
-
aborted: false,
|
|
2747
|
-
iterable: dir.iterable,
|
|
2748
|
-
iterator,
|
|
2749
|
-
/** @type {ChildNode[]} */ nodes: [],
|
|
2750
|
-
};
|
|
2751
|
-
part.child = state;
|
|
2752
|
-
|
|
2753
|
-
consumeAsyncStream(state, part, dir);
|
|
2754
|
-
}
|
|
2755
|
-
|
|
2756
|
-
/**
|
|
2757
|
-
* @typedef {{
|
|
2758
|
-
* kind: 'async-stream',
|
|
2759
|
-
* mode: 'append' | 'replace',
|
|
2760
|
-
* aborted: boolean,
|
|
2761
|
-
* iterable: AsyncIterable<unknown>,
|
|
2762
|
-
* iterator: AsyncIterator<unknown>,
|
|
2763
|
-
* nodes: ChildNode[],
|
|
2764
|
-
* }} AsyncStreamState
|
|
2765
|
-
*/
|
|
2766
|
-
|
|
2767
|
-
/**
|
|
2768
|
-
* Consume an AsyncIterable for `asyncAppend` / `asyncReplace`. Drives
|
|
2769
|
-
* the iterator with an explicit `.next()` loop (rather than `for await`)
|
|
2770
|
-
* so that `teardownAsyncStream` can call `iterator.return()` to break
|
|
2771
|
-
* a generator parked on an `await`. The `aborted` flag is also checked
|
|
2772
|
-
* after every `next()` resolve to short-circuit if abortion happened
|
|
2773
|
-
* while the iterator was suspended.
|
|
2774
|
-
*
|
|
2775
|
-
* Each pass carries TWO try spans, and which failure lands in which is the
|
|
2776
|
-
* load-bearing part. SPAN A is the author's own code, the iterable AND the
|
|
2777
|
-
* `mapper` it was given, and a throw from either is logged to the console and
|
|
2778
|
-
* ends the stream, on the long-standing reasoning that an author's iterable
|
|
2779
|
-
* should handle its own errors. SPAN B is the chunk COMMIT, which is a render
|
|
2780
|
-
* failure of the component whose template holds the binding, so it routes to
|
|
2781
|
-
* that component's `renderError()` and stops the stream.
|
|
2782
|
-
*
|
|
2783
|
-
* Scope note: only a throw from the COMMIT can stop the stream from here. A
|
|
2784
|
-
* directive nested INSIDE a committed chunk (a `watch` whose signal changes
|
|
2785
|
-
* later) throws from its own handler, outside this loop entirely, so it
|
|
2786
|
-
* reaches the boundary but this loop knows nothing about it and keeps
|
|
2787
|
-
* pulling. That is the same for any directive nested anywhere else. lit is no authority
|
|
2788
|
-
* either way here (it has no per-component boundary, and both failures become
|
|
2789
|
-
* unhandled rejections at the window), so this follows the
|
|
2790
|
-
* per-component error isolation WebJs has instead.
|
|
2791
|
-
*
|
|
2792
|
-
* @param {AsyncStreamState} state
|
|
2793
|
-
* @param {Extract<BoundPart, {kind:'child'}>} part
|
|
2794
|
-
* @param {{ iterable: AsyncIterable<unknown>, mapper?: (v: unknown, i: number) => unknown }} dir
|
|
2795
|
-
*/
|
|
2796
|
-
async function consumeAsyncStream(state, part, dir) {
|
|
2797
|
-
const marker = part.marker;
|
|
2798
|
-
let i = 0;
|
|
2799
|
-
while (!state.aborted) {
|
|
2800
|
-
/** @type {IteratorResult<unknown>} */
|
|
2801
|
-
let result;
|
|
2802
|
-
/** @type {unknown} */
|
|
2803
|
-
let mapped;
|
|
2804
|
-
// SPAN A, the author's iterable. A throw here is the author's generator
|
|
2805
|
-
// failing, not a render, so it keeps the long-standing console.error and
|
|
2806
|
-
// ends the stream. It is a separate span from the commit below on purpose
|
|
2807
|
-
// rather than a flag the one catch inspects, because the distinction is
|
|
2808
|
-
// the whole point: `reportOutOfBandCommitError` RETHROWS for a part with
|
|
2809
|
-
// no owner, and a single enclosing try would hand that rethrow straight
|
|
2810
|
-
// back to this swallow, which is the escape this split exists to stop.
|
|
2811
|
-
try {
|
|
2812
|
-
result = await state.iterator.next();
|
|
2813
|
-
if (state.aborted) break;
|
|
2814
|
-
if (result.done) break;
|
|
2815
|
-
mapped = dir.mapper ? dir.mapper(result.value, i) : result.value;
|
|
2816
|
-
} catch (err) {
|
|
2817
|
-
// Note this ENDS the stream, and always has: the catch used to sit
|
|
2818
|
-
// outside the loop, so there has never been a resume path here.
|
|
2819
|
-
if (typeof console !== 'undefined') console.error('[webjs] asyncStream error:', err);
|
|
2820
|
-
return;
|
|
2821
|
-
}
|
|
2822
|
-
|
|
2823
|
-
// SPAN B, the chunk commit. This is a render of the component whose
|
|
2824
|
-
// TEMPLATE holds the binding, so a throw is that component's render
|
|
2825
|
-
// failure and routes to its `renderError()`, the same as `watch` and
|
|
2826
|
-
// `until` already do from their own out-of-band commits. `renderToNodes`
|
|
2827
|
-
// is INSIDE the wrap because that is where a nested directive is
|
|
2828
|
-
// installed and reads `currentRenderRoot`; without it, a `watch()` inside
|
|
2829
|
-
// a chunk is stamped with no owner and its later throw escapes.
|
|
2830
|
-
// `commitInto` is a different concern (the renderer-write window for a
|
|
2831
|
-
// light slot host), so the two nest rather than replace each other.
|
|
2832
|
-
try {
|
|
2833
|
-
commitOutOfBand(part, () => {
|
|
2834
|
-
const newNodes = renderToNodes(mapped);
|
|
2835
|
-
|
|
2836
|
-
// This chunk commit runs in an async loop OUTSIDE any render() window,
|
|
2837
|
-
// so open the renderer-write window explicitly: without it, committing a
|
|
2838
|
-
// stream chunk into a light slot host would hit the patched insertBefore /
|
|
2839
|
-
// removeChild and fold the renderer's own output into `authored`.
|
|
2840
|
-
commitInto(marker.parentNode, () => {
|
|
2841
|
-
if (state.mode === 'replace') {
|
|
2842
|
-
for (const n of state.nodes) {
|
|
2843
|
-
if (n.parentNode) n.parentNode.removeChild(n);
|
|
2844
|
-
}
|
|
2845
|
-
state.nodes = [];
|
|
2846
|
-
}
|
|
2847
|
-
|
|
2848
|
-
const frag = document.createDocumentFragment();
|
|
2849
|
-
for (const n of newNodes) frag.appendChild(n);
|
|
2850
|
-
marker.parentNode?.insertBefore(frag, marker);
|
|
2851
|
-
state.nodes.push(...newNodes);
|
|
2852
|
-
});
|
|
2853
|
-
});
|
|
2854
|
-
} catch (err) {
|
|
2855
|
-
// Stop the stream. The boundary is about to render an error state, and
|
|
2856
|
-
// appending later chunks into a region it may have replaced is not a
|
|
2857
|
-
// recovery. The rendered nodes are left alone: blanking the region is a
|
|
2858
|
-
// separate decision, and `teardownAsyncStream` is for the part being
|
|
2859
|
-
// reset, not for this.
|
|
2860
|
-
state.aborted = true;
|
|
2861
|
-
try { state.iterator.return?.()?.catch?.(() => {}); } catch { /* best effort */ }
|
|
2862
|
-
// Rethrows when nothing can receive the error, which for a bare
|
|
2863
|
-
// `render()` into a plain container is an owner that carries no
|
|
2864
|
-
// `_handleRenderError` (the stamp records the container itself, so the
|
|
2865
|
-
// owner is present, just not a component). Surfacing beats swallowing
|
|
2866
|
-
// there, and it matches `watch` and `until`, which rethrow from their
|
|
2867
|
-
// own out-of-band handlers for the same reason. The exact shape differs
|
|
2868
|
-
// by site rather than being one thing: this rejects the loop's
|
|
2869
|
-
// promise, `until` rejects from its `.then`, and `watch` throws inside
|
|
2870
|
-
// a `queueMicrotask`, which is an uncaught error rather than a
|
|
2871
|
-
// rejection.
|
|
2872
|
-
reportOutOfBandCommitError(part, err);
|
|
2873
|
-
return;
|
|
2874
|
-
}
|
|
2875
|
-
|
|
2876
|
-
i++;
|
|
2877
|
-
}
|
|
2878
|
-
}
|
|
2879
|
-
|
|
2880
|
-
/**
|
|
2881
|
-
* Render a single value into a flat list of DOM nodes for insertion via
|
|
2882
|
-
* insertBefore. Handles strings, numbers, TemplateResult, and arrays.
|
|
2883
|
-
* @param {unknown} value
|
|
2884
|
-
* @returns {ChildNode[]}
|
|
2885
|
-
*/
|
|
2886
|
-
function renderToNodes(value) {
|
|
2887
|
-
if (value == null || value === false || value === true) return [];
|
|
2888
|
-
if (isTemplate(value)) {
|
|
2889
|
-
const tr = /** @type any */ (value);
|
|
2890
|
-
const { templateEl, parts, formActions } = compile(tr);
|
|
2891
|
-
const frag = /** @type DocumentFragment */ (templateEl.content.cloneNode(true));
|
|
2892
|
-
const bound = parts.map((p) => bindPart(p, frag));
|
|
2893
|
-
for (let i = 0; i < tr.values.length; i++) {
|
|
2894
|
-
applyPart(bound[i], tr.values[i], undefined, tr.values);
|
|
2895
|
-
}
|
|
2896
|
-
reconcileFormActions(formActions, bound, tr.values);
|
|
2897
|
-
// Slot parts need their one-shot apply here too (same contract as
|
|
2898
|
-
// createInstance / nested templates / buildDetached): the caller
|
|
2899
|
-
// (consumeAsyncStream) inserts these nodes synchronously in the same
|
|
2900
|
-
// task, so the slot-part's one-microtask finalize retry lands in the
|
|
2901
|
-
// live tree. Without this, a <slot> inside streamed chunk content never
|
|
2902
|
-
// finalizes and its name suppresses parking forever.
|
|
2903
|
-
for (const p of bound) {
|
|
2904
|
-
if (p.kind === 'slot') applyPart(p, undefined, undefined, []);
|
|
2905
|
-
}
|
|
2906
|
-
return [...frag.childNodes];
|
|
2907
|
-
}
|
|
2908
|
-
if (Array.isArray(value)) {
|
|
2909
|
-
const nodes = [];
|
|
2910
|
-
for (const v of value) nodes.push(...renderToNodes(v));
|
|
2911
|
-
return nodes;
|
|
2912
|
-
}
|
|
2913
|
-
return [document.createTextNode(String(value))];
|
|
2914
|
-
}
|
|
2915
|
-
|
|
2916
|
-
/**
|
|
2917
|
-
* Abort an async-stream directive. Sets `aborted = true` (so the next
|
|
2918
|
-
* `await iterator.next()` resolution short-circuits), removes all nodes
|
|
2919
|
-
* rendered so far, and explicitly calls `iterator.return()` so a
|
|
2920
|
-
* generator parked on `await` can unwind via its `finally` blocks
|
|
2921
|
-
* instead of leaking.
|
|
2922
|
-
* @param {AsyncStreamState} state
|
|
2923
|
-
*/
|
|
2924
|
-
function teardownAsyncStream(state) {
|
|
2925
|
-
state.aborted = true;
|
|
2926
|
-
for (const n of state.nodes) {
|
|
2927
|
-
if (n.parentNode) n.parentNode.removeChild(n);
|
|
2928
|
-
}
|
|
2929
|
-
state.nodes = [];
|
|
2930
|
-
// Best-effort iterator cleanup. `.return()` is optional on AsyncIterators;
|
|
2931
|
-
// generators built via `async function*` provide it and run their
|
|
2932
|
-
// `finally` blocks. Swallow any rejection so teardown can't throw.
|
|
2933
|
-
try {
|
|
2934
|
-
state.iterator.return?.()?.catch?.(() => {});
|
|
2935
|
-
} catch {
|
|
2936
|
-
// ignore
|
|
2937
|
-
}
|
|
2938
|
-
}
|
|
1
|
+
export { render } from './render-client/reconciler.js';
|