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 ``. 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 ``, 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)