From b95add7b27adf14e1ec5463186c10c467f20c864 Mon Sep 17 00:00:00 2001 From: Tomek Date: Fri, 18 Sep 2026 16:16:07 +0200 Subject: [PATCH] Apply release notes tag filters from URL via CSS before first paint (#4613) Co-authored-by: Claude Haiku 4.5 --- .changeset/quiet-tags-filter.md | 5 + .../components/DocumentView/UpdatesFilter.tsx | 160 ++++++++++++------ .../DocumentView/UpdatesFilterScript.test.ts | 14 ++ .../DocumentView/UpdatesFilterScript.tsx | 95 +++++++++++ .../PageAside/ScrollSectionsList.tsx | 32 ++-- .../src/components/SitePage/SitePage.tsx | 30 +++- .../src/components/hooks/useScrollActiveId.ts | 5 +- packages/gitbook/src/lib/updates.test.ts | 26 ++- packages/gitbook/src/lib/updates.ts | 125 ++++++++++++++ 9 files changed, 417 insertions(+), 75 deletions(-) create mode 100644 .changeset/quiet-tags-filter.md create mode 100644 packages/gitbook/src/components/DocumentView/UpdatesFilterScript.test.ts create mode 100644 packages/gitbook/src/components/DocumentView/UpdatesFilterScript.tsx diff --git a/.changeset/quiet-tags-filter.md b/.changeset/quiet-tags-filter.md new file mode 100644 index 000000000..a1c44b7ce --- /dev/null +++ b/.changeset/quiet-tags-filter.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Apply release notes tag filters from the URL via CSS before first paint, so the filtered entries, the "page contents" section list, and the tag chip highlighting all show correctly from the start on cached pages, with no flash of unfiltered content. Also stop the "page contents" scroll highlight from defaulting to an entry hidden by the filter. diff --git a/packages/gitbook/src/components/DocumentView/UpdatesFilter.tsx b/packages/gitbook/src/components/DocumentView/UpdatesFilter.tsx index 9218b9a4b..cf26933a3 100644 --- a/packages/gitbook/src/components/DocumentView/UpdatesFilter.tsx +++ b/packages/gitbook/src/components/DocumentView/UpdatesFilter.tsx @@ -9,8 +9,18 @@ import { Icon } from '@gitbook/icons'; import { TagIcon } from '../Tag'; import { Button } from '@/components/primitives'; import { tcls } from '@/lib/tailwind'; - -const UPDATES_FILTER_SEARCH_PARAM = 'tag'; +import { + UPDATES_FILTER_KEY_ATTR, + UPDATES_FILTER_SEARCH_PARAM, + UPDATES_TAG_ATTR, + UPDATES_TAG_CHIP_SELECTED_ATTR, + UPDATES_TAG_CHIP_UNSELECTED_DIMMED_ATTR, + UPDATES_TAG_CHIP_UNSELECTED_PLAIN_ATTR, + UPDATES_TAG_CLEAR_ATTR, + UPDATES_TAG_FILTER_ATTR, + normalizeUpdatesFilterTags, + updatesFilterStyleKey, +} from '@/lib/updates'; type UpdatesFilterContextValue = { selectedTags: string[]; @@ -36,21 +46,7 @@ export function UpdatesFilterProvider(props: { tagSlugs: string[]; children: Rea const availableTags = React.useMemo(() => new Set(tagSlugs), [tagSlugs]); const sanitizeTags = React.useCallback( - (tags: string[]) => { - const next: string[] = []; - const seen = new Set(); - - for (const tag of tags) { - if (!availableTags.has(tag) || seen.has(tag)) { - continue; - } - - next.push(tag); - seen.add(tag); - } - - return next; - }, + (tags: string[]) => normalizeUpdatesFilterTags(tags, availableTags), [availableTags] ); @@ -63,8 +59,20 @@ export function UpdatesFilterProvider(props: { tagSlugs: string[]; children: Rea () => sanitizeTags(rawSelectedTags), [sanitizeTags, rawSelectedTags] ); - const [selectedTags, setSelectedTags] = React.useState(urlSelectedTags); + const [selectedTags, setSelectedTags] = React.useState([]); const selectedTagsRef = React.useRef(selectedTags); + const styleKey = React.useMemo(() => updatesFilterStyleKey(tagSlugs), [tagSlugs]); + + // Clean up on unmount so a nav to a page with no filterable updates doesn't leave a stale + // stylesheet hiding everything (see UPDATES_FILTER_KEY_ATTR). + React.useLayoutEffect(() => { + document.documentElement.setAttribute(UPDATES_FILTER_KEY_ATTR, styleKey); + + return () => { + document.documentElement.removeAttribute(UPDATES_FILTER_KEY_ATTR); + document.documentElement.removeAttribute(UPDATES_TAG_FILTER_ATTR); + }; + }, [styleKey]); const replaceTags = React.useCallback( (nextTags: string[]) => { @@ -88,7 +96,10 @@ export function UpdatesFilterProvider(props: { tagSlugs: string[]; children: Rea } }, [rawSelectedTags, replaceTags, urlSelectedTags]); - React.useEffect(() => { + // Layout effect so the filter attribute is applied before paint, avoiding a flash in the chip UI. + React.useLayoutEffect(() => { + applyTagFilterAttribute(urlSelectedTags); + if (areTagsEqual(selectedTagsRef.current, urlSelectedTags)) { return; } @@ -104,6 +115,7 @@ export function UpdatesFilterProvider(props: { tagSlugs: string[]; children: Rea selectedTagsRef.current = nextTags; setSelectedTags(nextTags); + applyTagFilterAttribute(nextTags); replaceTags(nextTags); }, [replaceTags, sanitizeTags] @@ -152,8 +164,7 @@ export function UpdatesTagFilters(props: { clearLabel: string; }) { const { tags, tagsLabel, clearLabel } = props; - const { selectedTagSet, selectedTags, toggleTag, clearTags } = useUpdatesFilter(); - const isFiltering = selectedTags.length > 0; + const { toggleTag, clearTags } = useUpdatesFilter(); if (tags.length === 0) { return null; @@ -166,64 +177,105 @@ export function UpdatesTagFilters(props: { {tagsLabel} + {/* Visible/clickable only while a filter is active — see generateUpdatesFilterCSS. */} - ); - })} + {tags.map((tag) => ( + + ))} ); } +const CHIP_CLASS = + 'inline-flex max-w-full rounded-full circular-corners:rounded-2xl straight-corners:rounded-xs not-focus-visible:outline-0 focus-visible:ring-2 focus-visible:ring-primary'; + +const CHIP_VARIANT_CLASS = + 'max-w-full items-center gap-1 rounded-full px-2 py-1 font-medium text-xs leading-normal transition-colors circular-corners:rounded-2xl straight-corners:rounded-xs'; + +function TagChip(props: { tag: RevisionTag; onToggle: (tag: string) => void }) { + const { tag, onToggle } = props; + const { selectedTagSet } = useUpdatesFilter(); + const onClick = () => onToggle(tag.slug); + + return ( + + ); +} + function areTagsEqual(left: string[], right: string[]): boolean { return left.length === right.length && left.every((tag, index) => tag === right[index]); } +/** Mirrors the active filter onto ``, matching what the pre-paint script does on first load. */ +function applyTagFilterAttribute(tags: string[]) { + if (typeof document === 'undefined') { + return; + } + + if (tags.length > 0) { + document.documentElement.setAttribute(UPDATES_TAG_FILTER_ATTR, tags.join(' ')); + } else { + document.documentElement.removeAttribute(UPDATES_TAG_FILTER_ATTR); + } +} + +/** Visibility is driven purely by CSS against `data-update-tags` (see generateUpdatesFilterCSS). */ export function FilteredUpdate(props: { tagSlugs: string[]; className?: string; children: React.ReactNode; }) { const { tagSlugs, className, children } = props; - const { selectedTagSet } = useUpdatesFilter(); - - const isVisible = - selectedTagSet.size === 0 || tagSlugs.some((tagSlug) => selectedTagSet.has(tagSlug)); return ( -