webspresso 0.0.111 → 0.0.113

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.
@@ -65,6 +65,16 @@ function parseFragmentInputAttr(raw) {
65
65
  }
66
66
  }
67
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
+
68
78
  /**
69
79
  * Filter a list of fragment records so that if fragment B's DOM element is inside
70
80
  * fragment A's DOM subtree, B is omitted from the batch because refreshing A will
@@ -79,7 +89,7 @@ function deduplicateParentChildFragments(candidates) {
79
89
  if (!candidate.el) return true;
80
90
  for (const other of candidates) {
81
91
  if (other === candidate || !other.el) continue;
82
- if (other.el !== candidate.el && typeof other.el.contains === 'function' && other.el.contains(candidate.el)) {
92
+ if (isStrictAncestor(other.el, candidate.el)) {
83
93
  return false;
84
94
  }
85
95
  }
@@ -140,11 +150,179 @@ function createFragmentCoordinator(options = {}) {
140
150
 
141
151
  // Automatic refetch (focus / reconnect / mount / interval) state
142
152
  const pendingAutoInstanceIds = new Set();
153
+ const pendingAutoReasons = new Set();
143
154
  let autoBatchScheduled = false;
155
+ let autoBatchTimer = null;
144
156
  let focusDebounceTimer = null;
145
157
  let isDisposed = false;
146
158
  let lifecycleListenersAttached = false;
147
159
 
160
+ // Per-version patch options (mode, limit, target, removeKeys, onBeforePatch, onAfterPatch)
161
+ const pendingPatchOptsByKey = new Map();
162
+
163
+ // Lazy fragment IntersectionObserver state
164
+ let lazyObserver = null;
165
+ const observedLazyEls = new WeakSet();
166
+
167
+ function getLazyObserver() {
168
+ if (lazyObserver) return lazyObserver;
169
+ const win =
170
+ typeof window !== 'undefined'
171
+ ? window
172
+ : typeof globalThis !== 'undefined'
173
+ ? globalThis
174
+ : null;
175
+ const IOCtor = win && typeof win.IntersectionObserver === 'function' ? win.IntersectionObserver : null;
176
+ if (!IOCtor) return null;
177
+ lazyObserver = new IOCtor((entries) => {
178
+ if (isDisposed) return;
179
+ const visibleIds = [];
180
+ for (let i = 0; i < entries.length; i++) {
181
+ const entry = entries[i];
182
+ if (!entry || !entry.isIntersecting) continue;
183
+ const targetEl = entry.target;
184
+ try {
185
+ lazyObserver.unobserve(targetEl);
186
+ } catch (_e) {}
187
+ const fragId = targetEl && targetEl.getAttribute ? targetEl.getAttribute('data-w-fragment-id') : null;
188
+ const inst = fragId ? instances.get(fragId) : null;
189
+ if (inst && inst.lazy && inst.lazyState === 'idle') {
190
+ inst.lazyState = 'loading';
191
+ visibleIds.push(inst.instanceId);
192
+ }
193
+ }
194
+ if (visibleIds.length > 0) {
195
+ scheduleAutoRefresh(visibleIds, 'lazy');
196
+ }
197
+ });
198
+ return lazyObserver;
199
+ }
200
+
201
+ function observeLazyFragment(inst) {
202
+ if (!inst || !inst.el || !inst.lazy || inst.lazyState !== 'idle') return;
203
+ if (observedLazyEls.has(inst.el)) return;
204
+ const obs = getLazyObserver();
205
+ if (!obs) return;
206
+ observedLazyEls.add(inst.el);
207
+ try {
208
+ obs.observe(inst.el);
209
+ } catch (_e) {}
210
+ }
211
+
212
+ function unobserveLazyFragment(inst) {
213
+ if (!inst || !inst.el || !lazyObserver) return;
214
+ try {
215
+ lazyObserver.unobserve(inst.el);
216
+ } catch (_e) {}
217
+ }
218
+
219
+ function getListItemKey(node) {
220
+ if (!node || node.nodeType !== 1 || typeof node.getAttribute !== 'function') return null;
221
+ if (node.id) return `id:${node.id}`;
222
+ const wKey = node.getAttribute('data-w-key');
223
+ if (wKey) return `wkey:${wKey}`;
224
+ return null;
225
+ }
226
+
227
+ function patchIncrementalList(fragEl, html, opts = {}) {
228
+ const doc = fragEl.ownerDocument || (typeof document !== 'undefined' ? document : null);
229
+ const parsed = datastar && typeof datastar.parseHtmlFragment === 'function'
230
+ ? datastar.parseHtmlFragment(html, doc)
231
+ : null;
232
+ if (!parsed) return false;
233
+
234
+ const targetSel = opts.target || null;
235
+ const container =
236
+ (targetSel && fragEl.querySelector ? fragEl.querySelector(targetSel) : null) ||
237
+ (fragEl.querySelector ? fragEl.querySelector('[data-w-list]') : null) ||
238
+ fragEl;
239
+ const incomingContainer =
240
+ (targetSel && parsed.querySelector ? parsed.querySelector(targetSel) : null) ||
241
+ (parsed.querySelector ? parsed.querySelector('[data-w-list]') : null) ||
242
+ parsed;
243
+
244
+ // 1. Remove explicitly requested keys if any
245
+ if (isArray(opts.removeKeys) && opts.removeKeys.length > 0) {
246
+ const removeSet = new Set(opts.removeKeys.map(String));
247
+ const curChildren = Array.from(container.children || []);
248
+ for (let i = 0; i < curChildren.length; i++) {
249
+ const child = curChildren[i];
250
+ const rawKey = child.getAttribute('data-w-key') || child.id || '';
251
+ const fullKey = getListItemKey(child);
252
+ if (removeSet.has(rawKey) || (fullKey && removeSet.has(fullKey))) {
253
+ if (typeof opts.onBeforeNodeRemove === 'function') opts.onBeforeNodeRemove(child);
254
+ container.removeChild(child);
255
+ }
256
+ }
257
+ }
258
+
259
+ // 2. Index existing keyed items in container
260
+ const existingByKey = new Map();
261
+ const existingChildren = Array.from(container.children || []);
262
+ for (let i = 0; i < existingChildren.length; i++) {
263
+ const k = getListItemKey(existingChildren[i]);
264
+ if (k && !existingByKey.has(k)) {
265
+ existingByKey.set(k, existingChildren[i]);
266
+ }
267
+ }
268
+
269
+ const incomingItems = Array.from(incomingContainer.children || []);
270
+ const mode = opts.mode === 'prepend' ? 'prepend' : 'append';
271
+ const itemsToInsert = mode === 'prepend' ? incomingItems.slice().reverse() : incomingItems;
272
+
273
+ for (let i = 0; i < itemsToInsert.length; i++) {
274
+ const incoming = itemsToInsert[i];
275
+ if (incoming.nodeType !== 1) continue;
276
+ const key = getListItemKey(incoming);
277
+ const existingMatch = key ? existingByKey.get(key) : null;
278
+
279
+ if (existingMatch && existingMatch.tagName === incoming.tagName) {
280
+ // Update existing keyed item in place without duplicating
281
+ if (datastar && typeof datastar.morphDomTrees === 'function') {
282
+ datastar.morphDomTrees(existingMatch, incoming, {
283
+ onBeforeNodeRemove: opts.onBeforeNodeRemove,
284
+ onNodeAdded: opts.onNodeAdded,
285
+ });
286
+ }
287
+ continue;
288
+ }
289
+
290
+ const imported = container.ownerDocument
291
+ ? container.ownerDocument.importNode(incoming, true)
292
+ : incoming.cloneNode(true);
293
+
294
+ if (mode === 'prepend') {
295
+ const firstRef = (container.children && container.children[0]) || container.firstChild || null;
296
+ container.insertBefore(imported, firstRef);
297
+ } else {
298
+ container.appendChild(imported);
299
+ }
300
+
301
+ if (key) {
302
+ existingByKey.set(key, imported);
303
+ }
304
+ if (typeof opts.onNodeAdded === 'function') {
305
+ opts.onNodeAdded(imported);
306
+ }
307
+ }
308
+
309
+ // 3. Enforce optional retained item limit
310
+ const limitAttr = container.getAttribute ? container.getAttribute('data-w-limit') : null;
311
+ const rawLimit = opts.limit !== undefined ? opts.limit : limitAttr;
312
+ const limit = rawLimit !== null && rawLimit !== undefined && rawLimit !== '' ? Number(rawLimit) : 0;
313
+ if (Number.isFinite(limit) && limit > 0) {
314
+ while (container.children && container.children.length > limit) {
315
+ const ch = container.children;
316
+ const victim = mode === 'prepend' ? ch[ch.length - 1] : ch[0];
317
+ if (!victim) break;
318
+ if (typeof opts.onBeforeNodeRemove === 'function') opts.onBeforeNodeRemove(victim);
319
+ container.removeChild(victim);
320
+ }
321
+ }
322
+
323
+ return true;
324
+ }
325
+
148
326
  function isDocumentHidden() {
149
327
  const root = getRoot();
150
328
  const doc = root && root.nodeType === 9 ? root : root && root.ownerDocument ? root.ownerDocument : typeof document !== 'undefined' ? document : null;
@@ -195,76 +373,100 @@ function createFragmentCoordinator(options = {}) {
195
373
  }
196
374
  }
197
375
 
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);
376
+ async function flushAutoRefresh() {
377
+ autoBatchScheduled = false;
378
+ if (autoBatchTimer) {
379
+ clearTimeout(autoBatchTimer);
380
+ autoBatchTimer = null;
202
381
  }
203
- if (autoBatchScheduled || pendingAutoInstanceIds.size === 0) return;
204
- autoBatchScheduled = true;
382
+ if (isDisposed || pendingAutoInstanceIds.size === 0) return;
383
+ const idsToRefresh = Array.from(pendingAutoInstanceIds);
384
+ const reasons = Array.from(pendingAutoReasons);
385
+ pendingAutoInstanceIds.clear();
386
+ pendingAutoReasons.clear();
387
+
388
+ // Skip background auto-refresh if a mutation invalidation batch is currently in flight
389
+ if (inFlightBatchCount > 0) return;
390
+
391
+ const candidates = [];
392
+ for (const id of idsToRefresh) {
393
+ const inst = instances.get(id);
394
+ if (
395
+ inst &&
396
+ inst.el &&
397
+ inst.el.isConnected !== false &&
398
+ !inst.abortController &&
399
+ !(inst.inFlightCount > 0)
400
+ ) {
401
+ candidates.push(inst);
402
+ }
403
+ }
404
+ if (candidates.length === 0) return;
205
405
 
206
- Promise.resolve().then(async () => {
207
- autoBatchScheduled = false;
208
- if (isDisposed || pendingAutoInstanceIds.size === 0) return;
209
- const idsToRefresh = Array.from(pendingAutoInstanceIds);
210
- pendingAutoInstanceIds.clear();
406
+ const deduped = deduplicateParentChildFragments(candidates);
407
+ const targets = deduped.map((inst) => {
408
+ inst.version += 1;
409
+ return {
410
+ instanceId: inst.instanceId,
411
+ name: inst.name,
412
+ pagePath: inst.pagePath,
413
+ input: { ...inst.input },
414
+ reqVersion: inst.version,
415
+ };
416
+ });
211
417
 
212
- // Skip background auto-refresh if a mutation invalidation batch is currently in flight
213
- if (inFlightBatchCount > 0) return;
418
+ if (debug && typeof debug.recordEvent === 'function') {
419
+ debug.recordEvent('fragment:auto-refetch', {
420
+ reason: reasons.length === 1 ? reasons[0] : reasons.join('+'),
421
+ reasons,
422
+ fragments: targets.map((t) => t.instanceId),
423
+ });
424
+ }
214
425
 
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);
426
+ try {
427
+ await executeBatchRefresh(targets);
428
+ } catch (_err) {
429
+ // Background auto-refetch errors are non-fatal; reset lazyState if still unloaded so retry/re-observe works
430
+ for (const inst of deduped) {
431
+ if (inst.lazy && inst.lazyState === 'loading') {
432
+ inst.lazyState = 'idle';
220
433
  }
221
434
  }
222
- if (candidates.length === 0) return;
435
+ }
436
+ }
223
437
 
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
- });
438
+ function scheduleAutoRefresh(instanceIds, reason = 'auto') {
439
+ if (isDisposed || !isArray(instanceIds) || instanceIds.length === 0) return;
440
+ for (const id of instanceIds) {
441
+ if (id) pendingAutoInstanceIds.add(id);
442
+ }
443
+ if (reason) pendingAutoReasons.add(reason);
444
+ if (pendingAutoInstanceIds.size === 0) return;
235
445
 
236
- if (debug && typeof debug.recordEvent === 'function') {
237
- debug.recordEvent('fragment:auto-refetch', {
238
- reason,
239
- fragments: targets.map((t) => t.instanceId),
240
- });
446
+ if (reason === 'mount' || reason === 'lazy') {
447
+ if (!autoBatchScheduled) {
448
+ autoBatchScheduled = true;
449
+ Promise.resolve().then(flushAutoRefresh);
241
450
  }
451
+ return;
452
+ }
242
453
 
243
- try {
244
- await executeBatchRefresh(targets);
245
- } catch (_err) {
246
- // Background auto-refetch errors are non-fatal
247
- }
248
- });
454
+ if (autoBatchTimer || autoBatchScheduled) return;
455
+ autoBatchTimer = setTimeout(flushAutoRefresh, 5);
249
456
  }
250
457
 
251
458
  function triggerFocusRefetch() {
252
459
  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');
460
+ scanDomInstances();
461
+ const matchedIds = [];
462
+ for (const inst of instances.values()) {
463
+ if (isInstanceStale(inst, inst.refetchOnWindowFocus)) {
464
+ matchedIds.push(inst.instanceId);
266
465
  }
267
- }, 20);
466
+ }
467
+ if (matchedIds.length > 0) {
468
+ scheduleAutoRefresh(matchedIds, 'window-focus');
469
+ }
268
470
  }
269
471
 
270
472
  function triggerReconnectRefetch() {
@@ -394,12 +596,21 @@ function createFragmentCoordinator(options = {}) {
394
596
  `frag_input:${name}`;
395
597
 
396
598
  let effectiveInput = input;
397
- if (isInputPersistEnabled) {
599
+ let pendingRestoredInput = null;
600
+ if (!existing && isInputPersistEnabled) {
398
601
  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;
602
+ let stored = store.get(inputStorageKey, null);
603
+ if (
604
+ (!stored || typeof stored !== 'object' || isArray(stored)) &&
605
+ inputStorageKey !== `frag_input:${instanceId}`
606
+ ) {
607
+ stored = store.get(`frag_input:${instanceId}`, null);
608
+ }
609
+ if (stored && typeof stored === 'object' && !isArray(stored)) {
610
+ if (rawInput === null || Object.keys(effectiveInput).length === 0) {
611
+ effectiveInput = { ...stored };
612
+ } else if (JSON.stringify(stored) !== JSON.stringify(effectiveInput)) {
613
+ pendingRestoredInput = { ...stored };
403
614
  }
404
615
  }
405
616
  }
@@ -429,13 +640,16 @@ function createFragmentCoordinator(options = {}) {
429
640
  ensureLifecycleListeners();
430
641
  }
431
642
 
643
+ const rawLazyAttr = el.getAttribute('data-w-lazy');
644
+ const isLazyUnloaded = Boolean(rawLazyAttr && rawLazyAttr !== 'false' && rawLazyAttr !== 'loaded');
645
+
432
646
  if (existing) {
433
647
  existing.name = name;
434
648
  existing.pagePath = pagePath;
435
649
  existing.el = el;
436
650
  existing.dependsOn = dependsOn;
437
- if (rawInput !== null || isInputPersistEnabled) {
438
- existing.input = effectiveInput;
651
+ if (rawInput !== null && rawInput !== existing._rawInput) {
652
+ existing.input = input;
439
653
  }
440
654
  existing.persistInput = isInputPersistEnabled ? { type: inputStorageType, key: inputStorageKey } : null;
441
655
  existing.staleTime = staleTime;
@@ -449,16 +663,21 @@ function createFragmentCoordinator(options = {}) {
449
663
  existing._rawStale = rawStale;
450
664
  existing._rawInterval = rawInterval;
451
665
  syncInstanceInterval(existing);
666
+ if (existing.lazy && existing.lazyState === 'idle') {
667
+ observeLazyFragment(existing);
668
+ }
452
669
  } else {
453
670
  const created = {
454
671
  instanceId,
455
672
  name,
456
673
  pagePath,
457
674
  input: effectiveInput,
675
+ restoredInput: pendingRestoredInput,
458
676
  dependsOn,
459
677
  version: 0,
460
678
  el,
461
679
  abortController: null,
680
+ inFlightCount: 0,
462
681
  persistInput: isInputPersistEnabled ? { type: inputStorageType, key: inputStorageKey } : null,
463
682
  lastUpdatedAt: Date.now(),
464
683
  staleTime,
@@ -469,6 +688,9 @@ function createFragmentCoordinator(options = {}) {
469
688
  refetchIntervalInBackground,
470
689
  intervalTimer: null,
471
690
  activeIntervalMs: 0,
691
+ lazy: isLazyUnloaded,
692
+ lazyState: isLazyUnloaded ? 'idle' : 'loaded',
693
+ staleWhileUnloaded: false,
472
694
  _rawInput: rawInput,
473
695
  _rawDepends: rawDepends,
474
696
  _rawStale: rawStale,
@@ -476,7 +698,15 @@ function createFragmentCoordinator(options = {}) {
476
698
  };
477
699
  instances.set(instanceId, created);
478
700
  syncInstanceInterval(created);
479
- if (refetchOnMount === 'always' || (refetchOnMount === true && staleTime <= 0)) {
701
+ if (isLazyUnloaded) {
702
+ observeLazyFragment(created);
703
+ } else if (pendingRestoredInput) {
704
+ Promise.resolve().then(() => {
705
+ if (!isDisposed && created.version === 0 && instances.get(instanceId) === created) {
706
+ refresh(instanceId, pendingRestoredInput).catch(() => {});
707
+ }
708
+ });
709
+ } else if (refetchOnMount === 'always' || (refetchOnMount === true && staleTime <= 0)) {
480
710
  scheduleAutoRefresh([instanceId], 'mount');
481
711
  }
482
712
  }
@@ -510,6 +740,7 @@ function createFragmentCoordinator(options = {}) {
510
740
  !seenIds.has(id) || (inst.el && inst.el.isConnected === false);
511
741
  if (disconnected) {
512
742
  clearInstanceInterval(inst);
743
+ unobserveLazyFragment(inst);
513
744
  if (inst.abortController) {
514
745
  try {
515
746
  inst.abortController.abort();
@@ -535,7 +766,7 @@ function createFragmentCoordinator(options = {}) {
535
766
  * Apply a single server fragment result `{ instanceId, name, html, input, loadMs, renderMs }`
536
767
  * to the DOM if and only if `reqVersion >= inst.version`.
537
768
  */
