@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
@@ -566,7 +566,6 @@ var feedbackInputHeights = (fonts, verticalPadding = 0) => {
566
566
 
567
567
  // src/context/deviceId.ts
568
568
  var AUTO_DEVICE_ID_PREFIX = "wdev_";
569
- var deviceIdStorageKey = (appId) => `wireai:analytics:deviceKey:${appId != null ? appId : "default"}`;
570
569
  var randomChunk = () => Math.floor(Math.random() * 4294967296).toString(36).padStart(6, "0");
571
570
  var mintDeviceId = () => {
572
571
  const time = Date.now().toString(36);
@@ -581,65 +580,13 @@ var autoDeviceKeyRegistry = () => {
581
580
  deviceKeyGlobal[AUTO_DEVICE_KEY_SLOT] = created;
582
581
  return created;
583
582
  };
584
- var startHydration = (registry, appId, storage, minted) => {
585
- if (!registry.pending) registry.pending = /* @__PURE__ */ new Map();
586
- const existing = registry.pending.get(appId);
587
- if (existing) return existing;
588
- const slot = deviceIdStorageKey(appId);
589
- const degraded = () => {
590
- var _a;
591
- return { value: (_a = registry.keys.get(appId)) != null ? _a : minted, durable: false };
592
- };
593
- const adopted = (value) => ({ value, durable: true });
594
- let run;
595
- try {
596
- run = Promise.resolve(storage.getItem(slot)).then((saved) => {
597
- const persisted = typeof saved === "string" && saved.trim() ? saved.trim() : void 0;
598
- if (persisted) {
599
- registry.keys.set(appId, persisted);
600
- return adopted(persisted);
601
- }
602
- return Promise.resolve(storage.setItem(slot, minted)).then(
603
- () => {
604
- var _a;
605
- return adopted((_a = registry.keys.get(appId)) != null ? _a : minted);
606
- },
607
- degraded
608
- );
609
- }).catch(degraded);
610
- } catch {
611
- run = Promise.resolve(degraded());
612
- }
613
- registry.pending.set(appId, run);
614
- void run.then((outcome) => {
615
- var _a;
616
- if (outcome.durable) return;
617
- (_a = registry.pending) == null ? void 0 : _a.delete(appId);
618
- registry.hydrating.delete(appId);
619
- });
620
- return run;
621
- };
622
- var resolveAutoDeviceKey = (opts = {}) => {
623
- var _a, _b;
624
- const registry = autoDeviceKeyRegistry();
625
- const appId = (_a = opts.appId) != null ? _a : "default";
626
- let id = registry.keys.get(appId);
627
- if (!id) {
628
- id = mintDeviceId();
629
- registry.keys.set(appId, id);
630
- }
631
- const storage = opts.storage;
632
- if (storage && !registry.hydrating.has(appId)) {
633
- registry.hydrating.add(appId);
634
- void startHydration(registry, appId, storage, id);
635
- }
636
- return (_b = registry.keys.get(appId)) != null ? _b : id;
637
- };
638
583
  var ambientAutoDeviceKey = () => {
639
584
  const registry = autoDeviceKeyRegistry();
640
- if (registry.keys.size === 0) return resolveAutoDeviceKey();
641
585
  if (registry.keys.size > 1) return void 0;
642
- return [...registry.keys.values()][0];
586
+ const sole = [...registry.keys.values()][0];
587
+ if (sole) return sole;
588
+ if (!registry.ambient) registry.ambient = mintDeviceId();
589
+ return registry.ambient;
643
590
  };
644
591
 
645
592
  // src/reviews/idempotency.ts
@@ -831,6 +778,28 @@ var requestStoreReview = async (store) => {
831
778
  return opened ? "store_url" : "none";
832
779
  };
833
780
 
781
+ // src/utils/withDeadline.ts
782
+ var DEADLINE_EXPIRED = /* @__PURE__ */ Symbol("wire-deadline-expired");
783
+ var DEFAULT_DEADLINE_MS = 15e3;
784
+ var withDeadline = async (exchange, timeoutMs = DEFAULT_DEADLINE_MS) => {
785
+ const controller = typeof AbortController !== "undefined" ? new AbortController() : void 0;
786
+ let timer;
787
+ const expired = new Promise((resolve) => {
788
+ timer = setTimeout(() => {
789
+ controller == null ? void 0 : controller.abort();
790
+ resolve(DEADLINE_EXPIRED);
791
+ }, timeoutMs);
792
+ });
793
+ const running = exchange(controller == null ? void 0 : controller.signal);
794
+ try {
795
+ return await Promise.race([running, expired]);
796
+ } finally {
797
+ clearTimeout(timer);
798
+ void running.catch(() => {
799
+ });
800
+ }
801
+ };
802
+
834
803
  // src/analytics/reportClientEvent.ts
835
804
  var makeSessionId = () => `wire_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
836
805
  var toWireEvents = (events) => events.map((event) => {
@@ -872,17 +841,51 @@ var CURRENT_SESSION_ID_SLOT = /* @__PURE__ */ Symbol.for(
872
841
  "@wireai/activation:currentSessionId"
873
842
  );
874
843
  var globalSlot = globalThis;
844
+ var SESSION_ID_LISTENERS_SLOT = /* @__PURE__ */ Symbol.for(
845
+ "@wireai/activation:currentSessionIdListeners"
846
+ );
847
+ var listenerSlot = globalThis;
848
+ var listeners = () => {
849
+ const existing = listenerSlot[SESSION_ID_LISTENERS_SLOT];
850
+ if (existing) return existing;
851
+ const created = /* @__PURE__ */ new Set();
852
+ listenerSlot[SESSION_ID_LISTENERS_SLOT] = created;
853
+ return created;
854
+ };
855
+ var writeCurrentSessionId = (id) => {
856
+ if (globalSlot[CURRENT_SESSION_ID_SLOT] === id) return;
857
+ globalSlot[CURRENT_SESSION_ID_SLOT] = id;
858
+ for (const listener of Array.from(listeners())) {
859
+ try {
860
+ listener();
861
+ } catch {
862
+ }
863
+ }
864
+ };
865
+ var subscribeCurrentSessionId = (listener) => {
866
+ const set = listeners();
867
+ set.add(listener);
868
+ return () => {
869
+ set.delete(listener);
870
+ };
871
+ };
875
872
  var getCurrentSessionId = () => globalSlot[CURRENT_SESSION_ID_SLOT];
876
873
  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.";
877
874
  var MINT_WARNED_SLOT = /* @__PURE__ */ Symbol.for(
878
875
  "@wireai/activation:currentSessionIdMintWarned"
879
876
  );
880
877
  var warnSlot = globalThis;
878
+ var MINTED_SESSION_ID_SLOT = /* @__PURE__ */ Symbol.for(
879
+ "@wireai/activation:mintedSessionId"
880
+ );
881
+ var mintedSlot = globalThis;
882
+ var isMintedSessionId = (id) => id !== void 0 && id === mintedSlot[MINTED_SESSION_ID_SLOT];
881
883
  var ensureCurrentSessionId = () => {
882
884
  const existing = globalSlot[CURRENT_SESSION_ID_SLOT];
883
885
  if (typeof existing === "string" && existing.length > 0) return existing;
884
886
  const minted = makeSessionId();
885
- globalSlot[CURRENT_SESSION_ID_SLOT] = minted;
887
+ writeCurrentSessionId(minted);
888
+ mintedSlot[MINTED_SESSION_ID_SLOT] = minted;
886
889
  if (!warnSlot[MINT_WARNED_SLOT] && warnInDev(MINT_WARNING)) {
887
890
  warnSlot[MINT_WARNED_SLOT] = true;
888
891
  }
@@ -896,11 +899,15 @@ var submitReview = async (target, review) => {
896
899
  const url = `${target.serverUrl.replace(/\/$/, "")}/v1/reviews`;
897
900
  const headers = { "Content-Type": "application/json" };
898
901
  if (target.apiKey) headers.Authorization = `Bearer ${target.apiKey}`;
899
- const res = await fetch(url, {
900
- method: "POST",
901
- headers,
902
- body: JSON.stringify(review)
903
- });
902
+ const res = await withDeadline(
903
+ (signal) => fetch(url, {
904
+ method: "POST",
905
+ headers,
906
+ body: JSON.stringify(review),
907
+ signal
908
+ })
909
+ );
910
+ if (res === DEADLINE_EXPIRED) return "unsent";
904
911
  if (!res) return "unsent";
905
912
  return res.ok ? "accepted" : "rejected";
906
913
  } catch {
@@ -918,9 +925,12 @@ var fetchReviewDecision = async (target, options = {}) => {
918
925
  const url = `${base}/v1/reviews/decision${qs ? `?${qs}` : ""}`;
919
926
  const headers = {};
920
927
  if (target.apiKey) headers.Authorization = `Bearer ${target.apiKey}`;
921
- const res = await fetch(url, { headers });
922
- if (!res || !res.ok) return null;
923
- const json = await res.json();
928
+ const json = await withDeadline(async (signal) => {
929
+ const res = await fetch(url, { headers, signal });
930
+ if (!res || !res.ok) return null;
931
+ return await res.json();
932
+ });
933
+ if (json === DEADLINE_EXPIRED) return null;
924
934
  if (!json || typeof json.fire !== "boolean") return null;
925
935
  return json;
926
936
  } catch {
@@ -1015,6 +1025,10 @@ var _ReviewGate = ({
1015
1025
  );
1016
1026
  const postedRef = React7__default.default.useRef(false);
1017
1027
  const unackedRef = React7__default.default.useRef(null);
1028
+ const recoverOnSettleRef = React7__default.default.useRef(false);
1029
+ const recoveredRef = React7__default.default.useRef(false);
1030
+ const postOnceRef = React7__default.default.useRef(() => {
1031
+ });
1018
1032
  const postOnce = React7.useCallback(
1019
1033
  (body) => {
1020
1034
  if (postedRef.current) return;
@@ -1023,6 +1037,10 @@ var _ReviewGate = ({
1023
1037
  void submitReview(target, body).then((result) => {
1024
1038
  if (result === "unsent") {
1025
1039
  postedRef.current = false;
1040
+ if (recoverOnSettleRef.current && !recoveredRef.current) {
1041
+ recoveredRef.current = true;
1042
+ postOnceRef.current(body);
1043
+ }
1026
1044
  return;
1027
1045
  }
1028
1046
  unackedRef.current = null;
@@ -1030,6 +1048,7 @@ var _ReviewGate = ({
1030
1048
  },
1031
1049
  [target]
1032
1050
  );
1051
+ postOnceRef.current = postOnce;
1033
1052
  const abandonRef = React7__default.default.useRef(() => {
1034
1053
  });
1035
1054
  abandonRef.current = () => {
@@ -1038,6 +1057,10 @@ var _ReviewGate = ({
1038
1057
  postOnce(undelivered);
1039
1058
  return;
1040
1059
  }
1060
+ if (postedRef.current && undelivered) {
1061
+ recoverOnSettleRef.current = true;
1062
+ return;
1063
+ }
1041
1064
  if (postedRef.current || stars < 1) return;
1042
1065
  postOnce(
1043
1066
  buildBody({
@@ -1383,17 +1406,31 @@ var failOpen = async (storage, key) => {
1383
1406
  const cached = await readCachedFeatures(storage, key);
1384
1407
  return (_a = cached == null ? void 0 : cached.features) != null ? _a : defaultWireFeatures;
1385
1408
  };
1386
- var fetchWithTimeout = async (url, apiKey, timeoutMs) => {
1409
+ var NO_ANSWER = /* @__PURE__ */ Symbol("wire-features-no-answer");
1410
+ var fetchFeaturesJson = async (url, apiKey, timeoutMs) => {
1387
1411
  const controller = typeof AbortController !== "undefined" ? new AbortController() : void 0;
1388
- const timer = setTimeout(() => controller == null ? void 0 : controller.abort(), timeoutMs);
1389
- try {
1390
- return await fetch(url, {
1412
+ let timer;
1413
+ const expired = new Promise((resolve) => {
1414
+ timer = setTimeout(() => {
1415
+ controller == null ? void 0 : controller.abort();
1416
+ resolve(NO_ANSWER);
1417
+ }, timeoutMs);
1418
+ });
1419
+ const exchange = (async () => {
1420
+ const res = await fetch(url, {
1391
1421
  method: "GET",
1392
1422
  headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` },
1393
1423
  signal: controller == null ? void 0 : controller.signal
1394
1424
  });
