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/MountObserver.ts CHANGED
@@ -3,29 +3,47 @@ import {
3
3
  MountObserverOptions,
4
4
  IMountObserver,
5
5
  MountContext,
6
- AttrChange
6
+ AttrChange,
7
+ WeakDual,
8
+ EventConfig,
9
+ EventConstructor
7
10
  } from './types.js';
8
11
  import {
9
12
  MountEvent,
10
13
  DismountEvent,
11
14
  DisconnectEvent,
12
15
  LoadEvent,
13
- AttrChangeEvent
16
+ AttrChangeEvent,
14
17
  } from './Events.js';
18
+ import {
19
+ registerSharedObserver,
20
+ unregisterSharedObserver,
21
+ type MutationCallback
22
+ } from './SharedMutationObserver.js';
23
+ import { whereOutside } from './whereOutside.js';
15
24
 
16
25
  export class MountObserver extends EventTarget implements IMountObserver {
17
26
  #init: MountInit;
18
27
  #options: MountObserverOptions;
19
28
  #abortController: AbortController;
20
29
  #modules: any[] = [];
21
- #mountedElements = new WeakSet<Element>();
22
- #processedElements = new WeakSet<Element>();
23
- #mutationObserver: MutationObserver | undefined;
30
+ #mountedElements: WeakDual<Element> = {
31
+ weakSet: new WeakSet(),
32
+ setWeak: new Set()
33
+ };
34
+ #processedDoForElement = new WeakSet<Element>();
35
+ #processedEventsForElement = new WeakMap<Element, Set<string>>();
36
+ #mutationCallback: MutationCallback | undefined;
24
37
  #rootNode: WeakRef<Node> | undefined;
25
38
  #importsLoaded = false;
26
39
  #elementAttrStates = new WeakMap<Element, Map<string, string | null>>();
40
+ #elementOnceAttrs = new WeakMap<Element, Set<string>>();
27
41
  #matchesWhereAttrFn: ((element: Element, whereAttr: any) => boolean) | null = null;
28
42
  #buildAttrCoordinateMapFn: ((whereAttr: any, isCustomElement: boolean) => any) | null = null;
43
+ #checkAttrChangesFn: ((element: Element) => AttrChange[]) | null = null;
44
+ #mediaQueryCleanup?: () => void;
45
+ #mediaMatches: boolean = true;
46
+ #assignGingerlySource: Record<string, any> | undefined;
29
47
 
