mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-21 01:53:26 +00:00
Fix: select no longer stored in url (#4466)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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,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'],
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user