Fix: select no longer stored in url (#4466)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Brett Jephson
2026-08-05 11:59:56 +01:00
committed by GitHub
parent 4be043d96e
commit a6644074cb
13 changed files with 124 additions and 113 deletions
+5
View File
@@ -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.
@@ -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]
@@ -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<T extends { id: string; title: string }>(
items: T[]
@@ -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<string | null>(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 `<html data-sel-*>` 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 `<html data-sel-*>` 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;
}
@@ -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 (
<script
@@ -1,14 +1,14 @@
/**
* Read the `select` state (URL `?select=` + localStorage) and apply it to `<html>` as `data-sel-N`
* attributes as early as possible, so the correct content variant is visible before hydration — no
* flash, and it works on cached/static HTML.
* Read the `select` state from localStorage and apply it to `<html>` as `data-sel-N` attributes as
* early as possible, so the correct content variant is visible before hydration — no flash, and it
* works on cached/static HTML.
*
* NOTE: this runs in `<head>` before `<body>` exists, and is stringified and injected — so it must be
* self-contained (no imports/closures) and touch only `document.documentElement`. The attribute name
* and merge rules mirror `lib/select` (`selectRankAttribute`, the store's `normalize`); keep them in
* sync. URL slugs are prepended so a shared link wins while the visitor's other preferences survive.
* and dedupe rules mirror `lib/select` (`selectRankAttribute`, the store's `normalize`); keep them in
* sync.
*/
export function applySelectStateScript(storageKey: string, urlParam: string, cap: number) {
export function applySelectStateScript(storageKey: string, cap: number) {
try {
const slugs: string[] = [];
// A Set (not a plain object) so slugs like "constructor"/"toString" aren't treated as
@@ -26,22 +26,14 @@ export function applySelectStateScript(storageKey: string, urlParam: string, cap
slugs.push(slug);
};
const fromUrl = new URLSearchParams(window.location.search).get(urlParam);
if (fromUrl) {
const parts = fromUrl.split(',');
for (let i = 0; i < parts.length; i++) {
push(parts[i]);
}
}
const storedStr = window.localStorage.getItem(storageKey);
if (storedStr) {
const stored = JSON.parse(storedStr);
// Only trust a real array — corrupted storage (a string, or an object with `length`)
// would otherwise iterate per character/index. Matches the runtime store's handling.
if (Array.isArray(stored)) {
for (let j = 0; j < stored.length; j++) {
push(stored[j]);
for (let i = 0; i < stored.length; i++) {
push(stored[i]);
}
}
}
@@ -56,8 +48,6 @@ export function applySelectStateScript(storageKey: string, urlParam: string, cap
el.removeAttribute(attribute);
}
}
window.localStorage.setItem(storageKey, JSON.stringify(slugs));
} catch {
// localStorage blocked (private mode) or malformed state — fall through to block defaults.
}
@@ -5,12 +5,6 @@
*/
export const SELECT_STORAGE_KEY = '@gitbook/select';
/**
* Single query parameter carrying shareable selection state, e.g. `?select=python,cloud`
* (most-recent-first). A fixed key so author-chosen names never collide with reserved params.
*/
export const SELECT_URL_PARAM = 'select';
/**
* How many slugs are remembered, most-recent-first. This is also the depth of the CSS "rank ladder"
* (see generateSelectCSS): since pane visibility is CSS-only, the ladder must cover every stored
@@ -108,7 +108,7 @@ export function generateSelectCSS(candidateSlugs: string[], depth = SELECT_LIST_
// Duplicate tab names in one group would otherwise reveal two panes at once. Keep only the first:
// hide any option pane preceded by a same-slug sibling. Emitted last and prefixed with `html` so
// it beats the show rules above (equal specificity, later source order). The slug stays shared, so
// syncing and the `?select=` URL are unaffected — only the second pane's visibility changes.
// syncing is unaffected — only the second pane's visibility changes.
for (const slug of slugs) {
const value = escapeCssString(slug);
const pane = `[${SELECT_OPTION_ATTR}="${value}"]`;
+2 -2
View File
@@ -2,8 +2,8 @@ import { describe, expect, it } from 'bun:test';
import { SLUG_MAX_CODE_POINTS, slugifySelectValue } from './slug';
describe('slugifySelectValue', () => {
// This table IS the frozen public contract for `?select=` URLs — see SLUG_ALGO_VERSION.
// Changing any expectation here is a breaking change to already-shared links.
// This table IS the slug contract — see SLUG_ALGO_VERSION. Changing any expectation here orphans
// selections already persisted in visitors' localStorage.
const cases: Array<[input: string, expected: string]> = [
['Python', 'python'],
['JavaScript', 'javascript'],
+13 -13
View File
@@ -1,35 +1,35 @@
/**
* Version of the slugification algorithm below.
*
* The slugs it produces are the keys that sync content across the site AND the values that appear
* in the public `?select=` URL parameter. Once those URLs are in the wild the algorithm is frozen:
* changing it would silently re-resolve links people have already shared. Any future change must
* bump this version and be gated behind it, never applied in place.
* The slugs it produces are the keys that sync content across the site, baked into server-rendered
* markup and CSS and persisted in the visitor's localStorage. Changing the algorithm in place would
* orphan every stored selection and desync it from the markup, so any future change must bump this
* version and be gated behind it.
*/
export const SLUG_ALGO_VERSION = 1;
/**
* Maximum slug length, counted in code points (not UTF-16 units, so we never cleave a surrogate
* pair). Guards against pathological titles — 30 CJK characters is already ~270 bytes once
* percent-encoded into `?select=`. Part of the frozen contract (see {@link SLUG_ALGO_VERSION}).
* pair). Guards against pathological titles bloating the generated CSS and stored state. Part of the
* contract (see {@link SLUG_ALGO_VERSION}).
*/
export const SLUG_MAX_CODE_POINTS = 64;
/**
* Turn an author-typed name (a tab title, button label, picker option…) into a `select` slug.
*
* DO NOT CHANGE — this is the frozen public `?select=` URL contract (see {@link SLUG_ALGO_VERSION}).
* The output must be byte-identical on the server (baking slugs into markup/CSS) and the client
* (parsing URLs/storage), so it relies only on locale-independent primitives: Unicode NFKC
* DO NOT CHANGE in place — see {@link SLUG_ALGO_VERSION}. The output must be byte-identical on the
* server (baking slugs into markup/CSS) and the client (reading storage), so it relies only on
* locale-independent primitives: Unicode NFKC
* normalization + `String.prototype.toLowerCase` (Unicode default case folding, not locale-sensitive).
*
* It keeps letters, numbers and marks from every script (so `café`, `安装`, `日本語` survive) plus a
* small safelist of symbols — `+ # . _` — that distinguish technical names that would otherwise
* collide (`c` vs `c++` vs `c#`, `node.js`, `on_prem`). Every other run of characters collapses to a
* single `-`, and leading/trailing `-` are trimmed. A slug can never contain the `,` that delimits
* `?select=`, and none of these characters need escaping in a URL-encoded query param — but the
* safelist widens the set beyond bare word characters, so consumers that interpolate a slug into
* another syntax must still escape for it (see the CSS escaping in generateSelectCSS).
* single `-`, and leading/trailing `-` are trimmed. A slug can never contain a `,`, which callers rely
* on to join slug lists into a single key (see `useResolvedSlug`) — but the safelist widens the set
* beyond bare word characters, so consumers that interpolate a slug into another syntax must still
* escape for it (see the CSS escaping in generateSelectCSS).
*
* Control, format, bidi and lone-surrogate characters (`\p{C}`) are dropped outright rather than
* turned into a `-`, and the string is re-normalized after `toLowerCase` (case mapping can leave it
@@ -0,0 +1,56 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import { SELECT_STORAGE_KEY } from './constants';
// Own file, own module instance: the store hydrates once per page load, so the "mutation before
// init()" path can only be exercised on a store nothing has touched yet.
const storage = new Map<string, string>();
const globals = globalThis as unknown as { localStorage?: unknown };
const originalLocalStorage = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
beforeEach(() => {
storage.clear();
globals.localStorage = {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => {
storage.set(key, value);
},
removeItem: (key: string) => {
storage.delete(key);
},
};
});
// Bun shares globals across test files, so leave `localStorage` exactly as we found it (absent) —
// every other suite relies on the `typeof localStorage` guard in `lib/browser` short-circuiting.
afterEach(() => {
if (originalLocalStorage) {
Object.defineProperty(globalThis, 'localStorage', originalLocalStorage);
} else {
delete globals.localStorage;
}
storage.clear();
});
describe('select store hydration', () => {
it('merges a mutation that lands before init() into the stored list', async () => {
storage.set(SELECT_STORAGE_KEY, JSON.stringify(['go', 'rust']));
// Query string busts the module cache so this store is untouched by the sibling suite; the
// specifier is held in a variable because TS won't resolve it as a literal.
const freshStore = './store?hydration';
const { activate, getState, init } = (await import(freshStore)) as typeof import('./store');
// A deep-linked pane activating during hydration, ahead of the provider's init effect.
activate('python');
expect(getState().slugs).toEqual(['python', 'go', 'rust']);
expect(JSON.parse(storage.get(SELECT_STORAGE_KEY) as string)).toEqual([
'python',
'go',
'rust',
]);
// The later init() must not resurrect the pre-mutation list.
init();
expect(getState().slugs).toEqual(['python', 'go', 'rust']);
});
});
@@ -60,7 +60,7 @@ describe('select store', () => {
unsubscribe();
});
it('does not notify when the list is unchanged (prevents URL mirror loops)', () => {
it('does not notify when the list is unchanged (avoids redundant re-renders)', () => {
setSlugs(['python', 'go']);
let calls = 0;
const unsubscribe = subscribe(() => {
+20 -8
View File
@@ -50,8 +50,11 @@ function sameList(a: string[], b: string[]): boolean {
}
function commit(nextSlugs: string[]) {
// Latch hydration even for callers that replace the list wholesale, so a later `init()` can't
// overwrite this write with what was in storage beforehand.
hydrate();
const slugs = normalize(nextSlugs);
// No-op when nothing changed — this is what keeps the store⇄URL mirror from looping.
// No-op when nothing changed, so we don't rewrite storage or re-render every consumer.
if (sameList(slugs, state.slugs)) {
return;
}
@@ -68,6 +71,9 @@ export function activate(slug: string) {
if (!slug) {
return;
}
// Hydrate before reading `state`, not just before writing: a mutation that lands ahead of `init()`
// (a deep-linked pane activating during hydration) must merge into the stored list, not replace it.
hydrate();
commit([slug, ...state.slugs]);
}
@@ -76,10 +82,11 @@ export function deactivate(slug: string) {
if (!slug) {
return;
}
hydrate();
commit(state.slugs.filter((s) => s !== slug));
}
/** Replace the whole list (used when hydrating from URL + storage). */
/** Replace the whole list. */
export function setSlugs(slugs: string[]) {
commit(slugs);
}
@@ -104,20 +111,25 @@ export function resolveActiveSlug(candidates: string[]): string | null {
/**
* Hydrate the in-memory store from localStorage (once per full page load). The pre-paint script has
* already merged `?select=` into storage and written `<html>` before this runs, so we just adopt it;
* we re-mirror to `<html>` too, to stay correct after a client-side navigation.
* already read the same storage and written `<html>` before this runs, so we just adopt it; we
* re-mirror to `<html>` too, to stay correct after a client-side navigation.
*/
export function init() {
hydrate();
mirrorToHtml(state.slugs);
for (const listener of listeners) {
listener();
}
}
/** Read the persisted list into memory, once per page load. Silent — callers notify if they need to. */
function hydrate() {
if (initialized) {
return;
}
initialized = true;
const stored = getLocalStorageItem<string[]>(SELECT_STORAGE_KEY, []);
state = { slugs: normalize(Array.isArray(stored) ? stored : []) };
mirrorToHtml(state.slugs);
for (const listener of listeners) {
listener();
}
}
/**