@wireai/activation 0.15.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +95 -20
- package/CHANGELOG.md +707 -0
- package/INTEGRATION_PROMPT.md +61 -23
- package/README.md +100 -25
- package/dist/analytics/index.d.mts +32 -10
- package/dist/analytics/index.d.ts +32 -10
- package/dist/analytics/index.js +288 -127
- package/dist/analytics/index.js.map +1 -1
- package/dist/analytics/index.mjs +288 -127
- package/dist/analytics/index.mjs.map +1 -1
- package/dist/coachmarks/index.d.mts +14 -0
- package/dist/coachmarks/index.d.ts +14 -0
- package/dist/coachmarks/index.js +73 -20
- package/dist/coachmarks/index.js.map +1 -1
- package/dist/coachmarks/index.mjs +73 -20
- package/dist/coachmarks/index.mjs.map +1 -1
- package/dist/{currentSession-orZy5p1e.d.mts → currentSession-Bz7G6lno.d.mts} +25 -35
- package/dist/{currentSession-CFSRZ2wg.d.ts → currentSession-z-CZ55ad.d.ts} +25 -35
- package/dist/index.d.mts +5 -2
- package/dist/index.d.ts +5 -2
- package/dist/index.js +125 -36
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +125 -36
- package/dist/index.mjs.map +1 -1
- package/dist/questionnaire/index.d.mts +0 -13
- package/dist/questionnaire/index.d.ts +0 -13
- package/dist/questionnaire/index.js +154 -43
- package/dist/questionnaire/index.js.map +1 -1
- package/dist/questionnaire/index.mjs +155 -44
- package/dist/questionnaire/index.mjs.map +1 -1
- package/dist/reviews/index.js +159 -91
- package/dist/reviews/index.js.map +1 -1
- package/dist/reviews/index.mjs +160 -92
- package/dist/reviews/index.mjs.map +1 -1
- package/dist/showcase/index.js +59 -18
- package/dist/showcase/index.js.map +1 -1
- package/dist/showcase/index.mjs +60 -19
- package/dist/showcase/index.mjs.map +1 -1
- package/llms.txt +9 -9
- package/package.json +6 -9
- package/src/analytics/currentSession.ts +141 -4
- package/src/analytics/index.ts +6 -1
- package/src/analytics/reportClientEvent.ts +19 -10
- package/src/analytics/useAnalytics.ts +74 -15
- package/src/analytics/wireDoctor.ts +152 -7
- package/src/coachmarks/CoachmarkProvider.tsx +26 -5
- package/src/coachmarks/runtime.ts +53 -0
- package/src/coachmarks/useCoachmarkTour.ts +51 -1
- package/src/context/deviceId.ts +49 -15
- package/src/features/WireFeaturesProvider.tsx +72 -12
- package/src/features/fetchWireFeatures.ts +49 -11
- package/src/features/useWireFeatures.ts +39 -3
- package/src/identity/identityRecord.ts +15 -2
- package/src/questionnaire/QuestionnaireGate.tsx +40 -1
- package/src/questionnaire/transport.ts +22 -8
- package/src/questionnaire/useQuestionnaireGate.ts +58 -7
- package/src/reviews/ReviewGate.tsx +39 -0
- package/src/reviews/idempotency.ts +38 -0
- package/src/reviews/runtime.ts +39 -10
- package/src/reviews/transport.ts +22 -8
- package/src/reviews/useReviewGate.ts +57 -7
- package/src/session-analytics/lifecycle.ts +16 -0
- package/src/session-analytics/useLifecycleEvents.ts +30 -2
- package/src/session-analytics/useSessionStart.ts +22 -2
- package/src/showcase/FeatureShowcase.tsx +50 -3
- package/src/types.ts +5 -4
- package/src/utils/withDeadline.ts +70 -0
package/llms.txt
CHANGED
|
@@ -8,19 +8,19 @@
|
|
|
8
8
|
|
|
9
9
|
## What an agent needs to know
|
|
10
10
|
|
|
11
|
-
- **Install:** `npm install @wireai/activation wireai-rn` (react + react-native are peers).
|
|
11
|
+
- **Install:** `npm install @wireai/activation wireai-rn zod react-native-safe-area-context` (react + react-native are peers).
|
|
12
12
|
- **Metro (required):** `module.exports = withWireOnboarding(getDefaultConfig(__dirname))` from `@wireai/activation/metro`, which pins one copy of react/react-native/wireai-rn/zod (prevents the dual-React crash).
|
|
13
|
-
- **Two secrets:** an app `apiKey` (a `wai_…` key that resolves the tenant server-side) and the backend `serverUrl`.
|
|
14
|
-
- **Render:** drop `<WireOnboarding config={
|
|
13
|
+
- **Two secrets:** an app `apiKey` (a `wai_…` key that resolves the tenant server-side) and the backend `serverUrl`. Both come from the console: sign up at https://getwireai.com/signup (app name + email; the confirmation link is where you choose a password, and that one step creates your first app and its key and lands you in the console already signed in), then read both off that app's Integrate tab at `https://getwireai.com/console/<app-id>/integrate`, where the key is a copy field and the server URL is the `serverUrl` line of the init snippet. The key stays readable there on every visit. Sign back in later at https://getwireai.com/login; register further apps at https://getwireai.com/console/register. Keys are per environment (dev / prod / staging). Nothing renders without both.
|
|
14
|
+
- **Render:** build the config once and gate on it (`wireConfigFromEnv` returns `null` when the env is unset, and `config` is a required non-null prop), then drop `<WireOnboarding config={config} theme={...} onComplete={persist} fallbackFlow={<YourStaticOnboarding/>} ... />` into the signup flow.
|
|
15
15
|
- **The join key.** Pass `userContext={activationJoinContext(deviceKey)}` on `<WireOnboarding>`, or, when the app owns no device id, pass a working `storage` and leave `userContext` alone so the kit injects its own (⛔ never hand-build it from the synchronous `resolveAutoDeviceKey`, which cannot report whether the id survives the launch). `user_context.device_key` is the ONLY thing that joins an onboarding session to the app's later events; omit it and the `activated` funnel reads zero with no error.
|
|
16
|
-
- **Lifecycle (mount it once):** `useLifecycleEvents(
|
|
16
|
+
- **Lifecycle (mount it once):** `useLifecycleEvents({ serverUrl, apiKey, appId, storage }, { deviceKey, sessionCount, userId })` at the app root, before anything else touches analytics. The first argument is a `LifecycleConfig` built from explicit fields, NOT the `WireOnboardingConfig` (`wireConfigFromEnv` can return `null`, and `storage` is not a config field). It is the only path that emits `app.first_open`, it registers the per-open session id `createAnalytics` / `wire.track` / the gates all correlate to, and with a `storage` adapter in that first argument it stamps the persisted auto `device_key` on `app.session_started`, which is what the server counts `min_sessions` from. Storage that throws or rejects counts as no storage since 0.14.0: the events fire with no auto key rather than a per-launch one. `sessionCount` in the second argument is the HOST's obligation: a counter the app persists and increments once per open, starting at 1. A hardcoded `1` compiles and pins every user at `returning: false` forever with no error anywhere. `useSessionStart` / `reportSessionStart` are the alternatives for a host that already owns an open counter; neither emits `first_open`, and `useSessionStart` follows the same auto-key rule since 0.14.0.
|
|
17
17
|
- **Capture is deterministic.** `onComplete` returns an `OnboardingResult`: `answers` and `raw` always, plus (since 0.13.6) `plan` when the backend sent an onboarding plan and `variant` when the tenant runs an experiment and the backend assigned an arm. No second LLM call. Persist all answers through the app's normal profile-update path. Those two extra keys are set ONLY when the backend actually sent them, so a run without them returns exactly the object earlier versions returned, and the kit interprets neither: validate `plan` before applying it.
|
|
18
18
|
- **It can never break onboarding.** Pass `fallbackFlow` (your existing static onboarding) so a backend error/timeout degrades instead of dead-ending.
|
|
19
19
|
|
|
20
20
|
## Helpers (the reusable substrate)
|
|
21
21
|
|
|
22
22
|
- `wireConfigFromEnv({ appId, metadata? })` → config or null (reads `EXPO_PUBLIC_WIREAI_API_KEY`/`_SERVER_URL`/`_APP_ID`).
|
|
23
|
-
- `isOnboardingEnabled({ remote? })` → the standard gate (transport present: apiKey + serverUrl). No on/off env flag; `remote` is an optional kill-switch the app passes in.
|
|
23
|
+
- `isOnboardingEnabled({ remote?, config? })` → the standard gate (transport present: apiKey + serverUrl). No on/off env flag; `remote` is an optional kill-switch the app passes in, and `config` forwards `WireConfigOverrides` (a programmatic apiKey/serverUrl) to the transport-present check.
|
|
24
24
|
- `WIRE_ONBOARDING_EVENTS` + `toAnalyticsEvent(event)` → canonical `wire_onboarding_*` funnel names; log via the app's own analytics.
|
|
25
25
|
- `attributionMetadata(a)` → shape install/ad attribution into `config.metadata` (forwarded to the agent).
|
|
26
26
|
- `reportClientEvent(target, event)` / `reportClientEvents` / `makeSessionId` (ROOT-exported, not a subpath) → report device-only funnel events. Contract: `POST {serverUrl}/v1/events`, header `Authorization: Bearer {apiKey}`, body `{ "events": [ ... ] }`; `target = { serverUrl, apiKey }` from the config. `<WireOnboarding>` does this automatically: `dropped` on unmount-without-complete, `client_fallback` on degrade-to-static. Hosts must not double-report fallback.
|
|
@@ -30,10 +30,10 @@
|
|
|
30
30
|
|
|
31
31
|
The package is more than `<WireOnboarding>`. Tree-shakeable subpath exports (each keeps the onboarding UI out of an analytics-only bundle):
|
|
32
32
|
|
|
33
|
-
- `@wireai/activation/analytics`: `createAnalytics({ serverUrl, apiKey, storage })` (Segment/PostHog-shaped `track` / `screen` / `identify` / `setUserContext` / `reset` over an offline-first queue), `createScreenTracker` / `screenTrackingHandler` (auto screen views), `reportAppEvent`, `createEventQueue`, `clearUserContext` (logout). Rich `WireUserContext` supports an opt-in `userEmail` (raw by default, `hashEmail` to fold) and auto-mints a persisted per-install `device_key`. `identify` refuses email-shaped ids unless `allowEmailAsUserId`. See the README "Rich user context & PII" section. Also `wireDoctor({ target, storage })`, the DEV-ONLY self-check:
|
|
34
|
-
- `@wireai/activation/reviews`: the in-app review gate. `useReviewGate`, `fetchReviewDecision` (server AI seam; a `{fire:false}` survives intact), `ReviewGate` UI. `minSessions` defaults to 2 (never prompts on the first session; a server decision still overrides).
|
|
33
|
+
- `@wireai/activation/analytics`: `createAnalytics({ serverUrl, apiKey, storage })` (Segment/PostHog-shaped `track` / `screen` / `identify` / `setUserContext` / `reset` over an offline-first queue), `createScreenTracker` / `screenTrackingHandler` (auto screen views), `reportAppEvent`, `createEventQueue`, `clearUserContext` (logout). Rich `WireUserContext` supports an opt-in `userEmail` (raw by default, `hashEmail` to fold) and auto-mints a persisted per-install `device_key`. `identify` refuses email-shaped ids unless `allowEmailAsUserId`. See the README "Rich user context & PII" section. Also `wireDoctor({ target, storage, join })`, the DEV-ONLY self-check: five `{name, ok, detail}` checks (target shape, server reachability, storage writability, a `dry_run` round trip whose verdict is `written === 1 && skipped === 0` read from the ack body because `/v1/events` answers 200 for a batch it discards, and `join_key`). It never throws, never prints any part of the apiKey or the device key, and does nothing at all when `__DEV__` is unset. `join_key` answers whether the session will carry a `user_context.device_key` (host-supplied or the kit's auto-injected one), and it needs the `join: { appId, userContext, autoJoinKey }` argument; omitting `join` FAILS that check rather than skipping it. It is strictly read-only: no mint, no identity-registry write, nothing persisted. Read the scope of a green report: it now says events reach the server AND can be joined. `wireDoctor` still does not check that the lifecycle hook is mounted and does not check `min_sessions`, so an integration missing either reports all-green while its `activated` funnel stays permanently at zero.
|
|
34
|
+
- `@wireai/activation/reviews`: the in-app review gate. `useReviewGate`, `fetchReviewDecision` (server AI seam; a `{fire:false}` survives intact), `ReviewGate` UI. `minSessions` defaults to 2 (never prompts on the first session; a server decision still overrides). THE GATE NEEDS SYNCHRONOUS STORAGE: pass `storage` to the hook, or mount `CoachmarkProvider` with one, as a `CoachmarkStorage` whose `getItem(key)` returns `string | null` and not a Promise. That is a DIFFERENT shape from `<WireOnboarding storage>`, which takes the AsyncStorage-style adapter. Without it the app-open counter is pinned at 1 forever, the fail-closed `minSessions` rule can never be met, and the gate silently never fires; an async adapter is detected and substituted with a fail-closed store, same outcome. The hook's `config` is the GATE's config (it needs an `id`), not the transport config, and `decision` takes `served ?? undefined` because the fetch resolves `ReviewDecisionResponse | null`. `<ReviewGate>` has no `gate` prop: render it on `gate.visible` and pass `onShown={gate.markShown}` + `onResolved={gate.markResolved}`, which are what persist the cooldown and the once-per-version seen gate. Omit them and it compiles, shows, and re-prompts forever. Full wiring: the "Wiring `<ReviewGate>`" block in AGENTS.md.
|
|
35
35
|
- `@wireai/activation/questionnaire`: the pre-onboarding questionnaire gate. `useQuestionnaireGate`, `fetchQuestionnaireDecision` (same server seam; a body without a boolean `fire` resolves to null, never shows).
|
|
36
|
-
- `createRevenueCatBridge({ analytics, entitlementId })` (ROOT-exported) → the RevenueCat purchase funnel: `paywallShown` / `checkoutStarted` / `purchaseCompleted` / `purchaseFailed` / `purchasesRestored` / `syncPlanTier`, emitting the canonical `wire_paywall_shown` / `wire_checkout_started` / `wire_purchase_completed` / `wire_purchase_failed` / `wire_purchase_restored` names. The kit does NOT depend on `react-native-purchases` (a native module); the RevenueCat objects are typed structurally, so a host passes the real ones. THE JOIN KEY between a purchase and an onboarding is `user_context.device_key`, never `session_id` (an onboarding session id is the A2A contextId, an app-event session id is the per-open id, and they are separate spaces): pass the same device key to `createAnalytics`/`createWireActivation` AND to `<WireOnboarding userContext={activationJoinContext(deviceKey)} />`.
|
|
36
|
+
- `createRevenueCatBridge({ analytics, entitlementId })` (ROOT-exported) → the RevenueCat purchase funnel: `paywallShown` / `checkoutStarted` / `purchaseCompleted` / `purchaseFailed` / `purchasesRestored` / `syncPlanTier`, emitting the canonical `wire_paywall_shown` / `wire_checkout_started` / `wire_purchase_completed` / `wire_purchase_failed` / `wire_purchase_restored` names. The kit does NOT depend on `react-native-purchases` (a native module); the RevenueCat objects are typed structurally, so a host passes the real ones. THE JOIN KEY between a purchase and an onboarding is `user_context.device_key`, never `session_id` (an onboarding session id is the A2A contextId, an app-event session id is the per-open id, and they are separate spaces): pass the same device key to `createAnalytics`/`createWireActivation` AND to `<WireOnboarding userContext={activationJoinContext(deviceKey)} ... />`.
|
|
37
37
|
- `useWireActivation({ serverUrl, apiKey, deviceKey? })` → `{ track, sessionId, revalidation }` (ROOT-exported; React-free factory `createWireActivation(config)`). `await track(name, meta?)` POSTs an `app_event` (`question_key=name`) under the current session, resolves `true` on 2xx, and bumps `revalidation`; list `revalidation` in a `fetchReviewDecision` / `fetchQuestionnaireDecision` effect's deps so a review/questionnaire gate re-fetches and fires off an in-app action instead of the host hand-rolling session-id + await-POST + revalidate.
|
|
38
38
|
|
|
39
39
|
## Files
|
|
@@ -45,4 +45,4 @@ The package is more than `<WireOnboarding>`. Tree-shakeable subpath exports (eac
|
|
|
45
45
|
|
|
46
46
|
## Fastest path
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
Paste [INTEGRATION_PROMPT.md](./INTEGRATION_PROMPT.md) into a coding agent. It ships in this package and drives the whole integration: detect the app's conventions, wire Metro, derive a theme, scaffold the screen, wire a fallback, and type-check.
|
package/package.json
CHANGED
|
@@ -1,17 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wireai/activation",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Premium, fully-themable drop-in AI onboarding kit for React Native / Expo, on top of the open-source wireai-rn SDK.",
|
|
6
6
|
"author": "Malik Chohra <malik@getwireai.com>",
|
|
7
7
|
"license": "MIT",
|
|
8
|
-
"homepage": "https://
|
|
9
|
-
"repository": {
|
|
10
|
-
"type": "git",
|
|
11
|
-
"url": "git+https://github.com/chohra-med/wireai-onboarding.git"
|
|
12
|
-
},
|
|
8
|
+
"homepage": "https://getwireai.com",
|
|
13
9
|
"bugs": {
|
|
14
|
-
"url": "https://
|
|
10
|
+
"url": "https://getwireai.com/console/support",
|
|
11
|
+
"email": "malik@getwireai.com"
|
|
15
12
|
},
|
|
16
13
|
"keywords": [
|
|
17
14
|
"react-native",
|
|
@@ -137,12 +134,12 @@
|
|
|
137
134
|
"scripts": {
|
|
138
135
|
"build": "tsup",
|
|
139
136
|
"prepack": "npm run build",
|
|
140
|
-
"prepublishOnly": "npm run build && npm
|
|
137
|
+
"prepublishOnly": "npm run build && npm test",
|
|
141
138
|
"typecheck": "tsc --noEmit",
|
|
142
139
|
"pretest": "npm run build",
|
|
143
140
|
"test": "node --test --experimental-strip-types --import ./test/register-ts-resolver.mjs \"src/**/*.test.ts\" \"test/canary/**/*.test.tsx\"",
|
|
144
141
|
"test:canary": "node --test --experimental-strip-types --import ./test/register-ts-resolver.mjs \"test/canary/**/*.test.tsx\"",
|
|
145
|
-
"check:contract": "node --no-warnings --experimental-strip-types scripts/check-events-contract.mjs",
|
|
142
|
+
"check:contract": "node --no-warnings --experimental-strip-types --import ./test/register-ts-resolver.mjs scripts/check-events-contract.mjs",
|
|
146
143
|
"pack:tarball": "npm pack"
|
|
147
144
|
},
|
|
148
145
|
"peerDependencies": {
|
|
@@ -77,13 +77,93 @@ type GlobalWithSlot = typeof globalThis & {
|
|
|
77
77
|
|
|
78
78
|
const globalSlot = globalThis as GlobalWithSlot;
|
|
79
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Subscribers to be told when the id above CHANGES.
|
|
82
|
+
*
|
|
83
|
+
* ── WHY A REGISTRY THAT ONLY EMITS IS NOT ENOUGH ─────────────────────────────────────────────────
|
|
84
|
+
* The gates' app-open counter is bumped from a `useState` INITIALIZER, i.e. exactly once per
|
|
85
|
+
* component instance. `bumpSessionCount` itself follows real app-opens correctly, but nothing ever
|
|
86
|
+
* calls it a second time on a screen that stays mounted — and iOS suspends rather than kills, so a
|
|
87
|
+
* home screen holding `useReviewGate` outlives many opens. `useLifecycleEvents` registers each one
|
|
88
|
+
* here and the SERVER's `min_sessions` advances, while `wire_review_<id>_sessions` sits at its launch
|
|
89
|
+
* value and the LOCAL `minSessions` rule is unsatisfiable for the life of the process. Silent, and
|
|
90
|
+
* fails toward a prompt that never shows.
|
|
91
|
+
*
|
|
92
|
+
* The fix is not a second counter: it is that this module — the one place that knows an open was
|
|
93
|
+
* registered — says so. The gates subscribe (through `useSyncExternalStore`) and re-ask
|
|
94
|
+
* `bumpSessionCount`, which was already idempotent per open, so a notification that names the SAME
|
|
95
|
+
* open changes nothing.
|
|
96
|
+
*
|
|
97
|
+
* EVERY write to the id slot notifies, including `ensureCurrentSessionId`'s mint and the test-only
|
|
98
|
+
* reset: `getCurrentSessionId` is the `useSyncExternalStore` snapshot, and a snapshot that can change
|
|
99
|
+
* without a notification is a torn read, not an optimisation.
|
|
100
|
+
*
|
|
101
|
+
* @globalSlot LATCH — the listener SET is created once and its identity is then stable, for the same
|
|
102
|
+
* reason `activation/revalidation`'s store is: a second write replaces the set every existing
|
|
103
|
+
* subscriber registered in, so their unsubscribes free the wrong set and a registration reaches
|
|
104
|
+
* nobody — silently, which is the whole failure class this seam exists to close. Its CONTENTS are
|
|
105
|
+
* live and mutated in place, which is why every accessor goes through `listeners()`.
|
|
106
|
+
*/
|
|
107
|
+
const SESSION_ID_LISTENERS_SLOT: unique symbol = Symbol.for(
|
|
108
|
+
"@wireai/activation:currentSessionIdListeners",
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
type GlobalWithListeners = typeof globalThis & {
|
|
112
|
+
[SESSION_ID_LISTENERS_SLOT]?: Set<() => void>;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const listenerSlot = globalThis as GlobalWithListeners;
|
|
116
|
+
|
|
117
|
+
/** The ONE shared listener set, lazily created on the runtime global (never a second copy per bundle). */
|
|
118
|
+
const listeners = (): Set<() => void> => {
|
|
119
|
+
const existing = listenerSlot[SESSION_ID_LISTENERS_SLOT];
|
|
120
|
+
if (existing) return existing;
|
|
121
|
+
const created = new Set<() => void>();
|
|
122
|
+
listenerSlot[SESSION_ID_LISTENERS_SLOT] = created;
|
|
123
|
+
return created;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Write the slot and, only on a REAL change, tell every subscriber. The single writer for all three
|
|
128
|
+
* public entry points, so "the snapshot moved" and "the subscribers were told" can never drift.
|
|
129
|
+
*/
|
|
130
|
+
const writeCurrentSessionId = (id: string | undefined): void => {
|
|
131
|
+
if (globalSlot[CURRENT_SESSION_ID_SLOT] === id) return;
|
|
132
|
+
globalSlot[CURRENT_SESSION_ID_SLOT] = id;
|
|
133
|
+
// Iterate a snapshot so a listener that (un)subscribes during notify never corrupts the walk, and
|
|
134
|
+
// isolate each one: a throwing subscriber must not stop the others (fail-safe).
|
|
135
|
+
for (const listener of Array.from(listeners())) {
|
|
136
|
+
try {
|
|
137
|
+
listener();
|
|
138
|
+
} catch {
|
|
139
|
+
/* a subscriber's failure is not this registry's business */
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Subscribe to app-open registrations. Returns an unsubscribe fn. Shaped for `useSyncExternalStore`
|
|
146
|
+
* over {@link getCurrentSessionId} — that pairing is what makes a mounted gate follow real opens
|
|
147
|
+
* instead of the one it sampled at mount.
|
|
148
|
+
*
|
|
149
|
+
* Internal seam — deliberately NOT on the public barrels (same as {@link isMintedSessionId}); the
|
|
150
|
+
* callers are `useReviewGate` / `useQuestionnaireGate`.
|
|
151
|
+
*/
|
|
152
|
+
export const subscribeCurrentSessionId = (listener: () => void): (() => void) => {
|
|
153
|
+
const set = listeners();
|
|
154
|
+
set.add(listener);
|
|
155
|
+
return () => {
|
|
156
|
+
set.delete(listener);
|
|
157
|
+
};
|
|
158
|
+
};
|
|
159
|
+
|
|
80
160
|
/**
|
|
81
161
|
* Record the current per-open `session_id`. Called by `reportSessionStart` when it emits an
|
|
82
162
|
* app-open. A blank / non-string id is ignored (the previous id stays current). Idempotent.
|
|
83
163
|
*/
|
|
84
164
|
export const setCurrentSessionId = (id: string | undefined): void => {
|
|
85
165
|
if (typeof id === "string" && id.length > 0) {
|
|
86
|
-
|
|
166
|
+
writeCurrentSessionId(id);
|
|
87
167
|
}
|
|
88
168
|
};
|
|
89
169
|
|
|
@@ -91,9 +171,17 @@ export const setCurrentSessionId = (id: string | undefined): void => {
|
|
|
91
171
|
export const getCurrentSessionId = (): string | undefined =>
|
|
92
172
|
globalSlot[CURRENT_SESSION_ID_SLOT];
|
|
93
173
|
|
|
94
|
-
/** Test-only: forget the current session id so a unit test starts from a clean registry.
|
|
174
|
+
/** Test-only: forget the current session id so a unit test starts from a clean registry. Does NOT
|
|
175
|
+
* clear the MINTED-id record (see `isMintedSessionId`), for the same reason the warn latch below is
|
|
176
|
+
* not cleared: `makeSessionId()` never repeats, so a remembered fallback id can never again equal a
|
|
177
|
+
* live one, and the record is overwritten by the next mint anyway. */
|
|
95
178
|
export const resetCurrentSessionId = (): void => {
|
|
96
|
-
|
|
179
|
+
writeCurrentSessionId(undefined);
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
/** Test-only: drop every subscriber so one case's mounted gate cannot be notified by the next. */
|
|
183
|
+
export const resetCurrentSessionIdListeners = (): void => {
|
|
184
|
+
listeners().clear();
|
|
97
185
|
};
|
|
98
186
|
|
|
99
187
|
/** The one-time message. Hoisted so a prod mint does not rebuild a string nobody will read. */
|
|
@@ -120,6 +208,52 @@ type GlobalWithWarnSlot = typeof globalThis & { [MINT_WARNED_SLOT]?: boolean };
|
|
|
120
208
|
|
|
121
209
|
const warnSlot = globalThis as GlobalWithWarnSlot;
|
|
122
210
|
|
|
211
|
+
/**
|
|
212
|
+
* The id THIS PROCESS minted, so a reader can tell a fallback id from a real app-open.
|
|
213
|
+
*
|
|
214
|
+
* WHY IT HAS TO EXIST. A mint means "no app-open was registered and something still needed a
|
|
215
|
+
* `session_id`". It is a fallback for the wire, NOT evidence that the app was opened. But the mint
|
|
216
|
+
* writes the shared slot above (it must — see the header: every later event has to correlate to the
|
|
217
|
+
* same id), so a reader of `getCurrentSessionId()` cannot tell the two apart. `reviews/runtime`'s
|
|
218
|
+
* `currentOpenId` could not, and that cost an app-open: it pinned the launch with
|
|
219
|
+
* `observed = <the minted id>`, so its cold-start adopt branch (which keys off `observed ===
|
|
220
|
+
* undefined`) never fired and the REAL `app.session_started` that followed read as a genuinely NEW
|
|
221
|
+
* open. One launch counted as TWO, making the fail-closed `minSessions: 2` default — the guard from
|
|
222
|
+
* the 2026-07-16 one-star incident — satisfiable inside a user's FIRST session. It failed in the
|
|
223
|
+
* UNSAFE direction.
|
|
224
|
+
*
|
|
225
|
+
* Its OWN `Symbol.for` slot, for the same cross-bundle reason as the id itself: a plain module
|
|
226
|
+
* `let` would be per inlined bundle copy, so a mint reached through `./analytics` would be invisible
|
|
227
|
+
* to the gate reading through `.`.
|
|
228
|
+
*
|
|
229
|
+
* @globalSlot LIVE — a real process mints at most once (the mint only fires when the id slot is
|
|
230
|
+
* EMPTY), but a process that clears the id and mints again — `resetCurrentSessionId` in the suite —
|
|
231
|
+
* MOVES this, and it must move: a record still naming the previous launch's fallback would make the
|
|
232
|
+
* new one unrecognisable, and a fallback id would be counted as an app-open again. So a reader that
|
|
233
|
+
* caches this answers
|
|
234
|
+
* for a launch that is over. Read it at the moment of use, through `isMintedSessionId()`.
|
|
235
|
+
*/
|
|
236
|
+
const MINTED_SESSION_ID_SLOT: unique symbol = Symbol.for(
|
|
237
|
+
"@wireai/activation:mintedSessionId",
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
type GlobalWithMintedSlot = typeof globalThis & {
|
|
241
|
+
[MINTED_SESSION_ID_SLOT]?: string | undefined;
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const mintedSlot = globalThis as GlobalWithMintedSlot;
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Was `id` MINTED by this process rather than registered as a real app-open?
|
|
248
|
+
*
|
|
249
|
+
* `false` for `undefined`, for any id a host registered through `setCurrentSessionId`, and for
|
|
250
|
+
* every id once the launch has moved on. Internal seam — deliberately NOT on the public barrels;
|
|
251
|
+
* the only caller is `reviews/runtime`'s `currentOpenId`, which must not count a fallback id as an
|
|
252
|
+
* app-open.
|
|
253
|
+
*/
|
|
254
|
+
export const isMintedSessionId = (id: string | undefined): boolean =>
|
|
255
|
+
id !== undefined && id === mintedSlot[MINTED_SESSION_ID_SLOT];
|
|
256
|
+
|
|
123
257
|
/**
|
|
124
258
|
* The current per-open `session_id`, MINTING and registering one when no app-open has been
|
|
125
259
|
* registered yet. Always returns a non-empty string. Idempotent (a second call returns the same id)
|
|
@@ -141,7 +275,10 @@ export const ensureCurrentSessionId = (): string => {
|
|
|
141
275
|
const existing = globalSlot[CURRENT_SESSION_ID_SLOT];
|
|
142
276
|
if (typeof existing === "string" && existing.length > 0) return existing;
|
|
143
277
|
const minted = makeSessionId();
|
|
144
|
-
|
|
278
|
+
writeCurrentSessionId(minted);
|
|
279
|
+
// Remember that WE minted it. A fallback id is not an app-open, and `reviews/runtime` has to be
|
|
280
|
+
// able to tell the difference or the real registration that follows counts as a second open.
|
|
281
|
+
mintedSlot[MINTED_SESSION_ID_SLOT] = minted;
|
|
145
282
|
if (!warnSlot[MINT_WARNED_SLOT] && warnInDev(MINT_WARNING)) {
|
|
146
283
|
warnSlot[MINT_WARNED_SLOT] = true;
|
|
147
284
|
}
|
package/src/analytics/index.ts
CHANGED
|
@@ -44,7 +44,12 @@ export type { ClientEvent, ClientEventType, ClientEventTarget } from "./reportCl
|
|
|
44
44
|
// `readEventsAck` is deliberately NOT re-exported here: the doctor imports it from the transport
|
|
45
45
|
// module directly, so reuse costs nothing and the package's public surface stays as it was.
|
|
46
46
|
export { wireDoctor } from "./wireDoctor";
|
|
47
|
-
export type {
|
|
47
|
+
export type {
|
|
48
|
+
WireDoctorCheck,
|
|
49
|
+
WireDoctorReport,
|
|
50
|
+
WireDoctorOptions,
|
|
51
|
+
WireDoctorJoinTarget,
|
|
52
|
+
} from "./wireDoctor";
|
|
48
53
|
|
|
49
54
|
// ─── Canonical onboarding funnel names + kit-event mapper ──────────────────────
|
|
50
55
|
export { WIRE_ONBOARDING_EVENTS, toAnalyticsEvent } from "./analyticsEvent";
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
* Fire-and-forget: this never throws into the UI and never awaits — analytics must never
|
|
25
25
|
* be able to break onboarding.
|
|
26
26
|
*/
|
|
27
|
+
import { DEADLINE_EXPIRED, withDeadline } from "../utils/withDeadline";
|
|
27
28
|
import type { DeviceContext } from "../device/deviceContext";
|
|
28
29
|
|
|
29
30
|
/** Event types a CLIENT may report. The rest of the funnel is server-side; sending those
|
|
@@ -368,16 +369,24 @@ export const reportClientEventsOutcome = async (
|
|
|
368
369
|
const req = buildEventsRequest(target, events);
|
|
369
370
|
// No target / nothing serializable: there is no endpoint to retry against.
|
|
370
371
|
if (!req) return "refused";
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
//
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
372
|
+
// Under a deadline, BODY READ INCLUDED (see `utils/withDeadline`): this is the awaited path a
|
|
373
|
+
// host's `await wire.track()` sits on, and without a ceiling a hung POST parks it for the ~60s
|
|
374
|
+
// platform default. A ceiling cleared when the headers land would leave `readEventsAck` — which
|
|
375
|
+
// reads the body — uncapped, which is the `fetchWireFeatures` defect in a second place.
|
|
376
|
+
const outcome = await withDeadline(async (signal): Promise<SendOutcome> => {
|
|
377
|
+
const res = await fetch(req.url, { ...req.init, signal });
|
|
378
|
+
if (!res || !res.ok) return "unreachable";
|
|
379
|
+
// ONE body read, used for both the verdict and the dev warning — it can only be read once.
|
|
380
|
+
const ack = await readEventsAck(res);
|
|
381
|
+
if (!ack || ack.skipped <= 0) return "delivered";
|
|
382
|
+
if (typeof __DEV__ !== "undefined" && __DEV__ && typeof console !== "undefined" && console.warn) {
|
|
383
|
+
console.warn(describeDiscarded(ack));
|
|
384
|
+
}
|
|
385
|
+
// The server answered and declined THIS event. A retry is a re-decline.
|
|
386
|
+
return "refused";
|
|
387
|
+
});
|
|
388
|
+
// A deadline that expired means nothing came back — retryable, exactly like a network error.
|
|
389
|
+
return outcome === DEADLINE_EXPIRED ? "unreachable" : outcome;
|
|
381
390
|
} catch {
|
|
382
391
|
// Unreachable / missing-fetch / network — best-effort, and retryable.
|
|
383
392
|
return "unreachable";
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* useAnalytics — a THIN optional React hook over the pure {@link createAnalytics} factory.
|
|
3
3
|
*
|
|
4
|
-
* It builds ONE analytics instance for the component's lifetime and returns
|
|
5
|
-
* never rebuild the queue or lose the in-memory user binding. React is a REQUIRED peer of the
|
|
4
|
+
* It builds ONE analytics instance for the component's lifetime and returns a STABLE FACADE over
|
|
5
|
+
* it (see below), so re-renders never rebuild the queue or lose the in-memory user binding. React is a REQUIRED peer of the
|
|
6
6
|
* kit, so importing it here is allowed; the hook adds NO other dependency. Mirrors the existing
|
|
7
7
|
* `createScreenTracker` / `useScreenTracking` split — the factory stays React-free, this is the
|
|
8
8
|
* glue.
|
|
@@ -25,6 +25,45 @@ import {
|
|
|
25
25
|
* instance is stable for the component's lifetime, held in a ref). Returns the {@link Analytics}
|
|
26
26
|
* surface so the component can `track` / `screen` / `identify` and drive `notifyOnline` on reconnect.
|
|
27
27
|
*/
|
|
28
|
+
/**
|
|
29
|
+
* The STABLE object the host holds, delegating to whatever instance is live AT CALL TIME.
|
|
30
|
+
*
|
|
31
|
+
* ── WHY THE HOOK CANNOT JUST RETURN THE INSTANCE ─────────────────────────────────────────────────
|
|
32
|
+
* React's StrictMode dev remount runs a mount effect **setup → cleanup → setup with no intervening
|
|
33
|
+
* render**. The cleanup below disposes the instance and nulls the ref; the component is still
|
|
34
|
+
* mounted, and no render happens to rebuild it. So the object the host was handed at render is a
|
|
35
|
+
* DISPOSED one: `EventQueue.enqueue` returns early when disposed, which makes every `track()` on it
|
|
36
|
+
* a silent no-op — no error, no warning, `size()` stays 0 — until the host's next render. Dev builds
|
|
37
|
+
* only (production `<StrictMode>` is a passthrough), but dev is where a developer verifies that
|
|
38
|
+
* their analytics work.
|
|
39
|
+
*
|
|
40
|
+
* ── WHY THIS SHAPE AND NOT THE TWO OBVIOUS ALTERNATIVES ──────────────────────────────────────────
|
|
41
|
+
* • Stop nulling the ref in the cleanup → the render guard reads `!ref.current`, so a real
|
|
42
|
+
* credentials change would never rebuild. That trades a dev-only silence for a production one.
|
|
43
|
+
* • A `disposedRef` the render guard reads → the defining property of this window is that NO RENDER
|
|
44
|
+
* HAPPENS in it. Any repair spelled at render time re-closes the same hole one line lower.
|
|
45
|
+
* What has to keep working is the reference the host is ALREADY HOLDING, and only a stable
|
|
46
|
+
* indirection can do that. It also fixes the ordinary credentials-swap case for free: a host that
|
|
47
|
+
* captured the instance in a callback used to hold the pre-swap queue.
|
|
48
|
+
*
|
|
49
|
+
* ⚠️ A facade is safe only while it is COMPLETE. `Analytics` growing a member this forgets would be
|
|
50
|
+
* `undefined` on the object every host holds — a TypeError with a green typecheck, since the facade
|
|
51
|
+
* is written against the very declaration that changed. `test/canary/strictModeInstanceHandoff`
|
|
52
|
+
* compares the runtime key sets against `createAnalytics` and goes red the day they drift.
|
|
53
|
+
*/
|
|
54
|
+
const stableAnalyticsFacade = (live: () => Analytics | undefined): Analytics => ({
|
|
55
|
+
track: (event, props) => live()?.track(event, props),
|
|
56
|
+
screen: (name, props) => live()?.screen(name, props),
|
|
57
|
+
identify: (userId, traits) => live()?.identify(userId, traits),
|
|
58
|
+
setUserContext: (partial) => live()?.setUserContext(partial),
|
|
59
|
+
reset: () => live()?.reset(),
|
|
60
|
+
flush: () => live()?.flush(),
|
|
61
|
+
notifyOnline: () => live()?.notifyOnline(),
|
|
62
|
+
// After a real unmount there is no instance and nothing pending on it — 0 is the honest answer.
|
|
63
|
+
size: () => live()?.size() ?? 0,
|
|
64
|
+
dispose: () => live()?.dispose(),
|
|
65
|
+
});
|
|
66
|
+
|
|
28
67
|
export const useAnalytics = (
|
|
29
68
|
config: CreateAnalyticsConfig,
|
|
30
69
|
options: AnalyticsOptions = {},
|
|
@@ -32,6 +71,10 @@ export const useAnalytics = (
|
|
|
32
71
|
// One instance per mount; kept in a ref so re-renders never rebuild the queue or drop the binding.
|
|
33
72
|
const ref = useRef<Analytics | undefined>(undefined);
|
|
34
73
|
const prevKeys = useRef<string>("");
|
|
74
|
+
// The construction inputs, read fresh, so the rebuild below uses the CURRENT config rather than
|
|
75
|
+
// the first render's closure.
|
|
76
|
+
const inputs = useRef({ config, options });
|
|
77
|
+
inputs.current = { config, options };
|
|
35
78
|
|
|
36
79
|
const currentKeys = `${config.serverUrl}|${config.apiKey}|${config.appId}`;
|
|
37
80
|
if (!ref.current || prevKeys.current !== currentKeys) {
|
|
@@ -44,6 +87,34 @@ export const useAnalytics = (
|
|
|
44
87
|
ref.current = createAnalytics(config, options);
|
|
45
88
|
}
|
|
46
89
|
|
|
90
|
+
// Built once and never replaced: it is the whole point that the host's reference outlives every
|
|
91
|
+
// instance behind it.
|
|
92
|
+
const facade = useRef<Analytics | undefined>(undefined);
|
|
93
|
+
if (!facade.current) facade.current = stableAnalyticsFacade(() => ref.current);
|
|
94
|
+
|
|
95
|
+
// ⛔ DECLARED FIRST, AND THAT ORDER IS LOAD-BEARING. React runs every cleanup, then every setup.
|
|
96
|
+
// With the device-key effect below declared first, its second setup would run while `ref.current`
|
|
97
|
+
// was still `undefined` (this effect not having re-run yet), `?.setUserContext` would silently
|
|
98
|
+
// no-op, and the host's real device key would be dropped for the life of the mount — which is the
|
|
99
|
+
// very defect the paragraph below exists to describe.
|
|
100
|
+
//
|
|
101
|
+
// SETUP: rebuild the instance this effect's own cleanup disposed. On a first mount there is
|
|
102
|
+
// nothing to do (render just built one); after StrictMode's cleanup→setup there is, and no render
|
|
103
|
+
// is coming to do it.
|
|
104
|
+
// UNMOUNT: give the storage claim and the retry timer back. A screen that mounts this hook is
|
|
105
|
+
// built and torn down repeatedly (navigation, Fast Refresh, StrictMode's double-invoke), and each
|
|
106
|
+
// rebuild used to leave the previous queue holding the appId's slot — so every remount after the
|
|
107
|
+
// first persisted into `…#2`, `…#3`, and the next launch read none of them.
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
if (!ref.current) {
|
|
110
|
+
ref.current = createAnalytics(inputs.current.config, inputs.current.options);
|
|
111
|
+
}
|
|
112
|
+
return () => {
|
|
113
|
+
ref.current?.dispose();
|
|
114
|
+
ref.current = undefined;
|
|
115
|
+
};
|
|
116
|
+
}, []);
|
|
117
|
+
|
|
47
118
|
// A LATE-ARRIVING host device key must still reach the instance.
|
|
48
119
|
//
|
|
49
120
|
// `createAnalytics` copies `config.userContext` into a closure at construction and never re-reads
|
|
@@ -64,17 +135,5 @@ export const useAnalytics = (
|
|
|
64
135
|
if (hostDeviceKey) ref.current?.setUserContext({ deviceKey: hostDeviceKey });
|
|
65
136
|
}, [hostDeviceKey]);
|
|
66
137
|
|
|
67
|
-
|
|
68
|
-
// built and torn down repeatedly (navigation, Fast Refresh, StrictMode's double-invoke), and each
|
|
69
|
-
// rebuild used to leave the previous queue holding the appId's slot — so every remount after the
|
|
70
|
-
// first persisted into `…#2`, `…#3`, and the next launch read none of them.
|
|
71
|
-
useEffect(
|
|
72
|
-
() => () => {
|
|
73
|
-
ref.current?.dispose();
|
|
74
|
-
ref.current = undefined;
|
|
75
|
-
},
|
|
76
|
-
[],
|
|
77
|
-
);
|
|
78
|
-
|
|
79
|
-
return ref.current;
|
|
138
|
+
return facade.current;
|
|
80
139
|
};
|