@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
|
@@ -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({...}))`.
|
|
@@ -256,8 +257,11 @@ export type WireOnboardingProps = {
|
|
|
256
257
|
*
|
|
257
258
|
* SINCE 0.12.2, leaving it out no longer silently empties the funnel: when you pass `storage` and
|
|
258
259
|
* this prop carries no `device_key`, the kit injects its OWN per-install key — the same one the
|
|
259
|
-
* analytics surfaces mint and persist — so the default wiring joins.
|
|
260
|
-
* verbatim and is never touched
|
|
260
|
+
* analytics surfaces mint and persist — so the default wiring joins. A `device_key` you DO pass
|
|
261
|
+
* wins verbatim and is never touched, provided it is a string with at least one non-whitespace
|
|
262
|
+
* character (`resolveIdentity`) — this map also accepts numbers and booleans, and a non-string or
|
|
263
|
+
* all-whitespace value is not a usable join key, so it is read as absent and the auto key is
|
|
264
|
+
* merged over it. See `autoJoinKey` for the opt-out and the two cases where the kit
|
|
261
265
|
* still cannot fill the gap (no `storage`, or you opted out), which keep warning in dev.
|
|
262
266
|
*
|
|
263
267
|
* Never hand-write `userContext={{ deviceKey }}`: the server's device lookup reads `device_key`,
|
|
@@ -284,8 +288,11 @@ export type WireOnboardingProps = {
|
|
|
284
288
|
* `autoJoinKey={false}` if you genuinely want an UNLINKED onboarding session; that restores the
|
|
285
289
|
* pre-0.12.2 behavior exactly (nothing injected) and the dev warning fires again.
|
|
286
290
|
*
|
|
287
|
-
* Two things this flag does NOT do. It never overrides a `device_key` you
|
|
288
|
-
* key always wins, whatever this is set to
|
|
291
|
+
* Two things this flag does NOT do. It never overrides a `device_key` you actually supplied — a
|
|
292
|
+
* host-supplied key always wins, whatever this is set to, where "supplied" means a string with at
|
|
293
|
+
* least one non-whitespace character (`resolveIdentity`); a non-string or an all-whitespace value
|
|
294
|
+
* is read as absent and the auto key is merged over it. And it cannot conjure a key without
|
|
295
|
+
* `storage`: with no
|
|
289
296
|
* persistence the kit's id is minted fresh every launch, and a per-launch key corrupts
|
|
290
297
|
* `min_sessions` instead of merely leaving the join empty, so the kit declines to inject and warns
|
|
291
298
|
* in dev instead.
|
|
@@ -21,19 +21,27 @@
|
|
|
21
21
|
*
|
|
22
22
|
* `submitReview` was a boolean for exactly one unpublished release, and the boolean WAS the bug.
|
|
23
23
|
* `false` meant both "nothing reached the server" and "the server answered non-2xx", and the caller
|
|
24
|
-
* treats `false` as "still owed" and re-posts.
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
24
|
+
* treats `false` as "still owed" and re-posts. At the time neither body carried a client id, so
|
|
25
|
+
* there was NO idempotency key on the wire on either surface: a 502 returned AFTER the insert
|
|
26
|
+
* commits meant the re-post wrote a SECOND row. A response of any status proves the server was
|
|
27
|
+
* reached, and that is a different question from whether it liked the payload — which is why the
|
|
28
|
+
* three values stay, on both surfaces, whatever the wire gains.
|
|
29
29
|
*
|
|
30
30
|
* ── THE RESIDUAL, STATED HONESTLY ─────────────────────────────────────────────────────────────
|
|
31
31
|
*
|
|
32
32
|
* `unsent` is not proof the server never got it. A connection dropped after the request was written
|
|
33
33
|
* — or after the row committed — surfaces as a thrown/rejected `fetch`, exactly like an offline
|
|
34
|
-
* device.
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
34
|
+
* device. Closing that window needs a client-minted idempotency key the server upserts on, and AS
|
|
35
|
+
* OF 2026-08-19 THE TWO SURFACES DIFFER — do not read this paragraph as one fact about both:
|
|
36
|
+
*
|
|
37
|
+
* • REVIEWS — closed. `CreateReviewRequest` takes an optional `idempotency_key` and the server
|
|
38
|
+
* upserts a derived row on it, so a `ReviewGate` re-post merges instead of duplicating. It is a
|
|
39
|
+
* capability, not a guarantee: the server honours the key only when the post also carries a
|
|
40
|
+
* unit to scope it to (`meta.device_key`, else `session_id`), so a caller that strips both is
|
|
41
|
+
* back to the old behaviour.
|
|
42
|
+
* • QUESTIONNAIRES — still open, and still exactly as this paragraph used to read for both. The
|
|
43
|
+
* response body carries no client id and the server has no idempotency path for it at all, so
|
|
44
|
+
* retrying only on `unsent` there is SAFER, not SAFE. Prefer losing a row over inventing one: a
|
|
45
|
+
* lost detractor is a gap in the data, a duplicated one is a lie in the data.
|
|
38
46
|
*/
|
|
39
47
|
export type SubmitResult = "accepted" | "rejected" | "unsent";
|
|
@@ -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
|
+
};
|