@wix/himalaya-cli 0.1178.0 → 0.1179.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 (46) 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/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/rich-content/src/index.d.ts +8 -0
  3. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-rich-content.mjs +24 -0
  4. package/dist/cli.mjs +17 -18
  5. package/dist/preview-assets.generated.bin +0 -0
  6. package/guides/sdk-reference.json +7 -1
  7. package/package.json +2 -2
  8. package/templates/beauty-salon-owner/tier5-src/_shared/owner-editor-value.ts +57 -39
  9. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-post-form/index.ts +23 -13
  10. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-product-form/index.ts +20 -8
  11. package/templates/community-club-owner/tier5-src/_shared/owner-editor-value.ts +57 -39
  12. package/templates/community-club-owner/tier5-src/himi-owner-lite-event-form/index.ts +21 -10
  13. package/templates/community-club-owner/tier5-src/himi-owner-lite-post-form/index.ts +23 -13
  14. package/templates/community-club-owner/tier5-src/himi-owner-lite-product-form/index.ts +20 -8
  15. package/templates/events-meetups-owner/tier5-src/_shared/owner-editor-value.ts +57 -39
  16. package/templates/events-meetups-owner/tier5-src/himi-owner-lite-event-form/index.ts +21 -10
  17. package/templates/events-meetups-owner/tier5-src/himi-owner-lite-post-form/index.ts +23 -13
  18. package/templates/gym-fitness-owner/tier5-src/_shared/owner-editor-value.ts +57 -39
  19. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-event-form/index.ts +21 -10
  20. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-post-form/index.ts +23 -13
  21. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-product-form/index.ts +20 -8
  22. package/templates/nonprofit-cause-owner/tier5-src/_shared/owner-editor-value.ts +57 -39
  23. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-event-form/index.ts +21 -10
  24. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-post-form/index.ts +23 -13
  25. package/templates/owner-lite/tier5-src/_shared/owner-editor-value.ts +57 -39
  26. package/templates/owner-lite/tier5-src/himi-owner-lite-event-form/index.ts +21 -10
  27. package/templates/owner-lite/tier5-src/himi-owner-lite-faq-question-form/index.ts +16 -10
  28. package/templates/owner-lite/tier5-src/himi-owner-lite-post-form/index.ts +23 -13
  29. package/templates/owner-lite/tier5-src/himi-owner-lite-product-form/index.ts +20 -8
  30. package/templates/podcast-creator-owner/tier5-src/_shared/owner-editor-value.ts +57 -39
  31. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-event-form/index.ts +21 -10
  32. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-post-form/index.ts +23 -13
  33. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-product-form/index.ts +20 -8
  34. package/templates/portfolio-creative-owner/tier5-src/_shared/owner-editor-value.ts +57 -39
  35. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-post-form/index.ts +23 -13
  36. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-product-form/index.ts +20 -8
  37. package/templates/restaurant-owner/tier5-src/_shared/owner-editor-value.ts +57 -39
  38. package/templates/restaurant-owner/tier5-src/himi-owner-lite-event-form/index.ts +21 -10
  39. package/templates/restaurant-owner/tier5-src/himi-owner-lite-post-form/index.ts +23 -13
  40. package/templates/restaurant-owner/tier5-src/himi-owner-lite-product-form/index.ts +20 -8
  41. package/templates/retail-store-owner/tier5-src/_shared/owner-editor-value.ts +57 -39
  42. package/templates/retail-store-owner/tier5-src/himi-owner-lite-post-form/index.ts +23 -13
  43. package/templates/retail-store-owner/tier5-src/himi-owner-lite-product-form/index.ts +20 -8
  44. package/templates/yoga-studio-owner/tier5-src/_shared/owner-editor-value.ts +57 -39
  45. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-event-form/index.ts +21 -10
  46. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-post-form/index.ts +23 -13
@@ -8,7 +8,7 @@
8
8
  // drops nodes the owner never touched).
9
9
  // Inside the platform source this import is a relative path, so node test runs resolve it; an app's
10
10
  // copy imports "@himalaya/wix-rich-content".
11
- import { parseRicosJson, isPlainRicos, RICH_CONTENT_LIMITS, type RicosDocument } from "@himalaya/wix-rich-content";
11
+ import { parseRicosJson, isPlainRicos, richContentOverLimits, type RicosDocument } from "@himalaya/wix-rich-content";
12
12
 
13
13
  export type EditorValue =
14
14
  | { kind: "absent" } // leave the stored body untouched
@@ -17,34 +17,6 @@ export type EditorValue =
17
17
  | { kind: "too_large" } // past the rich-content limits: save the rest, say why
18
18
  | { kind: "invalid" }; // not a Ricos document: refuse, write nothing
19
19
 
20
- function utf8Bytes(s: string): number {
21
- let n = 0;
22
- for (let i = 0; i < s.length; i++) {
23
- const c = s.charCodeAt(i);
24
- if (c < 0x80) n += 1;
25
- else if (c < 0x800) n += 2;
26
- else if (c >= 0xd800 && c <= 0xdbff && i + 1 < s.length) { n += 4; i++; }
27
- else n += 3;
28
- }
29
- return n;
30
- }
31
-
32
- /** Every viewer renders a body past the rich-content limits as its fallback, so it is never written.
33
- * The viewer's order: bytes (before parsing), then depth, then the node count. */
34
- function overLimits(doc: RicosDocument): boolean {
35
- let count = 0;
36
- const work: Array<[unknown, number]> = doc.nodes.map((n) => [n, 1] as [unknown, number]);
37
- while (work.length) {
38
- const [n, depth] = work.pop()!;
39
- if (!n || typeof n !== "object" || Array.isArray(n)) continue;
40
- if (depth > RICH_CONTENT_LIMITS.maxDepth) return true;
41
- if (++count > RICH_CONTENT_LIMITS.maxNodes) return true;
42
- const kids = (n as { nodes?: unknown }).nodes;
43
- if (Array.isArray(kids)) for (const k of kids) work.push([k, depth + 1]);
44
- }
45
- return false;
46
- }
47
-
48
20
  const TEXT_ONLY = new Set(["PARAGRAPH", "TEXT", "HEADING", "BULLETED_LIST", "ORDERED_LIST", "LIST_ITEM", "BLOCKQUOTE", "CODE_BLOCK"]);
