webspresso 0.0.109 → 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,5 +1,8 @@
1
1
  'use strict';
2
2
 
3
+ const { defaultStorage } = require('./storage');
4
+ const { isArray, isNumber, parseDurationMs, parseRefetchMode, uid } = require('./utils');
5
+
3
6
  /**
4
7
  * Webspresso Client-Side SSR Fragment Coordinator & Invalidation Engine
5
8
  *
@@ -31,7 +34,7 @@ function parseDependsOnAttr(raw) {
31
34
  if (trimmed.startsWith('[')) {
32
35
  try {
33
36
  const parsed = JSON.parse(trimmed);
34
- if (Array.isArray(parsed)) {
37
+ if (isArray(parsed)) {
35
38
  return parsed.map((s) => String(s).trim()).filter(Boolean);
36
39
  }
37
40
  } catch (_e) {
@@ -56,7 +59,7 @@ function parseFragmentInputAttr(raw) {
56
59
  if (!trimmed) return {};
57
60
  try {
58
61
  const parsed = JSON.parse(trimmed);
59
- return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
62
+ return parsed && typeof parsed === 'object' && !isArray(parsed) ? parsed : {};
60
63
  } catch (_e) {
61
64
  return {};
62
65
  }
@@ -106,8 +109,24 @@ function createFragmentCoordinator(options = {}) {
106
109
  const endpoint = options.endpoint || DEFAULT_FRAGMENT_ENDPOINT;
107
110
  const datastar = options.datastar || options.adapter;
108
111
  const debug = options.debug || null;
109
-
110
- // 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, ... }>
111
130
  const instances = new Map();
112
131
 
113
132
  // Coalescing & in-flight state for tag invalidations
@@ -119,67 +138,378 @@ function createFragmentCoordinator(options = {}) {
119
138
  let lastFailedBatch = null;
120
139
  const inflightBatchWaiters = [];
121
140
 
122
- /**
123
- * Scan DOM and synchronize tracked `[data-w-fragment]` instances.
124
- */
125
- function syncDomInstances(containerEl) {
126
- const root = containerEl || getRoot();
127
- 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
+ }
128
153
 
129
- const elements = Array.from(root.querySelectorAll('[data-w-fragment]'));
130
- if (root.nodeType === 1 && root.hasAttribute && root.hasAttribute('data-w-fragment')) {
131
- elements.unshift(root);
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;
190
+ }
191
+ scheduleAutoRefresh([inst.instanceId], 'interval');
192
+ }, targetMs);
193
+ if (inst.intervalTimer && typeof inst.intervalTimer.unref === 'function') {
194
+ inst.intervalTimer.unref();
195
+ }
196
+ }
197
+
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);
132
202
  }
203
+ if (autoBatchScheduled || pendingAutoInstanceIds.size === 0) return;
204
+ autoBatchScheduled = true;
133
205
 
134
- const seenIds = new Set();
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);
220
+ }
221
+ }
222
+ if (candidates.length === 0) return;
135
223
 
136
- for (let i = 0; i < elements.length; i++) {
137
- const el = elements[i];
138
- const name = (el.getAttribute('data-w-fragment') || '').trim();
139
- if (!name) continue;
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
+ });
140
235
 
141
- let instanceId = (el.getAttribute('data-w-fragment-id') || '').trim();
142
- if (!instanceId) {
143
- instanceId = i === 0 ? `${name}:default` : `${name}:${i}`;
144
- el.setAttribute('data-w-fragment-id', instanceId);
236
+ if (debug && typeof debug.recordEvent === 'function') {
237
+ debug.recordEvent('fragment:auto-refetch', {
238
+ reason,
239
+ fragments: targets.map((t) => t.instanceId),
240
+ });
145
241
  }
146
242
 
147
- seenIds.add(instanceId);
148
- const pagePath =
149
- (el.getAttribute('data-w-fragment-page') || '').trim() ||
150
- (typeof window !== 'undefined' && window.location ? window.location.pathname : '/');
151
- const input = parseFragmentInputAttr(el.getAttribute('data-w-fragment-input'));
152
- const dependsOn = new Set(parseDependsOnAttr(el.getAttribute('data-w-depends-on')));
243
+ try {
244
+ await executeBatchRefresh(targets);
245
+ } catch (_err) {
246
+ // Background auto-refetch errors are non-fatal
247
+ }
248
+ });
249
+ }
153
250
 
