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.
@@ -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
- html: string;
38
- markers: string[];
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
- const fragment = document.importNode(template.content, true);
100
- const parts = discoverParts(fragment, parsed.markers, host);
101
- const nodes = Array.from(fragment.childNodes);
102
- parts.forEach((part, index) => part.update(result.values[index]));
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(values: TemplateValue[]) {
108
- parts.forEach((part, index) => part.update(values[index]));
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 parsed = { html: parsedHtml, markers };
134
- templateCache.set(strings, parsed);
135
- return parsed;
136
- };
260
+ const template = document.createElement('template');
261
+ template.innerHTML = parsedHtml;
262
+ const content = template.content;
137
263
 
138
- const isAttributePosition = (text: string): boolean => {
139
- const lastOpen = text.lastIndexOf('<');
140
- const lastClose = text.lastIndexOf('>');
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
- const discoverParts = (fragment: DocumentFragment, markers: string[], host?: unknown): Part[] => {
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
- fragment,
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 marker = current.nodeValue || '';
159
- const index = markerToIndex.get(marker);
278
+ const index = markerToIndex.get(current.nodeValue || '');
160
279
  if (index !== undefined) {
161
- parts[index] = new ChildPart(current as Comment, host);
280
+ (current as Comment).textContent = '';
281
+ descriptors[index] = { kind: 'child', name: '', path: getNodePath(current, content) };
162
282
  }
163
- } else if (current.nodeType === Node.ELEMENT_NODE) {
164
- discoverAttributeParts(current as Element, markerToIndex, parts, host);
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
- return parts;
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 discoverAttributeParts = (
173
- element: Element,
174
- markerToIndex: Map<string, number>,
175
- parts: Part[],
176
- host?: unknown
177
- ) => {
178
- Array.from(element.attributes).forEach((attribute) => {
179
- const index = markerToIndex.get(attribute.value);
180
- if (index === undefined) {
181
- return;
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
- const name = attribute.name;
185
- element.removeAttribute(name);
186
- if (name.startsWith('@')) {
187
- parts[index] = new EventPart(element, name.slice(1), host);
188
- } else if (name.startsWith('.')) {
189
- parts[index] = new PropertyPart(element, name.slice(1));
190
- } else {
191
- parts[index] = new AttributePart(element, name);
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 === false || resolved === null || resolved === undefined) {
332
- this.element.removeAttribute(this.name);
333
- } else {
334
- this.element.setAttribute(this.name, String(resolved));
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
  }