@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
|
@@ -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
|
<>
|
|
@@ -51,6 +51,12 @@ type CoachmarkRuntime = {
|
|
|
51
51
|
// resolved flags. Disabled → the tour never ARMS and the overlay `show()` is a no-op, so nothing
|
|
52
52
|
// paints and — critically — no once-gate is written, so re-enabling replays the tour correctly.
|
|
53
53
|
coachmarksEnabled: boolean;
|
|
54
|
+
// Whether `coachmarksEnabled` is an ANSWER from the tenant, or still the optimistic all-on
|
|
55
|
+
// default nobody has confirmed. Default true = fail-open, and OPTIONAL on the type for the same
|
|
56
|
+
// reason as `listeners` below: a record left by an inlined copy from an OLDER kit version has no
|
|
57
|
+
// such field, and reading that absence as `false` would stop every tour under it from arming.
|
|
58
|
+
// See setCoachmarksResolved for why this is a SECOND flag and not a third state on the first.
|
|
59
|
+
coachmarksResolved?: boolean;
|
|
54
60
|
// Subscribers to the kill switch. OPTIONAL on the type (never on the behaviour) so a record left
|
|
55
61
|
// in the registry by an inlined copy from an OLDER kit version — which had no listener set — is
|
|
56
62
|
// upgraded in place rather than read as corrupt.
|
|
@@ -118,6 +124,50 @@ export const setCoachmarksEnabled = (value: boolean): void => {
|
|
|
118
124
|
/** Whether the coachmarks module is enabled. False → tours/overlays are silently skipped. */
|
|
119
125
|
export const areCoachmarksEnabled = (): boolean => coachmarkRuntime().coachmarksEnabled;
|
|
120
126
|
|
|
127
|
+
/**
|
|
128
|
+
* Mark whether the value in `coachmarksEnabled` is the tenant's ANSWER or still the optimistic
|
|
129
|
+
* default nobody has confirmed. Written by CoachmarkProvider from the resolved features' `settled`;
|
|
130
|
+
* read (subscribed) by `useCoachmarkTour`'s ARM effect, and by nothing else.
|
|
131
|
+
*
|
|
132
|
+
* ── WHY A SECOND FLAG AND NOT A THIRD STATE ON `coachmarksEnabled` ──────────────────────────────
|
|
133
|
+
* The features fetch seeds the all-on defaults and swaps when `GET /v1/features` resolves, so on
|
|
134
|
+
* every cold start there is a window where "coachmarks: on" only means "nobody has asked yet". A
|
|
135
|
+
* tenant who switched the module OFF still gets the first tour of every launch out of that window.
|
|
136
|
+
*
|
|
137
|
+
* The obvious repair — hold the surface by writing `setCoachmarksEnabled(false)` until the answer
|
|
138
|
+
* lands — CANNOT be used here, and would ship a strictly worse defect. That flag is TERMINAL: the
|
|
139
|
+
* tour subscribes to it and a flip to false calls `exit()`, ending the tour. Writing it `false`
|
|
140
|
+
* during the wait would end the tour of every ENABLED tenant on every cold start. So the two facts
|
|
141
|
+
* are kept apart, and only one of them is terminal:
|
|
142
|
+
* • `coachmarksEnabled` — the verdict. `false` tears a tour down. Stays `true` while the answer
|
|
143
|
+
* is unknown, so nothing is ever torn down on a guess.
|
|
144
|
+
* • `coachmarksResolved` — "is that verdict an answer?". `false` only blocks ARMING a NEW tour.
|
|
145
|
+
* No timer starts, no overlay shows, and no once-gate is consumed, so the tour plays in full
|
|
146
|
+
* the moment an ON answer lands.
|
|
147
|
+
*
|
|
148
|
+
* "Resolved" means the fetch ANSWERED, not that it SUCCEEDED — `fetchWireFeatures` swallows a
|
|
149
|
+
* timeout / 401 / 5xx into the all-on defaults and that fallback IS the answer, so an unreachable
|
|
150
|
+
* control plane arms the tour rather than darking it (fail-open, unchanged).
|
|
151
|
+
*
|
|
152
|
+
* Shares the kill switch's listener set: a subscriber has to wake on either fact changing, and one
|
|
153
|
+
* set is one thing to keep correct rather than two.
|
|
154
|
+
*/
|
|
155
|
+
export const setCoachmarksResolved = (value: boolean): void => {
|
|
156
|
+
const runtime = coachmarkRuntime();
|
|
157
|
+
if ((runtime.coachmarksResolved ?? true) === value) return;
|
|
158
|
+
runtime.coachmarksResolved = value;
|
|
159
|
+
notifyCoachmarksEnabled();
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Whether the coachmarks verdict is an ANSWER yet. False → a NEW tour must not arm (a tour already
|
|
164
|
+
* in flight is untouched). Absent field → true: no provider, or an older inlined copy's record,
|
|
165
|
+
* means nothing is going to answer this, which is the same fail-open default the features
|
|
166
|
+
* `settled` context takes.
|
|
167
|
+
*/
|
|
168
|
+
export const areCoachmarksResolved = (): boolean =>
|
|
169
|
+
coachmarkRuntime().coachmarksResolved ?? true;
|
|
170
|
+
|
|
121
171
|
/**
|
|
122
172
|
* Subscribe to kill-switch flips. Returns the unsubscribe function — the `useSyncExternalStore`
|
|
123
173
|
* contract, and deliberately the SAME shape as `coachmarkOverlay.subscribe` next door rather than
|
|
@@ -129,6 +179,9 @@ export const areCoachmarksEnabled = (): boolean => coachmarkRuntime().coachmarks
|
|
|
129
179
|
* nothing can be tapped to advance, and no terminal path is reachable. `useCoachmarkTour`
|
|
130
180
|
* subscribes instead. The listener set lives in the SAME `globalThis` record as the flag, so every
|
|
131
181
|
* inlined copy of this module shares one subscriber list.
|
|
182
|
+
*
|
|
183
|
+
* It also carries `coachmarksResolved` (see `setCoachmarksResolved`): both facts wake the same
|
|
184
|
+
* listeners, so a `useSyncExternalStore` over either one uses this same subscribe function.
|
|
132
185
|
*/
|
|
133
186
|
export const subscribeCoachmarksEnabled = (listener: () => void): (() => void) => {
|
|
134
187
|
const listeners = enabledListeners();
|
|
@@ -4,6 +4,7 @@ import { coachmarkAnchors } from "./coachmarkAnchorRegistry";
|
|
|
4
4
|
import { coachmarkOverlay } from "./coachmarkOverlayStore";
|
|
5
5
|
import {
|
|
6
6
|
areCoachmarksEnabled,
|
|
7
|
+
areCoachmarksResolved,
|
|
7
8
|
coachmarkGateKey,
|
|
8
9
|
hasSeenGate,
|
|
9
10
|
markSeenGate,
|
|
@@ -76,6 +77,15 @@ const DEFAULT_START_DELAY_MS = 3000;
|
|
|
76
77
|
* `show()` is a no-op while the flag is off, so a tour that carried on would paint nothing, could
|
|
77
78
|
* not be tapped to advance, and would never reach a terminal state.
|
|
78
79
|
*
|
|
80
|
+
* ARM vs TERMINAL — the third gate, and why it is not the second one. Until the tenant's flags
|
|
81
|
+
* land, that kill switch reads ON because nobody has asked yet, so a tenant who turned coachmarks
|
|
82
|
+
* OFF still got the first tour of every cold start. The wait CANNOT be expressed by writing the
|
|
83
|
+
* kill switch false, precisely because that flag is terminal: it would end the tour of every
|
|
84
|
+
* ENABLED tenant on every cold start, a worse defect than the one being fixed. So an UNANSWERED
|
|
85
|
+
* verdict (`areCoachmarksResolved`, written by CoachmarkProvider from the features' `settled`)
|
|
86
|
+
* blocks only the ARM below — a tour already in flight is never touched by it — and costs nothing
|
|
87
|
+
* while it waits: no timer, no overlay, no once-gate spent.
|
|
88
|
+
*
|
|
79
89
|
* IMPORTANT: `steps` MUST be a stable (memoized) array. If a new array identity
|
|
80
90
|
* is passed on every render the drive effect re-runs and re-shows the current
|
|
81
91
|
* step (wasteful anchor re-resolves / overlay churn), and analytics can
|
|
@@ -120,6 +130,15 @@ export const useCoachmarkTour = (
|
|
|
120
130
|
areCoachmarksEnabled,
|
|
121
131
|
);
|
|
122
132
|
|
|
133
|
+
// Whether that kill switch is an ANSWER yet, or still the optimistic all-on default. Gates the
|
|
134
|
+
// ARM effect only — see the ARM vs TERMINAL note above. Same subscribe function: both facts live
|
|
135
|
+
// in one runtime record and wake one listener set.
|
|
136
|
+
const coachmarksResolved = useSyncExternalStore(
|
|
137
|
+
subscribeCoachmarksEnabled,
|
|
138
|
+
areCoachmarksResolved,
|
|
139
|
+
areCoachmarksResolved,
|
|
140
|
+
);
|
|
141
|
+
|
|
123
142
|
/**
|
|
124
143
|
* The single terminal exit. `writeGate` is what separates the two ways a tour can end:
|
|
125
144
|
* • the user reached the end (or dismissed the last step) → the once-gate IS written, so a
|
|
@@ -147,18 +166,40 @@ export const useCoachmarkTour = (
|
|
|
147
166
|
// Arm once, after the delay, when enabled and the gate is unseen.
|
|
148
167
|
useEffect(() => {
|
|
149
168
|
if (!enabled || startedRef.current) return undefined;
|
|
169
|
+
// AN EMPTY TOUR IS NOT A TOUR — never arm on one. With no steps the arm timer still fired, the
|
|
170
|
+
// drive effect below immediately hit `activeIndex >= steps.length`, and `finish()` wrote the
|
|
171
|
+
// once-gate for a tour the user never saw a single frame of. The gate is persisted and
|
|
172
|
+
// `finishedRef` blocks recovery in this mount, so the tour was dead for good — even after the
|
|
173
|
+
// catalog that produced the empty list was fixed. It is not a hypothetical list either: on the
|
|
174
|
+
// documented AI path `selectTourSteps(catalog, selection)` returns `[]` whenever the server
|
|
175
|
+
// sends ids that match nothing in the SHIPPED catalog (an app that cannot be force-updated),
|
|
176
|
+
// and any host whose steps arrive async passes `[]` on the first render.
|
|
177
|
+
//
|
|
178
|
+
// Keyed on `steps.length`, deliberately NOT on the array itself: the drive effect below already
|
|
179
|
+
// requires a memoized `steps`, and listing the identity here would restart the arm timer on
|
|
180
|
+
// every render of a host that passes an inline array (frequent_rules #11) — a tour that never
|
|
181
|
+
// arms, swapping one silent failure for another. The length is a value, so late-arriving steps
|
|
182
|
+
// still re-run this effect and arm the tour properly.
|
|
183
|
+
if (steps.length === 0) return undefined;
|
|
150
184
|
// Feature kill switch: never arm while coachmarks are disabled, so no timer starts, no
|
|
151
185
|
// overlay shows, and the once-gate is NOT consumed — re-enabling replays the tour. Reading
|
|
152
186
|
// the SUBSCRIBED value (and listing it in the deps) is what makes that last clause true
|
|
153
187
|
// without a remount: flipping it off clears a pending arm timer, flipping it back on re-arms.
|
|
154
188
|
if (!coachmarksOn) return undefined;
|
|
189
|
+
// …and never arm on an UNANSWERED one. `coachmarksOn` is all-on until the tenant's flags land,
|
|
190
|
+
// so without this a tenant who switched coachmarks off still got the first tour of every cold
|
|
191
|
+
// start — the kill switch working everywhere except the one moment it is read. Blocking the
|
|
192
|
+
// ARM (rather than writing the kill switch false, which is terminal — see the note above) is
|
|
193
|
+
// what makes the wait free: no timer, no overlay, no once-gate spent, so an ON answer plays
|
|
194
|
+
// the tour in full. A FAILED fetch is an answer too, so a dead control plane still arms.
|
|
195
|
+
if (!coachmarksResolved) return undefined;
|
|
155
196
|
if (showOnce && hasSeenGate(coachmarkGateKey(tourId))) return undefined;
|
|
156
197
|
const timer = setTimeout(() => {
|
|
157
198
|
startedRef.current = true;
|
|
158
199
|
setActiveIndex(0);
|
|
159
200
|
}, startDelayMs);
|
|
160
201
|
return () => clearTimeout(timer);
|
|
161
|
-
}, [enabled, coachmarksOn, startDelayMs, showOnce, tourId]);
|
|
202
|
+
}, [enabled, coachmarksOn, coachmarksResolved, startDelayMs, showOnce, tourId, steps.length]);
|
|
162
203
|
|
|
163
204
|
// Drive the active step. Keyed on `enabled` too, so a mid-tour focus loss
|
|
164
205
|
// pauses (hide, keep position) and a return to focus resumes the same step.
|
|
@@ -182,6 +223,15 @@ export const useCoachmarkTour = (
|
|
|
182
223
|
return undefined;
|
|
183
224
|
}
|
|
184
225
|
|
|
226
|
+
// The other half of the empty-tour rule, for a list that goes empty AFTER the tour armed (a
|
|
227
|
+
// re-selection, a catalog swap): end it, but take the kill-switch exit — `exit(false)` — so the
|
|
228
|
+
// once-gate is not written. `finish()` here would spend it on a tour with nothing to show, which
|
|
229
|
+
// is exactly the burn the arm guard above refuses at the other end.
|
|
230
|
+
if (steps.length === 0) {
|
|
231
|
+
exit(false);
|
|
232
|
+
return undefined;
|
|
233
|
+
}
|
|
234
|
+
|
|
185
235
|
if (activeIndex >= steps.length) {
|
|
186
236
|
finish();
|
|
187
237
|
return undefined;
|
package/src/context/deviceId.ts
CHANGED
|
@@ -73,8 +73,8 @@ export const mintDeviceId = (): string => {
|
|
|
73
73
|
* Well-known key into the runtime-global symbol registry — one auto-id registry across every bundle.
|
|
74
74
|
*
|
|
75
75
|
* @globalSlot LATCH — the REGISTRY OBJECT is created once and its identity is then stable. A second
|
|
76
|
-
* write empties `keys`/`hydrating`/`pending`, so the next surface mints a SECOND auto id
|
|
77
|
-
* install and the `device_key` the server joins sessions on splits in two — the halved-counter
|
|
76
|
+
* write empties `keys`/`hydrating`/`pending`/`ambient`, so the next surface mints a SECOND auto id
|
|
77
|
+
* for one install and the `device_key` the server joins sessions on splits in two — the halved-counter
|
|
78
78
|
* defect this registry exists to close. Its CONTENTS are live (the id per `appId` is replaced when
|
|
79
79
|
* an async hydration adopts a persisted value), so callers must re-read through
|
|
80
80
|
* `resolveAutoDeviceKey()` rather than hold the string a mount happened to see first.
|
|
@@ -91,11 +91,17 @@ const AUTO_DEVICE_KEY_SLOT: unique symbol = Symbol.for("@wireai/activation:autoD
|
|
|
91
91
|
type HydrationOutcome = { value: string; durable: boolean };
|
|
92
92
|
|
|
93
93
|
/** The shared registry: the live id per `appId`, the set of appIds whose hydration already started,
|
|
94
|
-
* and the in-flight (or settled) hydration promise per `appId` so a waiter can join it.
|
|
94
|
+
* and the in-flight (or settled) hydration promise per `appId` so a waiter can join it.
|
|
95
|
+
*
|
|
96
|
+
* `ambient` is deliberately NOT an entry in `keys`: it is the last-resort id
|
|
97
|
+
* {@link ambientAutoDeviceKey} mints for a process that has registered no id space at all, and
|
|
98
|
+
* `keys` is the TENANT CENSUS that same function counts to decide whether it may answer. Putting
|
|
99
|
+
* the fallback in the census made the fallback look like a tenant — see the note on that function. */
|
|
95
100
|
type AutoDeviceKeyRegistry = {
|
|
96
101
|
keys: Map<string, string>;
|
|
97
102
|
hydrating: Set<string>;
|
|
98
103
|
pending?: Map<string, Promise<HydrationOutcome>>;
|
|
104
|
+
ambient?: string;
|
|
99
105
|
};
|
|
100
106
|
|
|
101
107
|
type GlobalWithDeviceKeys = typeof globalThis & {
|
|
@@ -237,8 +243,19 @@ export const resolveAutoDeviceKey = (opts: ResolveAutoDeviceKeyOptions = {}): st
|
|
|
237
243
|
* follows it. Only a caller that can afford one storage read should use this; the fire-and-forget
|
|
238
244
|
* event paths must stay on the sync function.
|
|
239
245
|
*
|
|
240
|
-
* Never throws or rejects: a missing
|
|
241
|
-
*
|
|
246
|
+
* Never throws or rejects: a missing or rejecting adapter resolves to the in-memory id, and with no
|
|
247
|
+
* `storage` it resolves immediately (there is nothing to hydrate from).
|
|
248
|
+
*
|
|
249
|
+
* ⛔ A HUNG ADAPTER IS THE ONE CASE IT DOES NOT COVER, and this line used to claim it did. The read
|
|
250
|
+
* underneath is a bare `storage.getItem` with no ceiling of its own, so an adapter that neither
|
|
251
|
+
* resolves nor rejects (a locked keychain, a wedged native bridge) leaves THIS PROMISE PENDING
|
|
252
|
+
* FOREVER — it does not fall back to the in-memory id, it simply never answers. Anything that gates
|
|
253
|
+
* a user-visible or metric-bearing action on the result must therefore race it against a ceiling of
|
|
254
|
+
* its own: `<WireOnboarding>` does (`AUTO_JOIN_HYDRATION_TIMEOUT_MS`), and so do the two lifecycle
|
|
255
|
+
* hooks (`session-analytics/useLifecycleEvents` + `useSessionStart`, through the shared
|
|
256
|
+
* `withTimeout` + `READ_TIMEOUT_MS` in `session/persistedSession`) — a hung read there had silently
|
|
257
|
+
* killed `app.session_started` and `app.first_open` for the whole process. Use the SYNC
|
|
258
|
+
* {@link resolveAutoDeviceKey} when you cannot afford to wait at all.
|
|
242
259
|
*
|
|
243
260
|
* ⚠️ IT RETURNS A BARE STRING, so it CANNOT say whether the id survives the launch — a degraded
|
|
244
261
|
* adapter resolves to the in-memory mint and reads identically to a persisted one. No kit surface
|
|
@@ -263,7 +280,9 @@ export const hydrateAutoDeviceKey = async (
|
|
|
263
280
|
*
|
|
264
281
|
* Resolves `undefined` only when there is no usable id at all. With no `storage` it resolves
|
|
265
282
|
* immediately with `durable: false` — a process-scoped id is exactly what "no persistence" means.
|
|
266
|
-
* Never throws or rejects
|
|
283
|
+
* Never throws or rejects — but, exactly like {@link hydrateAutoDeviceKey} above, it can also never
|
|
284
|
+
* SETTLE on a hung adapter (the underlying `getItem` carries no ceiling), so every caller races it
|
|
285
|
+
* against one of its own. See the note there.
|
|
267
286
|
*/
|
|
268
287
|
export const hydrateDeviceIdentity = async (
|
|
269
288
|
opts: ResolveAutoDeviceKeyOptions = {},
|
|
@@ -290,6 +309,7 @@ export const resetAutoDeviceKeys = (): void => {
|
|
|
290
309
|
registry.keys.clear();
|
|
291
310
|
registry.hydrating.clear();
|
|
292
311
|
registry.pending?.clear();
|
|
312
|
+
registry.ambient = undefined;
|
|
293
313
|
};
|
|
294
314
|
|
|
295
315
|
/**
|
|
@@ -303,23 +323,37 @@ export const resetAutoDeviceKeys = (): void => {
|
|
|
303
323
|
* the halved-counter defect this registry exists to close (see the registry note above), arrived at
|
|
304
324
|
* from the other direction.
|
|
305
325
|
*
|
|
306
|
-
* So this READS and does not address:
|
|
326
|
+
* So this READS the TENANT CENSUS (`registry.keys`) and does not address it:
|
|
307
327
|
* • exactly one id space in the process → that id, whoever registered it. This is every
|
|
308
328
|
* single-tenant app, i.e. every real app, and it is how the gate joins the analytics surfaces.
|
|
309
|
-
* • none at all →
|
|
310
|
-
* so
|
|
311
|
-
* persist through), which is honest and still resolvable: a review row is written
|
|
312
|
-
* so a per-launch value here cannot corrupt a counter the way it corrupts
|
|
329
|
+
* • none at all → the registry's `ambient` fallback, minted here on first ask. Nothing exists to
|
|
330
|
+
* collide with, so it is not a second space, it is the only one. PROCESS-scoped (there is no
|
|
331
|
+
* storage to persist through), which is honest and still resolvable: a review row is written
|
|
332
|
+
* once per user, so a per-launch value here cannot corrupt a counter the way it corrupts
|
|
333
|
+
* `min_sessions` — and without SOME unit the server discards the impression's
|
|
334
|
+
* `idempotency_key` outright, so refusing to answer costs a re-post its upsert.
|
|
313
335
|
* • two or more → `undefined`. Two tenants in one process must never share an id, and a caller
|
|
314
336
|
* with no `appId` cannot say which one it belongs to. Refusing is the only safe answer;
|
|
315
337
|
* guessing would stamp one tenant's device onto the other's row.
|
|
316
338
|
*
|
|
317
|
-
*
|
|
318
|
-
*
|
|
339
|
+
* ⛔ THE FALLBACK IS NOT A TENANT, AND THAT IS THE WHOLE REASON IT LIVES IN ITS OWN FIELD (0.15.1).
|
|
340
|
+
* It used to be minted through `resolveAutoDeviceKey()`, which registered it in `keys` under
|
|
341
|
+
* `"default"`. The three states above are only exhaustive at ONE INSTANT, and this one wrote into
|
|
342
|
+
* the very map the next instant is judged by: a gate that fired before any Wire surface had
|
|
343
|
+
* initialised (the review gate lives on the home feed, so a cold start can reach it first) left a
|
|
344
|
+
* `"default"` entry behind, and the moment the host's real `appId` registered the census read TWO —
|
|
345
|
+
* so every later call returned `undefined` and the injected `device_key` was silently dead for the
|
|
346
|
+
* rest of the process. Holding the fallback OUTSIDE the census makes it yield instead: a real id
|
|
347
|
+
* space appearing later simply wins, which is the answer the middle bullet wanted all along.
|
|
348
|
+
*
|
|
349
|
+
* Never throws, never mints a SECOND id into the census, and never overrides anything a host
|
|
350
|
+
* supplied — the caller checks for a host value first.
|
|
319
351
|
*/
|
|
320
352
|
export const ambientAutoDeviceKey = (): string | undefined => {
|
|
321
353
|
const registry = autoDeviceKeyRegistry();
|
|
322
|
-
if (registry.keys.size === 0) return resolveAutoDeviceKey();
|
|
323
354
|
if (registry.keys.size > 1) return undefined;
|
|
324
|
-
|
|
355
|
+
const sole = [...registry.keys.values()][0];
|
|
356
|
+
if (sole) return sole;
|
|
357
|
+
if (!registry.ambient) registry.ambient = mintDeviceId();
|
|
358
|
+
return registry.ambient;
|
|
325
359
|
};
|