@wireai/activation 0.14.2 → 0.15.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 +1 -1
  2. package/CHANGELOG.md +144 -0
  3. package/README.md +23 -9
  4. package/dist/analytics/index.d.mts +5 -5
  5. package/dist/analytics/index.d.ts +5 -5
  6. package/dist/analytics/index.js.map +1 -1
  7. package/dist/analytics/index.mjs.map +1 -1
  8. package/dist/coachmarks/index.d.mts +13 -5
  9. package/dist/coachmarks/index.d.ts +13 -5
  10. package/dist/coachmarks/index.js +252 -58
  11. package/dist/coachmarks/index.js.map +1 -1
  12. package/dist/coachmarks/index.mjs +204 -9
  13. package/dist/coachmarks/index.mjs.map +1 -1
  14. package/dist/{currentSession-CW_5Mq4O.d.ts → currentSession-CFSRZ2wg.d.ts} +10 -4
  15. package/dist/{currentSession-CUvTOchb.d.mts → currentSession-orZy5p1e.d.mts} +10 -4
  16. package/dist/{decision-Bgo17oH7.d.mts → decision-3vWLuBlO.d.ts} +11 -2
  17. package/dist/{decision-Bkh_LigV.d.ts → decision-yBj2AyPW.d.mts} +11 -2
  18. package/dist/index.d.mts +2 -2
  19. package/dist/index.d.ts +2 -2
  20. package/dist/index.js.map +1 -1
  21. package/dist/index.mjs.map +1 -1
  22. package/dist/questionnaire/index.d.mts +3 -3
  23. package/dist/questionnaire/index.d.ts +3 -3
  24. package/dist/questionnaire/index.js +48 -3
  25. package/dist/questionnaire/index.js.map +1 -1
  26. package/dist/questionnaire/index.mjs +48 -3
  27. package/dist/questionnaire/index.mjs.map +1 -1
  28. package/dist/reviews/index.d.mts +10 -6
  29. package/dist/reviews/index.d.ts +10 -6
  30. package/dist/reviews/index.js +167 -18
  31. package/dist/reviews/index.js.map +1 -1
  32. package/dist/reviews/index.mjs +167 -18
  33. package/dist/reviews/index.mjs.map +1 -1
  34. package/dist/showcase/index.d.mts +1 -1
  35. package/dist/showcase/index.d.ts +1 -1
  36. package/dist/showcase/index.js +282 -48
  37. package/dist/showcase/index.js.map +1 -1
  38. package/dist/showcase/index.mjs +248 -12
  39. package/dist/showcase/index.mjs.map +1 -1
  40. package/dist/{transport-j5gFfJhK.d.mts → transport-s5QxA-ci.d.mts} +18 -13
  41. package/dist/{transport-B_0SgCBe.d.ts → transport-xqqSFqxs.d.ts} +18 -13
  42. package/dist/{types-Cju-1_jT.d.mts → types-Byx306Kv.d.mts} +25 -10
  43. package/dist/{types-BcmagF6K.d.mts → types-D_0B0yay.d.mts} +7 -2
  44. package/dist/{types-BcmagF6K.d.ts → types-D_0B0yay.d.ts} +7 -2
  45. package/dist/{types-h2BZvl1t.d.ts → types-tdATL5z0.d.ts} +25 -10
  46. package/metro/index.js +16 -5
  47. package/package.json +1 -1
  48. package/src/WireOnboarding.tsx +4 -1
  49. package/src/coachmarks/GestureHint.tsx +16 -7
  50. package/src/coachmarks/SpotlightOverlay.tsx +19 -13
  51. package/src/coachmarks/index.ts +7 -2
  52. package/src/coachmarks/reanimated.ts +342 -0
  53. package/src/coachmarks/runtime.ts +103 -2
  54. package/src/coachmarks/types.ts +7 -2
  55. package/src/context/deviceId.ts +32 -0
  56. package/src/reviews/ReviewGate.tsx +86 -37
  57. package/src/reviews/decision.ts +11 -1
  58. package/src/reviews/idempotency.ts +71 -0
  59. package/src/reviews/index.ts +4 -2
  60. package/src/reviews/runtime.ts +5 -3
  61. package/src/reviews/transport.ts +17 -12
  62. package/src/reviews/types.ts +7 -0
  63. package/src/showcase/FeatureShowcase.tsx +25 -9
  64. package/src/showcase/blazejOnboarding.ts +136 -0
  65. package/src/showcase/index.ts +8 -4
  66. package/src/types.ts +10 -4
  67. package/src/utils/submitResult.ts +17 -9
