webspresso 0.0.112 → 0.0.114

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.
Files changed (42) hide show
  1. package/README.md +26 -0
  2. package/bin/commands/admin-password.js +1 -7
  3. package/bin/commands/api.js +1 -7
  4. package/bin/commands/new.js +1 -7
  5. package/bin/commands/page.js +1 -7
  6. package/bin/commands/seed.js +1 -7
  7. package/bin/commands/service.js +1 -7
  8. package/bin/utils/prompt.js +17 -0
  9. package/core/cache/tagged-lru.js +9 -3
  10. package/core/client/adapter/datastar.js +602 -26
  11. package/core/client/api.js +151 -7
  12. package/core/client/controller.js +172 -11
  13. package/core/client/devtool.js +59 -33
  14. package/core/client/forms.js +479 -29
  15. package/core/client/fragments.js +456 -27
  16. package/core/client/index.js +18 -1
  17. package/core/client/server-fragments.js +125 -40
  18. package/core/client/utils.js +72 -17
  19. package/core/console/commands.js +26 -5
  20. package/core/console/repl.js +7 -1
  21. package/core/http/pipeline.js +82 -1
  22. package/core/openapi/build-from-api-routes.js +5 -5
  23. package/core/orm/json-fields.js +7 -6
  24. package/core/orm/schema-helpers.js +18 -15
  25. package/core/orm/utils/nanoid.js +7 -8
  26. package/core/plugins/plugin-manager.js +155 -1
  27. package/core/services/validator.js +12 -1
  28. package/core/validation/apply.js +3 -0
  29. package/core/validation/index.js +25 -0
  30. package/core/validation/zod-introspect.js +187 -0
  31. package/index.d.ts +113 -1
  32. package/package.json +12 -11
  33. package/plugins/mcp/providers/services.js +2 -2
  34. package/src/api/define-api.js +2 -0
  35. package/src/api/validation-i18n.js +6 -2
  36. package/src/bundle/index.js +140 -6
  37. package/src/dev/api-typegen.js +79 -22
  38. package/src/dev/typegen.js +61 -35
  39. package/src/dev-reload/index.js +44 -2
  40. package/src/file-router.js +24 -6
  41. package/src/routing/mount-discovered-routes.js +24 -6
  42. package/src/server.js +59 -3
