@webjsdev/core 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -0
- package/index.d.ts +28 -0
- package/index.js +39 -0
- package/package.json +46 -0
- package/src/component.d.ts +120 -0
- package/src/component.js +1061 -0
- package/src/context.js +327 -0
- package/src/css.js +54 -0
- package/src/directives.js +454 -0
- package/src/escape.js +15 -0
- package/src/expose.js +90 -0
- package/src/html.js +30 -0
- package/src/lazy-loader.js +107 -0
- package/src/nav.js +45 -0
- package/src/registry.js +190 -0
- package/src/render-client.js +1717 -0
- package/src/render-server.js +1128 -0
- package/src/repeat.js +37 -0
- package/src/rich-fetch.js +70 -0
- package/src/router-client.js +1434 -0
- package/src/serialize.js +452 -0
- package/src/signal.js +684 -0
- package/src/slot.js +860 -0
- package/src/suspense.js +46 -0
- package/src/task.js +386 -0
- package/src/testing.js +157 -0
- package/src/webjs-frame.js +65 -0
- package/src/websocket-client.js +111 -0
|
@@ -0,0 +1,1717 @@
|
|
|
1
|
+
import { isTemplate, MARKER } from './html.js';
|
|
2
|
+
import { escapeAttr } from './escape.js';
|
|
3
|
+
import { isRepeat } from './repeat.js';
|
|
4
|
+
import { isUnsafeHTML, isLive, isKeyed, isGuard, isTemplateContent, isRef, isCache, isUntil, isAsyncAppend, isAsyncReplace, isWatch } from './directives.js';
|
|
5
|
+
import { Signal } from './signal.js';
|
|
6
|
+
import {
|
|
7
|
+
LIGHT_SLOT_ATTR,
|
|
8
|
+
PROJECTION_ATTR,
|
|
9
|
+
PROJECTION_FALLBACK,
|
|
10
|
+
SLOT_FALLBACK_FRAG,
|
|
11
|
+
SLOT_STATE,
|
|
12
|
+
scheduleProjection,
|
|
13
|
+
moveSlotChildrenToPending,
|
|
14
|
+
ensureSlotState,
|
|
15
|
+
} from './slot.js';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Client-side renderer with **fine-grained** updates.
|
|
19
|
+
*
|
|
20
|
+
* Each TemplateResult is compiled once (keyed by the tagged-template's
|
|
21
|
+
* `strings` array identity, so reuse is free across renders) into:
|
|
22
|
+
* - a `<template>` element with static HTML + marker comments/attributes
|
|
23
|
+
* at each dynamic hole
|
|
24
|
+
* - a list of `Part` descriptors (kind + DOM location + attr/event name)
|
|
25
|
+
*
|
|
26
|
+
* On first render the template is cloned into the container and each Part
|
|
27
|
+
* is bound to the freshly-created node. Subsequent renders compare the new
|
|
28
|
+
* values to the last-applied values and only touch parts that changed.
|
|
29
|
+
* Text-position holes containing nested TemplateResults reuse the existing
|
|
30
|
+
* child instance when the inner `strings` match; they only rebuild when the
|
|
31
|
+
* template shape changes.
|
|
32
|
+
*
|
|
33
|
+
* Consequences worth knowing:
|
|
34
|
+
* - Input focus, cursor position, selection, and scroll inside components
|
|
35
|
+
* survive re-renders triggered by property assignments, signal changes,
|
|
36
|
+
* or `requestUpdate()`.
|
|
37
|
+
* - Event listeners are attached once and retargeted when the handler
|
|
38
|
+
* reference changes (swap-in-place via a dispatch closure, so `addEventListener`
|
|
39
|
+
* isn't churned every render).
|
|
40
|
+
* - This is a conservative implementation; no keyed list diffing yet: array
|
|
41
|
+
* changes rebuild the whole text part.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/** @type {WeakMap<TemplateStringsArray | string[], { templateEl: HTMLTemplateElement, parts: PartDescriptor[] }>} */
|
|
45
|
+
const templateCache = new WeakMap();
|
|
46
|
+
const INSTANCE = Symbol.for('webjs.instance');
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @typedef {{
|
|
50
|
+
* kind: 'child' | 'attr' | 'attr-mixed' | 'event' | 'prop' | 'bool' | 'element' | 'slot' | 'noop',
|
|
51
|
+
* path: number[],
|
|
52
|
+
* name?: string,
|
|
53
|
+
* statics?: string[],
|
|
54
|
+
* group?: number[],
|
|
55
|
+
* }} PartDescriptor
|
|
56
|
+
*
|
|
57
|
+
* @typedef {{
|
|
58
|
+
* strings: TemplateStringsArray | string[],
|
|
59
|
+
* bound: BoundPart[],
|
|
60
|
+
* lastValues: unknown[],
|
|
61
|
+
* startNode: Comment,
|
|
62
|
+
* endNode: Comment,
|
|
63
|
+
* }} TemplateInstance
|
|
64
|
+
*
|
|
65
|
+
* @typedef {
|
|
66
|
+
* | { kind: 'child', marker: Comment, child?: TemplateInstance | ChildNode[] }
|
|
67
|
+
* | { kind: 'attr', el: Element, name: string }
|
|
68
|
+
* | { kind: 'attr-mixed', el: Element, name: string, statics: string[], group: number[] }
|
|
69
|
+
* | { kind: 'event', el: Element, name: string, handler: ((e: Event) => void) | null, dispatcher: (e: Event) => void }
|
|
70
|
+
* | { kind: 'prop', el: Element, name: string }
|
|
71
|
+
* | { kind: 'bool', el: Element, name: string }
|
|
72
|
+
* | { kind: 'element', el: Element, lastTarget?: any }
|
|
73
|
+
* | { kind: 'slot', slotEl: HTMLSlotElement, applied: boolean }
|
|
74
|
+
* | { kind: 'noop' }
|
|
75
|
+
* } BoundPart
|
|
76
|
+
*/
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Render a value into a container, reusing DOM where possible.
|
|
80
|
+
*
|
|
81
|
+
* @param {unknown} value
|
|
82
|
+
* @param {Element | DocumentFragment | ShadowRoot} container
|
|
83
|
+
*/
|
|
84
|
+
export function render(value, container) {
|
|
85
|
+
const host = /** @type any */ (container);
|
|
86
|
+
const prev = host[INSTANCE];
|
|
87
|
+
|
|
88
|
+
if (isTemplate(value)) {
|
|
89
|
+
const tr = /** @type {import('./html.js').TemplateResult} */ (value);
|
|
90
|
+
if (prev && prev.strings === tr.strings) {
|
|
91
|
+
updateInstance(prev, tr.values);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (prev) clearInstance(prev, container);
|
|
95
|
+
|
|
96
|
+
// Light DOM hydration: if container has SSR content (marked by
|
|
97
|
+
// <!--webjs-hydrate-->), remove the marker and proceed with normal
|
|
98
|
+
// rendering. The content will be replaced with identical output -
|
|
99
|
+
// no visible flash because SSR and client render produce the same HTML.
|
|
100
|
+
const firstChild = container.firstChild;
|
|
101
|
+
if (firstChild && firstChild.nodeType === 8 && /** @type {Comment} */ (firstChild).data === 'webjs-hydrate') {
|
|
102
|
+
firstChild.remove();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const inst = createInstance(tr, container);
|
|
106
|
+
host[INSTANCE] = inst;
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Non-template value: treat as a single text child.
|
|
111
|
+
if (prev) clearInstance(prev, container);
|
|
112
|
+
host[INSTANCE] = null;
|
|
113
|
+
container.replaceChildren();
|
|
114
|
+
if (value == null || value === false || value === true) return;
|
|
115
|
+
if (Array.isArray(value)) {
|
|
116
|
+
for (const v of value) {
|
|
117
|
+
const text = document.createTextNode(String(v ?? ''));
|
|
118
|
+
container.appendChild(text);
|
|
119
|
+
}
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
container.appendChild(document.createTextNode(String(value)));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/* ================================================================
|
|
126
|
+
* Template compilation
|
|
127
|
+
* ================================================================ */
|
|
128
|
+
|
|
129
|
+
/** @param {import('./html.js').TemplateResult} tr */
|
|
130
|
+
function compile(tr) {
|
|
131
|
+
const { strings } = tr;
|
|
132
|
+
let cached = templateCache.get(strings);
|
|
133
|
+
if (cached) return cached;
|
|
134
|
+
|
|
135
|
+
/** @type {PartDescriptor[]} */
|
|
136
|
+
const parts = [];
|
|
137
|
+
let html = '';
|
|
138
|
+
let state = 'text';
|
|
139
|
+
let attrName = '';
|
|
140
|
+
let attrStart = 0;
|
|
141
|
+
let attrQuote = '';
|
|
142
|
+
let commentDashes = 0;
|
|
143
|
+
/** @type {{ name: string, firstPartIdx: number } | null} */
|
|
144
|
+
let mixedAttr = null;
|
|
145
|
+
let currentTag = '';
|
|
146
|
+
let rawTail = '';
|
|
147
|
+
|
|
148
|
+
for (let i = 0; i < strings.length; i++) {
|
|
149
|
+
const s = strings[i];
|
|
150
|
+
for (let j = 0; j < s.length; j++) {
|
|
151
|
+
const c = s[j];
|
|
152
|
+
switch (state) {
|
|
153
|
+
case 'text':
|
|
154
|
+
html += c;
|
|
155
|
+
if (c === '<') state = 'tag-open';
|
|
156
|
+
break;
|
|
157
|
+
case 'tag-open':
|
|
158
|
+
html += c;
|
|
159
|
+
if (c === '!') state = 'bang-1';
|
|
160
|
+
else if (c === '/') { state = 'tag-name'; currentTag = ''; }
|
|
161
|
+
else if (/[a-zA-Z]/.test(c)) { state = 'tag-name'; currentTag = c.toLowerCase(); }
|
|
162
|
+
else state = 'text';
|
|
163
|
+
break;
|
|
164
|
+
case 'bang-1':
|
|
165
|
+
html += c;
|
|
166
|
+
state = c === '-' ? 'bang-dash' : 'tag-name';
|
|
167
|
+
break;
|
|
168
|
+
case 'bang-dash':
|
|
169
|
+
html += c;
|
|
170
|
+
if (c === '-') { state = 'comment'; commentDashes = 0; }
|
|
171
|
+
else state = 'tag-name';
|
|
172
|
+
break;
|
|
173
|
+
case 'comment':
|
|
174
|
+
html += c;
|
|
175
|
+
if (c === '-') commentDashes += 1;
|
|
176
|
+
else if (c === '>' && commentDashes >= 2) { state = 'text'; commentDashes = 0; }
|
|
177
|
+
else commentDashes = 0;
|
|
178
|
+
break;
|
|
179
|
+
case 'tag-name':
|
|
180
|
+
html += c;
|
|
181
|
+
if (c === '>') {
|
|
182
|
+
state = (currentTag === 'script' || currentTag === 'style') ? 'rawtext' : 'text';
|
|
183
|
+
if (state === 'rawtext') rawTail = '';
|
|
184
|
+
} else if (/\s/.test(c)) state = 'in-tag';
|
|
185
|
+
else currentTag += c.toLowerCase();
|
|
186
|
+
break;
|
|
187
|
+
case 'in-tag':
|
|
188
|
+
html += c;
|
|
189
|
+
if (c === '>') {
|
|
190
|
+
state = (currentTag === 'script' || currentTag === 'style') ? 'rawtext' : 'text';
|
|
191
|
+
if (state === 'rawtext') rawTail = '';
|
|
192
|
+
} else if (!/\s/.test(c) && c !== '/') {
|
|
193
|
+
state = 'attr-name';
|
|
194
|
+
attrName = c;
|
|
195
|
+
attrStart = html.length - 1;
|
|
196
|
+
}
|
|
197
|
+
break;
|
|
198
|
+
case 'rawtext':
|
|
199
|
+
html += c;
|
|
200
|
+
rawTail = (rawTail + c.toLowerCase()).slice(-9);
|
|
201
|
+
if (rawTail.endsWith('</script>') || rawTail.endsWith('</style>')) {
|
|
202
|
+
state = 'text';
|
|
203
|
+
rawTail = '';
|
|
204
|
+
currentTag = '';
|
|
205
|
+
}
|
|
206
|
+
break;
|
|
207
|
+
case 'attr-name':
|
|
208
|
+
if (c === '=') { state = 'after-eq'; html += c; }
|
|
209
|
+
else if (/\s/.test(c)) { state = 'in-tag'; attrName = ''; html += c; }
|
|
210
|
+
else if (c === '>') { state = 'text'; attrName = ''; html += c; }
|
|
211
|
+
else { attrName += c; html += c; }
|
|
212
|
+
break;
|
|
213
|
+
case 'after-eq':
|
|
214
|
+
if (c === '"' || c === "'") { state = 'attr-quoted'; attrQuote = c; html += c; }
|
|
215
|
+
else if (/\s/.test(c)) { state = 'in-tag'; attrName = ''; html += c; }
|
|
216
|
+
else if (c === '>') { state = 'text'; attrName = ''; html += c; }
|
|
217
|
+
else { state = 'attr-unquoted'; html += c; }
|
|
218
|
+
break;
|
|
219
|
+
case 'attr-unquoted':
|
|
220
|
+
if (/\s/.test(c)) { state = 'in-tag'; attrName = ''; html += c; }
|
|
221
|
+
else if (c === '>') { state = 'text'; attrName = ''; html += c; }
|
|
222
|
+
else html += c;
|
|
223
|
+
break;
|
|
224
|
+
case 'attr-quoted':
|
|
225
|
+
html += c;
|
|
226
|
+
if (c === attrQuote) { state = 'in-tag'; attrName = ''; }
|
|
227
|
+
break;
|
|
228
|
+
case 'skip-attr':
|
|
229
|
+
// Consume mixed-attribute chars without appending to html.
|
|
230
|
+
// The attribute was replaced with a sentinel on the first hole.
|
|
231
|
+
if (c === attrQuote) {
|
|
232
|
+
// Closing quote: finalize the attr-mixed part.
|
|
233
|
+
if (mixedAttr) {
|
|
234
|
+
const idx0 = mixedAttr.firstPartIdx;
|
|
235
|
+
const group = [];
|
|
236
|
+
for (let k = idx0; k < parts.length; k++) {
|
|
237
|
+
if (parts[k].kind === 'noop' || parts[k].kind === 'attr-mixed') group.push(k);
|
|
238
|
+
}
|
|
239
|
+
// Build statics from the template strings array.
|
|
240
|
+
// For `attr="a ${x} b ${y} c"`, group=[idx0,idx1].
|
|
241
|
+
// statics[0] = tail of strings[idx0] after the `="`
|
|
242
|
+
// statics[1] = strings[idx1] (between holes)
|
|
243
|
+
// statics[n] = prefix of strings[last+1] up to closing quote
|
|
244
|
+
const statics = [];
|
|
245
|
+
const s0 = strings[group[0]];
|
|
246
|
+
const qp = s0.lastIndexOf(attrQuote);
|
|
247
|
+
statics.push(qp >= 0 ? s0.slice(qp + 1) : s0);
|
|
248
|
+
for (let k = 1; k < group.length; k++) {
|
|
249
|
+
statics.push(strings[group[k]]);
|
|
250
|
+
}
|
|
251
|
+
const sLast = strings[group[group.length - 1] + 1];
|
|
252
|
+
const eq = sLast.indexOf(attrQuote);
|
|
253
|
+
statics.push(eq >= 0 ? sLast.slice(0, eq) : sLast);
|
|
254
|
+
|
|
255
|
+
parts[idx0] = {
|
|
256
|
+
kind: 'attr-mixed',
|
|
257
|
+
path: [],
|
|
258
|
+
name: mixedAttr.name,
|
|
259
|
+
statics,
|
|
260
|
+
group,
|
|
261
|
+
};
|
|
262
|
+
mixedAttr = null;
|
|
263
|
+
}
|
|
264
|
+
state = 'in-tag';
|
|
265
|
+
attrName = '';
|
|
266
|
+
}
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (i < strings.length - 1) {
|
|
272
|
+
const partIdx = parts.length;
|
|
273
|
+
if (state === 'comment') {
|
|
274
|
+
// Holes inside <!-- ... --> are dropped. Comments are inert and
|
|
275
|
+
// the compile cache is keyed on `strings`, so per-render values
|
|
276
|
+
// can't be baked in anyway.
|
|
277
|
+
commentDashes = 0;
|
|
278
|
+
parts.push({ kind: 'noop', path: [] });
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
if (state === 'rawtext') {
|
|
282
|
+
// Inside <script>/<style>: per-render interpolation isn't supported;
|
|
283
|
+
// the compile cache would lock in whatever was first rendered. The
|
|
284
|
+
// hole is dropped and authors should set body text via a child part
|
|
285
|
+
// outside the raw-text container, or inline style/script directly.
|
|
286
|
+
rawTail = '';
|
|
287
|
+
parts.push({ kind: 'noop', path: [] });
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
if (state === 'text') {
|
|
291
|
+
// Child hole: insert a comment marker. Use bracketed markers so we can
|
|
292
|
+
// later walk all comments and find ours without ambiguity.
|
|
293
|
+
html += `<!--${MARKER}${partIdx}-->`;
|
|
294
|
+
parts.push({ kind: 'child', path: [] });
|
|
295
|
+
} else if (state === 'in-tag') {
|
|
296
|
+
// Element-position hole: `<tag ${expr}>`. Used by the `ref` directive
|
|
297
|
+
// (and any future element-bound directive). Emit a sentinel attribute
|
|
298
|
+
// on the current open tag; at bind time the attribute is stripped
|
|
299
|
+
// and the element is captured into the part.
|
|
300
|
+
const sentinel = `data-${MARKER}${partIdx}`;
|
|
301
|
+
html += `${sentinel}=""`;
|
|
302
|
+
parts.push({ kind: 'element', path: [] });
|
|
303
|
+
} else if (state === 'after-eq') {
|
|
304
|
+
const prefix = attrName[0];
|
|
305
|
+
const name = attrName.slice(1);
|
|
306
|
+
if (prefix === '@' || prefix === '.' || prefix === '?') {
|
|
307
|
+
// Strip the attribute name+"=" from html and add a sentinel attr.
|
|
308
|
+
html = html.slice(0, attrStart);
|
|
309
|
+
const kind = prefix === '@' ? 'event' : prefix === '.' ? 'prop' : 'bool';
|
|
310
|
+
const sentinel = `data-${MARKER}${partIdx}`;
|
|
311
|
+
html += `${sentinel}=""`;
|
|
312
|
+
parts.push({ kind, path: [], name });
|
|
313
|
+
} else {
|
|
314
|
+
// Regular attribute: rewrite to `attrName="__MARKER__"` and parse as attr.
|
|
315
|
+
html = html.slice(0, attrStart);
|
|
316
|
+
const sentinel = `data-${MARKER}${partIdx}`;
|
|
317
|
+
html += `${sentinel}=""`;
|
|
318
|
+
parts.push({ kind: 'attr', path: [], name: attrName });
|
|
319
|
+
}
|
|
320
|
+
state = 'in-tag';
|
|
321
|
+
attrName = '';
|
|
322
|
+
} else if (state === 'attr-quoted' || state === 'attr-unquoted') {
|
|
323
|
+
// First hole inside a quoted attribute value: start mixed-attr tracking.
|
|
324
|
+
// Replace the entire attribute with a sentinel (same as regular attr).
|
|
325
|
+
html = html.slice(0, attrStart);
|
|
326
|
+
const sentinel = `data-${MARKER}${partIdx}`;
|
|
327
|
+
html += `${sentinel}=""`;
|
|
328
|
+
mixedAttr = { name: attrName, firstPartIdx: partIdx };
|
|
329
|
+
parts.push({ kind: 'noop', path: [] }); // patched to attr-mixed at close-quote
|
|
330
|
+
state = 'skip-attr';
|
|
331
|
+
} else if (state === 'skip-attr') {
|
|
332
|
+
// Subsequent hole in the same mixed attribute.
|
|
333
|
+
parts.push({ kind: 'noop', path: [] });
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const templateEl = document.createElement('template');
|
|
339
|
+
templateEl.innerHTML = html;
|
|
340
|
+
|
|
341
|
+
// Mark every <slot> in the template for framework projection and
|
|
342
|
+
// register a SLOT part for each so projectChildren can find them on
|
|
343
|
+
// clones. This runs BEFORE assignPaths so the sentinel attributes the
|
|
344
|
+
// discovery step adds are picked up in the same path-recording walk.
|
|
345
|
+
discoverSlots(templateEl.content, parts);
|
|
346
|
+
|
|
347
|
+
// Walk the parsed fragment and record DOM paths for each part.
|
|
348
|
+
assignPaths(templateEl.content, parts);
|
|
349
|
+
|
|
350
|
+
cached = { templateEl, parts };
|
|
351
|
+
templateCache.set(strings, cached);
|
|
352
|
+
return cached;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Walk the compiled template content for <slot> elements (the static ones
|
|
357
|
+
* written into the template, not dynamically-inserted ones). For each:
|
|
358
|
+
* 1. Add the `data-webjs-light` attribute so slot.js's polyfilled APIs
|
|
359
|
+
* recognise it as a framework-managed light-DOM slot.
|
|
360
|
+
* 2. Add a sentinel attribute (`data-MARKER<idx>`) so the subsequent
|
|
361
|
+
* assignPaths walk records the slot's path into the new SLOT part.
|
|
362
|
+
* 3. Move the slot's authored children into a `fallbackTemplate`
|
|
363
|
+
* DocumentFragment stored on the PartDescriptor. The slot in the
|
|
364
|
+
* cached template becomes empty, so every clone starts empty too.
|
|
365
|
+
* bindPart clones a fresh fallback fragment per instance from this
|
|
366
|
+
* template, giving each instance an independent fallback supply
|
|
367
|
+
* that slot.js swaps in via the SLOT_FALLBACK_FRAG symbol.
|
|
368
|
+
*
|
|
369
|
+
* Fallback content with template holes (`<slot>fallback ${x}</slot>`)
|
|
370
|
+
* is captured as a static-HTML snapshot of the template state at
|
|
371
|
+
* compile time. Dynamic holes inside fallback content are not
|
|
372
|
+
* re-bound per instance in v1; authors should put dynamic content
|
|
373
|
+
* outside the slot.
|
|
374
|
+
*
|
|
375
|
+
* @param {DocumentFragment} root
|
|
376
|
+
* @param {PartDescriptor[]} parts
|
|
377
|
+
*/
|
|
378
|
+
function discoverSlots(root, parts) {
|
|
379
|
+
const slots = root.querySelectorAll('slot');
|
|
380
|
+
for (const slot of slots) {
|
|
381
|
+
slot.setAttribute(LIGHT_SLOT_ATTR, '');
|
|
382
|
+
const partIdx = parts.length;
|
|
383
|
+
slot.setAttribute(`data-${MARKER}${partIdx}`, '');
|
|
384
|
+
parts.push({ kind: 'slot', path: [] });
|
|
385
|
+
}
|
|
386
|
+
// NOTE: fallback content stays IN the slot's children in the cached
|
|
387
|
+
// template. Each clone gets its own copy. For shadow-DOM components,
|
|
388
|
+
// native browser projection uses those children as fallback content
|
|
389
|
+
// when no light child matches. For light-DOM components, the slot's
|
|
390
|
+
// apply step (run after the cloned template is in the live tree)
|
|
391
|
+
// moves the cloned fallback into a per-instance holding fragment
|
|
392
|
+
// owned by slot.js, so the slot is empty and ready to receive
|
|
393
|
+
// projected children. Doing the strip at apply time, not at compile
|
|
394
|
+
// time, lets a single cached template serve both DOM modes.
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Walk the template fragment and record the path (chain of child indices) to
|
|
399
|
+
* each part's anchor node. We use marker comments for child parts and sentinel
|
|
400
|
+
* attributes for everything else.
|
|
401
|
+
*
|
|
402
|
+
* @param {DocumentFragment} root
|
|
403
|
+
* @param {PartDescriptor[]} parts
|
|
404
|
+
*/
|
|
405
|
+
function assignPaths(root, parts) {
|
|
406
|
+
/** @type {number[]} */
|
|
407
|
+
const path = [];
|
|
408
|
+
/** @param {Node} node */
|
|
409
|
+
function visit(node) {
|
|
410
|
+
for (let i = 0; i < node.childNodes.length; i++) {
|
|
411
|
+
const child = node.childNodes[i];
|
|
412
|
+
path.push(i);
|
|
413
|
+
// Comment marker?
|
|
414
|
+
if (child.nodeType === 8) {
|
|
415
|
+
const txt = /** @type Comment */ (child).data;
|
|
416
|
+
if (txt.startsWith(MARKER)) {
|
|
417
|
+
const idx = Number(txt.slice(MARKER.length));
|
|
418
|
+
if (parts[idx] && parts[idx].kind === 'child') {
|
|
419
|
+
parts[idx].path = path.slice();
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
} else if (child.nodeType === 1) {
|
|
423
|
+
const el = /** @type Element */ (child);
|
|
424
|
+
// Sentinel attribute?
|
|
425
|
+
const toRemove = [];
|
|
426
|
+
for (const attr of el.attributes) {
|
|
427
|
+
if (attr.name.startsWith(`data-${MARKER}`)) {
|
|
428
|
+
const idx = Number(attr.name.slice(`data-${MARKER}`.length));
|
|
429
|
+
if (parts[idx] && parts[idx].kind !== 'child') {
|
|
430
|
+
parts[idx].path = path.slice();
|
|
431
|
+
}
|
|
432
|
+
toRemove.push(attr.name);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
for (const a of toRemove) el.removeAttribute(a);
|
|
436
|
+
visit(child);
|
|
437
|
+
}
|
|
438
|
+
path.pop();
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
visit(root);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/* ================================================================
|
|
445
|
+
* Instance lifecycle
|
|
446
|
+
* ================================================================ */
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* @param {import('./html.js').TemplateResult} tr
|
|
450
|
+
* @param {Element | DocumentFragment | ShadowRoot} container
|
|
451
|
+
*/
|
|
452
|
+
function createInstance(tr, container) {
|
|
453
|
+
const { templateEl, parts } = compile(tr);
|
|
454
|
+
const frag = /** @type DocumentFragment */ (templateEl.content.cloneNode(true));
|
|
455
|
+
|
|
456
|
+
// Bookend markers bound the instance so we can tear it down cleanly.
|
|
457
|
+
const startNode = document.createComment(`${MARKER}s`);
|
|
458
|
+
const endNode = document.createComment(`${MARKER}e`);
|
|
459
|
+
|
|
460
|
+
const bound = parts.map((p) => bindPart(p, frag));
|
|
461
|
+
const lastValues = [];
|
|
462
|
+
for (let i = 0; i < tr.values.length; i++) {
|
|
463
|
+
applyPart(bound[i], tr.values[i], undefined, tr.values);
|
|
464
|
+
lastValues.push(tr.values[i]);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** @type any */ (container).replaceChildren(startNode, ...frag.childNodes, endNode);
|
|
468
|
+
|
|
469
|
+
// Slot parts have no value-hole to drive applyPart from the loop above.
|
|
470
|
+
// Apply them once now that the fragment is inserted into the live
|
|
471
|
+
// container, so each slot can locate its host by walking parents and
|
|
472
|
+
// schedule the first projection through slot.js.
|
|
473
|
+
for (const part of bound) {
|
|
474
|
+
if (part.kind === 'slot') applyPart(part, undefined, undefined, []);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
return { strings: tr.strings, bound, lastValues, startNode, endNode };
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* @param {PartDescriptor} p
|
|
482
|
+
* @param {DocumentFragment | Element} root
|
|
483
|
+
* @returns {BoundPart}
|
|
484
|
+
*/
|
|
485
|
+
function bindPart(p, root) {
|
|
486
|
+
if (p.kind === 'noop') return /** @type any */ ({ kind: 'noop' });
|
|
487
|
+
let node = /** @type Node */ (root);
|
|
488
|
+
for (const i of p.path) node = node.childNodes[i];
|
|
489
|
+
if (p.kind === 'child') {
|
|
490
|
+
return { kind: 'child', marker: /** @type Comment */ (node) };
|
|
491
|
+
}
|
|
492
|
+
const el = /** @type Element */ (node);
|
|
493
|
+
if (p.kind === 'event') {
|
|
494
|
+
/** @type {BoundPart} */
|
|
495
|
+
const part = {
|
|
496
|
+
kind: 'event',
|
|
497
|
+
el,
|
|
498
|
+
name: p.name || '',
|
|
499
|
+
handler: null,
|
|
500
|
+
// The dispatcher is the registered listener; handler swaps behind it.
|
|
501
|
+
dispatcher(ev) { part.handler?.(ev); },
|
|
502
|
+
};
|
|
503
|
+
el.addEventListener(part.name, part.dispatcher);
|
|
504
|
+
return part;
|
|
505
|
+
}
|
|
506
|
+
if (p.kind === 'attr') return { kind: 'attr', el, name: p.name || '' };
|
|
507
|
+
if (p.kind === 'attr-mixed') return { kind: 'attr-mixed', el, name: p.name || '', statics: p.statics || [], group: p.group || [] };
|
|
508
|
+
if (p.kind === 'prop') return { kind: 'prop', el, name: p.name || '' };
|
|
509
|
+
if (p.kind === 'bool') return { kind: 'bool', el, name: p.name || '' };
|
|
510
|
+
if (p.kind === 'element') return { kind: 'element', el };
|
|
511
|
+
if (p.kind === 'slot') {
|
|
512
|
+
const slotEl = /** @type {HTMLSlotElement} */ (el);
|
|
513
|
+
// Defer fallback-strip and SLOT_FALLBACK_FRAG installation to apply
|
|
514
|
+
// time so we know whether the slot is light or shadow at the point
|
|
515
|
+
// where the decision matters. At bind time the cloned slot still
|
|
516
|
+
// holds its fallback content from the template clone; we just
|
|
517
|
+
// record the slot ref.
|
|
518
|
+
return { kind: 'slot', slotEl, applied: false };
|
|
519
|
+
}
|
|
520
|
+
throw new Error(`unknown part kind ${/** @type any */(p).kind}`);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* @param {TemplateInstance} inst
|
|
525
|
+
* @param {unknown[]} values
|
|
526
|
+
*/
|
|
527
|
+
function updateInstance(inst, values) {
|
|
528
|
+
for (let i = 0; i < values.length; i++) {
|
|
529
|
+
const next = values[i];
|
|
530
|
+
if (Object.is(next, inst.lastValues[i])) continue;
|
|
531
|
+
applyPart(inst.bound[i], next, inst.lastValues[i], values);
|
|
532
|
+
inst.lastValues[i] = next;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* @param {TemplateInstance} inst
|
|
538
|
+
* @param {Element | DocumentFragment | ShadowRoot} container
|
|
539
|
+
*/
|
|
540
|
+
function clearInstance(inst, container) {
|
|
541
|
+
// Dispose event listeners on event parts, unbind active refs on
|
|
542
|
+
// element parts, and rescue any projected children sitting inside
|
|
543
|
+
// slot parts so they survive teardown of a collapsing conditional
|
|
544
|
+
// fragment.
|
|
545
|
+
for (const p of inst.bound) {
|
|
546
|
+
if (p.kind === 'event') p.el.removeEventListener(p.name, p.dispatcher);
|
|
547
|
+
if (p.kind === 'element') {
|
|
548
|
+
const prev = /** @type any */ (p).lastTarget;
|
|
549
|
+
if (prev) {
|
|
550
|
+
if (typeof prev === 'function') {
|
|
551
|
+
try { prev(undefined); } catch { /* swallow */ }
|
|
552
|
+
} else if (typeof prev === 'object') {
|
|
553
|
+
prev.value = undefined;
|
|
554
|
+
}
|
|
555
|
+
/** @type any */ (p).lastTarget = undefined;
|
|
556
|
+
/** @type any */ (p).__lastEl = undefined;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
if (p.kind === 'slot') {
|
|
560
|
+
const host = findSlotHost(p.slotEl);
|
|
561
|
+
if (host) moveSlotChildrenToPending(host, p.slotEl);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
/** @type any */ (container).replaceChildren();
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/* ================================================================
|
|
568
|
+
* Part application
|
|
569
|
+
* ================================================================ */
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* @param {BoundPart} part
|
|
573
|
+
* @param {unknown} value
|
|
574
|
+
* @param {unknown} _prev
|
|
575
|
+
*/
|
|
576
|
+
function applyPart(part, value, _prev, allValues) {
|
|
577
|
+
// Unwrap live() to dirty-check against the live DOM value, not the
|
|
578
|
+
// last rendered value. Essential for <input> two-way binding.
|
|
579
|
+
if (isLive(value)) {
|
|
580
|
+
const liveVal = /** @type any */ (value).value;
|
|
581
|
+
if (part.kind === 'prop' && /** @type any */ (part.el)[part.name] === liveVal) return;
|
|
582
|
+
if (part.kind === 'attr' && part.el.getAttribute(part.name) === String(liveVal)) return;
|
|
583
|
+
if (part.kind === 'bool' && part.el.hasAttribute(part.name) === !!liveVal) return;
|
|
584
|
+
value = liveVal;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
switch (part.kind) {
|
|
588
|
+
case 'child':
|
|
589
|
+
applyChild(part, value);
|
|
590
|
+
break;
|
|
591
|
+
case 'attr': {
|
|
592
|
+
if (value == null || value === false) part.el.removeAttribute(part.name);
|
|
593
|
+
else part.el.setAttribute(part.name, String(value));
|
|
594
|
+
break;
|
|
595
|
+
}
|
|
596
|
+
case 'prop':
|
|
597
|
+
/** @type any */ (part.el)[part.name] = value;
|
|
598
|
+
break;
|
|
599
|
+
case 'bool':
|
|
600
|
+
if (value) part.el.setAttribute(part.name, '');
|
|
601
|
+
else part.el.removeAttribute(part.name);
|
|
602
|
+
break;
|
|
603
|
+
case 'event':
|
|
604
|
+
part.handler = typeof value === 'function' ? /** @type any */ (value) : null;
|
|
605
|
+
break;
|
|
606
|
+
case 'element':
|
|
607
|
+
applyElement(part, value);
|
|
608
|
+
break;
|
|
609
|
+
case 'attr-mixed': {
|
|
610
|
+
// Reconstruct the attribute from static pieces + all dynamic values.
|
|
611
|
+
const mp = /** @type {{ statics: string[], group: number[] }} */ (/** @type any */ (part));
|
|
612
|
+
let val = mp.statics[0];
|
|
613
|
+
for (let j = 0; j < mp.group.length; j++) {
|
|
614
|
+
val += String((allValues ? allValues[mp.group[j]] : value) ?? '');
|
|
615
|
+
val += mp.statics[j + 1] || '';
|
|
616
|
+
}
|
|
617
|
+
part.el.setAttribute(part.name, val);
|
|
618
|
+
break;
|
|
619
|
+
}
|
|
620
|
+
case 'slot': {
|
|
621
|
+
// Slot parts have no template-hole value to apply. The "apply" is
|
|
622
|
+
// a one-shot trigger that runs after the template fragment has
|
|
623
|
+
// been inserted into the host's render root. At this point the
|
|
624
|
+
// slot's parent chain reveals whether it lives inside a shadow
|
|
625
|
+
// root (browser native projection) or in light DOM (framework
|
|
626
|
+
// projection). For shadow-DOM slots we leave the cloned fallback
|
|
627
|
+
// in place. For light-DOM slots we move the fallback into a
|
|
628
|
+
// per-slot holding fragment owned by slot.js (via the
|
|
629
|
+
// SLOT_FALLBACK_FRAG symbol) so the slot can receive projected
|
|
630
|
+
// children, and we kick off projection.
|
|
631
|
+
//
|
|
632
|
+
// For NESTED templates (a slot inside `${cond ? html`<slot/>` : ''}`),
|
|
633
|
+
// the slot's parent chain at first apply may still lead through
|
|
634
|
+
// an unattached fragment. findSlotHost returns null then; we
|
|
635
|
+
// retry on the next microtask, by which point the outer's
|
|
636
|
+
// replaceChildren has placed the entire tree into the host.
|
|
637
|
+
if (part.applied) break;
|
|
638
|
+
const slotEl = part.slotEl;
|
|
639
|
+
const finalize = () => {
|
|
640
|
+
part.applied = true;
|
|
641
|
+
const host = findSlotHost(slotEl);
|
|
642
|
+
if (!host) return; // truly orphan slot
|
|
643
|
+
// Shadow DOM: native projection. Leave fallback in place.
|
|
644
|
+
if (isInShadowRootEl(slotEl)) return;
|
|
645
|
+
// Light DOM: harvest the cloned fallback into a holding
|
|
646
|
+
// fragment, then trigger projection.
|
|
647
|
+
const frag = document.createDocumentFragment();
|
|
648
|
+
while (slotEl.firstChild) frag.appendChild(slotEl.firstChild);
|
|
649
|
+
/** @type {any} */ (slotEl)[SLOT_FALLBACK_FRAG] = frag;
|
|
650
|
+
scheduleProjection(host);
|
|
651
|
+
};
|
|
652
|
+
const directHost = findSlotHost(slotEl);
|
|
653
|
+
if (directHost) {
|
|
654
|
+
finalize();
|
|
655
|
+
} else {
|
|
656
|
+
queueMicrotask(finalize);
|
|
657
|
+
}
|
|
658
|
+
break;
|
|
659
|
+
}
|
|
660
|
+
case 'noop':
|
|
661
|
+
// intentionally empty: used for holes inside HTML comments
|
|
662
|
+
break;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Walk a slot element's parent chain looking for a WebComponent host
|
|
668
|
+
* (an element that has slot state initialised). Used by the slot-part's
|
|
669
|
+
* apply and teardown steps to coordinate with slot.js.
|
|
670
|
+
*
|
|
671
|
+
* @param {HTMLSlotElement} slotEl
|
|
672
|
+
* @returns {Element | null}
|
|
673
|
+
*/
|
|
674
|
+
function findSlotHost(slotEl) {
|
|
675
|
+
let p = slotEl.parentElement;
|
|
676
|
+
while (p) {
|
|
677
|
+
if (/** @type any */ (p)[SLOT_STATE]) return p;
|
|
678
|
+
p = p.parentElement;
|
|
679
|
+
}
|
|
680
|
+
return null;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* True when an element is inside a shadow root (so native browser slot
|
|
685
|
+
* projection applies). Mirrors slot.js's helper; duplicated here to
|
|
686
|
+
* avoid the round trip through the slot.js public surface for this
|
|
687
|
+
* hot path.
|
|
688
|
+
* @param {Element} el
|
|
689
|
+
* @returns {boolean}
|
|
690
|
+
*/
|
|
691
|
+
function isInShadowRootEl(el) {
|
|
692
|
+
let n = /** @type {Node} */ (el);
|
|
693
|
+
for (let depth = 0; depth < 128; depth++) {
|
|
694
|
+
const parent = n.parentNode;
|
|
695
|
+
if (!parent) return false;
|
|
696
|
+
if (parent === n) return false;
|
|
697
|
+
if (/** @type any */ (parent).host) return true;
|
|
698
|
+
n = parent;
|
|
699
|
+
}
|
|
700
|
+
return false;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* Child (text-position) part. Replace the marker's surrounding nodes with the
|
|
705
|
+
* new value's rendered form. Nested TemplateResults get an instance with its
|
|
706
|
+
* own parts; we reuse on `strings` identity.
|
|
707
|
+
*
|
|
708
|
+
* @param {Extract<BoundPart, {kind:'child'}>} part
|
|
709
|
+
* @param {unknown} value
|
|
710
|
+
*/
|
|
711
|
+
/**
|
|
712
|
+
* Apply a value at an element-position part (`<tag ${expr}>`). The
|
|
713
|
+
* sole supported directive here is `ref(refOrCallback)` and
|
|
714
|
+
* `createRef()`. Other values are ignored so a stray non-ref hole
|
|
715
|
+
* doesn't crash. Tracks the prior target so a change from one ref to
|
|
716
|
+
* another correctly unsets the old target before binding the new one.
|
|
717
|
+
*
|
|
718
|
+
* @param {Extract<BoundPart, {kind:'element'}>} part
|
|
719
|
+
* @param {unknown} value
|
|
720
|
+
*/
|
|
721
|
+
function applyElement(part, value) {
|
|
722
|
+
// Matches lit-html's RefDirective.update():
|
|
723
|
+
// 1. If the ref target changed since last render, unbind the prior one.
|
|
724
|
+
// 2. If the ref target OR the element identity changed, bind the new
|
|
725
|
+
// (ref, element) pair. If both are stable, skip entirely.
|
|
726
|
+
// For callback refs, an unset-before-bind cycle runs whenever the
|
|
727
|
+
// same callback is now pointing at a different element.
|
|
728
|
+
const partAny = /** @type any */ (part);
|
|
729
|
+
const nextTarget = isRef(value) ? /** @type any */ (value).target : undefined;
|
|
730
|
+
const prevTarget = partAny.__refTarget;
|
|
731
|
+
const refChanged = nextTarget !== prevTarget;
|
|
732
|
+
|
|
733
|
+
if (refChanged && prevTarget) {
|
|
734
|
+
if (typeof prevTarget === 'function') {
|
|
735
|
+
try { prevTarget(undefined); } catch { /* swallow */ }
|
|
736
|
+
} else if (typeof prevTarget === 'object') {
|
|
737
|
+
prevTarget.value = undefined;
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
if (refChanged || partAny.__refElement !== part.el) {
|
|
742
|
+
partAny.__refTarget = nextTarget;
|
|
743
|
+
if (nextTarget) {
|
|
744
|
+
if (typeof nextTarget === 'function') {
|
|
745
|
+
// Same callback now pointing at a different element: deliver
|
|
746
|
+
// an `undefined` cleanup for the prior element first.
|
|
747
|
+
if (!refChanged && partAny.__refElement !== undefined) {
|
|
748
|
+
try { nextTarget(undefined); } catch { /* swallow */ }
|
|
749
|
+
}
|
|
750
|
+
try { nextTarget(part.el); } catch { /* swallow */ }
|
|
751
|
+
} else if (typeof nextTarget === 'object') {
|
|
752
|
+
nextTarget.value = part.el;
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
partAny.__refElement = part.el;
|
|
756
|
+
// Keep the legacy `lastTarget` field in sync for clearInstance /
|
|
757
|
+
// disposeInstance which read it for template-disposal cleanup.
|
|
758
|
+
part.lastTarget = nextTarget;
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function applyChild(part, value) {
|
|
763
|
+
// Drop directive state from prior renders when the new value is for a
|
|
764
|
+
// different directive (or no directive at all). Keeps __untilState
|
|
765
|
+
// from leaking across replacements, __guardDeps from causing a stale
|
|
766
|
+
// short-circuit, etc. Done once per outermost applyChild call; the
|
|
767
|
+
// directive handlers recurse via applyChildInner (no re-clear) so
|
|
768
|
+
// their own state survives the recursion.
|
|
769
|
+
clearStaleDirectiveState(part, value);
|
|
770
|
+
return applyChildInner(part, value);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* Internal dispatch. Used both by `applyChild` (which first clears
|
|
775
|
+
* stale per-part directive state) and by directive handlers that
|
|
776
|
+
* recurse with a different value at the same part. Recursing via
|
|
777
|
+
* `applyChild` would clear the directive state that was just set,
|
|
778
|
+
* because the inner value almost always isn't itself a directive.
|
|
779
|
+
*
|
|
780
|
+
* @param {Extract<BoundPart, {kind:'child'}>} part
|
|
781
|
+
* @param {unknown} value
|
|
782
|
+
*/
|
|
783
|
+
function applyChildInner(part, value) {
|
|
784
|
+
const marker = part.marker;
|
|
785
|
+
|
|
786
|
+
// unsafeHTML directive: inject raw HTML string as DOM nodes.
|
|
787
|
+
if (isUnsafeHTML(value)) {
|
|
788
|
+
teardownChild(part);
|
|
789
|
+
const htmlStr = String(/** @type any */ (value).value ?? '');
|
|
790
|
+
const template = document.createElement('template');
|
|
791
|
+
template.innerHTML = htmlStr;
|
|
792
|
+
const nodes = [...template.content.childNodes];
|
|
793
|
+
const frag = document.createDocumentFragment();
|
|
794
|
+
for (const n of nodes) frag.appendChild(n);
|
|
795
|
+
marker.parentNode?.insertBefore(frag, marker);
|
|
796
|
+
part.child = nodes;
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// keyed directive: when key changes, tear down and remount fresh DOM.
|
|
801
|
+
// When the key matches, recurse so the standard template-reconciliation
|
|
802
|
+
// path can update the existing DOM in place.
|
|
803
|
+
if (isKeyed(value)) {
|
|
804
|
+
const v = /** @type any */ (value);
|
|
805
|
+
const prevKey = /** @type any */ (part).__keyedKey;
|
|
806
|
+
if (prevKey !== undefined && !Object.is(prevKey, v.key)) {
|
|
807
|
+
teardownChild(part);
|
|
808
|
+
}
|
|
809
|
+
/** @type any */ (part).__keyedKey = v.key;
|
|
810
|
+
applyChildInner(part, v.value);
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// guard directive: skip re-evaluation when the deps array is shallow-
|
|
815
|
+
// equal to the prior call. Stored deps live on the part so they
|
|
816
|
+
// persist across renders that reuse the same template (and thus the
|
|
817
|
+
// same part).
|
|
818
|
+
if (isGuard(value)) {
|
|
819
|
+
const v = /** @type any */ (value);
|
|
820
|
+
const prevDeps = /** @type any */ (part).__guardDeps;
|
|
821
|
+
const nextDeps = v.deps;
|
|
822
|
+
// Accept any value for deps. When deps is an array, compare shallowly.
|
|
823
|
+
// When it's a primitive (number, string, undefined), compare with
|
|
824
|
+
// Object.is. Mirrors lit-html's tolerance for non-array deps so user
|
|
825
|
+
// code like `guard(this.id, () => ...)` works without crashing.
|
|
826
|
+
if (prevDeps !== undefined) {
|
|
827
|
+
const equal = Array.isArray(prevDeps) && Array.isArray(nextDeps)
|
|
828
|
+
? shallowEqualArray(prevDeps, nextDeps)
|
|
829
|
+
: Object.is(prevDeps, nextDeps);
|
|
830
|
+
if (equal) return;
|
|
831
|
+
}
|
|
832
|
+
/** @type any */ (part).__guardDeps = Array.isArray(nextDeps)
|
|
833
|
+
? nextDeps.slice()
|
|
834
|
+
: nextDeps;
|
|
835
|
+
applyChildInner(part, v.fn());
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// templateContent directive: clone the content of a <template> element.
|
|
840
|
+
if (isTemplateContent(value)) {
|
|
841
|
+
teardownChild(part);
|
|
842
|
+
const tpl = /** @type any */ (value).template;
|
|
843
|
+
if (tpl && tpl.content) {
|
|
844
|
+
const frag = tpl.content.cloneNode(true);
|
|
845
|
+
const nodes = [...frag.childNodes];
|
|
846
|
+
marker.parentNode?.insertBefore(frag, marker);
|
|
847
|
+
part.child = nodes;
|
|
848
|
+
}
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// ref directive in a child position: no DOM produced. Element-position
|
|
853
|
+
// refs are bound via element parts; a stray ref() in a child position
|
|
854
|
+
// is a no-op for compatibility.
|
|
855
|
+
if (isRef(value)) {
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// cache directive: real DOM retention. When the inner value changes
|
|
860
|
+
// to a different template shape, detach (rather than destroy) the
|
|
861
|
+
// current DOM and stash it keyed by its template strings. When a
|
|
862
|
+
// previously-cached shape returns, re-attach it before the marker
|
|
863
|
+
// and reconcile values. Preserves input state, scroll, focus across
|
|
864
|
+
// toggles between sub-templates (e.g. tab interfaces).
|
|
865
|
+
if (isCache(value)) {
|
|
866
|
+
return applyCache(part, /** @type any */ (value).value);
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// until directive: render the highest-priority resolved value among
|
|
870
|
+
// the candidates. Synchronous values are rendered immediately; Promises
|
|
871
|
+
// are awaited in the background and applied if no higher-priority
|
|
872
|
+
// candidate has resolved yet. When the marker is torn down, in-flight
|
|
873
|
+
// priorities are cleared so late resolves cannot overwrite later DOM.
|
|
874
|
+
if (isUntil(value)) {
|
|
875
|
+
return applyUntil(part, /** @type any */ (value).args);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// watch directive: bind a part to a signal. Reads the signal at
|
|
879
|
+
// render time and subscribes the part to changes. When the signal
|
|
880
|
+
// fires, only this part updates; the host component's render() does
|
|
881
|
+
// not re-run. The signal read inside the watcher's observe is
|
|
882
|
+
// tracked against the part's private Watcher, NOT the host's render
|
|
883
|
+
// watcher (so the host doesn't subscribe to a full re-render too).
|
|
884
|
+
if (isWatch(value)) {
|
|
885
|
+
return applyWatch(part, /** @type any */ (value).signal);
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
// asyncAppend / asyncReplace: subscribe to the AsyncIterable. Each
|
|
889
|
+
// yielded value is mapped (optional) and appended (asyncAppend) or
|
|
890
|
+
// replaces (asyncReplace) the prior content. Teardown aborts the
|
|
891
|
+
// iteration so leaked iterators do not keep references to detached
|
|
892
|
+
// DOM.
|
|
893
|
+
if (isAsyncAppend(value)) {
|
|
894
|
+
return applyAsyncAppend(part, /** @type any */ (value));
|
|
895
|
+
}
|
|
896
|
+
if (isAsyncReplace(value)) {
|
|
897
|
+
return applyAsyncReplace(part, /** @type any */ (value));
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// Repeat directive: keyed reconciliation. Keep previous state when both
|
|
901
|
+
// old and new are repeats; otherwise tear down and rebuild.
|
|
902
|
+
if (isRepeat(value)) {
|
|
903
|
+
if (part.child && /** @type any */ (part.child).kind === 'repeat') {
|
|
904
|
+
reconcileRepeat(part, value);
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
teardownChild(part);
|
|
908
|
+
const state = { kind: 'repeat', map: new Map() };
|
|
909
|
+
part.child = state;
|
|
910
|
+
applyRepeatFresh(marker, state, value);
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// Remove previously rendered nodes between marker and its next sibling we own.
|
|
915
|
+
if (part.child) {
|
|
916
|
+
const c = /** @type any */ (part.child);
|
|
917
|
+
if (c.kind === 'repeat') {
|
|
918
|
+
teardownRepeat(c);
|
|
919
|
+
part.child = undefined;
|
|
920
|
+
} else if (c.kind === 'async-stream') {
|
|
921
|
+
teardownAsyncStream(c);
|
|
922
|
+
part.child = undefined;
|
|
923
|
+
} else if ('strings' in /** @type any */ (part.child)) {
|
|
924
|
+
// Previous was a TemplateInstance.
|
|
925
|
+
const inst = /** @type TemplateInstance */ (part.child);
|
|
926
|
+
if (isTemplate(value) && inst.strings === /** @type any */ (value).strings) {
|
|
927
|
+
updateInstance(inst, /** @type any */ (value).values);
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
removeBetween(inst.startNode, inst.endNode);
|
|
931
|
+
part.child = undefined;
|
|
932
|
+
} else {
|
|
933
|
+
// Previous was ChildNode[]: remove each node we inserted.
|
|
934
|
+
for (const n of /** @type ChildNode[] */ (part.child)) {
|
|
935
|
+
if (n.parentNode) n.parentNode.removeChild(n);
|
|
936
|
+
}
|
|
937
|
+
part.child = undefined;
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
if (value == null || value === false || value === true) return;
|
|
942
|
+
|
|
943
|
+
if (isTemplate(value)) {
|
|
944
|
+
const tr = /** @type any */ (value);
|
|
945
|
+
const { templateEl, parts } = compile(tr);
|
|
946
|
+
const frag = /** @type DocumentFragment */ (templateEl.content.cloneNode(true));
|
|
947
|
+
const startNode = document.createComment(`${MARKER}s`);
|
|
948
|
+
const endNode = document.createComment(`${MARKER}e`);
|
|
949
|
+
const bound = parts.map((p) => bindPart(p, frag));
|
|
950
|
+
const lastValues = [];
|
|
951
|
+
for (let i = 0; i < tr.values.length; i++) {
|
|
952
|
+
applyPart(bound[i], tr.values[i], undefined, tr.values);
|
|
953
|
+
lastValues.push(tr.values[i]);
|
|
954
|
+
}
|
|
955
|
+
const nodes = [startNode, ...frag.childNodes, endNode];
|
|
956
|
+
marker.parentNode?.insertBefore(nodesToFrag(nodes), marker);
|
|
957
|
+
// Slot parts in this nested template need their one-shot apply just
|
|
958
|
+
// like createInstance does for top-level templates. The slot is now
|
|
959
|
+
// in the live tree (insertBefore above) so its parent walk can
|
|
960
|
+
// reach the host. Without this loop, conditional / nested templates
|
|
961
|
+
// with <slot> inside never trigger projection.
|
|
962
|
+
for (const p of bound) {
|
|
963
|
+
if (p.kind === 'slot') applyPart(p, undefined, undefined, []);
|
|
964
|
+
}
|
|
965
|
+
part.child = { strings: tr.strings, bound, lastValues, startNode, endNode };
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
if (Array.isArray(value)) {
|
|
970
|
+
const list = [];
|
|
971
|
+
for (const v of value) {
|
|
972
|
+
if (isTemplate(v)) {
|
|
973
|
+
// Create an inline instance. No keyed reconciliation yet.
|
|
974
|
+
const tr = /** @type any */ (v);
|
|
975
|
+
const { templateEl, parts } = compile(tr);
|
|
976
|
+
const frag = /** @type DocumentFragment */ (templateEl.content.cloneNode(true));
|
|
977
|
+
const bound = parts.map((p) => bindPart(p, frag));
|
|
978
|
+
for (let i = 0; i < tr.values.length; i++) {
|
|
979
|
+
applyPart(bound[i], tr.values[i], undefined, tr.values);
|
|
980
|
+
}
|
|
981
|
+
list.push(...frag.childNodes);
|
|
982
|
+
} else if (v != null && v !== false && v !== true) {
|
|
983
|
+
list.push(document.createTextNode(String(v)));
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
const frag = nodesToFrag(list);
|
|
987
|
+
marker.parentNode?.insertBefore(frag, marker);
|
|
988
|
+
part.child = list;
|
|
989
|
+
return;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
const node = document.createTextNode(String(value));
|
|
993
|
+
marker.parentNode?.insertBefore(node, marker);
|
|
994
|
+
part.child = [node];
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
/** @param {ChildNode[]} nodes */
|
|
998
|
+
function nodesToFrag(nodes) {
|
|
999
|
+
const frag = document.createDocumentFragment();
|
|
1000
|
+
for (const n of nodes) frag.appendChild(n);
|
|
1001
|
+
return frag;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
/** @param {Node} start @param {Node} end */
|
|
1005
|
+
function removeBetween(start, end) {
|
|
1006
|
+
if (!start.parentNode) return;
|
|
1007
|
+
let n = start;
|
|
1008
|
+
while (n && n !== end) {
|
|
1009
|
+
const next = n.nextSibling;
|
|
1010
|
+
n.parentNode?.removeChild(n);
|
|
1011
|
+
n = next;
|
|
1012
|
+
}
|
|
1013
|
+
if (end.parentNode === start.parentNode) end.parentNode?.removeChild(end);
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
/* ================================================================
|
|
1017
|
+
* Keyed list (repeat) support
|
|
1018
|
+
* ================================================================ */
|
|
1019
|
+
|
|
1020
|
+
/**
|
|
1021
|
+
* Build a TemplateInstance whose nodes (including bookends) live in a
|
|
1022
|
+
* document fragment that the caller will insert wherever it wants.
|
|
1023
|
+
* @param {import('./html.js').TemplateResult} tr
|
|
1024
|
+
* @returns {{ inst: TemplateInstance, frag: DocumentFragment }}
|
|
1025
|
+
*/
|
|
1026
|
+
function buildDetached(tr) {
|
|
1027
|
+
const { templateEl, parts } = compile(tr);
|
|
1028
|
+
const frag = /** @type DocumentFragment */ (templateEl.content.cloneNode(true));
|
|
1029
|
+
const startNode = document.createComment(`${MARKER}s`);
|
|
1030
|
+
const endNode = document.createComment(`${MARKER}e`);
|
|
1031
|
+
const bound = parts.map((p) => bindPart(p, frag));
|
|
1032
|
+
const lastValues = [];
|
|
1033
|
+
for (let i = 0; i < tr.values.length; i++) {
|
|
1034
|
+
applyPart(bound[i], tr.values[i], undefined, tr.values);
|
|
1035
|
+
lastValues.push(tr.values[i]);
|
|
1036
|
+
}
|
|
1037
|
+
const outFrag = document.createDocumentFragment();
|
|
1038
|
+
outFrag.appendChild(startNode);
|
|
1039
|
+
while (frag.firstChild) outFrag.appendChild(frag.firstChild);
|
|
1040
|
+
outFrag.appendChild(endNode);
|
|
1041
|
+
return {
|
|
1042
|
+
inst: { strings: tr.strings, bound, lastValues, startNode, endNode },
|
|
1043
|
+
frag: outFrag,
|
|
1044
|
+
};
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
/** @param {TemplateInstance} inst */
|
|
1048
|
+
function disposeInstance(inst) {
|
|
1049
|
+
for (const p of inst.bound) {
|
|
1050
|
+
if (p.kind === 'event') p.el.removeEventListener(p.name, p.dispatcher);
|
|
1051
|
+
if (p.kind === 'element') {
|
|
1052
|
+
// Unbind any active ref so the user observes the element being
|
|
1053
|
+
// removed (callback receives undefined / Ref.value cleared).
|
|
1054
|
+
// Mirrors lit-html's cleanup-on-disconnect for element parts.
|
|
1055
|
+
const prev = /** @type any */ (p).lastTarget;
|
|
1056
|
+
if (prev) {
|
|
1057
|
+
if (typeof prev === 'function') {
|
|
1058
|
+
try { prev(undefined); } catch { /* swallow */ }
|
|
1059
|
+
} else if (typeof prev === 'object') {
|
|
1060
|
+
prev.value = undefined;
|
|
1061
|
+
}
|
|
1062
|
+
/** @type any */ (p).lastTarget = undefined;
|
|
1063
|
+
/** @type any */ (p).__lastEl = undefined;
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
/**
|
|
1070
|
+
* Initial fresh render of a repeat directive. Inserts all items' nodes
|
|
1071
|
+
* immediately before the part's marker comment.
|
|
1072
|
+
*
|
|
1073
|
+
* @param {Comment} marker
|
|
1074
|
+
* @param {{ kind: 'repeat', map: Map<any, TemplateInstance> }} state
|
|
1075
|
+
* @param {any} value
|
|
1076
|
+
*/
|
|
1077
|
+
function applyRepeatFresh(marker, state, value) {
|
|
1078
|
+
const { items, keyFn, templateFn } = value;
|
|
1079
|
+
const parent = marker.parentNode;
|
|
1080
|
+
if (!parent) return;
|
|
1081
|
+
const bulk = document.createDocumentFragment();
|
|
1082
|
+
for (let i = 0; i < items.length; i++) {
|
|
1083
|
+
const key = keyFn(items[i], i);
|
|
1084
|
+
const tr = templateFn(items[i], i);
|
|
1085
|
+
if (!isTemplate(tr)) continue;
|
|
1086
|
+
const { inst, frag } = buildDetached(/** @type any */ (tr));
|
|
1087
|
+
state.map.set(key, inst);
|
|
1088
|
+
bulk.appendChild(frag);
|
|
1089
|
+
}
|
|
1090
|
+
parent.insertBefore(bulk, marker);
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
/**
|
|
1094
|
+
* Keyed reconciliation. For each key in the new list:
|
|
1095
|
+
* - hit: update the existing instance in place (if template shape matches),
|
|
1096
|
+
* then move its nodes into position
|
|
1097
|
+
* - miss: build a new instance and insert
|
|
1098
|
+
* Finally drop instances whose keys aren't in the new list.
|
|
1099
|
+
*
|
|
1100
|
+
* @param {Extract<BoundPart, {kind:'child'}>} part
|
|
1101
|
+
* @param {any} value
|
|
1102
|
+
*/
|
|
1103
|
+
function reconcileRepeat(part, value) {
|
|
1104
|
+
const marker = part.marker;
|
|
1105
|
+
const parent = marker.parentNode;
|
|
1106
|
+
if (!parent) return;
|
|
1107
|
+
const state = /** @type {{ kind: 'repeat', map: Map<any, TemplateInstance> }} */ (part.child);
|
|
1108
|
+
const { items, keyFn, templateFn } = value;
|
|
1109
|
+
|
|
1110
|
+
const newMap = new Map();
|
|
1111
|
+
|
|
1112
|
+
// Walk the new list and position each item's nodes immediately before the marker.
|
|
1113
|
+
for (let i = 0; i < items.length; i++) {
|
|
1114
|
+
const key = keyFn(items[i], i);
|
|
1115
|
+
const tr = templateFn(items[i], i);
|
|
1116
|
+
if (!isTemplate(tr)) continue;
|
|
1117
|
+
const existing = state.map.get(key);
|
|
1118
|
+
if (existing && existing.strings === /** @type any */ (tr).strings) {
|
|
1119
|
+
updateInstance(existing, /** @type any */ (tr).values);
|
|
1120
|
+
// Move nodes before marker preserving element identity.
|
|
1121
|
+
moveRange(existing.startNode, existing.endNode, parent, marker);
|
|
1122
|
+
newMap.set(key, existing);
|
|
1123
|
+
state.map.delete(key);
|
|
1124
|
+
} else {
|
|
1125
|
+
if (existing) {
|
|
1126
|
+
disposeInstance(existing);
|
|
1127
|
+
removeBetween(existing.startNode, existing.endNode);
|
|
1128
|
+
state.map.delete(key);
|
|
1129
|
+
}
|
|
1130
|
+
const { inst, frag } = buildDetached(/** @type any */ (tr));
|
|
1131
|
+
parent.insertBefore(frag, marker);
|
|
1132
|
+
newMap.set(key, inst);
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
// Remove any keys that remain in the old map.
|
|
1137
|
+
for (const inst of state.map.values()) {
|
|
1138
|
+
disposeInstance(inst);
|
|
1139
|
+
removeBetween(inst.startNode, inst.endNode);
|
|
1140
|
+
}
|
|
1141
|
+
state.map = newMap;
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
/** @param {{ kind: 'repeat', map: Map<any, TemplateInstance> }} state */
|
|
1145
|
+
function teardownRepeat(state) {
|
|
1146
|
+
for (const inst of state.map.values()) {
|
|
1147
|
+
disposeInstance(inst);
|
|
1148
|
+
removeBetween(inst.startNode, inst.endNode);
|
|
1149
|
+
}
|
|
1150
|
+
state.map.clear();
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
/**
|
|
1154
|
+
* Collect [start .. end] (inclusive) and insert immediately before `anchor`.
|
|
1155
|
+
* Browsers treat insertBefore of an already-connected node as a move and
|
|
1156
|
+
* preserve element identity + focus.
|
|
1157
|
+
*
|
|
1158
|
+
* @param {Node} start
|
|
1159
|
+
* @param {Node} end
|
|
1160
|
+
* @param {Node} parent
|
|
1161
|
+
* @param {Node} anchor
|
|
1162
|
+
*/
|
|
1163
|
+
function moveRange(start, end, parent, anchor) {
|
|
1164
|
+
// No-op if the range is already immediately before the anchor.
|
|
1165
|
+
if (end.nextSibling === anchor && start.parentNode === parent) return;
|
|
1166
|
+
const frag = document.createDocumentFragment();
|
|
1167
|
+
let n = start;
|
|
1168
|
+
while (n) {
|
|
1169
|
+
const next = n.nextSibling;
|
|
1170
|
+
frag.appendChild(n);
|
|
1171
|
+
if (n === end) break;
|
|
1172
|
+
n = next;
|
|
1173
|
+
}
|
|
1174
|
+
parent.insertBefore(frag, anchor);
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
/**
|
|
1178
|
+
* Shallow array equality (Object.is on each element). Used by the
|
|
1179
|
+
* `guard` directive to skip re-evaluation when deps are unchanged.
|
|
1180
|
+
* @param {readonly unknown[]} a
|
|
1181
|
+
* @param {readonly unknown[]} b
|
|
1182
|
+
*/
|
|
1183
|
+
function shallowEqualArray(a, b) {
|
|
1184
|
+
if (a === b) return true;
|
|
1185
|
+
if (!a || !b || a.length !== b.length) return false;
|
|
1186
|
+
for (let i = 0; i < a.length; i++) {
|
|
1187
|
+
if (!Object.is(a[i], b[i])) return false;
|
|
1188
|
+
}
|
|
1189
|
+
return true;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
/** @param {Extract<BoundPart, {kind:'child'}>} part */
|
|
1193
|
+
function teardownChild(part) {
|
|
1194
|
+
// Always abort any in-flight directive state on the part, even if
|
|
1195
|
+
// `part.child` itself is something else (e.g. an `until` directive
|
|
1196
|
+
// installed __untilState but applyChild's recursion overwrote
|
|
1197
|
+
// part.child to the rendered fallback shape).
|
|
1198
|
+
const partAny = /** @type any */ (part);
|
|
1199
|
+
if (partAny.__untilState) {
|
|
1200
|
+
partAny.__untilState.aborted = true;
|
|
1201
|
+
partAny.__untilState = undefined;
|
|
1202
|
+
}
|
|
1203
|
+
if (partAny.__watchSub) {
|
|
1204
|
+
teardownWatch(partAny);
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
if (!part.child) return;
|
|
1208
|
+
const c = /** @type any */ (part.child);
|
|
1209
|
+
if (c.kind === 'repeat') {
|
|
1210
|
+
teardownRepeat(c);
|
|
1211
|
+
} else if (c.kind === 'async-stream') {
|
|
1212
|
+
teardownAsyncStream(c);
|
|
1213
|
+
} else if ('strings' in c) {
|
|
1214
|
+
const inst = /** @type TemplateInstance */ (part.child);
|
|
1215
|
+
disposeInstance(inst);
|
|
1216
|
+
removeBetween(inst.startNode, inst.endNode);
|
|
1217
|
+
} else {
|
|
1218
|
+
for (const n of /** @type ChildNode[] */ (part.child)) {
|
|
1219
|
+
if (n.parentNode) n.parentNode.removeChild(n);
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
part.child = undefined;
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
/**
|
|
1226
|
+
* Clear per-part directive state slots that don't apply to the value
|
|
1227
|
+
* currently being rendered. Prevents stale `__guardDeps` from short-
|
|
1228
|
+
* circuiting a render when the directive at this position is no longer
|
|
1229
|
+
* a guard, stale `__cacheMap` from accumulating across non-cache
|
|
1230
|
+
* renders, and stale `__untilState` from letting a prior Promise
|
|
1231
|
+
* resolution overwrite newer DOM.
|
|
1232
|
+
*
|
|
1233
|
+
* @param {Extract<BoundPart, {kind:'child'}>} part
|
|
1234
|
+
* @param {unknown} value
|
|
1235
|
+
*/
|
|
1236
|
+
function clearStaleDirectiveState(part, value) {
|
|
1237
|
+
const partAny = /** @type any */ (part);
|
|
1238
|
+
if (partAny.__untilState && !isUntil(value)) {
|
|
1239
|
+
partAny.__untilState.aborted = true;
|
|
1240
|
+
partAny.__untilState = undefined;
|
|
1241
|
+
}
|
|
1242
|
+
if (partAny.__guardDeps !== undefined && !isGuard(value)) {
|
|
1243
|
+
partAny.__guardDeps = undefined;
|
|
1244
|
+
}
|
|
1245
|
+
if (partAny.__cacheMap && !isCache(value)) {
|
|
1246
|
+
partAny.__cacheMap = undefined;
|
|
1247
|
+
}
|
|
1248
|
+
if (partAny.__keyedKey !== undefined && !isKeyed(value)) {
|
|
1249
|
+
partAny.__keyedKey = undefined;
|
|
1250
|
+
}
|
|
1251
|
+
if (partAny.__watchSub && !isWatch(value)) {
|
|
1252
|
+
teardownWatch(partAny);
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
/* ================================================================
|
|
1257
|
+
* Cache directive: detach + retain prior template instances so that
|
|
1258
|
+
* toggling between sub-templates preserves their DOM state.
|
|
1259
|
+
* ================================================================ */
|
|
1260
|
+
|
|
1261
|
+
/**
|
|
1262
|
+
* Apply the `cache` directive at a child position. The cache is stored
|
|
1263
|
+
* on the part as `__cacheMap: Map<strings, { inst, holderFrag }>`.
|
|
1264
|
+
*
|
|
1265
|
+
* When the new inner value is a template whose `strings` already lives
|
|
1266
|
+
* in the cache map, re-attach the stashed nodes before the marker and
|
|
1267
|
+
* reconcile values against the new template. When the new inner is a
|
|
1268
|
+
* template whose strings aren't cached, stash the currently-attached
|
|
1269
|
+
* instance (if any) into the cache map before rendering the new one.
|
|
1270
|
+
*
|
|
1271
|
+
* Non-template inner values fall through to the generic applyChild path
|
|
1272
|
+
* (after first stashing any currently-attached cached instance).
|
|
1273
|
+
*
|
|
1274
|
+
* @param {Extract<BoundPart, {kind:'child'}>} part
|
|
1275
|
+
* @param {unknown} inner
|
|
1276
|
+
*/
|
|
1277
|
+
function applyCache(part, inner) {
|
|
1278
|
+
const marker = part.marker;
|
|
1279
|
+
const partAny = /** @type any */ (part);
|
|
1280
|
+
/** @type {Map<TemplateStringsArray, { inst: TemplateInstance, holder: DocumentFragment }>} */
|
|
1281
|
+
let cacheMap = partAny.__cacheMap;
|
|
1282
|
+
if (!cacheMap) {
|
|
1283
|
+
cacheMap = new Map();
|
|
1284
|
+
partAny.__cacheMap = cacheMap;
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
const currentChild = /** @type any */ (part.child);
|
|
1288
|
+
const currentIsInstance = currentChild && 'strings' in currentChild;
|
|
1289
|
+
|
|
1290
|
+
// If the currently-attached child IS a template instance, decide
|
|
1291
|
+
// whether to update-in-place, stash for later, or destroy.
|
|
1292
|
+
if (currentIsInstance) {
|
|
1293
|
+
const currentInst = /** @type TemplateInstance */ (currentChild);
|
|
1294
|
+
|
|
1295
|
+
// Same template structure: reconcile values, no detach/re-attach.
|
|
1296
|
+
if (isTemplate(inner) && currentInst.strings === /** @type any */ (inner).strings) {
|
|
1297
|
+
updateInstance(currentInst, /** @type any */ (inner).values);
|
|
1298
|
+
return;
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
// Different shape: detach the current instance into a holder fragment
|
|
1302
|
+
// and store it in the cache map. We keep the existing instance, slot
|
|
1303
|
+
// markers, and rendered nodes; only the parent changes. moveRange's
|
|
1304
|
+
// null anchor means "append to parent".
|
|
1305
|
+
const holder = document.createDocumentFragment();
|
|
1306
|
+
moveRange(currentInst.startNode, currentInst.endNode, holder, null);
|
|
1307
|
+
cacheMap.set(currentInst.strings, { inst: currentInst, holder });
|
|
1308
|
+
part.child = undefined;
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
// Now part.child is either undefined or some non-instance shape (rare;
|
|
1312
|
+
// happens when prior render had a string / array / etc.). For non-
|
|
1313
|
+
// instance shapes, fall through to the generic teardown via applyChild.
|
|
1314
|
+
|
|
1315
|
+
// If the new inner is a template AND we've cached an instance for its
|
|
1316
|
+
// strings, re-attach it.
|
|
1317
|
+
if (isTemplate(inner)) {
|
|
1318
|
+
const tr = /** @type any */ (inner);
|
|
1319
|
+
const cached = cacheMap.get(tr.strings);
|
|
1320
|
+
if (cached) {
|
|
1321
|
+
cacheMap.delete(tr.strings);
|
|
1322
|
+
// Tear down any non-instance child currently attached (a string /
|
|
1323
|
+
// array of text nodes from a prior cache(non-template) render).
|
|
1324
|
+
// Without this the prior nodes remain in the DOM alongside the
|
|
1325
|
+
// re-attached cached template.
|
|
1326
|
+
if (part.child) {
|
|
1327
|
+
teardownChild(part);
|
|
1328
|
+
}
|
|
1329
|
+
// Move the cached nodes back before the marker.
|
|
1330
|
+
moveRange(cached.inst.startNode, cached.inst.endNode, /** @type Node */ (marker.parentNode), marker);
|
|
1331
|
+
// Reconcile values so any state changes since detachment apply.
|
|
1332
|
+
updateInstance(cached.inst, tr.values);
|
|
1333
|
+
part.child = cached.inst;
|
|
1334
|
+
return;
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
// No cached instance available. Render the new inner value via the
|
|
1339
|
+
// standard applyChild path. The currentIsInstance branch already
|
|
1340
|
+
// handled detaching the prior instance; if part.child still holds a
|
|
1341
|
+
// non-instance shape, applyChild will tear it down generically.
|
|
1342
|
+
applyChildInner(part, inner);
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
/* ================================================================
|
|
1346
|
+
* Until directive: render highest-priority resolved candidate.
|
|
1347
|
+
* ================================================================ */
|
|
1348
|
+
|
|
1349
|
+
/**
|
|
1350
|
+
* Apply the `until` directive at a child position.
|
|
1351
|
+
*
|
|
1352
|
+
* Priority is left-to-right: args[0] has the highest priority. The
|
|
1353
|
+
* highest-priority synchronous candidate (if any) renders immediately.
|
|
1354
|
+
* Strictly-higher-priority Promises are awaited in the background; when
|
|
1355
|
+
* one resolves AND no higher-priority Promise has already resolved, its
|
|
1356
|
+
* result becomes the rendered value.
|
|
1357
|
+
*
|
|
1358
|
+
* The directive's state lives on `part.__untilState` (a stable slot
|
|
1359
|
+
* that survives `applyChild`'s overwrites of `part.child`). When a new
|
|
1360
|
+
* render replaces the directive, the prior state's `aborted` flag flips
|
|
1361
|
+
* to `true` so any in-flight Promise resolutions short-circuit instead
|
|
1362
|
+
* of overwriting newer DOM.
|
|
1363
|
+
*
|
|
1364
|
+
* @param {Extract<BoundPart, {kind:'child'}>} part
|
|
1365
|
+
* @param {readonly unknown[]} args
|
|
1366
|
+
*/
|
|
1367
|
+
function applyUntil(part, args) {
|
|
1368
|
+
// Carry forward the prior render's `highestResolved` ONLY when the
|
|
1369
|
+
// args list is unchanged. When any argument identity changes, prior
|
|
1370
|
+
// priorities no longer apply (a Promise that won at index 0 may now
|
|
1371
|
+
// sit at a different index, or have been replaced entirely); the
|
|
1372
|
+
// state must reset to Infinity so the new args' Promises can compete.
|
|
1373
|
+
//
|
|
1374
|
+
// For TemplateResult args, compare by `strings` array identity rather
|
|
1375
|
+
// than the wrapper object identity. `html\`loading...\`` evaluates to
|
|
1376
|
+
// a fresh TemplateResult on every call but the strings array is
|
|
1377
|
+
// interned per call site, so the conceptual value is unchanged.
|
|
1378
|
+
const partAny = /** @type any */ (part);
|
|
1379
|
+
const prevState = partAny.__untilState;
|
|
1380
|
+
const prevArgs = partAny.__untilArgs;
|
|
1381
|
+
const argEq = (a, b) => {
|
|
1382
|
+
if (Object.is(a, b)) return true;
|
|
1383
|
+
if (isTemplate(a) && isTemplate(b)
|
|
1384
|
+
&& /** @type any */ (a).strings === /** @type any */ (b).strings) return true;
|
|
1385
|
+
return false;
|
|
1386
|
+
};
|
|
1387
|
+
const argsEqual = prevArgs && prevArgs.length === args.length
|
|
1388
|
+
&& prevArgs.every((a, i) => argEq(a, args[i]));
|
|
1389
|
+
const carriedHighest = argsEqual && prevState ? prevState.highestResolved : Infinity;
|
|
1390
|
+
if (prevState) prevState.aborted = true;
|
|
1391
|
+
partAny.__untilArgs = args.slice();
|
|
1392
|
+
|
|
1393
|
+
/** @type {{aborted:boolean, highestResolved:number}} */
|
|
1394
|
+
const state = { aborted: false, highestResolved: carriedHighest };
|
|
1395
|
+
partAny.__untilState = state;
|
|
1396
|
+
|
|
1397
|
+
// Highest-priority synchronous candidate. If found, render it now
|
|
1398
|
+
// and cap further Promise subscription to strictly-higher priorities.
|
|
1399
|
+
let firstSyncIdx = -1;
|
|
1400
|
+
let firstSyncVal = undefined;
|
|
1401
|
+
for (let i = 0; i < args.length; i++) {
|
|
1402
|
+
const a = args[i];
|
|
1403
|
+
if (!a || typeof (/** @type any */ (a).then) !== 'function') {
|
|
1404
|
+
firstSyncIdx = i;
|
|
1405
|
+
firstSyncVal = a;
|
|
1406
|
+
break;
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
if (firstSyncIdx !== -1 && firstSyncIdx <= state.highestResolved) {
|
|
1411
|
+
// The sync candidate beats any previously-rendered Promise value
|
|
1412
|
+
// (when firstSyncIdx < state.highestResolved) OR re-renders the
|
|
1413
|
+
// sync fallback at the same priority slot (when ===), in case its
|
|
1414
|
+
// value changed between renders.
|
|
1415
|
+
applyChildInner(part, firstSyncVal);
|
|
1416
|
+
state.highestResolved = firstSyncIdx;
|
|
1417
|
+
} else if (firstSyncIdx === -1 && !partAny.__untilEverRendered) {
|
|
1418
|
+
// First-ever render of this part with all-Promise args: render
|
|
1419
|
+
// empty as the initial fallback while Promises settle.
|
|
1420
|
+
applyChildInner(part, '');
|
|
1421
|
+
}
|
|
1422
|
+
// Else: either there is no sync candidate but the part has rendered
|
|
1423
|
+
// before (preserve existing DOM until a Promise resolves), OR the
|
|
1424
|
+
// sync candidate is lower-priority than what's already rendered.
|
|
1425
|
+
// Either way: leave the existing DOM in place. This prevents the
|
|
1426
|
+
// "all-Promises wipes prior content" flash on re-renders.
|
|
1427
|
+
partAny.__untilEverRendered = true;
|
|
1428
|
+
|
|
1429
|
+
// Subscribe to Promises with priority strictly less than what's
|
|
1430
|
+
// currently rendered. (Lower index = higher priority in lit's model.)
|
|
1431
|
+
// Each subscription wraps in Promise.resolve() so synchronous
|
|
1432
|
+
// thenables get a microtask boundary, matching lit's contract that
|
|
1433
|
+
// all Promise/thenable resolutions are deferred.
|
|
1434
|
+
const cap = firstSyncIdx === -1
|
|
1435
|
+
? Math.min(args.length, state.highestResolved)
|
|
1436
|
+
: Math.min(firstSyncIdx, state.highestResolved);
|
|
1437
|
+
for (let i = 0; i < cap; i++) {
|
|
1438
|
+
const a = args[i];
|
|
1439
|
+
if (!a || typeof (/** @type any */ (a).then) !== 'function') continue;
|
|
1440
|
+
|
|
1441
|
+
Promise.resolve(/** @type Promise<unknown> */ (a)).then(
|
|
1442
|
+
(resolved) => {
|
|
1443
|
+
if (state.aborted) return;
|
|
1444
|
+
if (i >= state.highestResolved) return;
|
|
1445
|
+
state.highestResolved = i;
|
|
1446
|
+
applyChildInner(part, resolved);
|
|
1447
|
+
},
|
|
1448
|
+
() => {
|
|
1449
|
+
// Swallow rejection. A rejected Promise is treated as "no value";
|
|
1450
|
+
// the existing render stays in place.
|
|
1451
|
+
},
|
|
1452
|
+
);
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
/**
|
|
1457
|
+
* Abort an `until` directive's in-flight Promise tracking. Called from
|
|
1458
|
+
* `teardownChild` when the part is being reset.
|
|
1459
|
+
* @param {{aborted:boolean}} state
|
|
1460
|
+
*/
|
|
1461
|
+
function teardownUntil(state) {
|
|
1462
|
+
state.aborted = true;
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
/* ================================================================
|
|
1466
|
+
* watch (signal binding): fine-grained reactive part.
|
|
1467
|
+
* ================================================================ */
|
|
1468
|
+
|
|
1469
|
+
/**
|
|
1470
|
+
* Bind a child part to a signal. Reads the signal once and writes its
|
|
1471
|
+
* value into the part. Installs a per-part `Signal.subtle.Watcher`
|
|
1472
|
+
* that, on signal change, re-reads and re-applies the value WITHOUT
|
|
1473
|
+
* re-running the host component's render(). When the part is torn
|
|
1474
|
+
* down (teardownChild) the watcher is disposed.
|
|
1475
|
+
*
|
|
1476
|
+
* The signal read happens inside the watcher's `observe()`, so the
|
|
1477
|
+
* dependency edge connects the signal to THIS watcher. The host's
|
|
1478
|
+
* own render watcher is outside the active stack here, so the host
|
|
1479
|
+
* does not also subscribe to the signal (which would double-fire as
|
|
1480
|
+
* both a full re-render and a watch update).
|
|
1481
|
+
*
|
|
1482
|
+
* @param {Extract<BoundPart, {kind:'child'}>} part
|
|
1483
|
+
* @param {{ get: () => unknown, __isSignal: true }} sig
|
|
1484
|
+
*/
|
|
1485
|
+
function applyWatch(part, sig) {
|
|
1486
|
+
const partAny = /** @type any */ (part);
|
|
1487
|
+
// Same signal as last render: refresh dep tracking via observe (the
|
|
1488
|
+
// spec-aligned Watcher fires once per arm, so re-observing re-arms).
|
|
1489
|
+
if (partAny.__watchSig === sig && partAny.__watchSub) {
|
|
1490
|
+
let value;
|
|
1491
|
+
partAny.__watchSub.observe(() => { value = sig.get(); });
|
|
1492
|
+
applyChildInner(part, value);
|
|
1493
|
+
return;
|
|
1494
|
+
}
|
|
1495
|
+
// Signal changed (or first render). Tear down any prior watcher.
|
|
1496
|
+
if (partAny.__watchSub) {
|
|
1497
|
+
partAny.__watchSub.dispose();
|
|
1498
|
+
partAny.__watchSub = undefined;
|
|
1499
|
+
}
|
|
1500
|
+
partAny.__watchSig = sig;
|
|
1501
|
+
// Notify defers to a microtask because the spec forbids signal reads
|
|
1502
|
+
// inside the notify itself. The microtask re-observes (re-arms + re-
|
|
1503
|
+
// records the dep) and applies the new value to the part.
|
|
1504
|
+
const watcher = new Signal.subtle.Watcher(() => {
|
|
1505
|
+
queueMicrotask(() => {
|
|
1506
|
+
if (partAny.__watchSub !== watcher) return; // disposed mid-flight
|
|
1507
|
+
let v;
|
|
1508
|
+
watcher.observe(() => { v = sig.get(); });
|
|
1509
|
+
applyChildInner(part, v);
|
|
1510
|
+
});
|
|
1511
|
+
});
|
|
1512
|
+
partAny.__watchSub = watcher;
|
|
1513
|
+
let initial;
|
|
1514
|
+
watcher.observe(() => { initial = sig.get(); });
|
|
1515
|
+
applyChildInner(part, initial);
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
/**
|
|
1519
|
+
* Dispose a `watch` directive's per-part watcher. Called from
|
|
1520
|
+
* `teardownChild` and from `clearStaleDirectiveState` when the value
|
|
1521
|
+
* at the part is no longer a watch.
|
|
1522
|
+
* @param {any} partAny
|
|
1523
|
+
*/
|
|
1524
|
+
function teardownWatch(partAny) {
|
|
1525
|
+
if (partAny.__watchSub) {
|
|
1526
|
+
partAny.__watchSub.dispose();
|
|
1527
|
+
partAny.__watchSub = undefined;
|
|
1528
|
+
partAny.__watchSig = undefined;
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
/* ================================================================
|
|
1533
|
+
* asyncAppend / asyncReplace: stream from AsyncIterable.
|
|
1534
|
+
* ================================================================ */
|
|
1535
|
+
|
|
1536
|
+
/**
|
|
1537
|
+
* Apply `asyncAppend(iterable, mapper?)` at a child position.
|
|
1538
|
+
*
|
|
1539
|
+
* Iterates the AsyncIterable in the background. Each yielded value is
|
|
1540
|
+
* mapped (optional) and rendered as a node group, appended before the
|
|
1541
|
+
* marker. The state is stored on `part.child` so `teardownChild` can
|
|
1542
|
+
* abort the iteration when the part is reset.
|
|
1543
|
+
*
|
|
1544
|
+
* @param {Extract<BoundPart, {kind:'child'}>} part
|
|
1545
|
+
* @param {{ iterable: AsyncIterable<unknown>, mapper?: (v: unknown, i: number) => unknown }} dir
|
|
1546
|
+
*/
|
|
1547
|
+
function applyAsyncAppend(part, dir) {
|
|
1548
|
+
// Same-iterable short-circuit: if the prior render's iterable identity
|
|
1549
|
+
// matches, the existing iterator is still consuming it. Re-subscribing
|
|
1550
|
+
// would start a fresh iterator that misses already-yielded values.
|
|
1551
|
+
// Matches lit-html's behavior.
|
|
1552
|
+
const currentChild = /** @type any */ (part.child);
|
|
1553
|
+
if (currentChild && currentChild.kind === 'async-stream'
|
|
1554
|
+
&& currentChild.mode === 'append'
|
|
1555
|
+
&& currentChild.iterable === dir.iterable) {
|
|
1556
|
+
return;
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
teardownChild(part);
|
|
1560
|
+
|
|
1561
|
+
const iterator = /** @type AsyncIterator<unknown> */ (
|
|
1562
|
+
dir.iterable[Symbol.asyncIterator]()
|
|
1563
|
+
);
|
|
1564
|
+
/** @type {AsyncStreamState} */
|
|
1565
|
+
const state = {
|
|
1566
|
+
kind: 'async-stream',
|
|
1567
|
+
mode: 'append',
|
|
1568
|
+
aborted: false,
|
|
1569
|
+
iterable: dir.iterable,
|
|
1570
|
+
iterator,
|
|
1571
|
+
/** @type {ChildNode[]} */ nodes: [],
|
|
1572
|
+
};
|
|
1573
|
+
part.child = state;
|
|
1574
|
+
|
|
1575
|
+
consumeAsyncStream(state, part, dir);
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
/**
|
|
1579
|
+
* Apply `asyncReplace(iterable, mapper?)` at a child position. Same as
|
|
1580
|
+
* `applyAsyncAppend` but each new value replaces the previous content.
|
|
1581
|
+
*
|
|
1582
|
+
* @param {Extract<BoundPart, {kind:'child'}>} part
|
|
1583
|
+
* @param {{ iterable: AsyncIterable<unknown>, mapper?: (v: unknown, i: number) => unknown }} dir
|
|
1584
|
+
*/
|
|
1585
|
+
function applyAsyncReplace(part, dir) {
|
|
1586
|
+
// Same-iterable short-circuit: see comment in applyAsyncAppend.
|
|
1587
|
+
const currentChild = /** @type any */ (part.child);
|
|
1588
|
+
if (currentChild && currentChild.kind === 'async-stream'
|
|
1589
|
+
&& currentChild.mode === 'replace'
|
|
1590
|
+
&& currentChild.iterable === dir.iterable) {
|
|
1591
|
+
return;
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
teardownChild(part);
|
|
1595
|
+
|
|
1596
|
+
const iterator = /** @type AsyncIterator<unknown> */ (
|
|
1597
|
+
dir.iterable[Symbol.asyncIterator]()
|
|
1598
|
+
);
|
|
1599
|
+
/** @type {AsyncStreamState} */
|
|
1600
|
+
const state = {
|
|
1601
|
+
kind: 'async-stream',
|
|
1602
|
+
mode: 'replace',
|
|
1603
|
+
aborted: false,
|
|
1604
|
+
iterable: dir.iterable,
|
|
1605
|
+
iterator,
|
|
1606
|
+
/** @type {ChildNode[]} */ nodes: [],
|
|
1607
|
+
};
|
|
1608
|
+
part.child = state;
|
|
1609
|
+
|
|
1610
|
+
consumeAsyncStream(state, part, dir);
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
/**
|
|
1614
|
+
* @typedef {{
|
|
1615
|
+
* kind: 'async-stream',
|
|
1616
|
+
* mode: 'append' | 'replace',
|
|
1617
|
+
* aborted: boolean,
|
|
1618
|
+
* iterable: AsyncIterable<unknown>,
|
|
1619
|
+
* iterator: AsyncIterator<unknown>,
|
|
1620
|
+
* nodes: ChildNode[],
|
|
1621
|
+
* }} AsyncStreamState
|
|
1622
|
+
*/
|
|
1623
|
+
|
|
1624
|
+
/**
|
|
1625
|
+
* Consume an AsyncIterable for `asyncAppend` / `asyncReplace`. Drives
|
|
1626
|
+
* the iterator with an explicit `.next()` loop (rather than `for await`)
|
|
1627
|
+
* so that `teardownAsyncStream` can call `iterator.return()` to break
|
|
1628
|
+
* a generator parked on an `await`. The `aborted` flag is also checked
|
|
1629
|
+
* after every `next()` resolve to short-circuit if abortion happened
|
|
1630
|
+
* while the iterator was suspended.
|
|
1631
|
+
*
|
|
1632
|
+
* @param {AsyncStreamState} state
|
|
1633
|
+
* @param {Extract<BoundPart, {kind:'child'}>} part
|
|
1634
|
+
* @param {{ iterable: AsyncIterable<unknown>, mapper?: (v: unknown, i: number) => unknown }} dir
|
|
1635
|
+
*/
|
|
1636
|
+
async function consumeAsyncStream(state, part, dir) {
|
|
1637
|
+
const marker = part.marker;
|
|
1638
|
+
let i = 0;
|
|
1639
|
+
try {
|
|
1640
|
+
while (!state.aborted) {
|
|
1641
|
+
const result = await state.iterator.next();
|
|
1642
|
+
if (state.aborted) break;
|
|
1643
|
+
if (result.done) break;
|
|
1644
|
+
const mapped = dir.mapper ? dir.mapper(result.value, i) : result.value;
|
|
1645
|
+
const newNodes = renderToNodes(mapped);
|
|
1646
|
+
|
|
1647
|
+
if (state.mode === 'replace') {
|
|
1648
|
+
for (const n of state.nodes) {
|
|
1649
|
+
if (n.parentNode) n.parentNode.removeChild(n);
|
|
1650
|
+
}
|
|
1651
|
+
state.nodes = [];
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
const frag = document.createDocumentFragment();
|
|
1655
|
+
for (const n of newNodes) frag.appendChild(n);
|
|
1656
|
+
marker.parentNode?.insertBefore(frag, marker);
|
|
1657
|
+
state.nodes.push(...newNodes);
|
|
1658
|
+
|
|
1659
|
+
i++;
|
|
1660
|
+
}
|
|
1661
|
+
} catch (err) {
|
|
1662
|
+
// Swallow iteration errors. A leaked iterator throwing should not
|
|
1663
|
+
// crash the host's render cycle. Authors who care about errors
|
|
1664
|
+
// should handle them in their iterable / generator.
|
|
1665
|
+
if (typeof console !== 'undefined') console.error('[webjs] asyncStream error:', err);
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
/**
|
|
1670
|
+
* Render a single value into a flat list of DOM nodes for insertion via
|
|
1671
|
+
* insertBefore. Handles strings, numbers, TemplateResult, and arrays.
|
|
1672
|
+
* @param {unknown} value
|
|
1673
|
+
* @returns {ChildNode[]}
|
|
1674
|
+
*/
|
|
1675
|
+
function renderToNodes(value) {
|
|
1676
|
+
if (value == null || value === false || value === true) return [];
|
|
1677
|
+
if (isTemplate(value)) {
|
|
1678
|
+
const tr = /** @type any */ (value);
|
|
1679
|
+
const { templateEl, parts } = compile(tr);
|
|
1680
|
+
const frag = /** @type DocumentFragment */ (templateEl.content.cloneNode(true));
|
|
1681
|
+
const bound = parts.map((p) => bindPart(p, frag));
|
|
1682
|
+
for (let i = 0; i < tr.values.length; i++) {
|
|
1683
|
+
applyPart(bound[i], tr.values[i], undefined, tr.values);
|
|
1684
|
+
}
|
|
1685
|
+
return [...frag.childNodes];
|
|
1686
|
+
}
|
|
1687
|
+
if (Array.isArray(value)) {
|
|
1688
|
+
const nodes = [];
|
|
1689
|
+
for (const v of value) nodes.push(...renderToNodes(v));
|
|
1690
|
+
return nodes;
|
|
1691
|
+
}
|
|
1692
|
+
return [document.createTextNode(String(value))];
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
/**
|
|
1696
|
+
* Abort an async-stream directive. Sets `aborted = true` (so the next
|
|
1697
|
+
* `await iterator.next()` resolution short-circuits), removes all nodes
|
|
1698
|
+
* rendered so far, and explicitly calls `iterator.return()` so a
|
|
1699
|
+
* generator parked on `await` can unwind via its `finally` blocks
|
|
1700
|
+
* instead of leaking.
|
|
1701
|
+
* @param {AsyncStreamState} state
|
|
1702
|
+
*/
|
|
1703
|
+
function teardownAsyncStream(state) {
|
|
1704
|
+
state.aborted = true;
|
|
1705
|
+
for (const n of state.nodes) {
|
|
1706
|
+
if (n.parentNode) n.parentNode.removeChild(n);
|
|
1707
|
+
}
|
|
1708
|
+
state.nodes = [];
|
|
1709
|
+
// Best-effort iterator cleanup. `.return()` is optional on AsyncIterators;
|
|
1710
|
+
// generators built via `async function*` provide it and run their
|
|
1711
|
+
// `finally` blocks. Swallow any rejection so teardown can't throw.
|
|
1712
|
+
try {
|
|
1713
|
+
state.iterator.return?.()?.catch?.(() => {});
|
|
1714
|
+
} catch {
|
|
1715
|
+
// ignore
|
|
1716
|
+
}
|
|
1717
|
+
}
|