1425
+ if (!res || !res.ok) return NO_ANSWER;
1426
+ return await res.json();
1427
+ })();
1428
+ try {
1429
+ return await Promise.race([exchange, expired]);
1395
1430
  } finally {
1396
1431
  clearTimeout(timer);
1432
+ void exchange.catch(() => {
1433
+ });
1397
1434
  }
1398
1435
  };
1399
1436
  var fetchWireFeatures = async (config) => {
@@ -1403,9 +1440,8 @@ var fetchWireFeatures = async (config) => {
1403
1440
  const url = `${config.serverUrl.replace(/\/$/, "")}/v1/features`;
1404
1441
  const timeoutMs = config.timeoutMs && config.timeoutMs > 0 ? config.timeoutMs : DEFAULT_TIMEOUT_MS;
1405
1442
  try {
1406
- const res = await fetchWithTimeout(url, config.apiKey, timeoutMs);
1407
- if (!res || !res.ok) return failOpen(storage, key);
1408
- const json = await res.json();
1443
+ const json = await fetchFeaturesJson(url, config.apiKey, timeoutMs);
1444
+ if (json === NO_ANSWER) return failOpen(storage, key);
1409
1445
  const features = parseWireFeatures(json);
1410
1446
  if (storage) writeCachedFeatures(storage, key, features);
1411
1447
  return features;
@@ -1421,39 +1457,51 @@ var useStableValue = (value, isEqual) => {
1421
1457
  return ref.current;
1422
1458
  };
1423
1459
  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);
1424
- var useWireFeatures = (config) => {
1460
+ var useWireFeaturesState = (config) => {
1425
1461
  const stableConfig = useStableValue(config, sameFetchKey);
1426
1462
  const [flags, setFlags] = React7.useState(defaultWireFeatures);
1427
1463
  const canFetch = !!(stableConfig == null ? void 0 : stableConfig.serverUrl) && !!(stableConfig == null ? void 0 : stableConfig.apiKey);
1464
+ const [settled, setSettled] = React7.useState(!canFetch);
1428
1465
  React7.useEffect(() => {
1429
1466
  if (!canFetch) {
1430
1467
  setFlags((prev) => featuresEqual(prev, defaultWireFeatures) ? prev : defaultWireFeatures);
1468
+ setSettled(true);
1431
1469
  return;
1432
1470
  }
1433
1471
  let alive = true;
1434
1472
  void fetchWireFeatures(stableConfig).then((next) => {
1435
1473
  if (!alive) return;
1436
1474
  setFlags((prev) => featuresEqual(prev, next) ? prev : next);
1475
+ setSettled(true);
1437
1476
  });
1438
1477
  return () => {
1439
1478
  alive = false;
1440
1479
  };
1441
1480
  }, [canFetch, stableConfig]);
1442
- return flags;
1481
+ return React7.useMemo(() => ({ flags, settled }), [flags, settled]);
1443
1482
  };
1444
1483
  var CONTEXT_SYMBOL = /* @__PURE__ */ Symbol.for("wireai.features.context");
1445
- var globalObj = globalThis;
1446
- if (!globalObj[CONTEXT_SYMBOL]) {
1447
- globalObj[CONTEXT_SYMBOL] = React7.createContext(null);
1484
+ var featuresContextGlobal = globalThis;
1485
+ if (!featuresContextGlobal[CONTEXT_SYMBOL]) {
1486
+ featuresContextGlobal[CONTEXT_SYMBOL] = React7.createContext(null);
1487
+ }
1488
+ var WireFeaturesContext = featuresContextGlobal[CONTEXT_SYMBOL];
1489
+ var SETTLED_SYMBOL = /* @__PURE__ */ Symbol.for("wireai.features.settled.context");
1490
+ var settledContextGlobal = globalThis;
1491
+ if (!settledContextGlobal[SETTLED_SYMBOL]) {
1492
+ settledContextGlobal[SETTLED_SYMBOL] = React7.createContext(true);
1448
1493
  }
1449
- var WireFeaturesContext = globalObj[CONTEXT_SYMBOL];
1494
+ var WireFeaturesSettledContext = settledContextGlobal[SETTLED_SYMBOL];
1450
1495
  var useWireFeaturesContext = () => React7.useContext(WireFeaturesContext);
1451
- var useResolvedFeatures = (options) => {
1496
+ var useResolvedFeaturesState = (options) => {
1452
1497
  var _a, _b, _c;
1453
1498
  const ctx = useWireFeaturesContext();
1499
+ const ctxSettled = React7.useContext(WireFeaturesSettledContext);
1454
1500
  const shouldFetch = !(options == null ? void 0 : options.flags) && ctx == null;
1455
- const fetched = useWireFeatures(shouldFetch ? options == null ? void 0 : options.config : void 0);
1456
- return (_c = (_b = (_a = options == null ? void 0 : options.flags) != null ? _a : ctx) != null ? _b : fetched) != null ? _c : defaultWireFeatures;
1501
+ const fetched = useWireFeaturesState(shouldFetch ? options == null ? void 0 : options.config : void 0);
1502
+ const flags = (_c = (_b = (_a = options == null ? void 0 : options.flags) != null ? _a : ctx) != null ? _b : fetched.flags) != null ? _c : defaultWireFeatures;
1503
+ const settled = (options == null ? void 0 : options.flags) ? true : ctx != null ? ctxSettled : fetched.settled;
1504
+ return React7.useMemo(() => ({ flags, settled }), [flags, settled]);
1457
1505
  };
1458
1506
 
1459
1507
  // src/reviews/equality.ts
@@ -1527,18 +1575,23 @@ var PROCESS_OPEN_ID_SLOT = /* @__PURE__ */ Symbol.for("@wireai/activation:proces
1527
1575
  var openIdGlobal = globalThis;
1528
1576
  var currentOpenId = () => {
1529
1577
  const live = getCurrentSessionId();
1578
+ const liveMinted = isMintedSessionId(live);
1530
1579
  const pin = openIdGlobal[PROCESS_OPEN_ID_SLOT];
1531
1580
  if (!pin) {
1532
1581
  const openId = live != null ? live : makeSessionId();
1533
- openIdGlobal[PROCESS_OPEN_ID_SLOT] = { openId, observed: live };
1582
+ openIdGlobal[PROCESS_OPEN_ID_SLOT] = { openId, observed: live, observedMinted: liveMinted };
1534
1583
  return openId;
1535
1584
  }
1536
1585
  if (!live || live === pin.observed) return pin.openId;
1537
- if (pin.observed === void 0) {
1538
- openIdGlobal[PROCESS_OPEN_ID_SLOT] = { openId: pin.openId, observed: live };
1586
+ if (pin.observed === void 0 || pin.observedMinted) {
1587
+ openIdGlobal[PROCESS_OPEN_ID_SLOT] = {
1588
+ openId: pin.openId,
1589
+ observed: live,
1590
+ observedMinted: liveMinted
1591
+ };
1539
1592
  return pin.openId;
1540
1593
  }
1541
- openIdGlobal[PROCESS_OPEN_ID_SLOT] = { openId: live, observed: live };
1594
+ openIdGlobal[PROCESS_OPEN_ID_SLOT] = { openId: live, observed: live, observedMinted: liveMinted };
1542
1595
  return live;
1543
1596
  };
1544
1597
  var bumpSessionCount = (storage, sessionsKey, openKey, openId = currentOpenId()) => {
@@ -1576,17 +1629,32 @@ var useReviewGate = ({
1576
1629
  decisionProp,
1577
1630
  (a, b) => sameDecision(a, b)
1578
1631
  );
1579
- const featureEnabled = useResolvedFeatures({ flags: features, config: featuresConfig }).review.enabled;
1632
+ const { flags: resolvedFeatures, settled: featuresSettled } = useResolvedFeaturesState({
1633
+ flags: features,
1634
+ config: featuresConfig
1635
+ });
1636
+ const featureEnabled = resolvedFeatures.review.enabled;
1580
1637
  const testing = isTesting != null ? isTesting : isCoachmarkTesting();
1581
1638
  const seenKey = reviewSeenKey(config.id, config.oncePerVersion === false ? void 0 : config.appVersion);
1582
1639
  const lastKey = reviewLastShownKey(config.id);
1583
1640
  const sessionsKey = reviewSessionsKey(config.id);
1584
1641
  const sessionOpenKey = reviewSessionOpenKey(config.id);
1585
- const sessions = React7.useState(() => {
1642
+ const openSessionId = React7.useSyncExternalStore(
1643
+ subscribeCurrentSessionId,
1644
+ getCurrentSessionId,
1645
+ getCurrentSessionId
1646
+ );
1647
+ const storageRef = React7.useRef(storage);
1648
+ storageRef.current = storage;
1649
+ const [sessions, setSessions] = React7.useState(() => {
1586
1650
  const store = resolveStorage(storage);
1587
1651
  warnMissingGateStorage(store, "review");
1588
1652
  return bumpSessionCount(store, sessionsKey, sessionOpenKey);
1589
- })[0];
1653
+ });
1654
+ React7.useEffect(() => {
1655
+ const next = bumpSessionCount(resolveStorage(storageRef.current), sessionsKey, sessionOpenKey);
1656
+ setSessions((prev) => prev === next ? prev : next);
1657
+ }, [openSessionId, sessionsKey, sessionOpenKey]);
1590
1658
  const hasServerDecision = decision != null;
1591
1659
  const timeoutMs = config.timeoutFallbackMs;
1592
1660
  const waits = !hasServerDecision && timeoutMs != null && timeoutMs > 0;
@@ -1612,9 +1680,9 @@ var useReviewGate = ({
1612
1680
  });
1613
1681
  const resolved = decideReview(local, decision);
1614
1682
  const alreadySeen = hasSeenGate(seenKey, storage, isTesting);
1615
- const ready = hasServerDecision || elapsed;
1683
+ const ready = (hasServerDecision || elapsed) && featuresSettled;
1616
1684
  return { visible: ready && resolved.fire && !alreadySeen, verdict: resolved };
1617
- }, [featureEnabled, testing, config, decision, events, sessions, elapsed, hasServerDecision, seenKey, lastKey, storage, isTesting]);
1685
+ }, [featureEnabled, featuresSettled, testing, config, decision, events, sessions, elapsed, hasServerDecision, seenKey, lastKey, storage, isTesting]);
1618
1686
  const shownRef = React7.useRef(false);
1619
1687
  const markShown = React7.useCallback(() => {
1620
1688
  if (shownRef.current) return;