@jwiedeman/gtm-kit-remix 1.2.0 → 1.2.1

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/dist/index.cjs CHANGED
@@ -52,15 +52,23 @@ function GtmProviderInner({ config, children, onBeforeInit, onAfterInit }) {
52
52
  }
53
53
  return () => {
54
54
  teardownTimerRef.current = setTimeout(() => {
55
+ teardownTimerRef.current = null;
55
56
  if (!document.querySelector("[data-gtm-kit-provider]")) {
56
57
  client.teardown();
57
58
  clientRef.current = null;
58
59
  initializedRef.current = false;
59
60
  }
60
- teardownTimerRef.current = null;
61
61
  }, 100);
62
62
  };
63
63
  }, [client, onBeforeInit, onAfterInit]);
64
+ react.useEffect(() => {
65
+ return () => {
66
+ if (teardownTimerRef.current) {
67
+ clearTimeout(teardownTimerRef.current);
68
+ teardownTimerRef.current = null;
69
+ }
70
+ };
71
+ }, []);
64
72
  const contextValue = react.useMemo(
65
73
  () => ({
66
74
  client,
@@ -205,9 +213,20 @@ function GtmScripts({
205
213
  dataLayerName = "dataLayer",
206
214
  scriptAttributes = {}
207
215
  }) {
216
+ if (!/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(dataLayerName)) {
217
+ throw new Error(
218
+ `[gtm-kit/remix] Invalid dataLayerName "${dataLayerName}". Must be a valid JavaScript identifier.`
219
+ );
220
+ }
208
221
  const containerConfigs = normalizeContainers(containers);
222
+ const safeScriptAttributes = {};
223
+ for (const [key, value] of Object.entries(scriptAttributes)) {
224
+ if (!key.toLowerCase().startsWith("on")) {
225
+ safeScriptAttributes[key] = value;
226
+ }
227
+ }
209
228
  const safeDataLayerName = escapeJsString(dataLayerName);
210
- const safeNonce = scriptAttributes.nonce ? escapeJsString(scriptAttributes.nonce) : "";
229
+ const safeNonce = safeScriptAttributes.nonce ? escapeJsString(safeScriptAttributes.nonce) : "";
211
230
  const inlineScript = `
212
231
  window['${safeDataLayerName}'] = window['${safeDataLayerName}'] || [];
213
232
  ${containerConfigs.map((config) => {
@@ -224,7 +243,7 @@ function GtmScripts({
224
243
  `.trim();
225
244
  const noscriptHtml = gtmKit.createNoscriptMarkup(containerConfigs, { host });
226
245
  return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
227
- /* @__PURE__ */ jsxRuntime.jsx("script", { ...scriptAttributes, dangerouslySetInnerHTML: { __html: inlineScript } }),
246
+ /* @__PURE__ */ jsxRuntime.jsx("script", { ...safeScriptAttributes, dangerouslySetInnerHTML: { __html: inlineScript } }),
228
247
  /* @__PURE__ */ jsxRuntime.jsx("noscript", { dangerouslySetInnerHTML: { __html: noscriptHtml } })
229
248
  ] });
230
249
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/provider.tsx","../src/route-tracker.tsx","../src/scripts.tsx"],"names":["useEffect","useRef","Fragment","jsx"],"mappings":"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","sourcesContent":["import React, {\n Component,\n createContext,\n useContext,\n useEffect,\n useRef,\n useMemo,\n type ErrorInfo,\n type ReactNode\n} from 'react';\nimport {\n createGtmClient,\n type ConsentRegionOptions,\n type ConsentState,\n type CreateGtmClientOptions,\n type DataLayerValue,\n type GtmClient,\n type ScriptLoadState\n} from '@jwiedeman/gtm-kit';\n\n/**\n * Props for the GTM Provider component.\n */\nexport interface GtmProviderProps {\n /** GTM client configuration */\n config: CreateGtmClientOptions;\n\n /** Child components */\n children: ReactNode;\n\n /**\n * Callback executed before GTM initialization.\n * Use this to set consent defaults.\n */\n onBeforeInit?: (client: GtmClient) => void;\n\n /**\n * Callback executed after GTM initialization.\n */\n onAfterInit?: (client: GtmClient) => void;\n}\n\n/**\n * The GTM context value containing all GTM functionality.\n */\nexport interface GtmContextValue {\n /** The underlying GTM client instance */\n client: GtmClient;\n /** Push a value to the data layer */\n push: (value: DataLayerValue) => void;\n /** Set consent defaults (must be called before init) */\n setConsentDefaults: (state: ConsentState, options?: ConsentRegionOptions) => void;\n /** Update consent state */\n updateConsent: (state: ConsentState, options?: ConsentRegionOptions) => void;\n /** Synchronously check if all GTM scripts have finished loading */\n isReady: () => boolean;\n /** Returns a promise that resolves when all GTM scripts are loaded */\n whenReady: () => Promise<ScriptLoadState[]>;\n}\n\n/**\n * Consent-specific API subset.\n */\nexport interface GtmConsentApi {\n setConsentDefaults: (state: ConsentState, options?: ConsentRegionOptions) => void;\n updateConsent: (state: ConsentState, options?: ConsentRegionOptions) => void;\n}\n\n/**\n * The GTM context for Remix.\n */\nexport const GtmContext = createContext<GtmContextValue | null>(null);\n\nconst warnOnNestedProvider = (): void => {\n if (process.env.NODE_ENV !== 'production') {\n console.warn(\n '[gtm-kit/remix] Nested GtmProvider detected. You should only have one GtmProvider at the root of your app. ' +\n 'The nested provider will be ignored.'\n );\n }\n};\n\n/**\n * GTM Provider component for Remix.\n * Handles StrictMode correctly and provides GTM context to children.\n *\n * @example\n * ```tsx\n * // app/root.tsx\n * import { GtmProvider } from '@jwiedeman/gtm-kit-remix';\n *\n * export default function App() {\n * return (\n * <html>\n * <head />\n * <body>\n * <GtmProvider config={{ containers: 'GTM-XXXXXX' }}>\n * <Outlet />\n * </GtmProvider>\n * </body>\n * </html>\n * );\n * }\n * ```\n */\nexport function GtmProvider({ config, children, onBeforeInit, onAfterInit }: GtmProviderProps): React.ReactElement {\n // Check for nested provider\n const existingContext = useContext(GtmContext);\n\n // Warn if we're inside another GtmProvider (nested providers)\n useEffect(() => {\n if (existingContext) {\n warnOnNestedProvider();\n }\n }, [existingContext]);\n\n // If nested, just pass through children without creating a new context\n if (existingContext) {\n return <>{children}</>;\n }\n\n return (\n <GtmProviderInner config={config} onBeforeInit={onBeforeInit} onAfterInit={onAfterInit}>\n {children}\n </GtmProviderInner>\n );\n}\n\nfunction GtmProviderInner({ config, children, onBeforeInit, onAfterInit }: GtmProviderProps): React.ReactElement {\n // Create client once and store in ref to survive StrictMode remounts\n const clientRef = useRef<GtmClient | null>(null);\n const initializedRef = useRef(false);\n const teardownTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n // Create client on first render only\n if (!clientRef.current) {\n clientRef.current = createGtmClient(config);\n }\n\n const client = clientRef.current;\n\n // Initialize GTM (handles StrictMode correctly)\n useEffect(() => {\n // Clear any pending teardown from StrictMode unmount/remount cycle\n if (teardownTimerRef.current) {\n clearTimeout(teardownTimerRef.current);\n teardownTimerRef.current = null;\n }\n\n // Skip if already initialized (StrictMode protection)\n if (initializedRef.current) {\n return;\n }\n\n // Call onBeforeInit hook for consent defaults\n if (onBeforeInit) {\n onBeforeInit(client);\n }\n\n // Initialize GTM\n client.init();\n initializedRef.current = true;\n\n // Call onAfterInit hook\n if (onAfterInit) {\n onAfterInit(client);\n }\n\n // Cleanup on unmount - defer to allow StrictMode remount\n return () => {\n teardownTimerRef.current = setTimeout(() => {\n // Only teardown if we're truly unmounting (no provider in DOM)\n if (!document.querySelector('[data-gtm-kit-provider]')) {\n client.teardown();\n clientRef.current = null;\n initializedRef.current = false;\n }\n teardownTimerRef.current = null;\n }, 100);\n };\n }, [client, onBeforeInit, onAfterInit]);\n\n // Memoize context value\n const contextValue = useMemo<GtmContextValue>(\n () => ({\n client,\n push: (value: DataLayerValue) => client.push(value),\n setConsentDefaults: (state: ConsentState, options?: ConsentRegionOptions) =>\n client.setConsentDefaults(state, options),\n updateConsent: (state: ConsentState, options?: ConsentRegionOptions) => client.updateConsent(state, options),\n isReady: () => client.isReady(),\n whenReady: () => client.whenReady()\n }),\n [client]\n );\n\n return (\n <GtmContext.Provider value={contextValue}>\n <div data-gtm-kit-provider=\"\" style={{ display: 'contents' }}>\n {children}\n </div>\n </GtmContext.Provider>\n );\n}\n\n/**\n * Internal helper to get the GTM context with proper error handling.\n */\nconst useGtmContext = (): GtmContextValue => {\n const context = useContext(GtmContext);\n if (!context) {\n throw new Error(\n '[gtm-kit/remix] useGtm() was called outside of a GtmProvider. ' +\n 'Make sure to wrap your app with <GtmProvider config={{ containers: \"GTM-XXXXXX\" }}>.'\n );\n }\n return context;\n};\n\n/**\n * Hook to access the full GTM context.\n *\n * @example\n * ```tsx\n * import { useGtm } from '@jwiedeman/gtm-kit-remix';\n *\n * function MyComponent() {\n * const { push, client } = useGtm();\n *\n * return (\n * <button onClick={() => push({ event: 'click' })}>\n * Track\n * </button>\n * );\n * }\n * ```\n */\nexport const useGtm = (): GtmContextValue => {\n return useGtmContext();\n};\n\n/**\n * Hook to get just the push function.\n *\n * @example\n * ```tsx\n * import { useGtmPush } from '@jwiedeman/gtm-kit-remix';\n *\n * function BuyButton() {\n * const push = useGtmPush();\n *\n * return (\n * <button onClick={() => push({ event: 'purchase', value: 99 })}>\n * Buy\n * </button>\n * );\n * }\n * ```\n */\nexport const useGtmPush = (): ((value: DataLayerValue) => void) => {\n return useGtmContext().push;\n};\n\n/**\n * Hook to access consent management functions.\n *\n * @example\n * ```tsx\n * import { useGtmConsent } from '@jwiedeman/gtm-kit-remix';\n *\n * function CookieBanner() {\n * const { updateConsent } = useGtmConsent();\n *\n * return (\n * <button onClick={() => updateConsent({ analytics_storage: 'granted' })}>\n * Accept\n * </button>\n * );\n * }\n * ```\n */\nexport const useGtmConsent = (): GtmConsentApi => {\n const { setConsentDefaults, updateConsent } = useGtmContext();\n return { setConsentDefaults, updateConsent };\n};\n\n/**\n * Hook to get the raw GTM client instance.\n *\n * @example\n * ```tsx\n * import { useGtmClient } from '@jwiedeman/gtm-kit-remix';\n *\n * function MyComponent() {\n * const client = useGtmClient();\n * return <div>{client.isInitialized() ? 'Ready' : 'Loading'}</div>;\n * }\n * ```\n */\nexport const useGtmClient = (): GtmClient => {\n return useGtmContext().client;\n};\n\n/**\n * Hook to get the whenReady function.\n *\n * @example\n * ```tsx\n * import { useGtmReady } from '@jwiedeman/gtm-kit-remix';\n * import { useEffect } from 'react';\n *\n * function MyComponent() {\n * const whenReady = useGtmReady();\n *\n * useEffect(() => {\n * whenReady().then(() => console.log('GTM ready!'));\n * }, [whenReady]);\n *\n * return <div>Loading...</div>;\n * }\n * ```\n */\nexport const useGtmReady = (): (() => Promise<ScriptLoadState[]>) => {\n return useGtmContext().whenReady;\n};\n\n/**\n * Hook to check if GTM scripts have finished loading synchronously.\n *\n * @example\n * ```tsx\n * import { useIsGtmReady } from '@jwiedeman/gtm-kit-remix';\n *\n * function MyComponent() {\n * const isReady = useIsGtmReady();\n * return <div>{isReady() ? 'GTM Ready' : 'Loading...'}</div>;\n * }\n * ```\n */\nexport const useIsGtmReady = (): (() => boolean) => {\n return useGtmContext().isReady;\n};\n\n/**\n * Props for GtmErrorBoundary component.\n */\nexport interface GtmErrorBoundaryProps {\n children: ReactNode;\n /** Fallback UI to render when an error occurs */\n fallback?: ReactNode | ((error: Error, reset: () => void) => ReactNode);\n /** Callback invoked when an error is caught */\n onError?: (error: Error, errorInfo: ErrorInfo) => void;\n /** Whether to log errors to console (default: true in development) */\n logErrors?: boolean;\n}\n\ninterface GtmErrorBoundaryState {\n hasError: boolean;\n error: Error | null;\n}\n\n/**\n * Error boundary component for GTM provider in Remix apps.\n * Catches errors during GTM initialization and renders a fallback UI.\n * Analytics and tracking will be disabled when an error occurs.\n *\n * @example\n * ```tsx\n * import { GtmProvider, GtmErrorBoundary } from '@jwiedeman/gtm-kit-remix';\n *\n * export default function App() {\n * return (\n * <GtmErrorBoundary fallback={<div>GTM failed to load</div>}>\n * <GtmProvider config={{ containers: 'GTM-XXXXXX' }}>\n * <Outlet />\n * </GtmProvider>\n * </GtmErrorBoundary>\n * );\n * }\n * ```\n */\nexport class GtmErrorBoundary extends Component<GtmErrorBoundaryProps, GtmErrorBoundaryState> {\n constructor(props: GtmErrorBoundaryProps) {\n super(props);\n this.state = { hasError: false, error: null };\n }\n\n static getDerivedStateFromError(error: Error): GtmErrorBoundaryState {\n return { hasError: true, error };\n }\n\n componentDidCatch(error: Error, errorInfo: ErrorInfo): void {\n const { onError, logErrors = process.env.NODE_ENV !== 'production' } = this.props;\n\n if (logErrors) {\n console.error('[gtm-kit/remix] Error caught by GtmErrorBoundary:', error);\n console.error('[gtm-kit/remix] Component stack:', errorInfo.componentStack);\n }\n\n if (onError) {\n try {\n onError(error, errorInfo);\n } catch {\n // Ignore callback errors\n }\n }\n }\n\n reset = (): void => {\n this.setState({ hasError: false, error: null });\n };\n\n render(): ReactNode {\n const { hasError, error } = this.state;\n const { children, fallback } = this.props;\n\n if (hasError && error) {\n if (fallback === undefined) {\n // Default: render children without GTM (silent fallback)\n return children;\n }\n\n if (typeof fallback === 'function') {\n return fallback(error, this.reset);\n }\n\n return fallback;\n }\n\n return children;\n }\n}\n","import { useEffect, useRef } from 'react';\nimport { useLocation } from '@remix-run/react';\nimport { useGtmPush } from './provider';\n\n/**\n * Options for the useTrackPageViews hook.\n */\nexport interface UseTrackPageViewsOptions {\n /**\n * The event name to use for page view events.\n * @default 'page_view'\n */\n eventName?: string;\n\n /**\n * Whether to track the initial page load.\n * @default true\n */\n trackInitialPageView?: boolean;\n\n /**\n * Custom data to include with each page view event.\n */\n customData?: Record<string, unknown>;\n\n /**\n * Callback to transform the page view event data before pushing.\n * Use this to add custom properties or modify the event.\n */\n transformEvent?: (data: PageViewData) => Record<string, unknown>;\n}\n\n/**\n * Data included with each page view event.\n */\nexport interface PageViewData {\n event: string;\n page_path: string;\n page_search: string;\n page_hash: string;\n page_url: string;\n [key: string]: unknown;\n}\n\n/**\n * Hook to automatically track page views on route changes.\n * Uses Remix's useLocation to detect navigation.\n *\n * @example\n * ```tsx\n * // app/root.tsx\n * import { GtmProvider, useTrackPageViews } from '@jwiedeman/gtm-kit-remix';\n *\n * function PageViewTracker() {\n * useTrackPageViews();\n * return null;\n * }\n *\n * export default function App() {\n * return (\n * <GtmProvider config={{ containers: 'GTM-XXXXXX' }}>\n * <PageViewTracker />\n * <Outlet />\n * </GtmProvider>\n * );\n * }\n * ```\n *\n * @example With custom options\n * ```tsx\n * useTrackPageViews({\n * eventName: 'virtual_page_view',\n * customData: { app_version: '1.0.0' },\n * transformEvent: (data) => ({\n * ...data,\n * user_id: getCurrentUserId()\n * })\n * });\n * ```\n */\nexport function useTrackPageViews(options: UseTrackPageViewsOptions = {}): void {\n const { eventName = 'page_view', trackInitialPageView = true, customData = {}, transformEvent } = options;\n\n const location = useLocation();\n const push = useGtmPush();\n const lastPathRef = useRef<string | null>(null);\n const isFirstRenderRef = useRef(true);\n\n useEffect(() => {\n const currentPath = location.pathname + location.search + location.hash;\n\n // Skip if this is the same path (prevents double-firing)\n if (currentPath === lastPathRef.current) {\n return;\n }\n\n // Skip initial page view if configured\n if (isFirstRenderRef.current && !trackInitialPageView) {\n isFirstRenderRef.current = false;\n lastPathRef.current = currentPath;\n return;\n }\n\n isFirstRenderRef.current = false;\n lastPathRef.current = currentPath;\n\n // Build page view data\n const pageViewData: PageViewData = {\n event: eventName,\n page_path: location.pathname,\n page_search: location.search,\n page_hash: location.hash,\n page_url: typeof window !== 'undefined' ? window.location.href : currentPath,\n ...customData\n };\n\n // Apply transform if provided\n const eventData = transformEvent ? transformEvent(pageViewData) : pageViewData;\n\n // Push to GTM\n push(eventData);\n }, [location, push, eventName, trackInitialPageView, customData, transformEvent]);\n}\n","import React from 'react';\nimport { createNoscriptMarkup, type ContainerConfigInput, type ContainerDescriptor } from '@jwiedeman/gtm-kit';\n\n/**\n * Escape a string for safe use in JavaScript string literals.\n * Prevents XSS when interpolating user-provided values into inline scripts.\n */\nfunction escapeJsString(value: string): string {\n return value\n .replace(/\\\\/g, '\\\\\\\\')\n .replace(/'/g, \"\\\\'\")\n .replace(/\"/g, '\\\\\"')\n .replace(/\\n/g, '\\\\n')\n .replace(/\\r/g, '\\\\r')\n .replace(/</g, '\\\\x3c')\n .replace(/>/g, '\\\\x3e')\n .replace(/\\u2028/g, '\\\\u2028')\n .replace(/\\u2029/g, '\\\\u2029');\n}\n\n/**\n * Props for the GtmScripts component.\n */\nexport interface GtmScriptsProps {\n /**\n * GTM container ID(s).\n */\n containers: ContainerConfigInput | ContainerConfigInput[];\n\n /**\n * Custom GTM host URL.\n * @default 'https://www.googletagmanager.com'\n */\n host?: string;\n\n /**\n * Custom dataLayer name.\n * @default 'dataLayer'\n */\n dataLayerName?: string;\n\n /**\n * Script attributes (e.g., nonce for CSP).\n */\n scriptAttributes?: Record<string, string>;\n}\n\n/**\n * Normalize container config to array format.\n */\nfunction normalizeContainers(containers: ContainerConfigInput | ContainerConfigInput[]): ContainerDescriptor[] {\n if (typeof containers === 'string') {\n return [{ id: containers }];\n }\n if (!Array.isArray(containers)) {\n return [containers];\n }\n return containers.map((c) => (typeof c === 'string' ? { id: c } : c));\n}\n\n/**\n * Server component that renders GTM script tags for Remix.\n * Use this in your root.tsx to add GTM scripts.\n *\n * @example\n * ```tsx\n * // app/root.tsx\n * import { GtmScripts } from '@jwiedeman/gtm-kit-remix';\n *\n * export default function App() {\n * return (\n * <html>\n * <head>\n * <GtmScripts containers=\"GTM-XXXXXX\" />\n * </head>\n * <body>\n * <Outlet />\n * </body>\n * </html>\n * );\n * }\n * ```\n *\n * @example With CSP nonce\n * ```tsx\n * <GtmScripts\n * containers=\"GTM-XXXXXX\"\n * scriptAttributes={{ nonce: 'your-csp-nonce' }}\n * />\n * ```\n */\n/**\n * Build the GTM script URL for a container.\n */\nfunction buildGtmScriptUrl(\n containerId: string,\n host: string,\n dataLayerName: string,\n queryParams?: Record<string, string | number | boolean>\n): string {\n const normalizedHost = host.endsWith('/') ? host.slice(0, -1) : host;\n const params = new URLSearchParams();\n params.set('id', containerId);\n\n if (dataLayerName !== 'dataLayer') {\n params.set('l', dataLayerName);\n }\n\n if (queryParams) {\n for (const [key, value] of Object.entries(queryParams)) {\n if (key !== 'id' && key !== 'l') {\n params.set(key, String(value));\n }\n }\n }\n\n return `${normalizedHost}/gtm.js?${params.toString()}`;\n}\n\nexport function GtmScripts({\n containers,\n host = 'https://www.googletagmanager.com',\n dataLayerName = 'dataLayer',\n scriptAttributes = {}\n}: GtmScriptsProps): React.ReactElement {\n const containerConfigs = normalizeContainers(containers);\n\n // Escape values for safe use in JavaScript string literals\n const safeDataLayerName = escapeJsString(dataLayerName);\n const safeNonce = scriptAttributes.nonce ? escapeJsString(scriptAttributes.nonce) : '';\n\n // Generate inline script for dataLayer initialization and GTM loading\n const inlineScript = `\n window['${safeDataLayerName}'] = window['${safeDataLayerName}'] || [];\n ${containerConfigs\n .map((config) => {\n const safeContainerId = escapeJsString(config.id);\n const scriptSrc = escapeJsString(buildGtmScriptUrl(config.id, host, dataLayerName, config.queryParams));\n return `\n (function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':\n new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],\n j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=\n '${scriptSrc}';${safeNonce ? `j.nonce='${safeNonce}';` : ''}f.parentNode.insertBefore(j,f);\n })(window,document,'script','${safeDataLayerName}','${safeContainerId}');\n `;\n })\n .join('\\n')}\n `.trim();\n\n // Generate noscript HTML using the core package\n const noscriptHtml = createNoscriptMarkup(containerConfigs, { host });\n\n return (\n <>\n <script {...scriptAttributes} dangerouslySetInnerHTML={{ __html: inlineScript }} />\n <noscript dangerouslySetInnerHTML={{ __html: noscriptHtml }} />\n </>\n );\n}\n"]}
1
+ {"version":3,"sources":["../src/provider.tsx","../src/route-tracker.tsx","../src/scripts.tsx"],"names":["useEffect","useRef","Fragment","jsx"],"mappings":";AAAA;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OAGK;AACP;AAAA,EACE;AAAA,OAOK;AAoGI;AA/CJ,IAAM,aAAa,cAAsC,IAAI;AAEpE,IAAM,uBAAuB,MAAY;AACvC,MAAI,QAAQ,IAAI,aAAa,cAAc;AACzC,YAAQ;AAAA,MACN;AAAA,IAEF;AAAA,EACF;AACF;AAyBO,SAAS,YAAY,EAAE,QAAQ,UAAU,cAAc,YAAY,GAAyC;AAEjH,QAAM,kBAAkB,WAAW,UAAU;AAG7C,YAAU,MAAM;AACd,QAAI,iBAAiB;AACnB,2BAAqB;AAAA,IACvB;AAAA,EACF,GAAG,CAAC,eAAe,CAAC;AAGpB,MAAI,iBAAiB;AACnB,WAAO,gCAAG,UAAS;AAAA,EACrB;AAEA,SACE,oBAAC,oBAAiB,QAAgB,cAA4B,aAC3D,UACH;AAEJ;AAEA,SAAS,iBAAiB,EAAE,QAAQ,UAAU,cAAc,YAAY,GAAyC;AAE/G,QAAM,YAAY,OAAyB,IAAI;AAC/C,QAAM,iBAAiB,OAAO,KAAK;AACnC,QAAM,mBAAmB,OAA6C,IAAI;AAG1E,MAAI,CAAC,UAAU,SAAS;AACtB,cAAU,UAAU,gBAAgB,MAAM;AAAA,EAC5C;AAEA,QAAM,SAAS,UAAU;AAGzB,YAAU,MAAM;AAEd,QAAI,iBAAiB,SAAS;AAC5B,mBAAa,iBAAiB,OAAO;AACrC,uBAAiB,UAAU;AAAA,IAC7B;AAGA,QAAI,eAAe,SAAS;AAC1B;AAAA,IACF;AAGA,QAAI,cAAc;AAChB,mBAAa,MAAM;AAAA,IACrB;AAGA,WAAO,KAAK;AACZ,mBAAe,UAAU;AAGzB,QAAI,aAAa;AACf,kBAAY,MAAM;AAAA,IACpB;AAGA,WAAO,MAAM;AACX,uBAAiB,UAAU,WAAW,MAAM;AAC1C,yBAAiB,UAAU;AAE3B,YAAI,CAAC,SAAS,cAAc,yBAAyB,GAAG;AACtD,iBAAO,SAAS;AAChB,oBAAU,UAAU;AACpB,yBAAe,UAAU;AAAA,QAC3B;AAAA,MACF,GAAG,GAAG;AAAA,IACR;AAAA,EACF,GAAG,CAAC,QAAQ,cAAc,WAAW,CAAC;AAGtC,YAAU,MAAM;AACd,WAAO,MAAM;AACX,UAAI,iBAAiB,SAAS;AAC5B,qBAAa,iBAAiB,OAAO;AACrC,yBAAiB,UAAU;AAAA,MAC7B;AAAA,IACF;AAAA,EACF,GAAG,CAAC,CAAC;AAGL,QAAM,eAAe;AAAA,IACnB,OAAO;AAAA,MACL;AAAA,MACA,MAAM,CAAC,UAA0B,OAAO,KAAK,KAAK;AAAA,MAClD,oBAAoB,CAAC,OAAqB,YACxC,OAAO,mBAAmB,OAAO,OAAO;AAAA,MAC1C,eAAe,CAAC,OAAqB,YAAmC,OAAO,cAAc,OAAO,OAAO;AAAA,MAC3G,SAAS,MAAM,OAAO,QAAQ;AAAA,MAC9B,WAAW,MAAM,OAAO,UAAU;AAAA,IACpC;AAAA,IACA,CAAC,MAAM;AAAA,EACT;AAEA,SACE,oBAAC,WAAW,UAAX,EAAoB,OAAO,cAC1B,8BAAC,SAAI,yBAAsB,IAAG,OAAO,EAAE,SAAS,WAAW,GACxD,UACH,GACF;AAEJ;AAKA,IAAM,gBAAgB,MAAuB;AAC3C,QAAM,UAAU,WAAW,UAAU;AACrC,MAAI,CAAC,SAAS;AACZ,UAAM,IAAI;AAAA,MACR;AAAA,IAEF;AAAA,EACF;AACA,SAAO;AACT;AAoBO,IAAM,SAAS,MAAuB;AAC3C,SAAO,cAAc;AACvB;AAoBO,IAAM,aAAa,MAAyC;AACjE,SAAO,cAAc,EAAE;AACzB;AAoBO,IAAM,gBAAgB,MAAqB;AAChD,QAAM,EAAE,oBAAoB,cAAc,IAAI,cAAc;AAC5D,SAAO,EAAE,oBAAoB,cAAc;AAC7C;AAeO,IAAM,eAAe,MAAiB;AAC3C,SAAO,cAAc,EAAE;AACzB;AAqBO,IAAM,cAAc,MAA0C;AACnE,SAAO,cAAc,EAAE;AACzB;AAeO,IAAM,gBAAgB,MAAuB;AAClD,SAAO,cAAc,EAAE;AACzB;AAwCO,IAAM,mBAAN,cAA+B,UAAwD;AAAA,EAC5F,YAAY,OAA8B;AACxC,UAAM,KAAK;AAyBb,iBAAQ,MAAY;AAClB,WAAK,SAAS,EAAE,UAAU,OAAO,OAAO,KAAK,CAAC;AAAA,IAChD;AA1BE,SAAK,QAAQ,EAAE,UAAU,OAAO,OAAO,KAAK;AAAA,EAC9C;AAAA,EAEA,OAAO,yBAAyB,OAAqC;AACnE,WAAO,EAAE,UAAU,MAAM,MAAM;AAAA,EACjC;AAAA,EAEA,kBAAkB,OAAc,WAA4B;AAC1D,UAAM,EAAE,SAAS,YAAY,QAAQ,IAAI,aAAa,aAAa,IAAI,KAAK;AAE5E,QAAI,WAAW;AACb,cAAQ,MAAM,qDAAqD,KAAK;AACxE,cAAQ,MAAM,oCAAoC,UAAU,cAAc;AAAA,IAC5E;AAEA,QAAI,SAAS;AACX,UAAI;AACF,gBAAQ,OAAO,SAAS;AAAA,MAC1B,SAAQ;AAAA,MAER;AAAA,IACF;AAAA,EACF;AAAA,EAMA,SAAoB;AAClB,UAAM,EAAE,UAAU,MAAM,IAAI,KAAK;AACjC,UAAM,EAAE,UAAU,SAAS,IAAI,KAAK;AAEpC,QAAI,YAAY,OAAO;AACrB,UAAI,aAAa,QAAW;AAE1B,eAAO;AAAA,MACT;AAEA,UAAI,OAAO,aAAa,YAAY;AAClC,eAAO,SAAS,OAAO,KAAK,KAAK;AAAA,MACnC;AAEA,aAAO;AAAA,IACT;AAEA,WAAO;AAAA,EACT;AACF;;;ACzbA,SAAS,aAAAA,YAAW,UAAAC,eAAc;AAClC,SAAS,mBAAmB;AA+ErB,SAAS,kBAAkB,UAAoC,CAAC,GAAS;AAC9E,QAAM,EAAE,YAAY,aAAa,uBAAuB,MAAM,aAAa,CAAC,GAAG,eAAe,IAAI;AAElG,QAAM,WAAW,YAAY;AAC7B,QAAM,OAAO,WAAW;AACxB,QAAM,cAAcA,QAAsB,IAAI;AAC9C,QAAM,mBAAmBA,QAAO,IAAI;AAEpC,EAAAD,WAAU,MAAM;AACd,UAAM,cAAc,SAAS,WAAW,SAAS,SAAS,SAAS;AAGnE,QAAI,gBAAgB,YAAY,SAAS;AACvC;AAAA,IACF;AAGA,QAAI,iBAAiB,WAAW,CAAC,sBAAsB;AACrD,uBAAiB,UAAU;AAC3B,kBAAY,UAAU;AACtB;AAAA,IACF;AAEA,qBAAiB,UAAU;AAC3B,gBAAY,UAAU;AAGtB,UAAM,eAA6B;AAAA,MACjC,OAAO;AAAA,MACP,WAAW,SAAS;AAAA,MACpB,aAAa,SAAS;AAAA,MACtB,WAAW,SAAS;AAAA,MACpB,UAAU,OAAO,WAAW,cAAc,OAAO,SAAS,OAAO;AAAA,MACjE,GAAG;AAAA,IACL;AAGA,UAAM,YAAY,iBAAiB,eAAe,YAAY,IAAI;AAGlE,SAAK,SAAS;AAAA,EAChB,GAAG,CAAC,UAAU,MAAM,WAAW,sBAAsB,YAAY,cAAc,CAAC;AAClF;;;ACzHA,SAAS,4BAAiF;AAuKtF,qBAAAE,WACE,OAAAC,MADF;AAjKJ,SAAS,eAAe,OAAuB;AAC7C,SAAO,MACJ,QAAQ,OAAO,MAAM,EACrB,QAAQ,MAAM,KAAK,EACnB,QAAQ,MAAM,KAAK,EACnB,QAAQ,OAAO,KAAK,EACpB,QAAQ,OAAO,KAAK,EACpB,QAAQ,MAAM,OAAO,EACrB,QAAQ,MAAM,OAAO,EACrB,QAAQ,WAAW,SAAS,EAC5B,QAAQ,WAAW,SAAS;AACjC;AAgCA,SAAS,oBAAoB,YAAkF;AAC7G,MAAI,OAAO,eAAe,UAAU;AAClC,WAAO,CAAC,EAAE,IAAI,WAAW,CAAC;AAAA,EAC5B;AACA,MAAI,CAAC,MAAM,QAAQ,UAAU,GAAG;AAC9B,WAAO,CAAC,UAAU;AAAA,EACpB;AACA,SAAO,WAAW,IAAI,CAAC,MAAO,OAAO,MAAM,WAAW,EAAE,IAAI,EAAE,IAAI,CAAE;AACtE;AAoCA,SAAS,kBACP,aACA,MACA,eACA,aACQ;AACR,QAAM,iBAAiB,KAAK,SAAS,GAAG,IAAI,KAAK,MAAM,GAAG,EAAE,IAAI;AAChE,QAAM,SAAS,IAAI,gBAAgB;AACnC,SAAO,IAAI,MAAM,WAAW;AAE5B,MAAI,kBAAkB,aAAa;AACjC,WAAO,IAAI,KAAK,aAAa;AAAA,EAC/B;AAEA,MAAI,aAAa;AACf,eAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,WAAW,GAAG;AACtD,UAAI,QAAQ,QAAQ,QAAQ,KAAK;AAC/B,eAAO,IAAI,KAAK,OAAO,KAAK,CAAC;AAAA,MAC/B;AAAA,IACF;AAAA,EACF;AAEA,SAAO,GAAG,cAAc,WAAW,OAAO,SAAS,CAAC;AACtD;AAEO,SAAS,WAAW;AAAA,EACzB;AAAA,EACA,OAAO;AAAA,EACP,gBAAgB;AAAA,EAChB,mBAAmB,CAAC;AACtB,GAAwC;AAEtC,MAAI,CAAC,6BAA6B,KAAK,aAAa,GAAG;AACrD,UAAM,IAAI;AAAA,MACR,0CAA0C,aAAa;AAAA,IACzD;AAAA,EACF;AAEA,QAAM,mBAAmB,oBAAoB,UAAU;AAGvD,QAAM,uBAA+C,CAAC;AACtD,aAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,gBAAgB,GAAG;AAC3D,QAAI,CAAC,IAAI,YAAY,EAAE,WAAW,IAAI,GAAG;AACvC,2BAAqB,GAAG,IAAI;AAAA,IAC9B;AAAA,EACF;AAGA,QAAM,oBAAoB,eAAe,aAAa;AACtD,QAAM,YAAY,qBAAqB,QAAQ,eAAe,qBAAqB,KAAK,IAAI;AAG5F,QAAM,eAAe;AAAA,cACT,iBAAiB,gBAAgB,iBAAiB;AAAA,MAC1D,iBACC,IAAI,CAAC,WAAW;AACf,UAAM,kBAAkB,eAAe,OAAO,EAAE;AAChD,UAAM,YAAY,eAAe,kBAAkB,OAAO,IAAI,MAAM,eAAe,OAAO,WAAW,CAAC;AACtG,WAAO;AAAA;AAAA;AAAA;AAAA,SAIN,SAAS,KAAK,YAAY,YAAY,SAAS,OAAO,EAAE;AAAA,qCAC5B,iBAAiB,MAAM,eAAe;AAAA;AAAA,EAErE,CAAC,EACA,KAAK,IAAI,CAAC;AAAA,IACb,KAAK;AAGP,QAAM,eAAe,qBAAqB,kBAAkB,EAAE,KAAK,CAAC;AAEpE,SACE,qBAAAD,WAAA,EACE;AAAA,oBAAAC,KAAC,YAAQ,GAAG,sBAAsB,yBAAyB,EAAE,QAAQ,aAAa,GAAG;AAAA,IACrF,gBAAAA,KAAC,cAAS,yBAAyB,EAAE,QAAQ,aAAa,GAAG;AAAA,KAC/D;AAEJ","sourcesContent":["import React, {\n Component,\n createContext,\n useContext,\n useEffect,\n useRef,\n useMemo,\n type ErrorInfo,\n type ReactNode\n} from 'react';\nimport {\n createGtmClient,\n type ConsentRegionOptions,\n type ConsentState,\n type CreateGtmClientOptions,\n type DataLayerValue,\n type GtmClient,\n type ScriptLoadState\n} from '@jwiedeman/gtm-kit';\n\n/**\n * Props for the GTM Provider component.\n */\nexport interface GtmProviderProps {\n /** GTM client configuration */\n config: CreateGtmClientOptions;\n\n /** Child components */\n children: ReactNode;\n\n /**\n * Callback executed before GTM initialization.\n * Use this to set consent defaults.\n */\n onBeforeInit?: (client: GtmClient) => void;\n\n /**\n * Callback executed after GTM initialization.\n */\n onAfterInit?: (client: GtmClient) => void;\n}\n\n/**\n * The GTM context value containing all GTM functionality.\n */\nexport interface GtmContextValue {\n /** The underlying GTM client instance */\n client: GtmClient;\n /** Push a value to the data layer */\n push: (value: DataLayerValue) => void;\n /** Set consent defaults (must be called before init) */\n setConsentDefaults: (state: ConsentState, options?: ConsentRegionOptions) => void;\n /** Update consent state */\n updateConsent: (state: ConsentState, options?: ConsentRegionOptions) => void;\n /** Synchronously check if all GTM scripts have finished loading */\n isReady: () => boolean;\n /** Returns a promise that resolves when all GTM scripts are loaded */\n whenReady: () => Promise<ScriptLoadState[]>;\n}\n\n/**\n * Consent-specific API subset.\n */\nexport interface GtmConsentApi {\n setConsentDefaults: (state: ConsentState, options?: ConsentRegionOptions) => void;\n updateConsent: (state: ConsentState, options?: ConsentRegionOptions) => void;\n}\n\n/**\n * The GTM context for Remix.\n */\nexport const GtmContext = createContext<GtmContextValue | null>(null);\n\nconst warnOnNestedProvider = (): void => {\n if (process.env.NODE_ENV !== 'production') {\n console.warn(\n '[gtm-kit/remix] Nested GtmProvider detected. You should only have one GtmProvider at the root of your app. ' +\n 'The nested provider will be ignored.'\n );\n }\n};\n\n/**\n * GTM Provider component for Remix.\n * Handles StrictMode correctly and provides GTM context to children.\n *\n * @example\n * ```tsx\n * // app/root.tsx\n * import { GtmProvider } from '@jwiedeman/gtm-kit-remix';\n *\n * export default function App() {\n * return (\n * <html>\n * <head />\n * <body>\n * <GtmProvider config={{ containers: 'GTM-XXXXXX' }}>\n * <Outlet />\n * </GtmProvider>\n * </body>\n * </html>\n * );\n * }\n * ```\n */\nexport function GtmProvider({ config, children, onBeforeInit, onAfterInit }: GtmProviderProps): React.ReactElement {\n // Check for nested provider\n const existingContext = useContext(GtmContext);\n\n // Warn if we're inside another GtmProvider (nested providers)\n useEffect(() => {\n if (existingContext) {\n warnOnNestedProvider();\n }\n }, [existingContext]);\n\n // If nested, just pass through children without creating a new context\n if (existingContext) {\n return <>{children}</>;\n }\n\n return (\n <GtmProviderInner config={config} onBeforeInit={onBeforeInit} onAfterInit={onAfterInit}>\n {children}\n </GtmProviderInner>\n );\n}\n\nfunction GtmProviderInner({ config, children, onBeforeInit, onAfterInit }: GtmProviderProps): React.ReactElement {\n // Create client once and store in ref to survive StrictMode remounts\n const clientRef = useRef<GtmClient | null>(null);\n const initializedRef = useRef(false);\n const teardownTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n // Create client on first render only\n if (!clientRef.current) {\n clientRef.current = createGtmClient(config);\n }\n\n const client = clientRef.current;\n\n // Initialize GTM (handles StrictMode correctly)\n useEffect(() => {\n // Clear any pending teardown from StrictMode unmount/remount cycle\n if (teardownTimerRef.current) {\n clearTimeout(teardownTimerRef.current);\n teardownTimerRef.current = null;\n }\n\n // Skip if already initialized (StrictMode protection)\n if (initializedRef.current) {\n return;\n }\n\n // Call onBeforeInit hook for consent defaults\n if (onBeforeInit) {\n onBeforeInit(client);\n }\n\n // Initialize GTM\n client.init();\n initializedRef.current = true;\n\n // Call onAfterInit hook\n if (onAfterInit) {\n onAfterInit(client);\n }\n\n // Cleanup on unmount - defer to allow StrictMode remount\n return () => {\n teardownTimerRef.current = setTimeout(() => {\n teardownTimerRef.current = null;\n // Only teardown if we're truly unmounting (no provider in DOM)\n if (!document.querySelector('[data-gtm-kit-provider]')) {\n client.teardown();\n clientRef.current = null;\n initializedRef.current = false;\n }\n }, 100);\n };\n }, [client, onBeforeInit, onAfterInit]);\n\n // Clear deferred teardown timer on final unmount to prevent leaked timers\n useEffect(() => {\n return () => {\n if (teardownTimerRef.current) {\n clearTimeout(teardownTimerRef.current);\n teardownTimerRef.current = null;\n }\n };\n }, []);\n\n // Memoize context value\n const contextValue = useMemo<GtmContextValue>(\n () => ({\n client,\n push: (value: DataLayerValue) => client.push(value),\n setConsentDefaults: (state: ConsentState, options?: ConsentRegionOptions) =>\n client.setConsentDefaults(state, options),\n updateConsent: (state: ConsentState, options?: ConsentRegionOptions) => client.updateConsent(state, options),\n isReady: () => client.isReady(),\n whenReady: () => client.whenReady()\n }),\n [client]\n );\n\n return (\n <GtmContext.Provider value={contextValue}>\n <div data-gtm-kit-provider=\"\" style={{ display: 'contents' }}>\n {children}\n </div>\n </GtmContext.Provider>\n );\n}\n\n/**\n * Internal helper to get the GTM context with proper error handling.\n */\nconst useGtmContext = (): GtmContextValue => {\n const context = useContext(GtmContext);\n if (!context) {\n throw new Error(\n '[gtm-kit/remix] useGtm() was called outside of a GtmProvider. ' +\n 'Make sure to wrap your app with <GtmProvider config={{ containers: \"GTM-XXXXXX\" }}>.'\n );\n }\n return context;\n};\n\n/**\n * Hook to access the full GTM context.\n *\n * @example\n * ```tsx\n * import { useGtm } from '@jwiedeman/gtm-kit-remix';\n *\n * function MyComponent() {\n * const { push, client } = useGtm();\n *\n * return (\n * <button onClick={() => push({ event: 'click' })}>\n * Track\n * </button>\n * );\n * }\n * ```\n */\nexport const useGtm = (): GtmContextValue => {\n return useGtmContext();\n};\n\n/**\n * Hook to get just the push function.\n *\n * @example\n * ```tsx\n * import { useGtmPush } from '@jwiedeman/gtm-kit-remix';\n *\n * function BuyButton() {\n * const push = useGtmPush();\n *\n * return (\n * <button onClick={() => push({ event: 'purchase', value: 99 })}>\n * Buy\n * </button>\n * );\n * }\n * ```\n */\nexport const useGtmPush = (): ((value: DataLayerValue) => void) => {\n return useGtmContext().push;\n};\n\n/**\n * Hook to access consent management functions.\n *\n * @example\n * ```tsx\n * import { useGtmConsent } from '@jwiedeman/gtm-kit-remix';\n *\n * function CookieBanner() {\n * const { updateConsent } = useGtmConsent();\n *\n * return (\n * <button onClick={() => updateConsent({ analytics_storage: 'granted' })}>\n * Accept\n * </button>\n * );\n * }\n * ```\n */\nexport const useGtmConsent = (): GtmConsentApi => {\n const { setConsentDefaults, updateConsent } = useGtmContext();\n return { setConsentDefaults, updateConsent };\n};\n\n/**\n * Hook to get the raw GTM client instance.\n *\n * @example\n * ```tsx\n * import { useGtmClient } from '@jwiedeman/gtm-kit-remix';\n *\n * function MyComponent() {\n * const client = useGtmClient();\n * return <div>{client.isInitialized() ? 'Ready' : 'Loading'}</div>;\n * }\n * ```\n */\nexport const useGtmClient = (): GtmClient => {\n return useGtmContext().client;\n};\n\n/**\n * Hook to get the whenReady function.\n *\n * @example\n * ```tsx\n * import { useGtmReady } from '@jwiedeman/gtm-kit-remix';\n * import { useEffect } from 'react';\n *\n * function MyComponent() {\n * const whenReady = useGtmReady();\n *\n * useEffect(() => {\n * whenReady().then(() => console.log('GTM ready!'));\n * }, [whenReady]);\n *\n * return <div>Loading...</div>;\n * }\n * ```\n */\nexport const useGtmReady = (): (() => Promise<ScriptLoadState[]>) => {\n return useGtmContext().whenReady;\n};\n\n/**\n * Hook to check if GTM scripts have finished loading synchronously.\n *\n * @example\n * ```tsx\n * import { useIsGtmReady } from '@jwiedeman/gtm-kit-remix';\n *\n * function MyComponent() {\n * const isReady = useIsGtmReady();\n * return <div>{isReady() ? 'GTM Ready' : 'Loading...'}</div>;\n * }\n * ```\n */\nexport const useIsGtmReady = (): (() => boolean) => {\n return useGtmContext().isReady;\n};\n\n/**\n * Props for GtmErrorBoundary component.\n */\nexport interface GtmErrorBoundaryProps {\n children: ReactNode;\n /** Fallback UI to render when an error occurs */\n fallback?: ReactNode | ((error: Error, reset: () => void) => ReactNode);\n /** Callback invoked when an error is caught */\n onError?: (error: Error, errorInfo: ErrorInfo) => void;\n /** Whether to log errors to console (default: true in development) */\n logErrors?: boolean;\n}\n\ninterface GtmErrorBoundaryState {\n hasError: boolean;\n error: Error | null;\n}\n\n/**\n * Error boundary component for GTM provider in Remix apps.\n * Catches errors during GTM initialization and renders a fallback UI.\n * Analytics and tracking will be disabled when an error occurs.\n *\n * @example\n * ```tsx\n * import { GtmProvider, GtmErrorBoundary } from '@jwiedeman/gtm-kit-remix';\n *\n * export default function App() {\n * return (\n * <GtmErrorBoundary fallback={<div>GTM failed to load</div>}>\n * <GtmProvider config={{ containers: 'GTM-XXXXXX' }}>\n * <Outlet />\n * </GtmProvider>\n * </GtmErrorBoundary>\n * );\n * }\n * ```\n */\nexport class GtmErrorBoundary extends Component<GtmErrorBoundaryProps, GtmErrorBoundaryState> {\n constructor(props: GtmErrorBoundaryProps) {\n super(props);\n this.state = { hasError: false, error: null };\n }\n\n static getDerivedStateFromError(error: Error): GtmErrorBoundaryState {\n return { hasError: true, error };\n }\n\n componentDidCatch(error: Error, errorInfo: ErrorInfo): void {\n const { onError, logErrors = process.env.NODE_ENV !== 'production' } = this.props;\n\n if (logErrors) {\n console.error('[gtm-kit/remix] Error caught by GtmErrorBoundary:', error);\n console.error('[gtm-kit/remix] Component stack:', errorInfo.componentStack);\n }\n\n if (onError) {\n try {\n onError(error, errorInfo);\n } catch {\n // Ignore callback errors\n }\n }\n }\n\n reset = (): void => {\n this.setState({ hasError: false, error: null });\n };\n\n render(): ReactNode {\n const { hasError, error } = this.state;\n const { children, fallback } = this.props;\n\n if (hasError && error) {\n if (fallback === undefined) {\n // Default: render children without GTM (silent fallback)\n return children;\n }\n\n if (typeof fallback === 'function') {\n return fallback(error, this.reset);\n }\n\n return fallback;\n }\n\n return children;\n }\n}\n","import { useEffect, useRef } from 'react';\nimport { useLocation } from '@remix-run/react';\nimport { useGtmPush } from './provider';\n\n/**\n * Options for the useTrackPageViews hook.\n */\nexport interface UseTrackPageViewsOptions {\n /**\n * The event name to use for page view events.\n * @default 'page_view'\n */\n eventName?: string;\n\n /**\n * Whether to track the initial page load.\n * @default true\n */\n trackInitialPageView?: boolean;\n\n /**\n * Custom data to include with each page view event.\n */\n customData?: Record<string, unknown>;\n\n /**\n * Callback to transform the page view event data before pushing.\n * Use this to add custom properties or modify the event.\n */\n transformEvent?: (data: PageViewData) => Record<string, unknown>;\n}\n\n/**\n * Data included with each page view event.\n */\nexport interface PageViewData {\n event: string;\n page_path: string;\n page_search: string;\n page_hash: string;\n page_url: string;\n [key: string]: unknown;\n}\n\n/**\n * Hook to automatically track page views on route changes.\n * Uses Remix's useLocation to detect navigation.\n *\n * @example\n * ```tsx\n * // app/root.tsx\n * import { GtmProvider, useTrackPageViews } from '@jwiedeman/gtm-kit-remix';\n *\n * function PageViewTracker() {\n * useTrackPageViews();\n * return null;\n * }\n *\n * export default function App() {\n * return (\n * <GtmProvider config={{ containers: 'GTM-XXXXXX' }}>\n * <PageViewTracker />\n * <Outlet />\n * </GtmProvider>\n * );\n * }\n * ```\n *\n * @example With custom options\n * ```tsx\n * useTrackPageViews({\n * eventName: 'virtual_page_view',\n * customData: { app_version: '1.0.0' },\n * transformEvent: (data) => ({\n * ...data,\n * user_id: getCurrentUserId()\n * })\n * });\n * ```\n */\nexport function useTrackPageViews(options: UseTrackPageViewsOptions = {}): void {\n const { eventName = 'page_view', trackInitialPageView = true, customData = {}, transformEvent } = options;\n\n const location = useLocation();\n const push = useGtmPush();\n const lastPathRef = useRef<string | null>(null);\n const isFirstRenderRef = useRef(true);\n\n useEffect(() => {\n const currentPath = location.pathname + location.search + location.hash;\n\n // Skip if this is the same path (prevents double-firing)\n if (currentPath === lastPathRef.current) {\n return;\n }\n\n // Skip initial page view if configured\n if (isFirstRenderRef.current && !trackInitialPageView) {\n isFirstRenderRef.current = false;\n lastPathRef.current = currentPath;\n return;\n }\n\n isFirstRenderRef.current = false;\n lastPathRef.current = currentPath;\n\n // Build page view data\n const pageViewData: PageViewData = {\n event: eventName,\n page_path: location.pathname,\n page_search: location.search,\n page_hash: location.hash,\n page_url: typeof window !== 'undefined' ? window.location.href : currentPath,\n ...customData\n };\n\n // Apply transform if provided\n const eventData = transformEvent ? transformEvent(pageViewData) : pageViewData;\n\n // Push to GTM\n push(eventData);\n }, [location, push, eventName, trackInitialPageView, customData, transformEvent]);\n}\n","import React from 'react';\nimport { createNoscriptMarkup, type ContainerConfigInput, type ContainerDescriptor } from '@jwiedeman/gtm-kit';\n\n/**\n * Escape a string for safe use in JavaScript string literals.\n * Prevents XSS when interpolating user-provided values into inline scripts.\n */\nfunction escapeJsString(value: string): string {\n return value\n .replace(/\\\\/g, '\\\\\\\\')\n .replace(/'/g, \"\\\\'\")\n .replace(/\"/g, '\\\\\"')\n .replace(/\\n/g, '\\\\n')\n .replace(/\\r/g, '\\\\r')\n .replace(/</g, '\\\\x3c')\n .replace(/>/g, '\\\\x3e')\n .replace(/\\u2028/g, '\\\\u2028')\n .replace(/\\u2029/g, '\\\\u2029');\n}\n\n/**\n * Props for the GtmScripts component.\n */\nexport interface GtmScriptsProps {\n /**\n * GTM container ID(s).\n */\n containers: ContainerConfigInput | ContainerConfigInput[];\n\n /**\n * Custom GTM host URL.\n * @default 'https://www.googletagmanager.com'\n */\n host?: string;\n\n /**\n * Custom dataLayer name.\n * @default 'dataLayer'\n */\n dataLayerName?: string;\n\n /**\n * Script attributes (e.g., nonce for CSP).\n */\n scriptAttributes?: Record<string, string>;\n}\n\n/**\n * Normalize container config to array format.\n */\nfunction normalizeContainers(containers: ContainerConfigInput | ContainerConfigInput[]): ContainerDescriptor[] {\n if (typeof containers === 'string') {\n return [{ id: containers }];\n }\n if (!Array.isArray(containers)) {\n return [containers];\n }\n return containers.map((c) => (typeof c === 'string' ? { id: c } : c));\n}\n\n/**\n * Server component that renders GTM script tags for Remix.\n * Use this in your root.tsx to add GTM scripts.\n *\n * @example\n * ```tsx\n * // app/root.tsx\n * import { GtmScripts } from '@jwiedeman/gtm-kit-remix';\n *\n * export default function App() {\n * return (\n * <html>\n * <head>\n * <GtmScripts containers=\"GTM-XXXXXX\" />\n * </head>\n * <body>\n * <Outlet />\n * </body>\n * </html>\n * );\n * }\n * ```\n *\n * @example With CSP nonce\n * ```tsx\n * <GtmScripts\n * containers=\"GTM-XXXXXX\"\n * scriptAttributes={{ nonce: 'your-csp-nonce' }}\n * />\n * ```\n */\n/**\n * Build the GTM script URL for a container.\n */\nfunction buildGtmScriptUrl(\n containerId: string,\n host: string,\n dataLayerName: string,\n queryParams?: Record<string, string | number | boolean>\n): string {\n const normalizedHost = host.endsWith('/') ? host.slice(0, -1) : host;\n const params = new URLSearchParams();\n params.set('id', containerId);\n\n if (dataLayerName !== 'dataLayer') {\n params.set('l', dataLayerName);\n }\n\n if (queryParams) {\n for (const [key, value] of Object.entries(queryParams)) {\n if (key !== 'id' && key !== 'l') {\n params.set(key, String(value));\n }\n }\n }\n\n return `${normalizedHost}/gtm.js?${params.toString()}`;\n}\n\nexport function GtmScripts({\n containers,\n host = 'https://www.googletagmanager.com',\n dataLayerName = 'dataLayer',\n scriptAttributes = {}\n}: GtmScriptsProps): React.ReactElement {\n // Validate dataLayerName is a valid JavaScript identifier\n if (!/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(dataLayerName)) {\n throw new Error(\n `[gtm-kit/remix] Invalid dataLayerName \"${dataLayerName}\". Must be a valid JavaScript identifier.`\n );\n }\n\n const containerConfigs = normalizeContainers(containers);\n\n // Filter out event handler attributes to prevent XSS\n const safeScriptAttributes: Record<string, string> = {};\n for (const [key, value] of Object.entries(scriptAttributes)) {\n if (!key.toLowerCase().startsWith('on')) {\n safeScriptAttributes[key] = value;\n }\n }\n\n // Escape values for safe use in JavaScript string literals\n const safeDataLayerName = escapeJsString(dataLayerName);\n const safeNonce = safeScriptAttributes.nonce ? escapeJsString(safeScriptAttributes.nonce) : '';\n\n // Generate inline script for dataLayer initialization and GTM loading\n const inlineScript = `\n window['${safeDataLayerName}'] = window['${safeDataLayerName}'] || [];\n ${containerConfigs\n .map((config) => {\n const safeContainerId = escapeJsString(config.id);\n const scriptSrc = escapeJsString(buildGtmScriptUrl(config.id, host, dataLayerName, config.queryParams));\n return `\n (function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':\n new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],\n j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=\n '${scriptSrc}';${safeNonce ? `j.nonce='${safeNonce}';` : ''}f.parentNode.insertBefore(j,f);\n })(window,document,'script','${safeDataLayerName}','${safeContainerId}');\n `;\n })\n .join('\\n')}\n `.trim();\n\n // Generate noscript HTML using the core package\n const noscriptHtml = createNoscriptMarkup(containerConfigs, { host });\n\n return (\n <>\n <script {...safeScriptAttributes} dangerouslySetInnerHTML={{ __html: inlineScript }} />\n <noscript dangerouslySetInnerHTML={{ __html: noscriptHtml }} />\n </>\n );\n}\n"]}
package/dist/index.js CHANGED
@@ -50,15 +50,23 @@ function GtmProviderInner({ config, children, onBeforeInit, onAfterInit }) {
50
50
  }
51
51
  return () => {
52
52
  teardownTimerRef.current = setTimeout(() => {
53
+ teardownTimerRef.current = null;
53
54
  if (!document.querySelector("[data-gtm-kit-provider]")) {
54
55
  client.teardown();
55
56
  clientRef.current = null;
56
57
  initializedRef.current = false;
57
58
  }
58
- teardownTimerRef.current = null;
59
59
  }, 100);
60
60
  };
61
61
  }, [client, onBeforeInit, onAfterInit]);
