mount-observer 0.1.2 → 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,7 +1,16 @@
1
+ import { arr } from './arr.js';
1
2
  import { MountEvent, DismountEvent, DisconnectEvent, LoadEvent, AttrChangeEvent, } from './Events.js';
2
3
  import { registerSharedObserver, unregisterSharedObserver } from './SharedMutationObserver.js';
3
4
  import { whereOutside } from './whereOutside.js';
4
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
+ }
5
14
  #init;
6
15
  #options;
7
16
  #abortController;
@@ -22,30 +31,81 @@ export class MountObserver extends EventTarget {
22
31
  #checkAttrChangesFn = null;
23
32
  #mediaQueryCleanup;
24
33
  #mediaMatches = true;
25
- #assignGingerlySource;
34
+ #asgMtSource;
35
+ #asgDisMtSource;
36
+ #elementNotifiers = new WeakMap();
37
+ #notifierMountedElements = new WeakSet();
26
38
  constructor(init, options = {}) {
27
39
  super();
28
40
  this.#init = init;
29
41
  this.#options = options;
30
42
  this.#abortController = new AbortController();
31
- // Make a copy of assignGingerly config using structuredClone
32
- if (init.assignGingerly !== undefined) {
33
- this.#assignGingerlySource = structuredClone(init.assignGingerly);
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);
34
50
  }
35
51
  if (options.disconnectedSignal) {
36
52
  options.disconnectedSignal.addEventListener('abort', () => {
37
53
  this.disconnect();
38
54
  });
39
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
+ }
40
64
  // Preload whereAttr utilities if needed
41
- if (init.whereAttr) {
65
+ if (whereAttr) {
42
66
  this.#preloadWhereAttrUtilities();
43
67
  }
44
68
  // Start loading imports if eager
45
- if (init.loadingEagerness === 'eager' && init.import) {
69
+ if (loadingEagerness === 'eager' && imp) {
46
70
  this.#loadImports();
47
71
  }
48
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
+ }
49
109
  async #preloadWhereAttrUtilities() {
50
110
  if (!this.#matchesWhereAttrFn) {
51
111
  const { matchesWhereAttr } = await import('./whereAttr.js');
@@ -75,10 +135,24 @@ export class MountObserver extends EventTarget {
75
135
  get disconnectedSignal() {
76
136
  return this.#abortController.signal;
77
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
+ }
78
149
  async observe(rootNode) {
79
150
  if (this.#rootNode) {
80
151
  throw new Error('Already observing');
81
152
  }
153
+ if (this.#asgMtSource || this.#asgDisMtSource) {
154
+ await import('assign-gingerly/object-extension.js');
155
+ }
82
156
  this.#rootNode = new WeakRef(rootNode);
83
157
  // Set up media query if specified (needs rootNode to be set first)
84
158
  if (this.#init.whereMediaMatches) {
@@ -88,6 +162,10 @@ export class MountObserver extends EventTarget {
88
162
  if (this.#init.whereAttr && !this.#matchesWhereAttrFn) {
89
163
  await this.#preloadWhereAttrUtilities();
90
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
+ }
91
169
  // Process existing elements only if media matches
92
170
  if (this.#mediaMatches) {
93
171
  this.#processNode(rootNode);
@@ -124,6 +202,20 @@ export class MountObserver extends EventTarget {
124
202
  // Batch and dispatch attribute changes
125
203
  if (attrChanges.length > 0) {
126
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
+ }
127
219
  }
128
220
  };
129
221
  const observerConfig = {
@@ -161,6 +253,23 @@ export class MountObserver extends EventTarget {
161
253
  const { loadImports } = await import('./loadImports.js');
162
254
  this.#modules = await loadImports(this.#init.import);
163
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
+ }
164
273
  this.dispatchEvent(new LoadEvent(this.#modules, this.#init));
165
274
  }
166
275
  #processNode(node) {
@@ -209,15 +318,28 @@ export class MountObserver extends EventTarget {
209
318
  }
210
319
  // Check whereInstanceOf condition if specified
211
320
  if (this.#init.whereInstanceOf) {
212
- const constructors = Array.isArray(this.#init.whereInstanceOf)
213
- ? this.#init.whereInstanceOf
214
- : [this.#init.whereInstanceOf];
321
+ const constructors = arr(this.#init.whereInstanceOf);
215
322
  // Element must be an instance of at least one constructor (OR logic for array)
216
323
  const matchesInstanceOf = constructors.some(constructor => element instanceof constructor);
217
324
  if (!matchesInstanceOf) {
218
325
  return false;
219
326
  }
220
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
+ }
221
343
  // All conditions passed
222
344
  return true;
223
345
  }
@@ -243,26 +365,55 @@ export class MountObserver extends EventTarget {
243
365
  const context = {
244
366
  modules: this.#modules,
245
367
  observer: this,
246
- observeInfo: {
247
- rootNode
248
- }
368
+ rootNode,
369
+ mountInit: this.#init,
249
370
  };
250
371
  // Apply assignGingerly if specified
251
- if (this.#assignGingerlySource) {
252
- const { assignGingerly } = await import('assign-gingerly/index.js');
253
- assignGingerly(element, this.#assignGingerlySource);
254
- }
255
- // Call do callback
256
- if (this.#init.do) {
257
- if (typeof this.#init.do === 'function') {
258
- 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
+ }
259
392
  }
260
- else if (this.#init.do.mount) {
261
- 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
+ }
262
402
  }
263
403
  }
264
404
  // Dispatch mount event
265
- 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
+ }
266
417
  // Emit events from mounted element if configured
267
418
  if (this.#init.mountedElemEmits) {
268
419
  const { emitMountedElementEvents } = await import('./emitEvents.js');
@@ -273,37 +424,48 @@ export class MountObserver extends EventTarget {
273
424
  const changes = this.#checkAttrChangesFn(element);
274
425
  if (changes.length > 0) {
275
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
+ }
276
432
  }
277
433
  }
278
434
  }
279
435
  async assignGingerly(config) {
280
436
  // Handle undefined case
281
437
  if (config === undefined) {
282
- this.#assignGingerlySource = undefined;
438
+ this.#asgMtSource = undefined;
283
439
  return;
284
440
  }
285
- const { assignGingerly } = await import('assign-gingerly/index.js');
441
+ await import('assign-gingerly/object-extension.js');
286
442
  // Update the source config for future mounted elements
287
- if (this.#assignGingerlySource === undefined) {
443
+ if (this.#asgMtSource === undefined) {
288
444
  // No existing config, just clone the passed in object
289
- this.#assignGingerlySource = structuredClone(config);
445
+ this.#asgMtSource = structuredClone(config);
290
446
  }
291
447
  else {
292
448
  // Merge into existing config using assignGingerly
293
- assignGingerly(this.#assignGingerlySource, config);
449
+ this.#asgMtSource.assignGingerly(config);
450
+ //assignGingerly(this.#asgMtSource, config);
294
451
  }
295
452
  // Apply to already mounted elements using setWeak for iteration
296
453
  for (const ref of this.#mountedElements.setWeak) {
297
454
  const element = ref.deref();
298
455
  if (element) {
299
- assignGingerly(element, config);
456
+ element.assignGingerly(config);
457
+ //assignGingerly(element, config);
300
458
  }
301
459
  }
302
460
  }
303
- #handleRemoval(element) {
461
+ async #handleRemoval(element) {
304
462
  if (!this.#mountedElements.weakSet.has(element)) {
305
463
  return;
306
464
  }
465
+ // Apply assignGingerly if specified for dismount
466
+ if (this.#asgDisMtSource) {
467
+ element.assignGingerly(this.#asgDisMtSource);
468
+ }
307
469
  // Remove from both structures
308
470
  this.#mountedElements.weakSet.delete(element);
309
471
  for (const ref of this.#mountedElements.setWeak) {
@@ -312,6 +474,10 @@ export class MountObserver extends EventTarget {
312
474
  break;
313
475
  }
314
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);
315
481
  const rootNode = this.#rootNode?.deref();
316
482
  if (!rootNode) {
317
483
  // Root node was garbage collected
@@ -320,24 +486,28 @@ export class MountObserver extends EventTarget {
320
486
  const context = {
321
487
  modules: this.#modules,
322
488
  observer: this,
323
- observeInfo: {
324
- rootNode
325
- }
489
+ rootNode,
490
+ mountInit: this.#init,
326
491
  };
327
- // Call dismount callback
328
- if (this.#init.do && typeof this.#init.do !== 'function' && this.#init.do.dismount) {
329
- this.#init.do.dismount(element, context);
330
- }
331
492
  // Dispatch dismount event
332
- 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
+ }
333
500
  // Check if element is being moved within the same root
334
501
  // If it's truly disconnected, dispatch disconnect event
335
502
  setTimeout(() => {
336
503
  if (!rootNode.contains(element)) {
337
- if (this.#init.do && typeof this.#init.do !== 'function' && this.#init.do.disconnect) {
338
- 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);
339
510
  }
340
- this.dispatchEvent(new DisconnectEvent(element, this.#init));
341
511
  }
342
512
  }, 0);
343
513
  }