@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.
Files changed (67) hide show
  1. package/AGENTS.md +95 -20
  2. package/CHANGELOG.md +707 -0
  3. package/INTEGRATION_PROMPT.md +61 -23
  4. package/README.md +100 -25
  5. package/dist/analytics/index.d.mts +32 -10
  6. package/dist/analytics/index.d.ts +32 -10
  7. package/dist/analytics/index.js +288 -127
  8. package/dist/analytics/index.js.map +1 -1
  9. package/dist/analytics/index.mjs +288 -127
  10. package/dist/analytics/index.mjs.map +1 -1
  11. package/dist/coachmarks/index.d.mts +14 -0
  12. package/dist/coachmarks/index.d.ts +14 -0
  13. package/dist/coachmarks/index.js +73 -20
  14. package/dist/coachmarks/index.js.map +1 -1
  15. package/dist/coachmarks/index.mjs +73 -20
  16. package/dist/coachmarks/index.mjs.map +1 -1
  17. package/dist/{currentSession-orZy5p1e.d.mts → currentSession-Bz7G6lno.d.mts} +25 -35
  18. package/dist/{currentSession-CFSRZ2wg.d.ts → currentSession-z-CZ55ad.d.ts} +25 -35
  19. package/dist/index.d.mts +5 -2
  20. package/dist/index.d.ts +5 -2
  21. package/dist/index.js +125 -36
  22. package/dist/index.js.map +1 -1
  23. package/dist/index.mjs +125 -36
  24. package/dist/index.mjs.map +1 -1
  25. package/dist/questionnaire/index.d.mts +0 -13
  26. package/dist/questionnaire/index.d.ts +0 -13
  27. package/dist/questionnaire/index.js +154 -43
  28. package/dist/questionnaire/index.js.map +1 -1
  29. package/dist/questionnaire/index.mjs +155 -44
  30. package/dist/questionnaire/index.mjs.map +1 -1
  31. package/dist/reviews/index.js +159 -91
  32. package/dist/reviews/index.js.map +1 -1
  33. package/dist/reviews/index.mjs +160 -92
  34. package/dist/reviews/index.mjs.map +1 -1
  35. package/dist/showcase/index.js +59 -18
  36. package/dist/showcase/index.js.map +1 -1
  37. package/dist/showcase/index.mjs +60 -19
  38. package/dist/showcase/index.mjs.map +1 -1
  39. package/llms.txt +9 -9
  40. package/package.json +6 -9
  41. package/src/analytics/currentSession.ts +141 -4
  42. package/src/analytics/index.ts +6 -1
  43. package/src/analytics/reportClientEvent.ts +19 -10
  44. package/src/analytics/useAnalytics.ts +74 -15
  45. package/src/analytics/wireDoctor.ts +152 -7
  46. package/src/coachmarks/CoachmarkProvider.tsx +26 -5
  47. package/src/coachmarks/runtime.ts +53 -0
  48. package/src/coachmarks/useCoachmarkTour.ts +51 -1
  49. package/src/context/deviceId.ts +49 -15
  50. package/src/features/WireFeaturesProvider.tsx +72 -12
  51. package/src/features/fetchWireFeatures.ts +49 -11
  52. package/src/features/useWireFeatures.ts +39 -3
  53. package/src/identity/identityRecord.ts +15 -2
  54. package/src/questionnaire/QuestionnaireGate.tsx +40 -1
  55. package/src/questionnaire/transport.ts +22 -8
  56. package/src/questionnaire/useQuestionnaireGate.ts +58 -7
  57. package/src/reviews/ReviewGate.tsx +39 -0
  58. package/src/reviews/idempotency.ts +38 -0
  59. package/src/reviews/runtime.ts +39 -10
  60. package/src/reviews/transport.ts +22 -8
  61. package/src/reviews/useReviewGate.ts +57 -7
  62. package/src/session-analytics/lifecycle.ts +16 -0
  63. package/src/session-analytics/useLifecycleEvents.ts +30 -2
  64. package/src/session-analytics/useSessionStart.ts +22 -2
  65. package/src/showcase/FeatureShowcase.tsx +50 -3
  66. package/src/types.ts +5 -4
  67. package/src/utils/withDeadline.ts +70 -0
@@ -15,7 +15,7 @@
15
15
  import React, { createContext, useContext, useMemo } from "react";
16
16
 
17
17
  import { defaultWireFeatures } from "./defaults";
18
- import { useWireFeatures } from "./useWireFeatures";
18
+ import { useWireFeaturesState, type WireFeaturesState } from "./useWireFeatures";
19
19
  import type { WireFeatures, WireFeaturesConfig } from "./types";
20
20
 
21
21
  /**
@@ -26,16 +26,49 @@ import type { WireFeatures, WireFeaturesConfig } from "./types";
26
26
  * the all-on defaults with no crash and no warning. Because it is a latch, and only because of
27
27
  * that, the module-local `const WireFeaturesContext` below may cache it.
28
28
  */
29
- const CONTEXT_SYMBOL = Symbol.for("wireai.features.context");
29
+ const CONTEXT_SYMBOL: unique symbol = Symbol.for("wireai.features.context");
30
30
  // `globalThis`, not `global`: the other nine `Symbol.for` slots in this kit all use it, and `global`
31
31
  // simply does not exist in a plain browser (RN-web / SSR-in-the-browser), where this line would
32
32
  // throw a ReferenceError before the provider could render anything.
