diff --git a/.changeset/fancy-webs-sneeze.md b/.changeset/fancy-webs-sneeze.md new file mode 100644 index 000000000..54ccc16cc --- /dev/null +++ b/.changeset/fancy-webs-sneeze.md @@ -0,0 +1,7 @@ +--- +"gitbook": patch +--- + +Introduce client-side content selection (`select`): a site-wide, recency-ordered list of selected slugs, persisted in localStorage and shareable via `?select=`, applied to `` before first paint so the right variant renders with no flash. All variants stay server-rendered, so pages are byte-identical for every visitor (no cache impact). + +Tabs now use it: switching a tab activates its slug, and every tab group offering that slug follows, across pages. Tabs no longer write to the URL fragment (`#` returns to anchors only); deep-links into a tab still activate and scroll to it. diff --git a/packages/gitbook/e2e/select.spec.ts b/packages/gitbook/e2e/select.spec.ts new file mode 100644 index 000000000..1417f87e0 --- /dev/null +++ b/packages/gitbook/e2e/select.spec.ts @@ -0,0 +1,238 @@ +import { type Page, expect, test } from '@playwright/test'; + +// Import the specific modules (not the package barrel) so this stays free of the `@/` path alias +// that the store pulls in — Playwright's loader doesn't resolve it. +import { SELECT_LIST_CAP, selectRankAttribute } from '../src/lib/select/constants'; +import { generateSelectCSS, selectSetClassName } from '../src/lib/select/generateSelectCSS'; + +/** + * Behaviour tests for the `select` CSS: given a recency-ordered selection applied to ``, a + * group must show exactly the most-recently-activated of its options (its "first ranking" + * selection), falling back to its default when none are active. These run in a real browser against + * the actual generated CSS, so they assert observable visibility — not how the selectors are built. + */ + +/** Render a single group of option panes with the generated stylesheet. First slug = default. */ +async function renderGroup(page: Page, slugs: string[]) { + const css = generateSelectCSS(slugs); + const scope = selectSetClassName(slugs); + const panes = slugs + .map( + (slug, index) => + `
${slug}
` + ) + .join(''); + + await page.setContent( + `
${panes}
` + ); +} + +/** + * Apply the recency list to `` as `data-sel-*` attributes (most-recent first), via the shared + * attribute-name helper — mirroring what the pre-paint script / store do at runtime. + */ +async function applySelection(page: Page, active: string[]) { + for (const [rank, value] of active.entries()) { + await page.evaluate( + ({ attr, value }) => document.documentElement.setAttribute(attr, value), + { attr: selectRankAttribute(rank), value } + ); + } +} + +/** Assert exactly one pane is visible, and it is the expected slug. */ +async function expectOnlyVisible(page: Page, slugs: string[], expectedSlug: string) { + for (const slug of slugs) { + const pane = page.getByTestId(`pane-${slug}`); + if (slug === expectedSlug) { + await expect(pane).toBeVisible(); + } else { + await expect(pane).toBeHidden(); + } + } +} + +async function setup(page: Page, slugs: string[], active: string[]) { + await renderGroup(page, slugs); + await applySelection(page, active); +} + +test.describe('select CSS visibility', () => { + const slugs = ['python', 'go', 'java']; + + test('shows the default when nothing is selected', async ({ page }) => { + await setup(page, slugs, []); + await expectOnlyVisible(page, slugs, 'python'); // first pane is the default + }); + + test('shows the selected option and hides the rest', async ({ page }) => { + await setup(page, slugs, ['go']); + await expectOnlyVisible(page, slugs, 'go'); + }); + + test('shows the most-recently-activated option of the group', async ({ page }) => { + // Recency list is most-recent-first: `go` is more recent than `python`. + await setup(page, slugs, ['go', 'python']); + await expectOnlyVisible(page, slugs, 'go'); + + await setup(page, slugs, ['python', 'go']); + await expectOnlyVisible(page, slugs, 'python'); + }); + + test('ignores more-recent selections that are not in the group', async ({ page }) => { + // `dark` is more recent but not one of this group's options, so `go` still wins. + await setup(page, slugs, ['dark', 'go', 'python']); + await expectOnlyVisible(page, slugs, 'go'); + }); + + test('falls back to the default when no active slug is in the group', async ({ page }) => { + await setup(page, slugs, ['dark', 'light']); + await expectOnlyVisible(page, slugs, 'python'); + }); + + test('keeps symbol-bearing slugs (c / c++ / c#) distinct through the CSS selectors', async ({ + page, + }) => { + // Slugs can contain `+` and `#` (see slugifySelectValue); they must survive quoted attribute + // selectors without collapsing together. + const symbols = ['c', 'c++', 'c#']; + await setup(page, symbols, ['c++']); + await expectOnlyVisible(page, symbols, 'c++'); + }); + + test('shows only the first pane when a group repeats a slug (duplicate tab names)', async ({ + page, + }) => { + // Two panes share the slug `js`; activating it must reveal only the first, never both. + const scope = selectSetClassName(['js', 'ts']); + await page.setContent( + `
js 1
js 2
ts
` + ); + await applySelection(page, ['js']); + await expect(page.getByTestId('js-first')).toBeVisible(); + await expect(page.getByTestId('js-second')).toBeHidden(); + await expect(page.getByTestId('ts')).toBeHidden(); + }); + + test('a pinned pane overrides first-match (the duplicate the visitor clicked)', async ({ + page, + }) => { + // The client marks the clicked pane data-select-pinned and its same-slug sibling unpinned; + // the pinned one must win over the first-match default. + const scope = selectSetClassName(['js', 'ts']); + await page.setContent( + `
js 1
js 2
` + ); + await applySelection(page, ['js']); + await expect(page.getByTestId('js-second')).toBeVisible(); + await expect(page.getByTestId('js-first')).toBeHidden(); + }); +}); + +interface GroupSpec { + id: string; + slugs: string[]; +} + +/** + * Render several tab groups, each with clickable tab buttons wired to `__select` — an in-page + * stand-in for the store's `activate()`/`mirrorToHtml()` (whose recency/dedupe/cap logic is unit + * tested in store.test.ts). It prepends the clicked slug onto the `data-sel-*` recency list on + * ``, most-recent first. This keeps the test focused on the observable behaviour a visitor + * sees — a real click switching every group that offers that option — driven by real browser CSS. + */ +async function renderGroups(page: Page, groups: GroupSpec[]) { + const styles = [ + ...new Map( + groups.map((group) => [selectSetClassName(group.slugs), generateSelectCSS(group.slugs)]) + ).values(), + ] + .map((css) => ``) + .join(''); + + const markup = groups + .map((group) => { + const scope = selectSetClassName(group.slugs); + const buttons = group.slugs + .map( + (slug) => + `` + ) + .join(''); + const panes = group.slugs + .map( + (slug, index) => + `
${slug}
` + ) + .join(''); + return `
${buttons}
${panes}
`; + }) + .join(''); + + const selectScript = `window.__select=function(slug){var el=document.documentElement,cur=[],i,v;for(i=0;i<${SELECT_LIST_CAP};i++){v=el.getAttribute('data-sel-'+i);if(v)cur.push(v);}var next=[slug];for(i=0;i${styles}${markup}` + ); +} + +/** Assert a specific group shows exactly `expectedSlug` and hides its other options. */ +async function expectGroupShows( + page: Page, + groupId: string, + slugs: string[], + expectedSlug: string +) { + for (const slug of slugs) { + const pane = page.getByTestId(`${groupId}-pane-${slug}`); + if (slug === expectedSlug) { + await expect(pane).toBeVisible(); + } else { + await expect(pane).toBeHidden(); + } + } +} + +test.describe('select syncing across groups (click-driven)', () => { + test('clicking a tab syncs every group offering that option', async ({ page }) => { + const slugs = ['python', 'go']; + await renderGroups(page, [ + { id: 'a', slugs }, + { id: 'b', slugs }, + ]); + + // Both groups start on their default (first) pane. + await expectGroupShows(page, 'a', slugs, 'python'); + await expectGroupShows(page, 'b', slugs, 'python'); + + // Clicking a tab in group A switches group B too. + await page.getByTestId('a-btn-go').click(); + await expectGroupShows(page, 'a', slugs, 'go'); + await expectGroupShows(page, 'b', slugs, 'go'); + + // And the sync works from either group. + await page.getByTestId('b-btn-python').click(); + await expectGroupShows(page, 'a', slugs, 'python'); + await expectGroupShows(page, 'b', slugs, 'python'); + }); + + test('only groups that share the clicked option follow along', async ({ page }) => { + const shared = ['python', 'go']; + const other = ['go', 'rust']; + await renderGroups(page, [ + { id: 'a', slugs: shared }, + { id: 'b', slugs: other }, + ]); + + // `rust` exists only in group B, so clicking it leaves group A on its default. + await page.getByTestId('b-btn-rust').click(); + await expectGroupShows(page, 'b', other, 'rust'); + await expectGroupShows(page, 'a', shared, 'python'); + + // `go` is shared, so clicking it in A moves both groups. + await page.getByTestId('a-btn-go').click(); + await expectGroupShows(page, 'a', shared, 'go'); + await expectGroupShows(page, 'b', other, 'go'); + }); +}); diff --git a/packages/gitbook/package.json b/packages/gitbook/package.json index 71fa52924..8a4c28b6e 100644 --- a/packages/gitbook/package.json +++ b/packages/gitbook/package.json @@ -132,7 +132,7 @@ "dev:cloudflare": "wrangler dev --port 8771 --env preview", "dev:cf:middleware": "wrangler dev --port 8771 --inspector-port 9230 --env dev --config ./openNext/customWorkers/middlewareWrangler.jsonc", "dev:cf:server": "wrangler dev --port 8772 --env dev --config ./openNext/customWorkers/defaultWrangler.jsonc", - "e2e": "playwright test e2e/internal.spec.ts e2e/cookie-banner.spec.ts e2e/pdf.spec.ts --project=chromium", + "e2e": "playwright test e2e/internal.spec.ts e2e/cookie-banner.spec.ts e2e/pdf.spec.ts e2e/select.spec.ts --project=chromium", "e2e-customers": "playwright test e2e/customers.spec.ts --project=chromium", "unit": "bun test {src,packages} --preload ./tests/preload-bun.ts", "e2e-browserless": "bun test ./tests/", diff --git a/packages/gitbook/src/components/DocumentView/Tabs/DynamicTabs.tsx b/packages/gitbook/src/components/DocumentView/Tabs/DynamicTabs.tsx index 6ba79f951..3702c755b 100644 --- a/packages/gitbook/src/components/DocumentView/Tabs/DynamicTabs.tsx +++ b/packages/gitbook/src/components/DocumentView/Tabs/DynamicTabs.tsx @@ -1,213 +1,107 @@ 'use client'; -import React, { - memo, - useCallback, - useMemo, - useRef, - useState, - type ComponentPropsWithRef, -} from 'react'; +import type React from 'react'; +import { type ComponentPropsWithRef, memo, useCallback, useMemo, useState } from 'react'; -import { NavigationStatusContext, useListOverflow } from '@/components/hooks'; +import { useResolvedSlug, useSelect } from '@/components/Select'; +import { useListOverflow } from '@/components/hooks'; import { DropdownMenu, DropdownMenuItem } from '@/components/primitives'; import { useLanguage } from '@/intl/client'; import { tString } from '@/intl/translate'; -import { getLocalStorageItem, setLocalStorageItem } from '@/lib/browser'; +import { + SELECT_DEFAULT_ATTR, + SELECT_GROUP_ATTR, + SELECT_OPTION_ATTR, + SELECT_PINNED_ATTR, + SELECT_UNPINNED_ATTR, +} from '@/lib/select'; import { tcls } from '@/lib/tailwind'; import { Icon, type IconName } from '@gitbook/icons'; -import { useRouter } from 'next/navigation'; - -interface TabsState { - activeIds: { - [tabsBlockId: string]: string; - }; - activeTitles: string[]; -} - -const defaultTabsState: TabsState = { - activeIds: {}, - activeTitles: [], -}; - -let globalTabsState = getLocalStorageItem('@gitbook/tabsState', defaultTabsState); -const listeners = new Set<() => void>(); - -function useTabsState() { - const subscribe = useCallback((callback: () => void) => { - listeners.add(callback); - return () => listeners.delete(callback); - }, []); - - const getSnapshot = useCallback(() => globalTabsState, []); - - const setTabsState = useCallback((updater: (previous: TabsState) => TabsState) => { - globalTabsState = updater(globalTabsState); - setLocalStorageItem('@gitbook/tabsState', globalTabsState); - listeners.forEach((listener) => listener()); - }, []); - const state = React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot); - return [state, setTabsState] as const; -} - -// How many titles are remembered: -const TITLES_MAX = 5; export interface TabsItem { id: string; title: string; + /** The `select` slug for this tab, derived from its title (see Tabs.tsx). */ + slug: string; icon?: IconName; body: React.ReactNode; } -interface TabsState { - activeIds: { - [tabsBlockId: string]: string; - }; - activeTitles: string[]; -} - /** * Client side component for the tabs, taking care of interactions. + * + * Pane visibility is driven entirely by CSS (see generateSelectCSS): each pane carries its slug as + * `data-select-option`, and the generated stylesheet shows the most-recently-activated one based on + * the `data-sel-*` attributes on ``. That means the correct pane is visible before hydration + * (no flash) and with JS disabled. + * + * The one thing CSS can't decide is *which* of several same-named tabs in one group the visitor + * clicked — by slug they're identical, so the stylesheet falls back to the first. After an explicit + * click we pin the exact pane via `data-select-pinned`/`-unpinned` (a client-only override that + * reverts to first-match on reload). The tablist highlight follows the same resolved tab. */ export function DynamicTabs(props: { - id: string; tabs: TabsItem[]; + setClassName: string; className?: string; }) { - const { id, tabs, className } = props; - const router = useRouter(); + const { tabs, setClassName, className } = props; + const { activate } = useSelect(); + // The tab the visitor explicitly clicked this session (not persisted — reload reverts to CSS). + const [manualId, setManualId] = useState(null); - const { onNavigationClick, hash } = React.useContext(NavigationStatusContext); - const [initialized, setInitialized] = useState(false); - const [tabsState, setTabsState] = useTabsState(); - const activeState = useMemo(() => { - const input = { id, tabs }; - return ( - getTabBySelection(input, tabsState) ?? getTabByTitle(input, tabsState) ?? input.tabs[0] - ); - }, [id, tabs, tabsState]); + const candidateSlugs = useMemo(() => tabs.map((tab) => tab.slug), [tabs]); + const activeSlug = useResolvedSlug(candidateSlugs, tabs[0]?.slug ?? null); - // Track if the tab has been touched by the user. - const touchedRef = useRef(false); + // Resolve which tab is shown. Default: the first tab of the active slug — matching the CSS + // first-match. A manual click pins a specific tab, but only while its slug is the active one; if + // the active slug is a duplicate and the pinned tab isn't the first of it, `override` carries the + // pin so the panes below can steer CSS past first-match. + const { activeTabId, override } = useMemo(() => { + const firstMatch = tabs.find((tab) => tab.slug === activeSlug) ?? tabs[0]; + const manual = manualId ? tabs.find((tab) => tab.id === manualId) : undefined; + const pinned = + manual && manual.slug === activeSlug && manual.id !== firstMatch?.id ? manual : null; + return { activeTabId: pinned?.id ?? firstMatch?.id ?? null, override: pinned }; + }, [tabs, activeSlug, manualId]); - // To avoid issue with hydration, we only use the state from localStorage - // once the component has been initialized (=mounted). - // Otherwise because of the streaming/suspense approach, tabs can be first-rendered at different time - // and get stuck into an inconsistent state. - const active = initialized ? activeState : tabs[0]; - - // When clicking to select a tab, we: - // - update the URL hash - // - mark this specific ID as selected - // - store the ID to auto-select other tabs with the same title const selectTab = useCallback( - (tabId: string, manual = true) => { - const tab = tabs.find((tab) => tab.id === tabId); - - if (!tab) { - return; + (tabId: string) => { + const tab = tabs.find((item) => item.id === tabId); + if (tab?.slug) { + activate(tab.slug); + setManualId(tabId); } - - if (manual) { - touchedRef.current = true; - const href = `#${tab.id}`; - if (window.location.hash !== href) { - onNavigationClick(href); - router.replace(href, { scroll: false }); - } - } - - setTabsState((prev) => { - if (prev.activeIds[id] === tab.id) { - return prev; - } - return { - activeIds: { - ...prev.activeIds, - [id]: tab.id, - }, - activeTitles: tab.title - ? prev.activeTitles - .filter((t) => t !== tab.title) - .concat([tab.title]) - .slice(-TITLES_MAX) - : prev.activeTitles, - }; - }); }, - [router, setTabsState, tabs, id] + [tabs, activate] ); - // When the hash changes, we try to select the tab containing the targetted element. - React.useLayoutEffect(() => { - setInitialized(true); - - if (hash) { - // First check if the hash matches a tab ID. - const hashIsTab = tabs.some((tab) => tab.id === hash); - if (hashIsTab) { - selectTab(hash, false); - return; - } - - // Then check if the hash matches an element inside a tab. - const activeElement = document.getElementById(hash); - if (!activeElement) { - return; - } - - const tabPanel = activeElement.closest('[role="tabpanel"]'); - if (!tabPanel) { - return; - } - - selectTab(tabPanel.id, false); - } - }, [selectTab, tabs, hash]); - - // Scroll to active element in the tab. - React.useLayoutEffect(() => { - // If there is no hash or active tab, nothing to scroll. - if (!hash || hash !== '' || !active) { - return; - } - - // If the tab is touched, we don't want to scroll. - if (touchedRef.current) { - return; - } - - // If the hash matches a tab, then the scroll is already done. - const hashIsTab = tabs.some((tab) => tab.id === hash); - if (hashIsTab) { - return; - } - - const activeElement = document.getElementById(hash); - if (!activeElement) { - return; - } - - activeElement.scrollIntoView({ - block: 'start', - behavior: 'instant', - }); - }, [active, tabs, hash]); - return (
- - {tabs.map((tab) => ( - + + {tabs.map((tab, index) => ( + ))}
); @@ -215,19 +109,24 @@ export function DynamicTabs(props: { const TabPanel = memo(function TabPanel(props: { tab: TabsItem; - isActive: boolean; + isDefault: boolean; + pin?: 'pinned' | 'unpinned'; }) { - const { tab, isActive } = props; + const { tab, isDefault, pin } = props; return (
- +
{tab.body}
); }); @@ -438,42 +337,3 @@ function getTabIdFromButtonId(buttonId: string) { } return buttonId; } - -/** - * Get explicitly selected tab in a set of tabs. - */ -function getTabBySelection( - input: { - id: string; - tabs: TabsItem[]; - }, - state: TabsState -): TabsItem | null { - const activeId = state.activeIds[input.id]; - return activeId ? (input.tabs.find((child) => child.id === activeId) ?? null) : null; -} - -/** - * Get the best selected tab in a set of tabs by taking only title into account. - */ -function getTabByTitle( - input: { - id: string; - tabs: TabsItem[]; - }, - state: TabsState -): TabsItem | null { - return ( - input.tabs - .map((item) => { - return { - item, - score: state.activeTitles.indexOf(item.title), - }; - }) - .filter(({ score }) => score >= 0) - // .sortBy(({ score }) => -score) - .sort(({ score: a }, { score: b }) => b - a) - .map(({ item }) => item)[0] ?? null - ); -} diff --git a/packages/gitbook/src/components/DocumentView/Tabs/Tabs.tsx b/packages/gitbook/src/components/DocumentView/Tabs/Tabs.tsx index 4151d0e9e..5308f586b 100644 --- a/packages/gitbook/src/components/DocumentView/Tabs/Tabs.tsx +++ b/packages/gitbook/src/components/DocumentView/Tabs/Tabs.tsx @@ -2,11 +2,12 @@ import type { DocumentBlockTabs } from '@gitbook/api'; import type { IconName } from '@gitbook/icons'; import { validateIconName } from '@gitbook/icons/icons'; +import { generateSelectCSS, selectSetClassName, slugifySelectValue } from '@/lib/select'; import { tcls } from '@/lib/tailwind'; import type { BlockProps } from '../Block'; import { Blocks } from '../Blocks'; -import { DynamicTabs, type TabsItem } from './DynamicTabs'; +import { DynamicTabs } from './DynamicTabs'; export function Tabs(props: BlockProps) { const { block, ancestorBlocks, document, style, context } = props; @@ -15,9 +16,7 @@ export function Tabs(props: BlockProps) { throw new Error('Tabs block is missing a key'); } - const id = block.key; - - const tabs: TabsItem[] = block.nodes.map((tab) => { + const items = block.nodes.map((tab) => { if (!tab.key) { throw new Error('Tab block is missing a key'); } @@ -43,12 +42,72 @@ export function Tabs(props: BlockProps) { }; }); - // When printing, we display the tab, one after the other + const tabs = withSelectSlugs(items); + + // When printing, we display the tabs one after the other, each as its own single-tab group so + // every variant is visible (no selection to hide them). + // When printing we show every tab, one after another, so there's no selection to resolve — skip + // the generated stylesheet entirely (each single-tab group's pane is its own default and stays + // visible on its own). if (context.mode === 'print') { - return tabs.map((tab) => { - return ; - }); + return tabs.map((tab) => ( + + )); } - return ; + const slugs = tabs.map((tab) => tab.slug); + + return ( + <> + + + + ); +} + +/** + * Stylesheet that resolves which pane a tab group shows, purely in CSS (see generateSelectCSS). + * Byte-identical for every visitor, so it has no cache impact. + * + * `href` + `precedence` opt into React's stylesheet hoisting: the tag is moved to `` (out of + * the content flow, so sibling/child selectors like Tailwind's `space-y-*` never count it as a + * phantom node) and deduped by `href`, so identical option-sets across the page share one sheet. + */ +function SelectGroupStyle({ slugs }: { slugs: string[] }) { + const css = generateSelectCSS(slugs); + if (!css) { + return null; + } + return ( + + ); +} + +/** + * Derive a `select` slug for each tab from its title. Untitled tabs fall back to their (stable) id + * so they stay selectable. + * + * Same-named tabs deliberately share a slug — selecting one syncs every tab of that name, here and + * on other pages, which is the whole point of name-based selection. We don't disambiguate duplicates + * with a positional suffix: that would desync the duplicate and, because the slug rides in the + * frozen `?select=` URL, a shared link would silently retarget when tabs are renamed or reordered. + */ +function withSelectSlugs( + items: T[] +): Array { + return items.map((item) => ({ + ...item, + slug: slugifySelectValue(item.title) || slugifySelectValue(item.id) || item.id, + })); } diff --git a/packages/gitbook/src/components/RootLayout/CustomizationRootLayout.tsx b/packages/gitbook/src/components/RootLayout/CustomizationRootLayout.tsx index ee6e74323..bf7878295 100644 --- a/packages/gitbook/src/components/RootLayout/CustomizationRootLayout.tsx +++ b/packages/gitbook/src/components/RootLayout/CustomizationRootLayout.tsx @@ -40,6 +40,7 @@ import { } from '@/lib/icons/inline'; import { defaultCustomization } from '@/lib/utils'; import { AnnouncementDismissedScript } from '../Announcement'; +import { SelectStateScript } from '../Select'; import { OperatingSystemClassScript } from './OperatingSystemClassScript'; function preloadFont(fontData: FontData) { @@ -147,6 +148,9 @@ export async function CustomizationRootLayout(props: { + {/* Apply the visitor's content selection to before first paint (no flash) */} + + {/* Inject custom font @font-face rules */} {fontData.type === 'custom' ? : null} {monospaceFontData.type === 'custom' ? ( diff --git a/packages/gitbook/src/components/Select/SelectProvider.tsx b/packages/gitbook/src/components/Select/SelectProvider.tsx new file mode 100644 index 000000000..d614b2ab9 --- /dev/null +++ b/packages/gitbook/src/components/Select/SelectProvider.tsx @@ -0,0 +1,67 @@ +'use client'; + +import { SELECT_URL_PARAM, selectStore } from '@/lib/select'; +import { parseAsString, useQueryState } from 'nuqs'; +import type React from 'react'; +import { useEffect, useLayoutEffect, useRef } from 'react'; +import { useSelect } from './useSelect'; +import { useSelectAnchor } from './useSelectAnchor'; + +// `useLayoutEffect` runs before paint but warns during SSR (effects don't run on the server anyway), +// so fall back to `useEffect` there. +const useIsomorphicLayoutEffect = typeof document !== 'undefined' ? useLayoutEffect : useEffect; + +function parseSelectParam(value: string | null): string[] { + if (!value) { + return []; + } + return value + .split(',') + .map((slug) => slug.trim()) + .filter(Boolean); +} + +/** + * Wires the `select` store to the `?select=` URL param and hydrates it from localStorage. Mounted + * once at the site layout level (inside NuqsAdapter). Provides no React context — the store is a + * module singleton — so it simply renders its children. + */ +export function SelectProvider(props: { children: React.ReactNode }) { + const [param, setParam] = useQueryState(SELECT_URL_PARAM, parseAsString); + const { slugs } = useSelect(); + // The last value we wrote to the URL, so we can tell our own writes apart from external ones. + const mirroredRef = useRef(null); + + useSelectAnchor(); + + // Adopt whatever the pre-paint script already merged (URL + storage) into the in-memory store. + // Layout effect so the store (and the tab highlight it drives) is settled before first paint, + // matching the `` the pre-paint script already applied. + useIsomorphicLayoutEffect(() => { + selectStore.init(); + }, []); + + // URL → store: a shared link or client-side navigation carrying ?select= prepends its slugs, so + // the link wins while the visitor's other preferences survive. + useEffect(() => { + if (param === mirroredRef.current) { + return; + } + const fromUrl = parseSelectParam(param); + if (fromUrl.length > 0) { + selectStore.setSlugs([...fromUrl, ...selectStore.getState().slugs]); + } + }, [param]); + + // store → URL: keep ?select= as a shareable mirror of the recency list (replaceState, no history spam). + useEffect(() => { + const desired = slugs.length > 0 ? slugs.join(',') : null; + if ((param ?? null) === desired) { + return; + } + mirroredRef.current = desired; + setParam(desired); + }, [slugs, param, setParam]); + + return props.children; +} diff --git a/packages/gitbook/src/components/Select/SelectStateScript.tsx b/packages/gitbook/src/components/Select/SelectStateScript.tsx new file mode 100644 index 000000000..d30b9451d --- /dev/null +++ b/packages/gitbook/src/components/Select/SelectStateScript.tsx @@ -0,0 +1,23 @@ +import { SELECT_LIST_CAP, SELECT_STORAGE_KEY, SELECT_URL_PARAM } from '@/lib/select'; +import { applySelectStateScript } from './script'; + +/** + * Inline `` script that applies the visitor's `select` state to `` before first paint, + * so the right content variant renders with no flash. Mounted once in the root layout head. + */ +export function SelectStateScript() { + const scriptArgs = JSON.stringify([ + SELECT_STORAGE_KEY, + SELECT_URL_PARAM, + SELECT_LIST_CAP, + ]).slice(1, -1); + + return ( +