@wix/himalaya-cli 0.1442.0 → 0.1443.0

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 (157) hide show
  1. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/state/src/index.d.ts +7 -0
  2. package/dist/cli.mjs +33 -26
  3. package/dist/preview-assets.generated.bin +0 -0
  4. package/guides/sdk-reference.json +1 -1
  5. package/package.json +2 -2
  6. package/templates/beauty-salon/tier5-src/_shared/collection-query.ts +32 -5
  7. package/templates/beauty-salon/tier5-src/_shared/collection-service.ts +2 -1
  8. package/templates/beauty-salon-owner/tier5-src/_shared/collection-query.ts +32 -5
  9. package/templates/beauty-salon-owner/tier5-src/_shared/collection-service.ts +2 -1
  10. package/templates/beauty-salon-owner/tier5-src/_shared/owner-editor-value.ts +10 -0
  11. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-manage/index.ts +18 -3
  12. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-post/index.ts +2 -0
  13. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-post-form/index.ts +10 -4
  14. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-product/index.ts +2 -0
  15. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-product-form/index.ts +10 -4
  16. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-search/index.ts +11 -0
  17. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-search/logic.ts +35 -1
  18. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-service-form/index.ts +2 -1
  19. package/templates/branded-lite/tier5-src/_shared/collection-query.ts +32 -5
  20. package/templates/branded-lite/tier5-src/_shared/collection-service.ts +2 -1
  21. package/templates/community-club/tier5-src/_shared/collection-query.ts +32 -5
  22. package/templates/community-club/tier5-src/_shared/collection-service.ts +2 -1
  23. package/templates/community-club-owner/tier5-src/_shared/collection-query.ts +32 -5
  24. package/templates/community-club-owner/tier5-src/_shared/collection-service.ts +2 -1
  25. package/templates/community-club-owner/tier5-src/_shared/owner-editor-value.ts +10 -0
  26. package/templates/community-club-owner/tier5-src/himi-owner-lite-event/index.ts +2 -0
  27. package/templates/community-club-owner/tier5-src/himi-owner-lite-event-form/index.ts +4 -1
  28. package/templates/community-club-owner/tier5-src/himi-owner-lite-manage/index.ts +18 -3
  29. package/templates/community-club-owner/tier5-src/himi-owner-lite-post/index.ts +2 -0
  30. package/templates/community-club-owner/tier5-src/himi-owner-lite-post-form/index.ts +10 -4
  31. package/templates/community-club-owner/tier5-src/himi-owner-lite-product/index.ts +2 -0
  32. package/templates/community-club-owner/tier5-src/himi-owner-lite-product-form/index.ts +10 -4
  33. package/templates/community-club-owner/tier5-src/himi-owner-lite-search/index.ts +11 -0
  34. package/templates/community-club-owner/tier5-src/himi-owner-lite-search/logic.ts +35 -1
  35. package/templates/events-meetups/tier5-src/_shared/collection-query.ts +32 -5
  36. package/templates/events-meetups/tier5-src/_shared/collection-service.ts +2 -1
  37. package/templates/events-meetups-owner/tier5-src/_shared/collection-query.ts +32 -5
  38. package/templates/events-meetups-owner/tier5-src/_shared/collection-service.ts +2 -1
  39. package/templates/events-meetups-owner/tier5-src/_shared/owner-editor-value.ts +10 -0
  40. package/templates/events-meetups-owner/tier5-src/himi-owner-lite-event/index.ts +2 -0
  41. package/templates/events-meetups-owner/tier5-src/himi-owner-lite-event-form/index.ts +4 -1
  42. package/templates/events-meetups-owner/tier5-src/himi-owner-lite-manage/index.ts +18 -3
  43. package/templates/events-meetups-owner/tier5-src/himi-owner-lite-post/index.ts +2 -0
  44. package/templates/events-meetups-owner/tier5-src/himi-owner-lite-post-form/index.ts +10 -4
  45. package/templates/events-meetups-owner/tier5-src/himi-owner-lite-search/index.ts +11 -0
  46. package/templates/events-meetups-owner/tier5-src/himi-owner-lite-search/logic.ts +35 -1
  47. package/templates/gym-fitness/tier5-src/_shared/collection-query.ts +32 -5
  48. package/templates/gym-fitness/tier5-src/_shared/collection-service.ts +2 -1
  49. package/templates/gym-fitness-owner/tier5-src/_shared/collection-query.ts +32 -5
  50. package/templates/gym-fitness-owner/tier5-src/_shared/collection-service.ts +2 -1
  51. package/templates/gym-fitness-owner/tier5-src/_shared/owner-editor-value.ts +10 -0
  52. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-event/index.ts +2 -0
  53. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-event-form/index.ts +4 -1
  54. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-manage/index.ts +18 -3
  55. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-post/index.ts +2 -0
  56. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-post-form/index.ts +10 -4
  57. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-product/index.ts +2 -0
  58. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-product-form/index.ts +10 -4
  59. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-search/index.ts +11 -0
  60. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-search/logic.ts +35 -1
  61. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-service-form/index.ts +2 -1
  62. package/templates/nonprofit-cause/tier5-src/_shared/collection-query.ts +32 -5
  63. package/templates/nonprofit-cause/tier5-src/_shared/collection-service.ts +2 -1
  64. package/templates/nonprofit-cause-owner/tier5-src/_shared/collection-query.ts +32 -5
  65. package/templates/nonprofit-cause-owner/tier5-src/_shared/collection-service.ts +2 -1
  66. package/templates/nonprofit-cause-owner/tier5-src/_shared/owner-editor-value.ts +10 -0
  67. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-event/index.ts +2 -0
  68. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-event-form/index.ts +4 -1
  69. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-manage/index.ts +18 -3
  70. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-post/index.ts +2 -0
  71. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-post-form/index.ts +10 -4
  72. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-search/index.ts +11 -0
  73. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-search/logic.ts +35 -1
  74. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-service-form/index.ts +2 -1
  75. package/templates/owner-lite/dev/index.ts +8 -2
  76. package/templates/owner-lite/tier5-src/_shared/collection-query.ts +32 -5
  77. package/templates/owner-lite/tier5-src/_shared/collection-service.ts +2 -1
  78. package/templates/owner-lite/tier5-src/_shared/owner-editor-value.ts +10 -0
  79. package/templates/owner-lite/tier5-src/himi-owner-lite-event/index.ts +2 -0
  80. package/templates/owner-lite/tier5-src/himi-owner-lite-event-form/index.ts +4 -1
  81. package/templates/owner-lite/tier5-src/himi-owner-lite-faq-category-form/index.ts +5 -1
  82. package/templates/owner-lite/tier5-src/himi-owner-lite-faq-question-form/index.ts +9 -3
  83. package/templates/owner-lite/tier5-src/himi-owner-lite-manage/index.ts +18 -3
  84. package/templates/owner-lite/tier5-src/himi-owner-lite-post/index.ts +2 -0
  85. package/templates/owner-lite/tier5-src/himi-owner-lite-post-form/index.ts +10 -4
  86. package/templates/owner-lite/tier5-src/himi-owner-lite-product/index.ts +2 -0
  87. package/templates/owner-lite/tier5-src/himi-owner-lite-product-form/index.ts +10 -4
  88. package/templates/owner-lite/tier5-src/himi-owner-lite-search/index.ts +11 -0
  89. package/templates/owner-lite/tier5-src/himi-owner-lite-search/logic.ts +35 -1
  90. package/templates/owner-lite/tier5-src/himi-owner-lite-service-form/index.ts +2 -1
  91. package/templates/podcast-creator/tier5-src/_shared/collection-query.ts +32 -5
  92. package/templates/podcast-creator/tier5-src/_shared/collection-service.ts +2 -1
  93. package/templates/podcast-creator-owner/tier5-src/_shared/collection-query.ts +32 -5
  94. package/templates/podcast-creator-owner/tier5-src/_shared/collection-service.ts +2 -1
  95. package/templates/podcast-creator-owner/tier5-src/_shared/owner-editor-value.ts +10 -0
  96. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-event/index.ts +2 -0
  97. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-event-form/index.ts +4 -1
  98. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-manage/index.ts +18 -3
  99. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-post/index.ts +2 -0
  100. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-post-form/index.ts +10 -4
  101. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-product/index.ts +2 -0
  102. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-product-form/index.ts +10 -4
  103. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-search/index.ts +11 -0
  104. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-search/logic.ts +35 -1
  105. package/templates/portfolio-creative/tier5-src/_shared/collection-query.ts +32 -5
  106. package/templates/portfolio-creative/tier5-src/_shared/collection-service.ts +2 -1
  107. package/templates/portfolio-creative-owner/tier5-src/_shared/collection-query.ts +32 -5
  108. package/templates/portfolio-creative-owner/tier5-src/_shared/collection-service.ts +2 -1
  109. package/templates/portfolio-creative-owner/tier5-src/_shared/owner-editor-value.ts +10 -0
  110. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-manage/index.ts +18 -3
  111. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-post/index.ts +2 -0
  112. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-post-form/index.ts +10 -4
  113. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-product/index.ts +2 -0
  114. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-product-form/index.ts +10 -4
  115. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-search/index.ts +11 -0
  116. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-search/logic.ts +35 -1
  117. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-service-form/index.ts +2 -1
  118. package/templates/restaurant/tier5-src/_shared/collection-query.ts +32 -5
  119. package/templates/restaurant/tier5-src/_shared/collection-service.ts +2 -1
  120. package/templates/restaurant-owner/tier5-src/_shared/collection-query.ts +32 -5
  121. package/templates/restaurant-owner/tier5-src/_shared/collection-service.ts +2 -1
  122. package/templates/restaurant-owner/tier5-src/_shared/owner-editor-value.ts +10 -0
  123. package/templates/restaurant-owner/tier5-src/himi-owner-lite-event/index.ts +2 -0
  124. package/templates/restaurant-owner/tier5-src/himi-owner-lite-event-form/index.ts +4 -1
  125. package/templates/restaurant-owner/tier5-src/himi-owner-lite-manage/index.ts +18 -3
  126. package/templates/restaurant-owner/tier5-src/himi-owner-lite-post/index.ts +2 -0
  127. package/templates/restaurant-owner/tier5-src/himi-owner-lite-post-form/index.ts +10 -4
  128. package/templates/restaurant-owner/tier5-src/himi-owner-lite-product/index.ts +2 -0
  129. package/templates/restaurant-owner/tier5-src/himi-owner-lite-product-form/index.ts +10 -4
  130. package/templates/restaurant-owner/tier5-src/himi-owner-lite-search/index.ts +11 -0
  131. package/templates/restaurant-owner/tier5-src/himi-owner-lite-search/logic.ts +35 -1
  132. package/templates/restaurant-owner/tier5-src/himi-owner-lite-service-form/index.ts +2 -1
  133. package/templates/retail-store/tier5-src/_shared/collection-query.ts +32 -5
  134. package/templates/retail-store/tier5-src/_shared/collection-service.ts +2 -1
  135. package/templates/retail-store-owner/tier5-src/_shared/collection-query.ts +32 -5
  136. package/templates/retail-store-owner/tier5-src/_shared/collection-service.ts +2 -1
  137. package/templates/retail-store-owner/tier5-src/_shared/owner-editor-value.ts +10 -0
  138. package/templates/retail-store-owner/tier5-src/himi-owner-lite-manage/index.ts +18 -3
  139. package/templates/retail-store-owner/tier5-src/himi-owner-lite-post/index.ts +2 -0
  140. package/templates/retail-store-owner/tier5-src/himi-owner-lite-post-form/index.ts +10 -4
  141. package/templates/retail-store-owner/tier5-src/himi-owner-lite-product/index.ts +2 -0
  142. package/templates/retail-store-owner/tier5-src/himi-owner-lite-product-form/index.ts +10 -4
  143. package/templates/retail-store-owner/tier5-src/himi-owner-lite-search/index.ts +11 -0
  144. package/templates/retail-store-owner/tier5-src/himi-owner-lite-search/logic.ts +35 -1
  145. package/templates/yoga-studio/tier5-src/_shared/collection-query.ts +32 -5
  146. package/templates/yoga-studio/tier5-src/_shared/collection-service.ts +2 -1
  147. package/templates/yoga-studio-owner/tier5-src/_shared/collection-query.ts +32 -5
  148. package/templates/yoga-studio-owner/tier5-src/_shared/collection-service.ts +2 -1
  149. package/templates/yoga-studio-owner/tier5-src/_shared/owner-editor-value.ts +10 -0
  150. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-event/index.ts +2 -0
  151. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-event-form/index.ts +4 -1
  152. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-manage/index.ts +18 -3
  153. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-post/index.ts +2 -0
  154. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-post-form/index.ts +10 -4
  155. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-search/index.ts +11 -0
  156. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-search/logic.ts +35 -1
  157. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-service-form/index.ts +2 -1
