From e8d4c5ad09f7bf1e955d4be0afdf51c870e682ef Mon Sep 17 00:00:00 2001 From: miteruzo Date: Tue, 20 Oct 2026 02:42:14 +0900 Subject: [PATCH] #428 --- frontend/server.ssr.test.mjs | 91 +++++++++- frontend/src/App.tsx | 195 ++++++++++++++-------- frontend/src/components/TopNav.tsx | 40 ++++- frontend/src/index.css | 10 ++ frontend/src/lib/routeLocales.ts | 1 + frontend/src/locales/ain-Kana/shared.json | 2 +- frontend/src/main.tsx | 3 +- 7 files changed, 256 insertions(+), 86 deletions(-) diff --git a/frontend/server.ssr.test.mjs b/frontend/server.ssr.test.mjs index badee08..b5ec314 100644 --- a/frontend/server.ssr.test.mjs +++ b/frontend/server.ssr.test.mjs @@ -470,7 +470,7 @@ test ('hydrate every route with stored browser preferences', { ws = new WebSocket (endpoint) await new Promise (resolve => ws.on ('open', resolve)) const pending = new Map () - let sequence = 0, errors = [] + let sequence = 0, errors = [], probeActive = false, delayedChunks = 0, documents = 0 const send = (method, params = {}, sessionId) => new Promise ((resolve, reject) => { const id = ++sequence pending.set (id, { resolve, reject }) @@ -487,7 +487,12 @@ test ('hydrate every route with stored browser preferences', { const { requestId, request } = message.params const url = new URL (request.url) if (url.origin === httpOrigin) { - void send ('Fetch.continueRequest', { requestId }, message.sessionId) + const delayed = probeActive + && /\/(MaterialBasePage|MaterialListPage|TagListPage)-[^/]+\.js$/.test (url.pathname) + if (delayed) ++delayedChunks + setTimeout (() => { + void send ('Fetch.continueRequest', { requestId }, message.sessionId) + }, delayed ? 600 : 0) return } const data = responseFor (url) @@ -504,6 +509,9 @@ test ('hydrate every route with stored browser preferences', { } else if (message.method === 'Runtime.consoleAPICalled' && message.params.type === 'error') { errors.push (message.params.args.map (arg => arg.value ?? arg.description).join (' ')) + } else if (message.method === 'Network.requestWillBeSent' + && message.params.type === 'Document') { + ++documents } }) const { targetId } = await send ('Target.createTarget', { url: 'about:blank' }) @@ -511,6 +519,7 @@ test ('hydrate every route with stored browser preferences', { await send ('Runtime.enable', {}, sessionId) await send ('Page.enable', {}, sessionId) await send ('Network.enable', {}, sessionId) + await send ('Network.setCacheDisabled', { cacheDisabled: true }, sessionId) await send ('Fetch.enable', { patterns: [{ urlPattern: '*' }] }, sessionId) const cookie = await authenticatedCookie () await send ('Network.setExtraHTTPHeaders', { headers: { @@ -519,12 +528,39 @@ test ('hydrate every route with stored browser preferences', { localStorage.setItem('user_code','test-recovery-secret'); localStorage.setItem('btrc_hub.client_settings',JSON.stringify({ lists:{postList:{limit:50},tagList:{limit:50},postSearch:{limit:50}}, - behavior:{animation:'off'},theatre:{layoutMode:'tagsBottom',tagFlow:'horizontal'} + behavior:{animation:new URLSearchParams(location.search).get('animation')??'off'}, + theatre:{layoutMode:'tagsBottom',tagFlow:'horizontal'} })); new MutationObserver(()=>{ const child=document.getElementById('root')?.firstElementChild; if(child&&!window.__initialSSRNode)window.__initialSSRNode=child; - }).observe(document,{childList:true,subtree:true});` }, sessionId) + }).observe(document,{childList:true,subtree:true}); + if(new URLSearchParams(location.search).has('layoutProbe')){ + window.__layoutProbe={samples:[],mainLost:false,earlyAnimation:false}; + let originalMain=null; + const sample=()=>{ + const main=document.querySelector('main'); + const layout=document.querySelector('.top-nav-layout'); + const controls=document.querySelector('.top-nav-mobile-controls'); + if(main&&layout&&controls){ + originalMain??=main; + if(!originalMain.isConnected)window.__layoutProbe.mainLost=true; + const ready=layout.dataset.layoutReady==='true'; + const state={mobile:getComputedStyle(controls).display!=='none', + measured:layout.hasAttribute('data-desktop'),ready}; + const previous=window.__layoutProbe.samples.at(-1); + if(JSON.stringify(previous)!==JSON.stringify(state)) + window.__layoutProbe.samples.push(state); + if(!ready&&layout.getAnimations({subtree:true}) + .some(animation=>animation.playState==='running')) + window.__layoutProbe.earlyAnimation=true; + }else if(originalMain&&!originalMain.isConnected){ + window.__layoutProbe.mainLost=true; + } + requestAnimationFrame(sample); + }; + requestAnimationFrame(sample); + }` }, sessionId) const evaluate = async expression => (await send ('Runtime.evaluate', { expression, returnByValue: true }, sessionId)).result.value for (const route of routes.filter (entry => !entry.handle?.redirectTo)) { @@ -553,6 +589,53 @@ test ('hydrate every route with stored browser preferences', { }) } } + probeActive = true + for (const width of [375, 1240]) { + await send ('Emulation.setDeviceMetricsOverride', { + width, height: 900, deviceScaleFactor: 1, mobile: width < 768 }, sessionId) + for (const animation of ['off', 'reduced', 'normal']) { + for (const locale of ['ja', 'ain-Kana']) { + for (const page of ['materials', 'tags']) { + await t.test (`stable first paint ${width} ${animation} ${locale} ${page}`, + async () => { + errors = [] + documents = 0 + delayedChunks = 0 + await send ('Page.navigate', { url: + `${httpOrigin}/${locale}/${page}?layoutProbe=1&animation=${animation}` }, sessionId) + let ready = false + for (let attempt = 0; attempt < 100; ++attempt) { + await new Promise (resolve => setTimeout (resolve, 75)) + try { + ready = await evaluate (`document.querySelector('.top-nav-layout') + ?.dataset.layoutReady==='true' + &&Object.keys(document.getElementById('root')??{}) + .some(key=>key.startsWith('__reactContainer'))`) + } catch {} + if (ready) break + } + assert.ok (ready, 'hydration and the first layout measurement must finish') + await new Promise (resolve => setTimeout (resolve, 150)) + const probe = await evaluate ('window.__layoutProbe') + assert.ok (delayedChunks >= (page === 'materials' ? 2 : 1)) + assert.equal (documents, 1) + assert.deepEqual (errors, []) + assert.equal (probe.mainLost, false, 'SSR page content must stay attached') + assert.equal (probe.earlyAnimation, false, 'first layout must not animate') + const initial = probe.samples.find (sample => !sample.measured) + assert.ok (initial, 'observe the CSS layout before hydration') + assert.equal (initial.mobile, width < 768) + const final = probe.samples.at (-1) + if (!final.mobile) + assert.ok (probe.samples.every (sample => !sample.mobile), + 'a desktop page must not initially show the mobile controls') + assert.ok (await evaluate (`document.documentElement.scrollWidth<=innerWidth`), + 'the measured layout must fit the viewport') + }) + } + } + } + } } finally { ws?.close () chrome.kill () diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5a3b511..07a1872 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query' import { AnimatePresence, LayoutGroup, MotionConfig, motion } from 'framer-motion' -import { Suspense, lazy, useEffect, useLayoutEffect, useMemo, useState } from 'react' +import { createElement, Suspense, lazy, useEffect, + useLayoutEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { createBrowserRouter, matchRoutes, @@ -41,46 +42,64 @@ import NotFound from '@/pages/NotFound' import HelpDetailPage from '@/pages/help/HelpDetailPage' import ServiceUnavailable from '@/pages/ServiceUnavailable' -import type { Dispatch, FC, ReactNode, SetStateAction } from 'react' +import type { ComponentType, Dispatch, FC, ReactNode, SetStateAction } from 'react' import type { RouteMatch } from 'react-router-dom' -import type { ClientAnimationMode } from '@/lib/settings' import type { AvailableLocale, LocaleCode } from '@/lib/locales' import type { PageRoute } from '@/lib/routeLocales' import type { User } from '@/types' -const GekanatorPage = lazy (() => import ('@/pages/GekanatorPage')) -const DeerjikistDetailPage = lazy (() => import ('@/pages/deerjikists/DeerjikistDetailPage')) -const MaterialBasePage = lazy (() => import ('@/pages/materials/MaterialBasePage')) -const MaterialDetailPage = lazy (() => import ('@/pages/materials/MaterialDetailPage')) -const MaterialHistoryPage = lazy (() => import ('@/pages/materials/MaterialHistoryPage')) -const MaterialListPage = lazy (() => import ('@/pages/materials/MaterialListPage')) -const MaterialNewPage = lazy (() => import ('@/pages/materials/MaterialNewPage')) -const MaterialSyncSuppressionsPage = lazy ( +const lazyPage = ( + load: () => Promise<{ default: ComponentType }>, +) => { + let loaded: ComponentType | null = null + let pending: ReturnType | null = null + const preload = () => pending ??= load ().then (module => { + loaded = module.default + return module + }).catch (error => { + pending = null + throw error + }) + const Deferred = lazy (preload) + // A preloaded page must render synchronously on the first hydration pass. + const Page = (props: Props) => createElement (loaded ?? Deferred, props) + return Object.assign (Page, { preload }) +} + + +const GekanatorPage = lazyPage (() => import ('@/pages/GekanatorPage')) +const DeerjikistDetailPage = lazyPage (() => import ('@/pages/deerjikists/DeerjikistDetailPage')) +const MaterialBasePage = lazyPage (() => import ('@/pages/materials/MaterialBasePage')) +const MaterialDetailPage = lazyPage (() => import ('@/pages/materials/MaterialDetailPage')) +const MaterialHistoryPage = lazyPage (() => import ('@/pages/materials/MaterialHistoryPage')) +const MaterialListPage = lazyPage (() => import ('@/pages/materials/MaterialListPage')) +const MaterialNewPage = lazyPage (() => import ('@/pages/materials/MaterialNewPage')) +const MaterialSyncSuppressionsPage = lazyPage ( () => import ('@/pages/materials/MaterialSyncSuppressionsPage')) -const MorePage = lazy (() => import ('@/pages/MorePage')) -const GekanatorUnavailablePage = lazy (() => import ('@/pages/GekanatorUnavailablePage')) -const NicoTagListPage = lazy (() => import ('@/pages/tags/NicoTagListPage')) -const TOSPage = lazy (() => import ('@/pages/TOSPage')) -const HelpChangesPage = lazy (() => import ('@/pages/help/HelpChangesPage')) -const HelpDiffPage = lazy (() => import ('@/pages/help/HelpDiffPage')) -const HelpListPage = lazy (() => import ('@/pages/help/HelpListPage')) -const PostDetailPage = lazy (() => import ('@/pages/posts/PostDetailPage')) -const PostHistoryPage = lazy (() => import ('@/pages/posts/PostHistoryPage')) -const PostNewPage = lazy (() => import ('@/pages/posts/PostNewPage')) -const PostListPage = lazy (() => import ('@/pages/posts/PostListPage')) -const PostSearchPage = lazy (() => import ('@/pages/posts/PostSearchPage')) -const SettingPage = lazy (() => import ('@/pages/users/SettingPage')) -const TagDetailPage = lazy (() => import ('@/pages/tags/TagDetailPage')) -const TagHistoryPage = lazy (() => import ('@/pages/tags/TagHistoryPage')) -const TagListPage = lazy (() => import ('@/pages/tags/TagListPage')) -const TheatreDetailPage = lazy (() => import ('@/pages/theatres/TheatreDetailPage')) -const WikiDetailPage = lazy (() => import ('@/pages/wiki/WikiDetailPage')) -const WikiDiffPage = lazy (() => import ('@/pages/wiki/WikiDiffPage')) -const WikiEditPage = lazy (() => import ('@/pages/wiki/WikiEditPage')) -const WikiHistoryPage = lazy (() => import ('@/pages/wiki/WikiHistoryPage')) -const WikiNewPage = lazy (() => import ('@/pages/wiki/WikiNewPage')) -const WikiSearchPage = lazy (() => import ('@/pages/wiki/WikiSearchPage')) +const MorePage = lazyPage (() => import ('@/pages/MorePage')) +const GekanatorUnavailablePage = lazyPage (() => import ('@/pages/GekanatorUnavailablePage')) +const NicoTagListPage = lazyPage (() => import ('@/pages/tags/NicoTagListPage')) +const TOSPage = lazyPage (() => import ('@/pages/TOSPage')) +const HelpChangesPage = lazyPage (() => import ('@/pages/help/HelpChangesPage')) +const HelpDiffPage = lazyPage (() => import ('@/pages/help/HelpDiffPage')) +const HelpListPage = lazyPage (() => import ('@/pages/help/HelpListPage')) +const PostDetailPage = lazyPage (() => import ('@/pages/posts/PostDetailPage')) +const PostHistoryPage = lazyPage (() => import ('@/pages/posts/PostHistoryPage')) +const PostNewPage = lazyPage (() => import ('@/pages/posts/PostNewPage')) +const PostListPage = lazyPage (() => import ('@/pages/posts/PostListPage')) +const PostSearchPage = lazyPage (() => import ('@/pages/posts/PostSearchPage')) +const SettingPage = lazyPage (() => import ('@/pages/users/SettingPage')) +const TagDetailPage = lazyPage (() => import ('@/pages/tags/TagDetailPage')) +const TagHistoryPage = lazyPage (() => import ('@/pages/tags/TagHistoryPage')) +const TagListPage = lazyPage (() => import ('@/pages/tags/TagListPage')) +const TheatreDetailPage = lazyPage (() => import ('@/pages/theatres/TheatreDetailPage')) +const WikiDetailPage = lazyPage (() => import ('@/pages/wiki/WikiDetailPage')) +const WikiDiffPage = lazyPage (() => import ('@/pages/wiki/WikiDiffPage')) +const WikiEditPage = lazyPage (() => import ('@/pages/wiki/WikiEditPage')) +const WikiHistoryPage = lazyPage (() => import ('@/pages/wiki/WikiHistoryPage')) +const WikiNewPage = lazyPage (() => import ('@/pages/wiki/WikiNewPage')) +const WikiSearchPage = lazyPage (() => import ('@/pages/wiki/WikiSearchPage')) const GekanatorRoute = ({ user }: { user: User | null }) => { @@ -105,72 +124,103 @@ export const pageRoutes = ( { path: '', element: , handle: { redirectTo: '/posts' } }, { path: 'posts', element: , - handle: { switchLocale: switchPostFilterLocale } }, + handle: { preload: PostListPage.preload, switchLocale: switchPostFilterLocale } }, { path: 'posts/new', element: , - handle: { robots: 'noindex' } }, + handle: { preload: PostNewPage.preload, robots: 'noindex' } }, { path: 'posts/search', element: , - handle: { switchLocale: switchPostFilterLocale } }, - { path: 'posts/:id', element: }, - { path: 'posts/changes', element: }, - { path: 'tags', element: }, - { path: 'tags/:id', element: }, - { path: 'tags/:id/deerjikists', element: }, - { path: 'tags/nico', element: }, - { path: 'nico/tags', element: }, - { path: 'tags/changes', element: }, - { path: 'theatres/:id', element: }, - { path: 'materials', element: , children: [ - { index: true, element: }, - { path: 'changes', element: }, - { path: 'new', element: , handle: { robots: 'noindex' } }, + handle: { preload: PostSearchPage.preload, switchLocale: switchPostFilterLocale } }, + { path: 'posts/:id', element: , + handle: { preload: PostDetailPage.preload } }, + { path: 'posts/changes', element: , + handle: { preload: PostHistoryPage.preload } }, + { path: 'tags', element: , handle: { preload: TagListPage.preload } }, + { path: 'tags/:id', element: , + handle: { preload: TagDetailPage.preload } }, + { path: 'tags/:id/deerjikists', element: , + handle: { preload: DeerjikistDetailPage.preload } }, + { path: 'tags/nico', element: , + handle: { preload: NicoTagListPage.preload } }, + { path: 'nico/tags', element: , + handle: { preload: NicoTagListPage.preload } }, + { path: 'tags/changes', element: , + handle: { preload: TagHistoryPage.preload } }, + { path: 'theatres/:id', element: , + handle: { preload: TheatreDetailPage.preload } }, + { path: 'materials', element: , + handle: { preload: MaterialBasePage.preload }, children: [ + { index: true, element: , + handle: { preload: MaterialListPage.preload } }, + { path: 'changes', element: , + handle: { preload: MaterialHistoryPage.preload } }, + { path: 'new', element: , + handle: { preload: MaterialNewPage.preload, robots: 'noindex' } }, { path: 'suppressions', element: , - handle: { robots: 'noindex', authentication: 'user' } }, - { path: ':id', element: }] }, - { path: 'wiki', element: }, + handle: { preload: MaterialSyncSuppressionsPage.preload, + robots: 'noindex', authentication: 'user' } }, + { path: ':id', element: , + handle: { preload: MaterialDetailPage.preload } }] }, + { path: 'wiki', element: , + handle: { preload: WikiSearchPage.preload } }, { path: 'wiki/:title', element: , - handle: { switchLocale: switchWikiLocale } }, + handle: { preload: WikiDetailPage.preload, switchLocale: switchWikiLocale } }, { path: 'wiki/new', element: , - handle: { robots: 'noindex' } }, + handle: { preload: WikiNewPage.preload, robots: 'noindex' } }, { path: 'wiki/:id/edit', element: , - handle: { robots: 'noindex', + handle: { preload: WikiEditPage.preload, robots: 'noindex', switchLocale: request => switchWikiLocale (request, 'edit') } }, { path: 'wiki/:id/diff', element: , - handle: { switchLocale: request => switchWikiLocale (request, 'diff') } }, + handle: { preload: WikiDiffPage.preload, + switchLocale: request => switchWikiLocale (request, 'diff') } }, { path: 'wiki/changes', element: , - handle: { switchLocale: request => switchWikiLocale (request, 'history') } }, - { path: 'help', element: }, - { path: 'help/changes', element: }, - { path: 'help/:identifier/diff', element: }, + handle: { preload: WikiHistoryPage.preload, + switchLocale: request => switchWikiLocale (request, 'history') } }, + { path: 'help', element: , handle: { preload: HelpListPage.preload } }, + { path: 'help/changes', element: , + handle: { preload: HelpChangesPage.preload } }, + { path: 'help/:identifier/diff', element: , + handle: { preload: HelpDiffPage.preload } }, { id: 'help.detail', path: 'help/:identifier', element: , handle: { prepare: prepareHelpPage } }, { path: 'users/settings', element: , - handle: { robots: 'noindex', authentication: 'user' } }, + handle: { preload: SettingPage.preload, robots: 'noindex', authentication: 'user' } }, { path: 'settings', element: , handle: { redirectTo: '/users/settings' } }, - { path: 'tos', element: }, + { path: 'tos', element: , handle: { preload: TOSPage.preload } }, { id: 'gekanator', path: 'gekanator', element: , - handle: { locale: 'ja' } }, - { path: 'more', element: }, + handle: { locale: 'ja', preload: locale => locale === 'ja' + ? GekanatorPage.preload () : GekanatorUnavailablePage.preload () } }, + { path: 'more', element: , handle: { preload: MorePage.preload } }, { path: '*', element: , handle: { notFound: true } }] +const matchPageRoutes = (pathname: string) => + matchRoutes (pageRoutes (null, () => { }), unlocalisedPath (pathname)) + + +export const preloadPageRoute = async ( + pathname: string, + locale: LocaleCode, +): Promise => { + await Promise.all ((matchPageRoutes (pathname) ?? []) + .map (match => match.route.handle?.preload?.(locale))) +} + + export const resolvePageRoute = ( pathname: string, ): RouteMatch | null => { - const match = matchRoutes ( - pageRoutes (null, () => { }), unlocalisedPath (pathname))?.at (-1) + const match = matchPageRoutes (pathname)?.at (-1) return (match == null || match.route.handle?.notFound) ? null : match } -const RouteTransitionWrapper = ({ animationMode, user, setUser }: { - animationMode: ClientAnimationMode - user: User | null - setUser: Dispatch> }) => { +const RouteTransitionWrapper = ({ user, setUser }: { + user: User | null + setUser: Dispatch> }) => { const routes = useRoutes (pageRoutes (user, setUser)) return ( - + {routes} ) } @@ -299,7 +349,6 @@ const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => { className="relative flex h-dvh w-full flex-col overflow-y-hidden"> diff --git a/frontend/src/components/TopNav.tsx b/frontend/src/components/TopNav.tsx index 9a0033f..5f31ef1 100644 --- a/frontend/src/components/TopNav.tsx +++ b/frontend/src/components/TopNav.tsx @@ -146,7 +146,8 @@ const TopNav: FC = ({ user, locales, resolveLocaleSwitch }) => { }, [location.key]) const behaviourSettings = useClientBehaviourSettings () const animationMode = behaviourSettings.animation ?? 'normal' - const animationsOff = animationMode === 'off' + const [layoutReady, setLayoutReady] = useState (false) + const animationsOff = animationMode === 'off' || !layoutReady const reducedAnimations = animationMode === 'reduced' const dirRef = useRef<(-1) | 1> (1) @@ -191,7 +192,7 @@ const TopNav: FC = ({ user, locales, resolveLocaleSwitch }) => { visible: false }) const [menuOpen, setMenuOpen] = useState (false) const menuToggleLabel = message (menuOpen ? 'dialogue.close' : 'navigation:menuToggle') - const [desktop, setDesktop] = useState (false) + const [desktop, setDesktop] = useState (null) const [density, setDensity] = useState ('auto') const [moreVsbl, setMoreVsbl] = useState (false) const [openItemIdx, setOpenItemIdx] = useState (-1) @@ -347,6 +348,27 @@ const TopNav: FC = ({ user, locales, resolveLocaleSwitch }) => { } }, [activeIdx, moreVsbl, desktop, density, user?.name, locale, locales, menuLabels]) + useEffect (() => { + if (desktop == null || layoutReady) + return + let active = true + let frame = 0 + void (document.fonts?.ready ?? Promise.resolve ()).then (() => { + if (!active) + return + frame = requestAnimationFrame (() => { + frame = requestAnimationFrame (() => { + if (active) + setLayoutReady (true) + }) + }) + }) + return () => { + active = false + cancelAnimationFrame (frame) + } + }, [desktop, density, layoutReady]) + useLayoutEffect (() => { measure (moreVsbl ? -1 : activeIdx) if (!(desktop)) @@ -432,7 +454,8 @@ const TopNav: FC = ({ user, locales, resolveLocaleSwitch }) => { return (
+ data-desktop={desktop ?? undefined} data-density={density} + data-layout-ready={layoutReady}>