@wireai/activation 0.14.3 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/AGENTS.md +96 -21
  2. package/CHANGELOG.md +780 -0
  3. package/INTEGRATION_PROMPT.md +61 -23
  4. package/README.md +110 -31
  5. package/dist/analytics/index.d.mts +35 -13
  6. package/dist/analytics/index.d.ts +35 -13
  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 +15 -1
  12. package/dist/coachmarks/index.d.ts +15 -1
  13. package/dist/coachmarks/index.js +120 -22
  14. package/dist/coachmarks/index.js.map +1 -1
  15. package/dist/coachmarks/index.mjs +120 -22
  16. package/dist/coachmarks/index.mjs.map +1 -1
  17. package/dist/{currentSession-CUvTOchb.d.mts → currentSession-Bz7G6lno.d.mts} +35 -39
  18. package/dist/{currentSession-CW_5Mq4O.d.ts → currentSession-z-CZ55ad.d.ts} +35 -39
  19. package/dist/{decision-Bgo17oH7.d.mts → decision-3vWLuBlO.d.ts} +11 -2
  20. package/dist/{decision-Bkh_LigV.d.ts → decision-yBj2AyPW.d.mts} +11 -2
  21. package/dist/index.d.mts +5 -2
  22. package/dist/index.d.ts +5 -2
  23. package/dist/index.js +125 -36
  24. package/dist/index.js.map +1 -1
  25. package/dist/index.mjs +125 -36
  26. package/dist/index.mjs.map +1 -1
  27. package/dist/questionnaire/index.d.mts +3 -16
  28. package/dist/questionnaire/index.d.ts +3 -16
  29. package/dist/questionnaire/index.js +202 -46
  30. package/dist/questionnaire/index.js.map +1 -1
  31. package/dist/questionnaire/index.mjs +203 -47
  32. package/dist/questionnaire/index.mjs.map +1 -1
  33. package/dist/reviews/index.d.mts +10 -6
  34. package/dist/reviews/index.d.ts +10 -6
  35. package/dist/reviews/index.js +269 -52
  36. package/dist/reviews/index.js.map +1 -1
  37. package/dist/reviews/index.mjs +270 -53
  38. package/dist/reviews/index.mjs.map +1 -1
  39. package/dist/showcase/index.d.mts +1 -1
  40. package/dist/showcase/index.d.ts +1 -1
  41. package/dist/showcase/index.js +106 -20
  42. package/dist/showcase/index.js.map +1 -1
  43. package/dist/showcase/index.mjs +107 -21
  44. package/dist/showcase/index.mjs.map +1 -1
  45. package/dist/{transport-j5gFfJhK.d.mts → transport-s5QxA-ci.d.mts} +18 -13
  46. package/dist/{transport-B_0SgCBe.d.ts → transport-xqqSFqxs.d.ts} +18 -13
  47. package/dist/{types-Cju-1_jT.d.mts → types-Byx306Kv.d.mts} +25 -10
  48. package/dist/{types-BcmagF6K.d.mts → types-D_0B0yay.d.mts} +7 -2
  49. package/dist/{types-BcmagF6K.d.ts → types-D_0B0yay.d.ts} +7 -2
  50. package/dist/{types-h2BZvl1t.d.ts → types-tdATL5z0.d.ts} +25 -10
  51. package/llms.txt +9 -9
  52. package/package.json +6 -9
  53. package/src/WireOnboarding.tsx +4 -1
  54. package/src/analytics/currentSession.ts +141 -4
  55. package/src/analytics/index.ts +6 -1
  56. package/src/analytics/reportClientEvent.ts +19 -10
  57. package/src/analytics/useAnalytics.ts +74 -15
  58. package/src/analytics/wireDoctor.ts +152 -7
  59. package/src/coachmarks/CoachmarkProvider.tsx +26 -5
  60. package/src/coachmarks/runtime.ts +156 -2
  61. package/src/coachmarks/types.ts +7 -2
  62. package/src/coachmarks/useCoachmarkTour.ts +51 -1
  63. package/src/context/deviceId.ts +72 -6
  64. package/src/features/WireFeaturesProvider.tsx +72 -12
  65. package/src/features/fetchWireFeatures.ts +49 -11
  66. package/src/features/useWireFeatures.ts +39 -3
  67. package/src/identity/identityRecord.ts +15 -2
  68. package/src/questionnaire/QuestionnaireGate.tsx +40 -1
  69. package/src/questionnaire/transport.ts +22 -8
  70. package/src/questionnaire/useQuestionnaireGate.ts +58 -7
  71. package/src/reviews/ReviewGate.tsx +125 -37
  72. package/src/reviews/decision.ts +11 -1
  73. package/src/reviews/idempotency.ts +109 -0
  74. package/src/reviews/index.ts +4 -2
  75. package/src/reviews/runtime.ts +44 -13
  76. package/src/reviews/transport.ts +39 -20
  77. package/src/reviews/types.ts +7 -0
  78. package/src/reviews/useReviewGate.ts +57 -7
  79. package/src/session-analytics/lifecycle.ts +16 -0
  80. package/src/session-analytics/useLifecycleEvents.ts +30 -2
  81. package/src/session-analytics/useSessionStart.ts +22 -2
  82. package/src/showcase/FeatureShowcase.tsx +50 -3
  83. package/src/types.ts +15 -8
  84. package/src/utils/submitResult.ts +17 -9
  85. package/src/utils/withDeadline.ts +70 -0
