@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.
- package/README.md +81 -9
- package/build/compiler/plugins/vite.d.ts +8 -4
- package/build/compiler/plugins/vite.js +37 -2
- package/build/hmr.d.ts +10 -0
- package/build/hmr.js +42 -0
- package/build/index.d.ts +1 -0
- package/build/index.js +1 -0
- package/build/slot/array.js +21 -2
- package/build/slot/effect.d.ts +3 -3
- package/build/slot/effect.js +36 -17
- package/llm.txt +90 -11
- package/package.json +1 -1
- package/src/compiler/plugins/vite.ts +74 -5
- package/src/hmr.ts +70 -0
- package/src/index.ts +1 -0
- package/src/slot/array.ts +30 -2
- package/src/slot/effect.ts +46 -20
- package/tests/attributes.ts +142 -2
- package/tests/compiler/codegen.ts +78 -0
- package/tests/compiler/transform.ts +159 -0
- package/tests/compiler/ts-analyzer.ts +128 -8
- package/tests/compiler/vite-hmr.ts +213 -0
- package/tests/event/index.ts +71 -0
- package/tests/hmr.ts +146 -0
- package/tests/slot/array.ts +201 -0
- package/tests/slot/async.ts +389 -0
- package/tests/slot/effect.ts +88 -1
- package/storage/feature-research-2026-03-24.md +0 -475
- package/test-output.txt +0 -0
|
@@ -1,10 +1,79 @@
|
|
|
1
|
+
import { NAMESPACE, PACKAGE_NAME } from '../constants';
|
|
1
2
|
import { plugin } from '@esportsplus/typescript/compiler';
|
|
2
|
-
|
|
3
|
+
|
|
3
4
|
import reactivity from '@esportsplus/reactivity/compiler';
|
|
4
5
|
import template from '..';
|
|
5
6
|
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
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
|
-
|
|
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,
|
package/src/slot/effect.ts
CHANGED
|
@@ -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: (
|
|
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 =
|
|
35
|
-
value = read( fn(dispose) );
|
|
32
|
+
this.disposer = null;
|
|
36
33
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
130
|
+
tail: tail as Element
|
|
105
131
|
};
|
|
106
132
|
|
|
107
133
|
anchor.after(fragment);
|
package/tests/attributes.ts
CHANGED
|
@@ -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
|
|
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>\`;`);
|