mount-observer 0.1.12 → 0.1.13

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
@@ -21,9 +21,10 @@ import {
21
21
  type MutationCallback
22
22
  } from './SharedMutationObserver.js';
23
23
  import { withScopePerimeter } from './withScopePerimeter.js';
24
+ import { getRegistryRoot } from './getRegistryRoot.js';
24
25
  import type { assignTentatively as AssignTentativelyType } from 'assign-gingerly/assignTentatively.js';
25
26
 
26
- export class MountObserver extends EventTarget implements IMountObserver {
27
+ export class MountObserver<TKeys extends string = string> extends EventTarget implements IMountObserver {
27
28
  // Static registry for registered handlers
28
29
  static #handlerRegistry = new Map<string, Constructor>();
29
30
 
@@ -38,6 +39,7 @@ export class MountObserver extends EventTarget implements IMountObserver {
38
39
  #options: MountObserverOptions;
39
40
  #abortController: AbortController;
40
41
  #modules: any[] = [];
42
+ #configFromPromise: Promise<void> | undefined;
41
43
  #mountedElements: WeakDual<Element> = {
42
44
  weakSet: new WeakSet(),
43
45
  setWeak: new Set()
@@ -62,6 +64,7 @@ export class MountObserver extends EventTarget implements IMountObserver {
62
64
  #assignTentatively: typeof AssignTentativelyType | undefined;
63
65
  #elementNotifiers = new WeakMap<Element, EventTarget>();
64
66
  #notifierMountedElements = new WeakSet<Element>();
67
+ #subObservers: Map<string, MountObserver> | undefined;
65
68
 
66
69
  #mergeHandlerDefaults(config: MountConfig): MountConfig {
67
70
  const doValue = config.do;
@@ -94,7 +97,7 @@ export class MountObserver extends EventTarget implements IMountObserver {
94
97
  return { ...handlerDefaults, ...config };
95
98
  }
96
99
 
97
- constructor(config: MountConfig, options: MountObserverOptions = {}) {
100
+ constructor(config: MountConfig<TKeys>, options: MountObserverOptions = {}) {
98
101
  super();
99
102
 
100
103
  // Merge handler defaults if do is a string reference
@@ -105,8 +108,8 @@ export class MountObserver extends EventTarget implements IMountObserver {
105
108
  this.#abortController = new AbortController();
106
109
 
107
110
  const {
108
- assignOnMount, assignOnDismount, stageOnMount, do: doValue, reference, loadingEagerness,
109
- import: imp
111
+ assignOnMount, assignOnDismount, stageOnMount, do: doValue, loadingEagerness,
112
+ import: imp, configFrom
110
113
  } = mergedConfig;
111
114
  // Make a copy of assignOnMount config using structuredClone
112
115
  if (assignOnMount !== undefined) {
@@ -130,9 +133,9 @@ export class MountObserver extends EventTarget implements IMountObserver {
130
133
  this.#validateDoHandlers();
131
134
  }
132
135
 
133
- // Validate reference property if present
134
- if (reference !== undefined) {
135
- this.#validateReference();
136
+ // Load configFrom modules if specified
137
+ if (configFrom !== undefined) {
138
+ this.#configFromPromise = this.#loadConfigFrom();
136
139
  }
137
140
 
138
141
  // Start loading imports if eager
@@ -155,35 +158,87 @@ export class MountObserver extends EventTarget implements IMountObserver {
155
158
  }
156
159
  }
157
160
  }
158
-
159
- #validateReference(): void {
160
- if (!this.#init.import) {
161
- throw new Error('reference property requires import to be defined');
161
+
162
+ /**
163
+ * Loads configuration from external modules specified in configFrom property.
164
+ * Merges multiple configs left-to-right, with inline config taking final precedence.
165
+ */
166
+ async #loadConfigFrom(): Promise<void> {
167
+ const { configFrom } = this.#init;
168
+ if (!configFrom) return;
169
+
170
+ // Normalize to array
171
+ const configPaths = Array.isArray(configFrom) ? configFrom : [configFrom];
172
+
173
+ // Check for duplicates
174
+ const pathSet = new Set<string>();
175
+ for (const path of configPaths) {
176
+ if (pathSet.has(path)) {
177
+ throw new Error(`Duplicate configFrom module: '${path}'`);
178
+ }
179
+ pathSet.add(path);
162
180
  }
163
181
 
164
- // Normalize import to array
165
- const imports = Array.isArray(this.#init.import)
166
- ? this.#init.import
167
- : [this.#init.import];
182
+ // Load all modules
183
+ const loadedConfigs: MountConfig[] = [];
184
+ for (const path of configPaths) {
185
+ try {
186
+ const module = await import(path);
168
187
 
169
- // Normalize reference to array
170
- const references = arr(this.#init.reference);
188
+ if (!module.mountConfig) {
189
+ throw new Error(`Module '${path}' does not export 'mountConfig'`);
190
+ }
171
191
 
172
- // Validate each reference index
173
- for (const index of references) {
174
- // Check if index is within bounds
175
- if (index < 0 || index >= imports.length) {
176
- throw new Error(`reference index ${index} is out of bounds (import array length: ${imports.length})`);
192
+ if (typeof module.mountConfig !== 'object' || module.mountConfig === null) {
193
+ throw new Error(`Module '${path}' exports invalid mountConfig: must be an object`);
194
+ }
195
+
196
+ loadedConfigs.push(module.mountConfig);
197
+ } catch (error) {
198
+ // Re-throw with better context if it's not already our error
199
+ if (error instanceof Error && !error.message.includes(path)) {
200
+ throw new Error(`Failed to load config from '${path}': ${error.message}`);
201
+ }
202
+ throw error;
177
203
  }
204
+ }
178
205
 
179
- const importItem = imports[index];
206
+ // Merge configs: loaded configs first (left-to-right), then inline config
207
+ // Save the original inline config
208
+ const inlineConfig = { ...this.#init };
180
209
 
181
- // Check if it's a JS module (not a 2D array with type option)
182
- if (Array.isArray(importItem)) {
183
- throw new Error(`reference index ${index} points to a non-JS module import (array with type option)`);
184
- }
210
+ // Start with empty object, merge all loaded configs, then merge inline
211
+ let mergedConfig: MountConfig = {};
212
+ for (const loadedConfig of loadedConfigs) {
213
+ mergedConfig = Object.assign(mergedConfig, loadedConfig);
185
214
  }
215
+
216
+ // Inline config takes final precedence
217
+ mergedConfig = Object.assign(mergedConfig, inlineConfig);
218
+
219
+ // Update the init config with merged result
220
+ this.#init = mergedConfig;
186
221
  }
222
+
223
+ /**
224
+ * Creates and initializes sub-observers from the `with` property.
225
+ * Each sub-observer observes the same root node as the parent.
226
+ * Sub-observers are stored in #subObservers Map for lifecycle management.
227
+ */
228
+ async #createSubObservers(rootNode: Node): Promise<void> {
229
+ const withConfig = this.#init.with;
230
+ if (!withConfig) return;
231
+
232
+ this.#subObservers = new Map();
233
+
234
+ for (const [key, subConfig] of Object.entries(withConfig)) {
235
+ const subObserver = new MountObserver(subConfig as MountConfig);
236
+ this.#subObservers.set(key, subObserver);
237
+ await subObserver.observe(rootNode);
238
+ }
239
+ }
240
+
241
+
187
242
 
188
243
 
189
244
  async #setupMediaQuery(): Promise<void> {
@@ -267,6 +322,17 @@ export class MountObserver extends EventTarget implements IMountObserver {
267
322
  return this.#abortController.signal;
268
323
  }
269
324
 
325
+ get mountedElements(): Element[] {
326
+ const elements: Element[] = [];
327
+ for (const ref of this.#mountedElements.setWeak) {
328
+ const element = ref.deref();
329
+ if (element !== undefined) {
330
+ elements.push(element);
331
+ }
332
+ }
333
+ return elements;
334
+ }
335
+
270
336
  getNotifier(element: Element): EventTarget {
271
337
  // Return cached notifier if it exists
272
338
  let notifier = this.#elementNotifiers.get(element);
@@ -280,10 +346,29 @@ export class MountObserver extends EventTarget implements IMountObserver {
280
346
  return notifier;
281
347
  }
282
348
 
283
- async observe(rootNode: Node): Promise<void> {
349
+ /**
350
+ * Begins observing elements within the provided node.
351
+ *
352
+ * @param observedNode - The node to observe for matching elements. This is the root
353
+ * of the observation scope where the mutation observer will be
354
+ * registered. All matching elements within this node (and its
355
+ * descendants) will trigger mount callbacks.
356
+ *
357
+ * Common values:
358
+ * - `document` - Observe the entire document
359
+ * - `element` - Observe a specific subtree
360
+ * - `shadowRoot` - Observe within a shadow DOM
361
+ */
362
+ async observe(observedNode: Node): Promise<void> {
284
363
  if (this.#rootNode) {
285
364
  throw new Error('Already observing');
286
365
  }
366
+
367
+ // Wait for configFrom loading to complete if it was started
368
+ if (this.#configFromPromise) {
369
+ await this.#configFromPromise;
370
+ }
371
+
287
372
  if(this.#asgMtSource || this.#asgDisMtSource){
288
373
  await import('assign-gingerly/object-extension.js');
289
374
  }
@@ -292,7 +377,10 @@ export class MountObserver extends EventTarget implements IMountObserver {
292
377
  this.#assignTentatively = assignTentatively;
293
378
  }
294
379
 
295
- this.#rootNode = new WeakRef(rootNode);
380
+ this.#rootNode = new WeakRef(observedNode);
381
+
382
+ // Create sub-observers from `with` property
383
+ await this.#createSubObservers(observedNode);
296
384
 
297
385
  // Set up media query if specified (needs rootNode to be set first)
298
386
  if (this.#init.withMediaMatching) {
@@ -321,7 +409,7 @@ export class MountObserver extends EventTarget implements IMountObserver {
321
409
 
322
410
  // Process existing elements only if all conditions match
323
411
  if (this.#mediaMatches && this.#rootSizeMatches && this.#connectionMatches) {
324
- this.#processNode(rootNode);
412
+ this.#processNode(observedNode);
325
413
  }
326
414
 
327
415
  // Create mutation callback
@@ -353,12 +441,21 @@ export class MountObserver extends EventTarget implements IMountObserver {
353
441
  };
354
442
 
355
443
  // Register with shared mutation observer
356
- registerSharedObserver(rootNode, this.#mutationCallback, observerConfig);
444
+ registerSharedObserver(observedNode, this.#mutationCallback, observerConfig);
357
445
  }
358
446
 
359
447
  disconnect(): void {
360
448
  const rootNode = this.#rootNode?.deref();
361
449
 
450
+ // Disconnect all sub-observers first (recursive)
451
+ if (this.#subObservers) {
452
+ for (const subObserver of this.#subObservers.values()) {
453
+ subObserver.disconnect();
454
+ }
455
+ this.#subObservers.clear();
456
+ this.#subObservers = undefined;
457
+ }
458
+
362
459
  // Unregister from shared mutation observer
363
460
  if (rootNode && this.#mutationCallback) {
364
461
  unregisterSharedObserver(rootNode, this.#mutationCallback);
@@ -403,26 +500,6 @@ export class MountObserver extends EventTarget implements IMountObserver {
403
500
  this.#modules = await loadImports(this.#init.import);
404
501
  this.#importsLoaded = true;
405
502
 
406
- // Validate referenced whereInstanceOf if reference is specified
407
- if (this.#init.reference !== undefined) {
408
- const references = arr(this.#init.reference);
409
-
410
- for (const index of references) {
411
- const module = this.#modules[index];
412
- if (module && module.whereInstanceOf !== undefined) {
413
- // Validate that it's a Constructor or array of Constructors
414
- const whereInstanceOf = module.whereInstanceOf;
415
- const constructors = arr(whereInstanceOf);
416
-
417
- for (const constructor of constructors) {
418
- if (typeof constructor !== 'function') {
419
- throw new Error(`Referenced module at index ${index} exports invalid whereInstanceOf: must be a Constructor or array of Constructors`);
420
- }
421
- }
422
- }
423
- }
424
- }
425
-
426
503
  this.dispatchEvent(new LoadEvent(this.#modules, this.#init));
427
504
  }
428
505
 
@@ -445,7 +522,8 @@ export class MountObserver extends EventTarget implements IMountObserver {
445
522
  const root = node as DocumentFragment;
446
523
 
447
524
  // Get all elements matching the CSS selector first
448
- root.querySelectorAll(this.#init.matching).forEach(child => {
525
+ const matches = root.querySelectorAll(this.#init.matching);
526
+ matches.forEach(child => {
449
527
  // If intersection observer is active, start observing the element
450
528
  if (this.#intersectionObserver) {
451
529
  this.#intersectionObserver.observe(child);
@@ -468,9 +546,22 @@ export class MountObserver extends EventTarget implements IMountObserver {
468
546
  return false;
469
547
  }
470
548
 
549
+ // Check that element's customElementRegistry matches root node's registry
550
+ const rootNode = this.#rootNode?.deref();
551
+ if (rootNode) {
552
+ const registriesMatch = (rootNode as any).customElementRegistry === (element as any).customElementRegistry;
553
+
554
+ // If whereDifferentCustomElementRegistry is true, exclude matching registries
555
+ if (this.#init.whereDifferentCustomElementRegistry) {
556
+ if (registriesMatch) return false;
557
+ } else {
558
+ // Default behavior: exclude non-matching registries
559
+ if (!registriesMatch) return false;
560
+ }
561
+ }
562
+
471
563
  // Check withScopePerimeter condition if specified (donut hole scoping)
472
564
  if (this.#init.withScopePerimeter) {
473
- const rootNode = this.#rootNode?.deref();
474
565
  if (!rootNode || !withScopePerimeter(rootNode, element, this.#init.withScopePerimeter)) {
475
566
  return false;
476
567
  }
@@ -493,22 +584,14 @@ export class MountObserver extends EventTarget implements IMountObserver {
493
584
  }
494
585
  }
495
586
 
496
- // Check referenced whereInstanceOf if imports are loaded and reference is specified
497
- if (this.#importsLoaded && this.#init.reference !== undefined) {
498
- const references = arr(this.#init.reference);
499
-
500
- for (const index of references) {
501
- const module = this.#modules[index];
502
- if (module && module.whereInstanceOf !== undefined) {
503
- const constructors = arr(module.whereInstanceOf);
504
-
505
- // Element must be an instance of at least one constructor (OR logic within this module)
506
- const matchesInstanceOf = constructors.some((constructor: Constructor) => element instanceof constructor);
507
-
508
- if (!matchesInstanceOf) {
509
- return false;
510
- }
511
- }
587
+ // Check whereLocalNameMatches condition if specified
588
+ if (this.#init.whereLocalNameMatches) {
589
+ const pattern = typeof this.#init.whereLocalNameMatches === 'string'
590
+ ? new RegExp(this.#init.whereLocalNameMatches)
591
+ : this.#init.whereLocalNameMatches;
592
+
593
+ if (!pattern.test(element.localName)) {
594
+ return false;
512
595
  }
513
596
  }
514
597
 
@@ -541,12 +624,55 @@ export class MountObserver extends EventTarget implements IMountObserver {
541
624
  return;
542
625
  }
543
626
 
544
- const context: MountContext = {
627
+ const context: MountContext<TKeys> = {
545
628
  modules: this.#modules,
546
629
  observer: this,
547
630
  rootNode,
548
- MountConfig: this.#init,
631
+ mountConfig: this.#init,
549
632
  };
633
+
634
+ // Add withObservers if sub-observers exist
635
+ if (this.#subObservers && this.#subObservers.size > 0) {
636
+ context.withObservers = {} as {[K in TKeys]: IMountObserver};
637
+ for (const [key, subObserver] of this.#subObservers.entries()) {
638
+ (context.withObservers as any)[key] = subObserver;
639
+ }
640
+ }
641
+
642
+ // Check shouldMount condition if specified (final gate before mounting)
643
+ if (this.#init.shouldMount) {
644
+ try {
645
+ const shouldMount = this.#init.shouldMount(element, context);
646
+ if (!shouldMount) {
647
+ // shouldMount returned false - don't mount this element
648
+ // Remove from processed set so it can be re-evaluated later
649
+ this.#processedDoForElement.delete(element);
650
+ // Remove from mounted elements tracking
651
+ this.#mountedElements.weakSet.delete(element);
652
+ for (const ref of this.#mountedElements.setWeak) {
653
+ if (ref.deref() === element) {
654
+ this.#mountedElements.setWeak.delete(ref);
655
+ break;
656
+ }
657
+ }
658
+ return;
659
+ }
660
+ } catch (error) {
661
+ // shouldMount threw an error - treat as false and log
662
+ console.error('shouldMount check failed:', error);
663
+ // Remove from processed set so it can be re-evaluated later
664
+ this.#processedDoForElement.delete(element);
665
+ // Remove from mounted elements tracking
666
+ this.#mountedElements.weakSet.delete(element);
667
+ for (const ref of this.#mountedElements.setWeak) {
668
+ if (ref.deref() === element) {
669
+ this.#mountedElements.setWeak.delete(ref);
670
+ break;
671
+ }
672
+ }
673
+ return;
674
+ }
675
+ }
550
676
 
551
677
  // Apply assignGingerly if specified
552
678
  if (this.#asgMtSource) {
@@ -581,18 +707,6 @@ export class MountObserver extends EventTarget implements IMountObserver {
581
707
  }
582
708
  }
583
709
 
584
- // Call referenced do functions from imported modules
585
- if (this.#init.reference !== undefined) {
586
- const references = arr(this.#init.reference);
587
-
588
- for (const index of references) {
589
- const module = this.#modules[index];
590
- if (module && typeof module.do === 'function') {
591
- module.do(element, context);
592
- }
593
- }
594
- }
595
-
596
710
  // Dispatch mount event
597
711
  const mountEvent = new MountEvent(element, this.#modules, this.#init, context);
598
712
  this.dispatchEvent(mountEvent);
@@ -683,12 +797,20 @@ export class MountObserver extends EventTarget implements IMountObserver {
683
797
  return;
684
798
  }
685
799
 
686
- const context: MountContext = {
800
+ const context: MountContext<TKeys> = {
687
801
  modules: this.#modules,
688
802
  observer: this,
689
803
  rootNode,
690
- MountConfig: this.#init,
804
+ mountConfig: this.#init,
691
805
  };
806
+
807
+ // Add withObservers if sub-observers exist
808
+ if (this.#subObservers && this.#subObservers.size > 0) {
809
+ context.withObservers = {} as {[K in TKeys]: IMountObserver};
810
+ for (const [key, subObserver] of this.#subObservers.entries()) {
811
+ (context.withObservers as any)[key] = subObserver;
812
+ }
813
+ }
692
814
 
693
815
 
694
816
  // Dispatch dismount event