@@ -78394,7 +78394,10 @@ const SCREENS = runtimeSetting({
78394
78394
  "bottom": 24
78395
78395
  },
78396
78396
  "onAppear": {
78397
- "actionId": "tier5.himiOwnerLiteFaqQuestionForm.load"
78397
+ "actionId": "tier5.himiOwnerLiteFaqQuestionForm.load",
78398
+ "params": {
78399
+ "mode": "create"
78400
+ }
78398
78401
  }
78399
78402
  }
78400
78403
  },
@@ -78875,7 +78878,10 @@ const SCREENS = runtimeSetting({
78875
78878
  "bottom": 24
78876
78879
  },
78877
78880
  "onAppear": {
78878
- "actionId": "tier5.himiOwnerLiteFaqCategoryForm.load"
78881
+ "actionId": "tier5.himiOwnerLiteFaqCategoryForm.load",
78882
+ "params": {
78883
+ "mode": "create"
78884
+ }
78879
78885
  }
78880
78886
  }
78881
78887
  },
@@ -27,7 +27,9 @@ export interface CollectionQuerySource<Row> {
27
27
  cached?(criteria: CollectionQuery): Promise<CollectionPage<Row> | undefined>;
28
28
  id(row: Row): string;
29
29
  errorMessage?(error: unknown): string;
30
- /** Private collections must discard prior rows when access or identity changes. */
30
+ /** Private collections must discard prior rows when access or identity changes. A revalidation keeps the
31
+ * rows it shows through any other failure: safe because one worker never serves two identities (its pool
32
+ * key carries the account, and a purge tears parked workers down). */
31
33
  clearOnError?(error: unknown): boolean;
32
34
  }
