mount-observer 0.1.0 → 0.1.1

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/Events.js CHANGED
@@ -1,44 +1,78 @@
1
- // Event classes for MountObserver
2
- import { mountEventName, dismountEventName, disconnectEventName, loadEventName, attrchangeEventName } from './constants.js';
1
+ // Event name constants
2
+ export const loadEventName = 'load';
3
+ export const mountEventName = 'mount';
4
+ export const dismountEventName = 'dismount';
5
+ export const disconnectEventName = 'disconnect';
6
+ export const attrchangeEventName = 'attrchange';
7
+ export const mediamatchEventName = 'mediamatch';
8
+ export const mediaunmatchEventName = 'mediaunmatch';
3
9
  export class MountEvent extends Event {
4
10
  matchingElement;
5
11
  modules;
12
+ mountInit;
6
13
  static eventName = mountEventName;
7
- constructor(matchingElement, modules) {
14
+ constructor(matchingElement, modules, mountInit) {
8
15
  super(MountEvent.eventName);
9
16
  this.matchingElement = matchingElement;
10
17
  this.modules = modules;
18
+ this.mountInit = mountInit;
11
19
  }
12
20
  }
13
21
  export class DismountEvent extends Event {
14
22
  matchingElement;
23
+ reason;
24
+ mountInit;
15
25
  static eventName = dismountEventName;
16
- constructor(matchingElement) {
26
+ constructor(matchingElement, reason, mountInit) {
17
27
  super(DismountEvent.eventName);
18
28
  this.matchingElement = matchingElement;
29
+ this.reason = reason;
30
+ this.mountInit = mountInit;
19
31
  }
20
32
  }
21
33
  export class DisconnectEvent extends Event {
22
34
  matchingElement;
35
+ mountInit;
23
36
  static eventName = disconnectEventName;
24
- constructor(matchingElement) {
37
+ constructor(matchingElement, mountInit) {
25
38
  super(DisconnectEvent.eventName);
26
39
  this.matchingElement = matchingElement;
40
+ this.mountInit = mountInit;
27
41
  }
28
42
  }
29
43
  export class LoadEvent extends Event {
30
44
  modules;
45
+ mountInit;
31
46
  static eventName = loadEventName;
32
- constructor(modules) {
47
+ constructor(modules, mountInit) {
33
48
  super(LoadEvent.eventName);
34
49
  this.modules = modules;
50
+ this.mountInit = mountInit;
35
51
  }
36
52
  }
37
53
  export class AttrChangeEvent extends Event {
38
54
  changes;
55
+ mountInit;
39
56
  static eventName = attrchangeEventName;
40
- constructor(changes) {
57
+ constructor(changes, mountInit) {
41
58
  super(AttrChangeEvent.eventName);
42
59
  this.changes = changes;
60
+ this.mountInit = mountInit;
61
+ }
62
+ }
63
+ export class MediaMatchEvent extends Event {
64
+ mountInit;
65
+ static eventName = mediamatchEventName;
66
+ constructor(mountInit) {
67
+ super(MediaMatchEvent.eventName);
68
+ this.mountInit = mountInit;
69
+ }
70
+ }
71
+ export class MediaUnmatchEvent extends Event {
72
+ mountInit;
73
+ static eventName = mediaunmatchEventName;
74
+ constructor(mountInit) {
75
+ super(MediaUnmatchEvent.eventName);
76
+ this.mountInit = mountInit;
43
77
  }
44
78
  }
package/Events.ts CHANGED
@@ -1,17 +1,19 @@
1
1
  // Event classes for MountObserver
2
- import {
3
- mountEventName,
4
- dismountEventName,
5
- disconnectEventName,
6
- loadEventName,
7
- attrchangeEventName
8
- } from './constants.js';
9
- import type { IMountEvent, IDismountEvent, IAttrChangeEvent, AttrChange } from './types.js';
2
+ import type { IMountEvent, IDismountEvent, IAttrChangeEvent, AttrChange, MountInit, DismountReason } from './types.js';
3
+
4
+ // Event name constants
5
+ export const loadEventName = 'load';
6
+ export const mountEventName = 'mount';
7
+ export const dismountEventName = 'dismount';
8
+ export const disconnectEventName = 'disconnect';
9
+ export const attrchangeEventName = 'attrchange';
10
+ export const mediamatchEventName = 'mediamatch';
11
+ export const mediaunmatchEventName = 'mediaunmatch';
10
12
 
11
13
  export class MountEvent extends Event implements IMountEvent {
12
14
  static eventName: typeof mountEventName = mountEventName;
13
15
 
14
- constructor(public matchingElement: Element, public modules: any[]) {
16
+ constructor(public matchingElement: Element, public modules: any[], public mountInit: MountInit) {
15
17
  super(MountEvent.eventName);
16
18
  }
17
19
  }
@@ -19,7 +21,7 @@ export class MountEvent extends Event implements IMountEvent {
19
21
  export class DismountEvent extends Event implements IDismountEvent {
20
22
  static eventName: typeof dismountEventName = dismountEventName;
21
23
 
22
- constructor(public matchingElement: Element) {
24
+ constructor(public matchingElement: Element, public reason: DismountReason, public mountInit: MountInit) {
23
25
  super(DismountEvent.eventName);
24
26
  }
25
27
  }
@@ -27,7 +29,7 @@ export class DismountEvent extends Event implements IDismountEvent {
27
29
  export class DisconnectEvent extends Event {
28
30
  static eventName: typeof disconnectEventName = disconnectEventName;
29
31
 
30
- constructor(public matchingElement: Element) {
32
+ constructor(public matchingElement: Element, public mountInit: MountInit) {
31
33
  super(DisconnectEvent.eventName);
32
34
  }
33
35
  }
@@ -35,7 +37,7 @@ export class DisconnectEvent extends Event {
35
37
  export class LoadEvent extends Event {
36
38
  static eventName: typeof loadEventName = loadEventName;
37
39
 
38
- constructor(public modules: any[]) {
40
+ constructor(public modules: any[], public mountInit: MountInit) {
39
41
  super(LoadEvent.eventName);
40
42
  }
41
43
  }
@@ -43,7 +45,23 @@ export class LoadEvent extends Event {
43
45
  export class AttrChangeEvent extends Event implements IAttrChangeEvent {
44
46
  static eventName: typeof attrchangeEventName = attrchangeEventName;
45
47
 
46
- constructor(public changes: AttrChange[]) {
48
+ constructor(public changes: AttrChange[], public mountInit: MountInit) {
47
49
  super(AttrChangeEvent.eventName);
48
50
  }
49
51
  }
52
+
53
+ export class MediaMatchEvent extends Event {
54
+ static eventName: typeof mediamatchEventName = mediamatchEventName;
55
+
56
+ constructor(public mountInit: MountInit) {
57
+ super(MediaMatchEvent.eventName);
58
+ }
59
+ }
60
+
61
+ export class MediaUnmatchEvent extends Event {
62
+ static eventName: typeof mediaunmatchEventName = mediaunmatchEventName;
63
+
64
+ constructor(public mountInit: MountInit) {
65
+ super(MediaUnmatchEvent.eventName);
66
+ }
67
+ }
package/MountObserver.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { MountEvent, DismountEvent, DisconnectEvent, LoadEvent, AttrChangeEvent } from './Events.js';
2
+ import { registerSharedObserver, unregisterSharedObserver } from './SharedMutationObserver.js';
2
3
  export class MountObserver extends EventTarget {
3
4
  #init;
4
5
  #options;
@@ -6,12 +7,15 @@ export class MountObserver extends EventTarget {
6
7
  #modules = [];
7
8
  #mountedElements = new WeakSet();
8
9
  #processedElements = new WeakSet();
9
- #mutationObserver;
10
+ #mutationCallback;
10
11
  #rootNode;
11
12
  #importsLoaded = false;
12
13
  #elementAttrStates = new WeakMap();
14
+ #elementOnceAttrs = new WeakMap();
13
15
  #matchesWhereAttrFn = null;
14
16
  #buildAttrCoordinateMapFn = null;
17
+ #mediaQueryCleanup;
18
+ #mediaMatches = true;
15
19
  constructor(init, options = {}) {
16
20
  super();
17
21
  this.#init = init;
@@ -41,6 +45,15 @@ export class MountObserver extends EventTarget {
41
45
  this.#buildAttrCoordinateMapFn = buildAttrCoordinateMap;
42
46
  }
43
47
  }
48
+ async #setupMediaQuery() {
49
+ if (!this.#rootNode) {
50
+ throw new Error('Cannot setup media query before observe() is called');
51
+ }
52
+ const { setupMediaQuery } = await import('./mediaQuery.js');
53
+ const result = setupMediaQuery(this.#init, this.#rootNode, this.#mountedElements, this.#modules, this, (node) => this.#processNode(node));
54
+ this.#mediaMatches = result.mediaMatches;
55
+ this.#mediaQueryCleanup = result.cleanup;
56
+ }
44
57
  get disconnectedSignal() {
45
58
  return this.#abortController.signal;
46
59
  }
@@ -49,14 +62,24 @@ export class MountObserver extends EventTarget {
49
62
  throw new Error('Already observing');
50
63
  }
51
64
  this.#rootNode = new WeakRef(rootNode);
65
+ // Set up media query if specified (needs rootNode to be set first)
66
+ if (this.#init.whereMediaMatches) {
67
+ await this.#setupMediaQuery();
68
+ }
52
69
  // Wait for whereAttr utilities to load if needed
53
70
  if (this.#init.whereAttr && !this.#matchesWhereAttrFn) {
54
71
  await this.#preloadWhereAttrUtilities();
55
72
  }
56
- // Process existing elements
57
- this.#processNode(rootNode);
58
- // Set up mutation observer
59
- this.#mutationObserver = new MutationObserver((mutations) => {
73
+ // Process existing elements only if media matches
74
+ if (this.#mediaMatches) {
75
+ this.#processNode(rootNode);
76
+ }
77
+ // Create mutation callback
78
+ this.#mutationCallback = (mutations) => {
79
+ // Skip processing if media doesn't match
80
+ if (!this.#mediaMatches) {
81
+ return;
82
+ }
60
83
  const attrChanges = [];
61
84
  for (const mutation of mutations) {
62
85
  if (mutation.type === 'childList') {
@@ -82,9 +105,9 @@ export class MountObserver extends EventTarget {
82
105
  }
83
106
  // Batch and dispatch attribute changes
84
107
  if (attrChanges.length > 0) {
85
- this.dispatchEvent(new AttrChangeEvent(attrChanges));
108
+ this.dispatchEvent(new AttrChangeEvent(attrChanges, this.#init));
86
109
  }
87
- });
110
+ };
88
111
  const observerConfig = {
89
112
  childList: true,
90
113
  subtree: true
@@ -94,12 +117,20 @@ export class MountObserver extends EventTarget {
94
117
  observerConfig.attributes = true;
95
118
  observerConfig.attributeOldValue = true;
96
119
  }
97
- this.#mutationObserver.observe(rootNode, observerConfig);
120
+ // Register with shared mutation observer
121
+ registerSharedObserver(rootNode, this.#mutationCallback, observerConfig);
98
122
  }
99
123
  disconnect() {
100
- if (this.#mutationObserver) {
101
- this.#mutationObserver.disconnect();
102
- this.#mutationObserver = undefined;
124
+ const rootNode = this.#rootNode?.deref();
125
+ // Unregister from shared mutation observer
126
+ if (rootNode && this.#mutationCallback) {
127
+ unregisterSharedObserver(rootNode, this.#mutationCallback);
128
+ this.#mutationCallback = undefined;
129
+ }
130
+ // Remove media query listener
131
+ if (this.#mediaQueryCleanup) {
132
+ this.#mediaQueryCleanup();
133
+ this.#mediaQueryCleanup = undefined;
103
134
  }
104
135
  this.#abortController.abort();
105
136
  this.#rootNode = undefined;
@@ -112,7 +143,7 @@ export class MountObserver extends EventTarget {
112
143
  const { loadImports } = await import('./loadImports.js');
113
144
  this.#modules = await loadImports(this.#init.import);
114
145
  this.#importsLoaded = true;
115
- this.dispatchEvent(new LoadEvent(this.#modules));
146
+ this.dispatchEvent(new LoadEvent(this.#modules, this.#init));
116
147
  }
117
148
  #processNode(node) {
118
149
  // If it's an element node, check if it matches
@@ -125,38 +156,45 @@ export class MountObserver extends EventTarget {
125
156
  // Process children
126
157
  if (node.nodeType === Node.ELEMENT_NODE || node.nodeType === Node.DOCUMENT_NODE) {
127
158
  const root = node;
128
- // If whereAttr is specified, we need to check all elements
129
- // since we can't use querySelectorAll for complex attribute matching
130
- if (this.#init.whereAttr) {
131
- // Get all elements matching the CSS selector first
132
- root.querySelectorAll(this.#init.whereElementMatches).forEach(child => {
133
- if (this.#matchesSelector(child)) {
134
- this.#handleMatch(child);
135
- }
136
- });
137
- }
138
- else {
139
- // Optimize: use querySelectorAll directly when no whereAttr
140
- root.querySelectorAll(this.#init.whereElementMatches).forEach(child => {
159
+ // Get all elements matching the CSS selector first
160
+ root.querySelectorAll(this.#init.whereElementMatches).forEach(child => {
161
+ if (this.#matchesSelector(child)) {
141
162
  this.#handleMatch(child);
142
- });
143
- }
163
+ }
164
+ });
144
165
  }
145
166
  }
146
167
  #matchesSelector(element) {
168
+ //TODO: reduce redundncy with this.#init?
147
169
  // Check whereElementMatches condition
148
170
  const matchesElement = element.matches(this.#init.whereElementMatches);
149
- // If whereAttr is not specified, only check whereElementMatches
150
- if (!this.#init.whereAttr) {
151
- return matchesElement;
152
- }
153
- // Use cached function (should be loaded by now from constructor)
154
- if (!this.#matchesWhereAttrFn) {
155
- console.warn('whereAttr utilities not loaded yet');
171
+ if (!matchesElement) {
156
172
  return false;
157
173
  }
158
- // Both conditions must be true (AND logic)
159
- return matchesElement && this.#matchesWhereAttrFn(element, this.#init.whereAttr);
174
+ // Check whereAttr condition if specified
175
+ if (this.#init.whereAttr) {
176
+ // Use cached function (should be loaded by now from constructor)
177
+ if (!this.#matchesWhereAttrFn) {
178
+ console.warn('whereAttr utilities not loaded yet');
179
+ return false;
180
+ }
181
+ if (!this.#matchesWhereAttrFn(element, this.#init.whereAttr)) {
182
+ return false;
183
+ }
184
+ }
185
+ // Check whereInstanceOf condition if specified
186
+ if (this.#init.whereInstanceOf) {
187
+ const constructors = Array.isArray(this.#init.whereInstanceOf)
188
+ ? this.#init.whereInstanceOf
189
+ : [this.#init.whereInstanceOf];
190
+ // Element must be an instance of at least one constructor (OR logic for array)
191
+ const matchesInstanceOf = constructors.some(constructor => element instanceof constructor);
192
+ if (!matchesInstanceOf) {
193
+ return false;
194
+ }
195
+ }
196
+ // All conditions passed
197
+ return true;
160
198
  }
161
199
  async #handleMatch(element) {
162
200
  if (this.#processedElements.has(element)) {
@@ -195,12 +233,12 @@ export class MountObserver extends EventTarget {
195
233
  }
196
234
  }
197
235
  // Dispatch mount event
198
- this.dispatchEvent(new MountEvent(element, this.#modules));
236
+ this.dispatchEvent(new MountEvent(element, this.#modules, this.#init));
199
237
  // Check for initial attribute changes if whereAttr is configured
200
238
  if (this.#init.whereAttr) {
201
239
  const changes = this.#checkAttrChanges(element);
202
240
  if (changes.length > 0) {
203
- this.dispatchEvent(new AttrChangeEvent(changes));
241
+ this.dispatchEvent(new AttrChangeEvent(changes, this.#init));
204
242
  }
205
243
  }
206
244
  }
@@ -226,12 +264,30 @@ export class MountObserver extends EventTarget {
226
264
  if (currentValue !== null) {
227
265
  currentAttrs.add(attrName);
228
266
  }
267
+ // Check if this attribute has "once: true" in its map entry
268
+ const mapEntry = this.#init.map?.[coordinate] || null;
269
+ const isOnce = mapEntry?.once === true;
270
+ // If "once" is true, check if we've already seen this attribute
271
+ if (isOnce) {
272
+ let onceAttrs = this.#elementOnceAttrs.get(element);
273
+ if (!onceAttrs) {
274
+ onceAttrs = new Set();
275
+ this.#elementOnceAttrs.set(element, onceAttrs);
276
+ }
277
+ // If we've already seen this attribute, skip it
278
+ if (onceAttrs.has(attrName)) {
279
+ continue;
280
+ }
281
+ // Mark this attribute as seen if it currently has a value
282
+ if (currentValue !== null) {
283
+ onceAttrs.add(attrName);
284
+ }
285
+ }
229
286
  // Include if: currently has value OR previously had value but now removed
230
287
  if (currentValue !== null || (previousValue !== undefined && currentValue === null)) {
231
288
  // Check if value changed
232
289
  if (currentValue !== previousValue) {
233
290
  const attrNode = currentValue !== null ? element.getAttributeNode(attrName) : null;
234
- const mapEntry = this.#init.map?.[coordinate] || null;
235
291
  changes.push({
236
292
  value: currentValue,
237
293
  attrNode,
@@ -274,7 +330,7 @@ export class MountObserver extends EventTarget {
274
330
  this.#init.do.dismount(element, context);
275
331
  }
276
332
  // Dispatch dismount event
277
- this.dispatchEvent(new DismountEvent(element));
333
+ this.dispatchEvent(new DismountEvent(element, 'where-element-matches-failed', this.#init));
278
334
  // Check if element is being moved within the same root
279
335
  // If it's truly disconnected, dispatch disconnect event
280
336
  setTimeout(() => {
@@ -282,7 +338,7 @@ export class MountObserver extends EventTarget {
282
338
  if (this.#init.do && typeof this.#init.do !== 'function' && this.#init.do.disconnect) {
283
339
  this.#init.do.disconnect(element, context);
284
340
  }
285
- this.dispatchEvent(new DisconnectEvent(element));
341
+ this.dispatchEvent(new DisconnectEvent(element, this.#init));
286
342
  }
287
343
  }, 0);
288
344
  }
package/MountObserver.ts CHANGED
@@ -10,8 +10,15 @@ import {
10
10
  DismountEvent,
11
11
  DisconnectEvent,
12
12
  LoadEvent,
13
- AttrChangeEvent
13
+ AttrChangeEvent,
14
+ MediaMatchEvent,
15
+ MediaUnmatchEvent
14
16
  } from './Events.js';
17
+ import {
18
+ registerSharedObserver,
19
+ unregisterSharedObserver,
20
+ type MutationCallback
21
+ } from './SharedMutationObserver.js';
15
22
 
16
23
  export class MountObserver extends EventTarget implements IMountObserver {
17
24
  #init: MountInit;
@@ -20,12 +27,15 @@ export class MountObserver extends EventTarget implements IMountObserver {
20
27
  #modules: any[] = [];
21
28
  #mountedElements = new WeakSet<Element>();
22
29
  #processedElements = new WeakSet<Element>();
23
- #mutationObserver: MutationObserver | undefined;
30
+ #mutationCallback: MutationCallback | undefined;
24
31
  #rootNode: WeakRef<Node> | undefined;
25
32
  #importsLoaded = false;
26
33
  #elementAttrStates = new WeakMap<Element, Map<string, string | null>>();
34
+ #elementOnceAttrs = new WeakMap<Element, Set<string>>();
27
35
  #matchesWhereAttrFn: ((element: Element, whereAttr: any) => boolean) | null = null;
28
36
  #buildAttrCoordinateMapFn: ((whereAttr: any, isCustomElement: boolean) => any) | null = null;
37
+ #mediaQueryCleanup?: () => void;
38
+ #mediaMatches: boolean = true;
29
39
 
30
40
  constructor(init: MountInit, options: MountObserverOptions = {}) {
31
41
  super();
@@ -60,6 +70,25 @@ export class MountObserver extends EventTarget implements IMountObserver {
60
70
  this.#buildAttrCoordinateMapFn = buildAttrCoordinateMap;
61
71
  }
62
72
  }
73
+
74
+ async #setupMediaQuery(): Promise<void> {
75
+ if (!this.#rootNode) {
76
+ throw new Error('Cannot setup media query before observe() is called');
77
+ }
78
+
79
+ const { setupMediaQuery } = await import('./mediaQuery.js');
80
+ const result = setupMediaQuery(
81
+ this.#init,
82
+ this.#rootNode,
83
+ this.#mountedElements,
84
+ this.#modules,
85
+ this,
86
+ (node) => this.#processNode(node)
87
+ );
88
+
89
+ this.#mediaMatches = result.mediaMatches;
90
+ this.#mediaQueryCleanup = result.cleanup;
91
+ }
63
92
 
64
93
  get disconnectedSignal(): AbortSignal {
65
94
  return this.#abortController.signal;
@@ -72,16 +101,28 @@ export class MountObserver extends EventTarget implements IMountObserver {
72
101
 
73
102
  this.#rootNode = new WeakRef(rootNode);
74
103
 
104
+ // Set up media query if specified (needs rootNode to be set first)
105
+ if (this.#init.whereMediaMatches) {
106
+ await this.#setupMediaQuery();
107
+ }
108
+
75
109
  // Wait for whereAttr utilities to load if needed
76
110
  if (this.#init.whereAttr && !this.#matchesWhereAttrFn) {
77
111
  await this.#preloadWhereAttrUtilities();
78
112
  }
79
113
 
80
- // Process existing elements
81
- this.#processNode(rootNode);
114
+ // Process existing elements only if media matches
115
+ if (this.#mediaMatches) {
116
+ this.#processNode(rootNode);
117
+ }
82
118
 
83
- // Set up mutation observer
84
- this.#mutationObserver = new MutationObserver((mutations) => {
119
+ // Create mutation callback
120
+ this.#mutationCallback = (mutations) => {
121
+ // Skip processing if media doesn't match
122
+ if (!this.#mediaMatches) {
123
+ return;
124
+ }
125
+
85
126
  const attrChanges: AttrChange[] = [];
86
127
 
87
128
  for (const mutation of mutations) {
@@ -108,9 +149,9 @@ export class MountObserver extends EventTarget implements IMountObserver {
108
149
 
109
150
  // Batch and dispatch attribute changes
110
151
  if (attrChanges.length > 0) {
111
- this.dispatchEvent(new AttrChangeEvent(attrChanges));
152
+ this.dispatchEvent(new AttrChangeEvent(attrChanges, this.#init));
112
153
  }
113
- });
154
+ };
114
155
 
115
156
  const observerConfig: MutationObserverInit = {
116
157
  childList: true,
@@ -123,14 +164,25 @@ export class MountObserver extends EventTarget implements IMountObserver {
123
164
  observerConfig.attributeOldValue = true;
124
165
  }
125
166
 
126
- this.#mutationObserver.observe(rootNode, observerConfig);
167
+ // Register with shared mutation observer
168
+ registerSharedObserver(rootNode, this.#mutationCallback, observerConfig);
127
169
  }
128
170
 
129
171
  disconnect(): void {
130
- if (this.#mutationObserver) {
131
- this.#mutationObserver.disconnect();
132
- this.#mutationObserver = undefined;
172
+ const rootNode = this.#rootNode?.deref();
173
+
174
+ // Unregister from shared mutation observer
175
+ if (rootNode && this.#mutationCallback) {
176
+ unregisterSharedObserver(rootNode, this.#mutationCallback);
177
+ this.#mutationCallback = undefined;
133
178
  }
179
+
180
+ // Remove media query listener
181
+ if (this.#mediaQueryCleanup) {
182
+ this.#mediaQueryCleanup();
183
+ this.#mediaQueryCleanup = undefined;
184
+ }
185
+
134
186
  this.#abortController.abort();
135
187
  this.#rootNode = undefined;
136
188
  }
@@ -145,7 +197,7 @@ export class MountObserver extends EventTarget implements IMountObserver {
145
197
  this.#modules = await loadImports(this.#init.import);
146
198
  this.#importsLoaded = true;
147
199
 
148
- this.dispatchEvent(new LoadEvent(this.#modules));
200
+ this.dispatchEvent(new LoadEvent(this.#modules, this.#init));
149
201
  }
150
202
 
151
203
  #processNode(node: Node): void {
@@ -162,41 +214,52 @@ export class MountObserver extends EventTarget implements IMountObserver {
162
214
  if (node.nodeType === Node.ELEMENT_NODE || node.nodeType === Node.DOCUMENT_NODE) {
163
215
  const root = node as Element | Document;
164
216
 
165
- // If whereAttr is specified, we need to check all elements
166
- // since we can't use querySelectorAll for complex attribute matching
167
- if (this.#init.whereAttr) {
168
- // Get all elements matching the CSS selector first
169
- root.querySelectorAll(this.#init.whereElementMatches).forEach(child => {
170
- if (this.#matchesSelector(child)) {
171
- this.#handleMatch(child);
172
- }
173
- });
174
- } else {
175
- // Optimize: use querySelectorAll directly when no whereAttr
176
- root.querySelectorAll(this.#init.whereElementMatches).forEach(child => {
217
+ // Get all elements matching the CSS selector first
218
+ root.querySelectorAll(this.#init.whereElementMatches).forEach(child => {
219
+ if (this.#matchesSelector(child)) {
177
220
  this.#handleMatch(child);
178
- });
179
- }
221
+ }
222
+ });
180
223
  }
181
224
  }
182
225
 
183
226
  #matchesSelector(element: Element): boolean {
227
+ //TODO: reduce redundncy with this.#init?
184
228
  // Check whereElementMatches condition
185
229
  const matchesElement = element.matches(this.#init.whereElementMatches);
230
+ if (!matchesElement) {
231
+ return false;
232
+ }
186
233
 
187
- // If whereAttr is not specified, only check whereElementMatches
188
- if (!this.#init.whereAttr) {
189
- return matchesElement;
234
+ // Check whereAttr condition if specified
235
+ if (this.#init.whereAttr) {
236
+ // Use cached function (should be loaded by now from constructor)
237
+ if (!this.#matchesWhereAttrFn) {
238
+ console.warn('whereAttr utilities not loaded yet');
239
+ return false;
240
+ }
241
+
242
+ if (!this.#matchesWhereAttrFn(element, this.#init.whereAttr)) {
243
+ return false;
244
+ }
190
245
  }
191
246
 
192
- // Use cached function (should be loaded by now from constructor)
193
- if (!this.#matchesWhereAttrFn) {
194
- console.warn('whereAttr utilities not loaded yet');
195
- return false;
247
+ // Check whereInstanceOf condition if specified
248
+ if (this.#init.whereInstanceOf) {
249
+ const constructors = Array.isArray(this.#init.whereInstanceOf)
250
+ ? this.#init.whereInstanceOf
251
+ : [this.#init.whereInstanceOf];
252
+
253
+ // Element must be an instance of at least one constructor (OR logic for array)
254
+ const matchesInstanceOf = constructors.some(constructor => element instanceof constructor);
255
+
256
+ if (!matchesInstanceOf) {
257
+ return false;
258
+ }
196
259
  }
197
260
 
198
- // Both conditions must be true (AND logic)
199
- return matchesElement && this.#matchesWhereAttrFn(element, this.#init.whereAttr);
261
+ // All conditions passed
262
+ return true;
200
263
  }
201
264
 
202
265
  async #handleMatch(element: Element): Promise<void> {
@@ -242,13 +305,13 @@ export class MountObserver extends EventTarget implements IMountObserver {
242
305
  }
243
306
 
244
307
  // Dispatch mount event
245
- this.dispatchEvent(new MountEvent(element, this.#modules));
308
+ this.dispatchEvent(new MountEvent(element, this.#modules, this.#init));
246
309
 
247
310
  // Check for initial attribute changes if whereAttr is configured
248
311
  if (this.#init.whereAttr) {
249
312
  const changes = this.#checkAttrChanges(element);
250
313
  if (changes.length > 0) {
251
- this.dispatchEvent(new AttrChangeEvent(changes));
314
+ this.dispatchEvent(new AttrChangeEvent(changes, this.#init));
252
315
  }
253
316
  }
254
317
  }
@@ -281,12 +344,34 @@ export class MountObserver extends EventTarget implements IMountObserver {
281
344
  currentAttrs.add(attrName);
282
345
  }
283
346
 
347
+ // Check if this attribute has "once: true" in its map entry
348
+ const mapEntry = this.#init.map?.[coordinate] || null;
349
+ const isOnce = mapEntry?.once === true;
350
+
351
+ // If "once" is true, check if we've already seen this attribute
352
+ if (isOnce) {
353
+ let onceAttrs = this.#elementOnceAttrs.get(element);
354
+ if (!onceAttrs) {
355
+ onceAttrs = new Set<string>();
356
+ this.#elementOnceAttrs.set(element, onceAttrs);
357
+ }
358
+
359
+ // If we've already seen this attribute, skip it
360
+ if (onceAttrs.has(attrName)) {
361
+ continue;
362
+ }
363
+
364
+ // Mark this attribute as seen if it currently has a value
365
+ if (currentValue !== null) {
366
+ onceAttrs.add(attrName);
367
+ }
368
+ }
369
+
284
370
  // Include if: currently has value OR previously had value but now removed
285
371
  if (currentValue !== null || (previousValue !== undefined && currentValue === null)) {
286
372
  // Check if value changed
287
373
  if (currentValue !== previousValue) {
288
374
  const attrNode = currentValue !== null ? element.getAttributeNode(attrName) : null;
289
- const mapEntry = this.#init.map?.[coordinate] || null;
290
375
 
291
376
  changes.push({
292
377
  value: currentValue,
@@ -337,7 +422,7 @@ export class MountObserver extends EventTarget implements IMountObserver {
337
422
  }
338
423
 
339
424
  // Dispatch dismount event
340
- this.dispatchEvent(new DismountEvent(element));
425
+ this.dispatchEvent(new DismountEvent(element, 'where-element-matches-failed', this.#init));
341
426
 
342
427
  // Check if element is being moved within the same root
343
428
  // If it's truly disconnected, dispatch disconnect event
@@ -347,7 +432,7 @@ export class MountObserver extends EventTarget implements IMountObserver {
347
432
  this.#init.do.disconnect(element, context);
348
433
  }
349
434
 
350
- this.dispatchEvent(new DisconnectEvent(element));
435
+ this.dispatchEvent(new DisconnectEvent(element, this.#init));
351
436
  }
352
437
  }, 0);
353
438
  }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Manages shared MutationObserver instances for multiple MountObserver instances
3
+ * observing the same root node. This reduces overhead when multiple observers
4
+ * are watching the same DOM fragment.
5
+ */
6
+ /**
7
+ * Global registry of shared observers, keyed by root node
8
+ */
9
+ const sharedObservers = new WeakMap();
10
+ /**
11
+ * Registers a callback with the shared MutationObserver for the given root node.
12
+ * Creates a new shared observer if one doesn't exist for this root.
13
+ */
14
+ export function registerSharedObserver(rootNode, callback, config) {
15
+ let sharedData = sharedObservers.get(rootNode);
16
+ if (!sharedData) {
17
+ // Create new shared observer for this root node
18
+ const observer = new MutationObserver((mutations) => {
19
+ // Distribute mutations to all registered callbacks
20
+ const callbacks = sharedData.callbacks;
21
+ for (const cb of callbacks) {
22
+ cb(mutations);
23
+ }
24
+ });
25
+ observer.observe(rootNode, config);
26
+ sharedData = {
27
+ observer,
28
+ callbacks: new Set(),
29
+ config
30
+ };
31
+ sharedObservers.set(rootNode, sharedData);
32
+ }
33
+ else {
34
+ // Verify config matches (for safety)
35
+ // In practice, all MountObservers should use the same config
36
+ if (!configsMatch(sharedData.config, config)) {
37
+ console.warn('MutationObserver config mismatch detected. Using existing config.');
38
+ }
39
+ }
40
+ // Register the callback
41
+ sharedData.callbacks.add(callback);
42
+ }
43
+ /**
44
+ * Unregisters a callback from the shared MutationObserver.
45
+ * If this was the last callback, disconnects and removes the shared observer.
46
+ */
47
+ export function unregisterSharedObserver(rootNode, callback) {
48
+ const sharedData = sharedObservers.get(rootNode);
49
+ if (!sharedData) {
50
+ return;
51
+ }
52
+ // Remove the callback
53
+ sharedData.callbacks.delete(callback);
54
+ // If no more callbacks, disconnect and cleanup
55
+ if (sharedData.callbacks.size === 0) {
56
+ sharedData.observer.disconnect();
57
+ sharedObservers.delete(rootNode);
58
+ }
59
+ }
60
+ /**
61
+ * Checks if two MutationObserverInit configs are equivalent
62
+ */
63
+ function configsMatch(a, b) {
64
+ return a.childList === b.childList &&
65
+ a.subtree === b.subtree &&
66
+ a.attributes === b.attributes &&
67
+ a.attributeOldValue === b.attributeOldValue &&
68
+ a.characterData === b.characterData &&
69
+ a.characterDataOldValue === b.characterDataOldValue;
70
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Manages shared MutationObserver instances for multiple MountObserver instances
3
+ * observing the same root node. This reduces overhead when multiple observers
4
+ * are watching the same DOM fragment.
5
+ */
6
+
7
+ export type MutationCallback = (mutations: MutationRecord[]) => void;
8
+
9
+ interface SharedObserverData {
10
+ observer: MutationObserver;
11
+ callbacks: Set<MutationCallback>;
12
+ config: MutationObserverInit;
13
+ }
14
+
15
+ /**
16
+ * Global registry of shared observers, keyed by root node
17
+ */
18
+ const sharedObservers = new WeakMap<Node, SharedObserverData>();
19
+
20
+ /**
21
+ * Registers a callback with the shared MutationObserver for the given root node.
22
+ * Creates a new shared observer if one doesn't exist for this root.
23
+ */
24
+ export function registerSharedObserver(
25
+ rootNode: Node,
26
+ callback: MutationCallback,
27
+ config: MutationObserverInit
28
+ ): void {
29
+ let sharedData = sharedObservers.get(rootNode);
30
+
31
+ if (!sharedData) {
32
+ // Create new shared observer for this root node
33
+ const observer = new MutationObserver((mutations) => {
34
+ // Distribute mutations to all registered callbacks
35
+ const callbacks = sharedData!.callbacks;
36
+ for (const cb of callbacks) {
37
+ cb(mutations);
38
+ }
39
+ });
40
+
41
+ observer.observe(rootNode, config);
42
+
43
+ sharedData = {
44
+ observer,
45
+ callbacks: new Set(),
46
+ config
47
+ };
48
+
49
+ sharedObservers.set(rootNode, sharedData);
50
+ } else {
51
+ // Verify config matches (for safety)
52
+ // In practice, all MountObservers should use the same config
53
+ if (!configsMatch(sharedData.config, config)) {
54
+ console.warn('MutationObserver config mismatch detected. Using existing config.');
55
+ }
56
+ }
57
+
58
+ // Register the callback
59
+ sharedData.callbacks.add(callback);
60
+ }
61
+
62
+ /**
63
+ * Unregisters a callback from the shared MutationObserver.
64
+ * If this was the last callback, disconnects and removes the shared observer.
65
+ */
66
+ export function unregisterSharedObserver(
67
+ rootNode: Node,
68
+ callback: MutationCallback
69
+ ): void {
70
+ const sharedData = sharedObservers.get(rootNode);
71
+
72
+ if (!sharedData) {
73
+ return;
74
+ }
75
+
76
+ // Remove the callback
77
+ sharedData.callbacks.delete(callback);
78
+
79
+ // If no more callbacks, disconnect and cleanup
80
+ if (sharedData.callbacks.size === 0) {
81
+ sharedData.observer.disconnect();
82
+ sharedObservers.delete(rootNode);
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Checks if two MutationObserverInit configs are equivalent
88
+ */
89
+ function configsMatch(a: MutationObserverInit, b: MutationObserverInit): boolean {
90
+ return a.childList === b.childList &&
91
+ a.subtree === b.subtree &&
92
+ a.attributes === b.attributes &&
93
+ a.attributeOldValue === b.attributeOldValue &&
94
+ a.characterData === b.characterData &&
95
+ a.characterDataOldValue === b.characterDataOldValue;
96
+ }
package/index.ts CHANGED
@@ -15,5 +15,8 @@ export {
15
15
  mountEventName,
16
16
  dismountEventName,
17
17
  disconnectEventName,
18
- loadEventName
19
- } from './constants.js';
18
+ loadEventName,
19
+ attrchangeEventName,
20
+ mediamatchEventName,
21
+ mediaunmatchEventName
22
+ } from './Events.js';
package/mediaQuery.js ADDED
@@ -0,0 +1,86 @@
1
+ import { MediaMatchEvent, MediaUnmatchEvent, DismountEvent } from './Events.js';
2
+ export function setupMediaQuery(init, rootNodeRef, mountedElements, modules, observer, processNode) {
3
+ const { whereMediaMatches } = init;
4
+ // Create or use MediaQueryList
5
+ let mediaQueryList;
6
+ if (typeof whereMediaMatches === 'string') {
7
+ mediaQueryList = window.matchMedia(whereMediaMatches);
8
+ }
9
+ else {
10
+ mediaQueryList = whereMediaMatches;
11
+ }
12
+ // Track current state
13
+ let mediaMatches = mediaQueryList.matches;
14
+ // Set up change listener
15
+ const mediaChangeHandler = (e) => {
16
+ const previousMatches = mediaMatches;
17
+ mediaMatches = e.matches;
18
+ if (e.matches && !previousMatches) {
19
+ // Media query now matches - wake up and process elements
20
+ handleMediaMatch();
21
+ }
22
+ else if (!e.matches && previousMatches) {
23
+ // Media query no longer matches - dismount all elements
24
+ handleMediaUnmatch();
25
+ }
26
+ };
27
+ function handleMediaMatch() {
28
+ // Dispatch mediamatch event if requested
29
+ if (init.getPlayByPlay) {
30
+ observer.dispatchEvent(new MediaMatchEvent(init));
31
+ }
32
+ // Process all elements in the observed node
33
+ const rootNode = rootNodeRef.deref();
34
+ if (rootNode) {
35
+ processNode(rootNode);
36
+ }
37
+ }
38
+ function handleMediaUnmatch() {
39
+ // Dispatch mediaunmatch event if requested
40
+ if (init.getPlayByPlay) {
41
+ observer.dispatchEvent(new MediaUnmatchEvent(init));
42
+ }
43
+ // Dismount all currently mounted elements
44
+ const rootNode = rootNodeRef.deref();
45
+ if (!rootNode) {
46
+ return;
47
+ }
48
+ const context = {
49
+ modules,
50
+ observer: observer,
51
+ observeInfo: {
52
+ rootNode
53
+ }
54
+ };
55
+ // Get all mounted elements (we need to iterate through the DOM to find them)
56
+ const mountedElementsList = [];
57
+ const collectMountedElements = (node) => {
58
+ if (node.nodeType === Node.ELEMENT_NODE) {
59
+ const element = node;
60
+ if (mountedElements.has(element)) {
61
+ mountedElementsList.push(element);
62
+ }
63
+ }
64
+ node.childNodes.forEach(child => collectMountedElements(child));
65
+ };
66
+ collectMountedElements(rootNode);
67
+ // Dismount each element
68
+ for (const element of mountedElementsList) {
69
+ mountedElements.delete(element);
70
+ // Call dismount callback
71
+ if (init.do && typeof init.do !== 'function' && init.do.dismount) {
72
+ init.do.dismount(element, context);
73
+ }
74
+ // Dispatch dismount event with reason
75
+ observer.dispatchEvent(new DismountEvent(element, 'media-query-failed', init));
76
+ }
77
+ }
78
+ mediaQueryList.addEventListener('change', mediaChangeHandler);
79
+ return {
80
+ mediaQueryList,
81
+ mediaMatches,
82
+ cleanup: () => {
83
+ mediaQueryList.removeEventListener('change', mediaChangeHandler);
84
+ }
85
+ };
86
+ }
package/mediaQuery.ts ADDED
@@ -0,0 +1,113 @@
1
+ // Media query handling for MountObserver
2
+ import type { MountInit, MountContext } from './types.js';
3
+ import { MediaMatchEvent, MediaUnmatchEvent, DismountEvent } from './Events.js';
4
+
5
+ export function setupMediaQuery(
6
+ init: MountInit,
7
+ rootNodeRef: WeakRef<Node>,
8
+ mountedElements: WeakSet<Element>,
9
+ modules: any[],
10
+ observer: EventTarget,
11
+ processNode: (node: Node) => void
12
+ ): {
13
+ mediaQueryList: MediaQueryList;
14
+ mediaMatches: boolean;
15
+ cleanup: () => void;
16
+ } {
17
+ const { whereMediaMatches } = init;
18
+
19
+ // Create or use MediaQueryList
20
+ let mediaQueryList: MediaQueryList;
21
+ if (typeof whereMediaMatches === 'string') {
22
+ mediaQueryList = window.matchMedia(whereMediaMatches);
23
+ } else {
24
+ mediaQueryList = whereMediaMatches!;
25
+ }
26
+
27
+ // Track current state
28
+ let mediaMatches = mediaQueryList.matches;
29
+
30
+ // Set up change listener
31
+ const mediaChangeHandler = (e: MediaQueryListEvent) => {
32
+ const previousMatches = mediaMatches;
33
+ mediaMatches = e.matches;
34
+
35
+ if (e.matches && !previousMatches) {
36
+ // Media query now matches - wake up and process elements
37
+ handleMediaMatch();
38
+ } else if (!e.matches && previousMatches) {
39
+ // Media query no longer matches - dismount all elements
40
+ handleMediaUnmatch();
41
+ }
42
+ };
43
+
44
+ function handleMediaMatch(): void {
45
+ // Dispatch mediamatch event if requested
46
+ if (init.getPlayByPlay) {
47
+ observer.dispatchEvent(new MediaMatchEvent(init));
48
+ }
49
+
50
+ // Process all elements in the observed node
51
+ const rootNode = rootNodeRef.deref();
52
+ if (rootNode) {
53
+ processNode(rootNode);
54
+ }
55
+ }
56
+
57
+ function handleMediaUnmatch(): void {
58
+ // Dispatch mediaunmatch event if requested
59
+ if (init.getPlayByPlay) {
60
+ observer.dispatchEvent(new MediaUnmatchEvent(init));
61
+ }
62
+
63
+ // Dismount all currently mounted elements
64
+ const rootNode = rootNodeRef.deref();
65
+ if (!rootNode) {
66
+ return;
67
+ }
68
+
69
+ const context: MountContext = {
70
+ modules,
71
+ observer: observer as any,
72
+ observeInfo: {
73
+ rootNode
74
+ }
75
+ };
76
+
77
+ // Get all mounted elements (we need to iterate through the DOM to find them)
78
+ const mountedElementsList: Element[] = [];
79
+ const collectMountedElements = (node: Node) => {
80
+ if (node.nodeType === Node.ELEMENT_NODE) {
81
+ const element = node as Element;
82
+ if (mountedElements.has(element)) {
83
+ mountedElementsList.push(element);
84
+ }
85
+ }
86
+ node.childNodes.forEach(child => collectMountedElements(child));
87
+ };
88
+ collectMountedElements(rootNode);
89
+
90
+ // Dismount each element
91
+ for (const element of mountedElementsList) {
92
+ mountedElements.delete(element);
93
+
94
+ // Call dismount callback
95
+ if (init.do && typeof init.do !== 'function' && init.do.dismount) {
96
+ init.do.dismount(element, context);
97
+ }
98
+
99
+ // Dispatch dismount event with reason
100
+ observer.dispatchEvent(new DismountEvent(element, 'media-query-failed', init));
101
+ }
102
+ }
103
+
104
+ mediaQueryList.addEventListener('change', mediaChangeHandler);
105
+
106
+ return {
107
+ mediaQueryList,
108
+ mediaMatches,
109
+ cleanup: () => {
110
+ mediaQueryList.removeEventListener('change', mediaChangeHandler);
111
+ }
112
+ };
113
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mount-observer",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Observe and act on css matches.",
5
5
  "main": "MountObserver.js",
6
6
  "module": "MountObserver.js",
@@ -13,22 +13,25 @@
13
13
  },
14
14
  "exports": {
15
15
  ".": {
16
+ "default": "./index.js",
17
+ "types": "./index.ts"
18
+ },
19
+ "./MountObserver.js": {
16
20
  "default": "./MountObserver.js",
17
21
  "types": "./MountObserver.ts"
18
22
  }
19
-
20
23
  },
21
24
  "files": [
22
25
  "*.js",
23
26
  "*.ts"
24
27
  ],
25
- "types": "./ts-refs/mount-observer/types.d.ts",
28
+ "types": "./types.d.ts",
26
29
  "scripts": {
27
30
  "serve": "node ./node_modules/spa-ssi/serve.js",
28
31
  "test": "playwright test",
29
32
  "safari": "npx playwright wk http://localhost:8000",
30
33
  "update": "ncu -u && npm install"
31
34
  },
32
- "author": "",
35
+ "author": "Bruce B. Anderson <anderson.bruce.b@gmail.com>",
33
36
  "license": "MIT"
34
37
  }
package/types.d.ts CHANGED
@@ -1,13 +1,22 @@
1
1
  // Core types for MountObserver v2 - Polyfill Supported Scenario I
2
2
 
3
+ export type Constructor = new (...args: any[]) => any;
4
+
5
+ export type DismountReason =
6
+ | 'media-query-failed'
7
+ | 'where-element-matches-failed';
8
+
3
9
  export interface MountInit {
4
10
  whereElementMatches: string;
5
11
  whereAttr?: WhereAttr;
12
+ whereInstanceOf?: Constructor | Constructor[];
13
+ whereMediaMatches?: string | MediaQueryList;
6
14
  import?: string | ImportSpec | Array<string | ImportSpec>;
7
15
  do?: DoCallback | DoCallbacks;
8
16
  loadingEagerness?: 'eager' | 'lazy';
9
17
  assignGingerly?: Record<string, any>;
10
18
  map?: MapConfig;
19
+ getPlayByPlay?: boolean;
11
20
  }
12
21
 
13
22
  export interface MapConfig {
@@ -17,6 +26,11 @@ export interface MapConfig {
17
26
  export interface MapEntry {
18
27
  instanceOf?: string;
19
28
  mapsTo?: string;
29
+ /**
30
+ * Only notify the presence of this attribute
31
+ * the first time it is seen
32
+ */
33
+ once?: boolean;
20
34
  [key: string]: any;
21
35
  }
22
36
 
@@ -66,14 +80,18 @@ export interface IMountObserver extends EventTarget {
66
80
  export interface IMountEvent extends Event {
67
81
  matchingElement: Element;
68
82
  modules: any[];
83
+ mountInit: MountInit;
69
84
  }
70
85
 
71
86
  export interface IDismountEvent extends Event {
72
87
  matchingElement: Element;
88
+ reason: DismountReason;
89
+ mountInit: MountInit;
73
90
  }
74
91
 
75
92
  export interface IAttrChangeEvent extends Event {
76
93
  changes: AttrChange[];
94
+ mountInit: MountInit;
77
95
  }
78
96
 
79
97
  export interface AttrChange {
package/constants.js DELETED
@@ -1,6 +0,0 @@
1
- // Constants for MountObserver
2
- export const loadEventName = 'load';
3
- export const mountEventName = 'mount';
4
- export const dismountEventName = 'dismount';
5
- export const disconnectEventName = 'disconnect';
6
- export const attrchangeEventName = 'attrchange';
package/constants.ts DELETED
@@ -1,7 +0,0 @@
1
- // Constants for MountObserver
2
-
3
- export const loadEventName = 'load';
4
- export const mountEventName = 'mount';
5
- export const dismountEventName = 'dismount';
6
- export const disconnectEventName = 'disconnect';
7
- export const attrchangeEventName = 'attrchange';