mount-observer 0.1.1 → 0.1.3

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.js CHANGED
@@ -1,12 +1,26 @@
1
- import { MountEvent, DismountEvent, DisconnectEvent, LoadEvent, AttrChangeEvent } from './Events.js';
1
+ import { arr } from './arr.js';
2
+ import { MountEvent, DismountEvent, DisconnectEvent, LoadEvent, AttrChangeEvent, } from './Events.js';
2
3
  import { registerSharedObserver, unregisterSharedObserver } from './SharedMutationObserver.js';
4
+ import { whereOutside } from './whereOutside.js';
3
5
  export class MountObserver extends EventTarget {
6
+ // Static registry for registered handlers
7
+ static #handlerRegistry = new Map();
8
+ static define(name, handler) {
9
+ if (this.#handlerRegistry.has(name)) {
10
+ throw new Error(`${name} already in use`);
11
+ }
12
+ this.#handlerRegistry.set(name, handler);
13
+ }
4
14
  #init;
5
15
  #options;
6
16
  #abortController;
7
17
  #modules = [];
8
- #mountedElements = new WeakSet();
9
- #processedElements = new WeakSet();
18
+ #mountedElements = {
19
+ weakSet: new WeakSet(),
20
+ setWeak: new Set()
21
+ };
22
+ #processedDoForElement = new WeakSet();
23
+ #processedEventsForElement = new WeakMap();
10
24
  #mutationCallback;
11
25
  #rootNode;
12
26
  #importsLoaded = false;
