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.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,7 @@ export class MountObserver extends EventTarget {
39
40
  #assignTentatively;
40
41
  #elementNotifiers = new WeakMap();
41
42
  #notifierMountedElements = new WeakSet();
43
+ #subObservers;
42
44
  #mergeHandlerDefaults(config) {
43
45
  const doValue = config.do;
44
46
  // Only process if do is a string (single handler reference)
@@ -71,7 +73,7 @@ export class MountObserver extends EventTarget {
71
73
  this.#init = mergedConfig;
72
74
  this.#options = options;
73
75
  this.#abortController = new AbortController();
74
- const { assignOnMount, assignOnDismount, stageOnMount, do: doValue, reference, loadingEagerness, import: imp } = mergedConfig;
76
+ const { assignOnMount, assignOnDismount, stageOnMount, do: doValue, loadingEagerness, import: imp, configFrom } = mergedConfig;
75
77
  // Make a copy of assignOnMount config using structuredClone
76
78
  if (assignOnMount !== undefined) {
77
79
  this.#asgMtSource = structuredClone(assignOnMount);
@@ -91,9 +93,9 @@ export class MountObserver extends EventTarget {
91
93
  if (doValue !== undefined) {
92
94
  this.#validateDoHandlers();
93
95
  }
94
- // Validate reference property if present
95
- if (reference !== undefined) {
96
- this.#validateReference();
96
+ // Load configFrom modules if specified
97
+ if (configFrom !== undefined) {
98
+ this.#configFromPromise = this.#loadConfigFrom();
97
99
  }
98
100
  // Start loading imports if eager
99
101
  if (loadingEagerness === 'eager' && imp) {
@@ -113,28 +115,73 @@ export class MountObserver extends EventTarget {
113
115
  }
114
116
  }
115
117
  }
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})`);
118
+ /**
119
+ * Loads configuration from external modules specified in configFrom property.
120
+ * Merges multiple configs left-to-right, with inline config taking final precedence.
121
+ */
122
+ async #loadConfigFrom() {
123
+ const { configFrom } = this.#init;
124
+ if (!configFrom)
125
+ return;
126
+ // Normalize to array
127
+ const configPaths = Array.isArray(configFrom) ? configFrom : [configFrom];
128
+ // Check for duplicates
129
+ const pathSet = new Set();
130
+ for (const path of configPaths) {
131
+ if (pathSet.has(path)) {
132
+ throw new Error(`Duplicate configFrom module: '${path}'`);
133
+ }
134
+ pathSet.add(path);
135
+ }
136
+ // Load all modules
137
+ const loadedConfigs = [];
138
+ for (const path of configPaths) {
139
+ try {
140
+ const module = await import(path);
141
+ if (!module.mountConfig) {
142
+ throw new Error(`Module '${path}' does not export 'mountConfig'`);
143
+ }
144
+ if (typeof module.mountConfig !== 'object' || module.mountConfig === null) {
145
+ throw new Error(`Module '${path}' exports invalid mountConfig: must be an object`);
146
+ }
147
+ loadedConfigs.push(module.mountConfig);
131
148
  }
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)`);
149
+ catch (error) {
150
+ // Re-throw with better context if it's not already our error
151
+ if (error instanceof Error && !error.message.includes(path)) {
152
+ throw new Error(`Failed to load config from '${path}': ${error.message}`);
153
+ }
154
+ throw error;
136
155
  }
137
156
  }
157
+ // Merge configs: loaded configs first (left-to-right), then inline config
158
+ // Save the original inline config
159
+ const inlineConfig = { ...this.#init };
160
+ // Start with empty object, merge all loaded configs, then merge inline
161
+ let mergedConfig = {};
162
+ for (const loadedConfig of loadedConfigs) {
163
+ mergedConfig = Object.assign(mergedConfig, loadedConfig);
164
+ }
165
+ // Inline config takes final precedence
166
+ mergedConfig = Object.assign(mergedConfig, inlineConfig);
167
+ // Update the init config with merged result
168
+ this.#init = mergedConfig;
169
+ }
170
+ /**
171
+ * Creates and initializes sub-observers from the `with` property.
172
+ * Each sub-observer observes the same root node as the parent.
173
+ * Sub-observers are stored in #subObservers Map for lifecycle management.
174
+ */
175
+ async #createSubObservers(rootNode) {
176
+ const withConfig = this.#init.with;
177
+ if (!withConfig)
178
+ return;
179
+ this.#subObservers = new Map();
180
+ for (const [key, subConfig] of Object.entries(withConfig)) {
181
+ const subObserver = new MountObserver(subConfig);
182
+ this.#subObservers.set(key, subObserver);
183
+ await subObserver.observe(rootNode);
184
+ }
138
185
  }
139
186
  async #setupMediaQuery() {
