mount-observer 0.1.12 → 0.1.14

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,8 @@ 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;
68
+ #whenDefinedResolved = false;
65
69
 
66
70
  #mergeHandlerDefaults(config: MountConfig): MountConfig {
67
71
  const doValue = config.do;
@@ -94,7 +98,7 @@ export class MountObserver extends EventTarget implements IMountObserver {
94
98
  return { ...handlerDefaults, ...config };
95
99
  }
96
100
 
97
- constructor(config: MountConfig, options: MountObserverOptions = {}) {
101
+ constructor(config: MountConfig<TKeys>, options: MountObserverOptions = {}) {
98
102
  super();
99
103
 
100
104
  // Merge handler defaults if do is a string reference
@@ -105,8 +109,8 @@ export class MountObserver extends EventTarget implements IMountObserver {
105
109
  this.#abortController = new AbortController();
106
110
 
107
111
  const {
108
- assignOnMount, assignOnDismount, stageOnMount, do: doValue, reference, loadingEagerness,
109
- import: imp
112
+ assignOnMount, assignOnDismount, stageOnMount, do: doValue, loadingEagerness,
113
+ import: imp, configFrom
110
114
  } = mergedConfig;
111
115
  // Make a copy of assignOnMount config using structuredClone
112
116
  if (assignOnMount !== undefined) {
@@ -130,9 +134,9 @@ export class MountObserver extends EventTarget implements IMountObserver {
130
134
  this.#validateDoHandlers();
131
135
  }
132
136
 
133
- // Validate reference property if present
134
- if (reference !== undefined) {
135
- this.#validateReference();
137
+ // Load configFrom modules if specified
138
+ if (configFrom !== undefined) {
139
+ this.#configFromPromise = this.#loadConfigFrom();
136
140
  }
137
141
 
138
142
  // Start loading imports if eager
@@ -155,35 +159,110 @@ export class MountObserver extends EventTarget implements IMountObserver {
155
159
  }
156
160
  }
157
161
  }
158
-
159
- #validateReference(): void {
160
- if (!this.#init.import) {
161
- throw new Error('reference property requires import to be defined');
162
+
163
+ /**
164
+ * Loads configuration from external modules specified in configFrom property.
165
+ * Merges multiple configs left-to-right, with inline config taking final precedence.
166
+ */
167
+ async #loadConfigFrom(): Promise<void> {
168
+ const { configFrom } = this.#init;
169
+ if (!configFrom) return;
170
+
171
+ // Normalize to array
172
+ const configPaths = Array.isArray(configFrom) ? configFrom : [configFrom];
173
+
174
+ // Check for duplicates
175
+ const pathSet = new Set<string>();
176
+ for (const path of configPaths) {
177
+ if (pathSet.has(path)) {
178
+ throw new Error(`Duplicate configFrom module: '${path}'`);
179
+ }
180
+ pathSet.add(path);
162
181
  }
163
182
 
164
- // Normalize import to array
165
- const imports = Array.isArray(this.#init.import)
166
- ? this.#init.import
167
- : [this.#init.import];
183
+ // Load all modules
184
+ const loadedConfigs: MountConfig[] = [];
185
+ for (const path of configPaths) {
186
+ try {
187
+ const module = await import(path);
188
+
189
+ if (!module.mountConfig) {
190
+ throw new Error(`Module '${path}' does not export 'mountConfig'`);
191
+ }
168
192
 
169
- // Normalize reference to array
170
- const references = arr(this.#init.reference);
193
+ if (typeof module.mountConfig !== 'object' || module.mountConfig === null) {
194
+ throw new Error(`Module '${path}' exports invalid mountConfig: must be an object`);
195
+ }
171
196
 
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})`);
197
+ loadedConfigs.push(module.mountConfig);
198
+ } catch (error) {
199
+ // Re-throw with better context if it's not already our error
200
+ if (error instanceof Error && !error.message.includes(path)) {
201
+ throw new Error(`Failed to load config from '${path}': ${error.message}`);
202
+ }
203
+ throw error;
177
204
  }
205
+ }
178
206
 
179
- const importItem = imports[index];
207
+ // Merge configs: loaded configs first (left-to-right), then inline config
208
+ // Save the original inline config
209
+ const inlineConfig = { ...this.#init };
180
210
 
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
- }
211
+ // Start with empty object, merge all loaded configs, then merge inline
212
+ let mergedConfig: MountConfig = {};
213
+ for (const loadedConfig of loadedConfigs) {
214
+ mergedConfig = Object.assign(mergedConfig, loadedConfig);
215
+ }
216
+
217
+ // Inline config takes final precedence
218
+ mergedConfig = Object.assign(mergedConfig, inlineConfig);
219
+
220
+ // Update the init config with merged result
221
+ this.#init = mergedConfig;
222
+ }
223
+
224
+ /**
225
+ * Waits for custom elements to be defined before mounting.
226
+ * Only runs once per observer instance.
227
+ */
228
+ async #waitForWhenDefined(rootNode: Node): Promise<void> {
229
+ // Skip if already resolved or not configured
230
+ if (this.#whenDefinedResolved || !this.#init.whenDefined) {
231
+ return;
185
232
  }
233
+
234
+ // Get the custom element registry from the root node
235
+ const registry = (rootNode as any).customElementRegistry || customElements;
236
+
237
+ // Normalize to array
238
+ const tagNames = arr(this.#init.whenDefined);
239
+
240
+ // Wait for all tags to be defined
241
+ await Promise.all(tagNames.map(tag => registry.whenDefined(tag)));
242
+
243
+ // Mark as resolved so we don't check again
244
+ this.#whenDefinedResolved = true;
186
245
  }
246
+
247
+ /**
248
+ * Creates and initializes sub-observers from the `with` property.
249
+ * Each sub-observer observes the same root node as the parent.
250
+ * Sub-observers are stored in #subObservers Map for lifecycle management.
251
+ */
252
+ async #createSubObservers(rootNode: Node): Promise<void> {
253
+ const withConfig = this.#init.with;
254
+ if (!withConfig) return;
255
+
256
+ this.#subObservers = new Map();
257
+
258
+ for (const [key, subConfig] of Object.entries(withConfig)) {
259
+ const subObserver = new MountObserver(subConfig as MountConfig);
260
+ this.#subObservers.set(key, subObserver);
261
+ await subObserver.observe(rootNode);
262
+ }
263
+ }
264
+
265
+
187
266
 
188
267
 
189
268
  async #setupMediaQuery(): Promise<void> {
@@ -267,6 +346,17 @@ export class MountObserver extends EventTarget implements IMountObserver {
267
346
  return this.#abortController.signal;
268
347
  }
269
348
 
349
+ get mountedElements(): Element[] {
350
+ const elements: Element[] = [];
351
+ for (const ref of this.#mountedElements.setWeak) {
352
+ const element = ref.deref();
353
+ if (element !== undefined) {
354
+ elements.push(element);
355
+ }
356
+ }
357
+ return elements;
358
+ }
359
+
270
360
  getNotifier(element: Element): EventTarget {
271
361
  // Return cached notifier if it exists
272
362
  let notifier = this.#elementNotifiers.get(element);
@@ -280,10 +370,29 @@ export class MountObserver extends EventTarget implements IMountObserver {
280
370
  return notifier;
281
371
  }
282
372
 
283
- async observe(rootNode: Node): Promise<void> {
373
+ /**
374
+ * Begins observing elements within the provided node.
375
+ *
376
+ * @param observedNode - The node to observe for matching elements. This is the root
377
+ * of the observation scope where the mutation observer will be
378
+ * registered. All matching elements within this node (and its
379
+ * descendants) will trigger mount callbacks.
380
+ *
381
+ * Common values:
382
+ * - `document` - Observe the entire document
383
+ * - `element` - Observe a specific subtree
384
+ * - `shadowRoot` - Observe within a shadow DOM
385
+ */
386
+ async observe(observedNode: Node): Promise<void> {
284
387
  if (this.#rootNode) {
285
388
  throw new Error('Already observing');
286
389
  }
390
+
391
+ // Wait for configFrom loading to complete if it was started
392
+ if (this.#configFromPromise) {
393
+ await this.#configFromPromise;
394
+ }
395
+
287
396
  if(this.#asgMtSource || this.#asgDisMtSource){
288
397
  await import('assign-gingerly/object-extension.js');
289
398
  }
@@ -292,7 +401,13 @@ export class MountObserver extends EventTarget implements IMountObserver {
292
401
  this.#assignTentatively = assignTentatively;
293
402
  }
294
403
 
295
- this.#rootNode = new WeakRef(rootNode);
404
+ this.#rootNode = new WeakRef(observedNode);
405
+
406
+ // Wait for whenDefined if specified (must be first check)
407
+ await this.#waitForWhenDefined(observedNode);
408
+
409
+ // Create sub-observers from `with` property
410
+ await this.#createSubObservers(observedNode);
296
411
 
297
412
  // Set up media query if specified (needs rootNode to be set first)
298
413
  if (this.#init.withMediaMatching) {
@@ -321,7 +436,7 @@ export class MountObserver extends EventTarget implements IMountObserver {
321
436
 
322
437
  // Process existing elements only if all conditions match
323
438
  if (this.#mediaMatches && this.#rootSizeMatches && this.#connectionMatches) {
324
- this.#processNode(rootNode);
439
+ this.#processNode(observedNode);
325
440
  }
326
441
 
327
442
  // Create mutation callback
@@ -353,12 +468,21 @@ export class MountObserver extends EventTarget implements IMountObserver {
353
468
  };
354
469
 
355
470
  // Register with shared mutation observer
356
- registerSharedObserver(rootNode, this.#mutationCallback, observerConfig);
471
+ registerSharedObserver(observedNode, this.#mutationCallback, observerConfig);
357
472
  }
358
473
 
359
474
  disconnect(): void {
360
475
  const rootNode = this.#rootNode?.deref();
361
476
 
477
+ // Disconnect all sub-observers first (recursive)
478
+ if (this.#subObservers) {
479
+ for (const subObserver of this.#subObservers.values()) {
480
+ subObserver.disconnect();
481
+ }
482
+ this.#subObservers.clear();
483
+ this.#subObservers = undefined;
484
+ }
485
+
362
486
  // Unregister from shared mutation observer
363
487
  if (rootNode && this.#mutationCallback) {
364
488
  unregisterSharedObserver(rootNode, this.#mutationCallback);
@@ -403,26 +527,6 @@ export class MountObserver extends EventTarget implements IMountObserver {
403
527
  this.#modules = await loadImports(this.#init.import);
404
528
  this.#importsLoaded = true;
405
529
 
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
530
  this.dispatchEvent(new LoadEvent(this.#modules, this.#init));
427
531
  }
428
532
 
@@ -445,7 +549,8 @@ export class MountObserver extends EventTarget implements IMountObserver {
445
549
  const root = node as DocumentFragment;
446
550
 
447
551
  // Get all elements matching the CSS selector first
448
- root.querySelectorAll(this.#init.matching).forEach(child => {
552
+ const matches = root.querySelectorAll(this.#init.matching);
553
+ matches.forEach(child => {
449
554
  // If intersection observer is active, start observing the element
450
555
  if (this.#intersectionObserver) {
451
556
  this.#intersectionObserver.observe(child);
@@ -468,9 +573,22 @@ export class MountObserver extends EventTarget implements IMountObserver {
468
573
  return false;
469
574
  }
470
575
 
576
+ // Check that element's customElementRegistry matches root node's registry
577
+ const rootNode = this.#rootNode?.deref();
578
+ if (rootNode) {
579
+ const registriesMatch = (rootNode as any).customElementRegistry === (element as any).customElementRegistry;
580
+
581
+ // If whereDifferentCustomElementRegistry is true, exclude matching registries
582
+ if (this.#init.whereDifferentCustomElementRegistry) {
583
+ if (registriesMatch) return false;
584
+ } else {
585
+ // Default behavior: exclude non-matching registries
586
+ if (!registriesMatch) return false;
587
+ }
588
+ }
589
+
471
590
  // Check withScopePerimeter condition if specified (donut hole scoping)
472
591
  if (this.#init.withScopePerimeter) {
473
- const rootNode = this.#rootNode?.deref();
474
592
  if (!rootNode || !withScopePerimeter(rootNode, element, this.#init.withScopePerimeter)) {
475
593
  return false;
476
594
  }
@@ -493,22 +611,14 @@ export class MountObserver extends EventTarget implements IMountObserver {
493
611
  }
494
612
  }
495
613
 
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
- }
614
+ // Check whereLocalNameMatches condition if specified
615
+ if (this.#init.whereLocalNameMatches) {
616
+ const pattern = typeof this.#init.whereLocalNameMatches === 'string'
617
+ ? new RegExp(this.#init.whereLocalNameMatches)
618
+ : this.#init.whereLocalNameMatches;
619
+
620
+ if (!pattern.test(element.localName)) {
621
+ return false;
512
622
  }
513
623
  }
514
624
 
@@ -541,12 +651,55 @@ export class MountObserver extends EventTarget implements IMountObserver {
541
651
  return;
542
652
  }
543
653
 
544
- const context: MountContext = {
654
+ const context: MountContext<TKeys> = {
545
655
  modules: this.#modules,
546
656
  observer: this,
547
657
  rootNode,
548
- MountConfig: this.#init,
658
+ mountConfig: this.#init,
549
659
  };
660
+
661
+ // Add withObservers if sub-observers exist
662
+ if (this.#subObservers && this.#subObservers.size > 0) {
663
+ context.withObservers = {} as {[K in TKeys]: IMountObserver};
664
+ for (const [key, subObserver] of this.#subObservers.entries()) {
665
+ (context.withObservers as any)[key] = subObserver;
666
+ }
667
+ }
668
+
669
+ // Check shouldMount condition if specified (final gate before mounting)
670
+ if (this.#init.shouldMount) {
671
+ try {
672
+ const shouldMount = this.#init.shouldMount(element, context);
673
+ if (!shouldMount) {
674
+ // shouldMount returned false - don't mount this element
675
+ // Remove from processed set so it can be re-evaluated later
676
+ this.#processedDoForElement.delete(element);
677
+ // Remove from mounted elements tracking
678
+ this.#mountedElements.weakSet.delete(element);
679
+ for (const ref of this.#mountedElements.setWeak) {
680
+ if (ref.deref() === element) {
681
+ this.#mountedElements.setWeak.delete(ref);
682
+ break;
683
+ }
684
+ }
685
+ return;
686
+ }
687
+ } catch (error) {
688
+ // shouldMount threw an error - treat as false and log
689
+ console.error('shouldMount check failed:', error);
690
+ // Remove from processed set so it can be re-evaluated later
691
+ this.#processedDoForElement.delete(element);
692
+ // Remove from mounted elements tracking
693
+ this.#mountedElements.weakSet.delete(element);
694
+ for (const ref of this.#mountedElements.setWeak) {
695
+ if (ref.deref() === element) {
696
+ this.#mountedElements.setWeak.delete(ref);
697
+ break;
698
+ }
699
+ }
700
+ return;
701
+ }
702
+ }
550
703
 
551
704
  // Apply assignGingerly if specified
552
705
  if (this.#asgMtSource) {
@@ -581,18 +734,6 @@ export class MountObserver extends EventTarget implements IMountObserver {
581
734
  }
582
735
  }
583
736
 
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
737
  // Dispatch mount event
597
738
  const mountEvent = new MountEvent(element, this.#modules, this.#init, context);
598
739
  this.dispatchEvent(mountEvent);
@@ -683,12 +824,20 @@ export class MountObserver extends EventTarget implements IMountObserver {
683
824
  return;
684
825
  }
685
826
 
686
- const context: MountContext = {
827
+ const context: MountContext<TKeys> = {
687
828
  modules: this.#modules,
688
829
  observer: this,
689
830
  rootNode,
690
- MountConfig: this.#init,
831
+ mountConfig: this.#init,
691
832
  };
833
+
834
+ // Add withObservers if sub-observers exist
835
+ if (this.#subObservers && this.#subObservers.size > 0) {
836
+ context.withObservers = {} as {[K in TKeys]: IMountObserver};
837
+ for (const [key, subObserver] of this.#subObservers.entries()) {
838
+ (context.withObservers as any)[key] = subObserver;
839
+ }
840
+ }
692
841
 
693
842
 
694
843
  // Dispatch dismount event