このコミットが含まれているのは:
2026-10-20 02:42:14 +09:00
コミット e8d4c5ad09
7個のファイルの変更、256行の追加、86行の削除
+87 -4
ファイルの表示
@@ -470,7 +470,7 @@ test ('hydrate every route with stored browser preferences', {
ws = new WebSocket (endpoint) ws = new WebSocket (endpoint)
await new Promise (resolve => ws.on ('open', resolve)) await new Promise (resolve => ws.on ('open', resolve))
const pending = new Map () 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 send = (method, params = {}, sessionId) => new Promise ((resolve, reject) => {
const id = ++sequence const id = ++sequence
pending.set (id, { resolve, reject }) pending.set (id, { resolve, reject })
@@ -487,7 +487,12 @@ test ('hydrate every route with stored browser preferences', {
const { requestId, request } = message.params const { requestId, request } = message.params
const url = new URL (request.url) const url = new URL (request.url)
if (url.origin === httpOrigin) { 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 return
} }
const data = responseFor (url) const data = responseFor (url)
@@ -504,6 +509,9 @@ test ('hydrate every route with stored browser preferences', {
} else if (message.method === 'Runtime.consoleAPICalled' } else if (message.method === 'Runtime.consoleAPICalled'
&& message.params.type === 'error') { && message.params.type === 'error') {
errors.push (message.params.args.map (arg => arg.value ?? arg.description).join (' ')) 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' }) 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 ('Runtime.enable', {}, sessionId)
await send ('Page.enable', {}, sessionId) await send ('Page.enable', {}, sessionId)
await send ('Network.enable', {}, sessionId) await send ('Network.enable', {}, sessionId)
await send ('Network.setCacheDisabled', { cacheDisabled: true }, sessionId)
await send ('Fetch.enable', { patterns: [{ urlPattern: '*' }] }, sessionId) await send ('Fetch.enable', { patterns: [{ urlPattern: '*' }] }, sessionId)
const cookie = await authenticatedCookie () const cookie = await authenticatedCookie ()
await send ('Network.setExtraHTTPHeaders', { headers: { 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('user_code','test-recovery-secret');
localStorage.setItem('btrc_hub.client_settings',JSON.stringify({ localStorage.setItem('btrc_hub.client_settings',JSON.stringify({
lists:{postList:{limit:50},tagList:{limit:50},postSearch:{limit:50}}, 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(()=>{ new MutationObserver(()=>{
const child=document.getElementById('root')?.firstElementChild; const child=document.getElementById('root')?.firstElementChild;
if(child&&!window.__initialSSRNode)window.__initialSSRNode=child; 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', { const evaluate = async expression => (await send ('Runtime.evaluate', {
expression, returnByValue: true }, sessionId)).result.value expression, returnByValue: true }, sessionId)).result.value
for (const route of routes.filter (entry => !entry.handle?.redirectTo)) { 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 { } finally {
ws?.close () ws?.close ()
chrome.kill () chrome.kill ()
+122 -73
ファイルの表示
@@ -1,6 +1,7 @@
import { useQuery, useQueryClient } from '@tanstack/react-query' import { useQuery, useQueryClient } from '@tanstack/react-query'
import { AnimatePresence, LayoutGroup, MotionConfig, motion } from 'framer-motion' 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 { useTranslation } from 'react-i18next'
import { createBrowserRouter, import { createBrowserRouter,
matchRoutes, matchRoutes,
@@ -41,46 +42,64 @@ import NotFound from '@/pages/NotFound'
import HelpDetailPage from '@/pages/help/HelpDetailPage' import HelpDetailPage from '@/pages/help/HelpDetailPage'
import ServiceUnavailable from '@/pages/ServiceUnavailable' 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 { RouteMatch } from 'react-router-dom'
import type { ClientAnimationMode } from '@/lib/settings'
import type { AvailableLocale, LocaleCode } from '@/lib/locales' import type { AvailableLocale, LocaleCode } from '@/lib/locales'
import type { PageRoute } from '@/lib/routeLocales' import type { PageRoute } from '@/lib/routeLocales'
import type { User } from '@/types' import type { User } from '@/types'
const GekanatorPage = lazy (() => import ('@/pages/GekanatorPage')) const lazyPage = <Props extends object,> (
const DeerjikistDetailPage = lazy (() => import ('@/pages/deerjikists/DeerjikistDetailPage')) load: () => Promise<{ default: ComponentType<Props> }>,
const MaterialBasePage = lazy (() => import ('@/pages/materials/MaterialBasePage')) ) => {
const MaterialDetailPage = lazy (() => import ('@/pages/materials/MaterialDetailPage')) let loaded: ComponentType<Props> | null = null
const MaterialHistoryPage = lazy (() => import ('@/pages/materials/MaterialHistoryPage')) let pending: ReturnType<typeof load> | null = null
const MaterialListPage = lazy (() => import ('@/pages/materials/MaterialListPage')) const preload = () => pending ??= load ().then (module => {
const MaterialNewPage = lazy (() => import ('@/pages/materials/MaterialNewPage')) loaded = module.default
const MaterialSyncSuppressionsPage = lazy ( 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')) () => import ('@/pages/materials/MaterialSyncSuppressionsPage'))
const MorePage = lazy (() => import ('@/pages/MorePage')) const MorePage = lazyPage (() => import ('@/pages/MorePage'))
const GekanatorUnavailablePage = lazy (() => import ('@/pages/GekanatorUnavailablePage')) const GekanatorUnavailablePage = lazyPage (() => import ('@/pages/GekanatorUnavailablePage'))
const NicoTagListPage = lazy (() => import ('@/pages/tags/NicoTagListPage')) const NicoTagListPage = lazyPage (() => import ('@/pages/tags/NicoTagListPage'))
const TOSPage = lazy (() => import ('@/pages/TOSPage')) const TOSPage = lazyPage (() => import ('@/pages/TOSPage'))
const HelpChangesPage = lazy (() => import ('@/pages/help/HelpChangesPage')) const HelpChangesPage = lazyPage (() => import ('@/pages/help/HelpChangesPage'))
const HelpDiffPage = lazy (() => import ('@/pages/help/HelpDiffPage')) const HelpDiffPage = lazyPage (() => import ('@/pages/help/HelpDiffPage'))
const HelpListPage = lazy (() => import ('@/pages/help/HelpListPage')) const HelpListPage = lazyPage (() => import ('@/pages/help/HelpListPage'))
const PostDetailPage = lazy (() => import ('@/pages/posts/PostDetailPage')) const PostDetailPage = lazyPage (() => import ('@/pages/posts/PostDetailPage'))
const PostHistoryPage = lazy (() => import ('@/pages/posts/PostHistoryPage')) const PostHistoryPage = lazyPage (() => import ('@/pages/posts/PostHistoryPage'))
const PostNewPage = lazy (() => import ('@/pages/posts/PostNewPage')) const PostNewPage = lazyPage (() => import ('@/pages/posts/PostNewPage'))
const PostListPage = lazy (() => import ('@/pages/posts/PostListPage')) const PostListPage = lazyPage (() => import ('@/pages/posts/PostListPage'))
const PostSearchPage = lazy (() => import ('@/pages/posts/PostSearchPage')) const PostSearchPage = lazyPage (() => import ('@/pages/posts/PostSearchPage'))
const SettingPage = lazy (() => import ('@/pages/users/SettingPage')) const SettingPage = lazyPage (() => import ('@/pages/users/SettingPage'))
const TagDetailPage = lazy (() => import ('@/pages/tags/TagDetailPage')) const TagDetailPage = lazyPage (() => import ('@/pages/tags/TagDetailPage'))
const TagHistoryPage = lazy (() => import ('@/pages/tags/TagHistoryPage')) const TagHistoryPage = lazyPage (() => import ('@/pages/tags/TagHistoryPage'))
const TagListPage = lazy (() => import ('@/pages/tags/TagListPage')) const TagListPage = lazyPage (() => import ('@/pages/tags/TagListPage'))
const TheatreDetailPage = lazy (() => import ('@/pages/theatres/TheatreDetailPage')) const TheatreDetailPage = lazyPage (() => import ('@/pages/theatres/TheatreDetailPage'))
const WikiDetailPage = lazy (() => import ('@/pages/wiki/WikiDetailPage')) const WikiDetailPage = lazyPage (() => import ('@/pages/wiki/WikiDetailPage'))
const WikiDiffPage = lazy (() => import ('@/pages/wiki/WikiDiffPage')) const WikiDiffPage = lazyPage (() => import ('@/pages/wiki/WikiDiffPage'))
const WikiEditPage = lazy (() => import ('@/pages/wiki/WikiEditPage')) const WikiEditPage = lazyPage (() => import ('@/pages/wiki/WikiEditPage'))
const WikiHistoryPage = lazy (() => import ('@/pages/wiki/WikiHistoryPage')) const WikiHistoryPage = lazyPage (() => import ('@/pages/wiki/WikiHistoryPage'))
const WikiNewPage = lazy (() => import ('@/pages/wiki/WikiNewPage')) const WikiNewPage = lazyPage (() => import ('@/pages/wiki/WikiNewPage'))
const WikiSearchPage = lazy (() => import ('@/pages/wiki/WikiSearchPage')) const WikiSearchPage = lazyPage (() => import ('@/pages/wiki/WikiSearchPage'))
const GekanatorRoute = ({ user }: { user: User | null }) => { const GekanatorRoute = ({ user }: { user: User | null }) => {
@@ -105,72 +124,103 @@ export const pageRoutes = (
{ path: '', element: <LocaleRedirect to="/posts"/>, { path: '', element: <LocaleRedirect to="/posts"/>,
handle: { redirectTo: '/posts' } }, handle: { redirectTo: '/posts' } },
{ path: 'posts', element: <PostListPage/>, { path: 'posts', element: <PostListPage/>,
handle: { switchLocale: switchPostFilterLocale } }, handle: { preload: PostListPage.preload, switchLocale: switchPostFilterLocale } },
{ path: 'posts/new', element: <PostNewPage user={user}/>, { path: 'posts/new', element: <PostNewPage user={user}/>,
handle: { robots: 'noindex' } }, handle: { preload: PostNewPage.preload, robots: 'noindex' } },
{ path: 'posts/search', element: <PostSearchPage/>, { path: 'posts/search', element: <PostSearchPage/>,
handle: { switchLocale: switchPostFilterLocale } }, handle: { preload: PostSearchPage.preload, switchLocale: switchPostFilterLocale } },
{ path: 'posts/:id', element: <PostDetailRoute user={user}/> }, { path: 'posts/:id', element: <PostDetailRoute user={user}/>,
{ path: 'posts/changes', element: <PostHistoryPage/> }, handle: { preload: PostDetailPage.preload } },
{ path: 'tags', element: <TagListPage/> }, { path: 'posts/changes', element: <PostHistoryPage/>,
{ path: 'tags/:id', element: <TagDetailPage/> }, handle: { preload: PostHistoryPage.preload } },
{ path: 'tags/:id/deerjikists', element: <DeerjikistDetailPage/> }, { path: 'tags', element: <TagListPage/>, handle: { preload: TagListPage.preload } },
{ path: 'tags/nico', element: <NicoTagListPage user={user}/> }, { path: 'tags/:id', element: <TagDetailPage/>,
{ path: 'nico/tags', element: <NicoTagListPage user={user}/> }, handle: { preload: TagDetailPage.preload } },
{ path: 'tags/changes', element: <TagHistoryPage/> }, { path: 'tags/:id/deerjikists', element: <DeerjikistDetailPage/>,
{ path: 'theatres/:id', element: <TheatreDetailPage user={user}/> }, handle: { preload: DeerjikistDetailPage.preload } },
{ path: 'materials', element: <MaterialBasePage/>, children: [ { path: 'tags/nico', element: <NicoTagListPage user={user}/>,
{ index: true, element: <MaterialListPage/> }, handle: { preload: NicoTagListPage.preload } },
{ path: 'changes', element: <MaterialHistoryPage/> }, { path: 'nico/tags', element: <NicoTagListPage user={user}/>,
{ path: 'new', element: <MaterialNewPage/>, handle: { robots: 'noindex' } }, handle: { preload: NicoTagListPage.preload } },
{ path: 'tags/changes', element: <TagHistoryPage/>,
handle: { preload: TagHistoryPage.preload } },
{ path: 'theatres/:id', element: <TheatreDetailPage user={user}/>,
handle: { preload: TheatreDetailPage.preload } },
{ path: 'materials', element: <MaterialBasePage/>,
handle: { preload: MaterialBasePage.preload }, children: [
{ index: true, element: <MaterialListPage/>,
handle: { preload: MaterialListPage.preload } },
{ path: 'changes', element: <MaterialHistoryPage/>,
handle: { preload: MaterialHistoryPage.preload } },
{ path: 'new', element: <MaterialNewPage/>,
handle: { preload: MaterialNewPage.preload, robots: 'noindex' } },
{ path: 'suppressions', element: <MaterialSyncSuppressionsPage/>, { path: 'suppressions', element: <MaterialSyncSuppressionsPage/>,
handle: { robots: 'noindex', authentication: 'user' } }, handle: { preload: MaterialSyncSuppressionsPage.preload,
{ path: ':id', element: <MaterialDetailPage/> }] }, robots: 'noindex', authentication: 'user' } },
{ path: 'wiki', element: <WikiSearchPage/> }, { path: ':id', element: <MaterialDetailPage/>,
handle: { preload: MaterialDetailPage.preload } }] },
{ path: 'wiki', element: <WikiSearchPage/>,
handle: { preload: WikiSearchPage.preload } },
{ path: 'wiki/:title', element: <WikiDetailPage/>, { path: 'wiki/:title', element: <WikiDetailPage/>,
handle: { switchLocale: switchWikiLocale } }, handle: { preload: WikiDetailPage.preload, switchLocale: switchWikiLocale } },
{ path: 'wiki/new', element: <WikiNewPage user={user}/>, { path: 'wiki/new', element: <WikiNewPage user={user}/>,
handle: { robots: 'noindex' } }, handle: { preload: WikiNewPage.preload, robots: 'noindex' } },
{ path: 'wiki/:id/edit', element: <WikiEditPage user={user}/>, { path: 'wiki/:id/edit', element: <WikiEditPage user={user}/>,
handle: { robots: 'noindex', handle: { preload: WikiEditPage.preload, robots: 'noindex',
switchLocale: request => switchWikiLocale (request, 'edit') } }, switchLocale: request => switchWikiLocale (request, 'edit') } },
{ path: 'wiki/:id/diff', element: <WikiDiffPage/>, { path: 'wiki/:id/diff', element: <WikiDiffPage/>,
handle: { switchLocale: request => switchWikiLocale (request, 'diff') } }, handle: { preload: WikiDiffPage.preload,
switchLocale: request => switchWikiLocale (request, 'diff') } },
{ path: 'wiki/changes', element: <WikiHistoryPage/>, { path: 'wiki/changes', element: <WikiHistoryPage/>,
handle: { switchLocale: request => switchWikiLocale (request, 'history') } }, handle: { preload: WikiHistoryPage.preload,
{ path: 'help', element: <HelpListPage/> }, switchLocale: request => switchWikiLocale (request, 'history') } },
{ path: 'help/changes', element: <HelpChangesPage/> }, { path: 'help', element: <HelpListPage/>, handle: { preload: HelpListPage.preload } },
{ path: 'help/:identifier/diff', element: <HelpDiffPage/> }, { path: 'help/changes', element: <HelpChangesPage/>,
handle: { preload: HelpChangesPage.preload } },
{ path: 'help/:identifier/diff', element: <HelpDiffPage/>,
handle: { preload: HelpDiffPage.preload } },
{ id: 'help.detail', path: 'help/:identifier', element: <HelpDetailPage/>, { id: 'help.detail', path: 'help/:identifier', element: <HelpDetailPage/>,
handle: { prepare: prepareHelpPage } }, handle: { prepare: prepareHelpPage } },
{ path: 'users/settings', element: <SettingPage user={user} setUser={setUser}/>, { path: 'users/settings', element: <SettingPage user={user} setUser={setUser}/>,
handle: { robots: 'noindex', authentication: 'user' } }, handle: { preload: SettingPage.preload, robots: 'noindex', authentication: 'user' } },
{ path: 'settings', element: <LocaleRedirect to="/users/settings"/>, { path: 'settings', element: <LocaleRedirect to="/users/settings"/>,
handle: { redirectTo: '/users/settings' } }, handle: { redirectTo: '/users/settings' } },
{ path: 'tos', element: <TOSPage/> }, { path: 'tos', element: <TOSPage/>, handle: { preload: TOSPage.preload } },
{ id: 'gekanator', path: 'gekanator', element: <GekanatorRoute user={user}/>, { id: 'gekanator', path: 'gekanator', element: <GekanatorRoute user={user}/>,
handle: { locale: 'ja' } }, handle: { locale: 'ja', preload: locale => locale === 'ja'
{ path: 'more', element: <MorePage/> }, ? GekanatorPage.preload () : GekanatorUnavailablePage.preload () } },
{ path: 'more', element: <MorePage/>, handle: { preload: MorePage.preload } },
{ path: '*', element: <NotFound/>, handle: { notFound: true } }] { path: '*', element: <NotFound/>, handle: { notFound: true } }]
const matchPageRoutes = (pathname: string) =>
matchRoutes<PageRoute> (pageRoutes (null, () => { }), unlocalisedPath (pathname))
export const preloadPageRoute = async (
pathname: string,
locale: LocaleCode,
): Promise<void> => {
await Promise.all ((matchPageRoutes (pathname) ?? [])
.map (match => match.route.handle?.preload?.(locale)))
}
export const resolvePageRoute = ( export const resolvePageRoute = (
pathname: string, pathname: string,
): RouteMatch<string, PageRoute> | null => { ): RouteMatch<string, PageRoute> | null => {
const match = matchRoutes<PageRoute> ( const match = matchPageRoutes (pathname)?.at (-1)
pageRoutes (null, () => { }), unlocalisedPath (pathname))?.at (-1)
return (match == null || match.route.handle?.notFound) ? null : match return (match == null || match.route.handle?.notFound) ? null : match
} }
const RouteTransitionWrapper = ({ animationMode, user, setUser }: { const RouteTransitionWrapper = ({ user, setUser }: {
animationMode: ClientAnimationMode user: User | null
user: User | null setUser: Dispatch<SetStateAction<User | null>> }) => {
setUser: Dispatch<SetStateAction<User | null>> }) => {
const routes = useRoutes (pageRoutes (user, setUser)) const routes = useRoutes (pageRoutes (user, setUser))
return ( return (
<AnimatePresence mode="wait" initial={animationMode !== 'off'}> <AnimatePresence mode="wait" initial={false}>
<Suspense fallback={null}>{routes}</Suspense> <Suspense fallback={null}>{routes}</Suspense>
</AnimatePresence>) </AnimatePresence>)
} }
@@ -299,7 +349,6 @@ const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => {
className="relative flex h-dvh w-full flex-col overflow-y-hidden"> className="relative flex h-dvh w-full flex-col overflow-y-hidden">
<TopNav user={user} locales={locales} resolveLocaleSwitch={switchLocale}/> <TopNav user={user} locales={locales} resolveLocaleSwitch={switchLocale}/>
<RouteTransitionWrapper <RouteTransitionWrapper
animationMode={animationMode}
user={user} user={user}
setUser={setUser}/> setUser={setUser}/>
</motion.div> </motion.div>
+33 -7
ファイルの表示
@@ -146,7 +146,8 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
}, [location.key]) }, [location.key])
const behaviourSettings = useClientBehaviourSettings () const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal' const animationMode = behaviourSettings.animation ?? 'normal'
const animationsOff = animationMode === 'off' const [layoutReady, setLayoutReady] = useState (false)
const animationsOff = animationMode === 'off' || !layoutReady
const reducedAnimations = animationMode === 'reduced' const reducedAnimations = animationMode === 'reduced'
const dirRef = useRef<(-1) | 1> (1) const dirRef = useRef<(-1) | 1> (1)
@@ -191,7 +192,7 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
visible: false }) visible: false })
const [menuOpen, setMenuOpen] = useState (false) const [menuOpen, setMenuOpen] = useState (false)
const menuToggleLabel = message (menuOpen ? 'dialogue.close' : 'navigation:menuToggle') const menuToggleLabel = message (menuOpen ? 'dialogue.close' : 'navigation:menuToggle')
const [desktop, setDesktop] = useState (false) const [desktop, setDesktop] = useState<boolean | null> (null)
const [density, setDensity] = useState<NavDensity> ('auto') const [density, setDensity] = useState<NavDensity> ('auto')
const [moreVsbl, setMoreVsbl] = useState (false) const [moreVsbl, setMoreVsbl] = useState (false)
const [openItemIdx, setOpenItemIdx] = useState (-1) const [openItemIdx, setOpenItemIdx] = useState (-1)
@@ -347,6 +348,27 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
} }
}, [activeIdx, moreVsbl, desktop, density, user?.name, locale, locales, menuLabels]) }, [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 (() => { useLayoutEffect (() => {
measure (moreVsbl ? -1 : activeIdx) measure (moreVsbl ? -1 : activeIdx)
if (!(desktop)) if (!(desktop))
@@ -432,7 +454,8 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
return ( return (
<div ref={layoutRef} className="top-nav-layout w-full min-w-0 shrink-0" <div ref={layoutRef} className="top-nav-layout w-full min-w-0 shrink-0"
data-desktop={desktop} data-density={density}> data-desktop={desktop ?? undefined} data-density={density}
data-layout-ready={layoutReady}>
<nav ref={barRef} lang={uiMessageLanguage (locale, 'posts')} <nav ref={barRef} lang={uiMessageLanguage (locale, 'posts')}
className="top-nav-root top-nav-bar flex justify-between items-center w-full"> className="top-nav-root top-nav-bar flex justify-between items-center w-full">
<div className="top-nav-primary flex shrink-0 items-center h-12"> <div className="top-nav-primary flex shrink-0 items-center h-12">
@@ -458,15 +481,17 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
{visibleMenu.map ((item, i) => ( {visibleMenu.map ((item, i) => (
<motion.div <motion.div
key={item.to} key={item.to}
initial={false}
className="shrink-0 whitespace-nowrap" className="shrink-0 whitespace-nowrap"
{...(animationsOff {...(animationsOff
? { } ? { }
: { layoutId: `menu-${ menu.indexOf (item) }` })} : { layoutId: `menu-${ menu.indexOf (item) }` })}
onLayoutAnimationComplete={() => measure (highlightTargetRef.current)} onLayoutAnimationComplete={() => measure (highlightTargetRef.current)}
animate={{ opacity: moreVsbl ? 0 : 1 }} animate={layoutReady ? { opacity: moreVsbl ? 0 : 1 } : undefined}
transition={{ opacity: opacityTransition, transition={{ opacity: opacityTransition,
layout: topNavTransition }} layout: topNavTransition }}
style={{ pointerEvents: moreVsbl ? 'none' : 'auto' }} style={{ pointerEvents: moreVsbl ? 'none' : 'auto',
opacity: layoutReady ? undefined : (moreVsbl ? 0 : 1) }}
onMouseEnter={() => setMoreVsbl (false)}> onMouseEnter={() => setMoreVsbl (false)}>
<PrefetchLink <PrefetchLink
to={item.to} to={item.to}
@@ -522,16 +547,17 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
<AnimatePresence initial={false}> <AnimatePresence initial={false}>
<motion.div <motion.div
key="submenu-shell" key="submenu-shell"
initial={false}
{...(animationsOff ? { } {...(animationsOff ? { }
: { layout: true })} : { layout: true })}
className="top-nav-desktop-submenu relative z-20 shrink-0 className="top-nav-desktop-submenu relative z-20 shrink-0
overflow-hidden top-nav-submenu" overflow-hidden top-nav-submenu"
animate={{ height: submenuHeight }} animate={{ height: desktop == null ? 'auto' : submenuHeight }}
onMouseLeave={() => { onMouseLeave={() => {
if (moreVsbl) if (moreVsbl)
setMoreVsbl (false) setMoreVsbl (false)
}} }}
transition={{ layout: topNavTransition }} transition={{ ...topNavTransition, layout: topNavTransition }}
onAnimationComplete={() => { onAnimationComplete={() => {
measure (highlightTargetRef.current) measure (highlightTargetRef.current)
}} }}
+10
ファイルの表示
@@ -345,12 +345,14 @@ body
max-width: none; max-width: none;
} }
.top-nav-layout:not([data-desktop]) .top-nav-locale-name,
.top-nav-layout[data-desktop='true'] .top-nav-locale-name, .top-nav-layout[data-desktop='true'] .top-nav-locale-name,
.top-nav-layout[data-measuring] .top-nav-locale-name .top-nav-layout[data-measuring] .top-nav-locale-name
{ {
display: inline; display: inline;
} }
.top-nav-layout:not([data-desktop]) .top-nav-account .top-nav-user-desktop,
.top-nav-layout[data-desktop='true'] .top-nav-account .top-nav-user-desktop, .top-nav-layout[data-desktop='true'] .top-nav-account .top-nav-user-desktop,
.top-nav-layout[data-measuring] .top-nav-account .top-nav-user-desktop .top-nav-layout[data-measuring] .top-nav-account .top-nav-user-desktop
{ {
@@ -362,11 +364,14 @@ body
font-size: 1.125rem; font-size: 1.125rem;
} }
.top-nav-layout:not([data-desktop]) .top-nav-root,
.top-nav-layout[data-desktop='true'] .top-nav-root .top-nav-layout[data-desktop='true'] .top-nav-root
{ {
background: var(--top-nav-root-bg-desktop); background: var(--top-nav-root-bg-desktop);
} }
.top-nav-layout:not([data-desktop]) .top-nav-desktop-menu,
.top-nav-layout:not([data-desktop]) .top-nav-account,
.top-nav-layout[data-desktop='true'] .top-nav-desktop-menu, .top-nav-layout[data-desktop='true'] .top-nav-desktop-menu,
.top-nav-layout[data-measuring] .top-nav-desktop-menu, .top-nav-layout[data-measuring] .top-nav-desktop-menu,
.top-nav-layout[data-desktop='true'] .top-nav-account, .top-nav-layout[data-desktop='true'] .top-nav-account,
@@ -375,6 +380,8 @@ body
display: flex; display: flex;
} }
.top-nav-layout:not([data-desktop]) .top-nav-user-desktop,
.top-nav-layout:not([data-desktop]) .top-nav-desktop-submenu,
.top-nav-layout[data-desktop='true'] .top-nav-user-desktop, .top-nav-layout[data-desktop='true'] .top-nav-user-desktop,
.top-nav-layout[data-measuring] .top-nav-user-desktop, .top-nav-layout[data-measuring] .top-nav-user-desktop,
.top-nav-layout[data-desktop='true'] .top-nav-desktop-submenu .top-nav-layout[data-desktop='true'] .top-nav-desktop-submenu
@@ -382,6 +389,9 @@ body
display: block; display: block;
} }
.top-nav-layout:not([data-desktop]) .top-nav-mobile-controls,
.top-nav-layout:not([data-desktop]) .top-nav-mobile-menu,
.top-nav-layout:not([data-desktop]) .top-nav-user-mobile,
.top-nav-layout[data-desktop='true'] .top-nav-mobile-toggle, .top-nav-layout[data-desktop='true'] .top-nav-mobile-toggle,
.top-nav-layout[data-measuring] .top-nav-mobile-toggle, .top-nav-layout[data-measuring] .top-nav-mobile-toggle,
.top-nav-layout[data-desktop='true'] .top-nav-mobile-controls, .top-nav-layout[data-desktop='true'] .top-nav-mobile-controls,
+1
ファイルの表示
@@ -16,6 +16,7 @@ export type LocaleSwitchRequest = {
targetLocale: LocaleCode } targetLocale: LocaleCode }
export type PageRouteHandle = { export type PageRouteHandle = {
preload?: (locale: LocaleCode) => Promise<unknown>
prepare?: (request: PagePreparation) => Promise<number> prepare?: (request: PagePreparation) => Promise<number>
switchLocale?: (request: LocaleSwitchRequest) => Promise<string> switchLocale?: (request: LocaleSwitchRequest) => Promise<string>
locale?: LocaleCode locale?: LocaleCode
+1 -1
ファイルの表示
@@ -35,7 +35,7 @@
"hideTags": "▲▲▲ シロシ ソモ ヌカㇻ ▲▲▲", "hideTags": "▲▲▲ シロシ ソモ ヌカㇻ ▲▲▲",
"showTags": "▼▼▼ シロシ ヌカㇻ ▼▼▼", "showTags": "▼▼▼ シロシ ヌカㇻ ▼▼▼",
"resultsCount": "{{count}} ペ", "resultsCount": "{{count}} ペ",
"menu": "イヌㇺケ オピッタ", "menu": "Menu",
"anonymous": "レサㇰ ユㇰ ニチカ ウタㇻ", "anonymous": "レサㇰ ユㇰ ニチカ ウタㇻ",
"bot": "Bot カㇻ", "bot": "Bot カㇻ",
"deprecatedSuffix": "(エイワンケ ソモ エアㇱカイ)", "deprecatedSuffix": "(エイワンケ ソモ エアㇱカイ)",
+2 -1
ファイルの表示
@@ -4,7 +4,7 @@ import { HelmetProvider } from 'react-helmet-async'
import { I18nextProvider } from 'react-i18next' import { I18nextProvider } from 'react-i18next'
import '@/index.css' import '@/index.css'
import App, { resolvePageRoute } from '@/App' import App, { preloadPageRoute, resolvePageRoute } from '@/App'
import { createUII18n } from '@/lib/i18n' import { createUII18n } from '@/lib/i18n'
import { currentViewingLocale } from '@/lib/locales' import { currentViewingLocale } from '@/lib/locales'
import { localesKeys } from '@/lib/queryKeys' import { localesKeys } from '@/lib/queryKeys'
@@ -80,6 +80,7 @@ const start = () => {
if (status !== data.status) if (status !== data.status)
throw new Error ('Server and browser page versions differ') throw new Error ('Server and browser page versions differ')
} }
await preloadPageRoute (url.pathname, currentViewingLocale ())
mount (true) mount (true)
mounted = true mounted = true
for (const event of ['online', 'focus', 'pageshow']) for (const event of ['online', 'focus', 'pageshow'])