@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.
- package/AGENTS.md +13 -10
- package/CHANGELOG.md +130 -0
- package/INTEGRATION_PROMPT.md +14 -3
- package/README.md +40 -2
- package/dist/analytics/index.d.mts +5 -4
- package/dist/analytics/index.d.ts +5 -4
- package/dist/analytics/index.js +39 -24
- package/dist/analytics/index.js.map +1 -1
- package/dist/analytics/index.mjs +39 -24
- package/dist/analytics/index.mjs.map +1 -1
- package/dist/coachmarks/index.d.mts +16 -0
- package/dist/coachmarks/index.d.ts +16 -0
- package/dist/coachmarks/index.js +19 -13
- package/dist/coachmarks/index.js.map +1 -1
- package/dist/coachmarks/index.mjs +19 -13
- package/dist/coachmarks/index.mjs.map +1 -1
- package/dist/{currentSession-DsSDHqor.d.mts → currentSession-BlCeDP0f.d.mts} +59 -5
- package/dist/{currentSession-D6RiVtc8.d.ts → currentSession-BxEB37xt.d.ts} +59 -5
- package/dist/index.d.mts +10 -20
- package/dist/index.d.ts +10 -20
- package/dist/index.js +301 -187
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +300 -188
- package/dist/index.mjs.map +1 -1
- package/dist/questionnaire/index.js +41 -19
- package/dist/questionnaire/index.js.map +1 -1
- package/dist/questionnaire/index.mjs +41 -19
- package/dist/questionnaire/index.mjs.map +1 -1
- package/dist/reviews/index.js +41 -19
- package/dist/reviews/index.js.map +1 -1
- package/dist/reviews/index.mjs +41 -19
- package/dist/reviews/index.mjs.map +1 -1
- package/dist/showcase/index.js +15 -6
- package/dist/showcase/index.js.map +1 -1
- package/dist/showcase/index.mjs +15 -6
- package/dist/showcase/index.mjs.map +1 -1
- package/llms.txt +2 -0
- package/package.json +1 -1
- package/src/WireOnboarding.tsx +140 -5
- package/src/activation/wireActivation.ts +8 -1
- package/src/analytics/analyticsFacade.ts +15 -11
- package/src/analytics/reportClientEvent.ts +11 -7
- package/src/coachmarks/runtime.ts +53 -17
- package/src/config/wireConfigFromEnv.ts +46 -2
- package/src/context/deviceId.ts +82 -18
- package/src/index.ts +9 -1
- package/src/questionnaire/runtime.ts +1 -0
- package/src/questionnaire/useQuestionnaireGate.ts +8 -3
- package/src/reviews/runtime.ts +68 -7
- package/src/reviews/useReviewGate.ts +8 -3
- package/src/session-analytics/useLifecycleEvents.ts +57 -27
- package/src/session-analytics/useSessionStart.ts +37 -1
- 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 {
|
|
30
|
+
import { ensureCurrentSessionId } from "./currentSession";
|
|
31
31
|
import { createEventQueue, type EventQueue, type EventQueueOptions } from "./eventQueue";
|
|
32
|
-
import {
|
|
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
|
-
*
|
|
74
|
-
*
|
|
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
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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 =>
|
|
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 ??
|
|
77
|
-
const storage = storageOverride ??
|
|
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 ??
|
|
96
|
-
const storage = storageOverride ??
|
|
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
|
-
|
|
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
|
};
|
package/src/context/deviceId.ts
CHANGED
|
@@ -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
|
|
63
|
-
//
|
|
64
|
-
//
|
|
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`,
|
|
70
|
-
|
|
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
|
-
|
|
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 {
|
|
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";
|
|
@@ -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
|
-
|
|
83
|
-
|
|
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.
|
package/src/reviews/runtime.ts
CHANGED
|
@@ -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
|
|
89
|
-
*
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
85
|
-
|
|
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.
|