@esportsplus/template 0.16.15 → 0.17.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 +33 -1
- 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 +63 -4
- 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/compiler/vite-hmr.ts +126 -0
- package/tests/hmr.ts +146 -0
- package/tests/slot/array.ts +201 -0
- package/tests/slot/async.ts +389 -0
- package/storage/feature-research-2026-03-24.md +0 -475
- package/test-output.txt +0 -0
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);
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { NAMESPACE } from '../../src/compiler/constants';
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
// Reconstruct the same regex and injection logic from vite.ts for testing
|
|
6
|
+
let TEMPLATE_SEARCH = NAMESPACE + '.template(',
|
|
7
|
+
TEMPLATE_CALL_REGEX = new RegExp(
|
|
8
|
+
'(const\\s+(\\w+)\\s*=\\s*' + NAMESPACE + '\\.template\\()(`)',
|
|
9
|
+
'g'
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
function injectHMR(code: string, id: string): string {
|
|
13
|
+
let hmrId = id.replace(/\\/g, '/'),
|
|
14
|
+
hotReplace = NAMESPACE + '.createHotTemplate("' + hmrId + '", "',
|
|
15
|
+
injected = code.replace(TEMPLATE_CALL_REGEX, function(_match: string, prefix: string, varName: string, backtick: string) {
|
|
16
|
+
return prefix.replace(TEMPLATE_SEARCH, hotReplace + varName + '", ') + backtick;
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
if (injected === code) {
|
|
20
|
+
return code;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
injected += '\nif (import.meta.hot) { import.meta.hot.accept(() => { ' + NAMESPACE + '.accept("' + hmrId + '"); }); }';
|
|
24
|
+
|
|
25
|
+
return injected;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
describe('compiler/vite-hmr', () => {
|
|
30
|
+
describe('injectHMR', () => {
|
|
31
|
+
it('returns unchanged code when no template calls found', () => {
|
|
32
|
+
let code = 'let x = 1;',
|
|
33
|
+
result = injectHMR(code, '/src/app.ts');
|
|
34
|
+
|
|
35
|
+
expect(result).toBe(code);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('replaces template() with createHotTemplate() for single template', () => {
|
|
39
|
+
let code = 'const ' + NAMESPACE + '_t0 = ' + NAMESPACE + '.template(`<div>hello</div>`);',
|
|
40
|
+
result = injectHMR(code, '/src/app.ts');
|
|
41
|
+
|
|
42
|
+
expect(result).toContain(NAMESPACE + '.createHotTemplate("/src/app.ts", "' + NAMESPACE + '_t0", `<div>hello</div>`)');
|
|
43
|
+
expect(result).not.toContain(NAMESPACE + '.template(');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('replaces multiple template calls in same module', () => {
|
|
47
|
+
let code = 'const tpl1 = ' + NAMESPACE + '.template(`<div>a</div>`);\n'
|
|
48
|
+
+ 'const tpl2 = ' + NAMESPACE + '.template(`<span>b</span>`);',
|
|
49
|
+
result = injectHMR(code, '/src/page.ts');
|
|
50
|
+
|
|
51
|
+
expect(result).toContain('createHotTemplate("/src/page.ts", "tpl1", `<div>a</div>`)');
|
|
52
|
+
expect(result).toContain('createHotTemplate("/src/page.ts", "tpl2", `<span>b</span>`)');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('appends import.meta.hot.accept block', () => {
|
|
56
|
+
let code = 'const tpl = ' + NAMESPACE + '.template(`<div></div>`);',
|
|
57
|
+
result = injectHMR(code, '/src/app.ts');
|
|
58
|
+
|
|
59
|
+
expect(result).toContain('if (import.meta.hot)');
|
|
60
|
+
expect(result).toContain('import.meta.hot.accept');
|
|
61
|
+
expect(result).toContain(NAMESPACE + '.accept("/src/app.ts")');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('normalizes backslashes in module id', () => {
|
|
65
|
+
let code = 'const tpl = ' + NAMESPACE + '.template(`<div></div>`);',
|
|
66
|
+
result = injectHMR(code, 'C:\\Users\\dev\\src\\app.ts');
|
|
67
|
+
|
|
68
|
+
expect(result).toContain('C:/Users/dev/src/app.ts');
|
|
69
|
+
expect(result).not.toContain('\\');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('does not append HMR code when no templates matched', () => {
|
|
73
|
+
let code = 'let x = someFunction();',
|
|
74
|
+
result = injectHMR(code, '/src/app.ts');
|
|
75
|
+
|
|
76
|
+
expect(result).not.toContain('import.meta.hot');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('preserves surrounding code', () => {
|
|
80
|
+
let code = 'import something from "pkg";\n'
|
|
81
|
+
+ 'const tpl = ' + NAMESPACE + '.template(`<div></div>`);\n'
|
|
82
|
+
+ 'let x = tpl();',
|
|
83
|
+
result = injectHMR(code, '/src/app.ts');
|
|
84
|
+
|
|
85
|
+
expect(result).toContain('import something from "pkg";');
|
|
86
|
+
expect(result).toContain('let x = tpl();');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('handles template with complex html', () => {
|
|
90
|
+
let code = 'const tpl = ' + NAMESPACE + '.template(`<div class="wrapper"><span><!--$--></span></div>`);',
|
|
91
|
+
result = injectHMR(code, '/src/app.ts');
|
|
92
|
+
|
|
93
|
+
expect(result).toContain('createHotTemplate');
|
|
94
|
+
expect(result).toContain('<div class="wrapper"><span><!--$--></span></div>');
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('plugin behavior', () => {
|
|
99
|
+
it('exported factory returns an object with expected HMR hooks', async () => {
|
|
100
|
+
// Dynamic import to avoid compiler transformation issues
|
|
101
|
+
let mod = await import('../../src/compiler/plugins/vite');
|
|
102
|
+
let factory = mod.default;
|
|
103
|
+
let plugin = factory();
|
|
104
|
+
|
|
105
|
+
expect(typeof plugin.configResolved).toBe('function');
|
|
106
|
+
expect(typeof plugin.transform).toBe('function');
|
|
107
|
+
expect(typeof plugin.handleHotUpdate).toBe('function');
|
|
108
|
+
expect(plugin.enforce).toBe('pre');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('configResolved sets dev mode from config.command', async () => {
|
|
112
|
+
let mod = await import('../../src/compiler/plugins/vite');
|
|
113
|
+
let plugin = mod.default();
|
|
114
|
+
|
|
115
|
+
// Should not throw
|
|
116
|
+
plugin.configResolved({ command: 'serve', root: '/test' });
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('configResolved sets dev mode from config.mode', async () => {
|
|
120
|
+
let mod = await import('../../src/compiler/plugins/vite');
|
|
121
|
+
let plugin = mod.default();
|
|
122
|
+
|
|
123
|
+
plugin.configResolved({ mode: 'development', root: '/test' });
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
});
|
package/tests/hmr.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { describe, expect, it, beforeEach } from 'vitest';
|
|
2
|
+
import { accept, createHotTemplate, hmrReset, modules } from '../src/hmr';
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
describe('hmr', () => {
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
hmrReset();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
describe('createHotTemplate', () => {
|
|
11
|
+
it('registers a new template and returns a factory', () => {
|
|
12
|
+
let factory = createHotTemplate('mod1', 'tpl1', '<div>hello</div>');
|
|
13
|
+
|
|
14
|
+
expect(typeof factory).toBe('function');
|
|
15
|
+
expect(modules.has('mod1')).toBe(true);
|
|
16
|
+
expect(modules.get('mod1')!.has('tpl1')).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('factory returns a DocumentFragment clone', () => {
|
|
20
|
+
let factory = createHotTemplate('mod1', 'tpl1', '<div>hello</div>');
|
|
21
|
+
let frag = factory();
|
|
22
|
+
|
|
23
|
+
expect(frag).toBeInstanceOf(DocumentFragment);
|
|
24
|
+
expect(frag.firstChild).toBeInstanceOf(HTMLDivElement);
|
|
25
|
+
expect((frag.firstChild as HTMLElement).textContent).toBe('hello');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('factory caches the parsed fragment and returns clones', () => {
|
|
29
|
+
let factory = createHotTemplate('mod1', 'tpl1', '<span>test</span>');
|
|
30
|
+
let frag1 = factory(),
|
|
31
|
+
frag2 = factory();
|
|
32
|
+
|
|
33
|
+
expect(frag1).not.toBe(frag2);
|
|
34
|
+
expect((frag1.firstChild as HTMLElement).tagName).toBe('SPAN');
|
|
35
|
+
expect((frag2.firstChild as HTMLElement).tagName).toBe('SPAN');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('returns existing factory when called with same moduleId and templateId', () => {
|
|
39
|
+
let factory1 = createHotTemplate('mod1', 'tpl1', '<div>v1</div>'),
|
|
40
|
+
factory2 = createHotTemplate('mod1', 'tpl1', '<div>v2</div>');
|
|
41
|
+
|
|
42
|
+
expect(factory1).toBe(factory2);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('updates html when re-registered with same ids', () => {
|
|
46
|
+
createHotTemplate('mod1', 'tpl1', '<div>v1</div>');
|
|
47
|
+
createHotTemplate('mod1', 'tpl1', '<div>v2</div>');
|
|
48
|
+
|
|
49
|
+
let factory = createHotTemplate('mod1', 'tpl1', '<div>v2</div>');
|
|
50
|
+
let frag = factory();
|
|
51
|
+
|
|
52
|
+
expect((frag.firstChild as HTMLElement).textContent).toBe('v2');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('invalidates cache when html changes', () => {
|
|
56
|
+
let factory = createHotTemplate('mod1', 'tpl1', '<div>old</div>');
|
|
57
|
+
|
|
58
|
+
factory(); // populate cache
|
|
59
|
+
|
|
60
|
+
createHotTemplate('mod1', 'tpl1', '<div>new</div>');
|
|
61
|
+
|
|
62
|
+
let frag = factory();
|
|
63
|
+
|
|
64
|
+
expect((frag.firstChild as HTMLElement).textContent).toBe('new');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('registers multiple templates per module', () => {
|
|
68
|
+
createHotTemplate('mod1', 'tpl1', '<div>a</div>');
|
|
69
|
+
createHotTemplate('mod1', 'tpl2', '<span>b</span>');
|
|
70
|
+
|
|
71
|
+
expect(modules.get('mod1')!.size).toBe(2);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('registers templates across different modules', () => {
|
|
75
|
+
createHotTemplate('mod1', 'tpl1', '<div>a</div>');
|
|
76
|
+
createHotTemplate('mod2', 'tpl1', '<div>b</div>');
|
|
77
|
+
|
|
78
|
+
expect(modules.size).toBe(2);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('accept', () => {
|
|
83
|
+
it('invalidates all cached templates for a module', () => {
|
|
84
|
+
let factory1 = createHotTemplate('mod1', 'tpl1', '<div>a</div>'),
|
|
85
|
+
factory2 = createHotTemplate('mod1', 'tpl2', '<span>b</span>');
|
|
86
|
+
|
|
87
|
+
factory1(); // populate cache
|
|
88
|
+
factory2(); // populate cache
|
|
89
|
+
|
|
90
|
+
let entry1 = modules.get('mod1')!.get('tpl1')!,
|
|
91
|
+
entry2 = modules.get('mod1')!.get('tpl2')!;
|
|
92
|
+
|
|
93
|
+
expect(entry1.cached).toBeDefined();
|
|
94
|
+
expect(entry2.cached).toBeDefined();
|
|
95
|
+
|
|
96
|
+
accept('mod1');
|
|
97
|
+
|
|
98
|
+
expect(entry1.cached).toBeUndefined();
|
|
99
|
+
expect(entry2.cached).toBeUndefined();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('does not affect templates from other modules', () => {
|
|
103
|
+
let factory1 = createHotTemplate('mod1', 'tpl1', '<div>a</div>'),
|
|
104
|
+
factory2 = createHotTemplate('mod2', 'tpl1', '<div>b</div>');
|
|
105
|
+
|
|
106
|
+
factory1();
|
|
107
|
+
factory2();
|
|
108
|
+
|
|
109
|
+
let entry2 = modules.get('mod2')!.get('tpl1')!;
|
|
110
|
+
|
|
111
|
+
accept('mod1');
|
|
112
|
+
|
|
113
|
+
expect(entry2.cached).toBeDefined();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('is a no-op for unknown module ids', () => {
|
|
117
|
+
expect(() => accept('nonexistent')).not.toThrow();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('after accept, factory produces new fragments from same html', () => {
|
|
121
|
+
let factory = createHotTemplate('mod1', 'tpl1', '<div>content</div>');
|
|
122
|
+
|
|
123
|
+
let frag1 = factory();
|
|
124
|
+
|
|
125
|
+
accept('mod1');
|
|
126
|
+
|
|
127
|
+
let frag2 = factory();
|
|
128
|
+
|
|
129
|
+
expect(frag1).not.toBe(frag2);
|
|
130
|
+
expect((frag2.firstChild as HTMLElement).textContent).toBe('content');
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe('hmrReset', () => {
|
|
135
|
+
it('clears all registered modules', () => {
|
|
136
|
+
createHotTemplate('mod1', 'tpl1', '<div>a</div>');
|
|
137
|
+
createHotTemplate('mod2', 'tpl1', '<div>b</div>');
|
|
138
|
+
|
|
139
|
+
expect(modules.size).toBe(2);
|
|
140
|
+
|
|
141
|
+
hmrReset();
|
|
142
|
+
|
|
143
|
+
expect(modules.size).toBe(0);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
});
|
package/tests/slot/array.ts
CHANGED
|
@@ -687,6 +687,207 @@ describe('slot/ArraySlot', () => {
|
|
|
687
687
|
});
|
|
688
688
|
});
|
|
689
689
|
|
|
690
|
+
describe('moveBefore API', () => {
|
|
691
|
+
it('sort uses moveBefore when available on parent', async () => {
|
|
692
|
+
let arr = reactive(['c', 'a', 'b'] as string[]),
|
|
693
|
+
moveBeforeCalls: [Node, Node | null][] = [],
|
|
694
|
+
slot = new ArraySlot(arr, (s) => {
|
|
695
|
+
let frag = document.createDocumentFragment(),
|
|
696
|
+
span = document.createElement('span');
|
|
697
|
+
|
|
698
|
+
span.textContent = s;
|
|
699
|
+
frag.appendChild(span);
|
|
700
|
+
|
|
701
|
+
return frag as unknown as DocumentFragment;
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
container.appendChild(slot.fragment);
|
|
705
|
+
|
|
706
|
+
// Polyfill moveBefore on the parent
|
|
707
|
+
(container as any).moveBefore = function (node: Node, ref: Node | null) {
|
|
708
|
+
moveBeforeCalls.push([node, ref]);
|
|
709
|
+
container.insertBefore(node, ref);
|
|
710
|
+
};
|
|
711
|
+
|
|
712
|
+
arr.sort();
|
|
713
|
+
|
|
714
|
+
await new Promise(resolve => requestAnimationFrame(resolve));
|
|
715
|
+
|
|
716
|
+
let spans = container.querySelectorAll('span');
|
|
717
|
+
|
|
718
|
+
expect(spans[0].textContent).toBe('a');
|
|
719
|
+
expect(spans[1].textContent).toBe('b');
|
|
720
|
+
expect(spans[2].textContent).toBe('c');
|
|
721
|
+
expect(moveBeforeCalls.length).toBeGreaterThan(0);
|
|
722
|
+
|
|
723
|
+
delete (container as any).moveBefore;
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
it('sort falls back to insertBefore without moveBefore', async () => {
|
|
727
|
+
let arr = reactive(['c', 'a', 'b'] as string[]),
|
|
728
|
+
slot = new ArraySlot(arr, (s) => {
|
|
729
|
+
let frag = document.createDocumentFragment(),
|
|
730
|
+
span = document.createElement('span');
|
|
731
|
+
|
|
732
|
+
span.textContent = s;
|
|
733
|
+
frag.appendChild(span);
|
|
734
|
+
|
|
735
|
+
return frag as unknown as DocumentFragment;
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
container.appendChild(slot.fragment);
|
|
739
|
+
|
|
740
|
+
// Ensure no moveBefore
|
|
741
|
+
expect('moveBefore' in container).toBe(false);
|
|
742
|
+
|
|
743
|
+
arr.sort();
|
|
744
|
+
|
|
745
|
+
await new Promise(resolve => requestAnimationFrame(resolve));
|
|
746
|
+
|
|
747
|
+
let spans = container.querySelectorAll('span');
|
|
748
|
+
|
|
749
|
+
expect(spans[0].textContent).toBe('a');
|
|
750
|
+
expect(spans[1].textContent).toBe('b');
|
|
751
|
+
expect(spans[2].textContent).toBe('c');
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
it('reverse uses moveBefore when available via sync', async () => {
|
|
755
|
+
let arr = reactive(['a', 'b', 'c'] as string[]),
|
|
756
|
+
moveBeforeCalls: [Node, Node | null][] = [],
|
|
757
|
+
slot = new ArraySlot(arr, (s) => {
|
|
758
|
+
let frag = document.createDocumentFragment(),
|
|
759
|
+
span = document.createElement('span');
|
|
760
|
+
|
|
761
|
+
span.textContent = s;
|
|
762
|
+
frag.appendChild(span);
|
|
763
|
+
|
|
764
|
+
return frag as unknown as DocumentFragment;
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
container.appendChild(slot.fragment);
|
|
768
|
+
|
|
769
|
+
(container as any).moveBefore = function (node: Node, ref: Node | null) {
|
|
770
|
+
moveBeforeCalls.push([node, ref]);
|
|
771
|
+
container.insertBefore(node, ref);
|
|
772
|
+
};
|
|
773
|
+
|
|
774
|
+
arr.reverse();
|
|
775
|
+
|
|
776
|
+
await new Promise(resolve => requestAnimationFrame(resolve));
|
|
777
|
+
|
|
778
|
+
let spans = container.querySelectorAll('span');
|
|
779
|
+
|
|
780
|
+
expect(spans[0].textContent).toBe('c');
|
|
781
|
+
expect(spans[1].textContent).toBe('b');
|
|
782
|
+
expect(spans[2].textContent).toBe('a');
|
|
783
|
+
expect(moveBeforeCalls.length).toBeGreaterThan(0);
|
|
784
|
+
|
|
785
|
+
delete (container as any).moveBefore;
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
it('reverse falls back to fragment approach without moveBefore', async () => {
|
|
789
|
+
let arr = reactive(['a', 'b', 'c'] as string[]),
|
|
790
|
+
slot = new ArraySlot(arr, (s) => {
|
|
791
|
+
let frag = document.createDocumentFragment(),
|
|
792
|
+
span = document.createElement('span');
|
|
793
|
+
|
|
794
|
+
span.textContent = s;
|
|
795
|
+
frag.appendChild(span);
|
|
796
|
+
|
|
797
|
+
return frag as unknown as DocumentFragment;
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
container.appendChild(slot.fragment);
|
|
801
|
+
|
|
802
|
+
expect('moveBefore' in container).toBe(false);
|
|
803
|
+
|
|
804
|
+
arr.reverse();
|
|
805
|
+
|
|
806
|
+
await new Promise(resolve => requestAnimationFrame(resolve));
|
|
807
|
+
|
|
808
|
+
let spans = container.querySelectorAll('span');
|
|
809
|
+
|
|
810
|
+
expect(spans[0].textContent).toBe('c');
|
|
811
|
+
expect(spans[1].textContent).toBe('b');
|
|
812
|
+
expect(spans[2].textContent).toBe('a');
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
it('moveBefore receives correct arguments during sort', async () => {
|
|
816
|
+
let arr = reactive(['b', 'a'] as string[]),
|
|
817
|
+
moveBeforeCalls: [string, string | null][] = [],
|
|
818
|
+
slot = new ArraySlot(arr, (s) => {
|
|
819
|
+
let frag = document.createDocumentFragment(),
|
|
820
|
+
span = document.createElement('span');
|
|
821
|
+
|
|
822
|
+
span.textContent = s;
|
|
823
|
+
span.setAttribute('data-id', s);
|
|
824
|
+
frag.appendChild(span);
|
|
825
|
+
|
|
826
|
+
return frag as unknown as DocumentFragment;
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
container.appendChild(slot.fragment);
|
|
830
|
+
|
|
831
|
+
(container as any).moveBefore = function (node: Node, ref: Node | null) {
|
|
832
|
+
let nodeId = (node as HTMLElement).getAttribute?.('data-id') || '?',
|
|
833
|
+
refId = ref ? ((ref as HTMLElement).getAttribute?.('data-id') || '?') : null;
|
|
834
|
+
|
|
835
|
+
moveBeforeCalls.push([nodeId, refId]);
|
|
836
|
+
container.insertBefore(node, ref);
|
|
837
|
+
};
|
|
838
|
+
|
|
839
|
+
arr.sort();
|
|
840
|
+
|
|
841
|
+
await new Promise(resolve => requestAnimationFrame(resolve));
|
|
842
|
+
|
|
843
|
+
let spans = container.querySelectorAll('span');
|
|
844
|
+
|
|
845
|
+
expect(spans[0].textContent).toBe('a');
|
|
846
|
+
expect(spans[1].textContent).toBe('b');
|
|
847
|
+
|
|
848
|
+
// 'a' should be moved before 'b'
|
|
849
|
+
expect(moveBeforeCalls.some(([n]) => n === 'a')).toBe(true);
|
|
850
|
+
|
|
851
|
+
delete (container as any).moveBefore;
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
it('sort with moveBefore preserves node order for already-sorted LIS', async () => {
|
|
855
|
+
let arr = reactive(['a', 'b', 'c'] as string[]),
|
|
856
|
+
moveBeforeCalls: [Node, Node | null][] = [],
|
|
857
|
+
slot = new ArraySlot(arr, (s) => {
|
|
858
|
+
let frag = document.createDocumentFragment(),
|
|
859
|
+
span = document.createElement('span');
|
|
860
|
+
|
|
861
|
+
span.textContent = s;
|
|
862
|
+
frag.appendChild(span);
|
|
863
|
+
|
|
864
|
+
return frag as unknown as DocumentFragment;
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
container.appendChild(slot.fragment);
|
|
868
|
+
|
|
869
|
+
(container as any).moveBefore = function (node: Node, ref: Node | null) {
|
|
870
|
+
moveBeforeCalls.push([node, ref]);
|
|
871
|
+
container.insertBefore(node, ref);
|
|
872
|
+
};
|
|
873
|
+
|
|
874
|
+
arr.sort();
|
|
875
|
+
|
|
876
|
+
await new Promise(resolve => requestAnimationFrame(resolve));
|
|
877
|
+
|
|
878
|
+
let spans = container.querySelectorAll('span');
|
|
879
|
+
|
|
880
|
+
expect(spans[0].textContent).toBe('a');
|
|
881
|
+
expect(spans[1].textContent).toBe('b');
|
|
882
|
+
expect(spans[2].textContent).toBe('c');
|
|
883
|
+
|
|
884
|
+
// Already sorted — LIS covers all nodes, no moves needed
|
|
885
|
+
expect(moveBeforeCalls.length).toBe(0);
|
|
886
|
+
|
|
887
|
+
delete (container as any).moveBefore;
|
|
888
|
+
});
|
|
889
|
+
});
|
|
890
|
+
|
|
690
891
|
describe('disconnect cleanup', () => {
|
|
691
892
|
it('cleans up nodes when cleared', async () => {
|
|
692
893
|
let cleanupCount = 0,
|