@wireai/activation 0.12.0 → 0.12.2

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 (53) hide show
  1. package/AGENTS.md +13 -10
  2. package/CHANGELOG.md +130 -0
  3. package/INTEGRATION_PROMPT.md +14 -3
  4. package/README.md +40 -2
  5. package/dist/analytics/index.d.mts +5 -4
  6. package/dist/analytics/index.d.ts +5 -4
  7. package/dist/analytics/index.js +39 -24
  8. package/dist/analytics/index.js.map +1 -1
  9. package/dist/analytics/index.mjs +39 -24
  10. package/dist/analytics/index.mjs.map +1 -1
  11. package/dist/coachmarks/index.d.mts +16 -0
  12. package/dist/coachmarks/index.d.ts +16 -0
  13. package/dist/coachmarks/index.js +19 -13
  14. package/dist/coachmarks/index.js.map +1 -1
  15. package/dist/coachmarks/index.mjs +19 -13
  16. package/dist/coachmarks/index.mjs.map +1 -1
  17. package/dist/{currentSession-DsSDHqor.d.mts → currentSession-BlCeDP0f.d.mts} +59 -5
  18. package/dist/{currentSession-D6RiVtc8.d.ts → currentSession-BxEB37xt.d.ts} +59 -5
  19. package/dist/index.d.mts +10 -20
  20. package/dist/index.d.ts +10 -20
  21. package/dist/index.js +301 -187
  22. package/dist/index.js.map +1 -1
  23. package/dist/index.mjs +300 -188
  24. package/dist/index.mjs.map +1 -1
  25. package/dist/questionnaire/index.js +41 -19
  26. package/dist/questionnaire/index.js.map +1 -1
  27. package/dist/questionnaire/index.mjs +41 -19
  28. package/dist/questionnaire/index.mjs.map +1 -1
  29. package/dist/reviews/index.js +41 -19
  30. package/dist/reviews/index.js.map +1 -1
  31. package/dist/reviews/index.mjs +41 -19
  32. package/dist/reviews/index.mjs.map +1 -1
  33. package/dist/showcase/index.js +15 -6
  34. package/dist/showcase/index.js.map +1 -1
  35. package/dist/showcase/index.mjs +15 -6
  36. package/dist/showcase/index.mjs.map +1 -1
  37. package/llms.txt +2 -0
  38. package/package.json +1 -1
  39. package/src/WireOnboarding.tsx +140 -5
  40. package/src/activation/wireActivation.ts +8 -1
  41. package/src/analytics/analyticsFacade.ts +15 -11
  42. package/src/analytics/reportClientEvent.ts +11 -7
  43. package/src/coachmarks/runtime.ts +53 -17
  44. package/src/config/wireConfigFromEnv.ts +46 -2
  45. package/src/context/deviceId.ts +82 -18
  46. package/src/index.ts +9 -1
  47. package/src/questionnaire/runtime.ts +1 -0
  48. package/src/questionnaire/useQuestionnaireGate.ts +8 -3
  49. package/src/reviews/runtime.ts +68 -7
  50. package/src/reviews/useReviewGate.ts +8 -3
  51. package/src/session-analytics/useLifecycleEvents.ts +57 -27
  52. package/src/session-analytics/useSessionStart.ts +37 -1
  53. package/src/types.ts +41 -4
@@ -30,6 +30,7 @@ import {
30
30
  } from "../analytics/reportClientEvent";
31
31
  import { resolveAutoDeviceKey, type ResolveAutoDeviceKeyOptions } from "../context/deviceId";
32
32
  import { resolveUserContext, type WireUserContext } from "../context/userContext";
33
+ import { detectAppVersion } from "../device/appVersion";
33
34
  import type { WireOnboardingStorage } from "../session/persistedSession";
34
35
  import {
35
36
  bumpActivationRevalidation,
@@ -117,6 +118,12 @@ export const createWireActivation = (config: WireActivationConfig): WireActivati
117
118
  // as it used to be. The return value is deliberately discarded — every event re-resolves.
118
119
  if (!explicitDeviceKey) resolveAutoDeviceKey(autoDeviceKeyOptions);
119
120
 
121
+ // The auto-detected host app version, read ONCE (cheap, sync, never throws) — same as the facade.
122
+ // Without it this path had NO `app_version` fallback at all, so every `wire.track` event from a host
123
+ // that passed no `config.appVersion` (which `wireConfigFromEnv` could not even carry) shipped
124
+ // without one, while the facade and lifecycle families both had theirs.
125
+ const detectedAppVersion = detectAppVersion();
126
+
120
127
  // Stamp the resolved rich context onto the event: `user_context` bucket (device_key always, plus any
121
128
  // app_version / opt-in user_email / namespaced extra) and the top-level opaque `user_id`.
122
129
  const applyContext = (event: ClientEvent): void => {
@@ -126,7 +133,7 @@ export const createWireActivation = (config: WireActivationConfig): WireActivati
126
133
  ...(config.userContext ?? {}),
127
134
  deviceKey: explicitDeviceKey ?? resolveAutoDeviceKey(autoDeviceKeyOptions),
128
135
  },
129
- { autoAppVersion: config.appVersion },
136
+ { autoAppVersion: config.appVersion ?? detectedAppVersion },
130
137
  );