30
48
  constructor(init: MountInit, options: MountObserverOptions = {}) {
31
49
  super();
@@ -33,6 +51,11 @@ export class MountObserver extends EventTarget implements IMountObserver {
33
51
  this.#options = options;
34
52
  this.#abortController = new AbortController();
35
53
 
54
+ // Make a copy of assignGingerly config using structuredClone
55
+ if (init.assignGingerly !== undefined) {
56
+ this.#assignGingerlySource = structuredClone(init.assignGingerly);
57
+ }
58
+
36
59
  if (options.disconnectedSignal) {
37
60
  options.disconnectedSignal.addEventListener('abort', () => {
38
61
  this.disconnect();
@@ -59,6 +82,38 @@ export class MountObserver extends EventTarget implements IMountObserver {
59
82
  const { buildAttrCoordinateMap } = await import('./attrCoordinates.js');
60
83
  this.#buildAttrCoordinateMapFn = buildAttrCoordinateMap;
61
84
  }
85
+ if (!this.#checkAttrChangesFn) {
86
+ const { checkAttrChanges } = await import('./attrChanges.js');
87
+ // Create a bound function that passes the required parameters
88
+ this.#checkAttrChangesFn = (element: Element) => {
89
+ return checkAttrChanges(
90
+ element,
91
+ this.#init,
92
+ this.#buildAttrCoordinateMapFn!,
93
+ this.#elementAttrStates,
94
+ this.#elementOnceAttrs
95
+ );
96
+ };
97
+ }
98
+ }
99
+
100
+ async #setupMediaQuery(): Promise<void> {
101
+ if (!this.#rootNode) {
102
+ throw new Error('Cannot setup media query before observe() is called');
103
+ }
104
+
105
+ const { setupMediaQuery } = await import('./mediaQuery.js');
106
+ const result = setupMediaQuery(
107
+ this.#init,
108
+ this.#rootNode,
109
+ this.#mountedElements,
110
+ this.#modules,
111
+ this,
112
+ (node) => this.#processNode(node)
113
+ );
114
+
115
+ this.#mediaMatches = result.mediaMatches;
116
+ this.#mediaQueryCleanup = result.cleanup;
62
117
  }
63
118
 
64
119
  get disconnectedSignal(): AbortSignal {
@@ -72,16 +127,28 @@ export class MountObserver extends EventTarget implements IMountObserver {
72
127
 
73
128
  this.#rootNode = new WeakRef(rootNode);
74
129
 
130
+ // Set up media query if specified (needs rootNode to be set first)
131
+ if (this.#init.whereMediaMatches) {
132
+ await this.#setupMediaQuery();
133
+ }
134
+
75
135
  // Wait for whereAttr utilities to load if needed
76
136
  if (this.#init.whereAttr && !this.#matchesWhereAttrFn) {
77
137
  await this.#preloadWhereAttrUtilities();
78
138
  }
79
139
 
80
- // Process existing elements
81
- this.#processNode(rootNode);
140
+ // Process existing elements only if media matches
141
+ if (this.#mediaMatches) {
142
+ this.#processNode(rootNode);
143
+ }
82
144
 
83
- // Set up mutation observer
84
- this.#mutationObserver = new MutationObserver((mutations) => {
145
+ // Create mutation callback
146
+ this.#mutationCallback = (mutations) => {
147
+ // Skip processing if media doesn't match
148
+ if (!this.#mediaMatches) {
149
+ return;
150
+ }
151
+
85
152
  const attrChanges: AttrChange[] = [];
86
153
 
87
154
  for (const mutation of mutations) {
@@ -99,8 +166,8 @@ export class MountObserver extends EventTarget implements IMountObserver {
99
166
  } else if (mutation.type === 'attributes' && mutation.target.nodeType === Node.ELEMENT_NODE) {
100
167
  // Handle attribute changes for mounted elements
101
168
  const element = mutation.target as Element;
102
- if (this.#mountedElements.has(element) && this.#init.whereAttr) {
103
- const changes = this.#checkAttrChanges(element);
169
+ if (this.#mountedElements.weakSet.has(element) && this.#checkAttrChangesFn) {
170
+ const changes = this.#checkAttrChangesFn(element);
104
171
  attrChanges.push(...changes);
105
172
  }
106
173
  }
@@ -108,9 +175,9 @@ export class MountObserver extends EventTarget implements IMountObserver {
108
175
 
109
176
  // Batch and dispatch attribute changes
110
177
  if (attrChanges.length > 0) {
111
- this.dispatchEvent(new AttrChangeEvent(attrChanges));
178
+ this.dispatchEvent(new AttrChangeEvent(attrChanges, this.#init));
112
179
  }
113
- });
180
+ };
114
181
 
115
182
  const observerConfig: MutationObserverInit = {
116
183
  childList: true,
@@ -123,14 +190,25 @@ export class MountObserver extends EventTarget implements IMountObserver {
123
190
  observerConfig.attributeOldValue = true;
124
191
  }
125
192
 
126
- this.#mutationObserver.observe(rootNode, observerConfig);
193
+ // Register with shared mutation observer
194
+ registerSharedObserver(rootNode, this.#mutationCallback, observerConfig);
127
195
  }
128
196
 
129
197
  disconnect(): void {
130
- if (this.#mutationObserver) {
131
- this.#mutationObserver.disconnect();
132
- this.#mutationObserver = undefined;
198
+ const rootNode = this.#rootNode?.deref();
199
+
200
+ // Unregister from shared mutation observer
201
+ if (rootNode && this.#mutationCallback) {
202
+ unregisterSharedObserver(rootNode, this.#mutationCallback);
203
+ this.#mutationCallback = undefined;
204
+ }
205
+
206
+ // Remove media query listener
207
+ if (this.#mediaQueryCleanup) {
208
+ this.#mediaQueryCleanup();
209
+ this.#mediaQueryCleanup = undefined;
133
210
  }
211
+
134
212
  this.#abortController.abort();
135
213
  this.#rootNode = undefined;
136
214
  }
@@ -145,7 +223,7 @@ export class MountObserver extends EventTarget implements IMountObserver {
145
223
  this.#modules = await loadImports(this.#init.import);
146
224
  this.#importsLoaded = true;
147
225
 
148
- this.dispatchEvent(new LoadEvent(this.#modules));
226
+ this.dispatchEvent(new LoadEvent(this.#modules, this.#init));
149
227
  }
150
228
 
151
229
  #processNode(node: Node): void {
@@ -162,45 +240,64 @@ export class MountObserver extends EventTarget implements IMountObserver {
162
240
  if (node.nodeType === Node.ELEMENT_NODE || node.nodeType === Node.DOCUMENT_NODE) {
163
241
  const root = node as Element | Document;
164
242
 
165
- // If whereAttr is specified, we need to check all elements
166
- // since we can't use querySelectorAll for complex attribute matching
167
- if (this.#init.whereAttr) {
168
- // Get all elements matching the CSS selector first
169
- root.querySelectorAll(this.#init.whereElementMatches).forEach(child => {
170
- if (this.#matchesSelector(child)) {
171
- this.#handleMatch(child);
172
- }
173
- });
174
- } else {
175
- // Optimize: use querySelectorAll directly when no whereAttr
176
- root.querySelectorAll(this.#init.whereElementMatches).forEach(child => {
243
+ // Get all elements matching the CSS selector first
244
+ root.querySelectorAll(this.#init.whereElementMatches).forEach(child => {
245
+ if (this.#matchesSelector(child)) {
177
246
  this.#handleMatch(child);
178
- });
179
- }
247
+ }
248
+ });
180
249
  }
181
250
  }
182
251
 
183
252
  #matchesSelector(element: Element): boolean {
253
+ //TODO: reduce redundncy with this.#init?
184
254
  // Check whereElementMatches condition
185
255
  const matchesElement = element.matches(this.#init.whereElementMatches);
256
+ if (!matchesElement) {
257
+ return false;
258
+ }
186
259
 
187
- // If whereAttr is not specified, only check whereElementMatches
188
- if (!this.#init.whereAttr) {
189
- return matchesElement;
260
+ // Check whereOutside condition if specified (donut hole scoping)
261
+ if (this.#init.whereOutside) {
262
+ const rootNode = this.#rootNode?.deref();
263
+ if (!rootNode || !whereOutside(rootNode, element, this.#init.whereOutside)) {
264
+ return false;
265
+ }
190
266
  }
191
267
 
192
- // Use cached function (should be loaded by now from constructor)
193
- if (!this.#matchesWhereAttrFn) {
194
- console.warn('whereAttr utilities not loaded yet');
195
- return false;
268
+ // Check whereAttr condition if specified
269
+ if (this.#init.whereAttr) {
270
+ // Use cached function (should be loaded by now from constructor)
271
+ if (!this.#matchesWhereAttrFn) {
272
+ console.warn('whereAttr utilities not loaded yet');
273
+ return false;
274
+ }
275
+
276
+ if (!this.#matchesWhereAttrFn(element, this.#init.whereAttr)) {
277
+ return false;
278
+ }
279
+ }
280
+
281
+ // Check whereInstanceOf condition if specified
282
+ if (this.#init.whereInstanceOf) {
283
+ const constructors = Array.isArray(this.#init.whereInstanceOf)
284
+ ? this.#init.whereInstanceOf
285
+ : [this.#init.whereInstanceOf];
286
+
287
+ // Element must be an instance of at least one constructor (OR logic for array)
288
+ const matchesInstanceOf = constructors.some(constructor => element instanceof constructor);
289
+
290
+ if (!matchesInstanceOf) {
291
+ return false;
292
+ }
196
293
  }
197
294
 
198
- // Both conditions must be true (AND logic)
199
- return matchesElement && this.#matchesWhereAttrFn(element, this.#init.whereAttr);
295
+ // All conditions passed
296
+ return true;
200
297
  }
201
298
 
202
299
  async #handleMatch(element: Element): Promise<void> {
203
- if (this.#processedElements.has(element)) {
300
+ if (this.#processedDoForElement.has(element)) {
204
301
  return;
205
302
  }
206
303
 
@@ -209,8 +306,13 @@ export class MountObserver extends EventTarget implements IMountObserver {
209
306
  await this.#loadImports();
210
307
  }
211
308
 
212
- this.#processedElements.add(element);
213
- this.#mountedElements.add(element);
309
+ this.#processedDoForElement.add(element);
310
+
311
+ // Add to both WeakSet and Set<WeakRef> for efficient operations
312
+ if (!this.#mountedElements.weakSet.has(element)) {
313
+ this.#mountedElements.weakSet.add(element);
314
+ this.#mountedElements.setWeak.add(new WeakRef(element));
315
+ }
214
316
 
215
317
  const rootNode = this.#rootNode?.deref();
216
318
  if (!rootNode) {
@@ -227,9 +329,9 @@ export class MountObserver extends EventTarget implements IMountObserver {
227
329
  };
228
330
 
229
331
  // Apply assignGingerly if specified
230
- if (this.#init.assignGingerly) {
332
+ if (this.#assignGingerlySource) {
231
333
  const { assignGingerly } = await import('assign-gingerly/index.js');
232
- assignGingerly(element, this.#init.assignGingerly);
334
+ assignGingerly(element, this.#assignGingerlySource);
233
335
  }
234
336
 
235
337
  // Call do callback
@@ -242,80 +344,63 @@ export class MountObserver extends EventTarget implements IMountObserver {
242
344
  }
243
345
 
244
346
  // Dispatch mount event
245
- this.dispatchEvent(new MountEvent(element, this.#modules));
347
+ this.dispatchEvent(new MountEvent(element, this.#modules, this.#init));
348
+
349
+ // Emit events from mounted element if configured
350
+ if (this.#init.mountedElemEmits) {
351
+ const { emitMountedElementEvents } = await import('./emitEvents.js');
352
+ await emitMountedElementEvents(element, this.#init, this.#processedEventsForElement);
353
+ }
246
354
 
247
355
  // Check for initial attribute changes if whereAttr is configured
248
- if (this.#init.whereAttr) {
249
- const changes = this.#checkAttrChanges(element);
356
+ if (this.#checkAttrChangesFn) {
357
+ const changes = this.#checkAttrChangesFn(element);
250
358
  if (changes.length > 0) {
251
- this.dispatchEvent(new AttrChangeEvent(changes));
359
+ this.dispatchEvent(new AttrChangeEvent(changes, this.#init));
252
360
  }
253
361
  }
254
362
  }
255
363
 
256
- #checkAttrChanges(element: Element): AttrChange[] {
257
- if (!this.#init.whereAttr || !this.#buildAttrCoordinateMapFn) {
258
- return [];
364
+ async assignGingerly(config: Record<string, any> | undefined): Promise<void> {
365
+ // Handle undefined case
366
+ if (config === undefined) {
367
+ this.#assignGingerlySource = undefined;
368
+ return;
259
369
  }
260
-
261
- const isCustomElement = element.tagName.toLowerCase().includes('-');
262
- const attrCoordMap = this.#buildAttrCoordinateMapFn(this.#init.whereAttr, isCustomElement);
263
-
264
- // Get or create the attribute state for this element
265
- let attrState = this.#elementAttrStates.get(element);
266
- if (!attrState) {
267
- attrState = new Map<string, string | null>();
268
- this.#elementAttrStates.set(element, attrState);
370
+
371
+ const { assignGingerly } = await import('assign-gingerly/index.js');
372
+
373
+ // Update the source config for future mounted elements
374
+ if (this.#assignGingerlySource === undefined) {
375
+ // No existing config, just clone the passed in object
376
+ this.#assignGingerlySource = structuredClone(config);
377
+ } else {
378
+ // Merge into existing config using assignGingerly
379
+ assignGingerly(this.#assignGingerlySource, config);
269
380
  }
270
-
271
- const changes: AttrChange[] = [];
272
- const currentAttrs = new Set<string>();
273
-
274
- // Check all possible attributes from the coordinate map
275
- for (const attrName of Object.keys(attrCoordMap)) {
276
- const coordinate = attrCoordMap[attrName];
277
- const currentValue = element.getAttribute(attrName);
278
- const previousValue = attrState.get(attrName);
279
-
280
- if (currentValue !== null) {
281
- currentAttrs.add(attrName);
282
- }
283
-
284
- // Include if: currently has value OR previously had value but now removed
285
- if (currentValue !== null || (previousValue !== undefined && currentValue === null)) {
286
- // Check if value changed
287
- if (currentValue !== previousValue) {
288
- const attrNode = currentValue !== null ? element.getAttributeNode(attrName) : null;
289
- const mapEntry = this.#init.map?.[coordinate] || null;
290
-
291
- changes.push({
292
- value: currentValue,
293
- attrNode,
294
- mapEntry,
295
- attrName,
296
- coordinate,
297
- element
298
- });
299
-
300
- // Update state
301
- if (currentValue !== null) {
302
- attrState.set(attrName, currentValue);
303
- } else {
304
- attrState.delete(attrName);
305
- }
306
- }
381
+
382
+ // Apply to already mounted elements using setWeak for iteration
383
+ for (const ref of this.#mountedElements.setWeak) {
384
+ const element = ref.deref();
385
+ if (element) {
386
+ assignGingerly(element, config);
307
387
  }
308
388
  }
309
-
310
- return changes;
311
389
  }
312
390
 
313
391
  #handleRemoval(element: Element): void {
314
- if (!this.#mountedElements.has(element)) {
392
+ if (!this.#mountedElements.weakSet.has(element)) {
315
393
  return;
316
394
  }
317
395
 
318
- this.#mountedElements.delete(element);
396
+ // Remove from both structures
397
+ this.#mountedElements.weakSet.delete(element);
398
+ for (const ref of this.#mountedElements.setWeak) {
399
+ if (ref.deref() === element) {
400
+ this.#mountedElements.setWeak.delete(ref);
401
+ break;
402
+ }
403
+ }
319
404
 
320
405
  const rootNode = this.#rootNode?.deref();
321
406
  if (!rootNode) {
@@ -337,7 +422,7 @@ export class MountObserver extends EventTarget implements IMountObserver {
337
422
  }
338
423
 
339
424
  // Dispatch dismount event
340
- this.dispatchEvent(new DismountEvent(element));
425
+ this.dispatchEvent(new DismountEvent(element, 'where-element-matches-failed', this.#init));
341
426
 
342
427
  // Check if element is being moved within the same root
343
428
  // If it's truly disconnected, dispatch disconnect event
@@ -347,7 +432,7 @@ export class MountObserver extends EventTarget implements IMountObserver {
347
432
  this.#init.do.disconnect(element, context);
348
433
  }
349
434
 
350
- this.dispatchEvent(new DisconnectEvent(element));
435
+ this.dispatchEvent(new DisconnectEvent(element, this.#init));
351
436
  }
352
437
  }, 0);
353
438
  }