diff --git a/.changeset/moody-nails-admire.md b/.changeset/moody-nails-admire.md new file mode 100644 index 000000000..3660db76e --- /dev/null +++ b/.changeset/moody-nails-admire.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Persist content selection (tabs and other `select` blocks) in localStorage only, dropping the `?select=` query parameter from the URL. A tab click still writes the tab's hash, so a copied URL lands on that tab and reactivates it on load. diff --git a/packages/gitbook/src/components/DocumentView/Tabs/DynamicTabs.tsx b/packages/gitbook/src/components/DocumentView/Tabs/DynamicTabs.tsx index c02d75dc7..d33eddadf 100644 --- a/packages/gitbook/src/components/DocumentView/Tabs/DynamicTabs.tsx +++ b/packages/gitbook/src/components/DocumentView/Tabs/DynamicTabs.tsx @@ -41,11 +41,7 @@ export interface TabsItem { * 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: { - tabs: TabsItem[]; - setClassName: string; - className?: string; -}) { +export function DynamicTabs(props: { tabs: TabsItem[]; setClassName: string; className?: string }) { const { tabs, setClassName, className } = props; const { activate } = useSelect(); // The tab the visitor explicitly clicked this session (not persisted — reload reverts to CSS). @@ -74,9 +70,10 @@ export function DynamicTabs(props: { } activate(tab.slug); setManualId(tabId); - // The hash is purely positional now — `select` carries the selection — so writing it just - // makes a copied URL land on this tab. We deliberately bypass the navigation context: it - // would report a hash change and scroll the tab the visitor is already looking at. + // The hash is the only URL handle for a selection, so a copied URL lands on this tab and + // `useSelectAnchor` re-activates its slug on load. We deliberately bypass the navigation + // context: it would report a hash change and scroll the tab the visitor is already + // looking at. window.history.replaceState(null, '', resolveAnchorURL(`#${tab.id}`, window.location)); }, [tabs, activate] diff --git a/packages/gitbook/src/components/DocumentView/Tabs/Tabs.tsx b/packages/gitbook/src/components/DocumentView/Tabs/Tabs.tsx index 5308f586b..04a9229cf 100644 --- a/packages/gitbook/src/components/DocumentView/Tabs/Tabs.tsx +++ b/packages/gitbook/src/components/DocumentView/Tabs/Tabs.tsx @@ -100,8 +100,8 @@ function SelectGroupStyle({ slugs }: { slugs: string[] }) { * * 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. + * with a positional suffix: that would desync the duplicate and make a stored selection retarget + * whenever tabs are renamed or reordered. */ function withSelectSlugs( items: T[] diff --git a/packages/gitbook/src/components/Select/SelectProvider.tsx b/packages/gitbook/src/components/Select/SelectProvider.tsx index d614b2ab9..cddd41863 100644 --- a/packages/gitbook/src/components/Select/SelectProvider.tsx +++ b/packages/gitbook/src/components/Select/SelectProvider.tsx @@ -1,67 +1,28 @@ 'use client'; -import { SELECT_URL_PARAM, selectStore } from '@/lib/select'; -import { parseAsString, useQueryState } from 'nuqs'; +import { selectStore } from '@/lib/select'; import type React from 'react'; -import { useEffect, useLayoutEffect, useRef } from 'react'; -import { useSelect } from './useSelect'; +import { useEffect, useLayoutEffect } from 'react'; 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. + * Hydrates the `select` store from localStorage. Mounted once at the site layout level. 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. + // Adopt what the pre-paint script already applied, before paint, so the store (and the tab + // highlight it drives) agrees with the `` on the page. + // Must stay registered before `useSelectAnchor`, whose effect can activate slugs: a write before + // hydration would persist over the visitor's stored list instead of merging into it. 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]); + useSelectAnchor(); return props.children; } diff --git a/packages/gitbook/src/components/Select/SelectStateScript.tsx b/packages/gitbook/src/components/Select/SelectStateScript.tsx index d30b9451d..3cd454b49 100644 --- a/packages/gitbook/src/components/Select/SelectStateScript.tsx +++ b/packages/gitbook/src/components/Select/SelectStateScript.tsx @@ -1,4 +1,4 @@ -import { SELECT_LIST_CAP, SELECT_STORAGE_KEY, SELECT_URL_PARAM } from '@/lib/select'; +import { SELECT_LIST_CAP, SELECT_STORAGE_KEY } from '@/lib/select'; import { applySelectStateScript } from './script'; /** @@ -6,11 +6,7 @@ import { applySelectStateScript } from './script'; * 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); + const scriptArgs = JSON.stringify([SELECT_STORAGE_KEY, SELECT_LIST_CAP]).slice(1, -1); return (