webspresso 0.0.111 → 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.
@@ -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,7 +150,9 @@ 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;
@@ -195,76 +207,95 @@ function createFragmentCoordinator(options = {}) {
195
207
  }
196
208
  }
197
209
 
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);
210
+ async function flushAutoRefresh() {
211
+ autoBatchScheduled = false;
212
+ if (autoBatchTimer) {
213
+ clearTimeout(autoBatchTimer);
214
+ autoBatchTimer = null;
202
215
  }
203
- if (autoBatchScheduled || pendingAutoInstanceIds.size === 0) return;
204
- autoBatchScheduled = true;
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;
205
239
 
206
- Promise.resolve().then(async () => {
207
- autoBatchScheduled = false;
208
- if (isDisposed || pendingAutoInstanceIds.size === 0) return;
209
- const idsToRefresh = Array.from(pendingAutoInstanceIds);
210
- pendingAutoInstanceIds.clear();
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
+ });
211
251
 
212
- // Skip background auto-refresh if a mutation invalidation batch is currently in flight
213
- if (inFlightBatchCount > 0) return;
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
+ }
214
259
 
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;
260
+ try {
261
+ await executeBatchRefresh(targets);
262
+ } catch (_err) {
263
+ // Background auto-refetch errors are non-fatal
264
+ }
265
+ }
223
266
 
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
- });
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;
235
274
 
236
- if (debug && typeof debug.recordEvent === 'function') {
237
- debug.recordEvent('fragment:auto-refetch', {
238
- reason,
239
- fragments: targets.map((t) => t.instanceId),
240
- });
275
+ if (reason === 'mount') {
276
+ if (!autoBatchScheduled) {
277
+ autoBatchScheduled = true;
278
+ Promise.resolve().then(flushAutoRefresh);
241
279
  }
280
+ return;
281
+ }
242
282
 
243
- try {
244
- await executeBatchRefresh(targets);
245
- } catch (_err) {
246
- // Background auto-refetch errors are non-fatal
247
- }
248
- });
283
+ if (autoBatchTimer || autoBatchScheduled) return;
284
+ autoBatchTimer = setTimeout(flushAutoRefresh, 5);
249
285
  }
250
286
 
251
287
  function triggerFocusRefetch() {
252
288
  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');
289
+ scanDomInstances();
290
+ const matchedIds = [];
291
+ for (const inst of instances.values()) {
292
+ if (isInstanceStale(inst, inst.refetchOnWindowFocus)) {
293
+ matchedIds.push(inst.instanceId);
266
294
  }
267
- }, 20);
295
+ }
296
+ if (matchedIds.length > 0) {
297
+ scheduleAutoRefresh(matchedIds, 'window-focus');
298
+ }
268
299
  }
269
300
 
270
301
  function triggerReconnectRefetch() {
@@ -394,12 +425,21 @@ function createFragmentCoordinator(options = {}) {
394
425
  `frag_input:${name}`;
395
426
 
396
427
  let effectiveInput = input;
397
- if (isInputPersistEnabled) {
428
+ let pendingRestoredInput = null;
429
+ if (!existing && isInputPersistEnabled) {
398
430
  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;
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 };
403
443
  }
404
444
  }
405
445
  }