@@ -1,4 +1,4 @@
1
- import React7, { createContext, forwardRef, useRef, useState, useEffect, useCallback, useImperativeHandle, useMemo, useContext } from 'react';
1
+ import React7, { createContext, forwardRef, useRef, useState, useEffect, useCallback, useImperativeHandle, useMemo, useContext, useSyncExternalStore } from 'react';
2
2
  import { StyleSheet, Easing, Animated, BackHandler, Modal, Pressable, Text, View, AccessibilityInfo, TextInput, Linking, Platform } from 'react-native';
3
3
  import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
4
4
  import { SafeAreaView } from 'react-native-safe-area-context';
@@ -540,6 +540,7 @@ var buildReviewSubmission = (input) => {
540
540
  if (contact) body.contact = contact;
541
541
  if (input.sessionId) body.session_id = input.sessionId;
542
542
  if (input.source) body.source = input.source;
543
+ if (input.idempotencyKey) body.idempotency_key = input.idempotencyKey;
543
544
  if (input.meta && Object.keys(input.meta).length > 0) body.meta = input.meta;
544
545
  return body;
545
546
  };
@@ -557,6 +558,43 @@ var feedbackInputHeights = (fonts, verticalPadding = 0) => {
557
558
  };
558
559
  };
559
560
 
561
+ // src/context/deviceId.ts
562
+ var AUTO_DEVICE_ID_PREFIX = "wdev_";
563
+ var randomChunk = () => Math.floor(Math.random() * 4294967296).toString(36).padStart(6, "0");
564
+ var mintDeviceId = () => {
565
+ const time = Date.now().toString(36);
566
+ return `${AUTO_DEVICE_ID_PREFIX}${time}_${randomChunk()}${randomChunk()}`;
567
+ };
568
+ var AUTO_DEVICE_KEY_SLOT = /* @__PURE__ */ Symbol.for("@wireai/activation:autoDeviceKeys");
569
+ var deviceKeyGlobal = globalThis;
570
+ var autoDeviceKeyRegistry = () => {
571
+ const existing = deviceKeyGlobal[AUTO_DEVICE_KEY_SLOT];
572
+ if (existing) return existing;
573
+ const created = { keys: /* @__PURE__ */ new Map(), hydrating: /* @__PURE__ */ new Set() };
574
+ deviceKeyGlobal[AUTO_DEVICE_KEY_SLOT] = created;
575
+ return created;
576
+ };
577
+ var ambientAutoDeviceKey = () => {
578
+ const registry = autoDeviceKeyRegistry();
579
+ if (registry.keys.size > 1) return void 0;
580
+ const sole = [...registry.keys.values()][0];
581
+ if (sole) return sole;
582
+ if (!registry.ambient) registry.ambient = mintDeviceId();
583
+ return registry.ambient;
584
+ };
585
+
586
+ // src/reviews/idempotency.ts
587
+ var REVIEW_IDEMPOTENCY_PREFIX = "rvw_";
588
+ var mintIdempotencyKey = () => `${REVIEW_IDEMPOTENCY_PREFIX}${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
589
+ var hasDeviceKey = (meta) => {
590
+ const value = meta == null ? void 0 : meta.device_key;
591
+ return typeof value === "string" && value.trim().length > 0;
592
+ };
593
+ var createImpression = () => ({
594
+ idempotencyKey: mintIdempotencyKey(),
595
+ deviceKey: ambientAutoDeviceKey()
596
+ });
597
+
560
598
  // src/motion/centeredModalMotion.ts
561
599
  var CENTERED_MODAL_ANIM = {
562
600
  /** Spring config for the card scale/opacity entrance (mirrors CompletionView's pop). */
@@ -734,6 +772,28 @@ var requestStoreReview = async (store) => {
734
772
  return opened ? "store_url" : "none";
735
773
  };
736
774
 
775
+ // src/utils/withDeadline.ts
776
+ var DEADLINE_EXPIRED = /* @__PURE__ */ Symbol("wire-deadline-expired");
777
+ var DEFAULT_DEADLINE_MS = 15e3;
778
+ var withDeadline = async (exchange, timeoutMs = DEFAULT_DEADLINE_MS) => {
779
+ const controller = typeof AbortController !== "undefined" ? new AbortController() : void 0;
780
+ let timer;
781
+ const expired = new Promise((resolve) => {
782
+ timer = setTimeout(() => {
783
+ controller == null ? void 0 : controller.abort();
784
+ resolve(DEADLINE_EXPIRED);
785
+ }, timeoutMs);
786
+ });
787
+ const running = exchange(controller == null ? void 0 : controller.signal);
788
+ try {
789
+ return await Promise.race([running, expired]);
790
+ } finally {
791
+ clearTimeout(timer);
792
+ void running.catch(() => {
793
+ });
794
+ }
795
+ };
796
+
737
797
  // src/analytics/reportClientEvent.ts
738
798
  var makeSessionId = () => `wire_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
