@wireai/activation 0.13.6 → 0.14.1

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 (47) hide show
  1. package/AGENTS.md +21 -9
  2. package/CHANGELOG.md +227 -1
  3. package/INTEGRATION_PROMPT.md +7 -4
  4. package/README.md +15 -2
  5. package/dist/analytics/index.d.mts +9 -2
  6. package/dist/analytics/index.d.ts +9 -2
  7. package/dist/analytics/index.js +56 -12
  8. package/dist/analytics/index.js.map +1 -1
  9. package/dist/analytics/index.mjs +56 -12
  10. package/dist/analytics/index.mjs.map +1 -1
  11. package/dist/{currentSession-DngW-QoD.d.mts → currentSession-CUvTOchb.d.mts} +35 -6
  12. package/dist/{currentSession-C5976akx.d.ts → currentSession-CW_5Mq4O.d.ts} +35 -6
  13. package/dist/index.d.mts +31 -7
  14. package/dist/index.d.ts +31 -7
  15. package/dist/index.js +643 -539
  16. package/dist/index.js.map +1 -1
  17. package/dist/index.mjs +643 -539
  18. package/dist/index.mjs.map +1 -1
  19. package/dist/questionnaire/index.js +14 -6
  20. package/dist/questionnaire/index.js.map +1 -1
  21. package/dist/questionnaire/index.mjs +14 -6
  22. package/dist/questionnaire/index.mjs.map +1 -1
  23. package/dist/reviews/index.js +14 -6
  24. package/dist/reviews/index.js.map +1 -1
  25. package/dist/reviews/index.mjs +14 -6
  26. package/dist/reviews/index.mjs.map +1 -1
  27. package/llms.txt +2 -2
  28. package/package.json +1 -1
  29. package/src/OnboardingFlow.tsx +4 -3
  30. package/src/WireOnboarding.tsx +48 -8
  31. package/src/activation/useWireActivation.ts +14 -1
  32. package/src/activation/wireActivation.ts +68 -2
  33. package/src/analytics/analyticsFacade.ts +24 -0
  34. package/src/analytics/currentSession.ts +3 -2
  35. package/src/analytics/eventQueue.ts +106 -11
  36. package/src/analytics/reportClientEvent.ts +31 -7
  37. package/src/analytics/useAnalytics.ts +17 -0
  38. package/src/context/deviceId.ts +10 -3
  39. package/src/permissions/permissionMemory.ts +41 -5
  40. package/src/reviews/runtime.ts +75 -23
  41. package/src/session/persistedSession.ts +32 -8
  42. package/src/session-analytics/lifecycle.ts +26 -5
  43. package/src/session-analytics/reportSessionStart.ts +14 -10
  44. package/src/session-analytics/useLifecycleEvents.ts +70 -32
  45. package/src/session-analytics/useSessionStart.ts +57 -15
  46. package/src/types.ts +16 -6
  47. package/src/utils/readPlan.ts +8 -5
package/AGENTS.md CHANGED
@@ -51,7 +51,7 @@ subpaths are optional secondary feature modules; import one only if you use it.
51
51
  - `WIRE_PERMISSION_EVENTS`, `resolvePermissionCopy(...)`: the canonical permission-funnel names and the shipped rationale copy behind `permissionScreens` (see the prop below). The kit imports no native permission module; the host injects `request`.
52
52
  - `createRevenueCatBridge({ analytics, entitlementId })`: the RevenueCat purchase funnel (see "RevenueCat" below).
53
53
  - `activationJoinContext(deviceKey)`: builds the `userContext` value that joins an onboarding session to the app's later events. Every `<WireOnboarding>` needs it (see step 7).
54
- - `resolveAutoDeviceKey({ appId, storage })`: the kit's own persisted per-install `device_key`, for a host that owns none.
54
+ - `resolveAutoDeviceKey({ appId, storage })`: the kit's own per-install `device_key`, read synchronously. Use it for a stamp on an ordinary event, never as the cross-launch join key on `<WireOnboarding>` (it cannot tell you whether the id persists, see the join-key section). `hydrateDeviceIdentity({ appId, storage })` is the awaited, durability-reporting sibling.
55
55
  - `useLifecycleEvents(config, options)`: the app-root lifecycle hook (see step 8). `useSessionStart` / `reportSessionStart` are the counter-owning alternatives.
56
56
  - Types: `OnboardingTheme`, `OnboardingResult`, `OnboardingEvent`, `WireOnboardingConfig`, `WireOnboardingProps`, `StepValidator`, `OnboardingCopy`, `IllustrationRegistry`.
57
57
 
@@ -60,7 +60,7 @@ subpaths are optional secondary feature modules; import one only if you use it.
60
60
  | Prop | Type | Required | Notes |
61
61
  |---|---|---|---|
62
62
  | `config` | `WireOnboardingConfig` | yes | A2A transport + tenant. See the `config` fields table below. |
