Compare commits

...

3 Commits

Author SHA1 Message Date
Samy Pessé 295a7fab34 Changeset 2025-09-14 18:04:52 +02:00
Samy Pessé 3a23005020 Fix it 2025-09-14 12:58:12 +02:00
Samy Pessé 0b64e40d9f Expose hook useVisitorSession 2025-09-14 12:39:32 +02:00
5 changed files with 160 additions and 109 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Expose a hook useVisitorSession to access the current user/visitor
@@ -8,7 +8,7 @@ import { useDebounceCallback, useEventCallback } from 'usehooks-ts';
import { getAllBrowserCookiesMap } from '@/lib/browser';
import { type CurrentContentContext, useCurrentContent } from '../hooks';
import { getSession } from './sessions';
import { getVisitorId } from './visitorId';
import { type SessionResponse, useVisitorSession } from './visitorId';
export type InsightsEventName = api.SiteInsightsEvent['type'];
@@ -55,12 +55,6 @@ interface InsightsProviderProps {
/** If true, the events will be sent to the server. */
enabled: boolean;
/** If true, the visitor cookie tracking will be used */
visitorCookieTrackingEnabled: boolean;
/** The application URL. */
appURL: string;
/** The url of the endpoint to send events to */
eventUrl: string;
@@ -72,10 +66,10 @@ interface InsightsProviderProps {
* Wrap the content of the app with the InsightsProvider to track events.
*/
export function InsightsProvider(props: InsightsProviderProps) {
const { enabled, children, visitorCookieTrackingEnabled, eventUrl, appURL } = props;
const { enabled, children, eventUrl } = props;
const visitorSession = useVisitorSession();
const currentContent = useCurrentContent();
const visitorIdRef = React.useRef<string | null>(null);
const eventsRef = React.useRef<{
[pathname: string]:
| {
@@ -92,9 +86,8 @@ export function InsightsProvider(props: InsightsProviderProps) {
*/
const flushEventsSync = useEventCallback(() => {
const session = getSession();
const visitorId = visitorIdRef.current;
if (!visitorId) {
throw new Error('Visitor ID should be set before flushing events');
if (!visitorSession) {
return;
}
const allEvents: api.SiteInsightsEvent[] = [];
@@ -114,7 +107,7 @@ export function InsightsProvider(props: InsightsProviderProps) {
events: eventsForPathname.events,
context: currentContent,
pageContext: eventsForPathname.pageContext,
visitorId,
visitorSession,
sessionId: session.id,
})
);
@@ -137,14 +130,17 @@ export function InsightsProvider(props: InsightsProviderProps) {
}
});
const flushBatchedEvents = useDebounceCallback(async () => {
const visitorId =
visitorIdRef.current ?? (await getVisitorId(appURL, visitorCookieTrackingEnabled));
visitorIdRef.current = visitorId;
const flushBatchedEvents = useDebounceCallback(() => {
flushEventsSync();
}, 1500);
// Flush events once the visitor session is set
React.useEffect(() => {
if (visitorSession) {
flushEventsSync();
}
}, [visitorSession, flushEventsSync]);
const trackEvent: TrackEventCallback = useEventCallback(
(
event: TrackEventInput<InsightsEventName>,
@@ -169,7 +165,7 @@ export function InsightsProvider(props: InsightsProviderProps) {
if (eventsRef.current[pathname].pageContext !== undefined) {
// If the pageId is set, we know that the page_view event has been tracked
// and we can flush the events
if (options?.immediate && visitorIdRef.current) {
if (options?.immediate) {
flushBatchedEvents.cancel();
flushEventsSync();
} else {
@@ -180,18 +176,14 @@ export function InsightsProvider(props: InsightsProviderProps) {
);
/**
* Get the visitor ID and store it in a ref.
* When the page is unloaded, flush all events.
*/
React.useEffect(() => {
getVisitorId(appURL, visitorCookieTrackingEnabled).then((visitorId) => {
visitorIdRef.current = visitorId;
// When the page is unloaded, flush all events, but only if the visitor ID is set
window.addEventListener('beforeunload', flushEventsSync);
});
window.addEventListener('beforeunload', flushEventsSync);
return () => {
window.removeEventListener('beforeunload', flushEventsSync);
};
}, [flushEventsSync, visitorCookieTrackingEnabled, appURL]);
}, [flushEventsSync]);
return (
<InsightsContext.Provider value={trackEvent}>
@@ -245,12 +237,12 @@ function transformEvents(input: {
events: TrackEventInput<InsightsEventName>[];
context: CurrentContentContext;
pageContext: InsightsEventPageContext;
visitorId: string;
visitorSession: SessionResponse;
sessionId: string;
}): api.SiteInsightsEvent[] {
const session: api.SiteInsightsEventSession = {
sessionId: input.sessionId,
visitorId: input.visitorId,
visitorId: input.visitorSession.deviceId,
userAgent: window.navigator.userAgent,
language: window.navigator.language,
cookies: getAllBrowserCookiesMap(),
@@ -1,73 +0,0 @@
'use client';
import { getBrowserCookie } from '@/lib/browser';
import { isCookiesTrackingDisabled } from './cookies';
import { generateRandomId } from './utils';
const VISITORID_COOKIE = '__session';
let visitorId: string | null = null;
let pendingVisitorId: Promise<string> | null = null;
/**
* Return the current visitor identifier.
*/
export async function getVisitorId(
appURL: string,
visitorCookieTrackingEnabled: boolean
): Promise<string> {
if (!visitorId) {
if (!pendingVisitorId) {
pendingVisitorId = fetchVisitorID(appURL, visitorCookieTrackingEnabled).finally(() => {
pendingVisitorId = null;
});
}
visitorId = await pendingVisitorId;
}
return visitorId;
}
/**
* Propose a visitor identifier to the GitBook.com server and get the devideId back.
*/
async function fetchVisitorID(
appURL: string,
visitorCookieTrackingEnabled: boolean
): Promise<string> {
const withoutCookies = isCookiesTrackingDisabled();
if (withoutCookies || !visitorCookieTrackingEnabled) {
return generateRandomId();
}
const existingTrackingCookie = getBrowserCookie(VISITORID_COOKIE);
if (existingTrackingCookie) {
// If the cookie already exists, we'll just use that. Avoids a server request.
return existingTrackingCookie;
}
// No tracking deviceId set, we'll need to consolidate with the server.
const proposed = generateRandomId();
const url = new URL(appURL);
url.pathname = '/__session/2/';
url.searchParams.set('proposed', proposed);
try {
const resp = await fetch(url, {
method: 'GET', // Use GET to play nicely with SameSite cookies.
credentials: 'include', // Make sure to send/receive cookies.
cache: 'no-cache',
mode: 'cors', // Need to use cors as we are on a different domain.
});
const { deviceId } = (await resp.json()) as { deviceId: string };
return deviceId;
} catch (error) {
console.error('Failed to fetch visitor session ID', error);
return proposed;
}
}
@@ -0,0 +1,127 @@
'use client';
import { createStore, useStore } from 'zustand';
import { getBrowserCookie } from '@/lib/browser';
import React from 'react';
import { isCookiesTrackingDisabled } from './cookies';
import { generateRandomId } from './utils';
const VISITORID_COOKIE = '__session';
type SessionVisitorResponse = {
deviceId: string;
};
type SessionUserResponse = SessionVisitorResponse & {
userId: string;
organizationId: string;
};
export type SessionResponse = SessionVisitorResponse | SessionUserResponse;
const visitorSessionStore = createStore<{
session: SessionResponse | null;
pendingSession: Promise<SessionResponse> | null;
}>(() => ({
session: null,
pendingSession: null,
}));
/**
* Fetch and provide the visitor session.
*/
export function VisitorSessionProvider(
props: React.PropsWithChildren<{
appURL: string;
visitorCookieTrackingEnabled: boolean;
}>
) {
const { appURL, visitorCookieTrackingEnabled, children } = props;
React.useEffect(() => {
const state = visitorSessionStore.getState();
if (state.pendingSession || state.session) {
return;
}
const pendingSession = fetchSession({ appURL, visitorCookieTrackingEnabled });
visitorSessionStore.setState({ pendingSession, session: null });
pendingSession.then((session) => {
visitorSessionStore.setState({ pendingSession: null, session });
});
}, [appURL, visitorCookieTrackingEnabled]);
return <>{children}</>;
}
/**
* Hook to get the current visitor session.
*/
export function useVisitorSession() {
return useStore(visitorSessionStore, (state) => state.session);
}
/**
* Propose a visitor identifier to the GitBook.com server and get the devideId back.
*/
async function fetchSession({
appURL,
visitorCookieTrackingEnabled,
}: {
appURL: string;
visitorCookieTrackingEnabled: boolean;
}): Promise<SessionResponse> {
const withoutCookies = isCookiesTrackingDisabled();
if (withoutCookies || !visitorCookieTrackingEnabled) {
return {
deviceId: generateRandomId(),
};
}
const existingTrackingCookie = getSessionCookie();
if (existingTrackingCookie) {
// If the cookie already exists, we'll just use that. Avoids a server request.
return existingTrackingCookie;
}
// No tracking deviceId set, we'll need to consolidate with the server.
const proposed = generateRandomId();
const url = new URL(appURL);
url.pathname = '/__session/2/';
url.searchParams.set('proposed', proposed);
try {
const resp = await fetch(url, {
method: 'GET', // Use GET to play nicely with SameSite cookies.
credentials: 'include', // Make sure to send/receive cookies.
cache: 'no-cache',
mode: 'cors', // Need to use cors as we are on a different domain.
});
const session = (await resp.json()) as SessionResponse;
return session;
} catch (error) {
console.error('Failed to fetch visitor session ID', error);
return {
deviceId: proposed,
};
}
}
function getSessionCookie(): SessionResponse | null {
const value = getBrowserCookie(VISITORID_COOKIE);
if (!(value && typeof value === 'string')) {
return null;
}
try {
const parsed = JSON.parse(value) as SessionResponse;
return parsed;
} catch {
return { deviceId: value };
}
}
@@ -21,7 +21,7 @@ import { AIChat } from '../AIChat';
import { AdaptiveVisitorContextProvider } from '../Adaptive';
import { Announcement } from '../Announcement';
import { SpacesDropdown, TranslationsDropdown } from '../Header/SpacesDropdown';
import { InsightsProvider } from '../Insights';
import { InsightsProvider, VisitorSessionProvider } from '../Insights';
import { SearchContainer } from '../Search';
import { SiteSectionList, encodeClientSiteSections } from '../SiteSections';
import { CurrentContentProvider } from '../hooks';
@@ -78,16 +78,16 @@ export function SpaceLayoutServerContext(props: SpaceLayoutProps) {
revisionId={context.revisionId}
visitorAuthClaims={visitorAuthClaims}
>
<InsightsProvider
enabled={withTracking}
<VisitorSessionProvider
appURL={GITBOOK_APP_URL}
eventUrl={eventUrl.toString()}
visitorCookieTrackingEnabled={customization.insights?.trackingCookie}
>
<AIChatProvider renderMessageOptions={aiChatRenderMessageOptions}>
{children}
</AIChatProvider>
</InsightsProvider>
<InsightsProvider enabled={withTracking} eventUrl={eventUrl.toString()}>
<AIChatProvider renderMessageOptions={aiChatRenderMessageOptions}>
{children}
</AIChatProvider>
</InsightsProvider>
</VisitorSessionProvider>
</CurrentContentProvider>
</AdaptiveVisitorContextProvider>
</SpaceLayoutContextProvider>