@wireai/activation 0.13.2 → 0.13.3

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 (69) hide show
  1. package/AGENTS.md +4 -3
  2. package/CHANGELOG.md +228 -4
  3. package/README.md +16 -2
  4. package/dist/analytics/index.d.mts +4 -4
  5. package/dist/analytics/index.d.ts +4 -4
  6. package/dist/analytics/index.js +104 -470
  7. package/dist/analytics/index.js.map +1 -1
  8. package/dist/analytics/index.mjs +102 -468
  9. package/dist/analytics/index.mjs.map +1 -1
  10. package/dist/coachmarks/index.d.mts +14 -1
  11. package/dist/coachmarks/index.d.ts +14 -1
  12. package/dist/coachmarks/index.js +58 -14
  13. package/dist/coachmarks/index.js.map +1 -1
  14. package/dist/coachmarks/index.mjs +58 -14
  15. package/dist/coachmarks/index.mjs.map +1 -1
  16. package/dist/{currentSession-ClkLjcJ0.d.mts → currentSession-BoWtr3Jp.d.mts} +2 -2
  17. package/dist/{currentSession-DOVZEWJl.d.ts → currentSession-CCOMlaQ2.d.ts} +2 -2
  18. package/dist/{decision-Bl_M2y3r.d.mts → decision-Cjw0jbEj.d.mts} +1 -1
  19. package/dist/{decision-Cau5KmP6.d.ts → decision-GfpXAsk_.d.ts} +1 -1
  20. package/dist/index.d.mts +25 -2
  21. package/dist/index.d.ts +25 -2
  22. package/dist/index.js +220 -573
  23. package/dist/index.js.map +1 -1
  24. package/dist/index.mjs +217 -570
  25. package/dist/index.mjs.map +1 -1
  26. package/dist/questionnaire/index.d.mts +42 -10
  27. package/dist/questionnaire/index.d.ts +42 -10
  28. package/dist/questionnaire/index.js +45 -13
  29. package/dist/questionnaire/index.js.map +1 -1
  30. package/dist/questionnaire/index.mjs +45 -13
  31. package/dist/questionnaire/index.mjs.map +1 -1
  32. package/dist/reviews/index.d.mts +4 -4
  33. package/dist/reviews/index.d.ts +4 -4
  34. package/dist/reviews/index.js +27 -7
  35. package/dist/reviews/index.js.map +1 -1
  36. package/dist/reviews/index.mjs +27 -7
  37. package/dist/reviews/index.mjs.map +1 -1
  38. package/dist/showcase/index.js +7 -2
  39. package/dist/showcase/index.js.map +1 -1
  40. package/dist/showcase/index.mjs +7 -2
  41. package/dist/showcase/index.mjs.map +1 -1
  42. package/dist/{transport-DsRe4epC.d.ts → transport-CefdERPs.d.mts} +44 -5
  43. package/dist/{transport-CF_eHwzC.d.mts → transport-DFuPyCoJ.d.ts} +44 -5
  44. package/dist/{types-Buj9Lw9t.d.ts → types-UVYt9BJe.d.ts} +41 -1
  45. package/dist/{types-CNUqMK0D.d.mts → types-l2tfg23P.d.mts} +41 -1
  46. package/package.json +4 -1
  47. package/src/OnboardingFlow.tsx +44 -15
  48. package/src/WireOnboarding.tsx +7 -6
  49. package/src/activation/wireActivation.ts +3 -3
  50. package/src/analytics/analyticsFacade.ts +2 -2
  51. package/src/analytics/eventQueue.ts +83 -20
  52. package/src/cards/CardGridSelectCard.tsx +1 -1
  53. package/src/cards/ChipSelectCard.tsx +2 -2
  54. package/src/cards/SelectionCard.tsx +1 -1
  55. package/src/coachmarks/runtime.ts +62 -2
  56. package/src/coachmarks/useCoachmarkTour.ts +62 -14
  57. package/src/context/deviceId.ts +4 -4
  58. package/src/features/WireFeaturesProvider.tsx +4 -1
  59. package/src/identity/userIdentity.ts +1 -1
  60. package/src/questionnaire/QuestionnaireGate.tsx +90 -10
  61. package/src/questionnaire/index.ts +9 -1
  62. package/src/questionnaire/transport.ts +46 -11
  63. package/src/reviews/ReviewGate.tsx +61 -6
  64. package/src/reviews/index.ts +10 -1
  65. package/src/reviews/transport.ts +58 -12
  66. package/src/session-analytics/useLifecycleEvents.ts +1 -1
  67. package/src/theme/ThemeContext.tsx +42 -1
  68. package/src/types.ts +1 -1
  69. package/src/utils/submitResult.ts +39 -0
@@ -1,8 +1,14 @@
1
- import { useCallback, useEffect, useRef, useState } from "react";
1
+ import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
2
2
 
3
3
  import { coachmarkAnchors } from "./coachmarkAnchorRegistry";
4
4
  import { coachmarkOverlay } from "./coachmarkOverlayStore";
5
- import { areCoachmarksEnabled, coachmarkGateKey, hasSeenGate, markSeenGate } from "./runtime";
5
+ import {
6
+ areCoachmarksEnabled,
7
+ coachmarkGateKey,
8
+ hasSeenGate,
9
+ markSeenGate,
10
+ subscribeCoachmarksEnabled,
11
+ } from "./runtime";
6
12
  import type { CoachmarkStep } from "./types";
7
13
 