49
21
  /** No visible text and no non-text node (an image, a divider, an embed…): an empty body. */
50
22
  export function isEmptyDocument(doc: RicosDocument): boolean {
@@ -55,10 +27,12 @@ export function isEmptyDocument(doc: RicosDocument): boolean {
55
27
 
56
28
  /** A Ricos JSON string checked as every viewer would: the limits first, then the shape. */
57
29
  function checkedDocument(raw: string): EditorValue {
58
- if (utf8Bytes(raw) > RICH_CONTENT_LIMITS.maxBytes) return { kind: "too_large" };
30
+ // Every viewer renders a body past the limits as its fallback, so it is never written. One parse
31
+ // feeds both checks; a string past the byte limit is refused before it is parsed at all.
32
+ if (richContentOverLimits(raw, null) === "too_large") return { kind: "too_large" };
59
33
  const doc = parseRicosJson(raw);
60
34
  if (!doc) return { kind: "invalid" };
61
- return overLimits(doc) ? { kind: "too_large" } : { kind: "doc", doc, json: raw };
35
+ return richContentOverLimits(raw, doc) ? { kind: "too_large" } : { kind: "doc", doc, json: raw };
62
36
  }
63
37
 
64
38
  /** `raw` is `values[name]`; `seededJson` is the string the form published as the editor's seed. */
@@ -108,15 +82,59 @@ export function cachedRichVerdict(doc: RicosDocument | null, knownPlain: boolean
108
82
  return knownPlain ? "false" : "true";
109
83
  }
110
84
 
111
- /** Cold boot: the runtime lays this service's last persisted snapshot over its declared state, so the
112
- * editor's first seed would be the document as it was when the screen last ran — and a Save after
113
- * typing would write it over whatever the record holds now. Blank the hydrated seed and reset the gate
114
- * before anything renders, in place (no action has run yet). `extra` resets further UI state. */
115
- export function resetHydratedEditor(svc: unknown, keys: { json: string; pending: string; dirty: string }, pendingAtBoot: boolean, extra: Record<string, unknown> = {}): void {
85
+ /** The keys of one editor field that describe THIS run, never the last one: the seed (`<key>Json`),
86
+ * the load gate (`<key>Pending`), the edit flag (`<key>Dirty`) and its counter (`<key>Edits`). A form
87
+ * lists them in `defineService(name, { notHydrated })`, so a cold boot takes their DECLARED values: the
88
+ * editor seeds once, and a persisted seed would be painted as its content and saved back over a newer
89
+ * record. */
90
+ export function editorRunKeys<K extends string>(key: K): [`${K}Json`, `${K}Pending`, `${K}Dirty`, `${K}Edits`] {
91
+ return [`${key}Json`, `${key}Pending`, `${key}Dirty`, `${key}Edits`];
92
+ }
93
+
94
+ /** The same keys on a runtime that ignores `notHydrated` (a binary from before #3644, still served new
95
+ * bundles over the air): reset them in place, before any action runs. A no-op where the runtime
96
+ * honours the option (`himalaya.hydrationOptOut`). */
97
+ export function resetHydratedEditorOnOlderRuntime(svc: unknown, key: string, pendingAtBoot: boolean, extra: Record<string, unknown> = {}): void {
98
+ if ((globalThis as { himalaya?: { hydrationOptOut?: unknown } }).himalaya?.hydrationOptOut === true) return;
116
99
  const booted = (svc as { getState?: () => Record<string, unknown> | undefined }).getState?.();
117
100
  if (!booted || typeof booted !== "object") return;
118
- if (booted[keys.json]) booted[keys.json] = "";
119
- booted[keys.pending] = pendingAtBoot;
120
- booted[keys.dirty] = false;
101
+ if (booted[`${key}Json`]) booted[`${key}Json`] = "";
102
+ booted[`${key}Pending`] = pendingAtBoot;
103
+ booted[`${key}Dirty`] = false;
104
+ booted[`${key}Edits`] = 0;
121
105
  Object.assign(booted, extra);
122
106
  }
107
+
108
+ /** What an awaited save leaves the field as, read from the LIVE state when it lands. An on_change that
109
+ * arrived while the save was in flight (`<key>Edits` moved) is an edit the save did not write: the
110
+ * field stays dirty and the save's confirmation is stale. Otherwise a save that wrote the field
111
+ * re-bases it as saved. `live` is the service's state now (none, or one that never moved: the start state). */
112
+ export function editorAfterSave(key: string, start: Record<string, unknown>, live: Record<string, unknown> | undefined, wrote: boolean):
113
+ { dirty: boolean; edits: number; editedDuringSave: boolean } {
114
+ const atStart = Number(start[`${key}Edits`] ?? 0);
115
+ const now = Number(live?.[`${key}Edits`] ?? atStart);
116
+ // Only a counter that MOVED ON counts; one behind the start is a host that does not apply results.
117
+ const editedDuringSave = now > atStart;
118
+ return editedDuringSave
119
+ ? { dirty: true, edits: now, editedDuringSave }
120
+ : { dirty: wrote ? false : Boolean(start[`${key}Dirty`]), edits: atStart, editedDuringSave };
121
+ }
122
+
123
+ /** The live state of a service handle, when the runtime gives one. */
124
+ export function liveState(svc: unknown): Record<string, unknown> | undefined {
125
+ const st = (svc as { getState?: () => unknown } | undefined)?.getState?.();
126
+ return st && typeof st === "object" ? st as Record<string, unknown> : undefined;
127
+ }
128
+
129
+ /** An awaited action's result, laid over the LIVE state: only the keys the action CHANGED (against the
130
+ * state it started from) are written, so anything the owner did while it awaited — an edit, a field in
131
+ * the details sheet, closing the sheet — survives a save that lands after it, success or failure. A
132
+ * key both changed goes to the action. Where the host gives no live state, the result as it is. */
133
+ export function settleOverLive<S extends object>(svc: unknown, start: S, result: S): S {
134
+ const now = liveState(svc) as S | undefined;
135
+ if (!now || now === start || now === result) return result;
136
+ const out = { ...now } as Record<string, unknown>;
137
+ const r = result as Record<string, unknown>, s0 = start as Record<string, unknown>;
138
+ for (const k of Object.keys(r)) if (!Object.is(r[k], s0[k])) out[k] = r[k];
139
+ return out as S;
140
+ }
@@ -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, resetHydratedEditor } from "../_shared/owner-editor-value.js";
13
+ import { editorSubmitValue, queuedDocument, editorChanged, cachedRichVerdict, editorRunKeys, resetHydratedEditorOnOlderRuntime, editorAfterSave, liveState, settleOverLive } 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, isBridgeUnavailable, isOwnerPreviewDemo, shouldQueueOwnerWrite } from "../_shared/owner-load-error.js";
@@ -59,6 +59,8 @@ interface EventFormState {
59
59
  * hidden until this run's load lands. */
60
60
  descriptionDirty: boolean;
61
61
  descriptionPending: boolean;
62
+ /** Counts the editor's on_change events, so a save can tell an edit made while it was in flight. */
63
+ descriptionEdits: number;
62
64
  /** Derived from the mode: "locked" shows the preview + `descriptionNote`, and refuses an edit. */
63
65
  descriptionProtected: boolean;
64
66
  descriptionEditable: boolean;
@@ -133,6 +135,7 @@ function shape(s: Partial<EventFormState>): EventFormState {
133
135
  descriptionEditorHidden: mode !== "editor",
134
136
  descriptionDirty: s.descriptionDirty ?? false,
135
137
  descriptionPending: s.descriptionPending ?? false,
138
+ descriptionEdits: s.descriptionEdits ?? 0,
136
139
  descriptionProtected: mode === "locked",
137
140
  descriptionEditable: mode !== "locked",
138
141
  descriptionNote: mode === "locked" ? copyParam("ownerEventForm.richDescriptionNote", "This description has formatting or media. Edit it in your site dashboard to keep that content intact; the title can be changed here.") : "",
@@ -163,8 +166,8 @@ function demoRefusal(s: Parameters<typeof shape>[0]): ReturnType<typeof shape> {
163
166
 
164
167
  // The RichContentEditor's image tool (the editor's upload contract). The component picks and PUTs
165
168
  // the bytes; this service mints the signed Wix Media URL with the worker's own wixApi, so no token
166
- // reaches the page. Registered BEFORE the form's service on purpose: the worker test harnesses treat a
167
- // bundle's LAST defineService as its service.
169
+ // reaches the page. Any order: the
170
+ // worker test harnesses find each service by name.
168
171
  ricosUploadService("himiOwnerLiteEventFormUploads");
169
172
 
170
173
  const svc = defineService<EventFormState, {
@@ -173,6 +176,8 @@ const svc = defineService<EventFormState, {
173
176
  submit: (s: EventFormState, values?: Record<string, unknown>) => Promise<EventFormState>;
174
177
  }>("himiOwnerLiteEventForm", {
175
178
  state: shape({ mode: startMode, id: initialInput.id ?? "", descriptionPending: true }),
179
+ // [rico] #3644 — this run's editor state: a cold boot takes it from `state`, never the snapshot.
180
+ notHydrated: editorRunKeys("description"),
176
181
  actions: {
177
182
  load: async (s, arg) => {
178
183
  stashCopy(arg); // absorb the descriptor's onAppear copy-pack param
@@ -258,8 +263,9 @@ const svc = defineService<EventFormState, {
258
263
  }
259
264
  },
260
265
  // The editor's on_change carries `args.dirty` only; the document stays in the form bag.
261
- descriptionChanged: (s) => shape({ ...s, descriptionDirty: true, ...editorChanged(s) }),
262
- submit: async (s, values) => {
266
+ descriptionChanged: (s) => shape({ ...s, descriptionDirty: true, descriptionEdits: s.descriptionEdits + 1, ...editorChanged(s) }),
267
+ // Laid over the live state ([rico] #3644): what the owner did while the save awaited survives it.
268
+ submit: async (s, values): Promise<EventFormState> => settleOverLive(svc, s, await (async (): Promise<EventFormState> => {
263
269
  if (isOwnerPreviewDemo() || s.demoNote) return demoRefusal(s);
264
270
  const v = values ?? {};
265
271
  const title = String(v.title ?? s.title).trim();
@@ -310,14 +316,19 @@ const svc = defineService<EventFormState, {
310
316
  // lands nothing is known, so the row stays protected (fail closed).
311
317
  const knownPlain = s.descriptionMode === "plain" || (s.descriptionMode === "editor" && !s.descriptionPending && s.descriptionJson === "");
312
318
  await cachePut({ id: s.id, name: title, title, description, descriptionProtected: cachedRichVerdict(heldDoc as never, knownPlain), ...(heldJson ? { descriptionJson: heldJson } : {}), when: s.when, location: s.location });
313
- const savedState = (savedMessage: string) => shape({
319
+ const savedState = (savedMessage: string) => {
320
+ // An edit typed while this save was in flight stays unsaved work ([rico] #3644).
321
+ const after = editorAfterSave("description", s as never, liveState(svc), !!doc);
322
+ return shape({
314
323
  ...s, mode: "edit", id: s.id, heading: copyParam("ownerEventForm.headingEdit", "Edit event"),
315
324
  title, description, originalDescription: doc ? description : s.originalDescription,
316
325
  ...(docRaw ? { descriptionJson: docRaw } : {}),
317
- descriptionDirty: doc ? false : s.descriptionDirty,
326
+ descriptionDirty: after.dirty, descriptionEdits: after.edits,
318
327
  status: "saved", savedMessage, savedId: s.id,
328
+ ...(after.editedDuringSave ? { status: "idle" as const, savedMessage: "" } : {}),
319
329
  error: tooLarge ? copyParam("ownerEventForm.descriptionTooLarge", "The description is too long to save, so it was left as it was. Your other changes were saved.") : "",
320
- });
330
+ });
331
+ };
321
332
  try {
322
333
  await updateEvent(s.id, event);
323
334
  return savedState(copyParam("ownerEventForm.savedMessage", "Event saved."));
@@ -337,11 +348,11 @@ const svc = defineService<EventFormState, {
337
348
  }
338
349
  return shape({ ...s, status: "error", error: ownerLoadError(e), savedMessage: "" });
339
350
  }
340
- },
351
+ })()),
341
352
  },
342
353
  });
343
354
 
344
355
  // Adopter contract: the editor seeds once, so a cold boot must not seed it with the last run's document.
345
- resetHydratedEditor(svc, { json: "descriptionJson", pending: "descriptionPending", dirty: "descriptionDirty" }, true);
356
+ resetHydratedEditorOnOlderRuntime(svc, "description", true);
346
357
 
347
358
  export { svc };
@@ -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, resetHydratedEditor } from "../_shared/owner-editor-value.js";
7
+ import { editorSubmitValue, queuedDocument, editorChanged, editorRunKeys, resetHydratedEditorOnOlderRuntime, editorAfterSave, liveState, settleOverLive } 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";
@@ -46,6 +46,8 @@ interface PostFormState {
46
46
  * the body was loaded or saved, and (edit mode) the editor is hidden until this run's load lands. */
47
47
  bodyDirty: boolean;
48
48
  bodyPending: boolean;
49
+ /** Counts the editor's on_change events, so a save can tell an edit made while it was in flight. */
50
+ bodyEdits: number;
49
51
  coverUrl: string;
50
52
  originalCoverUrl: string;
51
53
  coverPreview: string;
@@ -120,6 +122,7 @@ function shape(s: Partial<PostFormState>): PostFormState {
120
122
  detailsOpen: s.detailsOpen ?? false,
121
123
  bodyDirty: s.bodyDirty ?? false,
122
124
  bodyPending: s.bodyPending ?? false,
125
+ bodyEdits: s.bodyEdits ?? 0,
123
126
  coverUrl: s.coverUrl ?? "",
124
127
  originalCoverUrl: s.originalCoverUrl ?? "",
125
128
  coverPreview: wixMediaUrl(s.coverUrl ?? "", WIX_DETAIL_IMAGE),
@@ -150,9 +153,8 @@ function demoRefusal(s: Parameters<typeof shape>[0]): ReturnType<typeof shape> {
150
153
 
151
154
  // [rico] #3371 — the RichContentEditor's image tool (the editor's upload contract). The component picks
152
155
  // and PUTs the bytes; this service mints the signed Wix Media URL with the worker's own wixApi
153
- // (@himalaya/wix-site-media rides the `wix_api` grant), so no token reaches the page. Registered
154
- // BEFORE the form's service on purpose: the worker test harnesses treat a bundle's
155
- // LAST defineService as its service.
156
+ // (@himalaya/wix-site-media rides the `wix_api` grant), so no token reaches the page. Any order:
157
+ // the worker test harnesses find each service by name.
156
158
  ricosUploadService("himiOwnerLitePostFormUploads");
157
159
 
158
160
  const svc = defineService<PostFormState, {
@@ -165,6 +167,9 @@ const svc = defineService<PostFormState, {
165
167
  submit: (s: PostFormState, values?: Record<string, unknown>) => Promise<PostFormState>;
166
168
  }>("himiOwnerLitePostForm", {
167
169
  state: shape({ mode: startMode, id: initialInput.id ?? "", bodyPending: startMode === "edit" }),
170
+ // [rico] #3644 — this run's editor state and UI: a cold boot takes these from `state`, never from
171
+ // the persisted snapshot (the editor seeds once; last run's sheet is not this run's).
172
+ notHydrated: [...editorRunKeys("body"), "detailsOpen"],
168
173
  actions: {
169
174
  load: async (s, arg) => {
170
175
  stashCopy(arg); // absorb the descriptor's onAppear copy-pack param
@@ -236,8 +241,9 @@ const svc = defineService<PostFormState, {
236
241
  detailsOpen: (s) => shape({ ...s, detailsOpen: true }),
237
242
  detailsClose: (s) => shape({ ...s, detailsOpen: false }),
238
243
  // The editor's `on_change` carries `args.dirty` only; the document stays in the form bag.
239
- bodyChanged: (s) => shape({ ...s, bodyDirty: true, ...editorChanged(s) }),
240
- submit: async (s, values) => {
244
+ bodyChanged: (s) => shape({ ...s, bodyDirty: true, bodyEdits: s.bodyEdits + 1, ...editorChanged(s) }),
245
+ // Laid over the live state ([rico] #3644): what the owner did while the save awaited survives it.
246
+ submit: async (s, values): Promise<PostFormState> => settleOverLive(svc, s, await (async (): Promise<PostFormState> => {
241
247
  if (isOwnerPreviewDemo() || s.demoNote) return demoRefusal(s);
242
248
  const v = values ?? {};
243
249
  const title = String(v.title ?? s.title).trim();
@@ -292,14 +298,19 @@ const svc = defineService<PostFormState, {
292
298
  let kept: WixRichContent | null = bodyEdited ? richContent : (replay ? null : s.richContent);
293
299
  const localRow = (id: string): DbEntity => ({ id, name: title, title, excerpt, body: postBodyText(kept), coverUrl, ...(kept ? { richContent: JSON.stringify(kept) } : {}), isFeatured: isFeatured ? "true" : "false", postStatus: "Draft", postStatusRaw: "DRAFT" });
294
300
  const localId = editing ? targetId : `local-${Date.now()}`;
295
- const savedState = (id: string, savedMessage: string) => shape({
301
+ const savedState = (id: string, savedMessage: string) => {
302
+ // An edit typed while this save was in flight stays unsaved work ([rico] #3644).
303
+ const after = editorAfterSave("body", s as never, liveState(svc), bodyEdited);
304
+ return shape({
296
305
  ...s, mode: "edit", id, heading: copyParam("ownerPostForm.headingEdit", "Edit post"),
297
306
  title, excerpt, body: postBodyText(kept), richContent: kept, bodyJson: bodyEdited ? bodyRaw : s.bodyJson,
298
- bodyDirty: bodyEdited ? false : s.bodyDirty,
307
+ bodyDirty: after.dirty, bodyEdits: after.edits,
299
308
  coverUrl, originalCoverUrl: coverUrl, isFeatured: isFeatured ? "true" : "false",
300
309
  status: "saved", savedMessage, savedId: id,
310
+ ...(after.editedDuringSave ? { status: "idle" as const, savedMessage: "" } : {}),
301
311
  error: bodyTooLarge ? copyParam("ownerPostForm.bodyTooLarge", "The post body is too long to save, so it was left as it was. Your other changes were saved.") : "",
302
- });
312
+ });
313
+ };
303
314
  try {
304
315
  // A replay worker starts without the loaded document. Entity upserts replace whole rows, so
305
316
  // retain the cached rich body before writing back.
@@ -349,12 +360,11 @@ const svc = defineService<PostFormState, {
349
360
  }
350
361
  return shape({ ...s, status: "error", error: ownerLoadError(e), savedMessage: "" });
351
362
  }
352
- },
363
+ })()),
353
364
  },
354
365
  });
355
366
 
356
- // [rico] #3371 — adopter contract: the editor seeds once, so a cold boot must not seed it with the last
357
- // run's document. Nor does it carry this run's UI over: the details sheet is shut.
358
- resetHydratedEditor(svc, { json: "bodyJson", pending: "bodyPending", dirty: "bodyDirty" }, startMode === "edit", { detailsOpen: false });
367
+ // The same reset on a runtime from before `notHydrated` existed (an older binary on a new bundle).
368
+ resetHydratedEditorOnOlderRuntime(svc, "body", startMode === "edit", { detailsOpen: false });
359
369
 
360
370
  export { svc };
@@ -8,7 +8,7 @@
8
8
  // drops nodes the owner never touched).
9
9
  // Inside the platform source this import is a relative path, so node test runs resolve it; an app's
10
10
  // copy imports "@himalaya/wix-rich-content".
11
- import { parseRicosJson, isPlainRicos, RICH_CONTENT_LIMITS, type RicosDocument } from "@himalaya/wix-rich-content";
11
+ import { parseRicosJson, isPlainRicos, richContentOverLimits, type RicosDocument } from "@himalaya/wix-rich-content";
12
12
 
13
13
  export type EditorValue =
14
14
  | { kind: "absent" } // leave the stored body untouched
@@ -17,34 +17,6 @@ export type EditorValue =
17
17
  | { kind: "too_large" } // past the rich-content limits: save the rest, say why
18
18
  | { kind: "invalid" }; // not a Ricos document: refuse, write nothing
19
19
 
20
- function utf8Bytes(s: string): number {
21
- let n = 0;
22
- for (let i = 0; i < s.length; i++) {
23
- const c = s.charCodeAt(i);
24
- if (c < 0x80) n += 1;
25
- else if (c < 0x800) n += 2;
26
- else if (c >= 0xd800 && c <= 0xdbff && i + 1 < s.length) { n += 4; i++; }
27
- else n += 3;
28
- }
29
- return n;
30
- }
31
-
32
- /** Every viewer renders a body past the rich-content limits as its fallback, so it is never written.
33
- * The viewer's order: bytes (before parsing), then depth, then the node count. */
34
- function overLimits(doc: RicosDocument): boolean {
35
- let count = 0;
36
- const work: Array<[unknown, number]> = doc.nodes.map((n) => [n, 1] as [unknown, number]);
37
- while (work.length) {
38
- const [n, depth] = work.pop()!;
39
- if (!n || typeof n !== "object" || Array.isArray(n)) continue;
40
- if (depth > RICH_CONTENT_LIMITS.maxDepth) return true;
41
- if (++count > RICH_CONTENT_LIMITS.maxNodes) return true;
42
- const kids = (n as { nodes?: unknown }).nodes;
43
- if (Array.isArray(kids)) for (const k of kids) work.push([k, depth + 1]);
44
- }
45
- return false;
46
- }
47
-
48
20
  const TEXT_ONLY = new Set(["PARAGRAPH", "TEXT", "HEADING", "BULLETED_LIST", "ORDERED_LIST", "LIST_ITEM", "BLOCKQUOTE", "CODE_BLOCK"]);
49
21
  /** No visible text and no non-text node (an image, a divider, an embed…): an empty body. */
50
22
  export function isEmptyDocument(doc: RicosDocument): boolean {
@@ -55,10 +27,12 @@ export function isEmptyDocument(doc: RicosDocument): boolean {
55
27
 
56
28
  /** A Ricos JSON string checked as every viewer would: the limits first, then the shape. */
57
29
  function checkedDocument(raw: string): EditorValue {
58
- if (utf8Bytes(raw) > RICH_CONTENT_LIMITS.maxBytes) return { kind: "too_large" };
30
+ // Every viewer renders a body past the limits as its fallback, so it is never written. One parse
31
+ // feeds both checks; a string past the byte limit is refused before it is parsed at all.
32
+ if (richContentOverLimits(raw, null) === "too_large") return { kind: "too_large" };
59
33
  const doc = parseRicosJson(raw);
60
34
  if (!doc) return { kind: "invalid" };
61
- return overLimits(doc) ? { kind: "too_large" } : { kind: "doc", doc, json: raw };
35
+ return richContentOverLimits(raw, doc) ? { kind: "too_large" } : { kind: "doc", doc, json: raw };
62
36
  }
63
37
 
64
38
  /** `raw` is `values[name]`; `seededJson` is the string the form published as the editor's seed. */
@@ -108,15 +82,59 @@ export function cachedRichVerdict(doc: RicosDocument | null, knownPlain: boolean
108
82
  return knownPlain ? "false" : "true";
109
83
  }
110
84
 
111
- /** Cold boot: the runtime lays this service's last persisted snapshot over its declared state, so the
112
- * editor's first seed would be the document as it was when the screen last ran — and a Save after
113
- * typing would write it over whatever the record holds now. Blank the hydrated seed and reset the gate
114
- * before anything renders, in place (no action has run yet). `extra` resets further UI state. */
115
- export function resetHydratedEditor(svc: unknown, keys: { json: string; pending: string; dirty: string }, pendingAtBoot: boolean, extra: Record<string, unknown> = {}): void {
85
+ /** The keys of one editor field that describe THIS run, never the last one: the seed (`<key>Json`),
86
+ * the load gate (`<key>Pending`), the edit flag (`<key>Dirty`) and its counter (`<key>Edits`). A form
87
+ * lists them in `defineService(name, { notHydrated })`, so a cold boot takes their DECLARED values: the
88
+ * editor seeds once, and a persisted seed would be painted as its content and saved back over a newer
89
+ * record. */
90
+ export function editorRunKeys<K extends string>(key: K): [`${K}Json`, `${K}Pending`, `${K}Dirty`, `${K}Edits`] {
91
+ return [`${key}Json`, `${key}Pending`, `${key}Dirty`, `${key}Edits`];
92
+ }
93
+
94
+ /** The same keys on a runtime that ignores `notHydrated` (a binary from before #3644, still served new
95
+ * bundles over the air): reset them in place, before any action runs. A no-op where the runtime
96
+ * honours the option (`himalaya.hydrationOptOut`). */
97
+ export function resetHydratedEditorOnOlderRuntime(svc: unknown, key: string, pendingAtBoot: boolean, extra: Record<string, unknown> = {}): void {
98
+ if ((globalThis as { himalaya?: { hydrationOptOut?: unknown } }).himalaya?.hydrationOptOut === true) return;
116
99
  const booted = (svc as { getState?: () => Record<string, unknown> | undefined }).getState?.();
117
100
  if (!booted || typeof booted !== "object") return;
118
- if (booted[keys.json]) booted[keys.json] = "";
119
- booted[keys.pending] = pendingAtBoot;
120
- booted[keys.dirty] = false;
101
+ if (booted[`${key}Json`]) booted[`${key}Json`] = "";
102
+ booted[`${key}Pending`] = pendingAtBoot;
103
+ booted[`${key}Dirty`] = false;
104
+ booted[`${key}Edits`] = 0;
121
105
  Object.assign(booted, extra);
122
106
  }
107
+
108
+ /** What an awaited save leaves the field as, read from the LIVE state when it lands. An on_change that
109
+ * arrived while the save was in flight (`<key>Edits` moved) is an edit the save did not write: the
110
+ * field stays dirty and the save's confirmation is stale. Otherwise a save that wrote the field
111
+ * re-bases it as saved. `live` is the service's state now (none, or one that never moved: the start state). */
112
+ export function editorAfterSave(key: string, start: Record<string, unknown>, live: Record<string, unknown> | undefined, wrote: boolean):
113
+ { dirty: boolean; edits: number; editedDuringSave: boolean } {
114
+ const atStart = Number(start[`${key}Edits`] ?? 0);
115
+ const now = Number(live?.[`${key}Edits`] ?? atStart);
116
+ // Only a counter that MOVED ON counts; one behind the start is a host that does not apply results.
117
+ const editedDuringSave = now > atStart;
118
+ return editedDuringSave
119
+ ? { dirty: true, edits: now, editedDuringSave }
120
+ : { dirty: wrote ? false : Boolean(start[`${key}Dirty`]), edits: atStart, editedDuringSave };
121
+ }
122
+
123
+ /** The live state of a service handle, when the runtime gives one. */
124
+ export function liveState(svc: unknown): Record<string, unknown> | undefined {
125
+ const st = (svc as { getState?: () => unknown } | undefined)?.getState?.();
126
+ return st && typeof st === "object" ? st as Record<string, unknown> : undefined;
127
+ }
128
+
129
+ /** An awaited action's result, laid over the LIVE state: only the keys the action CHANGED (against the
130
+ * state it started from) are written, so anything the owner did while it awaited — an edit, a field in
131
+ * the details sheet, closing the sheet — survives a save that lands after it, success or failure. A
132
+ * key both changed goes to the action. Where the host gives no live state, the result as it is. */
133
+ export function settleOverLive<S extends object>(svc: unknown, start: S, result: S): S {
134
+ const now = liveState(svc) as S | undefined;
135
+ if (!now || now === start || now === result) return result;
136
+ const out = { ...now } as Record<string, unknown>;
137
+ const r = result as Record<string, unknown>, s0 = start as Record<string, unknown>;
138
+ for (const k of Object.keys(r)) if (!Object.is(r[k], s0[k])) out[k] = r[k];
139
+ return out as S;
140
+ }
@@ -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, resetHydratedEditor } from "../_shared/owner-editor-value.js";
13
+ import { editorSubmitValue, queuedDocument, editorChanged, cachedRichVerdict, editorRunKeys, resetHydratedEditorOnOlderRuntime, editorAfterSave, liveState, settleOverLive } 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, isBridgeUnavailable, isOwnerPreviewDemo, shouldQueueOwnerWrite } from "../_shared/owner-load-error.js";
@@ -59,6 +59,8 @@ interface EventFormState {
59
59
  * hidden until this run's load lands. */
60
60
  descriptionDirty: boolean;
61
61
  descriptionPending: boolean;
62
+ /** Counts the editor's on_change events, so a save can tell an edit made while it was in flight. */
63
+ descriptionEdits: number;
62
64
  /** Derived from the mode: "locked" shows the preview + `descriptionNote`, and refuses an edit. */
63
65
  descriptionProtected: boolean;
64
66
  descriptionEditable: boolean;
@@ -133,6 +135,7 @@ function shape(s: Partial<EventFormState>): EventFormState {
133
135
  descriptionEditorHidden: mode !== "editor",
134
136
  descriptionDirty: s.descriptionDirty ?? false,
135
137
  descriptionPending: s.descriptionPending ?? false,
138
+ descriptionEdits: s.descriptionEdits ?? 0,
136
139
  descriptionProtected: mode === "locked",
137
140
  descriptionEditable: mode !== "locked",
138
141
  descriptionNote: mode === "locked" ? copyParam("ownerEventForm.richDescriptionNote", "This description has formatting or media. Edit it in your site dashboard to keep that content intact; the title can be changed here.") : "",
@@ -163,8 +166,8 @@ function demoRefusal(s: Parameters<typeof shape>[0]): ReturnType<typeof shape> {
163
166
 
164
167
  // The RichContentEditor's image tool (the editor's upload contract). The component picks and PUTs
165
168
  // the bytes; this service mints the signed Wix Media URL with the worker's own wixApi, so no token
166
- // reaches the page. Registered BEFORE the form's service on purpose: the worker test harnesses treat a
167
- // bundle's LAST defineService as its service.
169
+ // reaches the page. Any order: the
170
+ // worker test harnesses find each service by name.
168
171
  ricosUploadService("himiOwnerLiteEventFormUploads");
169
172
 
170
173
  const svc = defineService<EventFormState, {
@@ -173,6 +176,8 @@ const svc = defineService<EventFormState, {
173
176
  submit: (s: EventFormState, values?: Record<string, unknown>) => Promise<EventFormState>;
174
177
  }>("himiOwnerLiteEventForm", {
175
178
  state: shape({ mode: startMode, id: initialInput.id ?? "", descriptionPending: true }),
179
+ // [rico] #3644 — this run's editor state: a cold boot takes it from `state`, never the snapshot.
180
+ notHydrated: editorRunKeys("description"),
176
181
  actions: {
177
182
  load: async (s, arg) => {
178
183
  stashCopy(arg); // absorb the descriptor's onAppear copy-pack param
@@ -258,8 +263,9 @@ const svc = defineService<EventFormState, {
258
263
  }
259
264
  },
260
265
  // The editor's on_change carries `args.dirty` only; the document stays in the form bag.
261
- descriptionChanged: (s) => shape({ ...s, descriptionDirty: true, ...editorChanged(s) }),
262
- submit: async (s, values) => {
266
+ descriptionChanged: (s) => shape({ ...s, descriptionDirty: true, descriptionEdits: s.descriptionEdits + 1, ...editorChanged(s) }),
267
+ // Laid over the live state ([rico] #3644): what the owner did while the save awaited survives it.
268
+ submit: async (s, values): Promise<EventFormState> => settleOverLive(svc, s, await (async (): Promise<EventFormState> => {
263
269
  if (isOwnerPreviewDemo() || s.demoNote) return demoRefusal(s);
264
270
  const v = values ?? {};
265
271
  const title = String(v.title ?? s.title).trim();
@@ -310,14 +316,19 @@ const svc = defineService<EventFormState, {
310
316
  // lands nothing is known, so the row stays protected (fail closed).
311
317
  const knownPlain = s.descriptionMode === "plain" || (s.descriptionMode === "editor" && !s.descriptionPending && s.descriptionJson === "");
312
318
  await cachePut({ id: s.id, name: title, title, description, descriptionProtected: cachedRichVerdict(heldDoc as never, knownPlain), ...(heldJson ? { descriptionJson: heldJson } : {}), when: s.when, location: s.location });
313
- const savedState = (savedMessage: string) => shape({
319
+ const savedState = (savedMessage: string) => {
320
+ // An edit typed while this save was in flight stays unsaved work ([rico] #3644).
321
+ const after = editorAfterSave("description", s as never, liveState(svc), !!doc);
322
+ return shape({
314
323
  ...s, mode: "edit", id: s.id, heading: copyParam("ownerEventForm.headingEdit", "Edit event"),
315
324
  title, description, originalDescription: doc ? description : s.originalDescription,
316
325
  ...(docRaw ? { descriptionJson: docRaw } : {}),
317
- descriptionDirty: doc ? false : s.descriptionDirty,
326
+ descriptionDirty: after.dirty, descriptionEdits: after.edits,
318
327
  status: "saved", savedMessage, savedId: s.id,
328
+ ...(after.editedDuringSave ? { status: "idle" as const, savedMessage: "" } : {}),
319
329
  error: tooLarge ? copyParam("ownerEventForm.descriptionTooLarge", "The description is too long to save, so it was left as it was. Your other changes were saved.") : "",
320
- });
330
+ });
331
+ };
321
332
  try {
322
333
  await updateEvent(s.id, event);
323
334
  return savedState(copyParam("ownerEventForm.savedMessage", "Event saved."));
@@ -337,11 +348,11 @@ const svc = defineService<EventFormState, {
337
348
  }
338
349
  return shape({ ...s, status: "error", error: ownerLoadError(e), savedMessage: "" });
339
350
  }
340
- },
351
+ })()),
341
352
  },
342
353
  });
343
354
 
344
355
  // Adopter contract: the editor seeds once, so a cold boot must not seed it with the last run's document.
345
- resetHydratedEditor(svc, { json: "descriptionJson", pending: "descriptionPending", dirty: "descriptionDirty" }, true);
356
+ resetHydratedEditorOnOlderRuntime(svc, "description", true);
346
357
 
347
358
  export { svc };
@@ -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, resetHydratedEditor } from "../_shared/owner-editor-value.js";
7
+ import { editorSubmitValue, isEmptyDocument, editorRunKeys, resetHydratedEditorOnOlderRuntime, editorAfterSave, liveState, settleOverLive } 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
 
@@ -15,7 +15,9 @@ interface State { id: string; revision: string; mode: "create" | "edit"; heading
15
15
  /** The editor's seed: the answer as a Ricos JSON string ("" until this run's load lands; see the boot reset). */
16
16
  answerJson: string; answerFieldHidden: boolean; answerEditorHidden: boolean;
17
17
  /** The editor gate (`EditorGate` in _shared/owner-editor-value.ts): edited since load/save; hidden until load lands. */
18
- answerDirty: boolean; answerPending: boolean; }
18
+ answerDirty: boolean; answerPending: boolean;
19
+ /** Counts the editor's on_change events, so a save can tell an edit made while it was in flight. */
20
+ answerEdits: number; }
19
21
  const input = ((globalThis as { himalaya?: { input?: { id?: string } } }).himalaya?.input) ?? {};
20
22
  const msg = (e: unknown) => String((e as { message?: unknown })?.message ?? e);
21
23
  const isConflict = (e: unknown) => /\b409\b|conflict|revision/i.test(msg(e));
@@ -39,20 +41,22 @@ const shape=(s:Partial<State>):State=>({ id:s.id??input.id??"", revision:s.revis
39
41
  const editing = (s.mode ?? ((s.id ?? input.id) ? "edit" : "create")) === "edit";
40
42
  const mode = s.answerMode ?? (f === "draftjs" ? "locked" : editing ? "editor" : "plain");
41
43
  return { answerFormat: f, answerMode: mode, answerJson: s.answerJson ?? "", answerFieldHidden: mode !== "plain", answerEditorHidden: mode !== "editor",
42
- answerDirty: s.answerDirty ?? false, answerPending: s.answerPending ?? false,
44
+ answerDirty: s.answerDirty ?? false, answerPending: s.answerPending ?? false, answerEdits: s.answerEdits ?? 0,
43
45
  answerProtected: mode === "locked", answerEditable: mode !== "locked", answerNote: mode === "locked" ? RICH_ANSWER_NOTE : "" }; })() });
44
46
  // The RichContentEditor's image tool: this service mints the signed Wix Media URL with the worker's own wixApi.
45
- // Registered BEFORE the form's service: the worker test harnesses treat a bundle's LAST defineService as its service.
46
47
  ricosUploadService("himiOwnerLiteFaqQuestionFormUploads");
47
- 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}),actions:{
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
+ // [rico] #3644 — this run's editor state: a cold boot takes it from `state`, never the snapshot.
50
+ notHydrated:editorRunKeys("answer"),actions:{
48
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,
49
52
  // The seed is the stored document byte for byte (never re-encoded); a plain answer seeds as paragraphs.
50
53
  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)});}},
51
54
  // The editor's on_change carries `args.dirty` only; the document stays in the form bag.
52
55
  // Any on_change after a save is an edit that save did not write (every arm fires the pending on_change
53
56
  // before releasing a submit), so the confirmation goes.
54
- answerChanged:(s)=>shape({...s,answerDirty:true,savedMessage:""}),
55
- submit:async(s,v)=>{
57
+ answerChanged:(s)=>shape({...s,answerDirty:true,answerEdits:s.answerEdits+1,savedMessage:""}),
58
+ // Laid over the live state ([rico] #3644): what the owner did while the save awaited survives it.
59
+ submit:async(s,v):Promise<State>=>settleOverLive(svc,s,await (async ():Promise<State>=>{
56
60
  const question=String(v?.question??s.question).trim(),categoryId=String(v?.categoryId??s.categoryId),statusValue=String(v?.status??s.statusValue)==="VISIBLE"?"VISIBLE":"HIDDEN";
57
61
  const editing=s.mode==="edit";
58
62
  // The answer, per mode. Editor: `values.answer` is the RichContentEditor's document, or absent (the editor
@@ -83,15 +87,17 @@ const svc=defineService<State,{load:(s:State,a?:unknown)=>Promise<State>;answerC
83
87
  const questionEntry={id:s.id,revision:s.revision,question,categoryId,status:statusValue,...(doc?{richContent:doc as never}:plainEdit?{plainText:answer}:{})};
84
88
  const fieldMask=["question",...(doc?["richContent"]:plainEdit?["plainText"]:[]),"categoryId","status"];
85
89
  const r=await updateQuestionEntry(s.id,{questionEntry,fieldMask});
90
+ // An edit typed while this save was in flight stays unsaved work ([rico] #3644).
91
+ const after=editorAfterSave("answer",s as never,liveState(svc),!!doc);
86
92
  return shape({...s,revision:r.questionEntry?.revision??s.revision,question,answer,...(doc?{answerFormat:"rich" as const,answerJson:docRaw}:{}),
87
- answerDirty:doc?false:s.answerDirty,categoryId,statusValue,conflict:false,savedMessage:"Question saved.",savedId:s.id,
93
+ answerDirty:after.dirty,answerEdits:after.edits,categoryId,statusValue,conflict:false,savedMessage:after.editedDuringSave?"":"Question saved.",savedId:s.id,
88
94
  error:tooLarge?"The answer is too long to save, so it was left as it was. Your other changes were saved.":""});
89
95
  }
90
96
  const r=await createQuestionEntry({questionEntry:{question,plainText:answer,categoryId,status:statusValue,sortOrder:await appendOrder()}});
91
97
  return shape({...s,id:r.questionEntry?.id??"",revision:r.questionEntry?.revision??"",mode:"edit",heading:"Edit question",question,answer,categoryId,statusValue,error:"",savedMessage:"Question created.",savedId:r.questionEntry?.id??""});
92
98
  }catch(e){if(isConflict(e))return shape({...s,error:"This question 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:""});}
93
- }
99
+ })())
94
100
  }});
95
101
  // Adopter contract: the editor seeds once, so a cold boot must not seed it with the last run's document.
96
- resetHydratedEditor(svc,{json:"answerJson",pending:"answerPending",dirty:"answerDirty"},!!input.id);
102
+ resetHydratedEditorOnOlderRuntime(svc,"answer",!!input.id);
97
103
  export {svc,appendOrder};