@wireai/activation 0.15.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +95 -20
- package/CHANGELOG.md +707 -0
- package/INTEGRATION_PROMPT.md +61 -23
- package/README.md +100 -25
- package/dist/analytics/index.d.mts +32 -10
- package/dist/analytics/index.d.ts +32 -10
- package/dist/analytics/index.js +288 -127
- package/dist/analytics/index.js.map +1 -1
- package/dist/analytics/index.mjs +288 -127
- package/dist/analytics/index.mjs.map +1 -1
- package/dist/coachmarks/index.d.mts +14 -0
- package/dist/coachmarks/index.d.ts +14 -0
- package/dist/coachmarks/index.js +73 -20
- package/dist/coachmarks/index.js.map +1 -1
- package/dist/coachmarks/index.mjs +73 -20
- package/dist/coachmarks/index.mjs.map +1 -1
- package/dist/{currentSession-orZy5p1e.d.mts → currentSession-Bz7G6lno.d.mts} +25 -35
- package/dist/{currentSession-CFSRZ2wg.d.ts → currentSession-z-CZ55ad.d.ts} +25 -35
- package/dist/index.d.mts +5 -2
- package/dist/index.d.ts +5 -2
- package/dist/index.js +125 -36
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +125 -36
- package/dist/index.mjs.map +1 -1
- package/dist/questionnaire/index.d.mts +0 -13
- package/dist/questionnaire/index.d.ts +0 -13
- package/dist/questionnaire/index.js +154 -43
- package/dist/questionnaire/index.js.map +1 -1
- package/dist/questionnaire/index.mjs +155 -44
- package/dist/questionnaire/index.mjs.map +1 -1
- package/dist/reviews/index.js +159 -91
- package/dist/reviews/index.js.map +1 -1
- package/dist/reviews/index.mjs +160 -92
- package/dist/reviews/index.mjs.map +1 -1
- package/dist/showcase/index.js +59 -18
- package/dist/showcase/index.js.map +1 -1
- package/dist/showcase/index.mjs +60 -19
- package/dist/showcase/index.mjs.map +1 -1
- package/llms.txt +9 -9
- package/package.json +6 -9
- package/src/analytics/currentSession.ts +141 -4
- package/src/analytics/index.ts +6 -1
- package/src/analytics/reportClientEvent.ts +19 -10
- package/src/analytics/useAnalytics.ts +74 -15
- package/src/analytics/wireDoctor.ts +152 -7
- package/src/coachmarks/CoachmarkProvider.tsx +26 -5
- package/src/coachmarks/runtime.ts +53 -0
- package/src/coachmarks/useCoachmarkTour.ts +51 -1
- package/src/context/deviceId.ts +49 -15
- package/src/features/WireFeaturesProvider.tsx +72 -12
- package/src/features/fetchWireFeatures.ts +49 -11
- package/src/features/useWireFeatures.ts +39 -3
- package/src/identity/identityRecord.ts +15 -2
- package/src/questionnaire/QuestionnaireGate.tsx +40 -1
- package/src/questionnaire/transport.ts +22 -8
- package/src/questionnaire/useQuestionnaireGate.ts +58 -7
- package/src/reviews/ReviewGate.tsx +39 -0
- package/src/reviews/idempotency.ts +38 -0
- package/src/reviews/runtime.ts +39 -10
- package/src/reviews/transport.ts +22 -8
- package/src/reviews/useReviewGate.ts +57 -7
- package/src/session-analytics/lifecycle.ts +16 -0
- package/src/session-analytics/useLifecycleEvents.ts +30 -2
- package/src/session-analytics/useSessionStart.ts +22 -2
- package/src/showcase/FeatureShowcase.tsx +50 -3
- package/src/types.ts +5 -4
- package/src/utils/withDeadline.ts +70 -0
|
@@ -64,6 +64,44 @@ export interface ReviewImpression {
|
|
|
64
64
|
/**
|
|
65
65
|
* Open one impression. Both fields are resolved EAGERLY here rather than read per post, which is the
|
|
66
66
|
* entire point: a lazily re-read key or identity is a per-post value wearing an impression's name.
|
|
67
|
+
*
|
|
68
|
+
* ── WHY A NON-DURABLE UNIT IS DELIBERATELY ACCEPTED HERE (ruled 0.15.1) ──────────────────────
|
|
69
|
+
*
|
|
70
|
+
* The kit's standing identity rule is that a caller writing a cross-launch join key must read
|
|
71
|
+
* `IdentityRecord.durable` and REFUSE a `false` — see `context/deviceId.ts`. That rule does not
|
|
72
|
+
* transfer to this field, and an audit that ports it here makes the data WORSE, so the reasoning is
|
|
73
|
+
* recorded rather than left to be rediscovered:
|
|
74
|
+
*
|
|
75
|
+
* • WHAT THE RULE PROTECTS elsewhere is a COUNTER. `app.session_started` is counted by distinct
|
|
76
|
+
* opens grouped on `device_key`, so a per-launch key there makes `min_sessions` structurally
|
|
77
|
+
* incapable of exceeding 1 — the key actively corrupts the metric. A review row is written once
|
|
78
|
+
* per user, so a per-launch value here cannot corrupt a count the same way.
|
|
79
|
+
* • WHAT REFUSING WOULD COST is the feature itself. With no unit the server's
|
|
80
|
+
* `_idempotency_unit` returns nothing, it DISCARDS the key and inserts a plain row — so the
|
|
81
|
+
* `unsent` re-post and the abandonment net each write a SECOND review row, double-counting
|
|
82
|
+
* `count` and corrupting `avg`. That is the exact corruption the key was added to prevent, and
|
|
83
|
+
* it is a corruption traded for an under-count, which is the wrong direction.
|
|
84
|
+
* • THE SCOPE THE UNIT ACTUALLY NEEDS is one impression inside one launch, and that is precisely
|
|
85
|
+
* what a pinned in-memory value guarantees. Durability buys nothing the server ever reads here.
|
|
86
|
+
* • THE ORPHANED-JOIN WORRY (a review row stamped with a key no analytics event carries) is real
|
|
87
|
+
* and is NOT closed by any of the above — it is an ID-SPACE problem, not a durability one, and
|
|
88
|
+
* it survives here. Two sequences still produce it, both of them ordinary:
|
|
89
|
+
* (1) the impression opens BEFORE any surface has registered a real `appId`, so it pins the
|
|
90
|
+
* ambient fallback while every later analytics event carries the host's id. This is the
|
|
91
|
+
* same cold-start ordering the ambient reader was fixed for, and the test that covers
|
|
92
|
+
* that fix calls it the sequence rather than an edge case.
|
|
93
|
+
* (2) the impression opens while a hydration is IN FLIGHT, so it pins the synchronous mint
|
|
94
|
+
* and the persisted id replaces it in the registry a moment later.
|
|
95
|
+
* What WAS closed is a different and larger failure: the ambient reader used to answer
|
|
96
|
+
* `undefined` permanently once the real `appId` registered, which cost every later impression
|
|
97
|
+
* its unit outright. The orphan that remains is bounded to impressions opened before the
|
|
98
|
+
* process's id space settles, and the gate is once-per-user, so in practice it is at most the
|
|
99
|
+
* first row.
|
|
100
|
+
* • AND THE ORPHAN IS NOT AN ARGUMENT FOR REFUSING, which is the trap. Dropping the unit does not
|
|
101
|
+
* repair the join — it removes it AND removes the server's ability to dedupe, so an orphaned
|
|
102
|
+
* row that is at least idempotent becomes a row that is neither. Closing it properly means
|
|
103
|
+
* giving the gate a settled identity to pin, which is a public-API change (`appId` / `storage`
|
|
104
|
+
* props on `ReviewGate`) and is deliberately not attempted here.
|
|
67
105
|
*/
|
|
68
106
|
export const createImpression = (): ReviewImpression => ({
|
|
69
107
|
idempotencyKey: mintIdempotencyKey(),
|
package/src/reviews/runtime.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import type { CoachmarkStorage } from "../coachmarks/types";
|
|
8
8
|
import { getCoachmarkStorage, validateGateStorage } from "../coachmarks/runtime";
|
|
9
|
-
import { getCurrentSessionId } from "../analytics/currentSession";
|
|
9
|
+
import { getCurrentSessionId, isMintedSessionId } from "../analytics/currentSession";
|
|
10
10
|
import { makeSessionId } from "../analytics/reportClientEvent";
|
|
11
11
|
|
|
12
12
|
/** Once-gate key. Keyed by app version when `oncePerVersion` is on, so a new release re-enables. */
|
|
@@ -156,12 +156,26 @@ const readStr = (storage: CoachmarkStorage | null, key: string): string | undefi
|
|
|
156
156
|
// that was live when the pin was taken, and read it lazily.
|
|
157
157
|
//
|
|
158
158
|
// • pin unset → pin = the registered id ?? a freshly minted one, and remember which
|
|
159
|
-
// registered id that was (`undefined` when we had to mint)
|
|
160
|
-
//
|
|
161
|
-
//
|
|
159
|
+
// registered id that was (`undefined` when we had to mint) and whether
|
|
160
|
+
// it was itself a MINTED fallback.
|
|
161
|
+
// • remembered `undefined`
|
|
162
|
+
// or remembered MINTED → the FIRST REAL registration merely NAMES the open the gate already
|
|
163
|
+
// counted under a fallback id. Adopt it, leave the PIN alone. This is
|
|
162
164
|
// the cold-start case, and it stays shut.
|
|
163
165
|
// • live id ≠ remembered → a genuinely new app-open. Re-pin to it; the counter bumps once.
|
|
164
166
|
//
|
|
167
|
+
// ── A MINTED ID IS NOT AN APP-OPEN (the second half of the same rule) ────────────────────────
|
|
168
|
+
// The bullet above says "remembered MINTED" because the registry has TWO writers, not one.
|
|
169
|
+
// `reportSessionStart` registers a real open — but `ensureCurrentSessionId()` also WRITES the shared
|
|
170
|
+
// slot, with a fallback id it minted so a fire-and-forget event is not dropped for want of a
|
|
171
|
+
// `session_id`, and it is reached from `analyticsFacade.resolveSessionId`, `wireActivation.track`
|
|
172
|
+
// and `reviews/transport.reportAppEvent`. A host can hit any of those before `useLifecycleEvents`
|
|
173
|
+
// mounts. That made the pin's `observed` a minted id rather than `undefined`, so the cold-start
|
|
174
|
+
// branch never fired and the real `app.session_started` that followed fell through to the last
|
|
175
|
+
// branch as "a genuinely new app-open": ONE launch counted as TWO, and `minSessions: 2` became
|
|
176
|
+
// satisfiable inside a user's FIRST session — failing in the UNSAFE direction. `isMintedSessionId`
|
|
177
|
+
// is the seam that tells the two writers apart; a fallback is not evidence the app was opened.
|
|
178
|
+
//
|
|
165
179
|
// ⚠️ ONE ACCEPTED MISS, deliberately in the safe direction: if the mount open never registers (a
|
|
166
180
|
// refused non-durable device key over a broken store) and a LATER foreground is the first
|
|
167
181
|
// registration the launch ever sees, that registration is ADOPTED rather than counted, costing one
|
|
@@ -184,6 +198,12 @@ type OpenPin = {
|
|
|
184
198
|
openId: string;
|
|
185
199
|
/** The registered session id observed at pin time; `undefined` when `openId` was minted. */
|
|
186
200
|
observed: string | undefined;
|
|
201
|
+
/**
|
|
202
|
+
* True when `observed` was a FALLBACK id `ensureCurrentSessionId` minted, not a real app-open.
|
|
203
|
+
* Such a pin has to behave exactly like `observed === undefined`: nothing has named this open yet,
|
|
204
|
+
* so the first REAL registration adopts it rather than starting a second one.
|
|
205
|
+
*/
|
|
206
|
+
observedMinted: boolean;
|
|
187
207
|
};
|
|
188
208
|
|
|
189
209
|
type GlobalWithOpenId = typeof globalThis & { [PROCESS_OPEN_ID_SLOT]?: OpenPin };
|
|
@@ -198,28 +218,37 @@ const openIdGlobal = globalThis as GlobalWithOpenId;
|
|
|
198
218
|
*/
|
|
199
219
|
export const currentOpenId = (): string => {
|
|
200
220
|
const live = getCurrentSessionId();
|
|
221
|
+
// A FALLBACK id is not an app-open (see the note above `OpenPin.observedMinted`), so it is
|
|
222
|
+
// recorded as such and never treated as a registration this pin could be measured against.
|
|
223
|
+
const liveMinted = isMintedSessionId(live);
|
|
201
224
|
const pin = openIdGlobal[PROCESS_OPEN_ID_SLOT];
|
|
202
225
|
|
|
203
226
|
// First read of the launch. Adopt the registered id when the host wired lifecycle BEFORE any gate
|
|
204
227
|
// rendered — that is the same unit the server counts — otherwise mint one and record that we did.
|
|
205
228
|
if (!pin) {
|
|
206
229
|
const openId = live ?? makeSessionId();
|
|
207
|
-
openIdGlobal[PROCESS_OPEN_ID_SLOT] = { openId, observed: live };
|
|
230
|
+
openIdGlobal[PROCESS_OPEN_ID_SLOT] = { openId, observed: live, observedMinted: liveMinted };
|
|
208
231
|
return openId;
|
|
209
232
|
}
|
|
210
233
|
|
|
211
234
|
// Nothing registered yet, or still the same registered open: the pin stands.
|
|
212
235
|
if (!live || live === pin.observed) return pin.openId;
|
|
213
236
|
|
|
214
|
-
// The first registration of the launch NAMES the open we already pinned
|
|
215
|
-
//
|
|
216
|
-
|
|
217
|
-
|
|
237
|
+
// The first REAL registration of the launch NAMES the open we already pinned — whether we pinned
|
|
238
|
+
// under an id this function minted (`observed === undefined`) or under one `ensureCurrentSessionId`
|
|
239
|
+
// minted for an early event (`observedMinted`). Either way nothing had named the open yet, so it is
|
|
240
|
+
// adopted, not counted. Re-pinning here is exactly the cold-start double-count described above.
|
|
241
|
+
if (pin.observed === undefined || pin.observedMinted) {
|
|
242
|
+
openIdGlobal[PROCESS_OPEN_ID_SLOT] = {
|
|
243
|
+
openId: pin.openId,
|
|
244
|
+
observed: live,
|
|
245
|
+
observedMinted: liveMinted,
|
|
246
|
+
};
|
|
218
247
|
return pin.openId;
|
|
219
248
|
}
|
|
220
249
|
|
|
221
250
|
// A second, DIFFERENT registration is a genuinely new app-open.
|
|
222
|
-
openIdGlobal[PROCESS_OPEN_ID_SLOT] = { openId: live, observed: live };
|
|
251
|
+
openIdGlobal[PROCESS_OPEN_ID_SLOT] = { openId: live, observed: live, observedMinted: liveMinted };
|
|
223
252
|
return live;
|
|
224
253
|
};
|
|
225
254
|
|
package/src/reviews/transport.ts
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { ensureCurrentSessionId } from "../analytics/currentSession";
|
|
18
18
|
import { buildEventsRequest, type ClientEvent } from "../analytics/reportClientEvent";
|
|
19
|
+
import { DEADLINE_EXPIRED, withDeadline } from "../utils/withDeadline";
|
|
19
20
|
import type { SubmitResult } from "../utils/submitResult";
|
|
20
21
|
import type { ReviewDecisionResponse, ReviewSubmission, ReviewTarget } from "./types";
|
|
21
22
|
|
|
@@ -77,11 +78,19 @@ export const submitReview = async (
|
|
|
77
78
|
const url = `${target.serverUrl.replace(/\/$/, "")}/v1/reviews`;
|
|
78
79
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
79
80
|
if (target.apiKey) headers.Authorization = `Bearer ${target.apiKey}`;
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
81
|
+
// Under a deadline (see `utils/withDeadline`): a hung POST used to hold `ReviewGate`'s
|
|
82
|
+
// `postOnce` latch closed for the ~60s platform default, which is exactly the window the
|
|
83
|
+
// unmount recovery net cannot re-post inside.
|
|
84
|
+
const res = await withDeadline((signal) =>
|
|
85
|
+
fetch(url, {
|
|
86
|
+
method: "POST",
|
|
87
|
+
headers,
|
|
88
|
+
body: JSON.stringify(review),
|
|
89
|
+
signal,
|
|
90
|
+
}),
|
|
91
|
+
);
|
|
92
|
+
// A deadline that expired is not a rejection: nothing reached the server, so it stays re-postable.
|
|
93
|
+
if (res === DEADLINE_EXPIRED) return "unsent";
|
|
85
94
|
// No response object at all is not an answer — treat it as nothing having reached the server
|
|
86
95
|
// rather than as a rejection, or a stubbed-out `fetch` would silently latch the row away.
|
|
87
96
|
if (!res) return "unsent";
|
|
@@ -193,9 +202,14 @@ export const fetchReviewDecision = async (
|
|
|
193
202
|
const url = `${base}/v1/reviews/decision${qs ? `?${qs}` : ""}`;
|
|
194
203
|
const headers: Record<string, string> = {};
|
|
195
204
|
if (target.apiKey) headers.Authorization = `Bearer ${target.apiKey}`;
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
const json =
|
|
205
|
+
// The BODY read is inside the deadline too: a ceiling cleared when the headers land is not a
|
|
206
|
+
// ceiling (the `fetchWireFeatures` lesson — see `utils/withDeadline`).
|
|
207
|
+
const json = await withDeadline(async (signal): Promise<ReviewDecisionResponse | null> => {
|
|
208
|
+
const res = await fetch(url, { headers, signal });
|
|
209
|
+
if (!res || !res.ok) return null;
|
|
210
|
+
return (await res.json()) as ReviewDecisionResponse | null;
|
|
211
|
+
});
|
|
212
|
+
if (json === DEADLINE_EXPIRED) return null;
|
|
199
213
|
// A body without a boolean `fire` is not a decision. Guard it explicitly rather than
|
|
200
214
|
// letting `{}` through as a truthy object that `decideReview` would treat as a verdict
|
|
201
215
|
// (`{}.fire === undefined` is falsy, so it would silently read as "never fire").
|
|
@@ -12,10 +12,11 @@
|
|
|
12
12
|
* (`wire_review_<id>_seen`) always applies locally, so a server bug can never spam the prompt.
|
|
13
13
|
* The global `isTestingCoachmark` flag force-shows the gate for QA replay.
|
|
14
14
|
*/
|
|
15
|
-
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
15
|
+
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
16
16
|
|
|
17
|
+
import { getCurrentSessionId, subscribeCurrentSessionId } from "../analytics/currentSession";
|
|
17
18
|
import { hasSeenGate, isCoachmarkTesting, markSeenGate } from "../coachmarks/runtime";
|
|
18
|
-
import {
|
|
19
|
+
import { useResolvedFeaturesState } from "../features/WireFeaturesProvider";
|
|
19
20
|
import { decideReview, evaluateGate, resolveRules } from "./decision";
|
|
20
21
|
import { sameDecision, shallowEqual } from "./equality";
|
|
21
22
|
import {
|
|
@@ -67,7 +68,19 @@ export const useReviewGate = ({
|
|
|
67
68
|
|
|
68
69
|
// The review MASTER switch. Disabled → the gate never fires, composing as an extra AND over
|
|
69
70
|
// the local rules + the server decision seam below (fail-open: defaults to enabled).
|
|
70
|
-
|
|
71
|
+
//
|
|
72
|
+
// `settled` is read too, and it is the half that makes the switch real. The flags start on the
|
|
73
|
+
// all-on defaults and swap only when the fetch resolves, so `featureEnabled` is `true` on the
|
|
74
|
+
// FIRST render of every mount — including a tenant with `review.enabled: false`. With no
|
|
75
|
+
// `timeoutFallbackMs` the local wait is skipped as well (`elapsed` starts `true`), so the gate
|
|
76
|
+
// could become visible, fire `review_prompt_shown`, and take a POSTed row from a user the tenant
|
|
77
|
+
// had switched off. The comment below ("must never see the gate") was falsified by its own async
|
|
78
|
+
// default. Holding the FIRST impression until an answer exists is what honours the switch.
|
|
79
|
+
const { flags: resolvedFeatures, settled: featuresSettled } = useResolvedFeaturesState({
|
|
80
|
+
flags: features,
|
|
81
|
+
config: featuresConfig,
|
|
82
|
+
});
|
|
83
|
+
const featureEnabled = resolvedFeatures.review.enabled;
|
|
71
84
|
|
|
72
85
|
const testing = isTesting ?? isCoachmarkTesting();
|
|
73
86
|
const seenKey = reviewSeenKey(config.id, config.oncePerVersion === false ? undefined : config.appVersion);
|
|
@@ -75,19 +88,51 @@ export const useReviewGate = ({
|
|
|
75
88
|
const sessionsKey = reviewSessionsKey(config.id);
|
|
76
89
|
const sessionOpenKey = reviewSessionOpenKey(config.id);
|
|
77
90
|
|
|
91
|
+
// ── FOLLOW real app-opens, do not sample one ────────────────────────────────────────────────
|
|
92
|
+
//
|
|
93
|
+
// `bumpSessionCount` is IDEMPOTENT per app-open and moves once per open — but it used to be reached
|
|
94
|
+
// ONLY from the `useState` initializer below, which runs once per component INSTANCE. On a screen
|
|
95
|
+
// that never unmounts (a home feed, a tab that stays alive) that meant once per PROCESS, and iOS
|
|
96
|
+
// suspends rather than kills: `useLifecycleEvents` fires a fresh `app.session_started` on every
|
|
97
|
+
// foreground past its threshold and the SERVER's `min_sessions` advances, while `wire_review_<id>_sessions`
|
|
98
|
+
// stayed at its launch value and the LOCAL `minSessions` rule was unsatisfiable for the life of the
|
|
99
|
+
// app. Silent, and in the safe direction (a prompt that never shows), which is why it survived —
|
|
100
|
+
// `sessionCountAcrossOpens.test.ts` proved the FUNCTION advances and could not see that the HOOK
|
|
101
|
+
// never asked it again.
|
|
102
|
+
//
|
|
103
|
+
// So subscribe to the registry that knows. A notification naming the same open is a no-op by
|
|
104
|
+
// construction (`bumpSessionCount` reads its stored count back), so the cold-start ordering this
|
|
105
|
+
// pin exists for is untouched.
|
|
106
|
+
const openSessionId = useSyncExternalStore(
|
|
107
|
+
subscribeCurrentSessionId,
|
|
108
|
+
getCurrentSessionId,
|
|
109
|
+
getCurrentSessionId,
|
|
110
|
+
);
|
|
111
|
+
// Read through a ref: a host that passes `storage={{ … }}` inline (idiomatic React, and what
|
|
112
|
+
// `frequent_rules` #11 requires this hook to tolerate) mints a fresh identity every render, and
|
|
113
|
+
// listing it in the deps below would re-run the effect on every one of them.
|
|
114
|
+
const storageRef = useRef(storage);
|
|
115
|
+
storageRef.current = storage;
|
|
116
|
+
|
|
78
117
|
// Read (and bump) the app-open counter. IDEMPOTENT per app-open, NOT per mount: `bumpSessionCount`
|
|
79
118
|
// keys off the live per-open session id (or a per-process id when no host wired the lifecycle
|
|
80
119
|
// events), so a remount, a navigation return, or React StrictMode's dev double-invoke of this
|
|
81
120
|
// initializer all read the same number back instead of inflating it. Before this, `sessions`
|
|
82
121
|
// counted mounts, so the fail-closed `minSessions: 2` default could be satisfied inside the user's
|
|
83
122
|
// very first app open — the exact scenario it was added to prevent.
|
|
84
|
-
const sessions = useState(() => {
|
|
123
|
+
const [sessions, setSessions] = useState(() => {
|
|
85
124
|
const store = resolveStorage(storage);
|
|
86
125
|
// No storage pins the counter at 1 forever, so the fail-closed minSessions rule can never be
|
|
87
126
|
// met and the gate silently never fires. Dev-only, once per process.
|
|
88
127
|
warnMissingGateStorage(store, "review");
|
|
89
128
|
return bumpSessionCount(store, sessionsKey, sessionOpenKey);
|
|
90
|
-
})
|
|
129
|
+
});
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
const next = bumpSessionCount(resolveStorage(storageRef.current), sessionsKey, sessionOpenKey);
|
|
132
|
+
// A bail-out when the count is unchanged, so a notification for an open already counted costs
|
|
133
|
+
// nothing: React skips the re-render when the state is identical.
|
|
134
|
+
setSessions((prev) => (prev === next ? prev : next));
|
|
135
|
+
}, [openSessionId, sessionsKey, sessionOpenKey]);
|
|
91
136
|
|
|
92
137
|
// Gate the local rules behind an optional client-side timeout, so a reachable server
|
|
93
138
|
// gets a window to answer first. A present `decision` bypasses the wait entirely.
|
|
@@ -123,10 +168,15 @@ export const useReviewGate = ({
|
|
|
123
168
|
const resolved = decideReview(local, decision);
|
|
124
169
|
// (3) the once-gate always wins locally, even over a server "fire".
|
|
125
170
|
const alreadySeen = hasSeenGate(seenKey, storage, isTesting);
|
|
126
|
-
|
|
171
|
+
// FAIL-OPEN IS NOT WEAKENED BY THIS. `featuresSettled` is `false` only while a fetch that could
|
|
172
|
+
// still change the answer is in flight; it is `true` with no `featuresConfig` (nothing to ask),
|
|
173
|
+
// with explicit `features`, and — the case that matters — the moment a FAILED fetch resolves,
|
|
174
|
+
// because `fetchWireFeatures` swallows a timeout / 401 / 5xx into the all-on defaults. So an
|
|
175
|
+
// unreachable control plane still allows the gate; only an unanswered one holds it.
|
|
176
|
+
const ready = (hasServerDecision || elapsed) && featuresSettled;
|
|
127
177
|
return { visible: ready && resolved.fire && !alreadySeen, verdict: resolved };
|
|
128
178
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
129
|
-
}, [featureEnabled, testing, config, decision, events, sessions, elapsed, hasServerDecision, seenKey, lastKey, storage, isTesting]);
|
|
179
|
+
}, [featureEnabled, featuresSettled, testing, config, decision, events, sessions, elapsed, hasServerDecision, seenKey, lastKey, storage, isTesting]);
|
|
130
180
|
|
|
131
181
|
// Stable callbacks + a stable controller object, so a host can safely list any of
|
|
132
182
|
// them in its own effect deps without the effect re-firing every render.
|
|
@@ -173,8 +173,24 @@ const emitFirstOpen = (opts: ReportFirstOpenOptions): void => {
|
|
|
173
173
|
* • Race guard: an in-memory latch is set SYNCHRONOUSLY before the async read, so two
|
|
174
174
|
* near-simultaneous calls fire at most once.
|
|
175
175
|
* • Without `storage`: degraded mode — fires once per PROCESS via the latch only (documented).
|
|
176
|
+
* • WITHOUT A TRANSPORT: no-op, and — the point of the check — the once-ever flag is NOT spent.
|
|
177
|
+
* See below.
|
|
176
178
|
*/
|
|
177
179
|
export const reportFirstOpen = (opts: ReportFirstOpenOptions): void => {
|
|
180
|
+
// NO TRANSPORT, NO SPEND. `emitFirstOpen` routes through `buildEventsRequest`, which returns
|
|
181
|
+
// `null` when there is no `target.serverUrl` — so with neither a `sink` nor a server URL the emit
|
|
182
|
+
// is a silent no-op. Both once-ever guards were still consumed underneath it: the in-memory latch
|
|
183
|
+
// synchronously, and then the PERSISTED `wireai:first_open:<appId>` flag on the write below. That
|
|
184
|
+
// marks the install as having reported its first open when NOTHING was ever sent, and the flag
|
|
185
|
+
// survives app kills — so `app.first_open` was dead for that install FOREVER, with no error and
|
|
186
|
+
// no event. Reachable on the documented wiring: a `LifecycleConfig` carrying `storage` + `appId`
|
|
187
|
+
// whose `serverUrl` is optional and resolves late (the hook's effect is mount-only).
|
|
188
|
+
//
|
|
189
|
+
// The sibling emitter already gets this right — `reportSessionStart` bails on
|
|
190
|
+
// `!opts.sink && !target?.serverUrl` BEFORE its own once-per-open guard — and this is the same
|
|
191
|
+
// check in the same position, for the same reason. A guard may only be spent by a real send.
|
|
192
|
+
if (!opts.sink && !opts.target?.serverUrl) return;
|
|
193
|
+
|
|
178
194
|
const appId = opts.appId ?? "default";
|
|
179
195
|
|
|
180
196
|
// Synchronous latch FIRST: guarantees at-most-one before any await (same-process race guard) and
|
|
@@ -36,7 +36,12 @@ import { makeSessionId, type ClientEvent, type ClientEventTarget } from "../anal
|
|
|
36
36
|
import { hydrateDeviceIdentity } from "../context/deviceId";
|
|
37
37
|
import { resolveIdentity } from "../identity/identityRecord";
|
|
38
38
|
import { collectDeviceContext } from "../device/deviceContext";
|
|
39
|
-
import
|
|
39
|
+
import {
|
|
40
|
+
READ_TIMED_OUT,
|
|
41
|
+
READ_TIMEOUT_MS,
|
|
42
|
+
withTimeout,
|
|
43
|
+
type WireOnboardingStorage,
|
|
44
|
+
} from "../session/persistedSession";
|
|
40
45
|
import { reportFirstOpen } from "./lifecycle";
|
|
41
46
|
import { reportSessionStart } from "./reportSessionStart";
|
|
42
47
|
import { BACKGROUND_SESSION_MS } from "./useSessionStart";
|
|
@@ -173,6 +178,23 @@ export const useLifecycleEvents = (
|
|
|
173
178
|
* emits the event with NO auto key — the same verdict `<WireOnboarding>` reaches on a broken
|
|
174
179
|
* adapter, and strictly better than a key that differs on every launch. Never rejects: the read
|
|
175
180
|
* resolves to a record or `undefined`, never a throw.
|
|
181
|
+
*
|
|
182
|
+
* ── AND IT IS CAPPED, BECAUSE "NEVER REJECTS" IS NOT "ALWAYS SETTLES" ──────────────────────
|
|
183
|
+
*
|
|
184
|
+
* `hydrateDeviceIdentity` awaits a BARE `storage.getItem`, so an adapter that HANGS (the read
|
|
185
|
+
* neither resolves nor rejects — a locked keychain, a wedged native bridge) leaves the promise
|
|
186
|
+
* pending forever. Every fire in this hook lives inside that `.then`, so a hung adapter did not
|
|
187
|
+
* degrade the device key: it silently killed `app.session_started` AND `app.first_open` for the
|
|
188
|
+
* whole process, on every launch. Those two events are the funnel's denominator and what the
|
|
189
|
+
* server counts `min_sessions` from, so `min_sessions` stays 0 and every review / questionnaire
|
|
190
|
+
* trigger becomes unsatisfiable — with nothing logged and nothing thrown.
|
|
191
|
+
*
|
|
192
|
+
* The read is therefore raced against the SAME ceiling the rest of the kit reads through
|
|
193
|
+
* (`session/persistedSession`'s exported `withTimeout` + `READ_TIMEOUT_MS`, which is also the
|
|
194
|
+
* ceiling `<WireOnboarding>`'s auto-join gate names). Blowing it fires with NO auto key —
|
|
195
|
+
* byte-identical to the refusal a rejecting adapter already gets — because a read that did not
|
|
196
|
+
* answer cannot prove the id is durable, and an event with no device key is a recoverable gap
|
|
197
|
+
* while no event at all is a permanent one.
|
|
176
198
|
*/
|
|
177
199
|
const openAutoDeviceKey = (fire: (autoDeviceKey: string | undefined) => void): void => {
|
|
178
200
|
const { config: cfg, options: opts } = latest.current;
|
|
@@ -182,8 +204,14 @@ export const useLifecycleEvents = (
|
|
|
182
204
|
fire(undefined);
|
|
183
205
|
return;
|
|
184
206
|
}
|
|
185
|
-
void
|
|
207
|
+
void withTimeout(
|
|
208
|
+
hydrateDeviceIdentity({ appId: cfg.appId, storage: cfg.storage }),
|
|
209
|
+
READ_TIMEOUT_MS,
|
|
210
|
+
).then((read) => {
|
|
186
211
|
if (cancelled) return;
|
|
212
|
+
// READ_TIMED_OUT = the adapter never answered in time. It collapses onto the SAME branch as
|
|
213
|
+
// a non-durable record — fire with no auto key — so the events still go out, degraded.
|
|
214
|
+
const identity = read === READ_TIMED_OUT ? undefined : read;
|
|
187
215
|
fire(identity?.durable ? identity.value : undefined);
|
|
188
216
|
});
|
|
189
217
|
};
|
|
@@ -31,7 +31,12 @@ import { AppState, Platform, type AppStateStatus } from "react-native";
|
|
|
31
31
|
import type { ClientEventTarget } from "../analytics/reportClientEvent";
|
|
32
32
|
import { hydrateDeviceIdentity } from "../context/deviceId";
|
|
33
33
|
import { collectDeviceContext } from "../device/deviceContext";
|
|
34
|
-
import
|
|
34
|
+
import {
|
|
35
|
+
READ_TIMED_OUT,
|
|
36
|
+
READ_TIMEOUT_MS,
|
|
37
|
+
withTimeout,
|
|
38
|
+
type WireOnboardingStorage,
|
|
39
|
+
} from "../session/persistedSession";
|
|
35
40
|
import { reportSessionStart } from "./reportSessionStart";
|
|
36
41
|
|
|
37
42
|
/** A foreground after at least this long in the background counts as a NEW app-open (30 min). */
|
|
@@ -125,6 +130,15 @@ export const useSessionStart = (
|
|
|
125
130
|
* rule `context/deviceId.ts` states for exactly these callers: *"Callers that write a key onto the
|
|
126
131
|
* wire as a cross-launch join must read `durable` and refuse a `false`."* A host-supplied key or a
|
|
127
132
|
* config with no `storage` still fires synchronously — there is nothing to read.
|
|
133
|
+
*
|
|
134
|
+
* AND THE WAIT IS CAPPED. `hydrateDeviceIdentity` awaits a BARE `storage.getItem`, so an adapter
|
|
135
|
+
* that HANGS (neither resolves nor rejects) leaves that promise pending forever — and since the
|
|
136
|
+
* only fire path in this hook lives inside the `.then`, `app.session_started` then never fires at
|
|
137
|
+
* all, for the whole process, on every launch. That is the event the server counts `min_sessions`
|
|
138
|
+
* from, so the counter stays 0 and every server-side trigger becomes unsatisfiable, silently. The
|
|
139
|
+
* read is raced against the kit's shared storage ceiling (`session/persistedSession`'s exported
|
|
140
|
+
* `withTimeout` + `READ_TIMEOUT_MS`, the same one `<WireOnboarding>`'s auto-join gate names), and
|
|
141
|
+
* blowing it fires with NO auto key — exactly the branch a rejecting adapter already takes.
|
|
128
142
|
*/
|
|
129
143
|
const openAutoDeviceKey = (fireOpen: (autoDeviceKey: string | undefined) => void): void => {
|
|
130
144
|
const { config: cfg, options: opts } = latest.current;
|
|
@@ -134,8 +148,14 @@ export const useSessionStart = (
|
|
|
134
148
|
fireOpen(undefined);
|
|
135
149
|
return;
|
|
136
150
|
}
|
|
137
|
-
void
|
|
151
|
+
void withTimeout(
|
|
152
|
+
hydrateDeviceIdentity({ appId: cfg.appId, storage: cfg.storage }),
|
|
153
|
+
READ_TIMEOUT_MS,
|
|
154
|
+
).then((read) => {
|
|
138
155
|
if (cancelled) return;
|
|
156
|
+
// READ_TIMED_OUT = the adapter never answered in time. Same branch as a non-durable record:
|
|
157
|
+
// fire with no auto key, so the app-open is still counted.
|
|
158
|
+
const identity = read === READ_TIMED_OUT ? undefined : read;
|
|
139
159
|
fireOpen(identity?.durable ? identity.value : undefined);
|
|
140
160
|
});
|
|
141
161
|
};
|
|
@@ -9,7 +9,8 @@ import { type ImageSourcePropType, StyleSheet, View } from "react-native";
|
|
|
9
9
|
|
|
10
10
|
import { GestureHint } from "../coachmarks/GestureHint";
|
|
11
11
|
import { hasSeenGate, markSeenGate, showcaseGateKey } from "../coachmarks/runtime";
|
|
12
|
-
import {
|
|
12
|
+
import { useResolvedFeaturesState } from "../features/WireFeaturesProvider";
|
|
13
|
+
import { READ_TIMEOUT_MS } from "../session/persistedSession";
|
|
13
14
|
import { useOnboardingTheme } from "../theme/ThemeContext";
|
|
14
15
|
import type { OnboardingTheme } from "../theme/types";
|
|
15
16
|
import { resolveShowcaseOnboarding } from "./blazejOnboarding";
|
|
@@ -49,6 +50,11 @@ const mergeThemeOver = (
|
|
|
49
50
|
* When the gate says "seen", it renders nothing and calls `onDone` from an
|
|
50
51
|
* effect (never during render).
|
|
51
52
|
*
|
|
53
|
+
* When the tenant's feature answer has not landed yet AND the flags could still change the
|
|
54
|
+
* outcome (an unseen gate + a resolvable pager), it renders nothing and calls nothing for up to
|
|
55
|
+
* `READ_TIMEOUT_MS`, then falls through to the optimistic path. That window is bounded, and only
|
|
56
|
+
* ever reached on the launch the showcase would actually play; see the hold note below.
|
|
57
|
+
*
|
|
52
58
|
* The underlying package is an OPTIONAL peer, resolved through the guarded lazy
|
|
53
59
|
* require in `blazejOnboarding.ts` rather than a static import, so the showcase
|
|
54
60
|
* subpath builds on a host that never installed it. Absent, this takes the SAME
|
|
@@ -76,7 +82,11 @@ const _FeatureShowcase: React.FC<FeatureShowcaseProps> = ({
|
|
|
76
82
|
|
|
77
83
|
// Feature kill switch: disabled → treat like "already seen" (render nothing, call onDone so the
|
|
78
84
|
// host flow continues) but WITHOUT writing the seen gate, so re-enabling replays the showcase.
|
|
79
|
-
|
|
85
|
+
// `settled` separates the tenant's ANSWER from the optimistic all-on default; see the hold below.
|
|
86
|
+
const { flags, settled } = useResolvedFeaturesState({
|
|
87
|
+
flags: features,
|
|
88
|
+
config: featuresConfig,
|
|
89
|
+
});
|
|
80
90
|
const disabled = !flags.showcase.enabled;
|
|
81
91
|
|
|
82
92
|
// The optional pager peer. Resolved once, on first render, instead of at module scope — so
|
|
@@ -94,6 +104,37 @@ const _FeatureShowcase: React.FC<FeatureShowcaseProps> = ({
|
|
|
94
104
|
// the feature is re-enabled or the peer is installed.
|
|
95
105
|
const skip = seen || disabled || !Onboarding;
|
|
96
106
|
|
|
107
|
+
/**
|
|
108
|
+
* THE FIRST-FRAME HOLD, and why it is scoped this tightly.
|
|
109
|
+
*
|
|
110
|
+
* The flags start all-on and swap when `GET /v1/features` resolves, so on a cold start `disabled`
|
|
111
|
+
* reads false because nobody has asked yet. A tenant who switched the showcase OFF therefore got
|
|
112
|
+
* the whole full-screen intro anyway — and if the user tapped through it inside that window,
|
|
113
|
+
* `finish` wrote the seen gate, so the showcase never replayed even after the tenant re-enabled
|
|
114
|
+
* it. Silent, permanent, once per install.
|
|
115
|
+
*
|
|
116
|
+
* The seam the review + questionnaire gates use — hold the first render outright until the
|
|
117
|
+
* flags settle — does not port unchanged: this component's disabled path calls `onDone`, so a
|
|
118
|
+
* naive hold on `!settled` would leave a blank full screen, and on every launch, including the
|
|
119
|
+
* ones where the flags cannot change the outcome at all.
|
|
120
|
+
* Two things bound it:
|
|
121
|
+
* • `flagsCouldMatter` — an already-seen gate or an absent pager skips whatever the tenant
|
|
122
|
+
* answers, so those launches (which is every launch after the first) never wait at all;
|
|
123
|
+
* • `READ_TIMEOUT_MS` — the repo's one exported read ceiling, reused rather than a third
|
|
124
|
+
* number invented here. The features fetch's own ceiling is 4000ms, which is too long to
|
|
125
|
+
* stare at nothing, so an answer that has not arrived by then falls through to the previous
|
|
126
|
+
* optimistic behaviour. Fail-open in the time dimension: the worst case is what shipped
|
|
127
|
+
* before, reached later.
|
|
128
|
+
*/
|
|
129
|
+
const flagsCouldMatter = !seen && !!Onboarding;
|
|
130
|
+
const [holdExpired, setHoldExpired] = useState(false);
|
|
131
|
+
useEffect(() => {
|
|
132
|
+
if (settled || holdExpired || !flagsCouldMatter) return undefined;
|
|
133
|
+
const timer = setTimeout(() => setHoldExpired(true), READ_TIMEOUT_MS);
|
|
134
|
+
return () => clearTimeout(timer);
|
|
135
|
+
}, [settled, holdExpired, flagsCouldMatter]);
|
|
136
|
+
const holding = flagsCouldMatter && !settled && !holdExpired;
|
|
137
|
+
|
|
97
138
|
const doneRef = useRef(false);
|
|
98
139
|
const finish = useCallback(() => {
|
|
99
140
|
if (doneRef.current) return;
|
|
@@ -105,11 +146,14 @@ const _FeatureShowcase: React.FC<FeatureShowcaseProps> = ({
|
|
|
105
146
|
// Already seen OR disabled → skip without rendering; call onDone from an effect so the host's
|
|
106
147
|
// navigation continues (never write the gate here — `finish` owns the seen write).
|
|
107
148
|
useEffect(() => {
|
|
149
|
+
// Never advance the host while holding: `onDone` is the host's navigation, and a skip decided
|
|
150
|
+
// on the optimistic default is the same guess the render path refuses below.
|
|
151
|
+
if (holding) return;
|
|
108
152
|
if (skip && !doneRef.current) {
|
|
109
153
|
doneRef.current = true;
|
|
110
154
|
onDone();
|
|
111
155
|
}
|
|
112
|
-
}, [skip, onDone]);
|
|
156
|
+
}, [holding, skip, onDone]);
|
|
113
157
|
|
|
114
158
|
const [activeIndex, setActiveIndex] = useState(0);
|
|
115
159
|
|
|
@@ -191,6 +235,9 @@ const _FeatureShowcase: React.FC<FeatureShowcaseProps> = ({
|
|
|
191
235
|
[t],
|
|
192
236
|
);
|
|
193
237
|
|
|
238
|
+
// Holding for the tenant's answer: render nothing AND advance nothing (the effect above is
|
|
239
|
+
// gated too), so a disabled tenant never gets the first frame and never burns the seen gate.
|
|
240
|
+
if (holding) return null;
|
|
194
241
|
// `!Onboarding` is already folded into `skip`; it is repeated here so the narrowing is explicit
|
|
195
242
|
// to the reader and to the compiler at the JSX below.
|
|
196
243
|
if (skip || !Onboarding) return null;
|
package/src/types.ts
CHANGED
|
@@ -240,10 +240,11 @@ export type WireOnboardingProps = {
|
|
|
240
240
|
* Build the value with the helper so the wire spelling is decided in one place:
|
|
241
241
|
*
|
|
242
242
|
* ```tsx
|
|
243
|
-
* <WireOnboarding userContext={activationJoinContext(deviceKey)} ... />
|
|
244
|
-
* // no device id of your own? pass `storage`
|
|
245
|
-
* //
|
|
246
|
-
*
|
|
243
|
+
* <WireOnboarding config={config} userContext={activationJoinContext(deviceKey)} ... />
|
|
244
|
+
* // no device id of your own? pass the `storage` PROP (it is a prop of this component, never a
|
|
245
|
+
* // field of `WireOnboardingConfig`) and leave this prop alone — the kit injects its own key,
|
|
246
|
+
* // and only after it has confirmed the key actually persists (see `autoJoinKey`).
|
|
247
|
+
* <WireOnboarding config={config} storage={storage} ... />
|
|
247
248
|
* ```
|
|
248
249
|
*
|
|
249
250
|
* ⛔ Do NOT hand-build the auto key with `activationJoinContext(resolveAutoDeviceKey({...}))`.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* withDeadline — put a CEILING on an awaited network exchange, body read included.
|
|
3
|
+
*
|
|
4
|
+
* ── THE DEFECT CLASS THIS CLOSES ───────────────────────────────────────────────────────────────
|
|
5
|
+
* The kit's awaited `fetch` calls used to split into two groups. `eventQueue.postBatch` and
|
|
6
|
+
* `wireDoctor` each carried their own `AbortController` + 15s / 10s timer; the review and
|
|
7
|
+
* questionnaire transports and `reportClientEventsOutcome` carried nothing at all. A hung request on
|
|
8
|
+
* one of those has no ceiling but the PLATFORM's, which on iOS is ~60s — and for that whole window
|
|
9
|
+
* `ReviewGate` / `QuestionnaireGate`'s `postOnce` latch stays closed (so the unmount recovery net
|
|
10
|
+
* cannot re-post) and a host's `await wire.track()` stays pending. It is bounded, so it is not the
|
|
11
|
+
* hang; it is the thing that WIDENS the in-flight window every other fix in this area narrows.
|
|
12
|
+
*
|
|
13
|
+
* ── WHY TWO MECHANISMS, NOT JUST AN ABORT ──────────────────────────────────────────────────────
|
|
14
|
+
* Lifted verbatim in shape from `features/fetchWireFeatures.ts`, which learned it the expensive way:
|
|
15
|
+
* a timer cleared around the `fetch` alone dies the moment the HEADERS land, so `await res.json()`
|
|
16
|
+
* then runs with no ceiling at all and a proxy that flushes headers and hangs pends forever. So the
|
|
17
|
+
* exchange callback owns the body read too, and there are two independent guards:
|
|
18
|
+
*
|
|
19
|
+
* • the ABORT tears the socket down, so a stalled stream is cancelled and not merely un-awaited;
|
|
20
|
+
* • the RACE guarantees this promise SETTLES even on a runtime that ignores `signal` once the body
|
|
21
|
+
* has started (RN's `fetch` is the XHR polyfill; abort support is not something to bet a latch on).
|
|
22
|
+
*
|
|
23
|
+
* A ceiling that depends on the host honouring abort is not a ceiling.
|
|
24
|
+
*
|
|
25
|
+
* INTERNAL SEAM — deliberately not on the public barrels, like `utils/warnInDev` and
|
|
26
|
+
* `analytics/currentSession`'s `isMintedSessionId`. It is dependency-free and imports nothing, so it
|
|
27
|
+
* is safe for the tree-shaken `./analytics` graph (`analytics/treeShake.test.ts`).
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* "The deadline passed before the exchange produced anything." A `unique symbol`, so it can never
|
|
32
|
+
* collide with a value the exchange itself resolved — `null`, `undefined` and `false` are all
|
|
33
|
+
* legitimate answers at these call sites, which is exactly why the sentinel cannot be one of them.
|
|
34
|
+
*/
|
|
35
|
+
export const DEADLINE_EXPIRED: unique symbol = Symbol("wire-deadline-expired");
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The one ceiling for the kit's awaited transports. 15s, matching `eventQueue.postBatch`'s existing
|
|
39
|
+
* ceiling on an awaited POST — the precedent in this repo for "long enough that a slow but real
|
|
40
|
+
* network still lands, short enough that nothing waits on the ~60s platform default".
|
|
41
|
+
*/
|
|
42
|
+
export const DEFAULT_DEADLINE_MS = 15_000;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Run `exchange` under `timeoutMs`, handing it the abort signal to pass to `fetch`. Resolves what
|
|
46
|
+
* the exchange resolved, or {@link DEADLINE_EXPIRED} if the deadline won. REJECTS with whatever the
|
|
47
|
+
* exchange threw (an abort included), so every existing `try/catch` at a call site keeps its meaning.
|
|
48
|
+
*/
|
|
49
|
+
export const withDeadline = async <T>(
|
|
50
|
+
exchange: (signal: AbortSignal | undefined) => Promise<T>,
|
|
51
|
+
timeoutMs: number = DEFAULT_DEADLINE_MS,
|
|
52
|
+
): Promise<T | typeof DEADLINE_EXPIRED> => {
|
|
53
|
+
const controller = typeof AbortController !== "undefined" ? new AbortController() : undefined;
|
|
54
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
55
|
+
const expired = new Promise<typeof DEADLINE_EXPIRED>((resolve) => {
|
|
56
|
+
timer = setTimeout(() => {
|
|
57
|
+
controller?.abort();
|
|
58
|
+
resolve(DEADLINE_EXPIRED);
|
|
59
|
+
}, timeoutMs);
|
|
60
|
+
});
|
|
61
|
+
const running = exchange(controller?.signal);
|
|
62
|
+
try {
|
|
63
|
+
return await Promise.race([running, expired]);
|
|
64
|
+
} finally {
|
|
65
|
+
clearTimeout(timer);
|
|
66
|
+
// The loser can still settle after the race is decided with nothing awaiting it, and an
|
|
67
|
+
// unobserved rejection is a hard crash on some hosts. Attach a handler either way.
|
|
68
|
+
void running.catch(() => {});
|
|
69
|
+
}
|
|
70
|
+
};
|