@wireai/activation 0.14.0 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wireai/activation",
3
- "version": "0.14.0",
3
+ "version": "0.14.1",
4
4
  "private": false,
5
5
  "description": "Premium, fully-themable drop-in AI onboarding kit for React Native / Expo, on top of the open-source wireai-rn SDK.",
6
6
  "author": "Malik Chohra <malik@getwireai.com>",
@@ -36,7 +36,7 @@ import {
36
36
  } from "./session/persistedSession";
37
37
  import {
38
38
  clearSettledPermissions,
39
- loadSettledPermissions,
39
+ loadSettledPermissionsOutcome,
40
40
  permissionStorageKey,
41
41
  saveSettledPermissions,
42
42
  } from "./permissions/permissionMemory";
@@ -384,18 +384,57 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
384
384
  // a late arrival cannot recreate the A2A adapter. The flow simply suppresses permission screens
385
385
  // while `permissionsPending` is true, and that window closes long before the first card arrives
386
386
  // (one timeout-capped storage read against a backend round trip).
387
+ //
388
+ // ── AN UNREADABLE RECORD IS NOT AN EMPTY ONE (ruled 2026-08-18: skip, never re-ask) ──────────
389
+ //
390
+ // A read that timed out or threw used to arrive here as `[]`, i.e. "this session has settled
391
+ // nothing", and that guess is wrong in the expensive direction TWICE. The flow re-asks a
392
+ // permission the OS grants exactly once, AND the first answer then persists a set grown from
393
+ // that `[]`, overwriting a stored record that may have held several ids with a single one. A
394
+ // slow adapter could therefore permanently shrink the memory it exists to keep.
395
+ //
396
+ // So `unknown` is now its own outcome and it means: leave the memory UNRESOLVED. Because
397
+ // `permissionsPending` below is exactly `settledPermissions === undefined`, an unresolved memory
398
+ // suppresses every permission screen for this mount — which is the ruling, and it also closes
399
+ // the write path for free (no screen renders → nothing settles → `saveSettledPermissions` is
400
+ // never called → the stored record is left exactly as it was).
401
+ //
402
+ // The cost is stated rather than hidden: on a host whose adapter never answers, configured
403
+ // permission screens silently never show. That is the failure `placement.ts` calls out as the
404
+ // one a host cannot see, so it warns in dev naming the adapter.
387
405
  const wantsPermissionMemory = Boolean(storage) && (permissionScreens?.length ?? 0) > 0;
388
406
  const [settledPermissions, setSettledPermissions] = useState<string[] | undefined>(undefined);
407
+ // LATCHED: without it, a hung or throwing adapter is re-read on every single render — an
408
+ // unbounded read loop against the storage that is already failing.
409
+ const [permissionMemoryUnreadable, setPermissionMemoryUnreadable] = useState(false);
389
410
  useEffect(() => {
390
- if (!wantsPermissionMemory || !storage || !sessionId || settledPermissions !== undefined) return;
411
+ if (!wantsPermissionMemory || !storage || !sessionId) return;
412
+ if (settledPermissions !== undefined || permissionMemoryUnreadable) return;
391
413
  let cancelled = false;
392
- void loadSettledPermissions(storage, permissionsKey, sessionId).then((ids) => {
393
- if (!cancelled) setSettledPermissions(ids);
414
+ void loadSettledPermissionsOutcome(storage, permissionsKey, sessionId).then((outcome) => {
415
+ if (cancelled) return;
416
+ if (outcome.status === "read") {
417
+ setSettledPermissions(outcome.ids);
418
+ return;
419
+ }
420
+ setPermissionMemoryUnreadable(true);
421
+ warnInDev(
422
+ "[wireai] the permission-screen memory could not be read (the storage adapter timed out " +
423
+ "or threw), so permission screens are suppressed for this session rather than re-asking " +
424
+ "a permission the OS grants once. Check the `storage` adapter passed to WireOnboarding.",
425
+ );
394
426
  });
395
427
  return () => {
396
428
  cancelled = true;
397
429
  };
398
- }, [wantsPermissionMemory, storage, permissionsKey, sessionId, settledPermissions]);
430
+ }, [
431
+ wantsPermissionMemory,
432
+ storage,
433
+ permissionsKey,
434
+ sessionId,
435
+ settledPermissions,
436
+ permissionMemoryUnreadable,
437
+ ]);
399
438
 
400
439
  // Persist the settled set as it grows. Fire-and-forget: a failed write only costs a re-ask on a
