@wireai/activation 0.13.6 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/AGENTS.md +21 -9
  2. package/CHANGELOG.md +172 -1
  3. package/INTEGRATION_PROMPT.md +7 -4
  4. package/README.md +10 -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 +24 -6
  14. package/dist/index.d.ts +24 -6
  15. package/dist/index.js +631 -548
  16. package/dist/index.js.map +1 -1
  17. package/dist/index.mjs +631 -548
  18. package/dist/index.mjs.map +1 -1
  19. package/dist/questionnaire/index.js.map +1 -1
  20. package/dist/questionnaire/index.mjs.map +1 -1
  21. package/dist/reviews/index.js.map +1 -1
  22. package/dist/reviews/index.mjs.map +1 -1
  23. package/llms.txt +2 -2
  24. package/package.json +1 -1
  25. package/src/OnboardingFlow.tsx +4 -3
  26. package/src/WireOnboarding.tsx +4 -3
  27. package/src/activation/useWireActivation.ts +14 -1
  28. package/src/activation/wireActivation.ts +68 -2
  29. package/src/analytics/analyticsFacade.ts +24 -0
  30. package/src/analytics/eventQueue.ts +106 -11
  31. package/src/analytics/reportClientEvent.ts +31 -7
  32. package/src/analytics/useAnalytics.ts +17 -0
  33. package/src/context/deviceId.ts +10 -3
  34. package/src/permissions/permissionMemory.ts +12 -1
  35. package/src/session/persistedSession.ts +32 -8
  36. package/src/session-analytics/lifecycle.ts +26 -5
  37. package/src/session-analytics/reportSessionStart.ts +14 -10
  38. package/src/session-analytics/useLifecycleEvents.ts +70 -32
  39. package/src/session-analytics/useSessionStart.ts +57 -15
  40. package/src/types.ts +16 -6
  41. 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,175 @@
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.0] - 2026-08-17
7
+
8
+ A durability release, in two rounds. Round 1 stopped the two counted lifecycle events from riding a
9
+ per-launch device key. Round 2 came out of a fresh-eyes audit of the whole package under a
10
+ reproduction-first rule: eight findings, seven reproduced with a failing test before anything was
11
+ changed, one reproduced and deliberately left unfixed because its fix is a design ruling.
12
+
13
+ **MINOR, not patch, and only because of new API.** Every behaviour change here is a bug fix; the
14
+ version moved because four public surfaces gained members: `EventQueue.dispose()`,
15
+ `Analytics.dispose()`, `WireActivation.dispose()` and `WireActivationConfig.sink`. Nothing was
16
+ removed, nothing was renamed, and no existing call signature changed.
17
+
18
+ ### Fixed
19
+
20
+ - **The event queue has a teardown, and needs one.** A queue claimed a persisted storage slot and
21
+ armed a backoff timer, and nothing ever gave either back — "already claimed" was read as "held by
22
+ a LIVE one", which is liveness nothing tracked. Two consequences, both silent. A remount (nav,
23
+ Fast Refresh, StrictMode's double-invoke, an `appId` that arrives async) rotated the replacement
24
+ onto `…#2`, so the whole launch persisted into a slot the NEXT launch never reads, and the `#N`
25
+ keys grew without bound. Worse, on the explicit-key path both queues address the SAME slot: the
26
+ dead one's retry timer woke up, drained, emptied, and `removeItem`d the live queue's persisted
27
+ `app.session_started`. (`unrefTimer` is a Node affordance; under Hermes that timer is fully
28
+ alive.) `dispose()` clears the timer, releases the claim and makes the queue inert;
29
+ `useLifecycleEvents`, `useAnalytics` and `useWireActivation` all call it on unmount and before
30
+ replacing an instance.
31
+
32
+ - **A storage read that TIMED OUT is no longer read as "nothing stored".** `withTimeout` resolved
33
+ `undefined` at the ceiling, which is byte-identical to a genuine empty read, and two callers then
34
+ did something irreversible with the guess. `loadPersistedSession` minted a new session id and
35
+ wrote it OVER the still-valid one it had not managed to read (the server then sees a second
36
+ `session_started` and back-fills the first as a phantom drop, the exact inflation that module
37
+ exists to prevent). `reportFirstOpen` treated the unread flag as "never fired" and emitted
38
+ `app.first_open` again on an install that already had one. Both now carry the distinct
39
+ `READ_TIMED_OUT` sentinel the event queue already had, and do nothing destructive on a timeout.
40
+
41
+ The same rule now covers an UNREADABLE flag (a locked / full / permission-denied adapter): that
42
+ fails on every launch, so emitting made `app.first_open` fire on every launch of that install,
43
+ counting one device as an unbounded number of installs. The kit's standing rule for an
44
+ unverifiable persistence answer, set in 0.13.0 and re-applied throughout this release, is to
45
+ decline rather than emit something that corrupts a metric. A host that wants `first_open` on a
46
+ device with no working storage should pass no `storage` at all, which is the documented degraded
47
+ mode and still fires once per process.
48
+
49
+ - **`Analytics.reset()` can no longer be undone by the read that was already in flight.** The
50
+ construction-time user-id hydration adopted its answer under `if (saved && !boundUserId)`, and a
51
+ logout is the one case where an empty binding is deliberate — so a read landing after `reset()`
52
+ re-bound the user who had just logged out, onto every subsequent event. `reset()` now marks the
53
+ hydration superseded.
54
+
55
+ - **The offline size cap no longer spends the funnel-denominator events first.** The buffer is
56
+ drop-oldest at 200, and at an app-open the oldest events in it are exactly `app.first_open` and
57
+ `app.session_started`. A host on the documented shared-sink wiring that went offline for a long
58
+ session pushed past the cap on screen views alone and evicted the open itself. Eviction now spends
59
+ ORDINARY events first; the cap stays a hard ceiling, so a buffer of nothing but `app.*` events
60
+ still evicts oldest-first rather than growing.
61
+
62
+ - **`wire.track()` no longer throws the event away when the POST fails.** It is the ACTION reporter,
63
+ called at exactly the moments a phone is offline, and a failed POST used to be the end of that
64
+ event's life — so the server-side trigger keyed on that `question_key` simply never fired for that
65
+ user. A failed send now goes to a durable buffer: your queue if you pass the new `sink`, otherwise
66
+ one this instance opens on the FIRST failure and never before. `track` still resolves `false` and
67
+ still does not revalidate, because the caller asked whether the server has it now.
68
+
69
+ ⛔ Only a TRANSPORT failure is buffered. An event the server READ and refused (HTTP 200 carrying
70
+ `skipped`) is not retried: a retry is a re-decline, and it would park a poison event in a
71
+ persisted backlog that fails on every drain and every launch. `reportClientEventsOutcome` is the
72
+ internal seam that tells the two apart.
73
+
74
+ - **The unread-backlog guard is armed at construction, not 1500ms later.** It was only set in the
75
+ timeout branch, so for the first 1500ms of every launch the queue wrote freely over a blob it had
76
+ not read, and a drain that emptied in that window `removeItem`d it outright. An adapter that
77
+ resolves a read against its current state then answers `null` and the backlog is gone. The comment
78
+ claiming writes were suppressed "until the read lands" is now true.
79
+
80
+ - **`reportSessionStart`'s direct-POST fallback goes through `buildEventsRequest`.** It hand-built
81
+ its own url, headers and body, so it was the one send path of five that skipped the epoch-ms →
82
+ ISO8601 `ts` conversion — on the event `min_sessions` is counted from, where the failure shape is
83
+ HTTP 200 with `{written: 0, skipped: N}` and no client-visible error. Harmless today (that event
84
+ carries no `ts`), latent tomorrow. The "single choke point" claim in `reportClientEvent.ts` is now
85
+ held to the code by a test rather than by prose.
86
+
87
+ ### Known, reproduced, NOT fixed
88
+
89
+ - **The review/questionnaire gates count PROCESSES, while an app-open is a 30-minute foreground.**
90
+ `currentOpenId()` is pinned for the life of the JS process. On iOS an app is suspended, not
91
+ killed, so a user opening the app daily for a week produces seven `app.session_started` events
92
+ (the SERVER's `min_sessions` advances to 7) while the on-device `wire_review_<id>_sessions` stays
93
+ at 1 forever, and a LOCAL `minSessions: 2` rule can never be met on a phone that is never
94
+ force-quit. It fails in the safe direction (a prompt that never shows), which is why it is not
95
+ being rushed.
96
+
97
+ Reproduced in `src/reviews/sessionCountAcrossOpens.test.ts`, marked `todo`, with the smallest
98
+ coherent fix written out next to it. It is deferred because that fix requires re-declaring the
99
+ `processOpenId` global slot LIVE and rewriting the `globalSlotDiscipline` canary exercise that
100
+ currently asserts this behaviour is correct — a canary written to guard the 2026-07-16 one-star
101
+ incident. Re-ruling it is a design decision, not an implementation detail.
102
+
103
+ - **`loadSettledPermissions` has the same timed-out-read class**, and its caller then persists a set
104
+ grown from `[]`, which can shrink the stored one. Made explicit in code rather than left to an
105
+ accident, but not changed: the fix needs a UX ruling (on an unknown set, does a resumed flow
106
+ re-ask a permission or skip it?), and guessing at that is how a user gets asked twice.
107
+
108
+ ### Changed (docs)
109
+
110
+ - Every host-facing surface stopped recommending the defect class this release fixes. The README
111
+ prop table, `AGENTS.md`, `llms.txt`, `INTEGRATION_PROMPT.md`, the `WireOnboardingProps.userContext`
112
+ docstring and the `__DEV__` missing-join-key warning all told a host with no device id of its own
113
+ to write `userContext={activationJoinContext(resolveAutoDeviceKey({ appId, storage }))}`. That is
114
+ the synchronous, durability-blind read, used as a cross-launch join key. They now say to pass a
115
+ working `storage` and let the kit inject the key it has confirmed persists.
116
+ - `ai_rules/context_map.md` gained the four module rows it was missing (`src/analytics/`,
117
+ `src/session-analytics/`, `src/context/`, `src/device/`). The map is the file the repo's own rules
118
+ tell an agent to read instead of grepping `src/`, and it was silent on the directories this whole
119
+ release lives in.
120
+
121
+ ## [0.13.7] — superseded by 0.14.0, never published
122
+
123
+ The round-1 device-key fixes below shipped as part of **0.14.0**. The entry is kept because it is the
124
+ detailed record of that half of the work.
125
+
126
+ The two events the server counts the funnel DENOMINATOR from stopped riding a per-launch device key.
127
+ `app.first_open` and `app.session_started` are the only events `min_sessions` is computed from (the
128
+ server counts distinct opens grouped by `user_context.device_key`), so a key that differs on every
129
+ launch does not merely leave that counter empty, it caps it at 1 and inflates distinct-device counts
130
+ at the same time. Both lifecycle hooks were doing exactly that, in two different ways.
131
+
132
+ Nothing on the wire changed shape, no export was added or removed, and a host-supplied `deviceKey` is
133
+ byte-identical to 0.13.6 on every path.
134
+
135
+ ### Fixed
136
+
137
+ - **`useLifecycleEvents(...)` no longer stamps the in-memory mint when the storage read settles
138
+ NON-DURABLE.** It already awaited hydration before the mount fire, so a healthy store was correct.
139
+ A locked / full / permission-denied adapter is the case it missed: the read settles degraded, the
140
+ `.then` fired anyway, and the synchronous auto id went onto both counted events. Measured on
141
+ 0.13.6: two launches over a throwing MMKV produced four distinct `wdev_*`.
142
+
143
+ The auto path now resolves through `hydrateDeviceIdentity(...)`, the provenance-carrying read, and
144
+ refuses a `durable: false` outcome, which is the rule `src/context/deviceId.ts` has stated for
145
+ these callers since 0.13.0 and `<WireOnboarding>`'s `autoJoinKey` has obeyed since then. A refusal
146
+ emits both events with NO auto `device_key`, the same branch a config with no `storage` already
147
+ took, never a silenced event. **Every fire path obeys it**, not just the mount one: the foreground
148
+ re-fire after a real background re-runs the same read, so a transient cold-boot storage lock gets a
149
+ fresh attempt on the next open instead of a verdict cached for the process lifetime.
150
+
151
+ - **`useSessionStart(...)` waits for the persisted key before its mount fire.** This hook never got
152
+ the 0.13.3 hydration fix its sibling did, so it stamped a brand-new `wdev_*` on `app.session_started`
153
+ on every launch even on a perfectly HEALTHY store, while every other kit surface adopted the
154
+ persisted id milliseconds later. Any host wiring this path had a structurally unsatisfiable
155
+ `min_sessions` and no error anywhere. It now takes the same awaited, durability-checked auto path as
156
+ `useLifecycleEvents(...)`, on the mount fire and on the foreground re-fire.
157
+
158
+ It is not deprecated and its contract is otherwise unchanged: a host-supplied `deviceKey` and a
159
+ config with no `storage` both still fire synchronously, because there is nothing to read.
160
+
161
+ ### Unchanged
162
+
163
+ - `createAnalytics(...)` / `createWireActivation(...)` keep stamping the synchronous
164
+ `resolveAutoDeviceKey(...)` per event, deliberately. Their documented contract is that they never
165
+ throw and degrade to the in-memory id, they are numerator paths rather than the counted opens, and
166
+ they re-read the registry per event so they adopt the persisted id as soon as it lands.
167
+ - `hydrateAutoDeviceKey(...)` stays exported and behaves identically. No kit surface uses it any more:
168
+ it returns a bare string, and a string cannot say whether it survives the launch, which is the whole
169
+ reason `hydrateDeviceIdentity(...)` exists.
170
+ - Docs swept for the claim this changes: the README lifecycle section, `AGENTS.md` step 8, `llms.txt`
171
+ and `INTEGRATION_PROMPT.md` all said "with `config.storage` it stamps the persisted auto
172
+ `device_key`". Presence of the prop was never the condition. It is now written as what it is: a
173
+ storage adapter that throws or rejects counts as no storage.
174
+
6
175
  ## [0.13.6] - 2026-08-09
7
176
 
8
177
  Two things the backend already decides, handed to the host instead of guessed at. Both ride the
@@ -19,7 +188,9 @@ entry. This entry is that debt paid, plus the `variant` half, released together
19
188
  On the AI path the server appends a SECOND A2A DataPart to the turn it finishes on,
20
189
  `{ kind: "onboarding_plan", plan: {...} }`, alongside the component envelope the renderer already
21
190
  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.
191
+ puts it on the result. A run the server finishes without a plan is unchanged: the key stays off
192
+ the result object entirely. Which runs carry a plan is the backend's configuration, and the
193
+ reader never infers it from the flow type.
23
194
 
24
195
  The one thing the reader is allowed to reject is a structural fact: a plan is an object. A scalar,
25
196
  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
 
@@ -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