@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
@@ -2,6 +2,46 @@ import { O as OnboardingTheme } from './types-BKfpdZzX.mjs';
2
2
  import { C as CoachmarkStorage } from './types-BcmagF6K.mjs';
3
3
  import { W as WireFeatures, a as WireFeaturesConfig } from './types-CKFhyrMu.mjs';
4
4
 
5
+ /**
6
+ * submitResult — the ONE delivery verdict every user-payload POST in this kit reports back.
7
+ *
8
+ * WHY IT IS SHARED, AND WHY IT IS NOT A BOOLEAN. Two surfaces post a payload the user typed —
9
+ * `submitReview` (`reviews/transport.ts`) and `submitQuestionnaireResponse`
10
+ * (`questionnaire/transport.ts`) — and NEITHER has a persisted queue behind it (unlike
11
+ * `analytics/eventQueue`, with its `maxSize`/`maxRetries`/backoff). Both sit behind a permanent
12
+ * once-gate: the host writes `wire_review_<id>_seen` / `wire_questionnaire_<id>_<ver>_seen` when the
13
+ * gate resolves, so a payload that dies in the socket is lost AND the user is never asked again.
14
+ * Reading the fate of the request is the only thing that lets a caller keep an undelivered payload
15
+ * recoverable, so the two surfaces must answer the same question in the same words.
16
+ *
17
+ * • `accepted` — the server answered 2xx. Stored.
18
+ * • `rejected` — the server ANSWERED, non-2xx. It was reached; it either stored the payload and
19
+ * lost the ack (a gateway 502 after the write commits) or deliberately refused it.
20
+ * Either way the server owns the outcome and a re-post risks a SECOND row.
21
+ * • `unsent` — nothing came back at all: no target, a missing/throwing `fetch`, a rejected
22
+ * request. **This is the ONLY outcome a caller may retry on.**
23
+ *
24
+ * ── WHY THREE VALUES AND NOT TWO (pinned here so the defect cannot move surface) ───────────────
25
+ *
26
+ * `submitReview` was a boolean for exactly one unpublished release, and the boolean WAS the bug.
27
+ * `false` meant both "nothing reached the server" and "the server answered non-2xx", and the caller
28
+ * treats `false` as "still owed" and re-posts. But the server mints its own row id
29
+ * (`create_review` / `_new_id()`), and neither `CreateReviewRequest` nor the questionnaire response
30
+ * body carries a client id, so there is NO idempotency key on the wire: a 502 returned AFTER the
31
+ * insert commits means the re-post writes a SECOND row. A response of any status proves the server
32
+ * was reached, and that is a different question from whether it liked the payload.
33
+ *
34
+ * ── THE RESIDUAL, STATED HONESTLY ─────────────────────────────────────────────────────────────
35
+ *
36
+ * `unsent` is not proof the server never got it. A connection dropped after the request was written
37
+ * — or after the row committed — surfaces as a thrown/rejected `fetch`, exactly like an offline
38
+ * device. Retrying only on `unsent` is therefore SAFER, not SAFE. Closing that window needs a
39
+ * CLIENT-MINTED IDEMPOTENCY KEY the server upserts on, which is a server change and not something
40
+ * the kit can fake. Until it exists, prefer losing a row over inventing one: a lost detractor is a
41
+ * gap in the data, a duplicated one is a lie in the data.
42
+ */
43
+ type SubmitResult = "accepted" | "rejected" | "unsent";
44
+
5
45
  /**
6
46
  * The firing verdict — the SAME contract on both sides of the seam. `decideReview`
7
47
  * turns local rules into one of these; a server `{fire, reason}` (from
@@ -161,4 +201,4 @@ interface ReviewGateController {
161
201
  markResolved: () => void;
162
202
  }
163
203
 
164
- export type { ReviewGateProps as R, UseReviewGateOptions as U, ReviewPresentation as a, ReviewGateController as b, ReviewDecision as c, ReviewStoreConfig as d, RatingRoute as e, ReviewConfig as f, ReviewDecisionResponse as g, ReviewGateEvent as h, ReviewGateEventName as i, ReviewSubmission as j, ReviewTarget as k };
204
+ export type { ReviewGateProps as R, SubmitResult as S, UseReviewGateOptions as U, ReviewPresentation as a, ReviewGateController as b, ReviewDecision as c, ReviewStoreConfig as d, RatingRoute as e, ReviewConfig as f, ReviewDecisionResponse as g, ReviewGateEvent as h, ReviewGateEventName as i, ReviewSubmission as j, ReviewTarget as k };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wireai/activation",
3
- "version": "0.13.2",
3
+ "version": "0.13.3",
4
4
  "private": false,
5
5
  "description": "Premium, fully-themable drop-in AI onboarding kit for React Native / Expo, on top of the open-source wireai-rn SDK.",
6
6
  "author": "Malik Chohra <malik@getwireai.com>",
@@ -137,8 +137,11 @@
137
137
  "scripts": {
138
138
  "build": "tsup",
139
139
  "prepack": "npm run build",
140
+ "prepublishOnly": "npm run build && npm run test:canary",
140
141
  "typecheck": "tsc --noEmit",
142
+ "pretest": "npm run build",
141
143
  "test": "node --test --experimental-strip-types --import ./test/register-ts-resolver.mjs \"src/**/*.test.ts\" \"test/canary/**/*.test.tsx\"",