@@ -14,27 +28,84 @@ export class MountObserver extends EventTarget {
14
28
  #elementOnceAttrs = new WeakMap();
15
29
  #matchesWhereAttrFn = null;
16
30
  #buildAttrCoordinateMapFn = null;
31
+ #checkAttrChangesFn = null;
17
32
  #mediaQueryCleanup;
18
33
  #mediaMatches = true;
34
+ #asgMtSource;
35
+ #asgDisMtSource;
36
+ #elementNotifiers = new WeakMap();
37
+ #notifierMountedElements = new WeakSet();
19
38
  constructor(init, options = {}) {
20
39
  super();
21
40
  this.#init = init;
22
41
  this.#options = options;
23
42
  this.#abortController = new AbortController();
43
+ const { assignOnMount, assignOnDismount, do: doValue, reference, whereAttr, loadingEagerness, import: imp } = init;
44
+ // Make a copy of assignOnMount config using structuredClone
45
+ if (assignOnMount !== undefined) {
46
+ this.#asgMtSource = structuredClone(assignOnMount);
47
+ }
48
+ if (assignOnDismount !== undefined) {
49
+ this.#asgDisMtSource = structuredClone(assignOnDismount);
50
+ }
24
51
  if (options.disconnectedSignal) {
25
52
  options.disconnectedSignal.addEventListener('abort', () => {
26
53
  this.disconnect();
27
54
  });
28
55
  }
56
+ // Validate do property if it contains string references
57
+ if (doValue !== undefined) {
58
+ this.#validateDoHandlers();
59
+ }
60
+ // Validate reference property if present
61
+ if (reference !== undefined) {
62
+ this.#validateReference();
63
+ }
29
64
  // Preload whereAttr utilities if needed
30
- if (init.whereAttr) {
65
+ if (whereAttr) {
31
66
  this.#preloadWhereAttrUtilities();
32
67
  }
33
68
  // Start loading imports if eager
34
- if (init.loadingEagerness === 'eager' && init.import) {
69
+ if (loadingEagerness === 'eager' && imp) {
35
70
  this.#loadImports();
36
71
  }
37
72
  }
73
+ #validateDoHandlers() {
74
+ const doValue = this.#init.do;
75
+ if (doValue === undefined)
76
+ return;
77
+ const handlers = Array.isArray(doValue) ? doValue : [doValue];
78
+ for (const handler of handlers) {
79
+ if (typeof handler === 'string') {
80
+ if (!MountObserver.#handlerRegistry.has(handler)) {
81
+ throw new Error(`No handler defined for ${handler}`);
82
+ }
83
+ }
84
+ }
85
+ }
86
+ #validateReference() {
87
+ if (!this.#init.import) {
88
+ throw new Error('reference property requires import to be defined');
89
+ }
90
+ // Normalize import to array
91
+ const imports = Array.isArray(this.#init.import)
92
+ ? this.#init.import
93
+ : [this.#init.import];
94
+ // Normalize reference to array
95
+ const references = arr(this.#init.reference);
96
+ // Validate each reference index
97
+ for (const index of references) {
98
+ // Check if index is within bounds
99
+ if (index < 0 || index >= imports.length) {
100
+ throw new Error(`reference index ${index} is out of bounds (import array length: ${imports.length})`);
101
+ }
102
+ const importItem = imports[index];
103
+ // Check if it's a JS module (not a 2D array with type option)
104
+ if (Array.isArray(importItem)) {
105
+ throw new Error(`reference index ${index} points to a non-JS module import (array with type option)`);
106
+ }
107
+ }
108
+ }
38
109
  async #preloadWhereAttrUtilities() {
39
110
  if (!this.#matchesWhereAttrFn) {
40
111
  const { matchesWhereAttr } = await import('./whereAttr.js');
@@ -44,6 +115,13 @@ export class MountObserver extends EventTarget {
44
115
  const { buildAttrCoordinateMap } = await import('./attrCoordinates.js');
45
116
  this.#buildAttrCoordinateMapFn = buildAttrCoordinateMap;
46
117
  }
118
+ if (!this.#checkAttrChangesFn) {
119
+ const { checkAttrChanges } = await import('./attrChanges.js');
120
+ // Create a bound function that passes the required parameters
121
+ this.#checkAttrChangesFn = (element) => {
122
+ return checkAttrChanges(element, this.#init, this.#buildAttrCoordinateMapFn, this.#elementAttrStates, this.#elementOnceAttrs);
123
+ };
124
+ }
47
125
  }
48
126
  async #setupMediaQuery() {
49
127
  if (!this.#rootNode) {
@@ -57,10 +135,24 @@ export class MountObserver extends EventTarget {
57
135
  get disconnectedSignal() {
58
136
  return this.#abortController.signal;
59
137
  }
138
+ getNotifier(element) {
139
+ // Return cached notifier if it exists
140
+ let notifier = this.#elementNotifiers.get(element);
141
+ if (notifier) {
142
+ return notifier;
143
+ }
144
+ // Create new EventTarget for this element
145
+ notifier = new EventTarget();
146
+ this.#elementNotifiers.set(element, notifier);
147
+ return notifier;
148
+ }
60
149
  async observe(rootNode) {
61
150
  if (this.#rootNode) {
62
151
  throw new Error('Already observing');
63
152
  }
153
+ if (this.#asgMtSource || this.#asgDisMtSource) {
154
+ await import('assign-gingerly/object-extension.js');
155
+ }
64
156
  this.#rootNode = new WeakRef(rootNode);
65
157
  // Set up media query if specified (needs rootNode to be set first)
66
158
  if (this.#init.whereMediaMatches) {
@@ -70,6 +162,10 @@ export class MountObserver extends EventTarget {
70
162
  if (this.#init.whereAttr && !this.#matchesWhereAttrFn) {
71
163
  await this.#preloadWhereAttrUtilities();
72
164
  }
165
+ // Wait for eager imports to complete if they were started in constructor
166
+ if (this.#init.loadingEagerness === 'eager' && this.#init.import && !this.#importsLoaded) {
167
+ await this.#loadImports();
168
+ }
73
169
  // Process existing elements only if media matches
74
170
  if (this.#mediaMatches) {
75
171
  this.#processNode(rootNode);
@@ -97,8 +193,8 @@ export class MountObserver extends EventTarget {
97
193
  else if (mutation.type === 'attributes' && mutation.target.nodeType === Node.ELEMENT_NODE) {
98
194
  // Handle attribute changes for mounted elements
99
195
  const element = mutation.target;
100
- if (this.#mountedElements.has(element) && this.#init.whereAttr) {
101
- const changes = this.#checkAttrChanges(element);
196
+ if (this.#mountedElements.weakSet.has(element) && this.#checkAttrChangesFn) {
197
+ const changes = this.#checkAttrChangesFn(element);
102
198
  attrChanges.push(...changes);
103
199
  }
104
200
  }
@@ -106,6 +202,20 @@ export class MountObserver extends EventTarget {
106
202
  // Batch and dispatch attribute changes
107
203
  if (attrChanges.length > 0) {
108
204
  this.dispatchEvent(new AttrChangeEvent(attrChanges, this.#init));
205
+ // Dispatch filtered attrchange events to element-specific notifiers
206
+ const changesByElement = new Map();
207
+ for (const change of attrChanges) {
208
+ if (!changesByElement.has(change.element)) {
209
+ changesByElement.set(change.element, []);
210
+ }
211
+ changesByElement.get(change.element).push(change);
212
+ }
213
+ for (const [element, changes] of changesByElement) {
214
+ const notifier = this.#elementNotifiers.get(element);
215
+ if (notifier) {
216
+ notifier.dispatchEvent(new AttrChangeEvent(changes, this.#init));
217
+ }
218
+ }
109
219
  }
110
220
  };
111
221
  const observerConfig = {
@@ -143,6 +253,23 @@ export class MountObserver extends EventTarget {
143
253
  const { loadImports } = await import('./loadImports.js');
144
254
  this.#modules = await loadImports(this.#init.import);
145
255
  this.#importsLoaded = true;
256
+ // Validate referenced whereInstanceOf if reference is specified
257
+ if (this.#init.reference !== undefined) {
258
+ const references = arr(this.#init.reference);
259
+ for (const index of references) {
260
+ const module = this.#modules[index];
261
+ if (module && module.whereInstanceOf !== undefined) {
262
+ // Validate that it's a Constructor or array of Constructors
263
+ const whereInstanceOf = module.whereInstanceOf;
264
+ const constructors = arr(whereInstanceOf);
265
+ for (const constructor of constructors) {
266
+ if (typeof constructor !== 'function') {
267
+ throw new Error(`Referenced module at index ${index} exports invalid whereInstanceOf: must be a Constructor or array of Constructors`);
268
+ }
269
+ }
270
+ }
271
+ }
272
+ }
146
273
  this.dispatchEvent(new LoadEvent(this.#modules, this.#init));
147
274
  }
148
275
  #processNode(node) {
@@ -171,6 +298,13 @@ export class MountObserver extends EventTarget {
171
298
  if (!matchesElement) {
172
299
  return false;
173
300
  }
301
+ // Check whereOutside condition if specified (donut hole scoping)
302
+ if (this.#init.whereOutside) {
303
+ const rootNode = this.#rootNode?.deref();
304
+ if (!rootNode || !whereOutside(rootNode, element, this.#init.whereOutside)) {
305
+ return false;
306
+ }
307
+ }
174
308
  // Check whereAttr condition if specified
175
309
  if (this.#init.whereAttr) {
176
310
  // Use cached function (should be loaded by now from constructor)
@@ -184,28 +318,45 @@ export class MountObserver extends EventTarget {
184
318
  }
185
319
  // Check whereInstanceOf condition if specified
186
320
  if (this.#init.whereInstanceOf) {
187
- const constructors = Array.isArray(this.#init.whereInstanceOf)
188
- ? this.#init.whereInstanceOf
189
- : [this.#init.whereInstanceOf];
321
+ const constructors = arr(this.#init.whereInstanceOf);
190
322
  // Element must be an instance of at least one constructor (OR logic for array)
191
323
  const matchesInstanceOf = constructors.some(constructor => element instanceof constructor);
192
324
  if (!matchesInstanceOf) {
193
325
  return false;
194
326
  }
195
327
  }
328
+ // Check referenced whereInstanceOf if imports are loaded and reference is specified
329
+ if (this.#importsLoaded && this.#init.reference !== undefined) {
330
+ const references = arr(this.#init.reference);
331
+ for (const index of references) {
332
+ const module = this.#modules[index];
333
+ if (module && module.whereInstanceOf !== undefined) {
334
+ const constructors = arr(module.whereInstanceOf);
335
+ // Element must be an instance of at least one constructor (OR logic within this module)
336
+ const matchesInstanceOf = constructors.some((constructor) => element instanceof constructor);
337
+ if (!matchesInstanceOf) {
338
+ return false;
339
+ }
340
+ }
341
+ }
342
+ }
196
343
  // All conditions passed
197
344
  return true;
198
345
  }
199
346
  async #handleMatch(element) {
200
- if (this.#processedElements.has(element)) {
347
+ if (this.#processedDoForElement.has(element)) {
201
348
  return;
202
349
  }
203
350
  // Load imports if not already loaded
204
351
  if (!this.#importsLoaded && this.#init.import) {
205
352
  await this.#loadImports();
206
353
  }
207
- this.#processedElements.add(element);
208
- this.#mountedElements.add(element);
354
+ this.#processedDoForElement.add(element);
355
+ // Add to both WeakSet and Set<WeakRef> for efficient operations
356
+ if (!this.#mountedElements.weakSet.has(element)) {
357
+ this.#mountedElements.weakSet.add(element);
358
+ this.#mountedElements.setWeak.add(new WeakRef(element));
359
+ }
209
360
  const rootNode = this.#rootNode?.deref();
210
361
  if (!rootNode) {
211
362
  // Root node was garbage collected
@@ -214,105 +365,119 @@ export class MountObserver extends EventTarget {
214
365
  const context = {
215
366
  modules: this.#modules,
216
367
  observer: this,
217
- observeInfo: {
218
- rootNode
219
- }
368
+ rootNode,
369
+ mountInit: this.#init,
220
370
  };
221
371
  // Apply assignGingerly if specified
222
- if (this.#init.assignGingerly) {
223
- const { assignGingerly } = await import('assign-gingerly/index.js');
224
- assignGingerly(element, this.#init.assignGingerly);
225
- }
226
- // Call do callback
227
- if (this.#init.do) {
228
- if (typeof this.#init.do === 'function') {
229
- this.#init.do(element, context);
372
+ if (this.#asgMtSource) {
373
+ element.assignGingerly(this.#asgMtSource);
374
+ }
375
+ // Check if notifier exists BEFORE calling do callback
376
+ const notifierExistedBeforeDo = this.#elementNotifiers.has(element);
377
+ // Call do callback(s) - can be string, function, or array
378
+ if (this.#init.do !== undefined) {
379
+ const doHandlers = Array.isArray(this.#init.do) ? this.#init.do : [this.#init.do];
380
+ for (const handler of doHandlers) {
381
+ if (typeof handler === 'string') {
382
+ // Registered handler - instantiate it
383
+ const HandlerClass = MountObserver.#handlerRegistry.get(handler);
384
+ if (HandlerClass) {
385
+ new HandlerClass(element, context);
386
+ }
387
+ }
388
+ else if (typeof handler === 'function') {
389
+ // Inline function
390
+ handler(element, context);
391
+ }
230
392
  }
231
- else if (this.#init.do.mount) {
232
- this.#init.do.mount(element, context);
393
+ }
394
+ // Call referenced do functions from imported modules
395
+ if (this.#init.reference !== undefined) {
396
+ const references = arr(this.#init.reference);
397
+ for (const index of references) {
398
+ const module = this.#modules[index];
399
+ if (module && typeof module.do === 'function') {
400
+ module.do(element, context);
401
+ }
233
402
  }
234
403
  }
235
404
  // Dispatch mount event
236
- this.dispatchEvent(new MountEvent(element, this.#modules, this.#init));
405
+ const mountEvent = new MountEvent(element, this.#modules, this.#init, context);
406
+ this.dispatchEvent(mountEvent);
407
+ // Dispatch to element-specific notifier only if:
408
+ // 1. Notifier existed before do callback (wasn't just created), AND
409
+ // 2. Element hasn't already received a mount event on its notifier
410
+ if (notifierExistedBeforeDo && !this.#notifierMountedElements.has(element)) {
411
+ const notifier = this.#elementNotifiers.get(element);
412
+ if (notifier) {
413
+ this.#notifierMountedElements.add(element);
414
+ notifier.dispatchEvent(mountEvent);
415
+ }
416
+ }
417
+ // Emit events from mounted element if configured
418
+ if (this.#init.mountedElemEmits) {
419
+ const { emitMountedElementEvents } = await import('./emitEvents.js');
420
+ await emitMountedElementEvents(element, this.#init, this.#processedEventsForElement);
421
+ }
237
422
  // Check for initial attribute changes if whereAttr is configured
238
- if (this.#init.whereAttr) {
239
- const changes = this.#checkAttrChanges(element);
423
+ if (this.#checkAttrChangesFn) {
424
+ const changes = this.#checkAttrChangesFn(element);
240
425
  if (changes.length > 0) {
241
426
  this.dispatchEvent(new AttrChangeEvent(changes, this.#init));
427
+ // Also dispatch to element-specific notifier
428
+ const notifier = this.#elementNotifiers.get(element);
429
+ if (notifier) {
430
+ notifier.dispatchEvent(new AttrChangeEvent(changes, this.#init));
431
+ }
242
432
  }
243
433
  }
244
434
  }
245
- #checkAttrChanges(element) {
246
- if (!this.#init.whereAttr || !this.#buildAttrCoordinateMapFn) {
247
- return [];
248
- }
249
- const isCustomElement = element.tagName.toLowerCase().includes('-');
250
- const attrCoordMap = this.#buildAttrCoordinateMapFn(this.#init.whereAttr, isCustomElement);
251
- // Get or create the attribute state for this element
252
- let attrState = this.#elementAttrStates.get(element);
253
- if (!attrState) {
254
- attrState = new Map();
255
- this.#elementAttrStates.set(element, attrState);
256
- }
257
- const changes = [];
258
- const currentAttrs = new Set();
259
- // Check all possible attributes from the coordinate map
260
- for (const attrName of Object.keys(attrCoordMap)) {
261
- const coordinate = attrCoordMap[attrName];
262
- const currentValue = element.getAttribute(attrName);
263
- const previousValue = attrState.get(attrName);
264
- if (currentValue !== null) {
265
- currentAttrs.add(attrName);
266
- }
267
- // Check if this attribute has "once: true" in its map entry
268
- const mapEntry = this.#init.map?.[coordinate] || null;
269
- const isOnce = mapEntry?.once === true;
270
- // If "once" is true, check if we've already seen this attribute
271
- if (isOnce) {
272
- let onceAttrs = this.#elementOnceAttrs.get(element);
273
- if (!onceAttrs) {
274
- onceAttrs = new Set();
275
- this.#elementOnceAttrs.set(element, onceAttrs);
276
- }
277
- // If we've already seen this attribute, skip it
278
- if (onceAttrs.has(attrName)) {
279
- continue;
280
- }
281
- // Mark this attribute as seen if it currently has a value
282
- if (currentValue !== null) {
283
- onceAttrs.add(attrName);
284
- }
285
- }
286
- // Include if: currently has value OR previously had value but now removed
287
- if (currentValue !== null || (previousValue !== undefined && currentValue === null)) {
288
- // Check if value changed
289
- if (currentValue !== previousValue) {
290
- const attrNode = currentValue !== null ? element.getAttributeNode(attrName) : null;
291
- changes.push({
292
- value: currentValue,
293
- attrNode,
294
- mapEntry,
295
- attrName,
296
- coordinate,
297
- element
298
- });
299
- // Update state
300
- if (currentValue !== null) {
301
- attrState.set(attrName, currentValue);
302
- }
303
- else {
304
- attrState.delete(attrName);
305
- }
306
- }
435
+ async assignGingerly(config) {
436
+ // Handle undefined case
437
+ if (config === undefined) {
438
+ this.#asgMtSource = undefined;
439
+ return;
440
+ }
441
+ await import('assign-gingerly/object-extension.js');
442
+ // Update the source config for future mounted elements
443
+ if (this.#asgMtSource === undefined) {
444
+ // No existing config, just clone the passed in object
445
+ this.#asgMtSource = structuredClone(config);
446
+ }
447
+ else {
448
+ // Merge into existing config using assignGingerly
449
+ this.#asgMtSource.assignGingerly(config);
450
+ //assignGingerly(this.#asgMtSource, config);
451
+ }
452
+ // Apply to already mounted elements using setWeak for iteration
453
+ for (const ref of this.#mountedElements.setWeak) {
454
+ const element = ref.deref();
455
+ if (element) {
456
+ element.assignGingerly(config);
457
+ //assignGingerly(element, config);
307
458
  }
308
459
  }
309
- return changes;
310
460
  }
311
- #handleRemoval(element) {
312
- if (!this.#mountedElements.has(element)) {
461
+ async #handleRemoval(element) {
462
+ if (!this.#mountedElements.weakSet.has(element)) {
313
463
  return;
314
464
  }
315
- this.#mountedElements.delete(element);
465
+ // Apply assignGingerly if specified for dismount
466
+ if (this.#asgDisMtSource) {
467
+ element.assignGingerly(this.#asgDisMtSource);
468
+ }
469
+ // Remove from both structures
470
+ this.#mountedElements.weakSet.delete(element);
471
+ for (const ref of this.#mountedElements.setWeak) {
472
+ if (ref.deref() === element) {
473
+ this.#mountedElements.setWeak.delete(ref);
474
+ break;
475
+ }
476
+ }
477
+ // Remove from processed set so element can be re-mounted
478
+ this.#processedDoForElement.delete(element);
479
+ // Remove from notifier mounted tracking so mount event can fire again
480
+ this.#notifierMountedElements.delete(element);
316
481
  const rootNode = this.#rootNode?.deref();
317
482
  if (!rootNode) {
318
483
  // Root node was garbage collected
@@ -321,24 +486,28 @@ export class MountObserver extends EventTarget {
321
486
  const context = {
322
487
  modules: this.#modules,
323
488
  observer: this,
324
- observeInfo: {
325
- rootNode
326
- }
489
+ rootNode,
490
+ mountInit: this.#init,
327
491
  };
328
- // Call dismount callback
329
- if (this.#init.do && typeof this.#init.do !== 'function' && this.#init.do.dismount) {
330
- this.#init.do.dismount(element, context);
331
- }
332
492
  // Dispatch dismount event
333
- this.dispatchEvent(new DismountEvent(element, 'where-element-matches-failed', this.#init));
493
+ const dismountEvent = new DismountEvent(element, 'where-element-matches-failed', this.#init);
494
+ this.dispatchEvent(dismountEvent);
495
+ // Dispatch to element-specific notifier
496
+ const notifier = this.#elementNotifiers.get(element);
497
+ if (notifier) {
498
+ notifier.dispatchEvent(dismountEvent);
499
+ }
334
500
  // Check if element is being moved within the same root
335
501
  // If it's truly disconnected, dispatch disconnect event
336
502
  setTimeout(() => {
337
503
  if (!rootNode.contains(element)) {
338
- if (this.#init.do && typeof this.#init.do !== 'function' && this.#init.do.disconnect) {
339
- this.#init.do.disconnect(element, context);
504
+ const disconnectEvent = new DisconnectEvent(element, this.#init);
505
+ this.dispatchEvent(disconnectEvent);
506
+ // Dispatch to element-specific notifier
507
+ const notifier = this.#elementNotifiers.get(element);
508
+ if (notifier) {
509
+ notifier.dispatchEvent(disconnectEvent);
340
510
  }
341
- this.dispatchEvent(new DisconnectEvent(element, this.#init));
342
511
  }
343
512
  }, 0);
344
513
  }