From 311578d634ec2f137d3931d058c9f57bd0b86c1d Mon Sep 17 00:00:00 2001 From: Nolann Biron Date: Wed, 19 Aug 2026 17:30:55 +0200 Subject: [PATCH 1/2] Only track embed view events once the frame is shown (RND-12362) --- packages/gitbook/e2e/internal.spec.ts | 50 ++++++++++++++++++- .../Embeddable/EmbeddableAIChat.tsx | 10 +++- .../Embeddable/EmbeddableRootLayout.tsx | 5 +- .../Embeddable/EmbeddableSearch.tsx | 8 ++- .../Insights/TrackPageViewEvent.tsx | 9 +++- .../VisibilityContext/VisibilityContext.tsx | 48 ++++++++++++++++++ .../src/components/VisibilityContext/index.ts | 1 + 7 files changed, 124 insertions(+), 7 deletions(-) create mode 100644 packages/gitbook/src/components/VisibilityContext/VisibilityContext.tsx create mode 100644 packages/gitbook/src/components/VisibilityContext/index.ts diff --git a/packages/gitbook/e2e/internal.spec.ts b/packages/gitbook/e2e/internal.spec.ts index e7322e3d17..25780416c7 100644 --- a/packages/gitbook/e2e/internal.spec.ts +++ b/packages/gitbook/e2e/internal.spec.ts @@ -1,4 +1,4 @@ -import { expect } from '@playwright/test'; +import { type Page, expect } from '@playwright/test'; import jwt from 'jsonwebtoken'; import { @@ -52,6 +52,27 @@ const AI_PROMPT = [ '4. Always end by proposing exactly 3 follow-up suggestions.', ].join('\n'); +// `InsightsProvider` debounces its flushes by 1.5s. +const INSIGHTS_FLUSH_TIMEOUT = 3000; + +/** + * Collect the insights events of a given type sent by the page and its frames. + */ +function trackInsightsEvents(page: Page, type: string) { + const collected: { type: string }[] = []; + + page.on('request', (request) => { + if (request.method() !== 'POST' || !request.url().includes('/~gitbook/__evt')) { + return; + } + + const body = request.postDataJSON() as { events?: { type: string }[] } | null; + collected.push(...(body?.events ?? []).filter((event) => event.type === type)); + }); + + return collected; +} + const overrideAIInitialState = () => { const greeting = document.querySelector('[data-testid="ai-chat-greeting-title"]'); if (greeting) { @@ -2261,6 +2282,33 @@ const testCases: TestsCase[] = [ ); }, }, + { + name: 'Only tracks ask_view once the widget is opened', + // `trigger=custom` loads the frame but leaves the window closed. + url: '?trigger=custom', + screenshot: false, + run: async (page) => { + const askViews = trackInsightsEvents(page, 'ask_view'); + const chat = page.frameLocator('#gitbook-widget-iframe').getByTestId('ai-chat'); + + // The assistant renders inside the hidden frame, but nobody has seen it. + await expect(chat).toBeAttached({ timeout: 20000 }); + await page.waitForTimeout(INSIGHTS_FLUSH_TIMEOUT); + expect(askViews).toHaveLength(0); + + await page.getByRole('button', { name: 'Open' }).click(); + await expect(chat).toBeVisible(); + await expect.poll(() => askViews.length, { timeout: 20000 }).toBe(1); + + // Hiding and showing the same frame again is not a second view. + await page.getByRole('button', { name: 'Close' }).click(); + await expect(chat).toBeHidden(); + await page.getByRole('button', { name: 'Open' }).click(); + await expect(chat).toBeVisible(); + await page.waitForTimeout(INSIGHTS_FLUSH_TIMEOUT); + expect(askViews).toHaveLength(1); + }, + }, ], }, { diff --git a/packages/gitbook/src/components/Embeddable/EmbeddableAIChat.tsx b/packages/gitbook/src/components/Embeddable/EmbeddableAIChat.tsx index cad5248774..c05f45dd10 100644 --- a/packages/gitbook/src/components/Embeddable/EmbeddableAIChat.tsx +++ b/packages/gitbook/src/components/Embeddable/EmbeddableAIChat.tsx @@ -6,6 +6,7 @@ import * as api from '@gitbook/api'; import { useTrackEvent } from '../Insights'; import { LinkContext } from '../primitives'; +import { useIsVisible } from '../VisibilityContext'; import { EmbeddableFrame, EmbeddableFrameBody, @@ -51,9 +52,14 @@ export function EmbeddableAIChat(props: EmbeddableAIChatProps) { chatController.open(); }, [chatController]); - // Track the view of the AI chat + // Track the view of the AI chat, once the reader is actually shown the frame const trackEvent = useTrackEvent(); + const isVisible = useIsVisible(); React.useEffect(() => { + if (!isVisible) { + return; + } + trackEvent( { type: 'ask_view', @@ -63,7 +69,7 @@ export function EmbeddableAIChat(props: EmbeddableAIChatProps) { displayContext: api.SiteInsightsDisplayContext.Embed, } ); - }, [trackEvent]); + }, [trackEvent, isVisible]); const tabsRef = React.useRef(null); const trademark = siteConfig.trademark; diff --git a/packages/gitbook/src/components/Embeddable/EmbeddableRootLayout.tsx b/packages/gitbook/src/components/Embeddable/EmbeddableRootLayout.tsx index eee22afcc5..eb6f93507e 100644 --- a/packages/gitbook/src/components/Embeddable/EmbeddableRootLayout.tsx +++ b/packages/gitbook/src/components/Embeddable/EmbeddableRootLayout.tsx @@ -4,6 +4,7 @@ import { SiteInsightsTrademarkPlacement } from '@gitbook/api'; import { NavigationLoader } from '../primitives/NavigationLoader'; import { SpaceLayoutServerContext } from '../SpaceLayout'; import { Trademark } from '../TableOfContents/Trademark'; +import { VisibilityProvider } from '../VisibilityContext'; import { EmbeddableAIContextProvider } from './EmbeddableAIContextProvider'; import { EmbeddableIframeAPI } from './EmbeddableIframeAPI'; import { EmbeddableThemeSync } from './EmbeddableThemeSync'; @@ -74,7 +75,7 @@ export async function EmbeddableRootLayout({ }} > -
+ {children} {context.customization.trademark.enabled ? ( @@ -85,7 +86,7 @@ export async function EmbeddableRootLayout({ /> ) : null} -
+ diff --git a/packages/gitbook/src/components/Embeddable/EmbeddableSearch.tsx b/packages/gitbook/src/components/Embeddable/EmbeddableSearch.tsx index 711eafe60e..0f1f866ec6 100644 --- a/packages/gitbook/src/components/Embeddable/EmbeddableSearch.tsx +++ b/packages/gitbook/src/components/Embeddable/EmbeddableSearch.tsx @@ -4,6 +4,7 @@ import React from 'react'; import { useTrackEvent } from '../Insights'; import { LinkContext } from '../primitives'; +import { useIsVisible } from '../VisibilityContext'; import { EmbeddableIframeButtons, EmbeddableIframeCloseButton, @@ -30,11 +31,16 @@ export function EmbeddableSearch(props: EmbeddableSearchProps) { const { hasDocsTab, linkContext } = useEmbeddableLinkContext(); const trackEvent = useTrackEvent(); + const isVisible = useIsVisible(); React.useEffect(() => { + if (!isVisible) { + return; + } + trackEvent({ type: 'search_open', }); - }, [trackEvent]); + }, [trackEvent, isVisible]); const tabsRef = React.useRef(null); const { diff --git a/packages/gitbook/src/components/Insights/TrackPageViewEvent.tsx b/packages/gitbook/src/components/Insights/TrackPageViewEvent.tsx index 310ab48364..1b7f59da97 100644 --- a/packages/gitbook/src/components/Insights/TrackPageViewEvent.tsx +++ b/packages/gitbook/src/components/Insights/TrackPageViewEvent.tsx @@ -5,6 +5,7 @@ import * as React from 'react'; import type { SiteInsightsDisplayContext } from '@gitbook/api'; import { useCurrentPage } from '../hooks'; +import { useIsVisible } from '../VisibilityContext'; import { useTrackEvent } from './InsightsProvider'; /** @@ -14,8 +15,14 @@ export function TrackPageViewEvent(props: { displayContext: SiteInsightsDisplayC const { displayContext } = props; const page = useCurrentPage(); const trackEvent = useTrackEvent(); + // Always true outside of the embed, whose frame can be loaded while hidden. + const isVisible = useIsVisible(); React.useEffect(() => { + if (!isVisible) { + return; + } + trackEvent( { type: 'page_view', @@ -25,7 +32,7 @@ export function TrackPageViewEvent(props: { displayContext: SiteInsightsDisplayC displayContext, } ); - }, [page, trackEvent, displayContext]); + }, [page, trackEvent, displayContext, isVisible]); return null; } diff --git a/packages/gitbook/src/components/VisibilityContext/VisibilityContext.tsx b/packages/gitbook/src/components/VisibilityContext/VisibilityContext.tsx new file mode 100644 index 0000000000..68270af280 --- /dev/null +++ b/packages/gitbook/src/components/VisibilityContext/VisibilityContext.tsx @@ -0,0 +1,48 @@ +'use client'; + +import React from 'react'; + +import { useInViewportListener } from '../hooks/useInViewportListener'; + +// Dwell before the content counts as seen. In the embed it also absorbs the initial +// `/assistant` render on frames configured without that tab, as `configure` arrives later. +const VISIBLE_DELAY_MS = 500; + +// Without a provider the content is always considered visible. +const VisibilityContext = React.createContext(true); + +// An iframe can be loaded while its host keeps it hidden, and a non-rendered iframe has a +// zero-sized viewport, which keeps the observer below non-intersecting until it is shown. +export function VisibilityProvider(props: { className: string; children: React.ReactNode }) { + const { className, children } = props; + + const ref = React.useRef(null); + const [visible, setVisible] = React.useState(false); + + const [inViewport, setInViewport] = React.useState(false); + useInViewportListener(ref, (isIntersecting) => setInViewport(isIntersecting)); + + // Latched: an observer inside an iframe also reports the host page scrolling it out of + // view, and scrolling past an inline embed is not a new view. Remounting is. + React.useEffect(() => { + if (!inViewport || visible) { + return; + } + + const timeout = setTimeout(() => setVisible(true), VISIBLE_DELAY_MS); + return () => clearTimeout(timeout); + }, [inViewport, visible]); + + return ( + +
+ {children} +
+
+ ); +} + +// Always true outside of a `VisibilityProvider`. +export function useIsVisible() { + return React.use(VisibilityContext); +} diff --git a/packages/gitbook/src/components/VisibilityContext/index.ts b/packages/gitbook/src/components/VisibilityContext/index.ts new file mode 100644 index 0000000000..f7375a381a --- /dev/null +++ b/packages/gitbook/src/components/VisibilityContext/index.ts @@ -0,0 +1 @@ +export * from './VisibilityContext'; From 0620615d43020721e8c166bc83a27f9a70087b49 Mon Sep 17 00:00:00 2001 From: Nolann Biron Date: Wed, 19 Aug 2026 17:31:00 +0200 Subject: [PATCH 2/2] changeset --- .changeset/wet-cases-invite.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/wet-cases-invite.md diff --git a/.changeset/wet-cases-invite.md b/.changeset/wet-cases-invite.md new file mode 100644 index 0000000000..a625ea6069 --- /dev/null +++ b/.changeset/wet-cases-invite.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Only track embed view events once the frame is actually shown to the reader