538
- function applyFragmentPatch(inst, fragmentPayload, reqVersion, chainId = null) {
769
+ function applyFragmentPatch(inst, fragmentPayload, reqVersion, chainId = null, requestedInput = null) {
539
770
  if (!inst || !inst.el) return false;
540
771
 
541
772
  if (reqVersion < inst.version) {
@@ -555,25 +786,58 @@ function createFragmentCoordinator(options = {}) {
555
786
  const html = fragmentPayload && typeof fragmentPayload.html === 'string' ? fragmentPayload.html : '';
556
787
  if (!html) return false;
557
788
 
789
+ const patchKey = `${inst.instanceId}:${reqVersion}`;
790
+ const patchOpts = pendingPatchOptsByKey.get(patchKey) || null;
791
+ if (patchOpts) {
792
+ pendingPatchOptsByKey.delete(patchKey);
793
+ }
794
+
795
+ if (typeof options.onBeforePatch === 'function') {
796
+ options.onBeforePatch(inst.el, inst);
797
+ }
798
+ if (patchOpts && typeof patchOpts.onBeforePatch === 'function') {
799
+ try {
800
+ patchOpts.onBeforePatch(inst.el, inst);
801
+ } catch (_e) {}
802
+ }
803
+
558
804
  const patchStart = Date.now();
559
805
 
806
+ const committedInput =
807
+ fragmentPayload.input && typeof fragmentPayload.input === 'object' && !isArray(fragmentPayload.input)
808
+ ? fragmentPayload.input
809
+ : requestedInput && typeof requestedInput === 'object' && !isArray(requestedInput)
810
+ ? requestedInput
811
+ : null;
812
+
560
813
  if (
561
- fragmentPayload.input &&
562
- typeof fragmentPayload.input === 'object' &&
814
+ committedInput &&
563
815
  !followUpNeeded &&
564
816
  pendingTags.size === 0
565
817
  ) {
566
- inst.input = fragmentPayload.input;
567
- const serializedInput = JSON.stringify(fragmentPayload.input);
568
- inst.el.setAttribute('data-w-fragment-input', serializedInput);
818
+ inst.input = { ...committedInput };
819
+ const serializedInput = JSON.stringify(inst.input);
820
+ if (inst.el && typeof inst.el.setAttribute === 'function') {
821
+ inst.el.setAttribute('data-w-fragment-input', serializedInput);
822
+ }
569
823
  inst._rawInput = serializedInput;
570
824
  if (inst.persistInput) {
571
825
  const store = inst.persistInput.type === 'local' ? defaultStorage.local : defaultStorage.session;
572
- store.set(inst.persistInput.key, fragmentPayload.input);
826
+ store.set(inst.persistInput.key, inst.input);
573
827
  }
574
828
  }
575
829
 
576
- if (datastar && typeof datastar.patchFragmentElement === 'function') {
830
+ const patchMode = fragmentPayload.mode || (patchOpts && patchOpts.mode) || 'replace';
831
+ if (patchMode === 'append' || patchMode === 'prepend') {
832
+ patchIncrementalList(inst.el, html, {
833
+ mode: patchMode,
834
+ limit: fragmentPayload.limit !== undefined ? fragmentPayload.limit : patchOpts && patchOpts.limit,
835
+ target: fragmentPayload.target || (patchOpts && patchOpts.target),
836
+ removeKeys: fragmentPayload.removeKeys || (patchOpts && patchOpts.removeKeys),
837
+ onBeforeNodeRemove: options.onBeforeNodeRemove,
838
+ onNodeAdded: options.onNodeAdded,
839
+ });
840
+ } else if (datastar && typeof datastar.patchFragmentElement === 'function') {
577
841
  datastar.patchFragmentElement(inst.el, html, {
578
842
  onBeforeNodeRemove: options.onBeforeNodeRemove,
579
843
  onNodeAdded: options.onNodeAdded,
@@ -582,15 +846,37 @@ function createFragmentCoordinator(options = {}) {
582
846
  inst.el.innerHTML = html;
583
847
  }
584
848
 
849
+ // Clear lazy unloaded markers once a real patch is applied
850
+ if (inst.lazy || inst.lazyState !== 'loaded') {
851
+ inst.lazy = false;
852
+ inst.lazyState = 'loaded';
853
+ inst.staleWhileUnloaded = false;
854
+ unobserveLazyFragment(inst);
855
+ if (inst.el && typeof inst.el.removeAttribute === 'function') {
856
+ inst.el.removeAttribute('data-w-lazy');
857
+ }
858
+ }
859
+
585
860
  const patchMs = Math.max(0, Date.now() - patchStart);
586
- inst.lastUpdatedAt = Date.now();
861
+ const now = Date.now();
862
+ inst.lastUpdatedAt = now;
587
863
 
588
864
  // Re-sync nested fragment instances that may have been updated inside this parent fragment
589
865
  scanDomInstances(inst.el);
866
+ for (const otherInst of instances.values()) {
867
+ if (otherInst !== inst && otherInst.el && isStrictAncestor(inst.el, otherInst.el)) {
868
+ otherInst.lastUpdatedAt = now;
869
+ }
870
+ }
590
871
 
591
872
  if (typeof options.onAfterPatch === 'function') {
592
873
  options.onAfterPatch(inst.el, inst);
593
874
  }
875
+ if (patchOpts && typeof patchOpts.onAfterPatch === 'function') {
876
+ try {
877
+ patchOpts.onAfterPatch(inst.el, inst);
878
+ } catch (_e) {}
879
+ }
594
880
 
595
881
  if (debug) {
596
882
  debug.recordChainPatch(chainId, {
@@ -633,6 +919,29 @@ function createFragmentCoordinator(options = {}) {
633
919
  targets[0].pagePath ||
634
920
  (typeof window !== 'undefined' && window.location ? window.location.pathname : '/');
635
921
 
922
+ // Collect active inputs of any nested descendant fragments inside the requested parent targets
923
+ // so server-side parent re-rendering preserves nested child filters & pagination.
924
+ const childInputs = {};
925
+ let hasChildInputs = false;
926
+ for (const t of targets) {
927
+ const parentInst = instances.get(t.instanceId);
928
+ if (!parentInst || !parentInst.el) continue;
929
+ for (const otherInst of instances.values()) {
930
+ if (otherInst !== parentInst && otherInst.el && isStrictAncestor(parentInst.el, otherInst.el)) {
931
+ // Supersede any older in-flight refresh on the nested child so a late child response
932
+ // cannot overwrite the newer parent+child snapshot.
933
+ otherInst.version += 1;
934
+ if (otherInst.input && typeof otherInst.input === 'object') {
935
+ childInputs[otherInst.instanceId] = { ...otherInst.input };
936
+ if (!childInputs[otherInst.name]) {
937
+ childInputs[otherInst.name] = { ...otherInst.input };
938
+ }
939
+ hasChildInputs = true;
940
+ }
941
+ }
942
+ }
943
+ }
944
+
636
945
  const requestPayload = {
637
946
  pagePath,
638
947
  fragments: targets.map((t) => ({
@@ -641,6 +950,29 @@ function createFragmentCoordinator(options = {}) {
641
950
  input: t.input || {},
642
951
  version: t.reqVersion,
643
952
  })),
953
+ ...(hasChildInputs ? { childInputs } : {}),
954
+ };
955
+
956
+ for (const t of targets) {
957
+ const inst = instances.get(t.instanceId);
958
+ if (inst) {
959
+ inst.inFlightCount = (inst.inFlightCount || 0) + 1;
960
+ if (inst.el && typeof inst.el.setAttribute === 'function') {
961
+ inst.el.setAttribute('aria-busy', 'true');
962
+ }
963
+ }
964
+ }
965
+
966
+ const decrementTargetBusy = () => {
967
+ for (const t of targets) {
968
+ const inst = instances.get(t.instanceId);
969
+ if (inst) {
970
+ inst.inFlightCount = Math.max(0, (inst.inFlightCount || 1) - 1);
971
+ if (inst.inFlightCount === 0 && inst.el && typeof inst.el.removeAttribute === 'function') {
972
+ inst.el.removeAttribute('aria-busy');
973
+ }
974
+ }
975
+ }
644
976
  };
645
977
 
646
978
  const reqId = uid('frag_req', 4);
@@ -686,6 +1018,7 @@ function createFragmentCoordinator(options = {}) {
686
1018
  signal: batchAbortController.signal,
687
1019
  });
688
1020
  } catch (err) {
1021
+ decrementTargetBusy();
689
1022
  if (timeoutTimer) {
690
1023
  clearTimeout(timeoutTimer);
691
1024
  timeoutTimer = null;
@@ -766,6 +1099,8 @@ function createFragmentCoordinator(options = {}) {
766
1099
  }
767
1100
  } catch (_e) {
768
1101
  body = null;
1102
+ } finally {
1103
+ decrementTargetBusy();
769
1104
  }
770
1105
 
771
1106
  if (!response.ok) {
@@ -800,16 +1135,61 @@ function createFragmentCoordinator(options = {}) {
800
1135
  debug.trackRequestEnd(reqId, { status: 'completed', httpStatus: response.status });
801
1136
  }
802
1137
 
803
- lastFailedBatch = null;
804
- const resultsMap = (body && body.fragments) || {};
1138
+ const rawFragments = (body && body.fragments) || {};
1139
+ const rawErrors = (body && body.errors && typeof body.errors === 'object' ? body.errors : {}) || {};
1140
+ const resultsMap = {};
1141
+ if (isArray(rawFragments)) {
1142
+ for (const item of rawFragments) {
1143
+ if (item && typeof item === 'object') {
1144
+ if (item.instanceId) resultsMap[item.instanceId] = item;
1145
+ if (item.name && !resultsMap[item.name]) resultsMap[item.name] = item;
1146
+ }
1147
+ }
1148
+ } else if (rawFragments && typeof rawFragments === 'object') {
1149
+ Object.assign(resultsMap, rawFragments);
1150
+ }
805
1151
  const refreshedIds = [];
1152
+ const batchErrors = [];
1153
+ const failedTargets = [];
1154
+ let staleDiscardCount = 0;
806
1155
  let totalLoadMs = 0;
807
1156
  let totalRenderMs = 0;
808
1157
 
809
1158
  for (const target of targets) {
810
1159
  const inst = instances.get(target.instanceId);
1160
+ const fragError =
1161
+ rawErrors[target.instanceId] ||
1162
+ rawErrors[target.name] ||
1163
+ (resultsMap[target.instanceId] && resultsMap[target.instanceId].error) ||
1164
+ (resultsMap[target.name] && resultsMap[target.name].error) ||
1165
+ null;
1166
+ if (fragError) {
1167
+ const errObj =
1168
+ fragError instanceof Error
1169
+ ? fragError
1170
+ : Object.assign(
1171
+ new Error(
1172
+ (fragError && typeof fragError === 'object' && fragError.message) ||
1173
+ String(fragError) ||
1174
+ `Fragment "${target.name}" failed to refresh`
1175
+ ),
1176
+ {
1177
+ status: (fragError && fragError.status) || 500,
1178
+ code: (fragError && fragError.code) || 'FRAGMENT_REFRESH_ERROR',
1179
+ instanceId: target.instanceId,
1180
+ fragmentName: target.name,
1181
+ }
1182
+ );
1183
+ batchErrors.push(errObj);
1184
+ failedTargets.push(target);
1185
+ continue;
1186
+ }
1187
+
811
1188
  const fragResult = resultsMap[target.instanceId] || resultsMap[target.name];
812
1189
  if (!inst || !fragResult) continue;
1190
+ if (target.reqVersion < inst.version) {
1191
+ staleDiscardCount += 1;
1192
+ }
813
1193
  if (isNumber(fragResult.loadMs)) totalLoadMs += fragResult.loadMs;
814
1194
  if (isNumber(fragResult.renderMs)) totalRenderMs += fragResult.renderMs;
815
1195
 
@@ -817,18 +1197,42 @@ function createFragmentCoordinator(options = {}) {
817
1197
  inst,
818
1198
  fragResult,
819
1199
  target.reqVersion,
820
- chainIds[0] || null
1200
+ chainIds[0] || null,
1201
+ target.input
821
1202
  );
822
1203
  if (applied) {
823
1204
  refreshedIds.push(target.instanceId);
824
1205
  }
825
1206
  }
826
1207
 
1208
+ const allSuperseded =
1209
+ refreshedIds.length === 0 &&
1210
+ batchErrors.length === 0 &&
1211
+ staleDiscardCount > 0 &&
1212
+ staleDiscardCount === targets.length;
1213
+
1214
+ if (refreshedIds.length > 0 && lastFailedBatch && isArray(lastFailedBatch.targets)) {
1215
+ const refreshedSet = new Set(refreshedIds);
1216
+ const remainingFailed = lastFailedBatch.targets.filter((t) => !refreshedSet.has(t.instanceId));
1217
+ lastFailedBatch =
1218
+ remainingFailed.length > 0
1219
+ ? { targets: remainingFailed, chainIds: lastFailedBatch.chainIds }
1220
+ : null;
1221
+ }
1222
+
1223
+ if (batchErrors.length > 0) {
1224
+ lastFailedBatch = {
1225
+ targets: failedTargets,
1226
+ chainIds,
1227
+ };
1228
+ }
1229
+
827
1230
  for (const cid of chainIds) {
828
1231
  if (debug) {
829
1232
  debug.recordChainRefresh(cid, {
830
1233
  fragmentIds: refreshedIds,
831
- status: 'ok',
1234
+ status: batchErrors.length > 0 ? (refreshedIds.length > 0 ? 'partial-error' : 'error') : allSuperseded ? 'superseded' : 'ok',
1235
+ error: batchErrors[0] || null,
832
1236
  loadMs: totalLoadMs,
833
1237
  renderMs: totalRenderMs,
834
1238
  totalServerMs: body && isNumber(body.totalMs) ? body.totalMs : null,
@@ -836,6 +1240,34 @@ function createFragmentCoordinator(options = {}) {
836
1240
  }
837
1241
  }
838
1242
 
1243
+ if (allSuperseded) {
1244
+ return {
1245
+ ok: false,
1246
+ canceled: true,
1247
+ superseded: true,
1248
+ refreshed: [],
1249
+ errors: [],
1250
+ timings: {
1251
+ loadMs: totalLoadMs,
1252
+ renderMs: totalRenderMs,
1253
+ },
1254
+ };
1255
+ }
1256
+
1257
+ if (batchErrors.length > 0) {
1258
+ return {
1259
+ ok: false,
1260
+ partial: refreshedIds.length > 0,
1261
+ refreshed: refreshedIds,
1262
+ errors: batchErrors,
1263
+ error: batchErrors[0],
1264
+ timings: {
1265
+ loadMs: totalLoadMs,
1266
+ renderMs: totalRenderMs,
1267
+ },
1268
+ };
1269
+ }
1270
+
839
1271
  return {
840
1272
  ok: true,
841
1273
  refreshed: refreshedIds,
@@ -876,13 +1308,18 @@ function createFragmentCoordinator(options = {}) {
876
1308
  for (const inst of instances.values()) {
877
1309
  let matched = false;
878
1310
  for (const tag of tagsToFlush) {
879
- if (inst.dependsOn.has(tag) || inst.name === tag) {
1311
+ if (inst.dependsOn.has(tag) || inst.name === tag || inst.instanceId === tag) {
880
1312
  matched = true;
881
1313
  break;
882
1314
  }
883
1315
  }
884
1316
  if (matched) {
885
- matchingInstances.push(inst);
1317
+ if (inst.lazy && inst.lazyState === 'idle') {
1318
+ // Do not fetch off-screen unloaded lazy fragments on invalidation; mark stale for when visible
1319
+ inst.staleWhileUnloaded = true;
1320
+ } else {
1321
+ matchingInstances.push(inst);
1322
+ }
886
1323
  }
887
1324
  }
888
1325
 
@@ -1038,34 +1475,73 @@ function createFragmentCoordinator(options = {}) {
1038
1475
  inst.abortController = abortController;
1039
1476
  inst.version += 1;
1040
1477
 
1041
- if (nextInput && typeof nextInput === 'object') {
1042
- inst.input = refreshOpts.replaceInput
1043
- ? { ...nextInput }
1044
- : { ...inst.input, ...nextInput };
1045
- if (inst.el && typeof inst.el.setAttribute === 'function') {
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);
1053
- }
1478
+ if (inst.lazy && inst.lazyState === 'idle') {
1479
+ inst.lazyState = 'loading';
1480
+ unobserveLazyFragment(inst);
1054
1481
  }
1055
1482
 
1483
+ if (
1484
+ refreshOpts.mode ||
1485
+ refreshOpts.limit !== undefined ||
1486
+ refreshOpts.target ||
1487
+ refreshOpts.removeKeys ||
1488
+ typeof refreshOpts.onBeforePatch === 'function' ||
1489
+ typeof refreshOpts.onAfterPatch === 'function'
1490
+ ) {
1491
+ pendingPatchOptsByKey.set(`${inst.instanceId}:${inst.version}`, {
1492
+ mode: refreshOpts.mode || null,
1493
+ limit: refreshOpts.limit,
1494
+ target: refreshOpts.target,
1495
+ removeKeys: refreshOpts.removeKeys,
1496
+ onBeforePatch: refreshOpts.onBeforePatch,
1497
+ onAfterPatch: refreshOpts.onAfterPatch,
1498
+ });
1499
+ }
1500
+
1501
+ const candidateInput =
1502
+ nextInput && typeof nextInput === 'object'
1503
+ ? refreshOpts.replaceInput
1504
+ ? { ...nextInput }
1505
+ : { ...inst.input, ...nextInput }
1506
+ : { ...inst.input };
1507
+
1056
1508
  return {
1057
1509
  instanceId: inst.instanceId,
1058
1510
  name: inst.name,
1059
1511
  pagePath: inst.pagePath,
1060
- input: { ...inst.input },
1512
+ input: candidateInput,
1061
1513
  reqVersion: inst.version,
1062
1514
  };
1063
1515
  });
1064
1516
 
1517
+ const clearOwnedAbortControllers = () => {
1518
+ for (const inst of deduped) {
1519
+ if (inst.abortController === abortController) {
1520
+ inst.abortController = null;
1521
+ }
1522
+ }
1523
+ };
1524
+
1065
1525
  try {
1066
1526
  const result = await executeBatchRefresh(targets, [], abortController.signal, refreshOpts.timeout);
1527
+ clearOwnedAbortControllers();
1067
1528
  const enrichedResult = { ok: true, ...result };
1068
- if (!enrichedResult.canceled && enrichedResult.ok !== false) {
1529
+ if (enrichedResult.ok === false && !enrichedResult.canceled && !enrichedResult.superseded && enrichedResult.error) {
1530
+ const failHandler = refreshOpts.onFailure || refreshOpts.onError;
1531
+ if (typeof failHandler === 'function') {
1532
+ await failHandler(enrichedResult.error);
1533
+ }
1534
+ if (typeof refreshOpts.onSettled === 'function') {
1535
+ await refreshOpts.onSettled(enrichedResult, enrichedResult.error);
1536
+ } else if (typeof refreshOpts.onComplete === 'function') {
1537
+ await refreshOpts.onComplete(enrichedResult, enrichedResult.error);
1538
+ }
1539
+ if (refreshOpts.throwOnError === true) {
1540
+ throw enrichedResult.error;
1541
+ }
1542
+ return enrichedResult;
1543
+ }
1544
+ if (!enrichedResult.canceled && !enrichedResult.superseded && enrichedResult.ok !== false) {
1069
1545
  if (typeof refreshOpts.onSuccess === 'function') {
1070
1546
  await refreshOpts.onSuccess(enrichedResult);
1071
1547
  }
@@ -1077,6 +1553,7 @@ function createFragmentCoordinator(options = {}) {
1077
1553
  }
1078
1554
  return enrichedResult;
1079
1555
  } catch (err) {
1556
+ clearOwnedAbortControllers();
1080
1557
  const failHandler = refreshOpts.onFailure || refreshOpts.onError;
1081
1558
  if (typeof failHandler === 'function') {
1082
1559
  try {
@@ -1121,7 +1598,7 @@ function createFragmentCoordinator(options = {}) {
1121
1598
  inst.version += 1;
1122
1599
  retryTargets.push({
1123
1600
  ...t,
1124
- input: { ...inst.input },
1601
+ input: t.input ? { ...t.input } : { ...inst.input },
1125
1602
  reqVersion: inst.version,
1126
1603
  });
1127
1604
  }
@@ -1134,6 +1611,24 @@ function createFragmentCoordinator(options = {}) {
1134
1611
  return executeBatchRefresh(retryTargets, chainIds);
1135
1612
  }
1136
1613
 
1614
+ function resolveInstanceByIdOrName(idOrName) {
1615
+ if (!idOrName) return null;
1616
+ const direct = instances.get(idOrName);
1617
+ if (direct && (!direct.el || direct.el.isConnected !== false)) {
1618
+ return direct;
1619
+ }
1620
+ const defaultInst = instances.get(`${idOrName}:default`);
1621
+ if (defaultInst && (!defaultInst.el || defaultInst.el.isConnected !== false)) {
1622
+ return defaultInst;
1623
+ }
1624
+ for (const inst of instances.values()) {
1625
+ if (inst.name === idOrName && (!inst.el || inst.el.isConnected !== false)) {
1626
+ return inst;
1627
+ }
1628
+ }
1629
+ return null;
1630
+ }
1631
+
1137
1632
  return {
1138
1633
  syncDomInstances,
1139
1634
  invalidate,
@@ -1145,17 +1640,28 @@ function createFragmentCoordinator(options = {}) {
1145
1640
  scanDomInstances();
1146
1641
  for (const item of items) {
1147
1642
  const key = item.instanceId || item.name;
1148
- const inst = instances.get(key) || Array.from(instances.values()).find((i) => i.name === key);
1149
- if (inst) {
1150
- if (item.input && typeof item.input === 'object') {
1151
- inst.input = { ...item.input };
1152
- if (inst.el && typeof inst.el.setAttribute === 'function') {
1153
- const serializedInput = JSON.stringify(inst.input);
1154
- inst.el.setAttribute('data-w-fragment-input', serializedInput);
1155
- inst._rawInput = serializedInput;
1643
+ const matchedList = [];
1644
+ if (item.instanceId && instances.has(item.instanceId)) {
1645
+ matchedList.push(instances.get(item.instanceId));
1646
+ } else {
1647
+ for (const inst of instances.values()) {
1648
+ if (inst.instanceId === key || inst.name === key) {
1649
+ matchedList.push(inst);
1650
+ }
1651
+ }
1652
+ }
1653
+ if (matchedList.length > 0) {
1654
+ for (const inst of matchedList) {
1655
+ if (item.input && typeof item.input === 'object') {
1656
+ inst.input = { ...item.input };
1657
+ if (inst.el && typeof inst.el.setAttribute === 'function') {
1658
+ const serializedInput = JSON.stringify(inst.input);
1659
+ inst.el.setAttribute('data-w-fragment-input', serializedInput);
1660
+ inst._rawInput = serializedInput;
1661
+ }
1156
1662
  }
1663
+ pendingTags.add(item.instanceId ? inst.instanceId : inst.name);
1157
1664
  }
1158
- pendingTags.add(inst.name);
1159
1665
  } else if (key) {
1160
1666
  pendingTags.add(String(key).trim());
1161
1667
  }
@@ -1168,7 +1674,7 @@ function createFragmentCoordinator(options = {}) {
1168
1674
  const matching = [];
1169
1675
  for (const inst of instances.values()) {
1170
1676
  for (const t of tagSet) {
1171
- if (inst.dependsOn.has(t) || inst.name === t) {
1677
+ if (inst.dependsOn.has(t) || inst.name === t || inst.instanceId === t) {
1172
1678
  matching.push(inst);
1173
1679
  break;
1174
1680
  }
@@ -1178,24 +1684,38 @@ function createFragmentCoordinator(options = {}) {
1178
1684
  },
1179
1685
  retryFailed,
1180
1686
  retryLastFailed: retryFailed,
1181
- getInstance(instanceId) {
1182
- const hit = instances.get(instanceId);
1183
- if (hit && (!hit.el || hit.el.isConnected !== false)) {
1687
+ getInstance(instanceIdOrName) {
1688
+ const hit = resolveInstanceByIdOrName(instanceIdOrName);
1689
+ if (hit) {
1184
1690
  return hit;
1185
1691
  }
1186
1692
  scanDomInstances();
1187
- return instances.get(instanceId) || null;
1693
+ return resolveInstanceByIdOrName(instanceIdOrName);
1188
1694
  },
1189
- getInstances() {
1695
+ getInstances(nameOrId) {
1190
1696
  scanDomInstances();
1191
- return Array.from(instances.values());
1697
+ const all = Array.from(instances.values());
1698
+ if (!nameOrId) return all;
1699
+ return all.filter((inst) => inst.instanceId === nameOrId || inst.name === nameOrId);
1192
1700
  },
1193
1701
  getStoredInput(instanceIdOrName, storageType = 'session') {
1194
1702
  const store = storageType === 'local' ? defaultStorage.local : defaultStorage.session;
1195
- return store.get(`frag_input:${instanceIdOrName}`, null);
1703
+ const direct = store.get(`frag_input:${instanceIdOrName}`, null);
1704
+ if (direct !== null) return direct;
1705
+ const inst = instances.get(instanceIdOrName) || Array.from(instances.values()).find((i) => i.name === instanceIdOrName);
1706
+ if (inst && inst.persistInput) {
1707
+ const instStore = inst.persistInput.type === 'local' ? defaultStorage.local : defaultStorage.session;
1708
+ return instStore.get(inst.persistInput.key, null);
1709
+ }
1710
+ return null;
1196
1711
  },
1197
1712
  clearStoredInput(instanceIdOrName, storageType = 'session') {
1198
1713
  const store = storageType === 'local' ? defaultStorage.local : defaultStorage.session;
1714
+ const inst = instances.get(instanceIdOrName) || Array.from(instances.values()).find((i) => i.name === instanceIdOrName);
1715
+ if (inst && inst.persistInput) {
1716
+ const instStore = inst.persistInput.type === 'local' ? defaultStorage.local : defaultStorage.session;
1717
+ instStore.remove(inst.persistInput.key);
1718
+ }
1199
1719
  return store.remove(`frag_input:${instanceIdOrName}`);
1200
1720
  },
1201
1721
  get hasFailedRefresh() {
@@ -1207,7 +1727,19 @@ function createFragmentCoordinator(options = {}) {
1207
1727
  clearTimeout(focusDebounceTimer);
1208
1728
  focusDebounceTimer = null;
1209
1729
  }
1730
+ if (autoBatchTimer) {
1731
+ clearTimeout(autoBatchTimer);
1732
+ autoBatchTimer = null;
1733
+ }
1210
1734
  pendingAutoInstanceIds.clear();
1735
+ pendingAutoReasons.clear();
1736
+ pendingPatchOptsByKey.clear();
1737
+ if (lazyObserver && typeof lazyObserver.disconnect === 'function') {
1738
+ try {
1739
+ lazyObserver.disconnect();
1740
+ } catch (_e) {}
1741
+ lazyObserver = null;
1742
+ }
1211
1743
  removeLifecycleListeners();
1212
1744
  for (const inst of instances.values()) {
1213
1745
  clearInstanceInterval(inst);