63
- | `onComplete` | `(result: OnboardingResult) => void` | yes | Terminal recap CTA tapped. Persist `answers` via your profile-update path, then navigate on. `result` always has `answers` + `raw`; since 0.13.6 it also carries `plan` (the backend's onboarding plan, AI path only) and `variant` (the assigned experiment arm) when the backend sent them. Both keys are ABSENT otherwise, so an integration written before 0.13.6 sees no change. The kit does not interpret either one: validate `plan` before applying it. |
63
+ | `onComplete` | `(result: OnboardingResult) => void` | yes | Terminal recap CTA tapped. Persist `answers` via your profile-update path, then navigate on. `result` always has `answers` + `raw`; since 0.13.6 it also carries `plan` (the backend's onboarding plan, whenever the backend sends one; the kit never infers it from the flow type) and `variant` (the assigned experiment arm) when the backend sent them. Both keys are ABSENT otherwise, so an integration written before 0.13.6 sees no change. The kit does not interpret either one: validate `plan` before applying it. |
64
64
  | `theme` | `Partial<OnboardingTheme>` | no | Colors + font family names + radius/spacing, deep-merged over a neutral default. For dark/light, pass a different theme per app theme-state. |
65
65
  | `components` | `WireAIComponent[]` | no | Override registered cards (default `onboardingComponents`). |
66
66
  | `illustrations` | `Record<string, ReactNode>` | no | Host artwork for `InterstitialCard`, keyed by name. `{ ...defaultIllustrations, ...myArt }`. |
@@ -112,8 +112,8 @@ FACADE (`createAnalytics` and friends) is a different surface and lives on `@wir
112
112
  4. **Config**: set `EXPO_PUBLIC_WIREAI_API_KEY` / `_SERVER_URL` / `_APP_ID`; build via `wireConfigFromEnv({ appId })`.
113
113
  5. **Fonts**: host must load the font families named in `theme.fonts.regular/medium/bold` (e.g. `expo-font`), else text falls back to system font.
114
114
  6. **Theme**: `themeFromBrand({ primary })` or a full `Partial<OnboardingTheme>`; for dark/light, pick the theme by the app's theme state.
115
- 7. **Render + continuation**: drop `<WireOnboarding>` into the signup flow; `onComplete` → persist + navigate, `onSkip` → navigate. Pass the join key: `userContext={activationJoinContext(deviceKey)}`, or `activationJoinContext(resolveAutoDeviceKey({ appId, storage }))` when the app owns no device id. Skipping it leaves the `activated` funnel permanently empty and reports no error.
116
- 8. **Lifecycle (do not skip)**: mount `useLifecycleEvents(config, { deviceKey?, sessionCount?, userId? })` ONCE at the app root, before anything else touches analytics. It is the only path that emits `app.first_open`, it registers the per-open session id that `createAnalytics` / `wire.track` / the gates all correlate to, and with `config.storage` it stamps the kit's persisted auto `device_key` on `app.session_started`, which is what the server's `min_sessions` rule counts. `useSessionStart` / `reportSessionStart` are the alternatives for a host that already owns an open counter; neither emits `first_open`.
115
+ 7. **Render + continuation**: drop `<WireOnboarding>` into the signup flow; `onComplete` → persist + navigate, `onSkip` → navigate. Pass the join key: `userContext={activationJoinContext(deviceKey)}`, 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 `resolveAutoDeviceKey`, see the join-key section). Skipping both leaves the `activated` funnel permanently empty and reports no error.
116
+ 8. **Lifecycle (do not skip)**: mount `useLifecycleEvents(config, { deviceKey?, sessionCount?, userId? })` ONCE at the app root, before anything else touches analytics. It is the only path that emits `app.first_open`, it registers the per-open session id that `createAnalytics` / `wire.track` / the gates all correlate to, and with `config.storage` it stamps the kit's persisted auto `device_key` on `app.session_started`, which is what the server's `min_sessions` rule counts. That last part needs storage that actually persists: since 0.14.0 an adapter that throws or rejects is treated exactly like no storage, so the events fire with no auto key rather than a per-launch one that corrupts the count. `useSessionStart` / `reportSessionStart` are the alternatives for a host that already owns an open counter; neither emits `first_open`, and since 0.14.0 `useSessionStart` follows the same auto-key rule.
117
117
  9. **Gate**: wrap behind a flag (env for dev, remote config for prod) **AND** `config != null`; fall through to existing onboarding when off/unconfigured. Decide who sees it (e.g. new signups only) in host nav logic.
118
118
 
119
119
  ## Review firing (do not hand-roll the decision fetch)
@@ -174,17 +174,29 @@ and activation surfaces auto-mint and stamp it on every event, and onboarding ge
174
174
  `userContext={{ deviceKey }}`, because the server's device lookup reads `device_key` and a
175
175
  misspelled bucket produces an empty funnel instead of an error.
176
176
 
177
- **If the host owns no device id, read the kit's.** Do not leave the onboarding side blank.
178
- `resolveAutoDeviceKey({ appId, storage })` returns the exact id `createAnalytics` /
179
- `createWireActivation` auto-mint and persist for this install, so both halves of the join agree:
177
+ **If the host owns no device id, let the kit supply it.** Do not leave the onboarding side blank and
178
+ do not build the key yourself. Pass a working `storage` and the kit injects the exact id
179
+ `createAnalytics` / `createWireActivation` auto-mint and persist for this install, so both halves of
180
+ the join agree:
180
181
 
181
182
  ```tsx
182
- import { resolveAutoDeviceKey, activationJoinContext } from "@wireai/activation";
183
+ import { activationJoinContext } from "@wireai/activation";
183
184
 
184
- const deviceKey = resolveAutoDeviceKey({ appId, storage }); // the SAME id the analytics side stamps
185
+ // The app owns a device id: pass it.
185
186
  <WireOnboarding config={config} userContext={activationJoinContext(deviceKey)} ... />
187
+
188
+ // The app owns none: pass `storage` and leave `userContext` alone. The kit injects the same id the
189
+ // analytics side stamps, after awaiting the storage read AND confirming the id actually persists.
190
+ <WireOnboarding config={{ ...config, storage }} ... />
186
191
  ```
187
192
 
193
+ ⛔ Do NOT hand-build that key with `activationJoinContext(resolveAutoDeviceKey({ appId, storage }))`.
194
+ `resolveAutoDeviceKey` is synchronous by contract, so it returns a freshly minted id and adopts the
195
+ persisted one a storage read later, and it cannot report whether the id survives the launch at all.
196
+ Since 0.14.0 every kit surface that puts a device key on the wire as a cross-launch join awaits
197
+ `hydrateDeviceIdentity` and refuses a non-durable record; a host that really wants the value in hand
198
+ should do the same rather than the sync call.
199
+
188
200
  Always pass `storage`. Without it the id is per-LAUNCH, not per-install, and a per-launch key makes
189
201
  every open look like a new device, which breaks `min_sessions` and A/B arm stickiness as surely as
190
202
  no key breaks the join. The same rule governs the lifecycle wiring: `useLifecycleEvents` falls back
package/CHANGELOG.md CHANGED
@@ -3,6 +3,230 @@
3
3
  All notable changes to `@wireai/activation` (formerly `wireai-onboarding`).
4
4
  Historical entries below the rename keep the old package name on purpose.
5
5
 
6
+ ## [0.14.1] - 2026-08-18
7
+
8
+ Two rulings from the 0.14.0 audit, applied. Both were reproduced there and left for a decision
9
+ rather than guessed at; both decisions came back on 2026-08-18.
10
+
11
+ **PATCH: no public surface gained, lost or renamed a member.** But read the first entry before you
12
+ upgrade — it is a behaviour change you will see in your funnel, not a silent internal fix.
13
+
14
+ ### Fixed
15
+
16
+ - **The review/questionnaire gates counted JS PROCESSES, not app-opens, so `minSessions` stalled at
17
+ 1 on iOS.** `useLifecycleEvents` already defines an app-open correctly — a foreground after 30+
18
+ minutes in the background — and fires a fresh `app.session_started` for it. The device-side
19
+ counter behind `minSessions` did not follow: it pinned one id for the life of the JS process, and
20
+ iOS suspends apps rather than killing them. So a user who opened the app every day for a week
21
+ produced seven server-side opens while `wire_review_<id>_sessions` stayed at 1, and any gate with
22
+ the fail-closed `minSessions: 2` default could never fire on a phone that is never force-quit —
23
+ which is most phones. **⚠️ UPGRADE NOTE: gates that have been silently dormant will start firing.**
24
+ That is the fix, but if you have been reading a flat prompt rate as "users decline", the number
25
+ will move. The counter still refuses the case it was pinned for in the first place: on a cold
26
+ start the gate renders (and counts) before the root lifecycle effect registers the session id, and
27
+ that first registration NAMES the open already counted rather than starting a second one — so the
28
+ 2026-07-16 incident, where `minSessions: 2` became satisfiable inside a user's very first launch,
29
+ stays closed. One accepted under-count remains, deliberately: if the mount open never registers (a
30
+ refused non-durable device key over a broken store) and a later foreground is the first
31
+ registration the launch sees, it is adopted rather than counted. A fail-closed gate may only err
32
+ toward prompting less.
33
+
34
+ - **A permission record that could not be READ is no longer read as "nothing settled".** A timed-out
35
+ or throwing storage read arrived as `[]`, which is indistinguishable from a genuine empty record,
36
+ and the flow acted on that guess twice: it re-showed a permission screen the user may already have
37
+ answered — spending the ONE native prompt iOS ever grants — and the answer then persisted a record
38
+ containing just that one id, OVER whatever the store actually held. A slow adapter could therefore
39
+ permanently shrink the memory that exists to prevent the re-ask. The read now reports whether the
40
+ store answered at all; on "it did not", permission screens are suppressed for that session and
41
+ nothing is written back. A successful read of a corrupt, absent or other-session entry is still a
42
+ genuine empty and still shows the screen. The trade is stated rather than hidden: on a host whose
43
+ adapter never answers, configured permission screens silently never show, so it warns in dev
44
+ naming the adapter.
45
+
46
+ ### Changed
47
+
48
+ - **`@wireai/activation:processOpenId` is re-declared LIVE (was LATCH).** The governance canary in
49
+ `test/canary/globalSlotDiscipline.test.tsx` asserted that this slot must NOT follow the session
50
+ registry, which is what froze the counter above; it now drives the slot through two real opens and
51
+ requires it to move. The cold-start property it used to guard — the first registration of a launch
52
+ must not re-pin — is asserted in `src/reviews/sessionCount.test.ts`, alongside the incident it
53
+ exists for. The slot holds the pinned open id plus the registered id observed when it was pinned,
54
+ because "is this a new open?" is only answerable against what was last seen.
55
+
56
+ Tests 808 → 814, all green, and the deferred `todo` reproduction is now a passing assertion.
57
+ Every fix was falsified first: reverting the counter fix reddens exactly the app-open test plus the
58
+ slot canary and leaves the cold-start test green; collapsing the unknown read back to empty reddens
59
+ both unit tests and the end-to-end canary while its positive control stays green.
60
+
61
+ ## [0.14.0] - 2026-08-17
62
+
63
+ A durability release, in two rounds. Round 1 stopped the two counted lifecycle events from riding a
64
+ per-launch device key. Round 2 came out of a fresh-eyes audit of the whole package under a
65
+ reproduction-first rule: eight findings, seven reproduced with a failing test before anything was
66
+ changed, one reproduced and deliberately left unfixed because its fix is a design ruling.
67
+
68
+ **MINOR, not patch, and only because of new API.** Every behaviour change here is a bug fix; the
69
+ version moved because four public surfaces gained members: `EventQueue.dispose()`,
70
+ `Analytics.dispose()`, `WireActivation.dispose()` and `WireActivationConfig.sink`. Nothing was
71
+ removed, nothing was renamed, and no existing call signature changed.
72
+
73
+ ### Fixed
74
+
75
+ - **The event queue has a teardown, and needs one.** A queue claimed a persisted storage slot and
76
+ armed a backoff timer, and nothing ever gave either back — "already claimed" was read as "held by
77
+ a LIVE one", which is liveness nothing tracked. Two consequences, both silent. A remount (nav,
78
+ Fast Refresh, StrictMode's double-invoke, an `appId` that arrives async) rotated the replacement
79
+ onto `…#2`, so the whole launch persisted into a slot the NEXT launch never reads, and the `#N`
80
+ keys grew without bound. Worse, on the explicit-key path both queues address the SAME slot: the
81
+ dead one's retry timer woke up, drained, emptied, and `removeItem`d the live queue's persisted
82
+ `app.session_started`. (`unrefTimer` is a Node affordance; under Hermes that timer is fully
83
+ alive.) `dispose()` clears the timer, releases the claim and makes the queue inert;
84
+ `useLifecycleEvents`, `useAnalytics` and `useWireActivation` all call it on unmount and before
85
+ replacing an instance.
86
+
87
+ - **A storage read that TIMED OUT is no longer read as "nothing stored".** `withTimeout` resolved
88
+ `undefined` at the ceiling, which is byte-identical to a genuine empty read, and two callers then
89
+ did something irreversible with the guess. `loadPersistedSession` minted a new session id and
90
+ wrote it OVER the still-valid one it had not managed to read (the server then sees a second
91
+ `session_started` and back-fills the first as a phantom drop, the exact inflation that module
92
+ exists to prevent). `reportFirstOpen` treated the unread flag as "never fired" and emitted
93
+ `app.first_open` again on an install that already had one. Both now carry the distinct
94
+ `READ_TIMED_OUT` sentinel the event queue already had, and do nothing destructive on a timeout.
95
+
96
+ The same rule now covers an UNREADABLE flag (a locked / full / permission-denied adapter): that
97
+ fails on every launch, so emitting made `app.first_open` fire on every launch of that install,
98
+ counting one device as an unbounded number of installs. The kit's standing rule for an
99
+ unverifiable persistence answer, set in 0.13.0 and re-applied throughout this release, is to
100
+ decline rather than emit something that corrupts a metric. A host that wants `first_open` on a
101
+ device with no working storage should pass no `storage` at all, which is the documented degraded
102
+ mode and still fires once per process.
103
+
104
+ - **`Analytics.reset()` can no longer be undone by the read that was already in flight.** The
105
+ construction-time user-id hydration adopted its answer under `if (saved && !boundUserId)`, and a
106
+ logout is the one case where an empty binding is deliberate — so a read landing after `reset()`
107
+ re-bound the user who had just logged out, onto every subsequent event. `reset()` now marks the
108
+ hydration superseded.
109
+
110
+ - **The offline size cap no longer spends the funnel-denominator events first.** The buffer is
111
+ drop-oldest at 200, and at an app-open the oldest events in it are exactly `app.first_open` and
112
+ `app.session_started`. A host on the documented shared-sink wiring that went offline for a long
113
+ session pushed past the cap on screen views alone and evicted the open itself. Eviction now spends
114
+ ORDINARY events first; the cap stays a hard ceiling, so a buffer of nothing but `app.*` events
115
+ still evicts oldest-first rather than growing.
116
+
117
+ - **`wire.track()` no longer throws the event away when the POST fails.** It is the ACTION reporter,
118
+ called at exactly the moments a phone is offline, and a failed POST used to be the end of that
119
+ event's life — so the server-side trigger keyed on that `question_key` simply never fired for that
120
+ user. A failed send now goes to a durable buffer: your queue if you pass the new `sink`, otherwise
121
+ one this instance opens on the FIRST failure and never before. `track` still resolves `false` and
122
+ still does not revalidate, because the caller asked whether the server has it now.
123
+
124
+ ⛔ Only a TRANSPORT failure is buffered. An event the server READ and refused (HTTP 200 carrying
125
+ `skipped`) is not retried: a retry is a re-decline, and it would park a poison event in a
126
+ persisted backlog that fails on every drain and every launch. `reportClientEventsOutcome` is the
127
+ internal seam that tells the two apart.
128
+
129
+ - **The unread-backlog guard is armed at construction, not 1500ms later.** It was only set in the
130
+ timeout branch, so for the first 1500ms of every launch the queue wrote freely over a blob it had
131
+ not read, and a drain that emptied in that window `removeItem`d it outright. An adapter that
132
+ resolves a read against its current state then answers `null` and the backlog is gone. The comment
133
+ claiming writes were suppressed "until the read lands" is now true.
134
+
135
+ - **`reportSessionStart`'s direct-POST fallback goes through `buildEventsRequest`.** It hand-built
136
+ its own url, headers and body, so it was the one send path of five that skipped the epoch-ms →
137
+ ISO8601 `ts` conversion — on the event `min_sessions` is counted from, where the failure shape is
138
+ HTTP 200 with `{written: 0, skipped: N}` and no client-visible error. Harmless today (that event
139
+ carries no `ts`), latent tomorrow. The "single choke point" claim in `reportClientEvent.ts` is now
140
+ held to the code by a test rather than by prose.
141
+
142
+ ### Known, reproduced, NOT fixed
143
+
144
+ - **The review/questionnaire gates count PROCESSES, while an app-open is a 30-minute foreground.**
145
+ `currentOpenId()` is pinned for the life of the JS process. On iOS an app is suspended, not
146
+ killed, so a user opening the app daily for a week produces seven `app.session_started` events
147
+ (the SERVER's `min_sessions` advances to 7) while the on-device `wire_review_<id>_sessions` stays
148
+ at 1 forever, and a LOCAL `minSessions: 2` rule can never be met on a phone that is never
149
+ force-quit. It fails in the safe direction (a prompt that never shows), which is why it is not
150
+ being rushed.
151
+
152
+ Reproduced in `src/reviews/sessionCountAcrossOpens.test.ts`, marked `todo`, with the smallest
153
+ coherent fix written out next to it. It is deferred because that fix requires re-declaring the
154
+ `processOpenId` global slot LIVE and rewriting the `globalSlotDiscipline` canary exercise that
155
+ currently asserts this behaviour is correct — a canary written to guard the 2026-07-16 one-star
156
+ incident. Re-ruling it is a design decision, not an implementation detail.
157
+
158
+ - **`loadSettledPermissions` has the same timed-out-read class**, and its caller then persists a set
159
+ grown from `[]`, which can shrink the stored one. Made explicit in code rather than left to an
160
+ accident, but not changed: the fix needs a UX ruling (on an unknown set, does a resumed flow
161
+ re-ask a permission or skip it?), and guessing at that is how a user gets asked twice.
162
+
163
+ ### Changed (docs)
164
+
165
+ - Every host-facing surface stopped recommending the defect class this release fixes. The README
166
+ prop table, `AGENTS.md`, `llms.txt`, `INTEGRATION_PROMPT.md`, the `WireOnboardingProps.userContext`
167
+ docstring and the `__DEV__` missing-join-key warning all told a host with no device id of its own
168
+ to write `userContext={activationJoinContext(resolveAutoDeviceKey({ appId, storage }))}`. That is
169
+ the synchronous, durability-blind read, used as a cross-launch join key. They now say to pass a
170
+ working `storage` and let the kit inject the key it has confirmed persists.
171
+ - `ai_rules/context_map.md` gained the four module rows it was missing (`src/analytics/`,
172
+ `src/session-analytics/`, `src/context/`, `src/device/`). The map is the file the repo's own rules
173
+ tell an agent to read instead of grepping `src/`, and it was silent on the directories this whole
174
+ release lives in.
175
+
176
+ ## [0.13.7] — superseded by 0.14.0, never published
177
+
178
+ The round-1 device-key fixes below shipped as part of **0.14.0**. The entry is kept because it is the
179
+ detailed record of that half of the work.
180
+
181
+ The two events the server counts the funnel DENOMINATOR from stopped riding a per-launch device key.
182
+ `app.first_open` and `app.session_started` are the only events `min_sessions` is computed from (the
183
+ server counts distinct opens grouped by `user_context.device_key`), so a key that differs on every
184
+ launch does not merely leave that counter empty, it caps it at 1 and inflates distinct-device counts
185
+ at the same time. Both lifecycle hooks were doing exactly that, in two different ways.
186
+
187
+ Nothing on the wire changed shape, no export was added or removed, and a host-supplied `deviceKey` is
188
+ byte-identical to 0.13.6 on every path.
189
+
190
+ ### Fixed
191
+
192
+ - **`useLifecycleEvents(...)` no longer stamps the in-memory mint when the storage read settles
193
+ NON-DURABLE.** It already awaited hydration before the mount fire, so a healthy store was correct.
194
+ A locked / full / permission-denied adapter is the case it missed: the read settles degraded, the
195
+ `.then` fired anyway, and the synchronous auto id went onto both counted events. Measured on
196
+ 0.13.6: two launches over a throwing MMKV produced four distinct `wdev_*`.
197
+
198
+ The auto path now resolves through `hydrateDeviceIdentity(...)`, the provenance-carrying read, and
199
+ refuses a `durable: false` outcome, which is the rule `src/context/deviceId.ts` has stated for
200
+ these callers since 0.13.0 and `<WireOnboarding>`'s `autoJoinKey` has obeyed since then. A refusal
201
+ emits both events with NO auto `device_key`, the same branch a config with no `storage` already
202
+ took, never a silenced event. **Every fire path obeys it**, not just the mount one: the foreground
203
+ re-fire after a real background re-runs the same read, so a transient cold-boot storage lock gets a
204
+ fresh attempt on the next open instead of a verdict cached for the process lifetime.
205
+
206
+ - **`useSessionStart(...)` waits for the persisted key before its mount fire.** This hook never got
207
+ the 0.13.3 hydration fix its sibling did, so it stamped a brand-new `wdev_*` on `app.session_started`
208
+ on every launch even on a perfectly HEALTHY store, while every other kit surface adopted the
209
+ persisted id milliseconds later. Any host wiring this path had a structurally unsatisfiable
210
+ `min_sessions` and no error anywhere. It now takes the same awaited, durability-checked auto path as
211
+ `useLifecycleEvents(...)`, on the mount fire and on the foreground re-fire.
212
+
213
+ It is not deprecated and its contract is otherwise unchanged: a host-supplied `deviceKey` and a
214
+ config with no `storage` both still fire synchronously, because there is nothing to read.
215
+
216
+ ### Unchanged
217
+
218
+ - `createAnalytics(...)` / `createWireActivation(...)` keep stamping the synchronous
219
+ `resolveAutoDeviceKey(...)` per event, deliberately. Their documented contract is that they never
220
+ throw and degrade to the in-memory id, they are numerator paths rather than the counted opens, and
221
+ they re-read the registry per event so they adopt the persisted id as soon as it lands.
222
+ - `hydrateAutoDeviceKey(...)` stays exported and behaves identically. No kit surface uses it any more:
223
+ it returns a bare string, and a string cannot say whether it survives the launch, which is the whole
224
+ reason `hydrateDeviceIdentity(...)` exists.
225
+ - Docs swept for the claim this changes: the README lifecycle section, `AGENTS.md` step 8, `llms.txt`
226
+ and `INTEGRATION_PROMPT.md` all said "with `config.storage` it stamps the persisted auto
227
+ `device_key`". Presence of the prop was never the condition. It is now written as what it is: a
228
+ storage adapter that throws or rejects counts as no storage.
229
+
6
230
  ## [0.13.6] - 2026-08-09
7
231
 
8
232
  Two things the backend already decides, handed to the host instead of guessed at. Both ride the
@@ -19,7 +243,9 @@ entry. This entry is that debt paid, plus the `variant` half, released together
19
243
  On the AI path the server appends a SECOND A2A DataPart to the turn it finishes on,
20
244
  `{ kind: "onboarding_plan", plan: {...} }`, alongside the component envelope the renderer already
21
245
  consumes. A new pure reader, `src/utils/readPlan.ts`, lifts it off the thread and `handleFinish`
22
- puts it on the result. The static (non-AI) flow carries no plan and is unchanged.
246
+ puts it on the result. A run the server finishes without a plan is unchanged: the key stays off
247
+ the result object entirely. Which runs carry a plan is the backend's configuration, and the
248
+ reader never infers it from the flow type.
23
249
 
24
250
  The one thing the reader is allowed to reject is a structural fact: a plan is an object. A scalar,
25
251
  an array, a misspelled marker or a missing payload yields `undefined` and the host falls back to
@@ -41,9 +41,10 @@ STEPS (do them in order, stop and ask if a convention is ambiguous):
41
41
  dead-ends.
42
42
  - Forward install attribution if I have it: `wireConfigFromEnv({ appId, metadata: attributionMetadata({...}) })`.
43
43
  - THE JOIN KEY: pass `userContext={activationJoinContext(deviceKey)}`. If my app owns no stable
44
- device id, use `activationJoinContext(resolveAutoDeviceKey({ appId, storage }))`, which returns
45
- the same id the analytics side stamps. Leaving it out makes the `activated` funnel read zero
46
- forever and reports no error.
44
+ device id, pass a working `storage` and leave `userContext` alone: the kit injects the same id
45
+ the analytics side stamps, after confirming it actually persists. Do NOT hand-build it from
46
+ `resolveAutoDeviceKey` (synchronous, and it cannot report durability). Leaving both out makes
47
+ the `activated` funnel read zero forever and reports no error.
47
48
  - Analytics: log via `toAnalyticsEvent(e)` → my logger, using the canonical `wire_onboarding_*` names.
48
49
  7. Navigation: mount the screen in my first-run/signup flow; branch to the static flow when the gate
49
50
  is off or config is missing.
@@ -51,7 +52,9 @@ STEPS (do them in order, stop and ask if a convention is ambiguous):
51
52
  ONCE at my app root, above the navigator, before anything else touches analytics. Pass
52
53
  `config.storage`. It is the only path that emits `app.first_open`, it registers the per-open
53
54
  session id every other surface correlates to, and with storage it stamps the kit's persisted auto
54
- `device_key` on `app.session_started`, which is what the server counts `min_sessions` from.
55
+ `device_key` on `app.session_started`, which is what the server counts `min_sessions` from. If my
56
+ storage adapter throws or rejects, the kit treats it as no storage and stamps no auto key at all
57
+ (0.14.0), because a key that changes every launch corrupts that count.
55
58
  `useSessionStart` / `reportSessionStart` are only for a host that already owns an open counter;
56
59
  neither emits `first_open`.
57
60
  9. Event triggers (only if I asked to fire a review/questionnaire off an in-app action): pick the
package/README.md CHANGED
@@ -231,7 +231,7 @@ import { WireOnboarding } from "@wireai/activation";
231
231
  | Prop | Type | Notes |
232
232
  |---|---|---|
233
233
  | `config` | `{ apiKey, serverUrl, appId, metadata?, appVersion? }` | A2A transport; the key resolves the tenant server-side. `appVersion` is host-injected (e.g. `Constants.expoConfig?.version`) and forwarded for analytics segmentation. **Required.** |
234
- | `onComplete` | `(result: OnboardingResult) => void` | Fires when the user taps the terminal recap's CTA. `result` always carries `answers` and `raw`. Since 0.13.6 it may also carry `plan` (the backend's onboarding plan, AI path only) and `variant` (the experiment arm the backend assigned, when the tenant runs one). Each of those two keys is set ONLY when the backend actually sent one, so a run without them returns exactly the object earlier versions returned: `"plan" in result` and `"variant" in result` both stay false. The kit does not interpret either: **validate `plan` before you apply it.** **Required.** |
234
+ | `onComplete` | `(result: OnboardingResult) => void` | Fires when the user taps the terminal recap's CTA. `result` always carries `answers` and `raw`. Since 0.13.6 it may also carry `plan` (the backend's onboarding plan, present whenever the backend sends one; the kit never infers it from the flow type) and `variant` (the experiment arm the backend assigned, when the tenant runs one). Each of those two keys is set ONLY when the backend actually sent one, so a run without them returns exactly the object earlier versions returned: `"plan" in result` and `"variant" in result` both stay false. The kit does not interpret either: **validate `plan` before you apply it.** **Required.** |
235
235
  | `theme` | `Partial<OnboardingTheme>` | Brand colors/fonts/radius/spacing, deep-merged over a neutral default. |
236
236
  | `illustrations` | `Record<string, ReactNode>` | App artwork for `InterstitialCard`, keyed by name. |
237
237
  | `icons` | `Record<string, ReactNode>` | Your own icon nodes, keyed by the semantic name the AI emits (`{ instagram: <BrandIg/> }`). Checked first, so use it to put your brand mark on a choice card, to add names the vocabulary does not carry, or to get icons at all without installing `@expo/vector-icons`. Anything you leave out falls back to that optional peer, then to no icon. Never a crash. See [Icons](#icons). |
@@ -251,7 +251,7 @@ import { WireOnboarding } from "@wireai/activation";
251
251
  | `sessionTtlMs` | `number` | How long a persisted session id stays resumable. Default `3600000` (1h, the backend's session TTL). With `storage` only. |
252
252
  | `persistKey` | `string` | Override the storage key (default `wireai:session:<appId>`). Scope per-user if one device can onboard multiple accounts mid-flow. With `storage` only. |
253
253
  | `retainSessionOnComplete` | `boolean` | Opt IN: keep the persisted session seed alive across completion. A multi-stage signup that re-enters onboarding (typically a `key=` remount) then resumes the SAME session instead of minting a fresh `metadata.sessionId`, which the backend adopts as a second `contextId` and double-counts as a `session_started`. Omitted or `false` clears the seed on completion, the legacy single-stage behavior, so leaving it unset changes nothing. With `true`, freshness comes from `sessionTtlMs` plus an explicit new-run signal (scope a new `persistKey`, e.g. a per-signup id). With `storage` only. |
254
- | `userContext` | `Record<string, string \| number \| boolean>` | **This is where the join key goes.** `user_context.device_key` is the only thing that joins this onboarding session to the app's later events, so pass `userContext={activationJoinContext(deviceKey)}` (or `activationJoinContext(resolveAutoDeviceKey({ appId, storage }))` if your app owns no device id). Since 0.12.2, omitting it no longer empties the funnel silently: with `storage`, the kit injects its own key (see [The join key](#the-join-key-device_key-never-session_id) and `autoJoinKey` below). Second job: any other non-PII context the app already knows (signup method, referral, plan, a HASHED user id), forwarded on session metadata + client events for funnel segmentation. **No PII on THIS prop** (no raw emails/names); primitives only; the server caps size/keys. See [Device & user context](#device--user-context). ⚠️ Distinct from the analytics/activation **`WireUserContext`** object (`createAnalytics` / `useWireActivation`), which additionally accepts an opt-in raw `userEmail` — see [Rich user context & PII](#rich-user-context--pii). |
254
+ | `userContext` | `Record<string, string \| number \| boolean>` | **This is where the join key goes.** `user_context.device_key` is the only thing that joins this onboarding session to the app's later events, so pass `userContext={activationJoinContext(deviceKey)}`. If your app owns no device id, pass a working `storage` and leave this prop alone: the kit injects its own key, and only after it has confirmed the key actually persists. ⛔ Do not hand-build that key with `activationJoinContext(resolveAutoDeviceKey({ appId, storage }))`, because `resolveAutoDeviceKey` is synchronous by contract (it hands back a fresh mint and adopts the persisted id a storage read later) and it cannot tell you whether the id survives the launch at all. Since 0.12.2, omitting it no longer empties the funnel silently: with `storage`, the kit injects its own key (see [The join key](#the-join-key-device_key-never-session_id) and `autoJoinKey` below). Second job: any other non-PII context the app already knows (signup method, referral, plan, a HASHED user id), forwarded on session metadata + client events for funnel segmentation. **No PII on THIS prop** (no raw emails/names); primitives only; the server caps size/keys. See [Device & user context](#device--user-context). ⚠️ Distinct from the analytics/activation **`WireUserContext`** object (`createAnalytics` / `useWireActivation`), which additionally accepts an opt-in raw `userEmail` — see [Rich user context & PII](#rich-user-context--pii). |
255
255
  | `autoJoinKey` | `boolean` | Opt OUT of the automatic join key. Default `true`. When this prop is left alone, `storage` is present **and actually persists** (0.13.0: a `storage` adapter that rejects or throws is treated exactly like no `storage` — the kit declines and says so, because a key it cannot persist is a different key on every launch, which corrupts `min_sessions` rather than merely leaving the join empty), and `userContext` carries no `device_key`, the kit injects its own per-install key (the same id `createAnalytics` / `createWireActivation` mint and persist), so the `activated` funnel joins with no host wiring. Pass `autoJoinKey={false}` if you genuinely want an UNLINKED onboarding session: that restores the pre-0.12.2 behavior exactly, and the dev warning fires again. It never overrides a `device_key` you passed, and it cannot inject without `storage` (see [The join key](#the-join-key-device_key-never-session_id)). |
256
256
  | `userId` | `string` | Your own OPAQUE user id, so onboarding sessions reconcile to real users later (console sessions to your user table / GA4 users). Optional and supports **late binding**: present at mount it rides the session-start metadata; if it changes mid-session (the user just registered) the kit emits an `identify` event; available only after the flow, use `identifyOnboarding(...)`. **No PII** (not an email/name/phone); trimmed and capped at 128 chars. See [User identity](#user-identity). |
257
257
 
@@ -421,6 +421,14 @@ none, and waits for that key to be read back before it fires, so the events the
421
421
  `min_sessions` from carry the id this install keeps. Without storage the id would be per-launch, which
422
422
  would make every open look like a new device, so the fallback deliberately does not engage.
423
423
 
424
+ Since 0.14.0 that also covers a `storage` adapter that is present but cannot persist (locked, full,
425
+ permission denied, so it throws or rejects). The read settles non-durable and the hook declines the
426
+ key rather than stamping the in-memory one. Both events still fire, they just carry no auto
427
+ `device_key`. That is the same verdict `autoJoinKey` reaches on `<WireOnboarding>`, for the same
428
+ reason: a key that changes on every launch corrupts `min_sessions` and inflates distinct-device
429
+ counts, where an absent key only leaves the join empty. A `deviceKey` you supply yourself is never
430
+ touched by any of this.
431
+
424
432
  The two alternatives below are for a host that already owns an open counter. Both emit
425
433
  `app.session_started` only, so `app.first_open` stays empty forever on those paths.
426
434
 
@@ -1154,6 +1162,11 @@ const gate = useReviewGate({ config: { id: "home", minSessions: 2 }, decision, s
1154
1162
  await track("journal_done"); // POST stored → revalidation bumps → effect re-fetches → gate can fire
1155
1163
  ```
1156
1164
 
1165
+ `minSessions` counts **app-opens**, not mounts and not launches: a foreground after 30+ minutes in
1166
+ the background is a new one. That matters on iOS, where an app is suspended rather than killed — a
1167
+ user who never force-quits still advances the counter. (Before 0.14.1 it counted JS processes, so
1168
+ on those devices it stayed at 1 forever and a `minSessions: 2` gate could never fire.)
1169
+
1157
1170
  The questionnaire gate uses the identical shape: swap `fetchReviewDecision` / `useReviewGate`
1158
1171
  for `fetchQuestionnaireDecision` / `useQuestionnaireGate` from `@wireai/activation/questionnaire`
1159
1172
  (both take a `decision` prop). One `wire.track` call feeds every subscribed gate.
@@ -1,6 +1,6 @@
1
1
  export { R as ReportAppEventOptions, r as reportAppEvent } from '../transport-j5gFfJhK.mjs';
2
- import { C as ClientEventTarget, W as WireUserContext, E as EventQueueOptions } from '../currentSession-DngW-QoD.mjs';
3
- export { A as AUTO_DEVICE_ID_PREFIX, a as AnalyticsEvent, b as ClearUserContextOptions, c as ClientEvent, d as ClientEventType, e as ContextEnvelope, f as ContextEnvelopeInput, D as DeviceKeyStorage, g as EnvelopeSource, h as EventQueue, R as ResolveAutoDeviceKeyOptions, i as WIRE_ONBOARDING_EVENTS, j as WireOnboardingEventName, k as analyticsUserIdStorageKey, l as buildContextEnvelope, m as clearPiiFromContext, n as clearUserContext, o as createEventQueue, p as deviceIdStorageKey, q as ensureCurrentSessionId, r as getCurrentSessionId, s as looksLikeEmail, t as makeSessionId, u as reportClientEvent, v as reportClientEventAwait, w as reportClientEvents, x as reportClientEventsAwait, y as resetAutoDeviceKeys, z as resetCurrentSessionId, B as resetEventQueueKeys, F as resolveAutoDeviceKey, G as setCurrentSessionId, H as toAnalyticsEvent } from '../currentSession-DngW-QoD.mjs';
2
+ import { C as ClientEventTarget, W as WireUserContext, E as EventQueueOptions } from '../currentSession-CUvTOchb.mjs';
3
+ export { A as AUTO_DEVICE_ID_PREFIX, a as AnalyticsEvent, b as ClearUserContextOptions, c as ClientEvent, d as ClientEventType, e as ContextEnvelope, f as ContextEnvelopeInput, D as DeviceKeyStorage, g as EnvelopeSource, h as EventQueue, R as ResolveAutoDeviceKeyOptions, i as WIRE_ONBOARDING_EVENTS, j as WireOnboardingEventName, k as analyticsUserIdStorageKey, l as buildContextEnvelope, m as clearPiiFromContext, n as clearUserContext, o as createEventQueue, p as deviceIdStorageKey, q as ensureCurrentSessionId, r as getCurrentSessionId, s as looksLikeEmail, t as makeSessionId, u as reportClientEvent, v as reportClientEventAwait, w as reportClientEvents, x as reportClientEventsAwait, y as resetAutoDeviceKeys, z as resetCurrentSessionId, B as resetEventQueueKeys, F as resolveAutoDeviceKey, G as setCurrentSessionId, H as toAnalyticsEvent } from '../currentSession-CUvTOchb.mjs';
4
4
  import { W as WireOnboardingStorage } from '../types-BpwiRpA8.mjs';
5
5
  import '../types-Cju-1_jT.mjs';
6
6
  import '../types-BKfpdZzX.mjs';
@@ -268,6 +268,13 @@ type Analytics = {
268
268
  notifyOnline(): void;
269
269
  /** Current pending (in-memory) count. */
270
270
  size(): number;
271
+ /**
272
+ * Tear the instance's event queue down when its owner goes away (see {@link EventQueue.dispose}).
273
+ * Idempotent, never throws. Call it if you build an instance per screen / per mount: the queue
274
+ * claims a persisted storage slot, and a replacement built before the old one released it lands on
275
+ * a rotated `…#2` key that no later launch ever reads. `useAnalytics` does this for you.
276
+ */
277
+ dispose(): void;
271
278
  };
272
279
  /**
273
280
  * Create a bound analytics instance. Seeds one correlation `sessionId`, builds an offline-first
@@ -1,6 +1,6 @@
1
1
  export { R as ReportAppEventOptions, r as reportAppEvent } from '../transport-B_0SgCBe.js';
2
- import { C as ClientEventTarget, W as WireUserContext, E as EventQueueOptions } from '../currentSession-C5976akx.js';
3
- export { A as AUTO_DEVICE_ID_PREFIX, a as AnalyticsEvent, b as ClearUserContextOptions, c as ClientEvent, d as ClientEventType, e as ContextEnvelope, f as ContextEnvelopeInput, D as DeviceKeyStorage, g as EnvelopeSource, h as EventQueue, R as ResolveAutoDeviceKeyOptions, i as WIRE_ONBOARDING_EVENTS, j as WireOnboardingEventName, k as analyticsUserIdStorageKey, l as buildContextEnvelope, m as clearPiiFromContext, n as clearUserContext, o as createEventQueue, p as deviceIdStorageKey, q as ensureCurrentSessionId, r as getCurrentSessionId, s as looksLikeEmail, t as makeSessionId, u as reportClientEvent, v as reportClientEventAwait, w as reportClientEvents, x as reportClientEventsAwait, y as resetAutoDeviceKeys, z as resetCurrentSessionId, B as resetEventQueueKeys, F as resolveAutoDeviceKey, G as setCurrentSessionId, H as toAnalyticsEvent } from '../currentSession-C5976akx.js';
2
+ import { C as ClientEventTarget, W as WireUserContext, E as EventQueueOptions } from '../currentSession-CW_5Mq4O.js';
3
+ export { A as AUTO_DEVICE_ID_PREFIX, a as AnalyticsEvent, b as ClearUserContextOptions, c as ClientEvent, d as ClientEventType, e as ContextEnvelope, f as ContextEnvelopeInput, D as DeviceKeyStorage, g as EnvelopeSource, h as EventQueue, R as ResolveAutoDeviceKeyOptions, i as WIRE_ONBOARDING_EVENTS, j as WireOnboardingEventName, k as analyticsUserIdStorageKey, l as buildContextEnvelope, m as clearPiiFromContext, n as clearUserContext, o as createEventQueue, p as deviceIdStorageKey, q as ensureCurrentSessionId, r as getCurrentSessionId, s as looksLikeEmail, t as makeSessionId, u as reportClientEvent, v as reportClientEventAwait, w as reportClientEvents, x as reportClientEventsAwait, y as resetAutoDeviceKeys, z as resetCurrentSessionId, B as resetEventQueueKeys, F as resolveAutoDeviceKey, G as setCurrentSessionId, H as toAnalyticsEvent } from '../currentSession-CW_5Mq4O.js';
4
4
  import { W as WireOnboardingStorage } from '../types-BpwiRpA8.js';
5
5
  import '../types-h2BZvl1t.js';
6
6
  import '../types-BKfpdZzX.js';
@@ -268,6 +268,13 @@ type Analytics = {
268
268
  notifyOnline(): void;
269
269
  /** Current pending (in-memory) count. */
270
270
  size(): number;
271
+ /**
272
+ * Tear the instance's event queue down when its owner goes away (see {@link EventQueue.dispose}).
273
+ * Idempotent, never throws. Call it if you build an instance per screen / per mount: the queue
274
+ * claims a persisted storage slot, and a replacement built before the old one released it lands on
275
+ * a rotated `…#2` key that no later launch ever reads. `useAnalytics` does this for you.
276
+ */
277
+ dispose(): void;
271
278
  };
272
279
  /**
273
280
  * Create a bound analytics instance. Seeds one correlation `sessionId`, builds an offline-first
@@ -76,20 +76,21 @@ var reportClientEvents = (target, events) => {
76
76
  }
77
77
  };
78
78
  var reportClientEvent = (target, event) => reportClientEvents(target, [event]);
79
- var reportClientEventsAwait = async (target, events) => {
79
+ var reportClientEventsAwait = async (target, events) => await reportClientEventsOutcome(target, events) === "delivered";
80
+ var reportClientEventsOutcome = async (target, events) => {
80
81
  try {
81
82
  const req = buildEventsRequest(target, events);
82
- if (!req) return false;
83
+ if (!req) return "refused";
83
84
  const res = await fetch(req.url, req.init);
84
- if (!res || !res.ok) return false;
85
+ if (!res || !res.ok) return "unreachable";
85
86
  const ack = await readEventsAck(res);
86
- if (!ack || ack.skipped <= 0) return true;
87
+ if (!ack || ack.skipped <= 0) return "delivered";
87
88
  if (typeof __DEV__ !== "undefined" && __DEV__ && typeof console !== "undefined" && console.warn) {
88
89
  console.warn(describeDiscarded(ack));
89
90
  }
90
- return false;
91
+ return "refused";
91
92
  } catch {
92
- return false;
93
+ return "unreachable";
93
94
  }
94
95
  };
95
96
  var reportClientEventAwait = (target, event) => reportClientEventsAwait(target, [event]);
@@ -692,6 +693,9 @@ var claimQueueKey = (preferred, explicit) => {
692
693
  );
693
694
  return key;
694
695
  };
696
+ var releaseQueueKey = (key) => {
697
+ claimedQueueKeys().delete(key);
698
+ };
695
699
  var resetEventQueueKeys = () => claimedQueueKeys().clear();
696
700
  var READ_TIMED_OUT = /* @__PURE__ */ Symbol("wireai:storage-read-timeout");
697
701
  var withTimeout = (p, ms) => {
@@ -737,7 +741,8 @@ var createEventQueue = (options) => {
737
741
  let flushing = false;
738
742
  let attempt = 0;
739
743
  let retryTimer;
740
- let backlogUnread = false;
744
+ let disposed = false;
745
+ let backlogUnread = storage !== void 0;
741
746
  const resolveEnvelope = () => {
742
747
  try {
743
748
  return typeof options.envelope === "function" ? options.envelope() : options.envelope;
@@ -762,6 +767,7 @@ var createEventQueue = (options) => {
762
767
  };
763
768
  const persist = () => {
764
769
  if (!storage) return;
770
+ if (disposed) return;
765
771
  if (backlogUnread) return;
766
772
  try {
767
773
  if (pending.length === 0) {
@@ -775,8 +781,20 @@ var createEventQueue = (options) => {
775
781
  } catch {
776
782
  }
777
783
  };
784
+ const isCountedOpenEvent = (event) => typeof event.question_key === "string" && event.question_key.startsWith("app.");
778
785
  const enforceSizeCap = () => {
779
- if (pending.length > maxSize) pending.splice(0, pending.length - maxSize);
786
+ let overflow = pending.length - maxSize;
787
+ if (overflow <= 0) return;
788
+ const kept = [];
789
+ for (const item of pending) {
790
+ if (overflow > 0 && !isCountedOpenEvent(item.event)) {
791
+ overflow--;
792
+ continue;
793
+ }
794
+ kept.push(item);
795
+ }
796
+ if (overflow > 0) kept.splice(0, overflow);
797
+ pending = kept;
780
798
  };
781
799
  const safeSig = (event) => {
782
800
  try {
@@ -822,8 +840,12 @@ var createEventQueue = (options) => {
822
840
  });
823
841
  return;
824
842
  }
843
+ backlogUnread = false;
825
844
  mergePersisted(parsePersisted(raced));
845
+ persist();
826
846
  } catch {
847
+ backlogUnread = false;
848
+ persist();
827
849
  }
828
850
  })();
829
851
  const postBatch = async (events) => {
@@ -859,10 +881,12 @@ var createEventQueue = (options) => {
859
881
  unrefTimer(retryTimer);
860
882
  };
861
883
  const drain = async () => {
884
+ if (disposed) return;
862
885
  try {
863
886
  await loadPromise;
864
887
  } catch {
865
888
  }
889
+ if (disposed) return;
866
890
  if (flushing) return;
867
891
  flushing = true;
868
892
  try {
@@ -890,6 +914,7 @@ var createEventQueue = (options) => {
890
914
  }
891
915
  };
892
916
  const enqueue = (event) => {
917
+ if (disposed) return;
893
918
  try {
894
919
  const stamped = stamp(event);
895
920
  const sig = safeSig(stamped);
@@ -909,7 +934,13 @@ var createEventQueue = (options) => {
909
934
  flush();
910
935
  };
911
936
  const size = () => pending.length;
912
- return { enqueue, flush, notifyOnline, size };
937
+ const dispose = () => {
938
+ if (disposed) return;
939
+ disposed = true;
940
+ clearRetry();
941
+ if (storage) releaseQueueKey(key);
942
+ };
943
+ return { enqueue, flush, notifyOnline, size, dispose };
913
944
  };
914
945
 
915
946
  // src/identity/userIdentity.ts
@@ -1147,8 +1178,10 @@ var createAnalytics = (config, options = {}) => {
1147
1178
  });
1148
1179
  let boundUserId = sanitizeUserId((_c = config.userContext) == null ? void 0 : _c.userId);
1149
1180
  const storageKey = analyticsUserIdStorageKey(config.appId);
1181
+ let hydrationSuperseded = false;
1150
1182
  if (config.storage) {
1151
1183
  void config.storage.getItem(storageKey).then((saved) => {
1184
+ if (hydrationSuperseded) return;
1152
1185
  if (saved && !boundUserId) boundUserId = saved;
1153
1186
  }).catch(() => {
1154
1187
  });
@@ -1190,6 +1223,7 @@ var createAnalytics = (config, options = {}) => {
1190
1223
  }
1191
1224
  };
1192
1225
  const reset = () => {
1226
+ hydrationSuperseded = true;
1193
1227
  boundUserId = void 0;
1194
1228
  userContext = clearPiiFromContext(userContext);
1195
1229
  if (config.storage) void config.storage.removeItem(storageKey).catch(() => {
@@ -1247,23 +1281,33 @@ var createAnalytics = (config, options = {}) => {
1247
1281
  reset,
1248
1282
  flush: queue.flush,
1249
1283
  notifyOnline: queue.notifyOnline,
1250
- size: queue.size
1284
+ size: queue.size,
1285
+ dispose: queue.dispose
1251
1286
  };
1252
1287
  };
1253
1288
  var useAnalytics = (config, options = {}) => {
1254
- var _a;
1289
+ var _a, _b;
1255
1290
  const ref = react.useRef(void 0);
1256
1291
  const prevKeys = react.useRef("");
1257
1292
  const currentKeys = `${config.serverUrl}|${config.apiKey}|${config.appId}`;
1258
1293
  if (!ref.current || prevKeys.current !== currentKeys) {
1259
1294
  prevKeys.current = currentKeys;
1295
+ (_a = ref.current) == null ? void 0 : _a.dispose();
1260
1296
  ref.current = createAnalytics(config, options);
1261
1297
  }
1262
- const hostDeviceKey = typeof ((_a = config.userContext) == null ? void 0 : _a.deviceKey) === "string" && config.userContext.deviceKey.trim() ? config.userContext.deviceKey.trim() : void 0;
1298
+ const hostDeviceKey = typeof ((_b = config.userContext) == null ? void 0 : _b.deviceKey) === "string" && config.userContext.deviceKey.trim() ? config.userContext.deviceKey.trim() : void 0;
1263
1299
  react.useEffect(() => {
1264
1300
  var _a2;
1265
1301
  if (hostDeviceKey) (_a2 = ref.current) == null ? void 0 : _a2.setUserContext({ deviceKey: hostDeviceKey });
1266
1302
  }, [hostDeviceKey]);
1303
+ react.useEffect(
1304
+ () => () => {
1305
+ var _a2;
1306
+ (_a2 = ref.current) == null ? void 0 : _a2.dispose();
1307
+ ref.current = void 0;
1308
+ },
1309
+ []
1310
+ );
1267
1311
  return ref.current;
1268
1312
  };
1269
1313