@@ -0,0 +1,342 @@
1
+ /**
2
+ * reanimated — resolve `react-native-reanimated`, lazily and optionally.
3
+ *
4
+ * ── WHY THIS DOES NOT BREAK THE NO-NATIVE-DEPENDENCY POLICY ──────────────────────────────
5
+ *
6
+ * `react-native-reanimated` is an OPTIONAL peer of the coachmarks subpath. `SpotlightOverlay` and
7
+ * `GestureHint` animate with it WHEN it is installed, and fall back to a STATIC render of the same
8
+ * visual when it is not — same ring, same tooltip, same glyph, same copy, same gestures, just
9
+ * without motion. So a host that never installs it can still run the guided tour and the feature
10
+ * showcase, and an Expo Go / Lite build is not forced into a native module it cannot take.
11
+ *
12
+ * Before this file, both components did `import Animated, { … } from "react-native-reanimated"` at
13
+ * module scope, and `coachmarks/index.ts` re-exports them with no wildcard escape — so importing
14
+ * ANYTHING from `@wireai/activation/coachmarks` (even `setCoachmarkStorage`) dragged the peer in,
15
+ * and a host without it hit a Metro resolution failure. `@wireai/activation/showcase` inherited the
16
+ * same edge through `GestureHint`. This resolver removes it. Same class as the 0.14.2 `expo-blur`
17
+ * fix (`expoBlur.ts`), same shape, one size up.
18
+ *
19
+ * ── THE SPECIFIER MUST BE A STRING LITERAL, INSIDE A TRY/CATCH ───────────────────────────
20
+ *
21
+ * This follows `icons/expoIcons.ts` and `coachmarks/expoBlur.ts` exactly, and expoIcons' header
22
+ * carries the full autopsy. In short: Metro collects dependencies STATICALLY, matching a call whose
23
+ * callee is literally the identifier `require` and whose argument is literally a string — an
24
+ * aliased callee (`const req = require; req(name)`) or a variable specifier is collected NOWHERE,
25
+ * so the module never enters the bundle (that shape shipped broken in 0.8.0). And the call sitting
26
+ * inside a TRY/CATCH is literally how Metro marks the dependency `isOptional`, which is what lets
27
+ * an absent peer degrade instead of failing the build. Do NOT "simplify" either half away.
28
+ *
29
+ * ── WHY THE EXPORTS ARE BARE FUNCTIONS WITH THE PEER'S OWN NAMES ─────────────────────────
30
+ *
31
+ * The call sites import `useAnimatedStyle` / `useAnimatedProps` / `withTiming` … from HERE instead
32
+ * of from the peer, and call them unqualified, exactly as before. That is load-bearing, not style:
33
+ * reanimated's Babel plugin decides what to workletize by the CALLEE NAME (`react-native-worklets`
34
+ * `plugin/index.js` → `reanimatedFunctionHooks` / `reanimatedFunctionArgsToWorkletize`; it reads
35
+ * `callee.name`, or `callee.property.name` for a member call, and never checks which module the
36
+ * name came from). Keeping the names identical keeps every worklet in `SpotlightOverlay` and
37
+ * `GestureHint` workletized byte-for-byte on a host that HAS the peer. Rename them and the
38
+ * animations silently stop running on the UI thread.
39
+ *
40
+ * ── THE DEGRADE: THE FINAL FRAME, NOT A DEAD ONE ─────────────────────────────────────────
41
+ *
42
+ * The shim resolves every animation to its RESTING value — `withTiming(to)` is `to`,
43
+ * `withSequence(a, b)` is `b`, `withRepeat(x)` is `x` — so a component lands on the pose it would
44
+ * have settled into anyway (ring at scale 1, glyph at full opacity, blur at BLUR_INTENSITY) rather
45
+ * than on frame zero, which for the blur would have meant a fully transparent overlay. Shared
46
+ * values are state-backed so a write from an effect actually reaches the next render; reanimated's
47
+ * own values do not need this because they drive the UI thread directly. The kit's own
48
+ * AccessibilityInfo-backed `useReducedMotion` stands in for the peer's, so the reduce-motion
49
+ * contract in ai_rules/rules/performance.md §3 holds with or without the peer.
50
+ *
51
+ * This is also what `ai_rules/rules/frequent_rules.md` #1 prescribes: when the kit needs motion
52
+ * without the optional peer, it uses core React Native, never Reanimated.
53
+ */
54
+ import {
55
+ createElement,
56
+ forwardRef,
57
+ useReducer,
58
+ useRef,
59
+ type ComponentType,
60
+ type ReactNode,
61
+ } from "react";
62
+ import { View, type ViewProps, type ViewStyle } from "react-native";
63
+
64
+ import { useReducedMotion as useOsReducedMotion } from "../motion/useReducedMotion";
65
+
66
+ // Metro injects a module-scoped `require`; it is ABSENT in a pure-ESM runtime (the kit's own tests
67
+ // run under `node --test` as ESM). Declared locally so this type-checks without ambient Node types;
68
+ // the `typeof` guard keeps the reference ESM-safe.
69
+ declare const require: ((id: string) => unknown) | undefined;
70
+
71
+ /** A `require`-like resolver. Injectable in tests; production uses the guarded literal require. */
72
+ export type OptionalRequire = (moduleName: string) => unknown;
73
+
74
+ /** A reanimated shared value, narrowed to the one member the kit uses. */
75
+ export interface SharedValue<T> {
76
+ value: T;
77
+ }
78
+
79
+ /** Props `Animated.View` accepts on top of a plain View (layout-animation descriptors). */
80
+ export interface AnimatedViewProps extends ViewProps {
81
+ entering?: unknown;
82
+ exiting?: unknown;
83
+ children?: ReactNode;
84
+ }
85
+
86
+ /** A component built by `createAnimatedComponent`: its own props plus reanimated's driven props. */
87
+ export type AnimatedComponent<P> = ComponentType<P & { animatedProps?: object }>;
88
+
89
+ /** The slice of reanimated the coachmark surfaces actually use. */
90
+ export interface ReanimatedApi {
91
+ /** True when the real peer resolved; false when this is the static fallback. */
92
+ present: boolean;
93
+ View: ComponentType<AnimatedViewProps>;
94
+ createAnimatedComponent: <P extends object>(component: ComponentType<P>) => AnimatedComponent<P>;
95
+ useSharedValue: <T>(initial: T) => SharedValue<T>;
96
+ useAnimatedStyle: (factory: () => ViewStyle) => ViewStyle;
97
+ useAnimatedProps: <P extends object>(factory: () => P) => P;
98
+ useReducedMotion: () => boolean;
99
+ withTiming: (toValue: number, config?: object) => number;
100
+ withRepeat: (animation: number, count?: number, reverse?: boolean) => number;
101
+ withSequence: (...animations: number[]) => number;
102
+ withDelay: (ms: number, animation: number) => number;
103
+ /** The `entering` / `exiting` descriptors, or undefined when the peer is absent. */
104
+ fadeIn: (durationMs: number) => unknown;
105
+ fadeOut: (durationMs: number) => unknown;
106
+ }
107
+
108
+ /**
109
+ * The production resolver. The specifier is a LITERAL so Metro collects it (see the header); the
110
+ * `moduleName` parameter exists only to keep the `OptionalRequire` seam shape, so anything other
111
+ * than the one module this file owns resolves to undefined.
112
+ */
113
+ const runtimeRequire: OptionalRequire = (moduleName) => {
114
+ if (moduleName !== "react-native-reanimated") return undefined;
115
+ if (typeof require !== "function") return undefined;
116
+ try {
117
+ return require("react-native-reanimated");
118
+ } catch {
119
+ return undefined;
120
+ }
121
+ };
122
+
123
+ /**
124
+ * TEST-ONLY seam. `SpotlightOverlay` / `GestureHint` have frozen public props, so neither can take
125
+ * a `requireModule` the way `WireIcon` does — this lets a component render exercise the PRESENT
126
+ * path. Production never sets it; `runtimeRequire` is the only resolver.
127
+ */
128
+ let testRequire: OptionalRequire | undefined;
129
+ export const __setReanimatedRequireForTests = (fn: OptionalRequire | undefined): void => {
130
+ testRequire = fn;
131
+ cached = undefined;
132
+ };
133
+
134
+ // ── the static fallback ───────────────────────────────────────────────────────
135
+
136
+ /**
137
+ * A shared value that re-renders its owner on write. Reanimated's own value drives the UI thread
138
+ * and deliberately does NOT re-render; here the render IS the only output, so a value written from
139
+ * an effect (`intensity.value = withTiming(26)`) has to reach it or the overlay would sit on frame
140
+ * zero forever — a fully transparent blur. The box identity is stable for the component's lifetime,
141
+ * exactly like the real one, so it stays safe in a dependency list.
142
+ */
143
+ const useStaticSharedValue = <T,>(initial: T): SharedValue<T> => {
144
+ const [, bump] = useReducer((n: number): number => n + 1, 0);
145
+ const box = useRef<SharedValue<T> | null>(null);
146
+ if (box.current === null) {
147
+ let current = initial;
148
+ box.current = {
149
+ get value(): T {
150
+ return current;
151
+ },
152
+ set value(next: T) {
153
+ if (Object.is(next, current)) return;
154
+ current = next;
155
+ bump();
156
+ },
157
+ };
158
+ }
159
+ return box.current;
160
+ };
161
+
162
+ /** Run a worklet body on the JS side. It must never throw into the render — degrade, don't crash. */
163
+ const runWorklet = <R extends object>(factory: () => R, fallback: R): R => {
164
+ try {
165
+ return factory() ?? fallback;
166
+ } catch {
167
+ return fallback;
168
+ }
169
+ };
170
+
171
+ /** `Animated.View` without the peer: a plain View, with the layout-animation props dropped. */
172
+ const StaticAnimatedView = forwardRef<View, AnimatedViewProps>(function StaticAnimatedView(
173
+ { entering: _entering, exiting: _exiting, children, ...rest },
174
+ ref,
175
+ ) {
176
+ return createElement(View, { ...rest, ref }, children);
177
+ });
178
+
179
+ /**
180
+ * `createAnimatedComponent` without the peer. The wrapper MERGES `animatedProps` into the real
181
+ * props, because that object is the only carrier for values the worklet would have driven (the
182
+ * spotlight's blur `intensity`). Built once per wrapped component at the call site's `useMemo`, so
183
+ * the returned type identity is stable and React never remounts the subtree.
184
+ */
185
+ const staticCreateAnimatedComponent = <P extends object>(
186
+ component: ComponentType<P>,
187
+ ): AnimatedComponent<P> =>
188
+ function StaticAnimatedComponent({ animatedProps, ...rest }) {
189
+ return createElement(component, { ...(rest as P), ...(animatedProps as object) });
190
+ };
191
+
192
+ /**
193
+ * The peer-absent API. Every animation resolves to its RESTING value, so components render the
194
+ * frame they would have settled on. Nothing here schedules work, allocates a native module, or
195
+ * throws.
196
+ */
197
+ const staticApi: ReanimatedApi = {
198
+ present: false,
199
+ View: StaticAnimatedView,
200
+ createAnimatedComponent: staticCreateAnimatedComponent,
201
+ useSharedValue: useStaticSharedValue,
202
+ useAnimatedStyle: (factory) => runWorklet(factory, {}),
203
+ useAnimatedProps: (factory) => runWorklet(factory, {} as never),
204
+ useReducedMotion: useOsReducedMotion,
205
+ withTiming: (toValue) => toValue,
206
+ withRepeat: (animation) => animation,
207
+ withSequence: (...animations) => animations[animations.length - 1] ?? 0,
208
+ withDelay: (_ms, animation) => animation,
209
+ fadeIn: () => undefined,
210
+ fadeOut: () => undefined,
211
+ };
212
+
213
+ // ── the real peer ─────────────────────────────────────────────────────────────
214
+
215
+ const isFunction = (value: unknown): boolean => typeof value === "function";
216
+
217
+ /** A React component is a function, or an object carrying `$$typeof` (memo / forwardRef). */
218
+ const isComponent = (value: unknown): boolean =>
219
+ typeof value === "function" || (!!value && typeof value === "object" && "$$typeof" in value);
220
+
221
+ /**
222
+ * Build the API from a resolved module namespace, or undefined when it is not the module we expect.
223
+ *
224
+ * The hooks are NAMED exports; `View` and `createAnimatedComponent` live on the DEFAULT export (the
225
+ * `Animated` object). A CJS interop wrapper puts everything one level down under `default`, so both
226
+ * are looked up on the namespace first and the default second. EVERY member is verified before the
227
+ * namespace is accepted: a partial module must degrade to the static fallback, never crash halfway
228
+ * through a render.
229
+ */
230
+ const fromModule = (mod: unknown): ReanimatedApi | undefined => {
231
+ if (!mod || typeof mod !== "object") return undefined;
232
+ const ns = mod as Record<string, unknown>;
233
+ const def =
234
+ ns.default && typeof ns.default === "object"
235
+ ? (ns.default as Record<string, unknown>)
236
+ : undefined;
237
+ const pick = (name: string): unknown => ns[name] ?? def?.[name];
238
+
239
+ const view = def?.View ?? ns.View;
240
+ const createAnimated = def?.createAnimatedComponent ?? ns.createAnimatedComponent;
241
+ const hooks = {
242
+ useSharedValue: pick("useSharedValue"),
243
+ useAnimatedStyle: pick("useAnimatedStyle"),
244
+ useAnimatedProps: pick("useAnimatedProps"),
245
+ useReducedMotion: pick("useReducedMotion"),
246
+ withTiming: pick("withTiming"),
247
+ withRepeat: pick("withRepeat"),
248
+ withSequence: pick("withSequence"),
249
+ withDelay: pick("withDelay"),
250
+ };
251
+ const fadeIn = pick("FadeIn");
252
+ const fadeOut = pick("FadeOut");
253
+
254
+ if (!isComponent(view) || !isFunction(createAnimated)) return undefined;
255
+ if (Object.values(hooks).some((member) => !isFunction(member))) return undefined;
256
+ if (!fadeIn || !fadeOut) return undefined;
257
+
258
+ // The single narrowing seam. Everything above proved the members exist and are callable; the peer
259
+ // is untyped here (it is resolved through `unknown`), so the cast happens once, at the boundary,
260
+ // and the exported signatures above are the contract from here on.
261
+ const api = {
262
+ present: true,
263
+ View: view,
264
+ createAnimatedComponent: createAnimated,
265
+ ...hooks,
266
+ fadeIn: (durationMs: number) => (fadeIn as { duration: (ms: number) => unknown }).duration(durationMs),
267
+ fadeOut: (durationMs: number) => (fadeOut as { duration: (ms: number) => unknown }).duration(durationMs),
268
+ } as unknown as ReanimatedApi;
269
+ return api;
270
+ };
271
+
272
+ /**
273
+ * Module-level memo. Holds the RESOLVED api (real or static), so an absent peer costs exactly one
274
+ * failed require per process, not one per mount — and, more importantly, so the implementation can
275
+ * never change between two renders of the same component, which would break the rules of hooks.
276
+ */
277
+ let cached: ReanimatedApi | undefined;
278
+
279
+ /** Reset the memo. TEST-ONLY seam — production never calls it. */
280
+ export const resetReanimatedModuleCache = (): void => {
281
+ cached = undefined;
282
+ };
283
+
284
+ /**
285
+ * Resolve the reanimated API, or the static fallback when the peer is absent/unresolvable.
286
+ * Never throws and never returns undefined: an absent animation library must degrade to a still
287
+ * frame, never break the tour.
288
+ */
289
+ export const resolveReanimated = (
290
+ requireModule: OptionalRequire = testRequire ?? runtimeRequire,
291
+ ): ReanimatedApi => {
292
+ try {
293
+ if (cached === undefined || requireModule !== runtimeRequire) {
294
+ const resolved = fromModule(requireModule("react-native-reanimated")) ?? staticApi;
295
+ // Don't poison the module memo from an injected test require.
296
+ if (requireModule === runtimeRequire) cached = resolved;
297
+ return resolved;
298
+ }
299
+ return cached;
300
+ } catch {
301
+ return staticApi;
302
+ }
303
+ };
304
+
305
+ // ── the call-site surface: the peer's own names, resolved lazily ──────────────
306
+ //
307
+ // Each one delegates on every call, so nothing is resolved at import time. `api()` is memoized, so
308
+ // the delegation is a property read.
309
+
310
+ const api = (): ReanimatedApi => resolveReanimated();
311
+
312
+ /**
313
+ * The `Animated` namespace, with `View` behind a getter so merely importing this module never
314
+ * touches the peer. The component identity it returns is stable (the peer's own `Animated.View`, or
315
+ * the module-level `StaticAnimatedView`), so React never remounts the tree under it.
316
+ */
317
+ export const Animated: {
318
+ readonly View: ComponentType<AnimatedViewProps>;
319
+ createAnimatedComponent: <P extends object>(component: ComponentType<P>) => AnimatedComponent<P>;
320
+ } = {
321
+ get View() {
322
+ return api().View;
323
+ },
324
+ createAnimatedComponent: (component) => api().createAnimatedComponent(component),
325
+ };
326
+
327
+ /** Layout-animation descriptors. `.duration()` is the only builder the kit uses. */
328
+ export const FadeIn = { duration: (durationMs: number): unknown => api().fadeIn(durationMs) };
329
+ export const FadeOut = { duration: (durationMs: number): unknown => api().fadeOut(durationMs) };
330
+
331
+ export const useSharedValue = <T,>(initial: T): SharedValue<T> => api().useSharedValue(initial);
332
+ export const useAnimatedStyle = (factory: () => ViewStyle): ViewStyle =>
333
+ api().useAnimatedStyle(factory);
334
+ export const useAnimatedProps = <P extends object>(factory: () => P): P =>
335
+ api().useAnimatedProps(factory);
336
+ export const useReducedMotion = (): boolean => api().useReducedMotion();
337
+ export const withTiming = (toValue: number, config?: object): number =>
338
+ api().withTiming(toValue, config);
339
+ export const withRepeat = (animation: number, count?: number, reverse?: boolean): number =>
340
+ api().withRepeat(animation, count, reverse);
341
+ export const withSequence = (...animations: number[]): number => api().withSequence(...animations);
342
+ export const withDelay = (ms: number, animation: number): number => api().withDelay(ms, animation);
@@ -27,6 +27,9 @@
27
27
  */