140
187
  if (!this.#rootNode) {
@@ -175,6 +222,16 @@ export class MountObserver extends EventTarget {
175
222
  get disconnectedSignal() {
176
223
  return this.#abortController.signal;
177
224
  }
225
+ get mountedElements() {
226
+ const elements = [];
227
+ for (const ref of this.#mountedElements.setWeak) {
228
+ const element = ref.deref();
229
+ if (element !== undefined) {
230
+ elements.push(element);
231
+ }
232
+ }
233
+ return elements;
234
+ }
178
235
  getNotifier(element) {
179
236
  // Return cached notifier if it exists
180
237
  let notifier = this.#elementNotifiers.get(element);
@@ -186,10 +243,27 @@ export class MountObserver extends EventTarget {
186
243
  this.#elementNotifiers.set(element, notifier);
187
244
  return notifier;
188
245
  }
189
- async observe(rootNode) {
246
+ /**
247
+ * Begins observing elements within the provided node.
248
+ *
249
+ * @param observedNode - The node to observe for matching elements. This is the root
250
+ * of the observation scope where the mutation observer will be
251
+ * registered. All matching elements within this node (and its
252
+ * descendants) will trigger mount callbacks.
253
+ *
254
+ * Common values:
255
+ * - `document` - Observe the entire document
256
+ * - `element` - Observe a specific subtree
257
+ * - `shadowRoot` - Observe within a shadow DOM
258
+ */
259
+ async observe(observedNode) {
190
260
  if (this.#rootNode) {
191
261
  throw new Error('Already observing');
192
262
  }
263
+ // Wait for configFrom loading to complete if it was started
264
+ if (this.#configFromPromise) {
265
+ await this.#configFromPromise;
266
+ }
193
267
  if (this.#asgMtSource || this.#asgDisMtSource) {
194
268
  await import('assign-gingerly/object-extension.js');
195
269
  }
@@ -197,7 +271,9 @@ export class MountObserver extends EventTarget {
197
271
  const { assignTentatively } = await import('assign-gingerly/assignTentatively.js');
198
272
  this.#assignTentatively = assignTentatively;
199
273
  }
200
- this.#rootNode = new WeakRef(rootNode);
274
+ this.#rootNode = new WeakRef(observedNode);
275
+ // Create sub-observers from `with` property
276
+ await this.#createSubObservers(observedNode);
201
277
  // Set up media query if specified (needs rootNode to be set first)
202
278
  if (this.#init.withMediaMatching) {
203
279
  await this.#setupMediaQuery();
@@ -220,7 +296,7 @@ export class MountObserver extends EventTarget {
220
296
  }
221
297
  // Process existing elements only if all conditions match
222
298
  if (this.#mediaMatches && this.#rootSizeMatches && this.#connectionMatches) {
223
- this.#processNode(rootNode);
299
+ this.#processNode(observedNode);
224
300
  }
225
301
  // Create mutation callback
226
302
  this.#mutationCallback = (mutations) => {
@@ -248,10 +324,18 @@ export class MountObserver extends EventTarget {
248
324
  subtree: true
249
325
  };
250
326
  // Register with shared mutation observer
251
- registerSharedObserver(rootNode, this.#mutationCallback, observerConfig);
327
+ registerSharedObserver(observedNode, this.#mutationCallback, observerConfig);
252
328
  }
253
329
  disconnect() {
254
330
  const rootNode = this.#rootNode?.deref();
331
+ // Disconnect all sub-observers first (recursive)
332
+ if (this.#subObservers) {
333
+ for (const subObserver of this.#subObservers.values()) {
334
+ subObserver.disconnect();
335
+ }
336
+ this.#subObservers.clear();
337
+ this.#subObservers = undefined;
338
+ }
255
339
  // Unregister from shared mutation observer
256
340
  if (rootNode && this.#mutationCallback) {
257
341
  unregisterSharedObserver(rootNode, this.#mutationCallback);
@@ -288,23 +372,6 @@ export class MountObserver extends EventTarget {
288
372
  const { loadImports } = await import('./loadImports.js');
289
373
  this.#modules = await loadImports(this.#init.import);
290
374
  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
375
  this.dispatchEvent(new LoadEvent(this.#modules, this.#init));
309
376
  }
310
377
  #processNode(node) {
@@ -324,7 +391,8 @@ export class MountObserver extends EventTarget {
324
391
  if ('querySelectorAll' in node && this.#init.matching) {
325
392
  const root = node;
326
393
  // Get all elements matching the CSS selector first
327
- root.querySelectorAll(this.#init.matching).forEach(child => {
394
+ const matches = root.querySelectorAll(this.#init.matching);
395
+ matches.forEach(child => {
328
396
  // If intersection observer is active, start observing the element
329
397
  if (this.#intersectionObserver) {
330
398
  this.#intersectionObserver.observe(child);
@@ -345,9 +413,23 @@ export class MountObserver extends EventTarget {
345
413
  if (!matchesElement) {
346
414
  return false;
347
415
  }
416
+ // Check that element's customElementRegistry matches root node's registry
417
+ const rootNode = this.#rootNode?.deref();
418
+ if (rootNode) {
419
+ const registriesMatch = rootNode.customElementRegistry === element.customElementRegistry;
420
+ // If whereDifferentCustomElementRegistry is true, exclude matching registries
421
+ if (this.#init.whereDifferentCustomElementRegistry) {
422
+ if (registriesMatch)
423
+ return false;
424
+ }
425
+ else {
426
+ // Default behavior: exclude non-matching registries
427
+ if (!registriesMatch)
428
+ return false;
429
+ }
430
+ }
348
431
  // Check withScopePerimeter condition if specified (donut hole scoping)
349
432
  if (this.#init.withScopePerimeter) {
350
- const rootNode = this.#rootNode?.deref();
351
433
  if (!rootNode || !withScopePerimeter(rootNode, element, this.#init.withScopePerimeter)) {
352
434
  return false;
353
435
  }
@@ -365,19 +447,13 @@ export class MountObserver extends EventTarget {
365
447
  return false;
366
448
  }
367
449
  }
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
- }
450
+ // Check whereLocalNameMatches condition if specified
451
+ if (this.#init.whereLocalNameMatches) {
452
+ const pattern = typeof this.#init.whereLocalNameMatches === 'string'
453
+ ? new RegExp(this.#init.whereLocalNameMatches)
454
+ : this.#init.whereLocalNameMatches;
455
+ if (!pattern.test(element.localName)) {
456
+ return false;
381
457
  }
382
458
  }
383
459
  // All conditions passed
@@ -406,8 +482,50 @@ export class MountObserver extends EventTarget {
406
482
  modules: this.#modules,
407
483
  observer: this,
408
484
  rootNode,
409
- MountConfig: this.#init,
485
+ mountConfig: this.#init,
410
486
  };
487
+ // Add withObservers if sub-observers exist
488
+ if (this.#subObservers && this.#subObservers.size > 0) {
489
+ context.withObservers = {};
490
+ for (const [key, subObserver] of this.#subObservers.entries()) {
491
+ context.withObservers[key] = subObserver;
492
+ }
493
+ }
494
+ // Check shouldMount condition if specified (final gate before mounting)
495
+ if (this.#init.shouldMount) {
496
+ try {
497
+ const shouldMount = this.#init.shouldMount(element, context);
498
+ if (!shouldMount) {
499
+ // shouldMount returned false - don't mount this element
500
+ // Remove from processed set so it can be re-evaluated later
501
+ this.#processedDoForElement.delete(element);
502
+ // Remove from mounted elements tracking
503
+ this.#mountedElements.weakSet.delete(element);
504
+ for (const ref of this.#mountedElements.setWeak) {
505
+ if (ref.deref() === element) {
506
+ this.#mountedElements.setWeak.delete(ref);
507
+ break;
508
+ }
509
+ }
510
+ return;
511
+ }
512
+ }
513
+ catch (error) {
514
+ // shouldMount threw an error - treat as false and log
515
+ console.error('shouldMount check failed:', error);
516
+ // Remove from processed set so it can be re-evaluated later
517
+ this.#processedDoForElement.delete(element);
518
+ // Remove from mounted elements tracking
519
+ this.#mountedElements.weakSet.delete(element);
520
+ for (const ref of this.#mountedElements.setWeak) {
521
+ if (ref.deref() === element) {
522
+ this.#mountedElements.setWeak.delete(ref);
523
+ break;
524
+ }
525
+ }
526
+ return;
527
+ }
528
+ }
411
529
  // Apply assignGingerly if specified
412
530
  if (this.#asgMtSource) {
413
531
  element.assignGingerly(this.#asgMtSource);
@@ -437,16 +555,6 @@ export class MountObserver extends EventTarget {
437
555
  }
438
556
  }
439
557
  }
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
558
  // Dispatch mount event
451
559
  const mountEvent = new MountEvent(element, this.#modules, this.#init, context);
452
560
  this.dispatchEvent(mountEvent);
@@ -529,8 +637,15 @@ export class MountObserver extends EventTarget {
529
637
  modules: this.#modules,
530
638
  observer: this,
531
639
  rootNode,
532
- MountConfig: this.#init,
640
+ mountConfig: this.#init,
533
641
  };
642
+ // Add withObservers if sub-observers exist
643
+ if (this.#subObservers && this.#subObservers.size > 0) {
644
+ context.withObservers = {};
645
+ for (const [key, subObserver] of this.#subObservers.entries()) {
646
+ context.withObservers[key] = subObserver;
647
+ }
648
+ }
534
649
  // Dispatch dismount event
535
650
  const dismountEvent = new DismountEvent(element, 'with-matching-failed', this.#init);
536
651
  this.dispatchEvent(dismountEvent);