33
35
  const normalize = (q: Partial<CollectionQuery>): CollectionQuery => ({
@@ -46,8 +48,17 @@ export function createCollectionQuery<Row>(source: CollectionQuerySource<Row>, p
46
48
  let generation = 0, disposed = false;
47
49
  let loadedPages = 0, restoringPages = 0;
48
50
  let state: CollectionQueryState<Row> = { criteria: normalize({}), items: [], nextCursor: '', status: 'idle', loadingMore: false, pageError: false, complete: false, error: '' };
51
+ /** The query a revalidation is running under: its snapshots are held back until it is done. */
52
+ let revalidating = -1;
53
+ /** What the screen shows: the last published state, restored when a revalidation fails. */
54
+ let shown: { state: CollectionQueryState<Row>; loadedPages: number } | undefined;
55
+ let clearedByError = false;
49
56
  const snapshot = (): CollectionQueryState<Row> => ({ ...state, criteria: copyQuery(state.criteria), items: [...state.items] });
50
- const emit = () => { if (!disposed) publish(snapshot()); };
57
+ const emit = () => {
58
+ if (disposed || revalidating === generation) return;
59
+ shown = { state: snapshot(), loadedPages };
60
+ publish(snapshot());
61
+ };
51
62
  const merge = (old: Row[], page: Row[]): Row[] => {
52
63
  const positions = new Map<string, number>(), result: Row[] = [];
53
64
  for (const item of [...old, ...page]) {
@@ -88,6 +99,8 @@ export function createCollectionQuery<Row>(source: CollectionQuerySource<Row>, p
88
99
  } catch (error) {
89
100
  if (disposed || generation !== revision) return;
90
101
  const clear = source.clearOnError?.(error) === true;
102
+ // Revoked access always ends what a revalidation showed, whether or not the source clears its rows.
103
+ clearedByError = clear || /HTTP 40[13]\b/.test(String(error));
91
104
  if (clear) loadedPages = 0;
92
105
  state = { ...state, ...(clear ? { items: [], nextCursor: '', total: undefined, metadata: undefined } : {}),
93
106
  status: append && !clear ? 'ready' : 'error', loadingMore: false, pageError: append && !clear, complete: false,
@@ -97,13 +110,18 @@ export function createCollectionQuery<Row>(source: CollectionQuerySource<Row>, p
97
110
  };
98
111
  return {
99
112
  getState: snapshot,
100
- async query(criteria: Partial<CollectionQuery>, restorePages = false): Promise<void> {
113
+ /** [revalidate]: a screen coming back re-queries the rows it shows. They stay on screen until every
114
+ * page is in, and a failure keeps them; revoked access still clears them, and other criteria reset. */
115
+ async query(criteria: Partial<CollectionQuery>, restorePages = false, revalidate = false): Promise<void> {
101
116
  if (disposed) return Promise.resolve();
102
117
  const next = normalize(criteria);
103
- const count = restorePages && JSON.stringify(next) === JSON.stringify(state.criteria)
104
- ? Math.max(loadedPages, restoringPages, 1) : 1;
118
+ const same = JSON.stringify(next) === JSON.stringify(state.criteria);
119
+ const count = restorePages && same ? Math.max(loadedPages, restoringPages, 1) : 1;
120
+ const showing = shown && shown.state.status === 'ready' && shown.state.items.length > 0 && !shown.state.pageError
121
+ && !shown.state.loadingMore && JSON.stringify(shown.state.criteria) === JSON.stringify(next) ? shown : undefined;
105
122
  restoringPages = count;
106
123
  state = { ...state, criteria: next };
124
+ if (revalidate && showing) { revalidating = generation + 1; clearedByError = false; }
107
125
  const pending = fetch(false);
108
126
  const revision = generation;
109
127
  await pending;
@@ -112,6 +130,15 @@ export function createCollectionQuery<Row>(source: CollectionQuerySource<Row>, p
112
130
  while (!disposed && generation === revision && loadedPages < count && state.status === 'ready'
113
131
  && !state.pageError && !state.loadingMore && state.nextCursor) await fetch(true);
114
132
  if (generation === revision) restoringPages = 0;
133
+ if (revalidating === revision) {
134
+ revalidating = -1;
135
+ if (generation !== revision) return;
136
+ if ((state.status !== 'ready' || state.pageError) && showing && !clearedByError) {
137
+ state = { ...showing.state, criteria: next };
138
+ loadedPages = showing.loadedPages;
139
+ }
140
+ emit();
141
+ }
115
142
  },
116
143
  refresh: () => { restoringPages = 0; return fetch(false); },
117
144
  loadMore: () => fetch(true),
@@ -299,7 +299,8 @@ export function decorateCollection(name: string, def: { state: State; actions?:
299
299
  actions.load = (_s, arg) => {
300
300
  stashCopy(arg); syncSaved(); applyNavigationCriteria(arg, _s);
301
301
  backend.onLoad?.(arg);
302
- return backend.controller.query(criteria(), backend.restorePagesOnLoad).then(() => undefined);
302
+ // A load of what the screen already shows is the screen coming back: it revalidates behind its rows.
303
+ return backend.controller.query(criteria(), backend.restorePagesOnLoad, true).then(() => undefined);
303
304
  };
304
305
  actions.refresh = (_s, arg) => {
305
306
  stashCopy(arg); syncSaved();
@@ -91,6 +91,16 @@ export function editorRunKeys<K extends string>(key: K): [`${K}Json`, `${K}Pendi
91
91
  return [`${key}Json`, `${key}Pending`, `${key}Dirty`, `${key}Edits`];
92
92
  }
93
93
 
94
+ /** Does the form already hold `target` with its editor run in progress? A `load` for it then keeps the form as
95
+ * it is: on the way back to a warm worker (its refresh and onAppear) a re-read would race an edit in flight and
96
+ * reset the reported edit, and the editor, which seeds once, would show another document than the state holds.
97
+ * An edit form holds its record once the editor loaded; a create form holds a draft once the owner edited. */
98
+ export function holdsLoadedForm(state: object, target: { id: string; mode: string }, key: string): boolean {
99
+ const s = state as Record<string, unknown>;
100
+ if (s.status === 'error' || s.error || s.mode !== target.mode || (s.id ?? '') !== target.id) return false;
101
+ return target.mode === 'create' ? s[`${key}Dirty`] === true || Number(s[`${key}Edits`] ?? 0) > 0 : s[`${key}Pending`] === false;
102
+ }
103
+
94
104
  /** The same keys on a runtime that ignores `notHydrated` (a binary from before #3644, still served new
95
105
  * bundles over the air): reset them in place, before any action runs. A no-op where the runtime
96
106
  * honours the option (`himalaya.hydrationOptOut`). */
@@ -262,6 +262,8 @@ const svc = defineService<EventState, {
262
262
  }>("himiOwnerLiteEvent", {
263
263
  state: shape({ status: "loading" }),
264
264
  notHydrated: [...DELETE_RUN_KEYS],
265
+ // perVisit: a cached embed's warm return starts this run fresh, as a cold boot does (`__resetRunState`).
266
+ perVisit: [...DELETE_RUN_KEYS],
265
267
  actions: {
266
268
  load: async (s0, arg) => {
267
269
  const s = disarm(s0);
@@ -10,7 +10,7 @@ import { defineService } from "@himalaya/state";
10
10
  import { getEvent, updateEvent, type WixEvent } from "@himalaya/wix-events";
11
11
  import { normalizeEvent } from "../_shared/himi-events.js";
12
12
  import { postBodyText, plainPostBody } from "../_shared/owner-post-content.js";
13
- import { editorSubmitValue, queuedDocument, editorChanged, cachedRichVerdict, editorRunKeys, resetHydratedEditorOnOlderRuntime, editorAfterSave, liveState, settleOverLive } from "../_shared/owner-editor-value.js";
13
+ import { editorSubmitValue, queuedDocument, editorChanged, cachedRichVerdict, editorRunKeys, resetHydratedEditorOnOlderRuntime, editorAfterSave, liveState, settleOverLive, holdsLoadedForm } from "../_shared/owner-editor-value.js";
14
14
  import { parseRicosJson } from "@himalaya/wix-rich-content";
15
15
  import { ricosUploadService } from "@himalaya/wix-rich-content-editor";
16
16
  import { ownerLoadError, isOwnerPreviewDemo, ownerDemoFallback, ownerWriteError } from "../_shared/owner-load-error.js";
@@ -176,6 +176,8 @@ const svc = defineService<EventFormState, {
176
176
  state: shape({ mode: startMode, id: initialInput.id ?? "", descriptionPending: true }),
177
177
  // [rico] #3644 — this run's editor state: a cold boot takes it from `state`, never the snapshot.
178
178
  notHydrated: editorRunKeys("description"),
179
+ // perVisit: a cached embed's warm return starts this run fresh, as a cold boot does (`__resetRunState`).
180
+ perVisit: editorRunKeys("description"),
179
181
  actions: {
180
182
  load: async (s, arg) => {
181
183
  stashCopy(arg); // absorb the descriptor's onAppear copy-pack param
@@ -183,6 +185,7 @@ const svc = defineService<EventFormState, {
183
185
  // WS-C — nav-param input is PRIMARY (the canonical form bakes no id); the
184
186
  // onAppear `id` param is the #570 suffixed-build fallback.
185
187
  const id = navInputId() || idFrom(arg, s.id);
188
+ if (id && holdsLoadedForm(s, { id, mode }, "description")) return s;
186
189
  if (!id) return shape({ ...s, mode, id: "", status: "error", error: copyParam("ownerEventForm.errorNoEvent", "No event to edit.") });
187
190
  if (isOwnerPreviewDemo()) {
188
191
  const d = DEMO_EVENTS.find((x) => x.id === id);
@@ -6,4 +6,8 @@ interface State { id:string;revision:string;mode:"create"|"edit";heading:string;
6
6
  const input=((globalThis as {himalaya?:{input?:{id?:string}}}).himalaya?.input)??{}; const msg=(e:unknown)=>String((e as {message?:unknown})?.message??e); const conflict=(e:unknown)=>/\b409\b|conflict|revision/i.test(msg(e)); const offline=(e:unknown)=>/offline|network|connect|bridge|fetch/i.test(msg(e));
7
7
  async function appendOrder():Promise<number>{let max=-1,cursor="";const seen=new Set<string>();do{const r=await queryCategories({query:{cursorPaging:{limit:100,...(cursor?{cursor}:{})}}});for(const c of r.categories??[])max=Math.max(max,c.sortOrder??-1);const next=r.pagingMetadata?.cursors?.next??"";if(!next||seen.has(next))break;seen.add(next);cursor=next;}while(cursor);return max+1;}
8
8
  const shape=(s:Partial<State>):State=>({id:s.id??input.id??"",revision:s.revision??"",mode:s.mode??((s.id??input.id)?"edit":"create"),heading:s.heading??((s.id??input.id)?"Edit category":"New category"),title:s.title??"",error:s.error??"",conflict:s.conflict??false,noConflict:!(s.conflict??false),savedMessage:s.savedMessage??"",savedId:s.savedId??""});
9
- const svc=defineService<State,{load:(s:State,a?:unknown)=>Promise<State>;submit:(s:State,v?:Record<string,unknown>)=>Promise<State>}>("himiOwnerLiteFaqCategoryForm",{state:shape({}),actions:{load:async(s,a)=>{const id=input.id||String((a as {id?:unknown})?.id??s.id);if(!id)return shape({...s,mode:"create"});try{const c=(await getCategory(id)).category;if(!c)return shape({...s,id,error:"Category not found"});return shape({...s,id,revision:c.revision??"",mode:"edit",heading:"Edit category",title:c.title??"",error:"",conflict:false});}catch(e){return shape({...s,id,error:offline(e)?"Connect to the internet to load this category.":msg(e)});}},submit:async(s,v)=>{const title=String(v?.title??s.title).trim();if(!title)return shape({...s,error:"Category name is required.",savedMessage:""});try{if(s.mode==="edit"){if(!s.revision)return shape({...s,error:"Refresh before saving; this category has no current revision.",conflict:true});const r=await updateCategory(s.id,{category:{id:s.id,revision:s.revision,title},fieldMask:["title"]});return shape({...s,revision:r.category?.revision??s.revision,title,error:"",conflict:false,savedMessage:"Category saved.",savedId:s.id});}const r=await createCategory({category:{title,sortOrder:await appendOrder()}});return shape({...s,id:r.category?.id??"",revision:r.category?.revision??"",mode:"edit",heading:"Edit category",title,error:"",savedMessage:"Category created.",savedId:r.category?.id??""});}catch(e){if(conflict(e))return shape({...s,error:"This category changed elsewhere. Refresh before saving again.",conflict:true,savedMessage:""});return shape({...s,error:offline(e)?"Connect to the internet and try again. This change was not queued.":msg(e),savedMessage:""});}}}});export{svc,appendOrder};
9
+ const svc=defineService<State,{load:(s:State,a?:unknown)=>Promise<State>;submit:(s:State,v?:Record<string,unknown>)=>Promise<State>}>("himiOwnerLiteFaqCategoryForm",{state:shape({}),actions:{load:async(s,a)=>{
10
+ // The held id is for the host's bare refresh only (no argument): an onAppear without an id is a create screen.
11
+ const id=input.id||String((a as {id?:unknown})?.id??(a===undefined?s.id:""));
12
+ // A form that held a record (the one just saved) opens blank.
13
+ if(!id)return shape({...(s.id?{}:s),mode:"create",id:""});try{const c=(await getCategory(id)).category;if(!c)return shape({...s,id,error:"Category not found"});return shape({...s,id,revision:c.revision??"",mode:"edit",heading:"Edit category",title:c.title??"",error:"",conflict:false});}catch(e){return shape({...s,id,error:offline(e)?"Connect to the internet to load this category.":msg(e)});}},submit:async(s,v)=>{const title=String(v?.title??s.title).trim();if(!title)return shape({...s,error:"Category name is required.",savedMessage:""});try{if(s.mode==="edit"){if(!s.revision)return shape({...s,error:"Refresh before saving; this category has no current revision.",conflict:true});const r=await updateCategory(s.id,{category:{id:s.id,revision:s.revision,title},fieldMask:["title"]});return shape({...s,revision:r.category?.revision??s.revision,title,error:"",conflict:false,savedMessage:"Category saved.",savedId:s.id});}const r=await createCategory({category:{title,sortOrder:await appendOrder()}});return shape({...s,id:r.category?.id??"",revision:r.category?.revision??"",mode:"edit",heading:"Edit category",title,error:"",savedMessage:"Category created.",savedId:r.category?.id??""});}catch(e){if(conflict(e))return shape({...s,error:"This category changed elsewhere. Refresh before saving again.",conflict:true,savedMessage:""});return shape({...s,error:offline(e)?"Connect to the internet and try again. This change was not queued.":msg(e),savedMessage:""});}}}});export{svc,appendOrder};
@@ -4,7 +4,7 @@ import { defineService } from "@himalaya/state";
4
4
  import { createQuestionEntry, getQuestionEntry, queryCategories, queryQuestionEntries, updateQuestionEntry, type WixCategory, type WixQuestionEntry } from "@himalaya/wix-faq";
5
5
  import { postBodyText, plainPostBody } from "../_shared/owner-post-content.js";
6
6
  import { draftjsText } from "../_shared/owner-rich-guard.js";
7
- import { editorSubmitValue, isEmptyDocument, editorRunKeys, resetHydratedEditorOnOlderRuntime, editorAfterSave, liveState, settleOverLive } from "../_shared/owner-editor-value.js";
7
+ import { editorSubmitValue, isEmptyDocument, editorRunKeys, resetHydratedEditorOnOlderRuntime, editorAfterSave, liveState, settleOverLive, holdsLoadedForm } from "../_shared/owner-editor-value.js";
8
8
  import { parseRicosJson, type RicosDocument } from "@himalaya/wix-rich-content";
9
9
  import { ricosUploadService } from "@himalaya/wix-rich-content-editor";
10
10
 
@@ -47,8 +47,14 @@ const shape=(s:Partial<State>):State=>({ id:s.id??input.id??"", revision:s.revis
47
47
  ricosUploadService("himiOwnerLiteFaqQuestionFormUploads");
48
48
  const svc=defineService<State,{load:(s:State,a?:unknown)=>Promise<State>;answerChanged:(s:State,a?:unknown)=>State;submit:(s:State,v?:Record<string,unknown>)=>Promise<State>}>("himiOwnerLiteFaqQuestionForm",{state:shape({answerPending:!!input.id}),
49
49
  // [rico] #3644 — this run's editor state: a cold boot takes it from `state`, never the snapshot.
50
- notHydrated:editorRunKeys("answer"),actions:{
51
- load:async(s,a)=>{const id=input.id||String((a as {id?:unknown})?.id??s.id);try{const cats=await categories();const options=cats.map(c=>({id:c.id??"",label:c.title?.trim()||"Untitled category"}));if(!id)return shape({...s,...ANSWER_LOADED,mode:"create",answerMode:"plain",answerJson:"",categoryOptions:options,statusValue:"HIDDEN"});const q=(await getQuestionEntry(id)).questionEntry;if(!q) return shape({...s,id,error:"Question not found",categoryOptions:options});return shape({...s,id,revision:q.revision??"",mode:"edit",heading:"Edit question",question:q.question??"",...((a)=>({...a,...ANSWER_LOADED,answerMode:a.answerFormat==="draftjs"?"locked" as const:"editor" as const,
50
+ notHydrated:editorRunKeys("answer"),
51
+ // perVisit: a cached embed's warm return starts this run fresh, as a cold boot does (`__resetRunState`).
52
+ perVisit:editorRunKeys("answer"),actions:{
53
+ load:async(s,a)=>{
54
+ // The held id is for the host's bare refresh only (no argument): an onAppear without an id is a create screen.
55
+ const id=input.id||String((a as {id?:unknown})?.id??(a===undefined?s.id:""));if(holdsLoadedForm(s,{id,mode:id?"edit":"create"},"answer"))return s;try{const cats=await categories();const options=cats.map(c=>({id:c.id??"",label:c.title?.trim()||"Untitled category"}));
56
+ // A form that held a record (the one just saved) opens blank.
57
+ if(!id)return shape({...(s.id?{}:s),...ANSWER_LOADED,mode:"create",id:"",answerMode:"plain",answerJson:"",categoryOptions:options,statusValue:"HIDDEN"});const q=(await getQuestionEntry(id)).questionEntry;if(!q) return shape({...s,id,error:"Question not found",categoryOptions:options});return shape({...s,id,revision:q.revision??"",mode:"edit",heading:"Edit question",question:q.question??"",...((a)=>({...a,...ANSWER_LOADED,answerMode:a.answerFormat==="draftjs"?"locked" as const:"editor" as const,
52
58
  // The seed is the stored document byte for byte (never re-encoded); a plain answer seeds as paragraphs.
53
59
  answerJson:a.answerFormat==="draftjs"?"":q.richContent?JSON.stringify(q.richContent):q.plainText?JSON.stringify(plainPostBody(q.plainText)):""}))(answerOf(q)),categoryId:q.categoryId??"",statusValue:q.status==="VISIBLE"?"VISIBLE":"HIDDEN",categoryOptions:options,error:"",conflict:false});}catch(e){return shape({...s,id,error:offline(e)?"Connect to the internet to load this question.":msg(e)});}},
54
60
  // The editor's on_change carries `args.dirty` only; the document stays in the form bag.
@@ -214,6 +214,22 @@ let loadGeneration = 0;
214
214
  let newestStarted: State | undefined;
215
215
  let newestSettled: { generation: number; state: State } | undefined;
216
216
 
217
+ /** A hub already showing its areas re-reads their summaries behind them, never back under the skeleton: the
218
+ * screen coming back (a warm worker's refresh and its onAppear, at once) keeps what it shows until they land. */
219
+ function revalidating(state: State, areas: State['areas'] = state.areas): State {
220
+ // The areas as delivered now (labels, routes), each still showing the summary it showed.
221
+ const shown = new Map(state.areas.map(area => [area.id, area]));
222
+ const next = areas.map(area => {
223
+ const was = shown.get(area.id);
224
+ return was ? { ...area, metricText: was.metricText, statusText: was.statusText, unavailable: was.unavailable } : area;
225
+ });
226
+ return derive({ ...state, areas: next, businessName: currentBusinessName, handoffError: '' }, next);
227
+ }
228
+
229
+ function sameAreas(shown: State['areas'], next: State['areas']): boolean {
230
+ return shown.length === next.length && shown.every((area, i) => area.id === next[i].id && area.metricKey === next[i].metricKey);
231
+ }
232
+
217
233
  async function loadMetrics(state: State): Promise<State> {
218
234
  const generation = ++loadGeneration;
219
235
  newestStarted = state;
@@ -292,12 +308,11 @@ const svc = defineService<State, {
292
308
  // it must not settle (that derived "0 business areas" and painted the
293
309
  // filtered-empty state); once they are, it re-reads their summaries.
294
310
  if (!('areas' in read(arg))) {
295
- return state.status === 'ready'
296
- ? loadMetrics(derive({ ...state, isLoading: true, notLoading: false, summaryNote: '', handoffError: '' }))
297
- : { ...state, businessName: currentBusinessName };
311
+ return state.status === 'ready' ? loadMetrics(revalidating(state)) : { ...state, businessName: currentBusinessName };
298
312
  }
299
313
  const parsed = (() => { try { return JSON.parse(str(arg, 'areas') || '[]'); } catch { return []; } })();
300
314
  const areas = parseAreas(parsed);
315
+ if (state.status === 'ready' && sameAreas(state.areas, areas)) return loadMetrics(revalidating(state, areas));
301
316
  return loadMetrics(derive({ ...state, status: 'ready', areas, isLoading: true, notLoading: false, summaryNote: '', handoffError: '' }, areas));
302
317
  },
303
318
  // Refresh re-reads summaries for KNOWN areas. Before the descriptor has
@@ -340,6 +340,8 @@ const svc = defineService<PostState, {
340
340
  }>("himiOwnerLitePost", {
341
341
  state: shape({ status: "loading" }),
342
342
  notHydrated: [...DELETE_RUN_KEYS],
343
+ // perVisit: a cached embed's warm return starts this run fresh, as a cold boot does (`__resetRunState`).
344
+ perVisit: [...DELETE_RUN_KEYS],
343
345
  actions: {
344
346
  load: async (s0, arg) => {
345
347
  const s = disarm(s0);
@@ -4,7 +4,7 @@
4
4
  // screen (himiOwnerLitePost.publish). Bound by buildForm ($service:himiOwnerLitePostForm.*).
5
5
 
6
6
  import { postBodyText, plainPostBody, postCoverUrl } from "../_shared/owner-post-content.js";
7
- import { editorSubmitValue, queuedDocument, editorChanged, editorRunKeys, resetHydratedEditorOnOlderRuntime, editorAfterSave, liveState, settleOverLive } from "../_shared/owner-editor-value.js";
7
+ import { editorSubmitValue, queuedDocument, editorChanged, editorRunKeys, resetHydratedEditorOnOlderRuntime, editorAfterSave, liveState, settleOverLive, holdsLoadedForm } from "../_shared/owner-editor-value.js";
8
8
  import { ricosUploadService } from "@himalaya/wix-rich-content-editor";
9
9
  import { wixCall } from "@himalaya/wix-site-media";
10
10
  import { wixMediaUrl, WIX_DETAIL_IMAGE } from "../_shared/himi-store.js";
@@ -165,18 +165,24 @@ const svc = defineService<PostFormState, {
165
165
  bodyChanged: (s: PostFormState, arg?: unknown) => PostFormState;
166
166
  submit: (s: PostFormState, values?: Record<string, unknown>) => Promise<PostFormState>;
167
167
  }>("himiOwnerLitePostForm", {
168
- state: shape({ mode: startMode, id: initialInput.id ?? "", bodyPending: startMode === "edit" }),
168
+ // The editor stays hidden until this run's load lands, however the form was opened (an id as input or in the params).
169
+ state: shape({ mode: startMode, id: initialInput.id ?? "", bodyPending: true }),
169
170
  // [rico] #3644 — this run's editor state and UI: a cold boot takes these from `state`, never from
170
171
  // the persisted snapshot (the editor seeds once; last run's sheet is not this run's).
171
172
  notHydrated: [...editorRunKeys("body"), "detailsOpen"],
173
+ // perVisit: a cached embed's warm return starts this run fresh, as a cold boot does (`__resetRunState`).
174
+ perVisit: [...editorRunKeys("body"), "detailsOpen"],
172
175
  actions: {
173
176
  load: async (s, arg) => {
174
177
  stashCopy(arg); // absorb the descriptor's onAppear copy-pack param
175
178
  // WS-C — nav-param input is PRIMARY (the canonical create-or-edit shell
176
179
  // bakes no id/mode); the onAppear `id` param is the #570 suffixed-build
177
180
  // fallback. An id ⇒ edit; no id anywhere ⇒ the create shell.
178
- const id = navInputId() || idFrom(arg, s.id);
179
- if (!id) return shape({ ...s, ...BODY_LOADED, mode: "create", id: "", bodyJson: "", status: "idle", error: "", demoNote: isOwnerPreviewDemo() ? copyParam("ownerCommon.demoSaveNote", DEMO_SAVE_NOTE) : "" });
181
+ // The held id is for the host's bare refresh only (no argument): an onAppear without an id is a create screen.
182
+ const id = navInputId() || idFrom(arg, arg === undefined ? s.id : "");
183
+ if (holdsLoadedForm(s, { id, mode: id ? "edit" : "create" }, "body")) return s;
184
+ // A form that held a record (the one just saved) opens blank.
185
+ if (!id) return shape({ ...(s.id ? {} : s), ...BODY_LOADED, mode: "create", id: "", bodyJson: "", status: "idle", error: "", demoNote: isOwnerPreviewDemo() ? copyParam("ownerCommon.demoSaveNote", DEMO_SAVE_NOTE) : "" });
180
186
  if (isOwnerPreviewDemo()) {
181
187
  const d = DEMO_POSTS.find((x) => x.id === id);
182
188
  if (d) {
@@ -305,6 +305,8 @@ const svc = defineService<ProductState, {
305
305
  }>("himiOwnerLiteProduct", {
306
306
  state: shape({ status: "loading" }),
307
307
  notHydrated: [...DELETE_RUN_KEYS],
308
+ // perVisit: a cached embed's warm return starts this run fresh, as a cold boot does (`__resetRunState`).
309
+ perVisit: [...DELETE_RUN_KEYS],
308
310
  actions: {
309
311
  load: async (s0, arg) => {
310
312
  const s = disarm(s0);
@@ -18,7 +18,7 @@ import {
18
18
  import { get as dbGet, upsert as dbUpsert, remove as dbRemove, type DbEntity } from "../_shared/owner-local-db.js";
19
19
  import { htmlIsTrivial, htmlPreviewText } from "../_shared/owner-rich-guard.js";
20
20
  import { postBodyText, plainPostBody } from "../_shared/owner-post-content.js";
21
- import { editorSubmitValue, queuedDocument, editorChanged, cachedRichVerdict, editorRunKeys, resetHydratedEditorOnOlderRuntime, editorAfterSave, liveState, settleOverLive } from "../_shared/owner-editor-value.js";
21
+ import { editorSubmitValue, queuedDocument, editorChanged, cachedRichVerdict, editorRunKeys, resetHydratedEditorOnOlderRuntime, editorAfterSave, liveState, settleOverLive, holdsLoadedForm } from "../_shared/owner-editor-value.js";
22
22
  import { parseRicosJson } from "@himalaya/wix-rich-content";
23
23
  import { ricosUploadService } from "@himalaya/wix-rich-content-editor";
24
24
  import { ownerLoadError, isOwnerPreviewDemo, ownerDemoFallback, ownerWriteError } from "../_shared/owner-load-error.js";
@@ -195,17 +195,23 @@ const svc = defineService<ProductFormState, {
195
195
  submit: (s: ProductFormState, values?: Record<string, unknown>) => Promise<ProductFormState>;
196
196
  remove: (s: ProductFormState) => Promise<ProductFormState>;
197
197
  }>("himiOwnerLiteProductForm", {
198
- state: shape({ mode: startMode, id: initialInput.id ?? "", descriptionPending: startMode === "edit" }),
198
+ // The editor stays hidden until this run's load lands, however the form was opened (an id as input or in the params).
199
+ state: shape({ mode: startMode, id: initialInput.id ?? "", descriptionPending: true }),
199
200
  // [rico] #3644 — this run's editor state: a cold boot takes it from `state`, never the snapshot.
200
201
  notHydrated: editorRunKeys("description"),
202
+ // perVisit: a cached embed's warm return starts this run fresh, as a cold boot does (`__resetRunState`).
203
+ perVisit: editorRunKeys("description"),
201
204
  actions: {
202
205
  load: async (s, arg) => {
203
206
  stashCopy(arg); // absorb the descriptor's onAppear copy-pack param
204
207
  // WS-C — nav-param input is PRIMARY (the canonical create-or-edit shell
205
208
  // bakes no id/mode); the onAppear `id` param is the #570 suffixed-build
206
209
  // fallback. An id ⇒ edit; no id anywhere ⇒ the create shell.
207
- const id = navInputId() || idFrom(arg, s.id);
208
- if (!id) return shape({ ...s, ...DESCRIPTION_LOADED, mode: "create", id: "", descriptionMode: "plain", descriptionJson: "", status: "idle", error: "", demoNote: isOwnerPreviewDemo() ? copyParam("ownerCommon.demoSaveNote", DEMO_SAVE_NOTE) : "" });
210
+ // The held id is for the host's bare refresh only (no argument): an onAppear without an id is a create screen.
211
+ const id = navInputId() || idFrom(arg, arg === undefined ? s.id : "");
212
+ if (holdsLoadedForm(s, { id, mode: id ? "edit" : "create" }, "description")) return s;
213
+ // A form that held a record (the one just saved) opens blank.
214
+ if (!id) return shape({ ...(s.id ? {} : s), ...DESCRIPTION_LOADED, mode: "create", id: "", descriptionMode: "plain", descriptionJson: "", status: "idle", error: "", demoNote: isOwnerPreviewDemo() ? copyParam("ownerCommon.demoSaveNote", DEMO_SAVE_NOTE) : "" });
209
215
  if (isOwnerPreviewDemo()) {
210
216
  const d = DEMO_PRODUCTS.find((p) => p.id === id);
211
217
  if (d) {
@@ -254,6 +254,8 @@ const IDLE: State = {
254
254
 
255
255
  /** Rebuilt on `load`, because the allowlist arrives with the descriptor. */
256
256
  let coordinator: ReturnType<typeof createGroupedSearch> | undefined;
257
+ /** The allowlist the coordinator was built for. */
258
+ let coordinatorSources = "";
257
259
  let published: State = IDLE;
258
260
  const memory = localPreferences("ownerSearch", normalizeMemory);
259
261
 
@@ -279,6 +281,15 @@ const svc = defineService<State, {
279
281
  actions: {
280
282
  load: async (_state, arg) => {
281
283
  stashCopy(arg);
284
+ // The screen coming back re-runs its search behind what it shows: a warm worker's refresh calls `load` bare (no allowlist),
285
+ // and its onAppear repeats the allowlist the coordinator was built for.
286
+ const sources = str(arg, "sources");
287
+ if (coordinator && (!sources || sources === coordinatorSources)) {
288
+ await coordinator.revalidate();
289
+ remember();
290
+ return published;
291
+ }
292
+ coordinatorSources = sources;
282
293
  const allowed = (() => {
283
294
  try {
284
295
  return JSON.parse(str(arg, "sources") || "[]");
@@ -446,7 +446,13 @@ export function createGroupedSearch(
446
446
  };
447
447
  };
448
448
 
449
- const emit = () => { if (!disposed) publish(snapshot()); };
449
+ /** The revalidation running behind the shown rows (0: none): its snapshots are held back until it is done. */
450
+ let holding = 0, holds = 0;
451
+ /** The depth the running revalidation restores, so one that overlaps it starts from the owner's depth, not
452
+ * from its reset; and the revalidation itself, which a "look further" tapped meanwhile waits for. */
453
+ let heldDepth: Record<string, number> = {};
454
+ let revalidation: Promise<void> | undefined;
455
+ const emit = () => { if (!disposed && !holding) publish(snapshot()); };
450
456
 
451
457
  /** Identity merge: a re-fetched row REPLACES its predecessor in place, so a
452
458
  * refresh cannot reorder the list under the user's finger or duplicate a row
@@ -604,6 +610,7 @@ export function createGroupedSearch(
604
610
  * the wrong result set. */
605
611
  async search(raw: unknown): Promise<void> {
606
612
  if (disposed) return;
613
+ holding = 0;
607
614
  const next = normalizeQuery(raw);
608
615
  query = next;
609
616
  for (const id of order) {
@@ -627,6 +634,7 @@ export function createGroupedSearch(
627
634
  */
628
635
  async restore(raw: unknown, depth: Record<string, number> = {}): Promise<void> {
629
636
  if (disposed) return;
637
+ holding = 0;
630
638
  const next = normalizeQuery(raw);
631
639
  query = next;
632
640
  for (const id of order) {
@@ -637,8 +645,34 @@ export function createGroupedSearch(
637
645
  if (!next) return;
638
646
  await fanOut(order, depth);
639
647
  },
648
+ /** A returning screen (a cached embed's warm worker): the same search re-run to the depth each group
649
+ * shows, behind the rows it shows, and published once. Rows are a live claim about records, so they
650
+ * are re-fetched, never repainted; a search typed meanwhile takes over at once. */
651
+ async revalidate(): Promise<void> {
652
+ if (disposed || !query) return;
653
+ const depth: Record<string, number> = holding ? { ...heldDepth } : {};
654
+ for (const id of order) { const pages = groups.get(id)?.pages ?? 0; if (pages > Math.max(1, depth[id] ?? 0)) depth[id] = pages; }
655
+ heldDepth = depth;
656
+ const held = ++holds;
657
+ holding = held;
658
+ for (const id of order) {
659
+ groups.set(id, idleGroup(byId.get(id)!));
660
+ sourceGeneration.set(id, (sourceGeneration.get(id) ?? 0) + 1);
661
+ }
662
+ const run = (async () => {
663
+ try {
664
+ await fanOut(order, depth);
665
+ } finally {
666
+ if (holding === held) { holding = 0; heldDepth = {}; emit(); }
667
+ }
668
+ })();
669
+ revalidation = run;
670
+ await run;
671
+ },
640
672
  /** One group's next page. */
641
673
  async loadMore(id: unknown): Promise<void> {
674
+ // Tapped while a revalidation re-fetches behind the shown rows: page on from what it lands.
675
+ while (holding && revalidation) await revalidation;
642
676
  const adapter = typeof id === "string" ? byId.get(id) : undefined;
643
677
  if (!adapter || disposed || !query) return;
644
678
  await runSource(adapter, true);
@@ -117,7 +117,8 @@ const svc = defineService<ServiceFormState, {
117
117
  stashCopy(arg); // absorb the descriptor's onAppear copy-pack param
118
118
  // WS-C — nav-param input is PRIMARY (the canonical form bakes no id); the
119
119
  // onAppear `id` param is the #570 suffixed-build fallback.
120
- const id = navInputId() || idFrom(arg, s.id);
120
+ // The held id is for the host's bare refresh only (no argument): an onAppear without an id is a create screen.
121
+ const id = navInputId() || idFrom(arg, arg === undefined ? s.id : "");
121
122
  if (!id) return shape({ ...s, status: "error", error: copyParam("ownerServiceForm.errorNoService", "No service to edit.") });
122
123
  if (isOwnerPreviewDemo()) {
123
124
  const d = DEMO_SERVICES.find((x) => x.id === id);
@@ -27,7 +27,9 @@ export interface CollectionQuerySource<Row> {
27
27
  cached?(criteria: CollectionQuery): Promise<CollectionPage<Row> | undefined>;
28
28
  id(row: Row): string;
29
29
  errorMessage?(error: unknown): string;
30
- /** Private collections must discard prior rows when access or identity changes. */
30
+ /** Private collections must discard prior rows when access or identity changes. A revalidation keeps the
31
+ * rows it shows through any other failure: safe because one worker never serves two identities (its pool
32
+ * key carries the account, and a purge tears parked workers down). */
31
33
  clearOnError?(error: unknown): boolean;
32
34
  }
33
35
  const normalize = (q: Partial<CollectionQuery>): CollectionQuery => ({
@@ -46,8 +48,17 @@ export function createCollectionQuery<Row>(source: CollectionQuerySource<Row>, p
46
48
  let generation = 0, disposed = false;
47
49
  let loadedPages = 0, restoringPages = 0;
48
50
  let state: CollectionQueryState<Row> = { criteria: normalize({}), items: [], nextCursor: '', status: 'idle', loadingMore: false, pageError: false, complete: false, error: '' };
51
+ /** The query a revalidation is running under: its snapshots are held back until it is done. */
52
+ let revalidating = -1;
53
+ /** What the screen shows: the last published state, restored when a revalidation fails. */
54
+ let shown: { state: CollectionQueryState<Row>; loadedPages: number } | undefined;
55
+ let clearedByError = false;
49
56
  const snapshot = (): CollectionQueryState<Row> => ({ ...state, criteria: copyQuery(state.criteria), items: [...state.items] });
50
- const emit = () => { if (!disposed) publish(snapshot()); };
57
+ const emit = () => {
58
+ if (disposed || revalidating === generation) return;
59
+ shown = { state: snapshot(), loadedPages };
60
+ publish(snapshot());
61
+ };
51
62
  const merge = (old: Row[], page: Row[]): Row[] => {
52
63
  const positions = new Map<string, number>(), result: Row[] = [];
53
64
  for (const item of [...old, ...page]) {
@@ -88,6 +99,8 @@ export function createCollectionQuery<Row>(source: CollectionQuerySource<Row>, p
88
99
  } catch (error) {
89
100
  if (disposed || generation !== revision) return;
90
101
  const clear = source.clearOnError?.(error) === true;
102
+ // Revoked access always ends what a revalidation showed, whether or not the source clears its rows.
103
+ clearedByError = clear || /HTTP 40[13]\b/.test(String(error));
91
104
  if (clear) loadedPages = 0;
92
105
  state = { ...state, ...(clear ? { items: [], nextCursor: '', total: undefined, metadata: undefined } : {}),
93
106
  status: append && !clear ? 'ready' : 'error', loadingMore: false, pageError: append && !clear, complete: false,
@@ -97,13 +110,18 @@ export function createCollectionQuery<Row>(source: CollectionQuerySource<Row>, p
97
110
  };
98
111
  return {
99
112
  getState: snapshot,
100
- async query(criteria: Partial<CollectionQuery>, restorePages = false): Promise<void> {
113
+ /** [revalidate]: a screen coming back re-queries the rows it shows. They stay on screen until every
114
+ * page is in, and a failure keeps them; revoked access still clears them, and other criteria reset. */
115
+ async query(criteria: Partial<CollectionQuery>, restorePages = false, revalidate = false): Promise<void> {
101
116
  if (disposed) return Promise.resolve();
102
117
  const next = normalize(criteria);
103
- const count = restorePages && JSON.stringify(next) === JSON.stringify(state.criteria)
104
- ? Math.max(loadedPages, restoringPages, 1) : 1;
118
+ const same = JSON.stringify(next) === JSON.stringify(state.criteria);
119
+ const count = restorePages && same ? Math.max(loadedPages, restoringPages, 1) : 1;
120
+ const showing = shown && shown.state.status === 'ready' && shown.state.items.length > 0 && !shown.state.pageError
121
+ && !shown.state.loadingMore && JSON.stringify(shown.state.criteria) === JSON.stringify(next) ? shown : undefined;
105
122
  restoringPages = count;
106
123
  state = { ...state, criteria: next };
124
+ if (revalidate && showing) { revalidating = generation + 1; clearedByError = false; }
107
125
  const pending = fetch(false);
108
126
  const revision = generation;
109
127
  await pending;
@@ -112,6 +130,15 @@ export function createCollectionQuery<Row>(source: CollectionQuerySource<Row>, p
112
130
  while (!disposed && generation === revision && loadedPages < count && state.status === 'ready'
113
131
  && !state.pageError && !state.loadingMore && state.nextCursor) await fetch(true);
114
132
  if (generation === revision) restoringPages = 0;
133
+ if (revalidating === revision) {
134
+ revalidating = -1;
135
+ if (generation !== revision) return;
136
+ if ((state.status !== 'ready' || state.pageError) && showing && !clearedByError) {
137
+ state = { ...showing.state, criteria: next };
138
+ loadedPages = showing.loadedPages;
139
+ }
140
+ emit();
141
+ }
115
142
  },
116
143
  refresh: () => { restoringPages = 0; return fetch(false); },
117
144
  loadMore: () => fetch(true),
@@ -299,7 +299,8 @@ export function decorateCollection(name: string, def: { state: State; actions?:
299
299
  actions.load = (_s, arg) => {
300
300
  stashCopy(arg); syncSaved(); applyNavigationCriteria(arg, _s);
301
301
  backend.onLoad?.(arg);
302
- return backend.controller.query(criteria(), backend.restorePagesOnLoad).then(() => undefined);
302
+ // A load of what the screen already shows is the screen coming back: it revalidates behind its rows.
303
+ return backend.controller.query(criteria(), backend.restorePagesOnLoad, true).then(() => undefined);
303
304
  };
304
305
  actions.refresh = (_s, arg) => {
305
306
  stashCopy(arg); syncSaved();