@@ -157,6 +157,173 @@ function createFragmentCoordinator(options = {}) {
157
157
  let isDisposed = false;
158
158
  let lifecycleListenersAttached = false;
159
159
 
160
+ // Per-version patch options (mode, limit, target, removeKeys, onBeforePatch, onAfterPatch, hookChainDepth)
161
+ const pendingPatchOptsByKey = new Map();
162
+ let pendingInvalidateHookDepth = 0;
163
+
164
+ // Lazy fragment IntersectionObserver state
165
+ let lazyObserver = null;
166
+ const observedLazyEls = new WeakSet();
167
+
168
+ function getLazyObserver() {
169
+ if (lazyObserver) return lazyObserver;
170
+ const win =
171
+ typeof window !== 'undefined'
172
+ ? window
173
+ : typeof globalThis !== 'undefined'
174
+ ? globalThis
175
+ : null;
176
+ const IOCtor = win && typeof win.IntersectionObserver === 'function' ? win.IntersectionObserver : null;
177
+ if (!IOCtor) return null;
178
+ lazyObserver = new IOCtor((entries) => {
179
+ if (isDisposed) return;
180
+ const visibleIds = [];
181
+ for (let i = 0; i < entries.length; i++) {
182
+ const entry = entries[i];
183
+ if (!entry || !entry.isIntersecting) continue;
184
+ const targetEl = entry.target;
185
+ try {
186
+ lazyObserver.unobserve(targetEl);
187
+ } catch (_e) {}
188
+ const fragId = targetEl && targetEl.getAttribute ? targetEl.getAttribute('data-w-fragment-id') : null;
189
+ const inst = fragId ? instances.get(fragId) : null;
190
+ if (inst && inst.lazy && inst.lazyState === 'idle') {
191
+ inst.lazyState = 'loading';
192
+ visibleIds.push(inst.instanceId);
193
+ }
194
+ }
195
+ if (visibleIds.length > 0) {
196
+ scheduleAutoRefresh(visibleIds, 'lazy');
197
+ }
198
+ });
199
+ return lazyObserver;
200
+ }
201
+
202
+ function observeLazyFragment(inst) {
203
+ if (!inst || !inst.el || !inst.lazy || inst.lazyState !== 'idle') return;
204
+ if (observedLazyEls.has(inst.el)) return;
205
+ const obs = getLazyObserver();
206
+ if (!obs) return;
207
+ observedLazyEls.add(inst.el);
208
+ try {
209
+ obs.observe(inst.el);
210
+ } catch (_e) {}
211
+ }
212
+
213
+ function unobserveLazyFragment(inst) {
214
+ if (!inst || !inst.el || !lazyObserver) return;
215
+ try {
216
+ lazyObserver.unobserve(inst.el);
217
+ } catch (_e) {}
218
+ }
219
+
220
+ function getListItemKey(node) {
221
+ if (!node || node.nodeType !== 1 || typeof node.getAttribute !== 'function') return null;
222
+ if (node.id) return `id:${node.id}`;
223
+ const wKey = node.getAttribute('data-w-key');
224
+ if (wKey) return `wkey:${wKey}`;
225
+ return null;
226
+ }
227
+
228
+ function patchIncrementalList(fragEl, html, opts = {}) {
229
+ const doc = fragEl.ownerDocument || (typeof document !== 'undefined' ? document : null);
230
+ const parsed = datastar && typeof datastar.parseHtmlFragment === 'function'
231
+ ? datastar.parseHtmlFragment(html, doc)
232
+ : null;
233
+ if (!parsed) return false;
234
+
235
+ const targetSel = opts.target || null;
236
+ const container =
237
+ (targetSel && fragEl.querySelector ? fragEl.querySelector(targetSel) : null) ||
238
+ (fragEl.querySelector ? fragEl.querySelector('[data-w-list]') : null) ||
239
+ fragEl;
240
+ const incomingContainer =
241
+ (targetSel && parsed.querySelector ? parsed.querySelector(targetSel) : null) ||
242
+ (parsed.querySelector ? parsed.querySelector('[data-w-list]') : null) ||
243
+ parsed;
244
+
245
+ // 1. Remove explicitly requested keys if any
246
+ if (isArray(opts.removeKeys) && opts.removeKeys.length > 0) {
247
+ const removeSet = new Set(opts.removeKeys.map(String));
248
+ const curChildren = Array.from(container.children || []);
249
+ for (let i = 0; i < curChildren.length; i++) {
250
+ const child = curChildren[i];
251
+ const rawKey = child.getAttribute('data-w-key') || child.id || '';
252
+ const fullKey = getListItemKey(child);
253
+ if (removeSet.has(rawKey) || (fullKey && removeSet.has(fullKey))) {
254
+ if (typeof opts.onBeforeNodeRemove === 'function') opts.onBeforeNodeRemove(child);
255
+ container.removeChild(child);
256
+ }
257
+ }
258
+ }
259
+
260
+ // 2. Index existing keyed items in container
261
+ const existingByKey = new Map();
262
+ const existingChildren = Array.from(container.children || []);
263
+ for (let i = 0; i < existingChildren.length; i++) {
264
+ const k = getListItemKey(existingChildren[i]);
265
+ if (k && !existingByKey.has(k)) {
266
+ existingByKey.set(k, existingChildren[i]);
267
+ }
268
+ }
269
+
270
+ const incomingItems = Array.from(incomingContainer.children || []);
271
+ const mode = opts.mode === 'prepend' ? 'prepend' : 'append';
272
+ const itemsToInsert = mode === 'prepend' ? incomingItems.slice().reverse() : incomingItems;
273
+
274
+ for (let i = 0; i < itemsToInsert.length; i++) {
275
+ const incoming = itemsToInsert[i];
276
+ if (incoming.nodeType !== 1) continue;
277
+ const key = getListItemKey(incoming);
278
+ const existingMatch = key ? existingByKey.get(key) : null;
279
+
280
+ if (existingMatch && existingMatch.tagName === incoming.tagName) {
281
+ // Update existing keyed item in place without duplicating
282
+ if (datastar && typeof datastar.morphDomTrees === 'function') {
283
+ datastar.morphDomTrees(existingMatch, incoming, {
284
+ onBeforeNodeRemove: opts.onBeforeNodeRemove,
285
+ onNodeAdded: opts.onNodeAdded,
286
+ });
287
+ }
288
+ continue;
289
+ }
290
+
291
+ const imported = container.ownerDocument
292
+ ? container.ownerDocument.importNode(incoming, true)
293
+ : incoming.cloneNode(true);
294
+
295
+ if (mode === 'prepend') {
296
+ const firstRef = (container.children && container.children[0]) || container.firstChild || null;
297
+ container.insertBefore(imported, firstRef);
298
+ } else {
299
+ container.appendChild(imported);
300
+ }
301
+
302
+ if (key) {
303
+ existingByKey.set(key, imported);
304
+ }
305
+ if (typeof opts.onNodeAdded === 'function') {
306
+ opts.onNodeAdded(imported);
307
+ }
308
+ }
309
+
310
+ // 3. Enforce optional retained item limit
311
+ const limitAttr = container.getAttribute ? container.getAttribute('data-w-limit') : null;
312
+ const rawLimit = opts.limit !== undefined ? opts.limit : limitAttr;
313
+ const limit = rawLimit !== null && rawLimit !== undefined && rawLimit !== '' ? Number(rawLimit) : 0;
314
+ if (Number.isFinite(limit) && limit > 0) {
315
+ while (container.children && container.children.length > limit) {
316
+ const ch = container.children;
317
+ const victim = mode === 'prepend' ? ch[ch.length - 1] : ch[0];
318
+ if (!victim) break;
319
+ if (typeof opts.onBeforeNodeRemove === 'function') opts.onBeforeNodeRemove(victim);
320
+ container.removeChild(victim);
321
+ }
322
+ }
323
+
324
+ return true;
325
+ }
326
+
160
327
  function isDocumentHidden() {
161
328
  const root = getRoot();
162
329
  const doc = root && root.nodeType === 9 ? root : root && root.ownerDocument ? root.ownerDocument : typeof document !== 'undefined' ? document : null;
@@ -260,7 +427,12 @@ function createFragmentCoordinator(options = {}) {
260
427
  try {
261
428
  await executeBatchRefresh(targets);
262
429
  } catch (_err) {
263
- // Background auto-refetch errors are non-fatal
430
+ // Background auto-refetch errors are non-fatal; reset lazyState if still unloaded so retry/re-observe works
431
+ for (const inst of deduped) {
432
+ if (inst.lazy && inst.lazyState === 'loading') {
433
+ inst.lazyState = 'idle';
434
+ }
435
+ }
264
436
  }
265
437
  }
266
438
 
@@ -272,7 +444,7 @@ function createFragmentCoordinator(options = {}) {
272
444
  if (reason) pendingAutoReasons.add(reason);
273
445
  if (pendingAutoInstanceIds.size === 0) return;
274
446
 
275
- if (reason === 'mount') {
447
+ if (reason === 'mount' || reason === 'lazy') {
276
448
  if (!autoBatchScheduled) {
277
449
  autoBatchScheduled = true;
278
450
  Promise.resolve().then(flushAutoRefresh);
@@ -469,6 +641,9 @@ function createFragmentCoordinator(options = {}) {
469
641
  ensureLifecycleListeners();
470
642
  }
471
643
 
644
+ const rawLazyAttr = el.getAttribute('data-w-lazy');
645
+ const isLazyUnloaded = Boolean(rawLazyAttr && rawLazyAttr !== 'false' && rawLazyAttr !== 'loaded');
646
+
472
647
  if (existing) {
473
648
  existing.name = name;
474
649
  existing.pagePath = pagePath;
@@ -489,6 +664,9 @@ function createFragmentCoordinator(options = {}) {
489
664
  existing._rawStale = rawStale;
490
665
  existing._rawInterval = rawInterval;
491
666
  syncInstanceInterval(existing);
667
+ if (existing.lazy && existing.lazyState === 'idle') {
668
+ observeLazyFragment(existing);
669
+ }
492
670
  } else {
493
671
  const created = {
494
672
  instanceId,
@@ -511,6 +689,9 @@ function createFragmentCoordinator(options = {}) {
511
689
  refetchIntervalInBackground,
512
690
  intervalTimer: null,
513
691
  activeIntervalMs: 0,
692
+ lazy: isLazyUnloaded,
693
+ lazyState: isLazyUnloaded ? 'idle' : 'loaded',
694
+ staleWhileUnloaded: false,
514
695
  _rawInput: rawInput,
515
696
  _rawDepends: rawDepends,
516
697
  _rawStale: rawStale,
@@ -518,7 +699,9 @@ function createFragmentCoordinator(options = {}) {
518
699
  };
519
700
  instances.set(instanceId, created);
520
701
  syncInstanceInterval(created);
521
- if (pendingRestoredInput) {
702
+ if (isLazyUnloaded) {
703
+ observeLazyFragment(created);
704
+ } else if (pendingRestoredInput) {
522
705
  Promise.resolve().then(() => {
523
706
  if (!isDisposed && created.version === 0 && instances.get(instanceId) === created) {
524
707
  refresh(instanceId, pendingRestoredInput).catch(() => {});
@@ -558,6 +741,7 @@ function createFragmentCoordinator(options = {}) {
558
741
  !seenIds.has(id) || (inst.el && inst.el.isConnected === false);
559
742
  if (disconnected) {
560
743
  clearInstanceInterval(inst);
744
+ unobserveLazyFragment(inst);
561
745
  if (inst.abortController) {
562
746
  try {
563
747
  inst.abortController.abort();
@@ -603,7 +787,27 @@ function createFragmentCoordinator(options = {}) {
603
787
  const html = fragmentPayload && typeof fragmentPayload.html === 'string' ? fragmentPayload.html : '';
604
788
  if (!html) return false;
605
789
 
790
+ const patchKey = `${inst.instanceId}:${reqVersion}`;
791
+ const patchOpts = pendingPatchOptsByKey.get(patchKey) || null;
792
+ if (patchOpts) {
793
+ pendingPatchOptsByKey.delete(patchKey);
794
+ }
795
+
796
+ const patchMeta = {
797
+ hookChainDepth: (patchOpts && patchOpts.hookChainDepth) || 0,
798
+ };
799
+
800
+ if (typeof options.onBeforePatch === 'function') {
801
+ options.onBeforePatch(inst.el, inst, patchMeta);
802
+ }
803
+ if (patchOpts && typeof patchOpts.onBeforePatch === 'function') {
804
+ try {
805
+ patchOpts.onBeforePatch(inst.el, inst, patchMeta);
806
+ } catch (_e) {}
807
+ }
808
+
606
809
  const patchStart = Date.now();
810
+ const patchMode = fragmentPayload.mode || (patchOpts && patchOpts.mode) || 'replace';
607
811
 
608
812
  const committedInput =
609
813
  fragmentPayload.input && typeof fragmentPayload.input === 'object' && !isArray(fragmentPayload.input)
@@ -613,6 +817,8 @@ function createFragmentCoordinator(options = {}) {
613
817
  : null;
614
818
 
615
819
  if (
820
+ patchMode !== 'append' &&
821
+ patchMode !== 'prepend' &&
616
822
  committedInput &&
617
823
  !followUpNeeded &&
618
824
  pendingTags.size === 0
@@ -629,7 +835,26 @@ function createFragmentCoordinator(options = {}) {
629
835
  }
630
836
  }
631
837
 
632
- if (datastar && typeof datastar.patchFragmentElement === 'function') {
838
+ // Track existing strict descendant fragments before replace morph so removed children can be cleaned up
839
+ const prevDescendants = [];
840
+ if (patchMode !== 'append' && patchMode !== 'prepend') {
841
+ for (const otherInst of instances.values()) {
842
+ if (otherInst !== inst && otherInst.el && isStrictAncestor(inst.el, otherInst.el)) {
843
+ prevDescendants.push(otherInst);
844
+ }
845
+ }
846
+ }
847
+
848
+ if (patchMode === 'append' || patchMode === 'prepend') {
849
+ patchIncrementalList(inst.el, html, {
850
+ mode: patchMode,
851
+ limit: fragmentPayload.limit !== undefined ? fragmentPayload.limit : patchOpts && patchOpts.limit,
852
+ target: fragmentPayload.target || (patchOpts && patchOpts.target),
853
+ removeKeys: fragmentPayload.removeKeys || (patchOpts && patchOpts.removeKeys),
854
+ onBeforeNodeRemove: options.onBeforeNodeRemove,
855
+ onNodeAdded: options.onNodeAdded,
856
+ });
857
+ } else if (datastar && typeof datastar.patchFragmentElement === 'function') {
633
858
  datastar.patchFragmentElement(inst.el, html, {
634
859
  onBeforeNodeRemove: options.onBeforeNodeRemove,
635
860
  onNodeAdded: options.onNodeAdded,
@@ -638,20 +863,56 @@ function createFragmentCoordinator(options = {}) {
638
863
  inst.el.innerHTML = html;
639
864
  }
640
865
 
866
+ // Clear lazy unloaded markers once a real patch is applied
867
+ if (inst.lazy || inst.lazyState !== 'loaded') {
868
+ inst.lazy = false;
869
+ inst.lazyState = 'loaded';
870
+ inst.staleWhileUnloaded = false;
871
+ unobserveLazyFragment(inst);
872
+ if (inst.el && typeof inst.el.removeAttribute === 'function') {
873
+ inst.el.removeAttribute('data-w-lazy');
874
+ }
875
+ }
876
+
641
877
  const patchMs = Math.max(0, Date.now() - patchStart);
642
878
  const now = Date.now();
643
879
  inst.lastUpdatedAt = now;
644
880
 
881
+ // Clean up any nested child fragments removed during this parent patch
882
+ for (let i = 0; i < prevDescendants.length; i++) {
883
+ const prevChild = prevDescendants[i];
884
+ if (!prevChild.el || prevChild.el.isConnected === false || !isStrictAncestor(inst.el, prevChild.el)) {
885
+ clearInstanceInterval(prevChild);
886
+ unobserveLazyFragment(prevChild);
887
+ if (prevChild.abortController) {
888
+ try {
889
+ prevChild.abortController.abort();
890
+ } catch (_e) {}
891
+ prevChild.abortController = null;
892
+ }
893
+ instances.delete(prevChild.instanceId);
894
+ }
895
+ }
896
+
645
897
  // Re-sync nested fragment instances that may have been updated inside this parent fragment
646
898
  scanDomInstances(inst.el);
647
899
  for (const otherInst of instances.values()) {
648
900
  if (otherInst !== inst && otherInst.el && isStrictAncestor(inst.el, otherInst.el)) {
649
- otherInst.lastUpdatedAt = now;
901
+ if (otherInst.el.__wPreservedLazyChild) {
902
+ delete otherInst.el.__wPreservedLazyChild;
903
+ } else if (!(otherInst.lazy && otherInst.lazyState === 'idle')) {
904
+ otherInst.lastUpdatedAt = now;
905
+ }
650
906
  }
651
907
  }
652
908
 
653
909
  if (typeof options.onAfterPatch === 'function') {
654
- options.onAfterPatch(inst.el, inst);
910
+ options.onAfterPatch(inst.el, inst, patchMeta);
911
+ }
912
+ if (patchOpts && typeof patchOpts.onAfterPatch === 'function') {
913
+ try {
914
+ patchOpts.onAfterPatch(inst.el, inst, patchMeta);
915
+ } catch (_e) {}
655
916
  }
656
917
 
657
918
  if (debug) {
@@ -704,6 +965,9 @@ function createFragmentCoordinator(options = {}) {
704
965
  if (!parentInst || !parentInst.el) continue;
705
966
  for (const otherInst of instances.values()) {
706
967
  if (otherInst !== parentInst && otherInst.el && isStrictAncestor(parentInst.el, otherInst.el)) {
968
+ // Supersede any older in-flight refresh on the nested child so a late child response
969
+ // cannot overwrite the newer parent+child snapshot.
970
+ otherInst.version += 1;
707
971
  if (otherInst.input && typeof otherInst.input === 'object') {
708
972
  childInputs[otherInst.instanceId] = { ...otherInst.input };
709
973
  if (!childInputs[otherInst.name]) {
@@ -908,8 +1172,8 @@ function createFragmentCoordinator(options = {}) {
908
1172
  debug.trackRequestEnd(reqId, { status: 'completed', httpStatus: response.status });
909
1173
  }
910
1174
 
911
- lastFailedBatch = null;
912
1175
  const rawFragments = (body && body.fragments) || {};
1176
+ const rawErrors = (body && body.errors && typeof body.errors === 'object' ? body.errors : {}) || {};
913
1177
  const resultsMap = {};
914
1178
  if (isArray(rawFragments)) {
915
1179
  for (const item of rawFragments) {
@@ -922,12 +1186,42 @@ function createFragmentCoordinator(options = {}) {
922
1186
  Object.assign(resultsMap, rawFragments);
923
1187
  }
924
1188
  const refreshedIds = [];
1189
+ const batchErrors = [];
1190
+ const failedTargets = [];
925
1191
  let staleDiscardCount = 0;
926
1192
  let totalLoadMs = 0;
927
1193
  let totalRenderMs = 0;
928
1194
 
929
1195
  for (const target of targets) {
930
1196
  const inst = instances.get(target.instanceId);
1197
+ const fragError =
1198
+ rawErrors[target.instanceId] ||
1199
+ rawErrors[target.name] ||
1200
+ (resultsMap[target.instanceId] && resultsMap[target.instanceId].error) ||
1201
+ (resultsMap[target.name] && resultsMap[target.name].error) ||
1202
+ null;
1203
+ if (fragError) {
1204
+ const errObj =
1205
+ fragError instanceof Error
1206
+ ? fragError
1207
+ : Object.assign(
1208
+ new Error(
1209
+ (fragError && typeof fragError === 'object' && fragError.message) ||
1210
+ String(fragError) ||
1211
+ `Fragment "${target.name}" failed to refresh`
1212
+ ),
1213
+ {
1214
+ status: (fragError && fragError.status) || 500,
1215
+ code: (fragError && fragError.code) || 'FRAGMENT_REFRESH_ERROR',
1216
+ instanceId: target.instanceId,
1217
+ fragmentName: target.name,
1218
+ }
1219
+ );
1220
+ batchErrors.push(errObj);
1221
+ failedTargets.push(target);
1222
+ continue;
1223
+ }
1224
+
931
1225
  const fragResult = resultsMap[target.instanceId] || resultsMap[target.name];
932
1226
  if (!inst || !fragResult) continue;
933
1227
  if (target.reqVersion < inst.version) {
@@ -949,13 +1243,33 @@ function createFragmentCoordinator(options = {}) {
949
1243
  }
950
1244
 
951
1245
  const allSuperseded =
952
- refreshedIds.length === 0 && staleDiscardCount > 0 && staleDiscardCount === targets.length;
1246
+ refreshedIds.length === 0 &&
1247
+ batchErrors.length === 0 &&
1248
+ staleDiscardCount > 0 &&
1249
+ staleDiscardCount === targets.length;
1250
+
1251
+ if (refreshedIds.length > 0 && lastFailedBatch && isArray(lastFailedBatch.targets)) {
1252
+ const refreshedSet = new Set(refreshedIds);
1253
+ const remainingFailed = lastFailedBatch.targets.filter((t) => !refreshedSet.has(t.instanceId));
1254
+ lastFailedBatch =
1255
+ remainingFailed.length > 0
1256
+ ? { targets: remainingFailed, chainIds: lastFailedBatch.chainIds }
1257
+ : null;
1258
+ }
1259
+
1260
+ if (batchErrors.length > 0) {
1261
+ lastFailedBatch = {
1262
+ targets: failedTargets,
1263
+ chainIds,
1264
+ };
1265
+ }
953
1266
 
954
1267
  for (const cid of chainIds) {
955
1268
  if (debug) {
956
1269
  debug.recordChainRefresh(cid, {
957
1270
  fragmentIds: refreshedIds,
958
- status: allSuperseded ? 'superseded' : 'ok',
1271
+ status: batchErrors.length > 0 ? (refreshedIds.length > 0 ? 'partial-error' : 'error') : allSuperseded ? 'superseded' : 'ok',
1272
+ error: batchErrors[0] || null,
959
1273
  loadMs: totalLoadMs,
960
1274
  renderMs: totalRenderMs,
961
1275
  totalServerMs: body && isNumber(body.totalMs) ? body.totalMs : null,
@@ -977,6 +1291,20 @@ function createFragmentCoordinator(options = {}) {
977
1291
  };
978
1292
  }
979
1293
 
1294
+ if (batchErrors.length > 0) {
1295
+ return {
1296
+ ok: false,
1297
+ partial: refreshedIds.length > 0,
1298
+ refreshed: refreshedIds,
1299
+ errors: batchErrors,
1300
+ error: batchErrors[0],
1301
+ timings: {
1302
+ loadMs: totalLoadMs,
1303
+ renderMs: totalRenderMs,
1304
+ },
1305
+ };
1306
+ }
1307
+
980
1308
  return {
981
1309
  ok: true,
982
1310
  refreshed: refreshedIds,
@@ -1017,13 +1345,18 @@ function createFragmentCoordinator(options = {}) {
1017
1345
  for (const inst of instances.values()) {
1018
1346
  let matched = false;
1019
1347
  for (const tag of tagsToFlush) {
1020
- if (inst.dependsOn.has(tag) || inst.name === tag) {
1348
+ if (inst.dependsOn.has(tag) || inst.name === tag || inst.instanceId === tag) {
1021
1349
  matched = true;
1022
1350
  break;
1023
1351
  }
1024
1352
  }
1025
1353
  if (matched) {
1026
- matchingInstances.push(inst);
1354
+ if (inst.lazy && inst.lazyState === 'idle') {
1355
+ // Do not fetch off-screen unloaded lazy fragments on invalidation; mark stale for when visible
1356
+ inst.staleWhileUnloaded = true;
1357
+ } else {
1358
+ matchingInstances.push(inst);
1359
+ }
1027
1360
  }
1028
1361
  }
1029
1362
 
@@ -1043,8 +1376,16 @@ function createFragmentCoordinator(options = {}) {
1043
1376
  // Parent/Child deduplication: if parent & child are both invalidated, parent refresh covers child
1044
1377
  const dedupedInstances = deduplicateParentChildFragments(matchingInstances);
1045
1378
 
1379
+ const batchHookDepth = pendingInvalidateHookDepth;
1380
+ pendingInvalidateHookDepth = 0;
1381
+
1046
1382
  const targets = dedupedInstances.map((inst) => {
1047
1383
  inst.version += 1;
1384
+ if (batchHookDepth > 0) {
1385
+ pendingPatchOptsByKey.set(`${inst.instanceId}:${inst.version}`, {
1386
+ hookChainDepth: batchHookDepth,
1387
+ });
1388
+ }
1048
1389
  return {
1049
1390
  instanceId: inst.instanceId,
1050
1391
  name: inst.name,
@@ -1089,6 +1430,13 @@ function createFragmentCoordinator(options = {}) {
1089
1430
  * @returns {Promise<{ refreshed: string[], skipped?: boolean }>}
1090
1431
  */
1091
1432
  function invalidate(tags, meta = {}) {
1433
+ const activeHook = typeof options.getPatchHookContext === 'function' ? options.getPatchHookContext() : null;
1434
+ if (activeHook) {
1435
+ const nextDepth = (activeHook.depth || 0) + 1;
1436
+ if (nextDepth > pendingInvalidateHookDepth) {
1437
+ pendingInvalidateHookDepth = nextDepth;
1438
+ }
1439
+ }
1092
1440
  const tagList = isArray(tags) ? tags : [tags];
1093
1441
  for (const raw of tagList) {
1094
1442
  if (typeof raw === 'string' && raw.trim()) {
@@ -1179,6 +1527,34 @@ function createFragmentCoordinator(options = {}) {
1179
1527
  inst.abortController = abortController;
1180
1528
  inst.version += 1;
1181
1529
 
1530
+ if (inst.lazy && inst.lazyState === 'idle') {
1531
+ inst.lazyState = 'loading';
1532
+ unobserveLazyFragment(inst);
1533
+ }
1534
+
1535
+ const activeHook = typeof options.getPatchHookContext === 'function' ? options.getPatchHookContext() : null;
1536
+ const hookChainDepth = activeHook ? (activeHook.depth || 0) + 1 : 0;
1537
+
1538
+ if (
1539
+ refreshOpts.mode ||
1540
+ refreshOpts.limit !== undefined ||
1541
+ refreshOpts.target ||
1542
+ refreshOpts.removeKeys ||
1543
+ hookChainDepth > 0 ||
1544
+ typeof refreshOpts.onBeforePatch === 'function' ||
1545
+ typeof refreshOpts.onAfterPatch === 'function'
1546
+ ) {
1547
+ pendingPatchOptsByKey.set(`${inst.instanceId}:${inst.version}`, {
1548
+ mode: refreshOpts.mode || null,
1549
+ limit: refreshOpts.limit,
1550
+ target: refreshOpts.target,
1551
+ removeKeys: refreshOpts.removeKeys,
1552
+ hookChainDepth,
1553
+ onBeforePatch: refreshOpts.onBeforePatch,
1554
+ onAfterPatch: refreshOpts.onAfterPatch,
1555
+ });
1556
+ }
1557
+
1182
1558
  const candidateInput =
1183
1559
  nextInput && typeof nextInput === 'object'
1184
1560
  ? refreshOpts.replaceInput
@@ -1207,6 +1583,21 @@ function createFragmentCoordinator(options = {}) {
1207
1583
  const result = await executeBatchRefresh(targets, [], abortController.signal, refreshOpts.timeout);
1208
1584
  clearOwnedAbortControllers();
1209
1585
  const enrichedResult = { ok: true, ...result };
1586
+ if (enrichedResult.ok === false && !enrichedResult.canceled && !enrichedResult.superseded && enrichedResult.error) {
1587
+ const failHandler = refreshOpts.onFailure || refreshOpts.onError;
1588
+ if (typeof failHandler === 'function') {
1589
+ await failHandler(enrichedResult.error);
1590
+ }
1591
+ if (typeof refreshOpts.onSettled === 'function') {
1592
+ await refreshOpts.onSettled(enrichedResult, enrichedResult.error);
1593
+ } else if (typeof refreshOpts.onComplete === 'function') {
1594
+ await refreshOpts.onComplete(enrichedResult, enrichedResult.error);
1595
+ }
1596
+ if (refreshOpts.throwOnError === true) {
1597
+ throw enrichedResult.error;
1598
+ }
1599
+ return enrichedResult;
1600
+ }
1210
1601
  if (!enrichedResult.canceled && !enrichedResult.superseded && enrichedResult.ok !== false) {
1211
1602
  if (typeof refreshOpts.onSuccess === 'function') {
1212
1603
  await refreshOpts.onSuccess(enrichedResult);
@@ -1277,6 +1668,24 @@ function createFragmentCoordinator(options = {}) {
1277
1668
  return executeBatchRefresh(retryTargets, chainIds);
1278
1669
  }
1279
1670
 
1671
+ function resolveInstanceByIdOrName(idOrName) {
1672
+ if (!idOrName) return null;
1673
+ const direct = instances.get(idOrName);
1674
+ if (direct && (!direct.el || direct.el.isConnected !== false)) {
1675
+ return direct;
1676
+ }
1677
+ const defaultInst = instances.get(`${idOrName}:default`);
1678
+ if (defaultInst && (!defaultInst.el || defaultInst.el.isConnected !== false)) {
1679
+ return defaultInst;
1680
+ }
1681
+ for (const inst of instances.values()) {
1682
+ if (inst.name === idOrName && (!inst.el || inst.el.isConnected !== false)) {
1683
+ return inst;
1684
+ }
1685
+ }
1686
+ return null;
1687
+ }
1688
+
1280
1689
  return {
1281
1690
  syncDomInstances,
1282
1691
  invalidate,
@@ -1288,17 +1697,28 @@ function createFragmentCoordinator(options = {}) {
1288
1697
  scanDomInstances();
1289
1698
  for (const item of items) {
1290
1699
  const key = item.instanceId || item.name;
1291
- const inst = instances.get(key) || Array.from(instances.values()).find((i) => i.name === key);
1292
- if (inst) {
1293
- if (item.input && typeof item.input === 'object') {
1294
- inst.input = { ...item.input };
1295
- if (inst.el && typeof inst.el.setAttribute === 'function') {
1296
- const serializedInput = JSON.stringify(inst.input);
1297
- inst.el.setAttribute('data-w-fragment-input', serializedInput);
1298
- inst._rawInput = serializedInput;
1700
+ const matchedList = [];
1701
+ if (item.instanceId && instances.has(item.instanceId)) {
1702
+ matchedList.push(instances.get(item.instanceId));
1703
+ } else {
1704
+ for (const inst of instances.values()) {
1705
+ if (inst.instanceId === key || inst.name === key) {
1706
+ matchedList.push(inst);
1707
+ }
1708
+ }
1709
+ }
1710
+ if (matchedList.length > 0) {
1711
+ for (const inst of matchedList) {
1712
+ if (item.input && typeof item.input === 'object') {
1713
+ inst.input = { ...item.input };
1714
+ if (inst.el && typeof inst.el.setAttribute === 'function') {
1715
+ const serializedInput = JSON.stringify(inst.input);
1716
+ inst.el.setAttribute('data-w-fragment-input', serializedInput);
1717
+ inst._rawInput = serializedInput;
1718
+ }
1299
1719
  }
1720
+ pendingTags.add(item.instanceId ? inst.instanceId : inst.name);
1300
1721
  }
1301
- pendingTags.add(inst.name);
1302
1722
  } else if (key) {
1303
1723
  pendingTags.add(String(key).trim());
1304
1724
  }
@@ -1311,7 +1731,7 @@ function createFragmentCoordinator(options = {}) {
1311
1731
  const matching = [];
1312
1732
  for (const inst of instances.values()) {
1313
1733
  for (const t of tagSet) {
1314
- if (inst.dependsOn.has(t) || inst.name === t) {
1734
+ if (inst.dependsOn.has(t) || inst.name === t || inst.instanceId === t) {
1315
1735
  matching.push(inst);
1316
1736
  break;
1317
1737
  }
@@ -1321,17 +1741,19 @@ function createFragmentCoordinator(options = {}) {
1321
1741
  },
1322
1742
  retryFailed,
1323
1743
  retryLastFailed: retryFailed,
1324
- getInstance(instanceId) {
1325
- const hit = instances.get(instanceId);
1326
- if (hit && (!hit.el || hit.el.isConnected !== false)) {
1744
+ getInstance(instanceIdOrName) {
1745
+ const hit = resolveInstanceByIdOrName(instanceIdOrName);
1746
+ if (hit) {
1327
1747
  return hit;
1328
1748
  }
1329
1749
  scanDomInstances();
1330
- return instances.get(instanceId) || null;
1750
+ return resolveInstanceByIdOrName(instanceIdOrName);
1331
1751
  },
1332
- getInstances() {
1752
+ getInstances(nameOrId) {
1333
1753
  scanDomInstances();
1334
- return Array.from(instances.values());
1754
+ const all = Array.from(instances.values());
1755
+ if (!nameOrId) return all;
1756
+ return all.filter((inst) => inst.instanceId === nameOrId || inst.name === nameOrId);
1335
1757
  },
1336
1758
  getStoredInput(instanceIdOrName, storageType = 'session') {
1337
1759
  const store = storageType === 'local' ? defaultStorage.local : defaultStorage.session;
@@ -1368,6 +1790,13 @@ function createFragmentCoordinator(options = {}) {
1368
1790
  }
1369
1791
  pendingAutoInstanceIds.clear();
1370
1792
  pendingAutoReasons.clear();
1793
+ pendingPatchOptsByKey.clear();
1794
+ if (lazyObserver && typeof lazyObserver.disconnect === 'function') {
1795
+ try {
1796
+ lazyObserver.disconnect();
1797
+ } catch (_e) {}
1798
+ lazyObserver = null;
1799
+ }
1371
1800
  removeLifecycleListeners();
1372
1801
  for (const inst of instances.values()) {
1373
1802
  clearInstanceInterval(inst);