Procházet zdrojové kódy

Neo: manifest reuses the page's connection, its icons load, Vercel scripts only on Vercel

Lighthouse on pr-5933.unacms.app put /static/manifest.json at the end of an
813 ms critical chain. Chrome fetches a manifest without cookies, and it keeps
those requests on separate sockets, so the manifest opened a second TCP + TLS
connection (350 ms there). The [...path] page now renders the manifest link
itself with crossOrigin="use-credentials", so it reuses the page's connection.
metadata.manifest only adds that attribute on Vercel previews.

On every deployment but api.neo.so the manifest is the /static/ copy, and its
relative icon paths pointed at /static/*.png files that were never copied.
Chrome requests one of them on every page load and got Next's 404 page back.
next.config.js now copies each icon the static manifest names from public/
when customization did not supply it. The default manifest's "any " purpose
(trailing space, ignored by Chrome) is "any".

Vercel's Analytics and Speed Insights scripts are served by Vercel only;
elsewhere (Railway, local) both 404 on every page load, rendering the 404 page
twice. They now mount only when process.env.VERCEL is set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Andrey Yasko před 1 dnem
rodič
revize
f4d6debd5f

+ 21 - 9
neo/apps/next/app/[...path]/page.js

@@ -9,6 +9,14 @@ import { resolveLangCode } from '../../lib/resolve-lang'
 import { cachedUnaPageJson, unaPageCacheKey } from '../../lib/una-page-cache'
 
 const SITE_TITLE = appSetting('config', 'title');
+const IS_CLIENT_PROJECT = UNA_URL != 'https://api.neo.so';
+
+// Rendered here rather than through metadata.manifest, which adds crossOrigin only on Vercel previews.
+// A manifest is fetched without cookies by default, and Chrome opens a second connection (TCP + TLS)
+// for such requests; with credentials it reuses the page's connection.
+function ManifestLink() {
+    return <link rel="manifest" href={IS_CLIENT_PROJECT ? '/static/manifest.json' : '/manifest.json'} crossOrigin="use-credentials" />;
+}
 
 async function loadPage(props) {
     const [params, searchParams] = await Promise.all([
@@ -138,7 +146,6 @@ export async function generateMetadata(props) {
     const description = decodeTextNode(data?.data?.description || SITE_TITLE);
     const name = decodeTextNode(data?.data?.title || SITE_TITLE);
     const image = data?.data?.image;
-    const isClientProject = UNA_URL != 'https://api.neo.so';
 
     const appleAppId = appSetting('smart_app_banner', 'apple_app_id');
     const appleAppArgument = appSetting('smart_app_banner', 'apple_app_argument');
@@ -154,9 +161,8 @@ export async function generateMetadata(props) {
     return {
         title: name,
         description: description,
-        manifest: isClientProject ? '/static/manifest.json' : '/manifest.json',
         icons: {
-            icon: isClientProject ? '/static/favicon.svg' : '/favicon.svg',
+            icon: IS_CLIENT_PROJECT ? '/static/favicon.svg' : '/favicon.svg',
         },
         other,
         openGraph: {
@@ -217,9 +223,12 @@ export default async function Page(props) {
         };
         
         return (
-            <Suspense fallback={null}>
-                <Root path={'home'} data={fallbackData} uri={'/'} url={'/'} code={200} />
-            </Suspense>
+            <>
+                <ManifestLink />
+                <Suspense fallback={null}>
+                    <Root path={'home'} data={fallbackData} uri={'/'} url={'/'} code={200} />
+                </Suspense>
+            </>
         );
     }
     
@@ -228,8 +237,11 @@ export default async function Page(props) {
     }
 
     return (
-        <Suspense fallback={null}>
-            <Root path={path} data={data?.data} uri={data?.data?.uri} url={data?.data?.url} code={data?.code}></Root>
-        </Suspense>
+        <>
+            <ManifestLink />
+            <Suspense fallback={null}>
+                <Root path={path} data={data?.data} uri={data?.data?.uri} url={data?.data?.url} code={data?.code}></Root>
+            </Suspense>
+        </>
     )
 }

+ 1 - 1
neo/apps/next/app/layout.js

@@ -26,7 +26,7 @@ export default async function RootLayout({ children }) {
             suppressHydrationWarning
         >
             <body className={appSetting('layout', 'body')}>
-                <Providers initialLang={langCode} initialResources={initialResources}>{children}</Providers>
+                <Providers initialLang={langCode} initialResources={initialResources} onVercel={!!process.env.VERCEL}>{children}</Providers>
             </body>
         </html>
     )

+ 4 - 3
neo/apps/next/app/providers.js

@@ -59,7 +59,7 @@ function ensureI18n(lang, initialResources) {
     }
 }
 
-export function Providers({ children, initialLang = 'en', initialResources }) {
+export function Providers({ children, initialLang = 'en', initialResources, onVercel = false }) {
     // Must match server-rendered copy before the first child paints (menu aria-labels, etc.).
     ensureI18n(initialLang, initialResources)
 
@@ -97,8 +97,9 @@ export function Providers({ children, initialLang = 'en', initialResources }) {
         <I18nextProvider i18n={i18nInstance}>
             <JotaiProvider>
                 <QueryClientProvider client={queryClient}>
-                    <Analytics />
-                    <SpeedInsights />
+                    {/* Their scripts are served by Vercel only; elsewhere both 404 on every page load. */}
+                    {onVercel ? <Analytics /> : null}
+                    {onVercel ? <SpeedInsights /> : null}
                     {children}
                     <Subscriber />
                     <AnalyticsTracker />

+ 22 - 0
neo/apps/next/next.config.js

@@ -47,6 +47,28 @@ function copyCustomizationResources() {
             fs.copyFileSync(src, destPath);
         }
     });
+
+    // The manifest's icon paths are relative, so they resolve under /static/ too. Fill in the
+    // repo's default for each icon it names that customization did not supply; a missing one
+    // 404s on every page load (Chrome fetches it to check installability).
+    const staticManifest = path.join(staticDest, 'manifest.json');
+    if (fs.existsSync(staticManifest)) {
+        let icons = [];
+        try {
+            icons = JSON.parse(fs.readFileSync(staticManifest, 'utf8')).icons || [];
+        } catch (e) {
+            console.warn('public/static/manifest.json is not valid JSON:', e.message);
+        }
+        icons.forEach(({ src }) => {
+            if (!src || src.startsWith('/') || /^[a-z]+:/i.test(src)) return;
+            const destPath = path.join(staticDest, src);
+            const defaultPath = path.resolve(__dirname, 'public', src);
+            if (!fs.existsSync(destPath) && fs.existsSync(defaultPath)) {
+                fs.mkdirSync(path.dirname(destPath), { recursive: true });
+                fs.copyFileSync(defaultPath, destPath);
+            }
+        });
+    }
 }
 
 // Run copy BEFORE loading config

+ 1 - 1
neo/apps/next/public/manifest.json

@@ -30,7 +30,7 @@
       "src": "maskable_icon_x192.png",
       "sizes": "192x192",
       "type": "image/png",
-      "purpose": "any "
+      "purpose": "any"
     },
     {
       "src": "maskable_icon_x192.png",