8
14
  export interface UseCoachmarkTourOptions {
@@ -61,6 +67,15 @@ const DEFAULT_START_DELAY_MS = 3000;
61
67
  * target is picked up. `onStepShown` is suppressed on that resume (last-shown
62
68
  * step id is tracked in a ref) so a pause/resume never double-counts a step.
63
69
  *
70
+ * KILL SWITCH vs PAUSE — two different gates, two different endings. `enabled` is the host's
71
+ * domain gate and PAUSES (position kept, gate unwritten, resumes on the same step). The tenant's
72
+ * coachmarks feature flag (`setCoachmarksEnabled`, from GET /v1/features) is TERMINAL: flipped off
73
+ * mid-tour it ends the tour and fires `onComplete`, but does NOT write the once-gate, so the tour
74
+ * replays on the next mount once the flag is back on. It is read through a subscription rather
75
+ * than sampled, because the flip has to reach a tour that is already in flight: the overlay's
76
+ * `show()` is a no-op while the flag is off, so a tour that carried on would paint nothing, could
77
+ * not be tapped to advance, and would never reach a terminal state.
78
+ *
64
79
  * IMPORTANT: `steps` MUST be a stable (memoized) array. If a new array identity
65
80
  * is passed on every render the drive effect re-runs and re-shows the current
66
81
  * step (wasteful anchor re-resolves / overlay churn), and analytics can
@@ -97,34 +112,67 @@ export const useCoachmarkTour = (
97
112
  const callbacksRef = useRef({ onStepShown, onStepEngaged, onStepDismissed });
98
113
  callbacksRef.current = { onStepShown, onStepEngaged, onStepDismissed };
99
114
 
100
- const finish = useCallback(() => {
101
- if (finishedRef.current) return;
102
- finishedRef.current = true;
103
- if (showOnce) markSeenGate(coachmarkGateKey(tourId));
104
- coachmarkOverlay.hide();
105
- setActiveIndex(null);
106
- onComplete?.();
107
- }, [onComplete, showOnce, tourId]);
115
+ // The tenant's coachmarks kill switch, SUBSCRIBED rather than sampled so a mid-tour flip reaches
116
+ // a tour already in flight. See the KILL SWITCH vs PAUSE note above.
117
+ const coachmarksOn = useSyncExternalStore(
118
+ subscribeCoachmarksEnabled,
119
+ areCoachmarksEnabled,
120
+ areCoachmarksEnabled,
121
+ );
122
+
123
+ /**
124
+ * The single terminal exit. `writeGate` is what separates the two ways a tour can end:
125
+ * • the user reached the end (or dismissed the last step) → the once-gate IS written, so a
126
+ * finished tour never nags again;
127
+ * • the kill switch went off mid-tour → the gate is NOT written. That gate belongs to a tour
128
+ * the user actually saw, and the module's whole kill-switch contract is that disabling
129
+ * never consumes it, so re-enabling replays the tour.
130
+ * Either way `onComplete` fires: a host that gates its own UI on the tour finishing must not be
131
+ * left waiting on a callback that can no longer arrive.
132
+ */
133
+ const exit = useCallback(
134
+ (writeGate: boolean) => {
135
+ if (finishedRef.current) return;
136
+ finishedRef.current = true;
137
+ if (writeGate && showOnce) markSeenGate(coachmarkGateKey(tourId));
138
+ coachmarkOverlay.hide();
139
+ setActiveIndex(null);
140
+ onComplete?.();
141
+ },
142
+ [onComplete, showOnce, tourId],
143
+ );
144
+
145
+ const finish = useCallback(() => exit(true), [exit]);
108
146
 
109
147
  // Arm once, after the delay, when enabled and the gate is unseen.
110
148
  useEffect(() => {
111
149
  if (!enabled || startedRef.current) return undefined;
112
150
  // Feature kill switch: never arm while coachmarks are disabled, so no timer starts, no
113
- // overlay shows, and the once-gate is NOT consumed — re-enabling replays the tour.
114
- if (!areCoachmarksEnabled()) return undefined;
151
+ // overlay shows, and the once-gate is NOT consumed — re-enabling replays the tour. Reading
152
+ // the SUBSCRIBED value (and listing it in the deps) is what makes that last clause true
153
+ // without a remount: flipping it off clears a pending arm timer, flipping it back on re-arms.
154
+ if (!coachmarksOn) return undefined;
115
155
  if (showOnce && hasSeenGate(coachmarkGateKey(tourId))) return undefined;
116
156
  const timer = setTimeout(() => {
117
157
  startedRef.current = true;
118
158
  setActiveIndex(0);
119
159
  }, startDelayMs);
120
160
  return () => clearTimeout(timer);
121
- }, [enabled, startDelayMs, showOnce, tourId]);
161
+ }, [enabled, coachmarksOn, startDelayMs, showOnce, tourId]);
122
162
 
123
163
  // Drive the active step. Keyed on `enabled` too, so a mid-tour focus loss
124
164
  // pauses (hide, keep position) and a return to focus resumes the same step.
