mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
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:
committed by
GitHub
parent
d0e9ecfcad
commit
9b7b5dcc6c
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user