custom-elements-ts 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintrc.json +3 -2
- package/README.md +10 -3
- package/assets/readme-header.png +0 -0
- package/assets/social-preview.jpg +0 -0
- package/demos/site/event-log/event-log.element.ts +3 -14
- package/demos/site/index.html +39 -17
- package/demos/site/index.ts +6 -0
- package/demos/site/llms.txt +53 -0
- package/demos/site/og-image.png +0 -0
- package/demos/site/scroll-restoration.ts +28 -0
- package/demos/site/source-toggle/source-toggle.ts +88 -0
- package/demos/site/source-viewer/source-viewer.element.scss +292 -0
- package/demos/site/source-viewer/source-viewer.element.ts +138 -0
- package/demos/site/source-viewer/sources.generated.ts +11 -0
- package/demos/site/styles/site.css +144 -32
- package/demos/todo-dashboard/todo-dashboard.element.scss +8 -3
- package/demos/todo-dashboard/todo-dashboard.element.ts +1 -0
- package/demos/todo-dashboard/todo-item.element.ts +1 -0
- package/package.json +3 -3
- package/src/index.ts +3 -2
- package/src/signal.ts +66 -0
- package/src/state.ts +20 -8
- package/src/template-runtime.ts +404 -56
- package/tests/map-shallow-state.spec.ts +184 -0
- package/tests/signal.spec.ts +117 -0
- package/tools/build.js +14 -5
- package/tools/generate-sources.js +76 -0
- package/tools/rollup-config.js +2 -2
- package/tools/start.js +13 -7
package/src/template-runtime.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { Signal, isSignal, signalTargetCollected } from './signal';
|
|
2
|
+
|
|
1
3
|
export type PrimitiveTemplateValue = string | number | boolean | null | undefined;
|
|
2
4
|
export type TemplateEventHandler = (event: any) => void;
|
|
3
5
|
|
|
@@ -6,10 +8,86 @@ export type TemplateValue =
|
|
|
6
8
|
| Node
|
|
7
9
|
| TemplateResult
|
|
8
10
|
| TemplateValue[]
|
|
11
|
+
| MappedTemplates<unknown>
|
|
12
|
+
| Signal<unknown>
|
|
9
13
|
| (() => TemplateValue)
|
|
10
14
|
| TemplateEventHandler
|
|
11
15
|
| EventListenerObject;
|
|
12
16
|
|
|
17
|
+
/** Minimal WeakRef typing so the library can keep targeting ES5 lib. */
|
|
18
|
+
declare const WeakRef:
|
|
19
|
+
| (new <T extends object>(target: T) => { deref(): T | undefined })
|
|
20
|
+
| undefined;
|
|
21
|
+
|
|
22
|
+
const createRef = <T extends object>(target: T): { deref(): T | undefined } =>
|
|
23
|
+
typeof WeakRef === 'function' ? new WeakRef(target) : { deref: () => target };
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Subscribes a DOM write to a signal and performs the initial write. The
|
|
27
|
+
* subscriber holds the node behind a WeakRef: once the node is garbage
|
|
28
|
+
* collected, the next notification unsubscribes it automatically (see
|
|
29
|
+
* signalTargetCollected), so wholesale-discarded subtrees never leak
|
|
30
|
+
* subscriptions.
|
|
31
|
+
*/
|
|
32
|
+
const bindSignal = <N extends object>(
|
|
33
|
+
sig: Signal<unknown>,
|
|
34
|
+
target: N,
|
|
35
|
+
write: (node: N, value: unknown) => void
|
|
36
|
+
): (() => void) => {
|
|
37
|
+
write(target, sig.value);
|
|
38
|
+
const ref = createRef(target);
|
|
39
|
+
const observer = (value: unknown) => {
|
|
40
|
+
const node = ref.deref();
|
|
41
|
+
if (!node) {
|
|
42
|
+
throw signalTargetCollected;
|
|
43
|
+
}
|
|
44
|
+
write(node, value);
|
|
45
|
+
};
|
|
46
|
+
sig.subscribe(observer);
|
|
47
|
+
return () => sig.unsubscribe(observer);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const writeSignalText = (node: Text, value: unknown): void => {
|
|
51
|
+
node.nodeValue = value === null || value === undefined ? '' : String(value);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const applyAttribute = (element: Element, name: string, value: unknown): void => {
|
|
55
|
+
if (value === false || value === null || value === undefined) {
|
|
56
|
+
element.removeAttribute(name);
|
|
57
|
+
} else {
|
|
58
|
+
element.setAttribute(name, String(value));
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A lazily-mapped list, as returned by `map()`. Handing the list and the
|
|
64
|
+
* mapping function to the renderer (instead of an array of TemplateResults)
|
|
65
|
+
* lets it skip rows whose item is identical (`===`) to the item the row was
|
|
66
|
+
* built from — no template call, no part updates, no visit.
|
|
67
|
+
*/
|
|
68
|
+
export interface MappedTemplates<T> {
|
|
69
|
+
readonly items: readonly T[];
|
|
70
|
+
readonly fn: (item: T, index: number) => TemplateValue;
|
|
71
|
+
readonly __customElementsTsMapped: true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const map = <T>(
|
|
75
|
+
items: readonly T[],
|
|
76
|
+
fn: (item: T, index: number) => TemplateValue
|
|
77
|
+
): MappedTemplates<T> => ({
|
|
78
|
+
items,
|
|
79
|
+
fn,
|
|
80
|
+
__customElementsTsMapped: true,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const isMappedTemplates = (value: unknown): value is MappedTemplates<unknown> => {
|
|
84
|
+
return Boolean(
|
|
85
|
+
value &&
|
|
86
|
+
typeof value === 'object' &&
|
|
87
|
+
(value as MappedTemplates<unknown>).__customElementsTsMapped === true
|
|
88
|
+
);
|
|
89
|
+
};
|
|
90
|
+
|
|
13
91
|
export interface TemplateResult {
|
|
14
92
|
readonly strings: TemplateStringsArray;
|
|
15
93
|
readonly values: TemplateValue[];
|
|
@@ -33,9 +111,26 @@ export interface RenderState {
|
|
|
33
111
|
part?: ChildPart;
|
|
34
112
|
}
|
|
35
113
|
|
|
114
|
+
type PartKind = 'child' | 'attr' | 'prop' | 'event';
|
|
115
|
+
|
|
116
|
+
interface PartDescriptor {
|
|
117
|
+
kind: PartKind;
|
|
118
|
+
/** Attribute / property / event name. Empty string for child parts. */
|
|
119
|
+
name: string;
|
|
120
|
+
/** childNodes index path from the template root to the part's node. */
|
|
121
|
+
path: number[];
|
|
122
|
+
}
|
|
123
|
+
|
|
36
124
|
interface ParsedTemplate {
|
|
37
|
-
|
|
38
|
-
|
|
125
|
+
/** Pristine parsed content, cloned for each instance. */
|
|
126
|
+
content: DocumentFragment;
|
|
127
|
+
/** Part descriptors indexed by expression position. */
|
|
128
|
+
parts: PartDescriptor[];
|
|
129
|
+
/**
|
|
130
|
+
* True when the template has exactly one root element; instances then
|
|
131
|
+
* clone that element directly, skipping a DocumentFragment per clone.
|
|
132
|
+
*/
|
|
133
|
+
singleRoot: boolean;
|
|
39
134
|
}
|
|
40
135
|
|
|
41
136
|
const templateCache = new WeakMap<TemplateStringsArray, ParsedTemplate>();
|
|
@@ -93,19 +188,51 @@ export const renderIntoAnchor = (
|
|
|
93
188
|
|
|
94
189
|
const createTemplateInstance = (result: TemplateResult, host?: unknown): TemplateInstance => {
|
|
95
190
|
const parsed = getParsedTemplate(result.strings);
|
|
96
|
-
const template = document.createElement('template');
|
|
97
|
-
template.innerHTML = parsed.html;
|
|
98
191
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
192
|
+
let root: Node;
|
|
193
|
+
let nodes: ChildNode[];
|
|
194
|
+
if (parsed.singleRoot) {
|
|
195
|
+
root = parsed.content.firstChild!.cloneNode(true);
|
|
196
|
+
nodes = [root as ChildNode];
|
|
197
|
+
} else {
|
|
198
|
+
root = parsed.content.cloneNode(true);
|
|
199
|
+
nodes = Array.from(root.childNodes);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Resolve each precompiled index path directly to its node — no tree
|
|
203
|
+
// walking or attribute scanning per instance.
|
|
204
|
+
const descriptors = parsed.parts;
|
|
205
|
+
const count = descriptors.length;
|
|
206
|
+
const parts: Part[] = new Array(count);
|
|
207
|
+
for (let i = 0; i < count; i++) {
|
|
208
|
+
const d = descriptors[i];
|
|
209
|
+
let node: Node = root;
|
|
210
|
+
const path = d.path;
|
|
211
|
+
for (let j = 0; j < path.length; j++) {
|
|
212
|
+
node = node.childNodes[path[j]];
|
|
213
|
+
}
|
|
214
|
+
if (d.kind === 'child') {
|
|
215
|
+
parts[i] = new ChildPart(node as Comment, host);
|
|
216
|
+
} else if (d.kind === 'event') {
|
|
217
|
+
parts[i] = new EventPart(node as Element, d.name, host);
|
|
218
|
+
} else if (d.kind === 'prop') {
|
|
219
|
+
parts[i] = new PropertyPart(node as Element, d.name);
|
|
220
|
+
} else {
|
|
221
|
+
parts[i] = new AttributePart(node as Element, d.name);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
const values = result.values;
|
|
225
|
+
for (let i = 0; i < count; i++) {
|
|
226
|
+
parts[i].update(values[i]);
|
|
227
|
+
}
|
|
103
228
|
|
|
104
229
|
return {
|
|
105
230
|
strings: result.strings,
|
|
106
231
|
nodes,
|
|
107
|
-
update(
|
|
108
|
-
|
|
232
|
+
update(newValues: TemplateValue[]) {
|
|
233
|
+
for (let i = 0; i < count; i++) {
|
|
234
|
+
parts[i].update(newValues[i]);
|
|
235
|
+
}
|
|
109
236
|
},
|
|
110
237
|
dispose() {
|
|
111
238
|
parts.forEach((part) => part.dispose());
|
|
@@ -130,67 +257,105 @@ const getParsedTemplate = (strings: TemplateStringsArray): ParsedTemplate => {
|
|
|
130
257
|
}
|
|
131
258
|
parsedHtml += strings[strings.length - 1];
|
|
132
259
|
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
};
|
|
260
|
+
const template = document.createElement('template');
|
|
261
|
+
template.innerHTML = parsedHtml;
|
|
262
|
+
const content = template.content;
|
|
137
263
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
if (lastOpen < lastClose) {
|
|
142
|
-
return false;
|
|
143
|
-
}
|
|
144
|
-
return /[^\s<>"'=/]+\s*=\s*["']?$/.test(text);
|
|
145
|
-
};
|
|
264
|
+
// Whitespace-only text nodes inside table-structure elements are never
|
|
265
|
+
// rendered; removing them once here makes every clone smaller and faster.
|
|
266
|
+
stripTableWhitespace(content);
|
|
146
267
|
|
|
147
|
-
|
|
148
|
-
const parts: Part[] = new Array(markers.length);
|
|
268
|
+
// Discover parts once at parse time and record index paths to their nodes.
|
|
149
269
|
const markerToIndex = new Map(markers.map((marker, index) => [marker, index]));
|
|
270
|
+
const descriptors: PartDescriptor[] = new Array(markers.length);
|
|
150
271
|
const walker = document.createTreeWalker(
|
|
151
|
-
|
|
272
|
+
content,
|
|
152
273
|
NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_COMMENT
|
|
153
274
|
);
|
|
154
|
-
|
|
155
275
|
let current = walker.nextNode();
|
|
156
276
|
while (current) {
|
|
157
277
|
if (current.nodeType === Node.COMMENT_NODE) {
|
|
158
|
-
const
|
|
159
|
-
const index = markerToIndex.get(marker);
|
|
278
|
+
const index = markerToIndex.get(current.nodeValue || '');
|
|
160
279
|
if (index !== undefined) {
|
|
161
|
-
|
|
280
|
+
(current as Comment).textContent = '';
|
|
281
|
+
descriptors[index] = { kind: 'child', name: '', path: getNodePath(current, content) };
|
|
162
282
|
}
|
|
163
|
-
} else
|
|
164
|
-
|
|
283
|
+
} else {
|
|
284
|
+
const element = current as Element;
|
|
285
|
+
let elementPath: number[] | undefined;
|
|
286
|
+
Array.from(element.attributes).forEach((attribute) => {
|
|
287
|
+
const index = markerToIndex.get(attribute.value);
|
|
288
|
+
if (index === undefined) {
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
const name = attribute.name;
|
|
292
|
+
element.removeAttribute(name);
|
|
293
|
+
elementPath ||= getNodePath(element, content);
|
|
294
|
+
const kind: PartKind = name.startsWith('@')
|
|
295
|
+
? 'event'
|
|
296
|
+
: name.startsWith('.')
|
|
297
|
+
? 'prop'
|
|
298
|
+
: 'attr';
|
|
299
|
+
descriptors[index] = {
|
|
300
|
+
kind,
|
|
301
|
+
name: kind === 'attr' ? name : name.slice(1),
|
|
302
|
+
path: elementPath,
|
|
303
|
+
};
|
|
304
|
+
});
|
|
165
305
|
}
|
|
166
306
|
current = walker.nextNode();
|
|
167
307
|
}
|
|
168
308
|
|
|
169
|
-
|
|
309
|
+
const singleRoot =
|
|
310
|
+
content.childNodes.length === 1 && content.firstChild!.nodeType === Node.ELEMENT_NODE;
|
|
311
|
+
if (singleRoot) {
|
|
312
|
+
// Paths become relative to the root element instead of the fragment.
|
|
313
|
+
for (const descriptor of descriptors) {
|
|
314
|
+
descriptor?.path.shift();
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const parsed: ParsedTemplate = { content, parts: descriptors, singleRoot };
|
|
319
|
+
templateCache.set(strings, parsed);
|
|
320
|
+
return parsed;
|
|
170
321
|
};
|
|
171
322
|
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
)
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
}
|
|
323
|
+
const getNodePath = (node: Node, root: Node): number[] => {
|
|
324
|
+
const path: number[] = [];
|
|
325
|
+
let current: Node = node;
|
|
326
|
+
while (current !== root) {
|
|
327
|
+
const parent = current.parentNode!;
|
|
328
|
+
path.push(Array.prototype.indexOf.call(parent.childNodes, current));
|
|
329
|
+
current = parent;
|
|
330
|
+
}
|
|
331
|
+
return path.reverse();
|
|
332
|
+
};
|
|
183
333
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
334
|
+
/** Elements whose whitespace-only text children are never rendered. */
|
|
335
|
+
const tableTags = ['TABLE', 'THEAD', 'TBODY', 'TFOOT', 'TR'];
|
|
336
|
+
|
|
337
|
+
const stripTableWhitespace = (el: Node): void => {
|
|
338
|
+
const isTable =
|
|
339
|
+
el.nodeType === Node.ELEMENT_NODE && tableTags.indexOf((el as Element).tagName) !== -1;
|
|
340
|
+
let child = el.firstChild;
|
|
341
|
+
while (child) {
|
|
342
|
+
const next = child.nextSibling;
|
|
343
|
+
if (child.nodeType === Node.ELEMENT_NODE) {
|
|
344
|
+
stripTableWhitespace(child);
|
|
345
|
+
} else if (isTable && child.nodeType === Node.TEXT_NODE && !child.nodeValue!.trim()) {
|
|
346
|
+
(child as ChildNode).remove();
|
|
192
347
|
}
|
|
193
|
-
|
|
348
|
+
child = next;
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
const isAttributePosition = (text: string): boolean => {
|
|
353
|
+
const lastOpen = text.lastIndexOf('<');
|
|
354
|
+
const lastClose = text.lastIndexOf('>');
|
|
355
|
+
if (lastOpen < lastClose) {
|
|
356
|
+
return false;
|
|
357
|
+
}
|
|
358
|
+
return /[^\s<>"'=/]+\s*=\s*["']?$/.test(text);
|
|
194
359
|
};
|
|
195
360
|
|
|
196
361
|
class ChildPart implements Part {
|
|
@@ -198,6 +363,10 @@ class ChildPart implements Part {
|
|
|
198
363
|
private nodes: ChildNode[] = [];
|
|
199
364
|
private templateInstance?: TemplateInstance;
|
|
200
365
|
private arrayItems: Array<{ anchor: Comment; part: ChildPart }> = [];
|
|
366
|
+
/** Items a map() list was last rendered from, aligned with arrayItems. */
|
|
367
|
+
private lastItems: unknown[] | null = null;
|
|
368
|
+
private boundSignal?: Signal<unknown>;
|
|
369
|
+
private unbindSignal?: () => void;
|
|
201
370
|
|
|
202
371
|
constructor(
|
|
203
372
|
private anchor: Comment,
|
|
@@ -206,11 +375,34 @@ class ChildPart implements Part {
|
|
|
206
375
|
|
|
207
376
|
update(value: TemplateValue): void {
|
|
208
377
|
const resolved = resolveValue(value);
|
|
378
|
+
if (this.boundSignal) {
|
|
379
|
+
if (resolved === this.boundSignal) {
|
|
380
|
+
// Same signal as last render: the text node updates itself.
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
this.detachSignal();
|
|
384
|
+
}
|
|
385
|
+
if (isSignal(resolved)) {
|
|
386
|
+
this.updateSignal(resolved);
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
209
389
|
if (resolved === null || resolved === undefined || resolved === false) {
|
|
210
390
|
this.clear();
|
|
211
391
|
return;
|
|
212
392
|
}
|
|
393
|
+
if (isMappedTemplates(resolved)) {
|
|
394
|
+
this.updateMapped(resolved);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
213
397
|
if (Array.isArray(resolved)) {
|
|
398
|
+
this.lastItems = null;
|
|
399
|
+
if (resolved.length === 0) {
|
|
400
|
+
// Clearing a list routes through clear() so it can take the
|
|
401
|
+
// whole-parent fast path instead of removing items one by one.
|
|
402
|
+
this.clear();
|
|
403
|
+
this.kind = 'array';
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
214
406
|
this.updateArray(resolved);
|
|
215
407
|
return;
|
|
216
408
|
}
|
|
@@ -229,7 +421,34 @@ class ChildPart implements Part {
|
|
|
229
421
|
this.clear();
|
|
230
422
|
}
|
|
231
423
|
|
|
424
|
+
private updateSignal(sig: Signal<unknown>): void {
|
|
425
|
+
this.clear();
|
|
426
|
+
const text = document.createTextNode('');
|
|
427
|
+
insertAfter(this.anchor, [text]);
|
|
428
|
+
this.nodes = [text];
|
|
429
|
+
this.kind = 'text';
|
|
430
|
+
this.unbindSignal = bindSignal(sig, text, writeSignalText);
|
|
431
|
+
this.boundSignal = sig;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
private detachSignal(): void {
|
|
435
|
+
this.unbindSignal?.();
|
|
436
|
+
this.unbindSignal = undefined;
|
|
437
|
+
this.boundSignal = undefined;
|
|
438
|
+
}
|
|
439
|
+
|
|
232
440
|
private clear(): void {
|
|
441
|
+
this.detachSignal();
|
|
442
|
+
this.lastItems = null;
|
|
443
|
+
if (this.fastClear()) {
|
|
444
|
+
// The subtree was discarded wholesale; just drop references. Event
|
|
445
|
+
// listeners and nested parts are garbage-collected with their nodes.
|
|
446
|
+
this.templateInstance = undefined;
|
|
447
|
+
this.arrayItems = [];
|
|
448
|
+
this.nodes = [];
|
|
449
|
+
this.kind = 'empty';
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
233
452
|
this.templateInstance?.dispose();
|
|
234
453
|
this.templateInstance = undefined;
|
|
235
454
|
this.arrayItems.forEach((item) => {
|
|
@@ -242,6 +461,29 @@ class ChildPart implements Part {
|
|
|
242
461
|
this.kind = 'empty';
|
|
243
462
|
}
|
|
244
463
|
|
|
464
|
+
/**
|
|
465
|
+
* When this part's content spans its whole parent (the anchor is the
|
|
466
|
+
* parent's first child and the content ends the parent), the parent can be
|
|
467
|
+
* emptied with one textContent write instead of removing every node
|
|
468
|
+
* individually — much faster for large lists.
|
|
469
|
+
*/
|
|
470
|
+
private fastClear(): boolean {
|
|
471
|
+
if (this.kind === 'empty') {
|
|
472
|
+
return false;
|
|
473
|
+
}
|
|
474
|
+
const parent = this.anchor.parentNode;
|
|
475
|
+
if (!parent || this.anchor.previousSibling) {
|
|
476
|
+
return false;
|
|
477
|
+
}
|
|
478
|
+
const end = this.getEndNode();
|
|
479
|
+
if (end === this.anchor || end.nextSibling) {
|
|
480
|
+
return false;
|
|
481
|
+
}
|
|
482
|
+
parent.textContent = '';
|
|
483
|
+
parent.appendChild(this.anchor);
|
|
484
|
+
return true;
|
|
485
|
+
}
|
|
486
|
+
|
|
245
487
|
private updateText(value: string): void {
|
|
246
488
|
if (this.kind === 'text' && this.nodes[0]?.nodeType === Node.TEXT_NODE) {
|
|
247
489
|
if (this.nodes[0].nodeValue !== value) {
|
|
@@ -278,6 +520,84 @@ class ChildPart implements Part {
|
|
|
278
520
|
this.kind = 'template';
|
|
279
521
|
}
|
|
280
522
|
|
|
523
|
+
private updateMapped(mapped: MappedTemplates<unknown>): void {
|
|
524
|
+
const items = mapped.items as unknown[];
|
|
525
|
+
const fn = mapped.fn as (item: unknown, index: number) => TemplateValue;
|
|
526
|
+
const length = items.length;
|
|
527
|
+
|
|
528
|
+
if (length === 0) {
|
|
529
|
+
this.clear();
|
|
530
|
+
this.kind = 'array';
|
|
531
|
+
this.lastItems = [];
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
if (this.kind !== 'array') {
|
|
535
|
+
this.clear();
|
|
536
|
+
this.kind = 'array';
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const last = this.lastItems;
|
|
540
|
+
// Identity skips are only valid while lastItems mirrors arrayItems.
|
|
541
|
+
const canSkip = last !== null && last.length === this.arrayItems.length;
|
|
542
|
+
|
|
543
|
+
// Pure contiguous removal: when identity shows the tail simply shifted
|
|
544
|
+
// left, detach just the removed rows' DOM and keep every other row
|
|
545
|
+
// untouched — instead of rewriting every row after the removal point.
|
|
546
|
+
if (canSkip && last!.length > length) {
|
|
547
|
+
let start = 0;
|
|
548
|
+
while (start < length && last![start] === items[start]) {
|
|
549
|
+
start++;
|
|
550
|
+
}
|
|
551
|
+
const removed = last!.length - length;
|
|
552
|
+
let isShift = true;
|
|
553
|
+
for (let i = start; i < length; i++) {
|
|
554
|
+
if (last![i + removed] !== items[i]) {
|
|
555
|
+
isShift = false;
|
|
556
|
+
break;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
if (isShift) {
|
|
560
|
+
const dropped = this.arrayItems.splice(start, removed);
|
|
561
|
+
for (let i = 0; i < dropped.length; i++) {
|
|
562
|
+
dropped[i].part.dispose();
|
|
563
|
+
dropped[i].anchor.parentNode?.removeChild(dropped[i].anchor);
|
|
564
|
+
}
|
|
565
|
+
last!.splice(start, removed);
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
while (this.arrayItems.length > length) {
|
|
571
|
+
const item = this.arrayItems.pop()!;
|
|
572
|
+
item.part.dispose();
|
|
573
|
+
item.anchor.parentNode?.removeChild(item.anchor);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
for (let index = 0; index < length; index++) {
|
|
577
|
+
let entry = this.arrayItems[index];
|
|
578
|
+
if (!entry) {
|
|
579
|
+
const itemAnchor = document.createComment('');
|
|
580
|
+
insertAfter(this.getEndNode(), [itemAnchor]);
|
|
581
|
+
entry = { anchor: itemAnchor, part: new ChildPart(itemAnchor, this.host) };
|
|
582
|
+
this.arrayItems[index] = entry;
|
|
583
|
+
} else if (canSkip && last![index] === items[index]) {
|
|
584
|
+
// The row still holds the item it was built from: nothing to do.
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
entry.part.update(fn(items[index], index));
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// Remember the items so the next render can identity-skip unchanged rows.
|
|
591
|
+
let copy = this.lastItems;
|
|
592
|
+
if (copy === null || copy.length !== length) {
|
|
593
|
+
copy = new Array(length);
|
|
594
|
+
this.lastItems = copy;
|
|
595
|
+
}
|
|
596
|
+
for (let index = 0; index < length; index++) {
|
|
597
|
+
copy[index] = items[index];
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
281
601
|
private updateArray(values: TemplateValue[]): void {
|
|
282
602
|
if (this.kind !== 'array') {
|
|
283
603
|
this.clear();
|
|
@@ -316,6 +636,7 @@ class ChildPart implements Part {
|
|
|
316
636
|
|
|
317
637
|
class AttributePart implements Part {
|
|
318
638
|
private currentValue: TemplateValue | typeof noValue = noValue;
|
|
639
|
+
private unbindSignal?: () => void;
|
|
319
640
|
|
|
320
641
|
constructor(
|
|
321
642
|
private element: Element,
|
|
@@ -327,15 +648,26 @@ class AttributePart implements Part {
|
|
|
327
648
|
if (Object.is(this.currentValue, resolved)) {
|
|
328
649
|
return;
|
|
329
650
|
}
|
|
651
|
+
if (this.unbindSignal) {
|
|
652
|
+
this.unbindSignal();
|
|
653
|
+
this.unbindSignal = undefined;
|
|
654
|
+
}
|
|
330
655
|
this.currentValue = resolved;
|
|
331
|
-
if (resolved
|
|
332
|
-
this.
|
|
333
|
-
|
|
334
|
-
|
|
656
|
+
if (isSignal(resolved)) {
|
|
657
|
+
const name = this.name;
|
|
658
|
+
this.unbindSignal = bindSignal(resolved, this.element, (el, v) =>
|
|
659
|
+
applyAttribute(el, name, v)
|
|
660
|
+
);
|
|
661
|
+
return;
|
|
335
662
|
}
|
|
663
|
+
applyAttribute(this.element, this.name, resolved);
|
|
336
664
|
}
|
|
337
665
|
|
|
338
666
|
dispose(): void {
|
|
667
|
+
if (this.unbindSignal) {
|
|
668
|
+
this.unbindSignal();
|
|
669
|
+
this.unbindSignal = undefined;
|
|
670
|
+
}
|
|
339
671
|
this.currentValue = noValue;
|
|
340
672
|
this.element.removeAttribute(this.name);
|
|
341
673
|
}
|
|
@@ -343,6 +675,7 @@ class AttributePart implements Part {
|
|
|
343
675
|
|
|
344
676
|
class PropertyPart implements Part {
|
|
345
677
|
private currentValue: TemplateValue | typeof noValue = noValue;
|
|
678
|
+
private unbindSignal?: () => void;
|
|
346
679
|
|
|
347
680
|
constructor(
|
|
348
681
|
private element: Element,
|
|
@@ -354,11 +687,26 @@ class PropertyPart implements Part {
|
|
|
354
687
|
if (Object.is(this.currentValue, resolved)) {
|
|
355
688
|
return;
|
|
356
689
|
}
|
|
690
|
+
if (this.unbindSignal) {
|
|
691
|
+
this.unbindSignal();
|
|
692
|
+
this.unbindSignal = undefined;
|
|
693
|
+
}
|
|
357
694
|
this.currentValue = resolved;
|
|
695
|
+
if (isSignal(resolved)) {
|
|
696
|
+
const name = this.name;
|
|
697
|
+
this.unbindSignal = bindSignal(resolved, this.element, (el, v) => {
|
|
698
|
+
(el as any)[name] = v === null || v === undefined ? '' : v;
|
|
699
|
+
});
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
358
702
|
(this.element as any)[this.name] = resolved === null || resolved === undefined ? '' : resolved;
|
|
359
703
|
}
|
|
360
704
|
|
|
361
705
|
dispose(): void {
|
|
706
|
+
if (this.unbindSignal) {
|
|
707
|
+
this.unbindSignal();
|
|
708
|
+
this.unbindSignal = undefined;
|
|
709
|
+
}
|
|
362
710
|
this.currentValue = noValue;
|
|
363
711
|
(this.element as any)[this.name] = '';
|
|
364
712
|
}
|