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.js CHANGED
@@ -15,6 +15,7 @@ export class MountObserver extends EventTarget {
15
15
  #options;
16
16
  #abortController;
17
17
  #modules = [];
18
+ #configFromPromise;
18
19
  #mountedElements = {
19
20
  weakSet: new WeakSet(),
20
21
  setWeak: new Set()
@@ -39,6 +40,8 @@ export class MountObserver extends EventTarget {
39
40
  #assignTentatively;
40
41
  #elementNotifiers = new WeakMap();
41
42
  #notifierMountedElements = new WeakSet();
43
+ #subObservers;
44
+ #whenDefinedResolved = false;
42
45
  #mergeHandlerDefaults(config) {
43
46
  const doValue = config.do;
44
47
  // Only process if do is a string (single handler reference)
@@ -71,7 +74,7 @@ export class MountObserver extends EventTarget {
71
74
  this.#init = mergedConfig;
72
75
  this.#options = options;
73
76
  this.#abortController = new AbortController();
74
- const { assignOnMount, assignOnDismount, stageOnMount, do: doValue, reference, loadingEagerness, import: imp } = mergedConfig;
77
+ const { assignOnMount, assignOnDismount, stageOnMount, do: doValue, loadingEagerness, import: imp, configFrom } = mergedConfig;
75
78
  // Make a copy of assignOnMount config using structuredClone
76
79
  if (assignOnMount !== undefined) {
77
80
  this.#asgMtSource = structuredClone(assignOnMount);
@@ -91,9 +94,9 @@ export class MountObserver extends EventTarget {
91
94
  if (doValue !== undefined) {
92
95
  this.#validateDoHandlers();
93
96
  }
94
- // Validate reference property if present
95
- if (reference !== undefined) {
96
- this.#validateReference();
97
+ // Load configFrom modules if specified
98
+ if (configFrom !== undefined) {
99
+ this.#configFromPromise = this.#loadConfigFrom();
97
100
  }
98
101
  // Start loading imports if eager
99
102
  if (loadingEagerness === 'eager' && imp) {
@@ -113,28 +116,91 @@ export class MountObserver extends EventTarget {
113
116
  }
114
117
  }
115
118
  }
116
- #validateReference() {
117
- if (!this.#init.import) {
118
- throw new Error('reference property requires import to be defined');
119
- }
120
- // Normalize import to array
121
- const imports = Array.isArray(this.#init.import)
122
- ? this.#init.import
123
- : [this.#init.import];
124
- // Normalize reference to array
125
- const references = arr(this.#init.reference);
126
- // Validate each reference index
127
- for (const index of references) {
128
- // Check if index is within bounds
129
- if (index < 0 || index >= imports.length) {
130
- throw new Error(`reference index ${index} is out of bounds (import array length: ${imports.length})`);
119
+ /**
120
+ * Loads configuration from external modules specified in configFrom property.
121
+ * Merges multiple configs left-to-right, with inline config taking final precedence.
122
+ */
123
+ async #loadConfigFrom() {
124
+ const { configFrom } = this.#init;
125
+ if (!configFrom)
126
+ return;
127
+ // Normalize to array
128
+ const configPaths = Array.isArray(configFrom) ? configFrom : [configFrom];
129
+ // Check for duplicates
130
+ const pathSet = new Set();
131
+ for (const path of configPaths) {
132
+ if (pathSet.has(path)) {
133
+ throw new Error(`Duplicate configFrom module: '${path}'`);
134
+ }
135
+ pathSet.add(path);
136
+ }
137
+ // Load all modules
138
+ const loadedConfigs = [];
139
+ for (const path of configPaths) {
140
+ try {
141
+ const module = await import(path);
142
+ if (!module.mountConfig) {
143
+ throw new Error(`Module '${path}' does not export 'mountConfig'`);
144
+ }
145
+ if (typeof module.mountConfig !== 'object' || module.mountConfig === null) {
146
+ throw new Error(`Module '${path}' exports invalid mountConfig: must be an object`);
147
+ }
148
+ loadedConfigs.push(module.mountConfig);
131
149
  }
132
- const importItem = imports[index];
133
- // Check if it's a JS module (not a 2D array with type option)
134
- if (Array.isArray(importItem)) {
135
- throw new Error(`reference index ${index} points to a non-JS module import (array with type option)`);
150
+ catch (error) {
151
+ // Re-throw with better context if it's not already our error
152
+ if (error instanceof Error && !error.message.includes(path)) {
153
+ throw new Error(`Failed to load config from '${path}': ${error.message}`);
154
+ }
155
+ throw error;
136
156
  }
137
157
  }
158
+ // Merge configs: loaded configs first (left-to-right), then inline config
159
+ // Save the original inline config
160
+ const inlineConfig = { ...this.#init };
161
+ // Start with empty object, merge all loaded configs, then merge inline
162
+ let mergedConfig = {};
163
+ for (const loadedConfig of loadedConfigs) {
164
+ mergedConfig = Object.assign(mergedConfig, loadedConfig);
165
+ }
166
+ // Inline config takes final precedence
167
+ mergedConfig = Object.assign(mergedConfig, inlineConfig);
168
+ // Update the init config with merged result
169
+ this.#init = mergedConfig;
170
+ }
171
+ /**
172
+ * Waits for custom elements to be defined before mounting.
173
+ * Only runs once per observer instance.
174
+ */
175
+ async #waitForWhenDefined(rootNode) {
176
+ // Skip if already resolved or not configured
177
+ if (this.#whenDefinedResolved || !this.#init.whenDefined) {
178
+ return;
179
+ }
180
+ // Get the custom element registry from the root node
181
+ const registry = rootNode.customElementRegistry || customElements;
182
+ // Normalize to array
183
+ const tagNames = arr(this.#init.whenDefined);
184
+ // Wait for all tags to be defined
185
+ await Promise.all(tagNames.map(tag => registry.whenDefined(tag)));
186
+ // Mark as resolved so we don't check again
187
+ this.#whenDefinedResolved = true;
188
+ }
189
+ /**
190
+ * Creates and initializes sub-observers from the `with` property.
191
+ * Each sub-observer observes the same root node as the parent.
192
+ * Sub-observers are stored in #subObservers Map for lifecycle management.
193
+ */
194
+ async #createSubObservers(rootNode) {
195
+ const withConfig = this.#init.with;
196
+ if (!withConfig)
197
+ return;
198
+ this.#subObservers = new Map();
199
+ for (const [key, subConfig] of Object.entries(withConfig)) {
200
+ const subObserver = new MountObserver(subConfig);
201
+ this.#subObservers.set(key, subObserver);
202
+ await subObserver.observe(rootNode);
203
+ }
138
204
  }
139
205
  async #setupMediaQuery() {
140
206
  if (!this.#rootNode) {
@@ -175,6 +241,16 @@ export class MountObserver extends EventTarget {
175
241
  get disconnectedSignal() {
176
242
  return this.#abortController.signal;
177
243
  }
244
+ get mountedElements() {
245
+ const elements = [];
246
+ for (const ref of this.#mountedElements.setWeak) {
247
+ const element = ref.deref();
248
+ if (element !== undefined) {
249
+ elements.push(element);
250
+ }
251
+ }
252
+ return elements;
253
+ }
178
254
  getNotifier(element) {
179
255
  // Return cached notifier if it exists
180
256
  let notifier = this.#elementNotifiers.get(element);
@@ -186,10 +262,27 @@ export class MountObserver extends EventTarget {
186
262
  this.#elementNotifiers.set(element, notifier);
187
263
  return notifier;
188
264
  }
189
- async observe(rootNode) {
265
+ /**
266
+ * Begins observing elements within the provided node.
267
+ *
268
+ * @param observedNode - The node to observe for matching elements. This is the root
269
+ * of the observation scope where the mutation observer will be
270
+ * registered. All matching elements within this node (and its
271
+ * descendants) will trigger mount callbacks.
272
+ *
273
+ * Common values:
274
+ * - `document` - Observe the entire document
275
+ * - `element` - Observe a specific subtree
276
+ * - `shadowRoot` - Observe within a shadow DOM
277
+ */
278
+ async observe(observedNode) {
190
279
  if (this.#rootNode) {
191
280
  throw new Error('Already observing');
192
281
  }
282
+ // Wait for configFrom loading to complete if it was started
283
+ if (this.#configFromPromise) {
284
+ await this.#configFromPromise;
285
+ }
193
286
  if (this.#asgMtSource || this.#asgDisMtSource) {
194
287
  await import('assign-gingerly/object-extension.js');
195
288
  }
@@ -197,7 +290,11 @@ export class MountObserver extends EventTarget {
197
290
  const { assignTentatively } = await import('assign-gingerly/assignTentatively.js');
198
291
  this.#assignTentatively = assignTentatively;
199
292
  }
200
- this.#rootNode = new WeakRef(rootNode);
293
+ this.#rootNode = new WeakRef(observedNode);
294
+ // Wait for whenDefined if specified (must be first check)
295
+ await this.#waitForWhenDefined(observedNode);
296
+ // Create sub-observers from `with` property
297
+ await this.#createSubObservers(observedNode);
201
298
  // Set up media query if specified (needs rootNode to be set first)
202
299
  if (this.#init.withMediaMatching) {
203
300
  await this.#setupMediaQuery();
@@ -220,7 +317,7 @@ export class MountObserver extends EventTarget {
220
317
  }
221
318
  // Process existing elements only if all conditions match
222
319
  if (this.#mediaMatches && this.#rootSizeMatches && this.#connectionMatches) {
223
- this.#processNode(rootNode);
320
+ this.#processNode(observedNode);
224
321
  }
225
322
  // Create mutation callback
226
323
  this.#mutationCallback = (mutations) => {
@@ -248,10 +345,18 @@ export class MountObserver extends EventTarget {
248
345
  subtree: true
249
346
  };
250
347
  // Register with shared mutation observer
251
- registerSharedObserver(rootNode, this.#mutationCallback, observerConfig);
348
+ registerSharedObserver(observedNode, this.#mutationCallback, observerConfig);
252
349
  }
253
350
  disconnect() {
254
351
  const rootNode = this.#rootNode?.deref();
352
+ // Disconnect all sub-observers first (recursive)
353
+ if (this.#subObservers) {
354
+ for (const subObserver of this.#subObservers.values()) {
355
+ subObserver.disconnect();
356
+ }
357
+ this.#subObservers.clear();
358
+ this.#subObservers = undefined;
359
+ }
255
360
  // Unregister from shared mutation observer
256
361
  if (rootNode && this.#mutationCallback) {
257
362
  unregisterSharedObserver(rootNode, this.#mutationCallback);
@@ -288,23 +393,6 @@ export class MountObserver extends EventTarget {
288
393
  const { loadImports } = await import('./loadImports.js');
289
394
  this.#modules = await loadImports(this.#init.import);
290
395
  this.#importsLoaded = true;
291
- // Validate referenced whereInstanceOf if reference is specified
292
- if (this.#init.reference !== undefined) {
293
- const references = arr(this.#init.reference);
294
- for (const index of references) {
295
- const module = this.#modules[index];
296
- if (module && module.whereInstanceOf !== undefined) {
297
- // Validate that it's a Constructor or array of Constructors
298
- const whereInstanceOf = module.whereInstanceOf;
299
- const constructors = arr(whereInstanceOf);
300
- for (const constructor of constructors) {
301
- if (typeof constructor !== 'function') {
302
- throw new Error(`Referenced module at index ${index} exports invalid whereInstanceOf: must be a Constructor or array of Constructors`);
303
- }
304
- }
305
- }
306
- }
307
- }
308
396
  this.dispatchEvent(new LoadEvent(this.#modules, this.#init));
309
397
  }
310
398
  #processNode(node) {
@@ -324,7 +412,8 @@ export class MountObserver extends EventTarget {
324
412
  if ('querySelectorAll' in node && this.#init.matching) {
325
413
  const root = node;
326
414
  // Get all elements matching the CSS selector first
327
- root.querySelectorAll(this.#init.matching).forEach(child => {
415
+ const matches = root.querySelectorAll(this.#init.matching);
416
+ matches.forEach(child => {
328
417
  // If intersection observer is active, start observing the element
329
418
  if (this.#intersectionObserver) {
330
419
  this.#intersectionObserver.observe(child);
@@ -345,9 +434,23 @@ export class MountObserver extends EventTarget {
345
434
  if (!matchesElement) {
346
435
  return false;
347
436
  }
437
+ // Check that element's customElementRegistry matches root node's registry
438
+ const rootNode = this.#rootNode?.deref();
439
+ if (rootNode) {
440
+ const registriesMatch = rootNode.customElementRegistry === element.customElementRegistry;
441
+ // If whereDifferentCustomElementRegistry is true, exclude matching registries
442
+ if (this.#init.whereDifferentCustomElementRegistry) {
443
+ if (registriesMatch)
444
+ return false;
445
+ }
446
+ else {
447
+ // Default behavior: exclude non-matching registries
448
+ if (!registriesMatch)
449
+ return false;
450
+ }
451
+ }
348
452
  // Check withScopePerimeter condition if specified (donut hole scoping)
349
453
  if (this.#init.withScopePerimeter) {
350
- const rootNode = this.#rootNode?.deref();
351
454
  if (!rootNode || !withScopePerimeter(rootNode, element, this.#init.withScopePerimeter)) {
352
455
  return false;
353
456
  }
@@ -365,19 +468,13 @@ export class MountObserver extends EventTarget {
365
468
  return false;
366
469
  }
367
470
  }
368
- // Check referenced whereInstanceOf if imports are loaded and reference is specified
369
- if (this.#importsLoaded && this.#init.reference !== undefined) {
370
- const references = arr(this.#init.reference);
371
- for (const index of references) {
372
- const module = this.#modules[index];
373
- if (module && module.whereInstanceOf !== undefined) {
374
- const constructors = arr(module.whereInstanceOf);
375
- // Element must be an instance of at least one constructor (OR logic within this module)
376
- const matchesInstanceOf = constructors.some((constructor) => element instanceof constructor);
377
- if (!matchesInstanceOf) {
378
- return false;
379
- }
380
- }
471
+ // Check whereLocalNameMatches condition if specified
472
+ if (this.#init.whereLocalNameMatches) {
473
+ const pattern = typeof this.#init.whereLocalNameMatches === 'string'
474
+ ? new RegExp(this.#init.whereLocalNameMatches)
475
+ : this.#init.whereLocalNameMatches;
476
+ if (!pattern.test(element.localName)) {
477
+ return false;
381
478
  }
382
479
  }
383
480
  // All conditions passed
@@ -406,8 +503,50 @@ export class MountObserver extends EventTarget {
406
503
  modules: this.#modules,
407
504
  observer: this,
408
505
  rootNode,
409
- MountConfig: this.#init,
506
+ mountConfig: this.#init,
410
507
  };
508
+ // Add withObservers if sub-observers exist
509
+ if (this.#subObservers && this.#subObservers.size > 0) {
510
+ context.withObservers = {};
511
+ for (const [key, subObserver] of this.#subObservers.entries()) {
512
+ context.withObservers[key] = subObserver;
513
+ }
514
+ }
515
+ // Check shouldMount condition if specified (final gate before mounting)
516
+ if (this.#init.shouldMount) {
517
+ try {
518
+ const shouldMount = this.#init.shouldMount(element, context);
519
+ if (!shouldMount) {
520
+ // shouldMount returned false - don't mount this element
521
+ // Remove from processed set so it can be re-evaluated later
522
+ this.#processedDoForElement.delete(element);
523
+ // Remove from mounted elements tracking
524
+ this.#mountedElements.weakSet.delete(element);
525
+ for (const ref of this.#mountedElements.setWeak) {
526
+ if (ref.deref() === element) {
527
+ this.#mountedElements.setWeak.delete(ref);
528
+ break;
529
+ }
530
+ }
531
+ return;
532
+ }
533
+ }
534
+ catch (error) {
535
+ // shouldMount threw an error - treat as false and log
536
+ console.error('shouldMount check failed:', error);
537
+ // Remove from processed set so it can be re-evaluated later
538
+ this.#processedDoForElement.delete(element);
539
+ // Remove from mounted elements tracking
540
+ this.#mountedElements.weakSet.delete(element);
541
+ for (const ref of this.#mountedElements.setWeak) {
542
+ if (ref.deref() === element) {
543
+ this.#mountedElements.setWeak.delete(ref);
544
+ break;
545
+ }
546
+ }
547
+ return;
548
+ }
549
+ }
411
550
  // Apply assignGingerly if specified
412
551
  if (this.#asgMtSource) {
413
552
  element.assignGingerly(this.#asgMtSource);
@@ -437,16 +576,6 @@ export class MountObserver extends EventTarget {
437
576
  }
438
577
  }
439
578
  }
440
- // Call referenced do functions from imported modules
441
- if (this.#init.reference !== undefined) {
442
- const references = arr(this.#init.reference);
443
- for (const index of references) {
444
- const module = this.#modules[index];
445
- if (module && typeof module.do === 'function') {
446
- module.do(element, context);
447
- }
448
- }
449
- }
450
579
  // Dispatch mount event
451
580
  const mountEvent = new MountEvent(element, this.#modules, this.#init, context);
452
581
  this.dispatchEvent(mountEvent);
@@ -529,8 +658,15 @@ export class MountObserver extends EventTarget {
529
658
  modules: this.#modules,
530
659
  observer: this,
531
660
  rootNode,
532
- MountConfig: this.#init,
661
+ mountConfig: this.#init,
533
662
  };
663
+ // Add withObservers if sub-observers exist
664
+ if (this.#subObservers && this.#subObservers.size > 0) {
665
+ context.withObservers = {};
666
+ for (const [key, subObserver] of this.#subObservers.entries()) {
667
+ context.withObservers[key] = subObserver;
668
+ }
669
+ }
534
670
  // Dispatch dismount event
535
671
  const dismountEvent = new DismountEvent(element, 'with-matching-failed', this.#init);
536
672
  this.dispatchEvent(dismountEvent);