@wireai/activation 0.15.0 → 0.16.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 (67) hide show
  1. package/AGENTS.md +95 -20
  2. package/CHANGELOG.md +707 -0
  3. package/INTEGRATION_PROMPT.md +61 -23
  4. package/README.md +100 -25
  5. package/dist/analytics/index.d.mts +32 -10
  6. package/dist/analytics/index.d.ts +32 -10
  7. package/dist/analytics/index.js +288 -127
  8. package/dist/analytics/index.js.map +1 -1
  9. package/dist/analytics/index.mjs +288 -127
  10. package/dist/analytics/index.mjs.map +1 -1
  11. package/dist/coachmarks/index.d.mts +14 -0
  12. package/dist/coachmarks/index.d.ts +14 -0
  13. package/dist/coachmarks/index.js +73 -20
  14. package/dist/coachmarks/index.js.map +1 -1
  15. package/dist/coachmarks/index.mjs +73 -20
  16. package/dist/coachmarks/index.mjs.map +1 -1
  17. package/dist/{currentSession-orZy5p1e.d.mts → currentSession-Bz7G6lno.d.mts} +25 -35
  18. package/dist/{currentSession-CFSRZ2wg.d.ts → currentSession-z-CZ55ad.d.ts} +25 -35
  19. package/dist/index.d.mts +5 -2
  20. package/dist/index.d.ts +5 -2
  21. package/dist/index.js +125 -36
  22. package/dist/index.js.map +1 -1
  23. package/dist/index.mjs +125 -36
  24. package/dist/index.mjs.map +1 -1
  25. package/dist/questionnaire/index.d.mts +0 -13
  26. package/dist/questionnaire/index.d.ts +0 -13
  27. package/dist/questionnaire/index.js +154 -43
  28. package/dist/questionnaire/index.js.map +1 -1
  29. package/dist/questionnaire/index.mjs +155 -44
  30. package/dist/questionnaire/index.mjs.map +1 -1
  31. package/dist/reviews/index.js +159 -91
  32. package/dist/reviews/index.js.map +1 -1
  33. package/dist/reviews/index.mjs +160 -92
  34. package/dist/reviews/index.mjs.map +1 -1
  35. package/dist/showcase/index.js +59 -18
  36. package/dist/showcase/index.js.map +1 -1
  37. package/dist/showcase/index.mjs +60 -19
  38. package/dist/showcase/index.mjs.map +1 -1
  39. package/llms.txt +9 -9
  40. package/package.json +6 -9
  41. package/src/analytics/currentSession.ts +141 -4
  42. package/src/analytics/index.ts +6 -1
  43. package/src/analytics/reportClientEvent.ts +19 -10
  44. package/src/analytics/useAnalytics.ts +74 -15
  45. package/src/analytics/wireDoctor.ts +152 -7
  46. package/src/coachmarks/CoachmarkProvider.tsx +26 -5
  47. package/src/coachmarks/runtime.ts +53 -0
  48. package/src/coachmarks/useCoachmarkTour.ts +51 -1
  49. package/src/context/deviceId.ts +49 -15
  50. package/src/features/WireFeaturesProvider.tsx +72 -12
  51. package/src/features/fetchWireFeatures.ts +49 -11
  52. package/src/features/useWireFeatures.ts +39 -3
  53. package/src/identity/identityRecord.ts +15 -2
  54. package/src/questionnaire/QuestionnaireGate.tsx +40 -1
  55. package/src/questionnaire/transport.ts +22 -8
  56. package/src/questionnaire/useQuestionnaireGate.ts +58 -7
  57. package/src/reviews/ReviewGate.tsx +39 -0
  58. package/src/reviews/idempotency.ts +38 -0
  59. package/src/reviews/runtime.ts +39 -10
  60. package/src/reviews/transport.ts +22 -8
  61. package/src/reviews/useReviewGate.ts +57 -7
  62. package/src/session-analytics/lifecycle.ts +16 -0
  63. package/src/session-analytics/useLifecycleEvents.ts +30 -2
  64. package/src/session-analytics/useSessionStart.ts +22 -2
  65. package/src/showcase/FeatureShowcase.tsx +50 -3
  66. package/src/types.ts +5 -4
  67. package/src/utils/withDeadline.ts +70 -0
