@wireai/activation 0.14.3 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +96 -21
- package/CHANGELOG.md +780 -0
- package/INTEGRATION_PROMPT.md +61 -23
- package/README.md +110 -31
- package/dist/analytics/index.d.mts +35 -13
- package/dist/analytics/index.d.ts +35 -13
- package/dist/analytics/index.js +288 -127
- package/dist/analytics/index.js.map +1 -1
- package/dist/analytics/index.mjs +288 -127
- package/dist/analytics/index.mjs.map +1 -1
- package/dist/coachmarks/index.d.mts +15 -1
- package/dist/coachmarks/index.d.ts +15 -1
- package/dist/coachmarks/index.js +120 -22
- package/dist/coachmarks/index.js.map +1 -1
- package/dist/coachmarks/index.mjs +120 -22
- package/dist/coachmarks/index.mjs.map +1 -1
- package/dist/{currentSession-CUvTOchb.d.mts → currentSession-Bz7G6lno.d.mts} +35 -39
- package/dist/{currentSession-CW_5Mq4O.d.ts → currentSession-z-CZ55ad.d.ts} +35 -39
- package/dist/{decision-Bgo17oH7.d.mts → decision-3vWLuBlO.d.ts} +11 -2
- package/dist/{decision-Bkh_LigV.d.ts → decision-yBj2AyPW.d.mts} +11 -2
- package/dist/index.d.mts +5 -2
- package/dist/index.d.ts +5 -2
- package/dist/index.js +125 -36
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +125 -36
- package/dist/index.mjs.map +1 -1
- package/dist/questionnaire/index.d.mts +3 -16
- package/dist/questionnaire/index.d.ts +3 -16
- package/dist/questionnaire/index.js +202 -46
- package/dist/questionnaire/index.js.map +1 -1
- package/dist/questionnaire/index.mjs +203 -47
- package/dist/questionnaire/index.mjs.map +1 -1
- package/dist/reviews/index.d.mts +10 -6
- package/dist/reviews/index.d.ts +10 -6
- package/dist/reviews/index.js +269 -52
- package/dist/reviews/index.js.map +1 -1
- package/dist/reviews/index.mjs +270 -53
- package/dist/reviews/index.mjs.map +1 -1
- package/dist/showcase/index.d.mts +1 -1
- package/dist/showcase/index.d.ts +1 -1
- package/dist/showcase/index.js +106 -20
- package/dist/showcase/index.js.map +1 -1
- package/dist/showcase/index.mjs +107 -21
- package/dist/showcase/index.mjs.map +1 -1
- package/dist/{transport-j5gFfJhK.d.mts → transport-s5QxA-ci.d.mts} +18 -13
- package/dist/{transport-B_0SgCBe.d.ts → transport-xqqSFqxs.d.ts} +18 -13
- package/dist/{types-Cju-1_jT.d.mts → types-Byx306Kv.d.mts} +25 -10
- package/dist/{types-BcmagF6K.d.mts → types-D_0B0yay.d.mts} +7 -2
- package/dist/{types-BcmagF6K.d.ts → types-D_0B0yay.d.ts} +7 -2
- package/dist/{types-h2BZvl1t.d.ts → types-tdATL5z0.d.ts} +25 -10
- package/llms.txt +9 -9
- package/package.json +6 -9
- package/src/WireOnboarding.tsx +4 -1
- package/src/analytics/currentSession.ts +141 -4
- package/src/analytics/index.ts +6 -1
- package/src/analytics/reportClientEvent.ts +19 -10
- package/src/analytics/useAnalytics.ts +74 -15
- package/src/analytics/wireDoctor.ts +152 -7
- package/src/coachmarks/CoachmarkProvider.tsx +26 -5
- package/src/coachmarks/runtime.ts +156 -2
- package/src/coachmarks/types.ts +7 -2
- package/src/coachmarks/useCoachmarkTour.ts +51 -1
- package/src/context/deviceId.ts +72 -6
- package/src/features/WireFeaturesProvider.tsx +72 -12
- package/src/features/fetchWireFeatures.ts +49 -11
- package/src/features/useWireFeatures.ts +39 -3
- package/src/identity/identityRecord.ts +15 -2
- package/src/questionnaire/QuestionnaireGate.tsx +40 -1
- package/src/questionnaire/transport.ts +22 -8
- package/src/questionnaire/useQuestionnaireGate.ts +58 -7
- package/src/reviews/ReviewGate.tsx +125 -37
- package/src/reviews/decision.ts +11 -1
- package/src/reviews/idempotency.ts +109 -0
- package/src/reviews/index.ts +4 -2
- package/src/reviews/runtime.ts +44 -13
- package/src/reviews/transport.ts +39 -20
- package/src/reviews/types.ts +7 -0
- package/src/reviews/useReviewGate.ts +57 -7
- package/src/session-analytics/lifecycle.ts +16 -0
- package/src/session-analytics/useLifecycleEvents.ts +30 -2
- package/src/session-analytics/useSessionStart.ts +22 -2
- package/src/showcase/FeatureShowcase.tsx +50 -3
- package/src/types.ts +15 -8
- package/src/utils/submitResult.ts +17 -9
- package/src/utils/withDeadline.ts +70 -0
|
@@ -27,6 +27,9 @@
|
|
|
27
27
|
*/
|
|
28
28
|
import type { CoachmarkStorage } from "./types";
|
|
29
29
|
|
|
30
|
+
/** RN sets this global; absent under node/SSR. Read defensively via the async-adapter warn below. */
|
|
31
|
+
declare const __DEV__: boolean | undefined;
|
|
32
|
+
|
|
30
33
|
/**
|
|
31
34
|
* Well-known key into the runtime-global symbol registry — one coachmark runtime across bundles.
|
|
32
35
|
*
|
|
@@ -48,6 +51,12 @@ type CoachmarkRuntime = {
|
|
|
48
51
|
// resolved flags. Disabled → the tour never ARMS and the overlay `show()` is a no-op, so nothing
|
|
49
52
|
// paints and — critically — no once-gate is written, so re-enabling replays the tour correctly.
|
|
50
53
|
coachmarksEnabled: boolean;
|
|
54
|
+
// Whether `coachmarksEnabled` is an ANSWER from the tenant, or still the optimistic all-on
|
|
55
|
+
// default nobody has confirmed. Default true = fail-open, and OPTIONAL on the type for the same
|
|
56
|
+
// reason as `listeners` below: a record left by an inlined copy from an OLDER kit version has no
|
|
57
|
+
// such field, and reading that absence as `false` would stop every tour under it from arming.
|
|
58
|
+
// See setCoachmarksResolved for why this is a SECOND flag and not a third state on the first.
|
|
59
|
+
coachmarksResolved?: boolean;
|
|
51
60
|
// Subscribers to the kill switch. OPTIONAL on the type (never on the behaviour) so a record left
|
|
52
61
|
// in the registry by an inlined copy from an OLDER kit version — which had no listener set — is
|
|
53
62
|
// upgraded in place rather than read as corrupt.
|
|
@@ -115,6 +124,50 @@ export const setCoachmarksEnabled = (value: boolean): void => {
|
|
|
115
124
|
/** Whether the coachmarks module is enabled. False → tours/overlays are silently skipped. */
|
|
116
125
|
export const areCoachmarksEnabled = (): boolean => coachmarkRuntime().coachmarksEnabled;
|
|
117
126
|
|
|
127
|
+
/**
|
|
128
|
+
* Mark whether the value in `coachmarksEnabled` is the tenant's ANSWER or still the optimistic
|
|
129
|
+
* default nobody has confirmed. Written by CoachmarkProvider from the resolved features' `settled`;
|
|
130
|
+
* read (subscribed) by `useCoachmarkTour`'s ARM effect, and by nothing else.
|
|
131
|
+
*
|
|
132
|
+
* ── WHY A SECOND FLAG AND NOT A THIRD STATE ON `coachmarksEnabled` ──────────────────────────────
|
|
133
|
+
* The features fetch seeds the all-on defaults and swaps when `GET /v1/features` resolves, so on
|
|
134
|
+
* every cold start there is a window where "coachmarks: on" only means "nobody has asked yet". A
|
|
135
|
+
* tenant who switched the module OFF still gets the first tour of every launch out of that window.
|
|
136
|
+
*
|
|
137
|
+
* The obvious repair — hold the surface by writing `setCoachmarksEnabled(false)` until the answer
|
|
138
|
+
* lands — CANNOT be used here, and would ship a strictly worse defect. That flag is TERMINAL: the
|
|
139
|
+
* tour subscribes to it and a flip to false calls `exit()`, ending the tour. Writing it `false`
|
|
140
|
+
* during the wait would end the tour of every ENABLED tenant on every cold start. So the two facts
|
|
141
|
+
* are kept apart, and only one of them is terminal:
|
|
142
|
+
* • `coachmarksEnabled` — the verdict. `false` tears a tour down. Stays `true` while the answer
|
|
143
|
+
* is unknown, so nothing is ever torn down on a guess.
|
|
144
|
+
* • `coachmarksResolved` — "is that verdict an answer?". `false` only blocks ARMING a NEW tour.
|
|
145
|
+
* No timer starts, no overlay shows, and no once-gate is consumed, so the tour plays in full
|
|
146
|
+
* the moment an ON answer lands.
|
|
147
|
+
*
|
|
148
|
+
* "Resolved" means the fetch ANSWERED, not that it SUCCEEDED — `fetchWireFeatures` swallows a
|
|
149
|
+
* timeout / 401 / 5xx into the all-on defaults and that fallback IS the answer, so an unreachable
|
|
150
|
+
* control plane arms the tour rather than darking it (fail-open, unchanged).
|
|
151
|
+
*
|
|
152
|
+
* Shares the kill switch's listener set: a subscriber has to wake on either fact changing, and one
|
|
153
|
+
* set is one thing to keep correct rather than two.
|
|
154
|
+
*/
|
|
155
|
+
export const setCoachmarksResolved = (value: boolean): void => {
|
|
156
|
+
const runtime = coachmarkRuntime();
|
|
157
|
+
if ((runtime.coachmarksResolved ?? true) === value) return;
|
|
158
|
+
runtime.coachmarksResolved = value;
|
|
159
|
+
notifyCoachmarksEnabled();
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Whether the coachmarks verdict is an ANSWER yet. False → a NEW tour must not arm (a tour already
|
|
164
|
+
* in flight is untouched). Absent field → true: no provider, or an older inlined copy's record,
|
|
165
|
+
* means nothing is going to answer this, which is the same fail-open default the features
|
|
166
|
+
* `settled` context takes.
|
|
167
|
+
*/
|
|
168
|
+
export const areCoachmarksResolved = (): boolean =>
|
|
169
|
+
coachmarkRuntime().coachmarksResolved ?? true;
|
|
170
|
+
|
|
118
171
|
/**
|
|
119
172
|
* Subscribe to kill-switch flips. Returns the unsubscribe function — the `useSyncExternalStore`
|
|
120
173
|
* contract, and deliberately the SAME shape as `coachmarkOverlay.subscribe` next door rather than
|
|
@@ -126,6 +179,9 @@ export const areCoachmarksEnabled = (): boolean => coachmarkRuntime().coachmarks
|
|
|
126
179
|
* nothing can be tapped to advance, and no terminal path is reachable. `useCoachmarkTour`
|
|
127
180
|
* subscribes instead. The listener set lives in the SAME `globalThis` record as the flag, so every
|
|
128
181
|
* inlined copy of this module shares one subscriber list.
|
|
182
|
+
*
|
|
183
|
+
* It also carries `coachmarksResolved` (see `setCoachmarksResolved`): both facts wake the same
|
|
184
|
+
* listeners, so a `useSyncExternalStore` over either one uses this same subscribe function.
|
|
129
185
|
*/
|
|
130
186
|
export const subscribeCoachmarksEnabled = (listener: () => void): (() => void) => {
|
|
131
187
|
const listeners = enabledListeners();
|
|
@@ -166,6 +222,104 @@ export const showcaseGateKey = (showcaseId: string): string =>
|
|
|
166
222
|
/** Marker value written for a seen gate. */
|
|
167
223
|
const SEEN_VALUE = "1";
|
|
168
224
|
|
|
225
|
+
/** Key used only by the one-off synchronicity probe below. Never carries a meaningful value. */
|
|
226
|
+
const GATE_PROBE_KEY = "wire_gate_probe";
|
|
227
|
+
|
|
228
|
+
/** Once-per-process latch on its own `Symbol.for` slot, mirroring `gateStorageWarned` next door
|
|
229
|
+
* in reviews/runtime: a plain `let` would warn once per inlined bundle copy, and the gates live
|
|
230
|
+
* in three of them.
|
|
231
|
+
*
|
|
232
|
+
* @globalSlot LATCH — written `true` by the first probe that caught an ASYNC gate adapter, and
|
|
233
|
+
* never again. A second differing write re-arms a warning the process already spent, so a host
|
|
234
|
+
* whose broken adapter reaches three gates gets the same paragraph three times and stops reading. */
|
|
235
|
+
const ASYNC_GATE_STORAGE_WARNED_SLOT: unique symbol = Symbol.for(
|
|
236
|
+
"@wireai/activation:asyncGateStorageWarned",
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
type GlobalWithAsyncGateWarn = typeof globalThis & {
|
|
240
|
+
[ASYNC_GATE_STORAGE_WARNED_SLOT]?: boolean;
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const asyncGateWarnGlobal = globalThis as GlobalWithAsyncGateWarn;
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* The substitute for an adapter whose `getItem` returned a thenable (an ASYNC adapter, e.g.
|
|
247
|
+
* AsyncStorage handed straight in). Every seen-gate reads SEEN and every write is dropped, so
|
|
248
|
+
* nothing shows and nothing fires — FAIL CLOSED, deliberately in both directions the async bug
|
|
249
|
+
* could err: a Promise is never `=== "1"`, so a raw async adapter reports every gate UNSEEN
|
|
250
|
+
* forever — tours and showcases replay on every open, and a review gate with `minSessions` 0/1
|
|
251
|
+
* RE-FIRES its prompt each open (the 2026-07-16 one-star incident class). Never-fire is the only
|
|
252
|
+
* direction a once-per-user gate may err in.
|
|
253
|
+
*/
|
|
254
|
+
const closedGateStorage: CoachmarkStorage = Object.freeze({
|
|
255
|
+
getItem: (): string => SEEN_VALUE,
|
|
256
|
+
setItem: (): void => {},
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
/** Adapters probed sync (or throwing, which every reader already tolerates): pass through. */
|
|
260
|
+
const syncGateAdapters = new WeakSet<CoachmarkStorage>();
|
|
261
|
+
/** Adapters caught returning a thenable: substituted, and never probed again. */
|
|
262
|
+
const asyncGateAdapters = new WeakSet<CoachmarkStorage>();
|
|
263
|
+
|
|
264
|
+
/** Warn once, in dev builds only, when a gate adapter turned out to be async. */
|
|
265
|
+
const warnAsyncGateStorage = (): void => {
|
|
266
|
+
if (asyncGateWarnGlobal[ASYNC_GATE_STORAGE_WARNED_SLOT]) return;
|
|
267
|
+
if (typeof __DEV__ === "undefined" || !__DEV__) return;
|
|
268
|
+
if (typeof console === "undefined" || !console.warn) return;
|
|
269
|
+
asyncGateWarnGlobal[ASYNC_GATE_STORAGE_WARNED_SLOT] = true;
|
|
270
|
+
console.warn(
|
|
271
|
+
"[wireai] the gate storage's getItem returned a Promise — an async adapter (e.g. AsyncStorage) " +
|
|
272
|
+
"cannot drive the once-per-user gates, so coachmark tours, showcases and the review/questionnaire " +
|
|
273
|
+
"gates are all DISABLED (fail closed: nothing shows, nothing fires) until a synchronous adapter " +
|
|
274
|
+
"is provided. Pass a sync adapter (e.g. MMKV: `{ getItem: (k) => mmkv.getString(k) ?? null, " +
|
|
275
|
+
"setItem: (k, v) => mmkv.set(k, v) }`) to CoachmarkProvider or the gate's `storage` prop.",
|
|
276
|
+
);
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
/** Test-only: forget the once-per-process async-adapter warn latch. */
|
|
280
|
+
export const resetAsyncGateStorageWarning = (): void => {
|
|
281
|
+
asyncGateWarnGlobal[ASYNC_GATE_STORAGE_WARNED_SLOT] = undefined;
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* The thenable probe (fail closed): resolve an adapter to itself when its `getItem` is
|
|
286
|
+
* synchronous, and to `closedGateStorage` when it returns a thenable. Called on every gate READ
|
|
287
|
+
* rather than at injection, so (a) a host handing storage straight to a gate's `storage` prop is
|
|
288
|
+
* probed too, and (b) `CoachmarkProvider`'s unmount cleanup — which compares the singleton to its
|
|
289
|
+
* own prop by IDENTITY — keeps seeing the object the host passed. Verdicts are WeakSet-cached, so
|
|
290
|
+
* each adapter pays for exactly one probe read.
|
|
291
|
+
*/
|
|
292
|
+
export const validateGateStorage = (
|
|
293
|
+
storage: CoachmarkStorage | null,
|
|
294
|
+
): CoachmarkStorage | null => {
|
|
295
|
+
if (!storage) return null;
|
|
296
|
+
if (syncGateAdapters.has(storage)) return storage;
|
|
297
|
+
if (asyncGateAdapters.has(storage)) return closedGateStorage;
|
|
298
|
+
let probed: unknown;
|
|
299
|
+
try {
|
|
300
|
+
probed = storage.getItem(GATE_PROBE_KEY);
|
|
301
|
+
} catch {
|
|
302
|
+
// A throwing adapter is synchronous, which is all the probe rules on; every reader already
|
|
303
|
+
// tolerates the throw itself.
|
|
304
|
+
syncGateAdapters.add(storage);
|
|
305
|
+
return storage;
|
|
306
|
+
}
|
|
307
|
+
if (probed != null && typeof (probed as { then?: unknown }).then === "function") {
|
|
308
|
+
asyncGateAdapters.add(storage);
|
|
309
|
+
try {
|
|
310
|
+
// Detach the probe's promise so an adapter that REJECTS (e.g. a broken native store) cannot
|
|
311
|
+
// surface as an unhandled rejection the host never scheduled.
|
|
312
|
+
(probed as PromiseLike<unknown>).then(undefined, () => {});
|
|
313
|
+
} catch {
|
|
314
|
+
// A hostile thenable whose `then` throws is still async; the verdict stands.
|
|
315
|
+
}
|
|
316
|
+
warnAsyncGateStorage();
|
|
317
|
+
return closedGateStorage;
|
|
318
|
+
}
|
|
319
|
+
syncGateAdapters.add(storage);
|
|
320
|
+
return storage;
|
|
321
|
+
};
|
|
322
|
+
|
|
169
323
|
/**
|
|
170
324
|
* Read a seen gate. Testing mode ALWAYS reports unseen (so everything replays).
|
|
171
325
|
* A missing storage (no provider) also reports unseen — the safe default is to
|
|
@@ -179,7 +333,7 @@ export const hasSeenGate = (
|
|
|
179
333
|
testingOverride?: boolean,
|
|
180
334
|
): boolean => {
|
|
181
335
|
if (testingOverride ?? isCoachmarkTesting()) return false;
|
|
182
|
-
const storage = storageOverride ?? getCoachmarkStorage();
|
|
336
|
+
const storage = validateGateStorage(storageOverride ?? getCoachmarkStorage());
|
|
183
337
|
if (!storage) return false;
|
|
184
338
|
try {
|
|
185
339
|
return storage.getItem(key) === SEEN_VALUE;
|
|
@@ -198,7 +352,7 @@ export const markSeenGate = (
|
|
|
198
352
|
testingOverride?: boolean,
|
|
199
353
|
): void => {
|
|
200
354
|
if (testingOverride ?? isCoachmarkTesting()) return;
|
|
201
|
-
const storage = storageOverride ?? getCoachmarkStorage();
|
|
355
|
+
const storage = validateGateStorage(storageOverride ?? getCoachmarkStorage());
|
|
202
356
|
if (!storage) return;
|
|
203
357
|
try {
|
|
204
358
|
storage.setItem(key, SEEN_VALUE);
|
package/src/coachmarks/types.ts
CHANGED
|
@@ -31,8 +31,13 @@ export interface TargetRect {
|
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
33
|
* The host-injected gate storage. SYNCHRONOUS on purpose — a coachmark's
|
|
34
|
-
* "seen?" gate must resolve during render
|
|
35
|
-
*
|
|
34
|
+
* "seen?" gate must resolve during render. This is ENFORCED, fail closed: a
|
|
35
|
+
* probe on first read catches an adapter whose `getItem` returns a thenable
|
|
36
|
+
* (AsyncStorage handed straight in) and substitutes a closed adapter — every
|
|
37
|
+
* gate reads "seen", nothing shows, nothing fires, one dev warning names the
|
|
38
|
+
* fix (see `validateGateStorage` in ./runtime). A raw async adapter would
|
|
39
|
+
* otherwise report every gate unseen forever: tours replay each open, and a
|
|
40
|
+
* review gate with `minSessions` 0/1 re-fires each open. MMKV's
|
|
36
41
|
* `getString`/`set` fit as-is; an in-memory `Map` wrapper works for tests. This
|
|
37
42
|
* is deliberately separate from the async `WireOnboardingStorage` used for
|
|
38
43
|
* session persistence.
|
|
@@ -4,6 +4,7 @@ import { coachmarkAnchors } from "./coachmarkAnchorRegistry";
|
|
|
4
4
|
import { coachmarkOverlay } from "./coachmarkOverlayStore";
|
|
5
5
|
import {
|
|
6
6
|
areCoachmarksEnabled,
|
|
7
|
+
areCoachmarksResolved,
|
|
7
8
|
coachmarkGateKey,
|
|
8
9
|
hasSeenGate,
|
|
9
10
|
markSeenGate,
|
|
@@ -76,6 +77,15 @@ const DEFAULT_START_DELAY_MS = 3000;
|
|
|
76
77
|
* `show()` is a no-op while the flag is off, so a tour that carried on would paint nothing, could
|
|
77
78
|
* not be tapped to advance, and would never reach a terminal state.
|
|
78
79
|
*
|
|
80
|
+
* ARM vs TERMINAL — the third gate, and why it is not the second one. Until the tenant's flags
|
|
81
|
+
* land, that kill switch reads ON because nobody has asked yet, so a tenant who turned coachmarks
|
|
82
|
+
* OFF still got the first tour of every cold start. The wait CANNOT be expressed by writing the
|
|
83
|
+
* kill switch false, precisely because that flag is terminal: it would end the tour of every
|
|
84
|
+
* ENABLED tenant on every cold start, a worse defect than the one being fixed. So an UNANSWERED
|
|
85
|
+
* verdict (`areCoachmarksResolved`, written by CoachmarkProvider from the features' `settled`)
|
|
86
|
+
* blocks only the ARM below — a tour already in flight is never touched by it — and costs nothing
|
|
87
|
+
* while it waits: no timer, no overlay, no once-gate spent.
|
|
88
|
+
*
|
|
79
89
|
* IMPORTANT: `steps` MUST be a stable (memoized) array. If a new array identity
|
|
80
90
|
* is passed on every render the drive effect re-runs and re-shows the current
|
|
81
91
|
* step (wasteful anchor re-resolves / overlay churn), and analytics can
|
|
@@ -120,6 +130,15 @@ export const useCoachmarkTour = (
|
|
|
120
130
|
areCoachmarksEnabled,
|
|
121
131
|
);
|
|
122
132
|
|
|
133
|
+
// Whether that kill switch is an ANSWER yet, or still the optimistic all-on default. Gates the
|
|
134
|
+
// ARM effect only — see the ARM vs TERMINAL note above. Same subscribe function: both facts live
|
|
135
|
+
// in one runtime record and wake one listener set.
|
|
136
|
+
const coachmarksResolved = useSyncExternalStore(
|
|
137
|
+
subscribeCoachmarksEnabled,
|
|
138
|
+
areCoachmarksResolved,
|
|
139
|
+
areCoachmarksResolved,
|
|
140
|
+
);
|
|
141
|
+
|
|
123
142
|
/**
|
|
124
143
|
* The single terminal exit. `writeGate` is what separates the two ways a tour can end:
|
|
125
144
|
* • the user reached the end (or dismissed the last step) → the once-gate IS written, so a
|
|
@@ -147,18 +166,40 @@ export const useCoachmarkTour = (
|
|
|
147
166
|
// Arm once, after the delay, when enabled and the gate is unseen.
|
|
148
167
|
useEffect(() => {
|
|
149
168
|
if (!enabled || startedRef.current) return undefined;
|
|
169
|
+
// AN EMPTY TOUR IS NOT A TOUR — never arm on one. With no steps the arm timer still fired, the
|
|
170
|
+
// drive effect below immediately hit `activeIndex >= steps.length`, and `finish()` wrote the
|
|
171
|
+
// once-gate for a tour the user never saw a single frame of. The gate is persisted and
|
|
172
|
+
// `finishedRef` blocks recovery in this mount, so the tour was dead for good — even after the
|
|
173
|
+
// catalog that produced the empty list was fixed. It is not a hypothetical list either: on the
|
|
174
|
+
// documented AI path `selectTourSteps(catalog, selection)` returns `[]` whenever the server
|
|
175
|
+
// sends ids that match nothing in the SHIPPED catalog (an app that cannot be force-updated),
|
|
176
|
+
// and any host whose steps arrive async passes `[]` on the first render.
|
|
177
|
+
//
|
|
178
|
+
// Keyed on `steps.length`, deliberately NOT on the array itself: the drive effect below already
|
|
179
|
+
// requires a memoized `steps`, and listing the identity here would restart the arm timer on
|
|
180
|
+
// every render of a host that passes an inline array (frequent_rules #11) — a tour that never
|
|
181
|
+
// arms, swapping one silent failure for another. The length is a value, so late-arriving steps
|
|
182
|
+
// still re-run this effect and arm the tour properly.
|
|
183
|
+
if (steps.length === 0) return undefined;
|
|
150
184
|
// Feature kill switch: never arm while coachmarks are disabled, so no timer starts, no
|
|
151
185
|
// overlay shows, and the once-gate is NOT consumed — re-enabling replays the tour. Reading
|
|
152
186
|
// the SUBSCRIBED value (and listing it in the deps) is what makes that last clause true
|
|
153
187
|
// without a remount: flipping it off clears a pending arm timer, flipping it back on re-arms.
|
|
154
188
|
if (!coachmarksOn) return undefined;
|
|
189
|
+
// …and never arm on an UNANSWERED one. `coachmarksOn` is all-on until the tenant's flags land,
|
|
190
|
+
// so without this a tenant who switched coachmarks off still got the first tour of every cold
|
|
191
|
+
// start — the kill switch working everywhere except the one moment it is read. Blocking the
|
|
192
|
+
// ARM (rather than writing the kill switch false, which is terminal — see the note above) is
|
|
193
|
+
// what makes the wait free: no timer, no overlay, no once-gate spent, so an ON answer plays
|
|
194
|
+
// the tour in full. A FAILED fetch is an answer too, so a dead control plane still arms.
|
|
195
|
+
if (!coachmarksResolved) return undefined;
|
|
155
196
|
if (showOnce && hasSeenGate(coachmarkGateKey(tourId))) return undefined;
|
|
156
197
|
const timer = setTimeout(() => {
|
|
157
198
|
startedRef.current = true;
|
|
158
199
|
setActiveIndex(0);
|
|
159
200
|
}, startDelayMs);
|
|
160
201
|
return () => clearTimeout(timer);
|
|
161
|
-
}, [enabled, coachmarksOn, startDelayMs, showOnce, tourId]);
|
|
202
|
+
}, [enabled, coachmarksOn, coachmarksResolved, startDelayMs, showOnce, tourId, steps.length]);
|
|
162
203
|
|
|
163
204
|
// Drive the active step. Keyed on `enabled` too, so a mid-tour focus loss
|
|
164
205
|
// pauses (hide, keep position) and a return to focus resumes the same step.
|
|
@@ -182,6 +223,15 @@ export const useCoachmarkTour = (
|
|
|
182
223
|
return undefined;
|
|
183
224
|
}
|
|
184
225
|
|
|
226
|
+
// The other half of the empty-tour rule, for a list that goes empty AFTER the tour armed (a
|
|
227
|
+
// re-selection, a catalog swap): end it, but take the kill-switch exit — `exit(false)` — so the
|
|
228
|
+
// once-gate is not written. `finish()` here would spend it on a tour with nothing to show, which
|
|
229
|
+
// is exactly the burn the arm guard above refuses at the other end.
|
|
230
|
+
if (steps.length === 0) {
|
|
231
|
+
exit(false);
|
|
232
|
+
return undefined;
|
|
233
|
+
}
|
|
234
|
+
|
|
185
235
|
if (activeIndex >= steps.length) {
|
|
186
236
|
finish();
|
|
187
237
|
return undefined;
|
package/src/context/deviceId.ts
CHANGED
|
@@ -73,8 +73,8 @@ export const mintDeviceId = (): string => {
|
|
|
73
73
|
* Well-known key into the runtime-global symbol registry — one auto-id registry across every bundle.
|
|
74
74
|
*
|
|
75
75
|
* @globalSlot LATCH — the REGISTRY OBJECT is created once and its identity is then stable. A second
|
|
76
|
-
* write empties `keys`/`hydrating`/`pending`, so the next surface mints a SECOND auto id
|
|
77
|
-
* install and the `device_key` the server joins sessions on splits in two — the halved-counter
|
|
76
|
+
* write empties `keys`/`hydrating`/`pending`/`ambient`, so the next surface mints a SECOND auto id
|
|
77
|
+
* for one install and the `device_key` the server joins sessions on splits in two — the halved-counter
|
|
78
78
|
* defect this registry exists to close. Its CONTENTS are live (the id per `appId` is replaced when
|
|
79
79
|
* an async hydration adopts a persisted value), so callers must re-read through
|
|
80
80
|
* `resolveAutoDeviceKey()` rather than hold the string a mount happened to see first.
|
|
@@ -91,11 +91,17 @@ const AUTO_DEVICE_KEY_SLOT: unique symbol = Symbol.for("@wireai/activation:autoD
|
|
|
91
91
|
type HydrationOutcome = { value: string; durable: boolean };
|
|
92
92
|
|
|
93
93
|
/** The shared registry: the live id per `appId`, the set of appIds whose hydration already started,
|
|
94
|
-
* and the in-flight (or settled) hydration promise per `appId` so a waiter can join it.
|
|
94
|
+
* and the in-flight (or settled) hydration promise per `appId` so a waiter can join it.
|
|
95
|
+
*
|
|
96
|
+
* `ambient` is deliberately NOT an entry in `keys`: it is the last-resort id
|
|
97
|
+
* {@link ambientAutoDeviceKey} mints for a process that has registered no id space at all, and
|
|
98
|
+
* `keys` is the TENANT CENSUS that same function counts to decide whether it may answer. Putting
|
|
99
|
+
* the fallback in the census made the fallback look like a tenant — see the note on that function. */
|
|
95
100
|
type AutoDeviceKeyRegistry = {
|
|
96
101
|
keys: Map<string, string>;
|
|
97
102
|
hydrating: Set<string>;
|
|
98
103
|
pending?: Map<string, Promise<HydrationOutcome>>;
|
|
104
|
+
ambient?: string;
|
|
99
105
|
};
|
|
100
106
|
|
|
101
107
|
type GlobalWithDeviceKeys = typeof globalThis & {
|
|
@@ -237,8 +243,19 @@ export const resolveAutoDeviceKey = (opts: ResolveAutoDeviceKeyOptions = {}): st
|
|
|
237
243
|
* follows it. Only a caller that can afford one storage read should use this; the fire-and-forget
|
|
238
244
|
* event paths must stay on the sync function.
|
|
239
245
|
*
|
|
240
|
-
* Never throws or rejects: a missing
|
|
241
|
-
*
|
|
246
|
+
* Never throws or rejects: a missing or rejecting adapter resolves to the in-memory id, and with no
|
|
247
|
+
* `storage` it resolves immediately (there is nothing to hydrate from).
|
|
248
|
+
*
|
|
249
|
+
* ⛔ A HUNG ADAPTER IS THE ONE CASE IT DOES NOT COVER, and this line used to claim it did. The read
|
|
250
|
+
* underneath is a bare `storage.getItem` with no ceiling of its own, so an adapter that neither
|
|
251
|
+
* resolves nor rejects (a locked keychain, a wedged native bridge) leaves THIS PROMISE PENDING
|
|
252
|
+
* FOREVER — it does not fall back to the in-memory id, it simply never answers. Anything that gates
|
|
253
|
+
* a user-visible or metric-bearing action on the result must therefore race it against a ceiling of
|
|
254
|
+
* its own: `<WireOnboarding>` does (`AUTO_JOIN_HYDRATION_TIMEOUT_MS`), and so do the two lifecycle
|
|
255
|
+
* hooks (`session-analytics/useLifecycleEvents` + `useSessionStart`, through the shared
|
|
256
|
+
* `withTimeout` + `READ_TIMEOUT_MS` in `session/persistedSession`) — a hung read there had silently
|
|
257
|
+
* killed `app.session_started` and `app.first_open` for the whole process. Use the SYNC
|
|
258
|
+
* {@link resolveAutoDeviceKey} when you cannot afford to wait at all.
|
|
242
259
|
*
|
|
243
260
|
* ⚠️ IT RETURNS A BARE STRING, so it CANNOT say whether the id survives the launch — a degraded
|
|
244
261
|
* adapter resolves to the in-memory mint and reads identically to a persisted one. No kit surface
|
|
@@ -263,7 +280,9 @@ export const hydrateAutoDeviceKey = async (
|
|
|
263
280
|
*
|
|
264
281
|
* Resolves `undefined` only when there is no usable id at all. With no `storage` it resolves
|
|
265
282
|
* immediately with `durable: false` — a process-scoped id is exactly what "no persistence" means.
|
|
266
|
-
* Never throws or rejects
|
|
283
|
+
* Never throws or rejects — but, exactly like {@link hydrateAutoDeviceKey} above, it can also never
|
|
284
|
+
* SETTLE on a hung adapter (the underlying `getItem` carries no ceiling), so every caller races it
|
|
285
|
+
* against one of its own. See the note there.
|
|
267
286
|
*/
|
|
268
287
|
export const hydrateDeviceIdentity = async (
|
|
269
288
|
opts: ResolveAutoDeviceKeyOptions = {},
|
|
@@ -290,4 +309,51 @@ export const resetAutoDeviceKeys = (): void => {
|
|
|
290
309
|
registry.keys.clear();
|
|
291
310
|
registry.hydrating.clear();
|
|
292
311
|
registry.pending?.clear();
|
|
312
|
+
registry.ambient = undefined;
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* The auto `device_key` a surface with NO `appId` and NO `storage` of its own may stamp onto a
|
|
317
|
+
* payload — the review gate being the one that exists (`ReviewGate` takes neither prop).
|
|
318
|
+
*
|
|
319
|
+
* WHY IT IS NOT JUST `resolveAutoDeviceKey()`. That function is keyed by `appId`, and `appId` is a
|
|
320
|
+
* REQUIRED field on `WireOnboardingConfig` that real hosts set to a real value. A propless caller
|
|
321
|
+
* can only pass `undefined`, which addresses the `"default"` slot — so on any host that names its
|
|
322
|
+
* app, a bare call would MINT a second id beside the one every other surface already shares. That is
|
|
323
|
+
* the halved-counter defect this registry exists to close (see the registry note above), arrived at
|
|
324
|
+
* from the other direction.
|
|
325
|
+
*
|
|
326
|
+
* So this READS the TENANT CENSUS (`registry.keys`) and does not address it:
|
|
327
|
+
* • exactly one id space in the process → that id, whoever registered it. This is every
|
|
328
|
+
* single-tenant app, i.e. every real app, and it is how the gate joins the analytics surfaces.
|
|
329
|
+
* • none at all → the registry's `ambient` fallback, minted here on first ask. Nothing exists to
|
|
330
|
+
* collide with, so it is not a second space, it is the only one. PROCESS-scoped (there is no
|
|
331
|
+
* storage to persist through), which is honest and still resolvable: a review row is written
|
|
332
|
+
* once per user, so a per-launch value here cannot corrupt a counter the way it corrupts
|
|
333
|
+
* `min_sessions` — and without SOME unit the server discards the impression's
|
|
334
|
+
* `idempotency_key` outright, so refusing to answer costs a re-post its upsert.
|
|
335
|
+
* • two or more → `undefined`. Two tenants in one process must never share an id, and a caller
|
|
336
|
+
* with no `appId` cannot say which one it belongs to. Refusing is the only safe answer;
|
|
337
|
+
* guessing would stamp one tenant's device onto the other's row.
|
|
338
|
+
*
|
|
339
|
+
* ⛔ THE FALLBACK IS NOT A TENANT, AND THAT IS THE WHOLE REASON IT LIVES IN ITS OWN FIELD (0.15.1).
|
|
340
|
+
* It used to be minted through `resolveAutoDeviceKey()`, which registered it in `keys` under
|
|
341
|
+
* `"default"`. The three states above are only exhaustive at ONE INSTANT, and this one wrote into
|
|
342
|
+
* the very map the next instant is judged by: a gate that fired before any Wire surface had
|
|
343
|
+
* initialised (the review gate lives on the home feed, so a cold start can reach it first) left a
|
|
344
|
+
* `"default"` entry behind, and the moment the host's real `appId` registered the census read TWO —
|
|
345
|
+
* so every later call returned `undefined` and the injected `device_key` was silently dead for the
|
|
346
|
+
* rest of the process. Holding the fallback OUTSIDE the census makes it yield instead: a real id
|
|
347
|
+
* space appearing later simply wins, which is the answer the middle bullet wanted all along.
|
|
348
|
+
*
|
|
349
|
+
* Never throws, never mints a SECOND id into the census, and never overrides anything a host
|
|
350
|
+
* supplied — the caller checks for a host value first.
|
|
351
|
+
*/
|
|
352
|
+
export const ambientAutoDeviceKey = (): string | undefined => {
|
|
353
|
+
const registry = autoDeviceKeyRegistry();
|
|
354
|
+
if (registry.keys.size > 1) return undefined;
|
|
355
|
+
const sole = [...registry.keys.values()][0];
|
|
356
|
+
if (sole) return sole;
|
|
357
|
+
if (!registry.ambient) registry.ambient = mintDeviceId();
|
|
358
|
+
return registry.ambient;
|
|
293
359
|
};
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import React, { createContext, useContext, useMemo } from "react";
|
|
16
16
|
|
|
17
17
|
import { defaultWireFeatures } from "./defaults";
|
|
18
|
-
import {
|
|
18
|
+
import { useWireFeaturesState, type WireFeaturesState } from "./useWireFeatures";
|
|
19
19
|
import type { WireFeatures, WireFeaturesConfig } from "./types";
|
|
20
20
|
|
|
21
21
|
/**
|
|
@@ -26,16 +26,49 @@ import type { WireFeatures, WireFeaturesConfig } from "./types";
|
|
|
26
26
|
* the all-on defaults with no crash and no warning. Because it is a latch, and only because of
|
|
27
27
|
* that, the module-local `const WireFeaturesContext` below may cache it.
|
|
28
28
|
*/
|
|
29
|
-
const CONTEXT_SYMBOL = Symbol.for("wireai.features.context");
|
|
29
|
+
const CONTEXT_SYMBOL: unique symbol = Symbol.for("wireai.features.context");
|
|
30
30
|
// `globalThis`, not `global`: the other nine `Symbol.for` slots in this kit all use it, and `global`
|
|
31
31
|
// simply does not exist in a plain browser (RN-web / SSR-in-the-browser), where this line would
|
|
32
32
|
// throw a ReferenceError before the provider could render anything.
|
|
33
|
-
|
|
33
|
+
//
|
|
34
|
+
// The slot is TYPED rather than cast through `any` (which is banned, `ai_rules/rules/core.md`). The
|
|
35
|
+
// cast was not cosmetic: with `any` on both sides, BOTH contexts below were inferred `any`, so every
|
|
36
|
+
// `<X.Provider value={...}>` in this file and every `useContext` read of them was type-unchecked —
|
|
37
|
+
// a provider publishing the wrong shape into either one would have compiled. Declaring each key
|
|
38
|
+
// `unique symbol` is what lets it appear as a property key in a type at all.
|
|
39
|
+
type GlobalWithFeaturesContext = typeof globalThis & {
|
|
40
|
+
[CONTEXT_SYMBOL]?: React.Context<WireFeatures | null>;
|
|
41
|
+
};
|
|
42
|
+
const featuresContextGlobal = globalThis as GlobalWithFeaturesContext;
|
|
34
43
|
|
|
35
|
-
if (!
|
|
36
|
-
|
|
44
|
+
if (!featuresContextGlobal[CONTEXT_SYMBOL]) {
|
|
45
|
+
featuresContextGlobal[CONTEXT_SYMBOL] = createContext<WireFeatures | null>(null);
|
|
37
46
|
}
|
|
38
|
-
const WireFeaturesContext =
|
|
47
|
+
const WireFeaturesContext = featuresContextGlobal[CONTEXT_SYMBOL];
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @globalSlot LATCH — the SECOND context, carrying only "has the provider's fetch answered yet?".
|
|
51
|
+
*
|
|
52
|
+
* WHY A SECOND CONTEXT AND NOT A WIDER VALUE: the value published above is a `WireFeatures`, and
|
|
53
|
+
* tsup inlines a copy of this module per subpath bundle. Changing the published SHAPE would mean a
|
|
54
|
+
* provider from one copy publishing an object a consumer from another copy reads as flags —
|
|
55
|
+
* silently wrong flags rather than a crash. An extra context is additive: a consumer that reads it
|
|
56
|
+
* without a provider (or under an OLDER copy's provider that publishes nothing here) gets the
|
|
57
|
+
* default below.
|
|
58
|
+
*
|
|
59
|
+
* THE DEFAULT IS `true`, AND FAIL-OPEN DEPENDS ON IT: "settled" gates a surface's first render, so
|
|
60
|
+
* defaulting to `false` would let a missing provider suppress a gate forever. `true` means "nothing
|
|
61
|
+
* above is going to answer this for you", which is the truth when there is no provider at all.
|
|
62
|
+
*/
|
|
63
|
+
const SETTLED_SYMBOL: unique symbol = Symbol.for("wireai.features.settled.context");
|
|
64
|
+
type GlobalWithSettledContext = typeof globalThis & {
|
|
65
|
+
[SETTLED_SYMBOL]?: React.Context<boolean>;
|
|
66
|
+
};
|
|
67
|
+
const settledContextGlobal = globalThis as GlobalWithSettledContext;
|
|
68
|
+
if (!settledContextGlobal[SETTLED_SYMBOL]) {
|
|
69
|
+
settledContextGlobal[SETTLED_SYMBOL] = createContext<boolean>(true);
|
|
70
|
+
}
|
|
71
|
+
const WireFeaturesSettledContext = settledContextGlobal[SETTLED_SYMBOL];
|
|
39
72
|
|
|
40
73
|
export interface WireFeaturesProviderProps {
|
|
41
74
|
/** Tenant creds (+ optional storage) to fetch the flags once for the whole tree. */
|
|
@@ -59,10 +92,16 @@ export const WireFeaturesProvider: React.FC<WireFeaturesProviderProps> = ({
|
|
|
59
92
|
}) => {
|
|
60
93
|
// Always call the hook (rules of hooks); when `flags` is supplied we pass no config so it
|
|
61
94
|
// never fetches and the explicit flags win below.
|
|
62
|
-
const fetched =
|
|
63
|
-
const value = flags ?? fetched;
|
|
95
|
+
const fetched = useWireFeaturesState(flags ? undefined : config);
|
|
96
|
+
const value = flags ?? fetched.flags;
|
|
97
|
+
// Explicit `flags` are an answer by definition; otherwise the fetch decides.
|
|
98
|
+
const settled = flags ? true : fetched.settled;
|
|
64
99
|
const stable = useMemo(() => value, [value]);
|
|
65
|
-
return
|
|
100
|
+
return (
|
|
101
|
+
<WireFeaturesSettledContext.Provider value={settled}>
|
|
102
|
+
<WireFeaturesContext.Provider value={stable}>{children}</WireFeaturesContext.Provider>
|
|
103
|
+
</WireFeaturesSettledContext.Provider>
|
|
104
|
+
);
|
|
66
105
|
};
|
|
67
106
|
|
|
68
107
|
WireFeaturesProvider.displayName = "WireFeaturesProvider";
|
|
@@ -87,11 +126,32 @@ export interface ResolveFeaturesOptions {
|
|
|
87
126
|
* 4. the all-on defaults.
|
|
88
127
|
* Always fail-open, always a stable reference for a given value (so it's safe in effect deps).
|
|
89
128
|
*/
|
|
90
|
-
export const useResolvedFeatures = (options?: ResolveFeaturesOptions): WireFeatures =>
|
|
129
|
+
export const useResolvedFeatures = (options?: ResolveFeaturesOptions): WireFeatures =>
|
|
130
|
+
useResolvedFeaturesState(options).flags;
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The same resolution, plus whether the value is an ANSWER or still the optimistic default —
|
|
134
|
+
* `useResolvedFeatures` is the flags-only wrapper over this.
|
|
135
|
+
*
|
|
136
|
+
* A gated surface whose first render is irreversible (the review gate fires `review_prompt_shown`
|
|
137
|
+
* and can take a review row the moment it appears) must hold that first render until `settled`,
|
|
138
|
+
* or a tenant that turned the module OFF still gets one impression per launch while the fetch is
|
|
139
|
+
* in flight — the kill switch working everywhere except the one moment it is read.
|
|
140
|
+
*
|
|
141
|
+
* `settled` follows the same precedence as the flags: explicit `flags` are an answer; under a
|
|
142
|
+
* provider it is the PROVIDER's fetch that has to answer; otherwise it is this surface's own lazy
|
|
143
|
+
* fetch, which is already `true` when there is nothing to fetch. Deliberately INTERNAL (not in the
|
|
144
|
+
* root barrel): it exists for the kit's own gated surfaces, and the public flags-only contract is
|
|
145
|
+
* unchanged.
|
|
146
|
+
*/
|
|
147
|
+
export const useResolvedFeaturesState = (options?: ResolveFeaturesOptions): WireFeaturesState => {
|
|
91
148
|
const ctx = useWireFeaturesContext();
|
|
149
|
+
const ctxSettled: boolean = useContext(WireFeaturesSettledContext);
|
|
92
150
|
// Only lazily fetch when nothing else provides the flags — otherwise pass no config so the
|
|
93
151
|
// hook stays inert (defaults, no network). The hook is always called (rules of hooks).
|
|
94
152
|
const shouldFetch = !options?.flags && ctx == null;
|
|
95
|
-
const fetched =
|
|
96
|
-
|
|
153
|
+
const fetched = useWireFeaturesState(shouldFetch ? options?.config : undefined);
|
|
154
|
+
const flags = options?.flags ?? ctx ?? fetched.flags ?? defaultWireFeatures;
|
|
155
|
+
const settled = options?.flags ? true : ctx != null ? ctxSettled : fetched.settled;
|
|
156
|
+
return useMemo(() => ({ flags, settled }), [flags, settled]);
|
|
97
157
|
};
|
|
@@ -30,23 +30,62 @@ const failOpen = async (
|
|
|
30
30
|
return cached?.features ?? defaultWireFeatures;
|
|
31
31
|
};
|
|
32
32
|
|
|
33
|
-
/**
|
|
34
|
-
|
|
33
|
+
/** "The exchange produced nothing usable" — a non-2xx, or a deadline the body never met. Both mean
|
|
34
|
+
* the caller must fail OPEN, and neither is distinguishable to it. A symbol so it can never
|
|
35
|
+
* collide with a JSON body the server actually sent. */
|
|
36
|
+
const NO_ANSWER: unique symbol = Symbol("wire-features-no-answer");
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* GET **and read the body** under ONE deadline. Never pends longer than `timeoutMs`.
|
|
40
|
+
*
|
|
41
|
+
* ── THE DEFECT THIS CLOSES ─────────────────────────────────────────────────────────────────────
|
|
42
|
+
* The timer used to be cleared in a `finally` around the `fetch` alone, so it died the moment the
|
|
43
|
+
* HEADERS landed and `await res.json()` ran with NO ceiling at all. A 200 whose body then stalls —
|
|
44
|
+
* a half-open connection, a proxy that flushes headers and hangs, a captive portal — left this
|
|
45
|
+
* function pending forever. `settled` never flips, so `useReviewGate` and `useQuestionnaireGate`
|
|
46
|
+
* never become ready and BOTH gates stay dark for the life of the process, silently. That INVERTS
|
|
47
|
+
* this module's own stated contract at the top of the file: fail-open, never fail-dark.
|
|
48
|
+
*
|
|
49
|
+
* SCOPE, honestly: on native RN `fetch` is the XHR polyfill and the body is already buffered by the
|
|
50
|
+
* time the promise resolves, so this was effectively unreachable there. On RN-Web / Expo web — which
|
|
51
|
+
* the kit explicitly supports (`WireFeaturesProvider`) — it is real.
|
|
52
|
+
*
|
|
53
|
+
* TWO mechanisms, deliberately, because they fail differently. The abort tears the socket DOWN
|
|
54
|
+
* (a stalled stream is not just un-awaited, it is cancelled); the race guarantees this promise
|
|
55
|
+
* SETTLES even on a runtime that ignores `signal` once the body has started. A ceiling that depends
|
|
56
|
+
* on the host honouring abort is not a ceiling on a surface whose whole contract is "never dark".
|
|
57
|
+
*/
|
|
58
|
+
const fetchFeaturesJson = async (
|
|
35
59
|
url: string,
|
|
36
60
|
apiKey: string,
|
|
37
61
|
timeoutMs: number,
|
|
38
|
-
): Promise<
|
|
62
|
+
): Promise<unknown> => {
|
|
39
63
|
const controller =
|
|
40
64
|
typeof AbortController !== "undefined" ? new AbortController() : undefined;
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
65
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
66
|
+
const expired = new Promise<typeof NO_ANSWER>((resolve) => {
|
|
67
|
+
timer = setTimeout(() => {
|
|
68
|
+
controller?.abort();
|
|
69
|
+
resolve(NO_ANSWER);
|
|
70
|
+
}, timeoutMs);
|
|
71
|
+
});
|
|
72
|
+
const exchange = (async (): Promise<unknown> => {
|
|
73
|
+
const res = await fetch(url, {
|
|
44
74
|
method: "GET",
|
|
45
75
|
headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` },
|
|
46
76
|
signal: controller?.signal,
|
|
47
77
|
});
|
|
78
|
+
// 401 (bad/rotated key), 5xx, any non-2xx → last-known, never dark.
|
|
79
|
+
if (!res || !res.ok) return NO_ANSWER;
|
|
80
|
+
return (await res.json()) as unknown; // throws on malformed body → caught by the caller
|
|
81
|
+
})();
|
|
82
|
+
try {
|
|
83
|
+
return await Promise.race([exchange, expired]);
|
|
48
84
|
} finally {
|
|
49
85
|
clearTimeout(timer);
|
|
86
|
+
// The loser can still settle after the race is decided and nothing is awaiting it. An
|
|
87
|
+
// unobserved rejection is a hard crash on some hosts, so it gets a handler either way.
|
|
88
|
+
void exchange.catch(() => {});
|
|
50
89
|
}
|
|
51
90
|
};
|
|
52
91
|
|
|
@@ -72,15 +111,14 @@ export const fetchWireFeatures = async (
|
|
|
72
111
|
const timeoutMs = config.timeoutMs && config.timeoutMs > 0 ? config.timeoutMs : DEFAULT_TIMEOUT_MS;
|
|
73
112
|
|
|
74
113
|
try {
|
|
75
|
-
const
|
|
76
|
-
//
|
|
77
|
-
if (
|
|
78
|
-
const json: unknown = await res.json(); // throws on malformed body → caught below
|
|
114
|
+
const json: unknown = await fetchFeaturesJson(url, config.apiKey, timeoutMs);
|
|
115
|
+
// A non-2xx, or a headers-then-stall body that never met the deadline → last-known, never dark.
|
|
116
|
+
if (json === NO_ANSWER) return failOpen(storage, key);
|
|
79
117
|
const features = parseWireFeatures(json);
|
|
80
118
|
if (storage) writeCachedFeatures(storage, key, features);
|
|
81
119
|
return features;
|
|
82
120
|
} catch {
|
|
83
|
-
//
|
|
121
|
+
// Abort, network error, missing fetch, or malformed JSON — all fail open.
|
|
84
122
|
return failOpen(storage, key);
|
|
85
123
|
}
|
|
86
124
|
};
|