@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
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
* Fire-and-forget: this never throws into the UI and never awaits — analytics must never
|
|
25
25
|
* be able to break onboarding.
|
|
26
26
|
*/
|
|
27
|
+
import { DEADLINE_EXPIRED, withDeadline } from "../utils/withDeadline";
|
|
27
28
|
import type { DeviceContext } from "../device/deviceContext";
|
|
28
29
|
|
|
29
30
|
/** Event types a CLIENT may report. The rest of the funnel is server-side; sending those
|
|
@@ -368,16 +369,24 @@ export const reportClientEventsOutcome = async (
|
|
|
368
369
|
const req = buildEventsRequest(target, events);
|
|
369
370
|
// No target / nothing serializable: there is no endpoint to retry against.
|
|
370
371
|
if (!req) return "refused";
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
//
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
372
|
+
// Under a deadline, BODY READ INCLUDED (see `utils/withDeadline`): this is the awaited path a
|
|
373
|
+
// host's `await wire.track()` sits on, and without a ceiling a hung POST parks it for the ~60s
|
|
374
|
+
// platform default. A ceiling cleared when the headers land would leave `readEventsAck` — which
|
|
375
|
+
// reads the body — uncapped, which is the `fetchWireFeatures` defect in a second place.
|
|
376
|
+
const outcome = await withDeadline(async (signal): Promise<SendOutcome> => {
|
|
377
|
+
const res = await fetch(req.url, { ...req.init, signal });
|
|
378
|
+
if (!res || !res.ok) return "unreachable";
|
|
379
|
+
// ONE body read, used for both the verdict and the dev warning — it can only be read once.
|
|
380
|
+
const ack = await readEventsAck(res);
|
|
381
|
+
if (!ack || ack.skipped <= 0) return "delivered";
|
|
382
|
+
if (typeof __DEV__ !== "undefined" && __DEV__ && typeof console !== "undefined" && console.warn) {
|
|
383
|
+
console.warn(describeDiscarded(ack));
|
|
384
|
+
}
|
|
385
|
+
// The server answered and declined THIS event. A retry is a re-decline.
|
|
386
|
+
return "refused";
|
|
387
|
+
});
|
|
388
|
+
// A deadline that expired means nothing came back — retryable, exactly like a network error.
|
|
389
|
+
return outcome === DEADLINE_EXPIRED ? "unreachable" : outcome;
|
|
381
390
|
} catch {
|
|
382
391
|
// Unreachable / missing-fetch / network — best-effort, and retryable.
|
|
383
392
|
return "unreachable";
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* useAnalytics — a THIN optional React hook over the pure {@link createAnalytics} factory.
|
|
3
3
|
*
|
|
4
|
-
* It builds ONE analytics instance for the component's lifetime and returns
|
|
5
|
-
* never rebuild the queue or lose the in-memory user binding. React is a REQUIRED peer of the
|
|
4
|
+
* It builds ONE analytics instance for the component's lifetime and returns a STABLE FACADE over
|
|
5
|
+
* it (see below), so re-renders never rebuild the queue or lose the in-memory user binding. React is a REQUIRED peer of the
|
|
6
6
|
* kit, so importing it here is allowed; the hook adds NO other dependency. Mirrors the existing
|
|
7
7
|
* `createScreenTracker` / `useScreenTracking` split — the factory stays React-free, this is the
|
|
8
8
|
* glue.
|
|
@@ -25,6 +25,45 @@ import {
|
|
|
25
25
|
* instance is stable for the component's lifetime, held in a ref). Returns the {@link Analytics}
|
|
26
26
|
* surface so the component can `track` / `screen` / `identify` and drive `notifyOnline` on reconnect.
|
|
27
27
|
*/
|
|
28
|
+
/**
|
|
29
|
+
* The STABLE object the host holds, delegating to whatever instance is live AT CALL TIME.
|
|
30
|
+
*
|
|
31
|
+
* ── WHY THE HOOK CANNOT JUST RETURN THE INSTANCE ─────────────────────────────────────────────────
|
|
32
|
+
* React's StrictMode dev remount runs a mount effect **setup → cleanup → setup with no intervening
|
|
33
|
+
* render**. The cleanup below disposes the instance and nulls the ref; the component is still
|
|
34
|
+
* mounted, and no render happens to rebuild it. So the object the host was handed at render is a
|
|
35
|
+
* DISPOSED one: `EventQueue.enqueue` returns early when disposed, which makes every `track()` on it
|
|
36
|
+
* a silent no-op — no error, no warning, `size()` stays 0 — until the host's next render. Dev builds
|
|
37
|
+
* only (production `<StrictMode>` is a passthrough), but dev is where a developer verifies that
|
|
38
|
+
* their analytics work.
|
|
39
|
+
*
|
|
40
|
+
* ── WHY THIS SHAPE AND NOT THE TWO OBVIOUS ALTERNATIVES ──────────────────────────────────────────
|
|
41
|
+
* • Stop nulling the ref in the cleanup → the render guard reads `!ref.current`, so a real
|
|
42
|
+
* credentials change would never rebuild. That trades a dev-only silence for a production one.
|
|
43
|
+
* • A `disposedRef` the render guard reads → the defining property of this window is that NO RENDER
|
|
44
|
+
* HAPPENS in it. Any repair spelled at render time re-closes the same hole one line lower.
|
|
45
|
+
* What has to keep working is the reference the host is ALREADY HOLDING, and only a stable
|
|
46
|
+
* indirection can do that. It also fixes the ordinary credentials-swap case for free: a host that
|
|
47
|
+
* captured the instance in a callback used to hold the pre-swap queue.
|
|
48
|
+
*
|
|
49
|
+
* ⚠️ A facade is safe only while it is COMPLETE. `Analytics` growing a member this forgets would be
|
|
50
|
+
* `undefined` on the object every host holds — a TypeError with a green typecheck, since the facade
|
|
51
|
+
* is written against the very declaration that changed. `test/canary/strictModeInstanceHandoff`
|
|
52
|
+
* compares the runtime key sets against `createAnalytics` and goes red the day they drift.
|
|
53
|
+
*/
|
|
54
|
+
const stableAnalyticsFacade = (live: () => Analytics | undefined): Analytics => ({
|
|
55
|
+
track: (event, props) => live()?.track(event, props),
|
|
56
|
+
screen: (name, props) => live()?.screen(name, props),
|
|
57
|
+
identify: (userId, traits) => live()?.identify(userId, traits),
|
|
58
|
+
setUserContext: (partial) => live()?.setUserContext(partial),
|
|
59
|
+
reset: () => live()?.reset(),
|
|
60
|
+
flush: () => live()?.flush(),
|
|
61
|
+
notifyOnline: () => live()?.notifyOnline(),
|
|
62
|
+
// After a real unmount there is no instance and nothing pending on it — 0 is the honest answer.
|
|
63
|
+
size: () => live()?.size() ?? 0,
|
|
64
|
+
dispose: () => live()?.dispose(),
|
|
65
|
+
});
|
|
66
|
+
|
|
28
67
|
export const useAnalytics = (
|
|
29
68
|
config: CreateAnalyticsConfig,
|
|
30
69
|
options: AnalyticsOptions = {},
|
|
@@ -32,6 +71,10 @@ export const useAnalytics = (
|
|
|
32
71
|
// One instance per mount; kept in a ref so re-renders never rebuild the queue or drop the binding.
|
|
33
72
|
const ref = useRef<Analytics | undefined>(undefined);
|
|
34
73
|
const prevKeys = useRef<string>("");
|
|
74
|
+
// The construction inputs, read fresh, so the rebuild below uses the CURRENT config rather than
|
|
75
|
+
// the first render's closure.
|
|
76
|
+
const inputs = useRef({ config, options });
|
|
77
|
+
inputs.current = { config, options };
|
|
35
78
|
|
|
36
79
|
const currentKeys = `${config.serverUrl}|${config.apiKey}|${config.appId}`;
|
|
37
80
|
if (!ref.current || prevKeys.current !== currentKeys) {
|
|
@@ -44,6 +87,34 @@ export const useAnalytics = (
|
|
|
44
87
|
ref.current = createAnalytics(config, options);
|
|
45
88
|
}
|
|
46
89
|
|
|
90
|
+
// Built once and never replaced: it is the whole point that the host's reference outlives every
|
|
91
|
+
// instance behind it.
|
|
92
|
+
const facade = useRef<Analytics | undefined>(undefined);
|
|
93
|
+
if (!facade.current) facade.current = stableAnalyticsFacade(() => ref.current);
|
|
94
|
+
|
|
95
|
+
// ⛔ DECLARED FIRST, AND THAT ORDER IS LOAD-BEARING. React runs every cleanup, then every setup.
|
|
96
|
+
// With the device-key effect below declared first, its second setup would run while `ref.current`
|
|
97
|
+
// was still `undefined` (this effect not having re-run yet), `?.setUserContext` would silently
|
|
98
|
+
// no-op, and the host's real device key would be dropped for the life of the mount — which is the
|
|
99
|
+
// very defect the paragraph below exists to describe.
|
|
100
|
+
//
|
|
101
|
+
// SETUP: rebuild the instance this effect's own cleanup disposed. On a first mount there is
|
|
102
|
+
// nothing to do (render just built one); after StrictMode's cleanup→setup there is, and no render
|
|
103
|
+
// is coming to do it.
|
|
104
|
+
// UNMOUNT: give the storage claim and the retry timer back. A screen that mounts this hook is
|
|
105
|
+
// built and torn down repeatedly (navigation, Fast Refresh, StrictMode's double-invoke), and each
|
|
106
|
+
// rebuild used to leave the previous queue holding the appId's slot — so every remount after the
|
|
107
|
+
// first persisted into `…#2`, `…#3`, and the next launch read none of them.
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
if (!ref.current) {
|
|
110
|
+
ref.current = createAnalytics(inputs.current.config, inputs.current.options);
|
|
111
|
+
}
|
|
112
|
+
return () => {
|
|
113
|
+
ref.current?.dispose();
|
|
114
|
+
ref.current = undefined;
|
|
115
|
+
};
|
|
116
|
+
}, []);
|
|
117
|
+
|
|
47
118
|
// A LATE-ARRIVING host device key must still reach the instance.
|
|
48
119
|
//
|
|
49
120
|
// `createAnalytics` copies `config.userContext` into a closure at construction and never re-reads
|
|
@@ -64,17 +135,5 @@ export const useAnalytics = (
|
|
|
64
135
|
if (hostDeviceKey) ref.current?.setUserContext({ deviceKey: hostDeviceKey });
|
|
65
136
|
}, [hostDeviceKey]);
|
|
66
137
|
|
|
67
|
-
|
|
68
|
-
// built and torn down repeatedly (navigation, Fast Refresh, StrictMode's double-invoke), and each
|
|
69
|
-
// rebuild used to leave the previous queue holding the appId's slot — so every remount after the
|
|
70
|
-
// first persisted into `…#2`, `…#3`, and the next launch read none of them.
|
|
71
|
-
useEffect(
|
|
72
|
-
() => () => {
|
|
73
|
-
ref.current?.dispose();
|
|
74
|
-
ref.current = undefined;
|
|
75
|
-
},
|
|
76
|
-
[],
|
|
77
|
-
);
|
|
78
|
-
|
|
79
|
-
return ref.current;
|
|
138
|
+
return facade.current;
|
|
80
139
|
};
|
|
@@ -8,13 +8,18 @@
|
|
|
8
8
|
* numeric `ts` did exactly that through 0.13.0. This turns that class of loss from something you
|
|
9
9
|
* discover in a funnel report weeks later into something the first run tells you.
|
|
10
10
|
*
|
|
11
|
-
* SHAPE:
|
|
11
|
+
* SHAPE: five independent checks, each a `{name, ok, detail}` unit that can be read (and tested)
|
|
12
12
|
* without the others. The report is data, never a thrown error and never a side effect on the host:
|
|
13
13
|
*
|
|
14
14
|
* 1. `target` : is there a server URL and a key, and do they look like a key and a URL?
|
|
15
15
|
* 2. `reachability`: is the server actually there? (`GET /v1/events/contract`, public and cheap)
|
|
16
16
|
* 3. `storage` : can the offline queue persist? (a write / read / delete probe)
|
|
17
17
|
* 4. `round_trip` : does a REAL event survive REAL server validation? (a `dry_run` POST)
|
|
18
|
+
* 5. `join_key` : will those events be JOINABLE? (read-only; needs `join`)
|
|
19
|
+
*
|
|
20
|
+
* Checks 1-4 all answer "does an event leave and get accepted". NONE of them answers "can it be
|
|
21
|
+
* joined", and an integration with no `user_context.device_key` writes every event, passes every
|
|
22
|
+
* one of those four, and still reports a permanent ZERO in the activated funnel. That is check 5.
|
|
18
23
|
*
|
|
19
24
|
* NEVER THROWS, under any input, any network condition, or any hostile response object. A doctor
|
|
20
25
|
* that can crash the screen it is diagnosing is worse than no doctor.
|
|
@@ -34,6 +39,9 @@
|
|
|
34
39
|
* const report = await wireDoctor({
|
|
35
40
|
* target: { serverUrl: "https://api.example.com", apiKey: DRIVELINE_KEY },
|
|
36
41
|
* storage: AsyncStorage,
|
|
42
|
+
* // The same values you hand <WireOnboarding>. Omit `join` and the join_key check FAILS,
|
|
43
|
+
* // because a report that never looked at the join cannot honestly read green.
|
|
44
|
+
* join: { appId: WIRE_APP_ID, userContext },
|
|
37
45
|
* });
|
|
38
46
|
* console.log(report.ok, report.checks);
|
|
39
47
|
*/
|
|
@@ -49,6 +57,12 @@ import {
|
|
|
49
57
|
type ClientEventTarget,
|
|
50
58
|
} from "./reportClientEvent";
|
|
51
59
|
import type { WireOnboardingStorage } from "../session/persistedSession";
|
|
60
|
+
// The SAME key builder the analytics side persists the auto id under, and the SAME predicate
|
|
61
|
+
// `resolveIdentity` gates on — imported rather than re-typed, so the doctor can never drift into
|
|
62
|
+
// answering about a key the kit does not actually use. `isUsableIdentityValue` is the PURE half:
|
|
63
|
+
// calling `resolveIdentity` here would write this reader into the provenance census (see below).
|
|
64
|
+
import { deviceIdStorageKey } from "../context/deviceId";
|
|
65
|
+
import { isUsableIdentityValue } from "../identity/identityRecord";
|
|
52
66
|
|
|
53
67
|
/** RN sets this global; absent under node/SSR. Read defensively, exactly as `warnOnSkippedEvents` does. */
|
|
54
68
|
declare const __DEV__: boolean | undefined;
|
|
@@ -56,7 +70,8 @@ declare const __DEV__: boolean | undefined;
|
|
|
56
70
|
/** One diagnosis. `name` is stable and machine-readable; `detail` is for a human reading a console. */
|
|
57
71
|
export type WireDoctorCheck = {
|
|
58
72
|
/**
|
|
59
|
-
* Stable id: `target` | `reachability` | `storage` | `round_trip` | `
|
|
73
|
+
* Stable id: `target` | `reachability` | `storage` | `round_trip` | `join_key` | `dev_only` |
|
|
74
|
+
* `internal_error`.
|
|
60
75
|
*
|
|
61
76
|
* `dev_only` means ONE thing and only that thing: `__DEV__` is unset or false, so nothing ran.
|
|
62
77
|
* `internal_error` is the separate catch-all for a failure that got past every check's own
|
|
@@ -75,6 +90,20 @@ export type WireDoctorReport = {
|
|
|
75
90
|
checks: WireDoctorCheck[];
|
|
76
91
|
};
|
|
77
92
|
|
|
93
|
+
/**
|
|
94
|
+
* What the join check needs: the same three values the host hands `<WireOnboarding>`. Pass the very
|
|
95
|
+
* props you pass the component — anything reconstructed here would diagnose a DIFFERENT integration
|
|
96
|
+
* than the one that ships.
|
|
97
|
+
*/
|
|
98
|
+
export type WireDoctorJoinTarget = {
|
|
99
|
+
/** The same `config.appId`. It namespaces the persisted auto key, so the wrong one reads the wrong slot. */
|
|
100
|
+
appId?: string;
|
|
101
|
+
/** The same `userContext` prop. Only `device_key` is read; nothing else is inspected or reported. */
|
|
102
|
+
userContext?: Record<string, string | number | boolean>;
|
|
103
|
+
/** The same `autoJoinKey` prop. `false` is the documented opt-out from the kit's auto-join. */
|
|
104
|
+
autoJoinKey?: boolean;
|
|
105
|
+
};
|
|
106
|
+
|
|
78
107
|
/** Input for {@link wireDoctor}. `storage` is optional: without it the queue runs in-memory only. */
|
|
79
108
|
export type WireDoctorOptions = {
|
|
80
109
|
/** The same `{serverUrl, apiKey}` the kit is configured with. */
|
|
@@ -84,6 +113,11 @@ export type WireDoctorOptions = {
|
|
|
84
113
|
* check reports the DEGRADED in-memory mode rather than failing.
|
|
85
114
|
*/
|
|
86
115
|
storage?: WireOnboardingStorage;
|
|
116
|
+
/**
|
|
117
|
+
* The join-key inputs. OMITTING THIS FAILS THE `join_key` CHECK — deliberately, and see
|
|
118
|
+
* {@link checkJoinKey} for why a skipped join check may not read as a pass.
|
|
119
|
+
*/
|
|
120
|
+
join?: WireDoctorJoinTarget;
|
|
87
121
|
};
|
|
88
122
|
|
|
89
123
|
/**
|
|
@@ -284,6 +318,110 @@ const checkRoundTrip = async (target: ClientEventTarget): Promise<WireDoctorChec
|
|
|
284
318
|
);
|
|
285
319
|
};
|
|
286
320
|
|
|
321
|
+
/**
|
|
322
|
+
* CHECK 5: will an onboarding session carry the JOIN KEY?
|
|
323
|
+
*
|
|
324
|
+
* WHY THIS IS NOT COVERED BY THE OTHER FOUR: they prove events LEAVE and are ACCEPTED. None of them
|
|
325
|
+
* proves the events can be JOINED. `user_context.device_key` is the only thing that stitches an
|
|
326
|
+
* onboarding session to everything the app reports later, so without it every event is written,
|
|
327
|
+
* every check is green, and the `activated` funnel still reads a permanent ZERO. Two real consumers
|
|
328
|
+
* shipped exactly that. A doctor that passes an integration whose funnel can never be non-zero is
|
|
329
|
+
* reporting on the wrong question.
|
|
330
|
+
*
|
|
331
|
+
* ⛔ STRICTLY READ-ONLY, and that constraint shapes the whole check. It would be far easier to call
|
|
332
|
+
* `hydrateDeviceIdentity` and read `.durable` off the result — but that MINTS, registers on the
|
|
333
|
+
* process-wide registry, and persists. Two things forbid it: this module's own contract ("mints no
|
|
334
|
+
* `globalThis` slot", "never a side effect on the host"), and the 0.15.1 defect where a propless
|
|
335
|
+
* READER wrote into the census it read, so the host's real `appId` registering afterwards made the
|
|
336
|
+
* census read two and the reader answered `undefined` for the rest of the process. A diagnostic that
|
|
337
|
+
* causes the defect class it diagnoses is worse than no diagnostic. So this check only ever READS:
|
|
338
|
+
* one `getItem`, plus pure predicates. Same reason it uses {@link isUsableIdentityValue} and not
|
|
339
|
+
* `resolveIdentity` — the latter WRITES the provenance registry for a `host`-sourced value.
|
|
340
|
+
*
|
|
341
|
+
* The verdicts mirror `WireOnboarding`'s own three auto-join conditions, in its order.
|
|
342
|
+
*/
|
|
343
|
+
const checkJoinKey = async (
|
|
344
|
+
join: WireDoctorJoinTarget | undefined,
|
|
345
|
+
storage: WireOnboardingStorage | undefined,
|
|
346
|
+
storageOk: boolean,
|
|
347
|
+
): Promise<WireDoctorCheck> => {
|
|
348
|
+
// NOT a pass. The whole module already rules that a check which never ran may not report healthy
|
|
349
|
+
// (that is why `dev_only` is `ok: false`), and this is the same shape: a green report that never
|
|
350
|
+
// looked at the join is precisely the outcome this check exists to remove.
|
|
351
|
+
if (!join) {
|
|
352
|
+
return check(
|
|
353
|
+
"join_key",
|
|
354
|
+
false,
|
|
355
|
+
"NOT EVALUATED: pass `join: { appId, userContext, autoJoinKey }` — the same values you pass " +
|
|
356
|
+
"<WireOnboarding> — so the doctor can tell whether this integration's sessions will be joinable. " +
|
|
357
|
+
"It is reported as a failure rather than skipped because every other check can pass while the " +
|
|
358
|
+
"funnel reads a permanent zero.",
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
// A host-supplied key ALWAYS wins and is never touched by the kit, so nothing else matters.
|
|
362
|
+
// ⛔ The key's VALUE is never reported, the same discipline the target check applies to the apiKey.
|
|
363
|
+
if (isUsableIdentityValue(join.userContext?.device_key)) {
|
|
364
|
+
return check(
|
|
365
|
+
"join_key",
|
|
366
|
+
true,
|
|
367
|
+
"user_context.device_key is supplied by the host, so onboarding sessions join the rest of the funnel.",
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
if (join.autoJoinKey === false) {
|
|
371
|
+
return check(
|
|
372
|
+
"join_key",
|
|
373
|
+
false,
|
|
374
|
+
"no user_context.device_key AND autoJoinKey is false, so these onboarding sessions are UNLINKED " +
|
|
375
|
+
"by choice: they join nothing the app reports later and the activated funnel reads zero. " +
|
|
376
|
+
"Supply user_context.device_key, or drop autoJoinKey:false to let the kit inject its own.",
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
if (!storage) {
|
|
380
|
+
return check(
|
|
381
|
+
"join_key",
|
|
382
|
+
false,
|
|
383
|
+
"no user_context.device_key, and the kit cannot auto-inject one without storage: an unpersisted key " +
|
|
384
|
+
"differs every launch, which corrupts the server's session counting rather than just leaving it " +
|
|
385
|
+
"empty, so the kit declines it. Pass AsyncStorage (or an MMKV wrapper) to <WireOnboarding>.",
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
if (!storageOk) {
|
|
389
|
+
return check(
|
|
390
|
+
"join_key",
|
|
391
|
+
false,
|
|
392
|
+
"no user_context.device_key, and the storage check above FAILED — the kit refuses a non-durable auto " +
|
|
393
|
+
"key, so it will inject nothing and these sessions will not join. Fix the storage adapter first.",
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
let persisted: string | null = null;
|
|
397
|
+
try {
|
|
398
|
+
persisted = await storage.getItem(deviceIdStorageKey(join.appId));
|
|
399
|
+
} catch {
|
|
400
|
+
return check(
|
|
401
|
+
"join_key",
|
|
402
|
+
false,
|
|
403
|
+
"no user_context.device_key, and reading the persisted auto key threw, so it is unknown whether the " +
|
|
404
|
+
"kit can supply one. Treat this as the storage adapter being unreliable.",
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
if (isUsableIdentityValue(persisted)) {
|
|
408
|
+
return check(
|
|
409
|
+
"join_key",
|
|
410
|
+
true,
|
|
411
|
+
"no host device_key, but a durable auto join key is already persisted for this appId, so sessions join.",
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
// First run on this install: nothing persisted YET. That is the normal cold start, not a fault —
|
|
415
|
+
// the kit mints and persists on first mount, and the storage check above already proved a real
|
|
416
|
+
// write/read round trip, which is the condition the kit gates that injection on.
|
|
417
|
+
return check(
|
|
418
|
+
"join_key",
|
|
419
|
+
true,
|
|
420
|
+
"no host device_key and none persisted yet (first run): the kit will mint and persist its own on first " +
|
|
421
|
+
"mount, and the storage check above proved the write/read round trip that injection is gated on.",
|
|
422
|
+
);
|
|
423
|
+
};
|
|
424
|
+
|
|
287
425
|
/**
|
|
288
426
|
* Run the full diagnosis. Resolves a report; NEVER throws and NEVER rejects.
|
|
289
427
|
*
|
|
@@ -307,16 +445,23 @@ export const wireDoctor = async (options: WireDoctorOptions): Promise<WireDoctor
|
|
|
307
445
|
}
|
|
308
446
|
try {
|
|
309
447
|
const target = options?.target;
|
|
310
|
-
const
|
|
448
|
+
const targetCheck = checkTarget(target);
|
|
449
|
+
const checks: WireDoctorCheck[] = [targetCheck];
|
|
311
450
|
// Reachability and the round trip both need a usable target; running them against a broken one
|
|
312
451
|
// would report a network failure and bury the real cause, which check 1 already named.
|
|
313
|
-
|
|
452
|
+
const targetUsable = targetCheck.ok && !!target;
|
|
453
|
+
if (targetUsable && target) {
|
|
314
454
|
checks.push(await checkReachability(target.serverUrl));
|
|
315
|
-
|
|
455
|
+
}
|
|
456
|
+
const storageCheck = await checkStorage(options?.storage);
|
|
457
|
+
checks.push(storageCheck);
|
|
458
|
+
if (targetUsable && target) {
|
|
316
459
|
checks.push(await checkRoundTrip(target));
|
|
317
|
-
} else {
|
|
318
|
-
checks.push(await checkStorage(options?.storage));
|
|
319
460
|
}
|
|
461
|
+
// Last, and it runs in BOTH branches: the join question is independent of the target entirely.
|
|
462
|
+
// An integration can have a perfect server and a permanently unjoinable funnel, which is the
|
|
463
|
+
// whole reason this check exists — so a broken target must not hide it.
|
|
464
|
+
checks.push(await checkJoinKey(options?.join, options?.storage, storageCheck.ok));
|
|
320
465
|
return { ok: checks.every((c) => c.ok), checks };
|
|
321
466
|
} catch {
|
|
322
467
|
// Belt and braces: every check above already swallows its own failures, so reaching here means
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import React, { useEffect } from "react";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { useResolvedFeaturesState } from "../features/WireFeaturesProvider";
|
|
4
4
|
import type { WireFeatures, WireFeaturesConfig } from "../features/types";
|
|
5
5
|
import { CoachmarkOverlayHost } from "./CoachmarkOverlayHost";
|
|
6
6
|
import {
|
|
7
7
|
getCoachmarkStorage,
|
|
8
8
|
setCoachmarkStorage,
|
|
9
9
|
setCoachmarksEnabled,
|
|
10
|
+
setCoachmarksResolved,
|
|
10
11
|
setCoachmarkTesting,
|
|
11
12
|
} from "./runtime";
|
|
12
13
|
import type { CoachmarkStorage } from "./types";
|
|
@@ -36,6 +37,11 @@ export interface CoachmarkProviderProps {
|
|
|
36
37
|
* pre-resolved `features`, OR a `featuresConfig` (serverUrl + apiKey) to lazily fetch once, OR
|
|
37
38
|
* mount a `WireFeaturesProvider` above (context is read automatically). Omit all three and
|
|
38
39
|
* coachmarks stay on (fail-open) — zero behavior change for hosts that never adopt flags.
|
|
40
|
+
*
|
|
41
|
+
* While a fetch is still in flight, a NEW tour does not arm (so a tenant who switched the module
|
|
42
|
+
* off gets no first impression), and a tour already in flight is never torn down. Nothing is
|
|
43
|
+
* spent by that wait: no once-gate is consumed, so an ON answer plays the tour in full. A failed
|
|
44
|
+
* fetch is an answer — fail-open is unchanged.
|
|
39
45
|
*/
|
|
40
46
|
features?: WireFeatures;
|
|
41
47
|
/** Lazy-fetch config for the flags, used when `features` is absent and no provider is above. */
|
|
@@ -69,17 +75,30 @@ export const CoachmarkProvider: React.FC<CoachmarkProviderProps> = ({
|
|
|
69
75
|
children,
|
|
70
76
|
}) => {
|
|
71
77
|
// Resolve the coachmarks kill switch: explicit `features` → context → lazy fetch → all-on.
|
|
72
|
-
|
|
73
|
-
|
|
78
|
+
// `settled` is what separates "on because the tenant says so" from "on because nobody has asked
|
|
79
|
+
// yet" — the flags alone cannot tell those apart, and the second one is a guess.
|
|
80
|
+
const { flags, settled } = useResolvedFeaturesState({
|
|
81
|
+
flags: features,
|
|
82
|
+
config: featuresConfig,
|
|
83
|
+
});
|
|
84
|
+
// NEVER publish a `false` verdict on a guess. The kill switch is TERMINAL for a tour in flight
|
|
85
|
+
// (`useCoachmarkTour` subscribes and exits on a flip to false), so holding the surface by writing
|
|
86
|
+
// it false while the fetch is in flight would end the tour of every ENABLED tenant on every cold
|
|
87
|
+
// start — a worse defect than the one this fixes. The wait is published separately below, where
|
|
88
|
+
// it blocks only the ARM of a NEW tour. (While unsettled the resolved flags ARE the all-on
|
|
89
|
+
// defaults, so this `true` is not overriding a tenant answer; it is naming the guess as one.)
|
|
90
|
+
const coachmarksOn = settled ? flags.coachmarks.enabled : true;
|
|
74
91
|
|
|
75
92
|
// Apply during render so gates + the kill switch are readable before any child effect fires.
|
|
76
93
|
setCoachmarkStorage(storage);
|
|
77
94
|
setCoachmarkTesting(isTestingCoachmark);
|
|
95
|
+
setCoachmarksResolved(settled);
|
|
78
96
|
setCoachmarksEnabled(coachmarksOn);
|
|
79
97
|
|
|
80
98
|
useEffect(() => {
|
|
81
99
|
setCoachmarkStorage(storage);
|
|
82
100
|
setCoachmarkTesting(isTestingCoachmark);
|
|
101
|
+
setCoachmarksResolved(settled);
|
|
83
102
|
setCoachmarksEnabled(coachmarksOn);
|
|
84
103
|
return () => {
|
|
85
104
|
// Only clear if THIS provider's storage is still the live singleton — a
|
|
@@ -87,10 +106,12 @@ export const CoachmarkProvider: React.FC<CoachmarkProviderProps> = ({
|
|
|
87
106
|
if (getCoachmarkStorage() === storage) {
|
|
88
107
|
setCoachmarkStorage(null);
|
|
89
108
|
}
|
|
90
|
-
// Restore the fail-open
|
|
109
|
+
// Restore the fail-open defaults so a torn-down provider never leaves coachmarks dark, nor
|
|
110
|
+
// leaves a tour that outlives it unable to arm.
|
|
91
111
|
setCoachmarksEnabled(true);
|
|
112
|
+
setCoachmarksResolved(true);
|
|
92
113
|
};
|
|
93
|
-
}, [storage, isTestingCoachmark, coachmarksOn]);
|
|
114
|
+
}, [storage, isTestingCoachmark, coachmarksOn, settled]);
|
|
94
115
|
|
|
95
116
|
return (
|
|
96
117
|
<>
|