mount-observer 0.1.0 → 0.1.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/Events.js CHANGED
@@ -1,44 +1,78 @@
1
- // Event classes for MountObserver
2
- import { mountEventName, dismountEventName, disconnectEventName, loadEventName, attrchangeEventName } from './constants.js';
1
+ // Event name constants
2
+ export const loadEventName = 'load';
3
+ export const mountEventName = 'mount';
4
+ export const dismountEventName = 'dismount';
5
+ export const disconnectEventName = 'disconnect';
6
+ export const attrchangeEventName = 'attrchange';
7
+ export const mediamatchEventName = 'mediamatch';
8
+ export const mediaunmatchEventName = 'mediaunmatch';
3
9
  export class MountEvent extends Event {
4
10
  matchingElement;
5
11
  modules;
12
+ mountInit;
6
13
  static eventName = mountEventName;
7
- constructor(matchingElement, modules) {
14
+ constructor(matchingElement, modules, mountInit) {
8
15
  super(MountEvent.eventName);
9
16
  this.matchingElement = matchingElement;
10
17
  this.modules = modules;
18
+ this.mountInit = mountInit;
11
19
  }
12
20
  }
13
21
  export class DismountEvent extends Event {
14
22
  matchingElement;
23
+ reason;
24
+ mountInit;
15
25
  static eventName = dismountEventName;
16
- constructor(matchingElement) {
26
+ constructor(matchingElement, reason, mountInit) {
17
27
  super(DismountEvent.eventName);
18
28
  this.matchingElement = matchingElement;
29
+ this.reason = reason;
30
+ this.mountInit = mountInit;
19
31
  }
20
32
  }
21
33
  export class DisconnectEvent extends Event {
22
34
  matchingElement;
35
+ mountInit;
23
36
  static eventName = disconnectEventName;
24
- constructor(matchingElement) {
37
+ constructor(matchingElement, mountInit) {
25
38
  super(DisconnectEvent.eventName);
26
39
  this.matchingElement = matchingElement;
40
+ this.mountInit = mountInit;
27
41
  }
28
42
  }
29
43
  export class LoadEvent extends Event {
30
44
  modules;
45
+ mountInit;
31
46
  static eventName = loadEventName;
32
- constructor(modules) {
47
+ constructor(modules, mountInit) {
33
48
  super(LoadEvent.eventName);
34
49
  this.modules = modules;
50
+ this.mountInit = mountInit;
35
51
  }
36
52
  }
37
53
  export class AttrChangeEvent extends Event {
38
54
  changes;
55
+ mountInit;
39
56
  static eventName = attrchangeEventName;
40
- constructor(changes) {
57
+ constructor(changes, mountInit) {
41
58
  super(AttrChangeEvent.eventName);
42
59
  this.changes = changes;
60
+ this.mountInit = mountInit;
61
+ }
62
+ }
63
+ export class MediaMatchEvent extends Event {
64
+ mountInit;
65
+ static eventName = mediamatchEventName;
66
+ constructor(mountInit) {
67
+ super(MediaMatchEvent.eventName);
68
+ this.mountInit = mountInit;
69
+ }
70
+ }
71
+ export class MediaUnmatchEvent extends Event {
72
+ mountInit;
73
+ static eventName = mediaunmatchEventName;
74
+ constructor(mountInit) {
75
+ super(MediaUnmatchEvent.eventName);
76
+ this.mountInit = mountInit;
43
77
  }
44
78
  }
package/Events.ts CHANGED
@@ -1,17 +1,19 @@
1
1
  // Event classes for MountObserver
2
- import {
3
- mountEventName,
4
- dismountEventName,
5
- disconnectEventName,
6
- loadEventName,
7
- attrchangeEventName
8
- } from './constants.js';
9
- import type { IMountEvent, IDismountEvent, IAttrChangeEvent, AttrChange } from './types.js';
2
+ import type { IMountEvent, IDismountEvent, IAttrChangeEvent, AttrChange, MountInit, DismountReason } from './types.js';
3
+
4
+ // Event name constants
5
+ export const loadEventName = 'load';
6
+ export const mountEventName = 'mount';
7
+ export const dismountEventName = 'dismount';
8
+ export const disconnectEventName = 'disconnect';
9
+ export const attrchangeEventName = 'attrchange';
10
+ export const mediamatchEventName = 'mediamatch';
11
+ export const mediaunmatchEventName = 'mediaunmatch';
10
12
 
11
13
  export class MountEvent extends Event implements IMountEvent {
12
14
  static eventName: typeof mountEventName = mountEventName;
13
15
 
14
- constructor(public matchingElement: Element, public modules: any[]) {
16
+ constructor(public matchingElement: Element, public modules: any[], public mountInit: MountInit) {
15
17
  super(MountEvent.eventName);
16
18
  }
17
19
  }
@@ -19,7 +21,7 @@ export class MountEvent extends Event implements IMountEvent {
19
21
  export class DismountEvent extends Event implements IDismountEvent {
20
22
  static eventName: typeof dismountEventName = dismountEventName;
21
23
 
22
- constructor(public matchingElement: Element) {
24
+ constructor(public matchingElement: Element, public reason: DismountReason, public mountInit: MountInit) {
23
25
  super(DismountEvent.eventName);
24
26
  }
25
27
  }
@@ -27,7 +29,7 @@ export class DismountEvent extends Event implements IDismountEvent {
27
29
  export class DisconnectEvent extends Event {
28
30
  static eventName: typeof disconnectEventName = disconnectEventName;
29
31
 
30
- constructor(public matchingElement: Element) {
32
+ constructor(public matchingElement: Element, public mountInit: MountInit) {
31
33
  super(DisconnectEvent.eventName);
32
34
  }
33
35
  }
@@ -35,7 +37,7 @@ export class DisconnectEvent extends Event {
35
37
  export class LoadEvent extends Event {
36
38
  static eventName: typeof loadEventName = loadEventName;
37
39
 
38
- constructor(public modules: any[]) {
40
+ constructor(public modules: any[], public mountInit: MountInit) {
39
41
  super(LoadEvent.eventName);
40
42
  }
41
43
  }
@@ -43,7 +45,23 @@ export class LoadEvent extends Event {
43
45
  export class AttrChangeEvent extends Event implements IAttrChangeEvent {
44
46
  static eventName: typeof attrchangeEventName = attrchangeEventName;
45
47
 
46
- constructor(public changes: AttrChange[]) {
48
+ constructor(public changes: AttrChange[], public mountInit: MountInit) {
47
49
  super(AttrChangeEvent.eventName);
48
50
  }
49
51
  }
52
+
53
+ export class MediaMatchEvent extends Event {
54
+ static eventName: typeof mediamatchEventName = mediamatchEventName;
55
+
56
+ constructor(public mountInit: MountInit) {
57
+ super(MediaMatchEvent.eventName);
58
+ }
59
+ }
60
+
61
+ export class MediaUnmatchEvent extends Event {
62
+ static eventName: typeof mediaunmatchEventName = mediaunmatchEventName;
63
+
64
+ constructor(public mountInit: MountInit) {
65
+ super(MediaUnmatchEvent.eventName);
66
+ }
67
+ }
package/MountObserver.js CHANGED
@@ -1,22 +1,37 @@
1
- import { MountEvent, DismountEvent, DisconnectEvent, LoadEvent, AttrChangeEvent } from './Events.js';
1
+ import { MountEvent, DismountEvent, DisconnectEvent, LoadEvent, AttrChangeEvent, } from './Events.js';
2
+ import { registerSharedObserver, unregisterSharedObserver } from './SharedMutationObserver.js';
3
+ import { whereOutside } from './whereOutside.js';
2
4
  export class MountObserver extends EventTarget {
3
5
  #init;
4
6
  #options;
5
7
  #abortController;
6
8
  #modules = [];
7
- #mountedElements = new WeakSet();
8
- #processedElements = new WeakSet();
9
- #mutationObserver;
9
+ #mountedElements = {
10
+ weakSet: new WeakSet(),
11
+ setWeak: new Set()
12
+ };
13
+ #processedDoForElement = new WeakSet();
14
+ #processedEventsForElement = new WeakMap();
15
+ #mutationCallback;
10
16
  #rootNode;
11
17
  #importsLoaded = false;
12
18
  #elementAttrStates = new WeakMap();
19
+ #elementOnceAttrs = new WeakMap();
13
20
  #matchesWhereAttrFn = null;
14
21
  #buildAttrCoordinateMapFn = null;
22
+ #checkAttrChangesFn = null;
23
+ #mediaQueryCleanup;
24
+ #mediaMatches = true;
25
+ #assignGingerlySource;
15
26
  constructor(init, options = {}) {
16
27
  super();
17
28
  this.#init = init;
18
29
  this.#options = options;
19
30
  this.#abortController = new AbortController();
31
+ // Make a copy of assignGingerly config using structuredClone
32
+ if (init.assignGingerly !== undefined) {
33
+ this.#assignGingerlySource = structuredClone(init.assignGingerly);
34
+ }
20
35
  if (options.disconnectedSignal) {
21
36
  options.disconnectedSignal.addEventListener('abort', () => {
22
37
  this.disconnect();
@@ -40,6 +55,22 @@ export class MountObserver extends EventTarget {
40
55
  const { buildAttrCoordinateMap } = await import('./attrCoordinates.js');
41
56
  this.#buildAttrCoordinateMapFn = buildAttrCoordinateMap;
42
57
  }
58
+ if (!this.#checkAttrChangesFn) {
59
+ const { checkAttrChanges } = await import('./attrChanges.js');
60
+ // Create a bound function that passes the required parameters
61
+ this.#checkAttrChangesFn = (element) => {
62
+ return checkAttrChanges(element, this.#init, this.#buildAttrCoordinateMapFn, this.#elementAttrStates, this.#elementOnceAttrs);
63
+ };
64
+ }
65
+ }
66
+ async #setupMediaQuery() {
67
+ if (!this.#rootNode) {
68
+ throw new Error('Cannot setup media query before observe() is called');
69
+ }
70
+ const { setupMediaQuery } = await import('./mediaQuery.js');
71
+ const result = setupMediaQuery(this.#init, this.#rootNode, this.#mountedElements, this.#modules, this, (node) => this.#processNode(node));
72
+ this.#mediaMatches = result.mediaMatches;
73
+ this.#mediaQueryCleanup = result.cleanup;
43
74
  }
44
75
  get disconnectedSignal() {
45
76
  return this.#abortController.signal;
@@ -49,14 +80,24 @@ export class MountObserver extends EventTarget {
49
80
  throw new Error('Already observing');
50
81
  }
51
82
  this.#rootNode = new WeakRef(rootNode);
83
+ // Set up media query if specified (needs rootNode to be set first)
84
+ if (this.#init.whereMediaMatches) {
85
+ await this.#setupMediaQuery();
86
+ }
52
87
  // Wait for whereAttr utilities to load if needed
53
88
  if (this.#init.whereAttr && !this.#matchesWhereAttrFn) {
54
89
  await this.#preloadWhereAttrUtilities();
55
90
  }
56
- // Process existing elements
57
- this.#processNode(rootNode);
58
- // Set up mutation observer
59
- this.#mutationObserver = new MutationObserver((mutations) => {
91
+ // Process existing elements only if media matches
92
+ if (this.#mediaMatches) {
93
+ this.#processNode(rootNode);
94
+ }
95
+ // Create mutation callback
96
+ this.#mutationCallback = (mutations) => {
97
+ // Skip processing if media doesn't match
98
+ if (!this.#mediaMatches) {
99
+ return;
100
+ }
60
101
  const attrChanges = [];
61
102
  for (const mutation of mutations) {
62
103
  if (mutation.type === 'childList') {
@@ -74,17 +115,17 @@ export class MountObserver extends EventTarget {
74
115
  else if (mutation.type === 'attributes' && mutation.target.nodeType === Node.ELEMENT_NODE) {
75
116
  // Handle attribute changes for mounted elements
76
117
  const element = mutation.target;
77
- if (this.#mountedElements.has(element) && this.#init.whereAttr) {
78
- const changes = this.#checkAttrChanges(element);
118
+ if (this.#mountedElements.weakSet.has(element) && this.#checkAttrChangesFn) {
119
+ const changes = this.#checkAttrChangesFn(element);
79
120
  attrChanges.push(...changes);
80
121
  }
81
122
  }
82
123
  }
83
124
  // Batch and dispatch attribute changes
84
125
  if (attrChanges.length > 0) {
85
- this.dispatchEvent(new AttrChangeEvent(attrChanges));
126
+ this.dispatchEvent(new AttrChangeEvent(attrChanges, this.#init));
86
127
  }
87
- });
128
+ };
88
129
  const observerConfig = {
89
130
  childList: true,
90
131
  subtree: true
@@ -94,12 +135,20 @@ export class MountObserver extends EventTarget {
94
135
  observerConfig.attributes = true;
95
136
  observerConfig.attributeOldValue = true;
96
137
  }
97
- this.#mutationObserver.observe(rootNode, observerConfig);
138
+ // Register with shared mutation observer
139
+ registerSharedObserver(rootNode, this.#mutationCallback, observerConfig);
98
140
  }
99
141
  disconnect() {
100
- if (this.#mutationObserver) {
101
- this.#mutationObserver.disconnect();
102
- this.#mutationObserver = undefined;
142
+ const rootNode = this.#rootNode?.deref();
143
+ // Unregister from shared mutation observer
144
+ if (rootNode && this.#mutationCallback) {
145
+ unregisterSharedObserver(rootNode, this.#mutationCallback);
146
+ this.#mutationCallback = undefined;
147
+ }
148
+ // Remove media query listener
149
+ if (this.#mediaQueryCleanup) {
150
+ this.#mediaQueryCleanup();
151
+ this.#mediaQueryCleanup = undefined;
103
152
  }
104
153
  this.#abortController.abort();
105
154
  this.#rootNode = undefined;
@@ -112,7 +161,7 @@ export class MountObserver extends EventTarget {
112
161
  const { loadImports } = await import('./loadImports.js');
113
162
  this.#modules = await loadImports(this.#init.import);
114
163
  this.#importsLoaded = true;
115
- this.dispatchEvent(new LoadEvent(this.#modules));
164
+ this.dispatchEvent(new LoadEvent(this.#modules, this.#init));
116
165
  }
117
166
  #processNode(node) {
118
167
  // If it's an element node, check if it matches
@@ -125,49 +174,67 @@ export class MountObserver extends EventTarget {
125
174
  // Process children
126
175
  if (node.nodeType === Node.ELEMENT_NODE || node.nodeType === Node.DOCUMENT_NODE) {
127
176
  const root = node;
128
- // If whereAttr is specified, we need to check all elements
129
- // since we can't use querySelectorAll for complex attribute matching
130
- if (this.#init.whereAttr) {
131
- // Get all elements matching the CSS selector first
132
- root.querySelectorAll(this.#init.whereElementMatches).forEach(child => {
133
- if (this.#matchesSelector(child)) {
134
- this.#handleMatch(child);
135
- }
136
- });
137
- }
138
- else {
139
- // Optimize: use querySelectorAll directly when no whereAttr
140
- root.querySelectorAll(this.#init.whereElementMatches).forEach(child => {
177
+ // Get all elements matching the CSS selector first
178
+ root.querySelectorAll(this.#init.whereElementMatches).forEach(child => {
179
+ if (this.#matchesSelector(child)) {
141
180
  this.#handleMatch(child);
142
- });
143
- }
181
+ }
182
+ });
144
183
  }
145
184
  }
146
185
  #matchesSelector(element) {
186
+ //TODO: reduce redundncy with this.#init?
147
187
  // Check whereElementMatches condition
148
188
  const matchesElement = element.matches(this.#init.whereElementMatches);
149
- // If whereAttr is not specified, only check whereElementMatches
150
- if (!this.#init.whereAttr) {
151
- return matchesElement;
152
- }
153
- // Use cached function (should be loaded by now from constructor)
154
- if (!this.#matchesWhereAttrFn) {
155
- console.warn('whereAttr utilities not loaded yet');
189
+ if (!matchesElement) {
156
190
  return false;
157
191
  }
158
- // Both conditions must be true (AND logic)
159
- return matchesElement && this.#matchesWhereAttrFn(element, this.#init.whereAttr);
192
+ // Check whereOutside condition if specified (donut hole scoping)
193
+ if (this.#init.whereOutside) {
194
+ const rootNode = this.#rootNode?.deref();
195
+ if (!rootNode || !whereOutside(rootNode, element, this.#init.whereOutside)) {
196
+ return false;
197
+ }
198
+ }
199
+ // Check whereAttr condition if specified
200
+ if (this.#init.whereAttr) {
201
+ // Use cached function (should be loaded by now from constructor)
202
+ if (!this.#matchesWhereAttrFn) {
203
+ console.warn('whereAttr utilities not loaded yet');
204
+ return false;
205
+ }
206
+ if (!this.#matchesWhereAttrFn(element, this.#init.whereAttr)) {
207
+ return false;
208
+ }
209
+ }
210
+ // Check whereInstanceOf condition if specified
211
+ if (this.#init.whereInstanceOf) {
212
+ const constructors = Array.isArray(this.#init.whereInstanceOf)
213
+ ? this.#init.whereInstanceOf
214
+ : [this.#init.whereInstanceOf];
215
+ // Element must be an instance of at least one constructor (OR logic for array)
216
+ const matchesInstanceOf = constructors.some(constructor => element instanceof constructor);
217
+ if (!matchesInstanceOf) {
218
+ return false;
219
+ }
220
+ }
221
+ // All conditions passed
222
+ return true;
160
223
  }
161
224
  async #handleMatch(element) {
162
- if (this.#processedElements.has(element)) {
225
+ if (this.#processedDoForElement.has(element)) {
163
226
  return;
164
227
  }
165
228
  // Load imports if not already loaded
166
229
  if (!this.#importsLoaded && this.#init.import) {
167
230
  await this.#loadImports();
168
231
  }
169
- this.#processedElements.add(element);
170
- this.#mountedElements.add(element);
232
+ this.#processedDoForElement.add(element);
233
+ // Add to both WeakSet and Set<WeakRef> for efficient operations
234
+ if (!this.#mountedElements.weakSet.has(element)) {
235
+ this.#mountedElements.weakSet.add(element);
236
+ this.#mountedElements.setWeak.add(new WeakRef(element));
237
+ }
171
238
  const rootNode = this.#rootNode?.deref();
172
239
  if (!rootNode) {
173
240
  // Root node was garbage collected
@@ -181,9 +248,9 @@ export class MountObserver extends EventTarget {
181
248
  }
182
249
  };
183
250
  // Apply assignGingerly if specified
184
- if (this.#init.assignGingerly) {
251
+ if (this.#assignGingerlySource) {
185
252
  const { assignGingerly } = await import('assign-gingerly/index.js');
186
- assignGingerly(element, this.#init.assignGingerly);
253
+ assignGingerly(element, this.#assignGingerlySource);
187
254
  }
188
255
  // Call do callback
189
256
  if (this.#init.do) {
@@ -195,68 +262,56 @@ export class MountObserver extends EventTarget {
195
262
  }
196
263
  }
197
264
  // Dispatch mount event
198
- this.dispatchEvent(new MountEvent(element, this.#modules));
265
+ this.dispatchEvent(new MountEvent(element, this.#modules, this.#init));
266
+ // Emit events from mounted element if configured
267
+ if (this.#init.mountedElemEmits) {
268
+ const { emitMountedElementEvents } = await import('./emitEvents.js');
269
+ await emitMountedElementEvents(element, this.#init, this.#processedEventsForElement);
270
+ }
199
271
  // Check for initial attribute changes if whereAttr is configured
200
- if (this.#init.whereAttr) {
201
- const changes = this.#checkAttrChanges(element);
272
+ if (this.#checkAttrChangesFn) {
273
+ const changes = this.#checkAttrChangesFn(element);
202
274
  if (changes.length > 0) {
203
- this.dispatchEvent(new AttrChangeEvent(changes));
275
+ this.dispatchEvent(new AttrChangeEvent(changes, this.#init));
204
276
  }
205
277
  }
206
278
  }
207
- #checkAttrChanges(element) {
208
- if (!this.#init.whereAttr || !this.#buildAttrCoordinateMapFn) {
209
- return [];
210
- }
211
- const isCustomElement = element.tagName.toLowerCase().includes('-');
212
- const attrCoordMap = this.#buildAttrCoordinateMapFn(this.#init.whereAttr, isCustomElement);
213
- // Get or create the attribute state for this element
214
- let attrState = this.#elementAttrStates.get(element);
215
- if (!attrState) {
216
- attrState = new Map();
217
- this.#elementAttrStates.set(element, attrState);
218
- }
219
- const changes = [];
220
- const currentAttrs = new Set();
221
- // Check all possible attributes from the coordinate map
222
- for (const attrName of Object.keys(attrCoordMap)) {
223
- const coordinate = attrCoordMap[attrName];
224
- const currentValue = element.getAttribute(attrName);
225
- const previousValue = attrState.get(attrName);
226
- if (currentValue !== null) {
227
- currentAttrs.add(attrName);
228
- }
229
- // Include if: currently has value OR previously had value but now removed
230
- if (currentValue !== null || (previousValue !== undefined && currentValue === null)) {
231
- // Check if value changed
232
- if (currentValue !== previousValue) {
233
- const attrNode = currentValue !== null ? element.getAttributeNode(attrName) : null;
234
- const mapEntry = this.#init.map?.[coordinate] || null;
235
- changes.push({
236
- value: currentValue,
237
- attrNode,
238
- mapEntry,
239
- attrName,
240
- coordinate,
241
- element
242
- });
243
- // Update state
244
- if (currentValue !== null) {
245
- attrState.set(attrName, currentValue);
246
- }
247
- else {
248
- attrState.delete(attrName);
249
- }
250
- }
279
+ async assignGingerly(config) {
280
+ // Handle undefined case
281
+ if (config === undefined) {
282
+ this.#assignGingerlySource = undefined;
283
+ return;
284
+ }
285
+ const { assignGingerly } = await import('assign-gingerly/index.js');
286
+ // Update the source config for future mounted elements
287
+ if (this.#assignGingerlySource === undefined) {
288
+ // No existing config, just clone the passed in object
289
+ this.#assignGingerlySource = structuredClone(config);
290
+ }
291
+ else {
292
+ // Merge into existing config using assignGingerly
293
+ assignGingerly(this.#assignGingerlySource, config);
294
+ }
295
+ // Apply to already mounted elements using setWeak for iteration
296
+ for (const ref of this.#mountedElements.setWeak) {
297
+ const element = ref.deref();
298
+ if (element) {
299
+ assignGingerly(element, config);
251
300
  }
252
301
  }
253
- return changes;
254
302
  }
255
303
  #handleRemoval(element) {
256
- if (!this.#mountedElements.has(element)) {
304
+ if (!this.#mountedElements.weakSet.has(element)) {
257
305
  return;
258
306
  }
259
- this.#mountedElements.delete(element);
307
+ // Remove from both structures
308
+ this.#mountedElements.weakSet.delete(element);
309
+ for (const ref of this.#mountedElements.setWeak) {
310
+ if (ref.deref() === element) {
311
+ this.#mountedElements.setWeak.delete(ref);
312
+ break;
313
+ }
314
+ }
260
315
  const rootNode = this.#rootNode?.deref();
261
316
  if (!rootNode) {
262
317
  // Root node was garbage collected
@@ -274,7 +329,7 @@ export class MountObserver extends EventTarget {
274
329
  this.#init.do.dismount(element, context);
275
330
  }
276
331
  // Dispatch dismount event
277
- this.dispatchEvent(new DismountEvent(element));
332
+ this.dispatchEvent(new DismountEvent(element, 'where-element-matches-failed', this.#init));
278
333
  // Check if element is being moved within the same root
279
334
  // If it's truly disconnected, dispatch disconnect event
280
335
  setTimeout(() => {
@@ -282,7 +337,7 @@ export class MountObserver extends EventTarget {
282
337
  if (this.#init.do && typeof this.#init.do !== 'function' && this.#init.do.disconnect) {
283
338
  this.#init.do.disconnect(element, context);
284
339
  }
285
- this.dispatchEvent(new DisconnectEvent(element));
340
+ this.dispatchEvent(new DisconnectEvent(element, this.#init));
286
341
  }
287
342
  }, 0);
288
343
  }