webspresso 0.0.110 → 0.0.111

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.
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const { defaultStorage } = require('./storage');
4
+ const { isArray, isNumber, parseDurationMs, parseRefetchMode, uid } = require('./utils');
4
5
 
5
6
  /**
6
7
  * Webspresso Client-Side SSR Fragment Coordinator & Invalidation Engine
@@ -33,7 +34,7 @@ function parseDependsOnAttr(raw) {
33
34
  if (trimmed.startsWith('[')) {
34
35
  try {
35
36
  const parsed = JSON.parse(trimmed);
36
- if (Array.isArray(parsed)) {
37
+ if (isArray(parsed)) {
37
38
  return parsed.map((s) => String(s).trim()).filter(Boolean);
38
39
  }
39
40
  } catch (_e) {
@@ -58,7 +59,7 @@ function parseFragmentInputAttr(raw) {
58
59
  if (!trimmed) return {};
59
60
  try {
60
61
  const parsed = JSON.parse(trimmed);
61
- return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
62
+ return parsed && typeof parsed === 'object' && !isArray(parsed) ? parsed : {};
62
63
  } catch (_e) {
63
64
  return {};
64
65
  }
@@ -108,9 +109,24 @@ function createFragmentCoordinator(options = {}) {
108
109
  const endpoint = options.endpoint || DEFAULT_FRAGMENT_ENDPOINT;
109
110
  const datastar = options.datastar || options.adapter;
110
111
  const debug = options.debug || null;
111
- const defaultTimeout = typeof options.timeout === 'number' ? options.timeout : 15000;
112
-
113
- // Map<instanceId, { instanceId, name, pagePath, input, dependsOn: Set<string>, version: number, el: Element, abortController: AbortController|null }>
112
+ const defaultTimeout = isNumber(options.timeout) ? options.timeout : 15000;
113
+
114
+ // React Query-style automatic revalidation defaults
115
+ const defaultStaleTime = parseDurationMs(options.staleTime, 0);
116
+ const defaultRefetchOnWindowFocus = parseRefetchMode(
117
+ options.refetchOnWindowFocus !== undefined ? options.refetchOnWindowFocus : options.refetchOnFocus,
118
+ false
119
+ );
120
+ const defaultRefetchOnReconnect = parseRefetchMode(options.refetchOnReconnect, false);
121
+ const defaultRefetchOnMount = parseRefetchMode(
122
+ options.refetchOnMount !== undefined ? options.refetchOnMount : options.refetchOnVisit,
123
+ false
124
+ );
125
+ const defaultRefetchInterval = parseDurationMs(options.refetchInterval, 0);
126
+ const defaultRefetchIntervalInBackground =
127
+ parseRefetchMode(options.refetchIntervalInBackground, false) === true;
128
+
129
+ // Map<instanceId, { instanceId, name, pagePath, input, dependsOn: Set<string>, version: number, el: Element, abortController: AbortController|null, ... }>
114
130
  const instances = new Map();
115
131
 
116
132
  // Coalescing & in-flight state for tag invalidations
@@ -122,91 +138,378 @@ function createFragmentCoordinator(options = {}) {
122
138
  let lastFailedBatch = null;
123
139
  const inflightBatchWaiters = [];
124
140
 
125
- /**
126
- * Scan DOM and synchronize tracked `[data-w-fragment]` instances.
127
- */
128
- function syncDomInstances(containerEl) {
129
- const root = containerEl || getRoot();
130
- if (!root || typeof root.querySelectorAll !== 'function') return [];
141
+ // Automatic refetch (focus / reconnect / mount / interval) state
142
+ const pendingAutoInstanceIds = new Set();
143
+ let autoBatchScheduled = false;
144
+ let focusDebounceTimer = null;
145
+ let isDisposed = false;
146
+ let lifecycleListenersAttached = false;
147
+
148
+ function isDocumentHidden() {
149
+ const root = getRoot();
150
+ const doc = root && root.nodeType === 9 ? root : root && root.ownerDocument ? root.ownerDocument : typeof document !== 'undefined' ? document : null;
151
+ return Boolean(doc && doc.visibilityState === 'hidden');
152
+ }
131
153
 
132
- const elements = Array.from(root.querySelectorAll('[data-w-fragment]'));
133
- if (root.nodeType === 1 && root.hasAttribute && root.hasAttribute('data-w-fragment')) {
134
- elements.unshift(root);
135
- }
136
-
137
- const seenIds = new Set();
138
-
139
- for (let i = 0; i < elements.length; i++) {
140
- const el = elements[i];
141
- const name = (el.getAttribute('data-w-fragment') || '').trim();
142
- if (!name) continue;
143
-
144
- let instanceId = (el.getAttribute('data-w-fragment-id') || '').trim();
145
- if (!instanceId) {
146
- instanceId = i === 0 ? `${name}:default` : `${name}:${i}`;
147
- el.setAttribute('data-w-fragment-id', instanceId);
148
- }
149
-
150
- seenIds.add(instanceId);
151
- const pagePath =
152
- (el.getAttribute('data-w-fragment-page') || '').trim() ||
153
- (typeof window !== 'undefined' && window.location ? window.location.pathname : '/');
154
- const input = parseFragmentInputAttr(el.getAttribute('data-w-fragment-input'));
155
- const dependsOn = new Set(parseDependsOnAttr(el.getAttribute('data-w-depends-on')));
156
-
157
- // Fragment input persistence setup
158
- const rawPersistInput =
159
- el.getAttribute('data-w-persist-input') ||
160
- (el.getAttribute('data-w-persist') === 'input' ? 'session' : null);
161
- const isInputPersistEnabled = Boolean(rawPersistInput && rawPersistInput !== 'false');
162
- const inputStorageType = rawPersistInput && rawPersistInput.toLowerCase() === 'local' ? 'local' : 'session';
163
- const inputStorageKey =
164
- el.getAttribute('data-w-persist-key') ||
165
- el.getAttribute('data-w-input-key') ||
166
- `frag_input:${name}`;
167
-
168
- let effectiveInput = input;
169
- if (isInputPersistEnabled) {
170
- const store = inputStorageType === 'local' ? defaultStorage.local : defaultStorage.session;
171
- if (!el.hasAttribute('data-w-fragment-input') || Object.keys(effectiveInput).length === 0) {
172
- const stored = store.get(inputStorageKey, null);
173
- if (stored && typeof stored === 'object' && !Array.isArray(stored)) {
174
- effectiveInput = stored;
175
- }
176
- }
154
+ function isInstanceStale(inst, mode) {
155
+ if (!inst || !mode) return false;
156
+ if (mode === 'always') return true;
157
+ const staleMs = isNumber(inst.staleTime) ? inst.staleTime : 0;
158
+ if (staleMs <= 0) return true;
159
+ const elapsed = Date.now() - (inst.lastUpdatedAt || 0);
160
+ return elapsed >= staleMs;
161
+ }
162
+
163
+ function clearInstanceInterval(inst) {
164
+ if (inst && inst.intervalTimer) {
165
+ clearInterval(inst.intervalTimer);
166
+ inst.intervalTimer = null;
167
+ inst.activeIntervalMs = 0;
168
+ }
169
+ }
170
+
171
+ function syncInstanceInterval(inst) {
172
+ if (!inst || isDisposed) return;
173
+ const targetMs = isNumber(inst.refetchInterval) && inst.refetchInterval > 0 ? inst.refetchInterval : 0;
174
+ if (targetMs <= 0) {
175
+ clearInstanceInterval(inst);
176
+ return;
177
+ }
178
+ if (inst.intervalTimer && inst.activeIntervalMs === targetMs) {
179
+ return;
180
+ }
181
+ clearInstanceInterval(inst);
182
+ inst.activeIntervalMs = targetMs;
183
+ inst.intervalTimer = setInterval(() => {
184
+ if (isDisposed || !inst.el || inst.el.isConnected === false) {
185
+ clearInstanceInterval(inst);
186
+ return;
187
+ }
188
+ if (!inst.refetchIntervalInBackground && isDocumentHidden()) {
189
+ return;
177
190
  }
191
+ scheduleAutoRefresh([inst.instanceId], 'interval');
192
+ }, targetMs);
193
+ if (inst.intervalTimer && typeof inst.intervalTimer.unref === 'function') {
194
+ inst.intervalTimer.unref();
195
+ }
196
+ }
178
197
 
179
- const existing = instances.get(instanceId);
180
- if (existing) {
181
- existing.name = name;
182
- existing.pagePath = pagePath;
183
- existing.el = el;
184
- existing.dependsOn = dependsOn;
185
- if (el.hasAttribute('data-w-fragment-input') || isInputPersistEnabled) {
186
- existing.input = effectiveInput;
198
+ function scheduleAutoRefresh(instanceIds, reason = 'auto') {
199
+ if (isDisposed || !isArray(instanceIds) || instanceIds.length === 0) return;
200
+ for (const id of instanceIds) {
201
+ if (id) pendingAutoInstanceIds.add(id);
202
+ }
203
+ if (autoBatchScheduled || pendingAutoInstanceIds.size === 0) return;
204
+ autoBatchScheduled = true;
205
+
206
+ Promise.resolve().then(async () => {
207
+ autoBatchScheduled = false;
208
+ if (isDisposed || pendingAutoInstanceIds.size === 0) return;
209
+ const idsToRefresh = Array.from(pendingAutoInstanceIds);
210
+ pendingAutoInstanceIds.clear();
211
+
212
+ // Skip background auto-refresh if a mutation invalidation batch is currently in flight
213
+ if (inFlightBatchCount > 0) return;
214
+
215
+ const candidates = [];
216
+ for (const id of idsToRefresh) {
217
+ const inst = instances.get(id);
218
+ if (inst && inst.el && inst.el.isConnected !== false && !inst.abortController) {
219
+ candidates.push(inst);
187
220
  }
188
- existing.persistInput = isInputPersistEnabled ? { type: inputStorageType, key: inputStorageKey } : null;
189
- } else {
190
- instances.set(instanceId, {
191
- instanceId,
192
- name,
193
- pagePath,
194
- input: effectiveInput,
195
- dependsOn,
196
- version: 0,
197
- el,
198
- abortController: null,
199
- persistInput: isInputPersistEnabled ? { type: inputStorageType, key: inputStorageKey } : null,
221
+ }
222
+ if (candidates.length === 0) return;
223
+
224
+ const deduped = deduplicateParentChildFragments(candidates);
225
+ const targets = deduped.map((inst) => {
226
+ inst.version += 1;
227
+ return {
228
+ instanceId: inst.instanceId,
229
+ name: inst.name,
230
+ pagePath: inst.pagePath,
231
+ input: { ...inst.input },
232
+ reqVersion: inst.version,
233
+ };
234
+ });
235
+
236
+ if (debug && typeof debug.recordEvent === 'function') {
237
+ debug.recordEvent('fragment:auto-refetch', {
238
+ reason,
239
+ fragments: targets.map((t) => t.instanceId),
200
240
  });
201
241
  }
242
+
243
+ try {
244
+ await executeBatchRefresh(targets);
245
+ } catch (_err) {
246
+ // Background auto-refetch errors are non-fatal
247
+ }
248
+ });
249
+ }
250
+
251
+ function triggerFocusRefetch() {
252
+ if (isDisposed || isDocumentHidden()) return;
253
+ if (focusDebounceTimer) clearTimeout(focusDebounceTimer);
254
+ focusDebounceTimer = setTimeout(() => {
255
+ focusDebounceTimer = null;
256
+ if (isDisposed || isDocumentHidden()) return;
257
+ scanDomInstances();
258
+ const matchedIds = [];
259
+ for (const inst of instances.values()) {
260
+ if (isInstanceStale(inst, inst.refetchOnWindowFocus)) {
261
+ matchedIds.push(inst.instanceId);
262
+ }
263
+ }
264
+ if (matchedIds.length > 0) {
265
+ scheduleAutoRefresh(matchedIds, 'window-focus');
266
+ }
267
+ }, 20);
268
+ }
269
+
270
+ function triggerReconnectRefetch() {
271
+ if (isDisposed) return;
272
+ scanDomInstances();
273
+ const matchedIds = [];
274
+ for (const inst of instances.values()) {
275
+ if (isInstanceStale(inst, inst.refetchOnReconnect)) {
276
+ matchedIds.push(inst.instanceId);
277
+ }
278
+ }
279
+ if (matchedIds.length > 0) {
280
+ scheduleAutoRefresh(matchedIds, 'reconnect');
281
+ }
282
+ }
283
+
284
+ function onVisibilityChange() {
285
+ if (!isDocumentHidden()) {
286
+ triggerFocusRefetch();
287
+ }
288
+ }
289
+
290
+ function onPageShow(event) {
291
+ if (isDisposed) return;
292
+ if (event && event.persisted) {
293
+ scanDomInstances();
294
+ const matchedIds = [];
295
+ for (const inst of instances.values()) {
296
+ const mode = inst.refetchOnMount || inst.refetchOnWindowFocus;
297
+ if (isInstanceStale(inst, mode)) {
298
+ matchedIds.push(inst.instanceId);
299
+ }
300
+ }
301
+ if (matchedIds.length > 0) {
302
+ scheduleAutoRefresh(matchedIds, 'pageshow');
303
+ }
304
+ }
305
+ }
306
+
307
+ function ensureLifecycleListeners() {
308
+ if (lifecycleListenersAttached || isDisposed) return;
309
+ const root = getRoot();
310
+ const doc = root && root.nodeType === 9 ? root : root && root.ownerDocument ? root.ownerDocument : typeof document !== 'undefined' ? document : null;
311
+ const win = (doc && doc.defaultView) || (typeof window !== 'undefined' ? window : null);
312
+
313
+ if (doc && typeof doc.addEventListener === 'function') {
314
+ doc.addEventListener('visibilitychange', onVisibilityChange);
315
+ }
316
+ if (win && typeof win.addEventListener === 'function') {
317
+ win.addEventListener('focus', triggerFocusRefetch);
318
+ win.addEventListener('online', triggerReconnectRefetch);
319
+ win.addEventListener('pageshow', onPageShow);
320
+ }
321
+ lifecycleListenersAttached = true;
322
+ }
323
+
324
+ function removeLifecycleListeners() {
325
+ if (!lifecycleListenersAttached) return;
326
+ const root = getRoot();
327
+ const doc = root && root.nodeType === 9 ? root : root && root.ownerDocument ? root.ownerDocument : typeof document !== 'undefined' ? document : null;
328
+ const win = (doc && doc.defaultView) || (typeof window !== 'undefined' ? window : null);
329
+
330
+ if (doc && typeof doc.removeEventListener === 'function') {
331
+ doc.removeEventListener('visibilitychange', onVisibilityChange);
332
+ }
333
+ if (win && typeof win.removeEventListener === 'function') {
334
+ win.removeEventListener('focus', triggerFocusRefetch);
335
+ win.removeEventListener('online', triggerReconnectRefetch);
336
+ win.removeEventListener('pageshow', onPageShow);
337
+ }
338
+ lifecycleListenersAttached = false;
339
+ }
340
+
341
+ function syncSingleElement(el, index, seenIds) {
342
+ const name = (el.getAttribute('data-w-fragment') || '').trim();
343
+ if (!name) return;
344
+
345
+ let instanceId = (el.getAttribute('data-w-fragment-id') || '').trim();
346
+ if (!instanceId) {
347
+ instanceId = index === 0 ? `${name}:default` : `${name}:${index}`;
348
+ el.setAttribute('data-w-fragment-id', instanceId);
349
+ }
350
+
351
+ if (seenIds) seenIds.add(instanceId);
352
+
353
+ const existing = instances.get(instanceId);
354
+ const rawInput = el.getAttribute('data-w-fragment-input');
355
+ const rawDepends = el.getAttribute('data-w-depends-on');
356
+ const rawStale = el.getAttribute('data-w-stale-time');
357
+ const rawInterval = el.getAttribute('data-w-refetch-interval');
358
+
359
+ // Fast-path: if the same DOM element is already registered and its reactive attributes are unchanged,
360
+ // skip JSON.parse, Set allocation, storage lookup, and timer reconfiguration.
361
+ if (
362
+ existing &&
363
+ existing.el === el &&
364
+ existing.name === name &&
365
+ existing._rawInput === rawInput &&
366
+ existing._rawDepends === rawDepends &&
367
+ existing._rawStale === rawStale &&
368
+ existing._rawInterval === rawInterval
369
+ ) {
370
+ return;
371
+ }
372
+
373
+ const pagePath =
374
+ (el.getAttribute('data-w-fragment-page') || '').trim() ||
375
+ (typeof window !== 'undefined' && window.location ? window.location.pathname : '/');
376
+ const input =
377
+ existing && existing.el === el && existing._rawInput === rawInput
378
+ ? existing.input
379
+ : parseFragmentInputAttr(rawInput);
380
+ const dependsOn =
381
+ existing && existing.el === el && existing._rawDepends === rawDepends
382
+ ? existing.dependsOn
383
+ : new Set(parseDependsOnAttr(rawDepends));
384
+
385
+ // Fragment input persistence setup
386
+ const rawPersistInput =
387
+ el.getAttribute('data-w-persist-input') ||
388
+ (el.getAttribute('data-w-persist') === 'input' ? 'session' : null);
389
+ const isInputPersistEnabled = Boolean(rawPersistInput && rawPersistInput !== 'false');
390
+ const inputStorageType = rawPersistInput && rawPersistInput.toLowerCase() === 'local' ? 'local' : 'session';
391
+ const inputStorageKey =
392
+ el.getAttribute('data-w-persist-key') ||
393
+ el.getAttribute('data-w-input-key') ||
394
+ `frag_input:${name}`;
395
+
396
+ let effectiveInput = input;
397
+ if (isInputPersistEnabled) {
398
+ const store = inputStorageType === 'local' ? defaultStorage.local : defaultStorage.session;
399
+ if (rawInput === null || Object.keys(effectiveInput).length === 0) {
400
+ const stored = store.get(inputStorageKey, null);
401
+ if (stored && typeof stored === 'object' && !isArray(stored)) {
402
+ effectiveInput = stored;
403
+ }
404
+ }
405
+ }
406
+
407
+ // Parse React Query-style automatic revalidation attributes
408
+ const staleTime = parseDurationMs(rawStale, defaultStaleTime);
409
+ const rawFocusAttr =
410
+ el.getAttribute('data-w-refetch-on-focus') ??
411
+ el.getAttribute('data-w-refetch-on-window-focus');
412
+ const refetchOnWindowFocus = parseRefetchMode(rawFocusAttr, defaultRefetchOnWindowFocus);
413
+ const refetchOnReconnect = parseRefetchMode(
414
+ el.getAttribute('data-w-refetch-on-reconnect'),
415
+ defaultRefetchOnReconnect
416
+ );
417
+ const rawMountAttr =
418
+ el.getAttribute('data-w-refetch-on-mount') ??
419
+ el.getAttribute('data-w-refetch-on-visit');
420
+ const refetchOnMount = parseRefetchMode(rawMountAttr, defaultRefetchOnMount);
421
+ const refetchInterval = parseDurationMs(rawInterval, defaultRefetchInterval);
422
+ const refetchIntervalInBackground =
423
+ parseRefetchMode(
424
+ el.getAttribute('data-w-refetch-in-background'),
425
+ defaultRefetchIntervalInBackground
426
+ ) === true;
427
+
428
+ if (refetchOnWindowFocus || refetchOnReconnect || refetchOnMount) {
429
+ ensureLifecycleListeners();
430
+ }
431
+
432
+ if (existing) {
433
+ existing.name = name;
434
+ existing.pagePath = pagePath;
435
+ existing.el = el;
436
+ existing.dependsOn = dependsOn;
437
+ if (rawInput !== null || isInputPersistEnabled) {
438
+ existing.input = effectiveInput;
439
+ }
440
+ existing.persistInput = isInputPersistEnabled ? { type: inputStorageType, key: inputStorageKey } : null;
441
+ existing.staleTime = staleTime;
442
+ existing.refetchOnWindowFocus = refetchOnWindowFocus;
443
+ existing.refetchOnReconnect = refetchOnReconnect;
444
+ existing.refetchOnMount = refetchOnMount;
445
+ existing.refetchInterval = refetchInterval;
446
+ existing.refetchIntervalInBackground = refetchIntervalInBackground;
447
+ existing._rawInput = rawInput;
448
+ existing._rawDepends = rawDepends;
449
+ existing._rawStale = rawStale;
450
+ existing._rawInterval = rawInterval;
451
+ syncInstanceInterval(existing);
452
+ } else {
453
+ const created = {
454
+ instanceId,
455
+ name,
456
+ pagePath,
457
+ input: effectiveInput,
458
+ dependsOn,
459
+ version: 0,
460
+ el,
461
+ abortController: null,
462
+ persistInput: isInputPersistEnabled ? { type: inputStorageType, key: inputStorageKey } : null,
463
+ lastUpdatedAt: Date.now(),
464
+ staleTime,
465
+ refetchOnWindowFocus,
466
+ refetchOnReconnect,
467
+ refetchOnMount,
468
+ refetchInterval,
469
+ refetchIntervalInBackground,
470
+ intervalTimer: null,
471
+ activeIntervalMs: 0,
472
+ _rawInput: rawInput,
473
+ _rawDepends: rawDepends,
474
+ _rawStale: rawStale,
475
+ _rawInterval: rawInterval,
476
+ };
477
+ instances.set(instanceId, created);
478
+ syncInstanceInterval(created);
479
+ if (refetchOnMount === 'always' || (refetchOnMount === true && staleTime <= 0)) {
480
+ scheduleAutoRefresh([instanceId], 'mount');
481
+ }
482
+ }
483
+ }
484
+
485
+ /**
486
+ * Internal DOM scan without allocating a return array.
487
+ */
488
+ function scanDomInstances(containerEl) {
489
+ const defaultRoot = getRoot();
490
+ const root = containerEl || defaultRoot;
491
+ if (!root || typeof root.querySelectorAll !== 'function') return;
492
+
493
+ const isFullScan = !containerEl || containerEl === defaultRoot;
494
+ const seenIds = isFullScan ? new Set() : null;
495
+ const nodeList = root.querySelectorAll('[data-w-fragment]');
496
+ let idx = 0;
497
+
498
+ if (root.nodeType === 1 && root.hasAttribute && root.hasAttribute('data-w-fragment')) {
499
+ syncSingleElement(root, idx++, seenIds);
500
+ }
501
+
502
+ for (let i = 0, len = nodeList.length; i < len; i++) {
503
+ syncSingleElement(nodeList[i], idx++, seenIds);
202
504
  }
203
505
 
204
506
  // Prune disconnected instances if scanning full root
205
- if (!containerEl || containerEl === getRoot()) {
507
+ if (isFullScan) {
206
508
  for (const [id, inst] of instances.entries()) {
207
509
  const disconnected =
208
510
  !seenIds.has(id) || (inst.el && inst.el.isConnected === false);
209
511
  if (disconnected) {
512
+ clearInstanceInterval(inst);
210
513
  if (inst.abortController) {
211
514
  try {
212
515
  inst.abortController.abort();
@@ -218,7 +521,13 @@ function createFragmentCoordinator(options = {}) {
218
521
  }
219
522
  }
220
523
  }
524
+ }
221
525
 
526
+ /**
527
+ * Scan DOM and synchronize tracked `[data-w-fragment]` instances.
528
+ */
529
+ function syncDomInstances(containerEl) {
530
+ scanDomInstances(containerEl);
222
531
  return Array.from(instances.values());
223
532
  }
224
533
 
@@ -255,7 +564,9 @@ function createFragmentCoordinator(options = {}) {
255
564
  pendingTags.size === 0
256
565
  ) {
257
566
  inst.input = fragmentPayload.input;
258
- inst.el.setAttribute('data-w-fragment-input', JSON.stringify(fragmentPayload.input));
567
+ const serializedInput = JSON.stringify(fragmentPayload.input);
568
+ inst.el.setAttribute('data-w-fragment-input', serializedInput);
569
+ inst._rawInput = serializedInput;
259
570
  if (inst.persistInput) {
260
571
  const store = inst.persistInput.type === 'local' ? defaultStorage.local : defaultStorage.session;
261
572
  store.set(inst.persistInput.key, fragmentPayload.input);
@@ -272,9 +583,10 @@ function createFragmentCoordinator(options = {}) {
272
583
  }
273
584
 
274
585
  const patchMs = Math.max(0, Date.now() - patchStart);
586
+ inst.lastUpdatedAt = Date.now();
275
587
 
276
588
  // Re-sync nested fragment instances that may have been updated inside this parent fragment
277
- syncDomInstances(inst.el);
589
+ scanDomInstances(inst.el);
278
590
 
279
591
  if (typeof options.onAfterPatch === 'function') {
280
592
  options.onAfterPatch(inst.el, inst);
@@ -331,7 +643,7 @@ function createFragmentCoordinator(options = {}) {
331
643
  })),
332
644
  };
333
645
 
334
- const reqId = `frag_req_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
646
+ const reqId = uid('frag_req', 4);
335
647
  if (debug) {
336
648
  debug.trackRequestStart({
337
649
  requestId: reqId,
@@ -346,7 +658,7 @@ function createFragmentCoordinator(options = {}) {
346
658
  const batchAbortController = new AbortController();
347
659
  let timeoutTimer = null;
348
660
  let timedOut = false;
349
- const timeoutMs = typeof batchTimeout === 'number' ? batchTimeout : defaultTimeout;
661
+ const timeoutMs = isNumber(batchTimeout) ? batchTimeout : defaultTimeout;
350
662
 
351
663
  if (timeoutMs > 0) {
352
664
  timeoutTimer = setTimeout(() => {
@@ -498,8 +810,8 @@ function createFragmentCoordinator(options = {}) {
498
810
  const inst = instances.get(target.instanceId);
499
811
  const fragResult = resultsMap[target.instanceId] || resultsMap[target.name];
500
812
  if (!inst || !fragResult) continue;
501
- if (typeof fragResult.loadMs === 'number') totalLoadMs += fragResult.loadMs;
502
- if (typeof fragResult.renderMs === 'number') totalRenderMs += fragResult.renderMs;
813
+ if (isNumber(fragResult.loadMs)) totalLoadMs += fragResult.loadMs;
814
+ if (isNumber(fragResult.renderMs)) totalRenderMs += fragResult.renderMs;
503
815
 
504
816
  const applied = applyFragmentPatch(
505
817
  inst,
@@ -519,7 +831,7 @@ function createFragmentCoordinator(options = {}) {
519
831
  status: 'ok',
520
832
  loadMs: totalLoadMs,
521
833
  renderMs: totalRenderMs,
522
- totalServerMs: body && typeof body.totalMs === 'number' ? body.totalMs : null,
834
+ totalServerMs: body && isNumber(body.totalMs) ? body.totalMs : null,
523
835
  });
524
836
  }
525
837
  }
@@ -557,7 +869,7 @@ function createFragmentCoordinator(options = {}) {
557
869
  pendingTags.clear();
558
870
  pendingChains.length = 0;
559
871
 
560
- syncDomInstances();
872
+ scanDomInstances();
561
873
 
562
874
  // Find all mounted fragment instances whose `dependsOn` intersects `tagsToFlush`
563
875
  const matchingInstances = [];
@@ -636,7 +948,7 @@ function createFragmentCoordinator(options = {}) {
636
948
  * @returns {Promise<{ refreshed: string[], skipped?: boolean }>}
637
949
  */
638
950
  function invalidate(tags, meta = {}) {
639
- const tagList = Array.isArray(tags) ? tags : [tags];
951
+ const tagList = isArray(tags) ? tags : [tags];
640
952
  for (const raw of tagList) {
641
953
  if (typeof raw === 'string' && raw.trim()) {
642
954
  pendingTags.add(raw.trim());
@@ -685,7 +997,7 @@ function createFragmentCoordinator(options = {}) {
685
997
  * @param {object} [refreshOpts]
686
998
  */
687
999
  async function refresh(idOrName, nextInput, refreshOpts = {}) {
688
- syncDomInstances();
1000
+ scanDomInstances();
689
1001
 
690
1002
  const matched = [];
691
1003
  if (instances.has(idOrName)) {
@@ -731,7 +1043,9 @@ function createFragmentCoordinator(options = {}) {
731
1043
  ? { ...nextInput }
732
1044
  : { ...inst.input, ...nextInput };
733
1045
  if (inst.el && typeof inst.el.setAttribute === 'function') {
734
- inst.el.setAttribute('data-w-fragment-input', JSON.stringify(inst.input));
1046
+ const serializedInput = JSON.stringify(inst.input);
1047
+ inst.el.setAttribute('data-w-fragment-input', serializedInput);
1048
+ inst._rawInput = serializedInput;
735
1049
  }
736
1050
  if (inst.persistInput) {
737
1051
  const store = inst.persistInput.type === 'local' ? defaultStorage.local : defaultStorage.session;
@@ -825,10 +1139,10 @@ function createFragmentCoordinator(options = {}) {
825
1139
  invalidate,
826
1140
  refresh,
827
1141
  refreshFragments(items = []) {
828
- if (!Array.isArray(items) || items.length === 0) {
1142
+ if (!isArray(items) || items.length === 0) {
829
1143
  return Promise.resolve({ refreshed: [], skipped: true });
830
1144
  }
831
- syncDomInstances();
1145
+ scanDomInstances();
832
1146
  for (const item of items) {
833
1147
  const key = item.instanceId || item.name;
834
1148
  const inst = instances.get(key) || Array.from(instances.values()).find((i) => i.name === key);
@@ -836,7 +1150,9 @@ function createFragmentCoordinator(options = {}) {
836
1150
  if (item.input && typeof item.input === 'object') {
837
1151
  inst.input = { ...item.input };
838
1152
  if (inst.el && typeof inst.el.setAttribute === 'function') {
839
- inst.el.setAttribute('data-w-fragment-input', JSON.stringify(inst.input));
1153
+ const serializedInput = JSON.stringify(inst.input);
1154
+ inst.el.setAttribute('data-w-fragment-input', serializedInput);
1155
+ inst._rawInput = serializedInput;
840
1156
  }
841
1157
  }
842
1158
  pendingTags.add(inst.name);
@@ -847,8 +1163,8 @@ function createFragmentCoordinator(options = {}) {
847
1163
  return flushInvalidations();
848
1164
  },
849
1165
  findFragmentsByTags(tags = []) {
850
- syncDomInstances();
851
- const tagSet = new Set((Array.isArray(tags) ? tags : [tags]).map((t) => String(t || '').trim()).filter(Boolean));
1166
+ scanDomInstances();
1167
+ const tagSet = new Set((isArray(tags) ? tags : [tags]).map((t) => String(t || '').trim()).filter(Boolean));
852
1168
  const matching = [];
853
1169
  for (const inst of instances.values()) {
854
1170
  for (const t of tagSet) {
@@ -863,11 +1179,15 @@ function createFragmentCoordinator(options = {}) {
863
1179
  retryFailed,
864
1180
  retryLastFailed: retryFailed,
865
1181
  getInstance(instanceId) {
866
- syncDomInstances();
1182
+ const hit = instances.get(instanceId);
1183
+ if (hit && (!hit.el || hit.el.isConnected !== false)) {
1184
+ return hit;
1185
+ }
1186
+ scanDomInstances();
867
1187
  return instances.get(instanceId) || null;
868
1188
  },
869
1189
  getInstances() {
870
- syncDomInstances();
1190
+ scanDomInstances();
871
1191
  return Array.from(instances.values());
872
1192
  },
873
1193
  getStoredInput(instanceIdOrName, storageType = 'session') {
@@ -881,6 +1201,24 @@ function createFragmentCoordinator(options = {}) {
881
1201
  get hasFailedRefresh() {
882
1202
  return Boolean(lastFailedBatch);
883
1203
  },
1204
+ dispose() {
1205
+ isDisposed = true;
1206
+ if (focusDebounceTimer) {
1207
+ clearTimeout(focusDebounceTimer);
1208
+ focusDebounceTimer = null;
1209
+ }
1210
+ pendingAutoInstanceIds.clear();
1211
+ removeLifecycleListeners();
1212
+ for (const inst of instances.values()) {
1213
+ clearInstanceInterval(inst);
1214
+ if (inst.abortController) {
1215
+ try {
1216
+ inst.abortController.abort();
1217
+ } catch (_e) {}
1218
+ inst.abortController = null;
1219
+ }
1220
+ }
1221
+ },
884
1222
  };
885
1223
  }
886
1224