assign-gingerly 0.0.57 → 0.0.59

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/resolveValues.ts CHANGED
@@ -1,55 +1,42 @@
1
1
  /**
2
- * Options for resolveValues
2
+ * resolveValues.ts — Async value resolution for path strings.
3
+ *
4
+ * Thin async wrapper around getValues that adds support for async protocol handlers.
5
+ * For synchronous-only use cases, import getValues/getValue directly for better performance.
6
+ *
7
+ * Re-exports ResolveValuesOptions for backward compatibility.
3
8
  */
4
- export interface ResolveValuesOptions {
5
- /**
6
- * Method names that should be called instead of accessed as properties.
7
- * When a path segment matches, it's called as a method with the next segment as argument.
8
- */
9
- withMethods?: string[] | Set<string>;
10
-
11
- /**
12
- * Alias mappings for path segments.
13
- * Substituted before path resolution, matching complete tokens between `?.` delimiters.
14
- */
15
- aka?: Record<string, string>;
16
9
 
17
- /**
18
- * Protocol handlers for resolving protocol-prefixed values (e.g., 'globalThis://key').
19
- * Each handler receives the key portion and returns the resolved value (sync or async).
20
- *
21
- * If a value contains '://' but the protocol isn't in this map, the value passes through unchanged.
22
- * If a '?.' appears after the protocol key, the remaining path is resolved against the handler's result.
23
- *
24
- * @example
25
- * protocols: {
26
- * globalThis: (key) => globalThis[key],
27
- * localStorage: (key) => JSON.parse(localStorage.getItem(key) || 'null')
28
- * }
29
- */
30
- protocols?: Record<string, (key: string) => any | Promise<any>>;
10
+ import { getValue } from './getValues.js';
11
+ import type { ResolveValuesOptions } from './types/assign-gingerly/types.js';
12
+
13
+ export type { ResolveValuesOptions };
14
+
15
+ // Re-export getValue as resolveValue for backward compatibility
16
+ export { getValue as resolveValue };
17
+
18
+ /**
19
+ * Checks if a string value looks like a protocol reference.
20
+ */
21
+ function hasProtocol(value: string): boolean {
22
+ return value.includes('://');
31
23
  }
32
24
 
33
25
  /**
34
26
  * Apply alias substitutions to a path string.
35
- * Replaces complete tokens between `?.` delimiters with their aliased values.
36
27
  */
37
28
  function applyAliases(path: string, aliasMap: Map<string, string>): string {
38
- if (aliasMap.size === 0) return path;
39
- const parts = path.split('?.');
40
- const substituted = parts.map(part => aliasMap.get(part) ?? part);
41
- return substituted.join('?.');
29
+ if (aliasMap.size === 0) return path;
30
+ const parts = path.split('?.');
31
+ const substituted = parts.map(part => aliasMap.get(part) ?? part);
32
+ return substituted.join('?.');
42
33
  }
43
34
 
44
35
  /**
45
36
  * Path cache for parsed path strings.
46
- * Avoids re-splitting the same path on repeated calls.
47
37
  */
48
38
  const pathCache = new Map<string, string[]>();
49
39
 
50
- /**
51
- * Parse a `?.`-delimited path string into segments, with caching.
52
- */
53
40
  function parseCachedPath(path: string): string[] {
54
41
  let parts = pathCache.get(path);
55
42
  if (!parts) {
@@ -60,265 +47,154 @@ function parseCachedPath(path: string): string[] {
60
47
  }
61
48
 
62
49
  /**
63
- * Resolves a protocol-prefixed value (e.g., 'globalThis://key?.path').
64
- *
65
- * 1. Extracts the protocol name (before '://')
66
- * 2. If the protocol isn't in the protocols map, returns the value unchanged (false positive)
67
- * 3. Extracts the key (between '://' and first '?.' or end of string)
68
- * 4. Calls the protocol handler with the key
69
- * 5. If there's a remaining '?.' path, resolves it against the handler's result
50
+ * Navigate a path against a source object, optionally calling methods.
51
+ */
52
+ function navigatePath(
53
+ source: any,
54
+ parts: string[],
55
+ withMethods: Set<string> | undefined
56
+ ): any {
57
+ let current = source;
58
+ let i = 0;
59
+ while (i < parts.length) {
60
+ if (current == null) return current;
61
+ const part = parts[i];
62
+ if (withMethods && withMethods.has(part)) {
63
+ const method = current[part];
64
+ if (typeof method === 'function') {
65
+ const nextPart = parts[i + 1];
66
+ if (nextPart !== undefined && !(withMethods.has(nextPart))) {
67
+ current = method.call(current, nextPart);
68
+ i += 2;
69
+ } else {
70
+ current = method.call(current);
71
+ i++;
72
+ }
73
+ } else {
74
+ current = current[part];
75
+ i++;
76
+ }
77
+ } else {
78
+ current = current[part];
79
+ i++;
80
+ }
81
+ }
82
+ return current;
83
+ }
84
+
85
+ /**
86
+ * Resolves a protocol-prefixed value asynchronously.
70
87
  */
71
88
  async function resolveProtocolValue(
72
89
  value: string,
73
90
  protocols: Record<string, (key: string) => any | Promise<any>>,
74
91
  options?: ResolveValuesOptions
75
92
  ): Promise<any> {
76
- // Extract protocol name (before ://)
77
93
  const protoEnd = value.indexOf('://');
78
94
  const protocol = value.substring(0, protoEnd);
79
95
 
80
- // Resolve via protocol handler
81
96
  const handler = protocols[protocol];
82
- if (!handler) return value; // false flag — coincidentally looks like a protocol
97
+ if (!handler) return value;
83
98
 
84
99
  const rest = value.substring(protoEnd + 3);
85
-
86
- // Split at first ?. to separate key from path
87
100
  const pathStart = rest.indexOf('?.');
88
101
  const key = pathStart === -1 ? rest : rest.substring(0, pathStart);
89
102
  const path = pathStart === -1 ? null : rest.substring(pathStart);
90
103
 
91
104
  const resolved = await handler(key);
92
105
 
93
- // If there's a remaining path, resolve it against the result
94
106
  if (path) {
95
- return resolveValue(path, resolved, options);
107
+ return getValue(path, resolved, options);
96
108
  }
97
109
  return resolved;
98
110
  }
99
111
 
100
112
  /**
101
- * Checks if a string value looks like a protocol reference.
102
- */
103
- function hasProtocol(value: string): boolean {
104
- return value.includes('://');
105
- }
106
-
107
- /**
108
- * Navigate a path against a source object, optionally calling methods.
109
- * Returns the resolved value at the end of the path.
110
- */
111
- function navigatePath(
112
- source: any,
113
- parts: string[],
114
- withMethods: Set<string> | undefined
115
- ): any {
116
- let current = source;
117
- let i = 0;
118
-
119
- while (i < parts.length) {
120
- if (current == null) return current;
121
-
122
- const part = parts[i];
123
-
124
- if (withMethods && withMethods.has(part)) {
125
- const method = current[part];
126
- if (typeof method === 'function') {
127
- const nextPart = parts[i + 1];
128
- if (nextPart !== undefined && !(withMethods.has(nextPart))) {
129
- // Call method with next segment as argument, consume it
130
- current = method.call(current, nextPart);
131
- i += 2;
132
- } else {
133
- // Consecutive methods or last segment — call with no args
134
- current = method.call(current);
135
- i++;
136
- }
137
- } else {
138
- current = current[part];
139
- i++;
140
- }
141
- } else {
142
- current = current[part];
143
- i++;
144
- }
145
- }
146
-
147
- return current;
148
- }
149
-
150
- /**
151
- * Resolve path strings and protocol references within an array.
152
- * Recurses into nested arrays and plain objects. Non-string elements,
153
- * class instances, and other non-plain objects pass through unchanged.
113
+ * Resolve path strings and protocol references within an array (async).
154
114
  */
155
115
  async function resolveArray(
156
- arr: any[],
157
- source: any,
158
- aliasMap: Map<string, string>,
159
- withMethods: Set<string> | undefined,
160
- protocols: Record<string, (key: string) => any | Promise<any>> | undefined,
161
- options?: ResolveValuesOptions
116
+ arr: any[],
117
+ source: any,
118
+ aliasMap: Map<string, string>,
119
+ withMethods: Set<string> | undefined,
120
+ protocols: Record<string, (key: string) => any | Promise<any>> | undefined,
121
+ options?: ResolveValuesOptions
162
122
  ): Promise<any[]> {
163
- const result: any[] = [];
164
- for (const item of arr) {
165
- if (typeof item === 'string' && item.startsWith('?.')) {
166
- const aliased = applyAliases(item, aliasMap);
167
- const parts = parseCachedPath(aliased);
168
- result.push(parts.length === 0 ? source : navigatePath(source, parts, withMethods));
169
- } else if (typeof item === 'string' && protocols && hasProtocol(item)) {
170
- result.push(await resolveProtocolValue(item, protocols, options));
171
- } else if (Array.isArray(item)) {
172
- result.push(await resolveArray(item, source, aliasMap, withMethods, protocols, options));
173
- } else if (item && typeof item === 'object') {
174
- const proto = Object.getPrototypeOf(item);
175
- if (proto === Object.prototype || proto === null) {
176
- result.push(await resolveValues(item, source, options));
177
- } else {
178
- result.push(item);
179
- }
180
- } else {
181
- result.push(item);
123
+ const result: any[] = [];
124
+ for (const item of arr) {
125
+ if (typeof item === 'string' && item.startsWith('?.')) {
126
+ const aliased = applyAliases(item, aliasMap);
127
+ const parts = parseCachedPath(aliased);
128
+ result.push(parts.length === 0 ? source : navigatePath(source, parts, withMethods));
129
+ } else if (typeof item === 'string' && protocols && hasProtocol(item)) {
130
+ result.push(await resolveProtocolValue(item, protocols, options));
131
+ } else if (Array.isArray(item)) {
132
+ result.push(await resolveArray(item, source, aliasMap, withMethods, protocols, options));
133
+ } else if (item && typeof item === 'object') {
134
+ const proto = Object.getPrototypeOf(item);
135
+ if (proto === Object.prototype || proto === null) {
136
+ result.push(await resolveValues(item, source, options));
137
+ } else {
138
+ result.push(item);
139
+ }
140
+ } else {
141
+ result.push(item);
142
+ }
182
143
  }
183
- }
184
- return result;
144
+ return result;
185
145
  }
186
146
 
187
147
  /**
188
- * Resolve RHS path strings in a pattern object against a source object.
189
- *
190
- * Any value that is a string starting with `?.` is treated as a path
191
- * and resolved against the source object using optional chaining semantics.
192
- * Non-string values and strings not starting with `?.` pass through unchanged.
148
+ * Async resolve RHS path strings in a pattern object against a source object.
193
149
  *
194
- * Supports `withMethods` for calling methods during resolution and `aka` for
195
- * alias substitution, consistent with assignGingerly's LHS path handling.
196
- *
197
- * Special case: `'?.'` (empty path) resolves to the source object itself.
150
+ * Supports async protocol handlers (e.g., fetch, IndexedDB).
151
+ * For synchronous-only patterns, use `getValues` from 'assign-gingerly/getValues.js' instead.
198
152
  *
199
153
  * @param pattern - Object whose RHS values may contain `?.` path strings
200
154
  * @param source - Object to resolve paths against
201
- * @param options - Optional withMethods and aka for method calls and aliases
155
+ * @param options - Optional withMethods, aka, and protocol handlers
202
156
  * @returns New object with path strings replaced by resolved values
203
- *
204
- * @example
205
- * const result = resolveValues({
206
- * hello: '?.myPropContainer?.stringProp',
207
- * foo: '?.myFooString',
208
- * literal: 42
209
- * }, source);
210
- *
211
- * @example
212
- * // With methods and aliases
213
- * const result = resolveValues({
214
- * text: '?.q?..username?.textContent'
215
- * }, source, {
216
- * withMethods: ['querySelector'],
217
- * aka: { 'q': 'querySelector' }
218
- * });
219
157
  */
220
158
  export async function resolveValues(
221
- pattern: Record<string, any>,
222
- source: any,
223
- options?: ResolveValuesOptions
159
+ pattern: Record<string, any>,
160
+ source: any,
161
+ options?: ResolveValuesOptions
224
162
  ): Promise<Record<string, any>> {
225
- // Build alias map
226
- const aliasMap = new Map<string, string>();
227
- if (options?.aka) {
228
- for (const [alias, target] of Object.entries(options.aka)) {
229
- aliasMap.set(alias, target);
230
- }
231
- }
232
-
233
- // Build methods set
234
- const withMethods = options?.withMethods
235
- ? options.withMethods instanceof Set
236
- ? options.withMethods
237
- : new Set(options.withMethods)
238
- : undefined;
239
-
240
- const protocols = options?.protocols;
241
-
242
- const result: Record<string, any> = {};
243
- for (const [key, value] of Object.entries(pattern)) {
244
- if (typeof value === 'string' && value.startsWith('?.')) {
245
- // Apply aliases to the RHS path
246
- const aliased = applyAliases(value, aliasMap);
247
-
248
- // Parse path with caching
249
- const parts = parseCachedPath(aliased);
250
-
251
- // Navigate with method support
252
- result[key] = parts.length === 0 ? source : navigatePath(source, parts, withMethods);
253
- } else if (typeof value === 'string' && protocols && hasProtocol(value)) {
254
- // Protocol-prefixed value — resolve asynchronously
255
- result[key] = await resolveProtocolValue(value, protocols, options);
256
- } else if (Array.isArray(value)) {
257
- // Resolve path strings and protocols within arrays (recursing into nested arrays)
258
- result[key] = await resolveArray(value, source, aliasMap, withMethods, protocols, options);
259
- } else if (typeof value === 'object' && value !== null) {
260
- // Recursively resolve nested plain objects (e.g., headers: { "...": "globalThis://key" })
261
- // Only recurse into plain objects — skip DOM elements, class instances, etc.
262
- const proto = Object.getPrototypeOf(value);
263
- if (proto === Object.prototype || proto === null) {
264
- result[key] = await resolveValues(value, source, options);
265
- } else {
266
- result[key] = value;
267
- }
268
- } else {
269
- result[key] = value;
270
- }
271
- }
272
- return result;
273
- }
274
-
275
- /**
276
- * Resolve a single `?.`-delimited path string against a source object.
277
- *
278
- * This is a lighter-weight alternative to `resolveValues` when you only need
279
- * to resolve one path and don't want the overhead of creating wrapper objects.
280
- *
281
- * @param path - A `?.`-delimited path string (e.g., '?.behaviors?.command')
282
- * @param source - Object to resolve the path against
283
- * @param options - Optional withMethods and aka for method calls and aliases
284
- * @returns The resolved value, or undefined if any segment is nullish
285
- *
286
- * @example
287
- * const value = resolveValue('?.behaviors?.commandBehavior?.command', el);
288
- *
289
- * @example
290
- * const value = resolveValue('?.q?.myEl?.textContent', el, {
291
- * withMethods: ['querySelector'],
292
- * aka: { 'q': 'querySelector' }
293
- * });
294
- */
295
- export function resolveValue(
296
- path: string,
297
- source: any,
298
- options?: ResolveValuesOptions
299
- ): any {
300
- if (!path.startsWith('?.')) return path;
301
-
302
- // Build alias map
303
- let aliased = path;
304
- if (options?.aka) {
305
163
  const aliasMap = new Map<string, string>();
306
- for (const [alias, target] of Object.entries(options.aka)) {
307
- aliasMap.set(alias, target);
164
+ if (options?.aka) {
165
+ for (const [alias, target] of Object.entries(options.aka)) {
166
+ aliasMap.set(alias, target);
167
+ }
308
168
  }
309
- aliased = applyAliases(path, aliasMap);
310
- }
311
-
312
- // Parse path with caching
313
- const parts = parseCachedPath(aliased);
314
- if (parts.length === 0) return source;
315
169
 
316
- // Build methods set
317
- const withMethods = options?.withMethods
318
- ? options.withMethods instanceof Set
319
- ? options.withMethods
320
- : new Set(options.withMethods)
321
- : undefined;
322
-
323
- return navigatePath(source, parts, withMethods);
170
+ const withMethods = options?.withMethods
171
+ ? options.withMethods instanceof Set
172
+ ? options.withMethods
173
+ : new Set(options.withMethods)
174
+ : undefined;
175
+
176
+ const protocols = options?.protocols;
177
+
178
+ const result: Record<string, any> = {};
179
+ for (const [key, value] of Object.entries(pattern)) {
180
+ if (typeof value === 'string' && value.startsWith('?.')) {
181
+ const aliased = applyAliases(value, aliasMap);
182
+ const parts = parseCachedPath(aliased);
183
+ result[key] = parts.length === 0 ? source : navigatePath(source, parts, withMethods);
184
+ } else if (typeof value === 'string' && protocols && hasProtocol(value)) {
185
+ result[key] = await resolveProtocolValue(value, protocols, options);
186
+ } else if (Array.isArray(value)) {
187
+ result[key] = await resolveArray(value, source, aliasMap, withMethods, protocols, options);
188
+ } else if (typeof value === 'object' && value !== null) {
189
+ const proto = Object.getPrototypeOf(value);
190
+ if (proto === Object.prototype || proto === null) {
191
+ result[key] = await resolveValues(value, source, options);
192
+ } else {
193
+ result[key] = value;
194
+ }
195
+ } else {
196
+ result[key] = value;
197
+ }
198
+ }
199
+ return result;
324
200
  }
@@ -77,9 +77,9 @@ export function withTransition(markerNode, direction, transitional, domMutation)
77
77
  }
78
78
 
79
79
  /**
80
- * Track which rootNodes already have the hide style injected.
80
+ * Track which (rootNode, className) combos already have styles injected.
81
81
  */
82
- const styleInjected = new WeakSet();
82
+ const styleInjected = new WeakMap();
83
83
 
84
84
  /**
85
85
  * Default CSS class name for hidden elements during transitions.
@@ -87,7 +87,7 @@ const styleInjected = new WeakSet();
87
87
  export const DEFAULT_HIDE_CLASS = 'ag-hide';
88
88
 
89
89
  /**
90
- * Ensure the hide class style is injected into the rootNode (once per rootNode).
90
+ * Ensure the hide class style is injected into the rootNode (once per rootNode + className combo).
91
91
  *
92
92
  * @param {any} rootNode - The Document, ShadowRoot, or element root to inject into
93
93
  * @param {string} [hideClass='ag-hide'] - CSS class name
@@ -100,8 +100,14 @@ export function ensureHideStyle(rootNode, hideClass = DEFAULT_HIDE_CLASS, hideCs
100
100
  target = document.head;
101
101
  }
102
102
 
103
- if (styleInjected.has(target)) return;
104
- styleInjected.add(target);
103
+ let injectedClasses = styleInjected.get(target);
104
+ if (!injectedClasses) {
105
+ injectedClasses = new Set();
106
+ styleInjected.set(target, injectedClasses);
107
+ }
108
+
109
+ if (injectedClasses.has(hideClass)) return;
110
+ injectedClasses.add(hideClass);
105
111
 
106
112
  const style = document.createElement('style');
107
113
  style.textContent = `.${hideClass} { ${hideCss} }`;
@@ -91,9 +91,9 @@ export function withTransition(
91
91
  }
92
92
 
93
93
  /**
94
- * Track which rootNodes already have the hide style injected.
94
+ * Track which (rootNode, className) combos already have styles injected.
95
95
  */
96
- const styleInjected = new WeakSet<object>();
96
+ const styleInjected = new WeakMap<object, Set<string>>();
97
97
 
98
98
  /**
99
99
  * Default CSS class name for hidden elements during transitions.
@@ -106,7 +106,7 @@ export const DEFAULT_HIDE_CLASS = 'ag-hide';
106
106
  const DEFAULT_HIDE_CSS = `display: none`;
107
107
 
108
108
  /**
109
- * Ensure the hide class style is injected into the rootNode (once per rootNode).
109
+ * Ensure the hide class style is injected into the rootNode (once per rootNode + className combo).
110
110
  *
111
111
  * @param rootNode - The Document, ShadowRoot, or element root to inject into
112
112
  * @param hideClass - CSS class name (default: 'ag-hide')
@@ -123,8 +123,14 @@ export function ensureHideStyle(
123
123
  target = document.head;
124
124
  }
125
125
 
126
- if (styleInjected.has(target)) return;
127
- styleInjected.add(target);
126
+ let injectedClasses = styleInjected.get(target);
127
+ if (!injectedClasses) {
128
+ injectedClasses = new Set();
129
+ styleInjected.set(target, injectedClasses);
130
+ }
131
+
132
+ if (injectedClasses.has(hideClass)) return;
133
+ injectedClasses.add(hideClass);
128
134
 
129
135
  const style = document.createElement('style');
130
136
  style.textContent = `.${hideClass} { ${hideCss} }`;
@@ -261,6 +261,37 @@ export interface IAssignGingerlyOptions {
261
261
  signal?: AbortSignal;
262
262
  }
263
263
 
264
+ /**
265
+ * Options for synchronous value resolution (getValues / getValue).
266
+ * Extends IAssignGingerlyOptions with synchronous protocol handlers.
267
+ */
268
+ export interface GetValuesOptions extends IAssignGingerlyOptions {
269
+ /**
270
+ * Synchronous protocol handlers for resolving protocol-prefixed values.
271
+ * Each handler receives the key portion and MUST return synchronously.
272
+ * For async protocols, use ResolveValuesOptions / resolveValues instead.
273
+ *
274
+ * @example
275
+ * protocols: {
276
+ * globalThis: (key) => globalThis[key],
277
+ * localStorage: (key) => JSON.parse(localStorage.getItem(key) || 'null')
278
+ * }
279
+ */
280
+ protocols?: Record<string, (key: string) => any>;
281
+ }
282
+
283
+ /**
284
+ * Options for async value resolution (resolveValues).
285
+ * Same as GetValuesOptions but protocol handlers may return Promises.
286
+ */
287
+ export interface ResolveValuesOptions extends IAssignGingerlyOptions {
288
+ /**
289
+ * Protocol handlers for resolving protocol-prefixed values (e.g., 'globalThis://key').
290
+ * Each handler receives the key portion and returns the resolved value (sync or async).
291
+ */
292
+ protocols?: Record<string, (key: string) => any | Promise<any>>;
293
+ }
294
+
264
295
  /**
265
296
  * Event dispatched when enhancement configs are registered
266
297
  */
@@ -628,6 +659,26 @@ export interface LazyLoadSwitchResolvedParams extends Omit<LazyLoadResolvedParam
628
659
  rhs: any;
629
660
  }
630
661
 
662
+ /**
663
+ * Resolved parameters received by ManageTemplateListHandler.assign().
664
+ */
665
+ export interface ManageTemplateListResolvedParams {
666
+ /** The iterable to loop over (resolved from VM) */
667
+ forEach: Iterable<any>;
668
+ /** Template element to clone per item */
669
+ instantiate: HTMLTemplateElement | DocumentFragment;
670
+ /** Insertion method (default: 'appendChild') */
671
+ method?: 'appendChild' | 'prepend' | 'after';
672
+ /** Remove nodes on hide instead of using hidden attribute */
673
+ forget?: boolean;
674
+ /** Override auto-derived marker name */
675
+ markerName?: string;
676
+ /** Wait for async rendering before DOM commit */
677
+ waitForSettled?: boolean | { idleMs?: number; timeout?: number };
678
+ /** Yield to browser every N items to prevent jank (default: undefined = no yielding) */
679
+ yieldEvery?: number;
680
+ }
681
+
631
682
  /**
632
683
  * Context passed to onInstantiated callbacks after template cloning.
633
684
  */
@@ -0,0 +1,57 @@
1
+ /**
2
+ * waitForSettled.ts — Waits for a DOM subtree to "settle" (mutations stop cascading).
3
+ *
4
+ * Observes a node for DOM mutations and debounces: each mutation resets an idle timer.
5
+ * When no mutations have occurred for `idleMs` milliseconds, the promise resolves.
6
+ *
7
+ * Useful for waiting for async rendering (itemscope managers, enhancements, features)
8
+ * to complete inside a DocumentFragment before committing to the live DOM.
9
+ *
10
+ * @example
11
+ * import { waitForSettled } from 'assign-gingerly/waitForSettled.js';
12
+ *
13
+ * const fragment = document.createDocumentFragment();
14
+ * // ... clone and assign into fragment ...
15
+ * await waitForSettled(fragment, 100, 2000);
16
+ * target.appendChild(fragment);
17
+ *
18
+ * @param root - The node to observe (typically a DocumentFragment or Element)
19
+ * @param idleMs - Debounce window in milliseconds. Default: 100
20
+ * @param timeout - Maximum wait time in milliseconds. If exceeded, rejects. Default: none (infinite)
21
+ */
22
+ export function waitForSettled(root, idleMs = 100, timeout) {
23
+ return new Promise((resolve, reject) => {
24
+ let timer;
25
+ let maxTimer;
26
+ const mo = new MutationObserver(() => {
27
+ clearTimeout(timer);
28
+ timer = setTimeout(() => {
29
+ mo.disconnect();
30
+ if (maxTimer)
31
+ clearTimeout(maxTimer);
32
+ resolve();
33
+ }, idleMs);
34
+ });
35
+ mo.observe(root, {
36
+ childList: true,
37
+ subtree: true,
38
+ attributes: true,
39
+ characterData: true
40
+ });
41
+ // Initial timer — resolves if no mutations happen at all
42
+ timer = setTimeout(() => {
43
+ mo.disconnect();
44
+ if (maxTimer)
45
+ clearTimeout(maxTimer);
46
+ resolve();
47
+ }, idleMs);
48
+ // Maximum timeout — rejects if mutations never quiesce
49
+ if (timeout !== undefined) {
50
+ maxTimer = setTimeout(() => {
51
+ mo.disconnect();
52
+ clearTimeout(timer);
53
+ reject(new Error(`waitForSettled: mutations did not quiesce within ${timeout}ms`));
54
+ }, timeout);
55
+ }
56
+ });
57
+ }