@feelflow/ffid-sdk 5.14.0 → 5.16.2

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/README.md CHANGED
@@ -46,6 +46,10 @@ export default function RootLayout({ children }) {
46
46
  baseUrl={process.env.NEXT_PUBLIC_FFID_BASE_URL!}
47
47
  serviceApiKey={process.env.FFID_SERVICE_API_KEY!}
48
48
  gaMeasurementId={process.env.NEXT_PUBLIC_GA_ID ?? null}
49
+ // Production-only measurement (v5.16.0+): suppress GA on
50
+ // preview/staging/local so non-prod traffic never pollutes the
51
+ // production GA4 property. Defaults to true if omitted.
52
+ gaEnabled={process.env.VERCEL_ENV === 'production'}
49
53
  >
50
54
  {children}
51
55
  <FFIDCookieBanner />
@@ -393,13 +393,19 @@ interface FFIDConsentSyncResult {
393
393
  /**
394
394
  * Error code literal union for consent SDK failures.
395
395
  *
396
- * - The first 10 codes (`cookie_consent_*`) are reserved by spec §8.2 for
397
- * correlation across SDK / FFID API / ops dashboards.
396
+ * - The `cookie_consent_*` codes reserved by spec §8.2 correlate across
397
+ * SDK / FFID API / ops dashboards.
398
+ * - `cookie_consent_aborted` is an SDK-only code (NOT spec §8.2 reserved):
399
+ * the in-flight fetch was cancelled via an `AbortSignal` (e.g. component
400
+ * unmount or page navigation). It is a benign teardown, not a backend
401
+ * failure, so consumers should generally NOT surface it as an error — it is
402
+ * kept distinct from `cookie_consent_api_unreachable` precisely so the
403
+ * provider can swallow it instead of emitting Sentry noise (#3869).
398
404
  * - `auth_failure` is added by the SDK to cover 401 / 403 responses; it is
399
405
  * NOT part of spec §8.2 reserved codes — the SDK shares this code with the
400
406
  * wider FFID auth surface so consumer error handlers can branch uniformly.
401
407
  */
402
- declare const FFID_CONSENT_ERROR_CODES: readonly ["cookie_consent_api_unreachable", "cookie_consent_response_invalid", "cookie_consent_rate_limited", "cookie_consent_cookie_write_failed", "cookie_consent_parse_failed", "cookie_consent_sync_conflict", "cookie_consent_immutable_violation", "cookie_consent_policy_outdated", "cookie_consent_capture_failed", "cookie_consent_db_write_failed", "auth_failure"];
408
+ declare const FFID_CONSENT_ERROR_CODES: readonly ["cookie_consent_api_unreachable", "cookie_consent_aborted", "cookie_consent_response_invalid", "cookie_consent_rate_limited", "cookie_consent_cookie_write_failed", "cookie_consent_parse_failed", "cookie_consent_sync_conflict", "cookie_consent_immutable_violation", "cookie_consent_policy_outdated", "cookie_consent_capture_failed", "cookie_consent_db_write_failed", "auth_failure"];
403
409
  type FFIDConsentErrorCode = (typeof FFID_CONSENT_ERROR_CODES)[number];
404
410
  /**
405
411
  * Error class for all consent-related failures surfaced through SDK callbacks
@@ -610,11 +616,31 @@ declare global {
610
616
  interface Window {
611
617
  dataLayer?: unknown[];
612
618
  gtag?: (...args: unknown[]) => void;
619
+ /**
620
+ * Set once `consent default` has been pushed onto THIS window's
621
+ * `dataLayer`. The default is page-global and only needs to land once
622
+ * before GA loads, so the bridge dedups on the window — the same scope
623
+ * that owns `dataLayer`/`gtag` — rather than per bridge instance. This
624
+ * survives provider remounts and multiple bridge instances, which a
625
+ * per-React-ref guard cannot (it never sees the shared global dataLayer),
626
+ * fixing the ≈142× duplicate `consent default` pushes seen in prod (#3982).
627
+ */
628
+ __ffidConsentDefaultApplied?: boolean;
613
629
  }
614
630
  }
