diff --git a/app/index.html b/app/index.html index 175fe23c0ea..77ce1bdfb6c 100644 --- a/app/index.html +++ b/app/index.html @@ -9,11 +9,11 @@ - + - any.plot() — any library. + any.plot() — any library. @@ -62,16 +62,16 @@ - - + + - - + + diff --git a/app/src/main.tsx b/app/src/main.tsx index 2876d90789f..ba5dcb47992 100644 --- a/app/src/main.tsx +++ b/app/src/main.tsx @@ -8,6 +8,11 @@ import ReactDOM from 'react-dom/client'; import { reportWebVitals } from 'src/analytics/reportWebVitals'; import { App } from 'src/app'; +import { watchShellDefaults } from 'src/utils/shellMetadata'; + +// The shell's default title and description stay as the fallback for every state in which a +// route renders none of its own, and step aside while one does. +watchShellDefaults(); ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/app/src/pages/DebugPage.tsx b/app/src/pages/DebugPage.tsx index 8addb3d5ccc..3d2a8c57fed 100644 --- a/app/src/pages/DebugPage.tsx +++ b/app/src/pages/DebugPage.tsx @@ -426,6 +426,7 @@ export function DebugPage() { if (authRequired) { return ( + debug | anyplot.ai debug · admin auth} /> + debug | anyplot.ai loading debug data... @@ -505,6 +507,7 @@ export function DebugPage() { if (error || !data) { return ( + debug | anyplot.ai failed to load{error ? `: ${error}` : ''} @@ -541,6 +544,7 @@ export function DebugPage() { return ( + debug | anyplot.ai {/* Header */} debug} /> diff --git a/app/src/pages/headMetadata.test.tsx b/app/src/pages/headMetadata.test.tsx new file mode 100644 index 00000000000..3b5d9190b58 --- /dev/null +++ b/app/src/pages/headMetadata.test.tsx @@ -0,0 +1,218 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { AboutPage } from 'src/pages/AboutPage'; +import { DebugPage } from 'src/pages/DebugPage'; +import { LandingPage } from 'src/pages/LandingPage'; +import { LegalPage } from 'src/pages/LegalPage'; +import { PlotsPage } from 'src/pages/PlotsPage'; +import { render } from 'src/test-utils'; +import { syncShellDefaults } from 'src/utils/shellMetadata'; + +// The six shell defaults as index.html marks them; filled in beforeEach. +let defaults: Element[] = []; + +/** Run the sync main.tsx's observer runs after every head change, then count the tags. */ +function headAfterSync(): { titles: string[]; descriptions: number } { + syncShellDefaults(document.head, defaults); + return { + titles: Array.from(document.head.querySelectorAll('title')).map(t => t.textContent ?? ''), + descriptions: document.head.querySelectorAll('meta[name="description"]').length, + }; +} + +vi.mock('src/hooks/useLayoutContext', async () => { + const actual = await vi.importActual( + 'src/hooks/useLayoutContext' + ); + return { + ...actual, + useTheme: () => ({ isDark: false, toggle: vi.fn() }), + useAppData: () => ({ + specs: [], + specTitles: {}, + filterCounts: null, + globalCounts: null, + fetchError: null, + hasData: true, + lastUpdated: '2023-01-01', + }), + useHomeState: () => ({ + specsData: [], + specTitles: {}, + allImages: [], + displayedImages: [], + activeFilters: [], + filterCounts: null, + globalCounts: null, + orCounts: [], + hasMore: false, + loading: false, + error: '', + setDisplayedImages: vi.fn(), + handleFilterClick: vi.fn(), + handleSortModeChange: vi.fn(), + loadMore: vi.fn(), + homeStateRef: { current: { scrollY: 0 } }, + saveScrollPosition: vi.fn(), + }), + }; +}); + +vi.mock('src/hooks/useFeaturedSpecs', () => ({ + useFeaturedSpecs: () => [], +})); + +vi.mock('src/hooks/usePlotOfTheDay', () => ({ + usePlotOfTheDay: () => null, +})); + +vi.mock('src/hooks', async () => { + const actual = await vi.importActual('src/hooks'); + return { + ...actual, + useAnalytics: () => ({ trackPageview: vi.fn(), trackEvent: vi.fn() }), + useAppData: () => ({ + specs: [], + specTitles: {}, + filterCounts: null, + globalCounts: null, + fetchError: null, + hasData: true, + lastUpdated: '2023-01-01', + }), + useHomeState: () => ({ + specsData: [], + specTitles: {}, + allImages: [], + displayedImages: [], + activeFilters: [], + filterCounts: null, + globalCounts: null, + orCounts: [], + hasMore: false, + loading: false, + error: '', + setDisplayedImages: vi.fn(), + handleFilterClick: vi.fn(), + handleSortModeChange: vi.fn(), + loadMore: vi.fn(), + homeStateRef: { current: { scrollY: 0 } }, + saveScrollPosition: vi.fn(), + }), + }; +}); + +// For PlotsPage filters and infinite scroll context +vi.mock('src/hooks/useFilterState', async () => { + const actual = await vi.importActual( + 'src/hooks/useFilterState' + ); + return { + ...actual, + useFilterState: () => ({ + activeFilters: [], + filterCounts: null, + globalCounts: null, + orCounts: [], + specTitles: {}, + allImages: [], + displayedImages: [], + hasMore: false, + loading: false, + error: '', + setDisplayedImages: vi.fn(), + handleFilterClick: vi.fn(), + handleSortModeChange: vi.fn(), + loadMore: vi.fn(), + }), + }; +}); + +vi.mock('src/hooks/useInfiniteScroll', () => ({ + useInfiniteScroll: () => ({ loadMoreRef: { current: null } }), +})); + +vi.mock('src/sections/landing/LibrariesSection', () => ({ + LibrariesSection: () =>
, +})); +vi.mock('src/sections/landing/HeroSection', () => ({ + HeroSection: () =>
, +})); +vi.mock('src/sections/landing/NumbersStrip', () => ({ + NumbersStrip: () =>
, +})); +vi.mock('src/sections/plots-gallery/FilterBar', () => ({ + FilterBar: () =>
FilterBar
, +})); +vi.mock('src/sections/plots-gallery/ImagesGrid', () => ({ + ImagesGrid: () =>
ImagesGrid
, +})); +vi.mock('src/layouts/Footer', () => ({ + Footer: () =>
Footer
, +})); +vi.mock('src/utils/filters', async () => { + const actual = await vi.importActual('src/utils/filters'); + return { ...actual }; +}); + +describe('head metadata deduplication', () => { + beforeEach(() => { + // Clear head + document.head.innerHTML = ''; + + // Insert the 6 marked shell tags + const tags = [ + { tag: 'title', attrs: {} }, + { tag: 'meta', attrs: { name: 'description', content: 'desc' } }, + { tag: 'meta', attrs: { property: 'og:title', content: 'og title' } }, + { tag: 'meta', attrs: { property: 'og:description', content: 'og desc' } }, + { tag: 'meta', attrs: { name: 'twitter:title', content: 'tw title' } }, + { tag: 'meta', attrs: { name: 'twitter:description', content: 'tw desc' } }, + ]; + + tags.forEach(({ tag, attrs }) => { + const el = document.createElement(tag); + el.setAttribute('data-shell-default', ''); + Object.entries(attrs).forEach(([key, val]) => el.setAttribute(key, val)); + if (tag === 'title') { + el.textContent = 'shell title'; + } + document.head.appendChild(el); + }); + defaults = Array.from(document.head.querySelectorAll('[data-shell-default]')); + }); + + afterEach(() => { + document.head.innerHTML = ''; + }); + + it.each([ + ['LandingPage', () => , 'any.plot() — any library.'], + ['PlotsPage', () => , 'plots | anyplot.ai'], + ['AboutPage', () => , 'about | anyplot.ai'], + ['LegalPage', () => , 'legal | anyplot.ai'], + ])('%s leaves exactly one title and one description, its own', (_name, page, title) => { + const { unmount } = render(page()); + expect(headAfterSync()).toEqual({ titles: [title], descriptions: 1 }); + expect( + document.head.querySelector('meta[name="description"]')?.hasAttribute('data-shell-default') + ).toBe(false); + unmount(); + }); + + it('DebugPage shows its own title and keeps the shell description it has none of', () => { + const { unmount } = render(); + expect(headAfterSync()).toEqual({ titles: ['debug | anyplot.ai'], descriptions: 1 }); + expect( + document.head.querySelector('meta[name="description"]')?.hasAttribute('data-shell-default') + ).toBe(true); + unmount(); + }); + + it('brings the shell title back when the page that replaced it is gone', () => { + const { unmount } = render(); + expect(headAfterSync().titles).toEqual(['about | anyplot.ai']); + unmount(); + expect(headAfterSync()).toEqual({ titles: ['shell title'], descriptions: 1 }); + }); +}); diff --git a/app/src/utils/shellMetadata.test.ts b/app/src/utils/shellMetadata.test.ts new file mode 100644 index 00000000000..b8e52fc94db --- /dev/null +++ b/app/src/utils/shellMetadata.test.ts @@ -0,0 +1,82 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { syncShellDefaults, watchShellDefaults } from 'src/utils/shellMetadata'; + +function shellTag(tag: 'title' | 'meta', attrs: Record, text = ''): Element { + const el = document.createElement(tag); + el.setAttribute('data-shell-default', ''); + Object.entries(attrs).forEach(([key, value]) => el.setAttribute(key, value)); + el.textContent = text; + document.head.appendChild(el); + return el; +} + +function pageTag(tag: 'title' | 'meta', attrs: Record, text = ''): Element { + const el = document.createElement(tag); + Object.entries(attrs).forEach(([key, value]) => el.setAttribute(key, value)); + el.textContent = text; + document.head.appendChild(el); + return el; +} + +describe('syncShellDefaults', () => { + afterEach(() => { + document.head.innerHTML = ''; + }); + + it('detaches a shell default only while a page tag of the same kind exists', () => { + const title = shellTag('title', {}, 'shell title'); + const description = shellTag('meta', { name: 'description', content: 'shell' }); + const ogTitle = shellTag('meta', { property: 'og:title', content: 'shell' }); + const keywords = pageTag('meta', { name: 'keywords', content: 'kept' }); + const pageTitle = pageTag('title', {}, 'page title'); + pageTag('meta', { property: 'og:title', content: 'page' }); + + expect(syncShellDefaults(document.head, [title, description, ogTitle])).toBe(2); + + expect(Array.from(document.head.querySelectorAll('title')).map(t => t.textContent)).toEqual([ + 'page title', + ]); + expect(document.head.querySelectorAll('meta[property="og:title"]')).toHaveLength(1); + // Nothing replaces the description, so the shell's stays. + expect(description.isConnected).toBe(true); + expect(keywords.isConnected).toBe(true); + + // The page's title goes away (an error state, a lazy chunk loading): the shell's comes back. + pageTitle.remove(); + expect(syncShellDefaults(document.head, [title, description, ogTitle])).toBe(1); + expect(document.title).toBe('shell title'); + }); + + it('is idempotent', () => { + const title = shellTag('title', {}, 'shell title'); + pageTag('title', {}, 'page title'); + syncShellDefaults(document.head, [title]); + const before = document.head.innerHTML; + expect(syncShellDefaults(document.head, [title])).toBe(1); + expect(document.head.innerHTML).toBe(before); + }); +}); + +describe('watchShellDefaults', () => { + afterEach(() => { + document.head.innerHTML = ''; + }); + + it('follows page tags as they come and go', async () => { + const title = shellTag('title', {}, 'shell title'); + const stop = watchShellDefaults(document.head); + expect(title.isConnected).toBe(true); + + const pageTitle = pageTag('title', {}, 'page title'); + await Promise.resolve(); + expect(title.isConnected).toBe(false); + expect(document.title).toBe('page title'); + + pageTitle.remove(); + await Promise.resolve(); + expect(title.isConnected).toBe(true); + expect(document.title).toBe('shell title'); + stop(); + }); +}); diff --git a/app/src/utils/shellMetadata.ts b/app/src/utils/shellMetadata.ts new file mode 100644 index 00000000000..8749b136c67 --- /dev/null +++ b/app/src/utils/shellMetadata.ts @@ -0,0 +1,48 @@ +/** + * The SPA shell (`index.html`) carries a default ``, description, and Open Graph and + * Twitter tags, marked `data-shell-default`, for clients without JavaScript. Pages render their + * own, which React 19 hoists into `<head>`. A shell default is therefore shown only while no + * page-rendered tag of the same kind exists: hidden while a page provides its own, back while + * a route shows none (a lazy chunk loading, the error boundary, a spec page still loading). + */ + +const SHELL_ATTR = 'data-shell-default'; + +/** The selector a page-rendered tag matches when it replaces this shell default. */ +function replacementSelector(shellTag: Element): string { + if (shellTag.tagName === 'TITLE') return 'title'; + const name = shellTag.getAttribute('name'); + if (name) return `meta[name="${name}"]`; + return `meta[property="${shellTag.getAttribute('property')}"]`; +} + +/** + * Detach every shell default that a page-rendered tag replaces and re-attach every one that + * nothing replaces any more. Idempotent: a second call changes nothing. Returns how many shell + * defaults are detached afterwards. + */ +export function syncShellDefaults(head: HTMLHeadElement, defaults: Element[]): number { + let detached = 0; + for (const shellTag of defaults) { + const replaced = Array.from(head.querySelectorAll(replacementSelector(shellTag))).some( + tag => tag !== shellTag && !tag.hasAttribute(SHELL_ATTR) + ); + if (replaced && shellTag.isConnected) shellTag.remove(); + if (!replaced && !shellTag.isConnected) head.appendChild(shellTag); + if (!shellTag.isConnected) detached++; + } + return detached; +} + +/** + * Keep the shell defaults in sync with the page's own tags from now on. Call once before the + * React root renders; returns a function that stops watching. + */ +export function watchShellDefaults(head: HTMLHeadElement = document.head): () => void { + const defaults = Array.from(head.querySelectorAll(`[${SHELL_ATTR}]`)); + syncShellDefaults(head, defaults); + // Our own detach or re-attach triggers the observer once more; the second sync is a no-op. + const observer = new MutationObserver(() => syncShellDefaults(head, defaults)); + observer.observe(head, { childList: true }); + return () => observer.disconnect(); +} diff --git a/changelog.d/head-metadata-dedup.md b/changelog.d/head-metadata-dedup.md new file mode 100644 index 00000000000..f353d4b1eee --- /dev/null +++ b/changelog.d/head-metadata-dedup.md @@ -0,0 +1,12 @@ +### Fixed + +- **One title and description per page.** The SPA shell in `app/index.html` + kept its default `<title>`, description, and Open Graph and Twitter tags + after a page rendered its own, so every route carried two of each and the + shell's description came first. The six defaults are now marked + `data-shell-default` and step aside only while a page renders a tag of the + same kind (`watchShellDefaults` in `app/src/main.tsx`), so they still serve + clients without JavaScript, a route whose chunk is loading, the error + boundary, and pages without a description of their own; `/debug` has its + own title. Built from the better of two Google Jules variants, measured on + the built pages; the other deleted the shell defaults outright. (#12442)