webspresso 0.0.110 → 0.0.112

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,12 +59,22 @@ 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
  }
65
66
  }
66
67
 
68
+ function isStrictAncestor(parentEl, childEl) {
69
+ return Boolean(
70
+ parentEl &&
71
+ childEl &&
72
+ parentEl !== childEl &&
73
+ typeof parentEl.contains === 'function' &&
74
+ parentEl.contains(childEl)
75
+ );
76
+ }
77
+
67
78
  /**
68
79
  * Filter a list of fragment records so that if fragment B's DOM element is inside
69
80
  * fragment A's DOM subtree, B is omitted from the batch because refreshing A will
@@ -78,7 +89,7 @@ function deduplicateParentChildFragments(candidates) {
78
89
  if (!candidate.el) return true;
79
90
  for (const other of candidates) {
80
91
  if (other === candidate || !other.el) continue;
81
- if (other.el !== candidate.el && typeof other.el.contains === 'function' && other.el.contains(candidate.el)) {
92
+ if (isStrictAncestor(other.el, candidate.el)) {
82
93
  return false;
83
94
  }
84
95
  }
@@ -108,9 +119,24 @@ function createFragmentCoordinator(options = {}) {
108
119
  const endpoint = options.endpoint || DEFAULT_FRAGMENT_ENDPOINT;
109
120
  const datastar = options.datastar || options.adapter;
110
121
  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 }>
122
+ const defaultTimeout = isNumber(options.timeout) ? options.timeout : 15000;
123
+
124
+ // React Query-style automatic revalidation defaults
125
+ const defaultStaleTime = parseDurationMs(options.staleTime, 0);
126
+ const defaultRefetchOnWindowFocus = parseRefetchMode(
127
+ options.refetchOnWindowFocus !== undefined ? options.refetchOnWindowFocus : options.refetchOnFocus,
128
+ false
129
+ );
130
+ const defaultRefetchOnReconnect = parseRefetchMode(options.refetchOnReconnect, false);
131
+ const defaultRefetchOnMount = parseRefetchMode(
132
+ options.refetchOnMount !== undefined ? options.refetchOnMount : options.refetchOnVisit,
133
+ false
134
+ );
135
+ const defaultRefetchInterval = parseDurationMs(options.refetchInterval, 0);
136
+ const defaultRefetchIntervalInBackground =
137
+ parseRefetchMode(options.refetchIntervalInBackground, false) === true;
138
+
139
+ // Map<instanceId, { instanceId, name, pagePath, input, dependsOn: Set<string>, version: number, el: Element, abortController: AbortController|null, ... }>
114
140
  const instances = new Map();
115
141
 
116
142
  // Coalescing & in-flight state for tag invalidations
@@ -122,91 +148,416 @@ function createFragmentCoordinator(options = {}) {
122
148
  let lastFailedBatch = null;
123
149
  const inflightBatchWaiters = [];
124
150
 
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 [];
151
+ // Automatic refetch (focus / reconnect / mount / interval) state
152
+ const pendingAutoInstanceIds = new Set();
153
+ const pendingAutoReasons = new Set();
154
+ let autoBatchScheduled = false;
155
+ let autoBatchTimer = null;
156
+ let focusDebounceTimer = null;
157
+ let isDisposed = false;
158
+ let lifecycleListenersAttached = false;
159
+
160
+ function isDocumentHidden() {
161
+ const root = getRoot();
162
+ const doc = root && root.nodeType === 9 ? root : root && root.ownerDocument ? root.ownerDocument : typeof document !== 'undefined' ? document : null;
163
+ return Boolean(doc && doc.visibilityState === 'hidden');
164
+ }
131
165
 
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
- }
166
+ function isInstanceStale(inst, mode) {
167
+ if (!inst || !mode) return false;
168
+ if (mode === 'always') return true;
169
+ const staleMs = isNumber(inst.staleTime) ? inst.staleTime : 0;
170
+ if (staleMs <= 0) return true;
171
+ const elapsed = Date.now() - (inst.lastUpdatedAt || 0);
172
+ return elapsed >= staleMs;
173
+ }
174
+
175
+ function clearInstanceInterval(inst) {
176
+ if (inst && inst.intervalTimer) {
177
+ clearInterval(inst.intervalTimer);
178
+ inst.intervalTimer = null;
179
+ inst.activeIntervalMs = 0;
180
+ }
181
+ }
182
+
183
+ function syncInstanceInterval(inst) {
184
+ if (!inst || isDisposed) return;
185
+ const targetMs = isNumber(inst.refetchInterval) && inst.refetchInterval > 0 ? inst.refetchInterval : 0;
186
+ if (targetMs <= 0) {
187
+ clearInstanceInterval(inst);
188
+ return;
189
+ }
190
+ if (inst.intervalTimer && inst.activeIntervalMs === targetMs) {
191
+ return;
192
+ }
193
+ clearInstanceInterval(inst);
194
+ inst.activeIntervalMs = targetMs;
195
+ inst.intervalTimer = setInterval(() => {
196
+ if (isDisposed || !inst.el || inst.el.isConnected === false) {
197
+ clearInstanceInterval(inst);
198
+ return;
199
+ }
200
+ if (!inst.refetchIntervalInBackground && isDocumentHidden()) {
201
+ return;
202
+ }
203
+ scheduleAutoRefresh([inst.instanceId], 'interval');
204
+ }, targetMs);
205
+ if (inst.intervalTimer && typeof inst.intervalTimer.unref === 'function') {
206
+ inst.intervalTimer.unref();
207
+ }
208
+ }
209
+
210
+ async function flushAutoRefresh() {
211
+ autoBatchScheduled = false;
212
+ if (autoBatchTimer) {
213
+ clearTimeout(autoBatchTimer);
214
+ autoBatchTimer = null;
215
+ }
216
+ if (isDisposed || pendingAutoInstanceIds.size === 0) return;
217
+ const idsToRefresh = Array.from(pendingAutoInstanceIds);
218
+ const reasons = Array.from(pendingAutoReasons);
219
+ pendingAutoInstanceIds.clear();
220
+ pendingAutoReasons.clear();
221
+
222
+ // Skip background auto-refresh if a mutation invalidation batch is currently in flight
223
+ if (inFlightBatchCount > 0) return;
224
+
225
+ const candidates = [];
226
+ for (const id of idsToRefresh) {
227
+ const inst = instances.get(id);
228
+ if (
229
+ inst &&
230
+ inst.el &&
231
+ inst.el.isConnected !== false &&
232
+ !inst.abortController &&
233
+ !(inst.inFlightCount > 0)
234
+ ) {
235
+ candidates.push(inst);
236
+ }
237
+ }
238
+ if (candidates.length === 0) return;
239
+
240
+ const deduped = deduplicateParentChildFragments(candidates);
241
+ const targets = deduped.map((inst) => {
242
+ inst.version += 1;
243
+ return {
244
+ instanceId: inst.instanceId,
245
+ name: inst.name,
246
+ pagePath: inst.pagePath,
247
+ input: { ...inst.input },
248
+ reqVersion: inst.version,
249
+ };
250
+ });
251
+
252
+ if (debug && typeof debug.recordEvent === 'function') {
253
+ debug.recordEvent('fragment:auto-refetch', {
254
+ reason: reasons.length === 1 ? reasons[0] : reasons.join('+'),
255
+ reasons,
256
+ fragments: targets.map((t) => t.instanceId),
257
+ });
258
+ }
259
+
260
+ try {
261
+ await executeBatchRefresh(targets);
262
+ } catch (_err) {
263
+ // Background auto-refetch errors are non-fatal
264
+ }
265
+ }
266
+
267
+ function scheduleAutoRefresh(instanceIds, reason = 'auto') {
268
+ if (isDisposed || !isArray(instanceIds) || instanceIds.length === 0) return;
269
+ for (const id of instanceIds) {
270
+ if (id) pendingAutoInstanceIds.add(id);
271
+ }
272
+ if (reason) pendingAutoReasons.add(reason);
273
+ if (pendingAutoInstanceIds.size === 0) return;
274
+
275
+ if (reason === 'mount') {
276
+ if (!autoBatchScheduled) {
277
+ autoBatchScheduled = true;
278
+ Promise.resolve().then(flushAutoRefresh);
279
+ }
280
+ return;
281
+ }
282
+
283
+ if (autoBatchTimer || autoBatchScheduled) return;
284
+ autoBatchTimer = setTimeout(flushAutoRefresh, 5);
285
+ }
286
+
287
+ function triggerFocusRefetch() {
288
+ if (isDisposed || isDocumentHidden()) return;
289
+ scanDomInstances();
290
+ const matchedIds = [];
291
+ for (const inst of instances.values()) {
292
+ if (isInstanceStale(inst, inst.refetchOnWindowFocus)) {
293
+ matchedIds.push(inst.instanceId);
294
+ }
295
+ }
296
+ if (matchedIds.length > 0) {
297
+ scheduleAutoRefresh(matchedIds, 'window-focus');
298
+ }
299
+ }
300
+
301
+ function triggerReconnectRefetch() {
302
+ if (isDisposed) return;
303
+ scanDomInstances();
304
+ const matchedIds = [];
305
+ for (const inst of instances.values()) {
306
+ if (isInstanceStale(inst, inst.refetchOnReconnect)) {
307
+ matchedIds.push(inst.instanceId);
308
+ }
309
+ }
310
+ if (matchedIds.length > 0) {
311
+ scheduleAutoRefresh(matchedIds, 'reconnect');
312
+ }
313
+ }
314
+
315
+ function onVisibilityChange() {
316
+ if (!isDocumentHidden()) {
317
+ triggerFocusRefetch();
318
+ }
319
+ }
320
+
321
+ function onPageShow(event) {
322
+ if (isDisposed) return;
323
+ if (event && event.persisted) {
324
+ scanDomInstances();
325
+ const matchedIds = [];
326
+ for (const inst of instances.values()) {
327
+ const mode = inst.refetchOnMount || inst.refetchOnWindowFocus;
328
+ if (isInstanceStale(inst, mode)) {
329
+ matchedIds.push(inst.instanceId);
176
330
  }
177
331
  }
332
+ if (matchedIds.length > 0) {
333
+ scheduleAutoRefresh(matchedIds, 'pageshow');
334
+ }
335
+ }
336
+ }
337
+
338
+ function ensureLifecycleListeners() {
339
+ if (lifecycleListenersAttached || isDisposed) return;
340
+ const root = getRoot();
341
+ const doc = root && root.nodeType === 9 ? root : root && root.ownerDocument ? root.ownerDocument : typeof document !== 'undefined' ? document : null;
342
+ const win = (doc && doc.defaultView) || (typeof window !== 'undefined' ? window : null);
343
+
344
+ if (doc && typeof doc.addEventListener === 'function') {
345
+ doc.addEventListener('visibilitychange', onVisibilityChange);
346
+ }
347
+ if (win && typeof win.addEventListener === 'function') {
348
+ win.addEventListener('focus', triggerFocusRefetch);
349
+ win.addEventListener('online', triggerReconnectRefetch);
350
+ win.addEventListener('pageshow', onPageShow);
351
+ }
352
+ lifecycleListenersAttached = true;
353
+ }
354
+
355
+ function removeLifecycleListeners() {
356
+ if (!lifecycleListenersAttached) return;
357
+ const root = getRoot();
358
+ const doc = root && root.nodeType === 9 ? root : root && root.ownerDocument ? root.ownerDocument : typeof document !== 'undefined' ? document : null;
359
+ const win = (doc && doc.defaultView) || (typeof window !== 'undefined' ? window : null);
360
+
361
+ if (doc && typeof doc.removeEventListener === 'function') {
362
+ doc.removeEventListener('visibilitychange', onVisibilityChange);
363
+ }
364
+ if (win && typeof win.removeEventListener === 'function') {
365
+ win.removeEventListener('focus', triggerFocusRefetch);
366
+ win.removeEventListener('online', triggerReconnectRefetch);
367
+ win.removeEventListener('pageshow', onPageShow);
368
+ }
369
+ lifecycleListenersAttached = false;
370
+ }
371
+
372
+ function syncSingleElement(el, index, seenIds) {
373
+ const name = (el.getAttribute('data-w-fragment') || '').trim();
374
+ if (!name) return;
375
+
376
+ let instanceId = (el.getAttribute('data-w-fragment-id') || '').trim();
377
+ if (!instanceId) {
378
+ instanceId = index === 0 ? `${name}:default` : `${name}:${index}`;
379
+ el.setAttribute('data-w-fragment-id', instanceId);
380
+ }
381
+
382
+ if (seenIds) seenIds.add(instanceId);
383
+
384
+ const existing = instances.get(instanceId);
385
+ const rawInput = el.getAttribute('data-w-fragment-input');
386
+ const rawDepends = el.getAttribute('data-w-depends-on');
387
+ const rawStale = el.getAttribute('data-w-stale-time');
388
+ const rawInterval = el.getAttribute('data-w-refetch-interval');
389
+
390
+ // Fast-path: if the same DOM element is already registered and its reactive attributes are unchanged,
391
+ // skip JSON.parse, Set allocation, storage lookup, and timer reconfiguration.
392
+ if (
393
+ existing &&
394
+ existing.el === el &&
395
+ existing.name === name &&
396
+ existing._rawInput === rawInput &&
397
+ existing._rawDepends === rawDepends &&
398
+ existing._rawStale === rawStale &&
399
+ existing._rawInterval === rawInterval
400
+ ) {
401
+ return;
402
+ }
178
403
 
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;
404
+ const pagePath =
405
+ (el.getAttribute('data-w-fragment-page') || '').trim() ||
406
+ (typeof window !== 'undefined' && window.location ? window.location.pathname : '/');
407
+ const input =
408
+ existing && existing.el === el && existing._rawInput === rawInput
409
+ ? existing.input
410
+ : parseFragmentInputAttr(rawInput);
411
+ const dependsOn =
412
+ existing && existing.el === el && existing._rawDepends === rawDepends
413
+ ? existing.dependsOn
414
+ : new Set(parseDependsOnAttr(rawDepends));
415
+
416
+ // Fragment input persistence setup
417
+ const rawPersistInput =
418
+ el.getAttribute('data-w-persist-input') ||
419
+ (el.getAttribute('data-w-persist') === 'input' ? 'session' : null);
420
+ const isInputPersistEnabled = Boolean(rawPersistInput && rawPersistInput !== 'false');
421
+ const inputStorageType = rawPersistInput && rawPersistInput.toLowerCase() === 'local' ? 'local' : 'session';
422
+ const inputStorageKey =
423
+ el.getAttribute('data-w-persist-key') ||
424
+ el.getAttribute('data-w-input-key') ||
425
+ `frag_input:${name}`;
426
+
427
+ let effectiveInput = input;
428
+ let pendingRestoredInput = null;
429
+ if (!existing && isInputPersistEnabled) {
430
+ const store = inputStorageType === 'local' ? defaultStorage.local : defaultStorage.session;
431
+ let stored = store.get(inputStorageKey, null);
432
+ if (
433
+ (!stored || typeof stored !== 'object' || isArray(stored)) &&
434
+ inputStorageKey !== `frag_input:${instanceId}`
435
+ ) {
436
+ stored = store.get(`frag_input:${instanceId}`, null);
437
+ }
438
+ if (stored && typeof stored === 'object' && !isArray(stored)) {
439
+ if (rawInput === null || Object.keys(effectiveInput).length === 0) {
440
+ effectiveInput = { ...stored };
441
+ } else if (JSON.stringify(stored) !== JSON.stringify(effectiveInput)) {
442
+ pendingRestoredInput = { ...stored };
187
443
  }
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,
444
+ }
445
+ }
446
+
447
+ // Parse React Query-style automatic revalidation attributes
448
+ const staleTime = parseDurationMs(rawStale, defaultStaleTime);
449
+ const rawFocusAttr =
450
+ el.getAttribute('data-w-refetch-on-focus') ??
451
+ el.getAttribute('data-w-refetch-on-window-focus');
452
+ const refetchOnWindowFocus = parseRefetchMode(rawFocusAttr, defaultRefetchOnWindowFocus);
453
+ const refetchOnReconnect = parseRefetchMode(
454
+ el.getAttribute('data-w-refetch-on-reconnect'),
455
+ defaultRefetchOnReconnect
456
+ );
457
+ const rawMountAttr =
458
+ el.getAttribute('data-w-refetch-on-mount') ??
459
+ el.getAttribute('data-w-refetch-on-visit');
460
+ const refetchOnMount = parseRefetchMode(rawMountAttr, defaultRefetchOnMount);
461
+ const refetchInterval = parseDurationMs(rawInterval, defaultRefetchInterval);
462
+ const refetchIntervalInBackground =
463
+ parseRefetchMode(
464
+ el.getAttribute('data-w-refetch-in-background'),
465
+ defaultRefetchIntervalInBackground
466
+ ) === true;
467
+
468
+ if (refetchOnWindowFocus || refetchOnReconnect || refetchOnMount) {
469
+ ensureLifecycleListeners();
470
+ }
471
+
472
+ if (existing) {
473
+ existing.name = name;
474
+ existing.pagePath = pagePath;
475
+ existing.el = el;
476
+ existing.dependsOn = dependsOn;
477
+ if (rawInput !== null && rawInput !== existing._rawInput) {
478
+ existing.input = input;
479
+ }
480
+ existing.persistInput = isInputPersistEnabled ? { type: inputStorageType, key: inputStorageKey } : null;
481
+ existing.staleTime = staleTime;
482
+ existing.refetchOnWindowFocus = refetchOnWindowFocus;
483
+ existing.refetchOnReconnect = refetchOnReconnect;
484
+ existing.refetchOnMount = refetchOnMount;
485
+ existing.refetchInterval = refetchInterval;
486
+ existing.refetchIntervalInBackground = refetchIntervalInBackground;
487
+ existing._rawInput = rawInput;
488
+ existing._rawDepends = rawDepends;
489
+ existing._rawStale = rawStale;
490
+ existing._rawInterval = rawInterval;
491
+ syncInstanceInterval(existing);
492
+ } else {
493
+ const created = {
494
+ instanceId,
495
+ name,
496
+ pagePath,
497
+ input: effectiveInput,
498
+ restoredInput: pendingRestoredInput,
499
+ dependsOn,
500
+ version: 0,
501
+ el,
502
+ abortController: null,
503
+ inFlightCount: 0,
504
+ persistInput: isInputPersistEnabled ? { type: inputStorageType, key: inputStorageKey } : null,
505
+ lastUpdatedAt: Date.now(),
506
+ staleTime,
507
+ refetchOnWindowFocus,
508
+ refetchOnReconnect,
509
+ refetchOnMount,
510
+ refetchInterval,
511
+ refetchIntervalInBackground,
512
+ intervalTimer: null,
513
+ activeIntervalMs: 0,
514
+ _rawInput: rawInput,
515
+ _rawDepends: rawDepends,
516
+ _rawStale: rawStale,
517
+ _rawInterval: rawInterval,
518
+ };
519
+ instances.set(instanceId, created);
520
+ syncInstanceInterval(created);
521
+ if (pendingRestoredInput) {
522
+ Promise.resolve().then(() => {
523
+ if (!isDisposed && created.version === 0 && instances.get(instanceId) === created) {
524
+ refresh(instanceId, pendingRestoredInput).catch(() => {});
525
+ }
200
526
  });
527
+ } else if (refetchOnMount === 'always' || (refetchOnMount === true && staleTime <= 0)) {
528
+ scheduleAutoRefresh([instanceId], 'mount');
201
529
  }
202
530
  }
531
+ }
532
+
533
+ /**
534
+ * Internal DOM scan without allocating a return array.
535
+ */
536
+ function scanDomInstances(containerEl) {
537
+ const defaultRoot = getRoot();
538
+ const root = containerEl || defaultRoot;
539
+ if (!root || typeof root.querySelectorAll !== 'function') return;
540
+
541
+ const isFullScan = !containerEl || containerEl === defaultRoot;
542
+ const seenIds = isFullScan ? new Set() : null;
543
+ const nodeList = root.querySelectorAll('[data-w-fragment]');
544
+ let idx = 0;
545
+
546
+ if (root.nodeType === 1 && root.hasAttribute && root.hasAttribute('data-w-fragment')) {
547
+ syncSingleElement(root, idx++, seenIds);
548
+ }
549
+
550
+ for (let i = 0, len = nodeList.length; i < len; i++) {
551
+ syncSingleElement(nodeList[i], idx++, seenIds);
552
+ }
203
553
 
204
554
  // Prune disconnected instances if scanning full root
205
- if (!containerEl || containerEl === getRoot()) {
555
+ if (isFullScan) {
206
556
  for (const [id, inst] of instances.entries()) {
207
557
  const disconnected =
208
558
  !seenIds.has(id) || (inst.el && inst.el.isConnected === false);
209
559
  if (disconnected) {
560
+ clearInstanceInterval(inst);
210
561
  if (inst.abortController) {
211
562
  try {
212
563
  inst.abortController.abort();
@@ -218,7 +569,13 @@ function createFragmentCoordinator(options = {}) {
218
569
  }
219
570
  }
220
571
  }
572
+ }
221
573
 
574
+ /**
575
+ * Scan DOM and synchronize tracked `[data-w-fragment]` instances.
576
+ */
577
+ function syncDomInstances(containerEl) {
578
+ scanDomInstances(containerEl);
222
579
  return Array.from(instances.values());
223
580
  }
224
581
 
@@ -226,7 +583,7 @@ function createFragmentCoordinator(options = {}) {
226
583
  * Apply a single server fragment result `{ instanceId, name, html, input, loadMs, renderMs }`
227
584
  * to the DOM if and only if `reqVersion >= inst.version`.
228
585
  */
229
- function applyFragmentPatch(inst, fragmentPayload, reqVersion, chainId = null) {
586
+ function applyFragmentPatch(inst, fragmentPayload, reqVersion, chainId = null, requestedInput = null) {
230
587
  if (!inst || !inst.el) return false;
231
588
 
232
589
  if (reqVersion < inst.version) {
@@ -248,17 +605,27 @@ function createFragmentCoordinator(options = {}) {
248
605
 
249
606
  const patchStart = Date.now();
250
607
 
608
+ const committedInput =
609
+ fragmentPayload.input && typeof fragmentPayload.input === 'object' && !isArray(fragmentPayload.input)
610
+ ? fragmentPayload.input
611
+ : requestedInput && typeof requestedInput === 'object' && !isArray(requestedInput)
612
+ ? requestedInput
613
+ : null;
614
+
251
615
  if (
252
- fragmentPayload.input &&
253
- typeof fragmentPayload.input === 'object' &&
616
+ committedInput &&
254
617
  !followUpNeeded &&
255
618
  pendingTags.size === 0
256
619
  ) {
257
- inst.input = fragmentPayload.input;
258
- inst.el.setAttribute('data-w-fragment-input', JSON.stringify(fragmentPayload.input));
620
+ inst.input = { ...committedInput };
621
+ const serializedInput = JSON.stringify(inst.input);
622
+ if (inst.el && typeof inst.el.setAttribute === 'function') {
623
+ inst.el.setAttribute('data-w-fragment-input', serializedInput);
624
+ }
625
+ inst._rawInput = serializedInput;
259
626
  if (inst.persistInput) {
260
627
  const store = inst.persistInput.type === 'local' ? defaultStorage.local : defaultStorage.session;
261
- store.set(inst.persistInput.key, fragmentPayload.input);
628
+ store.set(inst.persistInput.key, inst.input);
262
629
  }
263
630
  }
264
631
 
@@ -272,9 +639,16 @@ function createFragmentCoordinator(options = {}) {
272
639
  }
273
640
 
274
641
  const patchMs = Math.max(0, Date.now() - patchStart);
642
+ const now = Date.now();
643
+ inst.lastUpdatedAt = now;
275
644
 
276
645
  // Re-sync nested fragment instances that may have been updated inside this parent fragment
277
- syncDomInstances(inst.el);
646
+ scanDomInstances(inst.el);
647
+ for (const otherInst of instances.values()) {
648
+ if (otherInst !== inst && otherInst.el && isStrictAncestor(inst.el, otherInst.el)) {
649
+ otherInst.lastUpdatedAt = now;
650
+ }
651
+ }
278
652
 
279
653
  if (typeof options.onAfterPatch === 'function') {
280
654
  options.onAfterPatch(inst.el, inst);
@@ -321,6 +695,26 @@ function createFragmentCoordinator(options = {}) {
321
695
  targets[0].pagePath ||
322
696
  (typeof window !== 'undefined' && window.location ? window.location.pathname : '/');
323
697
 
698
+ // Collect active inputs of any nested descendant fragments inside the requested parent targets
699
+ // so server-side parent re-rendering preserves nested child filters & pagination.
700
+ const childInputs = {};
701
+ let hasChildInputs = false;
702
+ for (const t of targets) {
703
+ const parentInst = instances.get(t.instanceId);
704
+ if (!parentInst || !parentInst.el) continue;
705
+ for (const otherInst of instances.values()) {
706
+ if (otherInst !== parentInst && otherInst.el && isStrictAncestor(parentInst.el, otherInst.el)) {
707
+ if (otherInst.input && typeof otherInst.input === 'object') {
708
+ childInputs[otherInst.instanceId] = { ...otherInst.input };
709
+ if (!childInputs[otherInst.name]) {
710
+ childInputs[otherInst.name] = { ...otherInst.input };
711
+ }
712
+ hasChildInputs = true;
713
+ }
714
+ }
715
+ }
716
+ }
717
+
324
718
  const requestPayload = {
325
719
  pagePath,
326
720
  fragments: targets.map((t) => ({
@@ -329,9 +723,32 @@ function createFragmentCoordinator(options = {}) {
329
723
  input: t.input || {},
330
724
  version: t.reqVersion,
331
725
  })),
726
+ ...(hasChildInputs ? { childInputs } : {}),
727
+ };
728
+
729
+ for (const t of targets) {
730
+ const inst = instances.get(t.instanceId);
731
+ if (inst) {
732
+ inst.inFlightCount = (inst.inFlightCount || 0) + 1;
733
+ if (inst.el && typeof inst.el.setAttribute === 'function') {
734
+ inst.el.setAttribute('aria-busy', 'true');
735
+ }
736
+ }
737
+ }
738
+
739
+ const decrementTargetBusy = () => {
740
+ for (const t of targets) {
741
+ const inst = instances.get(t.instanceId);
742
+ if (inst) {
743
+ inst.inFlightCount = Math.max(0, (inst.inFlightCount || 1) - 1);
744
+ if (inst.inFlightCount === 0 && inst.el && typeof inst.el.removeAttribute === 'function') {
745
+ inst.el.removeAttribute('aria-busy');
746
+ }
747
+ }
748
+ }
332
749
  };
333
750
 
334
- const reqId = `frag_req_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
751
+ const reqId = uid('frag_req', 4);
335
752
  if (debug) {
336
753
  debug.trackRequestStart({
337
754
  requestId: reqId,
@@ -346,7 +763,7 @@ function createFragmentCoordinator(options = {}) {
346
763
  const batchAbortController = new AbortController();
347
764
  let timeoutTimer = null;
348
765
  let timedOut = false;
349
- const timeoutMs = typeof batchTimeout === 'number' ? batchTimeout : defaultTimeout;
766
+ const timeoutMs = isNumber(batchTimeout) ? batchTimeout : defaultTimeout;
350
767
 
351
768
  if (timeoutMs > 0) {
352
769
  timeoutTimer = setTimeout(() => {
@@ -374,6 +791,7 @@ function createFragmentCoordinator(options = {}) {
374
791
  signal: batchAbortController.signal,
375
792
  });
376
793
  } catch (err) {
794
+ decrementTargetBusy();
377
795
  if (timeoutTimer) {
378
796
  clearTimeout(timeoutTimer);
379
797
  timeoutTimer = null;
@@ -454,6 +872,8 @@ function createFragmentCoordinator(options = {}) {
454
872
  }
455
873
  } catch (_e) {
456
874
  body = null;
875
+ } finally {
876
+ decrementTargetBusy();
457
877
  }
458
878
 
459
879
  if (!response.ok) {
@@ -489,8 +909,20 @@ function createFragmentCoordinator(options = {}) {
489
909
  }
490
910
 
491
911
  lastFailedBatch = null;
492
- const resultsMap = (body && body.fragments) || {};
912
+ const rawFragments = (body && body.fragments) || {};
913
+ const resultsMap = {};
914
+ if (isArray(rawFragments)) {
915
+ for (const item of rawFragments) {
916
+ if (item && typeof item === 'object') {
917
+ if (item.instanceId) resultsMap[item.instanceId] = item;
918
+ if (item.name && !resultsMap[item.name]) resultsMap[item.name] = item;
919
+ }
920
+ }
921
+ } else if (rawFragments && typeof rawFragments === 'object') {
922
+ Object.assign(resultsMap, rawFragments);
923
+ }
493
924
  const refreshedIds = [];
925
+ let staleDiscardCount = 0;
494
926
  let totalLoadMs = 0;
495
927
  let totalRenderMs = 0;
496
928
 
@@ -498,32 +930,53 @@ function createFragmentCoordinator(options = {}) {
498
930
  const inst = instances.get(target.instanceId);
499
931
  const fragResult = resultsMap[target.instanceId] || resultsMap[target.name];
500
932
  if (!inst || !fragResult) continue;
501
- if (typeof fragResult.loadMs === 'number') totalLoadMs += fragResult.loadMs;
502
- if (typeof fragResult.renderMs === 'number') totalRenderMs += fragResult.renderMs;
933
+ if (target.reqVersion < inst.version) {
934
+ staleDiscardCount += 1;
935
+ }
936
+ if (isNumber(fragResult.loadMs)) totalLoadMs += fragResult.loadMs;
937
+ if (isNumber(fragResult.renderMs)) totalRenderMs += fragResult.renderMs;
503
938
 
504
939
  const applied = applyFragmentPatch(
505
940
  inst,
506
941
  fragResult,
507
942
  target.reqVersion,
508
- chainIds[0] || null
943
+ chainIds[0] || null,
944
+ target.input
509
945
  );
510
946
  if (applied) {
511
947
  refreshedIds.push(target.instanceId);
512
948
  }
513
949
  }
514
950
 
951
+ const allSuperseded =
952
+ refreshedIds.length === 0 && staleDiscardCount > 0 && staleDiscardCount === targets.length;
953
+
515
954
  for (const cid of chainIds) {
516
955
  if (debug) {
517
956
  debug.recordChainRefresh(cid, {
518
957
  fragmentIds: refreshedIds,
519
- status: 'ok',
958
+ status: allSuperseded ? 'superseded' : 'ok',
520
959
  loadMs: totalLoadMs,
521
960
  renderMs: totalRenderMs,
522
- totalServerMs: body && typeof body.totalMs === 'number' ? body.totalMs : null,
961
+ totalServerMs: body && isNumber(body.totalMs) ? body.totalMs : null,
523
962
  });
524
963
  }
525
964
  }
526
965
 
966
+ if (allSuperseded) {
967
+ return {
968
+ ok: false,
969
+ canceled: true,
970
+ superseded: true,
971
+ refreshed: [],
972
+ errors: [],
973
+ timings: {
974
+ loadMs: totalLoadMs,
975
+ renderMs: totalRenderMs,
976
+ },
977
+ };
978
+ }
979
+
527
980
  return {
528
981
  ok: true,
529
982
  refreshed: refreshedIds,
@@ -557,7 +1010,7 @@ function createFragmentCoordinator(options = {}) {
557
1010
  pendingTags.clear();
558
1011
  pendingChains.length = 0;
559
1012
 
560
- syncDomInstances();
1013
+ scanDomInstances();
561
1014
 
562
1015
  // Find all mounted fragment instances whose `dependsOn` intersects `tagsToFlush`
563
1016
  const matchingInstances = [];
@@ -636,7 +1089,7 @@ function createFragmentCoordinator(options = {}) {
636
1089
  * @returns {Promise<{ refreshed: string[], skipped?: boolean }>}
637
1090
  */
638
1091
  function invalidate(tags, meta = {}) {
639
- const tagList = Array.isArray(tags) ? tags : [tags];
1092
+ const tagList = isArray(tags) ? tags : [tags];
640
1093
  for (const raw of tagList) {
641
1094
  if (typeof raw === 'string' && raw.trim()) {
642
1095
  pendingTags.add(raw.trim());
@@ -685,7 +1138,7 @@ function createFragmentCoordinator(options = {}) {
685
1138
  * @param {object} [refreshOpts]
686
1139
  */
687
1140
  async function refresh(idOrName, nextInput, refreshOpts = {}) {
688
- syncDomInstances();
1141
+ scanDomInstances();
689
1142
 
690
1143
  const matched = [];
691
1144
  if (instances.has(idOrName)) {
@@ -726,32 +1179,35 @@ function createFragmentCoordinator(options = {}) {
726
1179
  inst.abortController = abortController;
727
1180
  inst.version += 1;
728
1181
 
729
- if (nextInput && typeof nextInput === 'object') {
730
- inst.input = refreshOpts.replaceInput
731
- ? { ...nextInput }
732
- : { ...inst.input, ...nextInput };
733
- if (inst.el && typeof inst.el.setAttribute === 'function') {
734
- inst.el.setAttribute('data-w-fragment-input', JSON.stringify(inst.input));
735
- }
736
- if (inst.persistInput) {
737
- const store = inst.persistInput.type === 'local' ? defaultStorage.local : defaultStorage.session;
738
- store.set(inst.persistInput.key, inst.input);
739
- }
740
- }
1182
+ const candidateInput =
1183
+ nextInput && typeof nextInput === 'object'
1184
+ ? refreshOpts.replaceInput
1185
+ ? { ...nextInput }
1186
+ : { ...inst.input, ...nextInput }
1187
+ : { ...inst.input };
741
1188
 
742
1189
  return {
743
1190
  instanceId: inst.instanceId,
744
1191
  name: inst.name,
745
1192
  pagePath: inst.pagePath,
746
- input: { ...inst.input },
1193
+ input: candidateInput,
747
1194
  reqVersion: inst.version,
748
1195
  };
749
1196
  });
750
1197
 
1198
+ const clearOwnedAbortControllers = () => {
1199
+ for (const inst of deduped) {
1200
+ if (inst.abortController === abortController) {
1201
+ inst.abortController = null;
1202
+ }
1203
+ }
1204
+ };
1205
+
751
1206
  try {
752
1207
  const result = await executeBatchRefresh(targets, [], abortController.signal, refreshOpts.timeout);
1208
+ clearOwnedAbortControllers();
753
1209
  const enrichedResult = { ok: true, ...result };
754
- if (!enrichedResult.canceled && enrichedResult.ok !== false) {
1210
+ if (!enrichedResult.canceled && !enrichedResult.superseded && enrichedResult.ok !== false) {
755
1211
  if (typeof refreshOpts.onSuccess === 'function') {
756
1212
  await refreshOpts.onSuccess(enrichedResult);
757
1213
  }
@@ -763,6 +1219,7 @@ function createFragmentCoordinator(options = {}) {
763
1219
  }
764
1220
  return enrichedResult;
765
1221
  } catch (err) {
1222
+ clearOwnedAbortControllers();
766
1223
  const failHandler = refreshOpts.onFailure || refreshOpts.onError;
767
1224
  if (typeof failHandler === 'function') {
768
1225
  try {
@@ -807,7 +1264,7 @@ function createFragmentCoordinator(options = {}) {
807
1264
  inst.version += 1;
808
1265
  retryTargets.push({
809
1266
  ...t,
810
- input: { ...inst.input },
1267
+ input: t.input ? { ...t.input } : { ...inst.input },
811
1268
  reqVersion: inst.version,
812
1269
  });
813
1270
  }
@@ -825,10 +1282,10 @@ function createFragmentCoordinator(options = {}) {
825
1282
  invalidate,
826
1283
  refresh,
827
1284
  refreshFragments(items = []) {
828
- if (!Array.isArray(items) || items.length === 0) {
1285
+ if (!isArray(items) || items.length === 0) {
829
1286
  return Promise.resolve({ refreshed: [], skipped: true });
830
1287
  }
831
- syncDomInstances();
1288
+ scanDomInstances();
832
1289
  for (const item of items) {
833
1290
  const key = item.instanceId || item.name;
834
1291
  const inst = instances.get(key) || Array.from(instances.values()).find((i) => i.name === key);
@@ -836,7 +1293,9 @@ function createFragmentCoordinator(options = {}) {
836
1293
  if (item.input && typeof item.input === 'object') {
837
1294
  inst.input = { ...item.input };
838
1295
  if (inst.el && typeof inst.el.setAttribute === 'function') {
839
- inst.el.setAttribute('data-w-fragment-input', JSON.stringify(inst.input));
1296
+ const serializedInput = JSON.stringify(inst.input);
1297
+ inst.el.setAttribute('data-w-fragment-input', serializedInput);
1298
+ inst._rawInput = serializedInput;
840
1299
  }
841
1300
  }
842
1301
  pendingTags.add(inst.name);
@@ -847,8 +1306,8 @@ function createFragmentCoordinator(options = {}) {
847
1306
  return flushInvalidations();
848
1307
  },
849
1308
  findFragmentsByTags(tags = []) {
850
- syncDomInstances();
851
- const tagSet = new Set((Array.isArray(tags) ? tags : [tags]).map((t) => String(t || '').trim()).filter(Boolean));
1309
+ scanDomInstances();
1310
+ const tagSet = new Set((isArray(tags) ? tags : [tags]).map((t) => String(t || '').trim()).filter(Boolean));
852
1311
  const matching = [];
853
1312
  for (const inst of instances.values()) {
854
1313
  for (const t of tagSet) {
@@ -863,24 +1322,63 @@ function createFragmentCoordinator(options = {}) {
863
1322
  retryFailed,
864
1323
  retryLastFailed: retryFailed,
865
1324
  getInstance(instanceId) {
866
- syncDomInstances();
1325
+ const hit = instances.get(instanceId);
1326
+ if (hit && (!hit.el || hit.el.isConnected !== false)) {
1327
+ return hit;
1328
+ }
1329
+ scanDomInstances();
867
1330
  return instances.get(instanceId) || null;
868
1331
  },
869
1332
  getInstances() {
870
- syncDomInstances();
1333
+ scanDomInstances();
871
1334
  return Array.from(instances.values());
872
1335
  },
873
1336
  getStoredInput(instanceIdOrName, storageType = 'session') {
874
1337
  const store = storageType === 'local' ? defaultStorage.local : defaultStorage.session;
875
- return store.get(`frag_input:${instanceIdOrName}`, null);
1338
+ const direct = store.get(`frag_input:${instanceIdOrName}`, null);
1339
+ if (direct !== null) return direct;
1340
+ const inst = instances.get(instanceIdOrName) || Array.from(instances.values()).find((i) => i.name === instanceIdOrName);
1341
+ if (inst && inst.persistInput) {
1342
+ const instStore = inst.persistInput.type === 'local' ? defaultStorage.local : defaultStorage.session;
1343
+ return instStore.get(inst.persistInput.key, null);
1344
+ }
1345
+ return null;
876
1346
  },
877
1347
  clearStoredInput(instanceIdOrName, storageType = 'session') {
878
1348
  const store = storageType === 'local' ? defaultStorage.local : defaultStorage.session;
1349
+ const inst = instances.get(instanceIdOrName) || Array.from(instances.values()).find((i) => i.name === instanceIdOrName);
1350
+ if (inst && inst.persistInput) {
1351
+ const instStore = inst.persistInput.type === 'local' ? defaultStorage.local : defaultStorage.session;
1352
+ instStore.remove(inst.persistInput.key);
1353
+ }
879
1354
  return store.remove(`frag_input:${instanceIdOrName}`);
880
1355
  },
881
1356
  get hasFailedRefresh() {
882
1357
  return Boolean(lastFailedBatch);
883
1358
  },
1359
+ dispose() {
1360
+ isDisposed = true;
1361
+ if (focusDebounceTimer) {
1362
+ clearTimeout(focusDebounceTimer);
1363
+ focusDebounceTimer = null;
1364
+ }
1365
+ if (autoBatchTimer) {
1366
+ clearTimeout(autoBatchTimer);
1367
+ autoBatchTimer = null;
1368
+ }
1369
+ pendingAutoInstanceIds.clear();
1370
+ pendingAutoReasons.clear();
1371
+ removeLifecycleListeners();
1372
+ for (const inst of instances.values()) {
1373
+ clearInstanceInterval(inst);
1374
+ if (inst.abortController) {
1375
+ try {
1376
+ inst.abortController.abort();
1377
+ } catch (_e) {}
1378
+ inst.abortController = null;
1379
+ }
1380
+ }
1381
+ },
884
1382
  };
885
1383
  }
886
1384