@wireai/activation 0.13.2 → 0.13.5
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 +6 -3
- package/CHANGELOG.md +291 -4
- package/INTEGRATION_PROMPT.md +7 -1
- package/README.md +68 -2
- package/dist/analytics/index.d.mts +86 -6
- package/dist/analytics/index.d.ts +86 -6
- package/dist/analytics/index.js +271 -472
- package/dist/analytics/index.js.map +1 -1
- package/dist/analytics/index.mjs +269 -471
- package/dist/analytics/index.mjs.map +1 -1
- package/dist/coachmarks/index.d.mts +15 -2
- package/dist/coachmarks/index.d.ts +15 -2
- package/dist/coachmarks/index.js +58 -14
- package/dist/coachmarks/index.js.map +1 -1
- package/dist/coachmarks/index.mjs +58 -14
- package/dist/coachmarks/index.mjs.map +1 -1
- package/dist/{currentSession-ClkLjcJ0.d.mts → currentSession-DgJf0fRz.d.mts} +4 -4
- package/dist/{currentSession-DOVZEWJl.d.ts → currentSession-EOHU64QD.d.ts} +4 -4
- package/dist/{decision-Bl_M2y3r.d.mts → decision-Bgo17oH7.d.mts} +1 -1
- package/dist/{decision-Cau5KmP6.d.ts → decision-Bkh_LigV.d.ts} +1 -1
- package/dist/index.d.mts +27 -4
- package/dist/index.d.ts +27 -4
- package/dist/index.js +224 -575
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +221 -572
- package/dist/index.mjs.map +1 -1
- package/dist/questionnaire/index.d.mts +43 -11
- package/dist/questionnaire/index.d.ts +43 -11
- package/dist/questionnaire/index.js +45 -13
- package/dist/questionnaire/index.js.map +1 -1
- package/dist/questionnaire/index.mjs +45 -13
- package/dist/questionnaire/index.mjs.map +1 -1
- package/dist/reviews/index.d.mts +5 -5
- package/dist/reviews/index.d.ts +5 -5
- package/dist/reviews/index.js +31 -9
- package/dist/reviews/index.js.map +1 -1
- package/dist/reviews/index.mjs +31 -9
- 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 +7 -2
- package/dist/showcase/index.js.map +1 -1
- package/dist/showcase/index.mjs +7 -2
- package/dist/showcase/index.mjs.map +1 -1
- package/dist/{transport-CF_eHwzC.d.mts → transport-B_0SgCBe.d.ts} +44 -5
- package/dist/{transport-DsRe4epC.d.ts → transport-j5gFfJhK.d.mts} +44 -5
- package/dist/{types-CKFhyrMu.d.mts → types-BpwiRpA8.d.mts} +1 -1
- package/dist/{types-CKFhyrMu.d.ts → types-BpwiRpA8.d.ts} +1 -1
- package/dist/{types-CNUqMK0D.d.mts → types-Cju-1_jT.d.mts} +42 -2
- package/dist/{types-Buj9Lw9t.d.ts → types-h2BZvl1t.d.ts} +42 -2
- package/llms.txt +1 -1
- package/package.json +6 -1
- package/src/OnboardingFlow.tsx +44 -15
- package/src/WireOnboarding.tsx +7 -6
- package/src/activation/revalidation.ts +9 -1
- package/src/activation/wireActivation.ts +3 -3
- package/src/analytics/analyticsFacade.ts +2 -2
- package/src/analytics/currentSession.ts +10 -0
- package/src/analytics/eventQueue.ts +88 -20
- package/src/analytics/index.ts +6 -0
- package/src/analytics/reportClientEvent.ts +29 -7
- package/src/analytics/wireDoctor.ts +330 -0
- package/src/cards/CardGridSelectCard.tsx +1 -1
- package/src/cards/ChipSelectCard.tsx +2 -2
- package/src/cards/SelectionCard.tsx +1 -1
- package/src/coachmarks/runtime.ts +72 -3
- package/src/coachmarks/useCoachmarkTour.ts +62 -14
- package/src/context/deviceId.ts +14 -5
- package/src/features/WireFeaturesProvider.tsx +12 -1
- package/src/identity/identityRecord.ts +9 -1
- package/src/identity/userIdentity.ts +1 -1
- package/src/questionnaire/QuestionnaireGate.tsx +90 -10
- package/src/questionnaire/index.ts +9 -1
- package/src/questionnaire/transport.ts +46 -11
- package/src/reviews/ReviewGate.tsx +61 -6
- package/src/reviews/index.ts +10 -1
- package/src/reviews/runtime.ts +11 -1
- package/src/reviews/transport.ts +58 -12
- package/src/session-analytics/useLifecycleEvents.ts +1 -1
- package/src/theme/ThemeContext.tsx +49 -1
- package/src/types.ts +1 -1
- package/src/utils/submitResult.ts +39 -0
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* wireDoctor: a DEV-ONLY, opt-in self-check that proves a fresh integration end to end before the
|
|
3
|
+
* first real user ever runs it.
|
|
4
|
+
*
|
|
5
|
+
* WHY IT EXISTS: the `/v1/events` endpoint answers HTTP **200** for a batch it throws away. It
|
|
6
|
+
* reports the refusal in the response body (`{written, skipped, errors:[{reason, field}]}`), so a
|
|
7
|
+
* mis-wired integration looks perfectly healthy from the outside while every event evaporates. A
|
|
8
|
+
* numeric `ts` did exactly that through 0.13.0. This turns that class of loss from something you
|
|
9
|
+
* discover in a funnel report weeks later into something the first run tells you.
|
|
10
|
+
*
|
|
11
|
+
* SHAPE: four independent checks, each a `{name, ok, detail}` unit that can be read (and tested)
|
|
12
|
+
* without the others. The report is data, never a thrown error and never a side effect on the host:
|
|
13
|
+
*
|
|
14
|
+
* 1. `target` : is there a server URL and a key, and do they look like a key and a URL?
|
|
15
|
+
* 2. `reachability`: is the server actually there? (`GET /v1/events/contract`, public and cheap)
|
|
16
|
+
* 3. `storage` : can the offline queue persist? (a write / read / delete probe)
|
|
17
|
+
* 4. `round_trip` : does a REAL event survive REAL server validation? (a `dry_run` POST)
|
|
18
|
+
*
|
|
19
|
+
* NEVER THROWS, under any input, any network condition, or any hostile response object. A doctor
|
|
20
|
+
* that can crash the screen it is diagnosing is worse than no doctor.
|
|
21
|
+
*
|
|
22
|
+
* ⛔ NEVER PRINTS A SECRET. The `target` check reports the key's SHAPE (present / absent, length,
|
|
23
|
+
* whether the prefix is the expected one) and never any character of the key itself, in any
|
|
24
|
+
* `detail`, any log line, or any error. `wireDoctor.test.ts` asserts that with a sentinel key.
|
|
25
|
+
*
|
|
26
|
+
* COSTS A NON-CALLER NOTHING: it is a plain function behind the `./analytics` subpath, the package
|
|
27
|
+
* is `sideEffects: false`, and this module has no top-level side effects, so a host that never
|
|
28
|
+
* imports it never bundles it. It also mints no `globalThis` slot and retains no timer or listener.
|
|
29
|
+
*
|
|
30
|
+
* USAGE (dev builds only):
|
|
31
|
+
*
|
|
32
|
+
* import { wireDoctor } from "@wireai/activation/analytics";
|
|
33
|
+
*
|
|
34
|
+
* const report = await wireDoctor({
|
|
35
|
+
* target: { serverUrl: "https://api.example.com", apiKey: DRIVELINE_KEY },
|
|
36
|
+
* storage: AsyncStorage,
|
|
37
|
+
* });
|
|
38
|
+
* console.log(report.ok, report.checks);
|
|
39
|
+
*/
|
|
40
|
+
// Imported DIRECTLY from the transport module, never through `./index`: the barrel would pull the
|
|
41
|
+
// whole analytics surface into anything that touches the doctor. `readEventsAck` is module-exported
|
|
42
|
+
// for exactly this, so the doctor reads an ack with the SAME reader the send paths use rather than
|
|
43
|
+
// growing a second one to drift.
|
|
44
|
+
import {
|
|
45
|
+
buildEventsRequest,
|
|
46
|
+
makeSessionId,
|
|
47
|
+
readEventsAck,
|
|
48
|
+
type ClientEvent,
|
|
49
|
+
type ClientEventTarget,
|
|
50
|
+
} from "./reportClientEvent";
|
|
51
|
+
import type { WireOnboardingStorage } from "../session/persistedSession";
|
|
52
|
+
|
|
53
|
+
/** RN sets this global; absent under node/SSR. Read defensively, exactly as `warnOnSkippedEvents` does. */
|
|
54
|
+
declare const __DEV__: boolean | undefined;
|
|
55
|
+
|
|
56
|
+
/** One diagnosis. `name` is stable and machine-readable; `detail` is for a human reading a console. */
|
|
57
|
+
export type WireDoctorCheck = {
|
|
58
|
+
/**
|
|
59
|
+
* Stable id: `target` | `reachability` | `storage` | `round_trip` | `dev_only` | `internal_error`.
|
|
60
|
+
*
|
|
61
|
+
* `dev_only` means ONE thing and only that thing: `__DEV__` is unset or false, so nothing ran.
|
|
62
|
+
* `internal_error` is the separate catch-all for a failure that got past every check's own
|
|
63
|
+
* swallow. They are distinct names because a consumer branching on `dev_only` would otherwise
|
|
64
|
+
* read an internal fault as "this is a release build" and report a healthy skip.
|
|
65
|
+
*/
|
|
66
|
+
name: string;
|
|
67
|
+
ok: boolean;
|
|
68
|
+
/** Human-readable result. ⛔ Never contains any part of an API key. */
|
|
69
|
+
detail: string;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** What {@link wireDoctor} resolves to. `ok` is true only when EVERY check passed. */
|
|
73
|
+
export type WireDoctorReport = {
|
|
74
|
+
ok: boolean;
|
|
75
|
+
checks: WireDoctorCheck[];
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** Input for {@link wireDoctor}. `storage` is optional: without it the queue runs in-memory only. */
|
|
79
|
+
export type WireDoctorOptions = {
|
|
80
|
+
/** The same `{serverUrl, apiKey}` the kit is configured with. */
|
|
81
|
+
target: ClientEventTarget | undefined;
|
|
82
|
+
/**
|
|
83
|
+
* The host storage the offline queue would use (AsyncStorage-compatible). Omit it and the storage
|
|
84
|
+
* check reports the DEGRADED in-memory mode rather than failing.
|
|
85
|
+
*/
|
|
86
|
+
storage?: WireOnboardingStorage;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The probe's own storage key. Deliberately NOT under the queue's `wireai:evtq:` namespace, so a
|
|
91
|
+
* doctor run can never read, overwrite or delete a pending backlog. A diagnostic that eats a user's
|
|
92
|
+
* unsent events is a worse bug than the one it was written to find.
|
|
93
|
+
*/
|
|
94
|
+
const PROBE_STORAGE_KEY = "wireai:doctor:probe";
|
|
95
|
+
|
|
96
|
+
/** The `question_key` the synthetic round-trip event carries. `dry_run` means it is never written. */
|
|
97
|
+
const PROBE_QUESTION_KEY = "wire_doctor_probe";
|
|
98
|
+
|
|
99
|
+
/** Tenant keys start with this. Used ONLY for a boolean comparison; ⛔ never interpolated into a detail. */
|
|
100
|
+
const EXPECTED_KEY_PREFIX = "wai_";
|
|
101
|
+
|
|
102
|
+
/** Ceiling on each network check, so a hung server degrades to a failed check, never a hung caller. */
|
|
103
|
+
const NETWORK_TIMEOUT_MS = 10_000;
|
|
104
|
+
|
|
105
|
+
const check = (name: string, ok: boolean, detail: string): WireDoctorCheck => ({ name, ok, detail });
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* `fetch` with a timeout that is ALWAYS cleared, including on rejection. Resolves `undefined`
|
|
109
|
+
* instead of throwing, so every caller stays on the happy path. Returns no timer to the caller and
|
|
110
|
+
* leaves none pending: a diagnostic must not keep the JS thread alive after it has answered.
|
|
111
|
+
*/
|
|
112
|
+
const fetchWithTimeout = async (url: string, init?: RequestInit): Promise<Response | undefined> => {
|
|
113
|
+
if (typeof fetch === "undefined") return undefined;
|
|
114
|
+
const controller = typeof AbortController !== "undefined" ? new AbortController() : undefined;
|
|
115
|
+
const timer = setTimeout(() => controller?.abort(), NETWORK_TIMEOUT_MS);
|
|
116
|
+
try {
|
|
117
|
+
return await fetch(url, controller ? { ...init, signal: controller.signal } : init);
|
|
118
|
+
} catch {
|
|
119
|
+
// Unreachable host, DNS failure, abort, or a missing fetch implementation.
|
|
120
|
+
return undefined;
|
|
121
|
+
} finally {
|
|
122
|
+
clearTimeout(timer);
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* CHECK 1: is the target usable at all?
|
|
128
|
+
*
|
|
129
|
+
* Reports the key's shape and NEVER its content: present/absent, character length, and whether the
|
|
130
|
+
* prefix matches what a tenant key starts with. Length and a yes/no are enough to tell "you pasted
|
|
131
|
+
* the wrong string" from "you pasted nothing", which is the entire diagnostic value here.
|
|
132
|
+
*/
|
|
133
|
+
const checkTarget = (target: ClientEventTarget | undefined): WireDoctorCheck => {
|
|
134
|
+
const serverUrl = target?.serverUrl;
|
|
135
|
+
if (typeof serverUrl !== "string" || serverUrl.trim().length === 0) {
|
|
136
|
+
return check("target", false, "no serverUrl configured: set `serverUrl` on the kit config.");
|
|
137
|
+
}
|
|
138
|
+
let parsedHost = false;
|
|
139
|
+
try {
|
|
140
|
+
const parsed = new URL(serverUrl);
|
|
141
|
+
parsedHost = parsed.protocol === "http:" || parsed.protocol === "https:";
|
|
142
|
+
} catch {
|
|
143
|
+
parsedHost = false;
|
|
144
|
+
}
|
|
145
|
+
if (!parsedHost) {
|
|
146
|
+
return check("target", false, "serverUrl is not a valid http(s) URL.");
|
|
147
|
+
}
|
|
148
|
+
const apiKey = target?.apiKey;
|
|
149
|
+
if (typeof apiKey !== "string" || apiKey.length === 0) {
|
|
150
|
+
return check("target", false, "serverUrl looks valid, but no apiKey is configured.");
|
|
151
|
+
}
|
|
152
|
+
// ⛔ `EXPECTED_KEY_PREFIX` is compared, never printed: it is itself a substring of a real key.
|
|
153
|
+
const prefixOk = apiKey.startsWith(EXPECTED_KEY_PREFIX);
|
|
154
|
+
return check(
|
|
155
|
+
"target",
|
|
156
|
+
true,
|
|
157
|
+
`serverUrl is a valid http(s) URL; apiKey present (${apiKey.length} chars, expected prefix: ${
|
|
158
|
+
prefixOk ? "yes" : "no"
|
|
159
|
+
}).`,
|
|
160
|
+
);
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* CHECK 2: is the server there, and is it a Wire server?
|
|
165
|
+
*
|
|
166
|
+
* `GET /v1/events/contract` is public, cheap, and it answers the question the round-trip check
|
|
167
|
+
* cannot answer on its own: a failure here means "wrong URL / server down", not "bad payload".
|
|
168
|
+
*/
|
|
169
|
+
const checkReachability = async (serverUrl: string): Promise<WireDoctorCheck> => {
|
|
170
|
+
const url = `${serverUrl.replace(/\/$/, "")}/v1/events/contract`;
|
|
171
|
+
const res = await fetchWithTimeout(url, { method: "GET" });
|
|
172
|
+
if (!res) {
|
|
173
|
+
return check("reachability", false, "could not reach the server (network error or timeout).");
|
|
174
|
+
}
|
|
175
|
+
const status = (res as { status?: unknown }).status;
|
|
176
|
+
const ok = !!(res as { ok?: boolean }).ok;
|
|
177
|
+
if (!ok) {
|
|
178
|
+
return check(
|
|
179
|
+
"reachability",
|
|
180
|
+
false,
|
|
181
|
+
`the server answered ${typeof status === "number" ? status : "an error"} for the events contract; ` +
|
|
182
|
+
"check the serverUrl points at a Wire server.",
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
return check("reachability", true, "the server answered the events contract.");
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* CHECK 3: can the offline queue actually persist?
|
|
190
|
+
*
|
|
191
|
+
* Write, read back, compare. Without working storage the queue silently degrades to in-memory only,
|
|
192
|
+
* so a backgrounded app loses whatever it had not flushed, and nothing anywhere says so.
|
|
193
|
+
*
|
|
194
|
+
* The probe key is REMOVED in a `finally`, so a failing read or a throwing adapter still cleans up.
|
|
195
|
+
*/
|
|
196
|
+
const checkStorage = async (storage: WireOnboardingStorage | undefined): Promise<WireDoctorCheck> => {
|
|
197
|
+
if (!storage) {
|
|
198
|
+
return check(
|
|
199
|
+
"storage",
|
|
200
|
+
true,
|
|
201
|
+
"no storage injected: the queue runs in DEGRADED in-memory mode and loses pending events on an app kill. " +
|
|
202
|
+
"Pass AsyncStorage (or an MMKV wrapper) to make it durable.",
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
const token = `probe_${Date.now().toString(36)}`;
|
|
206
|
+
try {
|
|
207
|
+
await storage.setItem(PROBE_STORAGE_KEY, token);
|
|
208
|
+
const read = await storage.getItem(PROBE_STORAGE_KEY);
|
|
209
|
+
if (read !== token) {
|
|
210
|
+
return check(
|
|
211
|
+
"storage",
|
|
212
|
+
false,
|
|
213
|
+
"the storage adapter accepted a write but did not read the same value back, so the queue cannot persist.",
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
// ⚠️ "write / read" only: this returns BEFORE the `finally` runs its delete, and a throwing
|
|
217
|
+
// `removeItem` is deliberately swallowed there, so the delete is attempted, never asserted.
|
|
218
|
+
return check("storage", true, "storage write / read round trip succeeded.");
|
|
219
|
+
} catch {
|
|
220
|
+
return check("storage", false, "the storage adapter threw, so the queue cannot persist events.");
|
|
221
|
+
} finally {
|
|
222
|
+
// Always, including after a failed write or read: never leave the probe key behind.
|
|
223
|
+
try {
|
|
224
|
+
await storage.removeItem(PROBE_STORAGE_KEY);
|
|
225
|
+
} catch {
|
|
226
|
+
// A remove that throws is not worth failing the run over; nothing else depends on it.
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* CHECK 4: does a real event survive real server validation?
|
|
233
|
+
*
|
|
234
|
+
* POSTs ONE synthetic event through the REAL {@link buildEventsRequest} with `dry_run: true`, so it
|
|
235
|
+
* travels the exact bytes a production event travels and the server validates it exactly the same
|
|
236
|
+
* way, but writes nothing. The probe is inert: it pollutes no funnel and no metric.
|
|
237
|
+
*
|
|
238
|
+
* ⚠️ The verdict is `written === 1 && skipped === 0`, read from the ACK BODY. A 200 is not a
|
|
239
|
+
* receipt here: the endpoint returns 200 for a batch it discarded. A `ts`-shaped drift shows up as
|
|
240
|
+
* `skipped: 1` with the server's own `field: "ts"`, which is the whole point of this check.
|
|
241
|
+
*/
|
|
242
|
+
const checkRoundTrip = async (target: ClientEventTarget): Promise<WireDoctorCheck> => {
|
|
243
|
+
const event: ClientEvent = {
|
|
244
|
+
event_type: "app_event",
|
|
245
|
+
session_id: makeSessionId(),
|
|
246
|
+
question_key: PROBE_QUESTION_KEY,
|
|
247
|
+
};
|
|
248
|
+
const req = buildEventsRequest(target, [event], { dryRun: true });
|
|
249
|
+
if (!req) {
|
|
250
|
+
return check("round_trip", false, "could not build the events request from this target.");
|
|
251
|
+
}
|
|
252
|
+
const res = await fetchWithTimeout(req.url, req.init);
|
|
253
|
+
if (!res) {
|
|
254
|
+
return check("round_trip", false, "the events POST failed (network error or timeout).");
|
|
255
|
+
}
|
|
256
|
+
if (!(res as { ok?: boolean }).ok) {
|
|
257
|
+
const status = (res as { status?: unknown }).status;
|
|
258
|
+
return check(
|
|
259
|
+
"round_trip",
|
|
260
|
+
false,
|
|
261
|
+
`the events endpoint answered ${typeof status === "number" ? status : "an error"}; ` +
|
|
262
|
+
"a 401 here means the apiKey is not accepted.",
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
const ack = await readEventsAck(res);
|
|
266
|
+
if (!ack) {
|
|
267
|
+
return check(
|
|
268
|
+
"round_trip",
|
|
269
|
+
false,
|
|
270
|
+
"the server answered 2xx but sent no readable ack body, so it is unknown whether the event validated.",
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
if (ack.written === 1 && ack.skipped === 0) {
|
|
274
|
+
return check("round_trip", true, "the server validated the probe event: written=1, skipped=0.");
|
|
275
|
+
}
|
|
276
|
+
// The server's own reasons, verbatim, each already carrying the field it refused.
|
|
277
|
+
return check(
|
|
278
|
+
"round_trip",
|
|
279
|
+
false,
|
|
280
|
+
`the server DISCARDED the probe event: written=${ack.written ?? "unknown"}, skipped=${ack.skipped}` +
|
|
281
|
+
(ack.reasons.length > 0
|
|
282
|
+
? `, reasons: ${ack.reasons.join(", ")}.`
|
|
283
|
+
: ". It gave no reason; check the server's ingest log for this request."),
|
|
284
|
+
);
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Run the full diagnosis. Resolves a report; NEVER throws and NEVER rejects.
|
|
289
|
+
*
|
|
290
|
+
* DEV-ONLY BY CONTRACT: the `__DEV__` guard is the FIRST statement, so a release build performs no
|
|
291
|
+
* network call, no storage write, and no work at all. The report says it was skipped rather than
|
|
292
|
+
* pretending everything passed, because a green report that never ran is the exact failure mode
|
|
293
|
+
* this whole feature exists to remove.
|
|
294
|
+
*/
|
|
295
|
+
export const wireDoctor = async (options: WireDoctorOptions): Promise<WireDoctorReport> => {
|
|
296
|
+
if (typeof __DEV__ === "undefined" || !__DEV__) {
|
|
297
|
+
return {
|
|
298
|
+
ok: false,
|
|
299
|
+
checks: [
|
|
300
|
+
check(
|
|
301
|
+
"dev_only",
|
|
302
|
+
false,
|
|
303
|
+
"wireDoctor is dev-only and did NOT run: __DEV__ is unset or false. Nothing was checked.",
|
|
304
|
+
),
|
|
305
|
+
],
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
try {
|
|
309
|
+
const target = options?.target;
|
|
310
|
+
const checks: WireDoctorCheck[] = [checkTarget(target)];
|
|
311
|
+
// Reachability and the round trip both need a usable target; running them against a broken one
|
|
312
|
+
// would report a network failure and bury the real cause, which check 1 already named.
|
|
313
|
+
if (checks[0]!.ok && target) {
|
|
314
|
+
checks.push(await checkReachability(target.serverUrl));
|
|
315
|
+
checks.push(await checkStorage(options?.storage));
|
|
316
|
+
checks.push(await checkRoundTrip(target));
|
|
317
|
+
} else {
|
|
318
|
+
checks.push(await checkStorage(options?.storage));
|
|
319
|
+
}
|
|
320
|
+
return { ok: checks.every((c) => c.ok), checks };
|
|
321
|
+
} catch {
|
|
322
|
+
// Belt and braces: every check above already swallows its own failures, so reaching here means
|
|
323
|
+
// something hostile got past them. Still a report, still not a throw. The name is
|
|
324
|
+
// `internal_error`, NOT `dev_only`: this ran and broke, which is the opposite of "never ran".
|
|
325
|
+
return {
|
|
326
|
+
ok: false,
|
|
327
|
+
checks: [check("internal_error", false, "wireDoctor could not complete: an unexpected error was swallowed.")],
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
};
|
|
@@ -104,7 +104,7 @@ const _OptionCard: React.FC<OptionCardProps> = ({ opt, isSelected, multiSelect,
|
|
|
104
104
|
const handlePressOut = useCallback(() => pressTo(1), [pressTo]);
|
|
105
105
|
|
|
106
106
|
// Selected fill: accent border + tinted wash on an absolute overlay whose opacity crossfades
|
|
107
|
-
// (native driver
|
|
107
|
+
// (native driver, never a color interpolation). Reduce motion: snap.
|
|
108
108
|
const selectT = useRef(new Animated.Value(isSelected ? 1 : 0)).current;
|
|
109
109
|
useEffect(() => {
|
|
110
110
|
const anim = Animated.timing(selectT, {
|
|
@@ -80,7 +80,7 @@ const _ChipItem: React.FC<ChipItemProps> = ({ chip, isSelected, multiSelect, onT
|
|
|
80
80
|
const handlePressOut = useCallback(() => pressTo(1), [pressTo]);
|
|
81
81
|
|
|
82
82
|
// Selected fill: full accent pill, crossfaded in over 160ms on a native-driver
|
|
83
|
-
// opacity overlay
|
|
83
|
+
// opacity overlay, never a color interpolation. Reduce motion: instant swap.
|
|
84
84
|
const fillT = useRef(new Animated.Value(isSelected ? 1 : 0)).current;
|
|
85
85
|
useEffect(() => {
|
|
86
86
|
const anim = Animated.timing(fillT, {
|
|
@@ -94,7 +94,7 @@ const _ChipItem: React.FC<ChipItemProps> = ({ chip, isSelected, multiSelect, onT
|
|
|
94
94
|
}, [isSelected, reduced, fillT]);
|
|
95
95
|
|
|
96
96
|
// Check pop on select (the width-slide of the canvas is a layout animation the
|
|
97
|
-
// native driver can't express
|
|
97
|
+
// native driver can't express, so it stays on the JS driver deliberately).
|
|
98
98
|
const checkT = useRef(new Animated.Value(isSelected ? 1 : 0)).current;
|
|
99
99
|
useEffect(() => {
|
|
100
100
|
if (!isSelected) {
|
|
@@ -80,7 +80,7 @@ const _OptionRow: React.FC<OptionRowProps> = ({ opt, isSelected, multiSelect, on
|
|
|
80
80
|
|
|
81
81
|
// Selected fill: the selected border+bg live on an absolute overlay whose opacity
|
|
82
82
|
// crossfades in 180ms ease-wire — same visual as a color interpolation, but it
|
|
83
|
-
// stays on the native driver
|
|
83
|
+
// stays on the native driver. Reduce motion: snap.
|
|
84
84
|
const selectT = useRef(new Animated.Value(isSelected ? 1 : 0)).current;
|
|
85
85
|
useEffect(() => {
|
|
86
86
|
const anim = Animated.timing(selectT, {
|
|
@@ -27,7 +27,16 @@
|
|
|
27
27
|
*/
|
|
28
28
|
import type { CoachmarkStorage } from "./types";
|
|
29
29
|
|
|
30
|
-
/**
|
|
30
|
+
/**
|
|
31
|
+
* Well-known key into the runtime-global symbol registry — one coachmark runtime across bundles.
|
|
32
|
+
*
|
|
33
|
+
* @globalSlot LATCH — the RUNTIME RECORD is created once and its identity is then stable. A second
|
|
34
|
+
* write replaces the record `subscribeCoachmarksEnabled` registered its listeners on, so a
|
|
35
|
+
* kill-switch flip notifies an orphaned set while a tour that believes it is running paints nothing
|
|
36
|
+
* — the exact limbo `coachmarkKillSwitch` canaries. Its FIELDS (`storage`, `testing`,
|
|
37
|
+
* `coachmarksEnabled`, `listeners`) are live, which is why every accessor calls `coachmarkRuntime()`
|
|
38
|
+
* instead of holding the record, and why `coachmarksEnabled` is subscribable rather than sampled.
|
|
39
|
+
*/
|
|
31
40
|
const COACHMARK_RUNTIME_SLOT: unique symbol = Symbol.for("@wireai/activation:coachmarkRuntime");
|
|
32
41
|
|
|
33
42
|
/** The shared runtime: the provider-injected storage, the QA replay flag, and the kill switch. */
|
|
@@ -39,6 +48,10 @@ type CoachmarkRuntime = {
|
|
|
39
48
|
// resolved flags. Disabled → the tour never ARMS and the overlay `show()` is a no-op, so nothing
|
|
40
49
|
// paints and — critically — no once-gate is written, so re-enabling replays the tour correctly.
|
|
41
50
|
coachmarksEnabled: boolean;
|
|
51
|
+
// Subscribers to the kill switch. OPTIONAL on the type (never on the behaviour) so a record left
|
|
52
|
+
// in the registry by an inlined copy from an OLDER kit version — which had no listener set — is
|
|
53
|
+
// upgraded in place rather than read as corrupt.
|
|
54
|
+
listeners?: Set<() => void>;
|
|
42
55
|
};
|
|
43
56
|
|
|
44
57
|
type GlobalWithCoachmarkRuntime = typeof globalThis & {
|
|
@@ -55,17 +68,73 @@ const coachmarkRuntime = (): CoachmarkRuntime => {
|
|
|
55
68
|
return created;
|
|
56
69
|
};
|
|
57
70
|
|
|
71
|
+
const enabledListeners = (): Set<() => void> => {
|
|
72
|
+
const runtime = coachmarkRuntime();
|
|
73
|
+
if (!runtime.listeners) runtime.listeners = new Set();
|
|
74
|
+
return runtime.listeners;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Wake the subscribers on a MICROTASK, never synchronously.
|
|
79
|
+
*
|
|
80
|
+
* `CoachmarkProvider` writes this flag during its RENDER phase on purpose (so a child's mount
|
|
81
|
+
* effect already sees the resolved value). Notifying from there synchronously would schedule a
|
|
82
|
+
* React update while a *different* component is rendering. Every subscriber reads the value
|
|
83
|
+
* through `areCoachmarksEnabled()` at render time anyway, so the notification only has to wake
|
|
84
|
+
* the components that are not already re-rendering — one microtask later is soon enough, and it
|
|
85
|
+
* keeps the write side legal from anywhere.
|
|
86
|
+
*/
|
|
87
|
+
const notifyCoachmarksEnabled = (): void => {
|
|
88
|
+
if (enabledListeners().size === 0) return;
|
|
89
|
+
void Promise.resolve().then(() => {
|
|
90
|
+
for (const listener of [...enabledListeners()]) {
|
|
91
|
+
try {
|
|
92
|
+
listener();
|
|
93
|
+
} catch {
|
|
94
|
+
// A broken subscriber must never break the kill switch for the others.
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
};
|
|
99
|
+
|
|
58
100
|
/**
|
|
59
101
|
* Set the coachmarks master switch (from the resolved feature flags). Default true (fail-open).
|
|
60
|
-
* Written by CoachmarkProvider; read imperatively by the tour arm + the overlay store
|
|
102
|
+
* Written by CoachmarkProvider; read imperatively by the tour arm + the overlay store, and
|
|
103
|
+
* SUBSCRIBED to by `useCoachmarkTour` (see `subscribeCoachmarksEnabled`).
|
|
104
|
+
*
|
|
105
|
+
* No-ops when the value is unchanged, which matters because the provider writes it on every
|
|
106
|
+
* render — without that guard every render would fan out a notification.
|
|
61
107
|
*/
|
|
62
108
|
export const setCoachmarksEnabled = (value: boolean): void => {
|
|
63
|
-
coachmarkRuntime()
|
|
109
|
+
const runtime = coachmarkRuntime();
|
|
110
|
+
if (runtime.coachmarksEnabled === value) return;
|
|
111
|
+
runtime.coachmarksEnabled = value;
|
|
112
|
+
notifyCoachmarksEnabled();
|
|
64
113
|
};
|
|
65
114
|
|
|
66
115
|
/** Whether the coachmarks module is enabled. False → tours/overlays are silently skipped. */
|
|
67
116
|
export const areCoachmarksEnabled = (): boolean => coachmarkRuntime().coachmarksEnabled;
|
|
68
117
|
|
|
118
|
+
/**
|
|
119
|
+
* Subscribe to kill-switch flips. Returns the unsubscribe function — the `useSyncExternalStore`
|
|
120
|
+
* contract, and deliberately the SAME shape as `coachmarkOverlay.subscribe` next door rather than
|
|
121
|
+
* a second pattern to keep correct.
|
|
122
|
+
*
|
|
123
|
+
* WHY IT EXISTS: the flag is a mutable `globalThis` value, so on its own it can only be SAMPLED.
|
|
124
|
+
* A tour that samples it cannot notice a tenant flipping it off mid-flight — the overlay's
|
|
125
|
+
* `show()` quietly becomes a no-op while the tour still believes it is running, so nothing paints,
|
|
126
|
+
* nothing can be tapped to advance, and no terminal path is reachable. `useCoachmarkTour`
|
|
127
|
+
* subscribes instead. The listener set lives in the SAME `globalThis` record as the flag, so every
|
|
128
|
+
* inlined copy of this module shares one subscriber list.
|
|
129
|
+
*/
|
|
130
|
+
export const subscribeCoachmarksEnabled = (listener: () => void): (() => void) => {
|
|
131
|
+
const listeners = enabledListeners();
|
|
132
|
+
listeners.add(listener);
|
|
133
|
+
return () => {
|
|
134
|
+
listeners.delete(listener);
|
|
135
|
+
};
|
|
136
|
+
};
|
|
137
|
+
|
|
69
138
|
/** Set (or clear) the injected sync gate storage. Called by CoachmarkProvider. */
|
|
70
139
|
export const setCoachmarkStorage = (storage: CoachmarkStorage | null): void => {
|
|
71
140
|
coachmarkRuntime().storage = storage;
|
|
@@ -1,8 +1,14 @@
|
|
|
1
|
-
import { useCallback, useEffect, useRef, useState } from "react";
|
|
1
|
+
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
2
2
|
|
|
3
3
|
import { coachmarkAnchors } from "./coachmarkAnchorRegistry";
|
|
4
4
|
import { coachmarkOverlay } from "./coachmarkOverlayStore";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
areCoachmarksEnabled,
|
|
7
|
+
coachmarkGateKey,
|
|
8
|
+
hasSeenGate,
|
|
9
|
+
markSeenGate,
|
|
10
|
+
subscribeCoachmarksEnabled,
|
|
11
|
+
} from "./runtime";
|
|
6
12
|
import type { CoachmarkStep } from "./types";
|
|
7
13
|
|
|
8
14
|
export interface UseCoachmarkTourOptions {
|
|
@@ -61,6 +67,15 @@ const DEFAULT_START_DELAY_MS = 3000;
|
|
|
61
67
|
* target is picked up. `onStepShown` is suppressed on that resume (last-shown
|
|
62
68
|
* step id is tracked in a ref) so a pause/resume never double-counts a step.
|
|
63
69
|
*
|
|
70
|
+
* KILL SWITCH vs PAUSE — two different gates, two different endings. `enabled` is the host's
|
|
71
|
+
* domain gate and PAUSES (position kept, gate unwritten, resumes on the same step). The tenant's
|
|
72
|
+
* coachmarks feature flag (`setCoachmarksEnabled`, from GET /v1/features) is TERMINAL: flipped off
|
|
73
|
+
* mid-tour it ends the tour and fires `onComplete`, but does NOT write the once-gate, so the tour
|
|
74
|
+
* replays on the next mount once the flag is back on. It is read through a subscription rather
|
|
75
|
+
* than sampled, because the flip has to reach a tour that is already in flight: the overlay's
|
|
76
|
+
* `show()` is a no-op while the flag is off, so a tour that carried on would paint nothing, could
|
|
77
|
+
* not be tapped to advance, and would never reach a terminal state.
|
|
78
|
+
*
|
|
64
79
|
* IMPORTANT: `steps` MUST be a stable (memoized) array. If a new array identity
|
|
65
80
|
* is passed on every render the drive effect re-runs and re-shows the current
|
|
66
81
|
* step (wasteful anchor re-resolves / overlay churn), and analytics can
|
|
@@ -97,34 +112,67 @@ export const useCoachmarkTour = (
|
|
|
97
112
|
const callbacksRef = useRef({ onStepShown, onStepEngaged, onStepDismissed });
|
|
98
113
|
callbacksRef.current = { onStepShown, onStepEngaged, onStepDismissed };
|
|
99
114
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
115
|
+
// The tenant's coachmarks kill switch, SUBSCRIBED rather than sampled so a mid-tour flip reaches
|
|
116
|
+
// a tour already in flight. See the KILL SWITCH vs PAUSE note above.
|
|
117
|
+
const coachmarksOn = useSyncExternalStore(
|
|
118
|
+
subscribeCoachmarksEnabled,
|
|
119
|
+
areCoachmarksEnabled,
|
|
120
|
+
areCoachmarksEnabled,
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The single terminal exit. `writeGate` is what separates the two ways a tour can end:
|
|
125
|
+
* • the user reached the end (or dismissed the last step) → the once-gate IS written, so a
|
|
126
|
+
* finished tour never nags again;
|
|
127
|
+
* • the kill switch went off mid-tour → the gate is NOT written. That gate belongs to a tour
|
|
128
|
+
* the user actually saw, and the module's whole kill-switch contract is that disabling
|
|
129
|
+
* never consumes it, so re-enabling replays the tour.
|
|
130
|
+
* Either way `onComplete` fires: a host that gates its own UI on the tour finishing must not be
|
|
131
|
+
* left waiting on a callback that can no longer arrive.
|
|
132
|
+
*/
|
|
133
|
+
const exit = useCallback(
|
|
134
|
+
(writeGate: boolean) => {
|
|
135
|
+
if (finishedRef.current) return;
|
|
136
|
+
finishedRef.current = true;
|
|
137
|
+
if (writeGate && showOnce) markSeenGate(coachmarkGateKey(tourId));
|
|
138
|
+
coachmarkOverlay.hide();
|
|
139
|
+
setActiveIndex(null);
|
|
140
|
+
onComplete?.();
|
|
141
|
+
},
|
|
142
|
+
[onComplete, showOnce, tourId],
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
const finish = useCallback(() => exit(true), [exit]);
|
|
108
146
|
|
|
109
147
|
// Arm once, after the delay, when enabled and the gate is unseen.
|
|
110
148
|
useEffect(() => {
|
|
111
149
|
if (!enabled || startedRef.current) return undefined;
|
|
112
150
|
// Feature kill switch: never arm while coachmarks are disabled, so no timer starts, no
|
|
113
|
-
// overlay shows, and the once-gate is NOT consumed — re-enabling replays the tour.
|
|
114
|
-
|
|
151
|
+
// overlay shows, and the once-gate is NOT consumed — re-enabling replays the tour. Reading
|
|
152
|
+
// the SUBSCRIBED value (and listing it in the deps) is what makes that last clause true
|
|
153
|
+
// without a remount: flipping it off clears a pending arm timer, flipping it back on re-arms.
|
|
154
|
+
if (!coachmarksOn) return undefined;
|
|
115
155
|
if (showOnce && hasSeenGate(coachmarkGateKey(tourId))) return undefined;
|
|
116
156
|
const timer = setTimeout(() => {
|
|
117
157
|
startedRef.current = true;
|
|
118
158
|
setActiveIndex(0);
|
|
119
159
|
}, startDelayMs);
|
|
120
160
|
return () => clearTimeout(timer);
|
|
121
|
-
}, [enabled, startDelayMs, showOnce, tourId]);
|
|
161
|
+
}, [enabled, coachmarksOn, startDelayMs, showOnce, tourId]);
|
|
122
162
|
|
|
123
163
|
// Drive the active step. Keyed on `enabled` too, so a mid-tour focus loss
|
|
124
164
|
// pauses (hide, keep position) and a return to focus resumes the same step.
|
|
125
165
|
useEffect(() => {
|
|
126
166
|
if (activeIndex === null) return undefined;
|
|
127
167
|
|
|
168
|
+
// KILL SWITCH, mid-tour: exit terminally, WITHOUT writing the once-gate, so the tour replays
|
|
169
|
+
// when it is switched back on. Checked BEFORE the pause branch below — a disable is terminal,
|
|
170
|
+
// a focus loss is not.
|
|
171
|
+
if (!coachmarksOn) {
|
|
172
|
+
exit(false);
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
|
|
128
176
|
// PAUSE: armed but the screen lost focus (enabled flipped false while in
|
|
129
177
|
// flight). Hide the overlay so a stale rect can't float over the wrong
|
|
130
178
|
// screen, but KEEP activeIndex and DON'T write the gate. When enabled
|
|
@@ -188,7 +236,7 @@ export const useCoachmarkTour = (
|
|
|
188
236
|
return () => {
|
|
189
237
|
cancelled = true;
|
|
190
238
|
};
|
|
191
|
-
}, [activeIndex, steps, finish, enabled]);
|
|
239
|
+
}, [activeIndex, steps, finish, exit, enabled, coachmarksOn]);
|
|
192
240
|
|
|
193
241
|
// Safety: clear the overlay if the screen unmounts mid-tour.
|
|
194
242
|
useEffect(
|
package/src/context/deviceId.ts
CHANGED
|
@@ -68,13 +68,22 @@ export const mintDeviceId = (): string => {
|
|
|
68
68
|
// events are the ONLY ones the server counts `min_sessions` from, so a per-launch id there is not a
|
|
69
69
|
// millisecond of noise, it is a counter that can never exceed 1.
|
|
70
70
|
|
|
71
|
-
/**
|
|
71
|
+
/**
|
|
72
|
+
* Well-known key into the runtime-global symbol registry — one auto-id registry across every bundle.
|
|
73
|
+
*
|
|
74
|
+
* @globalSlot LATCH — the REGISTRY OBJECT is created once and its identity is then stable. A second
|
|
75
|
+
* write empties `keys`/`hydrating`/`pending`, so the next surface mints a SECOND auto id for one
|
|
76
|
+
* install and the `device_key` the server joins sessions on splits in two — the halved-counter
|
|
77
|
+
* defect this registry exists to close. Its CONTENTS are live (the id per `appId` is replaced when
|
|
78
|
+
* an async hydration adopts a persisted value), so callers must re-read through
|
|
79
|
+
* `resolveAutoDeviceKey()` rather than hold the string a mount happened to see first.
|
|
80
|
+
*/
|
|
72
81
|
const AUTO_DEVICE_KEY_SLOT: unique symbol = Symbol.for("@wireai/activation:autoDeviceKeys");
|
|
73
82
|
|
|
74
83
|
/** What a hydration settled on: the id, and whether persistence actually CONFIRMED it.
|
|
75
84
|
*
|
|
76
85
|
* `durable: false` means the id lives only in this process's memory — the adapter rejected, threw,
|
|
77
|
-
* or there was no adapter at all. That distinction is the whole
|
|
86
|
+
* or there was no adapter at all. That distinction is the whole point here: a string is a string, so
|
|
78
87
|
* before 0.13.0 a caller could not tell a persisted id from a per-launch mint, and the auto-join
|
|
79
88
|
* gate (`Boolean(storage)`) was reading the PRESENCE of the prop rather than the SUCCESS of the
|
|
80
89
|
* write. See {@link hydrateDeviceIdentity}. */
|
|
@@ -131,7 +140,7 @@ export interface ResolveAutoDeviceKeyOptions {
|
|
|
131
140
|
* the write. The id is real but PROCESS-scoped, so anything that
|
|
132
141
|
* counts a device across launches must refuse it.
|
|
133
142
|
*
|
|
134
|
-
* A DEGRADED outcome also drops the registry latches so the NEXT caller starts a fresh read
|
|
143
|
+
* A DEGRADED outcome also drops the registry latches so the NEXT caller starts a fresh read. A
|
|
135
144
|
* cold-boot storage lock is transient; caching it as a verdict for the process lifetime turned a
|
|
136
145
|
* one-second problem into a whole-launch one, and nothing ever retried.
|
|
137
146
|
*/
|
|
@@ -170,7 +179,7 @@ const startHydration = (
|
|
|
170
179
|
run = Promise.resolve(degraded());
|
|
171
180
|
}
|
|
172
181
|
registry.pending.set(appId, run);
|
|
173
|
-
// Retry-on-failure
|
|
182
|
+
// Retry-on-failure: release the latches once a degraded outcome settles, so a later caller
|
|
174
183
|
// is not permanently bound to one bad read. Registered AFTER the `set` above, so the clean-up can
|
|
175
184
|
// never race ahead of the entry it is clearing.
|
|
176
185
|
void run.then((outcome) => {
|
|
@@ -238,7 +247,7 @@ export const hydrateAutoDeviceKey = async (
|
|
|
238
247
|
* The PROVENANCE-CARRYING sibling of {@link hydrateAutoDeviceKey}: the same awaited read, but it
|
|
239
248
|
* answers "is this id one this install will KEEP?" instead of only "what is the id?".
|
|
240
249
|
*
|
|
241
|
-
* WHY IT EXISTS
|
|
250
|
+
* WHY IT EXISTS. `<WireOnboarding>` gated auto-injection on `Boolean(storage)` — the presence of
|
|
242
251
|
* the prop — because a string carries no provenance and there was nothing better to gate on. A
|
|
243
252
|
* REJECTING adapter therefore injected a fresh `wdev_*` on every launch: strictly worse than
|
|
244
253
|
* injecting nothing, since the server counts `min_sessions` by distinct opens grouped on `device_key`,
|
|
@@ -18,8 +18,19 @@ import { defaultWireFeatures } from "./defaults";
|
|
|
18
18
|
import { useWireFeatures } from "./useWireFeatures";
|
|
19
19
|
import type { WireFeatures, WireFeaturesConfig } from "./types";
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* @globalSlot LATCH — the React context object is minted by the first copy of this module to load
|
|
23
|
+
* and every later copy ADOPTS it (the `if (!…)` guard below). A second write would re-split the
|
|
24
|
+
* identity the guard exists to keep whole: the provider would publish into one context while a
|
|
25
|
+
* subpath bundle's `useContext` read another, and `useResolvedFeatures`' null fallback would serve
|
|
26
|
+
* the all-on defaults with no crash and no warning. Because it is a latch, and only because of
|
|
27
|
+
* that, the module-local `const WireFeaturesContext` below may cache it.
|
|
28
|
+
*/
|
|
21
29
|
const CONTEXT_SYMBOL = Symbol.for("wireai.features.context");
|
|
22
|
-
|
|
30
|
+
// `globalThis`, not `global`: the other nine `Symbol.for` slots in this kit all use it, and `global`
|
|
31
|
+
// simply does not exist in a plain browser (RN-web / SSR-in-the-browser), where this line would
|
|
32
|
+
// throw a ReferenceError before the provider could render anything.
|
|
33
|
+
const globalObj = globalThis as any;
|
|
23
34
|
|
|
24
35
|
if (!globalObj[CONTEXT_SYMBOL]) {
|
|
25
36
|
globalObj[CONTEXT_SYMBOL] = createContext<WireFeatures | null>(null);
|