739
799
  var toWireEvents = (events) => events.map((event) => {
@@ -775,17 +835,51 @@ var CURRENT_SESSION_ID_SLOT = /* @__PURE__ */ Symbol.for(
775
835
  "@wireai/activation:currentSessionId"
776
836
  );
777
837
  var globalSlot = globalThis;
838
+ var SESSION_ID_LISTENERS_SLOT = /* @__PURE__ */ Symbol.for(
839
+ "@wireai/activation:currentSessionIdListeners"
840
+ );
841
+ var listenerSlot = globalThis;
842
+ var listeners = () => {
843
+ const existing = listenerSlot[SESSION_ID_LISTENERS_SLOT];
844
+ if (existing) return existing;
845
+ const created = /* @__PURE__ */ new Set();
846
+ listenerSlot[SESSION_ID_LISTENERS_SLOT] = created;
847
+ return created;
848
+ };
849
+ var writeCurrentSessionId = (id) => {
850
+ if (globalSlot[CURRENT_SESSION_ID_SLOT] === id) return;
851
+ globalSlot[CURRENT_SESSION_ID_SLOT] = id;
852
+ for (const listener of Array.from(listeners())) {
853
+ try {
854
+ listener();
855
+ } catch {
856
+ }
857
+ }
858
+ };
859
+ var subscribeCurrentSessionId = (listener) => {
860
+ const set = listeners();
861
+ set.add(listener);
862
+ return () => {
863
+ set.delete(listener);
864
+ };
865
+ };
778
866
  var getCurrentSessionId = () => globalSlot[CURRENT_SESSION_ID_SLOT];
779
867
  var MINT_WARNING = "[wireai] No app-open session was registered, so a session id was minted for this event (the server drops an event that has no session_id, and still answers 200). Mount useLifecycleEvents at your app root so events correlate to a real app-open.";
780
868
  var MINT_WARNED_SLOT = /* @__PURE__ */ Symbol.for(
781
869
  "@wireai/activation:currentSessionIdMintWarned"
782
870
  );
783
871
  var warnSlot = globalThis;
872
+ var MINTED_SESSION_ID_SLOT = /* @__PURE__ */ Symbol.for(
873
+ "@wireai/activation:mintedSessionId"
874
+ );
875
+ var mintedSlot = globalThis;
876
+ var isMintedSessionId = (id) => id !== void 0 && id === mintedSlot[MINTED_SESSION_ID_SLOT];
784
877
  var ensureCurrentSessionId = () => {
785
878
  const existing = globalSlot[CURRENT_SESSION_ID_SLOT];
786
879
  if (typeof existing === "string" && existing.length > 0) return existing;
787
880
  const minted = makeSessionId();
788
- globalSlot[CURRENT_SESSION_ID_SLOT] = minted;
881
+ writeCurrentSessionId(minted);
882
+ mintedSlot[MINTED_SESSION_ID_SLOT] = minted;
789
883
  if (!warnSlot[MINT_WARNED_SLOT] && warnInDev(MINT_WARNING)) {
790
884
  warnSlot[MINT_WARNED_SLOT] = true;
791
885
  }
@@ -799,11 +893,15 @@ var submitReview = async (target, review) => {
799
893
  const url = `${target.serverUrl.replace(/\/$/, "")}/v1/reviews`;
800
894
  const headers = { "Content-Type": "application/json" };
801
895
  if (target.apiKey) headers.Authorization = `Bearer ${target.apiKey}`;
802
- const res = await fetch(url, {
803
- method: "POST",
804
- headers,
805
- body: JSON.stringify(review)
806
- });
896
+ const res = await withDeadline(
897
+ (signal) => fetch(url, {
898
+ method: "POST",
899
+ headers,
900
+ body: JSON.stringify(review),
901
+ signal
902
+ })
903
+ );
904
+ if (res === DEADLINE_EXPIRED) return "unsent";
807
905
  if (!res) return "unsent";
808
906
  return res.ok ? "accepted" : "rejected";
809
907
  } catch {
@@ -821,9 +919,12 @@ var fetchReviewDecision = async (target, options = {}) => {
821
919
  const url = `${base}/v1/reviews/decision${qs ? `?${qs}` : ""}`;
822
920
  const headers = {};
823
921
  if (target.apiKey) headers.Authorization = `Bearer ${target.apiKey}`;
824
- const res = await fetch(url, { headers });
825
- if (!res || !res.ok) return null;
826
- const json = await res.json();
922
+ const json = await withDeadline(async (signal) => {
923
+ const res = await fetch(url, { headers, signal });
924
+ if (!res || !res.ok) return null;
925
+ return await res.json();
926
+ });
927
+ if (json === DEADLINE_EXPIRED) return null;
827
928
  if (!json || typeof json.fire !== "boolean") return null;
828
929
  return json;
829
930
  } catch {
@@ -895,8 +996,33 @@ var _ReviewGate = ({
895
996
  const resolve = useCallback(() => {
896
997
  onResolved == null ? void 0 : onResolved();
897
998
  }, [onResolved]);
999
+ const impressionRef = React7.useRef(null);
1000
+ const buildBody = useCallback(
1001
+ (input) => {
1002
+ let impression = impressionRef.current;
1003
+ if (!impression) {
1004
+ impression = createImpression();
1005
+ impressionRef.current = impression;
1006
+ }
1007
+ const merged = { ...meta, ...input.extraMeta };
1008
+ if (!hasDeviceKey(merged) && impression.deviceKey) merged.device_key = impression.deviceKey;
1009
+ return buildReviewSubmission({
1010
+ stars: input.stars,
1011
+ feedbackText: input.feedbackText,
1012
+ suggestion: input.suggestion,
1013
+ sessionId,
1014
+ meta: merged,
1015
+ idempotencyKey: impression.idempotencyKey
1016
+ });
1017
+ },
1018
+ [meta, sessionId]
1019
+ );
898
1020
  const postedRef = React7.useRef(false);
899
1021
  const unackedRef = React7.useRef(null);
1022
+ const recoverOnSettleRef = React7.useRef(false);
1023
+ const recoveredRef = React7.useRef(false);
1024
+ const postOnceRef = React7.useRef(() => {
1025
+ });
900
1026
  const postOnce = useCallback(
901
1027
  (body) => {
902
1028
  if (postedRef.current) return;
@@ -905,6 +1031,10 @@ var _ReviewGate = ({
905
1031
  void submitReview(target, body).then((result) => {
906
1032
  if (result === "unsent") {
907
1033
  postedRef.current = false;
1034
+ if (recoverOnSettleRef.current && !recoveredRef.current) {
1035
+ recoveredRef.current = true;
1036
+ postOnceRef.current(body);
1037
+ }
908
1038
  return;
909
1039
  }
910
1040
  unackedRef.current = null;
@@ -912,6 +1042,7 @@ var _ReviewGate = ({
912
1042
  },
913
1043
  [target]
914
1044
  );
1045
+ postOnceRef.current = postOnce;
915
1046
  const abandonRef = React7.useRef(() => {
916
1047
  });
917
1048
  abandonRef.current = () => {
@@ -920,14 +1051,17 @@ var _ReviewGate = ({
920
1051
  postOnce(undelivered);
921
1052
  return;
922
1053
  }
1054
+ if (postedRef.current && undelivered) {
1055
+ recoverOnSettleRef.current = true;
1056
+ return;
1057
+ }
923
1058
  if (postedRef.current || stars < 1) return;
924
1059
  postOnce(
925
- buildReviewSubmission({
1060
+ buildBody({
926
1061
  stars,
927
1062
  feedbackText: feedback,
928
1063
  suggestion,
929
- sessionId,
930
- meta: { ...meta, abandoned: true }
1064
+ extraMeta: { abandoned: true }
931
1065
  })
932
1066
  );
933
1067
  };
@@ -952,10 +1086,9 @@ var _ReviewGate = ({
952
1086
  onEvent == null ? void 0 : onEvent({ name: "store_review_requested", id, stars: value });
953
1087
  const route = await requestStoreReview(store);
954
1088
  postOnce(
955
- buildReviewSubmission({
1089
+ buildBody({
956
1090
  stars: value,
957
- sessionId,
958
- meta: { ...meta, store_route: route }
1091
+ extraMeta: { store_route: route }
959
1092
  })
960
1093
  );
961
1094
  setPhase("thanks");
@@ -964,24 +1097,18 @@ var _ReviewGate = ({
964
1097
  setPhase("feedback");
965
1098
  }
966
1099
  },
967
- [onEvent, id, store, postOnce, sessionId, meta, resolveWithDelay]
1100
+ [onEvent, id, store, postOnce, buildBody, resolveWithDelay]
968
1101
  );
969
1102
  const feedbackReady = feedback.trim().length > 0;
970
1103
  const sendFeedback = useCallback(() => {
971
1104
  if (!feedbackReady) return;
972
- const body = buildReviewSubmission({
973
- stars,
974
- feedbackText: feedback,
975
- suggestion,
976
- sessionId,
977
- meta
978
- });
1105
+ const body = buildBody({ stars, feedbackText: feedback, suggestion });
979
1106
  postOnce(body);
980
1107
  onEvent == null ? void 0 : onEvent({ name: "review_feedback_submitted", id, stars });
981
1108
  playHaptic("success");
982
1109
  setPhase("thanks");
983
1110
  resolveWithDelay();
984
- }, [feedbackReady, stars, feedback, suggestion, sessionId, meta, postOnce, onEvent, id, resolveWithDelay]);
1111
+ }, [feedbackReady, stars, feedback, suggestion, buildBody, postOnce, onEvent, id, resolveWithDelay]);
985
1112
  const dismiss = useCallback(() => {
986
1113
  setPhase("thanks");
987
1114
  resolveWithDelay();
@@ -1128,9 +1255,54 @@ var coachmarkRuntime = () => {
1128
1255
  var getCoachmarkStorage = () => coachmarkRuntime().storage;
1129
1256
  var isCoachmarkTesting = () => coachmarkRuntime().testing;
1130
1257
  var SEEN_VALUE = "1";
1258
+ var GATE_PROBE_KEY = "wire_gate_probe";
1259
+ var ASYNC_GATE_STORAGE_WARNED_SLOT = /* @__PURE__ */ Symbol.for(
1260
+ "@wireai/activation:asyncGateStorageWarned"
1261
+ );
1262
+ var asyncGateWarnGlobal = globalThis;
1263
+ var closedGateStorage = Object.freeze({
1264
+ getItem: () => SEEN_VALUE,
1265
+ setItem: () => {
1266
+ }
1267
+ });
1268
+ var syncGateAdapters = /* @__PURE__ */ new WeakSet();
1269
+ var asyncGateAdapters = /* @__PURE__ */ new WeakSet();
1270
+ var warnAsyncGateStorage = () => {
1271
+ if (asyncGateWarnGlobal[ASYNC_GATE_STORAGE_WARNED_SLOT]) return;
1272
+ if (typeof __DEV__ === "undefined" || !__DEV__) return;
1273
+ if (typeof console === "undefined" || !console.warn) return;
1274
+ asyncGateWarnGlobal[ASYNC_GATE_STORAGE_WARNED_SLOT] = true;
1275
+ console.warn(
1276
+ "[wireai] the gate storage's getItem returned a Promise \u2014 an async adapter (e.g. AsyncStorage) cannot drive the once-per-user gates, so coachmark tours, showcases and the review/questionnaire gates are all DISABLED (fail closed: nothing shows, nothing fires) until a synchronous adapter is provided. Pass a sync adapter (e.g. MMKV: `{ getItem: (k) => mmkv.getString(k) ?? null, setItem: (k, v) => mmkv.set(k, v) }`) to CoachmarkProvider or the gate's `storage` prop."
1277
+ );
1278
+ };
1279
+ var validateGateStorage = (storage) => {
1280
+ if (!storage) return null;
1281
+ if (syncGateAdapters.has(storage)) return storage;
1282
+ if (asyncGateAdapters.has(storage)) return closedGateStorage;
1283
+ let probed;
1284
+ try {
1285
+ probed = storage.getItem(GATE_PROBE_KEY);
1286
+ } catch {
1287
+ syncGateAdapters.add(storage);
1288
+ return storage;
1289
+ }
1290
+ if (probed != null && typeof probed.then === "function") {
1291
+ asyncGateAdapters.add(storage);
1292
+ try {
1293
+ probed.then(void 0, () => {
1294
+ });
1295
+ } catch {
1296
+ }
1297
+ warnAsyncGateStorage();
1298
+ return closedGateStorage;
1299
+ }
1300
+ syncGateAdapters.add(storage);
1301
+ return storage;
1302
+ };
1131
1303
  var hasSeenGate = (key, storageOverride, testingOverride) => {
1132
1304
  if (testingOverride != null ? testingOverride : isCoachmarkTesting()) return false;
1133
- const storage = storageOverride != null ? storageOverride : getCoachmarkStorage();
1305
+ const storage = validateGateStorage(storageOverride != null ? storageOverride : getCoachmarkStorage());
1134
1306
  if (!storage) return false;
1135
1307
  try {
1136
1308
  return storage.getItem(key) === SEEN_VALUE;
@@ -1140,7 +1312,7 @@ var hasSeenGate = (key, storageOverride, testingOverride) => {
1140
1312
  };
1141
1313
  var markSeenGate = (key, storageOverride, testingOverride) => {
1142
1314
  if (testingOverride != null ? testingOverride : isCoachmarkTesting()) return;
1143
- const storage = storageOverride != null ? storageOverride : getCoachmarkStorage();
1315
+ const storage = validateGateStorage(storageOverride != null ? storageOverride : getCoachmarkStorage());
1144
1316
  if (!storage) return;
1145
1317
  try {
1146
1318
  storage.setItem(key, SEEN_VALUE);
@@ -1228,17 +1400,31 @@ var failOpen = async (storage, key) => {
1228
1400
  const cached = await readCachedFeatures(storage, key);
1229
1401
  return (_a = cached == null ? void 0 : cached.features) != null ? _a : defaultWireFeatures;
1230
1402
  };
1231
- var fetchWithTimeout = async (url, apiKey, timeoutMs) => {
1403
+ var NO_ANSWER = /* @__PURE__ */ Symbol("wire-features-no-answer");
1404
+ var fetchFeaturesJson = async (url, apiKey, timeoutMs) => {
1232
1405
  const controller = typeof AbortController !== "undefined" ? new AbortController() : void 0;
1233
- const timer = setTimeout(() => controller == null ? void 0 : controller.abort(), timeoutMs);
1234
- try {
1235
- return await fetch(url, {
1406
+ let timer;
1407
+ const expired = new Promise((resolve) => {
1408
+ timer = setTimeout(() => {
1409
+ controller == null ? void 0 : controller.abort();
1410
+ resolve(NO_ANSWER);
1411
+ }, timeoutMs);
1412
+ });
1413
+ const exchange = (async () => {
1414
+ const res = await fetch(url, {
1236
1415
  method: "GET",
1237
1416
  headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` },
1238
1417
  signal: controller == null ? void 0 : controller.signal
1239
1418
  });
1419
+ if (!res || !res.ok) return NO_ANSWER;
1420
+ return await res.json();
1421
+ })();
1422
+ try {
1423
+ return await Promise.race([exchange, expired]);
1240
1424
  } finally {
1241
1425
  clearTimeout(timer);
1426
+ void exchange.catch(() => {
1427
+ });
1242
1428
  }
1243
1429
  };
1244
1430
  var fetchWireFeatures = async (config) => {
@@ -1248,9 +1434,8 @@ var fetchWireFeatures = async (config) => {
1248
1434
  const url = `${config.serverUrl.replace(/\/$/, "")}/v1/features`;
1249
1435
  const timeoutMs = config.timeoutMs && config.timeoutMs > 0 ? config.timeoutMs : DEFAULT_TIMEOUT_MS;
1250
1436
  try {
1251
- const res = await fetchWithTimeout(url, config.apiKey, timeoutMs);
1252
- if (!res || !res.ok) return failOpen(storage, key);
1253
- const json = await res.json();
1437
+ const json = await fetchFeaturesJson(url, config.apiKey, timeoutMs);
1438
+ if (json === NO_ANSWER) return failOpen(storage, key);
1254
1439
  const features = parseWireFeatures(json);
1255
1440
  if (storage) writeCachedFeatures(storage, key, features);
1256
1441
  return features;
@@ -1266,39 +1451,51 @@ var useStableValue = (value, isEqual) => {
1266
1451
  return ref.current;
1267
1452
  };
1268
1453
  var sameFetchKey = (a, b) => (a == null ? void 0 : a.serverUrl) === (b == null ? void 0 : b.serverUrl) && (a == null ? void 0 : a.apiKey) === (b == null ? void 0 : b.apiKey) && (a == null ? void 0 : a.appId) === (b == null ? void 0 : b.appId);
1269
- var useWireFeatures = (config) => {
1454
+ var useWireFeaturesState = (config) => {
1270
1455
  const stableConfig = useStableValue(config, sameFetchKey);
1271
1456
  const [flags, setFlags] = useState(defaultWireFeatures);
1272
1457
  const canFetch = !!(stableConfig == null ? void 0 : stableConfig.serverUrl) && !!(stableConfig == null ? void 0 : stableConfig.apiKey);
1458
+ const [settled, setSettled] = useState(!canFetch);
1273
1459
  useEffect(() => {
1274
1460
  if (!canFetch) {
1275
1461
  setFlags((prev) => featuresEqual(prev, defaultWireFeatures) ? prev : defaultWireFeatures);
1462
+ setSettled(true);
1276
1463
  return;
1277
1464
  }
1278
1465
  let alive = true;
1279
1466
  void fetchWireFeatures(stableConfig).then((next) => {
1280
1467
  if (!alive) return;
1281
1468
  setFlags((prev) => featuresEqual(prev, next) ? prev : next);
1469
+ setSettled(true);
1282
1470
  });
1283
1471
  return () => {
1284
1472
  alive = false;
1285
1473
  };
1286
1474
  }, [canFetch, stableConfig]);
1287
- return flags;
1475
+ return useMemo(() => ({ flags, settled }), [flags, settled]);
1288
1476
  };
1289
1477
  var CONTEXT_SYMBOL = /* @__PURE__ */ Symbol.for("wireai.features.context");
1290
- var globalObj = globalThis;
1291
- if (!globalObj[CONTEXT_SYMBOL]) {
1292
- globalObj[CONTEXT_SYMBOL] = createContext(null);
1478
+ var featuresContextGlobal = globalThis;
1479
+ if (!featuresContextGlobal[CONTEXT_SYMBOL]) {
1480
+ featuresContextGlobal[CONTEXT_SYMBOL] = createContext(null);
1293
1481
  }
1294
- var WireFeaturesContext = globalObj[CONTEXT_SYMBOL];
1482
+ var WireFeaturesContext = featuresContextGlobal[CONTEXT_SYMBOL];
1483
+ var SETTLED_SYMBOL = /* @__PURE__ */ Symbol.for("wireai.features.settled.context");
1484
+ var settledContextGlobal = globalThis;
1485
+ if (!settledContextGlobal[SETTLED_SYMBOL]) {
1486
+ settledContextGlobal[SETTLED_SYMBOL] = createContext(true);
1487
+ }
1488
+ var WireFeaturesSettledContext = settledContextGlobal[SETTLED_SYMBOL];
1295
1489
  var useWireFeaturesContext = () => useContext(WireFeaturesContext);
1296
- var useResolvedFeatures = (options) => {
1490
+ var useResolvedFeaturesState = (options) => {
1297
1491
  var _a, _b, _c;
1298
1492
  const ctx = useWireFeaturesContext();
1493
+ const ctxSettled = useContext(WireFeaturesSettledContext);
1299
1494
  const shouldFetch = !(options == null ? void 0 : options.flags) && ctx == null;
1300
- const fetched = useWireFeatures(shouldFetch ? options == null ? void 0 : options.config : void 0);
1301
- return (_c = (_b = (_a = options == null ? void 0 : options.flags) != null ? _a : ctx) != null ? _b : fetched) != null ? _c : defaultWireFeatures;
1495
+ const fetched = useWireFeaturesState(shouldFetch ? options == null ? void 0 : options.config : void 0);
1496
+ const flags = (_c = (_b = (_a = options == null ? void 0 : options.flags) != null ? _a : ctx) != null ? _b : fetched.flags) != null ? _c : defaultWireFeatures;
1497
+ const settled = (options == null ? void 0 : options.flags) ? true : ctx != null ? ctxSettled : fetched.settled;
1498
+ return useMemo(() => ({ flags, settled }), [flags, settled]);
1302
1499
  };
1303
1500
 
1304
1501
  // src/reviews/equality.ts
@@ -1327,7 +1524,7 @@ var reviewSeenKey = (id, version) => version ? `wire_review_${id}_${version}_see
1327
1524
  var reviewLastShownKey = (id) => `wire_review_${id}_last`;
1328
1525
  var reviewSessionsKey = (id) => `wire_review_${id}_sessions`;
1329
1526
  var reviewSessionOpenKey = (id) => `wire_review_${id}_open`;
1330
- var resolveStorage = (override) => override != null ? override : getCoachmarkStorage();
1527
+ var resolveStorage = (override) => validateGateStorage(override != null ? override : getCoachmarkStorage());
1331
1528
  var NO_STORAGE_WARNED_SLOT = /* @__PURE__ */ Symbol.for(
1332
1529
  "@wireai/activation:gateStorageWarned"
1333
1530
  );
@@ -1372,18 +1569,23 @@ var PROCESS_OPEN_ID_SLOT = /* @__PURE__ */ Symbol.for("@wireai/activation:proces
1372
1569
  var openIdGlobal = globalThis;
1373
1570
  var currentOpenId = () => {
1374
1571
  const live = getCurrentSessionId();
1572
+ const liveMinted = isMintedSessionId(live);
1375
1573
  const pin = openIdGlobal[PROCESS_OPEN_ID_SLOT];
1376
1574
  if (!pin) {
1377
1575
  const openId = live != null ? live : makeSessionId();
1378
- openIdGlobal[PROCESS_OPEN_ID_SLOT] = { openId, observed: live };
1576
+ openIdGlobal[PROCESS_OPEN_ID_SLOT] = { openId, observed: live, observedMinted: liveMinted };
1379
1577
  return openId;
1380
1578
  }
1381
1579
  if (!live || live === pin.observed) return pin.openId;
1382
- if (pin.observed === void 0) {
1383
- openIdGlobal[PROCESS_OPEN_ID_SLOT] = { openId: pin.openId, observed: live };
1580
+ if (pin.observed === void 0 || pin.observedMinted) {
1581
+ openIdGlobal[PROCESS_OPEN_ID_SLOT] = {
1582
+ openId: pin.openId,
1583
+ observed: live,
1584
+ observedMinted: liveMinted
1585
+ };
1384
1586
  return pin.openId;
1385
1587
  }
1386
- openIdGlobal[PROCESS_OPEN_ID_SLOT] = { openId: live, observed: live };
1588
+ openIdGlobal[PROCESS_OPEN_ID_SLOT] = { openId: live, observed: live, observedMinted: liveMinted };
1387
1589
  return live;
1388
1590
  };
1389
1591
  var bumpSessionCount = (storage, sessionsKey, openKey, openId = currentOpenId()) => {
@@ -1421,17 +1623,32 @@ var useReviewGate = ({
1421
1623
  decisionProp,
1422
1624
  (a, b) => sameDecision(a, b)
1423
1625
  );
1424
- const featureEnabled = useResolvedFeatures({ flags: features, config: featuresConfig }).review.enabled;
1626
+ const { flags: resolvedFeatures, settled: featuresSettled } = useResolvedFeaturesState({
1627
+ flags: features,
1628
+ config: featuresConfig
1629
+ });
1630
+ const featureEnabled = resolvedFeatures.review.enabled;
1425
1631
  const testing = isTesting != null ? isTesting : isCoachmarkTesting();
1426
1632
  const seenKey = reviewSeenKey(config.id, config.oncePerVersion === false ? void 0 : config.appVersion);
1427
1633
  const lastKey = reviewLastShownKey(config.id);
1428
1634
  const sessionsKey = reviewSessionsKey(config.id);
1429
1635
  const sessionOpenKey = reviewSessionOpenKey(config.id);
1430
- const sessions = useState(() => {
1636
+ const openSessionId = useSyncExternalStore(
1637
+ subscribeCurrentSessionId,
1638
+ getCurrentSessionId,
1639
+ getCurrentSessionId
1640
+ );
1641
+ const storageRef = useRef(storage);
1642
+ storageRef.current = storage;
1643
+ const [sessions, setSessions] = useState(() => {
1431
1644
  const store = resolveStorage(storage);
1432
1645
  warnMissingGateStorage(store, "review");
1433
1646
  return bumpSessionCount(store, sessionsKey, sessionOpenKey);
1434
- })[0];
1647
+ });
1648
+ useEffect(() => {
1649
+ const next = bumpSessionCount(resolveStorage(storageRef.current), sessionsKey, sessionOpenKey);
1650
+ setSessions((prev) => prev === next ? prev : next);
1651
+ }, [openSessionId, sessionsKey, sessionOpenKey]);
1435
1652
  const hasServerDecision = decision != null;
1436
1653
  const timeoutMs = config.timeoutFallbackMs;
1437
1654
  const waits = !hasServerDecision && timeoutMs != null && timeoutMs > 0;
@@ -1457,9 +1674,9 @@ var useReviewGate = ({
1457
1674
  });
1458
1675
  const resolved = decideReview(local, decision);
1459
1676
  const alreadySeen = hasSeenGate(seenKey, storage, isTesting);
1460
- const ready = hasServerDecision || elapsed;
1677
+ const ready = (hasServerDecision || elapsed) && featuresSettled;
1461
1678
  return { visible: ready && resolved.fire && !alreadySeen, verdict: resolved };
1462
- }, [featureEnabled, testing, config, decision, events, sessions, elapsed, hasServerDecision, seenKey, lastKey, storage, isTesting]);
1679
+ }, [featureEnabled, featuresSettled, testing, config, decision, events, sessions, elapsed, hasServerDecision, seenKey, lastKey, storage, isTesting]);
1463
1680
  const shownRef = useRef(false);
1464
1681
  const markShown = useCallback(() => {
1465
1682
  if (shownRef.current) return;