@lexriver/dome 2.0.1 → 2.0.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.
@@ -8,13 +8,16 @@ export declare abstract class DomeComponent<Attrs> {
8
8
  attrs: Attrs & InternalAttrs;
9
9
  children: any;
10
10
  rootElement: Element | HTMLElement;
11
+ private updateInProgress;
12
+ private updateRequested;
11
13
  constructor(attrs: Attrs & InternalAttrs, children: any);
12
14
  protected init(): void;
13
15
  protected abstract render(): HTMLElement;
14
16
  protected afterRender(): void;
15
17
  updateAsync(): Promise<void>;
18
+ private runScheduledUpdateAsync;
16
19
  scheduleUpdate: {
17
- (this: unknown, ...args: [] & any[]): Promise<void>;
20
+ (this: unknown, ...args: [] & any[]): Promise<Promise<void>>;
18
21
  cancel: (reason?: any) => void;
19
22
  };
20
23
  protected afterUpdate(): void;
@@ -4,6 +4,8 @@ export class DomeComponent {
4
4
  attrs;
5
5
  children;
6
6
  rootElement;
7
+ updateInProgress = false;
8
+ updateRequested = false;
7
9
  constructor(attrs, children) {
8
10
  //this.init()
9
11
  this.attrs = attrs;
@@ -35,9 +37,28 @@ export class DomeComponent {
35
37
  //console.timeEnd('browser')
36
38
  this.afterUpdate();
37
39
  }
38
- scheduleUpdate = debounce(() => {
39
- this.updateAsync();
40
- }, 5);
40
+ async runScheduledUpdateAsync() {
41
+ if (this.updateInProgress) {
42
+ this.updateRequested = true;
43
+ return;
44
+ }
45
+ this.updateInProgress = true;
46
+ try {
47
+ do {
48
+ this.updateRequested = false;
49
+ try {
50
+ await this.updateAsync();
51
+ }
52
+ catch (error) {
53
+ console.error('DomeComponent: scheduled update failed', error);
54
+ }
55
+ } while (this.updateRequested);
56
+ }
57
+ finally {
58
+ this.updateInProgress = false;
59
+ }
60
+ }
61
+ scheduleUpdate = debounce(() => this.runScheduledUpdateAsync(), 5);
41
62
  afterUpdate() {
42
63
  }
43
64
  }
@@ -16,6 +16,10 @@ export declare namespace DomeManipulator {
16
16
  function removeAllChildrenAsync(element: Element, animation?: Animation): Promise<void>;
17
17
  function appendChildAsync(containerElement: Element, child: Element, animation?: Animation): Promise<void>;
18
18
  function appendChildrenAsync(containerElement: Element, children: Element | Element[] | DocumentFragment | Text | string | null | undefined, animation?: Animation): Promise<void>;
19
+ /**
20
+ * Replacements for the same container run in call order. A rejected replacement does
21
+ * not block the queue, and replacements for different containers remain independent.
22
+ */
19
23
  function replaceAllChildrenAsync(containerElement: Element, childrenToInsert: Element | Element[] | DocumentFragment | Text | string | null | undefined, animationForHide?: Animation, animationForShow?: Animation): Promise<void>;
20
24
  function isInDom(el: Element | undefined): boolean;
21
25
  function isOnScreen(el: Element | undefined): boolean;
@@ -1,6 +1,7 @@
1
1
  import { Async } from "@lexriver/async";
2
2
  import { DataTypes } from "@lexriver/data-types";
3
3
  import { checkIfObservable } from "@lexriver/observable";
4
+ const replacementPromiseByElement = new WeakMap();
4
5
  export var DomeManipulator;
5
6
  (function (DomeManipulator) {
6
7
  async function hideElementAsync(element, animation) {
@@ -121,8 +122,8 @@ export var DomeManipulator;
121
122
  forEachChildrenOf(element, (child) => child.nodeType == Node.ELEMENT_NODE && child.classList.add(animation.cssClassName));
122
123
  //console.log('##', 'wait ms', animation.timeMs)
123
124
  await Async.waitMsAsync(animation.timeMs);
124
- //console.log('##', 'removing children', element.childNodes)
125
- forEachChildrenOf(element, (child) => child.remove());
125
+ // Take a snapshot because childNodes is live and shrinks as nodes are removed.
126
+ Array.from(element.childNodes).forEach(child => child.remove());
126
127
  //console.log('##', 'done')
127
128
  }
128
129
  // if(element.children.length>0) {
@@ -152,19 +153,29 @@ export var DomeManipulator;
152
153
  }
153
154
  }
154
155
  DomeManipulator.appendChildrenAsync = appendChildrenAsync;
156
+ /**
157
+ * Replacements for the same container run in call order. A rejected replacement does
158
+ * not block the queue, and replacements for different containers remain independent.
159
+ */
155
160
  async function replaceAllChildrenAsync(containerElement, childrenToInsert, animationForHide, animationForShow) {
156
- //await removeAllChildrenAsync(containerElement, animationForHide)
157
- if (containerElement.childNodes.length > 0) {
158
- await removeAllChildrenAsync(containerElement, animationForHide);
161
+ const previousReplacement = replacementPromiseByElement.get(containerElement);
162
+ const replacement = (previousReplacement ?? Promise.resolve())
163
+ .catch(() => undefined)
164
+ .then(async () => {
165
+ if (containerElement.childNodes.length > 0) {
166
+ await removeAllChildrenAsync(containerElement, animationForHide);
167
+ }
168
+ await appendChildrenAsync(containerElement, childrenToInsert, animationForShow);
169
+ });
170
+ replacementPromiseByElement.set(containerElement, replacement);
171
+ try {
172
+ await replacement;
173
+ }
174
+ finally {
175
+ if (replacementPromiseByElement.get(containerElement) === replacement) {
176
+ replacementPromiseByElement.delete(containerElement);
177
+ }
159
178
  }
160
- // while(containerElement.childNodes.length>0){
161
- // await removeAllChildrenAsync(containerElement, animationForHide) // can be executed more than once! TODO: why?
162
- // }
163
- // if(containerElement.children.length>0) {
164
- // console.error('DomeManipulator: replaceAllChildenrAsync() failed:', 'containerElement.children.length=', containerElement.children.length, 'children=', containerElement.children, 'containerElement=', containerElement, 'animationForHide=', animationForHide)
165
- // throw new Error()
166
- // }
167
- await appendChildrenAsync(containerElement, childrenToInsert, animationForShow);
168
179
  }
169
180
  DomeManipulator.replaceAllChildrenAsync = replaceAllChildrenAsync;
170
181
  function isInDom(el) {
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,203 @@
1
+ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
2
+ import { DomeComponent } from './DomeComponent.mjs';
3
+ import { DomeManipulator } from './DomeManipulator.mjs';
4
+ class FakeElement {
5
+ nodeType = 1;
6
+ childNodes = [];
7
+ classList = {
8
+ values: new Set(),
9
+ add: (name) => this.classList.values.add(name),
10
+ remove: (name) => this.classList.values.delete(name)
11
+ };
12
+ parentNode = null;
13
+ throwOnAppend = false;
14
+ get firstChild() {
15
+ return this.childNodes[0] ?? null;
16
+ }
17
+ appendChild(child) {
18
+ if (child.throwOnAppend) {
19
+ throw new Error('append failed');
20
+ }
21
+ child.parentNode = this;
22
+ this.childNodes.push(child);
23
+ return child;
24
+ }
25
+ remove() {
26
+ if (!this.parentNode)
27
+ return;
28
+ const index = this.parentNode.childNodes.indexOf(this);
29
+ if (index >= 0) {
30
+ this.parentNode.childNodes.splice(index, 1);
31
+ }
32
+ this.parentNode = null;
33
+ }
34
+ }
35
+ const asElement = (element) => element;
36
+ const animation = (name) => ({ cssClassName: name, timeMs: 5 });
37
+ const waitAsync = (timeMs) => new Promise(resolve => setTimeout(resolve, timeMs));
38
+ async function waitForAsync(condition) {
39
+ const timeoutAt = Date.now() + 500;
40
+ while (!condition()) {
41
+ if (Date.now() >= timeoutAt) {
42
+ throw new Error('Timed out waiting for condition');
43
+ }
44
+ await waitAsync(1);
45
+ }
46
+ }
47
+ function deferred() {
48
+ let resolve;
49
+ const promise = new Promise(resolvePromise => {
50
+ resolve = resolvePromise;
51
+ });
52
+ return { promise, resolve };
53
+ }
54
+ const originalNode = Object.getOwnPropertyDescriptor(globalThis, 'Node');
55
+ beforeAll(() => {
56
+ Object.defineProperty(globalThis, 'Node', {
57
+ configurable: true,
58
+ value: { ELEMENT_NODE: 1 }
59
+ });
60
+ });
61
+ afterAll(() => {
62
+ if (originalNode) {
63
+ Object.defineProperty(globalThis, 'Node', originalNode);
64
+ }
65
+ else {
66
+ delete globalThis.Node;
67
+ }
68
+ });
69
+ describe('DomeManipulator.replaceAllChildrenAsync', () => {
70
+ async function expectLastQueuedReplacement(animationForHide, animationForShow) {
71
+ const container = new FakeElement();
72
+ const existing = new FakeElement();
73
+ const first = new FakeElement();
74
+ const second = new FakeElement();
75
+ container.appendChild(existing);
76
+ await Promise.all([
77
+ DomeManipulator.replaceAllChildrenAsync(asElement(container), asElement(first), animationForHide, animationForShow),
78
+ DomeManipulator.replaceAllChildrenAsync(asElement(container), asElement(second), animationForHide, animationForShow)
79
+ ]);
80
+ expect(container.childNodes).toEqual([second]);
81
+ }
82
+ it('serializes two concurrent replacements without animations', async () => {
83
+ await expectLastQueuedReplacement();
84
+ });
85
+ it('serializes concurrent replacements with a hide animation', async () => {
86
+ await expectLastQueuedReplacement(animation('hide'));
87
+ });
88
+ it('serializes concurrent replacements with a show animation', async () => {
89
+ await expectLastQueuedReplacement(undefined, animation('show'));
90
+ });
91
+ it('serializes concurrent replacements with hide and show animations', async () => {
92
+ await expectLastQueuedReplacement(animation('hide'), animation('show'));
93
+ });
94
+ it('runs replacements on different containers independently', async () => {
95
+ const slowContainer = new FakeElement();
96
+ const fastContainer = new FakeElement();
97
+ const oldSlowChild = new FakeElement();
98
+ const slowChild = new FakeElement();
99
+ const fastChild = new FakeElement();
100
+ slowContainer.appendChild(oldSlowChild);
101
+ let slowReplacementFinished = false;
102
+ const slowReplacement = DomeManipulator.replaceAllChildrenAsync(asElement(slowContainer), asElement(slowChild), { cssClassName: 'hide', timeMs: 30 }).then(() => {
103
+ slowReplacementFinished = true;
104
+ });
105
+ await waitAsync(1);
106
+ await DomeManipulator.replaceAllChildrenAsync(asElement(fastContainer), asElement(fastChild));
107
+ expect(fastContainer.childNodes).toEqual([fastChild]);
108
+ expect(slowReplacementFinished).toBe(false);
109
+ expect(slowContainer.childNodes).toEqual([oldSlowChild]);
110
+ await slowReplacement;
111
+ });
112
+ it('does not let a failed replacement block a later replacement', async () => {
113
+ const container = new FakeElement();
114
+ const failingChild = new FakeElement();
115
+ const successfulChild = new FakeElement();
116
+ failingChild.throwOnAppend = true;
117
+ const failedReplacement = DomeManipulator.replaceAllChildrenAsync(asElement(container), asElement(failingChild));
118
+ const queuedReplacement = DomeManipulator.replaceAllChildrenAsync(asElement(container), asElement(successfulChild));
119
+ await expect(failedReplacement).rejects.toThrow('append failed');
120
+ await queuedReplacement;
121
+ expect(container.childNodes).toEqual([successfulChild]);
122
+ });
123
+ });
124
+ class ScheduledTestComponent extends DomeComponent {
125
+ updateCount = 0;
126
+ activeUpdates = 0;
127
+ maximumActiveUpdates = 0;
128
+ updateBehavior = async () => undefined;
129
+ render() {
130
+ return {};
131
+ }
132
+ async updateAsync() {
133
+ this.updateCount++;
134
+ this.activeUpdates++;
135
+ this.maximumActiveUpdates = Math.max(this.maximumActiveUpdates, this.activeUpdates);
136
+ try {
137
+ await this.updateBehavior();
138
+ }
139
+ finally {
140
+ this.activeUpdates--;
141
+ }
142
+ }
143
+ }
144
+ describe('DomeComponent.scheduleUpdate', () => {
145
+ it('debounces repeated requests made before an update starts', async () => {
146
+ const component = new ScheduledTestComponent({}, null);
147
+ await Promise.all([
148
+ component.scheduleUpdate(),
149
+ component.scheduleUpdate(),
150
+ component.scheduleUpdate()
151
+ ]);
152
+ expect(component.updateCount).toBe(1);
153
+ });
154
+ it('coalesces requests during an update into exactly one follow-up update', async () => {
155
+ const component = new ScheduledTestComponent({}, null);
156
+ const firstUpdate = deferred();
157
+ component.updateBehavior = () => component.updateCount === 1
158
+ ? firstUpdate.promise
159
+ : Promise.resolve();
160
+ const initialSchedule = component.scheduleUpdate();
161
+ await waitForAsync(() => component.updateCount === 1);
162
+ component.scheduleUpdate();
163
+ component.scheduleUpdate();
164
+ component.scheduleUpdate();
165
+ await waitAsync(10);
166
+ firstUpdate.resolve();
167
+ await initialSchedule;
168
+ expect(component.updateCount).toBe(2);
169
+ });
170
+ it('never runs scheduled updates concurrently for one component', async () => {
171
+ const component = new ScheduledTestComponent({}, null);
172
+ const firstUpdate = deferred();
173
+ component.updateBehavior = () => component.updateCount === 1
174
+ ? firstUpdate.promise
175
+ : Promise.resolve();
176
+ const initialSchedule = component.scheduleUpdate();
177
+ await waitForAsync(() => component.updateCount === 1);
178
+ component.scheduleUpdate();
179
+ await waitAsync(10);
180
+ expect(component.maximumActiveUpdates).toBe(1);
181
+ firstUpdate.resolve();
182
+ await initialSchedule;
183
+ expect(component.maximumActiveUpdates).toBe(1);
184
+ });
185
+ it('allows subsequent scheduled updates after updateAsync rejects', async () => {
186
+ const component = new ScheduledTestComponent({}, null);
187
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
188
+ component.updateBehavior = async () => {
189
+ if (component.updateCount === 1) {
190
+ throw new Error('update failed');
191
+ }
192
+ };
193
+ try {
194
+ await component.scheduleUpdate();
195
+ await component.scheduleUpdate();
196
+ }
197
+ finally {
198
+ consoleError.mockRestore();
199
+ }
200
+ expect(component.updateCount).toBe(2);
201
+ expect(component.maximumActiveUpdates).toBe(1);
202
+ });
203
+ });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "sideEffects": false,
3
3
  "name": "@lexriver/dome",
4
- "version": "2.0.1",
4
+ "version": "2.0.2",
5
5
  "description": "DOM manipulator",
6
6
  "type": "module",
7
7
  "exports": "./out/src/index.mjs",
@@ -10,6 +10,8 @@ interface InternalAttrs{
10
10
  }
11
11
  export abstract class DomeComponent<Attrs>{
12
12
  public rootElement!:Element|HTMLElement
13
+ private updateInProgress = false
14
+ private updateRequested = false
13
15
  constructor(
14
16
  public attrs:Attrs & InternalAttrs,
15
17
  public children:any
@@ -48,9 +50,28 @@ export abstract class DomeComponent<Attrs>{
48
50
  this.afterUpdate()
49
51
 
50
52
  }
51
- scheduleUpdate = debounce(() => {
52
- this.updateAsync()
53
- }, 5)
53
+ private async runScheduledUpdateAsync() {
54
+ if(this.updateInProgress){
55
+ this.updateRequested = true
56
+ return
57
+ }
58
+
59
+ this.updateInProgress = true
60
+ try {
61
+ do {
62
+ this.updateRequested = false
63
+ try {
64
+ await this.updateAsync()
65
+ } catch(error) {
66
+ console.error('DomeComponent: scheduled update failed', error)
67
+ }
68
+ } while(this.updateRequested)
69
+ } finally {
70
+ this.updateInProgress = false
71
+ }
72
+ }
73
+
74
+ scheduleUpdate = debounce(() => this.runScheduledUpdateAsync(), 5)
54
75
 
55
76
  protected afterUpdate(){
56
77
 
@@ -6,6 +6,8 @@ import { Animation } from './Animation.mjs'
6
6
 
7
7
  export type CssClass = {[key:string]:boolean|ObservableVariable<boolean>} | string[] | string
8
8
 
9
+ const replacementPromiseByElement = new WeakMap<Element, Promise<void>>()
10
+
9
11
  export namespace DomeManipulator {
10
12
 
11
13
  export async function hideElementAsync(element: Element, animation?:Animation) {
@@ -134,8 +136,8 @@ export namespace DomeManipulator {
134
136
  forEachChildrenOf(element, (child) => child.nodeType == Node.ELEMENT_NODE && (child as Element).classList.add(animation.cssClassName))
135
137
  //console.log('##', 'wait ms', animation.timeMs)
136
138
  await Async.waitMsAsync(animation.timeMs)
137
- //console.log('##', 'removing children', element.childNodes)
138
- forEachChildrenOf(element, (child) => child.remove())
139
+ // Take a snapshot because childNodes is live and shrinks as nodes are removed.
140
+ Array.from(element.childNodes).forEach(child => child.remove())
139
141
  //console.log('##', 'done')
140
142
  }
141
143
 
@@ -172,26 +174,35 @@ export namespace DomeManipulator {
172
174
  }
173
175
 
174
176
 
177
+ /**
178
+ * Replacements for the same container run in call order. A rejected replacement does
179
+ * not block the queue, and replacements for different containers remain independent.
180
+ */
175
181
  export async function replaceAllChildrenAsync(
176
- containerElement: Element,
177
- childrenToInsert: Element | Element[] | DocumentFragment | Text | string | null | undefined,
178
- animationForHide?:Animation,
182
+ containerElement: Element,
183
+ childrenToInsert: Element | Element[] | DocumentFragment | Text | string | null | undefined,
184
+ animationForHide?:Animation,
179
185
  animationForShow?:Animation
180
186
  ) {
181
- //await removeAllChildrenAsync(containerElement, animationForHide)
187
+ const previousReplacement = replacementPromiseByElement.get(containerElement)
188
+ const replacement = (previousReplacement ?? Promise.resolve())
189
+ .catch(() => undefined)
190
+ .then(async () => {
191
+ if(containerElement.childNodes.length>0){
192
+ await removeAllChildrenAsync(containerElement, animationForHide)
193
+ }
194
+ await appendChildrenAsync(containerElement, childrenToInsert, animationForShow)
195
+ })
182
196
 
183
- if(containerElement.childNodes.length>0){
184
- await removeAllChildrenAsync(containerElement, animationForHide)
185
- }
197
+ replacementPromiseByElement.set(containerElement, replacement)
186
198
 
187
- // while(containerElement.childNodes.length>0){
188
- // await removeAllChildrenAsync(containerElement, animationForHide) // can be executed more than once! TODO: why?
189
- // }
190
- // if(containerElement.children.length>0) {
191
- // console.error('DomeManipulator: replaceAllChildenrAsync() failed:', 'containerElement.children.length=', containerElement.children.length, 'children=', containerElement.children, 'containerElement=', containerElement, 'animationForHide=', animationForHide)
192
- // throw new Error()
193
- // }
194
- await appendChildrenAsync(containerElement, childrenToInsert, animationForShow)
199
+ try {
200
+ await replacement
201
+ } finally {
202
+ if(replacementPromiseByElement.get(containerElement) === replacement){
203
+ replacementPromiseByElement.delete(containerElement)
204
+ }
205
+ }
195
206
  }
196
207
 
197
208
 
@@ -0,0 +1,252 @@
1
+ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
2
+ import { DomeComponent } from './DomeComponent.mjs'
3
+ import { DomeManipulator } from './DomeManipulator.mjs'
4
+ import type { Animation } from './Animation.mjs'
5
+
6
+ class FakeElement {
7
+ readonly nodeType = 1
8
+ readonly childNodes: FakeElement[] = []
9
+ readonly classList = {
10
+ values: new Set<string>(),
11
+ add: (name:string) => this.classList.values.add(name),
12
+ remove: (name:string) => this.classList.values.delete(name)
13
+ }
14
+ parentNode: FakeElement | null = null
15
+ throwOnAppend = false
16
+
17
+ get firstChild() {
18
+ return this.childNodes[0] ?? null
19
+ }
20
+
21
+ appendChild(child:FakeElement) {
22
+ if(child.throwOnAppend){
23
+ throw new Error('append failed')
24
+ }
25
+ child.parentNode = this
26
+ this.childNodes.push(child)
27
+ return child
28
+ }
29
+
30
+ remove() {
31
+ if(!this.parentNode) return
32
+ const index = this.parentNode.childNodes.indexOf(this)
33
+ if(index >= 0){
34
+ this.parentNode.childNodes.splice(index, 1)
35
+ }
36
+ this.parentNode = null
37
+ }
38
+ }
39
+
40
+ const asElement = (element:FakeElement) => element as unknown as Element
41
+ const animation = (name:string):Animation => ({ cssClassName: name, timeMs: 5 })
42
+ const waitAsync = (timeMs:number) => new Promise<void>(resolve => setTimeout(resolve, timeMs))
43
+
44
+ async function waitForAsync(condition:() => boolean) {
45
+ const timeoutAt = Date.now() + 500
46
+ while(!condition()){
47
+ if(Date.now() >= timeoutAt){
48
+ throw new Error('Timed out waiting for condition')
49
+ }
50
+ await waitAsync(1)
51
+ }
52
+ }
53
+
54
+ function deferred() {
55
+ let resolve!:() => void
56
+ const promise = new Promise<void>(resolvePromise => {
57
+ resolve = resolvePromise
58
+ })
59
+ return { promise, resolve }
60
+ }
61
+
62
+ const originalNode = Object.getOwnPropertyDescriptor(globalThis, 'Node')
63
+
64
+ beforeAll(() => {
65
+ Object.defineProperty(globalThis, 'Node', {
66
+ configurable: true,
67
+ value: { ELEMENT_NODE: 1 }
68
+ })
69
+ })
70
+
71
+ afterAll(() => {
72
+ if(originalNode){
73
+ Object.defineProperty(globalThis, 'Node', originalNode)
74
+ } else {
75
+ delete (globalThis as { Node?:unknown }).Node
76
+ }
77
+ })
78
+
79
+ describe('DomeManipulator.replaceAllChildrenAsync', () => {
80
+ async function expectLastQueuedReplacement(
81
+ animationForHide?:Animation,
82
+ animationForShow?:Animation
83
+ ) {
84
+ const container = new FakeElement()
85
+ const existing = new FakeElement()
86
+ const first = new FakeElement()
87
+ const second = new FakeElement()
88
+ container.appendChild(existing)
89
+
90
+ await Promise.all([
91
+ DomeManipulator.replaceAllChildrenAsync(asElement(container), asElement(first), animationForHide, animationForShow),
92
+ DomeManipulator.replaceAllChildrenAsync(asElement(container), asElement(second), animationForHide, animationForShow)
93
+ ])
94
+
95
+ expect(container.childNodes).toEqual([second])
96
+ }
97
+
98
+ it('serializes two concurrent replacements without animations', async () => {
99
+ await expectLastQueuedReplacement()
100
+ })
101
+
102
+ it('serializes concurrent replacements with a hide animation', async () => {
103
+ await expectLastQueuedReplacement(animation('hide'))
104
+ })
105
+
106
+ it('serializes concurrent replacements with a show animation', async () => {
107
+ await expectLastQueuedReplacement(undefined, animation('show'))
108
+ })
109
+
110
+ it('serializes concurrent replacements with hide and show animations', async () => {
111
+ await expectLastQueuedReplacement(animation('hide'), animation('show'))
112
+ })
113
+
114
+ it('runs replacements on different containers independently', async () => {
115
+ const slowContainer = new FakeElement()
116
+ const fastContainer = new FakeElement()
117
+ const oldSlowChild = new FakeElement()
118
+ const slowChild = new FakeElement()
119
+ const fastChild = new FakeElement()
120
+ slowContainer.appendChild(oldSlowChild)
121
+
122
+ let slowReplacementFinished = false
123
+ const slowReplacement = DomeManipulator.replaceAllChildrenAsync(
124
+ asElement(slowContainer),
125
+ asElement(slowChild),
126
+ { cssClassName: 'hide', timeMs: 30 }
127
+ ).then(() => {
128
+ slowReplacementFinished = true
129
+ })
130
+ await waitAsync(1)
131
+
132
+ await DomeManipulator.replaceAllChildrenAsync(asElement(fastContainer), asElement(fastChild))
133
+
134
+ expect(fastContainer.childNodes).toEqual([fastChild])
135
+ expect(slowReplacementFinished).toBe(false)
136
+ expect(slowContainer.childNodes).toEqual([oldSlowChild])
137
+ await slowReplacement
138
+ })
139
+
140
+ it('does not let a failed replacement block a later replacement', async () => {
141
+ const container = new FakeElement()
142
+ const failingChild = new FakeElement()
143
+ const successfulChild = new FakeElement()
144
+ failingChild.throwOnAppend = true
145
+
146
+ const failedReplacement = DomeManipulator.replaceAllChildrenAsync(
147
+ asElement(container),
148
+ asElement(failingChild)
149
+ )
150
+ const queuedReplacement = DomeManipulator.replaceAllChildrenAsync(
151
+ asElement(container),
152
+ asElement(successfulChild)
153
+ )
154
+
155
+ await expect(failedReplacement).rejects.toThrow('append failed')
156
+ await queuedReplacement
157
+ expect(container.childNodes).toEqual([successfulChild])
158
+ })
159
+ })
160
+
161
+ class ScheduledTestComponent extends DomeComponent<{}> {
162
+ updateCount = 0
163
+ activeUpdates = 0
164
+ maximumActiveUpdates = 0
165
+ updateBehavior:() => Promise<void> = async () => undefined
166
+
167
+ protected render():HTMLElement {
168
+ return {} as HTMLElement
169
+ }
170
+
171
+ override async updateAsync() {
172
+ this.updateCount++
173
+ this.activeUpdates++
174
+ this.maximumActiveUpdates = Math.max(this.maximumActiveUpdates, this.activeUpdates)
175
+ try {
176
+ await this.updateBehavior()
177
+ } finally {
178
+ this.activeUpdates--
179
+ }
180
+ }
181
+ }
182
+
183
+ describe('DomeComponent.scheduleUpdate', () => {
184
+ it('debounces repeated requests made before an update starts', async () => {
185
+ const component = new ScheduledTestComponent({}, null)
186
+
187
+ await Promise.all([
188
+ component.scheduleUpdate(),
189
+ component.scheduleUpdate(),
190
+ component.scheduleUpdate()
191
+ ])
192
+
193
+ expect(component.updateCount).toBe(1)
194
+ })
195
+
196
+ it('coalesces requests during an update into exactly one follow-up update', async () => {
197
+ const component = new ScheduledTestComponent({}, null)
198
+ const firstUpdate = deferred()
199
+ component.updateBehavior = () => component.updateCount === 1
200
+ ? firstUpdate.promise
201
+ : Promise.resolve()
202
+
203
+ const initialSchedule = component.scheduleUpdate()
204
+ await waitForAsync(() => component.updateCount === 1)
205
+ component.scheduleUpdate()
206
+ component.scheduleUpdate()
207
+ component.scheduleUpdate()
208
+ await waitAsync(10)
209
+ firstUpdate.resolve()
210
+ await initialSchedule
211
+
212
+ expect(component.updateCount).toBe(2)
213
+ })
214
+
215
+ it('never runs scheduled updates concurrently for one component', async () => {
216
+ const component = new ScheduledTestComponent({}, null)
217
+ const firstUpdate = deferred()
218
+ component.updateBehavior = () => component.updateCount === 1
219
+ ? firstUpdate.promise
220
+ : Promise.resolve()
221
+
222
+ const initialSchedule = component.scheduleUpdate()
223
+ await waitForAsync(() => component.updateCount === 1)
224
+ component.scheduleUpdate()
225
+ await waitAsync(10)
226
+
227
+ expect(component.maximumActiveUpdates).toBe(1)
228
+ firstUpdate.resolve()
229
+ await initialSchedule
230
+ expect(component.maximumActiveUpdates).toBe(1)
231
+ })
232
+
233
+ it('allows subsequent scheduled updates after updateAsync rejects', async () => {
234
+ const component = new ScheduledTestComponent({}, null)
235
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
236
+ component.updateBehavior = async () => {
237
+ if(component.updateCount === 1){
238
+ throw new Error('update failed')
239
+ }
240
+ }
241
+
242
+ try {
243
+ await component.scheduleUpdate()
244
+ await component.scheduleUpdate()
245
+ } finally {
246
+ consoleError.mockRestore()
247
+ }
248
+
249
+ expect(component.updateCount).toBe(2)
250
+ expect(component.maximumActiveUpdates).toBe(1)
251
+ })
252
+ })