144
+ "test:canary": "node --test --experimental-strip-types --import ./test/register-ts-resolver.mjs \"test/canary/**/*.test.tsx\"",
142
145
  "pack:tarball": "npm pack"
143
146
  },
144
147
  "peerDependencies": {
@@ -179,7 +179,17 @@ export const OnboardingFlow: React.FC<OnboardingFlowProps> = ({
179
179
  // Trailing-edge debounce timer for prefetch-on-select (see previewOnSelect below).
180
180
  const previewTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
181
181
 
182
- const started = useRef(false);
182
+ // The attempt generation. It lives in STATE, not in a ref, and both the auto-start effect and
183
+ // the startup watchdog list it in their deps — a retry has to genuinely RE-ARM both, and a ref
184
+ // cannot re-trigger an effect. Nothing else in those deps moves on a retry: `sendMessage` is
185
+ // memoized in wireai-rn on [registry, provider, model], and `lastCard` is still `undefined`
186
+ // because no card ever arrived. Without the generation the kickoff is never re-sent, `timedOut`
187
+ // can never become true a second time, and the retry budget can never be spent — which leaves a
188
+ // backend that never answers sitting on the loader forever.
189
+ const [runId, setRunId] = useState(0);
190
+ // The generation whose kickoff has already been sent. -1 = nothing sent yet (so the very first
191
+ // run can be told apart from a retry, which must NOT re-fire `started`/`resumed`).
192
+ const startedRunRef = useRef(-1);
183
193
  const finished = useRef(false);
184
194
  const errored = useRef(false);
185
195
  const attempts = useRef(0);
@@ -210,33 +220,50 @@ export const OnboardingFlow: React.FC<OnboardingFlowProps> = ({
210
220
  const progress = useMemo(() => readProgress(lastCard?.response), [lastCard]);
211
221
 
212
222
  // 1. Auto-start: the backend treats the first inbound message as "begin".
213
- // On a RESUMED session (persisted id restored after an app kill) the kickoff is
214
- // the skip sentinel instead: the server has this session mid-flow with a pending
215
- // question, and would record `startMessage`'s literal text as its ANSWER the
216
- // sentinel advances one card recording nothing (a `screen_skipped`). On a session
217
- // the server no longer knows (restart/expiry), the sentinel degrades cleanly to a
218
- // normal kickoff under the same id. Retries re-send the same kickoff.
223
+ // ONLY the very first attempt of a fresh session sends `startMessage`. Every other kickoff
224
+ // a RESUMED session (persisted id restored after an app kill) and every RETRY — sends the
225
+ // skip sentinel, because both can reach a server that already has a question PENDING on this
226
+ // session, and a non-empty message arriving against a pending question is recorded as that
227
+ // question's ANSWER (server engine dispatch: `if user_message and pending and not
228
+ // _stop_requested(...)` appends `{key, question, answer}` to `session["answers"]`). For a
229
+ // retry that is not hypothetical: the watchdog fires at `startTimeoutMs` while a slow LLM is
230
+ // still generating, so the server can genuinely hold card 1 pending when the retry lands, and
231
+ // the user's first onboarding answer becomes the literal kickoff text — silently.
232
+ // THE COST, stated plainly: when the server DID have card 1 pending, the sentinel SKIPS that
233
+ // card — the user loses one question and the server logs a `screen_skipped`. Deliberate. A
234
+ // skipped question is visible in the funnel and recorded as nothing; a kickoff string stored
235
+ // as the user's first answer is invisible and poisons the data the product is built on.
236
+ // When the server has no pending (restart/expiry, or a retry whose predecessor never landed)
237
+ // the sentinel is zeroed and falls through — it degrades cleanly to a normal kickoff under
238
+ // the same id, which is what makes it safe to send on an attempt that may be either.
219
239
  useEffect(() => {
220
- if (started.current) return;
221
- started.current = true;
222
- sendMessage(resumed ? SKIP_ONE_SENTINEL : startMessage);
240
+ if (startedRunRef.current === runId) return;
241
+ const isFirstRun = startedRunRef.current < 0;
242
+ startedRunRef.current = runId;
243
+ sendMessage(isFirstRun && !resumed ? startMessage : SKIP_ONE_SENTINEL);
244
+ // Only the FIRST run is a session start. A retry re-kicks the flow (with the sentinel) but
245
+ // must not re-fire `started`/`resumed`, or every host funnel double-counts the session; the
246
+ // retry itself is already surfaced as a `retry` event by the error effect below.
247
+ if (!isFirstRun) return;
223
248
  // Surface the session id (the A2A contextId) on the start event so a host can capture it
224
249
  // and bind a user AFTER the flow via `identifyOnboarding({ contextId })` — completion clears
225
250
  // the persisted session, so the in-flight event is the reliable post-flow handle. sessionId
226
- // is stable for the whole session (minted before this subtree mounts), so this stays mount-once.
251
+ // is stable for the whole session (minted before this subtree mounts), so this stays once
252
+ // per session.
227
253
  onEventRef.current?.(
228
254
  resumed
229
255
  ? { type: "resumed", contextId: sessionId }
230
256
  : { type: "started", contextId: sessionId },
231
257
  );
232
- }, [sendMessage, startMessage, resumed, sessionId]);
258
+ }, [sendMessage, startMessage, resumed, sessionId, runId]);
233
259
 
234
- // 2. Startup watchdog.
260
+ // 2. Startup watchdog. `runId` is load-bearing in these deps: after a retry there is still no
261
+ // card, so `lastCard` does not change and nothing else would re-arm the timer.
235
262
  useEffect(() => {
236
263
  if (lastCard) return;
237
264
  const timer = setTimeout(() => setTimedOut(true), startTimeoutMs);
238
265
  return () => clearTimeout(timer);
239
- }, [lastCard, startTimeoutMs]);
266
+ }, [lastCard, startTimeoutMs, runId]);
240
267
 
241
268
  // A new card arrived → clear any prior inline validation error + emit a turn event.
242
269
  useEffect(() => {
@@ -287,8 +314,10 @@ export const OnboardingFlow: React.FC<OnboardingFlowProps> = ({
287
314
  // Does NOT clear the attempt counter or the degraded flag — the caller decides that.
288
315
  const restartThread = useCallback(() => {
289
316
  setTimedOut(false);
290
- started.current = false;
291
317
  errored.current = false;
318
+ // Bumping the generation IN STATE is what actually re-runs the kickoff effect and re-arms the
319
+ // watchdog. Flipping a ref here would re-trigger neither.
320
+ setRunId((n) => n + 1);
292
321
  reset();
293
322
  }, [reset]);
294
323
 
@@ -114,7 +114,8 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
114
114
  // `activated` funnel rather than an error. Two real consumers shipped without it.
115
115
  //
116
116
  // A host-supplied key is also RECORDED on the process-wide provenance registry, so a sibling
117
- // surface can later ask "does this app own a device id?" — see the K9 warning below. The registry
117
+ // surface can later ask "does this app own a device id?" — see the third-id-space warning below.
118
+ // The registry
118
119
  // write is idempotent (same key, same value), so running it from a memo is safe under StrictMode's
119
120
  // double-invoke; it lives here rather than in an effect so the answer is already true for any
120
121
  // surface constructed later in the same tick.
@@ -143,7 +144,7 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
143
144
  // counter rather than leaving it empty, and it inflates distinct-device counts on top. Same
144
145
  // reason `useLifecycleEvents` gates its own fallback on storage.
145
146
  //
146
- // ⚠️ 0.13.0 (K1): the third condition used to be `Boolean(storage)` — the PRESENCE of the prop,
147
+ // ⚠️ 0.13.0: the third condition used to be `Boolean(storage)` — the PRESENCE of the prop,
147
148
  // never the SUCCESS of the write. A REJECTING adapter (a locked / full / permission-denied
148
149
  // AsyncStorage, the most common real breakage) therefore walked the per-launch key in through the
149
150
  // front door: `hydrateAutoDeviceKey` resolves to the in-memory mint on every failure branch, and a
@@ -153,8 +154,8 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
153
154
  //
154
155
  // It resolves through `hydrateDeviceIdentity`, never the sync `resolveAutoDeviceKey`: the sync
155
156
  // contract returns the freshly minted id and adopts the persisted one milliseconds later, which
156
- // is exactly the 0.12.1 H1 defect — the key stamped here must be the one the analytics side
157
- // stamps, not a fresh mint per launch. The value is therefore awaited BEHIND THE LOADER GATE
157
+ // is exactly the split-identity defect 0.12.1 shipped — the key stamped here must be the one the
158
+ // analytics side stamps, not a fresh mint per launch. It is therefore awaited BEHIND THE LOADER GATE
158
159
  // below (the same gate the persisted-session read already holds, and it is only ever open when
159
160
  // `storage` is present anyway), because `userContext` feeds the `llm` memo: swapping it after the
160
161
  // provider mounted would recreate the A2A adapter and drop the server-learned `contextId`.
@@ -201,7 +202,7 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
201
202
  [userContextStable, injectedJoinKey],
202
203
  );
203
204
 
204
- // THE JOIN DECISION IS FROZEN AT THE MOMENT THE GATE OPENS (K5), exactly as `startupUserIdRef`
205
+ // THE JOIN DECISION IS FROZEN AT THE MOMENT THE GATE OPENS, exactly as `startupUserIdRef`
205
206
  // below freezes the user id, and for the identical reason: `userContext` feeds the `llm` memo, and
206
207
  // wireai-rn recreates its A2A adapter whenever the `llm` identity changes — the adapter ctor resets
207
208
  // `contextId` (`this.contextId = void 0`), so the server-learned session id is dropped.
@@ -261,7 +262,7 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
261
262
  );
262
263
  }, [warnMissingJoinKey, autoJoinReason]);
263
264
 
264
- // THE SECOND CASE AUTO-JOIN WOULD OTHERWISE SILENCE (K9). 0.12.2 suppressed the missing-join-key
265
+ // THE SECOND CASE AUTO-JOIN WOULD OTHERWISE SILENCE. 0.12.2 suppressed the missing-join-key
265
266
  // warning whenever injection succeeded, which is right when the host genuinely owns no device id —
266
267
  // and wrong when it owns one and simply forgot it HERE. That host used to get a loud warning and
267
268
  // now gets a silent third id space: its app events under its own key, this onboarding session under
@@ -5,8 +5,8 @@
5
5
  * because the kit didn't own them — an awaitable action POST (`wireTrackActionAwait`), its OWN
6
6
  * per-open session id (`getWireSessionId`, minted because the kit's `getCurrentSessionId` desynced
7
7
  * cross-bundle — defect B), and an await-then-bump revalidation dance (`bumpActivationDecision`).
8
- * Defect B is fixed (PR #48: `getCurrentSessionId` is a `globalThis` singleton, reliable under
9
- * `dist`), so the kit can now own all three behind one thin surface:
8
+ * Defect B is fixed `getCurrentSessionId` is now a `globalThis` singleton, reliable under
9
+ * `dist` so the kit can own all three behind one thin surface:
10
10
  *
11
11
  * const wire = createWireActivation({ serverUrl, apiKey }); // or useWireActivation(...)
12
12
  * await wire.track("journal_done"); // awaitable POST + auto-revalidate
@@ -108,7 +108,7 @@ export const createWireActivation = (config: WireActivationConfig): WireActivati
108
108
  const explicitDeviceKey = clean(config.deviceKey) ?? clean(config.userContext?.deviceKey);
109
109
  // Record a HOST-supplied key on the process provenance registry, so a `<WireOnboarding>` mount that
110
110
  // was NOT given one can tell "this app owns no device id" (fine, inject) from "this app owns one
111
- // and forgot it here" (the silent third id space — K9). Recording only; nothing reads it on this path.
111
+ // and forgot it here" (the silent third id space). Recording only; nothing reads it on this path.
112
112
  resolveIdentity({
113
113
  value: explicitDeviceKey,
114
114
  space: "device",
@@ -198,7 +198,7 @@ export const createAnalytics = (
198
198
  : undefined;
199
199
  // Record a HOST-supplied key on the process provenance registry, so a `<WireOnboarding>` mount that
200
200
  // was NOT given one can tell "this app owns no device id" (fine, inject) from "this app owns one
201
- // and forgot it here" (the silent third id space — K9). Recording only; nothing reads it here.
201
+ // and forgot it here" (the silent third id space). Recording only; nothing reads it here.
202
202
  resolveIdentity({
203
203
  value: hostDeviceKeyAtInit,
204
204
  space: "device",
@@ -333,7 +333,7 @@ export const createAnalytics = (
333
333
 
334
334
  const screen = (name: string, props?: AnalyticsProps): void => {
335
335
  if (!name) return;
336
- // Reuse the K6 screen event shape (question_key='screen'); route through the queue for offline-first.
336
+ // Reuse the existing screen event shape (question_key='screen'); route through the queue for offline-first.
337
337
  const meta = { screen: name, ...(props ?? {}) };
338
338
  const clientEvent: ClientEvent = {
339
339
  event_type: "app_event",
@@ -86,7 +86,7 @@ const DEFAULTS = {
86
86
  /** Ceiling on the persisted-backlog read — a hung adapter degrades to an empty start, never a stall. */
87
87
  const READ_TIMEOUT_MS = 1500;
88
88
 
89
- // ── One storage slot per QUEUE, not per appId (K4) ───────────────────────────────────────────────
89
+ // ── One storage slot per QUEUE, not per appId ────────────────────────────────────────────────────
90
90
  //
91
91
  // The default key was derived from `appId` alone, so two `createAnalytics` instances for one tenant —
92
92
  // the documented double-wiring, a façade for `track`/`screen` plus an activation instance — shared ONE
@@ -153,10 +153,19 @@ type QueuedItem = { id: number; event: ClientEvent; sig: string };
153
153
  /** Persisted shape — the local id + the (already envelope-stamped) event. `sig` is recomputed on load. */
154
154
  type PersistedItem = { id: number; event: ClientEvent };
155
155
 
156
- const withTimeout = <T>(p: Promise<T>, ms: number): Promise<T | undefined> => {
156
+ /**
157
+ * The verdict of a read that ran out of time. A DISTINCT value, and never `undefined` — an
158
+ * `undefined` here is byte-identical to "the adapter answered, there is no backlog", and that
159
+ * conflation destroys data silently: `parsePersisted(undefined)` gives `[]`, hydration takes its
160
+ * `length === 0` early return without throwing, and the next `persist()` writes the in-memory
161
+ * pending list over a blob nobody has read — or, with nothing pending, `removeItem`s it outright.
162
+ */
163
+ const READ_TIMED_OUT: unique symbol = Symbol("wireai:storage-read-timeout");
164
+
165
+ const withTimeout = <T>(p: Promise<T>, ms: number): Promise<T | typeof READ_TIMED_OUT> => {
157
166
  let timer: ReturnType<typeof setTimeout>;
158
- const timeout = new Promise<undefined>((resolve) => {
159
- timer = setTimeout(() => resolve(undefined), ms);
167
+ const timeout = new Promise<typeof READ_TIMED_OUT>((resolve) => {
168
+ timer = setTimeout(() => resolve(READ_TIMED_OUT), ms);
160
169
  });
161
170
  return Promise.race([p, timeout]).finally(() => clearTimeout(timer));
162
171
  };
@@ -198,7 +207,7 @@ const parsePersisted = (raw: string | null | undefined): PersistedItem[] => {
198
207
  export const createEventQueue = (options: EventQueueOptions): EventQueue => {
199
208
  const target = options.target;
200
209
  const storage = options.storage;
201
- // One slot per QUEUE (K4): an explicit key is taken verbatim; the appId-derived default is rotated
210
+ // One slot per QUEUE: an explicit key is taken verbatim; the appId-derived default is rotated
202
211
  // to `…#2` when a live queue already holds it, so two instances can never share one backlog.
203
212
  //
204
213
  // Only claimed when there IS storage. The defect is entirely about the persisted slot, and a
@@ -217,6 +226,13 @@ export const createEventQueue = (options: EventQueueOptions): EventQueue => {
217
226
  let flushing = false;
218
227
  let attempt = 0;
219
228
  let retryTimer: ReturnType<typeof setTimeout> | undefined;
229
+ // The persisted blob exists but has NOT been read yet (the cold-start read blew its deadline and
230
+ // is still in flight). While this is true every write is suppressed: the only thing the queue
231
+ // could write is a view of `pending` that does not include the backlog it has not seen, and
232
+ // `setItem`/`removeItem` would destroy it. Cleared as soon as the slow read settles, whichever
233
+ // way it settles, so a read that ultimately fails resumes normal persistence rather than
234
+ // suppressing it for the life of the process.
235
+ let backlogUnread = false;
220
236
 
221
237
  const resolveEnvelope = (): ContextEnvelope | undefined => {
222
238
  try {
@@ -254,6 +270,8 @@ export const createEventQueue = (options: EventQueueOptions): EventQueue => {
254
270
 
255
271
  const persist = (): void => {
256
272
  if (!storage) return;
273
+ // NEVER write over a blob that has not been read yet. See `backlogUnread`.
274
+ if (backlogUnread) return;
257
275
  try {
258
276
  if (pending.length === 0) {
259
277
  void storage.removeItem(key).catch(() => {});
@@ -280,25 +298,70 @@ export const createEventQueue = (options: EventQueueOptions): EventQueue => {
280
298
  }
281
299
  };
282
300
 
283
- // Load any persisted backlog. Anything enqueued before this settles stays in memory; we merge
284
- // persisted (older) ahead of it and reassign monotonic ids so dequeue-after-ack is deterministic.
301
+ /**
302
+ * Merge a loaded backlog into the in-memory buffer: persisted (older) events go AHEAD of
303
+ * whatever was enqueued while the read was in flight, duplicates collapse, and the size cap
304
+ * applies as usual.
305
+ *
306
+ * Ids are minted FRESH from the running `nextId` rather than reset to 0. A drain may already be
307
+ * in flight holding a batch of ids it will filter out on ack, and re-numbering from 0 would make
308
+ * those ids point at different events — the ack would then dequeue (silently drop) whichever
309
+ * events happened to inherit them. Monotonic ids are never reused, so an in-flight ack stays
310
+ * correct whatever lands in between.
311
+ */
312
+ const mergePersisted = (persistedItems: PersistedItem[]): void => {
313
+ if (persistedItems.length === 0) return;
314
+ const seen = new Set(pending.map((item) => item.sig));
315
+ const restored: QueuedItem[] = [];
316
+ for (const persisted of persistedItems) {
317
+ const sig = safeSig(persisted.event);
318
+ if (seen.has(sig)) continue; // already in memory — collapse the duplicate
319
+ seen.add(sig);
320
+ restored.push({ id: nextId++, event: persisted.event, sig });
321
+ }
322
+ if (restored.length === 0) return;
323
+ pending = [...restored, ...pending];
324
+ enforceSizeCap();
325
+ persist();
326
+ };
327
+
328
+ // Load any persisted backlog. Anything enqueued before this settles stays in memory; the merge
329
+ // above puts persisted (older) events ahead of it.
330
+ //
331
+ // A read that blows READ_TIMEOUT_MS is NOT treated as "no backlog". The race only unblocks the
332
+ // DRAIN — the original read is kept and merged whenever it lands, and until then every write is
333
+ // suppressed so the unread blob survives intact.
285
334
  const loadPromise: Promise<void> = (async () => {
286
335
  if (!storage) return;
287
336
  try {
288
- const persistedItems = parsePersisted(await withTimeout(storage.getItem(key), READ_TIMEOUT_MS));
289
- if (persistedItems.length === 0) return;
290
- const events = [...persistedItems.map((p) => p.event), ...pending.map((p) => p.event)];
291
- pending = [];
292
- nextId = 0;
293
- const seen = new Set<string>();
294
- for (const event of events) {
295
- const sig = safeSig(event);
296
- if (seen.has(sig)) continue; // collapse duplicates carried across the merge
297
- seen.add(sig);
298
- pending.push({ id: nextId++, event, sig });
337
+ const read = Promise.resolve(storage.getItem(key));
338
+ // A rejecting read must not surface as an unhandled rejection when the race is won by the
339
+ // timeout; the recovery path below re-attaches its own handlers.
340
+ read.catch(() => {});
341
+ const raced = await withTimeout(read, READ_TIMEOUT_MS);
342
+ if (raced === READ_TIMED_OUT) {
343
+ backlogUnread = true;
344
+ warnInDev(
345
+ `[wireai] the persisted analytics backlog at "${key}" took longer than ${READ_TIMEOUT_MS}ms ` +
346
+ "to read, so the queue started without it. The stored events are NOT discarded: writes " +
347
+ "are held back until the read lands, and the backlog is merged in then. If you see this " +
348
+ "on every cold start, your storage adapter is too slow to be on the launch path.",
349
+ );
350
+ void read
351
+ .then((late) => {
352
+ backlogUnread = false;
353
+ mergePersisted(parsePersisted(late));
354
+ // Send whatever was just recovered; without this it would wait for the next enqueue.
355
+ flush();
356
+ })
357
+ .catch(() => {
358
+ // The slow read ultimately failed → nothing to preserve, resume normal persistence.
359
+ backlogUnread = false;
360
+ persist();
361
+ });
362
+ return;
299
363
  }
300
- enforceSizeCap();
301
- persist();
364
+ mergePersisted(parsePersisted(raced));
302
365
  } catch {
303
366
  // Unreadable backlog → start empty; nothing enqueued in-memory is lost.
304
367
  }
@@ -104,7 +104,7 @@ const _OptionCard: React.FC<OptionCardProps> = ({ opt, isSelected, multiSelect,
104
104
  const handlePressOut = useCallback(() => pressTo(1), [pressTo]);
105
105
 
106
106
  // Selected fill: accent border + tinted wash on an absolute overlay whose opacity crossfades
107
- // (native driver see EXTRACTED-SPEC.md D7). Reduce motion: snap.
107
+ // (native driver, never a color interpolation). Reduce motion: snap.
108
108
  const selectT = useRef(new Animated.Value(isSelected ? 1 : 0)).current;
109
109
  useEffect(() => {
110
110
  const anim = Animated.timing(selectT, {
@@ -80,7 +80,7 @@ const _ChipItem: React.FC<ChipItemProps> = ({ chip, isSelected, multiSelect, onT
80
80
  const handlePressOut = useCallback(() => pressTo(1), [pressTo]);
81
81
 
82
82
  // Selected fill: full accent pill, crossfaded in over 160ms on a native-driver
83
- // opacity overlay (EXTRACTED-SPEC.md D7). Reduce motion: instant swap.
83
+ // opacity overlay, never a color interpolation. Reduce motion: instant swap.
84
84
  const fillT = useRef(new Animated.Value(isSelected ? 1 : 0)).current;
85
85
  useEffect(() => {
86
86
  const anim = Animated.timing(fillT, {
@@ -94,7 +94,7 @@ const _ChipItem: React.FC<ChipItemProps> = ({ chip, isSelected, multiSelect, onT
94
94
  }, [isSelected, reduced, fillT]);
95
95
 
96
96
  // Check pop on select (the width-slide of the canvas is a layout animation the
97
- // native driver can't express see EXTRACTED-SPEC.md D4).
97
+ // native driver can't express, so it stays on the JS driver deliberately).
98
98
  const checkT = useRef(new Animated.Value(isSelected ? 1 : 0)).current;
99
99
  useEffect(() => {
100
100
  if (!isSelected) {
@@ -80,7 +80,7 @@ const _OptionRow: React.FC<OptionRowProps> = ({ opt, isSelected, multiSelect, on
80
80
 
81
81
  // Selected fill: the selected border+bg live on an absolute overlay whose opacity
82
82
  // crossfades in 180ms ease-wire — same visual as a color interpolation, but it
83
- // stays on the native driver (see EXTRACTED-SPEC.md D7). Reduce motion: snap.
83
+ // stays on the native driver. Reduce motion: snap.
84
84
  const selectT = useRef(new Animated.Value(isSelected ? 1 : 0)).current;
85
85
  useEffect(() => {
86
86
  const anim = Animated.timing(selectT, {
@@ -39,6 +39,10 @@ type CoachmarkRuntime = {
39
39
  // resolved flags. Disabled → the tour never ARMS and the overlay `show()` is a no-op, so nothing
40
40
  // paints and — critically — no once-gate is written, so re-enabling replays the tour correctly.
41
41
  coachmarksEnabled: boolean;
42
+ // Subscribers to the kill switch. OPTIONAL on the type (never on the behaviour) so a record left
43
+ // in the registry by an inlined copy from an OLDER kit version — which had no listener set — is
44
+ // upgraded in place rather than read as corrupt.
45
+ listeners?: Set<() => void>;
42
46
  };
43
47
 
44
48
  type GlobalWithCoachmarkRuntime = typeof globalThis & {
@@ -55,17 +59,73 @@ const coachmarkRuntime = (): CoachmarkRuntime => {
55
59
  return created;
56
60
  };
57
61
 
62
+ const enabledListeners = (): Set<() => void> => {
63
+ const runtime = coachmarkRuntime();
64
+ if (!runtime.listeners) runtime.listeners = new Set();
65
+ return runtime.listeners;
66
+ };
67
+
68
+ /**
69
+ * Wake the subscribers on a MICROTASK, never synchronously.
70
+ *
71
+ * `CoachmarkProvider` writes this flag during its RENDER phase on purpose (so a child's mount
72
+ * effect already sees the resolved value). Notifying from there synchronously would schedule a
73
+ * React update while a *different* component is rendering. Every subscriber reads the value
74
+ * through `areCoachmarksEnabled()` at render time anyway, so the notification only has to wake
75
+ * the components that are not already re-rendering — one microtask later is soon enough, and it
76
+ * keeps the write side legal from anywhere.
77
+ */
78
+ const notifyCoachmarksEnabled = (): void => {
79
+ if (enabledListeners().size === 0) return;
80
+ void Promise.resolve().then(() => {
81
+ for (const listener of [...enabledListeners()]) {
82
+ try {
83
+ listener();
84
+ } catch {
85
+ // A broken subscriber must never break the kill switch for the others.
86
+ }
87
+ }
88
+ });
89
+ };
90
+
58
91
  /**
59
92
  * Set the coachmarks master switch (from the resolved feature flags). Default true (fail-open).
60
- * Written by CoachmarkProvider; read imperatively by the tour arm + the overlay store.
93
+ * Written by CoachmarkProvider; read imperatively by the tour arm + the overlay store, and
94
+ * SUBSCRIBED to by `useCoachmarkTour` (see `subscribeCoachmarksEnabled`).
95
+ *
96
+ * No-ops when the value is unchanged, which matters because the provider writes it on every
97
+ * render — without that guard every render would fan out a notification.
61
98
  */
62
99
  export const setCoachmarksEnabled = (value: boolean): void => {
63
- coachmarkRuntime().coachmarksEnabled = value;
100
+ const runtime = coachmarkRuntime();
101
+ if (runtime.coachmarksEnabled === value) return;
102
+ runtime.coachmarksEnabled = value;
103
+ notifyCoachmarksEnabled();
64
104
  };
65
105
 
66
106
  /** Whether the coachmarks module is enabled. False → tours/overlays are silently skipped. */
67
107
  export const areCoachmarksEnabled = (): boolean => coachmarkRuntime().coachmarksEnabled;
68
108
 
109
+ /**
110
+ * Subscribe to kill-switch flips. Returns the unsubscribe function — the `useSyncExternalStore`
111
+ * contract, and deliberately the SAME shape as `coachmarkOverlay.subscribe` next door rather than
112
+ * a second pattern to keep correct.
113
+ *
114
+ * WHY IT EXISTS: the flag is a mutable `globalThis` value, so on its own it can only be SAMPLED.
115
+ * A tour that samples it cannot notice a tenant flipping it off mid-flight — the overlay's
116
+ * `show()` quietly becomes a no-op while the tour still believes it is running, so nothing paints,
117
+ * nothing can be tapped to advance, and no terminal path is reachable. `useCoachmarkTour`
118
+ * subscribes instead. The listener set lives in the SAME `globalThis` record as the flag, so every
119
+ * inlined copy of this module shares one subscriber list.
120
+ */
121
+ export const subscribeCoachmarksEnabled = (listener: () => void): (() => void) => {
122
+ const listeners = enabledListeners();
123
+ listeners.add(listener);
124
+ return () => {
125
+ listeners.delete(listener);
126
+ };
127
+ };
128
+
69
129
  /** Set (or clear) the injected sync gate storage. Called by CoachmarkProvider. */
70
130
  export const setCoachmarkStorage = (storage: CoachmarkStorage | null): void => {
71
131
  coachmarkRuntime().storage = storage;