401
440
  // resume, never a broken flow. Without `storage` there is nothing to write to and the
@@ -63,8 +63,9 @@ import { warnInDev } from "../utils/warnInDev";
63
63
  * @globalSlot LIVE — every app-open overwrites this with that open's id, so a reader that captures
64
64
  * it into a module-local (or a `const` taken once at mount) posts the PREVIOUS open's session to a
65
65
  * server that has already moved on. Read it at the moment of use, through `getCurrentSessionId()` /
66
- * `ensureCurrentSessionId()`. `reviews/runtime`'s `currentOpenId()` deliberately pins one sample of
67
- * it for the launch; that is a documented latch DERIVED from this slot, not a cache of it.
66
+ * `ensureCurrentSessionId()`. `reviews/runtime`'s `currentOpenId()` derives its own pinned value
67
+ * from this slot: it holds one sample for the duration of an app-open and re-pins when a genuinely
68
+ * NEW open is registered here, so it follows this slot deliberately and late, never eagerly.
68
69
  */
69
70
  const CURRENT_SESSION_ID_SLOT: unique symbol = Symbol.for(
70
71
  "@wireai/activation:currentSessionId",
@@ -48,29 +48,54 @@ export const readSettledPermissions = (
48
48
  }
49
49
  };
50
50
 