154
- const existing = instances.get(instanceId);
155
- if (existing) {
156
- existing.name = name;
157
- existing.pagePath = pagePath;
158
- existing.el = el;
159
- existing.dependsOn = dependsOn;
160
- if (el.hasAttribute('data-w-fragment-input')) {
161
- existing.input = input;
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);
162
262
  }
163
- } else {
164
- instances.set(instanceId, {
165
- instanceId,
166
- name,
167
- pagePath,
168
- input,
169
- dependsOn,
170
- version: 0,
171
- el,
172
- abortController: null,
173
- });
174
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);
175
504
  }
176
505
 
177
506
  // Prune disconnected instances if scanning full root
178
- if (!containerEl || containerEl === getRoot()) {
507
+ if (isFullScan) {
179
508
  for (const [id, inst] of instances.entries()) {
180
509
  const disconnected =
181
510
  !seenIds.has(id) || (inst.el && inst.el.isConnected === false);
182
511
  if (disconnected) {
512
+ clearInstanceInterval(inst);
183
513
  if (inst.abortController) {
184
514
  try {
185
515
  inst.abortController.abort();
@@ -191,7 +521,13 @@ function createFragmentCoordinator(options = {}) {
191
521
  }
192
522
  }
193
523
  }
524
+ }
194
525
 
526
+ /**
527
+ * Scan DOM and synchronize tracked `[data-w-fragment]` instances.
528
+ */
529
+ function syncDomInstances(containerEl) {
530
+ scanDomInstances(containerEl);
195
531
  return Array.from(instances.values());
196
532
  }
197
533
 
@@ -228,7 +564,13 @@ function createFragmentCoordinator(options = {}) {
228
564
  pendingTags.size === 0
229
565
  ) {
230
566
  inst.input = fragmentPayload.input;
231
- 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;
570
+ if (inst.persistInput) {
571
+ const store = inst.persistInput.type === 'local' ? defaultStorage.local : defaultStorage.session;
572
+ store.set(inst.persistInput.key, fragmentPayload.input);
573
+ }
232
574
  }
233
575
 
234
576
  if (datastar && typeof datastar.patchFragmentElement === 'function') {
@@ -241,9 +583,10 @@ function createFragmentCoordinator(options = {}) {
241
583
  }
242
584
 
243
585
  const patchMs = Math.max(0, Date.now() - patchStart);
586
+ inst.lastUpdatedAt = Date.now();
244
587
 
245
588
  // Re-sync nested fragment instances that may have been updated inside this parent fragment
246
- syncDomInstances(inst.el);
589
+ scanDomInstances(inst.el);
247
590
 
248
591
  if (typeof options.onAfterPatch === 'function') {
249
592
  options.onAfterPatch(inst.el, inst);
@@ -266,7 +609,7 @@ function createFragmentCoordinator(options = {}) {
266
609
  * Execute a network refresh for a list of target fragment descriptors:
267
610
  * `Array<{ instanceId, name, pagePath, input, reqVersion }>`
268
611
  */
269
- async function executeBatchRefresh(targets, chainIds = [], abortSignal = null) {
612
+ async function executeBatchRefresh(targets, chainIds = [], abortSignal = null, batchTimeout = defaultTimeout) {
270
613
  if (!targets || targets.length === 0) {
271
614
  return { refreshed: [], skipped: true };
272
615
  }
@@ -300,7 +643,7 @@ function createFragmentCoordinator(options = {}) {
300
643
  })),
301
644
  };
302
645
 
303
- const reqId = `frag_req_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
646
+ const reqId = uid('frag_req', 4);
304
647
  if (debug) {
305
648
  debug.trackRequestStart({
306
649
  requestId: reqId,
@@ -312,6 +655,27 @@ function createFragmentCoordinator(options = {}) {
312
655
  });
313
656
  }
314
657
 
658
+ const batchAbortController = new AbortController();
659
+ let timeoutTimer = null;
660
+ let timedOut = false;
661
+ const timeoutMs = isNumber(batchTimeout) ? batchTimeout : defaultTimeout;
662
+
663
+ if (timeoutMs > 0) {
664
+ timeoutTimer = setTimeout(() => {
665
+ timedOut = true;
666
+ batchAbortController.abort();
667
+ }, timeoutMs);
668
+ }
669
+
670
+ const onCallerAbort = () => batchAbortController.abort();
671
+ if (abortSignal) {
672
+ if (abortSignal.aborted) {
673
+ batchAbortController.abort();
674
+ } else {
675
+ abortSignal.addEventListener('abort', onCallerAbort, { once: true });
676
+ }
677
+ }
678
+
315
679
  let response;
316
680
  try {
317
681
  response = await fetchImpl(endpoint, {
@@ -319,11 +683,45 @@ function createFragmentCoordinator(options = {}) {
319
683
  headers,
320
684
  body: JSON.stringify(requestPayload),
321
685
  credentials: 'same-origin',
322
- signal: abortSignal || undefined,
686
+ signal: batchAbortController.signal,
323
687
  });
324
688
  } catch (err) {
689
+ if (timeoutTimer) {
690
+ clearTimeout(timeoutTimer);
691
+ timeoutTimer = null;
692
+ }
693
+ if (abortSignal) {
694
+ try {
695
+ abortSignal.removeEventListener('abort', onCallerAbort);
696
+ } catch (_e) {}
697
+ }
698
+
699
+ if (timedOut) {
700
+ if (debug) {
701
+ debug.trackRequestEnd(reqId, {
702
+ status: 'failed',
703
+ errorKind: 'timeout',
704
+ });
705
+ }
706
+ const timeoutError = new Error(`Fragment refresh timed out after ${timeoutMs}ms.`);
707
+ timeoutError.code = 'FRAGMENT_REFRESH_TIMEOUT';
708
+ timeoutError.isTimedOut = true;
709
+ lastFailedBatch = { targets, chainIds };
710
+ for (const cid of chainIds) {
711
+ if (debug) {
712
+ debug.recordChainRefresh(cid, {
713
+ fragmentIds: targets.map((t) => t.instanceId),
714
+ status: 'failed',
715
+ error: timeoutError,
716
+ });
717
+ }
718
+ }
719
+ throw timeoutError;
720
+ }
721
+
325
722
  const isAbort =
326
723
  (abortSignal && abortSignal.aborted) ||
724
+ batchAbortController.signal.aborted ||
327
725
  (err && (err.name === 'AbortError' || err.code === 'ABORT_ERR'));
328
726
 
329
727
  if (debug) {
@@ -333,7 +731,7 @@ function createFragmentCoordinator(options = {}) {
333
731
  });
334
732
  }
335
733
  if (isAbort) {
336
- return { refreshed: [], canceled: true };
734
+ return { ok: false, refreshed: [], canceled: true };
337
735
  }
338
736
  lastFailedBatch = { targets, chainIds };
339
737
  for (const cid of chainIds) {
@@ -346,6 +744,16 @@ function createFragmentCoordinator(options = {}) {
346
744
  }
347
745
  }
348
746
  throw err;
747
+ } finally {
748
+ if (timeoutTimer) {
749
+ clearTimeout(timeoutTimer);
750
+ timeoutTimer = null;
751
+ }
752
+ if (abortSignal) {
753
+ try {
754
+ abortSignal.removeEventListener('abort', onCallerAbort);
755
+ } catch (_e) {}
756
+ }
349
757
  }
350
758
 
351
759
  let body = null;
@@ -402,8 +810,8 @@ function createFragmentCoordinator(options = {}) {
402
810
  const inst = instances.get(target.instanceId);
403
811
  const fragResult = resultsMap[target.instanceId] || resultsMap[target.name];
404
812
  if (!inst || !fragResult) continue;
405
- if (typeof fragResult.loadMs === 'number') totalLoadMs += fragResult.loadMs;
406
- if (typeof fragResult.renderMs === 'number') totalRenderMs += fragResult.renderMs;
813
+ if (isNumber(fragResult.loadMs)) totalLoadMs += fragResult.loadMs;
814
+ if (isNumber(fragResult.renderMs)) totalRenderMs += fragResult.renderMs;
407
815
 
408
816
  const applied = applyFragmentPatch(
409
817
  inst,
@@ -423,12 +831,13 @@ function createFragmentCoordinator(options = {}) {
423
831
  status: 'ok',
424
832
  loadMs: totalLoadMs,
425
833
  renderMs: totalRenderMs,
426
- totalServerMs: body && typeof body.totalMs === 'number' ? body.totalMs : null,
834
+ totalServerMs: body && isNumber(body.totalMs) ? body.totalMs : null,
427
835
  });
428
836
  }
429
837
  }
430
838
 
431
839
  return {
840
+ ok: true,
432
841
  refreshed: refreshedIds,
433
842
  errors: [],
434
843
  timings: {
@@ -452,7 +861,7 @@ function createFragmentCoordinator(options = {}) {
452
861
  }
453
862
 
454
863
  if (pendingTags.size === 0) {
455
- return { refreshed: [], skipped: true };
864
+ return { ok: true, refreshed: [], skipped: true };
456
865
  }
457
866
 
458
867
  const tagsToFlush = new Set(pendingTags);
@@ -460,7 +869,7 @@ function createFragmentCoordinator(options = {}) {
460
869
  pendingTags.clear();
461
870
  pendingChains.length = 0;
462
871
 
463
- syncDomInstances();
872
+ scanDomInstances();
464
873
 
465
874
  // Find all mounted fragment instances whose `dependsOn` intersects `tagsToFlush`
466
875
  const matchingInstances = [];
@@ -487,7 +896,7 @@ function createFragmentCoordinator(options = {}) {
487
896
  });
488
897
  }
489
898
  }
490
- return { refreshed: [], skipped: true };
899
+ return { ok: true, refreshed: [], skipped: true };
491
900
  }
492
901
 
493
902
  // Parent/Child deduplication: if parent & child are both invalidated, parent refresh covers child
@@ -539,7 +948,7 @@ function createFragmentCoordinator(options = {}) {
539
948
  * @returns {Promise<{ refreshed: string[], skipped?: boolean }>}
540
949
  */
541
950
  function invalidate(tags, meta = {}) {
542
- const tagList = Array.isArray(tags) ? tags : [tags];
951
+ const tagList = isArray(tags) ? tags : [tags];
543
952
  for (const raw of tagList) {
544
953
  if (typeof raw === 'string' && raw.trim()) {
545
954
  pendingTags.add(raw.trim());
@@ -588,7 +997,7 @@ function createFragmentCoordinator(options = {}) {
588
997
  * @param {object} [refreshOpts]
589
998
  */
590
999
  async function refresh(idOrName, nextInput, refreshOpts = {}) {
591
- syncDomInstances();
1000
+ scanDomInstances();
592
1001
 
593
1002
  const matched = [];
594
1003
  if (instances.has(idOrName)) {
@@ -602,7 +1011,16 @@ function createFragmentCoordinator(options = {}) {
602
1011
  }
603
1012
 
604
1013
  if (matched.length === 0) {
605
- return { refreshed: [], skipped: true };
1014
+ const skippedRes = { ok: true, refreshed: [], skipped: true };
1015
+ if (typeof refreshOpts.onSuccess === 'function') {
1016
+ await refreshOpts.onSuccess(skippedRes);
1017
+ }
1018
+ if (typeof refreshOpts.onSettled === 'function') {
1019
+ await refreshOpts.onSettled(skippedRes, null);
1020
+ } else if (typeof refreshOpts.onComplete === 'function') {
1021
+ await refreshOpts.onComplete(skippedRes, null);
1022
+ }
1023
+ return skippedRes;
606
1024
  }
607
1025
 
608
1026
  const deduped = deduplicateParentChildFragments(matched);
@@ -625,7 +1043,13 @@ function createFragmentCoordinator(options = {}) {
625
1043
  ? { ...nextInput }
626
1044
  : { ...inst.input, ...nextInput };
627
1045
  if (inst.el && typeof inst.el.setAttribute === 'function') {
628
- 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;
1049
+ }
1050
+ if (inst.persistInput) {
1051
+ const store = inst.persistInput.type === 'local' ? defaultStorage.local : defaultStorage.session;
1052
+ store.set(inst.persistInput.key, inst.input);
629
1053
  }
630
1054
  }
631
1055
 
@@ -638,7 +1062,45 @@ function createFragmentCoordinator(options = {}) {
638
1062
  };
639
1063
  });
640
1064
 
641
- return executeBatchRefresh(targets, [], abortController.signal);
1065
+ try {
1066
+ const result = await executeBatchRefresh(targets, [], abortController.signal, refreshOpts.timeout);
1067
+ const enrichedResult = { ok: true, ...result };
1068
+ if (!enrichedResult.canceled && enrichedResult.ok !== false) {
1069
+ if (typeof refreshOpts.onSuccess === 'function') {
1070
+ await refreshOpts.onSuccess(enrichedResult);
1071
+ }
1072
+ }
1073
+ if (typeof refreshOpts.onSettled === 'function') {
1074
+ await refreshOpts.onSettled(enrichedResult, null);
1075
+ } else if (typeof refreshOpts.onComplete === 'function') {
1076
+ await refreshOpts.onComplete(enrichedResult, null);
1077
+ }
1078
+ return enrichedResult;
1079
+ } catch (err) {
1080
+ const failHandler = refreshOpts.onFailure || refreshOpts.onError;
1081
+ if (typeof failHandler === 'function') {
1082
+ try {
1083
+ await failHandler(err);
1084
+ } catch (callbackErr) {
1085
+ throw callbackErr;
1086
+ }
1087
+ }
1088
+ if (typeof refreshOpts.onSettled === 'function') {
1089
+ await refreshOpts.onSettled(null, err);
1090
+ } else if (typeof refreshOpts.onComplete === 'function') {
1091
+ await refreshOpts.onComplete(null, err);
1092
+ }
1093
+
1094
+ if (refreshOpts.throwOnError !== true && (typeof failHandler === 'function' || refreshOpts.throwOnError === false)) {
1095
+ return {
1096
+ ok: false,
1097
+ error: err,
1098
+ refreshed: [],
1099
+ timings: { loadMs: 0, renderMs: 0 },
1100
+ };
1101
+ }
1102
+ throw err;
1103
+ }
642
1104
  }
643
1105
 
644
1106
  /**
@@ -677,10 +1139,10 @@ function createFragmentCoordinator(options = {}) {
677
1139
  invalidate,
678
1140
  refresh,
679
1141
  refreshFragments(items = []) {
680
- if (!Array.isArray(items) || items.length === 0) {
1142
+ if (!isArray(items) || items.length === 0) {
681
1143
  return Promise.resolve({ refreshed: [], skipped: true });
682
1144
  }
683
- syncDomInstances();
1145
+ scanDomInstances();
684
1146
  for (const item of items) {
685
1147
  const key = item.instanceId || item.name;
686
1148
  const inst = instances.get(key) || Array.from(instances.values()).find((i) => i.name === key);
@@ -688,7 +1150,9 @@ function createFragmentCoordinator(options = {}) {
688
1150
  if (item.input && typeof item.input === 'object') {
689
1151
  inst.input = { ...item.input };
690
1152
  if (inst.el && typeof inst.el.setAttribute === 'function') {
691
- 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;
692
1156
  }
693
1157
  }
694
1158
  pendingTags.add(inst.name);
@@ -699,8 +1163,8 @@ function createFragmentCoordinator(options = {}) {
699
1163
  return flushInvalidations();
700
1164
  },
701
1165
  findFragmentsByTags(tags = []) {
702
- syncDomInstances();
703
- 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));
704
1168
  const matching = [];
705
1169
  for (const inst of instances.values()) {
706
1170
  for (const t of tagSet) {
@@ -715,16 +1179,46 @@ function createFragmentCoordinator(options = {}) {
715
1179
  retryFailed,
716
1180
  retryLastFailed: retryFailed,
717
1181
  getInstance(instanceId) {
718
- syncDomInstances();
1182
+ const hit = instances.get(instanceId);
1183
+ if (hit && (!hit.el || hit.el.isConnected !== false)) {
1184
+ return hit;
1185
+ }
1186
+ scanDomInstances();
719
1187
  return instances.get(instanceId) || null;
720
1188
  },
721
1189
  getInstances() {
722
- syncDomInstances();
1190
+ scanDomInstances();
723
1191
  return Array.from(instances.values());
724
1192
  },
1193
+ getStoredInput(instanceIdOrName, storageType = 'session') {
1194
+ const store = storageType === 'local' ? defaultStorage.local : defaultStorage.session;
1195
+ return store.get(`frag_input:${instanceIdOrName}`, null);
1196
+ },
1197
+ clearStoredInput(instanceIdOrName, storageType = 'session') {
1198
+ const store = storageType === 'local' ? defaultStorage.local : defaultStorage.session;
1199
+ return store.remove(`frag_input:${instanceIdOrName}`);
1200
+ },
725
1201
  get hasFailedRefresh() {
726
1202
  return Boolean(lastFailedBatch);
727
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
+ },
728
1222
  };
729
1223
  }
730
1224