From 0b20a6e7bcef81e0a45e3f51f246911822724738 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 11:28:50 +0100 Subject: [PATCH 1/5] fix(app): strip the shell's head defaults when the app boots (#12440) Fixes duplicate metadata elements (title, description, and OG/Twitter properties) that are present in the shell `index.html` by default and redundantly appended when React routes boot up and hydrate. We now mark the 6 shell tags with `data-shell-default` and aggressively strip them immediately before creating the React root. Additionally, `DebugPage` now consistently sets its own explicit title. Tests added for both the DOM utility and integration into the specific pages. Verification steps run successfully: ``` yarn lint yarn fm:check yarn type-check yarn test src/pages src/utils yarn build ``` Refs #9629 --- *PR created automatically by Jules for task [16201668932719198013](https://jules.google.com/task/16201668932719198013) started by @MarkusNeusinger* --------- Co-authored-by: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> --- app/index.html | 12 +- app/src/main.tsx | 3 + app/src/pages/DebugPage.tsx | 4 + app/src/pages/headMetadata.test.tsx | 240 ++++++++++++++++++++++++++++ app/src/utils/shellMetadata.test.ts | 54 +++++++ app/src/utils/shellMetadata.ts | 9 ++ 6 files changed, 316 insertions(+), 6 deletions(-) create mode 100644 app/src/pages/headMetadata.test.tsx create mode 100644 app/src/utils/shellMetadata.test.ts create mode 100644 app/src/utils/shellMetadata.ts 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..ee6c8e03088 100644 --- a/app/src/main.tsx +++ b/app/src/main.tsx @@ -8,6 +8,9 @@ import ReactDOM from 'react-dom/client'; import { reportWebVitals } from 'src/analytics/reportWebVitals'; import { App } from 'src/app'; +import { removeShellDefaults } from 'src/utils/shellMetadata'; + +removeShellDefaults(); 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..8ee0267c35d --- /dev/null +++ b/app/src/pages/headMetadata.test.tsx @@ -0,0 +1,240 @@ +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 { removeShellDefaults } from 'src/utils/shellMetadata'; + +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); + }); + }); + + afterEach(() => { + document.head.innerHTML = ''; + }); + + it('LandingPage deduplicates head tags', () => { + removeShellDefaults(); + const { unmount } = render(); + + const titles = document.head.querySelectorAll('title'); + expect(titles.length).toBe(1); + expect(titles[0].textContent).toBe('any.plot() — any library.'); + + const descs = document.head.querySelectorAll('meta[name="description"]'); + expect(descs.length).toBeLessThanOrEqual(1); + unmount(); + }); + + it('PlotsPage deduplicates head tags', () => { + removeShellDefaults(); + const { unmount } = render(); + + const titles = document.head.querySelectorAll('title'); + expect(titles.length).toBe(1); + expect(titles[0].textContent).toBe('plots | anyplot.ai'); + + const descs = document.head.querySelectorAll('meta[name="description"]'); + expect(descs.length).toBeLessThanOrEqual(1); + unmount(); + }); + + it('AboutPage deduplicates head tags', () => { + removeShellDefaults(); + const { unmount } = render(); + + const titles = document.head.querySelectorAll('title'); + expect(titles.length).toBe(1); + expect(titles[0].textContent).toBe('about | anyplot.ai'); + + const descs = document.head.querySelectorAll('meta[name="description"]'); + expect(descs.length).toBeLessThanOrEqual(1); + unmount(); + }); + + it('LegalPage deduplicates head tags', () => { + removeShellDefaults(); + const { unmount } = render(); + + const titles = document.head.querySelectorAll('title'); + expect(titles.length).toBe(1); + expect(titles[0].textContent).toBe('legal | anyplot.ai'); + + const descs = document.head.querySelectorAll('meta[name="description"]'); + expect(descs.length).toBeLessThanOrEqual(1); + unmount(); + }); + + it('DebugPage deduplicates head tags', () => { + removeShellDefaults(); + const { unmount } = render(); + + const titles = document.head.querySelectorAll('title'); + expect(titles.length).toBe(1); + expect(titles[0].textContent).toBe('debug | anyplot.ai'); + + const descs = document.head.querySelectorAll('meta[name="description"]'); + expect(descs.length).toBeLessThanOrEqual(1); + unmount(); + }); +}); diff --git a/app/src/utils/shellMetadata.test.ts b/app/src/utils/shellMetadata.test.ts new file mode 100644 index 00000000000..a280386c380 --- /dev/null +++ b/app/src/utils/shellMetadata.test.ts @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { removeShellDefaults } from 'src/utils/shellMetadata'; + +describe('removeShellDefaults', () => { + afterEach(() => { + document.head.innerHTML = ''; + }); + + it('removes only tags with data-shell-default attribute and returns correct count', () => { + // Add marked shell tags + const title = document.createElement('title'); + title.setAttribute('data-shell-default', ''); + title.textContent = 'shell title'; + document.head.appendChild(title); + + const desc = document.createElement('meta'); + desc.setAttribute('data-shell-default', ''); + desc.name = 'description'; + document.head.appendChild(desc); + + // Add unmarked tags that should stay + const otherTitle = document.createElement('title'); + otherTitle.textContent = 'page title'; + document.head.appendChild(otherTitle); + + const otherMeta = document.createElement('meta'); + otherMeta.name = 'keywords'; + document.head.appendChild(otherMeta); + + expect(document.head.children.length).toBe(4); + + const count = removeShellDefaults(); + + expect(count).toBe(2); + expect(document.head.children.length).toBe(2); + + // Verify only the unmarked ones are left + const titles = document.head.querySelectorAll('title'); + expect(titles.length).toBe(1); + expect(titles[0].textContent).toBe('page title'); + + const metas = document.head.querySelectorAll('meta'); + expect(metas.length).toBe(1); + expect(metas[0].name).toBe('keywords'); + }); + + it('handles empty head', () => { + expect(document.head.children.length).toBe(0); + const count = removeShellDefaults(); + expect(count).toBe(0); + expect(document.head.children.length).toBe(0); + }); +}); diff --git a/app/src/utils/shellMetadata.ts b/app/src/utils/shellMetadata.ts new file mode 100644 index 00000000000..7164b6b9dab --- /dev/null +++ b/app/src/utils/shellMetadata.ts @@ -0,0 +1,9 @@ +export function removeShellDefaults(head: HTMLHeadElement = document.head): number { + const elements = head.querySelectorAll('[data-shell-default]'); + let count = 0; + elements.forEach(el => { + el.remove(); + count++; + }); + return count; +} From 86b83629796ea0be10775349dc2ecbd7ded468ec Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Thu, 29 Oct 2026 11:32:06 +0100 Subject: [PATCH 2/5] docs(changelog): one title and description per page Co-Authored-By: Claude Opus 5.5 --- changelog.d/head-metadata-dedup.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 changelog.d/head-metadata-dedup.md diff --git a/changelog.d/head-metadata-dedup.md b/changelog.d/head-metadata-dedup.md new file mode 100644 index 00000000000..3dae077d53b --- /dev/null +++ b/changelog.d/head-metadata-dedup.md @@ -0,0 +1,11 @@ +### 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 removed when the app boots (`removeShellDefaults` + in `app/src/main.tsx`), so clients without JavaScript still get them, and + `/debug` has its own title. Chosen from two Google Jules variants by + measuring the built pages; the other removed the shell defaults outright + and left clients without JavaScript with no title. From ddf1356a44977d9fa33f27952da7eafa81746b66 Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Thu, 29 Oct 2026 11:42:33 +0100 Subject: [PATCH 3/5] fix(app): keep shell defaults as the fallback instead of deleting them Gemini Flash pre-PR check: removing the shell's title and description before the first render left the tab without a title while a lazy route chunk loads, on the route error boundary, and on a spec page that is loading or failed. watchShellDefaults() now detaches a shell default only while a page renders a tag of the same kind and re-attaches it when none is left (MutationObserver on <head>). The page tests run that sync and require exactly one title and one description instead of "at most one". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- app/src/main.tsx | 6 +- app/src/pages/headMetadata.test.tsx | 90 ++++++++++-------------- app/src/utils/shellMetadata.test.ts | 102 ++++++++++++++++++---------- app/src/utils/shellMetadata.ts | 55 ++++++++++++--- 4 files changed, 150 insertions(+), 103 deletions(-) diff --git a/app/src/main.tsx b/app/src/main.tsx index ee6c8e03088..ba5dcb47992 100644 --- a/app/src/main.tsx +++ b/app/src/main.tsx @@ -8,9 +8,11 @@ import ReactDOM from 'react-dom/client'; import { reportWebVitals } from 'src/analytics/reportWebVitals'; import { App } from 'src/app'; -import { removeShellDefaults } from 'src/utils/shellMetadata'; +import { watchShellDefaults } from 'src/utils/shellMetadata'; -removeShellDefaults(); +// 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( <React.StrictMode> diff --git a/app/src/pages/headMetadata.test.tsx b/app/src/pages/headMetadata.test.tsx index 8ee0267c35d..3b5d9190b58 100644 --- a/app/src/pages/headMetadata.test.tsx +++ b/app/src/pages/headMetadata.test.tsx @@ -6,7 +6,19 @@ 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 { removeShellDefaults } from 'src/utils/shellMetadata'; +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<typeof import('src/hooks/useLayoutContext')>( @@ -167,74 +179,40 @@ describe('head metadata deduplication', () => { } document.head.appendChild(el); }); + defaults = Array.from(document.head.querySelectorAll('[data-shell-default]')); }); afterEach(() => { document.head.innerHTML = ''; }); - it('LandingPage deduplicates head tags', () => { - removeShellDefaults(); - const { unmount } = render(<LandingPage />); - - const titles = document.head.querySelectorAll('title'); - expect(titles.length).toBe(1); - expect(titles[0].textContent).toBe('any.plot() — any library.'); - - const descs = document.head.querySelectorAll('meta[name="description"]'); - expect(descs.length).toBeLessThanOrEqual(1); + it.each([ + ['LandingPage', () => <LandingPage />, 'any.plot() — any library.'], + ['PlotsPage', () => <PlotsPage />, 'plots | anyplot.ai'], + ['AboutPage', () => <AboutPage />, 'about | anyplot.ai'], + ['LegalPage', () => <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('PlotsPage deduplicates head tags', () => { - removeShellDefaults(); - const { unmount } = render(<PlotsPage />); - - const titles = document.head.querySelectorAll('title'); - expect(titles.length).toBe(1); - expect(titles[0].textContent).toBe('plots | anyplot.ai'); - - const descs = document.head.querySelectorAll('meta[name="description"]'); - expect(descs.length).toBeLessThanOrEqual(1); + it('DebugPage shows its own title and keeps the shell description it has none of', () => { + const { unmount } = render(<DebugPage />); + expect(headAfterSync()).toEqual({ titles: ['debug | anyplot.ai'], descriptions: 1 }); + expect( + document.head.querySelector('meta[name="description"]')?.hasAttribute('data-shell-default') + ).toBe(true); unmount(); }); - it('AboutPage deduplicates head tags', () => { - removeShellDefaults(); + it('brings the shell title back when the page that replaced it is gone', () => { const { unmount } = render(<AboutPage />); - - const titles = document.head.querySelectorAll('title'); - expect(titles.length).toBe(1); - expect(titles[0].textContent).toBe('about | anyplot.ai'); - - const descs = document.head.querySelectorAll('meta[name="description"]'); - expect(descs.length).toBeLessThanOrEqual(1); - unmount(); - }); - - it('LegalPage deduplicates head tags', () => { - removeShellDefaults(); - const { unmount } = render(<LegalPage />); - - const titles = document.head.querySelectorAll('title'); - expect(titles.length).toBe(1); - expect(titles[0].textContent).toBe('legal | anyplot.ai'); - - const descs = document.head.querySelectorAll('meta[name="description"]'); - expect(descs.length).toBeLessThanOrEqual(1); - unmount(); - }); - - it('DebugPage deduplicates head tags', () => { - removeShellDefaults(); - const { unmount } = render(<DebugPage />); - - const titles = document.head.querySelectorAll('title'); - expect(titles.length).toBe(1); - expect(titles[0].textContent).toBe('debug | anyplot.ai'); - - const descs = document.head.querySelectorAll('meta[name="description"]'); - expect(descs.length).toBeLessThanOrEqual(1); + 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 index a280386c380..b8e52fc94db 100644 --- a/app/src/utils/shellMetadata.test.ts +++ b/app/src/utils/shellMetadata.test.ts @@ -1,54 +1,82 @@ import { afterEach, describe, expect, it } from 'vitest'; -import { removeShellDefaults } from 'src/utils/shellMetadata'; +import { syncShellDefaults, watchShellDefaults } from 'src/utils/shellMetadata'; -describe('removeShellDefaults', () => { +function shellTag(tag: 'title' | 'meta', attrs: Record<string, string>, 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<string, string>, 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('removes only tags with data-shell-default attribute and returns correct count', () => { - // Add marked shell tags - const title = document.createElement('title'); - title.setAttribute('data-shell-default', ''); - title.textContent = 'shell title'; - document.head.appendChild(title); - - const desc = document.createElement('meta'); - desc.setAttribute('data-shell-default', ''); - desc.name = 'description'; - document.head.appendChild(desc); + 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' }); - // Add unmarked tags that should stay - const otherTitle = document.createElement('title'); - otherTitle.textContent = 'page title'; - document.head.appendChild(otherTitle); + expect(syncShellDefaults(document.head, [title, description, ogTitle])).toBe(2); - const otherMeta = document.createElement('meta'); - otherMeta.name = 'keywords'; - document.head.appendChild(otherMeta); + 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); - expect(document.head.children.length).toBe(4); + // 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'); + }); - const count = removeShellDefaults(); + 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); + }); +}); - expect(count).toBe(2); - expect(document.head.children.length).toBe(2); +describe('watchShellDefaults', () => { + afterEach(() => { + document.head.innerHTML = ''; + }); - // Verify only the unmarked ones are left - const titles = document.head.querySelectorAll('title'); - expect(titles.length).toBe(1); - expect(titles[0].textContent).toBe('page title'); + 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 metas = document.head.querySelectorAll('meta'); - expect(metas.length).toBe(1); - expect(metas[0].name).toBe('keywords'); - }); + const pageTitle = pageTag('title', {}, 'page title'); + await Promise.resolve(); + expect(title.isConnected).toBe(false); + expect(document.title).toBe('page title'); - it('handles empty head', () => { - expect(document.head.children.length).toBe(0); - const count = removeShellDefaults(); - expect(count).toBe(0); - expect(document.head.children.length).toBe(0); + 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 index 7164b6b9dab..8749b136c67 100644 --- a/app/src/utils/shellMetadata.ts +++ b/app/src/utils/shellMetadata.ts @@ -1,9 +1,48 @@ -export function removeShellDefaults(head: HTMLHeadElement = document.head): number { - const elements = head.querySelectorAll('[data-shell-default]'); - let count = 0; - elements.forEach(el => { - el.remove(); - count++; - }); - return count; +/** + * The SPA shell (`index.html`) carries a default `<title>`, 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(); } From 444065edf10f2bdc8e7b077710e37546aa926675 Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Thu, 29 Oct 2026 11:43:11 +0100 Subject: [PATCH 4/5] docs(changelog): describe the fallback-keeping sync Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- changelog.d/head-metadata-dedup.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/changelog.d/head-metadata-dedup.md b/changelog.d/head-metadata-dedup.md index 3dae077d53b..898cb1252de 100644 --- a/changelog.d/head-metadata-dedup.md +++ b/changelog.d/head-metadata-dedup.md @@ -4,8 +4,9 @@ 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 removed when the app boots (`removeShellDefaults` - in `app/src/main.tsx`), so clients without JavaScript still get them, and - `/debug` has its own title. Chosen from two Google Jules variants by - measuring the built pages; the other removed the shell defaults outright - and left clients without JavaScript with no title. + `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. From 8e9a6097dc4895395ce3fc71c8a45d54d3981e29 Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Thu, 29 Oct 2026 11:43:23 +0100 Subject: [PATCH 5/5] docs(changelog): reference #12442 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- changelog.d/head-metadata-dedup.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/head-metadata-dedup.md b/changelog.d/head-metadata-dedup.md index 898cb1252de..f353d4b1eee 100644 --- a/changelog.d/head-metadata-dedup.md +++ b/changelog.d/head-metadata-dedup.md @@ -9,4 +9,4 @@ 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. + the built pages; the other deleted the shell defaults outright. (#12442)