@wix/himalaya-cli 0.1471.0 → 0.1473.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 (95) hide show
  1. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/dev-server/src/app-config.d.ts +6 -9
  2. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/ts/src/auth-gate-screen.d.ts +29 -0
  3. package/dist/cli.mjs +30 -18
  4. package/package.json +2 -2
  5. package/templates/beauty-salon/tier5-src/_shared/collection-query.ts +6 -4
  6. package/templates/beauty-salon/tier5-src/_shared/collection-service.ts +5 -2
  7. package/templates/beauty-salon-owner/tier5-src/_shared/collection-query.ts +6 -4
  8. package/templates/beauty-salon-owner/tier5-src/_shared/collection-service.ts +5 -2
  9. package/templates/beauty-salon-owner/tier5-src/_shared/owner-editor-value.ts +14 -0
  10. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-post-form/index.ts +4 -4
  11. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-product-form/index.ts +4 -4
  12. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-service-form/index.ts +4 -3
  13. package/templates/branded-lite/tier5-src/_shared/collection-query.ts +6 -4
  14. package/templates/branded-lite/tier5-src/_shared/collection-service.ts +5 -2
  15. package/templates/community-club/tier5-src/_shared/collection-query.ts +6 -4
  16. package/templates/community-club/tier5-src/_shared/collection-service.ts +5 -2
  17. package/templates/community-club-owner/tier5-src/_shared/collection-query.ts +6 -4
  18. package/templates/community-club-owner/tier5-src/_shared/collection-service.ts +5 -2
  19. package/templates/community-club-owner/tier5-src/_shared/owner-editor-value.ts +14 -0
  20. package/templates/community-club-owner/tier5-src/himi-owner-lite-event-form/index.ts +4 -4
  21. package/templates/community-club-owner/tier5-src/himi-owner-lite-post-form/index.ts +4 -4
  22. package/templates/community-club-owner/tier5-src/himi-owner-lite-product-form/index.ts +4 -4
  23. package/templates/events-meetups/tier5-src/_shared/collection-query.ts +6 -4
  24. package/templates/events-meetups/tier5-src/_shared/collection-service.ts +5 -2
  25. package/templates/events-meetups-owner/tier5-src/_shared/collection-query.ts +6 -4
  26. package/templates/events-meetups-owner/tier5-src/_shared/collection-service.ts +5 -2
  27. package/templates/events-meetups-owner/tier5-src/_shared/owner-editor-value.ts +14 -0
  28. package/templates/events-meetups-owner/tier5-src/himi-owner-lite-event-form/index.ts +4 -4
  29. package/templates/events-meetups-owner/tier5-src/himi-owner-lite-post-form/index.ts +4 -4
  30. package/templates/gym-fitness/tier5-src/_shared/collection-query.ts +6 -4
  31. package/templates/gym-fitness/tier5-src/_shared/collection-service.ts +5 -2
  32. package/templates/gym-fitness-owner/tier5-src/_shared/collection-query.ts +6 -4
  33. package/templates/gym-fitness-owner/tier5-src/_shared/collection-service.ts +5 -2
  34. package/templates/gym-fitness-owner/tier5-src/_shared/owner-editor-value.ts +14 -0
  35. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-event-form/index.ts +4 -4
  36. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-post-form/index.ts +4 -4
  37. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-product-form/index.ts +4 -4
  38. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-service-form/index.ts +4 -3
  39. package/templates/nonprofit-cause/tier5-src/_shared/collection-query.ts +6 -4
  40. package/templates/nonprofit-cause/tier5-src/_shared/collection-service.ts +5 -2
  41. package/templates/nonprofit-cause-owner/tier5-src/_shared/collection-query.ts +6 -4
  42. package/templates/nonprofit-cause-owner/tier5-src/_shared/collection-service.ts +5 -2
  43. package/templates/nonprofit-cause-owner/tier5-src/_shared/owner-editor-value.ts +14 -0
  44. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-event-form/index.ts +4 -4
  45. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-post-form/index.ts +4 -4
  46. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-service-form/index.ts +4 -3
  47. package/templates/owner-lite/tier5-src/_shared/collection-query.ts +6 -4
  48. package/templates/owner-lite/tier5-src/_shared/collection-service.ts +5 -2
  49. package/templates/owner-lite/tier5-src/_shared/owner-editor-value.ts +14 -0
  50. package/templates/owner-lite/tier5-src/himi-owner-lite-event-form/index.ts +4 -4
  51. package/templates/owner-lite/tier5-src/himi-owner-lite-faq-category-form/index.ts +3 -2
  52. package/templates/owner-lite/tier5-src/himi-owner-lite-faq-question-form/index.ts +4 -4
  53. package/templates/owner-lite/tier5-src/himi-owner-lite-post-form/index.ts +4 -4
  54. package/templates/owner-lite/tier5-src/himi-owner-lite-product-form/index.ts +4 -4
  55. package/templates/owner-lite/tier5-src/himi-owner-lite-service-form/index.ts +4 -3
  56. package/templates/podcast-creator/tier5-src/_shared/collection-query.ts +6 -4
  57. package/templates/podcast-creator/tier5-src/_shared/collection-service.ts +5 -2
  58. package/templates/podcast-creator-owner/tier5-src/_shared/collection-query.ts +6 -4
  59. package/templates/podcast-creator-owner/tier5-src/_shared/collection-service.ts +5 -2
  60. package/templates/podcast-creator-owner/tier5-src/_shared/owner-editor-value.ts +14 -0
  61. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-event-form/index.ts +4 -4
  62. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-post-form/index.ts +4 -4
  63. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-product-form/index.ts +4 -4
  64. package/templates/portfolio-creative/tier5-src/_shared/collection-query.ts +6 -4
  65. package/templates/portfolio-creative/tier5-src/_shared/collection-service.ts +5 -2
  66. package/templates/portfolio-creative-owner/tier5-src/_shared/collection-query.ts +6 -4
  67. package/templates/portfolio-creative-owner/tier5-src/_shared/collection-service.ts +5 -2
  68. package/templates/portfolio-creative-owner/tier5-src/_shared/owner-editor-value.ts +14 -0
  69. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-post-form/index.ts +4 -4
  70. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-product-form/index.ts +4 -4
  71. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-service-form/index.ts +4 -3
  72. package/templates/restaurant/tier5-src/_shared/collection-query.ts +6 -4
  73. package/templates/restaurant/tier5-src/_shared/collection-service.ts +5 -2
  74. package/templates/restaurant-owner/tier5-src/_shared/collection-query.ts +6 -4
  75. package/templates/restaurant-owner/tier5-src/_shared/collection-service.ts +5 -2
  76. package/templates/restaurant-owner/tier5-src/_shared/owner-editor-value.ts +14 -0
  77. package/templates/restaurant-owner/tier5-src/himi-owner-lite-event-form/index.ts +4 -4
  78. package/templates/restaurant-owner/tier5-src/himi-owner-lite-post-form/index.ts +4 -4
  79. package/templates/restaurant-owner/tier5-src/himi-owner-lite-product-form/index.ts +4 -4
  80. package/templates/restaurant-owner/tier5-src/himi-owner-lite-service-form/index.ts +4 -3
  81. package/templates/retail-store/tier5-src/_shared/collection-query.ts +6 -4
  82. package/templates/retail-store/tier5-src/_shared/collection-service.ts +5 -2
  83. package/templates/retail-store-owner/tier5-src/_shared/collection-query.ts +6 -4
  84. package/templates/retail-store-owner/tier5-src/_shared/collection-service.ts +5 -2
  85. package/templates/retail-store-owner/tier5-src/_shared/owner-editor-value.ts +14 -0
  86. package/templates/retail-store-owner/tier5-src/himi-owner-lite-post-form/index.ts +4 -4
  87. package/templates/retail-store-owner/tier5-src/himi-owner-lite-product-form/index.ts +4 -4
  88. package/templates/yoga-studio/tier5-src/_shared/collection-query.ts +6 -4
  89. package/templates/yoga-studio/tier5-src/_shared/collection-service.ts +5 -2
  90. package/templates/yoga-studio-owner/tier5-src/_shared/collection-query.ts +6 -4
  91. package/templates/yoga-studio-owner/tier5-src/_shared/collection-service.ts +5 -2
  92. package/templates/yoga-studio-owner/tier5-src/_shared/owner-editor-value.ts +14 -0
  93. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-event-form/index.ts +4 -4
  94. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-post-form/index.ts +4 -4
  95. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-service-form/index.ts +4 -3