@@ -64,6 +64,44 @@ export interface ReviewImpression {
64
64
  /**
65
65
  * Open one impression. Both fields are resolved EAGERLY here rather than read per post, which is the
66
66
  * entire point: a lazily re-read key or identity is a per-post value wearing an impression's name.
67
+ *
68
+ * ── WHY A NON-DURABLE UNIT IS DELIBERATELY ACCEPTED HERE (ruled 0.15.1) ──────────────────────
69
+ *
70
+ * The kit's standing identity rule is that a caller writing a cross-launch join key must read
71
+ * `IdentityRecord.durable` and REFUSE a `false` — see `context/deviceId.ts`. That rule does not
72
+ * transfer to this field, and an audit that ports it here makes the data WORSE, so the reasoning is
73
+ * recorded rather than left to be rediscovered:
74
+ *
75
+ * • WHAT THE RULE PROTECTS elsewhere is a COUNTER. `app.session_started` is counted by distinct
76
+ * opens grouped on `device_key`, so a per-launch key there makes `min_sessions` structurally
77
+ * incapable of exceeding 1 — the key actively corrupts the metric. A review row is written once
78
+ * per user, so a per-launch value here cannot corrupt a count the same way.
79
+ * • WHAT REFUSING WOULD COST is the feature itself. With no unit the server's
80
+ * `_idempotency_unit` returns nothing, it DISCARDS the key and inserts a plain row — so the
81
+ * `unsent` re-post and the abandonment net each write a SECOND review row, double-counting
82
+ * `count` and corrupting `avg`. That is the exact corruption the key was added to prevent, and
83
+ * it is a corruption traded for an under-count, which is the wrong direction.
84
+ * • THE SCOPE THE UNIT ACTUALLY NEEDS is one impression inside one launch, and that is precisely
85
+ * what a pinned in-memory value guarantees. Durability buys nothing the server ever reads here.
86
+ * • THE ORPHANED-JOIN WORRY (a review row stamped with a key no analytics event carries) is real
87
+ * and is NOT closed by any of the above — it is an ID-SPACE problem, not a durability one, and
88
+ * it survives here. Two sequences still produce it, both of them ordinary:
89
+ * (1) the impression opens BEFORE any surface has registered a real `appId`, so it pins the
90
+ * ambient fallback while every later analytics event carries the host's id. This is the
91
+ * same cold-start ordering the ambient reader was fixed for, and the test that covers
92
+ * that fix calls it the sequence rather than an edge case.
93
+ * (2) the impression opens while a hydration is IN FLIGHT, so it pins the synchronous mint
94
+ * and the persisted id replaces it in the registry a moment later.
95
+ * What WAS closed is a different and larger failure: the ambient reader used to answer
96
+ * `undefined` permanently once the real `appId` registered, which cost every later impression
97
+ * its unit outright. The orphan that remains is bounded to impressions opened before the
98
+ * process's id space settles, and the gate is once-per-user, so in practice it is at most the
99
+ * first row.
100
+ * • AND THE ORPHAN IS NOT AN ARGUMENT FOR REFUSING, which is the trap. Dropping the unit does not
101
+ * repair the join — it removes it AND removes the server's ability to dedupe, so an orphaned
102
+ * row that is at least idempotent becomes a row that is neither. Closing it properly means
103
+ * giving the gate a settled identity to pin, which is a public-API change (`appId` / `storage`
104
+ * props on `ReviewGate`) and is deliberately not attempted here.
67
105
  */
68
106
  export const createImpression = (): ReviewImpression => ({
69
107
  idempotencyKey: mintIdempotencyKey(),
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import type { CoachmarkStorage } from "../coachmarks/types";
8
8
  import { getCoachmarkStorage, validateGateStorage } from "../coachmarks/runtime";
9
- import { getCurrentSessionId } from "../analytics/currentSession";
9
+ import { getCurrentSessionId, isMintedSessionId } from "../analytics/currentSession";
10
10
  import { makeSessionId } from "../analytics/reportClientEvent";
11
11
 
12
12
  /** Once-gate key. Keyed by app version when `oncePerVersion` is on, so a new release re-enables. */
@@ -156,12 +156,26 @@ const readStr = (storage: CoachmarkStorage | null, key: string): string | undefi
156
156
  // that was live when the pin was taken, and read it lazily.
157
157
  //
158
158
  // • pin unset → pin = the registered id ?? a freshly minted one, and remember which
159
- // registered id that was (`undefined` when we had to mint).
160
- // remembered `undefined` the FIRST registration merely NAMES the open the gate already
161
- // counted under a minted id. Adopt it, leave the PIN alone. This is
159
+ // registered id that was (`undefined` when we had to mint) and whether
160
+ // it was itself a MINTED fallback.
161
+ // remembered `undefined`
162
+ // or remembered MINTED → the FIRST REAL registration merely NAMES the open the gate already
163
+ // counted under a fallback id. Adopt it, leave the PIN alone. This is
162
164
  // the cold-start case, and it stays shut.
163
165
  // • live id ≠ remembered → a genuinely new app-open. Re-pin to it; the counter bumps once.
164
166
  //
167
+ // ── A MINTED ID IS NOT AN APP-OPEN (the second half of the same rule) ────────────────────────
168
+ // The bullet above says "remembered MINTED" because the registry has TWO writers, not one.
169
+ // `reportSessionStart` registers a real open — but `ensureCurrentSessionId()` also WRITES the shared
170
+ // slot, with a fallback id it minted so a fire-and-forget event is not dropped for want of a
171
+ // `session_id`, and it is reached from `analyticsFacade.resolveSessionId`, `wireActivation.track`
172
+ // and `reviews/transport.reportAppEvent`. A host can hit any of those before `useLifecycleEvents`
173
+ // mounts. That made the pin's `observed` a minted id rather than `undefined`, so the cold-start
174
+ // branch never fired and the real `app.session_started` that followed fell through to the last
175
+ // branch as "a genuinely new app-open": ONE launch counted as TWO, and `minSessions: 2` became
176
+ // satisfiable inside a user's FIRST session — failing in the UNSAFE direction. `isMintedSessionId`
177
+ // is the seam that tells the two writers apart; a fallback is not evidence the app was opened.
178
+ //
165
179
  // ⚠️ ONE ACCEPTED MISS, deliberately in the safe direction: if the mount open never registers (a
166
180
  // refused non-durable device key over a broken store) and a LATER foreground is the first
167
181
  // registration the launch ever sees, that registration is ADOPTED rather than counted, costing one
@@ -184,6 +198,12 @@ type OpenPin = {
184
198
  openId: string;
185
199
  /** The registered session id observed at pin time; `undefined` when `openId` was minted. */
186
200
  observed: string | undefined;
201
+ /**
202
+ * True when `observed` was a FALLBACK id `ensureCurrentSessionId` minted, not a real app-open.
203
+ * Such a pin has to behave exactly like `observed === undefined`: nothing has named this open yet,
204
+ * so the first REAL registration adopts it rather than starting a second one.
205
+ */
206
+ observedMinted: boolean;
187
207
  };
188
208
 
189
209
  type GlobalWithOpenId = typeof globalThis & { [PROCESS_OPEN_ID_SLOT]?: OpenPin };
@@ -198,28 +218,37 @@ const openIdGlobal = globalThis as GlobalWithOpenId;
198
218
  */
199
219
  export const currentOpenId = (): string => {
200
220
  const live = getCurrentSessionId();
221
+ // A FALLBACK id is not an app-open (see the note above `OpenPin.observedMinted`), so it is
222
+ // recorded as such and never treated as a registration this pin could be measured against.
223
+ const liveMinted = isMintedSessionId(live);
201
224
  const pin = openIdGlobal[PROCESS_OPEN_ID_SLOT];
202
225
 
203
226
  // First read of the launch. Adopt the registered id when the host wired lifecycle BEFORE any gate
204
227
  // rendered — that is the same unit the server counts — otherwise mint one and record that we did.
205
228
  if (!pin) {
206
229
  const openId = live ?? makeSessionId();
207
- openIdGlobal[PROCESS_OPEN_ID_SLOT] = { openId, observed: live };
230
+ openIdGlobal[PROCESS_OPEN_ID_SLOT] = { openId, observed: live, observedMinted: liveMinted };
208
231
  return openId;
209
232
  }
210
233
 
211
234
  // Nothing registered yet, or still the same registered open: the pin stands.
212
235
  if (!live || live === pin.observed) return pin.openId;
213
236
 
214
- // The first registration of the launch NAMES the open we already pinned under a minted id, so it
215
- // is adopted, not counted. Re-pinning here is exactly the cold-start double-count described above.
216
- if (pin.observed === undefined) {
217
- openIdGlobal[PROCESS_OPEN_ID_SLOT] = { openId: pin.openId, observed: live };
237
+ // The first REAL registration of the launch NAMES the open we already pinned whether we pinned
238
+ // under an id this function minted (`observed === undefined`) or under one `ensureCurrentSessionId`
239
+ // minted for an early event (`observedMinted`). Either way nothing had named the open yet, so it is
240
+ // adopted, not counted. Re-pinning here is exactly the cold-start double-count described above.
241
+ if (pin.observed === undefined || pin.observedMinted) {
242
+ openIdGlobal[PROCESS_OPEN_ID_SLOT] = {
243
+ openId: pin.openId,
244
+ observed: live,
245
+ observedMinted: liveMinted,
246
+ };
218
247
  return pin.openId;
219
248
  }
220
249
 
221
250
  // A second, DIFFERENT registration is a genuinely new app-open.
222
- openIdGlobal[PROCESS_OPEN_ID_SLOT] = { openId: live, observed: live };
251
+ openIdGlobal[PROCESS_OPEN_ID_SLOT] = { openId: live, observed: live, observedMinted: liveMinted };
223
252
  return live;
224
253
  };
225
254
 
@@ -16,6 +16,7 @@
16
16
  */
17
17
  import { ensureCurrentSessionId } from "../analytics/currentSession";
18
18
  import { buildEventsRequest, type ClientEvent } from "../analytics/reportClientEvent";
19
+ import { DEADLINE_EXPIRED, withDeadline } from "../utils/withDeadline";
19
20
  import type { SubmitResult } from "../utils/submitResult";
20
21
  import type { ReviewDecisionResponse, ReviewSubmission, ReviewTarget } from "./types";
21
22
 
@@ -77,11 +78,19 @@ export const submitReview = async (
77
78
  const url = `${target.serverUrl.replace(/\/$/, "")}/v1/reviews`;
78
79
  const headers: Record<string, string> = { "Content-Type": "application/json" };
79
80
  if (target.apiKey) headers.Authorization = `Bearer ${target.apiKey}`;
80
- const res = await fetch(url, {
81
- method: "POST",
82
- headers,
83
- body: JSON.stringify(review),
84
- });
81
+ // Under a deadline (see `utils/withDeadline`): a hung POST used to hold `ReviewGate`'s
82
+ // `postOnce` latch closed for the ~60s platform default, which is exactly the window the
83
+ // unmount recovery net cannot re-post inside.
84
+ const res = await withDeadline((signal) =>
85
+ fetch(url, {
86
+ method: "POST",
87
+ headers,
88
+ body: JSON.stringify(review),
89
+ signal,
90
+ }),
91
+ );
92
+ // A deadline that expired is not a rejection: nothing reached the server, so it stays re-postable.
93
+ if (res === DEADLINE_EXPIRED) return "unsent";
85
94
  // No response object at all is not an answer — treat it as nothing having reached the server
86
95
  // rather than as a rejection, or a stubbed-out `fetch` would silently latch the row away.
87
96
  if (!res) return "unsent";
@@ -193,9 +202,14 @@ export const fetchReviewDecision = async (
193
202
  const url = `${base}/v1/reviews/decision${qs ? `?${qs}` : ""}`;
194
203
  const headers: Record<string, string> = {};
195
204
  if (target.apiKey) headers.Authorization = `Bearer ${target.apiKey}`;
196
- const res = await fetch(url, { headers });
197
- if (!res || !res.ok) return null;
198
- const json = (await res.json()) as ReviewDecisionResponse | null;
205
+ // The BODY read is inside the deadline too: a ceiling cleared when the headers land is not a
206
+ // ceiling (the `fetchWireFeatures` lesson see `utils/withDeadline`).
207
+ const json = await withDeadline(async (signal): Promise<ReviewDecisionResponse | null> => {
208
+ const res = await fetch(url, { headers, signal });
209
+ if (!res || !res.ok) return null;
210
+ return (await res.json()) as ReviewDecisionResponse | null;
211
+ });
212
+ if (json === DEADLINE_EXPIRED) return null;
199
213
  // A body without a boolean `fire` is not a decision. Guard it explicitly rather than
200
214
  // letting `{}` through as a truthy object that `decideReview` would treat as a verdict
201
215
  // (`{}.fire === undefined` is falsy, so it would silently read as "never fire").
@@ -12,10 +12,11 @@
12
12
  * (`wire_review_<id>_seen`) always applies locally, so a server bug can never spam the prompt.
13
13
  * The global `isTestingCoachmark` flag force-shows the gate for QA replay.
14
14
  */
15
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
15
+ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
16
16
 
17
+ import { getCurrentSessionId, subscribeCurrentSessionId } from "../analytics/currentSession";
17
18
  import { hasSeenGate, isCoachmarkTesting, markSeenGate } from "../coachmarks/runtime";
18
- import { useResolvedFeatures } from "../features/WireFeaturesProvider";
19
+ import { useResolvedFeaturesState } from "../features/WireFeaturesProvider";
19
20
  import { decideReview, evaluateGate, resolveRules } from "./decision";
20
21
  import { sameDecision, shallowEqual } from "./equality";
21
22
  import {
@@ -67,7 +68,19 @@ export const useReviewGate = ({
67
68
 
68
69
  // The review MASTER switch. Disabled → the gate never fires, composing as an extra AND over
69
70
  // the local rules + the server decision seam below (fail-open: defaults to enabled).
70
- const featureEnabled = useResolvedFeatures({ flags: features, config: featuresConfig }).review.enabled;
71
+ //
72
+ // `settled` is read too, and it is the half that makes the switch real. The flags start on the
73
+ // all-on defaults and swap only when the fetch resolves, so `featureEnabled` is `true` on the
74
+ // FIRST render of every mount — including a tenant with `review.enabled: false`. With no
75
+ // `timeoutFallbackMs` the local wait is skipped as well (`elapsed` starts `true`), so the gate
76
+ // could become visible, fire `review_prompt_shown`, and take a POSTed row from a user the tenant
77
+ // had switched off. The comment below ("must never see the gate") was falsified by its own async
78
+ // default. Holding the FIRST impression until an answer exists is what honours the switch.
79
+ const { flags: resolvedFeatures, settled: featuresSettled } = useResolvedFeaturesState({
80
+ flags: features,
81
+ config: featuresConfig,
82
+ });
83
+ const featureEnabled = resolvedFeatures.review.enabled;
71
84
 
72
85
  const testing = isTesting ?? isCoachmarkTesting();
73
86
  const seenKey = reviewSeenKey(config.id, config.oncePerVersion === false ? undefined : config.appVersion);
@@ -75,19 +88,51 @@ export const useReviewGate = ({
75
88
  const sessionsKey = reviewSessionsKey(config.id);
76
89
  const sessionOpenKey = reviewSessionOpenKey(config.id);
77
90
 
91
+ // ── FOLLOW real app-opens, do not sample one ────────────────────────────────────────────────
92
+ //
93
+ // `bumpSessionCount` is IDEMPOTENT per app-open and moves once per open — but it used to be reached
94
+ // ONLY from the `useState` initializer below, which runs once per component INSTANCE. On a screen
95
+ // that never unmounts (a home feed, a tab that stays alive) that meant once per PROCESS, and iOS
96
+ // suspends rather than kills: `useLifecycleEvents` fires a fresh `app.session_started` on every
97
+ // foreground past its threshold and the SERVER's `min_sessions` advances, while `wire_review_<id>_sessions`
98
+ // stayed at its launch value and the LOCAL `minSessions` rule was unsatisfiable for the life of the
99
+ // app. Silent, and in the safe direction (a prompt that never shows), which is why it survived —
100
+ // `sessionCountAcrossOpens.test.ts` proved the FUNCTION advances and could not see that the HOOK
101
+ // never asked it again.
102
+ //
103
+ // So subscribe to the registry that knows. A notification naming the same open is a no-op by
104
+ // construction (`bumpSessionCount` reads its stored count back), so the cold-start ordering this
105
+ // pin exists for is untouched.
106
+ const openSessionId = useSyncExternalStore(
107
+ subscribeCurrentSessionId,
108
+ getCurrentSessionId,
109
+ getCurrentSessionId,
110
+ );
111
+ // Read through a ref: a host that passes `storage={{ … }}` inline (idiomatic React, and what
112
+ // `frequent_rules` #11 requires this hook to tolerate) mints a fresh identity every render, and
113
+ // listing it in the deps below would re-run the effect on every one of them.
114
+ const storageRef = useRef(storage);
115
+ storageRef.current = storage;
116
+
78
117
  // Read (and bump) the app-open counter. IDEMPOTENT per app-open, NOT per mount: `bumpSessionCount`
79
118
  // keys off the live per-open session id (or a per-process id when no host wired the lifecycle
80
119
  // events), so a remount, a navigation return, or React StrictMode's dev double-invoke of this
81
120
  // initializer all read the same number back instead of inflating it. Before this, `sessions`
82
121
  // counted mounts, so the fail-closed `minSessions: 2` default could be satisfied inside the user's
83
122
  // very first app open — the exact scenario it was added to prevent.
84
- const sessions = useState(() => {
123
+ const [sessions, setSessions] = useState(() => {
85
124
  const store = resolveStorage(storage);
86
125
  // No storage pins the counter at 1 forever, so the fail-closed minSessions rule can never be
87
126
  // met and the gate silently never fires. Dev-only, once per process.
88
127
  warnMissingGateStorage(store, "review");
89
128
  return bumpSessionCount(store, sessionsKey, sessionOpenKey);
90
- })[0];
129
+ });
130
+ useEffect(() => {
131
+ const next = bumpSessionCount(resolveStorage(storageRef.current), sessionsKey, sessionOpenKey);
132
+ // A bail-out when the count is unchanged, so a notification for an open already counted costs
133
+ // nothing: React skips the re-render when the state is identical.
134
+ setSessions((prev) => (prev === next ? prev : next));
135
+ }, [openSessionId, sessionsKey, sessionOpenKey]);
91
136
 
92
137
  // Gate the local rules behind an optional client-side timeout, so a reachable server
93
138
  // gets a window to answer first. A present `decision` bypasses the wait entirely.
@@ -123,10 +168,15 @@ export const useReviewGate = ({
123
168
  const resolved = decideReview(local, decision);
124
169
  // (3) the once-gate always wins locally, even over a server "fire".
125
170
  const alreadySeen = hasSeenGate(seenKey, storage, isTesting);
126
- const ready = hasServerDecision || elapsed;
171
+ // FAIL-OPEN IS NOT WEAKENED BY THIS. `featuresSettled` is `false` only while a fetch that could
172
+ // still change the answer is in flight; it is `true` with no `featuresConfig` (nothing to ask),
173
+ // with explicit `features`, and — the case that matters — the moment a FAILED fetch resolves,
174
+ // because `fetchWireFeatures` swallows a timeout / 401 / 5xx into the all-on defaults. So an
175
+ // unreachable control plane still allows the gate; only an unanswered one holds it.
176
+ const ready = (hasServerDecision || elapsed) && featuresSettled;
127
177
  return { visible: ready && resolved.fire && !alreadySeen, verdict: resolved };
128
178
  // eslint-disable-next-line react-hooks/exhaustive-deps
129
- }, [featureEnabled, testing, config, decision, events, sessions, elapsed, hasServerDecision, seenKey, lastKey, storage, isTesting]);
179
+ }, [featureEnabled, featuresSettled, testing, config, decision, events, sessions, elapsed, hasServerDecision, seenKey, lastKey, storage, isTesting]);
130
180
 
131
181
  // Stable callbacks + a stable controller object, so a host can safely list any of
132
182
  // them in its own effect deps without the effect re-firing every render.
@@ -173,8 +173,24 @@ const emitFirstOpen = (opts: ReportFirstOpenOptions): void => {
173
173
  * • Race guard: an in-memory latch is set SYNCHRONOUSLY before the async read, so two
174
174
  * near-simultaneous calls fire at most once.
175
175
  * • Without `storage`: degraded mode — fires once per PROCESS via the latch only (documented).
176
+ * • WITHOUT A TRANSPORT: no-op, and — the point of the check — the once-ever flag is NOT spent.
177
+ * See below.
176
178
  */
177
179
  export const reportFirstOpen = (opts: ReportFirstOpenOptions): void => {
180
+ // NO TRANSPORT, NO SPEND. `emitFirstOpen` routes through `buildEventsRequest`, which returns
181
+ // `null` when there is no `target.serverUrl` — so with neither a `sink` nor a server URL the emit
182
+ // is a silent no-op. Both once-ever guards were still consumed underneath it: the in-memory latch
183
+ // synchronously, and then the PERSISTED `wireai:first_open:<appId>` flag on the write below. That
184
+ // marks the install as having reported its first open when NOTHING was ever sent, and the flag
185
+ // survives app kills — so `app.first_open` was dead for that install FOREVER, with no error and
186
+ // no event. Reachable on the documented wiring: a `LifecycleConfig` carrying `storage` + `appId`
187
+ // whose `serverUrl` is optional and resolves late (the hook's effect is mount-only).
188
+ //
189
+ // The sibling emitter already gets this right — `reportSessionStart` bails on
190
+ // `!opts.sink && !target?.serverUrl` BEFORE its own once-per-open guard — and this is the same
191
+ // check in the same position, for the same reason. A guard may only be spent by a real send.
192
+ if (!opts.sink && !opts.target?.serverUrl) return;
193
+
178
194
  const appId = opts.appId ?? "default";
179
195
 
180
196
  // Synchronous latch FIRST: guarantees at-most-one before any await (same-process race guard) and
@@ -36,7 +36,12 @@ import { makeSessionId, type ClientEvent, type ClientEventTarget } from "../anal
36
36
  import { hydrateDeviceIdentity } from "../context/deviceId";
37
37
  import { resolveIdentity } from "../identity/identityRecord";
38
38
  import { collectDeviceContext } from "../device/deviceContext";
39
- import type { WireOnboardingStorage } from "../session/persistedSession";
39
+ import {
40
+ READ_TIMED_OUT,
41
+ READ_TIMEOUT_MS,
42
+ withTimeout,
43
+ type WireOnboardingStorage,
44
+ } from "../session/persistedSession";
40
45
  import { reportFirstOpen } from "./lifecycle";
41
46
  import { reportSessionStart } from "./reportSessionStart";
42
47
  import { BACKGROUND_SESSION_MS } from "./useSessionStart";
@@ -173,6 +178,23 @@ export const useLifecycleEvents = (
173
178
  * emits the event with NO auto key — the same verdict `<WireOnboarding>` reaches on a broken
174
179
  * adapter, and strictly better than a key that differs on every launch. Never rejects: the read
175
180
  * resolves to a record or `undefined`, never a throw.
181
+ *
182
+ * ── AND IT IS CAPPED, BECAUSE "NEVER REJECTS" IS NOT "ALWAYS SETTLES" ──────────────────────
183
+ *
184
+ * `hydrateDeviceIdentity` awaits a BARE `storage.getItem`, so an adapter that HANGS (the read
185
+ * neither resolves nor rejects — a locked keychain, a wedged native bridge) leaves the promise
186
+ * pending forever. Every fire in this hook lives inside that `.then`, so a hung adapter did not
187
+ * degrade the device key: it silently killed `app.session_started` AND `app.first_open` for the
188
+ * whole process, on every launch. Those two events are the funnel's denominator and what the
189
+ * server counts `min_sessions` from, so `min_sessions` stays 0 and every review / questionnaire
190
+ * trigger becomes unsatisfiable — with nothing logged and nothing thrown.
191
+ *
192
+ * The read is therefore raced against the SAME ceiling the rest of the kit reads through
193
+ * (`session/persistedSession`'s exported `withTimeout` + `READ_TIMEOUT_MS`, which is also the
194
+ * ceiling `<WireOnboarding>`'s auto-join gate names). Blowing it fires with NO auto key —
195
+ * byte-identical to the refusal a rejecting adapter already gets — because a read that did not
196
+ * answer cannot prove the id is durable, and an event with no device key is a recoverable gap
197
+ * while no event at all is a permanent one.
176
198
  */
177
199
  const openAutoDeviceKey = (fire: (autoDeviceKey: string | undefined) => void): void => {
178
200
  const { config: cfg, options: opts } = latest.current;
@@ -182,8 +204,14 @@ export const useLifecycleEvents = (
182
204
  fire(undefined);
183
205
  return;
184
206
  }
185
- void hydrateDeviceIdentity({ appId: cfg.appId, storage: cfg.storage }).then((identity) => {
207
+ void withTimeout(
208
+ hydrateDeviceIdentity({ appId: cfg.appId, storage: cfg.storage }),
209
+ READ_TIMEOUT_MS,
210
+ ).then((read) => {
186
211
  if (cancelled) return;
212
+ // READ_TIMED_OUT = the adapter never answered in time. It collapses onto the SAME branch as
213
+ // a non-durable record — fire with no auto key — so the events still go out, degraded.
214
+ const identity = read === READ_TIMED_OUT ? undefined : read;
187
215
  fire(identity?.durable ? identity.value : undefined);
188
216
  });
189
217
  };
@@ -31,7 +31,12 @@ import { AppState, Platform, type AppStateStatus } from "react-native";
31
31
  import type { ClientEventTarget } from "../analytics/reportClientEvent";
32
32
  import { hydrateDeviceIdentity } from "../context/deviceId";
33
33
  import { collectDeviceContext } from "../device/deviceContext";
34
- import type { WireOnboardingStorage } from "../session/persistedSession";
34
+ import {
35
+ READ_TIMED_OUT,
36
+ READ_TIMEOUT_MS,
37
+ withTimeout,
38
+ type WireOnboardingStorage,
39
+ } from "../session/persistedSession";
35
40
  import { reportSessionStart } from "./reportSessionStart";
36
41
 
37
42
  /** A foreground after at least this long in the background counts as a NEW app-open (30 min). */
@@ -125,6 +130,15 @@ export const useSessionStart = (
125
130
  * rule `context/deviceId.ts` states for exactly these callers: *"Callers that write a key onto the
126
131
  * wire as a cross-launch join must read `durable` and refuse a `false`."* A host-supplied key or a
127
132
  * config with no `storage` still fires synchronously — there is nothing to read.
133
+ *
134
+ * AND THE WAIT IS CAPPED. `hydrateDeviceIdentity` awaits a BARE `storage.getItem`, so an adapter
135
+ * that HANGS (neither resolves nor rejects) leaves that promise pending forever — and since the
136
+ * only fire path in this hook lives inside the `.then`, `app.session_started` then never fires at
137
+ * all, for the whole process, on every launch. That is the event the server counts `min_sessions`
138
+ * from, so the counter stays 0 and every server-side trigger becomes unsatisfiable, silently. The
139
+ * read is raced against the kit's shared storage ceiling (`session/persistedSession`'s exported
140
+ * `withTimeout` + `READ_TIMEOUT_MS`, the same one `<WireOnboarding>`'s auto-join gate names), and
141
+ * blowing it fires with NO auto key — exactly the branch a rejecting adapter already takes.
128
142
  */
129
143
  const openAutoDeviceKey = (fireOpen: (autoDeviceKey: string | undefined) => void): void => {
130
144
  const { config: cfg, options: opts } = latest.current;
@@ -134,8 +148,14 @@ export const useSessionStart = (
134
148
  fireOpen(undefined);
135
149
  return;
136
150
  }
137
- void hydrateDeviceIdentity({ appId: cfg.appId, storage: cfg.storage }).then((identity) => {
151
+ void withTimeout(
152
+ hydrateDeviceIdentity({ appId: cfg.appId, storage: cfg.storage }),
153
+ READ_TIMEOUT_MS,
154
+ ).then((read) => {
138
155
  if (cancelled) return;
156
+ // READ_TIMED_OUT = the adapter never answered in time. Same branch as a non-durable record:
157
+ // fire with no auto key, so the app-open is still counted.
158
+ const identity = read === READ_TIMED_OUT ? undefined : read;
139
159
  fireOpen(identity?.durable ? identity.value : undefined);
140
160
  });
141
161
  };
@@ -9,7 +9,8 @@ import { type ImageSourcePropType, StyleSheet, View } from "react-native";
9
9
 
10
10
  import { GestureHint } from "../coachmarks/GestureHint";
11
11
  import { hasSeenGate, markSeenGate, showcaseGateKey } from "../coachmarks/runtime";
12
- import { useResolvedFeatures } from "../features/WireFeaturesProvider";
12
+ import { useResolvedFeaturesState } from "../features/WireFeaturesProvider";
13
+ import { READ_TIMEOUT_MS } from "../session/persistedSession";
13
14
  import { useOnboardingTheme } from "../theme/ThemeContext";
14
15
  import type { OnboardingTheme } from "../theme/types";
15
16
  import { resolveShowcaseOnboarding } from "./blazejOnboarding";
@@ -49,6 +50,11 @@ const mergeThemeOver = (
49
50
  * When the gate says "seen", it renders nothing and calls `onDone` from an
50
51
  * effect (never during render).
51
52
  *
53
+ * When the tenant's feature answer has not landed yet AND the flags could still change the
54
+ * outcome (an unseen gate + a resolvable pager), it renders nothing and calls nothing for up to
55
+ * `READ_TIMEOUT_MS`, then falls through to the optimistic path. That window is bounded, and only
56
+ * ever reached on the launch the showcase would actually play; see the hold note below.
57
+ *
52
58
  * The underlying package is an OPTIONAL peer, resolved through the guarded lazy
53
59
  * require in `blazejOnboarding.ts` rather than a static import, so the showcase
54
60
  * subpath builds on a host that never installed it. Absent, this takes the SAME
@@ -76,7 +82,11 @@ const _FeatureShowcase: React.FC<FeatureShowcaseProps> = ({
76
82
 
77
83
  // Feature kill switch: disabled → treat like "already seen" (render nothing, call onDone so the
78
84
  // host flow continues) but WITHOUT writing the seen gate, so re-enabling replays the showcase.
79
- const flags = useResolvedFeatures({ flags: features, config: featuresConfig });
85
+ // `settled` separates the tenant's ANSWER from the optimistic all-on default; see the hold below.
86
+ const { flags, settled } = useResolvedFeaturesState({
87
+ flags: features,
88
+ config: featuresConfig,
89
+ });
80
90
  const disabled = !flags.showcase.enabled;
81
91
 
82
92
  // The optional pager peer. Resolved once, on first render, instead of at module scope — so
@@ -94,6 +104,37 @@ const _FeatureShowcase: React.FC<FeatureShowcaseProps> = ({
94
104
  // the feature is re-enabled or the peer is installed.
95
105
  const skip = seen || disabled || !Onboarding;
96
106
 
107
+ /**
108
+ * THE FIRST-FRAME HOLD, and why it is scoped this tightly.
109
+ *
110
+ * The flags start all-on and swap when `GET /v1/features` resolves, so on a cold start `disabled`
111
+ * reads false because nobody has asked yet. A tenant who switched the showcase OFF therefore got
112
+ * the whole full-screen intro anyway — and if the user tapped through it inside that window,
113
+ * `finish` wrote the seen gate, so the showcase never replayed even after the tenant re-enabled
114
+ * it. Silent, permanent, once per install.
115
+ *
116
+ * The seam the review + questionnaire gates use — hold the first render outright until the
117
+ * flags settle — does not port unchanged: this component's disabled path calls `onDone`, so a
118
+ * naive hold on `!settled` would leave a blank full screen, and on every launch, including the
119
+ * ones where the flags cannot change the outcome at all.
120
+ * Two things bound it:
121
+ * • `flagsCouldMatter` — an already-seen gate or an absent pager skips whatever the tenant
122
+ * answers, so those launches (which is every launch after the first) never wait at all;
123
+ * • `READ_TIMEOUT_MS` — the repo's one exported read ceiling, reused rather than a third
124
+ * number invented here. The features fetch's own ceiling is 4000ms, which is too long to
125
+ * stare at nothing, so an answer that has not arrived by then falls through to the previous
126
+ * optimistic behaviour. Fail-open in the time dimension: the worst case is what shipped
127
+ * before, reached later.
128
+ */
129
+ const flagsCouldMatter = !seen && !!Onboarding;
130
+ const [holdExpired, setHoldExpired] = useState(false);
131
+ useEffect(() => {
132
+ if (settled || holdExpired || !flagsCouldMatter) return undefined;
133
+ const timer = setTimeout(() => setHoldExpired(true), READ_TIMEOUT_MS);
134
+ return () => clearTimeout(timer);
135
+ }, [settled, holdExpired, flagsCouldMatter]);
136
+ const holding = flagsCouldMatter && !settled && !holdExpired;
137
+
97
138
  const doneRef = useRef(false);
98
139
  const finish = useCallback(() => {
99
140
  if (doneRef.current) return;
@@ -105,11 +146,14 @@ const _FeatureShowcase: React.FC<FeatureShowcaseProps> = ({
105
146
  // Already seen OR disabled → skip without rendering; call onDone from an effect so the host's
106
147
  // navigation continues (never write the gate here — `finish` owns the seen write).
107
148
  useEffect(() => {
149
+ // Never advance the host while holding: `onDone` is the host's navigation, and a skip decided
150
+ // on the optimistic default is the same guess the render path refuses below.
151
+ if (holding) return;
108
152
  if (skip && !doneRef.current) {
109
153
  doneRef.current = true;
110
154
  onDone();
111
155
  }
112
- }, [skip, onDone]);
156
+ }, [holding, skip, onDone]);
113
157
 
114
158
  const [activeIndex, setActiveIndex] = useState(0);
115
159
 
@@ -191,6 +235,9 @@ const _FeatureShowcase: React.FC<FeatureShowcaseProps> = ({
191
235
  [t],
192
236
  );
193
237
 
238
+ // Holding for the tenant's answer: render nothing AND advance nothing (the effect above is
239
+ // gated too), so a disabled tenant never gets the first frame and never burns the seen gate.
240
+ if (holding) return null;
194
241
  // `!Onboarding` is already folded into `skip`; it is repeated here so the narrowing is explicit
195
242
  // to the reader and to the compiler at the JSX below.
196
243
  if (skip || !Onboarding) return null;
package/src/types.ts CHANGED
@@ -240,10 +240,11 @@ export type WireOnboardingProps = {
240
240
  * Build the value with the helper so the wire spelling is decided in one place:
241
241
  *
242
242
  * ```tsx
243
- * <WireOnboarding userContext={activationJoinContext(deviceKey)} ... />
244
- * // no device id of your own? pass `storage` and leave this prop alone the kit injects its
245
- * // own key, and only after it has confirmed the key actually persists (see `autoJoinKey`).
246
- * <WireOnboarding config={{ ...config, storage }} ... />
243
+ * <WireOnboarding config={config} userContext={activationJoinContext(deviceKey)} ... />
244
+ * // no device id of your own? pass the `storage` PROP (it is a prop of this component, never a
245
+ * // field of `WireOnboardingConfig`) and leave this prop alone the kit injects its own key,
246
+ * // and only after it has confirmed the key actually persists (see `autoJoinKey`).
247
+ * <WireOnboarding config={config} storage={storage} ... />
247
248
  * ```
248
249
  *
249
250
  * ⛔ Do NOT hand-build the auto key with `activationJoinContext(resolveAutoDeviceKey({...}))`.
@@ -0,0 +1,70 @@
1
+ /**
2
+ * withDeadline — put a CEILING on an awaited network exchange, body read included.
3
+ *
4
+ * ── THE DEFECT CLASS THIS CLOSES ───────────────────────────────────────────────────────────────
5
+ * The kit's awaited `fetch` calls used to split into two groups. `eventQueue.postBatch` and
6
+ * `wireDoctor` each carried their own `AbortController` + 15s / 10s timer; the review and
7
+ * questionnaire transports and `reportClientEventsOutcome` carried nothing at all. A hung request on
8
+ * one of those has no ceiling but the PLATFORM's, which on iOS is ~60s — and for that whole window
9
+ * `ReviewGate` / `QuestionnaireGate`'s `postOnce` latch stays closed (so the unmount recovery net
10
+ * cannot re-post) and a host's `await wire.track()` stays pending. It is bounded, so it is not the
11
+ * hang; it is the thing that WIDENS the in-flight window every other fix in this area narrows.
12
+ *
13
+ * ── WHY TWO MECHANISMS, NOT JUST AN ABORT ──────────────────────────────────────────────────────
14
+ * Lifted verbatim in shape from `features/fetchWireFeatures.ts`, which learned it the expensive way:
15
+ * a timer cleared around the `fetch` alone dies the moment the HEADERS land, so `await res.json()`
16
+ * then runs with no ceiling at all and a proxy that flushes headers and hangs pends forever. So the
17
+ * exchange callback owns the body read too, and there are two independent guards:
18
+ *
19
+ * • the ABORT tears the socket down, so a stalled stream is cancelled and not merely un-awaited;
20
+ * • the RACE guarantees this promise SETTLES even on a runtime that ignores `signal` once the body
21
+ * has started (RN's `fetch` is the XHR polyfill; abort support is not something to bet a latch on).
22
+ *
23
+ * A ceiling that depends on the host honouring abort is not a ceiling.
24
+ *
25
+ * INTERNAL SEAM — deliberately not on the public barrels, like `utils/warnInDev` and
26
+ * `analytics/currentSession`'s `isMintedSessionId`. It is dependency-free and imports nothing, so it
27
+ * is safe for the tree-shaken `./analytics` graph (`analytics/treeShake.test.ts`).
28
+ */
29
+
30
+ /**
31
+ * "The deadline passed before the exchange produced anything." A `unique symbol`, so it can never
32
+ * collide with a value the exchange itself resolved — `null`, `undefined` and `false` are all
33
+ * legitimate answers at these call sites, which is exactly why the sentinel cannot be one of them.
34
+ */
35
+ export const DEADLINE_EXPIRED: unique symbol = Symbol("wire-deadline-expired");
36
+
37
+ /**
38
+ * The one ceiling for the kit's awaited transports. 15s, matching `eventQueue.postBatch`'s existing
39
+ * ceiling on an awaited POST — the precedent in this repo for "long enough that a slow but real
40
+ * network still lands, short enough that nothing waits on the ~60s platform default".
41
+ */
42
+ export const DEFAULT_DEADLINE_MS = 15_000;
43
+
44
+ /**
45
+ * Run `exchange` under `timeoutMs`, handing it the abort signal to pass to `fetch`. Resolves what
46
+ * the exchange resolved, or {@link DEADLINE_EXPIRED} if the deadline won. REJECTS with whatever the
47
+ * exchange threw (an abort included), so every existing `try/catch` at a call site keeps its meaning.
48
+ */
49
+ export const withDeadline = async <T>(
50
+ exchange: (signal: AbortSignal | undefined) => Promise<T>,
51
+ timeoutMs: number = DEFAULT_DEADLINE_MS,
52
+ ): Promise<T | typeof DEADLINE_EXPIRED> => {
53
+ const controller = typeof AbortController !== "undefined" ? new AbortController() : undefined;
54
+ let timer: ReturnType<typeof setTimeout> | undefined;
55
+ const expired = new Promise<typeof DEADLINE_EXPIRED>((resolve) => {
56
+ timer = setTimeout(() => {
57
+ controller?.abort();
58
+ resolve(DEADLINE_EXPIRED);
59
+ }, timeoutMs);
60
+ });
61
+ const running = exchange(controller?.signal);
62
+ try {
63
+ return await Promise.race([running, expired]);
64
+ } finally {
65
+ clearTimeout(timer);
66
+ // The loser can still settle after the race is decided with nothing awaiting it, and an
67
+ // unobserved rejection is a hard crash on some hosts. Attach a handler either way.
68
+ void running.catch(() => {});
69
+ }
70
+ };