@esportsplus/template 0.16.15 → 0.17.2

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,10 +1,79 @@
1
+ import { NAMESPACE, PACKAGE_NAME } from '../constants';
1
2
  import { plugin } from '@esportsplus/typescript/compiler';
2
- import { PACKAGE_NAME } from '../constants';
3
+
3
4
  import reactivity from '@esportsplus/reactivity/compiler';
4
5
  import template from '..';
5
6
 
6
7
 
7
- export default plugin.vite({
8
- name: PACKAGE_NAME,
9
- plugins: [reactivity, template]
10
- });
8
+ type VitePlugin = {
9
+ configResolved: (config: any) => void;
10
+ enforce: 'pre';
11
+ handleHotUpdate?: (ctx: { file: string; modules: any[] }) => void;
12
+ name: string;
13
+ transform: (code: string, id: string) => { code: string; map: null } | null;
14
+ watchChange: (id: string) => void;
15
+ };
16
+
17
+
18
+ const TEMPLATE_SEARCH = NAMESPACE + '.template(';
19
+
20
+ const TEMPLATE_CALL_REGEX = new RegExp(
21
+ '(const\\s+(\\w+)\\s*=\\s*' + NAMESPACE + '\\.template\\()(`)',
22
+ 'g'
23
+ );
24
+
25
+
26
+ let base = plugin.vite({
27
+ name: PACKAGE_NAME,
28
+ plugins: [reactivity, template]
29
+ });
30
+
31
+
32
+ function injectHMR(code: string, id: string): string {
33
+ let hmrId = id.replace(/\\/g, '/'),
34
+ hotReplace = NAMESPACE + '.createHotTemplate("' + hmrId + '", "',
35
+ injected = code.replace(TEMPLATE_CALL_REGEX, function(_match: string, prefix: string, varName: string, backtick: string) {
36
+ return prefix.replace(TEMPLATE_SEARCH, hotReplace + varName + '", ') + backtick;
37
+ });
38
+
39
+ if (injected === code) {
40
+ return code;
41
+ }
42
+
43
+ injected += '\nif (import.meta.hot) { import.meta.hot.accept(() => { ' + NAMESPACE + '.accept("' + hmrId + '"); }); }';
44
+
45
+ return injected;
46
+ }
47
+
48
+
49
+ export default ({ root }: { root?: string } = {}) => {
50
+ let isDev = false,
51
+ vitePlugin = base({ root });
52
+
53
+ return {
54
+ ...vitePlugin,
55
+ configResolved(config: any) {
56
+ vitePlugin.configResolved(config);
57
+ isDev = config?.command === 'serve' || config?.mode === 'development';
58
+ },
59
+ handleHotUpdate(_ctx: { file: string; modules: any[] }) {
60
+ // Let Vite handle the default HMR flow
61
+ },
62
+ transform(code: string, id: string) {
63
+ let result = vitePlugin.transform(code, id);
64
+
65
+ if (!result || !isDev) {
66
+ return result;
67
+ }
68
+
69
+ let injected = injectHMR(result.code, id);
70
+
71
+ if (injected === result.code) {
72
+ return result;
73
+ }
74
+
75
+ return { code: injected, map: null };
76
+ }
77
+ } satisfies VitePlugin;
78
+ };
79
+
package/src/hmr.ts ADDED
@@ -0,0 +1,70 @@
1
+ let clone = <T extends DocumentFragment | Node>(node: T, deep: boolean = true) => node.cloneNode(deep) as T,
2
+ modules = new Map<string, Map<string, HotTemplate>>(),
3
+ tmpl = typeof document !== 'undefined' ? document.createElement('template') : null;
4
+
5
+
6
+ type HotTemplate = {
7
+ cached: DocumentFragment | undefined;
8
+ factory: () => DocumentFragment;
9
+ html: string;
10
+ };
11
+
12
+
13
+ function invalidate(moduleId: string): void {
14
+ let templates = modules.get(moduleId);
15
+
16
+ if (!templates) {
17
+ return;
18
+ }
19
+
20
+ for (let [, entry] of templates) {
21
+ entry.cached = undefined;
22
+ }
23
+ }
24
+
25
+ function register(moduleId: string, templateId: string, html: string): () => DocumentFragment {
26
+ let entry: HotTemplate = {
27
+ cached: undefined,
28
+ factory: () => {
29
+ if (!entry.cached) {
30
+ let element = tmpl!.cloneNode() as HTMLTemplateElement;
31
+
32
+ element.innerHTML = entry.html;
33
+ entry.cached = element.content;
34
+ }
35
+
36
+ return clone(entry.cached!, true) as DocumentFragment;
37
+ },
38
+ html
39
+ };
40
+
41
+ (modules.get(moduleId) ?? (modules.set(moduleId, new Map()), modules.get(moduleId)!)).set(templateId, entry);
42
+
43
+ return entry.factory;
44
+ }
45
+
46
+
47
+ const accept = (moduleId: string): void => {
48
+ invalidate(moduleId);
49
+ };
50
+
51
+ const createHotTemplate = (moduleId: string, templateId: string, html: string): () => DocumentFragment => {
52
+ let existing = modules.get(moduleId)?.get(templateId);
53
+
54
+ if (existing) {
55
+ existing.cached = undefined;
56
+ existing.html = html;
57
+
58
+ return existing.factory;
59
+ }
60
+
61
+ return register(moduleId, templateId, html);
62
+ };
63
+
64
+ // Test-only: reset state
65
+ const hmrReset = (): void => {
66
+ modules.clear();
67
+ };
68
+
69
+
70
+ export { accept, createHotTemplate, hmrReset, modules };
package/src/index.ts CHANGED
@@ -10,6 +10,7 @@ if (typeof Node !== 'undefined') {
10
10
 
11
11
  export * from './attributes';
12
12
  export * from './event';
13
+ export * from './hmr';
13
14
  export * from './utilities';
14
15
 
15
16
  export { default as html } from './html';
package/src/slot/array.ts CHANGED
@@ -264,7 +264,8 @@ class ArraySlot<T> {
264
264
  return;
265
265
  }
266
266
 
267
- let ref: Node | null = end;
267
+ let ref: Node | null = end,
268
+ useMoveBefore = 'moveBefore' in parent;
268
269
 
269
270
  for (let i = n - 1; i >= 0; i--) {
270
271
  let group = sorted[i];
@@ -279,7 +280,13 @@ class ArraySlot<T> {
279
280
  while (node) {
280
281
  let prev: Node | null = node === group.head ? null : node.previousSibling;
281
282
 
282
- parent.insertBefore(node, ref);
283
+ if (useMoveBefore) {
284
+ (parent as any).moveBefore(node, ref);
285
+ }
286
+ else {
287
+ parent.insertBefore(node, ref);
288
+ }
289
+
283
290
  ref = node;
284
291
  node = prev;
285
292
  }
@@ -304,6 +311,27 @@ class ArraySlot<T> {
304
311
  return;
305
312
  }
306
313
 
314
+ let parent = this.marker.parentNode;
315
+
316
+ if (parent && 'moveBefore' in parent) {
317
+ let ref: Node | null = nodes[0].tail.nextSibling;
318
+
319
+ for (let i = n - 1; i >= 0; i--) {
320
+ let group = nodes[i],
321
+ node: Node | null = group.tail;
322
+
323
+ while (node) {
324
+ let prev: Node | null = node === group.head ? null : node.previousSibling;
325
+
326
+ (parent as any).moveBefore(node, ref);
327
+ ref = node;
328
+ node = prev;
329
+ }
330
+ }
331
+
332
+ return;
333
+ }
334
+
307
335
  for (let i = 0; i < n; i++) {
308
336
  let group = nodes[i],
309
337
  next: Node | null,
@@ -1,4 +1,5 @@
1
1
  import { effect } from '@esportsplus/reactivity';
2
+ import { isAsyncFunction } from '@esportsplus/utilities';
2
3
  import { Element, Renderable, SlotGroup } from '../types';
3
4
  import { raf, text } from '../utilities'
4
5
  import { remove } from './cleanup';
@@ -20,37 +21,50 @@ function read(value: unknown): unknown {
20
21
 
21
22
  class EffectSlot {
22
23
  anchor: Element;
23
- disposer: VoidFunction;
24
+ disposer: VoidFunction | null;
24
25
  group: SlotGroup | null = null;
25
26
  scheduled = false;
26
27
  textnode: Node | null = null;
27
28
 
28
29
 
29
- constructor(anchor: Element, fn: (dispose?: VoidFunction) => Renderable<any>) {
30
- let dispose = fn.length ? () => this.dispose() : undefined,
31
- value: unknown;
32
-
30
+ constructor(anchor: Element, fn: ((...args: any[]) => any)) {
33
31
  this.anchor = anchor;
34
- this.disposer = effect(() => {
35
- value = read( fn(dispose) );
32
+ this.disposer = null;
36
33
 
37
- if (!this.disposer) {
38
- this.update(value);
39
- }
40
- else if (!this.scheduled) {
41
- this.scheduled = true;
34
+ if (isAsyncFunction(fn)) {
35
+ (fn as (fallback: (content: Renderable<any>) => void) => Promise<Renderable<any>>)(
36
+ (content) => this.update(content)
37
+ ).then((value) => this.update(value), () => {});
38
+ }
39
+ else {
40
+ let dispose = fn.length ? () => this.dispose() : undefined,
41
+ value: unknown;
42
+
43
+ this.disposer = effect(() => {
44
+ value = read( fn(dispose) );
42
45
 
43
- raf(() => {
44
- this.scheduled = false;
46
+ if (!this.disposer) {
45
47
  this.update(value);
46
- });
47
- }
48
- });
48
+ }
49
+ else if (!this.scheduled) {
50
+ this.scheduled = true;
51
+
52
+ raf(() => {
53
+ this.scheduled = false;
54
+ this.update(value);
55
+ });
56
+ }
57
+ });
58
+ }
49
59
  }
50
60
 
51
61
 
52
62
  dispose() {
53
- let { anchor, group, textnode } = this;
63
+ let { anchor, disposer, group, textnode } = this;
64
+
65
+ if (!disposer) {
66
+ return;
67
+ }
54
68
 
55
69
  if (textnode) {
56
70
  group = { head: anchor, tail: textnode as Element };
@@ -59,7 +73,7 @@ class EffectSlot {
59
73
  group.head = anchor;
60
74
  }
61
75
 
62
- this.disposer();
76
+ disposer();
63
77
 
64
78
  if (group) {
65
79
  remove(group);
@@ -69,6 +83,8 @@ class EffectSlot {
69
83
  update(value: unknown): void {
70
84
  let { anchor, group, textnode } = this;
71
85
 
86
+ value = read(value);
87
+
72
88
  if (group) {
73
89
  remove(group);
74
90
  this.group = null;
@@ -92,7 +108,17 @@ class EffectSlot {
92
108
  }
93
109
  else {
94
110
  let fragment = render(anchor, value),
111
+ head: Node | null,
112
+ tail: Node | null;
113
+
114
+ if (fragment.nodeType === 11) {
95
115
  head = fragment.firstChild;
116
+ tail = fragment.lastChild;
117
+ }
118
+ else {
119
+ head = fragment;
120
+ tail = fragment;
121
+ }
96
122
 
97
123
  if (textnode?.isConnected) {
98
124
  remove({ head: textnode as Element, tail: textnode as Element });
@@ -101,7 +127,7 @@ class EffectSlot {
101
127
  if (head) {
102
128
  this.group = {
103
129
  head: head as Element,
104
- tail: fragment.lastChild as Element
130
+ tail: tail as Element
105
131
  };
106
132
 
107
133
  anchor.after(fragment);
@@ -1,13 +1,22 @@
1
- import { describe, expect, it, beforeEach } from 'vitest';
1
+ import { describe, expect, it, beforeEach, afterEach } from 'vitest';
2
+ import { signal, read, write } from '@esportsplus/reactivity';
2
3
  import { setList, setProperty, setProperties } from '../src/attributes';
3
4
  import type { Element } from '../src/types';
4
5
 
5
6
 
6
7
  describe('attributes', () => {
7
- let element: HTMLElement & Record<symbol, unknown>;
8
+ let container: HTMLElement,
9
+ element: HTMLElement & Record<symbol, unknown>;
8
10
 
9
11
  beforeEach(() => {
12
+ container = document.createElement('div');
10
13
  element = document.createElement('div') as HTMLElement & Record<symbol, unknown>;
14
+ container.appendChild(element);
15
+ document.body.appendChild(container);
16
+ });
17
+
18
+ afterEach(() => {
19
+ document.body.removeChild(container);
11
20
  });
12
21
 
13
22
  describe('setProperty', () => {
@@ -309,4 +318,135 @@ describe('attributes', () => {
309
318
  expect(element.getAttribute('style')).toContain('color');
310
319
  });
311
320
  });
321
+
322
+ describe('reactive updates (schedule/task path)', () => {
323
+ it('removes stale dynamic class values on reactive update', async () => {
324
+ let s = signal('foo bar');
325
+
326
+ setList(element as unknown as Element, 'class', () => read(s));
327
+
328
+ expect(element.className).toContain('foo');
329
+ expect(element.className).toContain('bar');
330
+
331
+ write(s, 'foo baz');
332
+
333
+ await new Promise(resolve => requestAnimationFrame(resolve));
334
+
335
+ expect(element.className).toContain('foo');
336
+ expect(element.className).toContain('baz');
337
+ expect(element.className).not.toContain('bar');
338
+ });
339
+
340
+ it('removes stale dynamic style values on reactive update', async () => {
341
+ let s = signal('color: red; font-size: 14px');
342
+
343
+ setList(element as unknown as Element, 'style', () => read(s));
344
+
345
+ expect(element.getAttribute('style')).toContain('color: red');
346
+ expect(element.getAttribute('style')).toContain('font-size: 14px');
347
+
348
+ write(s, 'color: blue');
349
+
350
+ await new Promise(resolve => requestAnimationFrame(resolve));
351
+
352
+ expect(element.getAttribute('style')).toContain('color: blue');
353
+ expect(element.getAttribute('style')).not.toContain('font-size: 14px');
354
+ });
355
+
356
+ it('schedules property update via RAF on reactive change', async () => {
357
+ let s = signal('first');
358
+
359
+ setProperty(element as unknown as Element, 'id', () => read(s));
360
+
361
+ expect(element.id).toBe('first');
362
+
363
+ write(s, 'second');
364
+
365
+ await new Promise(resolve => requestAnimationFrame(resolve));
366
+
367
+ expect(element.id).toBe('second');
368
+ });
369
+
370
+ it('batches multiple property updates in single RAF', async () => {
371
+ let s1 = signal('a'),
372
+ s2 = signal('x');
373
+
374
+ setProperty(element as unknown as Element, 'id', () => read(s1));
375
+ setProperty(element as unknown as Element, 'data-value', () => read(s2));
376
+
377
+ expect(element.id).toBe('a');
378
+ expect(element.getAttribute('data-value')).toBe('x');
379
+
380
+ write(s1, 'b');
381
+ write(s2, 'y');
382
+
383
+ await new Promise(resolve => requestAnimationFrame(resolve));
384
+
385
+ expect(element.id).toBe('b');
386
+ expect(element.getAttribute('data-value')).toBe('y');
387
+ });
388
+
389
+ it('clears class via reactive update to empty', async () => {
390
+ let s = signal('foo bar');
391
+
392
+ setList(element as unknown as Element, 'class', () => read(s));
393
+
394
+ expect(element.className).toContain('foo');
395
+
396
+ write(s, '');
397
+
398
+ await new Promise(resolve => requestAnimationFrame(resolve));
399
+
400
+ expect(element.className).not.toContain('foo');
401
+ expect(element.className).not.toContain('bar');
402
+ });
403
+ });
404
+
405
+ describe('setProperties event handler routing', () => {
406
+ it('routes onclick handler function to runtime/delegate', () => {
407
+ let clicked = false;
408
+
409
+ setProperties(element as unknown as Element, {
410
+ onclick: () => { clicked = true; }
411
+ });
412
+
413
+ element.dispatchEvent(new MouseEvent('click', { bubbles: true }));
414
+
415
+ expect(clicked).toBe(true);
416
+ });
417
+
418
+ it('routes onmousedown handler function to runtime/delegate', () => {
419
+ let fired = false;
420
+
421
+ setProperties(element as unknown as Element, {
422
+ onmousedown: () => { fired = true; }
423
+ });
424
+
425
+ element.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
426
+
427
+ expect(fired).toBe(true);
428
+ });
429
+
430
+ it('routes onfocus handler function to runtime/on (direct attach)', () => {
431
+ let focused = false;
432
+
433
+ setProperties(element as unknown as Element, {
434
+ onfocus: () => { focused = true; }
435
+ });
436
+
437
+ element.dispatchEvent(new FocusEvent('focus'));
438
+
439
+ expect(focused).toBe(true);
440
+ });
441
+
442
+ it('routes non-event function property via reactive', () => {
443
+ let s = signal('hello');
444
+
445
+ setProperties(element as unknown as Element, {
446
+ 'data-val': () => read(s)
447
+ });
448
+
449
+ expect(element.getAttribute('data-val')).toBe('hello');
450
+ });
451
+ });
312
452
  });
@@ -270,6 +270,84 @@ describe('compiler/codegen', () => {
270
270
  });
271
271
  });
272
272
 
273
+ describe('generateCode - spread attribute slots (object literal expansion)', () => {
274
+ it('expands plain object literal into individual bindings', () => {
275
+ let { result } = codegen(`let x = html\`<div \${{ id: 'test', class: 'foo' }}>text</div>\`;`);
276
+ let code = result.replacements[0].generate(ts.createSourceFile('', '', ts.ScriptTarget.Latest));
277
+
278
+ expect(code).toContain(`${NAMESPACE}.setProperty(`);
279
+ expect(code).toContain("'id'");
280
+ expect(code).toContain(`${NAMESPACE}.setList(`);
281
+ expect(code).toContain("'class'");
282
+ });
283
+
284
+ it('falls back to setProperties for object with spread assignment', () => {
285
+ let { result } = codegen(`let x = html\`<div \${{ ...base, id: 'test' }}>text</div>\`;`);
286
+ let code = result.replacements[0].generate(ts.createSourceFile('', '', ts.ScriptTarget.Latest));
287
+
288
+ expect(code).toContain(`${NAMESPACE}.setProperties(`);
289
+ });
290
+
291
+ it('expands shorthand property assignment', () => {
292
+ let { result } = codegen(`let x = html\`<div \${{ className }}>text</div>\`;`);
293
+ let code = result.replacements[0].generate(ts.createSourceFile('', '', ts.ScriptTarget.Latest));
294
+
295
+ expect(code).toContain(`${NAMESPACE}.setProperty(`);
296
+ expect(code).toContain("'className'");
297
+ expect(code).toContain('className');
298
+ });
299
+
300
+ it('falls back to setProperties for non-object expression', () => {
301
+ let { result } = codegen(`let x = html\`<div \${props}>text</div>\`;`);
302
+ let code = result.replacements[0].generate(ts.createSourceFile('', '', ts.ScriptTarget.Latest));
303
+
304
+ expect(code).toContain(`${NAMESPACE}.setProperties(`);
305
+ });
306
+
307
+ it('expands object with string literal property name', () => {
308
+ let { result } = codegen(`let x = html\`<div \${{ 'data-value': val }}>text</div>\`;`);
309
+ let code = result.replacements[0].generate(ts.createSourceFile('', '', ts.ScriptTarget.Latest));
310
+
311
+ expect(code).toContain(`${NAMESPACE}.setProperty(`);
312
+ expect(code).toContain("'data-value'");
313
+ });
314
+
315
+ it('expands object with style property into setList', () => {
316
+ let { result } = codegen(`let x = html\`<div \${{ style: sty }}>text</div>\`;`);
317
+ let code = result.replacements[0].generate(ts.createSourceFile('', '', ts.ScriptTarget.Latest));
318
+
319
+ expect(code).toContain(`${NAMESPACE}.setList(`);
320
+ expect(code).toContain("'style'");
321
+ });
322
+
323
+ it('expands object with event handler into delegate', () => {
324
+ let { result } = codegen(`let x = html\`<div \${{ onclick: handler }}>text</div>\`;`);
325
+ let code = result.replacements[0].generate(ts.createSourceFile('', '', ts.ScriptTarget.Latest));
326
+
327
+ expect(code).toContain(`${NAMESPACE}.delegate(`);
328
+ expect(code).toContain("'click'");
329
+ });
330
+
331
+ it('falls back to setProperties for computed property name', () => {
332
+ let { result } = codegen(`let x = html\`<div \${{ [key]: val }}>text</div>\`;`);
333
+ let code = result.replacements[0].generate(ts.createSourceFile('', '', ts.ScriptTarget.Latest));
334
+
335
+ expect(code).toContain(`${NAMESPACE}.setProperties(`);
336
+ });
337
+
338
+ it('method declaration in object literal throws on print (EmitHint.Expression limitation)', () => {
339
+ // MethodDeclaration is not an Expression node, so printer.printNode
340
+ // with EmitHint.Expression throws a debug assertion error during codegen
341
+ expect(() => {
342
+ codegen(`let x = html\`<div \${{ onclick() { return true; } }}>text</div>\`;`);
343
+ }).toThrow();
344
+ });
345
+ });
346
+
347
+ // codegen.ts:170-171 (path.length === 0) and :176-177 (nodes.has(key)) are defensive
348
+ // guards unreachable via normal parser output. Parser always produces paths with at
349
+ // least ["firstChild"] and packs all attributes per element into a single slot entry.
350
+
273
351
  describe('generateCode - arrow function body optimization', () => {
274
352
  it('generates template ID directly for parameterless arrow with static body', () => {
275
353
  let { result } = codegen(`let fn = () => html\`<div>static</div>\`;`);