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/.github/composite/deploy-cloudflare/action.yaml b/.github/composite/deploy-cloudflare/action.yaml index a000568b1..346e295ac 100644 --- a/.github/composite/deploy-cloudflare/action.yaml +++ b/.github/composite/deploy-cloudflare/action.yaml @@ -70,6 +70,7 @@ runs: GITBOOK_BLOCK_SEARCH_INDEXATION: ${{ inputs.environment == 'preview' && 'true' || '' }} GITBOOK_ALLOW_CUSTOMIZATION_OVERRIDE: ${{ inputs.environment == 'preview' && 'true' || '' }} GITBOOK_DISABLE_INSIGHTS: ${{ inputs.environment == 'preview' && 'true' || '' }} + GITBOOK_DISABLE_LOOKUP_ALTERNATIVES: ${{ inputs.environment == 'staging' && 'true' || '' }} shell: bash - name: Upload the DO worker diff --git a/.github/composite/deploy-vercel/action.yaml b/.github/composite/deploy-vercel/action.yaml index 76968b3e2..4fd08e0c5 100644 --- a/.github/composite/deploy-vercel/action.yaml +++ b/.github/composite/deploy-vercel/action.yaml @@ -78,6 +78,11 @@ runs: echo "GITBOOK_DISABLE_INSIGHTS=true" >> .vercel/.env.${{ inputs.environment }}.local echo "--- .vercel/.env.${{ inputs.environment }}.local after inject ---" cat .vercel/.env.${{ inputs.environment }}.local + - name: Inject staging build env vars + if: ${{ inputs.environment == 'staging' }} + shell: bash + run: | + echo "GITBOOK_DISABLE_LOOKUP_ALTERNATIVES=true" >> .vercel/.env.${{ inputs.environment }}.local - name: Build Project Artifacts run: bun run vercel build --target=${{ inputs.environment }} --token=${{ inputs.vercelToken }} shell: bash diff --git a/packages/gitbook/src/app/~gitbook/env/route.ts b/packages/gitbook/src/app/~gitbook/env/route.ts index 80eec2e02..4d4382f4e 100644 --- a/packages/gitbook/src/app/~gitbook/env/route.ts +++ b/packages/gitbook/src/app/~gitbook/env/route.ts @@ -7,6 +7,7 @@ import { GITBOOK_APP_URL, GITBOOK_ASSETS_URL, GITBOOK_DISABLE_INSIGHTS, + GITBOOK_DISABLE_LOOKUP_ALTERNATIVES, GITBOOK_DISABLE_TRACKING, GITBOOK_FONTS_URL, GITBOOK_ICONS_URL, @@ -37,6 +38,7 @@ export async function GET(_req: NextRequest) { GITBOOK_INTEGRATIONS_CONTENT_HOST, GITBOOK_DISABLE_TRACKING, GITBOOK_DISABLE_INSIGHTS, + GITBOOK_DISABLE_LOOKUP_ALTERNATIVES, // Secret envs GITBOOK_SECRET: !!GITBOOK_SECRET, 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 ( -