@@ -2,7 +2,7 @@ import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import type { DerivedAppMeta } from "./app-module.js";
3
3
  import { type WixCachePolicy } from "./wix-cache-policy.js";
4
4
  import type { NetMocksFile, WixMockRule } from "./wix-gateway.js";
5
- import { gateScreenIdProblem } from "../../ts/src/auth-gate-screen.js";
5
+ import { gateScreenIdProblem, liftAuthBlock } from "../../ts/src/auth-gate-screen.js";
6
6
  import { type UnmappedPushDestination } from "../../ts/src/wix-push-links.js";
7
7
  import type { PushConfig, FlagConfig, AppControl, TabBarConfig, DrawerConfig, SidebarConfig, SplashConfig, LocaleConfig, NavKind, OrientationConfig, StreamPolicy } from "@wix/himalaya/core";
8
8
  export type { PushConfig, FlagConfig, AppControl, TabBarConfig, DrawerConfig, SidebarConfig, SplashConfig, LocaleConfig, NavKind, OrientationConfig, StreamPolicy };
@@ -202,14 +202,11 @@ export { gateScreenIdProblem };
202
202
  /** Derive the OTA authConfig block from a defineApp auth block
203
203
  * ({ providerId, config }). `required` is read from `config.required === true`
204
204
  * and lifted to the top level, as is `config.gateScreenId` (#4651); secret-ish
205
- * keys are scrubbed. When the app's screens are given, a `gateScreenId` naming none
206
- * of them is DROPPED (the shell falls back to the stock login) and warned about —
207
- * never thrown: this runs on every app-config serve and hot reload, and
208
- * `himalaya-dev app validate` is where a bad id fails ([gateScreenIdProblem]). */
209
- export declare function deriveAuthConfig(auth: {
210
- providerId: string;
211
- config: Record<string, unknown>;
212
- } | undefined, screenIds?: readonly string[]): AuthConfigBlock | undefined;
205
+ * keys are scrubbed — the same `liftAuthBlock` the auth bake runs (#4818). When
206
+ * the app's screens are given, a `gateScreenId` naming none of them is DROPPED
207
+ * (the shell falls back to the stock login) and warned about — never thrown: this
208
+ * runs on every app-config serve and hot reload, and `himalaya-dev app validate` is where a bad id fails ([gateScreenIdProblem]). */
209
+ export declare function deriveAuthConfig(auth: Parameters<typeof liftAuthBlock>[0], screenIds?: readonly string[]): AuthConfigBlock | undefined;
213
210
  /** Derive a baseline NavigationConfig from the loaded app's derived meta. */
214
211
  /** What `deriveNavigationConfig` needs to finish an app-level nav bundle ref.
215
212
  *
@@ -8,3 +8,32 @@ export declare function gateScreenIdProblem(auth: {
8
8
  providerId: string;
9
9
  config: Record<string, unknown>;
10
10
  } | undefined, screenIds: readonly string[]): string | null;
11
+ /** Keys never lifted into a client auth block — the baked auth.json or the OTA authConfig (defense in
12
+ * depth: public OAuth client ids are safe; anything secret-shaped is not). */
13
+ export declare const AUTH_SECRET_KEY_RE: RegExp;
14
+ /** The client auth block: what auth.json bakes and what the OTA `authConfig` serves. */
15
+ export interface LiftedAuthBlock {
16
+ providerId: string;
17
+ required: boolean;
18
+ /** #4651 — the app's own auth gate screen; absent ⇒ the stock login. */
19
+ gateScreenId?: string;
20
+ config: Record<string, unknown>;
21
+ }
22
+ /** #4818 — the one lift from an auth block to the client shape, shared by the auth bake and the OTA
23
+ * authConfig so the two cannot drift. Accepts the defineApp shape (`config.required`,
24
+ * `config.gateScreenId`) AND an already-lifted block (top-level `required` / `gateScreenId`, as an
25
+ * existing auth.json or a seed's `authConfig` carries them): re-lifting a lifted block is the
26
+ * identity, so a re-bake never fails open. `required` is true when either place says so; a
27
+ * `config.gateScreenId` wins over a top-level one. Secret-ish config keys are scrubbed. Returns null
28
+ * when there is no usable providerId. */
29
+ export declare function liftAuthBlock(auth: {
30
+ providerId?: unknown;
31
+ required?: unknown;
32
+ gateScreenId?: unknown;
33
+ config?: Record<string, unknown>;
34
+ } | undefined | null): LiftedAuthBlock | null;
35
+ /** The raw gate id of either auth shape (`config.gateScreenId` first), unvalidated — for the checks. */
36
+ export declare function liftedGateScreenId(auth: {
37
+ gateScreenId?: unknown;
38
+ config?: Record<string, unknown>;
39
+ }): unknown;
package/dist/cli.mjs CHANGED
@@ -10034,7 +10034,6 @@ function webViewOriginAllowed(url, origins) {
10034
10034
  }
10035
10035
  var init_webview_origin = __esm({
10036
10036
  "../../core/ts/src/webview-origin.ts"() {
10037
- "use strict";
10038
10037
  }
10039
10038
  });
10040
10039
 
@@ -31153,8 +31152,29 @@ function gateScreenIdProblem(auth, screenIds) {
31153
31152
  if (screenIds.includes(gateId)) return null;
31154
31153
  return `defineApp: auth.config.gateScreenId is "${gateId}", which is not one of the app's exact screens \u2014 a required-auth launch would have no login to show (a release serves exact screens, so a templated route 404s). Name an exact screen, or drop gateScreenId to use the stock login.`;
31155
31154
  }
31155
+ function liftAuthBlock(auth) {
31156
+ if (!auth || typeof auth.providerId !== "string" || auth.providerId.length === 0) return null;
31157
+ const config = {};
31158
+ for (const [key3, value] of Object.entries(auth.config ?? {})) {
31159
+ if (key3 === "required" || key3 === "gateScreenId") continue;
31160
+ if (AUTH_SECRET_KEY_RE.test(key3)) continue;
31161
+ config[key3] = value;
31162
+ }
31163
+ const gateScreenId = liftedGateScreenId(auth);
31164
+ return {
31165
+ providerId: auth.providerId,
31166
+ required: auth.config?.required === true || auth.required === true,
31167
+ ...typeof gateScreenId === "string" && gateScreenId.length > 0 ? { gateScreenId } : {},
31168
+ config
31169
+ };
31170
+ }
31171
+ function liftedGateScreenId(auth) {
31172
+ return auth.config?.gateScreenId ?? auth.gateScreenId;
31173
+ }
31174
+ var AUTH_SECRET_KEY_RE;
31156
31175
  var init_auth_gate_screen = __esm({
31157
31176
  "../../core/ts/src/auth-gate-screen.ts"() {
31177
+ AUTH_SECRET_KEY_RE = /secret|private/i;
31158
31178
  }
31159
31179
  });
31160
31180
 
@@ -31350,22 +31370,15 @@ function deriveTelemetryConfig(app) {
31350
31370
  return cfg;
31351
31371
  }
31352
31372
  function deriveAuthConfig(auth, screenIds) {
31353
- if (!auth?.providerId) return void 0;
31354
- const config = {};
31355
- for (const [key3, value] of Object.entries(auth.config ?? {})) {
31356
- if (key3 === "required" || key3 === "gateScreenId") continue;
31357
- if (AUTH_SECRET_KEY_RE.test(key3)) continue;
31358
- config[key3] = value;
31373
+ const lifted = liftAuthBlock(auth);
31374
+ if (!lifted) return void 0;
31375
+ const gateScreenId = liftedGateScreenId(auth);
31376
+ const problem = gateScreenId !== void 0 && screenIds ? gateScreenIdProblem({ providerId: lifted.providerId, config: { gateScreenId } }, screenIds) : null;
31377
+ if (problem) {
31378
+ console.warn(`[app-config] ${problem} \u2014 serving the stock login instead.`);
31379
+ delete lifted.gateScreenId;
31359
31380
  }
31360
- const gateScreenId = auth.config?.gateScreenId;
31361
- const problem = typeof gateScreenId === "string" && screenIds ? gateScreenIdProblem(auth, screenIds) : null;
31362
- if (problem) console.warn(`[app-config] ${problem} \u2014 serving the stock login instead.`);
31363
- return {
31364
- providerId: auth.providerId,
31365
- required: auth.config?.required === true,
31366
- ...typeof gateScreenId === "string" && gateScreenId.length > 0 && !problem ? { gateScreenId } : {},
31367
- config
31368
- };
31381
+ return lifted;
31369
31382
  }
31370
31383
  function expandNavBundle(ref, ctx) {
31371
31384
  const url = ref.url ?? "";
@@ -31559,7 +31572,7 @@ function handleAppConfigRequest(req, res, meta, override, bundleCtx, devNetMocks
31559
31572
  });
31560
31573
  res.end(body);
31561
31574
  }
31562
- var SHARED_WIX_SENTRY_DSN, AUTH_SECRET_KEY_RE, NAV_OVERRIDES;
31575
+ var SHARED_WIX_SENTRY_DSN, NAV_OVERRIDES;
31563
31576
  var init_app_config = __esm({
31564
31577
  "../../core/dev-server/src/app-config.ts"() {
31565
31578
  init_wix_cache_policy();
@@ -31568,7 +31581,6 @@ var init_app_config = __esm({
31568
31581
  init_preview_surfaces();
31569
31582
  init_wix_push_links();
31570
31583
  SHARED_WIX_SENTRY_DSN = "https://5dfa4f23057c43149b6219c07d177472@sentry.io/84240";
31571
- AUTH_SECRET_KEY_RE = /secret|private/i;
31572
31584
  NAV_OVERRIDES = {
31573
31585
  reference: {
31574
31586
  deepLinkPatterns: [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wix/himalaya-cli",
3
- "version": "0.1471.0",
3
+ "version": "0.1473.0",
4
4
  "type": "module",
5
5
  "description": "himi — the Himalaya authoring CLI. Build a Tier-5 content package on your own machine, push it to the production serve as a draft, preview it on web + Himi Player, and read worker logs back — the remote dev loop. Wraps @himalaya/serve-cli for transport.",
6
6
  "keywords": [
@@ -71,5 +71,5 @@
71
71
  "artifactId": "himalaya-cli"
72
72
  }
73
73
  },
74
- "falconPackageHash": "cd4d5ceeaf1823831031129d85d33f4d35a9aa4e5bfd8804fe964fe1"
74
+ "falconPackageHash": "f58b0d57918c163a9918dbe6f1170839ea6c6d6cf0d2b9c38b7a7004"
75
75
  }
@@ -66,9 +66,10 @@ export function createCollectionQuery<Row>(source: CollectionQuerySource<Row>, p
66
66
  const snapshot = (): CollectionQueryState<Row> => ({ ...state, criteria: copyQuery(state.criteria), items: [...state.items] });
67
67
  const emit = () => {
68
68
  if (disposed || revalidating === generation) return;
69
- // A settled answer replaces it, an empty one included: rows the live query proved gone are never kept.
69
+ // A settled answer replaces it, an empty one included: rows the live query proved gone are never kept, and a list
70
+ // with nothing in it comes back as its empty state, never its loading look.
70
71
  if (state.status === 'ready' && !state.pageError && !state.loadingMore && !state.provisional) {
71
- lastShown = state.items.length > 0 ? { state: snapshot(), loadedPages } : undefined;
72
+ lastShown = { state: snapshot(), loadedPages };
72
73
  }
73
74
  publish(snapshot());
74
75
  };
@@ -161,12 +162,13 @@ export function createCollectionQuery<Row>(source: CollectionQuerySource<Row>, p
161
162
  return { state: { ...page, criteria: copyQuery(page.criteria), items: [...page.items] }, loadedPages: lastShown.loadedPages };
162
163
  },
163
164
  /** A cold boot restored from disk: what the last run showed becomes what this one shows, so its first `load`
164
- * revalidates behind it instead of starting empty. Only before the first query, and only a well-formed page. */
165
+ * revalidates behind it instead of starting empty. Only before the first query, and only a well-formed page; an
166
+ * empty one is the list's empty state. */
165
167
  seed(page: unknown): boolean {
166
168
  const candidate = page && typeof page === 'object' ? page as Partial<CollectionShownPage<Row>> : undefined;
167
169
  const restored = candidate?.state;
168
170
  if (generation !== 0 || state.status !== 'idle' || !restored || restored.status !== 'ready'
169
- || !Array.isArray(restored.items) || restored.items.length === 0 || typeof restored.nextCursor !== 'string') return false;
171
+ || !Array.isArray(restored.items) || typeof restored.nextCursor !== 'string') return false;
170
172
  try { restored.items.forEach(item => { if (!source.id(item)) throw new Error('A backend collection row requires an identity'); }); } catch { return false; }
171
173
  state = { ...state, ...restored, criteria: normalize(restored.criteria ?? {}), items: [...restored.items], status: 'ready',
172
174
  loadingMore: false, pageError: false, provisional: false, error: '' };
@@ -352,11 +352,14 @@ export function defineBackendCollection<S extends object, Row extends object>(na
352
352
  const handle = (options.register ?? register)(name, definition);
353
353
  // Registration restored the state a cold boot saved: the page it showed is this run's, before the first load. Only
354
354
  // when the service's own rows came back with it: a bundle that keeps them out of the restore (its `notHydrated`)
355
- // starts its first load as before, never from rows it chose not to restore.
355
+ // starts its first load as before, never from rows it chose not to restore. Rows that came back empty are the
356
+ // list's own only when the page it showed was empty too; a page with rows means they were kept out.
356
357
  const restored = (handle as { getState?: () => State }).getState?.();
357
358
  const rowsKey = COLLECTIONS[name]?.rows;
358
359
  const restoredRows = rowsKey ? restored?.[rowsKey] : undefined;
359
- if (Array.isArray(restoredRows) && restoredRows.length > 0) controller.seed(restored?.collectionShown);
360
+ const shownItems = (restored?.collectionShown as { state?: { items?: unknown } } | undefined)?.state?.items;
361
+ const shownEmpty = Array.isArray(shownItems) && shownItems.length === 0;
362
+ if (Array.isArray(restoredRows) && (restoredRows.length > 0 || shownEmpty)) controller.seed(restored?.collectionShown);
360
363
  publish = () => handle.collectionDataChanged();
361
364
  return handle;
362
365
  }
@@ -66,9 +66,10 @@ export function createCollectionQuery<Row>(source: CollectionQuerySource<Row>, p
66
66
  const snapshot = (): CollectionQueryState<Row> => ({ ...state, criteria: copyQuery(state.criteria), items: [...state.items] });
67
67
  const emit = () => {
68
68
  if (disposed || revalidating === generation) return;
69
- // A settled answer replaces it, an empty one included: rows the live query proved gone are never kept.
69
+ // A settled answer replaces it, an empty one included: rows the live query proved gone are never kept, and a list
70
+ // with nothing in it comes back as its empty state, never its loading look.
70
71
  if (state.status === 'ready' && !state.pageError && !state.loadingMore && !state.provisional) {
71
- lastShown = state.items.length > 0 ? { state: snapshot(), loadedPages } : undefined;
72
+ lastShown = { state: snapshot(), loadedPages };
72
73
  }
73
74
  publish(snapshot());
74
75
  };
@@ -161,12 +162,13 @@ export function createCollectionQuery<Row>(source: CollectionQuerySource<Row>, p
161
162
  return { state: { ...page, criteria: copyQuery(page.criteria), items: [...page.items] }, loadedPages: lastShown.loadedPages };
162
163
  },
163
164
  /** A cold boot restored from disk: what the last run showed becomes what this one shows, so its first `load`
164
- * revalidates behind it instead of starting empty. Only before the first query, and only a well-formed page. */
165
+ * revalidates behind it instead of starting empty. Only before the first query, and only a well-formed page; an
166
+ * empty one is the list's empty state. */
165
167
  seed(page: unknown): boolean {
166
168
  const candidate = page && typeof page === 'object' ? page as Partial<CollectionShownPage<Row>> : undefined;
167
169
  const restored = candidate?.state;
168
170
  if (generation !== 0 || state.status !== 'idle' || !restored || restored.status !== 'ready'
169
- || !Array.isArray(restored.items) || restored.items.length === 0 || typeof restored.nextCursor !== 'string') return false;
171
+ || !Array.isArray(restored.items) || typeof restored.nextCursor !== 'string') return false;
170
172
  try { restored.items.forEach(item => { if (!source.id(item)) throw new Error('A backend collection row requires an identity'); }); } catch { return false; }
171
173
  state = { ...state, ...restored, criteria: normalize(restored.criteria ?? {}), items: [...restored.items], status: 'ready',
172
174
  loadingMore: false, pageError: false, provisional: false, error: '' };
@@ -352,11 +352,14 @@ export function defineBackendCollection<S extends object, Row extends object>(na
352
352
  const handle = (options.register ?? register)(name, definition);
353
353
  // Registration restored the state a cold boot saved: the page it showed is this run's, before the first load. Only
354
354
  // when the service's own rows came back with it: a bundle that keeps them out of the restore (its `notHydrated`)
355
- // starts its first load as before, never from rows it chose not to restore.
355
+ // starts its first load as before, never from rows it chose not to restore. Rows that came back empty are the
356
+ // list's own only when the page it showed was empty too; a page with rows means they were kept out.
356
357
  const restored = (handle as { getState?: () => State }).getState?.();
357
358
  const rowsKey = COLLECTIONS[name]?.rows;
358
359
  const restoredRows = rowsKey ? restored?.[rowsKey] : undefined;
359
- if (Array.isArray(restoredRows) && restoredRows.length > 0) controller.seed(restored?.collectionShown);
360
+ const shownItems = (restored?.collectionShown as { state?: { items?: unknown } } | undefined)?.state?.items;
361
+ const shownEmpty = Array.isArray(shownItems) && shownItems.length === 0;
362
+ if (Array.isArray(restoredRows) && (restoredRows.length > 0 || shownEmpty)) controller.seed(restored?.collectionShown);
360
363
  publish = () => handle.collectionDataChanged();
361
364
  return handle;
362
365
  }
@@ -140,6 +140,20 @@ export function liveState(svc: unknown): Record<string, unknown> | undefined {
140
140
  * state it started from) are written, so anything the owner did while it awaited — an edit, a field in
141
141
  * the details sheet, closing the sheet — survives a save that lands after it, success or failure. A
142
142
  * key both changed goes to the action. Where the host gives no live state, the result as it is. */
143
+ /** An awaited load's result, unless the form moved to another record while it read. On a warm return the host's bare
144
+ * refresh and the screen's onAppear run together: after a save the refresh fetches the record just saved while the
145
+ * onAppear of a create screen opens a blank form, and the refresh landing last turned the new form into that record's
146
+ * edit. Nothing lands then (`undefined`, the runtime's no-op), nor from a load that changes nothing: started with the
147
+ * same state as the onAppear, it would put back the record that one replaced. A load for the record the form still
148
+ * holds lands as it is; so does any load where the host gives no live state. */
149
+ export function landsOnLive<S extends object>(svc: unknown, start: S, result: S): S | undefined {
150
+ if (result === start) return undefined;
151
+ const now = liveState(svc);
152
+ if (!now || now === start) return result;
153
+ const s0 = start as Record<string, unknown>;
154
+ return now.id !== s0.id || now.mode !== s0.mode ? undefined : result;
155
+ }
156
+
143
157
  export function settleOverLive<S extends object>(svc: unknown, start: S, result: S): S {
144
158
  const now = liveState(svc) as S | undefined;
145
159
  if (!now || now === start || now === result) return result;
@@ -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, holdsLoadedForm } from "../_shared/owner-editor-value.js";
7
+ import { editorSubmitValue, queuedDocument, editorChanged, editorRunKeys, resetHydratedEditorOnOlderRuntime, editorAfterSave, liveState, settleOverLive, holdsLoadedForm, landsOnLive } 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";
@@ -157,7 +157,7 @@ function demoRefusal(s: Parameters<typeof shape>[0]): ReturnType<typeof shape> {
157
157
  ricosUploadService("himiOwnerLitePostFormUploads");
158
158
 
159
159
  const svc = defineService<PostFormState, {
160
- load: (s: PostFormState, arg?: unknown) => Promise<PostFormState>;
160
+ load: (s: PostFormState, arg?: unknown) => Promise<PostFormState | undefined>;
161
161
  setCover: (s: PostFormState, arg?: unknown) => PostFormState;
162
162
  setExcerpt: (s: PostFormState, arg?: unknown) => PostFormState;
163
163
  detailsOpen: (s: PostFormState) => PostFormState;
@@ -173,7 +173,7 @@ const svc = defineService<PostFormState, {
173
173
  // perVisit: a cached embed's warm return starts this run fresh, as a cold boot does (`__resetRunState`).
174
174
  perVisit: [...editorRunKeys("body"), "detailsOpen"],
175
175
  actions: {
176
- load: async (s, arg) => {
176
+ load: async (s, arg): Promise<PostFormState | undefined> => landsOnLive(svc, s, await (async () => {
177
177
  stashCopy(arg); // absorb the descriptor's onAppear copy-pack param
178
178
  // WS-C — nav-param input is PRIMARY (the canonical create-or-edit shell
179
179
  // bakes no id/mode); the onAppear `id` param is the #570 suffixed-build
@@ -240,7 +240,7 @@ const svc = defineService<PostFormState, {
240
240
  }
241
241
  return shape({ ...s, mode: "edit", id, status: "error", error: ownerLoadError(e) });
242
242
  }
243
- },
243
+ })()),
244
244
  setCover: (s, arg) => shape({ ...s, coverUrl: valueOf(arg).trim(), error: "" }),
245
245
  setExcerpt: (s, arg) => shape({ ...s, excerpt: valueOf(arg), error: "" }),
246
246
  detailsOpen: (s) => shape({ ...s, detailsOpen: true }),
@@ -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, holdsLoadedForm } from "../_shared/owner-editor-value.js";
21
+ import { editorSubmitValue, queuedDocument, editorChanged, cachedRichVerdict, editorRunKeys, resetHydratedEditorOnOlderRuntime, editorAfterSave, liveState, settleOverLive, holdsLoadedForm, landsOnLive } 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";
@@ -190,7 +190,7 @@ function demoRefusal(s: Parameters<typeof shape>[0]): ReturnType<typeof shape> {
190
190
  ricosUploadService("himiOwnerLiteProductFormUploads");
191
191
 
192
192
  const svc = defineService<ProductFormState, {
193
- load: (s: ProductFormState, arg?: unknown) => Promise<ProductFormState>;
193
+ load: (s: ProductFormState, arg?: unknown) => Promise<ProductFormState | undefined>;
194
194
  descriptionChanged: (s: ProductFormState, arg?: unknown) => ProductFormState;
195
195
  submit: (s: ProductFormState, values?: Record<string, unknown>) => Promise<ProductFormState>;
196
196
  remove: (s: ProductFormState) => Promise<ProductFormState>;
@@ -202,7 +202,7 @@ const svc = defineService<ProductFormState, {
202
202
  // perVisit: a cached embed's warm return starts this run fresh, as a cold boot does (`__resetRunState`).
203
203
  perVisit: editorRunKeys("description"),
204
204
  actions: {
205
- load: async (s, arg) => {
205
+ load: async (s, arg): Promise<ProductFormState | undefined> => landsOnLive(svc, s, await (async () => {
206
206
  stashCopy(arg); // absorb the descriptor's onAppear copy-pack param
207
207
  // WS-C — nav-param input is PRIMARY (the canonical create-or-edit shell
208
208
  // bakes no id/mode); the onAppear `id` param is the #570 suffixed-build
@@ -287,7 +287,7 @@ const svc = defineService<ProductFormState, {
287
287
  }
288
288
  return shape({ ...s, mode: "edit", id, status: "error", error: ownerLoadError(e) });
289
289
  }
290
- },
290
+ })()),
291
291
  // The editor's on_change carries `args.dirty` only; the document stays in the form bag.
292
292
  descriptionChanged: (s) => shape({ ...s, descriptionDirty: true, descriptionEdits: s.descriptionEdits + 1, ...editorChanged(s) }),
293
293
  // Laid over the live state ([rico] #3644): what the owner did while the save awaited survives it.
@@ -4,6 +4,7 @@
4
4
  // expose, so this form only edits an existing service's name/description via
5
5
  // updateService. Bound by buildForm ($service:himiOwnerLiteServiceForm.*).
6
6
 
7
+ import { landsOnLive } from "../_shared/owner-editor-value.js";
7
8
  import { defineService } from "@himalaya/state";
8
9
  import { getService, updateService, type WixService } from "@himalaya/wix-bookings";
9
10
  import { ownerLoadError, isOwnerPreviewDemo, ownerDemoFallback, ownerWriteError } from "../_shared/owner-load-error.js";
@@ -108,12 +109,12 @@ function demoRefusal(s: Parameters<typeof shape>[0]): ReturnType<typeof shape> {
108
109
  }
109
110
 
110
111
  const svc = defineService<ServiceFormState, {
111
- load: (s: ServiceFormState, arg?: unknown) => Promise<ServiceFormState>;
112
+ load: (s: ServiceFormState, arg?: unknown) => Promise<ServiceFormState | undefined>;
112
113
  submit: (s: ServiceFormState, values?: Record<string, unknown>) => Promise<ServiceFormState>;
113
114
  }>("himiOwnerLiteServiceForm", {
114
115
  state: shape({ id: initialId }),
115
116
  actions: {
116
- load: async (s, arg) => {
117
+ load: async (s, arg): Promise<ServiceFormState | undefined> => landsOnLive(svc, s, await (async () => {
117
118
  stashCopy(arg); // absorb the descriptor's onAppear copy-pack param
118
119
  // WS-C — nav-param input is PRIMARY (the canonical form bakes no id); the
119
120
  // onAppear `id` param is the #570 suffixed-build fallback.
@@ -167,7 +168,7 @@ const svc = defineService<ServiceFormState, {
167
168
  }
168
169
  return shape({ ...s, id, status: "error", error: ownerLoadError(e) });
169
170
  }
170
- },
171
+ })()),
171
172
  submit: async (s, values) => {
172
173
  if (isOwnerPreviewDemo() || s.demoNote) return demoRefusal(s);
173
174
  const v = values ?? {};
@@ -66,9 +66,10 @@ export function createCollectionQuery<Row>(source: CollectionQuerySource<Row>, p
66
66
  const snapshot = (): CollectionQueryState<Row> => ({ ...state, criteria: copyQuery(state.criteria), items: [...state.items] });
67
67
  const emit = () => {
68
68
  if (disposed || revalidating === generation) return;
69
- // A settled answer replaces it, an empty one included: rows the live query proved gone are never kept.
69
+ // A settled answer replaces it, an empty one included: rows the live query proved gone are never kept, and a list
70
+ // with nothing in it comes back as its empty state, never its loading look.
70
71
  if (state.status === 'ready' && !state.pageError && !state.loadingMore && !state.provisional) {
71
- lastShown = state.items.length > 0 ? { state: snapshot(), loadedPages } : undefined;
72
+ lastShown = { state: snapshot(), loadedPages };
72
73
  }
73
74
  publish(snapshot());
74
75
  };
@@ -161,12 +162,13 @@ export function createCollectionQuery<Row>(source: CollectionQuerySource<Row>, p
161
162
  return { state: { ...page, criteria: copyQuery(page.criteria), items: [...page.items] }, loadedPages: lastShown.loadedPages };
162
163
  },
163
164
  /** A cold boot restored from disk: what the last run showed becomes what this one shows, so its first `load`
164
- * revalidates behind it instead of starting empty. Only before the first query, and only a well-formed page. */
165
+ * revalidates behind it instead of starting empty. Only before the first query, and only a well-formed page; an
166
+ * empty one is the list's empty state. */
165
167
  seed(page: unknown): boolean {
166
168
  const candidate = page && typeof page === 'object' ? page as Partial<CollectionShownPage<Row>> : undefined;
167
169
  const restored = candidate?.state;
168
170
  if (generation !== 0 || state.status !== 'idle' || !restored || restored.status !== 'ready'
169
- || !Array.isArray(restored.items) || restored.items.length === 0 || typeof restored.nextCursor !== 'string') return false;
171
+ || !Array.isArray(restored.items) || typeof restored.nextCursor !== 'string') return false;
170
172
  try { restored.items.forEach(item => { if (!source.id(item)) throw new Error('A backend collection row requires an identity'); }); } catch { return false; }
171
173
  state = { ...state, ...restored, criteria: normalize(restored.criteria ?? {}), items: [...restored.items], status: 'ready',
172
174
  loadingMore: false, pageError: false, provisional: false, error: '' };
@@ -352,11 +352,14 @@ export function defineBackendCollection<S extends object, Row extends object>(na
352
352
  const handle = (options.register ?? register)(name, definition);
353
353
  // Registration restored the state a cold boot saved: the page it showed is this run's, before the first load. Only
354
354
  // when the service's own rows came back with it: a bundle that keeps them out of the restore (its `notHydrated`)
355
- // starts its first load as before, never from rows it chose not to restore.
355
+ // starts its first load as before, never from rows it chose not to restore. Rows that came back empty are the
356
+ // list's own only when the page it showed was empty too; a page with rows means they were kept out.
356
357
  const restored = (handle as { getState?: () => State }).getState?.();
357
358
  const rowsKey = COLLECTIONS[name]?.rows;
358
359
  const restoredRows = rowsKey ? restored?.[rowsKey] : undefined;
359
- if (Array.isArray(restoredRows) && restoredRows.length > 0) controller.seed(restored?.collectionShown);
360
+ const shownItems = (restored?.collectionShown as { state?: { items?: unknown } } | undefined)?.state?.items;
361
+ const shownEmpty = Array.isArray(shownItems) && shownItems.length === 0;
362
+ if (Array.isArray(restoredRows) && (restoredRows.length > 0 || shownEmpty)) controller.seed(restored?.collectionShown);
360
363
  publish = () => handle.collectionDataChanged();
361
364
  return handle;
362
365
  }
@@ -66,9 +66,10 @@ export function createCollectionQuery<Row>(source: CollectionQuerySource<Row>, p
66
66
  const snapshot = (): CollectionQueryState<Row> => ({ ...state, criteria: copyQuery(state.criteria), items: [...state.items] });
67
67
  const emit = () => {
68
68
  if (disposed || revalidating === generation) return;
69
- // A settled answer replaces it, an empty one included: rows the live query proved gone are never kept.
69
+ // A settled answer replaces it, an empty one included: rows the live query proved gone are never kept, and a list
70
+ // with nothing in it comes back as its empty state, never its loading look.
70
71
  if (state.status === 'ready' && !state.pageError && !state.loadingMore && !state.provisional) {
71
- lastShown = state.items.length > 0 ? { state: snapshot(), loadedPages } : undefined;
72
+ lastShown = { state: snapshot(), loadedPages };
72
73
  }
73
74
  publish(snapshot());
74
75
  };
@@ -161,12 +162,13 @@ export function createCollectionQuery<Row>(source: CollectionQuerySource<Row>, p
161
162
  return { state: { ...page, criteria: copyQuery(page.criteria), items: [...page.items] }, loadedPages: lastShown.loadedPages };
162
163
  },
163
164
  /** A cold boot restored from disk: what the last run showed becomes what this one shows, so its first `load`
164
- * revalidates behind it instead of starting empty. Only before the first query, and only a well-formed page. */
165
+ * revalidates behind it instead of starting empty. Only before the first query, and only a well-formed page; an
166
+ * empty one is the list's empty state. */
165
167
  seed(page: unknown): boolean {
166
168
  const candidate = page && typeof page === 'object' ? page as Partial<CollectionShownPage<Row>> : undefined;
167
169
  const restored = candidate?.state;
168
170
  if (generation !== 0 || state.status !== 'idle' || !restored || restored.status !== 'ready'
169
- || !Array.isArray(restored.items) || restored.items.length === 0 || typeof restored.nextCursor !== 'string') return false;
171
+ || !Array.isArray(restored.items) || typeof restored.nextCursor !== 'string') return false;
170
172
  try { restored.items.forEach(item => { if (!source.id(item)) throw new Error('A backend collection row requires an identity'); }); } catch { return false; }
171
173
  state = { ...state, ...restored, criteria: normalize(restored.criteria ?? {}), items: [...restored.items], status: 'ready',
172
174
  loadingMore: false, pageError: false, provisional: false, error: '' };
@@ -352,11 +352,14 @@ export function defineBackendCollection<S extends object, Row extends object>(na
352
352
  const handle = (options.register ?? register)(name, definition);
353
353
  // Registration restored the state a cold boot saved: the page it showed is this run's, before the first load. Only
354
354
  // when the service's own rows came back with it: a bundle that keeps them out of the restore (its `notHydrated`)
355
- // starts its first load as before, never from rows it chose not to restore.
355
+ // starts its first load as before, never from rows it chose not to restore. Rows that came back empty are the
356
+ // list's own only when the page it showed was empty too; a page with rows means they were kept out.
356
357
  const restored = (handle as { getState?: () => State }).getState?.();
357
358
  const rowsKey = COLLECTIONS[name]?.rows;
358
359
  const restoredRows = rowsKey ? restored?.[rowsKey] : undefined;
359
- if (Array.isArray(restoredRows) && restoredRows.length > 0) controller.seed(restored?.collectionShown);
360
+ const shownItems = (restored?.collectionShown as { state?: { items?: unknown } } | undefined)?.state?.items;
361
+ const shownEmpty = Array.isArray(shownItems) && shownItems.length === 0;
362
+ if (Array.isArray(restoredRows) && (restoredRows.length > 0 || shownEmpty)) controller.seed(restored?.collectionShown);
360
363
  publish = () => handle.collectionDataChanged();
361
364
  return handle;
362
365
  }
@@ -66,9 +66,10 @@ export function createCollectionQuery<Row>(source: CollectionQuerySource<Row>, p
66
66
  const snapshot = (): CollectionQueryState<Row> => ({ ...state, criteria: copyQuery(state.criteria), items: [...state.items] });
67
67
  const emit = () => {
68
68
  if (disposed || revalidating === generation) return;
69
- // A settled answer replaces it, an empty one included: rows the live query proved gone are never kept.
69
+ // A settled answer replaces it, an empty one included: rows the live query proved gone are never kept, and a list
70
+ // with nothing in it comes back as its empty state, never its loading look.
70
71
  if (state.status === 'ready' && !state.pageError && !state.loadingMore && !state.provisional) {
71
- lastShown = state.items.length > 0 ? { state: snapshot(), loadedPages } : undefined;
72
+ lastShown = { state: snapshot(), loadedPages };
72
73
  }
73
74
  publish(snapshot());
74
75
  };
@@ -161,12 +162,13 @@ export function createCollectionQuery<Row>(source: CollectionQuerySource<Row>, p
161
162
  return { state: { ...page, criteria: copyQuery(page.criteria), items: [...page.items] }, loadedPages: lastShown.loadedPages };
162
163
  },
163
164
  /** A cold boot restored from disk: what the last run showed becomes what this one shows, so its first `load`
164
- * revalidates behind it instead of starting empty. Only before the first query, and only a well-formed page. */
165
+ * revalidates behind it instead of starting empty. Only before the first query, and only a well-formed page; an
166
+ * empty one is the list's empty state. */
165
167
  seed(page: unknown): boolean {
166
168
  const candidate = page && typeof page === 'object' ? page as Partial<CollectionShownPage<Row>> : undefined;
167
169
  const restored = candidate?.state;
168
170
  if (generation !== 0 || state.status !== 'idle' || !restored || restored.status !== 'ready'
169
- || !Array.isArray(restored.items) || restored.items.length === 0 || typeof restored.nextCursor !== 'string') return false;
171
+ || !Array.isArray(restored.items) || typeof restored.nextCursor !== 'string') return false;
170
172
  try { restored.items.forEach(item => { if (!source.id(item)) throw new Error('A backend collection row requires an identity'); }); } catch { return false; }
171
173
  state = { ...state, ...restored, criteria: normalize(restored.criteria ?? {}), items: [...restored.items], status: 'ready',
172
174
  loadingMore: false, pageError: false, provisional: false, error: '' };
@@ -352,11 +352,14 @@ export function defineBackendCollection<S extends object, Row extends object>(na
352
352
  const handle = (options.register ?? register)(name, definition);
353
353
  // Registration restored the state a cold boot saved: the page it showed is this run's, before the first load. Only
354
354
  // when the service's own rows came back with it: a bundle that keeps them out of the restore (its `notHydrated`)
355
- // starts its first load as before, never from rows it chose not to restore.
355
+ // starts its first load as before, never from rows it chose not to restore. Rows that came back empty are the
356
+ // list's own only when the page it showed was empty too; a page with rows means they were kept out.
356
357
  const restored = (handle as { getState?: () => State }).getState?.();
357
358
  const rowsKey = COLLECTIONS[name]?.rows;
358
359
  const restoredRows = rowsKey ? restored?.[rowsKey] : undefined;
359
- if (Array.isArray(restoredRows) && restoredRows.length > 0) controller.seed(restored?.collectionShown);
360
+ const shownItems = (restored?.collectionShown as { state?: { items?: unknown } } | undefined)?.state?.items;
361
+ const shownEmpty = Array.isArray(shownItems) && shownItems.length === 0;
362
+ if (Array.isArray(restoredRows) && (restoredRows.length > 0 || shownEmpty)) controller.seed(restored?.collectionShown);
360
363
  publish = () => handle.collectionDataChanged();
361
364
  return handle;
362
365
  }
@@ -140,6 +140,20 @@ export function liveState(svc: unknown): Record<string, unknown> | undefined {
140
140
  * state it started from) are written, so anything the owner did while it awaited — an edit, a field in
141
141
  * the details sheet, closing the sheet — survives a save that lands after it, success or failure. A
142
142
  * key both changed goes to the action. Where the host gives no live state, the result as it is. */
143
+ /** An awaited load's result, unless the form moved to another record while it read. On a warm return the host's bare
144
+ * refresh and the screen's onAppear run together: after a save the refresh fetches the record just saved while the
145
+ * onAppear of a create screen opens a blank form, and the refresh landing last turned the new form into that record's
146
+ * edit. Nothing lands then (`undefined`, the runtime's no-op), nor from a load that changes nothing: started with the
147
+ * same state as the onAppear, it would put back the record that one replaced. A load for the record the form still
148
+ * holds lands as it is; so does any load where the host gives no live state. */
149
+ export function landsOnLive<S extends object>(svc: unknown, start: S, result: S): S | undefined {
150
+ if (result === start) return undefined;
151
+ const now = liveState(svc);
152
+ if (!now || now === start) return result;
153
+ const s0 = start as Record<string, unknown>;
154
+ return now.id !== s0.id || now.mode !== s0.mode ? undefined : result;
155
+ }
156
+
143
157
  export function settleOverLive<S extends object>(svc: unknown, start: S, result: S): S {
144
158
  const now = liveState(svc) as S | undefined;
145
159
  if (!now || now === start || now === result) return result;
@@ -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, holdsLoadedForm } from "../_shared/owner-editor-value.js";
13
+ import { editorSubmitValue, queuedDocument, editorChanged, cachedRichVerdict, editorRunKeys, resetHydratedEditorOnOlderRuntime, editorAfterSave, liveState, settleOverLive, holdsLoadedForm, landsOnLive } 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";
@@ -169,7 +169,7 @@ function demoRefusal(s: Parameters<typeof shape>[0]): ReturnType<typeof shape> {
169
169
  ricosUploadService("himiOwnerLiteEventFormUploads");
170
170
 
171
171
  const svc = defineService<EventFormState, {
172
- load: (s: EventFormState, arg?: unknown) => Promise<EventFormState>;
172
+ load: (s: EventFormState, arg?: unknown) => Promise<EventFormState | undefined>;
173
173
  descriptionChanged: (s: EventFormState, arg?: unknown) => EventFormState;
174
174
  submit: (s: EventFormState, values?: Record<string, unknown>) => Promise<EventFormState>;
175
175
  }>("himiOwnerLiteEventForm", {
@@ -179,7 +179,7 @@ const svc = defineService<EventFormState, {
179
179
  // perVisit: a cached embed's warm return starts this run fresh, as a cold boot does (`__resetRunState`).
180
180
  perVisit: editorRunKeys("description"),
181
181
  actions: {
182
- load: async (s, arg) => {
182
+ load: async (s, arg): Promise<EventFormState | undefined> => landsOnLive(svc, s, await (async () => {
183
183
  stashCopy(arg); // absorb the descriptor's onAppear copy-pack param
184
184
  const mode = modeFrom(arg, s.mode);
185
185
  // WS-C — nav-param input is PRIMARY (the canonical form bakes no id); the
@@ -262,7 +262,7 @@ const svc = defineService<EventFormState, {
262
262
  }
263
263
  return shape({ ...s, mode, id, status: "error", error: ownerLoadError(e) });
264
264
  }
265
- },
265
+ })()),
266
266
  // The editor's on_change carries `args.dirty` only; the document stays in the form bag.
267
267
  descriptionChanged: (s) => shape({ ...s, descriptionDirty: true, descriptionEdits: s.descriptionEdits + 1, ...editorChanged(s) }),
268
268
  // Laid over the live state ([rico] #3644): what the owner did while the save awaited survives it.