125
165
  useEffect(() => {
126
166
  if (activeIndex === null) return undefined;
127
167
 
168
+ // KILL SWITCH, mid-tour: exit terminally, WITHOUT writing the once-gate, so the tour replays
169
+ // when it is switched back on. Checked BEFORE the pause branch below — a disable is terminal,
170
+ // a focus loss is not.
171
+ if (!coachmarksOn) {
172
+ exit(false);
173
+ return undefined;
174
+ }
175
+
128
176
  // PAUSE: armed but the screen lost focus (enabled flipped false while in
129
177
  // flight). Hide the overlay so a stale rect can't float over the wrong
130
178
  // screen, but KEEP activeIndex and DON'T write the gate. When enabled
@@ -188,7 +236,7 @@ export const useCoachmarkTour = (
188
236
  return () => {
189
237
  cancelled = true;
190
238
  };
191
- }, [activeIndex, steps, finish, enabled]);
239
+ }, [activeIndex, steps, finish, exit, enabled, coachmarksOn]);
192
240
 
193
241
  // Safety: clear the overlay if the screen unmounts mid-tour.
194
242
  useEffect(
@@ -74,7 +74,7 @@ const AUTO_DEVICE_KEY_SLOT: unique symbol = Symbol.for("@wireai/activation:autoD
74
74
  /** What a hydration settled on: the id, and whether persistence actually CONFIRMED it.
75
75
  *
76
76
  * `durable: false` means the id lives only in this process's memory — the adapter rejected, threw,
77
- * or there was no adapter at all. That distinction is the whole of K1: a string is a string, so
77
+ * or there was no adapter at all. That distinction is the whole point here: a string is a string, so
78
78
  * before 0.13.0 a caller could not tell a persisted id from a per-launch mint, and the auto-join
79
79
  * gate (`Boolean(storage)`) was reading the PRESENCE of the prop rather than the SUCCESS of the
80
80
  * write. See {@link hydrateDeviceIdentity}. */
@@ -131,7 +131,7 @@ export interface ResolveAutoDeviceKeyOptions {
131
131
  * the write. The id is real but PROCESS-scoped, so anything that
132
132
  * counts a device across launches must refuse it.
133
133
  *
134
- * A DEGRADED outcome also drops the registry latches so the NEXT caller starts a fresh read (K8). A
134
+ * A DEGRADED outcome also drops the registry latches so the NEXT caller starts a fresh read. A
135
135
  * cold-boot storage lock is transient; caching it as a verdict for the process lifetime turned a
136
136
  * one-second problem into a whole-launch one, and nothing ever retried.
137
137
  */
@@ -170,7 +170,7 @@ const startHydration = (
170
170
  run = Promise.resolve(degraded());
171
171
  }
172
172
  registry.pending.set(appId, run);
173
- // Retry-on-failure (K8): release the latches once a degraded outcome settles, so a later caller
173
+ // Retry-on-failure: release the latches once a degraded outcome settles, so a later caller
174
174
  // is not permanently bound to one bad read. Registered AFTER the `set` above, so the clean-up can
175
175
  // never race ahead of the entry it is clearing.
176
176
  void run.then((outcome) => {
@@ -238,7 +238,7 @@ export const hydrateAutoDeviceKey = async (
238
238
  * The PROVENANCE-CARRYING sibling of {@link hydrateAutoDeviceKey}: the same awaited read, but it
239
239
  * answers "is this id one this install will KEEP?" instead of only "what is the id?".
240
240
  *
241
- * WHY IT EXISTS (K1). `<WireOnboarding>` gated auto-injection on `Boolean(storage)` — the presence of
241
+ * WHY IT EXISTS. `<WireOnboarding>` gated auto-injection on `Boolean(storage)` — the presence of
242
242
  * the prop — because a string carries no provenance and there was nothing better to gate on. A
243
243
  * REJECTING adapter therefore injected a fresh `wdev_*` on every launch: strictly worse than
244
244
  * injecting nothing, since the server counts `min_sessions` by distinct opens grouped on `device_key`,
@@ -19,7 +19,10 @@ import { useWireFeatures } from "./useWireFeatures";
19
19
  import type { WireFeatures, WireFeaturesConfig } from "./types";
20
20
 
21
21
  const CONTEXT_SYMBOL = Symbol.for("wireai.features.context");
22
- const globalObj = global as any;
22
+ // `globalThis`, not `global`: the other nine `Symbol.for` slots in this kit all use it, and `global`
23
+ // simply does not exist in a plain browser (RN-web / SSR-in-the-browser), where this line would
24
+ // throw a ReferenceError before the provider could render anything.
25
+ const globalObj = globalThis as any;
23
26
 
24
27
  if (!globalObj[CONTEXT_SYMBOL]) {
25
28
  globalObj[CONTEXT_SYMBOL] = createContext<WireFeatures | null>(null);
@@ -131,7 +131,7 @@ export const identifyOnboarding = async (
131
131
  }
132
132
  }
133
133
  let space: Exclude<IdentifyOnboardingBinding, false> = "onboarding";
134
- // The OPT-IN last resort (K3). Until 0.13.0 this ran unconditionally: with no captured contextId
134
+ // The OPT-IN last resort. Until 0.13.0 this ran unconditionally: with no captured contextId
135
135
  // it posted the LIVE PER-OPEN session id in the `session_id` field — which on this endpoint means
136
136
  // the ONBOARDING session — and then returned `true`. Two disjoint id spaces share that field, so
137
137
  // the row it wrote could never join the onboarding funnel, and the host got a success signal for a
@@ -27,8 +27,10 @@
27
27
  * step is simply an empty answer, which is what the builder already handled.
28
28
  *
29
29
  * ⚠️ This gate now REQUIRES the server to accept an empty `answers.opinion` - step 1 is
30
- * skippable and the live endpoint still 422s a blank opinion, silently (the transport is
31
- * fire-and-forget). See the REQUIRED_STEPS note in steps.ts. Server fix, not a client one.
30
+ * skippable and the live endpoint still 422s a blank opinion. The 422 is read (the transport
31
+ * resolves `"rejected"`) but deliberately not retried the server ANSWERED, so re-posting could
32
+ * duplicate the row. The user still sees the thank-you either way. See the REQUIRED_STEPS note in
33
+ * steps.ts. Server fix, not a client one.
32
34
  *
33
35
  * Motion: `ModalPaneHost` slides step→step (and mirrors the slide on Back) and resizes the
34
36
  * card; `StepDots` fills; `DoneCheck` plays the submit payoff. All shared, all reduce-motion
@@ -69,7 +71,7 @@ import {
69
71
  type StepDrafts,
70
72
  } from "./steps";
71
73
  import { submitQuestionnaireResponse } from "./transport";
72
- import type { QuestionnaireGateProps } from "./types";
74
+ import type { QuestionnaireGateProps, QuestionnaireSubmission } from "./types";
73
75
 
74
76
  type Phase = "form" | "thanks";
75
77
 
@@ -193,16 +195,94 @@ const _QuestionnaireGate: React.FC<QuestionnaireGateProps> = ({
193
195
  // eslint-disable-next-line react-hooks/exhaustive-deps
194
196
  }, [id, onResolved]);
195
197
 
198
+ /**
199
+ * EXACTLY ONE response row per run, ever — the twin of `postOnce` in `reviews/ReviewGate.tsx`,
200
+ * and the reason an answer given offline is no longer silently thrown away.
201
+ *
202
+ * ── THE DEFECT THIS CLOSES ─────────────────────────────────────────────────────────────────
203
+ *
204
+ * `finish` used to call a `void`-returning `submitQuestionnaireResponse` and move on. The
205
+ * response was never read, so a submission that died in the socket was indistinguishable from
206
+ * one the server stored — and `onResolved` → `markResolved` writes the PERMANENT
207
+ * `wire_questionnaire_<id>_<ver>_seen` key regardless. A user who answered offline lost their
208
+ * answers AND was never asked again. This is the same hole 0.13.3 closed for reviews, on the one
209
+ * surface that both burns a permanent once-gate and had zero evidence of delivery.
210
+ *
211
+ * ── THE LATCH IS A CLAIM, NOT A RECEIPT ────────────────────────────────────────────────────
212
+ *
213
+ * `postedRef` is taken OPTIMISTICALLY, before the row has landed, so two callers in the same tick
214
+ * cannot post twice. It is RELEASED only when the transport reports that NOTHING REACHED THE
215
+ * SERVER, and the undelivered body is kept in `unackedRef` so the unmount net below can re-post
216
+ * it when the gate goes away.
217
+ *
218
+ * ── A NON-2xx IS NOT A RETRY TICKET ────────────────────────────────────────────────────────
219
+ *
220
+ * Only `"unsent"` un-latches. A non-2xx means the server was REACHED, and the response id is
221
+ * server-minted with no idempotency key on the wire, so a 502 arriving after the insert commits
222
+ * looks identical to a refusal. Re-posting on that verdict writes a SECOND response row — the
223
+ * duplicate-row defect reviews already fixed, one module over. On any answer the latch stays
224
+ * closed and `unackedRef` is cleared.
225
+ *
226
+ * NOTHING HERE IS AWAITED. `postOnce` returns synchronously and `finish` advances to the
227
+ * thank-you on the next line, so the user never waits on the network.
228
+ *
229
+ * HONEST CEILING — identical to reviews, deliberately: recovery is IN-PROCESS, one re-post when
230
+ * the gate unmounts. A run answered offline and never recovered before the app is killed is still
231
+ * lost, and the once-gate is still written by the host on `onResolved`. Durable cross-launch
232
+ * retry needs a storage prop on this component (new public API) or a generalized queue (the
233
+ * analytics one is hardwired to POST /v1/events, and a questionnaire response is not an event);
234
+ * both are deliberately out of scope here.
235
+ */
236
+ const postedRef = useRef(false);
237
+ /** The last submission fired with no answer yet. Non-null after an UNSENT post = still owed. */
238
+ const unackedRef = useRef<QuestionnaireSubmission | null>(null);
239
+ const postOnce = useCallback(
240
+ (body: QuestionnaireSubmission) => {
241
+ if (postedRef.current) return;
242
+ postedRef.current = true;
243
+ unackedRef.current = body;
244
+ void submitQuestionnaireResponse(target, id, body).then((result) => {
245
+ if (result === "unsent") {
246
+ // Nothing reached the server → un-latch, so these answers can still go out.
247
+ postedRef.current = false;
248
+ return;
249
+ }
250
+ // `accepted` OR `rejected`: the server answered, so it owns this row. Never re-post it.
251
+ unackedRef.current = null;
252
+ });
253
+ },
254
+ [target, id],
255
+ );
256
+
257
+ /**
258
+ * The recovery point for a submission the server never ANSWERED — never for one it answered
259
+ * non-2xx, which `postOnce` clears rather than re-queues. Read through a ref and fired from an
260
+ * unmount-only effect, so it always sees the latest state without re-subscribing (the same ref
261
+ * convention the fire-once effect above uses).
262
+ *
263
+ * Unlike `ReviewGate` there is no abandonment branch here: a questionnaire that was never
264
+ * submitted has no payload to rescue (a half-typed draft is not an answer, and the last step is
265
+ * required precisely so a completed run always carries one). This net exists for exactly one
266
+ * case — the run FINISHED and the answers never made it onto the wire.
267
+ */
268
+ const recoverRef = useRef<() => void>(() => {});
269
+ recoverRef.current = () => {
270
+ const undelivered = unackedRef.current;
271
+ if (undelivered && !postedRef.current) postOnce(undelivered);
272
+ };
273
+ React.useEffect(() => () => recoverRef.current(), []);
274
+
196
275
  // The run is over: POST the answers and play the payoff. Identity is NEVER collected here -
197
276
  // the host already sends user_id / device_key / session via `sessionId` + `meta`.
198
277
  const finish = useCallback(
199
278
  (drafts: StepDrafts) => {
200
- const submission = buildQuestionnaireSubmission({
201
- answers: toAnswers(drafts),
202
- sessionId,
203
- meta,
204
- });
205
- submitQuestionnaireResponse(target, id, submission);
279
+ postOnce(
280
+ buildQuestionnaireSubmission({
281
+ answers: toAnswers(drafts),
282
+ sessionId,
283
+ meta,
284
+ }),
285
+ );
206
286
  submittedRef.current = true;
207
287
  onEvent?.({ name: "questionnaire_submitted", id });
208
288
  playHaptic("success");
@@ -212,7 +292,7 @@ const _QuestionnaireGate: React.FC<QuestionnaireGateProps> = ({
212
292
  modalRef.current?.close();
213
293
  }, 1500);
214
294
  },
215
- [sessionId, meta, target, id, onEvent],
295
+ [sessionId, meta, postOnce, id, onEvent],
216
296
  );
217
297
 
218
298
  // Apply a machine transition: either submit (last step) or seed the next step's draft.
@@ -28,8 +28,16 @@ export {
28
28
  export type { GateSignals, GateRules } from "./decision";
29
29
 
30
30
  // ─── Server transport: submit a response + best-effort fetch the decision ───────
31
+ // `submitQuestionnaireResponse` resolves a THREE-state `QuestionnaireSubmitResult`
32
+ // (accepted / rejected / unsent), not `void`: only `unsent` may be retried, because the wire has no
33
+ // idempotency key and a re-post after a non-2xx that already committed writes a second row. It is
34
+ // still non-throwing and still fired synchronously — the caller never awaits.
31
35
  export { submitQuestionnaireResponse, fetchQuestionnaireDecision } from "./transport";
32
- export type { FetchQuestionnaireDecisionOptions } from "./transport";
36
+ export type {
37
+ FetchQuestionnaireDecisionOptions,
38
+ QuestionnaireSubmitResult,
39
+ } from "./transport";
40
+ export type { SubmitResult } from "../utils/submitResult";
33
41
 
34
42
  // ─── Gate-key helpers (shared coachmark storage singleton) ──────────────────────
35
43
  export {
@@ -1,7 +1,7 @@
1
1
  /**
2
- * transport.ts - kit → Wire server requests for the questionnaire module, all fire-and-forget
2
+ * transport.ts - kit → Wire server requests for the questionnaire module, never throwing
3
3
  * (a pre-onboarding questionnaire must NEVER break the app). Mirrors `submitReview`: a thin
4
- * fetch wrapper, Bearer tenant key, swallow every error.
4
+ * fetch wrapper, Bearer tenant key, and since 0.13.3 — the SAME three-outcome delivery ack.
5
5
  *
6
6
  * • submitQuestionnaireResponse → POST {serverUrl}/v1/questionnaires/{id}/responses
7
7
  * • fetchQuestionnaireDecision → GET {serverUrl}/v1/questionnaires/decision (best-effort)
@@ -10,6 +10,7 @@
10
10
  * non-2xx (incl. 404), a missing `fetch`, or bad JSON all resolve to null, so a host wiring
11
11
  * pure server-directed firing simply shows nothing - exactly the reviews decision seam.
12
12
  */
13
+ import type { SubmitResult } from "../utils/submitResult";
13
14
  import type {
14
15
  QuestionnaireDecisionResponse,
15
16
  QuestionnaireSubmission,
@@ -17,29 +18,63 @@ import type {
17
18
  } from "./types";
18
19
 
19
20
  /**
20
- * POST a questionnaire response. Fire-and-forget: a missing target, a missing id, a build
21
- * error, a missing `fetch`, or a network failure is swallowed and the call returns immediately.
21
+ * What became of a questionnaire POST. The SHARED verdict identical values, identical meaning,
22
+ * identical retry rule as `ReviewSubmitResult`. The reasoning lives once, in `utils/submitResult.ts`.
22
23
  */
23
- export const submitQuestionnaireResponse = (
24
+ export type QuestionnaireSubmitResult = SubmitResult;
25
+
26
+ /**
27
+ * POST a questionnaire response.
28
+ *
29
+ * NON-BLOCKING, and ACKED. It never throws and never makes a caller wait — the request is fired
30
+ * synchronously and the UI is free to advance on the next line — but it RESOLVES with what became
31
+ * of the response.
32
+ *
33
+ * ── WHY THE RETURN VALUE EXISTS (this used to be `void`) ─────────────────────────────────────
34
+ *
35
+ * The body was `void fetch(...).catch(() => {})`: the response was never read, so the caller could
36
+ * not tell a delivered submission from one that died in the socket. `QuestionnaireGate.finish`
37
+ * therefore latched the run as submitted unconditionally and resolved the gate, and `onResolved` →
38
+ * `markResolved` writes the PERMANENT `wire_questionnaire_<id>_<ver>_seen` key. A user who answered
39
+ * offline was never asked again and their answers were gone — the exact failure 0.13.3's
40
+ * `submitReview` fix was written for ("a detractor who rated offline was dropped AND never asked
41
+ * again"), on the one surface that both burns a permanent once-gate and had zero evidence of
42
+ * delivery. This is the twin of that fix.
43
+ *
44
+ * ── ONLY `unsent` MAY BE RETRIED ─────────────────────────────────────────────────────────────
45
+ *
46
+ * The same rule as reviews, for the same reason: the server mints the response id and the body
47
+ * carries no client id, so there is no idempotency key on the wire. A non-2xx proves the server was
48
+ * REACHED — a gateway 502 after the insert commits is indistinguishable from a refusal — so
49
+ * re-posting on it writes a SECOND response row. See `utils/submitResult.ts` for the full split and
50
+ * the honest residual.
51
+ *
52
+ * A missing target or a missing id resolves `"unsent"` with no request at all: nothing reached a
53
+ * server, which is exactly what a caller must be told before it decides the answers are delivered.
54
+ */
55
+ export const submitQuestionnaireResponse = async (
24
56
  target: QuestionnaireTarget | undefined,
25
57
  id: string,
26
58
  submission: QuestionnaireSubmission,
27
- ): void => {
28
- if (!target?.serverUrl || !id) return;
59
+ ): Promise<QuestionnaireSubmitResult> => {
60
+ if (!target?.serverUrl || !id) return "unsent";
29
61
  try {
30
62
  const base = target.serverUrl.replace(/\/$/, "");
31
63
  const url = `${base}/v1/questionnaires/${encodeURIComponent(id)}/responses`;
32
64
  const headers: Record<string, string> = { "Content-Type": "application/json" };
33
65
  if (target.apiKey) headers.Authorization = `Bearer ${target.apiKey}`;
34
- void fetch(url, {
66
+ const res = await fetch(url, {
35
67
  method: "POST",
36
68
  headers,
37
69
  body: JSON.stringify(submission),
38
- }).catch(() => {
39
- /* best-effort, swallow */
40
70
  });
71
+ // No response object at all is not an answer — treat it as nothing having reached the server
72
+ // rather than as a rejection, or a stubbed-out `fetch` would silently latch the answers away.
73
+ if (!res) return "unsent";
74
+ return (res as { ok?: boolean }).ok ? "accepted" : "rejected";
41
75
  } catch {
42
- /* URL/JSON/missing-fetch - swallow */
76
+ /* URL/JSON/missing-fetch/network - nothing came back, and never surfaced to the UI */
77
+ return "unsent";
43
78
  }
44
79
  };
45
80
 
@@ -115,18 +115,64 @@ const _ReviewGate: React.FC<ReviewGateProps> = ({
115
115
  * client-supplied id so the second write UPSERTS. It does not, as deployed today:
116
116
  * `create_review` mints its own id unconditionally (`app/reviews.py` `_new_id()`) and
117
117
  * `CreateReviewRequest` (`app/schemas.py`) has no id field — so two posts = two rows, which
118
- * double-counts `count` and corrupts `avg`. That is the SAME class of data corruption this
119
- * whole change exists to fix. And `submitReview` is fire-and-forget (transport.ts: the
120
- * response is never read), so the kit CANNOT detect a rejection and repair it: a wrong guess
121
- * fails silently and permanently. So we post once, at the last responsible moment, and never
122
- * depend on an unmerged/undeployed server capability.
118
+ * double-counts `count` and corrupts `avg`. Reading the ack does not rescue that guess either:
119
+ * a second post against a server with no upsert comes back 2xx HAVING created the second row, so
120
+ * the failure is invisible to the caller and permanent. Post once, at the last responsible
121
+ * moment, and never depend on an unmerged/undeployed server capability.
122
+ *
123
+ * ── THE LATCH IS A CLAIM, NOT A RECEIPT ────────────────────────────────────────────────────
124
+ *
125
+ * The latch is taken OPTIMISTICALLY, before the row has landed, because its first job is to stop
126
+ * two callers in the same tick from posting twice (the double-row corruption above). It is
127
+ * RELEASED again when `submitReview` reports that NOTHING REACHED THE SERVER, and the undelivered
128
+ * body is kept in `unackedRef` so the abandonment net below can re-post it when the gate goes
129
+ * away. Without that release, a rating that died in the socket — the offline detractor, whose 1-4
130
+ * stars plus mandatory text is the most valuable payload this kit carries — is latched as posted
131
+ * and silently dropped, and the once-gate means they are never asked again.
132
+ *
133
+ * ── A NON-2xx IS NOT A RETRY TICKET ────────────────────────────────────────────────────────
134
+ *
135
+ * "Did it land" and "did the server answer" are DIFFERENT questions, which is why `submitReview`
136
+ * resolves `accepted` / `rejected` / `unsent` instead of a boolean. Only `unsent` un-latches.
137
+ * A non-2xx means the server was REACHED, and with no idempotency key on the wire (see above:
138
+ * the id is server-minted) we cannot know whether it stored the row before failing to say so — a
139
+ * gateway 502 after the insert commits looks identical to a refusal. Re-posting on that verdict
140
+ * writes the second row this whole latch exists to prevent, and does it on the exact code path
141
+ * meant to protect the data. So on any answer the latch stays closed and `unackedRef` is
142
+ * cleared: the row is the server's problem now.
143
+ *
144
+ * HONEST RESIDUAL: `unsent` is not proof the server never got it either. A connection dropped
145
+ * after the row committed throws here exactly like an offline device, so the one re-post below
146
+ * can still duplicate. This is SAFER, not safe. The real fix is a client-minted idempotency key
147
+ * the server upserts on — a server change, out of scope for the kit.
148
+ *
149
+ * NOTHING HERE IS AWAITED. `postOnce` returns synchronously and every caller advances the phase
150
+ * on the next line, so the user is never waiting on the network to dismiss the prompt.
151
+ *
152
+ * HONEST CEILING: recovery is IN-PROCESS — one re-post when the gate unmounts. A rating taken
153
+ * offline and never recovered before the app is killed is still lost. Durable cross-launch retry
154
+ * needs either a storage prop on this component (new public API) or a generalized
155
+ * `createEventQueue` (that queue is hardwired to POST /v1/events, and a review row is not an
156
+ * event); both are deliberately out of scope here.
123
157
  */
124
158
  const postedRef = React.useRef(false);
159
+ /** The last submission fired with no answer yet. Non-null after an UNSENT post = still owed. */
160
+ const unackedRef = React.useRef<ReviewSubmission | null>(null);
125
161
  const postOnce = useCallback(
126
162
  (body: ReviewSubmission) => {
127
163
  if (postedRef.current) return;
128
164
  postedRef.current = true;
129
- submitReview(target, body);
165
+ unackedRef.current = body;
166
+ void submitReview(target, body).then((result) => {
167
+ if (result === "unsent") {
168
+ // Nothing reached the server → un-latch, so this row can still go out.
169
+ postedRef.current = false;
170
+ return;
171
+ }
172
+ // `accepted` OR `rejected`: the server answered, so it owns this row. Never re-post it —
173
+ // a non-2xx can follow a committed insert, and the wire has no idempotency key.
174
+ unackedRef.current = null;
175
+ });
130
176
  },
131
177
  [target],
132
178
  );
@@ -142,9 +188,18 @@ const _ReviewGate: React.FC<ReviewGateProps> = ({
142
188
  *
143
189
  * Read through a ref and fired from an unmount-only effect, so it always sees the latest
144
190
  * state without re-subscribing (the same ref convention `review_prompt_shown` above uses).
191
+ *
192
+ * It is ALSO the recovery point for a submission the server never ANSWERED — never for one it
193
+ * answered non-2xx, which `postOnce` clears rather than re-queues. That body already carries the
194
+ * user's real text, so it is re-posted verbatim rather than re-flagged `abandoned`.
145
195
  */
146
196
  const abandonRef = React.useRef<() => void>(() => {});
147
197
  abandonRef.current = () => {
198
+ const undelivered = unackedRef.current;
199
+ if (undelivered && !postedRef.current) {
200
+ postOnce(undelivered);
201
+ return;
202
+ }
148
203
  if (postedRef.current || stars < 1) return;
149
204
  postOnce(
150
205
  buildReviewSubmission({
@@ -54,8 +54,17 @@ export { requestStoreReview, openStoreListing, storeUrl } from "./storeReview";
54
54
  // `fetchReviewDecision` is the mirror of `fetchQuestionnaireDecision`. Use it instead of
55
55
  // hand-rolling a decision fetch: a host-rolled one that collapses `{fire:false}` into undefined
56
56
  // makes the server able to turn prompts ON but never OFF (the 2026-07-16 first-session incident).
57
+ // `submitReview` resolves with a THREE-state `ReviewSubmitResult` (accepted / rejected / unsent),
58
+ // not a boolean: only `unsent` may be retried, because the wire has no idempotency key and a
59
+ // re-post after a non-2xx that already committed writes a second row.
57
60
  export { submitReview, fetchReviewDecision, reportAppEvent } from "./transport";
58
- export type { FetchReviewDecisionOptions, ReportAppEventOptions } from "./transport";
61
+ export type {
62
+ FetchReviewDecisionOptions,
63
+ ReportAppEventOptions,
64
+ ReviewSubmitResult,
65
+ } from "./transport";
66
+ // The same verdict under its shared name — `submitQuestionnaireResponse` resolves it too.
67
+ export type { SubmitResult } from "../utils/submitResult";
59
68
 
60
69
  // ─── Gate-key + storage helpers (shared coachmark storage singleton) ──────────
61
70
  export {
@@ -1,7 +1,9 @@
1
1
  /**
2
- * transport.ts — kit → Wire server requests for the review module, all fire-and-forget
3
- * (analytics/reviews must never break the app). Mirrors analytics/reportClientEvent: a
4
- * thin fetch wrapper, Bearer tenant key, swallow every error.
2
+ * transport.ts — kit → Wire server requests for the review module. Non-blocking and never
3
+ * throwing (analytics/reviews must never break the app). Mirrors analytics/reportClientEvent: a
4
+ * thin fetch wrapper with a Bearer tenant key. `submitReview` additionally REPORTS the fate of the
5
+ * row to its caller — accepted / rejected / unsent, three outcomes on purpose; the rest swallow
6
+ * every error.
5
7
  *
6
8
  * • submitReview → POST {serverUrl}/v1/reviews (the review row)
7
9
  * • fetchReviewDecision → GET {serverUrl}/v1/reviews/decision (best-effort, the AI seam)
@@ -14,30 +16,74 @@
14
16
  */
15
17
  import { ensureCurrentSessionId } from "../analytics/currentSession";
16
18
  import { buildEventsRequest, type ClientEvent } from "../analytics/reportClientEvent";
19
+ import type { SubmitResult } from "../utils/submitResult";
17
20
  import type { ReviewDecisionResponse, ReviewSubmission, ReviewTarget } from "./types";
18
21
 
19
22
  /**
20
- * POST a review (the 1-4 feedback path). Fire-and-forget: a missing target, a build error,
21
- * a missing `fetch`, or a network failure is swallowed and the call returns immediately.
23
+ * What became of a review POST the SHARED three-outcome verdict, re-exported under the name the
24
+ * reviews surface has always published. The split is load-bearing and the reasoning lives once, in
25
+ * `utils/submitResult.ts`, because `submitQuestionnaireResponse` answers the identical question.
26
+ * See `submitReview` below for what reads it.
22
27
  */
23
- export const submitReview = (
28
+ export type ReviewSubmitResult = SubmitResult;
29
+
30
+ /**
31
+ * POST a review (the 1-4 feedback path, and the text-less 5-star row).
32
+ *
33
+ * NON-BLOCKING, and ACKED. It never throws and never makes a caller wait — the request is fired
34
+ * synchronously and the UI is free to advance on the next line — but it RESOLVES with what became
35
+ * of the row.
36
+ *
37
+ * WHY THE RETURN VALUE EXISTS: a 1-4 star rating with mandatory free text is the kit's most
38
+ * valuable payload and it has no persisted queue behind it (unlike `analytics/eventQueue`). If the
39
+ * response is dropped on the floor, a caller cannot tell a delivered submission from one that died
40
+ * in the socket, so it must latch the rating as posted and a detractor who rated offline is lost
41
+ * AND never asked again. Reading the ack is what lets `ReviewGate` keep an undelivered row
42
+ * recoverable.
43
+ *
44
+ * ── WHY IT IS NOT A BOOLEAN ──────────────────────────────────────────────────────────────────
45
+ *
46
+ * It was, for exactly one unpublished release, and the boolean was the bug. `false` meant both
47
+ * "nothing reached the server" and "the server answered non-2xx", and the one caller that reads
48
+ * this (`ReviewGate.postOnce`) treats `false` as "still owed" and re-posts. But the server mints
49
+ * its own row id (`create_review` / `_new_id()`) and `CreateReviewRequest` carries no id, so there
50
+ * is NO idempotency key on the wire: a 502 returned AFTER the insert commits means the re-post
51
+ * writes a SECOND row, double-counting `count` and corrupting `avg` — the precise corruption the
52
+ * one-row latch exists to prevent. A response of any status proves the server was reached, and
53
+ * that is a different question from whether it liked the row. So the two are different values.
54
+ *
55
+ * ── THE RESIDUAL, STATED HONESTLY ────────────────────────────────────────────────────────────
56
+ *
57
+ * `unsent` is not proof the server never got the row. A connection dropped after the request was
58
+ * written — or after the row committed — surfaces as a thrown/rejected `fetch` here, exactly like
59
+ * an offline device. Retrying only on `unsent` is therefore SAFER, not SAFE: it removes the
60
+ * double-post the server itself told us about, and leaves the narrow window where the answer never
61
+ * made it back onto the wire. Closing that window needs a CLIENT-MINTED IDEMPOTENCY KEY the server
62
+ * upserts on, which is a server change (`CreateReviewRequest` + `create_review`) and is not
63
+ * something the kit can fake. Until it exists, prefer losing a row over inventing one: a lost
64
+ * detractor is a gap in the data, a duplicated one is a lie in the data.
65
+ */
66
+ export const submitReview = async (
24
67
  target: ReviewTarget | undefined,
25
68
  review: ReviewSubmission,
26
- ): void => {
27
- if (!target?.serverUrl) return;
69
+ ): Promise<ReviewSubmitResult> => {
70
+ if (!target?.serverUrl) return "unsent";
28
71
  try {
29
72
  const url = `${target.serverUrl.replace(/\/$/, "")}/v1/reviews`;
30
73
  const headers: Record<string, string> = { "Content-Type": "application/json" };
31
74
  if (target.apiKey) headers.Authorization = `Bearer ${target.apiKey}`;
32
- void fetch(url, {
75
+ const res = await fetch(url, {
33
76
  method: "POST",
34
77
  headers,
35
78
  body: JSON.stringify(review),
36
- }).catch(() => {
37
- /* best-effort, swallow */
38
79
  });
80
+ // No response object at all is not an answer — treat it as nothing having reached the server
81
+ // rather than as a rejection, or a stubbed-out `fetch` would silently latch the row away.
82
+ if (!res) return "unsent";
83
+ return (res as { ok?: boolean }).ok ? "accepted" : "rejected";
39
84
  } catch {
40
- /* URL/JSON/missing-fetch — swallow */
85
+ /* URL/JSON/missing-fetch/networknothing came back, and never surfaced to the UI */
86
+ return "unsent";
41
87
  }
42
88
  };
43
89
 
@@ -140,7 +140,7 @@ export const useLifecycleEvents = (
140
140
  ): string | undefined => {
141
141
  // A HOST-supplied key is recorded on the process provenance registry, so a `<WireOnboarding>`
142
142
  // mount that was not given one can tell "this app owns no device id" from "this app owns one
143
- // and forgot it there" — the silent third id space (K9). Recording only; nothing reads it here.
143
+ // and forgot it there" — the silent third id space. Recording only; nothing reads it here.
144
144
  const host = resolveIdentity({
145
145
  value: opts.deviceKey,
146
146
  space: "device",