diff --git a/packages/gitbook/src/components/Integrations/LoadIntegrations.test.ts b/packages/gitbook/src/components/Integrations/LoadIntegrations.test.ts new file mode 100644 index 000000000..851bbcd91 --- /dev/null +++ b/packages/gitbook/src/components/Integrations/LoadIntegrations.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'bun:test'; + +import { filterScriptsByConsent } from './scripts'; + +describe('filterScriptsByConsent', () => { + const scripts = [ + { script: 'https://example.com/no-consent.js', cookies: false }, + { script: 'https://example.com/consent-required.js', cookies: true }, + { script: 'https://example.com/default.js' }, + ]; + + it('loads only non-cookie scripts without explicit consent', () => { + expect(filterScriptsByConsent(scripts, false)).toEqual([ + { script: 'https://example.com/no-consent.js', cookies: false }, + { script: 'https://example.com/default.js' }, + ]); + }); + + it('loads cookie-dependent scripts after explicit consent', () => { + expect(filterScriptsByConsent(scripts, true)).toEqual(scripts); + }); +}); diff --git a/packages/gitbook/src/components/Integrations/LoadIntegrations.tsx b/packages/gitbook/src/components/Integrations/LoadIntegrations.tsx index 4910c1498..e8c2d1d37 100644 --- a/packages/gitbook/src/components/Integrations/LoadIntegrations.tsx +++ b/packages/gitbook/src/components/Integrations/LoadIntegrations.tsx @@ -15,6 +15,7 @@ import type { import * as React from 'react'; import * as zustand from 'zustand'; import type { Assistant } from '../AI'; +import { filterScriptsByConsent, type IntegrationScript } from './scripts'; const events = new Map(); @@ -140,19 +141,77 @@ export function useCustomCookieBanner(): CustomCookieBannerStore { } /** - * Dispatch the `load` event to all integrations. + * Dispatch the `load` event after all eligible integrations have been loaded. */ -export function LoadIntegrations() { +export function LoadIntegrations(props: { scripts: IntegrationScript[] }) { + const { scripts } = props; + const [hasExplicitCookieConsent, setHasExplicitCookieConsent] = React.useState(false); + const loadedScriptsRef = React.useRef(new Set()); + const hasInitializedRef = React.useRef(false); + React.useEffect(() => { - // Only dispatch 'load' event when there are scripts to load - - dispatchGitBookIntegrationEvent('load'); - - integrationsStore.setState({ loaded: true }); + setHasExplicitCookieConsent(isCookiesTrackingDisabled() === false); }, []); + + React.useEffect(() => { + const eligibleScripts = filterScriptsByConsent(scripts, hasExplicitCookieConsent); + const pendingScripts = eligibleScripts.filter( + ({ script }) => !loadedScriptsRef.current.has(script) + ); + + if (pendingScripts.length === 0) { + if (!hasInitializedRef.current) { + dispatchGitBookIntegrationEvent('load'); + integrationsStore.setState({ loaded: true }); + hasInitializedRef.current = true; + } + return; + } + + let cancelled = false; + + Promise.all( + pendingScripts.map(({ script }) => + loadIntegrationScript(script).finally(() => { + loadedScriptsRef.current.add(script); + }) + ) + ).then(() => { + if (cancelled) { + return; + } + + dispatchGitBookIntegrationEvent('load'); + + if (!hasInitializedRef.current) { + integrationsStore.setState({ loaded: true }); + hasInitializedRef.current = true; + } + }); + + return () => { + cancelled = true; + }; + }, [hasExplicitCookieConsent, scripts]); + return null; } +function loadIntegrationScript(src: string): Promise { + if (document.querySelector(`script[src="${CSS.escape(src)}"]`)) { + return Promise.resolve(); + } + + return new Promise((resolve) => { + const element = document.createElement('script'); + element.async = true; + element.src = src; + element.onload = () => resolve(); + element.onerror = () => resolve(); + document.body.appendChild(element); + }); +} + /** * Client function to dispatch a GitBook event. */ diff --git a/packages/gitbook/src/components/Integrations/scripts.ts b/packages/gitbook/src/components/Integrations/scripts.ts new file mode 100644 index 000000000..c9d8c67c1 --- /dev/null +++ b/packages/gitbook/src/components/Integrations/scripts.ts @@ -0,0 +1,14 @@ +export type IntegrationScript = { + script: string; + cookies?: boolean; +}; + +/** + * Keep only the scripts that are currently allowed to run. + */ +export function filterScriptsByConsent( + scripts: IntegrationScript[], + hasExplicitCookieConsent: boolean +): IntegrationScript[] { + return scripts.filter((script) => !script.cookies || hasExplicitCookieConsent); +} diff --git a/packages/gitbook/src/components/SiteLayout/SiteLayout.tsx b/packages/gitbook/src/components/SiteLayout/SiteLayout.tsx index 723dcba29..b5d799937 100644 --- a/packages/gitbook/src/components/SiteLayout/SiteLayout.tsx +++ b/packages/gitbook/src/components/SiteLayout/SiteLayout.tsx @@ -32,6 +32,7 @@ export async function SiteLayout(props: { const { customization } = context; // Scripts are disabled when tracking is disabled const scripts = withTracking ? context.scripts : []; + const preloadableScripts = scripts.filter((script) => !script.cookies); ReactDOM.preconnect(GITBOOK_API_PUBLIC_URL); ReactDOM.preconnect(GITBOOK_ICONS_URL); @@ -39,7 +40,7 @@ export async function SiteLayout(props: { ReactDOM.preconnect(GITBOOK_ASSETS_URL); } - scripts.forEach(({ script }) => { + preloadableScripts.forEach(({ script }) => { ReactDOM.preload(script, { as: 'script', }); @@ -70,10 +71,7 @@ export async function SiteLayout(props: { - - {scripts.length > 0 - ? scripts.map(({ script }) =>