@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/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);
@@ -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
+ });
@@ -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,