33
- const globalObj = globalThis as any;
33
+ //
34
+ // The slot is TYPED rather than cast through `any` (which is banned, `ai_rules/rules/core.md`). The
35
+ // cast was not cosmetic: with `any` on both sides, BOTH contexts below were inferred `any`, so every
36
+ // `<X.Provider value={...}>` in this file and every `useContext` read of them was type-unchecked —
37
+ // a provider publishing the wrong shape into either one would have compiled. Declaring each key
38
+ // `unique symbol` is what lets it appear as a property key in a type at all.
39
+ type GlobalWithFeaturesContext = typeof globalThis & {
40
+ [CONTEXT_SYMBOL]?: React.Context<WireFeatures | null>;
41
+ };
42
+ const featuresContextGlobal = globalThis as GlobalWithFeaturesContext;
34
43
 
35
- if (!globalObj[CONTEXT_SYMBOL]) {
36
- globalObj[CONTEXT_SYMBOL] = createContext<WireFeatures | null>(null);
44
+ if (!featuresContextGlobal[CONTEXT_SYMBOL]) {
45
+ featuresContextGlobal[CONTEXT_SYMBOL] = createContext<WireFeatures | null>(null);
37
46
  }
38
- const WireFeaturesContext = globalObj[CONTEXT_SYMBOL];
47
+ const WireFeaturesContext = featuresContextGlobal[CONTEXT_SYMBOL];
48
+
49
+ /**
50
+ * @globalSlot LATCH — the SECOND context, carrying only "has the provider's fetch answered yet?".
51
+ *
52
+ * WHY A SECOND CONTEXT AND NOT A WIDER VALUE: the value published above is a `WireFeatures`, and
53
+ * tsup inlines a copy of this module per subpath bundle. Changing the published SHAPE would mean a
54
+ * provider from one copy publishing an object a consumer from another copy reads as flags —
55
+ * silently wrong flags rather than a crash. An extra context is additive: a consumer that reads it
56
+ * without a provider (or under an OLDER copy's provider that publishes nothing here) gets the
57
+ * default below.
58
+ *
59
+ * THE DEFAULT IS `true`, AND FAIL-OPEN DEPENDS ON IT: "settled" gates a surface's first render, so
60
+ * defaulting to `false` would let a missing provider suppress a gate forever. `true` means "nothing
61
+ * above is going to answer this for you", which is the truth when there is no provider at all.
62
+ */
63
+ const SETTLED_SYMBOL: unique symbol = Symbol.for("wireai.features.settled.context");
64
+ type GlobalWithSettledContext = typeof globalThis & {
65
+ [SETTLED_SYMBOL]?: React.Context<boolean>;
66
+ };
67
+ const settledContextGlobal = globalThis as GlobalWithSettledContext;
68
+ if (!settledContextGlobal[SETTLED_SYMBOL]) {
69
+ settledContextGlobal[SETTLED_SYMBOL] = createContext<boolean>(true);
70
+ }
71
+ const WireFeaturesSettledContext = settledContextGlobal[SETTLED_SYMBOL];
39
72
 
40
73
  export interface WireFeaturesProviderProps {
41
74
  /** Tenant creds (+ optional storage) to fetch the flags once for the whole tree. */
@@ -59,10 +92,16 @@ export const WireFeaturesProvider: React.FC<WireFeaturesProviderProps> = ({
59
92
  }) => {
60
93
  // Always call the hook (rules of hooks); when `flags` is supplied we pass no config so it
61
94
  // never fetches and the explicit flags win below.
62
- const fetched = useWireFeatures(flags ? undefined : config);
63
- const value = flags ?? fetched;
95
+ const fetched = useWireFeaturesState(flags ? undefined : config);
96
+ const value = flags ?? fetched.flags;
97
+ // Explicit `flags` are an answer by definition; otherwise the fetch decides.
98
+ const settled = flags ? true : fetched.settled;
64
99
  const stable = useMemo(() => value, [value]);
65
- return <WireFeaturesContext.Provider value={stable}>{children}</WireFeaturesContext.Provider>;
100
+ return (
101
+ <WireFeaturesSettledContext.Provider value={settled}>
102
+ <WireFeaturesContext.Provider value={stable}>{children}</WireFeaturesContext.Provider>
103
+ </WireFeaturesSettledContext.Provider>
104
+ );
66
105
  };
67
106
 
68
107
  WireFeaturesProvider.displayName = "WireFeaturesProvider";
@@ -87,11 +126,32 @@ export interface ResolveFeaturesOptions {
87
126
  * 4. the all-on defaults.
88
127
  * Always fail-open, always a stable reference for a given value (so it's safe in effect deps).
89
128
  */
90
- export const useResolvedFeatures = (options?: ResolveFeaturesOptions): WireFeatures => {
129
+ export const useResolvedFeatures = (options?: ResolveFeaturesOptions): WireFeatures =>
130
+ useResolvedFeaturesState(options).flags;
131
+
132
+ /**
133
+ * The same resolution, plus whether the value is an ANSWER or still the optimistic default —
134
+ * `useResolvedFeatures` is the flags-only wrapper over this.
135
+ *
136
+ * A gated surface whose first render is irreversible (the review gate fires `review_prompt_shown`
137
+ * and can take a review row the moment it appears) must hold that first render until `settled`,
138
+ * or a tenant that turned the module OFF still gets one impression per launch while the fetch is
139
+ * in flight — the kill switch working everywhere except the one moment it is read.
140
+ *
141
+ * `settled` follows the same precedence as the flags: explicit `flags` are an answer; under a
142
+ * provider it is the PROVIDER's fetch that has to answer; otherwise it is this surface's own lazy
143
+ * fetch, which is already `true` when there is nothing to fetch. Deliberately INTERNAL (not in the
144
+ * root barrel): it exists for the kit's own gated surfaces, and the public flags-only contract is
145
+ * unchanged.
146
+ */
147
+ export const useResolvedFeaturesState = (options?: ResolveFeaturesOptions): WireFeaturesState => {
91
148
  const ctx = useWireFeaturesContext();
149
+ const ctxSettled: boolean = useContext(WireFeaturesSettledContext);
92
150
  // Only lazily fetch when nothing else provides the flags — otherwise pass no config so the
93
151
  // hook stays inert (defaults, no network). The hook is always called (rules of hooks).
94
152
  const shouldFetch = !options?.flags && ctx == null;
95
- const fetched = useWireFeatures(shouldFetch ? options?.config : undefined);
96
- return options?.flags ?? ctx ?? fetched ?? defaultWireFeatures;
153
+ const fetched = useWireFeaturesState(shouldFetch ? options?.config : undefined);
154
+ const flags = options?.flags ?? ctx ?? fetched.flags ?? defaultWireFeatures;
155
+ const settled = options?.flags ? true : ctx != null ? ctxSettled : fetched.settled;
156
+ return useMemo(() => ({ flags, settled }), [flags, settled]);
97
157
  };
@@ -30,23 +30,62 @@ const failOpen = async (
30
30
  return cached?.features ?? defaultWireFeatures;
31
31
  };
32
32
 
33
- /** GET with a hard timeout via AbortController (falls back to no-abort where unsupported). */
34
- const fetchWithTimeout = async (
33
+ /** "The exchange produced nothing usable" a non-2xx, or a deadline the body never met. Both mean
34
+ * the caller must fail OPEN, and neither is distinguishable to it. A symbol so it can never
35
+ * collide with a JSON body the server actually sent. */
36
+ const NO_ANSWER: unique symbol = Symbol("wire-features-no-answer");
37
+
38
+ /**
39
+ * GET **and read the body** under ONE deadline. Never pends longer than `timeoutMs`.
40
+ *
41
+ * ── THE DEFECT THIS CLOSES ─────────────────────────────────────────────────────────────────────
42
+ * The timer used to be cleared in a `finally` around the `fetch` alone, so it died the moment the
43
+ * HEADERS landed and `await res.json()` ran with NO ceiling at all. A 200 whose body then stalls —
44
+ * a half-open connection, a proxy that flushes headers and hangs, a captive portal — left this
45
+ * function pending forever. `settled` never flips, so `useReviewGate` and `useQuestionnaireGate`
46
+ * never become ready and BOTH gates stay dark for the life of the process, silently. That INVERTS
47
+ * this module's own stated contract at the top of the file: fail-open, never fail-dark.
48
+ *
49
+ * SCOPE, honestly: on native RN `fetch` is the XHR polyfill and the body is already buffered by the
50
+ * time the promise resolves, so this was effectively unreachable there. On RN-Web / Expo web — which
51
+ * the kit explicitly supports (`WireFeaturesProvider`) — it is real.
52
+ *
53
+ * TWO mechanisms, deliberately, because they fail differently. The abort tears the socket DOWN
54
+ * (a stalled stream is not just un-awaited, it is cancelled); the race guarantees this promise
55
+ * SETTLES even on a runtime that ignores `signal` once the body has started. A ceiling that depends
56
+ * on the host honouring abort is not a ceiling on a surface whose whole contract is "never dark".
57
+ */
58
+ const fetchFeaturesJson = async (
35
59
  url: string,
36
60
  apiKey: string,
37
61
  timeoutMs: number,
38
- ): Promise<Response> => {
62
+ ): Promise<unknown> => {
39
63
  const controller =
40
64
  typeof AbortController !== "undefined" ? new AbortController() : undefined;
41
- const timer = setTimeout(() => controller?.abort(), timeoutMs);
42
- try {
43
- return await fetch(url, {
65
+ let timer: ReturnType<typeof setTimeout> | undefined;
66
+ const expired = new Promise<typeof NO_ANSWER>((resolve) => {
67
+ timer = setTimeout(() => {
68
+ controller?.abort();
69
+ resolve(NO_ANSWER);
70
+ }, timeoutMs);
71
+ });
72
+ const exchange = (async (): Promise<unknown> => {
73
+ const res = await fetch(url, {
44
74
  method: "GET",
45
75
  headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` },
46
76
  signal: controller?.signal,
47
77
  });
78
+ // 401 (bad/rotated key), 5xx, any non-2xx → last-known, never dark.
79
+ if (!res || !res.ok) return NO_ANSWER;
80
+ return (await res.json()) as unknown; // throws on malformed body → caught by the caller
81
+ })();
82
+ try {
83
+ return await Promise.race([exchange, expired]);
48
84
  } finally {
49
85
  clearTimeout(timer);
86
+ // The loser can still settle after the race is decided and nothing is awaiting it. An
87
+ // unobserved rejection is a hard crash on some hosts, so it gets a handler either way.
88
+ void exchange.catch(() => {});
50
89
  }
51
90
  };
52
91
 
@@ -72,15 +111,14 @@ export const fetchWireFeatures = async (
72
111
  const timeoutMs = config.timeoutMs && config.timeoutMs > 0 ? config.timeoutMs : DEFAULT_TIMEOUT_MS;
73
112
 
74
113
  try {
75
- const res = await fetchWithTimeout(url, config.apiKey, timeoutMs);
76
- // 401 (bad/rotated key), 5xx, any non-2xx → last-known, never dark.
77
- if (!res || !res.ok) return failOpen(storage, key);
78
- const json: unknown = await res.json(); // throws on malformed body → caught below
114
+ const json: unknown = await fetchFeaturesJson(url, config.apiKey, timeoutMs);
115
+ // A non-2xx, or a headers-then-stall body that never met the deadline → last-known, never dark.
116
+ if (json === NO_ANSWER) return failOpen(storage, key);
79
117
  const features = parseWireFeatures(json);
80
118
  if (storage) writeCachedFeatures(storage, key, features);
81
119
  return features;
82
120
  } catch {
83
- // Timeout/abort, network error, missing fetch, or malformed JSON — all fail open.
121
+ // Abort, network error, missing fetch, or malformed JSON — all fail open.
84
122
  return failOpen(storage, key);
85
123
  }
86
124
  };
@@ -5,13 +5,21 @@
5
5
  * network resolves. When no `serverUrl`+`apiKey` is supplied it never fetches and just returns
6
6
  * defaults, so a host that never adopts flags sees ZERO behavior change and ZERO network calls.
7
7
  *
8
+ * ⚠️ THE OPTIMISTIC DEFAULT IS A GUESS, AND SOME CONSUMERS CANNOT ACT ON A GUESS. Fail-open is the
9
+ * right resting state, but "all on because we have not asked yet" and "all on because the tenant
10
+ * says so" are different answers and the returned flags cannot tell them apart. A surface whose
11
+ * first render is IRREVERSIBLE — the review gate posts a `review_prompt_shown` and can take a row
12
+ * the moment it appears — needs the difference, or a tenant with `review.enabled: false` still gets
13
+ * one impression on every launch before the answer lands. {@link useWireFeaturesState} exposes it as
14
+ * `settled`; `useWireFeatures` is the flags-only wrapper and is unchanged.
15
+ *
8
16
  * frequent_rules #11 (unstable inline props): the config is idiomatically passed inline
9
17
  * (`config={{ serverUrl, apiKey }}`), a fresh identity every render. We stabilize the fetch key
10
18
  * by VALUE so the effect fires once per real credential change, and we only swap the returned
11
19
  * flags object when its VALUES change — so a host that lists the returned flags in an effect dep
12
20
  * array settles in a bounded number of commits instead of looping (enforced by the canary suite).
13
21
  */
14
- import { useEffect, useRef, useState } from "react";
22
+ import { useEffect, useMemo, useRef, useState } from "react";
15
23
 
16
24
  import { defaultWireFeatures, featuresEqual } from "./defaults";
17
25
  import { fetchWireFeatures } from "./fetchWireFeatures";
@@ -28,16 +36,35 @@ const useStableValue = <T,>(value: T, isEqual: (a: T, b: T) => boolean): T => {
28
36
  const sameFetchKey = (a: WireFeaturesConfig | undefined, b: WireFeaturesConfig | undefined): boolean =>
29
37
  a?.serverUrl === b?.serverUrl && a?.apiKey === b?.apiKey && a?.appId === b?.appId;
30
38
 
31
- export const useWireFeatures = (config?: WireFeaturesConfig): WireFeatures => {
39
+ /**
40
+ * The flags PLUS whether they are an answer yet — the shape `useWireFeatures` returns half of.
41
+ *
42
+ * `settled: false` means "nobody has answered; these are the optimistic defaults". It becomes
43
+ * `true` the moment the fetch resolves (fail-open included: an unreachable control plane resolves
44
+ * to the defaults and that IS a settled answer, because it is the answer we are keeping), and it
45
+ * starts `true` whenever there is nothing to wait for — no creds, so no flag can ever arrive.
46
+ */
47
+ export interface WireFeaturesState {
48
+ flags: WireFeatures;
49
+ /** `false` only while a fetch that could still change the answer is in flight. */
50
+ settled: boolean;
51
+ }
52
+
53
+ export const useWireFeaturesState = (config?: WireFeaturesConfig): WireFeaturesState => {
32
54
  const stableConfig = useStableValue(config, sameFetchKey);
33
55
  const [flags, setFlags] = useState<WireFeatures>(defaultWireFeatures);
34
56
 
35
57
  const canFetch = !!stableConfig?.serverUrl && !!stableConfig?.apiKey;
58
+ // Nothing to fetch → the defaults are already the final answer, so a consumer that waits for an
59
+ // answer must not wait forever. Only ever moves to `true`: a later credential change refreshes a
60
+ // KNOWN answer, and un-answering it would re-hide a surface the user is already looking at.
61
+ const [settled, setSettled] = useState<boolean>(!canFetch);
36
62
 
37
63
  useEffect(() => {
38
64
  if (!canFetch) {
39
65
  // No creds → stay on the all-on defaults (and reset if creds were removed).
40
66
  setFlags((prev) => (featuresEqual(prev, defaultWireFeatures) ? prev : defaultWireFeatures));
67
+ setSettled(true);
41
68
  return;
42
69
  }
43
70
  let alive = true;
@@ -47,11 +74,20 @@ export const useWireFeatures = (config?: WireFeaturesConfig): WireFeatures => {
47
74
  // Only adopt a NEW reference on a real value change, so a host effect keyed on the
48
75
  // returned flags doesn't loop (frequent_rules #11).
49
76
  setFlags((prev) => (featuresEqual(prev, next) ? prev : next));
77
+ // Settled on EVERY resolution, including the fail-open one: `fetchWireFeatures` swallows a
78
+ // timeout / 401 / 5xx into the defaults, and that fallback is the final answer, not a
79
+ // pending one. This is what keeps fail-open intact — a broken control plane resolves fast
80
+ // and permissive, it does not leave a gated surface waiting.
81
+ setSettled(true);
50
82
  });
51
83
  return () => {
52
84
  alive = false;
53
85
  };
54
86
  }, [canFetch, stableConfig]);
55
87
 
56
- return flags;
88
+ return useMemo(() => ({ flags, settled }), [flags, settled]);
57
89
  };
90
+
91
+ /** The flags alone — the original hook, unchanged for every consumer that cannot act on `settled`. */
92
+ export const useWireFeatures = (config?: WireFeaturesConfig): WireFeatures =>
93
+ useWireFeaturesState(config).flags;
@@ -96,6 +96,20 @@ const provenanceRegistry = (): ProvenanceRegistry => {
96
96
  const provenanceKey = (space: IdentitySpace, scope?: string): string =>
97
97
  `${space}:${scope ?? "default"}`;
98
98
 
99
+ /**
100
+ * Is this candidate a usable identity value? A string with at least one non-whitespace character.
101
+ *
102
+ * ⛔ PURE, and that is the whole point of it existing separately from {@link resolveIdentity}: it is
103
+ * the predicate a READ-ONLY caller needs. `resolveIdentity` cannot serve that caller, because for a
104
+ * `host`-sourced input it WRITES to the process provenance registry — so merely asking it "is this a
105
+ * usable key?" would enrol the asker in the census it was only trying to read. That is the exact
106
+ * defect 0.15.1 fixed (a propless reader minting into `registry.keys` made the census read two, and
107
+ * the reader answered `undefined` forever), so the predicate is shared rather than re-typed:
108
+ * `resolveIdentity` below is its only other caller, so the two can never drift.
109
+ */
110
+ export const isUsableIdentityValue = (value: unknown): value is string =>
111
+ typeof value === "string" && value.trim().length > 0;
112
+
99
113
  /**
100
114
  * Build an {@link IdentityRecord} from a candidate value, or `undefined` when there is nothing usable
101
115
  * (a non-string, or blank after trimming) — so a caller can `if (record)`-gate instead of guessing
@@ -107,9 +121,8 @@ const provenanceKey = (space: IdentitySpace, scope?: string): string =>
107
121
  * key" from a silent third id space into a warnable condition. Never throws.
108
122
  */
109
123
  export const resolveIdentity = (input: ResolveIdentityInput): IdentityRecord | undefined => {
110
- if (typeof input.value !== "string") return undefined;
124
+ if (!isUsableIdentityValue(input.value)) return undefined;
111
125
  const value = input.value.trim();
112
- if (!value) return undefined;
113
126
  const durable = input.durable ?? input.source === "host";
114
127
  if (input.source === "host") {
115
128
  provenanceRegistry().host.set(provenanceKey(input.space, input.scope), value);
@@ -236,6 +236,31 @@ const _QuestionnaireGate: React.FC<QuestionnaireGateProps> = ({
236
236
  const postedRef = useRef(false);
237
237
  /** The last submission fired with no answer yet. Non-null after an UNSENT post = still owed. */
238
238
  const unackedRef = useRef<QuestionnaireSubmission | null>(null);
239
+ /**
240
+ * THE NET WAS INERT WHILE THE POST WAS IN FLIGHT, which is precisely when it is needed.
241
+ *
242
+ * This is the review-gate defect on its second copy: 0.15.1 closed it in `reviews/ReviewGate.tsx`
243
+ * and left the twin here open. The latch is taken OPTIMISTICALLY and only released in the `.then`
244
+ * below, while `finish` auto-closes the modal 1500ms after the post. So on any network slower than
245
+ * that — the offline answerer, a stalled radio, a captive portal — the unmount ran with the latch
246
+ * still CLOSED: `recoverRef`'s only branch requires `!postedRef.current`, so it no-opped, and the
247
+ * post later resolved `"unsent"` with nothing mounted to notice. The host's `onResolved` →
248
+ * `markResolved` has ALREADY written the permanent `wire_questionnaire_<id>_<ver>_seen` key, so
249
+ * that user is never asked again and their answers are gone. Silent. The existing canary missed it
250
+ * because every case awaits the submit BEFORE unmounting — the fast path, where the latch has
251
+ * already reopened.
252
+ *
253
+ * So the unmount hands the recovery to the promise: when it finds a post STILL IN FLIGHT — the one
254
+ * state its branch cannot serve — it arms `recoverOnSettleRef`, and the `.then` below does the
255
+ * re-post if nothing reached the server. It is armed ONLY in that state, so the fast path is
256
+ * untouched and still recovers exactly once, in the net. `recoveredRef` bounds this to exactly ONE
257
+ * extra post — the same in-process ceiling the net always had, never a retry loop.
258
+ */
259
+ const recoverOnSettleRef = useRef(false);
260
+ const recoveredRef = useRef(false);
261
+ /** Latest `postOnce`, so the recovery above can call it from inside its own promise chain
262
+ * (the same ref convention `recoverRef` below uses) without a self-referencing callback. */
263
+ const postOnceRef = useRef<(body: QuestionnaireSubmission) => void>(() => {});
239
264
  const postOnce = useCallback(
240
265
  (body: QuestionnaireSubmission) => {
241
266
  if (postedRef.current) return;
@@ -245,6 +270,12 @@ const _QuestionnaireGate: React.FC<QuestionnaireGateProps> = ({
245
270
  if (result === "unsent") {
246
271
  // Nothing reached the server → un-latch, so these answers can still go out.
247
272
  postedRef.current = false;
273
+ // The gate is already gone: the unmount net ran while this post was still in flight and
274
+ // found the latch closed, so nothing else will ever recover this row. Do it here, once.
275
+ if (recoverOnSettleRef.current && !recoveredRef.current) {
276
+ recoveredRef.current = true;
277
+ postOnceRef.current(body);
278
+ }
248
279
  return;
249
280
  }
250
281
  // `accepted` OR `rejected`: the server answered, so it owns this row. Never re-post it.
@@ -253,6 +284,7 @@ const _QuestionnaireGate: React.FC<QuestionnaireGateProps> = ({
253
284
  },
254
285
  [target, id],
255
286
  );
287
+ postOnceRef.current = postOnce;
256
288
 
257
289
  /**
258
290
  * The recovery point for a submission the server never ANSWERED — never for one it answered
@@ -268,7 +300,14 @@ const _QuestionnaireGate: React.FC<QuestionnaireGateProps> = ({
268
300
  const recoverRef = useRef<() => void>(() => {});
269
301
  recoverRef.current = () => {
270
302
  const undelivered = unackedRef.current;
271
- if (undelivered && !postedRef.current) postOnce(undelivered);
303
+ if (undelivered && !postedRef.current) {
304
+ postOnce(undelivered);
305
+ return;
306
+ }
307
+ // STILL IN FLIGHT (latch closed, row unanswered) — the state that made this net inert. The
308
+ // branch above cannot act from here, so the pending post is asked to recover itself when it
309
+ // settles unsent. Nothing else is scheduled to look at this row again.
310
+ if (postedRef.current && undelivered) recoverOnSettleRef.current = true;
272
311
  };
273
312
  React.useEffect(() => () => recoverRef.current(), []);
274
313
 
@@ -10,6 +10,7 @@
10
10
  * non-2xx (incl. 404), a missing `fetch`, or bad JSON all resolve to null, so a host wiring
11
11
  * pure server-directed firing simply shows nothing - exactly the reviews decision seam.
12
12
  */
13
+ import { DEADLINE_EXPIRED, withDeadline } from "../utils/withDeadline";
13
14
  import type { SubmitResult } from "../utils/submitResult";
14
15
  import type {
15
16
  QuestionnaireDecisionResponse,
@@ -63,11 +64,19 @@ export const submitQuestionnaireResponse = async (
63
64
  const url = `${base}/v1/questionnaires/${encodeURIComponent(id)}/responses`;
64
65
  const headers: Record<string, string> = { "Content-Type": "application/json" };
65
66
  if (target.apiKey) headers.Authorization = `Bearer ${target.apiKey}`;
66
- const res = await fetch(url, {
67
- method: "POST",
68
- headers,
69
- body: JSON.stringify(submission),
70
- });
67
+ // Under a deadline (see `utils/withDeadline`): a hung POST used to hold `QuestionnaireGate`'s
68
+ // `postOnce` latch closed for the ~60s platform default, which is exactly the window the
69
+ // unmount recovery net cannot re-post inside.
70
+ const res = await withDeadline((signal) =>
71
+ fetch(url, {
72
+ method: "POST",
73
+ headers,
74
+ body: JSON.stringify(submission),
75
+ signal,
76
+ }),
77
+ );
78
+ // A deadline that expired is not a rejection: nothing reached the server, so it stays re-postable.
79
+ if (res === DEADLINE_EXPIRED) return "unsent";
71
80
  // No response object at all is not an answer — treat it as nothing having reached the server
72
81
  // rather than as a rejection, or a stubbed-out `fetch` would silently latch the answers away.
73
82
  if (!res) return "unsent";
@@ -107,9 +116,14 @@ export const fetchQuestionnaireDecision = async (
107
116
  const url = `${base}/v1/questionnaires/decision${qs ? `?${qs}` : ""}`;
108
117
  const headers: Record<string, string> = {};
109
118
  if (target.apiKey) headers.Authorization = `Bearer ${target.apiKey}`;
110
- const res = await fetch(url, { headers });
111
- if (!res || !res.ok) return null;
112
- const json = (await res.json()) as QuestionnaireDecisionResponse | null;
119
+ // The BODY read is inside the deadline too: a ceiling cleared when the headers land is not a
120
+ // ceiling (the `fetchWireFeatures` lesson see `utils/withDeadline`).
121
+ const json = await withDeadline(async (signal): Promise<QuestionnaireDecisionResponse | null> => {
122
+ const res = await fetch(url, { headers, signal });
123
+ if (!res || !res.ok) return null;
124
+ return (await res.json()) as QuestionnaireDecisionResponse | null;
125
+ });
126
+ if (json === DEADLINE_EXPIRED) return null;
113
127
  // A body without a boolean `fire` is not a decision. Guard it explicitly (mirrors
114
128
  // `fetchReviewDecision`) rather than letting `{}` through as a truthy object a gate would treat
115
129
  // as a verdict (`{}.fire === undefined` is falsy, so it would silently read as "never fire").
@@ -13,10 +13,11 @@
13
13
  * the popup. The global `isTestingCoachmark` flag force-shows the gate for QA replay, and the
14
14
  * `questionnaire` master switch (a disabled tenant) wins over everything.
15
15
  */
16
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
16
+ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
17
17
 
18
+ import { getCurrentSessionId, subscribeCurrentSessionId } from "../analytics/currentSession";
18
19
  import { hasSeenGate, isCoachmarkTesting, markSeenGate } from "../coachmarks/runtime";
19
- import { useResolvedFeatures } from "../features/WireFeaturesProvider";
20
+ import { useResolvedFeaturesState } from "../features/WireFeaturesProvider";
20
21
  import { sameDecision, shallowEqual } from "../reviews/equality";
21
22
  import { decideQuestionnaire, evaluateGate, resolveRules } from "./decision";
22
23
  import {
@@ -64,8 +65,20 @@ export const useQuestionnaireGate = ({
64
65
 
65
66
  // The questionnaire MASTER switch. Disabled → the gate never fires, composing as an extra
66
67
  // AND over the local rules + the server decision seam below (fail-open: defaults to enabled).
67
- const featureEnabled = useResolvedFeatures({ flags: features, config: featuresConfig })
68
- .questionnaire.enabled;
68
+ //
69
+ // `settled` is read too, and it is the half that makes the switch real — the same asymmetry
70
+ // `useReviewGate` closed, on the same seam. The flags start on the all-on defaults and swap only
71
+ // when the fetch resolves, so `featureEnabled` is `true` on the FIRST render of every mount,
72
+ // including a tenant with `questionnaire.enabled: false`. With no `timeoutFallbackMs` the local
73
+ // wait is skipped as well (`elapsed` starts `true`), so the gate could become visible, fire
74
+ // `questionnaire_prompt_shown`, and POST a response from a user the tenant had switched off. The
75
+ // comment below ("must never see it") was falsified by its own async default. Holding the FIRST
76
+ // impression until an answer exists is what honours the switch.
77
+ const { flags: resolvedFeatures, settled: featuresSettled } = useResolvedFeaturesState({
78
+ flags: features,
79
+ config: featuresConfig,
80
+ });
81
+ const featureEnabled = resolvedFeatures.questionnaire.enabled;
69
82
 
70
83
  const testing = isTesting ?? isCoachmarkTesting();
71
84
  const seenKey = questionnaireSeenKey(
@@ -76,16 +89,48 @@ export const useQuestionnaireGate = ({
76
89
  const sessionsKey = questionnaireSessionsKey(config.id);
77
90
  const sessionOpenKey = questionnaireSessionOpenKey(config.id);
78
91
 
92
+ // ── FOLLOW real app-opens, do not sample one ────────────────────────────────────────────────
93
+ //
94
+ // `bumpSessionCount` is IDEMPOTENT per app-open and moves once per open — but it used to be reached
95
+ // ONLY from the `useState` initializer below, which runs once per component INSTANCE. On a screen
96
+ // that never unmounts (a home feed, a tab that stays alive) that meant once per PROCESS, and iOS
97
+ // suspends rather than kills: `useLifecycleEvents` fires a fresh `app.session_started` on every
98
+ // foreground past its threshold and the SERVER's `min_sessions` advances, while `wire_questionnaire_<id>_sessions`
99
+ // stayed at its launch value and the LOCAL `minSessions` rule was unsatisfiable for the life of the
100
+ // app. Silent, and in the safe direction (a prompt that never shows), which is why it survived —
101
+ // `sessionCountAcrossOpens.test.ts` proved the FUNCTION advances and could not see that the HOOK
102
+ // never asked it again.
103
+ //
104
+ // So subscribe to the registry that knows. A notification naming the same open is a no-op by
105
+ // construction (`bumpSessionCount` reads its stored count back), so the cold-start ordering this
106
+ // pin exists for is untouched.
107
+ const openSessionId = useSyncExternalStore(
108
+ subscribeCurrentSessionId,
109
+ getCurrentSessionId,
110
+ getCurrentSessionId,
111
+ );
112
+ // Read through a ref: a host that passes `storage={{ … }}` inline (idiomatic React, and what
113
+ // `frequent_rules` #11 requires this hook to tolerate) mints a fresh identity every render, and
114
+ // listing it in the deps below would re-run the effect on every one of them.
115
+ const storageRef = useRef(storage);
116
+ storageRef.current = storage;
117
+
79
118
  // Read (and bump) the app-open counter. IDEMPOTENT per app-open, NOT per mount — see the same note
80
119
  // in `useReviewGate`; `bumpSessionCount` keys off the live per-open session id, so a remount or a
81
120
  // StrictMode double-invoke of this initializer reads the same number back instead of inflating it.
82
- const sessions = useState(() => {
121
+ const [sessions, setSessions] = useState(() => {
83
122
  const store = resolveStorage(storage);
84
123
  // Same as `useReviewGate`: no storage means the counter never leaves 1, so the fail-closed
85
124
  // minSessions rule is unsatisfiable and the gate silently never fires. Dev-only, once per process.
86
125
  warnMissingGateStorage(store, "questionnaire");
87
126
  return bumpSessionCount(store, sessionsKey, sessionOpenKey);
88
- })[0];
127
+ });
128
+ useEffect(() => {
129
+ const next = bumpSessionCount(resolveStorage(storageRef.current), sessionsKey, sessionOpenKey);
130
+ // A bail-out when the count is unchanged, so a notification for an open already counted costs
131
+ // nothing: React skips the re-render when the state is identical.
132
+ setSessions((prev) => (prev === next ? prev : next));
133
+ }, [openSessionId, sessionsKey, sessionOpenKey]);
89
134
 
90
135
  // Gate the local rules behind an optional client-side timeout, so a reachable server gets a
91
136
  // window to answer first. A present `decision` bypasses the wait entirely.
@@ -129,11 +174,17 @@ export const useQuestionnaireGate = ({
129
174
  const resolved = decideQuestionnaire(local, decision);
130
175
  // (3) the once-gate always wins locally, even over a server "fire".
131
176
  const alreadySeen = hasSeenGate(seenKey, storage, isTesting);
132
- const ready = hasServerDecision || elapsed;
177
+ // FAIL-OPEN IS NOT WEAKENED BY THIS. `featuresSettled` is `false` only while a fetch that could
178
+ // still change the answer is in flight; it is `true` with no `featuresConfig` (nothing to ask),
179
+ // with explicit `features`, and — the case that matters — the moment a FAILED fetch resolves,
180
+ // because `fetchWireFeatures` swallows a timeout / 401 / 5xx into the all-on defaults. So an
181
+ // unreachable control plane still allows the gate; only an unanswered one holds it.
182
+ const ready = (hasServerDecision || elapsed) && featuresSettled;
133
183
  return { visible: ready && resolved.fire && !alreadySeen, verdict: resolved };
134
184
  // eslint-disable-next-line react-hooks/exhaustive-deps
135
185
  }, [
136
186
  featureEnabled,
187
+ featuresSettled,
137
188
  testing,
138
189
  config,
139
190
  decision,
@@ -214,6 +214,31 @@ const _ReviewGate: React.FC<ReviewGateProps> = ({
214
214
  const postedRef = React.useRef(false);
215
215
  /** The last submission fired with no answer yet. Non-null after an UNSENT post = still owed. */
216
216
  const unackedRef = React.useRef<ReviewSubmission | null>(null);
217
+ /**
218
+ * THE NET WAS INERT WHILE THE POST WAS IN FLIGHT, which is precisely when it is needed.
219
+ *
220
+ * The latch is taken OPTIMISTICALLY and only released in `submitReview`'s `.then`, while the gate
221
+ * auto-closes 1500ms after the post (`resolveWithDelay`). So on any network slower than that —
222
+ * the offline detractor, a stalled radio, a captive portal — the unmount ran with the latch still
223
+ * CLOSED: the recovery branch below requires `!postedRef.current`, and the abandonment branch
224
+ * returns on `postedRef.current`, so BOTH no-opped and a 1-4 rating plus its mandatory text was
225
+ * dropped in silence. The once-gate means that user is never asked again. The existing canary
226
+ * missed it because it awaits the submit BEFORE unmounting — the fast path, where the latch has
227
+ * already reopened.
228
+ *
229
+ * So the unmount hands the recovery to the promise: when it finds a post STILL IN FLIGHT — the
230
+ * one state neither of its branches can serve — it arms `recoverOnSettleRef`, and the `.then`
231
+ * below does the re-post if nothing reached the server. It is armed ONLY in that state, so the
232
+ * fast path (settled before unmount) is untouched and still recovers exactly once, in the net.
233
+ * `recoveredRef` bounds this to exactly ONE extra post — the same in-process ceiling the net
234
+ * always had, never a retry loop — and the re-post carries this impression's `idempotency_key`
235
+ * plus its unit, so a row that did land is upserted rather than duplicated.
236
+ */
237
+ const recoverOnSettleRef = React.useRef(false);
238
+ const recoveredRef = React.useRef(false);
239
+ /** Latest `postOnce`, so the recovery above can call it from inside its own promise chain
240
+ * (the same ref convention `abandonRef` below uses) without a self-referencing callback. */
241
+ const postOnceRef = React.useRef<(body: ReviewSubmission) => void>(() => {});
217
242
  const postOnce = useCallback(
218
243
  (body: ReviewSubmission) => {
219
244
  if (postedRef.current) return;
@@ -223,6 +248,12 @@ const _ReviewGate: React.FC<ReviewGateProps> = ({
223
248
  if (result === "unsent") {
224
249
  // Nothing reached the server → un-latch, so this row can still go out.
225
250
  postedRef.current = false;
251
+ // The gate is already gone: the unmount net ran while this post was still in flight and
252
+ // found the latch closed, so nothing else will ever recover this row. Do it here, once.
253
+ if (recoverOnSettleRef.current && !recoveredRef.current) {
254
+ recoveredRef.current = true;
255
+ postOnceRef.current(body);
256
+ }
226
257
  return;
227
258
  }
228
259
  // `accepted` OR `rejected`: the server answered, so it owns this row. Not re-posted here —
@@ -233,6 +264,7 @@ const _ReviewGate: React.FC<ReviewGateProps> = ({
233
264
  },
234
265
  [target],
235
266
  );
267
+ postOnceRef.current = postOnce;
236
268
 
237
269
  /**
238
270
  * The abandonment safety net, and the reason forcing feedback does not COST us detractor data.
@@ -257,6 +289,13 @@ const _ReviewGate: React.FC<ReviewGateProps> = ({
257
289
  postOnce(undelivered);
258
290
  return;
259
291
  }
292
+ // STILL IN FLIGHT (latch closed, row unanswered) — the state that made this net inert. Neither
293
+ // branch can act from here, so the pending post is asked to recover itself when it settles
294
+ // unsent. Nothing else is scheduled to look at this row again.
295
+ if (postedRef.current && undelivered) {
296
+ recoverOnSettleRef.current = true;
297
+ return;
298
+ }
260
299
  if (postedRef.current || stars < 1) return;
261
300
  postOnce(
262
301
  buildBody({