28
28
  import type { CoachmarkStorage } from "./types";
29
29
 
30
+ /** RN sets this global; absent under node/SSR. Read defensively via the async-adapter warn below. */
31
+ declare const __DEV__: boolean | undefined;
32
+
30
33
  /**
31
34
  * Well-known key into the runtime-global symbol registry — one coachmark runtime across bundles.
32
35
  *
@@ -166,6 +169,104 @@ export const showcaseGateKey = (showcaseId: string): string =>
166
169
  /** Marker value written for a seen gate. */
167
170
  const SEEN_VALUE = "1";
168
171
 
172
+ /** Key used only by the one-off synchronicity probe below. Never carries a meaningful value. */
173
+ const GATE_PROBE_KEY = "wire_gate_probe";
174
+
175
+ /** Once-per-process latch on its own `Symbol.for` slot, mirroring `gateStorageWarned` next door
176
+ * in reviews/runtime: a plain `let` would warn once per inlined bundle copy, and the gates live
177
+ * in three of them.
178
+ *
179
+ * @globalSlot LATCH — written `true` by the first probe that caught an ASYNC gate adapter, and
180
+ * never again. A second differing write re-arms a warning the process already spent, so a host
181
+ * whose broken adapter reaches three gates gets the same paragraph three times and stops reading. */
182
+ const ASYNC_GATE_STORAGE_WARNED_SLOT: unique symbol = Symbol.for(
183
+ "@wireai/activation:asyncGateStorageWarned",
184
+ );
185
+
186
+ type GlobalWithAsyncGateWarn = typeof globalThis & {
187
+ [ASYNC_GATE_STORAGE_WARNED_SLOT]?: boolean;
188
+ };
189
+
190
+ const asyncGateWarnGlobal = globalThis as GlobalWithAsyncGateWarn;
191
+
192
+ /**
193
+ * The substitute for an adapter whose `getItem` returned a thenable (an ASYNC adapter, e.g.
194
+ * AsyncStorage handed straight in). Every seen-gate reads SEEN and every write is dropped, so
195
+ * nothing shows and nothing fires — FAIL CLOSED, deliberately in both directions the async bug
196
+ * could err: a Promise is never `=== "1"`, so a raw async adapter reports every gate UNSEEN
197
+ * forever — tours and showcases replay on every open, and a review gate with `minSessions` 0/1
198
+ * RE-FIRES its prompt each open (the 2026-07-16 one-star incident class). Never-fire is the only
199
+ * direction a once-per-user gate may err in.
200
+ */
201
+ const closedGateStorage: CoachmarkStorage = Object.freeze({
202
+ getItem: (): string => SEEN_VALUE,
203
+ setItem: (): void => {},
204
+ });
205
+
206
+ /** Adapters probed sync (or throwing, which every reader already tolerates): pass through. */
207
+ const syncGateAdapters = new WeakSet<CoachmarkStorage>();
208
+ /** Adapters caught returning a thenable: substituted, and never probed again. */
209
+ const asyncGateAdapters = new WeakSet<CoachmarkStorage>();
210
+
211
+ /** Warn once, in dev builds only, when a gate adapter turned out to be async. */
212
+ const warnAsyncGateStorage = (): void => {
213
+ if (asyncGateWarnGlobal[ASYNC_GATE_STORAGE_WARNED_SLOT]) return;
214
+ if (typeof __DEV__ === "undefined" || !__DEV__) return;
215
+ if (typeof console === "undefined" || !console.warn) return;
216
+ asyncGateWarnGlobal[ASYNC_GATE_STORAGE_WARNED_SLOT] = true;
217
+ console.warn(
218
+ "[wireai] the gate storage's getItem returned a Promise — an async adapter (e.g. AsyncStorage) " +
219
+ "cannot drive the once-per-user gates, so coachmark tours, showcases and the review/questionnaire " +
220
+ "gates are all DISABLED (fail closed: nothing shows, nothing fires) until a synchronous adapter " +
221
+ "is provided. Pass a sync adapter (e.g. MMKV: `{ getItem: (k) => mmkv.getString(k) ?? null, " +
222
+ "setItem: (k, v) => mmkv.set(k, v) }`) to CoachmarkProvider or the gate's `storage` prop.",
223
+ );
224
+ };
225
+
226
+ /** Test-only: forget the once-per-process async-adapter warn latch. */
227
+ export const resetAsyncGateStorageWarning = (): void => {
228
+ asyncGateWarnGlobal[ASYNC_GATE_STORAGE_WARNED_SLOT] = undefined;
229
+ };
230
+
231
+ /**
232
+ * The thenable probe (fail closed): resolve an adapter to itself when its `getItem` is
233
+ * synchronous, and to `closedGateStorage` when it returns a thenable. Called on every gate READ
234
+ * rather than at injection, so (a) a host handing storage straight to a gate's `storage` prop is
235
+ * probed too, and (b) `CoachmarkProvider`'s unmount cleanup — which compares the singleton to its
236
+ * own prop by IDENTITY — keeps seeing the object the host passed. Verdicts are WeakSet-cached, so
237
+ * each adapter pays for exactly one probe read.
238
+ */
239
+ export const validateGateStorage = (
240
+ storage: CoachmarkStorage | null,
241
+ ): CoachmarkStorage | null => {
242
+ if (!storage) return null;
243
+ if (syncGateAdapters.has(storage)) return storage;
244
+ if (asyncGateAdapters.has(storage)) return closedGateStorage;
245
+ let probed: unknown;
246
+ try {
247
+ probed = storage.getItem(GATE_PROBE_KEY);
248
+ } catch {
249
+ // A throwing adapter is synchronous, which is all the probe rules on; every reader already
250
+ // tolerates the throw itself.
251
+ syncGateAdapters.add(storage);
252
+ return storage;
253
+ }
254
+ if (probed != null && typeof (probed as { then?: unknown }).then === "function") {
255
+ asyncGateAdapters.add(storage);
256
+ try {
257
+ // Detach the probe's promise so an adapter that REJECTS (e.g. a broken native store) cannot
258
+ // surface as an unhandled rejection the host never scheduled.
259
+ (probed as PromiseLike<unknown>).then(undefined, () => {});
260
+ } catch {
261
+ // A hostile thenable whose `then` throws is still async; the verdict stands.
262
+ }
263
+ warnAsyncGateStorage();
264
+ return closedGateStorage;
265
+ }
266
+ syncGateAdapters.add(storage);
267
+ return storage;
268
+ };
269
+
169
270
  /**
170
271
  * Read a seen gate. Testing mode ALWAYS reports unseen (so everything replays).
171
272
  * A missing storage (no provider) also reports unseen — the safe default is to
@@ -179,7 +280,7 @@ export const hasSeenGate = (
179
280
  testingOverride?: boolean,
180
281
  ): boolean => {
181
282
  if (testingOverride ?? isCoachmarkTesting()) return false;
182
- const storage = storageOverride ?? getCoachmarkStorage();
283
+ const storage = validateGateStorage(storageOverride ?? getCoachmarkStorage());
183
284
  if (!storage) return false;
184
285
  try {
185
286
  return storage.getItem(key) === SEEN_VALUE;
@@ -198,7 +299,7 @@ export const markSeenGate = (
198
299
  testingOverride?: boolean,
199
300
  ): void => {
200
301
  if (testingOverride ?? isCoachmarkTesting()) return;
201
- const storage = storageOverride ?? getCoachmarkStorage();
302
+ const storage = validateGateStorage(storageOverride ?? getCoachmarkStorage());
202
303
  if (!storage) return;
203
304
  try {
204
305
  storage.setItem(key, SEEN_VALUE);
@@ -31,8 +31,13 @@ export interface TargetRect {
31
31
 
32
32
  /**
33
33
  * The host-injected gate storage. SYNCHRONOUS on purpose — a coachmark's
34
- * "seen?" gate must resolve during render with no async flash (otherwise a ring
35
- * momentarily paints before the read resolves and says "already seen"). MMKV's
34
+ * "seen?" gate must resolve during render. This is ENFORCED, fail closed: a
35
+ * probe on first read catches an adapter whose `getItem` returns a thenable
36
+ * (AsyncStorage handed straight in) and substitutes a closed adapter — every
37
+ * gate reads "seen", nothing shows, nothing fires, one dev warning names the
38
+ * fix (see `validateGateStorage` in ./runtime). A raw async adapter would
39
+ * otherwise report every gate unseen forever: tours replay each open, and a
40
+ * review gate with `minSessions` 0/1 re-fires each open. MMKV's
36
41
  * `getString`/`set` fit as-is; an in-memory `Map` wrapper works for tests. This
37
42
  * is deliberately separate from the async `WireOnboardingStorage` used for
38
43
  * session persistence.
@@ -291,3 +291,35 @@ export const resetAutoDeviceKeys = (): void => {
291
291
  registry.hydrating.clear();
292
292
  registry.pending?.clear();
293
293
  };
294
+
295
+ /**
296
+ * The auto `device_key` a surface with NO `appId` and NO `storage` of its own may stamp onto a
297
+ * payload — the review gate being the one that exists (`ReviewGate` takes neither prop).
298
+ *
299
+ * WHY IT IS NOT JUST `resolveAutoDeviceKey()`. That function is keyed by `appId`, and `appId` is a
300
+ * REQUIRED field on `WireOnboardingConfig` that real hosts set to a real value. A propless caller
301
+ * can only pass `undefined`, which addresses the `"default"` slot — so on any host that names its
302
+ * app, a bare call would MINT a second id beside the one every other surface already shares. That is
303
+ * the halved-counter defect this registry exists to close (see the registry note above), arrived at
304
+ * from the other direction.
305
+ *
306
+ * So this READS and does not address:
307
+ * • exactly one id space in the process → that id, whoever registered it. This is every
308
+ * single-tenant app, i.e. every real app, and it is how the gate joins the analytics surfaces.
309
+ * • none at all → mint one through {@link resolveAutoDeviceKey}. Nothing exists to collide with,
310
+ * so `"default"` is not a second space, it is the first. PROCESS-scoped (there is no storage to
311
+ * persist through), which is honest and still resolvable: a review row is written once per user,
312
+ * so a per-launch value here cannot corrupt a counter the way it corrupts `min_sessions`.
313
+ * • two or more → `undefined`. Two tenants in one process must never share an id, and a caller
314
+ * with no `appId` cannot say which one it belongs to. Refusing is the only safe answer;
315
+ * guessing would stamp one tenant's device onto the other's row.
316
+ *
317
+ * Never throws, never mints a SECOND id, and never overrides anything a host supplied — the caller
318
+ * checks for a host value first.
319
+ */
320
+ export const ambientAutoDeviceKey = (): string | undefined => {
321
+ const registry = autoDeviceKeyRegistry();
322
+ if (registry.keys.size === 0) return resolveAutoDeviceKey();
323
+ if (registry.keys.size > 1) return undefined;
324
+ return [...registry.keys.values()][0];
325
+ };