615
631
  interface GtagBridgeOptions {
616
632
  /** GA Measurement ID, e.g., `G-XXXXXXX`. Empty/null = GA not wired. */
617
633
  gaMeasurementId?: string | null;
634
+ /**
635
+ * Master switch for GA tag injection. When `false`, the bridge still manages
636
+ * Consent Mode dataLayer pushes (`setDefaultConsent` / `updateConsent`) but
637
+ * `applyGaInjection` never appends the `gtag.js` script — regardless of
638
+ * `gaMeasurementId` or analytics consent. Defaults to `true` (legacy
639
+ * behavior). Consumers gate this on their own environment so non-production
640
+ * builds don't pollute the production GA4 property (e.g.
641
+ * `gaEnabled={process.env.VERCEL_ENV === 'production'}`).
642
+ */
643
+ gaEnabled?: boolean;
618
644
  /** Override script src for testing. Default: official gtag URL. */
619
645
  gtagScriptSrc?: string;
620
646
  /** Document/window injection target (server-side stays no-op). */
@@ -641,7 +667,12 @@ declare function mapCategoriesToGtagParams(cats: FFIDConsentCategories): GtagCon
641
667
  interface GtagBridge {
642
668
  /**
643
669
  * Push `consent default` with ALL_DENIED_EXCEPT_NECESSARY. Must be called
644
- * **synchronously** before any GA script tag is added. Idempotent.
670
+ * **synchronously** before any GA script tag is added.
671
+ *
672
+ * Idempotent at the window scope: `consent default` is pushed at most once
673
+ * per window's `dataLayer`, no matter how many bridge instances call it
674
+ * (provider remount, a second provider, strict-mode double-mount). The
675
+ * default is page-global and only needs to land once before GA loads (#3982).
645
676
  */
646
677
  setDefaultConsent(): void;
647
678
  /**
@@ -703,6 +734,23 @@ interface FFIDAnalyticsProviderProps {
703
734
  * avoiding the triple-state `string | null | undefined` anti-pattern.
704
735
  */
705
736
  gaMeasurementId?: string | null;
737
+ /**
738
+ * Master switch for GA tag injection. When `false`, the provider still runs
739
+ * Consent Mode (default/update dataLayer pushes) but never injects the
740
+ * `gtag.js` script — regardless of `gaMeasurementId` or analytics consent.
741
+ * Defaults to `true` (legacy behavior, fully backward compatible).
742
+ *
743
+ * Gate this on your own deploy environment so non-production builds don't
744
+ * send hits to the production GA4 property, e.g.
745
+ * `gaEnabled={process.env.VERCEL_ENV === 'production'}`. Read the env signal
746
+ * server-side where possible (it is captured once at mount, not reactive).
747
+ *
748
+ * Prefer `gaEnabled={false}` over `gaMeasurementId={null}` when gating by
749
+ * environment: `gaEnabled={false}` keeps a stable measurement ID wired across
750
+ * builds and keeps Consent Mode pushes flowing, whereas `null` means "GA was
751
+ * never configured for this app".
752
+ */
753
+ gaEnabled?: boolean;
706
754
  /** When true, control Sentry session replay based on `functional` consent. */
707
755
  sentryReplay?: boolean;
708
756
  /** Sentry namespace override; defaults to `(window as any).Sentry`. */
@@ -751,7 +799,7 @@ interface FFIDAnalyticsProviderProps {
751
799
  fetchImpl?: typeof fetch;
752
800
  children: ReactNode;
753
801
  }
754
- declare function FFIDAnalyticsProvider({ baseUrl, serviceApiKey, getAccessToken, gaMeasurementId, sentryReplay, sentry, onConsentChange, onError, autoShowBanner, dismissalCooldownMinutes, mergeWarningMessages, fetchImpl, children, }: FFIDAnalyticsProviderProps): React.JSX.Element;
802
+ declare function FFIDAnalyticsProvider({ baseUrl, serviceApiKey, getAccessToken, gaMeasurementId, gaEnabled, sentryReplay, sentry, onConsentChange, onError, autoShowBanner, dismissalCooldownMinutes, mergeWarningMessages, fetchImpl, children, }: FFIDAnalyticsProviderProps): React.JSX.Element;
755
803
 
756
804
  interface FFIDConsentContextValue {
757
805
  /** Current consent state. Always non-null; "not yet decided" is encoded by `hasDecided=false`. */
@@ -393,13 +393,19 @@ interface FFIDConsentSyncResult {
393
393
  /**
394
394
  * Error code literal union for consent SDK failures.
395
395
  *
396
- * - The first 10 codes (`cookie_consent_*`) are reserved by spec §8.2 for
397
- * correlation across SDK / FFID API / ops dashboards.
396
+ * - The `cookie_consent_*` codes reserved by spec §8.2 correlate across
397
+ * SDK / FFID API / ops dashboards.
398
+ * - `cookie_consent_aborted` is an SDK-only code (NOT spec §8.2 reserved):
399
+ * the in-flight fetch was cancelled via an `AbortSignal` (e.g. component
400
+ * unmount or page navigation). It is a benign teardown, not a backend
401
+ * failure, so consumers should generally NOT surface it as an error — it is
402
+ * kept distinct from `cookie_consent_api_unreachable` precisely so the
403
+ * provider can swallow it instead of emitting Sentry noise (#3869).
398
404
  * - `auth_failure` is added by the SDK to cover 401 / 403 responses; it is
399
405
  * NOT part of spec §8.2 reserved codes — the SDK shares this code with the
400
406
  * wider FFID auth surface so consumer error handlers can branch uniformly.
401
407
  */
402
- declare const FFID_CONSENT_ERROR_CODES: readonly ["cookie_consent_api_unreachable", "cookie_consent_response_invalid", "cookie_consent_rate_limited", "cookie_consent_cookie_write_failed", "cookie_consent_parse_failed", "cookie_consent_sync_conflict", "cookie_consent_immutable_violation", "cookie_consent_policy_outdated", "cookie_consent_capture_failed", "cookie_consent_db_write_failed", "auth_failure"];
408
+ declare const FFID_CONSENT_ERROR_CODES: readonly ["cookie_consent_api_unreachable", "cookie_consent_aborted", "cookie_consent_response_invalid", "cookie_consent_rate_limited", "cookie_consent_cookie_write_failed", "cookie_consent_parse_failed", "cookie_consent_sync_conflict", "cookie_consent_immutable_violation", "cookie_consent_policy_outdated", "cookie_consent_capture_failed", "cookie_consent_db_write_failed", "auth_failure"];
403
409
  type FFIDConsentErrorCode = (typeof FFID_CONSENT_ERROR_CODES)[number];
404
410
  /**
405
411
  * Error class for all consent-related failures surfaced through SDK callbacks
@@ -610,11 +616,31 @@ declare global {
610
616
  interface Window {
611
617
  dataLayer?: unknown[];
612
618
  gtag?: (...args: unknown[]) => void;
619
+ /**
620
+ * Set once `consent default` has been pushed onto THIS window's
621
+ * `dataLayer`. The default is page-global and only needs to land once
622
+ * before GA loads, so the bridge dedups on the window — the same scope
623
+ * that owns `dataLayer`/`gtag` — rather than per bridge instance. This
624
+ * survives provider remounts and multiple bridge instances, which a
625
+ * per-React-ref guard cannot (it never sees the shared global dataLayer),
626
+ * fixing the ≈142× duplicate `consent default` pushes seen in prod (#3982).
627
+ */
628
+ __ffidConsentDefaultApplied?: boolean;
613
629
  }
614
630
  }
615
631
  interface GtagBridgeOptions {
616
632
  /** GA Measurement ID, e.g., `G-XXXXXXX`. Empty/null = GA not wired. */
617
633
  gaMeasurementId?: string | null;
634
+ /**
635
+ * Master switch for GA tag injection. When `false`, the bridge still manages
636
+ * Consent Mode dataLayer pushes (`setDefaultConsent` / `updateConsent`) but
637
+ * `applyGaInjection` never appends the `gtag.js` script — regardless of
638
+ * `gaMeasurementId` or analytics consent. Defaults to `true` (legacy
639
+ * behavior). Consumers gate this on their own environment so non-production
640
+ * builds don't pollute the production GA4 property (e.g.
641
+ * `gaEnabled={process.env.VERCEL_ENV === 'production'}`).
642
+ */
643
+ gaEnabled?: boolean;
618
644
  /** Override script src for testing. Default: official gtag URL. */
619
645
  gtagScriptSrc?: string;
620
646
  /** Document/window injection target (server-side stays no-op). */
@@ -641,7 +667,12 @@ declare function mapCategoriesToGtagParams(cats: FFIDConsentCategories): GtagCon
641
667
  interface GtagBridge {
642
668
  /**
643
669
  * Push `consent default` with ALL_DENIED_EXCEPT_NECESSARY. Must be called
644
- * **synchronously** before any GA script tag is added. Idempotent.
670
+ * **synchronously** before any GA script tag is added.
671
+ *
672
+ * Idempotent at the window scope: `consent default` is pushed at most once
673
+ * per window's `dataLayer`, no matter how many bridge instances call it
674
+ * (provider remount, a second provider, strict-mode double-mount). The
675
+ * default is page-global and only needs to land once before GA loads (#3982).
645
676
  */
646
677
  setDefaultConsent(): void;
647
678
  /**
@@ -703,6 +734,23 @@ interface FFIDAnalyticsProviderProps {
703
734
  * avoiding the triple-state `string | null | undefined` anti-pattern.
704
735
  */
705
736
  gaMeasurementId?: string | null;
737
+ /**
738
+ * Master switch for GA tag injection. When `false`, the provider still runs
739
+ * Consent Mode (default/update dataLayer pushes) but never injects the
740
+ * `gtag.js` script — regardless of `gaMeasurementId` or analytics consent.
741
+ * Defaults to `true` (legacy behavior, fully backward compatible).
742
+ *
743
+ * Gate this on your own deploy environment so non-production builds don't
744
+ * send hits to the production GA4 property, e.g.
745
+ * `gaEnabled={process.env.VERCEL_ENV === 'production'}`. Read the env signal
746
+ * server-side where possible (it is captured once at mount, not reactive).
747
+ *
748
+ * Prefer `gaEnabled={false}` over `gaMeasurementId={null}` when gating by
749
+ * environment: `gaEnabled={false}` keeps a stable measurement ID wired across
750
+ * builds and keeps Consent Mode pushes flowing, whereas `null` means "GA was
751
+ * never configured for this app".
752
+ */
753
+ gaEnabled?: boolean;
706
754
  /** When true, control Sentry session replay based on `functional` consent. */
707
755
  sentryReplay?: boolean;
708
756
  /** Sentry namespace override; defaults to `(window as any).Sentry`. */
@@ -751,7 +799,7 @@ interface FFIDAnalyticsProviderProps {
751
799
  fetchImpl?: typeof fetch;
752
800
  children: ReactNode;
753
801
  }
754
- declare function FFIDAnalyticsProvider({ baseUrl, serviceApiKey, getAccessToken, gaMeasurementId, sentryReplay, sentry, onConsentChange, onError, autoShowBanner, dismissalCooldownMinutes, mergeWarningMessages, fetchImpl, children, }: FFIDAnalyticsProviderProps): React.JSX.Element;
802
+ declare function FFIDAnalyticsProvider({ baseUrl, serviceApiKey, getAccessToken, gaMeasurementId, gaEnabled, sentryReplay, sentry, onConsentChange, onError, autoShowBanner, dismissalCooldownMinutes, mergeWarningMessages, fetchImpl, children, }: FFIDAnalyticsProviderProps): React.JSX.Element;
755
803
 
756
804
  interface FFIDConsentContextValue {
757
805
  /** Current consent state. Always non-null; "not yet decided" is encoded by `hasDecided=false`. */
@@ -1093,7 +1093,7 @@ function createNonContractMethods(deps) {
1093
1093
  }
1094
1094
 
1095
1095
  // src/client/version-check.ts
1096
- var SDK_VERSION = "5.14.0";
1096
+ var SDK_VERSION = "5.16.2";
1097
1097
  var SDK_USER_AGENT = `FFID-SDK/${SDK_VERSION} (TypeScript)`;
1098
1098
  var SDK_VERSION_HEADER = "X-FFID-SDK-Version";
1099
1099
  function sdkHeaders() {
@@ -1095,7 +1095,7 @@ function createNonContractMethods(deps) {
1095
1095
  }
1096
1096
 
1097
1097
  // src/client/version-check.ts
1098
- var SDK_VERSION = "5.14.0";
1098
+ var SDK_VERSION = "5.16.2";
1099
1099
  var SDK_USER_AGENT = `FFID-SDK/${SDK_VERSION} (TypeScript)`;
1100
1100
  var SDK_VERSION_HEADER = "X-FFID-SDK-Version";
1101
1101
  function sdkHeaders() {
@@ -158,6 +158,7 @@ var ErrorBodyWireSchema = z.object({
158
158
  var FFID_CONSENT_ERROR_CODES = [
159
159
  // SDK client (network / response)
160
160
  "cookie_consent_api_unreachable",
161
+ "cookie_consent_aborted",
161
162
  "cookie_consent_response_invalid",
162
163
  "cookie_consent_rate_limited",
163
164
  // SDK storage
@@ -191,6 +192,7 @@ var FFIDConsentError = class extends Error {
191
192
  };
192
193
  var DEFAULT_CONSENT_ERROR_MESSAGES = {
193
194
  cookie_consent_api_unreachable: "\u901A\u4FE1\u30A8\u30E9\u30FC\u304C\u767A\u751F\u3057\u307E\u3057\u305F\u3002\u3082\u3046\u4E00\u5EA6\u304A\u8A66\u3057\u304F\u3060\u3055\u3044",
195
+ cookie_consent_aborted: "\u901A\u4FE1\u304C\u4E2D\u65AD\u3055\u308C\u307E\u3057\u305F\u3002\u3082\u3046\u4E00\u5EA6\u304A\u8A66\u3057\u304F\u3060\u3055\u3044",
194
196
  cookie_consent_response_invalid: "\u540C\u610F\u60C5\u5831\u306E\u53D6\u5F97\u306B\u5931\u6557\u3057\u307E\u3057\u305F\u3002\u6642\u9593\u3092\u7F6E\u3044\u3066\u518D\u5EA6\u304A\u8A66\u3057\u304F\u3060\u3055\u3044",
195
197
  cookie_consent_rate_limited: "\u30B5\u30FC\u30D3\u30B9\u304C\u6DF7\u96D1\u3057\u3066\u3044\u307E\u3059\u3002\u3057\u3070\u3089\u304F\u7D4C\u3063\u3066\u304B\u3089\u518D\u5EA6\u304A\u8A66\u3057\u304F\u3060\u3055\u3044",
196
198
  cookie_consent_cookie_write_failed: "\u30D6\u30E9\u30A6\u30B6\u306E\u8A2D\u5B9A\u306B\u3088\u308A\u540C\u610F\u60C5\u5831\u3092\u4FDD\u5B58\u3067\u304D\u307E\u305B\u3093\u3067\u3057\u305F\u3002\u30D7\u30E9\u30A4\u30D9\u30FC\u30C8\u30D6\u30E9\u30A6\u30BA\u3084\u62E1\u5F35\u6A5F\u80FD\u3092\u3054\u78BA\u8A8D\u304F\u3060\u3055\u3044",
@@ -656,6 +658,9 @@ function jitteredDelayMs(baseSec) {
656
658
  const jitter = (Math.random() * 2 - 1) * RETRY_JITTER_RATIO;
657
659
  return Math.max(0, baseSec * MS_PER_SECOND * (1 + jitter));
658
660
  }
661
+ function isAbortError(cause) {
662
+ return typeof cause === "object" && cause !== null && "name" in cause && cause.name === "AbortError";
663
+ }
659
664
  function parseErrorBody(json, retryAfterHeader) {
660
665
  const parsed = ErrorBodyWireSchema.safeParse(json);
661
666
  const body = parsed.success ? parsed.data : {};
@@ -765,6 +770,15 @@ function createConsentClient(opts) {
765
770
  const res = await fetchImpl(url, init);
766
771
  return { res };
767
772
  } catch (cause) {
773
+ if (isAbortError(cause)) {
774
+ return {
775
+ err: new FFIDConsentError(
776
+ "cookie_consent_aborted",
777
+ `Request to ${url} was aborted`,
778
+ { cause }
779
+ )
780
+ };
781
+ }
768
782
  return {
769
783
  err: new FFIDConsentError(
770
784
  "cookie_consent_api_unreachable",
@@ -978,30 +992,43 @@ function mapCategoriesToGtagParams(cats) {
978
992
  ad_personalization: bool(cats.marketing)
979
993
  };
980
994
  }
981
- function pushDataLayer(win, args) {
982
- if (!win) return;
983
- win.dataLayer = win.dataLayer ?? [];
984
- win.dataLayer.push(args);
995
+ function ensureGtag(win) {
996
+ const dataLayer = win.dataLayer = win.dataLayer ?? [];
997
+ let fn = win.gtag;
998
+ if (typeof fn !== "function") {
999
+ fn = function gtag() {
1000
+ dataLayer.push(arguments);
1001
+ };
1002
+ win.gtag = fn;
1003
+ }
1004
+ return fn;
985
1005
  }
986
1006
  function createGtagBridge(opts = {}) {
987
1007
  const win = opts.win ?? (typeof window !== "undefined" ? window : void 0);
988
1008
  const gaId = opts.gaMeasurementId ?? null;
1009
+ const gaEnabled = opts.gaEnabled ?? true;
989
1010
  const scriptSrc = opts.gtagScriptSrc ?? DEFAULT_GTAG_SRC;
990
1011
  let gaScriptInjected = false;
1012
+ function command(...args) {
1013
+ if (!win) return;
1014
+ ensureGtag(win)(...args);
1015
+ }
991
1016
  return {
992
1017
  setDefaultConsent() {
1018
+ if (!win || win.__ffidConsentDefaultApplied) return;
993
1019
  const denyParams = mapCategoriesToGtagParams({
994
1020
  functional: false,
995
1021
  analytics: false,
996
1022
  marketing: false
997
1023
  });
998
- pushDataLayer(win, ["consent", "default", denyParams]);
1024
+ command("consent", "default", denyParams);
1025
+ win.__ffidConsentDefaultApplied = true;
999
1026
  },
1000
1027
  updateConsent(cats) {
1001
- const params = mapCategoriesToGtagParams(cats);
1002
- pushDataLayer(win, ["consent", "update", params]);
1028
+ command("consent", "update", mapCategoriesToGtagParams(cats));
1003
1029
  },
1004
1030
  applyGaInjection(cats) {
1031
+ if (!gaEnabled) return;
1005
1032
  if (!win || !gaId) return;
1006
1033
  if (!cats.analytics) return;
1007
1034
  if (gaScriptInjected) return;
@@ -1011,8 +1038,8 @@ function createGtagBridge(opts = {}) {
1011
1038
  script.async = true;
1012
1039
  script.src = `${scriptSrc}?id=${encodeURIComponent(gaId)}`;
1013
1040
  doc.head.appendChild(script);
1014
- pushDataLayer(win, ["js", /* @__PURE__ */ new Date()]);
1015
- pushDataLayer(win, ["config", gaId]);
1041
+ command("js", /* @__PURE__ */ new Date());
1042
+ command("config", gaId);
1016
1043
  gaScriptInjected = true;
1017
1044
  }
1018
1045
  };
@@ -1070,6 +1097,7 @@ function FFIDAnalyticsProvider({
1070
1097
  serviceApiKey,
1071
1098
  getAccessToken,
1072
1099
  gaMeasurementId,
1100
+ gaEnabled,
1073
1101
  sentryReplay,
1074
1102
  sentry,
1075
1103
  onConsentChange,
@@ -1118,9 +1146,11 @@ function FFIDAnalyticsProvider({
1118
1146
  clientRef.current = createConsentClient(clientOpts);
1119
1147
  }
1120
1148
  if (!bridgeRef.current) {
1121
- bridgeRef.current = createGtagBridge({
1149
+ const bridgeOpts = {
1122
1150
  gaMeasurementId: gaMeasurementId ?? null
1123
- });
1151
+ };
1152
+ if (gaEnabled !== void 0) bridgeOpts.gaEnabled = gaEnabled;
1153
+ bridgeRef.current = createGtagBridge(bridgeOpts);
1124
1154
  bridgeRef.current.setDefaultConsent();
1125
1155
  }
1126
1156
  const applyCategoriesSideEffects = useCallback(
@@ -1187,14 +1217,16 @@ function FFIDAnalyticsProvider({
1187
1217
  );
1188
1218
  useEffect(() => {
1189
1219
  let cancelled = false;
1220
+ const controller = new AbortController();
1190
1221
  async function bootstrap() {
1191
1222
  const local = readConsentSessionMirror() ?? readConsentCookie();
1192
1223
  applyCategoriesSideEffects(local);
1193
1224
  setState(stateFromLocalCategories(local));
1194
1225
  const client = clientRef.current;
1195
- const result = await client.getMe();
1226
+ const result = await client.getMe(controller.signal);
1196
1227
  if (cancelled) return;
1197
1228
  if (!result.ok) {
1229
+ if (result.error.code === "cookie_consent_aborted") return;
1198
1230
  setError(result.error);
1199
1231
  onError?.(result.error);
1200
1232
  setIsLoading(false);
@@ -1218,6 +1250,7 @@ function FFIDAnalyticsProvider({
1218
1250
  void bootstrap();
1219
1251
  return () => {
1220
1252
  cancelled = true;
1253
+ controller.abort();
1221
1254
  };
1222
1255
  }, []);
1223
1256
  useEffect(() => {
@@ -160,6 +160,7 @@ var ErrorBodyWireSchema = zod.z.object({
160
160
  var FFID_CONSENT_ERROR_CODES = [
161
161
  // SDK client (network / response)
162
162
  "cookie_consent_api_unreachable",
163
+ "cookie_consent_aborted",
163
164
  "cookie_consent_response_invalid",
164
165
  "cookie_consent_rate_limited",
165
166
  // SDK storage
@@ -193,6 +194,7 @@ var FFIDConsentError = class extends Error {
193
194
  };
194
195
  var DEFAULT_CONSENT_ERROR_MESSAGES = {
195
196
  cookie_consent_api_unreachable: "\u901A\u4FE1\u30A8\u30E9\u30FC\u304C\u767A\u751F\u3057\u307E\u3057\u305F\u3002\u3082\u3046\u4E00\u5EA6\u304A\u8A66\u3057\u304F\u3060\u3055\u3044",
197
+ cookie_consent_aborted: "\u901A\u4FE1\u304C\u4E2D\u65AD\u3055\u308C\u307E\u3057\u305F\u3002\u3082\u3046\u4E00\u5EA6\u304A\u8A66\u3057\u304F\u3060\u3055\u3044",
196
198
  cookie_consent_response_invalid: "\u540C\u610F\u60C5\u5831\u306E\u53D6\u5F97\u306B\u5931\u6557\u3057\u307E\u3057\u305F\u3002\u6642\u9593\u3092\u7F6E\u3044\u3066\u518D\u5EA6\u304A\u8A66\u3057\u304F\u3060\u3055\u3044",
197
199
  cookie_consent_rate_limited: "\u30B5\u30FC\u30D3\u30B9\u304C\u6DF7\u96D1\u3057\u3066\u3044\u307E\u3059\u3002\u3057\u3070\u3089\u304F\u7D4C\u3063\u3066\u304B\u3089\u518D\u5EA6\u304A\u8A66\u3057\u304F\u3060\u3055\u3044",
198
200
  cookie_consent_cookie_write_failed: "\u30D6\u30E9\u30A6\u30B6\u306E\u8A2D\u5B9A\u306B\u3088\u308A\u540C\u610F\u60C5\u5831\u3092\u4FDD\u5B58\u3067\u304D\u307E\u305B\u3093\u3067\u3057\u305F\u3002\u30D7\u30E9\u30A4\u30D9\u30FC\u30C8\u30D6\u30E9\u30A6\u30BA\u3084\u62E1\u5F35\u6A5F\u80FD\u3092\u3054\u78BA\u8A8D\u304F\u3060\u3055\u3044",
@@ -658,6 +660,9 @@ function jitteredDelayMs(baseSec) {
658
660
  const jitter = (Math.random() * 2 - 1) * RETRY_JITTER_RATIO;
659
661
  return Math.max(0, baseSec * MS_PER_SECOND * (1 + jitter));
660
662
  }
663
+ function isAbortError(cause) {
664
+ return typeof cause === "object" && cause !== null && "name" in cause && cause.name === "AbortError";
665
+ }
661
666
  function parseErrorBody(json, retryAfterHeader) {
662
667
  const parsed = ErrorBodyWireSchema.safeParse(json);
663
668
  const body = parsed.success ? parsed.data : {};
@@ -767,6 +772,15 @@ function createConsentClient(opts) {
767
772
  const res = await fetchImpl(url, init);
768
773
  return { res };
769
774
  } catch (cause) {
775
+ if (isAbortError(cause)) {
776
+ return {
777
+ err: new FFIDConsentError(
778
+ "cookie_consent_aborted",
779
+ `Request to ${url} was aborted`,
780
+ { cause }
781
+ )
782
+ };
783
+ }
770
784
  return {
771
785
  err: new FFIDConsentError(
772
786
  "cookie_consent_api_unreachable",
@@ -980,30 +994,43 @@ function mapCategoriesToGtagParams(cats) {
980
994
  ad_personalization: bool(cats.marketing)
981
995
  };
982
996
  }
983
- function pushDataLayer(win, args) {
984
- if (!win) return;
985
- win.dataLayer = win.dataLayer ?? [];
986
- win.dataLayer.push(args);
997
+ function ensureGtag(win) {
998
+ const dataLayer = win.dataLayer = win.dataLayer ?? [];
999
+ let fn = win.gtag;
1000
+ if (typeof fn !== "function") {
1001
+ fn = function gtag() {
1002
+ dataLayer.push(arguments);
1003
+ };
1004
+ win.gtag = fn;
1005
+ }
1006
+ return fn;
987
1007
  }
988
1008
  function createGtagBridge(opts = {}) {
989
1009
  const win = opts.win ?? (typeof window !== "undefined" ? window : void 0);
990
1010
  const gaId = opts.gaMeasurementId ?? null;
1011
+ const gaEnabled = opts.gaEnabled ?? true;
991
1012
  const scriptSrc = opts.gtagScriptSrc ?? DEFAULT_GTAG_SRC;
992
1013
  let gaScriptInjected = false;
1014
+ function command(...args) {
1015
+ if (!win) return;
1016
+ ensureGtag(win)(...args);
1017
+ }
993
1018
  return {
994
1019
  setDefaultConsent() {
1020
+ if (!win || win.__ffidConsentDefaultApplied) return;
995
1021
  const denyParams = mapCategoriesToGtagParams({
996
1022
  functional: false,
997
1023
  analytics: false,
998
1024
  marketing: false
999
1025
  });
1000
- pushDataLayer(win, ["consent", "default", denyParams]);
1026
+ command("consent", "default", denyParams);
1027
+ win.__ffidConsentDefaultApplied = true;
1001
1028
  },
1002
1029
  updateConsent(cats) {
1003
- const params = mapCategoriesToGtagParams(cats);
1004
- pushDataLayer(win, ["consent", "update", params]);
1030
+ command("consent", "update", mapCategoriesToGtagParams(cats));
1005
1031
  },
1006
1032
  applyGaInjection(cats) {
1033
+ if (!gaEnabled) return;
1007
1034
  if (!win || !gaId) return;
1008
1035
  if (!cats.analytics) return;
1009
1036
  if (gaScriptInjected) return;
@@ -1013,8 +1040,8 @@ function createGtagBridge(opts = {}) {
1013
1040
  script.async = true;
1014
1041
  script.src = `${scriptSrc}?id=${encodeURIComponent(gaId)}`;
1015
1042
  doc.head.appendChild(script);
1016
- pushDataLayer(win, ["js", /* @__PURE__ */ new Date()]);
1017
- pushDataLayer(win, ["config", gaId]);
1043
+ command("js", /* @__PURE__ */ new Date());
1044
+ command("config", gaId);
1018
1045
  gaScriptInjected = true;
1019
1046
  }
1020
1047
  };
@@ -1072,6 +1099,7 @@ function FFIDAnalyticsProvider({
1072
1099
  serviceApiKey,
1073
1100
  getAccessToken,
1074
1101
  gaMeasurementId,
1102
+ gaEnabled,
1075
1103
  sentryReplay,
1076
1104
  sentry,
1077
1105
  onConsentChange,
@@ -1120,9 +1148,11 @@ function FFIDAnalyticsProvider({
1120
1148
  clientRef.current = createConsentClient(clientOpts);
1121
1149
  }
1122
1150
  if (!bridgeRef.current) {
1123
- bridgeRef.current = createGtagBridge({
1151
+ const bridgeOpts = {
1124
1152
  gaMeasurementId: gaMeasurementId ?? null
1125
- });
1153
+ };
1154
+ if (gaEnabled !== void 0) bridgeOpts.gaEnabled = gaEnabled;
1155
+ bridgeRef.current = createGtagBridge(bridgeOpts);
1126
1156
  bridgeRef.current.setDefaultConsent();
1127
1157
  }
1128
1158
  const applyCategoriesSideEffects = react.useCallback(
@@ -1189,14 +1219,16 @@ function FFIDAnalyticsProvider({
1189
1219
  );
1190
1220
  react.useEffect(() => {
1191
1221
  let cancelled = false;
1222
+ const controller = new AbortController();
1192
1223
  async function bootstrap() {
1193
1224
  const local = readConsentSessionMirror() ?? readConsentCookie();
1194
1225
  applyCategoriesSideEffects(local);
1195
1226
  setState(stateFromLocalCategories(local));
1196
1227
  const client = clientRef.current;
1197
- const result = await client.getMe();
1228
+ const result = await client.getMe(controller.signal);
1198
1229
  if (cancelled) return;
1199
1230
  if (!result.ok) {
1231
+ if (result.error.code === "cookie_consent_aborted") return;
1200
1232
  setError(result.error);
1201
1233
  onError?.(result.error);
1202
1234
  setIsLoading(false);
@@ -1220,6 +1252,7 @@ function FFIDAnalyticsProvider({
1220
1252
  void bootstrap();
1221
1253
  return () => {
1222
1254
  cancelled = true;
1255
+ controller.abort();
1223
1256
  };
1224
1257
  }, []);
1225
1258
  react.useEffect(() => {
@@ -1,34 +1,34 @@
1
1
  'use strict';
2
2
 
3
- var chunkBVDUQQHP_cjs = require('../chunk-BVDUQQHP.cjs');
3
+ var chunkFLLTPPUP_cjs = require('../chunk-FLLTPPUP.cjs');
4
4
 
5
5
 
6
6
 
7
7
  Object.defineProperty(exports, "FFIDAnnouncementBadge", {
8
8
  enumerable: true,
9
- get: function () { return chunkBVDUQQHP_cjs.FFIDAnnouncementBadge; }
9
+ get: function () { return chunkFLLTPPUP_cjs.FFIDAnnouncementBadge; }
10
10
  });
11
11
  Object.defineProperty(exports, "FFIDAnnouncementList", {
12
12
  enumerable: true,
13
- get: function () { return chunkBVDUQQHP_cjs.FFIDAnnouncementList; }
13
+ get: function () { return chunkFLLTPPUP_cjs.FFIDAnnouncementList; }
14
14
  });
15
15
  Object.defineProperty(exports, "FFIDInquiryForm", {
16
16
  enumerable: true,
17
- get: function () { return chunkBVDUQQHP_cjs.FFIDInquiryForm; }
17
+ get: function () { return chunkFLLTPPUP_cjs.FFIDInquiryForm; }
18
18
  });
19
19
  Object.defineProperty(exports, "FFIDLoginButton", {
20
20
  enumerable: true,
21
- get: function () { return chunkBVDUQQHP_cjs.FFIDLoginButton; }
21
+ get: function () { return chunkFLLTPPUP_cjs.FFIDLoginButton; }
22
22
  });
23
23
  Object.defineProperty(exports, "FFIDOrganizationSwitcher", {
24
24
  enumerable: true,
25
- get: function () { return chunkBVDUQQHP_cjs.FFIDOrganizationSwitcher; }
25
+ get: function () { return chunkFLLTPPUP_cjs.FFIDOrganizationSwitcher; }
26
26
  });
27
27
  Object.defineProperty(exports, "FFIDSubscriptionBadge", {
28
28
  enumerable: true,
29
- get: function () { return chunkBVDUQQHP_cjs.FFIDSubscriptionBadge; }
29
+ get: function () { return chunkFLLTPPUP_cjs.FFIDSubscriptionBadge; }
30
30
  });
31
31
  Object.defineProperty(exports, "FFIDUserMenu", {
32
32
  enumerable: true,
33
- get: function () { return chunkBVDUQQHP_cjs.FFIDUserMenu; }
33
+ get: function () { return chunkFLLTPPUP_cjs.FFIDUserMenu; }
34
34
  });
@@ -1 +1 @@
1
- export { FFIDAnnouncementBadge, FFIDAnnouncementList, FFIDInquiryForm, FFIDLoginButton, FFIDOrganizationSwitcher, FFIDSubscriptionBadge, FFIDUserMenu } from '../chunk-4II4R7NR.js';
1
+ export { FFIDAnnouncementBadge, FFIDAnnouncementList, FFIDInquiryForm, FFIDLoginButton, FFIDOrganizationSwitcher, FFIDSubscriptionBadge, FFIDUserMenu } from '../chunk-C7PURRNI.js';