このコミットが含まれているのは:
@@ -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) {
|
||||
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 ()
|
||||
|
||||
+120
-71
@@ -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 = <Props extends object,> (
|
||||
load: () => Promise<{ default: ComponentType<Props> }>,
|
||||
) => {
|
||||
let loaded: ComponentType<Props> | null = null
|
||||
let pending: ReturnType<typeof load> | 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: <LocaleRedirect to="/posts"/>,
|
||||
handle: { redirectTo: '/posts' } },
|
||||
{ path: 'posts', element: <PostListPage/>,
|
||||
handle: { switchLocale: switchPostFilterLocale } },
|
||||
handle: { preload: PostListPage.preload, switchLocale: switchPostFilterLocale } },
|
||||
{ path: 'posts/new', element: <PostNewPage user={user}/>,
|
||||
handle: { robots: 'noindex' } },
|
||||
handle: { preload: PostNewPage.preload, robots: 'noindex' } },
|
||||
{ path: 'posts/search', element: <PostSearchPage/>,
|
||||
handle: { switchLocale: switchPostFilterLocale } },
|
||||
{ path: 'posts/:id', element: <PostDetailRoute user={user}/> },
|
||||
{ path: 'posts/changes', element: <PostHistoryPage/> },
|
||||
{ path: 'tags', element: <TagListPage/> },
|
||||
{ path: 'tags/:id', element: <TagDetailPage/> },
|
||||
{ path: 'tags/:id/deerjikists', element: <DeerjikistDetailPage/> },
|
||||
{ path: 'tags/nico', element: <NicoTagListPage user={user}/> },
|
||||
{ path: 'nico/tags', element: <NicoTagListPage user={user}/> },
|
||||
{ path: 'tags/changes', element: <TagHistoryPage/> },
|
||||
{ path: 'theatres/:id', element: <TheatreDetailPage user={user}/> },
|
||||
{ path: 'materials', element: <MaterialBasePage/>, children: [
|
||||
{ index: true, element: <MaterialListPage/> },
|
||||
{ path: 'changes', element: <MaterialHistoryPage/> },
|
||||
{ path: 'new', element: <MaterialNewPage/>, handle: { robots: 'noindex' } },
|
||||
handle: { preload: PostSearchPage.preload, switchLocale: switchPostFilterLocale } },
|
||||
{ path: 'posts/:id', element: <PostDetailRoute user={user}/>,
|
||||
handle: { preload: PostDetailPage.preload } },
|
||||
{ path: 'posts/changes', element: <PostHistoryPage/>,
|
||||
handle: { preload: PostHistoryPage.preload } },
|
||||
{ path: 'tags', element: <TagListPage/>, handle: { preload: TagListPage.preload } },
|
||||
{ path: 'tags/:id', element: <TagDetailPage/>,
|
||||
handle: { preload: TagDetailPage.preload } },
|
||||
{ path: 'tags/:id/deerjikists', element: <DeerjikistDetailPage/>,
|
||||
handle: { preload: DeerjikistDetailPage.preload } },
|
||||
{ path: 'tags/nico', element: <NicoTagListPage user={user}/>,
|
||||
handle: { preload: NicoTagListPage.preload } },
|
||||
{ path: 'nico/tags', element: <NicoTagListPage user={user}/>,
|
||||
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/>,
|
||||
handle: { robots: 'noindex', authentication: 'user' } },
|
||||
{ path: ':id', element: <MaterialDetailPage/> }] },
|
||||
{ path: 'wiki', element: <WikiSearchPage/> },
|
||||
handle: { preload: MaterialSyncSuppressionsPage.preload,
|
||||
robots: 'noindex', authentication: 'user' } },
|
||||
{ path: ':id', element: <MaterialDetailPage/>,
|
||||
handle: { preload: MaterialDetailPage.preload } }] },
|
||||
{ path: 'wiki', element: <WikiSearchPage/>,
|
||||
handle: { preload: WikiSearchPage.preload } },
|
||||
{ path: 'wiki/:title', element: <WikiDetailPage/>,
|
||||
handle: { switchLocale: switchWikiLocale } },
|
||||
handle: { preload: WikiDetailPage.preload, switchLocale: switchWikiLocale } },
|
||||
{ path: 'wiki/new', element: <WikiNewPage user={user}/>,
|
||||
handle: { robots: 'noindex' } },
|
||||
handle: { preload: WikiNewPage.preload, robots: 'noindex' } },
|
||||
{ path: 'wiki/:id/edit', element: <WikiEditPage user={user}/>,
|
||||
handle: { robots: 'noindex',
|
||||
handle: { preload: WikiEditPage.preload, robots: 'noindex',
|
||||
switchLocale: request => switchWikiLocale (request, 'edit') } },
|
||||
{ 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/>,
|
||||
handle: { switchLocale: request => switchWikiLocale (request, 'history') } },
|
||||
{ path: 'help', element: <HelpListPage/> },
|
||||
{ path: 'help/changes', element: <HelpChangesPage/> },
|
||||
{ path: 'help/:identifier/diff', element: <HelpDiffPage/> },
|
||||
handle: { preload: WikiHistoryPage.preload,
|
||||
switchLocale: request => switchWikiLocale (request, 'history') } },
|
||||
{ path: 'help', element: <HelpListPage/>, handle: { preload: HelpListPage.preload } },
|
||||
{ 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/>,
|
||||
handle: { prepare: prepareHelpPage } },
|
||||
{ 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"/>,
|
||||
handle: { redirectTo: '/users/settings' } },
|
||||
{ path: 'tos', element: <TOSPage/> },
|
||||
{ path: 'tos', element: <TOSPage/>, handle: { preload: TOSPage.preload } },
|
||||
{ id: 'gekanator', path: 'gekanator', element: <GekanatorRoute user={user}/>,
|
||||
handle: { locale: 'ja' } },
|
||||
{ path: 'more', element: <MorePage/> },
|
||||
handle: { locale: 'ja', preload: locale => locale === 'ja'
|
||||
? GekanatorPage.preload () : GekanatorUnavailablePage.preload () } },
|
||||
{ path: 'more', element: <MorePage/>, handle: { preload: MorePage.preload } },
|
||||
{ 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 = (
|
||||
pathname: string,
|
||||
): RouteMatch<string, PageRoute> | null => {
|
||||
const match = matchRoutes<PageRoute> (
|
||||
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
|
||||
const RouteTransitionWrapper = ({ user, setUser }: {
|
||||
user: User | null
|
||||
setUser: Dispatch<SetStateAction<User | null>> }) => {
|
||||
const routes = useRoutes (pageRoutes (user, setUser))
|
||||
|
||||
return (
|
||||
<AnimatePresence mode="wait" initial={animationMode !== 'off'}>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<Suspense fallback={null}>{routes}</Suspense>
|
||||
</AnimatePresence>)
|
||||
}
|
||||
@@ -299,7 +349,6 @@ const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => {
|
||||
className="relative flex h-dvh w-full flex-col overflow-y-hidden">
|
||||
<TopNav user={user} locales={locales} resolveLocaleSwitch={switchLocale}/>
|
||||
<RouteTransitionWrapper
|
||||
animationMode={animationMode}
|
||||
user={user}
|
||||
setUser={setUser}/>
|
||||
</motion.div>
|
||||
|
||||
@@ -146,7 +146,8 @@ const TopNav: FC<Props> = ({ 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<Props> = ({ 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<boolean | null> (null)
|
||||
const [density, setDensity] = useState<NavDensity> ('auto')
|
||||
const [moreVsbl, setMoreVsbl] = useState (false)
|
||||
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])
|
||||
|
||||
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<Props> = ({ user, locales, resolveLocaleSwitch }) => {
|
||||
|
||||
return (
|
||||
<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')}
|
||||
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">
|
||||
@@ -458,15 +481,17 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
|
||||
{visibleMenu.map ((item, i) => (
|
||||
<motion.div
|
||||
key={item.to}
|
||||
initial={false}
|
||||
className="shrink-0 whitespace-nowrap"
|
||||
{...(animationsOff
|
||||
? { }
|
||||
: { layoutId: `menu-${ menu.indexOf (item) }` })}
|
||||
onLayoutAnimationComplete={() => measure (highlightTargetRef.current)}
|
||||
animate={{ opacity: moreVsbl ? 0 : 1 }}
|
||||
animate={layoutReady ? { opacity: moreVsbl ? 0 : 1 } : undefined}
|
||||
transition={{ opacity: opacityTransition,
|
||||
layout: topNavTransition }}
|
||||
style={{ pointerEvents: moreVsbl ? 'none' : 'auto' }}
|
||||
style={{ pointerEvents: moreVsbl ? 'none' : 'auto',
|
||||
opacity: layoutReady ? undefined : (moreVsbl ? 0 : 1) }}
|
||||
onMouseEnter={() => setMoreVsbl (false)}>
|
||||
<PrefetchLink
|
||||
to={item.to}
|
||||
@@ -522,16 +547,17 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
|
||||
<AnimatePresence initial={false}>
|
||||
<motion.div
|
||||
key="submenu-shell"
|
||||
initial={false}
|
||||
{...(animationsOff ? { }
|
||||
: { layout: true })}
|
||||
className="top-nav-desktop-submenu relative z-20 shrink-0
|
||||
overflow-hidden top-nav-submenu"
|
||||
animate={{ height: submenuHeight }}
|
||||
animate={{ height: desktop == null ? 'auto' : submenuHeight }}
|
||||
onMouseLeave={() => {
|
||||
if (moreVsbl)
|
||||
setMoreVsbl (false)
|
||||
}}
|
||||
transition={{ layout: topNavTransition }}
|
||||
transition={{ ...topNavTransition, layout: topNavTransition }}
|
||||
onAnimationComplete={() => {
|
||||
measure (highlightTargetRef.current)
|
||||
}}
|
||||
|
||||
@@ -345,12 +345,14 @@ body
|
||||
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-measuring] .top-nav-locale-name
|
||||
{
|
||||
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-measuring] .top-nav-account .top-nav-user-desktop
|
||||
{
|
||||
@@ -362,11 +364,14 @@ body
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.top-nav-layout:not([data-desktop]) .top-nav-root,
|
||||
.top-nav-layout[data-desktop='true'] .top-nav-root
|
||||
{
|
||||
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-measuring] .top-nav-desktop-menu,
|
||||
.top-nav-layout[data-desktop='true'] .top-nav-account,
|
||||
@@ -375,6 +380,8 @@ body
|
||||
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-measuring] .top-nav-user-desktop,
|
||||
.top-nav-layout[data-desktop='true'] .top-nav-desktop-submenu
|
||||
@@ -382,6 +389,9 @@ body
|
||||
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-measuring] .top-nav-mobile-toggle,
|
||||
.top-nav-layout[data-desktop='true'] .top-nav-mobile-controls,
|
||||
|
||||
@@ -16,6 +16,7 @@ export type LocaleSwitchRequest = {
|
||||
targetLocale: LocaleCode }
|
||||
|
||||
export type PageRouteHandle = {
|
||||
preload?: (locale: LocaleCode) => Promise<unknown>
|
||||
prepare?: (request: PagePreparation) => Promise<number>
|
||||
switchLocale?: (request: LocaleSwitchRequest) => Promise<string>
|
||||
locale?: LocaleCode
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"hideTags": "▲▲▲ シロシ ソモ ヌカㇻ ▲▲▲",
|
||||
"showTags": "▼▼▼ シロシ ヌカㇻ ▼▼▼",
|
||||
"resultsCount": "{{count}} ペ",
|
||||
"menu": "イヌㇺケ オピッタ",
|
||||
"menu": "Menu",
|
||||
"anonymous": "レサㇰ ユㇰ ニチカ ウタㇻ",
|
||||
"bot": "Bot カㇻ",
|
||||
"deprecatedSuffix": "(エイワンケ ソモ エアㇱカイ)",
|
||||
|
||||
+2
-1
@@ -4,7 +4,7 @@ import { HelmetProvider } from 'react-helmet-async'
|
||||
import { I18nextProvider } from 'react-i18next'
|
||||
|
||||
import '@/index.css'
|
||||
import App, { resolvePageRoute } from '@/App'
|
||||
import App, { preloadPageRoute, resolvePageRoute } from '@/App'
|
||||
import { createUII18n } from '@/lib/i18n'
|
||||
import { currentViewingLocale } from '@/lib/locales'
|
||||
import { localesKeys } from '@/lib/queryKeys'
|
||||
@@ -80,6 +80,7 @@ const start = () => {
|
||||
if (status !== data.status)
|
||||
throw new Error ('Server and browser page versions differ')
|
||||
}
|
||||
await preloadPageRoute (url.pathname, currentViewingLocale ())
|
||||
mount (true)
|
||||
mounted = true
|
||||
for (const event of ['online', 'focus', 'pageshow'])
|
||||
|
||||
新しいイシューから参照
ユーザーをブロックする