51
- /** Read the settled ids for `sessionId`. Never throws, never hangs past the shared read ceiling. */
52
- export const loadSettledPermissions = async (
51
+ /**
52
+ * Did the store ANSWER, or did it not? A read that timed out or threw is `unknown` — which is NOT
53
+ * the same fact as "this session has settled nothing", even though both used to arrive as `[]`.
54
+ *
55
+ * The distinction is load-bearing in two directions. A caller that treats `unknown` as empty
56
+ * (a) re-asks a permission the OS grants exactly once, and (b) persists a set it grew from that
57
+ * `[]`, SHRINKING the stored record to a single id. A successful read of a corrupt, absent, or
58
+ * other-session entry is a genuine `[]`: the store answered, and there is nothing here for us.
59
+ */
60
+ export type SettledPermissionsOutcome =
61
+ | { status: "read"; ids: string[] }
62
+ | { status: "unknown" };
63
+
64
+ /**
65
+ * Read the settled ids for `sessionId`, reporting WHETHER the store answered. Never throws, never
66
+ * hangs past the shared read ceiling.
67
+ */
68
+ export const loadSettledPermissionsOutcome = async (
53
69
  storage: WireOnboardingStorage,
54
70
  key: string,
55
71
  sessionId: string,
56
- ): Promise<string[]> => {
72
+ ): Promise<SettledPermissionsOutcome> => {
57
73
  try {
58
74
  const raw = await withTimeout(storage.getItem(key), READ_TIMEOUT_MS);
59
- // A timed-out read degrades to "nothing settled yet", the same answer as before this sentinel
60
- // existed (the symbol used to fall through `readSettledPermissions`'s JSON.parse and be caught).
61
- // Stated explicitly rather than left to an accident.
62
- //
63
- // ⚠️ KNOWN, NOT FIXED HERE: the CALLER (`WireOnboarding`) then persists the set it grows from
64
- // this `[]`, so a timed-out read can shrink the stored set to one id. It is the same class as
65
- // the two sites above, but the fix is a UX ruling, not a mechanical one — on an unknown set,
66
- // does a resumed flow re-ask a permission or skip it? Reported, deliberately not guessed at.
67
- if (raw === READ_TIMED_OUT) return [];
68
- return readSettledPermissions(raw, sessionId);
75
+ if (raw === READ_TIMED_OUT) return { status: "unknown" };
76
+ return { status: "read", ids: readSettledPermissions(raw, sessionId) };
69
77
  } catch {
70
- return [];
78
+ // The adapter threw or rejected: the store did not answer, so we know nothing either way.
79
+ return { status: "unknown" };
71
80
  }
72
81
  };
73
82
 
83
+ /**
84
+ * Read the settled ids for `sessionId`, collapsing "the store did not answer" to `[]`.
85
+ *
86
+ * Kept as the module's simple reader for callers that have no way to act on the difference. The
87
+ * one caller that CAN — `WireOnboarding`, which would otherwise re-ask and then overwrite the
88
+ * stored record — uses {@link loadSettledPermissionsOutcome} instead.
89
+ */
90
+ export const loadSettledPermissions = async (
91
+ storage: WireOnboardingStorage,
92
+ key: string,
93
+ sessionId: string,
94
+ ): Promise<string[]> => {
95
+ const outcome = await loadSettledPermissionsOutcome(storage, key, sessionId);
96
+ return outcome.status === "read" ? outcome.ids : [];
97
+ };
98
+
74
99
  /** Persist the settled ids for `sessionId` - fire-and-forget, all errors swallowed. */
75
100
  export const saveSettledPermissions = (
76
101
  storage: WireOnboardingStorage,
@@ -125,7 +125,7 @@ const readStr = (storage: CoachmarkStorage | null, key: string): string | undefi
125
125
  // for the same reason `currentSession` does: tsup inlines this module into several bundles and a
126
126
  // plain module-local `let` would give each bundle its own "process".
127
127
  //
128
- // ── WHY THE UNIT IS PINNED FOR THE WHOLE LAUNCH ──────────────────────────────────────────────
128
+ // ── WHY THE UNIT IS PINNED, AND WHEN IT IS ALLOWED TO MOVE ───────────────────────────────────
129
129
  // The first shape of this function read the two tiers LIVE on every call: the registered session id
130
130
  // when there was one, else the process id. That let the UNIT change mid-launch, and React's own
131
131
  // ordering guarantees it does. The gates call `bumpSessionCount` from a `useState` INITIALIZER,
@@ -137,39 +137,91 @@ const readStr = (storage: CoachmarkStorage | null, key: string): string | undefi
137
137
  // remount: gate initializer → SESSION id ≠ the stored open → count = 2
138
138
  //
139
139
  // Two "sessions" inside one app open, which makes the fail-closed `minSessions: 2` default (added
140
- // after the 2026-07-16 one-star incident) satisfiable in the very launch it exists to guard. So the
141
- // FIRST read pins whatever it resolved into the process slot and every later read returns that,
142
- // regardless of what the session registry does afterwards. The client counter only has to be
143
- // monotone and per-launch; the server's own `min_sessions` still counts real `app.session_started`
144
- // events, so nothing downstream needs the two ids to be identical.
140
+ // after the 2026-07-16 one-star incident) satisfiable in the very launch it exists to guard.
145
141
  //
146
- // @globalSlot LATCH pinned by the FIRST read and stable for the whole launch. This is the one
147
- // slot whose latch is the fix: it samples the LIVE `currentSessionId` once, on purpose, so that
148
- // registry changing underneath it (render before effect, on every cold start) cannot change the
149
- // unit mid-launch and hand `minSessions: 2` a second "session" inside one app open. A second
150
- // differing write is therefore not a stale-cache bug, it IS the defect see the block above.
142
+ // THE FIRST FIX PINNED THE WHOLE LAUNCH one sample, never revisited. That closed the cold start
143
+ // and opened the opposite defect. `useLifecycleEvents` defines an app-open as a foreground after 30
144
+ // minutes
145
+ // in the background and fires a fresh `app.session_started` for it, but on iOS an app is SUSPENDED,
146
+ // not killed, so ONE JS process outlives many opens. A user who opens the app daily for a week
147
+ // produces seven server-side opens while `wire_review_<id>_sessions` stays at 1, and the local
148
+ // `minSessions: 2` rule is unsatisfiable on a phone that is never force-quit — which is most
149
+ // phones. It failed in the SAFE direction (a prompt that never shows, not one that shows too
150
+ // early), but the counter's own docstring promised "once per APP-OPEN" and did not deliver it.
151
+ //
152
+ // THE DISTINCTION THAT RESOLVES BOTH. The two ids in the cold-start trace name the SAME open; a
153
+ // LATER registration names a DIFFERENT one. Nothing else has to change: remember the registered id
154
+ // that was live when the pin was taken, and read it lazily.
155
+ //
156
+ // • pin unset → pin = the registered id ?? a freshly minted one, and remember which
157
+ // registered id that was (`undefined` when we had to mint).
158
+ // • remembered `undefined` → the FIRST registration merely NAMES the open the gate already
159
+ // counted under a minted id. Adopt it, leave the PIN alone. This is
160
+ // the cold-start case, and it stays shut.
161
+ // • live id ≠ remembered → a genuinely new app-open. Re-pin to it; the counter bumps once.
162
+ //
163
+ // ⚠️ ONE ACCEPTED MISS, deliberately in the safe direction: if the mount open never registers (a
164
+ // refused non-durable device key over a broken store) and a LATER foreground is the first
165
+ // registration the launch ever sees, that registration is ADOPTED rather than counted, costing one
166
+ // open on an already storage-degraded device. Under-counting is the only direction a fail-closed
167
+ // gate may err in, so this is accepted rather than chased.
168
+ //
169
+ // The client counter only has to be monotone and to move once per real open; the server's own
170
+ // `min_sessions` still counts real `app.session_started` events, so nothing downstream needs the
171
+ // two ids to be identical.
172
+ //
173
+ // @globalSlot LIVE — it re-pins on a genuinely new app-open, so a reader that caches the returned
174
+ // string across opens re-freezes the counter at 1, and the local minSessions rule stops advancing
175
+ // forever on a suspended-not-killed app. The slot holds a PAIR rather than a bare id, because "is this a new
176
+ // open?" is only answerable against the registered id that was live when the pin was taken.
151
177
  const PROCESS_OPEN_ID_SLOT: unique symbol = Symbol.for("@wireai/activation:processOpenId");
152
178
 
153
- type GlobalWithOpenId = typeof globalThis & { [PROCESS_OPEN_ID_SLOT]?: string };
179
+ /** The pinned open id plus the registered session id that was live at the moment it was pinned. */
180
+ type OpenPin = {
181
+ /** The id gate counting treats as THIS app-open. Never empty. */
182
+ openId: string;
183
+ /** The registered session id observed at pin time; `undefined` when `openId` was minted. */
184
+ observed: string | undefined;
185
+ };
186
+
187
+ type GlobalWithOpenId = typeof globalThis & { [PROCESS_OPEN_ID_SLOT]?: OpenPin };
154
188
 
155
189
  const openIdGlobal = globalThis as GlobalWithOpenId;
156
190
 
157
191
  /**
158
- * The id identifying THIS app-open for gate counting, PINNED on first read for the whole launch:
159
- * the live per-open `session_id` if one was already registered when the first gate asked, else a
160
- * minted per-process id. Stable from the first render to the last. Never empty.
192
+ * The id identifying THIS app-open for gate counting: the live per-open `session_id` once one has
193
+ * been registered, else a minted per-process id. Stable for the whole of an app-open and across
194
+ * every remount inside it, and it moves exactly once when a genuinely new open is registered.
195
+ * Never empty.
161
196
  */
162
197
  export const currentOpenId = (): string => {
163
- const existing = openIdGlobal[PROCESS_OPEN_ID_SLOT];
164
- if (existing) return existing;
165
- // Adopt the registered session id when the host wired lifecycle BEFORE any gate rendered; that is
166
- // the same unit the server counts. Otherwise mint one. Either way it is pinned from here on.
167
- const resolved = getCurrentSessionId() ?? makeSessionId();
168
- openIdGlobal[PROCESS_OPEN_ID_SLOT] = resolved;
169
- return resolved;
198
+ const live = getCurrentSessionId();
199
+ const pin = openIdGlobal[PROCESS_OPEN_ID_SLOT];
200
+
201
+ // First read of the launch. Adopt the registered id when the host wired lifecycle BEFORE any gate
202
+ // rendered that is the same unit the server counts — otherwise mint one and record that we did.
203
+ if (!pin) {
204
+ const openId = live ?? makeSessionId();
205
+ openIdGlobal[PROCESS_OPEN_ID_SLOT] = { openId, observed: live };
206
+ return openId;
207
+ }
208
+
209
+ // Nothing registered yet, or still the same registered open: the pin stands.
210
+ if (!live || live === pin.observed) return pin.openId;
211
+
212
+ // The first registration of the launch NAMES the open we already pinned under a minted id, so it
213
+ // is adopted, not counted. Re-pinning here is exactly the cold-start double-count described above.
214
+ if (pin.observed === undefined) {
215
+ openIdGlobal[PROCESS_OPEN_ID_SLOT] = { openId: pin.openId, observed: live };
216
+ return pin.openId;
217
+ }
218
+
219
+ // A second, DIFFERENT registration is a genuinely new app-open.
220
+ openIdGlobal[PROCESS_OPEN_ID_SLOT] = { openId: live, observed: live };
221
+ return live;
170
222
  };
171
223
 
172
- /** Test-only: forget the process open id so a unit test starts from a clean launch. */
224
+ /** Test-only: forget the pinned open so a unit test starts from a clean launch. */
173
225
  export const resetProcessOpenId = (): void => {
174
226
  openIdGlobal[PROCESS_OPEN_ID_SLOT] = undefined;
175
227
  };