131
138
  if (resolved.userContext) {
132
139
  event.user_context = { ...resolved.userContext, ...(event.user_context ?? {}) };
@@ -27,9 +27,9 @@
27
27
  * FIRE-AND-FORGET: no method throws into the UI or blocks — the queue already guarantees that.
28
28
  */
29
29
  import { buildContextEnvelope, type ContextEnvelope } from "./contextEnvelope";
30
- import { getCurrentSessionId } from "./currentSession";
30
+ import { ensureCurrentSessionId } from "./currentSession";
31
31
  import { createEventQueue, type EventQueue, type EventQueueOptions } from "./eventQueue";
32
- import { makeSessionId, type ClientEvent } from "./reportClientEvent";
32
+ import type { ClientEvent } from "./reportClientEvent";
33
33
  import {
34
34
  analyticsUserIdStorageKey,
35
35
  clearPiiFromContext,
@@ -70,8 +70,9 @@ export type CreateAnalyticsConfig = {
70
70
  * analytics then collapse onto a single device-scoped session — one "first open", forever.
71
71
  *
72
72
  * OMIT IT — that is the correct default. Without it the kit reuses the live per-open session, and
73
- * falls back to a stable per-instance id only until an open has been registered. Pass one ONLY if
74
- * your host runs its own session lifecycle and owns the id the server should correlate on.
73
+ * when no open has been registered yet it mints one AND registers it, so every later surface joins
74
+ * the same session instead of each inventing its own. Pass one ONLY if your host runs its own
75
+ * session lifecycle and owns the id the server should correlate on.
75
76
  */
76
77
  sessionId?: string;
77
78
  /** Tenant/app id used to namespace the queue's default storage key (`wireai:evtq:<appId>`). */
@@ -158,16 +159,19 @@ export const createAnalytics = (
158
159
  config: CreateAnalyticsConfig,
159
160
  options: AnalyticsOptions = {},
160
161
  ): Analytics => {
161
- // A STABLE per-instance fallback id, used only when no explicit `config.sessionId` was given AND
162
- // no per-open session has been registered yet (see `resolveSessionId`).
163
- const instanceSessionId = config.sessionId ?? makeSessionId();
164
-
165
162
  // The session id every event correlates to. Precedence: an explicit `config.sessionId` freezes the
166
163
  // id (opt-out of the reuse); otherwise reuse the LIVE per-open session the server saw (via
167
164
  // `app.session_started`) so `identify`/app-events don't mint a fresh id the server back-fills into a
168
- // phantom session; finally fall back to the stable per-instance id when no open is registered yet.
169
- const resolveSessionId = (): string =>
170
- config.sessionId ?? getCurrentSessionId() ?? instanceSessionId;
165
+ // phantom session.
166
+ //
167
+ // `ensureCurrentSessionId` (not the bare `getCurrentSessionId`) is what closes the facade-first
168
+ // ordering hole: a `track` that runs BEFORE the root lifecycle effect used to fall back to a
169
+ // per-instance id this facade never REGISTERED, so the server saw a session it had no
170
+ // `session_started` for and back-filled a phantom one. The two paths that already mint on the wire
171
+ // (`wire.track`, `reportAppEvent`) both register; this one only read. Registering makes the
172
+ // fallback id the id every LATER surface joins on, and when an open IS registered this is exactly
173
+ // `getCurrentSessionId()` — so nothing changes for a host that mounts lifecycle first.
174
+ const resolveSessionId = (): string => config.sessionId ?? ensureCurrentSessionId();
171
175
 
172
176
  // A frozen id is almost always a mistake (it silently flattens every open into ONE session), so
173
177
  // name it once at construction — same dev-only channel as the email-shape guard below.
@@ -141,8 +141,14 @@ declare const __DEV__: boolean | undefined;
141
141
  *
142
142
  * LOG ONLY: returns immediately, never throws, and never influences retry / dequeue / return values.
143
143
  * A response with no usable `.json` (an old server, a test mock) is silently ignored.
144
+ *
145
+ * DEV-GATED FIRST: the `__DEV__` check is the FIRST statement, before the body is even looked at.
146
+ * The check used to sit inside the `.then`, so a release build parsed the JSON of every
147
+ * persistent-path POST to build a warning no one would ever read. Nothing here runs in production.
144
148
  */
145
149
  export const warnOnSkippedEvents = (res: unknown): void => {
150
+ if (typeof __DEV__ === "undefined" || !__DEV__) return;
151
+ if (typeof console === "undefined" || !console.warn) return;
146
152
  try {
147
153
  const json = (res as { json?: () => Promise<unknown> } | null | undefined)?.json;
148
154
  if (typeof json !== "function") return;
@@ -150,13 +156,11 @@ export const warnOnSkippedEvents = (res: unknown): void => {
150
156
  .then((body) => {
151
157
  const skipped = (body as { skipped?: unknown } | null | undefined)?.skipped;
152
158
  if (typeof skipped !== "number" || skipped <= 0) return;
153
- if (typeof __DEV__ !== "undefined" && __DEV__ && typeof console !== "undefined" && console.warn) {
154
- console.warn(
155
- `[wireai] the server ACCEPTED the /v1/events POST but DISCARDED ${skipped} event(s) ` +
156
- "(skipped in the response body) they are gone, not retried. The usual cause is an " +
157
- "event with a missing or empty session_id.",
158
- );
159
- }
159
+ console.warn(
160
+ `[wireai] the server ACCEPTED the /v1/events POST but DISCARDED ${skipped} event(s) ` +
161
+ "(skipped in the response body) they are gone, not retried. The usual cause is an " +
162
+ "event with a missing or empty session_id.",
163
+ );
160
164
  })
161
165
  .catch(() => {
162
166
  // Unreadable / already-consumed body — best-effort logging, swallow.
@@ -8,35 +8,71 @@
8
8
  * the root by `CoachmarkProvider`. Singletons let a hook read the gate without
9
9
  * every call site threading the storage through props. The provider is still the
10
10
  * single writer — it calls `setCoachmarkStorage` / `setCoachmarkTesting` on mount.
11
+ *
12
+ * ── WHY A globalThis SLOT, NOT PLAIN MODULE VARIABLES ─────────────────────────────────────────
13
+ * These three values used to be plain module `let`s, and this module is inlined by tsup into FOUR
14
+ * dist bundles (`./coachmarks`, `./reviews`, `./questionnaire`, `./showcase`). Under `dist`
15
+ * resolution — node `import`/`require`, SSR, jest, RN-web, anything that is not Metro — a host that
16
+ * mounts `CoachmarkProvider` from `@wireai/activation/coachmarks` wrote the COACHMARKS copy, while
17
+ * `useReviewGate` from `@wireai/activation/reviews` read the REVIEWS copy, which was still `null`.
18
+ * Everything downstream then failed silently: `readInt(null, …)` returns 0, so `bumpSessionCount`
19
+ * returns 1 forever and the fail-closed `minSessions: 2` default is unsatisfiable (the gate NEVER
20
+ * fires); the `seen` once-gate never persists; and a tenant's coachmarks kill switch never reaches
21
+ * the reviews bundle. Metro masked it by collapsing every subpath back to one `src/` file through
22
+ * the `react-native` export condition, which is a bundler accident, not a guarantee.
23
+ *
24
+ * Same remedy this repo already applies to `currentSession`, the auto device-key registry and the
25
+ * activation revalidation counter: ONE record in a `globalThis` slot keyed by `Symbol.for(...)`, so
26
+ * every inlined copy of this module addresses the SAME state.
11
27
  */
12
28
  import type { CoachmarkStorage } from "./types";
13
29
 
14
- let storageSingleton: CoachmarkStorage | null = null;
15
- let testing = false;
16
- // The coachmarks feature kill switch (from GET /v1/features). Default true = fail-open: with no
17
- // flags fetched, coachmarks behave exactly as before. CoachmarkProvider writes it from the
18
- // resolved flags. Disabled → the tour never ARMS and the overlay `show()` is a no-op, so nothing
19
- // paints and — critically — no once-gate is written, so re-enabling replays the tour correctly.
20
- let coachmarksEnabled = true;
30
+ /** Well-known key into the runtime-global symbol registry — one coachmark runtime across bundles. */
31
+ const COACHMARK_RUNTIME_SLOT: unique symbol = Symbol.for("@wireai/activation:coachmarkRuntime");
32
+
33
+ /** The shared runtime: the provider-injected storage, the QA replay flag, and the kill switch. */
34
+ type CoachmarkRuntime = {
35
+ storage: CoachmarkStorage | null;
36
+ testing: boolean;
37
+ // The coachmarks feature kill switch (from GET /v1/features). Default true = fail-open: with no
38
+ // flags fetched, coachmarks behave exactly as before. CoachmarkProvider writes it from the
39
+ // resolved flags. Disabled → the tour never ARMS and the overlay `show()` is a no-op, so nothing
40
+ // paints and — critically — no once-gate is written, so re-enabling replays the tour correctly.
41
+ coachmarksEnabled: boolean;
42
+ };
43
+
44
+ type GlobalWithCoachmarkRuntime = typeof globalThis & {
45
+ [COACHMARK_RUNTIME_SLOT]?: CoachmarkRuntime;
46
+ };
47
+
48
+ const runtimeGlobal = globalThis as GlobalWithCoachmarkRuntime;
49
+
50
+ const coachmarkRuntime = (): CoachmarkRuntime => {
51
+ const existing = runtimeGlobal[COACHMARK_RUNTIME_SLOT];
52
+ if (existing) return existing;
53
+ const created: CoachmarkRuntime = { storage: null, testing: false, coachmarksEnabled: true };
54
+ runtimeGlobal[COACHMARK_RUNTIME_SLOT] = created;
55
+ return created;
56
+ };
21
57
 
22
58
  /**
23
59
  * Set the coachmarks master switch (from the resolved feature flags). Default true (fail-open).
24
60
  * Written by CoachmarkProvider; read imperatively by the tour arm + the overlay store.
25
61
  */
26
62
  export const setCoachmarksEnabled = (value: boolean): void => {
27
- coachmarksEnabled = value;
63
+ coachmarkRuntime().coachmarksEnabled = value;
28
64
  };
29
65
 
30
66
  /** Whether the coachmarks module is enabled. False → tours/overlays are silently skipped. */
31
- export const areCoachmarksEnabled = (): boolean => coachmarksEnabled;
67
+ export const areCoachmarksEnabled = (): boolean => coachmarkRuntime().coachmarksEnabled;
32
68
 
33
69
  /** Set (or clear) the injected sync gate storage. Called by CoachmarkProvider. */
34
70
  export const setCoachmarkStorage = (storage: CoachmarkStorage | null): void => {
35
- storageSingleton = storage;
71
+ coachmarkRuntime().storage = storage;
36
72
  };
37
73
 
38
74
  /** The currently-injected gate storage, or null if no provider is mounted. */
39
- export const getCoachmarkStorage = (): CoachmarkStorage | null => storageSingleton;
75
+ export const getCoachmarkStorage = (): CoachmarkStorage | null => coachmarkRuntime().storage;
40
76
 
41
77
  /**
42
78
  * Toggle the global QA replay flag. When true, EVERY seen-gate reads as unseen
@@ -44,11 +80,11 @@ export const getCoachmarkStorage = (): CoachmarkStorage | null => storageSinglet
44
80
  * mount. The app flips ONE boolean to QA the whole coachmark surface.
45
81
  */
46
82
  export const setCoachmarkTesting = (value: boolean): void => {
47
- testing = value;
83
+ coachmarkRuntime().testing = value;
48
84
  };
49
85
 
50
86
  /** Whether replay-everything QA mode is on. */
51
- export const isCoachmarkTesting = (): boolean => testing;
87
+ export const isCoachmarkTesting = (): boolean => coachmarkRuntime().testing;
52
88
 
53
89
  /** Gate key for a tour: `wire_coachmark_<tourId>_seen`. */
54
90
  export const coachmarkGateKey = (tourId: string): string =>
@@ -73,8 +109,8 @@ export const hasSeenGate = (
73
109
  storageOverride?: CoachmarkStorage | null,
74
110
  testingOverride?: boolean,
75
111
  ): boolean => {
76
- if (testingOverride ?? testing) return false;
77
- const storage = storageOverride ?? storageSingleton;
112
+ if (testingOverride ?? isCoachmarkTesting()) return false;
113
+ const storage = storageOverride ?? getCoachmarkStorage();
78
114
  if (!storage) return false;
79
115
  try {
80
116
  return storage.getItem(key) === SEEN_VALUE;
@@ -92,8 +128,8 @@ export const markSeenGate = (
92
128
  storageOverride?: CoachmarkStorage | null,
93
129
  testingOverride?: boolean,
94
130
  ): void => {
95
- if (testingOverride ?? testing) return;
96
- const storage = storageOverride ?? storageSingleton;
131
+ if (testingOverride ?? isCoachmarkTesting()) return;
132
+ const storage = storageOverride ?? getCoachmarkStorage();
97
133
  if (!storage) return;
98
134
  try {
99
135
  storage.setItem(key, SEEN_VALUE);
@@ -12,12 +12,31 @@
12
12
  * return <WireOnboarding config={config} ... />;
13
13
  *
14
14
  * `appId` defaults to `EXPO_PUBLIC_WIREAI_APP_ID` (or "default"); pass overrides to
15
- * set `appId`/`metadata` or to substitute the key/URL programmatically.
15
+ * set `appId`/`metadata` or to substitute the key/URL programmatically. `appVersion`
16
+ * defaults to `detectAppVersion()` so every surface built from this config carries it.
16
17
  */
18
+ import { detectAppVersion } from "../device/appVersion";
17
19
  import type { WireOnboardingConfig } from "../types";
18
20
 
19
21
  export type WireConfigOverrides = Partial<WireOnboardingConfig>;
20
22
 
23
+ /** The env vars this helper reads. Exported so a host preflight can assert them itself. */
24
+ export const WIRE_ENV_VARS = [
25
+ "EXPO_PUBLIC_WIREAI_API_KEY",
26
+ "EXPO_PUBLIC_WIREAI_SERVER_URL",
27
+ "EXPO_PUBLIC_WIREAI_APP_ID",
28
+ ] as const;
29
+
30
+ /** RN sets this global; absent under node/SSR. Read defensively via {@link warnInDev}. */
31
+ declare const __DEV__: boolean | undefined;
32
+
33
+ /** Emit a one-line developer warning, but ONLY in a dev build (RN `__DEV__`). No-op in prod/tests. */
34
+ const warnInDev = (message: string): void => {
35
+ if (typeof __DEV__ !== "undefined" && __DEV__ && typeof console !== "undefined" && console.warn) {
36
+ console.warn(message);
37
+ }
38
+ };
39
+
21
40
  /**
22
41
  * Minimal `process.env` declaration so the kit stays RN-pure (no `@types/node`).
23
42
  * In RN/Expo, `process.env.EXPO_PUBLIC_*` is provided/inlined by Metro at build
@@ -41,12 +60,37 @@ export const wireConfigFromEnv = (
41
60
  overrides?.appId ?? process.env.EXPO_PUBLIC_WIREAI_APP_ID ?? "default";
42
61
 
43
62
  // Missing transport → null, so the host gates on one falsy check.
44
- if (!apiKey || !serverUrl) return null;
63
+ //
64
+ // The `null` return is the CORRECT contract and hosts rely on it — but it is also the quietest
65
+ // failure the kit has: a missing EAS env var makes this return null, and then the facade never
66
+ // constructs, `useLifecycleEvents` no-ops on `!cfg?.serverUrl`, and every gate returns null. Wire
67
+ // is 100% off, with no error anywhere. Name it once in dev, and name WHICH var is missing.
68
+ if (!apiKey || !serverUrl) {
69
+ const missing = [
70
+ apiKey ? undefined : "EXPO_PUBLIC_WIREAI_API_KEY",
71
+ serverUrl ? undefined : "EXPO_PUBLIC_WIREAI_SERVER_URL",
72
+ ].filter(Boolean);
73
+ warnInDev(
74
+ `[wireai] wireConfigFromEnv() returned null: ${missing.join(" and ")} ${
75
+ missing.length > 1 ? "are" : "is"
76
+ } missing. The kit is now FULLY DISABLED (no onboarding, no analytics, no gates) and nothing ` +
77
+ "else will report an error. Set the var(s) in your env / EAS secrets.",
78
+ );
79
+ return null;
80
+ }
81
+
82
+ // `appVersion` is part of `WireOnboardingConfig`, but this helper could not carry it — so a host
83
+ // that built its config here forwarded `config.appVersion === undefined` to every surface. The
84
+ // facade and the lifecycle path each auto-detect their own fallback; `createWireActivation` does
85
+ // not, so every `wire.track` event shipped with no `app_version` at all. Detect once here (an
86
+ // explicit override still wins) and the whole config carries it.
87
+ const appVersion = overrides?.appVersion ?? detectAppVersion();
45
88
 
46
89
  return {
47
90
  apiKey,
48
91
  serverUrl,
49
92
  appId,
93
+ ...(appVersion ? { appVersion } : {}),
50
94
  ...(overrides?.metadata ? { metadata: overrides.metadata } : {}),
51
95
  };
52
96
  };
@@ -59,15 +59,23 @@ export const mintDeviceId = (): string => {
59
59
  // (tsup duplicates modules across the `.` / `./analytics` bundles) addresses the SAME registry.
60
60
  // Keyed by `appId` so two tenants in one process never share an id.
61
61
  //
62
- // RESIDUAL WINDOW (documented, not fixed here): the storage read is async, so events emitted in the
63
- // milliseconds before hydration completes still carry the freshly minted id rather than the persisted
64
- // one. The registry makes every surface agree on WHICH id that is; it does not make the read sync.
62
+ // RESIDUAL WINDOW: the storage read is async, so an event emitted in the milliseconds before
63
+ // hydration completes carries the freshly minted id rather than the persisted one. The registry makes
64
+ // every surface agree on WHICH id that is; it does not make the read sync. A caller that can afford to
65
+ // wait (the lifecycle hook's mount effect — see `hydrateAutoDeviceKey`) should await instead: its two
66
+ // events are the ONLY ones the server counts `min_sessions` from, so a per-launch id there is not a
67
+ // millisecond of noise, it is a counter that can never exceed 1.
65
68
 
66
69
  /** Well-known key into the runtime-global symbol registry — one auto-id registry across every bundle. */
67
70
  const AUTO_DEVICE_KEY_SLOT: unique symbol = Symbol.for("@wireai/activation:autoDeviceKeys");
68
71
 
69
- /** The shared registry: the live id per `appId`, plus the set of appIds whose hydration already ran. */
70
- type AutoDeviceKeyRegistry = { keys: Map<string, string>; hydrating: Set<string> };
72
+ /** The shared registry: the live id per `appId`, the set of appIds whose hydration already started,
73
+ * and the in-flight (or settled) hydration promise per `appId` so a waiter can join it. */
74
+ type AutoDeviceKeyRegistry = {
75
+ keys: Map<string, string>;
76
+ hydrating: Set<string>;
77
+ pending?: Map<string, Promise<string>>;
78
+ };
71
79
 
72
80
  type GlobalWithDeviceKeys = typeof globalThis & {
73
81
  [AUTO_DEVICE_KEY_SLOT]?: AutoDeviceKeyRegistry;
@@ -99,6 +107,44 @@ export interface ResolveAutoDeviceKeyOptions {
99
107
  storage?: DeviceKeyStorage;
100
108
  }
101
109
 
110
+ /**
111
+ * Start (or join) the SINGLE-FLIGHT storage read for `appId` and resolve to the id it settles on.
112
+ * The promise is parked on the registry so a later `hydrateAutoDeviceKey` awaits the SAME read
113
+ * instead of starting a second one. Never rejects: any storage failure resolves to the live id.
114
+ */
115
+ const startHydration = (
116
+ registry: AutoDeviceKeyRegistry,
117
+ appId: string,
118
+ storage: DeviceKeyStorage,
119
+ minted: string,
120
+ ): Promise<string> => {
121
+ if (!registry.pending) registry.pending = new Map();
122
+ const existing = registry.pending.get(appId);
123
+ if (existing) return existing;
124
+
125
+ const slot = deviceIdStorageKey(appId);
126
+ const settled = (): string => registry.keys.get(appId) ?? minted;
127
+ let run: Promise<string>;
128
+ try {
129
+ run = Promise.resolve(storage.getItem(slot))
130
+ .then((saved) => {
131
+ const persisted = typeof saved === "string" && saved.trim() ? saved.trim() : undefined;
132
+ if (persisted) {
133
+ registry.keys.set(appId, persisted);
134
+ return persisted;
135
+ }
136
+ // First run on this install: persist the id we just minted so the next launch adopts it.
137
+ return Promise.resolve(storage.setItem(slot, minted)).then(settled, settled);
138
+ })
139
+ .catch(settled);
140
+ } catch {
141
+ // A storage adapter that throws synchronously — degrade to the in-memory id.
142
+ run = Promise.resolve(settled());
143
+ }
144
+ registry.pending.set(appId, run);
145
+ return run;
146
+ };
147
+
102
148
  /**
103
149
  * The ONE auto-minted `device_key` for an install, shared by every kit surface.
104
150
  *
@@ -126,27 +172,45 @@ export const resolveAutoDeviceKey = (opts: ResolveAutoDeviceKeyOptions = {}): st
126
172
  // read whatever the registry currently holds.
127
173
  if (storage && !registry.hydrating.has(appId)) {
128
174
  registry.hydrating.add(appId);
129
- const slot = deviceIdStorageKey(appId);
130
- const minted = id;
131
- try {
132
- void Promise.resolve(storage.getItem(slot))
133
- .then((saved) => {
134
- const persisted = typeof saved === "string" && saved.trim() ? saved.trim() : undefined;
135
- if (persisted) registry.keys.set(appId, persisted);
136
- else void Promise.resolve(storage.setItem(slot, minted)).catch(() => {});
137
- })
138
- .catch(() => {});
139
- } catch {
140
- // A storage adapter that throws synchronously — degrade to the in-memory id.
141
- }
175
+ void startHydration(registry, appId, storage, id);
142
176
  }
143
177
 
144
178
  return registry.keys.get(appId) ?? id;
145
179
  };
146
180
 
181
+ /**
182
+ * The AWAITABLE sibling of {@link resolveAutoDeviceKey}: resolve to the auto `device_key` AFTER the
183
+ * persisted id has been read back (or written, on a first run), so the caller stamps the id this
184
+ * install will keep rather than the one that was minted a millisecond ago.
185
+ *
186
+ * WHY IT EXISTS: `resolveAutoDeviceKey` is synchronous by contract, so a caller firing at mount got
187
+ * the freshly minted id and the persisted one landed milliseconds later. For most events that is
188
+ * noise. For `app.session_started` it is the whole metric: the server computes `min_sessions` by
189
+ * counting distinct opens grouped by `device_key`, so a per-launch key there makes the counter
190
+ * structurally incapable of exceeding 1, and it splits `first_open` off from every event that
191
+ * follows it. Only a caller that can afford one storage read should use this; the fire-and-forget
192
+ * event paths must stay on the sync function.
193
+ *
194
+ * Never throws or rejects: a missing, hung, or rejecting adapter resolves to the in-memory id, and
195
+ * with no `storage` it resolves immediately (there is nothing to hydrate from).
196
+ */
197
+ export const hydrateAutoDeviceKey = async (
198
+ opts: ResolveAutoDeviceKeyOptions = {},
199
+ ): Promise<string> => {
200
+ // Mint + register synchronously first, so a waiter and a concurrent sync caller share ONE id.
201
+ const id = resolveAutoDeviceKey(opts);
202
+ if (!opts.storage) return id;
203
+ const registry = autoDeviceKeyRegistry();
204
+ const appId = opts.appId ?? "default";
205
+ const pending = registry.pending?.get(appId);
206
+ if (pending) await pending;
207
+ return registry.keys.get(appId) ?? id;
208
+ };
209
+
147
210
  /** Test-only: forget every auto id + hydration flag so a unit test starts from a clean registry. */
148
211
  export const resetAutoDeviceKeys = (): void => {
149
212
  const registry = autoDeviceKeyRegistry();
150
213
  registry.keys.clear();
151
214
  registry.hydrating.clear();
215
+ registry.pending?.clear();
152
216
  };
package/src/index.ts CHANGED
@@ -109,7 +109,12 @@ export type {
109
109
  } from "./features";
110
110
 
111
111
  // ─── Config + env helpers ─────────────────────────────────────────────────────
112
- export { wireConfigFromEnv } from "./config/wireConfigFromEnv";
112
+ export {
113
+ wireConfigFromEnv,
114
+ // The three `EXPO_PUBLIC_WIREAI_*` names, so a host preflight can assert its own env instead of
115
+ // discovering a missing var as a silently disabled kit (`wireConfigFromEnv` returns null).
116
+ WIRE_ENV_VARS,
117
+ } from "./config/wireConfigFromEnv";
113
118
  export type { WireConfigOverrides } from "./config/wireConfigFromEnv";
114
119
  export { isOnboardingEnabled } from "./config/onboardingFlag";
115
120
  export type { OnboardingFlagOptions } from "./config/onboardingFlag";
@@ -174,6 +179,9 @@ export {
174
179
  // purchase events carried `wdev_*` while its onboarding session carried no `device_key` at all, and
175
180
  // the join returned the silent zero the README warns about.
176
181
  resolveAutoDeviceKey,
182
+ // The awaitable sibling: resolves AFTER the persisted id has been read back, for a caller that can
183
+ // afford one storage read and must not stamp a key minted a millisecond ago (the lifecycle mount).
184
+ hydrateAutoDeviceKey,
177
185
  resetAutoDeviceKeys,
178
186
  } from "./context/deviceId";
179
187
  export type { DeviceKeyStorage, ResolveAutoDeviceKeyOptions } from "./context/deviceId";
@@ -11,6 +11,7 @@ export {
11
11
  writeInt,
12
12
  bumpSessionCount,
13
13
  currentOpenId,
14
+ warnMissingGateStorage,
14
15
  } from "../reviews/runtime";
15
16
 
16
17
  /** Once-gate key. Keyed by app version when `oncePerVersion` is on, so a new release re-enables. */
@@ -27,6 +27,7 @@ import {
27
27
  questionnaireSessionsKey,
28
28
  readInt,
29
29
  resolveStorage,
30
+ warnMissingGateStorage,
30
31
  writeInt,
31
32
  } from "./runtime";
32
33
  import type {
@@ -78,9 +79,13 @@ export const useQuestionnaireGate = ({
78
79
  // Read (and bump) the app-open counter. IDEMPOTENT per app-open, NOT per mount — see the same note
79
80
  // in `useReviewGate`; `bumpSessionCount` keys off the live per-open session id, so a remount or a
80
81
  // StrictMode double-invoke of this initializer reads the same number back instead of inflating it.
81
- const sessions = useState(() =>
82
- bumpSessionCount(resolveStorage(storage), sessionsKey, sessionOpenKey),
83
- )[0];
82
+ const sessions = useState(() => {
83
+ const store = resolveStorage(storage);
84
+ // Same as `useReviewGate`: no storage means the counter never leaves 1, so the fail-closed
85
+ // minSessions rule is unsatisfiable and the gate silently never fires. Dev-only, once per process.
86
+ warnMissingGateStorage(store, "questionnaire");
87
+ return bumpSessionCount(store, sessionsKey, sessionOpenKey);
88
+ })[0];
84
89
 
85
90
  // Gate the local rules behind an optional client-side timeout, so a reachable server gets a
86
91
  // window to answer first. A present `decision` bypasses the wait entirely.
@@ -27,6 +27,48 @@ export const resolveStorage = (
27
27
  override?: CoachmarkStorage | null,
28
28
  ): CoachmarkStorage | null => override ?? getCoachmarkStorage();
29
29
 
30
+ /** RN sets this global; absent under node/SSR. Read defensively via {@link warnMissingGateStorage}. */
31
+ declare const __DEV__: boolean | undefined;
32
+
33
+ /** Once-per-process latch on its own `Symbol.for` slot: a plain `let` would warn once per inlined
34
+ * bundle copy, and the gates live in three of them. */
35
+ const NO_STORAGE_WARNED_SLOT: unique symbol = Symbol.for(
36
+ "@wireai/activation:gateStorageWarned",
37
+ );
38
+
39
+ type GlobalWithGateWarn = typeof globalThis & { [NO_STORAGE_WARNED_SLOT]?: boolean };
40
+
41
+ const gateWarnGlobal = globalThis as GlobalWithGateWarn;
42
+
43
+ /**
44
+ * Warn once, in dev builds only, when a gate has NO sync storage to count with.
45
+ *
46
+ * With no storage `readInt` returns 0, so `bumpSessionCount` answers `1` on every call forever and
47
+ * the fail-closed `minSessions: 2` default can never be met: the gate is pinned shut for the life of
48
+ * the app and nothing anywhere says so. The fix is one line at the app root (mount `CoachmarkProvider`
49
+ * with a sync storage, or pass `storage` to the gate), so name it.
50
+ */
51
+ export const warnMissingGateStorage = (
52
+ storage: CoachmarkStorage | null,
53
+ gate: "review" | "questionnaire",
54
+ ): void => {
55
+ if (storage) return;
56
+ if (gateWarnGlobal[NO_STORAGE_WARNED_SLOT]) return;
57
+ if (typeof __DEV__ === "undefined" || !__DEV__) return;
58
+ if (typeof console === "undefined" || !console.warn) return;
59
+ gateWarnGlobal[NO_STORAGE_WARNED_SLOT] = true;
60
+ console.warn(
61
+ `[wireai] the ${gate} gate has no sync storage, so its app-open counter is stuck at 1 and the ` +
62
+ "fail-closed minSessions rule can never be satisfied — the gate will never fire. Mount " +
63
+ "CoachmarkProvider with a sync storage adapter at your app root, or pass `storage` to the gate.",
64
+ );
65
+ };
66
+
67
+ /** Test-only: forget the once-per-process no-storage warn latch. */
68
+ export const resetGateStorageWarning = (): void => {
69
+ gateWarnGlobal[NO_STORAGE_WARNED_SLOT] = undefined;
70
+ };
71
+
30
72
  /** Read an integer from sync storage (0 on a missing/unparseable/throwing read). */
31
73
  export const readInt = (storage: CoachmarkStorage | null, key: string): number => {
32
74
  if (!storage) return 0;
@@ -78,6 +120,24 @@ const readStr = (storage: CoachmarkStorage | null, key: string): string | undefi
78
120
  // closer to a session than a mount is. It lives in a `globalThis` slot keyed by `Symbol.for(...)`
79
121
  // for the same reason `currentSession` does: tsup inlines this module into several bundles and a
80
122
  // plain module-local `let` would give each bundle its own "process".
123
+ //
124
+ // ── WHY THE UNIT IS PINNED FOR THE WHOLE LAUNCH ──────────────────────────────────────────────
125
+ // The first shape of this function read the two tiers LIVE on every call: the registered session id
126
+ // when there was one, else the process id. That let the UNIT change mid-launch, and React's own
127
+ // ordering guarantees it does. The gates call `bumpSessionCount` from a `useState` INITIALIZER,
128
+ // which runs during render; `useLifecycleEvents` registers the session id from a root EFFECT; and
129
+ // React runs every render before any effect. So a cold start went:
130
+ //
131
+ // render: gate initializer → no session registered yet → PROCESS id → count = 1, open = <process>
132
+ // effect: root lifecycle → setCurrentSessionId(<session id>)
133
+ // remount: gate initializer → SESSION id ≠ the stored open → count = 2
134
+ //
135
+ // Two "sessions" inside one app open, which makes the fail-closed `minSessions: 2` default (added
136
+ // after the 2026-07-16 one-star incident) satisfiable in the very launch it exists to guard. So the
137
+ // FIRST read pins whatever it resolved into the process slot and every later read returns that,
138
+ // regardless of what the session registry does afterwards. The client counter only has to be
139
+ // monotone and per-launch; the server's own `min_sessions` still counts real `app.session_started`
140
+ // events, so nothing downstream needs the two ids to be identical.
81
141
  const PROCESS_OPEN_ID_SLOT: unique symbol = Symbol.for("@wireai/activation:processOpenId");
82
142
 
83
143
  type GlobalWithOpenId = typeof globalThis & { [PROCESS_OPEN_ID_SLOT]?: string };
@@ -85,17 +145,18 @@ type GlobalWithOpenId = typeof globalThis & { [PROCESS_OPEN_ID_SLOT]?: string };
85
145
  const openIdGlobal = globalThis as GlobalWithOpenId;
86
146
 
87
147
  /**
88
- * The id identifying THIS app-open for gate counting: the live per-open `session_id` when a host
89
- * wired the lifecycle events, else a stable per-process id. Never empty.
148
+ * The id identifying THIS app-open for gate counting, PINNED on first read for the whole launch:
149
+ * the live per-open `session_id` if one was already registered when the first gate asked, else a
150
+ * minted per-process id. Stable from the first render to the last. Never empty.
90
151
  */
91
152
  export const currentOpenId = (): string => {
92
- const live = getCurrentSessionId();
93
- if (live) return live;
94
153
  const existing = openIdGlobal[PROCESS_OPEN_ID_SLOT];
95
154
  if (existing) return existing;
96
- const created = makeSessionId();
97
- openIdGlobal[PROCESS_OPEN_ID_SLOT] = created;
98
- return created;
155
+ // Adopt the registered session id when the host wired lifecycle BEFORE any gate rendered; that is
156
+ // the same unit the server counts. Otherwise mint one. Either way it is pinned from here on.
157
+ const resolved = getCurrentSessionId() ?? makeSessionId();
158
+ openIdGlobal[PROCESS_OPEN_ID_SLOT] = resolved;
159
+ return resolved;
99
160
  };
100
161
 
101
162
  /** Test-only: forget the process open id so a unit test starts from a clean launch. */
@@ -26,6 +26,7 @@ import {
26
26
  reviewSeenKey,
27
27
  reviewSessionOpenKey,
28
28
  reviewSessionsKey,
29
+ warnMissingGateStorage,
29
30
  writeInt,
30
31
  } from "./runtime";
31
32
  import type {
@@ -80,9 +81,13 @@ export const useReviewGate = ({
80
81
  // initializer all read the same number back instead of inflating it. Before this, `sessions`
81
82
  // counted mounts, so the fail-closed `minSessions: 2` default could be satisfied inside the user's
82
83
  // very first app open — the exact scenario it was added to prevent.
83
- const sessions = useState(() =>
84
- bumpSessionCount(resolveStorage(storage), sessionsKey, sessionOpenKey),
85
- )[0];
84
+ const sessions = useState(() => {
85
+ const store = resolveStorage(storage);
86
+ // No storage pins the counter at 1 forever, so the fail-closed minSessions rule can never be
87
+ // met and the gate silently never fires. Dev-only, once per process.
88
+ warnMissingGateStorage(store, "review");
89
+ return bumpSessionCount(store, sessionsKey, sessionOpenKey);
90
+ })[0];
86
91
 
87
92
  // Gate the local rules behind an optional client-side timeout, so a reachable server
88
93
  // gets a window to answer first. A present `decision` bypasses the wait entirely.