mount-observer 0.0.0 → 0.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/.github/workflows/CI.yml +18 -0
- package/MountObserver.js +269 -0
- package/MountObserver.ts +277 -0
- package/README.md +212 -1
- package/RootMutObs.js +29 -0
- package/RootMutObs.ts +37 -0
- package/demo/test1.html +28 -0
- package/package.json +11 -3
- package/playwright.config.ts +29 -0
- package/tests/test1.html +30 -0
- package/tests/test1.spec.mjs +8 -0
- package/tests/test2.html +34 -0
- package/tests/test2.spec.mjs +8 -0
- package/tests/test3.html +34 -0
- package/tests/test3.spec.mjs +8 -0
- package/tests/test4.html +31 -0
- package/tests/test4.spec.mjs +8 -0
- package/tests/test5.html +34 -0
- package/tests/test5.spec.mjs +8 -0
- package/tests/test6.html +39 -0
- package/tests/test6.spec.mjs +8 -0
- package/tsconfig.json +17 -0
- package/types.d.ts +110 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
name: Playwright Tests
|
|
2
|
+
on: [push]
|
|
3
|
+
jobs:
|
|
4
|
+
test:
|
|
5
|
+
timeout-minutes: 10
|
|
6
|
+
runs-on: ubuntu-latest
|
|
7
|
+
steps:
|
|
8
|
+
- uses: actions/checkout@v2
|
|
9
|
+
- uses: actions/setup-node@v2
|
|
10
|
+
with:
|
|
11
|
+
node-version: '16.x'
|
|
12
|
+
- name: Install dependencies
|
|
13
|
+
run: npm ci
|
|
14
|
+
- name: Install Playwright
|
|
15
|
+
run: npx playwright install --with-deps
|
|
16
|
+
- name: Run Playwright tests
|
|
17
|
+
run: npm run test
|
|
18
|
+
|
package/MountObserver.js
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { RootMutObs } from './RootMutObs.js';
|
|
2
|
+
const mutationObserverLookup = new WeakMap();
|
|
3
|
+
export class MountObserver extends EventTarget {
|
|
4
|
+
#mountInit;
|
|
5
|
+
#rootMutObs;
|
|
6
|
+
#abortController;
|
|
7
|
+
#mounted;
|
|
8
|
+
#mountedList;
|
|
9
|
+
#disconnected;
|
|
10
|
+
//#unmounted: WeakSet<Element>;
|
|
11
|
+
#isComplex;
|
|
12
|
+
constructor(init) {
|
|
13
|
+
super();
|
|
14
|
+
const { match, whereElementIntersectsWith, whereMediaMatches } = init;
|
|
15
|
+
this.#isComplex = match !== undefined && (match.includes(' ') || match.includes(':'));
|
|
16
|
+
if (whereElementIntersectsWith || whereMediaMatches)
|
|
17
|
+
throw 'NI'; //not implemented
|
|
18
|
+
this.#mountInit = init;
|
|
19
|
+
this.#abortController = new AbortController();
|
|
20
|
+
this.#mounted = new WeakSet();
|
|
21
|
+
this.#disconnected = new WeakSet();
|
|
22
|
+
//this.#unmounted = new WeakSet();
|
|
23
|
+
}
|
|
24
|
+
#calculatedSelector;
|
|
25
|
+
get #selector() {
|
|
26
|
+
if (this.#calculatedSelector !== undefined)
|
|
27
|
+
return this.#calculatedSelector;
|
|
28
|
+
const { match, attribMatches } = this.#mountInit;
|
|
29
|
+
const base = match || '*';
|
|
30
|
+
if (attribMatches === undefined)
|
|
31
|
+
return base;
|
|
32
|
+
const matches = [];
|
|
33
|
+
attribMatches.forEach(x => {
|
|
34
|
+
const { names } = x;
|
|
35
|
+
names.forEach(y => {
|
|
36
|
+
matches.push(`${base}[${y}]`);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
this.#calculatedSelector = matches.join(',');
|
|
40
|
+
return this.#calculatedSelector;
|
|
41
|
+
}
|
|
42
|
+
async observe(within) {
|
|
43
|
+
const nodeToMonitor = this.#isComplex ? (within instanceof ShadowRoot ? within : within.getRootNode()) : within;
|
|
44
|
+
if (!mutationObserverLookup.has(nodeToMonitor)) {
|
|
45
|
+
mutationObserverLookup.set(nodeToMonitor, new RootMutObs(nodeToMonitor));
|
|
46
|
+
}
|
|
47
|
+
const rootMutObs = mutationObserverLookup.get(within);
|
|
48
|
+
const { attribMatches } = this.#mountInit;
|
|
49
|
+
rootMutObs.addEventListener('mutation-event', (e) => {
|
|
50
|
+
//TODO: disconnected
|
|
51
|
+
if (this.#isComplex) {
|
|
52
|
+
this.#inspectWithin(within);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const { mutationRecords } = e;
|
|
56
|
+
const elsToInspect = [];
|
|
57
|
+
//const elsToDisconnect: Array<Element> = [];
|
|
58
|
+
const doDisconnect = this.#mountInit.do?.onDisconnect;
|
|
59
|
+
for (const mutationRecord of mutationRecords) {
|
|
60
|
+
const { addedNodes, type, removedNodes } = mutationRecord;
|
|
61
|
+
console.log(mutationRecord);
|
|
62
|
+
const addedElements = Array.from(addedNodes).filter(x => x instanceof Element);
|
|
63
|
+
addedElements.forEach(x => elsToInspect.push(x));
|
|
64
|
+
if (type === 'attributes') {
|
|
65
|
+
const { target, attributeName, oldValue } = mutationRecord;
|
|
66
|
+
if (target instanceof Element && attributeName !== null && attribMatches !== undefined && this.#mounted.has(target)) {
|
|
67
|
+
let idx = 0;
|
|
68
|
+
for (const attrMatch of attribMatches) {
|
|
69
|
+
const { names } = attrMatch;
|
|
70
|
+
if (names.includes(attributeName)) {
|
|
71
|
+
const newValue = target.getAttribute(attributeName);
|
|
72
|
+
// let parsedNewValue = undefined;
|
|
73
|
+
// switch(type){
|
|
74
|
+
// case 'boolean':
|
|
75
|
+
// parsedNewValue = newValue === 'true' ? true : newValue === 'false' ? false : null;
|
|
76
|
+
// break;
|
|
77
|
+
// case 'date':
|
|
78
|
+
// parsedNewValue = newValue === null ? null : new Date(newValue);
|
|
79
|
+
// break;
|
|
80
|
+
// case ''
|
|
81
|
+
// }
|
|
82
|
+
const attrChangeInfo = {
|
|
83
|
+
name: attributeName,
|
|
84
|
+
oldValue,
|
|
85
|
+
newValue,
|
|
86
|
+
idx
|
|
87
|
+
};
|
|
88
|
+
this.dispatchEvent(new AttrChangeEvent(target, attrChangeInfo));
|
|
89
|
+
}
|
|
90
|
+
idx++;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
elsToInspect.push(target);
|
|
94
|
+
}
|
|
95
|
+
const deletedElements = Array.from(removedNodes).filter(x => x instanceof Element);
|
|
96
|
+
for (const deletedElement of deletedElements) {
|
|
97
|
+
// if(!this.#mounted.has(deletedElement)) continue;
|
|
98
|
+
// this.#mounted.delete(deletedElement);
|
|
99
|
+
// this.#mountedList = this.#mountedList?.filter(x => x.deref() !== deletedElement);
|
|
100
|
+
this.#disconnected.add(deletedElement);
|
|
101
|
+
if (doDisconnect !== undefined) {
|
|
102
|
+
doDisconnect(deletedElement, this);
|
|
103
|
+
}
|
|
104
|
+
this.dispatchEvent(new DisconnectEvent(deletedElement));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
this.#filterAndMount(elsToInspect, true);
|
|
108
|
+
}, { signal: this.#abortController.signal });
|
|
109
|
+
await this.#inspectWithin(within);
|
|
110
|
+
}
|
|
111
|
+
#confirmInstanceOf(el, whereInstanceOf) {
|
|
112
|
+
for (const test of whereInstanceOf) {
|
|
113
|
+
if (el instanceof test)
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
async #mount(matching) {
|
|
119
|
+
//first unmount non matching
|
|
120
|
+
const alreadyMounted = this.#filterAndDismount();
|
|
121
|
+
const onMount = this.#mountInit.do?.onMount;
|
|
122
|
+
const { import: imp, attribMatches } = this.#mountInit;
|
|
123
|
+
for (const match of matching) {
|
|
124
|
+
if (alreadyMounted.has(match))
|
|
125
|
+
continue;
|
|
126
|
+
this.#mounted.add(match);
|
|
127
|
+
if (imp !== undefined) {
|
|
128
|
+
switch (typeof imp) {
|
|
129
|
+
case 'string':
|
|
130
|
+
this.module = await import(imp);
|
|
131
|
+
break;
|
|
132
|
+
case 'object':
|
|
133
|
+
if (Array.isArray(imp)) {
|
|
134
|
+
this.module = await import(imp[0], imp[1]);
|
|
135
|
+
}
|
|
136
|
+
break;
|
|
137
|
+
case 'function':
|
|
138
|
+
this.module = await imp(match, this, 'Import');
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (onMount !== undefined)
|
|
143
|
+
onMount(match, this, 'PostImport');
|
|
144
|
+
this.dispatchEvent(new MountEvent(match));
|
|
145
|
+
if (attribMatches !== undefined) {
|
|
146
|
+
let idx = 0;
|
|
147
|
+
for (const attribMatch of attribMatches) {
|
|
148
|
+
let newValue = null;
|
|
149
|
+
const { names } = attribMatch;
|
|
150
|
+
let nonNullName = names[0];
|
|
151
|
+
for (const name of names) {
|
|
152
|
+
const attrVal = match.getAttribute(name);
|
|
153
|
+
if (attrVal !== null)
|
|
154
|
+
nonNullName = name;
|
|
155
|
+
newValue = newValue || attrVal;
|
|
156
|
+
}
|
|
157
|
+
const attribInfo = {
|
|
158
|
+
oldValue: null,
|
|
159
|
+
newValue,
|
|
160
|
+
idx,
|
|
161
|
+
name: nonNullName
|
|
162
|
+
};
|
|
163
|
+
this.dispatchEvent(new AttrChangeEvent(match, attribInfo));
|
|
164
|
+
idx++;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
this.#mountedList?.push(new WeakRef(match));
|
|
168
|
+
//if(this.#unmounted.has(match)) this.#unmounted.delete(match);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
async #dismount(unmatching) {
|
|
172
|
+
const onDismount = this.#mountInit.do?.onDismount;
|
|
173
|
+
for (const unmatch of unmatching) {
|
|
174
|
+
if (onDismount !== undefined) {
|
|
175
|
+
onDismount(unmatch, this);
|
|
176
|
+
}
|
|
177
|
+
this.dispatchEvent(new DismountEvent(unmatch));
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
#filterAndDismount() {
|
|
181
|
+
const returnSet = new Set();
|
|
182
|
+
if (this.#mountedList !== undefined) {
|
|
183
|
+
const previouslyMounted = this.#mountedList.map(x => x.deref());
|
|
184
|
+
const { whereSatisfies, whereInstanceOf } = this.#mountInit;
|
|
185
|
+
const match = this.#selector;
|
|
186
|
+
const elsToUnMount = previouslyMounted.filter(x => {
|
|
187
|
+
if (x === undefined)
|
|
188
|
+
return false;
|
|
189
|
+
if (!x.matches(match))
|
|
190
|
+
return true;
|
|
191
|
+
if (whereSatisfies !== undefined) {
|
|
192
|
+
if (!whereSatisfies(x, this, 'Inspecting'))
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
returnSet.add(x);
|
|
196
|
+
return false;
|
|
197
|
+
});
|
|
198
|
+
this.#dismount(elsToUnMount);
|
|
199
|
+
}
|
|
200
|
+
this.#mountedList = Array.from(returnSet).map(x => new WeakRef(x));
|
|
201
|
+
return returnSet;
|
|
202
|
+
}
|
|
203
|
+
async #filterAndMount(els, checkMatch) {
|
|
204
|
+
const { whereSatisfies, whereInstanceOf } = this.#mountInit;
|
|
205
|
+
const match = this.#selector;
|
|
206
|
+
const elsToMount = els.filter(x => {
|
|
207
|
+
if (checkMatch) {
|
|
208
|
+
if (!x.matches(match))
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
if (whereSatisfies !== undefined) {
|
|
212
|
+
if (!whereSatisfies(x, this, 'Inspecting'))
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
if (whereInstanceOf !== undefined) {
|
|
216
|
+
if (!this.#confirmInstanceOf(x, whereInstanceOf))
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
return true;
|
|
220
|
+
});
|
|
221
|
+
this.#mount(elsToMount);
|
|
222
|
+
}
|
|
223
|
+
async #inspectWithin(within) {
|
|
224
|
+
const els = Array.from(within.querySelectorAll(this.#selector));
|
|
225
|
+
this.#filterAndMount(els, false);
|
|
226
|
+
}
|
|
227
|
+
unobserve() {
|
|
228
|
+
throw 'NI';
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
// https://github.com/webcomponents-cg/community-protocols/issues/12#issuecomment-872415080
|
|
232
|
+
/**
|
|
233
|
+
* The `mutation-event` event represents something that happened.
|
|
234
|
+
* We can document it here.
|
|
235
|
+
*/
|
|
236
|
+
export class MountEvent extends Event {
|
|
237
|
+
mountedElement;
|
|
238
|
+
static eventName = 'mount';
|
|
239
|
+
constructor(mountedElement) {
|
|
240
|
+
super(MountEvent.eventName);
|
|
241
|
+
this.mountedElement = mountedElement;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
export class DismountEvent extends Event {
|
|
245
|
+
dismountedElement;
|
|
246
|
+
static eventName = 'dismount';
|
|
247
|
+
constructor(dismountedElement) {
|
|
248
|
+
super(DismountEvent.eventName);
|
|
249
|
+
this.dismountedElement = dismountedElement;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
export class DisconnectEvent extends Event {
|
|
253
|
+
disconnectedElement;
|
|
254
|
+
static eventName = 'disconnect';
|
|
255
|
+
constructor(disconnectedElement) {
|
|
256
|
+
super(DisconnectEvent.eventName);
|
|
257
|
+
this.disconnectedElement = disconnectedElement;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
export class AttrChangeEvent extends Event {
|
|
261
|
+
mountedElement;
|
|
262
|
+
attrChangeInfo;
|
|
263
|
+
static eventName = 'attr-change';
|
|
264
|
+
constructor(mountedElement, attrChangeInfo) {
|
|
265
|
+
super(AttrChangeEvent.eventName);
|
|
266
|
+
this.mountedElement = mountedElement;
|
|
267
|
+
this.attrChangeInfo = attrChangeInfo;
|
|
268
|
+
}
|
|
269
|
+
}
|
package/MountObserver.ts
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import {MountInit, MountContext, AddMutationEventListener,
|
|
2
|
+
MutationEvent, dismountEventName, mountEventName, IMountEvent, IDismountEvent,
|
|
3
|
+
disconnectedEventName, IDisconnectEvent, IAttrChangeEvent, attrChangeEventName, AttrChangeInfo
|
|
4
|
+
} from './types';
|
|
5
|
+
import {RootMutObs} from './RootMutObs.js';
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
const mutationObserverLookup = new WeakMap<Node, RootMutObs>();
|
|
9
|
+
export class MountObserver extends EventTarget implements MountContext{
|
|
10
|
+
|
|
11
|
+
#mountInit: MountInit;
|
|
12
|
+
#rootMutObs: RootMutObs | undefined;
|
|
13
|
+
#abortController: AbortController;
|
|
14
|
+
#mounted: WeakSet<Element>;
|
|
15
|
+
#mountedList: Array<WeakRef<Element>> | undefined;
|
|
16
|
+
#disconnected: WeakSet<Element>;
|
|
17
|
+
//#unmounted: WeakSet<Element>;
|
|
18
|
+
#isComplex: boolean;
|
|
19
|
+
|
|
20
|
+
constructor(init: MountInit){
|
|
21
|
+
super();
|
|
22
|
+
const {match, whereElementIntersectsWith, whereMediaMatches} = init;
|
|
23
|
+
this.#isComplex = match !== undefined && (match.includes(' ') || match.includes(':'));
|
|
24
|
+
if(whereElementIntersectsWith || whereMediaMatches) throw 'NI'; //not implemented
|
|
25
|
+
this.#mountInit = init;
|
|
26
|
+
this.#abortController = new AbortController();
|
|
27
|
+
this.#mounted = new WeakSet();
|
|
28
|
+
this.#disconnected = new WeakSet();
|
|
29
|
+
//this.#unmounted = new WeakSet();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
#calculatedSelector: string | undefined;
|
|
33
|
+
get #selector(){
|
|
34
|
+
if(this.#calculatedSelector !== undefined) return this.#calculatedSelector;
|
|
35
|
+
const {match, attribMatches} = this.#mountInit;
|
|
36
|
+
const base = match || '*';
|
|
37
|
+
if(attribMatches === undefined) return base;
|
|
38
|
+
const matches: Array<string> = [];
|
|
39
|
+
attribMatches.forEach(x => {
|
|
40
|
+
const {names} = x;
|
|
41
|
+
names.forEach(y => {
|
|
42
|
+
matches.push(`${base}[${y}]`)
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
this.#calculatedSelector = matches.join(',');
|
|
46
|
+
return this.#calculatedSelector;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async observe(within: Node){
|
|
50
|
+
const nodeToMonitor = this.#isComplex ? (within instanceof ShadowRoot ? within : within.getRootNode()) : within;
|
|
51
|
+
if(!mutationObserverLookup.has(nodeToMonitor)){
|
|
52
|
+
mutationObserverLookup.set(nodeToMonitor, new RootMutObs(nodeToMonitor));
|
|
53
|
+
}
|
|
54
|
+
const rootMutObs = mutationObserverLookup.get(within)!;
|
|
55
|
+
const {attribMatches} = this.#mountInit;
|
|
56
|
+
(rootMutObs as any as AddMutationEventListener).addEventListener('mutation-event', (e: MutationEvent) => {
|
|
57
|
+
//TODO: disconnected
|
|
58
|
+
if(this.#isComplex){
|
|
59
|
+
this.#inspectWithin(within);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const {mutationRecords} = e;
|
|
63
|
+
const elsToInspect: Array<Element> = [];
|
|
64
|
+
//const elsToDisconnect: Array<Element> = [];
|
|
65
|
+
const doDisconnect = this.#mountInit.do?.onDisconnect;
|
|
66
|
+
for(const mutationRecord of mutationRecords){
|
|
67
|
+
const {addedNodes, type, removedNodes} = mutationRecord;
|
|
68
|
+
console.log(mutationRecord);
|
|
69
|
+
const addedElements = Array.from(addedNodes).filter(x => x instanceof Element) as Array<Element>;
|
|
70
|
+
addedElements.forEach(x => elsToInspect.push(x));
|
|
71
|
+
if(type === 'attributes'){
|
|
72
|
+
const {target, attributeName, oldValue} = mutationRecord;
|
|
73
|
+
if(target instanceof Element && attributeName !== null && attribMatches !== undefined && this.#mounted.has(target)){
|
|
74
|
+
let idx = 0;
|
|
75
|
+
for(const attrMatch of attribMatches){
|
|
76
|
+
const {names} = attrMatch;
|
|
77
|
+
if(names.includes(attributeName)){
|
|
78
|
+
const newValue = target.getAttribute(attributeName);
|
|
79
|
+
// let parsedNewValue = undefined;
|
|
80
|
+
// switch(type){
|
|
81
|
+
// case 'boolean':
|
|
82
|
+
// parsedNewValue = newValue === 'true' ? true : newValue === 'false' ? false : null;
|
|
83
|
+
// break;
|
|
84
|
+
// case 'date':
|
|
85
|
+
// parsedNewValue = newValue === null ? null : new Date(newValue);
|
|
86
|
+
// break;
|
|
87
|
+
// case ''
|
|
88
|
+
|
|
89
|
+
// }
|
|
90
|
+
const attrChangeInfo: AttrChangeInfo = {
|
|
91
|
+
name: attributeName,
|
|
92
|
+
oldValue,
|
|
93
|
+
newValue,
|
|
94
|
+
idx
|
|
95
|
+
};
|
|
96
|
+
this.dispatchEvent(new AttrChangeEvent(target, attrChangeInfo));
|
|
97
|
+
}
|
|
98
|
+
idx++;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
elsToInspect.push(target as Element);
|
|
102
|
+
}
|
|
103
|
+
const deletedElements = Array.from(removedNodes).filter(x => x instanceof Element) as Array<Element>;
|
|
104
|
+
for(const deletedElement of deletedElements){
|
|
105
|
+
// if(!this.#mounted.has(deletedElement)) continue;
|
|
106
|
+
// this.#mounted.delete(deletedElement);
|
|
107
|
+
// this.#mountedList = this.#mountedList?.filter(x => x.deref() !== deletedElement);
|
|
108
|
+
this.#disconnected.add(deletedElement);
|
|
109
|
+
if(doDisconnect !== undefined){
|
|
110
|
+
doDisconnect(deletedElement, this);
|
|
111
|
+
}
|
|
112
|
+
this.dispatchEvent(new DisconnectEvent(deletedElement));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
}
|
|
116
|
+
this.#filterAndMount(elsToInspect, true);
|
|
117
|
+
}, {signal: this.#abortController.signal});
|
|
118
|
+
await this.#inspectWithin(within);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
#confirmInstanceOf(el: Element, whereInstanceOf: Array<typeof Node>){
|
|
122
|
+
for(const test of whereInstanceOf){
|
|
123
|
+
if(el instanceof test) return true;
|
|
124
|
+
}
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async #mount(matching: Array<Element>){
|
|
129
|
+
//first unmount non matching
|
|
130
|
+
const alreadyMounted = this.#filterAndDismount();
|
|
131
|
+
const onMount = this.#mountInit.do?.onMount;
|
|
132
|
+
const {import: imp, attribMatches} = this.#mountInit;
|
|
133
|
+
for(const match of matching){
|
|
134
|
+
if(alreadyMounted.has(match)) continue;
|
|
135
|
+
this.#mounted.add(match);
|
|
136
|
+
if(imp !== undefined){
|
|
137
|
+
switch(typeof imp){
|
|
138
|
+
case 'string':
|
|
139
|
+
this.module = await import(imp);
|
|
140
|
+
break;
|
|
141
|
+
case 'object':
|
|
142
|
+
if(Array.isArray(imp)){
|
|
143
|
+
this.module = await import(imp[0], imp[1]);
|
|
144
|
+
}
|
|
145
|
+
break;
|
|
146
|
+
case 'function':
|
|
147
|
+
this.module = await imp(match, this, 'Import');
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if(onMount !== undefined) onMount(match, this, 'PostImport');
|
|
152
|
+
this.dispatchEvent(new MountEvent(match));
|
|
153
|
+
if(attribMatches !== undefined){
|
|
154
|
+
let idx = 0;
|
|
155
|
+
for(const attribMatch of attribMatches){
|
|
156
|
+
let newValue = null;
|
|
157
|
+
const {names} = attribMatch;
|
|
158
|
+
let nonNullName = names[0];
|
|
159
|
+
for(const name of names){
|
|
160
|
+
const attrVal = match.getAttribute(name);
|
|
161
|
+
if(attrVal !== null) nonNullName = name;
|
|
162
|
+
newValue = newValue || attrVal;
|
|
163
|
+
}
|
|
164
|
+
const attribInfo: AttrChangeInfo = {
|
|
165
|
+
oldValue: null,
|
|
166
|
+
newValue,
|
|
167
|
+
idx,
|
|
168
|
+
name: nonNullName
|
|
169
|
+
};
|
|
170
|
+
this.dispatchEvent(new AttrChangeEvent(match, attribInfo));
|
|
171
|
+
idx++;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
this.#mountedList?.push(new WeakRef(match));
|
|
175
|
+
//if(this.#unmounted.has(match)) this.#unmounted.delete(match);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async #dismount(unmatching: Array<Element>){
|
|
180
|
+
const onDismount = this.#mountInit.do?.onDismount
|
|
181
|
+
for(const unmatch of unmatching){
|
|
182
|
+
if(onDismount !== undefined){
|
|
183
|
+
onDismount(unmatch, this);
|
|
184
|
+
}
|
|
185
|
+
this.dispatchEvent(new DismountEvent(unmatch));
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
#filterAndDismount(): Set<Element>{
|
|
190
|
+
const returnSet = new Set<Element>();
|
|
191
|
+
if(this.#mountedList !== undefined){
|
|
192
|
+
const previouslyMounted = this.#mountedList.map(x => x.deref());
|
|
193
|
+
const {whereSatisfies, whereInstanceOf} = this.#mountInit;
|
|
194
|
+
const match = this.#selector;
|
|
195
|
+
const elsToUnMount = previouslyMounted.filter(x => {
|
|
196
|
+
if(x === undefined) return false;
|
|
197
|
+
if(!x.matches(match)) return true;
|
|
198
|
+
if(whereSatisfies !== undefined){
|
|
199
|
+
if(!whereSatisfies(x, this, 'Inspecting')) return true;
|
|
200
|
+
}
|
|
201
|
+
returnSet.add(x);
|
|
202
|
+
return false;
|
|
203
|
+
}) as Array<Element>;
|
|
204
|
+
this.#dismount(elsToUnMount);
|
|
205
|
+
}
|
|
206
|
+
this.#mountedList = Array.from(returnSet).map(x => new WeakRef(x));
|
|
207
|
+
return returnSet;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async #filterAndMount(els: Array<Element>, checkMatch: boolean){
|
|
211
|
+
const {whereSatisfies, whereInstanceOf} = this.#mountInit;
|
|
212
|
+
const match = this.#selector;
|
|
213
|
+
const elsToMount = els.filter(x => {
|
|
214
|
+
if(checkMatch){
|
|
215
|
+
if(!x.matches(match)) return false;
|
|
216
|
+
}
|
|
217
|
+
if(whereSatisfies !== undefined){
|
|
218
|
+
if(!whereSatisfies(x, this, 'Inspecting')) return false;
|
|
219
|
+
}
|
|
220
|
+
if(whereInstanceOf !== undefined){
|
|
221
|
+
if(!this.#confirmInstanceOf(x, whereInstanceOf)) return false;
|
|
222
|
+
}
|
|
223
|
+
return true;
|
|
224
|
+
});
|
|
225
|
+
this.#mount(elsToMount);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async #inspectWithin(within: Node){
|
|
229
|
+
const els = Array.from((within as Element).querySelectorAll(this.#selector));
|
|
230
|
+
this.#filterAndMount(els, false);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
unobserve(){
|
|
234
|
+
throw 'NI';
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export interface MountObserver extends MountContext{}
|
|
240
|
+
|
|
241
|
+
// https://github.com/webcomponents-cg/community-protocols/issues/12#issuecomment-872415080
|
|
242
|
+
/**
|
|
243
|
+
* The `mutation-event` event represents something that happened.
|
|
244
|
+
* We can document it here.
|
|
245
|
+
*/
|
|
246
|
+
export class MountEvent extends Event implements IMountEvent {
|
|
247
|
+
static eventName: mountEventName = 'mount';
|
|
248
|
+
|
|
249
|
+
constructor(public mountedElement: Element) {
|
|
250
|
+
super(MountEvent.eventName);
|
|
251
|
+
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export class DismountEvent extends Event implements IDismountEvent{
|
|
256
|
+
static eventName: dismountEventName = 'dismount';
|
|
257
|
+
|
|
258
|
+
constructor(public dismountedElement: Element){
|
|
259
|
+
super(DismountEvent.eventName)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export class DisconnectEvent extends Event implements IDisconnectEvent{
|
|
264
|
+
static eventName: disconnectedEventName = 'disconnect';
|
|
265
|
+
|
|
266
|
+
constructor(public disconnectedElement: Element){
|
|
267
|
+
super(DisconnectEvent.eventName);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export class AttrChangeEvent extends Event implements IAttrChangeEvent{
|
|
272
|
+
static eventName: attrChangeEventName = 'attr-change';
|
|
273
|
+
constructor(public mountedElement: Element, public attrChangeInfo: AttrChangeInfo){
|
|
274
|
+
super(AttrChangeEvent.eventName);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
package/README.md
CHANGED
|
@@ -1 +1,212 @@
|
|
|
1
|
-
#
|
|
1
|
+
# The MountObserver api.
|
|
2
|
+
|
|
3
|
+
Author: Bruce B. Anderson
|
|
4
|
+
|
|
5
|
+
Issues / pr's / polyfill: [mount-observer](https://github.com/bahrus/mount-observer)
|
|
6
|
+
|
|
7
|
+
Last Update: 2023-11-18
|
|
8
|
+
|
|
9
|
+
## Benefits of this API
|
|
10
|
+
|
|
11
|
+
What follows is a far more ambitious alternative to the [lazy custom element proposal](https://github.com/w3c/webcomponents/issues/782). The goals of the MountObserver api are more encompassing, and less focused on registering custom elements. In fact, this proposal addresses numerous use cases in one api. It is basically mapping common filtering conditions in the DOM, to common actions, like importing a resource, or progressively enhancing an element, or "binding from a distance".
|
|
12
|
+
|
|
13
|
+
"Binding from a distance" refers to empowering the developer to essentially manage their own "stylesheets" -- but rather than for purposes of styling, using these rules to attach behaviors, set property values, etc.
|
|
14
|
+
|
|
15
|
+
The underlying theme is this api is meant to make it easy for the developer to do the right thing, by encouraging lazy loading and smaller footprints. It rolls up most all the other observer api's into one.
|
|
16
|
+
|
|
17
|
+
### Does this api make the impossible possible?
|
|
18
|
+
|
|
19
|
+
There is quite a bit of functionality this proposal would open up, that is exceedingly difficult to polyfill reliably:
|
|
20
|
+
|
|
21
|
+
1. It is unclear how to use mutation observers to observe changes to [custom state](https://developer.mozilla.org/en-US/docs/Web/API/CustomStateSet). The closest thing might be a solution like [this](https://davidwalsh.name/detect-node-insertion), but that falls short for elements that aren't visible, or during template instantiation.
|
|
22
|
+
|
|
23
|
+
2. For simple css matches, like "my-element", or "[name='hello']" it is enough to use a mutation observer, and only observe the elements within the specified DOM region (more on that below). But as CSS has evolved, it is quite easy to think of numerous css selectors that would require us to expand our mutation observer to need to scan the entire Shadow DOM realm, or the entire DOM tree outside any Shadow DOM, for any and all mutations (including attribute changes), and re-evaluate every single element within the specified DOM region for new matches or old matches that no longer match. Things like child selectors, :has, and so on. All this is done, miraculously, by the browser in a performant way. Reproducing this in userland using JavaScript alone, matching the same performance seems impossible.
|
|
24
|
+
|
|
25
|
+
3. Knowing when an element, previously being monitored for, passes totally "out-of-scope", so that no more hard references to the element remain. This would allow for cleanup of no longer needed weak references without requiring polling.
|
|
26
|
+
|
|
27
|
+
### Most significant use cases.
|
|
28
|
+
|
|
29
|
+
The amount of code necessary to accomplish these common tasks designed to improve the user experience is significant. Building it into the platform would potentially:
|
|
30
|
+
|
|
31
|
+
1. Give the developer a strong signal to do the right thing, by
|
|
32
|
+
1. Making lazy loading of resource dependencies easy, to the benefit of users with expensive networks.
|
|
33
|
+
2. Supporting "binding from a distance" that can allow SSR to provide common, shared data using the "DRY" philosophy, similar to how CSS can reduce the amount of repetitive styling instructions found inline within the HTML Markup.
|
|
34
|
+
3. Supporting "progressive enhancement."
|
|
35
|
+
2. Allow numerous components / libraries to leverage this common functionality, which could potentially significantly reduce bandwidth.
|
|
36
|
+
3. Potentially by allowing the platform to do more work in the low-level (c/c++/rust?) code, without as much context switching into the JavaScript memory space, which may reduce cpu cycles as well.
|
|
37
|
+
4. To do the job right, polyfills really need to reexamine **all** the elements within the observed node for matches **anytime any element within the Shadow Root so much as sneezes (has attribute modified, changes custom state, etc)**, due to modern selectors such as the :has selector. Surely, the platform has found ways to do this more efficiently?
|
|
38
|
+
|
|
39
|
+
The extra flexibility this new primitive would provide could be quite useful to things other than lazy loading of custom elements, such as implementing [custom enhancements](https://github.com/WICG/webcomponents/issues/1000) as well as [binding from a distance](https://github.com/WICG/webcomponents/issues/1035#issuecomment-1806393525) in userland.
|
|
40
|
+
|
|
41
|
+
## First use case -- lazy loading custom elements
|
|
42
|
+
|
|
43
|
+
To specify the equivalent of what the alternative proposal linked to above would do, we can do the following:
|
|
44
|
+
|
|
45
|
+
```JavaScript
|
|
46
|
+
const observer = new MountObserver({
|
|
47
|
+
match:'my-element',
|
|
48
|
+
import: './my-element.js',
|
|
49
|
+
do: {
|
|
50
|
+
onMount: ({localName}, {module}) => {
|
|
51
|
+
if(!customElements.get(localName)) {
|
|
52
|
+
customElements.define(localName, module.default);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
observer.observe(document);
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
If no import is specified, it would go straight to do.* (if any such callbacks are specified), and it will also dispatch events as discussed below.
|
|
61
|
+
|
|
62
|
+
This only searches for elements matching 'my-element' outside any shadow DOM.
|
|
63
|
+
|
|
64
|
+
But the observe method can accept a shadowRoot, or a node inside a shadowRoot as well.
|
|
65
|
+
|
|
66
|
+
The import can also be a function:
|
|
67
|
+
|
|
68
|
+
```JavaScript
|
|
69
|
+
const observer = new MountObserver({
|
|
70
|
+
match: 'my-element',
|
|
71
|
+
import: async (matchingElement, {module}) => await import('./my-element.js');
|
|
72
|
+
});
|
|
73
|
+
observer.observe(myRootNode);
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
which would work better with current bundlers, I suspect. Also, we can do interesting things like merge multiple imports into one "module". But should this API built into the platform, such functions wouldn't be necessary, as bundlers could start to recognize strings that are passed to the MountObserver's constructor.
|
|
77
|
+
|
|
78
|
+
This proposal would also include support for CSS, JSON, HTML module imports.
|
|
79
|
+
|
|
80
|
+
"match" is a css query, and could include multiple matches using the comma separator, i.e. no limitation on CSS expressions.
|
|
81
|
+
|
|
82
|
+
The "observer" constant above is a class instance that inherits from EventTarget, which means it can be subscribed to by outside interests.
|
|
83
|
+
|
|
84
|
+
<!-- As matches are found (for example, right away if matching elements are immediately found), the imports object would maintain a read-only array of weak references, along with the imported module:
|
|
85
|
+
|
|
86
|
+
```TypeScript
|
|
87
|
+
interface MountContext {
|
|
88
|
+
weakReferences: readonly WeakRef<Element>[];
|
|
89
|
+
module: any;
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
This allows code that comes into being after the matching elements were found, to "get caught up" on all the matches. -->
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
## Extra lazy loading
|
|
97
|
+
|
|
98
|
+
By default, the matches would be reported as soon as an element matching the criterion is found or added into the DOM, inside the node specified by rootNode.
|
|
99
|
+
|
|
100
|
+
However, we could make the loading even more lazy by specifying intersection options:
|
|
101
|
+
|
|
102
|
+
```JavaScript
|
|
103
|
+
const observer = new MountObserver({
|
|
104
|
+
match: 'my-element',
|
|
105
|
+
whereElementIntersectsWith:{
|
|
106
|
+
rootMargin: "0px",
|
|
107
|
+
threshold: 1.0,
|
|
108
|
+
},
|
|
109
|
+
import: './my-element.js'
|
|
110
|
+
});
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Media / container queries / instanceOf / custom checks
|
|
114
|
+
|
|
115
|
+
Unlike traditional CSS @import, CSS Modules don't support specifying different imports based on media queries. That can be another condition we can attach (and why not throw in container queries, based on the rootNode?):
|
|
116
|
+
|
|
117
|
+
```JavaScript
|
|
118
|
+
const observer = new MountObserver({
|
|
119
|
+
match: 'my-element',
|
|
120
|
+
whereMediaMatches: '(max-width: 1250px)',
|
|
121
|
+
whereSizeOfContainerMatches: '(min-width: 700px)',
|
|
122
|
+
whereInstanceOf: [HTMLMarqueeElement],
|
|
123
|
+
whereSatisfies: async (matchingElement, context) => true,
|
|
124
|
+
import: ['./my-element-small.css', {type: 'css'}],
|
|
125
|
+
do: {
|
|
126
|
+
onMount: ({localName}, {module}) => {
|
|
127
|
+
...
|
|
128
|
+
},
|
|
129
|
+
onDismount: ...,
|
|
130
|
+
onDisconnect: ...,
|
|
131
|
+
onReconnect: ...,
|
|
132
|
+
onReconfirm: ...
|
|
133
|
+
onOutOfScope: ...,
|
|
134
|
+
}
|
|
135
|
+
})
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Subscribing
|
|
139
|
+
|
|
140
|
+
Subscribing can be done via:
|
|
141
|
+
|
|
142
|
+
```JavaScript
|
|
143
|
+
observer.addEventListener('mount', e => {
|
|
144
|
+
console.log({
|
|
145
|
+
matchingElement: e.matchingElement,
|
|
146
|
+
module: e.module
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
observer.addEventListener('dismount', e => {
|
|
151
|
+
...
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
observer.addEventListener('reconnect', e => {
|
|
155
|
+
...
|
|
156
|
+
});
|
|
157
|
+
observer.addEventListener('disconnect', e => {
|
|
158
|
+
...
|
|
159
|
+
});
|
|
160
|
+
observer.addEventListener('reconfirm', e => {
|
|
161
|
+
...
|
|
162
|
+
});
|
|
163
|
+
observer.addEventListener('out-of-scope', e => {
|
|
164
|
+
...
|
|
165
|
+
});
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## Explanation of all states / events
|
|
169
|
+
|
|
170
|
+
Normally, an element stays in its place in the DOM tree, but the conditions that the MountObserver instance is monitoring for can change for the element, based on modifications to the attributes of the element itself, or its custom state, or to other peer elements within the shadowRoot, if any, or window resizing, etc. As the element meets or doesn't meet all the conditions, the mountObserver will first call the corresponding mount/dismount callback, and then dispatch event "mount" or "dismount" according to whether the criteria are all met or not.
|
|
171
|
+
|
|
172
|
+
The moment a MountObserver instance's "observe" method is called (passing in a root node), it will inspect every element within its subtree (not counting ShadowRoots), and then call the "mount" callback, and dispatch event "mount" for those elements that match the criteria. It will *not* dispatch "dismount" for elements that don't.
|
|
173
|
+
|
|
174
|
+
If an element that is in "mounted" state according to a MountObserver instance is moved from one parent DOM element to another:
|
|
175
|
+
|
|
176
|
+
1) "disconnect" event is dispatched from the MountObserver instance the moment the element is disconnected from the DOM fragment.
|
|
177
|
+
2) If/when the element is added somewhere else in the DOM tree, the mountObserver instance will dispatch event "reconnect", regardless of where. [Note: can't polyfill this very easily]
|
|
178
|
+
3) If the element is added outside the rootNode being observed, the mountObserver instance will dispatch event "outside-root-node", and the MountObserver instance will relinquish any further responsibility for this element. Ideally this would also be dispatched when the platform garbage collects the element as well after all hard references are relinquished.
|
|
179
|
+
4) If the new place it was added remains within the original rootNode and remains either dismounted or mounted, the MountObserver instance dispatches event "reconfirmed".
|
|
180
|
+
5) If the element no longer satisfies the criteria of the MountObserver instance, the MountObserver instance will dispatch event "dismount". The same is done in reverse for moved elements that started out in a "dismounted" state.
|
|
181
|
+
|
|
182
|
+
## Special support for attributes
|
|
183
|
+
|
|
184
|
+
Extra support is provided for monitoring attributes.
|
|
185
|
+
|
|
186
|
+
## Preemptive downloading
|
|
187
|
+
|
|
188
|
+
There are two significant steps to imports, each of which imposes a cost:
|
|
189
|
+
|
|
190
|
+
1. Downloading the resource.
|
|
191
|
+
2. Loading the resource into memory.
|
|
192
|
+
|
|
193
|
+
What if we want to download the resource ahead of time, but only load into memory when needed?
|
|
194
|
+
|
|
195
|
+
The link rel=modulepreload option provides an already existing platform support for this, but the browser complains when no use of the resource is used within a short time span of page load. That doesn't really fit the bill for lazy loading custom elements and other resources.
|
|
196
|
+
|
|
197
|
+
So for this we add option:
|
|
198
|
+
|
|
199
|
+
```JavaScript
|
|
200
|
+
const observer = new MountObserver({
|
|
201
|
+
match: 'my-element',
|
|
202
|
+
loading: 'eager',
|
|
203
|
+
import: './my-element.js',
|
|
204
|
+
do:{
|
|
205
|
+
onMount: (matchingElement, {module}) => customElements.define(module.MyElement)
|
|
206
|
+
}
|
|
207
|
+
})
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
So what this does is only check for the presence of an element with tag name "my-element", and it starts downloading the resource, even before the element has "mounted" based on other criteria.
|
|
211
|
+
|
|
212
|
+
|
package/RootMutObs.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export class RootMutObs extends EventTarget {
|
|
2
|
+
constructor(rootNode) {
|
|
3
|
+
super();
|
|
4
|
+
this.#mutationObserver = new MutationObserver(mutationRecords => {
|
|
5
|
+
this.dispatchEvent(new MutationEvent(mutationRecords));
|
|
6
|
+
});
|
|
7
|
+
this.#mutationObserver.observe(rootNode, {
|
|
8
|
+
subtree: true,
|
|
9
|
+
childList: true,
|
|
10
|
+
attributes: true,
|
|
11
|
+
attributeOldValue: true,
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
#mutationObserver;
|
|
15
|
+
}
|
|
16
|
+
// https://github.com/webcomponents-cg/community-protocols/issues/12#issuecomment-872415080
|
|
17
|
+
/**
|
|
18
|
+
* The `mutation-event` event represents something that happened.
|
|
19
|
+
* We can document it here.
|
|
20
|
+
*/
|
|
21
|
+
export class MutationEvent extends Event {
|
|
22
|
+
mutationRecords;
|
|
23
|
+
static eventName = 'mutation-event';
|
|
24
|
+
constructor(mutationRecords) {
|
|
25
|
+
// Since these are hard-coded, dispatchers can't get them wrong
|
|
26
|
+
super(MutationEvent.eventName);
|
|
27
|
+
this.mutationRecords = mutationRecords;
|
|
28
|
+
}
|
|
29
|
+
}
|
package/RootMutObs.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import {mutationEventName, AddMutationEventListener} from './types';
|
|
2
|
+
|
|
3
|
+
export class RootMutObs extends EventTarget{
|
|
4
|
+
constructor(rootNode: Node ){
|
|
5
|
+
super();
|
|
6
|
+
this.#mutationObserver = new MutationObserver(mutationRecords => {
|
|
7
|
+
this.dispatchEvent(new MutationEvent(mutationRecords))
|
|
8
|
+
})
|
|
9
|
+
this.#mutationObserver.observe(rootNode, {
|
|
10
|
+
subtree: true,
|
|
11
|
+
childList: true,
|
|
12
|
+
attributes: true,
|
|
13
|
+
attributeOldValue: true,
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
#mutationObserver: MutationObserver;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
// https://github.com/webcomponents-cg/community-protocols/issues/12#issuecomment-872415080
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The `mutation-event` event represents something that happened.
|
|
25
|
+
* We can document it here.
|
|
26
|
+
*/
|
|
27
|
+
export class MutationEvent extends Event implements MutationEvent {
|
|
28
|
+
static eventName: mutationEventName = 'mutation-event';
|
|
29
|
+
|
|
30
|
+
constructor(public mutationRecords: Array<MutationRecord>) {
|
|
31
|
+
// Since these are hard-coded, dispatchers can't get them wrong
|
|
32
|
+
super(MutationEvent.eventName);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
|
package/demo/test1.html
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Document</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id=div>
|
|
10
|
+
<span id=span></span>
|
|
11
|
+
</div>
|
|
12
|
+
<script type=module>
|
|
13
|
+
import {MountObserver} from '../MountObserver.js';
|
|
14
|
+
const mo = new MountObserver({
|
|
15
|
+
match: '#span',
|
|
16
|
+
do:{
|
|
17
|
+
onMount: (el, ctx) => {
|
|
18
|
+
console.log({el, ctx});
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
mo.addEventListener('mount', e => {
|
|
23
|
+
console.log(e);
|
|
24
|
+
});
|
|
25
|
+
mo.observe(div);
|
|
26
|
+
</script>
|
|
27
|
+
</body>
|
|
28
|
+
</html>
|
package/package.json
CHANGED
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mount-observer",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.2",
|
|
4
4
|
"description": "",
|
|
5
|
-
"main": "
|
|
5
|
+
"main": "MountObserver.js",
|
|
6
|
+
"module": "MountObserver.js",
|
|
7
|
+
"devDependencies": {
|
|
8
|
+
"@playwright/test": "1.39.0",
|
|
9
|
+
"may-it-serve": "0.0.6"
|
|
10
|
+
},
|
|
6
11
|
"scripts": {
|
|
7
|
-
"
|
|
12
|
+
"serve": "node node_modules/may-it-serve",
|
|
13
|
+
"test": "playwright test",
|
|
14
|
+
"safari": "npx playwright wk http://localhost:3030",
|
|
15
|
+
"update": "ncu -u && npm install"
|
|
8
16
|
},
|
|
9
17
|
"author": "",
|
|
10
18
|
"license": "MIT"
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// playwright.config.ts
|
|
2
|
+
import { PlaywrightTestConfig, devices } from '@playwright/test';
|
|
3
|
+
const config: PlaywrightTestConfig = {
|
|
4
|
+
webServer: {
|
|
5
|
+
command: 'npm run serve',
|
|
6
|
+
url: 'http://localhost:3030/',
|
|
7
|
+
timeout: 120 * 1000,
|
|
8
|
+
reuseExistingServer: !process.env.CI,
|
|
9
|
+
},
|
|
10
|
+
use: {
|
|
11
|
+
baseURL: 'http://localhost:3030/',
|
|
12
|
+
},
|
|
13
|
+
projects: [
|
|
14
|
+
{
|
|
15
|
+
name: 'chromium',
|
|
16
|
+
use: { ...devices['Desktop Chrome'] },
|
|
17
|
+
},
|
|
18
|
+
//firefox lacks support for import assertions
|
|
19
|
+
// {
|
|
20
|
+
// name: 'firefox',
|
|
21
|
+
// use: { ...devices['Desktop Firefox'] },
|
|
22
|
+
// },
|
|
23
|
+
{
|
|
24
|
+
name: 'webkit',
|
|
25
|
+
use: { ...devices['Desktop Safari'] },
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
};
|
|
29
|
+
export default config;
|
package/tests/test1.html
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Document</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id=div>
|
|
10
|
+
<span id=span></span>
|
|
11
|
+
</div>
|
|
12
|
+
<div id=target></div>
|
|
13
|
+
<script type=module>
|
|
14
|
+
import {MountObserver} from '../MountObserver.js';
|
|
15
|
+
const mo = new MountObserver({
|
|
16
|
+
match: '#span',
|
|
17
|
+
do:{
|
|
18
|
+
onMount: (el, ctx) => {
|
|
19
|
+
console.log({el, ctx});
|
|
20
|
+
target.setAttribute('mark', 'good');
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
mo.addEventListener('mount', e => {
|
|
25
|
+
console.log(e);
|
|
26
|
+
});
|
|
27
|
+
mo.observe(div);
|
|
28
|
+
</script>
|
|
29
|
+
</body>
|
|
30
|
+
</html>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { test, expect } from '@playwright/test';
|
|
2
|
+
test('test1', async ({ page }) => {
|
|
3
|
+
await page.goto('./tests/test1.html');
|
|
4
|
+
// wait for 1 second
|
|
5
|
+
await page.waitForTimeout(1000);
|
|
6
|
+
const editor = page.locator('#target');
|
|
7
|
+
await expect(editor).toHaveAttribute('mark', 'good');
|
|
8
|
+
});
|
package/tests/test2.html
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Document</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id=div>
|
|
10
|
+
</div>
|
|
11
|
+
<div id=target></div>
|
|
12
|
+
<script type=module>
|
|
13
|
+
import {MountObserver} from '../MountObserver.js';
|
|
14
|
+
const mo = new MountObserver({
|
|
15
|
+
match: '#span',
|
|
16
|
+
do:{
|
|
17
|
+
onMount: (el, ctx) => {
|
|
18
|
+
console.log({el, ctx});
|
|
19
|
+
target.setAttribute('mark', 'good');
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
mo.addEventListener('mount', e => {
|
|
24
|
+
console.log(e);
|
|
25
|
+
});
|
|
26
|
+
mo.observe(div);
|
|
27
|
+
setTimeout(() => {
|
|
28
|
+
const span = document.createElement('span');
|
|
29
|
+
span.id = 'span';
|
|
30
|
+
div.appendChild(span);
|
|
31
|
+
}, 500);
|
|
32
|
+
</script>
|
|
33
|
+
</body>
|
|
34
|
+
</html>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { test, expect } from '@playwright/test';
|
|
2
|
+
test('test1', async ({ page }) => {
|
|
3
|
+
await page.goto('./tests/test2.html');
|
|
4
|
+
// wait for 1 second
|
|
5
|
+
await page.waitForTimeout(1000);
|
|
6
|
+
const editor = page.locator('#target');
|
|
7
|
+
await expect(editor).toHaveAttribute('mark', 'good');
|
|
8
|
+
});
|
package/tests/test3.html
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Document</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id=div>
|
|
10
|
+
<span></span>
|
|
11
|
+
</div>
|
|
12
|
+
<div id=target></div>
|
|
13
|
+
<script type=module>
|
|
14
|
+
import {MountObserver} from '../MountObserver.js';
|
|
15
|
+
const mo = new MountObserver({
|
|
16
|
+
match: '#span',
|
|
17
|
+
do:{
|
|
18
|
+
onMount: (el, ctx) => {
|
|
19
|
+
console.log({el, ctx});
|
|
20
|
+
target.setAttribute('mark', 'good');
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
mo.addEventListener('mount', e => {
|
|
25
|
+
console.log(e);
|
|
26
|
+
});
|
|
27
|
+
mo.observe(div);
|
|
28
|
+
setTimeout(() => {
|
|
29
|
+
const span = div.querySelector('span');
|
|
30
|
+
span.id = 'span';
|
|
31
|
+
}, 500);
|
|
32
|
+
</script>
|
|
33
|
+
</body>
|
|
34
|
+
</html>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { test, expect } from '@playwright/test';
|
|
2
|
+
test('test1', async ({ page }) => {
|
|
3
|
+
await page.goto('./tests/test3.html');
|
|
4
|
+
// wait for 1 second
|
|
5
|
+
await page.waitForTimeout(1000);
|
|
6
|
+
const editor = page.locator('#target');
|
|
7
|
+
await expect(editor).toHaveAttribute('mark', 'good');
|
|
8
|
+
});
|
package/tests/test4.html
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Document</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id=div>
|
|
10
|
+
<span id=span></span>
|
|
11
|
+
</div>
|
|
12
|
+
<div id=target></div>
|
|
13
|
+
<script type=module>
|
|
14
|
+
import {MountObserver} from '../MountObserver.js';
|
|
15
|
+
const mo = new MountObserver({
|
|
16
|
+
match: '#span',
|
|
17
|
+
do:{
|
|
18
|
+
onMount: (el, ctx) => {
|
|
19
|
+
console.log({el, ctx});
|
|
20
|
+
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
mo.addEventListener('mount', e => {
|
|
25
|
+
console.log(e);
|
|
26
|
+
target.setAttribute('mark', 'good');
|
|
27
|
+
});
|
|
28
|
+
mo.observe(div);
|
|
29
|
+
</script>
|
|
30
|
+
</body>
|
|
31
|
+
</html>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { test, expect } from '@playwright/test';
|
|
2
|
+
test('test1', async ({ page }) => {
|
|
3
|
+
await page.goto('./tests/test4.html');
|
|
4
|
+
// wait for 1 second
|
|
5
|
+
await page.waitForTimeout(1000);
|
|
6
|
+
const editor = page.locator('#target');
|
|
7
|
+
await expect(editor).toHaveAttribute('mark', 'good');
|
|
8
|
+
});
|
package/tests/test5.html
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Document</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id=div>
|
|
10
|
+
<span id=span></span>
|
|
11
|
+
</div>
|
|
12
|
+
<div id=target></div>
|
|
13
|
+
<script type=module>
|
|
14
|
+
import {MountObserver} from '../MountObserver.js';
|
|
15
|
+
const mo = new MountObserver({
|
|
16
|
+
match: '#span',
|
|
17
|
+
do:{
|
|
18
|
+
onDisconnect: (el, ctx) => {
|
|
19
|
+
console.log({el, ctx});
|
|
20
|
+
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
mo.addEventListener('disconnect', e => {
|
|
25
|
+
console.log(e);
|
|
26
|
+
target.setAttribute('mark', 'good');
|
|
27
|
+
});
|
|
28
|
+
mo.observe(div);
|
|
29
|
+
setTimeout(() => {
|
|
30
|
+
span.remove();
|
|
31
|
+
}, 1000);
|
|
32
|
+
</script>
|
|
33
|
+
</body>
|
|
34
|
+
</html>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { test, expect } from '@playwright/test';
|
|
2
|
+
test('test1', async ({ page }) => {
|
|
3
|
+
await page.goto('./tests/test5.html');
|
|
4
|
+
// wait for 1 second
|
|
5
|
+
await page.waitForTimeout(1000);
|
|
6
|
+
const editor = page.locator('#target');
|
|
7
|
+
await expect(editor).toHaveAttribute('mark', 'good');
|
|
8
|
+
});
|
package/tests/test6.html
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Document</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id=div>
|
|
10
|
+
<span id=span></span>
|
|
11
|
+
</div>
|
|
12
|
+
<div id=target></div>
|
|
13
|
+
<script type=module>
|
|
14
|
+
import {MountObserver} from '../MountObserver.js';
|
|
15
|
+
const mo = new MountObserver({
|
|
16
|
+
match: '#span',
|
|
17
|
+
attribMatches:[
|
|
18
|
+
{
|
|
19
|
+
names: ['test-1']
|
|
20
|
+
}
|
|
21
|
+
],
|
|
22
|
+
do:{
|
|
23
|
+
onDisconnect: (el, ctx) => {
|
|
24
|
+
console.log({el, ctx});
|
|
25
|
+
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
mo.addEventListener('attr-change', e => {
|
|
30
|
+
console.log(e);
|
|
31
|
+
target.setAttribute('mark', 'good');
|
|
32
|
+
});
|
|
33
|
+
mo.observe(div);
|
|
34
|
+
setTimeout(() => {
|
|
35
|
+
span.setAttribute('test-1', 'hello')
|
|
36
|
+
}, 1000);
|
|
37
|
+
</script>
|
|
38
|
+
</body>
|
|
39
|
+
</html>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { test, expect } from '@playwright/test';
|
|
2
|
+
test('test1', async ({ page }) => {
|
|
3
|
+
await page.goto('./tests/test6.html');
|
|
4
|
+
// wait for 1 second
|
|
5
|
+
await page.waitForTimeout(1000);
|
|
6
|
+
const editor = page.locator('#target');
|
|
7
|
+
await expect(editor).toHaveAttribute('mark', 'good');
|
|
8
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ESNext",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"sourceMap": false,
|
|
6
|
+
"experimentalDecorators": false,
|
|
7
|
+
"newLine": "LF",
|
|
8
|
+
"strict": true,
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"MountObserver.ts",
|
|
12
|
+
],
|
|
13
|
+
"exclude":[
|
|
14
|
+
"node_modules"
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
}
|
package/types.d.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
export interface MountInit{
|
|
2
|
+
readonly match?: CSSMatch,
|
|
3
|
+
readonly attribMatches?: Array<AttribMatch>,
|
|
4
|
+
readonly whereElementIntersectsWith?: IntersectionObserverInit,
|
|
5
|
+
readonly whereMediaMatches?: MediaQuery,
|
|
6
|
+
readonly whereInstanceOf?: Array<typeof Node>, //[TODO] What's the best way to type this?,
|
|
7
|
+
readonly whereSatisfies?: PipelineProcessor<boolean>,
|
|
8
|
+
readonly import?: ImportString | [ImportString, ImportAssertions] | PipelineProcessor,
|
|
9
|
+
readonly do?: {
|
|
10
|
+
readonly onMount: PipelineProcessor,
|
|
11
|
+
readonly onDismount: PipelineProcessor,
|
|
12
|
+
readonly onDisconnect: PipelineProcessor,
|
|
13
|
+
readonly onReconfirmed: PipelineProcessor,
|
|
14
|
+
readonly onOutsideRootNode: PipelineProcessor,
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
}
|
|
18
|
+
type CSSMatch = string;
|
|
19
|
+
type ImportString = string;
|
|
20
|
+
type MediaQuery = string;
|
|
21
|
+
|
|
22
|
+
export interface AttribMatch{
|
|
23
|
+
names: string[],
|
|
24
|
+
//for boolean, support true/false/mixed
|
|
25
|
+
// type?: 'number' | 'string' | 'date' | 'json-object' | 'boolean',
|
|
26
|
+
// valConverter?: (s: string) => any,
|
|
27
|
+
// validator?: (v: any) => boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface MountContext {
|
|
31
|
+
// readonly mountInit: MountInit,
|
|
32
|
+
// readonly mountedRefs: WeakRef<Element>[],
|
|
33
|
+
// readonly dismountedRefs: WeakRef<Element>[],
|
|
34
|
+
observe(within: Node): void;
|
|
35
|
+
unobserve(): void;
|
|
36
|
+
module?: any;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type PipelineStage = 'Inspecting' | 'PreImport' | 'PostImport' | 'Import'
|
|
40
|
+
export type PipelineProcessor<ReturnType = void> = (matchingElement: Element, ctx: MountContext, stage?: PipelineStage) => Promise<ReturnType>;
|
|
41
|
+
|
|
42
|
+
//#region mutation event
|
|
43
|
+
export type mutationEventName = 'mutation-event';
|
|
44
|
+
export interface MutationEvent{
|
|
45
|
+
mutationRecords: Array<MutationRecord>
|
|
46
|
+
}
|
|
47
|
+
export type mutationEventHandler = (e: MutationEvent) => void;
|
|
48
|
+
|
|
49
|
+
export interface AddMutationEventListener {
|
|
50
|
+
addEventListener(eventName: mutationEventName, handler: mutationEventHandler, options?: AddEventListenerOptions): void;
|
|
51
|
+
}
|
|
52
|
+
//#endregion
|
|
53
|
+
|
|
54
|
+
interface AttrChangeInfo{
|
|
55
|
+
name: string,
|
|
56
|
+
oldValue: string | null,
|
|
57
|
+
newValue: string | null,
|
|
58
|
+
idx: number,
|
|
59
|
+
//parsedNewValue?: any,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
//#region mount event
|
|
63
|
+
export type mountEventName = 'mount';
|
|
64
|
+
export interface IMountEvent{
|
|
65
|
+
mountedElement: Element,
|
|
66
|
+
}
|
|
67
|
+
export type mountEventHandler = (e: IMountEvent) => void;
|
|
68
|
+
|
|
69
|
+
export interface AddMountEventListener {
|
|
70
|
+
addEventListener(eventName: mountEventName, handler: mountEventHandler, options?: AddEventListenerOptions): void;
|
|
71
|
+
}
|
|
72
|
+
//#endregion
|
|
73
|
+
|
|
74
|
+
//#region dismount event
|
|
75
|
+
export type dismountEventName = 'dismount';
|
|
76
|
+
export interface IDismountEvent {
|
|
77
|
+
dismountedElement: Element
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export type dismountEventHandler = (e: IDismountEvent) => void;
|
|
81
|
+
|
|
82
|
+
export interface AddDismountEventListener {
|
|
83
|
+
addEventListener(eventName: dismountEventName, handler: dismountEventHandler, options?: AddEventListenerOptions): void;
|
|
84
|
+
}
|
|
85
|
+
//#endregion
|
|
86
|
+
|
|
87
|
+
//#region disconnected event
|
|
88
|
+
export type disconnectedEventName = 'disconnect';
|
|
89
|
+
export interface IDisconnectEvent {
|
|
90
|
+
disconnectedElement: Element
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export type disconnectedEventHandler = (e: IDisconnectEvent) => void;
|
|
94
|
+
|
|
95
|
+
export interface AddDisconnectEventListener {
|
|
96
|
+
addEventListener(eventName: disconnectedEventName, handler: disconnectedEventHandler, options?: AddEventListenerOptions): void;
|
|
97
|
+
}
|
|
98
|
+
//endregion
|
|
99
|
+
|
|
100
|
+
//#region attribute change event
|
|
101
|
+
export type attrChangeEventName = 'attr-change';
|
|
102
|
+
export interface IAttrChangeEvent extends IMountEvent {
|
|
103
|
+
attrChangeInfo: AttrChangeInfo,
|
|
104
|
+
}
|
|
105
|
+
export type attrChangeEventHander = (e: IAttrChangeEvent) => void;
|
|
106
|
+
export interface AddAttrChangeEventistener{
|
|
107
|
+
addEventListener(eventName: attrChangeEventName, handler: attrChangeEventHander, options?: AddEventListenerOptions): void;
|
|
108
|
+
}
|
|
109
|
+
//#endregion
|
|
110
|
+
|