@@ -434,8 +474,8 @@ function createFragmentCoordinator(options = {}) {
434
474
  existing.pagePath = pagePath;
435
475
  existing.el = el;
436
476
  existing.dependsOn = dependsOn;
437
- if (rawInput !== null || isInputPersistEnabled) {
438
- existing.input = effectiveInput;
477
+ if (rawInput !== null && rawInput !== existing._rawInput) {
478
+ existing.input = input;
439
479
  }
440
480
  existing.persistInput = isInputPersistEnabled ? { type: inputStorageType, key: inputStorageKey } : null;
441
481
  existing.staleTime = staleTime;
@@ -455,10 +495,12 @@ function createFragmentCoordinator(options = {}) {
455
495
  name,
456
496
  pagePath,
457
497
  input: effectiveInput,
498
+ restoredInput: pendingRestoredInput,
458
499
  dependsOn,
459
500
  version: 0,
460
501
  el,
461
502
  abortController: null,
503
+ inFlightCount: 0,
462
504
  persistInput: isInputPersistEnabled ? { type: inputStorageType, key: inputStorageKey } : null,
463
505
  lastUpdatedAt: Date.now(),
464
506
  staleTime,
@@ -476,7 +518,13 @@ function createFragmentCoordinator(options = {}) {
476
518
  };
477
519
  instances.set(instanceId, created);
478
520
  syncInstanceInterval(created);
479
- if (refetchOnMount === 'always' || (refetchOnMount === true && staleTime <= 0)) {
521
+ if (pendingRestoredInput) {
522
+ Promise.resolve().then(() => {
523
+ if (!isDisposed && created.version === 0 && instances.get(instanceId) === created) {
524
+ refresh(instanceId, pendingRestoredInput).catch(() => {});
525
+ }
526
+ });
527
+ } else if (refetchOnMount === 'always' || (refetchOnMount === true && staleTime <= 0)) {
480
528
  scheduleAutoRefresh([instanceId], 'mount');
481
529
  }
482
530
  }
@@ -535,7 +583,7 @@ function createFragmentCoordinator(options = {}) {
535
583
  * Apply a single server fragment result `{ instanceId, name, html, input, loadMs, renderMs }`
536
584
  * to the DOM if and only if `reqVersion >= inst.version`.
537
585
  */
538
- function applyFragmentPatch(inst, fragmentPayload, reqVersion, chainId = null) {
586
+ function applyFragmentPatch(inst, fragmentPayload, reqVersion, chainId = null, requestedInput = null) {
539
587
  if (!inst || !inst.el) return false;
540
588
 
541
589
  if (reqVersion < inst.version) {
@@ -557,19 +605,27 @@ function createFragmentCoordinator(options = {}) {
557
605
 
558
606
  const patchStart = Date.now();
559
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
+
560
615
  if (
561
- fragmentPayload.input &&
562
- typeof fragmentPayload.input === 'object' &&
616
+ committedInput &&
563
617
  !followUpNeeded &&
564
618
  pendingTags.size === 0
565
619
  ) {
566
- inst.input = fragmentPayload.input;
567
- const serializedInput = JSON.stringify(fragmentPayload.input);
568
- inst.el.setAttribute('data-w-fragment-input', serializedInput);
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
+ }
569
625
  inst._rawInput = serializedInput;
570
626
  if (inst.persistInput) {
571
627
  const store = inst.persistInput.type === 'local' ? defaultStorage.local : defaultStorage.session;
572
- store.set(inst.persistInput.key, fragmentPayload.input);
628
+ store.set(inst.persistInput.key, inst.input);
573
629
  }
574
630
  }
575
631
 
@@ -583,10 +639,16 @@ function createFragmentCoordinator(options = {}) {
583
639
  }
584
640
 
585
641
  const patchMs = Math.max(0, Date.now() - patchStart);
586
- inst.lastUpdatedAt = Date.now();
642
+ const now = Date.now();
643
+ inst.lastUpdatedAt = now;
587
644
 
588
645
  // Re-sync nested fragment instances that may have been updated inside this parent fragment
589
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
+ }
590
652
 
591
653
  if (typeof options.onAfterPatch === 'function') {
592
654
  options.onAfterPatch(inst.el, inst);
@@ -633,6 +695,26 @@ function createFragmentCoordinator(options = {}) {
633
695
  targets[0].pagePath ||
634
696
  (typeof window !== 'undefined' && window.location ? window.location.pathname : '/');
635
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
+
636
718
  const requestPayload = {
637
719
  pagePath,
638
720
  fragments: targets.map((t) => ({
@@ -641,6 +723,29 @@ function createFragmentCoordinator(options = {}) {
641
723
  input: t.input || {},
642
724
  version: t.reqVersion,
643
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
+ }
644
749
  };
645
750
 
646
751
  const reqId = uid('frag_req', 4);
@@ -686,6 +791,7 @@ function createFragmentCoordinator(options = {}) {
686
791
  signal: batchAbortController.signal,
687
792
  });
688
793
  } catch (err) {
794
+ decrementTargetBusy();
689
795
  if (timeoutTimer) {
690
796
  clearTimeout(timeoutTimer);
691
797
  timeoutTimer = null;
@@ -766,6 +872,8 @@ function createFragmentCoordinator(options = {}) {
766
872
  }
767
873
  } catch (_e) {
768
874
  body = null;
875
+ } finally {
876
+ decrementTargetBusy();
769
877
  }
770
878
 
771
879
  if (!response.ok) {
@@ -801,8 +909,20 @@ function createFragmentCoordinator(options = {}) {
801
909
  }
802
910
 
803
911
  lastFailedBatch = null;
804
- 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
+ }
805
924
  const refreshedIds = [];
925
+ let staleDiscardCount = 0;
806
926
  let totalLoadMs = 0;
807
927
  let totalRenderMs = 0;
808
928
 
@@ -810,6 +930,9 @@ function createFragmentCoordinator(options = {}) {
810
930
  const inst = instances.get(target.instanceId);
811
931
  const fragResult = resultsMap[target.instanceId] || resultsMap[target.name];
812
932
  if (!inst || !fragResult) continue;
933
+ if (target.reqVersion < inst.version) {
934
+ staleDiscardCount += 1;
935
+ }
813
936
  if (isNumber(fragResult.loadMs)) totalLoadMs += fragResult.loadMs;
814
937
  if (isNumber(fragResult.renderMs)) totalRenderMs += fragResult.renderMs;
815
938
 
@@ -817,18 +940,22 @@ function createFragmentCoordinator(options = {}) {
817
940
  inst,
818
941
  fragResult,
819
942
  target.reqVersion,
820
- chainIds[0] || null
943
+ chainIds[0] || null,
944
+ target.input
821
945
  );
822
946
  if (applied) {
823
947
  refreshedIds.push(target.instanceId);
824
948
  }
825
949
  }
826
950
 
951
+ const allSuperseded =
952
+ refreshedIds.length === 0 && staleDiscardCount > 0 && staleDiscardCount === targets.length;
953
+
827
954
  for (const cid of chainIds) {
828
955
  if (debug) {
829
956
  debug.recordChainRefresh(cid, {
830
957
  fragmentIds: refreshedIds,
831
- status: 'ok',
958
+ status: allSuperseded ? 'superseded' : 'ok',
832
959
  loadMs: totalLoadMs,
833
960
  renderMs: totalRenderMs,
834
961
  totalServerMs: body && isNumber(body.totalMs) ? body.totalMs : null,
@@ -836,6 +963,20 @@ function createFragmentCoordinator(options = {}) {
836
963
  }
837
964
  }
838
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
+
839
980
  return {
840
981
  ok: true,
841
982
  refreshed: refreshedIds,
@@ -1038,34 +1179,35 @@ function createFragmentCoordinator(options = {}) {
1038
1179
  inst.abortController = abortController;
1039
1180
  inst.version += 1;
1040
1181
 
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
- }
1054
- }
1182
+ const candidateInput =
1183
+ nextInput && typeof nextInput === 'object'
1184
+ ? refreshOpts.replaceInput
1185
+ ? { ...nextInput }
1186
+ : { ...inst.input, ...nextInput }
1187
+ : { ...inst.input };
1055
1188
 
1056
1189
  return {
1057
1190
  instanceId: inst.instanceId,
1058
1191
  name: inst.name,
1059
1192
  pagePath: inst.pagePath,
1060
- input: { ...inst.input },
1193
+ input: candidateInput,
1061
1194
  reqVersion: inst.version,
1062
1195
  };
1063
1196
  });
1064
1197
 
1198
+ const clearOwnedAbortControllers = () => {
1199
+ for (const inst of deduped) {
1200
+ if (inst.abortController === abortController) {
1201
+ inst.abortController = null;
1202
+ }
1203
+ }
1204
+ };
1205
+
1065
1206
  try {
1066
1207
  const result = await executeBatchRefresh(targets, [], abortController.signal, refreshOpts.timeout);
1208
+ clearOwnedAbortControllers();
1067
1209
  const enrichedResult = { ok: true, ...result };
1068
- if (!enrichedResult.canceled && enrichedResult.ok !== false) {
1210
+ if (!enrichedResult.canceled && !enrichedResult.superseded && enrichedResult.ok !== false) {
1069
1211
  if (typeof refreshOpts.onSuccess === 'function') {
1070
1212
  await refreshOpts.onSuccess(enrichedResult);
1071
1213
  }
@@ -1077,6 +1219,7 @@ function createFragmentCoordinator(options = {}) {
1077
1219
  }
1078
1220
  return enrichedResult;
1079
1221
  } catch (err) {
1222
+ clearOwnedAbortControllers();
1080
1223
  const failHandler = refreshOpts.onFailure || refreshOpts.onError;
1081
1224
  if (typeof failHandler === 'function') {
1082
1225
  try {
@@ -1121,7 +1264,7 @@ function createFragmentCoordinator(options = {}) {
1121
1264
  inst.version += 1;
1122
1265
  retryTargets.push({
1123
1266
  ...t,
1124
- input: { ...inst.input },
1267
+ input: t.input ? { ...t.input } : { ...inst.input },
1125
1268
  reqVersion: inst.version,
1126
1269
  });
1127
1270
  }
@@ -1192,10 +1335,22 @@ function createFragmentCoordinator(options = {}) {
1192
1335
  },
1193
1336
  getStoredInput(instanceIdOrName, storageType = 'session') {
1194
1337
  const store = storageType === 'local' ? defaultStorage.local : defaultStorage.session;
1195
- 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;
1196
1346
  },
1197
1347
  clearStoredInput(instanceIdOrName, storageType = 'session') {
1198
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
+ }
1199
1354
  return store.remove(`frag_input:${instanceIdOrName}`);
1200
1355
  },
1201
1356
  get hasFailedRefresh() {
@@ -1207,7 +1362,12 @@ function createFragmentCoordinator(options = {}) {
1207
1362
  clearTimeout(focusDebounceTimer);
1208
1363
  focusDebounceTimer = null;
1209
1364
  }
1365
+ if (autoBatchTimer) {
1366
+ clearTimeout(autoBatchTimer);
1367
+ autoBatchTimer = null;
1368
+ }
1210
1369
  pendingAutoInstanceIds.clear();
1370
+ pendingAutoReasons.clear();
1211
1371
  removeLifecycleListeners();
1212
1372
  for (const inst of instances.values()) {
1213
1373
  clearInstanceInterval(inst);
@@ -41,10 +41,14 @@ function resolveDocument(root) {
41
41
  */
42
42
  function resolveDefaultContainer(root, doc) {
43
43
  if (root && root.nodeType === 1 && root.tagName !== 'HTML') {
44
- // If a custom element root was passed and is not inside a fragment, use it or doc.body
45
44
  if (doc && doc.body && (root === doc.body || root === doc.documentElement)) {
46
45
  return doc.body;
47
46
  }
47
+ const insideFragment =
48
+ typeof root.closest === 'function' ? Boolean(root.closest('[data-w-fragment]')) : false;
49
+ if (!insideFragment) {
50
+ return root;
51
+ }
48
52
  if (!doc || !doc.body) {
49
53
  return root;
50
54
  }
@@ -3,7 +3,7 @@
3
3
  /**
4
4
  * Webspresso Lightweight Client Runtime (`core/client/lite.js`)
5
5
  *
6
- * Micro-footprint (~16.1 KB Brotli) client runtime for server-driven applications.
6
+ * Micro-footprint (~16.9 KB Brotli) client runtime for server-driven applications.
7
7
  * Includes:
8
8
  * - SSR Fragment coordinator (batch refresh, invalidation, auto-revalidation, DOM morphing, focus preservation)
9
9
  * - Native HTML Form enhancement (`<form data-w-form>`, validation errors, lifecycle hooks, draft persistence)
@@ -112,6 +112,12 @@ function defineFragment(config = {}) {
112
112
  refetchInterval: config.refetchInterval !== undefined ? config.refetchInterval : undefined,
113
113
  refetchIntervalInBackground:
114
114
  config.refetchIntervalInBackground !== undefined ? config.refetchIntervalInBackground : undefined,
115
+ persistInput:
116
+ config.persistInput !== undefined
117
+ ? config.persistInput
118
+ : config.persist !== undefined
119
+ ? config.persist
120
+ : undefined,
115
121
  };
116
122
  }
117
123
 
@@ -396,6 +402,7 @@ function wrapFragmentRootHtml({
396
402
  refetchOnMount,
397
403
  refetchInterval,
398
404
  refetchIntervalInBackground,
405
+ persistInput,
399
406
  }) {
400
407
  const safeTag = /^[a-zA-Z][a-zA-Z0-9-]*$/.test(tag || 'div') ? tag : 'div';
401
408
  const attrs = [
@@ -436,6 +443,12 @@ function wrapFragmentRootHtml({
436
443
  attrs.push(`data-w-refetch-in-background="${escapeHtmlAttr(String(refetchIntervalInBackground))}"`);
437
444
  }
438
445
 
446
+ if (persistInput !== undefined && persistInput !== null && persistInput !== false) {
447
+ const persistAttrVal =
448
+ persistInput === true || persistInput === 'input' ? 'session' : String(persistInput);
449
+ attrs.push(`data-w-persist-input="${escapeHtmlAttr(persistAttrVal)}"`);
450
+ }
451
+
439
452
  if (className) {
440
453
  attrs.push(`class="${escapeHtmlAttr(className)}"`);
441
454
  }
@@ -572,6 +585,12 @@ async function executeAndRenderFragment({
572
585
  options.refetchIntervalInBackground !== undefined
573
586
  ? options.refetchIntervalInBackground
574
587
  : fragmentDef.refetchIntervalInBackground,
588
+ persistInput:
589
+ options.persistInput !== undefined
590
+ ? options.persistInput
591
+ : options.persist !== undefined
592
+ ? options.persist
593
+ : fragmentDef.persistInput,
575
594
  });
576
595
 
577
596
  return {
@@ -620,6 +639,24 @@ function attachSsrFragmentHelper({ req, res, ctx, pageMod, pagePath, nunjucksEnv
620
639
 
621
640
  const currentRenderCtx = typeof getRenderCtx === 'function' ? getRenderCtx() : { fsy };
622
641
 
642
+ // If refreshing a parent fragment that contains this nested child fragment,
643
+ // merge the active client-side child input (`req.__fragmentChildInputs`) over static template defaults
644
+ const childInputs = req && req.__fragmentChildInputs;
645
+ let effectiveRawInput = rawInput || {};
646
+ if (childInputs && typeof childInputs === 'object') {
647
+ const override = Object.prototype.hasOwnProperty.call(childInputs, instanceId)
648
+ ? childInputs[instanceId]
649
+ : instanceId === `${cleanName}:default` && Object.prototype.hasOwnProperty.call(childInputs, cleanName)
650
+ ? childInputs[cleanName]
651
+ : undefined;
652
+ if (override && typeof override === 'object' && !isArray(override)) {
653
+ effectiveRawInput =
654
+ effectiveRawInput && typeof effectiveRawInput === 'object' && !isArray(effectiveRawInput)
655
+ ? { ...effectiveRawInput, ...override }
656
+ : { ...override };
657
+ }
658
+ }
659
+
623
660
  // Queue execution on `req.__pendingSsrFragments` and return placeholder token
624
661
  const placeholderIndex = req.__pendingSsrFragments.length;
625
662
  const token = `<!--__WEBSPRESSO_SSR_FRAGMENT_${placeholderIndex}__-->`;
@@ -629,7 +666,7 @@ function attachSsrFragmentHelper({ req, res, ctx, pageMod, pagePath, nunjucksEnv
629
666
  fragmentDef,
630
667
  instanceId,
631
668
  pagePath,
632
- rawInput: rawInput || {},
669
+ rawInput: effectiveRawInput,
633
670
  req,
634
671
  res,
635
672
  ctx,
@@ -857,6 +894,11 @@ function createFragmentRefreshHandler(config) {
857
894
  const ctx = typeof buildContext === 'function' ? buildContext(req, res, { fsy }) : { req, res, fsy };
858
895
  ensureRequestMemo(req, ctx);
859
896
 
897
+ req.__fragmentChildInputs =
898
+ payload.childInputs && typeof payload.childInputs === 'object' && !isArray(payload.childInputs)
899
+ ? payload.childInputs
900
+ : null;
901
+
860
902
  const renderedInstanceIds = new Set();
861
903
  attachSsrFragmentHelper({
862
904
  req,