62
+ useEffect(() => {
63
+ return () => {
64
+ if (teardownTimerRef.current) {
65
+ clearTimeout(teardownTimerRef.current);
66
+ teardownTimerRef.current = null;
67
+ }
68
+ };
69
+ }, []);
62
70
  const contextValue = useMemo(
63
71
  () => ({
64
72
  client,
@@ -203,9 +211,20 @@ function GtmScripts({
203
211
  dataLayerName = "dataLayer",
204
212
  scriptAttributes = {}
205
213
  }) {
214
+ if (!/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(dataLayerName)) {
215
+ throw new Error(
216
+ `[gtm-kit/remix] Invalid dataLayerName "${dataLayerName}". Must be a valid JavaScript identifier.`
217
+ );
218
+ }
206
219
  const containerConfigs = normalizeContainers(containers);
220
+ const safeScriptAttributes = {};
221
+ for (const [key, value] of Object.entries(scriptAttributes)) {
222
+ if (!key.toLowerCase().startsWith("on")) {
223
+ safeScriptAttributes[key] = value;
224
+ }
225
+ }
207
226
  const safeDataLayerName = escapeJsString(dataLayerName);
208
- const safeNonce = scriptAttributes.nonce ? escapeJsString(scriptAttributes.nonce) : "";
227
+ const safeNonce = safeScriptAttributes.nonce ? escapeJsString(safeScriptAttributes.nonce) : "";
209
228
  const inlineScript = `
210
229
  window['${safeDataLayerName}'] = window['${safeDataLayerName}'] || [];
211
230
  ${containerConfigs.map((config) => {
@@ -222,7 +241,7 @@ function GtmScripts({
222
241
  `.trim();
223
242
  const noscriptHtml = createNoscriptMarkup(containerConfigs, { host });
224
243
  return /* @__PURE__ */ jsxs(Fragment, { children: [
225
- /* @__PURE__ */ jsx("script", { ...scriptAttributes, dangerouslySetInnerHTML: { __html: inlineScript } }),
244
+ /* @__PURE__ */ jsx("script", { ...safeScriptAttributes, dangerouslySetInnerHTML: { __html: inlineScript } }),
226
245
  /* @__PURE__ */ jsx("noscript", { dangerouslySetInnerHTML: { __html: noscriptHtml } })
227
246
  ] });
228
247
  }
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/provider.tsx","../src/route-tracker.tsx","../src/scripts.tsx"],"names":["useEffect","useRef","Fragment","jsx"],"mappings":";AAAA;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OAGK;AACP;AAAA,EACE;AAAA,OAOK;AAoGI;AA/CJ,IAAM,aAAa,cAAsC,IAAI;AAEpE,IAAM,uBAAuB,MAAY;AACvC,MAAI,QAAQ,IAAI,aAAa,cAAc;AACzC,YAAQ;AAAA,MACN;AAAA,IAEF;AAAA,EACF;AACF;AAyBO,SAAS,YAAY,EAAE,QAAQ,UAAU,cAAc,YAAY,GAAyC;AAEjH,QAAM,kBAAkB,WAAW,UAAU;AAG7C,YAAU,MAAM;AACd,QAAI,iBAAiB;AACnB,2BAAqB;AAAA,IACvB;AAAA,EACF,GAAG,CAAC,eAAe,CAAC;AAGpB,MAAI,iBAAiB;AACnB,WAAO,gCAAG,UAAS;AAAA,EACrB;AAEA,SACE,oBAAC,oBAAiB,QAAgB,cAA4B,aAC3D,UACH;AAEJ;AAEA,SAAS,iBAAiB,EAAE,QAAQ,UAAU,cAAc,YAAY,GAAyC;AAE/G,QAAM,YAAY,OAAyB,IAAI;AAC/C,QAAM,iBAAiB,OAAO,KAAK;AACnC,QAAM,mBAAmB,OAA6C,IAAI;AAG1E,MAAI,CAAC,UAAU,SAAS;AACtB,cAAU,UAAU,gBAAgB,MAAM;AAAA,EAC5C;AAEA,QAAM,SAAS,UAAU;AAGzB,YAAU,MAAM;AAEd,QAAI,iBAAiB,SAAS;AAC5B,mBAAa,iBAAiB,OAAO;AACrC,uBAAiB,UAAU;AAAA,IAC7B;AAGA,QAAI,eAAe,SAAS;AAC1B;AAAA,IACF;AAGA,QAAI,cAAc;AAChB,mBAAa,MAAM;AAAA,IACrB;AAGA,WAAO,KAAK;AACZ,mBAAe,UAAU;AAGzB,QAAI,aAAa;AACf,kBAAY,MAAM;AAAA,IACpB;AAGA,WAAO,MAAM;AACX,uBAAiB,UAAU,WAAW,MAAM;AAE1C,YAAI,CAAC,SAAS,cAAc,yBAAyB,GAAG;AACtD,iBAAO,SAAS;AAChB,oBAAU,UAAU;AACpB,yBAAe,UAAU;AAAA,QAC3B;AACA,yBAAiB,UAAU;AAAA,MAC7B,GAAG,GAAG;AAAA,IACR;AAAA,EACF,GAAG,CAAC,QAAQ,cAAc,WAAW,CAAC;AAGtC,QAAM,eAAe;AAAA,IACnB,OAAO;AAAA,MACL;AAAA,MACA,MAAM,CAAC,UAA0B,OAAO,KAAK,KAAK;AAAA,MAClD,oBAAoB,CAAC,OAAqB,YACxC,OAAO,mBAAmB,OAAO,OAAO;AAAA,MAC1C,eAAe,CAAC,OAAqB,YAAmC,OAAO,cAAc,OAAO,OAAO;AAAA,MAC3G,SAAS,MAAM,OAAO,QAAQ;AAAA,MAC9B,WAAW,MAAM,OAAO,UAAU;AAAA,IACpC;AAAA,IACA,CAAC,MAAM;AAAA,EACT;AAEA,SACE,oBAAC,WAAW,UAAX,EAAoB,OAAO,cAC1B,8BAAC,SAAI,yBAAsB,IAAG,OAAO,EAAE,SAAS,WAAW,GACxD,UACH,GACF;AAEJ;AAKA,IAAM,gBAAgB,MAAuB;AAC3C,QAAM,UAAU,WAAW,UAAU;AACrC,MAAI,CAAC,SAAS;AACZ,UAAM,IAAI;AAAA,MACR;AAAA,IAEF;AAAA,EACF;AACA,SAAO;AACT;AAoBO,IAAM,SAAS,MAAuB;AAC3C,SAAO,cAAc;AACvB;AAoBO,IAAM,aAAa,MAAyC;AACjE,SAAO,cAAc,EAAE;AACzB;AAoBO,IAAM,gBAAgB,MAAqB;AAChD,QAAM,EAAE,oBAAoB,cAAc,IAAI,cAAc;AAC5D,SAAO,EAAE,oBAAoB,cAAc;AAC7C;AAeO,IAAM,eAAe,MAAiB;AAC3C,SAAO,cAAc,EAAE;AACzB;AAqBO,IAAM,cAAc,MAA0C;AACnE,SAAO,cAAc,EAAE;AACzB;AAeO,IAAM,gBAAgB,MAAuB;AAClD,SAAO,cAAc,EAAE;AACzB;AAwCO,IAAM,mBAAN,cAA+B,UAAwD;AAAA,EAC5F,YAAY,OAA8B;AACxC,UAAM,KAAK;AAyBb,iBAAQ,MAAY;AAClB,WAAK,SAAS,EAAE,UAAU,OAAO,OAAO,KAAK,CAAC;AAAA,IAChD;AA1BE,SAAK,QAAQ,EAAE,UAAU,OAAO,OAAO,KAAK;AAAA,EAC9C;AAAA,EAEA,OAAO,yBAAyB,OAAqC;AACnE,WAAO,EAAE,UAAU,MAAM,MAAM;AAAA,EACjC;AAAA,EAEA,kBAAkB,OAAc,WAA4B;AAC1D,UAAM,EAAE,SAAS,YAAY,QAAQ,IAAI,aAAa,aAAa,IAAI,KAAK;AAE5E,QAAI,WAAW;AACb,cAAQ,MAAM,qDAAqD,KAAK;AACxE,cAAQ,MAAM,oCAAoC,UAAU,cAAc;AAAA,IAC5E;AAEA,QAAI,SAAS;AACX,UAAI;AACF,gBAAQ,OAAO,SAAS;AAAA,MAC1B,SAAQ;AAAA,MAER;AAAA,IACF;AAAA,EACF;AAAA,EAMA,SAAoB;AAClB,UAAM,EAAE,UAAU,MAAM,IAAI,KAAK;AACjC,UAAM,EAAE,UAAU,SAAS,IAAI,KAAK;AAEpC,QAAI,YAAY,OAAO;AACrB,UAAI,aAAa,QAAW;AAE1B,eAAO;AAAA,MACT;AAEA,UAAI,OAAO,aAAa,YAAY;AAClC,eAAO,SAAS,OAAO,KAAK,KAAK;AAAA,MACnC;AAEA,aAAO;AAAA,IACT;AAEA,WAAO;AAAA,EACT;AACF;;;AC/aA,SAAS,aAAAA,YAAW,UAAAC,eAAc;AAClC,SAAS,mBAAmB;AA+ErB,SAAS,kBAAkB,UAAoC,CAAC,GAAS;AAC9E,QAAM,EAAE,YAAY,aAAa,uBAAuB,MAAM,aAAa,CAAC,GAAG,eAAe,IAAI;AAElG,QAAM,WAAW,YAAY;AAC7B,QAAM,OAAO,WAAW;AACxB,QAAM,cAAcA,QAAsB,IAAI;AAC9C,QAAM,mBAAmBA,QAAO,IAAI;AAEpC,EAAAD,WAAU,MAAM;AACd,UAAM,cAAc,SAAS,WAAW,SAAS,SAAS,SAAS;AAGnE,QAAI,gBAAgB,YAAY,SAAS;AACvC;AAAA,IACF;AAGA,QAAI,iBAAiB,WAAW,CAAC,sBAAsB;AACrD,uBAAiB,UAAU;AAC3B,kBAAY,UAAU;AACtB;AAAA,IACF;AAEA,qBAAiB,UAAU;AAC3B,gBAAY,UAAU;AAGtB,UAAM,eAA6B;AAAA,MACjC,OAAO;AAAA,MACP,WAAW,SAAS;AAAA,MACpB,aAAa,SAAS;AAAA,MACtB,WAAW,SAAS;AAAA,MACpB,UAAU,OAAO,WAAW,cAAc,OAAO,SAAS,OAAO;AAAA,MACjE,GAAG;AAAA,IACL;AAGA,UAAM,YAAY,iBAAiB,eAAe,YAAY,IAAI;AAGlE,SAAK,SAAS;AAAA,EAChB,GAAG,CAAC,UAAU,MAAM,WAAW,sBAAsB,YAAY,cAAc,CAAC;AAClF;;;ACzHA,SAAS,4BAAiF;AAwJtF,qBAAAE,WACE,OAAAC,MADF;AAlJJ,SAAS,eAAe,OAAuB;AAC7C,SAAO,MACJ,QAAQ,OAAO,MAAM,EACrB,QAAQ,MAAM,KAAK,EACnB,QAAQ,MAAM,KAAK,EACnB,QAAQ,OAAO,KAAK,EACpB,QAAQ,OAAO,KAAK,EACpB,QAAQ,MAAM,OAAO,EACrB,QAAQ,MAAM,OAAO,EACrB,QAAQ,WAAW,SAAS,EAC5B,QAAQ,WAAW,SAAS;AACjC;AAgCA,SAAS,oBAAoB,YAAkF;AAC7G,MAAI,OAAO,eAAe,UAAU;AAClC,WAAO,CAAC,EAAE,IAAI,WAAW,CAAC;AAAA,EAC5B;AACA,MAAI,CAAC,MAAM,QAAQ,UAAU,GAAG;AAC9B,WAAO,CAAC,UAAU;AAAA,EACpB;AACA,SAAO,WAAW,IAAI,CAAC,MAAO,OAAO,MAAM,WAAW,EAAE,IAAI,EAAE,IAAI,CAAE;AACtE;AAoCA,SAAS,kBACP,aACA,MACA,eACA,aACQ;AACR,QAAM,iBAAiB,KAAK,SAAS,GAAG,IAAI,KAAK,MAAM,GAAG,EAAE,IAAI;AAChE,QAAM,SAAS,IAAI,gBAAgB;AACnC,SAAO,IAAI,MAAM,WAAW;AAE5B,MAAI,kBAAkB,aAAa;AACjC,WAAO,IAAI,KAAK,aAAa;AAAA,EAC/B;AAEA,MAAI,aAAa;AACf,eAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,WAAW,GAAG;AACtD,UAAI,QAAQ,QAAQ,QAAQ,KAAK;AAC/B,eAAO,IAAI,KAAK,OAAO,KAAK,CAAC;AAAA,MAC/B;AAAA,IACF;AAAA,EACF;AAEA,SAAO,GAAG,cAAc,WAAW,OAAO,SAAS,CAAC;AACtD;AAEO,SAAS,WAAW;AAAA,EACzB;AAAA,EACA,OAAO;AAAA,EACP,gBAAgB;AAAA,EAChB,mBAAmB,CAAC;AACtB,GAAwC;AACtC,QAAM,mBAAmB,oBAAoB,UAAU;AAGvD,QAAM,oBAAoB,eAAe,aAAa;AACtD,QAAM,YAAY,iBAAiB,QAAQ,eAAe,iBAAiB,KAAK,IAAI;AAGpF,QAAM,eAAe;AAAA,cACT,iBAAiB,gBAAgB,iBAAiB;AAAA,MAC1D,iBACC,IAAI,CAAC,WAAW;AACf,UAAM,kBAAkB,eAAe,OAAO,EAAE;AAChD,UAAM,YAAY,eAAe,kBAAkB,OAAO,IAAI,MAAM,eAAe,OAAO,WAAW,CAAC;AACtG,WAAO;AAAA;AAAA;AAAA;AAAA,SAIN,SAAS,KAAK,YAAY,YAAY,SAAS,OAAO,EAAE;AAAA,qCAC5B,iBAAiB,MAAM,eAAe;AAAA;AAAA,EAErE,CAAC,EACA,KAAK,IAAI,CAAC;AAAA,IACb,KAAK;AAGP,QAAM,eAAe,qBAAqB,kBAAkB,EAAE,KAAK,CAAC;AAEpE,SACE,qBAAAD,WAAA,EACE;AAAA,oBAAAC,KAAC,YAAQ,GAAG,kBAAkB,yBAAyB,EAAE,QAAQ,aAAa,GAAG;AAAA,IACjF,gBAAAA,KAAC,cAAS,yBAAyB,EAAE,QAAQ,aAAa,GAAG;AAAA,KAC/D;AAEJ","sourcesContent":["import React, {\n Component,\n createContext,\n useContext,\n useEffect,\n useRef,\n useMemo,\n type ErrorInfo,\n type ReactNode\n} from 'react';\nimport {\n createGtmClient,\n type ConsentRegionOptions,\n type ConsentState,\n type CreateGtmClientOptions,\n type DataLayerValue,\n type GtmClient,\n type ScriptLoadState\n} from '@jwiedeman/gtm-kit';\n\n/**\n * Props for the GTM Provider component.\n */\nexport interface GtmProviderProps {\n /** GTM client configuration */\n config: CreateGtmClientOptions;\n\n /** Child components */\n children: ReactNode;\n\n /**\n * Callback executed before GTM initialization.\n * Use this to set consent defaults.\n */\n onBeforeInit?: (client: GtmClient) => void;\n\n /**\n * Callback executed after GTM initialization.\n */\n onAfterInit?: (client: GtmClient) => void;\n}\n\n/**\n * The GTM context value containing all GTM functionality.\n */\nexport interface GtmContextValue {\n /** The underlying GTM client instance */\n client: GtmClient;\n /** Push a value to the data layer */\n push: (value: DataLayerValue) => void;\n /** Set consent defaults (must be called before init) */\n setConsentDefaults: (state: ConsentState, options?: ConsentRegionOptions) => void;\n /** Update consent state */\n updateConsent: (state: ConsentState, options?: ConsentRegionOptions) => void;\n /** Synchronously check if all GTM scripts have finished loading */\n isReady: () => boolean;\n /** Returns a promise that resolves when all GTM scripts are loaded */\n whenReady: () => Promise<ScriptLoadState[]>;\n}\n\n/**\n * Consent-specific API subset.\n */\nexport interface GtmConsentApi {\n setConsentDefaults: (state: ConsentState, options?: ConsentRegionOptions) => void;\n updateConsent: (state: ConsentState, options?: ConsentRegionOptions) => void;\n}\n\n/**\n * The GTM context for Remix.\n */\nexport const GtmContext = createContext<GtmContextValue | null>(null);\n\nconst warnOnNestedProvider = (): void => {\n if (process.env.NODE_ENV !== 'production') {\n console.warn(\n '[gtm-kit/remix] Nested GtmProvider detected. You should only have one GtmProvider at the root of your app. ' +\n 'The nested provider will be ignored.'\n );\n }\n};\n\n/**\n * GTM Provider component for Remix.\n * Handles StrictMode correctly and provides GTM context to children.\n *\n * @example\n * ```tsx\n * // app/root.tsx\n * import { GtmProvider } from '@jwiedeman/gtm-kit-remix';\n *\n * export default function App() {\n * return (\n * <html>\n * <head />\n * <body>\n * <GtmProvider config={{ containers: 'GTM-XXXXXX' }}>\n * <Outlet />\n * </GtmProvider>\n * </body>\n * </html>\n * );\n * }\n * ```\n */\nexport function GtmProvider({ config, children, onBeforeInit, onAfterInit }: GtmProviderProps): React.ReactElement {\n // Check for nested provider\n const existingContext = useContext(GtmContext);\n\n // Warn if we're inside another GtmProvider (nested providers)\n useEffect(() => {\n if (existingContext) {\n warnOnNestedProvider();\n }\n }, [existingContext]);\n\n // If nested, just pass through children without creating a new context\n if (existingContext) {\n return <>{children}</>;\n }\n\n return (\n <GtmProviderInner config={config} onBeforeInit={onBeforeInit} onAfterInit={onAfterInit}>\n {children}\n </GtmProviderInner>\n );\n}\n\nfunction GtmProviderInner({ config, children, onBeforeInit, onAfterInit }: GtmProviderProps): React.ReactElement {\n // Create client once and store in ref to survive StrictMode remounts\n const clientRef = useRef<GtmClient | null>(null);\n const initializedRef = useRef(false);\n const teardownTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n // Create client on first render only\n if (!clientRef.current) {\n clientRef.current = createGtmClient(config);\n }\n\n const client = clientRef.current;\n\n // Initialize GTM (handles StrictMode correctly)\n useEffect(() => {\n // Clear any pending teardown from StrictMode unmount/remount cycle\n if (teardownTimerRef.current) {\n clearTimeout(teardownTimerRef.current);\n teardownTimerRef.current = null;\n }\n\n // Skip if already initialized (StrictMode protection)\n if (initializedRef.current) {\n return;\n }\n\n // Call onBeforeInit hook for consent defaults\n if (onBeforeInit) {\n onBeforeInit(client);\n }\n\n // Initialize GTM\n client.init();\n initializedRef.current = true;\n\n // Call onAfterInit hook\n if (onAfterInit) {\n onAfterInit(client);\n }\n\n // Cleanup on unmount - defer to allow StrictMode remount\n return () => {\n teardownTimerRef.current = setTimeout(() => {\n // Only teardown if we're truly unmounting (no provider in DOM)\n if (!document.querySelector('[data-gtm-kit-provider]')) {\n client.teardown();\n clientRef.current = null;\n initializedRef.current = false;\n }\n teardownTimerRef.current = null;\n }, 100);\n };\n }, [client, onBeforeInit, onAfterInit]);\n\n // Memoize context value\n const contextValue = useMemo<GtmContextValue>(\n () => ({\n client,\n push: (value: DataLayerValue) => client.push(value),\n setConsentDefaults: (state: ConsentState, options?: ConsentRegionOptions) =>\n client.setConsentDefaults(state, options),\n updateConsent: (state: ConsentState, options?: ConsentRegionOptions) => client.updateConsent(state, options),\n isReady: () => client.isReady(),\n whenReady: () => client.whenReady()\n }),\n [client]\n );\n\n return (\n <GtmContext.Provider value={contextValue}>\n <div data-gtm-kit-provider=\"\" style={{ display: 'contents' }}>\n {children}\n </div>\n </GtmContext.Provider>\n );\n}\n\n/**\n * Internal helper to get the GTM context with proper error handling.\n */\nconst useGtmContext = (): GtmContextValue => {\n const context = useContext(GtmContext);\n if (!context) {\n throw new Error(\n '[gtm-kit/remix] useGtm() was called outside of a GtmProvider. ' +\n 'Make sure to wrap your app with <GtmProvider config={{ containers: \"GTM-XXXXXX\" }}>.'\n );\n }\n return context;\n};\n\n/**\n * Hook to access the full GTM context.\n *\n * @example\n * ```tsx\n * import { useGtm } from '@jwiedeman/gtm-kit-remix';\n *\n * function MyComponent() {\n * const { push, client } = useGtm();\n *\n * return (\n * <button onClick={() => push({ event: 'click' })}>\n * Track\n * </button>\n * );\n * }\n * ```\n */\nexport const useGtm = (): GtmContextValue => {\n return useGtmContext();\n};\n\n/**\n * Hook to get just the push function.\n *\n * @example\n * ```tsx\n * import { useGtmPush } from '@jwiedeman/gtm-kit-remix';\n *\n * function BuyButton() {\n * const push = useGtmPush();\n *\n * return (\n * <button onClick={() => push({ event: 'purchase', value: 99 })}>\n * Buy\n * </button>\n * );\n * }\n * ```\n */\nexport const useGtmPush = (): ((value: DataLayerValue) => void) => {\n return useGtmContext().push;\n};\n\n/**\n * Hook to access consent management functions.\n *\n * @example\n * ```tsx\n * import { useGtmConsent } from '@jwiedeman/gtm-kit-remix';\n *\n * function CookieBanner() {\n * const { updateConsent } = useGtmConsent();\n *\n * return (\n * <button onClick={() => updateConsent({ analytics_storage: 'granted' })}>\n * Accept\n * </button>\n * );\n * }\n * ```\n */\nexport const useGtmConsent = (): GtmConsentApi => {\n const { setConsentDefaults, updateConsent } = useGtmContext();\n return { setConsentDefaults, updateConsent };\n};\n\n/**\n * Hook to get the raw GTM client instance.\n *\n * @example\n * ```tsx\n * import { useGtmClient } from '@jwiedeman/gtm-kit-remix';\n *\n * function MyComponent() {\n * const client = useGtmClient();\n * return <div>{client.isInitialized() ? 'Ready' : 'Loading'}</div>;\n * }\n * ```\n */\nexport const useGtmClient = (): GtmClient => {\n return useGtmContext().client;\n};\n\n/**\n * Hook to get the whenReady function.\n *\n * @example\n * ```tsx\n * import { useGtmReady } from '@jwiedeman/gtm-kit-remix';\n * import { useEffect } from 'react';\n *\n * function MyComponent() {\n * const whenReady = useGtmReady();\n *\n * useEffect(() => {\n * whenReady().then(() => console.log('GTM ready!'));\n * }, [whenReady]);\n *\n * return <div>Loading...</div>;\n * }\n * ```\n */\nexport const useGtmReady = (): (() => Promise<ScriptLoadState[]>) => {\n return useGtmContext().whenReady;\n};\n\n/**\n * Hook to check if GTM scripts have finished loading synchronously.\n *\n * @example\n * ```tsx\n * import { useIsGtmReady } from '@jwiedeman/gtm-kit-remix';\n *\n * function MyComponent() {\n * const isReady = useIsGtmReady();\n * return <div>{isReady() ? 'GTM Ready' : 'Loading...'}</div>;\n * }\n * ```\n */\nexport const useIsGtmReady = (): (() => boolean) => {\n return useGtmContext().isReady;\n};\n\n/**\n * Props for GtmErrorBoundary component.\n */\nexport interface GtmErrorBoundaryProps {\n children: ReactNode;\n /** Fallback UI to render when an error occurs */\n fallback?: ReactNode | ((error: Error, reset: () => void) => ReactNode);\n /** Callback invoked when an error is caught */\n onError?: (error: Error, errorInfo: ErrorInfo) => void;\n /** Whether to log errors to console (default: true in development) */\n logErrors?: boolean;\n}\n\ninterface GtmErrorBoundaryState {\n hasError: boolean;\n error: Error | null;\n}\n\n/**\n * Error boundary component for GTM provider in Remix apps.\n * Catches errors during GTM initialization and renders a fallback UI.\n * Analytics and tracking will be disabled when an error occurs.\n *\n * @example\n * ```tsx\n * import { GtmProvider, GtmErrorBoundary } from '@jwiedeman/gtm-kit-remix';\n *\n * export default function App() {\n * return (\n * <GtmErrorBoundary fallback={<div>GTM failed to load</div>}>\n * <GtmProvider config={{ containers: 'GTM-XXXXXX' }}>\n * <Outlet />\n * </GtmProvider>\n * </GtmErrorBoundary>\n * );\n * }\n * ```\n */\nexport class GtmErrorBoundary extends Component<GtmErrorBoundaryProps, GtmErrorBoundaryState> {\n constructor(props: GtmErrorBoundaryProps) {\n super(props);\n this.state = { hasError: false, error: null };\n }\n\n static getDerivedStateFromError(error: Error): GtmErrorBoundaryState {\n return { hasError: true, error };\n }\n\n componentDidCatch(error: Error, errorInfo: ErrorInfo): void {\n const { onError, logErrors = process.env.NODE_ENV !== 'production' } = this.props;\n\n if (logErrors) {\n console.error('[gtm-kit/remix] Error caught by GtmErrorBoundary:', error);\n console.error('[gtm-kit/remix] Component stack:', errorInfo.componentStack);\n }\n\n if (onError) {\n try {\n onError(error, errorInfo);\n } catch {\n // Ignore callback errors\n }\n }\n }\n\n reset = (): void => {\n this.setState({ hasError: false, error: null });\n };\n\n render(): ReactNode {\n const { hasError, error } = this.state;\n const { children, fallback } = this.props;\n\n if (hasError && error) {\n if (fallback === undefined) {\n // Default: render children without GTM (silent fallback)\n return children;\n }\n\n if (typeof fallback === 'function') {\n return fallback(error, this.reset);\n }\n\n return fallback;\n }\n\n return children;\n }\n}\n","import { useEffect, useRef } from 'react';\nimport { useLocation } from '@remix-run/react';\nimport { useGtmPush } from './provider';\n\n/**\n * Options for the useTrackPageViews hook.\n */\nexport interface UseTrackPageViewsOptions {\n /**\n * The event name to use for page view events.\n * @default 'page_view'\n */\n eventName?: string;\n\n /**\n * Whether to track the initial page load.\n * @default true\n */\n trackInitialPageView?: boolean;\n\n /**\n * Custom data to include with each page view event.\n */\n customData?: Record<string, unknown>;\n\n /**\n * Callback to transform the page view event data before pushing.\n * Use this to add custom properties or modify the event.\n */\n transformEvent?: (data: PageViewData) => Record<string, unknown>;\n}\n\n/**\n * Data included with each page view event.\n */\nexport interface PageViewData {\n event: string;\n page_path: string;\n page_search: string;\n page_hash: string;\n page_url: string;\n [key: string]: unknown;\n}\n\n/**\n * Hook to automatically track page views on route changes.\n * Uses Remix's useLocation to detect navigation.\n *\n * @example\n * ```tsx\n * // app/root.tsx\n * import { GtmProvider, useTrackPageViews } from '@jwiedeman/gtm-kit-remix';\n *\n * function PageViewTracker() {\n * useTrackPageViews();\n * return null;\n * }\n *\n * export default function App() {\n * return (\n * <GtmProvider config={{ containers: 'GTM-XXXXXX' }}>\n * <PageViewTracker />\n * <Outlet />\n * </GtmProvider>\n * );\n * }\n * ```\n *\n * @example With custom options\n * ```tsx\n * useTrackPageViews({\n * eventName: 'virtual_page_view',\n * customData: { app_version: '1.0.0' },\n * transformEvent: (data) => ({\n * ...data,\n * user_id: getCurrentUserId()\n * })\n * });\n * ```\n */\nexport function useTrackPageViews(options: UseTrackPageViewsOptions = {}): void {\n const { eventName = 'page_view', trackInitialPageView = true, customData = {}, transformEvent } = options;\n\n const location = useLocation();\n const push = useGtmPush();\n const lastPathRef = useRef<string | null>(null);\n const isFirstRenderRef = useRef(true);\n\n useEffect(() => {\n const currentPath = location.pathname + location.search + location.hash;\n\n // Skip if this is the same path (prevents double-firing)\n if (currentPath === lastPathRef.current) {\n return;\n }\n\n // Skip initial page view if configured\n if (isFirstRenderRef.current && !trackInitialPageView) {\n isFirstRenderRef.current = false;\n lastPathRef.current = currentPath;\n return;\n }\n\n isFirstRenderRef.current = false;\n lastPathRef.current = currentPath;\n\n // Build page view data\n const pageViewData: PageViewData = {\n event: eventName,\n page_path: location.pathname,\n page_search: location.search,\n page_hash: location.hash,\n page_url: typeof window !== 'undefined' ? window.location.href : currentPath,\n ...customData\n };\n\n // Apply transform if provided\n const eventData = transformEvent ? transformEvent(pageViewData) : pageViewData;\n\n // Push to GTM\n push(eventData);\n }, [location, push, eventName, trackInitialPageView, customData, transformEvent]);\n}\n","import React from 'react';\nimport { createNoscriptMarkup, type ContainerConfigInput, type ContainerDescriptor } from '@jwiedeman/gtm-kit';\n\n/**\n * Escape a string for safe use in JavaScript string literals.\n * Prevents XSS when interpolating user-provided values into inline scripts.\n */\nfunction escapeJsString(value: string): string {\n return value\n .replace(/\\\\/g, '\\\\\\\\')\n .replace(/'/g, \"\\\\'\")\n .replace(/\"/g, '\\\\\"')\n .replace(/\\n/g, '\\\\n')\n .replace(/\\r/g, '\\\\r')\n .replace(/</g, '\\\\x3c')\n .replace(/>/g, '\\\\x3e')\n .replace(/\\u2028/g, '\\\\u2028')\n .replace(/\\u2029/g, '\\\\u2029');\n}\n\n/**\n * Props for the GtmScripts component.\n */\nexport interface GtmScriptsProps {\n /**\n * GTM container ID(s).\n */\n containers: ContainerConfigInput | ContainerConfigInput[];\n\n /**\n * Custom GTM host URL.\n * @default 'https://www.googletagmanager.com'\n */\n host?: string;\n\n /**\n * Custom dataLayer name.\n * @default 'dataLayer'\n */\n dataLayerName?: string;\n\n /**\n * Script attributes (e.g., nonce for CSP).\n */\n scriptAttributes?: Record<string, string>;\n}\n\n/**\n * Normalize container config to array format.\n */\nfunction normalizeContainers(containers: ContainerConfigInput | ContainerConfigInput[]): ContainerDescriptor[] {\n if (typeof containers === 'string') {\n return [{ id: containers }];\n }\n if (!Array.isArray(containers)) {\n return [containers];\n }\n return containers.map((c) => (typeof c === 'string' ? { id: c } : c));\n}\n\n/**\n * Server component that renders GTM script tags for Remix.\n * Use this in your root.tsx to add GTM scripts.\n *\n * @example\n * ```tsx\n * // app/root.tsx\n * import { GtmScripts } from '@jwiedeman/gtm-kit-remix';\n *\n * export default function App() {\n * return (\n * <html>\n * <head>\n * <GtmScripts containers=\"GTM-XXXXXX\" />\n * </head>\n * <body>\n * <Outlet />\n * </body>\n * </html>\n * );\n * }\n * ```\n *\n * @example With CSP nonce\n * ```tsx\n * <GtmScripts\n * containers=\"GTM-XXXXXX\"\n * scriptAttributes={{ nonce: 'your-csp-nonce' }}\n * />\n * ```\n */\n/**\n * Build the GTM script URL for a container.\n */\nfunction buildGtmScriptUrl(\n containerId: string,\n host: string,\n dataLayerName: string,\n queryParams?: Record<string, string | number | boolean>\n): string {\n const normalizedHost = host.endsWith('/') ? host.slice(0, -1) : host;\n const params = new URLSearchParams();\n params.set('id', containerId);\n\n if (dataLayerName !== 'dataLayer') {\n params.set('l', dataLayerName);\n }\n\n if (queryParams) {\n for (const [key, value] of Object.entries(queryParams)) {\n if (key !== 'id' && key !== 'l') {\n params.set(key, String(value));\n }\n }\n }\n\n return `${normalizedHost}/gtm.js?${params.toString()}`;\n}\n\nexport function GtmScripts({\n containers,\n host = 'https://www.googletagmanager.com',\n dataLayerName = 'dataLayer',\n scriptAttributes = {}\n}: GtmScriptsProps): React.ReactElement {\n const containerConfigs = normalizeContainers(containers);\n\n // Escape values for safe use in JavaScript string literals\n const safeDataLayerName = escapeJsString(dataLayerName);\n const safeNonce = scriptAttributes.nonce ? escapeJsString(scriptAttributes.nonce) : '';\n\n // Generate inline script for dataLayer initialization and GTM loading\n const inlineScript = `\n window['${safeDataLayerName}'] = window['${safeDataLayerName}'] || [];\n ${containerConfigs\n .map((config) => {\n const safeContainerId = escapeJsString(config.id);\n const scriptSrc = escapeJsString(buildGtmScriptUrl(config.id, host, dataLayerName, config.queryParams));\n return `\n (function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':\n new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],\n j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=\n '${scriptSrc}';${safeNonce ? `j.nonce='${safeNonce}';` : ''}f.parentNode.insertBefore(j,f);\n })(window,document,'script','${safeDataLayerName}','${safeContainerId}');\n `;\n })\n .join('\\n')}\n `.trim();\n\n // Generate noscript HTML using the core package\n const noscriptHtml = createNoscriptMarkup(containerConfigs, { host });\n\n return (\n <>\n <script {...scriptAttributes} dangerouslySetInnerHTML={{ __html: inlineScript }} />\n <noscript dangerouslySetInnerHTML={{ __html: noscriptHtml }} />\n </>\n );\n}\n"]}
1
+ {"version":3,"sources":["../src/provider.tsx","../src/route-tracker.tsx","../src/scripts.tsx"],"names":["useEffect","useRef","Fragment","jsx"],"mappings":"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","sourcesContent":["import React, {\n Component,\n createContext,\n useContext,\n useEffect,\n useRef,\n useMemo,\n type ErrorInfo,\n type ReactNode\n} from 'react';\nimport {\n createGtmClient,\n type ConsentRegionOptions,\n type ConsentState,\n type CreateGtmClientOptions,\n type DataLayerValue,\n type GtmClient,\n type ScriptLoadState\n} from '@jwiedeman/gtm-kit';\n\n/**\n * Props for the GTM Provider component.\n */\nexport interface GtmProviderProps {\n /** GTM client configuration */\n config: CreateGtmClientOptions;\n\n /** Child components */\n children: ReactNode;\n\n /**\n * Callback executed before GTM initialization.\n * Use this to set consent defaults.\n */\n onBeforeInit?: (client: GtmClient) => void;\n\n /**\n * Callback executed after GTM initialization.\n */\n onAfterInit?: (client: GtmClient) => void;\n}\n\n/**\n * The GTM context value containing all GTM functionality.\n */\nexport interface GtmContextValue {\n /** The underlying GTM client instance */\n client: GtmClient;\n /** Push a value to the data layer */\n push: (value: DataLayerValue) => void;\n /** Set consent defaults (must be called before init) */\n setConsentDefaults: (state: ConsentState, options?: ConsentRegionOptions) => void;\n /** Update consent state */\n updateConsent: (state: ConsentState, options?: ConsentRegionOptions) => void;\n /** Synchronously check if all GTM scripts have finished loading */\n isReady: () => boolean;\n /** Returns a promise that resolves when all GTM scripts are loaded */\n whenReady: () => Promise<ScriptLoadState[]>;\n}\n\n/**\n * Consent-specific API subset.\n */\nexport interface GtmConsentApi {\n setConsentDefaults: (state: ConsentState, options?: ConsentRegionOptions) => void;\n updateConsent: (state: ConsentState, options?: ConsentRegionOptions) => void;\n}\n\n/**\n * The GTM context for Remix.\n */\nexport const GtmContext = createContext<GtmContextValue | null>(null);\n\nconst warnOnNestedProvider = (): void => {\n if (process.env.NODE_ENV !== 'production') {\n console.warn(\n '[gtm-kit/remix] Nested GtmProvider detected. You should only have one GtmProvider at the root of your app. ' +\n 'The nested provider will be ignored.'\n );\n }\n};\n\n/**\n * GTM Provider component for Remix.\n * Handles StrictMode correctly and provides GTM context to children.\n *\n * @example\n * ```tsx\n * // app/root.tsx\n * import { GtmProvider } from '@jwiedeman/gtm-kit-remix';\n *\n * export default function App() {\n * return (\n * <html>\n * <head />\n * <body>\n * <GtmProvider config={{ containers: 'GTM-XXXXXX' }}>\n * <Outlet />\n * </GtmProvider>\n * </body>\n * </html>\n * );\n * }\n * ```\n */\nexport function GtmProvider({ config, children, onBeforeInit, onAfterInit }: GtmProviderProps): React.ReactElement {\n // Check for nested provider\n const existingContext = useContext(GtmContext);\n\n // Warn if we're inside another GtmProvider (nested providers)\n useEffect(() => {\n if (existingContext) {\n warnOnNestedProvider();\n }\n }, [existingContext]);\n\n // If nested, just pass through children without creating a new context\n if (existingContext) {\n return <>{children}</>;\n }\n\n return (\n <GtmProviderInner config={config} onBeforeInit={onBeforeInit} onAfterInit={onAfterInit}>\n {children}\n </GtmProviderInner>\n );\n}\n\nfunction GtmProviderInner({ config, children, onBeforeInit, onAfterInit }: GtmProviderProps): React.ReactElement {\n // Create client once and store in ref to survive StrictMode remounts\n const clientRef = useRef<GtmClient | null>(null);\n const initializedRef = useRef(false);\n const teardownTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n // Create client on first render only\n if (!clientRef.current) {\n clientRef.current = createGtmClient(config);\n }\n\n const client = clientRef.current;\n\n // Initialize GTM (handles StrictMode correctly)\n useEffect(() => {\n // Clear any pending teardown from StrictMode unmount/remount cycle\n if (teardownTimerRef.current) {\n clearTimeout(teardownTimerRef.current);\n teardownTimerRef.current = null;\n }\n\n // Skip if already initialized (StrictMode protection)\n if (initializedRef.current) {\n return;\n }\n\n // Call onBeforeInit hook for consent defaults\n if (onBeforeInit) {\n onBeforeInit(client);\n }\n\n // Initialize GTM\n client.init();\n initializedRef.current = true;\n\n // Call onAfterInit hook\n if (onAfterInit) {\n onAfterInit(client);\n }\n\n // Cleanup on unmount - defer to allow StrictMode remount\n return () => {\n teardownTimerRef.current = setTimeout(() => {\n teardownTimerRef.current = null;\n // Only teardown if we're truly unmounting (no provider in DOM)\n if (!document.querySelector('[data-gtm-kit-provider]')) {\n client.teardown();\n clientRef.current = null;\n initializedRef.current = false;\n }\n }, 100);\n };\n }, [client, onBeforeInit, onAfterInit]);\n\n // Clear deferred teardown timer on final unmount to prevent leaked timers\n useEffect(() => {\n return () => {\n if (teardownTimerRef.current) {\n clearTimeout(teardownTimerRef.current);\n teardownTimerRef.current = null;\n }\n };\n }, []);\n\n // Memoize context value\n const contextValue = useMemo<GtmContextValue>(\n () => ({\n client,\n push: (value: DataLayerValue) => client.push(value),\n setConsentDefaults: (state: ConsentState, options?: ConsentRegionOptions) =>\n client.setConsentDefaults(state, options),\n updateConsent: (state: ConsentState, options?: ConsentRegionOptions) => client.updateConsent(state, options),\n isReady: () => client.isReady(),\n whenReady: () => client.whenReady()\n }),\n [client]\n );\n\n return (\n <GtmContext.Provider value={contextValue}>\n <div data-gtm-kit-provider=\"\" style={{ display: 'contents' }}>\n {children}\n </div>\n </GtmContext.Provider>\n );\n}\n\n/**\n * Internal helper to get the GTM context with proper error handling.\n */\nconst useGtmContext = (): GtmContextValue => {\n const context = useContext(GtmContext);\n if (!context) {\n throw new Error(\n '[gtm-kit/remix] useGtm() was called outside of a GtmProvider. ' +\n 'Make sure to wrap your app with <GtmProvider config={{ containers: \"GTM-XXXXXX\" }}>.'\n );\n }\n return context;\n};\n\n/**\n * Hook to access the full GTM context.\n *\n * @example\n * ```tsx\n * import { useGtm } from '@jwiedeman/gtm-kit-remix';\n *\n * function MyComponent() {\n * const { push, client } = useGtm();\n *\n * return (\n * <button onClick={() => push({ event: 'click' })}>\n * Track\n * </button>\n * );\n * }\n * ```\n */\nexport const useGtm = (): GtmContextValue => {\n return useGtmContext();\n};\n\n/**\n * Hook to get just the push function.\n *\n * @example\n * ```tsx\n * import { useGtmPush } from '@jwiedeman/gtm-kit-remix';\n *\n * function BuyButton() {\n * const push = useGtmPush();\n *\n * return (\n * <button onClick={() => push({ event: 'purchase', value: 99 })}>\n * Buy\n * </button>\n * );\n * }\n * ```\n */\nexport const useGtmPush = (): ((value: DataLayerValue) => void) => {\n return useGtmContext().push;\n};\n\n/**\n * Hook to access consent management functions.\n *\n * @example\n * ```tsx\n * import { useGtmConsent } from '@jwiedeman/gtm-kit-remix';\n *\n * function CookieBanner() {\n * const { updateConsent } = useGtmConsent();\n *\n * return (\n * <button onClick={() => updateConsent({ analytics_storage: 'granted' })}>\n * Accept\n * </button>\n * );\n * }\n * ```\n */\nexport const useGtmConsent = (): GtmConsentApi => {\n const { setConsentDefaults, updateConsent } = useGtmContext();\n return { setConsentDefaults, updateConsent };\n};\n\n/**\n * Hook to get the raw GTM client instance.\n *\n * @example\n * ```tsx\n * import { useGtmClient } from '@jwiedeman/gtm-kit-remix';\n *\n * function MyComponent() {\n * const client = useGtmClient();\n * return <div>{client.isInitialized() ? 'Ready' : 'Loading'}</div>;\n * }\n * ```\n */\nexport const useGtmClient = (): GtmClient => {\n return useGtmContext().client;\n};\n\n/**\n * Hook to get the whenReady function.\n *\n * @example\n * ```tsx\n * import { useGtmReady } from '@jwiedeman/gtm-kit-remix';\n * import { useEffect } from 'react';\n *\n * function MyComponent() {\n * const whenReady = useGtmReady();\n *\n * useEffect(() => {\n * whenReady().then(() => console.log('GTM ready!'));\n * }, [whenReady]);\n *\n * return <div>Loading...</div>;\n * }\n * ```\n */\nexport const useGtmReady = (): (() => Promise<ScriptLoadState[]>) => {\n return useGtmContext().whenReady;\n};\n\n/**\n * Hook to check if GTM scripts have finished loading synchronously.\n *\n * @example\n * ```tsx\n * import { useIsGtmReady } from '@jwiedeman/gtm-kit-remix';\n *\n * function MyComponent() {\n * const isReady = useIsGtmReady();\n * return <div>{isReady() ? 'GTM Ready' : 'Loading...'}</div>;\n * }\n * ```\n */\nexport const useIsGtmReady = (): (() => boolean) => {\n return useGtmContext().isReady;\n};\n\n/**\n * Props for GtmErrorBoundary component.\n */\nexport interface GtmErrorBoundaryProps {\n children: ReactNode;\n /** Fallback UI to render when an error occurs */\n fallback?: ReactNode | ((error: Error, reset: () => void) => ReactNode);\n /** Callback invoked when an error is caught */\n onError?: (error: Error, errorInfo: ErrorInfo) => void;\n /** Whether to log errors to console (default: true in development) */\n logErrors?: boolean;\n}\n\ninterface GtmErrorBoundaryState {\n hasError: boolean;\n error: Error | null;\n}\n\n/**\n * Error boundary component for GTM provider in Remix apps.\n * Catches errors during GTM initialization and renders a fallback UI.\n * Analytics and tracking will be disabled when an error occurs.\n *\n * @example\n * ```tsx\n * import { GtmProvider, GtmErrorBoundary } from '@jwiedeman/gtm-kit-remix';\n *\n * export default function App() {\n * return (\n * <GtmErrorBoundary fallback={<div>GTM failed to load</div>}>\n * <GtmProvider config={{ containers: 'GTM-XXXXXX' }}>\n * <Outlet />\n * </GtmProvider>\n * </GtmErrorBoundary>\n * );\n * }\n * ```\n */\nexport class GtmErrorBoundary extends Component<GtmErrorBoundaryProps, GtmErrorBoundaryState> {\n constructor(props: GtmErrorBoundaryProps) {\n super(props);\n this.state = { hasError: false, error: null };\n }\n\n static getDerivedStateFromError(error: Error): GtmErrorBoundaryState {\n return { hasError: true, error };\n }\n\n componentDidCatch(error: Error, errorInfo: ErrorInfo): void {\n const { onError, logErrors = process.env.NODE_ENV !== 'production' } = this.props;\n\n if (logErrors) {\n console.error('[gtm-kit/remix] Error caught by GtmErrorBoundary:', error);\n console.error('[gtm-kit/remix] Component stack:', errorInfo.componentStack);\n }\n\n if (onError) {\n try {\n onError(error, errorInfo);\n } catch {\n // Ignore callback errors\n }\n }\n }\n\n reset = (): void => {\n this.setState({ hasError: false, error: null });\n };\n\n render(): ReactNode {\n const { hasError, error } = this.state;\n const { children, fallback } = this.props;\n\n if (hasError && error) {\n if (fallback === undefined) {\n // Default: render children without GTM (silent fallback)\n return children;\n }\n\n if (typeof fallback === 'function') {\n return fallback(error, this.reset);\n }\n\n return fallback;\n }\n\n return children;\n }\n}\n","import { useEffect, useRef } from 'react';\nimport { useLocation } from '@remix-run/react';\nimport { useGtmPush } from './provider';\n\n/**\n * Options for the useTrackPageViews hook.\n */\nexport interface UseTrackPageViewsOptions {\n /**\n * The event name to use for page view events.\n * @default 'page_view'\n */\n eventName?: string;\n\n /**\n * Whether to track the initial page load.\n * @default true\n */\n trackInitialPageView?: boolean;\n\n /**\n * Custom data to include with each page view event.\n */\n customData?: Record<string, unknown>;\n\n /**\n * Callback to transform the page view event data before pushing.\n * Use this to add custom properties or modify the event.\n */\n transformEvent?: (data: PageViewData) => Record<string, unknown>;\n}\n\n/**\n * Data included with each page view event.\n */\nexport interface PageViewData {\n event: string;\n page_path: string;\n page_search: string;\n page_hash: string;\n page_url: string;\n [key: string]: unknown;\n}\n\n/**\n * Hook to automatically track page views on route changes.\n * Uses Remix's useLocation to detect navigation.\n *\n * @example\n * ```tsx\n * // app/root.tsx\n * import { GtmProvider, useTrackPageViews } from '@jwiedeman/gtm-kit-remix';\n *\n * function PageViewTracker() {\n * useTrackPageViews();\n * return null;\n * }\n *\n * export default function App() {\n * return (\n * <GtmProvider config={{ containers: 'GTM-XXXXXX' }}>\n * <PageViewTracker />\n * <Outlet />\n * </GtmProvider>\n * );\n * }\n * ```\n *\n * @example With custom options\n * ```tsx\n * useTrackPageViews({\n * eventName: 'virtual_page_view',\n * customData: { app_version: '1.0.0' },\n * transformEvent: (data) => ({\n * ...data,\n * user_id: getCurrentUserId()\n * })\n * });\n * ```\n */\nexport function useTrackPageViews(options: UseTrackPageViewsOptions = {}): void {\n const { eventName = 'page_view', trackInitialPageView = true, customData = {}, transformEvent } = options;\n\n const location = useLocation();\n const push = useGtmPush();\n const lastPathRef = useRef<string | null>(null);\n const isFirstRenderRef = useRef(true);\n\n useEffect(() => {\n const currentPath = location.pathname + location.search + location.hash;\n\n // Skip if this is the same path (prevents double-firing)\n if (currentPath === lastPathRef.current) {\n return;\n }\n\n // Skip initial page view if configured\n if (isFirstRenderRef.current && !trackInitialPageView) {\n isFirstRenderRef.current = false;\n lastPathRef.current = currentPath;\n return;\n }\n\n isFirstRenderRef.current = false;\n lastPathRef.current = currentPath;\n\n // Build page view data\n const pageViewData: PageViewData = {\n event: eventName,\n page_path: location.pathname,\n page_search: location.search,\n page_hash: location.hash,\n page_url: typeof window !== 'undefined' ? window.location.href : currentPath,\n ...customData\n };\n\n // Apply transform if provided\n const eventData = transformEvent ? transformEvent(pageViewData) : pageViewData;\n\n // Push to GTM\n push(eventData);\n }, [location, push, eventName, trackInitialPageView, customData, transformEvent]);\n}\n","import React from 'react';\nimport { createNoscriptMarkup, type ContainerConfigInput, type ContainerDescriptor } from '@jwiedeman/gtm-kit';\n\n/**\n * Escape a string for safe use in JavaScript string literals.\n * Prevents XSS when interpolating user-provided values into inline scripts.\n */\nfunction escapeJsString(value: string): string {\n return value\n .replace(/\\\\/g, '\\\\\\\\')\n .replace(/'/g, \"\\\\'\")\n .replace(/\"/g, '\\\\\"')\n .replace(/\\n/g, '\\\\n')\n .replace(/\\r/g, '\\\\r')\n .replace(/</g, '\\\\x3c')\n .replace(/>/g, '\\\\x3e')\n .replace(/\\u2028/g, '\\\\u2028')\n .replace(/\\u2029/g, '\\\\u2029');\n}\n\n/**\n * Props for the GtmScripts component.\n */\nexport interface GtmScriptsProps {\n /**\n * GTM container ID(s).\n */\n containers: ContainerConfigInput | ContainerConfigInput[];\n\n /**\n * Custom GTM host URL.\n * @default 'https://www.googletagmanager.com'\n */\n host?: string;\n\n /**\n * Custom dataLayer name.\n * @default 'dataLayer'\n */\n dataLayerName?: string;\n\n /**\n * Script attributes (e.g., nonce for CSP).\n */\n scriptAttributes?: Record<string, string>;\n}\n\n/**\n * Normalize container config to array format.\n */\nfunction normalizeContainers(containers: ContainerConfigInput | ContainerConfigInput[]): ContainerDescriptor[] {\n if (typeof containers === 'string') {\n return [{ id: containers }];\n }\n if (!Array.isArray(containers)) {\n return [containers];\n }\n return containers.map((c) => (typeof c === 'string' ? { id: c } : c));\n}\n\n/**\n * Server component that renders GTM script tags for Remix.\n * Use this in your root.tsx to add GTM scripts.\n *\n * @example\n * ```tsx\n * // app/root.tsx\n * import { GtmScripts } from '@jwiedeman/gtm-kit-remix';\n *\n * export default function App() {\n * return (\n * <html>\n * <head>\n * <GtmScripts containers=\"GTM-XXXXXX\" />\n * </head>\n * <body>\n * <Outlet />\n * </body>\n * </html>\n * );\n * }\n * ```\n *\n * @example With CSP nonce\n * ```tsx\n * <GtmScripts\n * containers=\"GTM-XXXXXX\"\n * scriptAttributes={{ nonce: 'your-csp-nonce' }}\n * />\n * ```\n */\n/**\n * Build the GTM script URL for a container.\n */\nfunction buildGtmScriptUrl(\n containerId: string,\n host: string,\n dataLayerName: string,\n queryParams?: Record<string, string | number | boolean>\n): string {\n const normalizedHost = host.endsWith('/') ? host.slice(0, -1) : host;\n const params = new URLSearchParams();\n params.set('id', containerId);\n\n if (dataLayerName !== 'dataLayer') {\n params.set('l', dataLayerName);\n }\n\n if (queryParams) {\n for (const [key, value] of Object.entries(queryParams)) {\n if (key !== 'id' && key !== 'l') {\n params.set(key, String(value));\n }\n }\n }\n\n return `${normalizedHost}/gtm.js?${params.toString()}`;\n}\n\nexport function GtmScripts({\n containers,\n host = 'https://www.googletagmanager.com',\n dataLayerName = 'dataLayer',\n scriptAttributes = {}\n}: GtmScriptsProps): React.ReactElement {\n // Validate dataLayerName is a valid JavaScript identifier\n if (!/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(dataLayerName)) {\n throw new Error(\n `[gtm-kit/remix] Invalid dataLayerName \"${dataLayerName}\". Must be a valid JavaScript identifier.`\n );\n }\n\n const containerConfigs = normalizeContainers(containers);\n\n // Filter out event handler attributes to prevent XSS\n const safeScriptAttributes: Record<string, string> = {};\n for (const [key, value] of Object.entries(scriptAttributes)) {\n if (!key.toLowerCase().startsWith('on')) {\n safeScriptAttributes[key] = value;\n }\n }\n\n // Escape values for safe use in JavaScript string literals\n const safeDataLayerName = escapeJsString(dataLayerName);\n const safeNonce = safeScriptAttributes.nonce ? escapeJsString(safeScriptAttributes.nonce) : '';\n\n // Generate inline script for dataLayer initialization and GTM loading\n const inlineScript = `\n window['${safeDataLayerName}'] = window['${safeDataLayerName}'] || [];\n ${containerConfigs\n .map((config) => {\n const safeContainerId = escapeJsString(config.id);\n const scriptSrc = escapeJsString(buildGtmScriptUrl(config.id, host, dataLayerName, config.queryParams));\n return `\n (function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':\n new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],\n j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=\n '${scriptSrc}';${safeNonce ? `j.nonce='${safeNonce}';` : ''}f.parentNode.insertBefore(j,f);\n })(window,document,'script','${safeDataLayerName}','${safeContainerId}');\n `;\n })\n .join('\\n')}\n `.trim();\n\n // Generate noscript HTML using the core package\n const noscriptHtml = createNoscriptMarkup(containerConfigs, { host });\n\n return (\n <>\n <script {...safeScriptAttributes} dangerouslySetInnerHTML={{ __html: inlineScript }} />\n <noscript dangerouslySetInnerHTML={{ __html: noscriptHtml }} />\n </>\n );\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jwiedeman/gtm-kit-remix",
3
- "version": "1.2.0",
3
+ "version": "1.2.1",
4
4
  "description": "Remix adapter for GTM Kit - Google Tag Manager integration with route tracking and streaming SSR.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -49,7 +49,7 @@
49
49
  "typecheck": "tsc --noEmit"
50
50
  },
51
51
  "dependencies": {
52
- "@jwiedeman/gtm-kit": "^1.2.0"
52
+ "@jwiedeman/gtm-kit": "^1.2.1"
53
53
  },
54
54
  "peerDependencies": {
55
55
  "@remix-run/react": "^2.0.0",