mount-observer 0.1.11 → 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()
@@ -25,7 +26,13 @@ export class MountObserver extends EventTarget {
25
26
  #rootNode;
26
27
  #importsLoaded = false;
27
28
  #mediaQueryCleanup;
29
+ #rootSizeCleanup;
30
+ #intersectionCleanup;
31
+ #connectionCleanup;
32
+ #intersectionObserver;
28
33
  #mediaMatches = true;
34
+ #rootSizeMatches = true;
35
+ #connectionMatches = true;
29
36
  #asgMtSource;
30
37
  #asgDisMtSource;
31
38
  #stageMtSource;
@@ -33,12 +40,40 @@ export class MountObserver extends EventTarget {
33
40
  #assignTentatively;
34
41
  #elementNotifiers = new WeakMap();
35
42
  #notifierMountedElements = new WeakSet();
43
+ #subObservers;
44
+ #mergeHandlerDefaults(config) {
45
+ const doValue = config.do;
46
+ // Only process if do is a string (single handler reference)
47
+ if (typeof doValue !== 'string') {
48
+ return config;
49
+ }
50
+ // Look up the handler class
51
+ const HandlerClass = MountObserver.#handlerRegistry.get(doValue);
52
+ if (!HandlerClass) {
53
+ // Validation will catch this later
54
+ return config;
55
+ }
56
+ // Extract static properties from the handler class
57
+ const handlerDefaults = {};
58
+ const proto = HandlerClass;
59
+ // Get all static properties
60
+ for (const key of Object.getOwnPropertyNames(proto)) {
61
+ if (key !== 'prototype' && key !== 'length' && key !== 'name') {
62
+ handlerDefaults[key] = proto[key];
63
+ }
64
+ }
65
+ // Merge: handler defaults first, then inline config (inline trumps)
66
+ // Using object spread - inline config overwrites handler defaults
67
+ return { ...handlerDefaults, ...config };
68
+ }
36
69
  constructor(config, options = {}) {
37
70
  super();
38
- this.#init = config;
71
+ // Merge handler defaults if do is a string reference
72
+ const mergedConfig = this.#mergeHandlerDefaults(config);
73
+ this.#init = mergedConfig;
39
74
  this.#options = options;
40
75
  this.#abortController = new AbortController();
41
- const { assignOnMount, assignOnDismount, stageOnMount, do: doValue, reference, loadingEagerness, import: imp } = config;
76
+ const { assignOnMount, assignOnDismount, stageOnMount, do: doValue, loadingEagerness, import: imp, configFrom } = mergedConfig;
42
77
  // Make a copy of assignOnMount config using structuredClone
43
78
  if (assignOnMount !== undefined) {
44
79
  this.#asgMtSource = structuredClone(assignOnMount);
@@ -58,9 +93,9 @@ export class MountObserver extends EventTarget {
58
93
  if (doValue !== undefined) {
59
94
  this.#validateDoHandlers();
60
95
  }
61
- // Validate reference property if present
62
- if (reference !== undefined) {
63
- this.#validateReference();
96
+ // Load configFrom modules if specified
97
+ if (configFrom !== undefined) {
98
+ this.#configFromPromise = this.#loadConfigFrom();
64
99
  }
65
100
  // Start loading imports if eager
66
101
  if (loadingEagerness === 'eager' && imp) {
@@ -80,28 +115,73 @@ export class MountObserver extends EventTarget {
80
115
  }
81
116
  }
82
117
  }
83
- #validateReference() {
84
- if (!this.#init.import) {
85
- throw new Error('reference property requires import to be defined');
86
- }
87
- // Normalize import to array
88
- const imports = Array.isArray(this.#init.import)
89
- ? this.#init.import
90
- : [this.#init.import];
91
- // Normalize reference to array
92
- const references = arr(this.#init.reference);
93
- // Validate each reference index
94
- for (const index of references) {
95
- // Check if index is within bounds
96
- if (index < 0 || index >= imports.length) {
97
- 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}'`);
98
133
  }
99
- const importItem = imports[index];
100
- // Check if it's a JS module (not a 2D array with type option)
101
- if (Array.isArray(importItem)) {
102
- throw new Error(`reference index ${index} points to a non-JS module import (array with type option)`);
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);
148
+ }
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;
103
155
  }
104
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
+ }
105
185
  }
106
186
  async #setupMediaQuery() {
107
187
  if (!this.#rootNode) {
@@ -112,9 +192,46 @@ export class MountObserver extends EventTarget {
112
192
  this.#mediaMatches = result.mediaMatches;
113
193
  this.#mediaQueryCleanup = result.cleanup;
114
194
  }
195
+ async #setupRootSizeObserver() {
196
+ if (!this.#rootNode) {
197
+ throw new Error('Cannot setup root size observer before observe() is called');
198
+ }
199
+ const { setupRootSizeObserver } = await import('./rootSizeObserver.js');
200
+ const result = setupRootSizeObserver(this.#init, this.#rootNode, this.#mountedElements, this.#modules, this, (node) => this.#processNode(node));
201
+ this.#rootSizeMatches = result.conditionMatches;
202
+ this.#rootSizeCleanup = result.cleanup;
203
+ }
204
+ async #setupElementIntersection() {
205
+ if (!this.#rootNode) {
206
+ throw new Error('Cannot setup element intersection before observe() is called');
207
+ }
208
+ const { setupElementIntersection } = await import('./elementIntersection.js');
209
+ const result = setupElementIntersection(this.#init, this.#rootNode, this.#mountedElements, this.#modules, this, (element) => this.#matchesSelector(element), (element) => this.#handleMatch(element));
210
+ this.#intersectionObserver = result.intersectionObserver;
211
+ this.#intersectionCleanup = result.cleanup;
212
+ }
213
+ async #setupConnectionMonitor() {
214
+ if (!this.#rootNode) {
215
+ throw new Error('Cannot setup connection monitor before observe() is called');
216
+ }
217
+ const { setupConnectionMonitor } = await import('./connectionMonitor.js');
218
+ const result = setupConnectionMonitor(this.#init, this.#rootNode, this.#mountedElements, this.#modules, this, (node) => this.#processNode(node));
219
+ this.#connectionMatches = result.conditionMatches;
220
+ this.#connectionCleanup = result.cleanup;
221
+ }
115
222
  get disconnectedSignal() {
116
223
  return this.#abortController.signal;
117
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
+ }
118
235
  getNotifier(element) {
119
236
  // Return cached notifier if it exists
120
237
  let notifier = this.#elementNotifiers.get(element);
@@ -126,10 +243,27 @@ export class MountObserver extends EventTarget {
126
243
  this.#elementNotifiers.set(element, notifier);
127
244
  return notifier;
128
245
  }
129
- 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) {
130
260
  if (this.#rootNode) {
131
261
  throw new Error('Already observing');
132
262
  }
263
+ // Wait for configFrom loading to complete if it was started
264
+ if (this.#configFromPromise) {
265
+ await this.#configFromPromise;
266
+ }
133
267
  if (this.#asgMtSource || this.#asgDisMtSource) {
134
268
  await import('assign-gingerly/object-extension.js');
135
269
  }
@@ -137,23 +271,37 @@ export class MountObserver extends EventTarget {
137
271
  const { assignTentatively } = await import('assign-gingerly/assignTentatively.js');
138
272
  this.#assignTentatively = assignTentatively;
139
273
  }
140
- this.#rootNode = new WeakRef(rootNode);
274
+ this.#rootNode = new WeakRef(observedNode);
275
+ // Create sub-observers from `with` property
276
+ await this.#createSubObservers(observedNode);
141
277
  // Set up media query if specified (needs rootNode to be set first)
142
278
  if (this.#init.withMediaMatching) {
143
279
  await this.#setupMediaQuery();
144
280
  }
281
+ // Set up root size observer if specified (needs rootNode to be set first)
282
+ if (this.#init.whereObservedRootSizeMatches) {
283
+ await this.#setupRootSizeObserver();
284
+ }
285
+ // Set up element intersection observer if specified (needs rootNode to be set first)
286
+ if (this.#init.whereElementIntersectsWith) {
287
+ await this.#setupElementIntersection();
288
+ }
289
+ // Set up connection monitor if specified (needs rootNode to be set first)
290
+ if (this.#init.whereConnectionHas) {
291
+ await this.#setupConnectionMonitor();
292
+ }
145
293
  // Wait for eager imports to complete if they were started in constructor
146
294
  if (this.#init.loadingEagerness === 'eager' && this.#init.import && !this.#importsLoaded) {
147
295
  await this.#loadImports();
148
296
  }
149
- // Process existing elements only if media matches
150
- if (this.#mediaMatches) {
151
- this.#processNode(rootNode);
297
+ // Process existing elements only if all conditions match
298
+ if (this.#mediaMatches && this.#rootSizeMatches && this.#connectionMatches) {
299
+ this.#processNode(observedNode);
152
300
  }
153
301
  // Create mutation callback
154
302
  this.#mutationCallback = (mutations) => {
155
- // Skip processing if media doesn't match
156
- if (!this.#mediaMatches) {
303
+ // Skip processing if any condition doesn't match
304
+ if (!this.#mediaMatches || !this.#rootSizeMatches || !this.#connectionMatches) {
157
305
  return;
158
306
  }
159
307
  for (const mutation of mutations) {
@@ -176,10 +324,18 @@ export class MountObserver extends EventTarget {
176
324
  subtree: true
177
325
  };
178
326
  // Register with shared mutation observer
179
- registerSharedObserver(rootNode, this.#mutationCallback, observerConfig);
327
+ registerSharedObserver(observedNode, this.#mutationCallback, observerConfig);
180
328
  }
181
329
  disconnect() {
182
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
+ }
183
339
  // Unregister from shared mutation observer
184
340
  if (rootNode && this.#mutationCallback) {
185
341
  unregisterSharedObserver(rootNode, this.#mutationCallback);
@@ -190,6 +346,21 @@ export class MountObserver extends EventTarget {
190
346
  this.#mediaQueryCleanup();
191
347
  this.#mediaQueryCleanup = undefined;
192
348
  }
349
+ // Remove root size observer
350
+ if (this.#rootSizeCleanup) {
351
+ this.#rootSizeCleanup();
352
+ this.#rootSizeCleanup = undefined;
353
+ }
354
+ // Remove intersection observer
355
+ if (this.#intersectionCleanup) {
356
+ this.#intersectionCleanup();
357
+ this.#intersectionCleanup = undefined;
358
+ }
359
+ // Remove connection monitor
360
+ if (this.#connectionCleanup) {
361
+ this.#connectionCleanup();
362
+ this.#connectionCleanup = undefined;
363
+ }
193
364
  this.#abortController.abort();
194
365
  this.#rootNode = undefined;
195
366
  }
@@ -201,30 +372,18 @@ export class MountObserver extends EventTarget {
201
372
  const { loadImports } = await import('./loadImports.js');
202
373
  this.#modules = await loadImports(this.#init.import);
203
374
  this.#importsLoaded = true;
204
- // Validate referenced withInstance if reference is specified
205
- if (this.#init.reference !== undefined) {
206
- const references = arr(this.#init.reference);
207
- for (const index of references) {
208
- const module = this.#modules[index];
209
- if (module && module.withInstance !== undefined) {
210
- // Validate that it's a Constructor or array of Constructors
211
- const withInstance = module.withInstance;
212
- const constructors = arr(withInstance);
213
- for (const constructor of constructors) {
214
- if (typeof constructor !== 'function') {
215
- throw new Error(`Referenced module at index ${index} exports invalid withInstance: must be a Constructor or array of Constructors`);
216
- }
217
- }
218
- }
219
- }
220
- }
221
375
  this.dispatchEvent(new LoadEvent(this.#modules, this.#init));
222
376
  }
223
377
  #processNode(node) {
224
378
  // If it's an element node, check if it matches
225
379
  if (node.nodeType === Node.ELEMENT_NODE) {
226
380
  const element = node;
227
- if (this.#matchesSelector(element)) {
381
+ // If intersection observer is active, start observing the element
382
+ // The intersection callback will handle mounting when it intersects
383
+ if (this.#intersectionObserver) {
384
+ this.#intersectionObserver.observe(element);
385
+ }
386
+ else if (this.#matchesSelector(element)) {
228
387
  this.#handleMatch(element);
229
388
  }
230
389
  }
@@ -232,8 +391,13 @@ export class MountObserver extends EventTarget {
232
391
  if ('querySelectorAll' in node && this.#init.matching) {
233
392
  const root = node;
234
393
  // Get all elements matching the CSS selector first
235
- root.querySelectorAll(this.#init.matching).forEach(child => {
236
- if (this.#matchesSelector(child)) {
394
+ const matches = root.querySelectorAll(this.#init.matching);
395
+ matches.forEach(child => {
396
+ // If intersection observer is active, start observing the element
397
+ if (this.#intersectionObserver) {
398
+ this.#intersectionObserver.observe(child);
399
+ }
400
+ else if (this.#matchesSelector(child)) {
237
401
  this.#handleMatch(child);
238
402
  }
239
403
  });
@@ -249,35 +413,47 @@ export class MountObserver extends EventTarget {
249
413
  if (!matchesElement) {
250
414
  return false;
251
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
+ }
252
431
  // Check withScopePerimeter condition if specified (donut hole scoping)
253
432
  if (this.#init.withScopePerimeter) {
254
- const rootNode = this.#rootNode?.deref();
255
433
  if (!rootNode || !withScopePerimeter(rootNode, element, this.#init.withScopePerimeter)) {
256
434
  return false;
257
435
  }
258
436
  }
259
- // Check withInstance condition if specified
260
- if (this.#init.withInstance) {
261
- const constructors = arr(this.#init.withInstance);
437
+ // Check whereObservedRootSizeMatches condition if specified
438
+ if (this.#init.whereObservedRootSizeMatches && !this.#rootSizeMatches) {
439
+ return false;
440
+ }
441
+ // Check whereInstanceOf condition if specified
442
+ if (this.#init.whereInstanceOf) {
443
+ const constructors = arr(this.#init.whereInstanceOf);
262
444
  // Element must be an instance of at least one constructor (OR logic for array)
263
445
  const matchesInstanceOf = constructors.some(constructor => element instanceof constructor);
264
446
  if (!matchesInstanceOf) {
265
447
  return false;
266
448
  }
267
449
  }
268
- // Check referenced withInstance if imports are loaded and reference is specified
269
- if (this.#importsLoaded && this.#init.reference !== undefined) {
270
- const references = arr(this.#init.reference);
271
- for (const index of references) {
272
- const module = this.#modules[index];
273
- if (module && module.withInstance !== undefined) {
274
- const constructors = arr(module.withInstance);
275
- // Element must be an instance of at least one constructor (OR logic within this module)
276
- const matchesInstanceOf = constructors.some((constructor) => element instanceof constructor);
277
- if (!matchesInstanceOf) {
278
- return false;
279
- }
280
- }
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;
281
457
  }
282
458
  }
283
459
  // All conditions passed
@@ -306,8 +482,50 @@ export class MountObserver extends EventTarget {
306
482
  modules: this.#modules,
307
483
  observer: this,
308
484
  rootNode,
309
- MountConfig: this.#init,
485
+ mountConfig: this.#init,
310
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
+ }
311
529
  // Apply assignGingerly if specified
312
530
  if (this.#asgMtSource) {
313
531
  element.assignGingerly(this.#asgMtSource);
@@ -337,16 +555,6 @@ export class MountObserver extends EventTarget {
337
555
  }
338
556
  }
339
557
  }
340
- // Call referenced do functions from imported modules
341
- if (this.#init.reference !== undefined) {
342
- const references = arr(this.#init.reference);
343
- for (const index of references) {
344
- const module = this.#modules[index];
345
- if (module && typeof module.do === 'function') {
346
- module.do(element, context);
347
- }
348
- }
349
- }
350
558
  // Dispatch mount event
351
559
  const mountEvent = new MountEvent(element, this.#modules, this.#init, context);
352
560
  this.dispatchEvent(mountEvent);
@@ -429,8 +637,15 @@ export class MountObserver extends EventTarget {
429
637
  modules: this.#modules,
430
638
  observer: this,
431
639
  rootNode,
432
- MountConfig: this.#init,
640
+ mountConfig: this.#init,
433
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
+ }
434
649
  // Dispatch dismount event
435
650
  const dismountEvent = new DismountEvent(element, 'with-matching-failed', this.#init);
436
651
  this.dispatchEvent(dismountEvent);