@lexriver/dome 2.0.0 → 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.
- package/README.md +10 -4
- package/out/src/DomeComponent.d.mts +4 -1
- package/out/src/DomeComponent.mjs +24 -3
- package/out/src/DomeManipulator.d.mts +4 -0
- package/out/src/DomeManipulator.mjs +24 -13
- package/out/src/DomeRouter.d.mts +3 -1
- package/out/src/DomeRouter.mjs +14 -1
- package/out/src/concurrent-updates.test.d.mts +1 -0
- package/out/src/concurrent-updates.test.mjs +203 -0
- package/package.json +1 -1
- package/src/AnimatedArray.mts +0 -0
- package/src/AnimatedTable.mts +0 -0
- package/src/AnimatedText.mts +0 -0
- package/src/Animation.mts +0 -0
- package/src/Dome.mts +0 -0
- package/src/DomeComponent.mts +24 -3
- package/src/DomeManipulator.mts +28 -17
- package/src/DomeRouter.console-test.mts +0 -0
- package/src/DomeRouter.mts +17 -3
- package/src/LongestCommonSubsequence.mts +0 -0
- package/src/concurrent-updates.test.mts +252 -0
- package/src/index.mts +0 -0
- package/src/temp-test.mts +0 -0
- package/tsconfig.json +0 -0
- package/vitest.config.ts +0 -0
package/README.md
CHANGED
|
@@ -1350,7 +1350,7 @@ DomeManipulator.scrollToY({
|
|
|
1350
1350
|
Use DomeRouter for navigation in single page app.
|
|
1351
1351
|
|
|
1352
1352
|
```typescript
|
|
1353
|
-
DomeRouter.onRoute('/about', true, async (params, url) => {
|
|
1353
|
+
DomeRouter.onRoute('/about', true, async (params, url, scrollTo, query) => {
|
|
1354
1354
|
const { PageAbout } = await import('./pages/PageAbout') // dynamic import component
|
|
1355
1355
|
DomeManipulator.replaceAllChildrenAsync(divContainer, <PageAbout />, animationHide, animationShow) // replace current page with new page
|
|
1356
1356
|
})
|
|
@@ -1507,12 +1507,18 @@ Parameters:
|
|
|
1507
1507
|
Where `RouteAction` type is:
|
|
1508
1508
|
```typescript
|
|
1509
1509
|
export type RouteAction = (
|
|
1510
|
-
params:{[key:string]:string}, // parameters from url
|
|
1511
|
-
url:string,
|
|
1512
|
-
scrollToPreviousPositionAsync:()=>Promise<void
|
|
1510
|
+
params:{[key:string]:string|number}, // parameters from url path
|
|
1511
|
+
url:string, // url path without query string
|
|
1512
|
+
scrollToPreviousPositionAsync:()=>Promise<void>, // this function can be called to scroll to previous page position
|
|
1513
|
+
query:{[key:string]:string} // query parameters from window.location.search
|
|
1513
1514
|
) => void|Promise<void>
|
|
1514
1515
|
```
|
|
1515
1516
|
|
|
1517
|
+
For example, for URL `/search?category=books&sort=price`, the `query` parameter will be:
|
|
1518
|
+
```typescript
|
|
1519
|
+
{category: 'books', sort: 'price'}
|
|
1520
|
+
```
|
|
1521
|
+
|
|
1516
1522
|
example:
|
|
1517
1523
|
```typescript
|
|
1518
1524
|
DomeRouter.onRoute('/login', true, (params, url) => changePage(<PageLogin />))
|
|
@@ -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
|
-
|
|
39
|
-
this.
|
|
40
|
-
|
|
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
|
-
//
|
|
125
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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) {
|
package/out/src/DomeRouter.d.mts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export type RouteAction = (params: {
|
|
2
|
+
[key: string]: string | number;
|
|
3
|
+
}, url: string, scrollToPreviousPositionAsync: () => Promise<void>, query: {
|
|
2
4
|
[key: string]: string;
|
|
3
|
-
}
|
|
5
|
+
}) => void | Promise<void>;
|
|
4
6
|
export declare namespace DomeRouter {
|
|
5
7
|
let maxHistoryUrlsCount: number;
|
|
6
8
|
function navigate(url: string): void;
|
package/out/src/DomeRouter.mjs
CHANGED
|
@@ -182,7 +182,20 @@ export var DomeRouter;
|
|
|
182
182
|
return [name.substring(1), value];
|
|
183
183
|
}
|
|
184
184
|
}
|
|
185
|
+
function parseQueryString(search) {
|
|
186
|
+
const query = {};
|
|
187
|
+
if (!search || search[0] !== '?')
|
|
188
|
+
return query;
|
|
189
|
+
const params = new URLSearchParams(search.substring(1));
|
|
190
|
+
for (const [key, value] of params.entries()) {
|
|
191
|
+
query[key] = value;
|
|
192
|
+
}
|
|
193
|
+
return query;
|
|
194
|
+
}
|
|
185
195
|
async function executeAsync(url = window.location.pathname, scrollToPosition) {
|
|
196
|
+
const queryString = window.location.search;
|
|
197
|
+
const query = parseQueryString(queryString);
|
|
198
|
+
url = url.split('?')[0]; // Strip query params
|
|
186
199
|
//const url = window.location.pathname
|
|
187
200
|
const urlSlices = getRouteSlices(url);
|
|
188
201
|
// urlSlices = show, category, 345
|
|
@@ -225,7 +238,7 @@ export var DomeRouter;
|
|
|
225
238
|
await DomeManipulator.scrollToAsync({
|
|
226
239
|
pxFromTop: scrollToPosition
|
|
227
240
|
});
|
|
228
|
-
});
|
|
241
|
+
}, query);
|
|
229
242
|
}
|
|
230
243
|
}
|
|
231
244
|
if (countOfFoundRoutes == 0 && onNotFoundAction) {
|
|
@@ -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
package/src/AnimatedArray.mts
CHANGED
|
File without changes
|
package/src/AnimatedTable.mts
CHANGED
|
File without changes
|
package/src/AnimatedText.mts
CHANGED
|
File without changes
|
package/src/Animation.mts
CHANGED
|
File without changes
|
package/src/Dome.mts
CHANGED
|
File without changes
|
package/src/DomeComponent.mts
CHANGED
|
@@ -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
|
-
|
|
52
|
-
this.
|
|
53
|
-
|
|
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
|
|
package/src/DomeManipulator.mts
CHANGED
|
@@ -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
|
-
//
|
|
138
|
-
|
|
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
|
-
|
|
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
|
-
|
|
184
|
-
await removeAllChildrenAsync(containerElement, animationForHide)
|
|
185
|
-
}
|
|
197
|
+
replacementPromiseByElement.set(containerElement, replacement)
|
|
186
198
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
|
|
File without changes
|
package/src/DomeRouter.mts
CHANGED
|
@@ -3,9 +3,10 @@ import { DomeManipulator } from "./DomeManipulator.mjs"
|
|
|
3
3
|
// const filename = '[DomeRouter]'
|
|
4
4
|
|
|
5
5
|
export type RouteAction = (
|
|
6
|
-
params:{[key:string]:string},
|
|
6
|
+
params:{[key:string]:string|number},
|
|
7
7
|
url:string,
|
|
8
|
-
scrollToPreviousPositionAsync:()=>Promise<void
|
|
8
|
+
scrollToPreviousPositionAsync:()=>Promise<void>,
|
|
9
|
+
query:{[key:string]:string}
|
|
9
10
|
) => void|Promise<void>
|
|
10
11
|
|
|
11
12
|
interface Route{
|
|
@@ -215,7 +216,20 @@ export namespace DomeRouter {
|
|
|
215
216
|
|
|
216
217
|
}
|
|
217
218
|
|
|
219
|
+
function parseQueryString(search:string):{[key:string]:string}{
|
|
220
|
+
const query:{[key:string]:string} = {}
|
|
221
|
+
if(!search || search[0] !== '?') return query
|
|
222
|
+
const params = new URLSearchParams(search.substring(1))
|
|
223
|
+
for(const [key, value] of params.entries()){
|
|
224
|
+
query[key] = value
|
|
225
|
+
}
|
|
226
|
+
return query
|
|
227
|
+
}
|
|
228
|
+
|
|
218
229
|
async function executeAsync(url:string = window.location.pathname, scrollToPosition:number){
|
|
230
|
+
const queryString = window.location.search
|
|
231
|
+
const query = parseQueryString(queryString)
|
|
232
|
+
url = url.split('?')[0] // Strip query params
|
|
219
233
|
//const url = window.location.pathname
|
|
220
234
|
const urlSlices = getRouteSlices(url)
|
|
221
235
|
// urlSlices = show, category, 345
|
|
@@ -259,7 +273,7 @@ export namespace DomeRouter {
|
|
|
259
273
|
await DomeManipulator.scrollToAsync({
|
|
260
274
|
pxFromTop: scrollToPosition
|
|
261
275
|
})
|
|
262
|
-
})
|
|
276
|
+
}, query)
|
|
263
277
|
}
|
|
264
278
|
}
|
|
265
279
|
if(countOfFoundRoutes==0 && onNotFoundAction){
|
|
File without changes
|
|
@@ -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
|
+
})
|
package/src/index.mts
CHANGED
|
File without changes
|
package/src/temp-test.mts
CHANGED
|
File without changes
|
package/tsconfig.json
CHANGED
|
File without changes
|
package/vitest.config.ts
CHANGED
|
File without changes
|