fix(seo): stop site schema from crashing every client-side navigation (#4867)

`MyApp.getInitialProps` returns early when `ctx.req` is absent, so a client-side
route change re-renders `_app` with no `domain` or `serverDomains` in pageProps —
they are only attached on the server branch. `getSiteSchema` indexed them
unguarded, so `serverDomains[domain]` threw `Cannot read properties of undefined
(reading 'undefined')` out of render and tripped the app-level error boundary.
Every in-app navigation in production hit it; a hard load was fine, which is why
it survived review.

Every other consumer of those props survives because `AppProvider` snapshots them
in a lazy `useState` and never re-reads the props. `_app` now does the same for
the schema, so the JSON-LD also stays in `<head>` across navigations instead of
being dropped once the props disappear. `getSiteSchema` takes both inputs as
optional and returns undefined when it cannot resolve a host.

Regression from #4859 / v5.1.101 (4fa44ae5f8).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Manuel Emilio Urena
2026-09-16 00:20:48 -04:00
committed by GitHub
parent d0e9ecfcad
commit 9b7b5dcc6c
3 changed files with 44 additions and 5 deletions
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';
import { getSiteSchema } from '~/components/Meta/site-schema';
import type { ServerDomains } from '~/shared/constants/domain.constants';
const serverDomains: ServerDomains = {
green: { primary: 'civitai.green', aliases: [] },
blue: { primary: 'civitai.com', aliases: [] },
red: { primary: 'civitai.red', aliases: [] },
};
describe('getSiteSchema', () => {
// `MyApp.getInitialProps` returns early when `ctx.req` is absent, so a client-side
// route change re-renders `_app` with no `domain`/`serverDomains` at all. Indexing
// them unguarded threw out of render and tripped the app-level error boundary on
// every in-app navigation (v5.1.101).
it('returns undefined instead of throwing when the props are absent', () => {
expect(getSiteSchema({ domain: undefined, serverDomains: undefined })).toBeUndefined();
expect(getSiteSchema({ domain: 'green', serverDomains: undefined })).toBeUndefined();
expect(getSiteSchema({ domain: undefined, serverDomains })).toBeUndefined();
});
it('returns undefined when the domain has no configured host', () => {
expect(
getSiteSchema({ domain: 'red', serverDomains: { ...serverDomains, red: undefined } })
).toBeUndefined();
});
it('attributes the green site to the Organization', () => {
const schema = getSiteSchema({ domain: 'green', serverDomains });
expect(schema?.['@graph'].map((node) => node['@type'])).toEqual(['WebSite', 'Organization']);
});
it('gives a non-green domain a WebSite node with no Organization', () => {
const schema = getSiteSchema({ domain: 'blue', serverDomains });
expect(schema?.['@graph']).toHaveLength(1);
expect(schema?.['@graph'][0]).toMatchObject({ '@type': 'WebSite', url: 'https://civitai.com' });
});
});
+4 -4
View File
@@ -41,14 +41,14 @@ export function getSiteSchema({
domain,
serverDomains,
}: {
domain: ColorDomain;
serverDomains: ServerDomains;
domain?: ColorDomain;
serverDomains?: ServerDomains;
}) {
const host = serverDomains[domain]?.primary;
const host = domain ? serverDomains?.[domain]?.primary : undefined;
if (!host) return undefined;
const siteUrl = httpsUrl(host);
const greenHost = serverDomains.green?.primary;
const greenHost = serverDomains?.green?.primary;
// No `potentialAction`/`SearchAction`: robots.txt deliberately disallows
// `/search/*` and `*?query=` as thin duplicate content, so declaring a search
+2 -1
View File
@@ -12,6 +12,7 @@ import type { AppContext, AppProps } from 'next/app';
import App from 'next/app';
import Head from 'next/head';
import type { ReactElement } from 'react';
import { useState } from 'react';
import { AdsProvider } from '~/components/Ads/AdsProvider';
import { AppLayout } from '~/components/AppLayout/AppLayout';
import { BaseLayout } from '~/components/AppLayout/BaseLayout';
@@ -167,7 +168,7 @@ function MyApp(props: CustomAppProps) {
},
} = props;
const siteSchema = getSiteSchema({ domain, serverDomains });
const [siteSchema] = useState(() => getSiteSchema({ domain, serverDomains }));
// // Standalone pages bypass all providers and render directly
// if ('standalone' in Component && Component.standalone) {