@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
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import React, { createContext, useContext, useMemo } from "react";
|
|
16
16
|
|
|
17
17
|
import { defaultWireFeatures } from "./defaults";
|
|
18
|
-
import {
|
|
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
|
-
|
|
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 (!
|
|
36
|
-
|
|
44
|
+
if (!featuresContextGlobal[CONTEXT_SYMBOL]) {
|
|
45
|
+
featuresContextGlobal[CONTEXT_SYMBOL] = createContext<WireFeatures | null>(null);
|
|
37
46
|
}
|
|
38
|
-
const WireFeaturesContext =
|
|
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 =
|
|
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
|
|
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 =
|
|
96
|
-
|
|
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
|
-
/**
|
|
34
|
-
|
|
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<
|
|
62
|
+
): Promise<unknown> => {
|
|
39
63
|
const controller =
|
|
40
64
|
typeof AbortController !== "undefined" ? new AbortController() : undefined;
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
76
|
-
//
|
|
77
|
-
if (
|
|
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
|
-
//
|
|
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
|
-
|
|
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 (
|
|
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)
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
const json =
|
|
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 {
|
|
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
|
-
|
|
68
|
-
|
|
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
|
-
})
|
|
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
|
-
|
|
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({
|