diff --git a/backend/app/controllers/users_controller.rb b/backend/app/controllers/users_controller.rb
index 95ae677..28c04b9 100644
--- a/backend/app/controllers/users_controller.rb
+++ b/backend/app/controllers/users_controller.rb
@@ -21,6 +21,12 @@ class UsersController < ApplicationController
render json: { valid: true, user: user.slice(:id, :name, :inheritance_code, :role) }
end
+ def session
+ return head :unauthorized unless current_user
+
+ render json: current_user.slice(:id, :name, :role)
+ end
+
def renew
return head :unauthorized unless current_user
diff --git a/backend/config/routes.rb b/backend/config/routes.rb
index 9752bde..2ad1ceb 100644
--- a/backend/config/routes.rb
+++ b/backend/config/routes.rb
@@ -100,6 +100,7 @@ Rails.application.routes.draw do
collection do
post :verify
get :me
+ get :session
post 'code/renew', action: :renew
end
end
diff --git a/backend/spec/requests/users_spec.rb b/backend/spec/requests/users_spec.rb
index c58f614..75183f5 100644
--- a/backend/spec/requests/users_spec.rb
+++ b/backend/spec/requests/users_spec.rb
@@ -13,6 +13,24 @@ RSpec.describe 'Users', type: :request do
{ 'X-Transfer-Code' => user.inheritance_code }
end
+ describe 'GET /users/session' do
+ it 'requires authentication' do
+ get '/users/session'
+ expect(response).to have_http_status(:unauthorized)
+ end
+
+ it 'returns public user fields without recording a visit' do
+ user = create(:user, name: 'SSR user')
+ expect {
+ get '/users/session', headers: auth_headers(user)
+ }.not_to change(UserIp, :count)
+
+ expect(response).to have_http_status(:ok)
+ expect(json).to eq('id' => user.id, 'name' => user.name, 'role' => user.role)
+ expect(response.body).not_to include(user.inheritance_code)
+ end
+ end
+
describe 'POST /users' do
it 'creates guest user, IpAddress and UserIp, and returns code' do
expect {
diff --git a/frontend/package.json b/frontend/package.json
index 134535e..7d93a4d 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -12,6 +12,7 @@
"lint": "eslint .",
"test": "vitest",
"test:run": "vitest run",
+ "test:ssr": "node --test server.ssr.test.mjs",
"preview": "vite preview --outDir dist/client"
},
"dependencies": {
diff --git a/frontend/server.mjs b/frontend/server.mjs
index cc759e2..9d88f62 100644
--- a/frontend/server.mjs
+++ b/frontend/server.mjs
@@ -1,6 +1,7 @@
import { createReadStream } from 'node:fs'
import { readFile, stat } from 'node:fs/promises'
import { createServer } from 'node:http'
+import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
@@ -10,6 +11,39 @@ const root = fileURLToPath (new URL ('./dist/client/', import.meta.url))
const template = await readFile (path.join (root, 'index.html'), 'utf8')
const origin = new URL (process.env.PUBLIC_ORIGIN ?? 'http://localhost:5173').origin
const apiBaseURL = process.env.SSR_API_BASE_URL ?? 'http://127.0.0.1:3002'
+const sessionSecret = process.env.SSR_COOKIE_SECRET
+if (process.env.NODE_ENV === 'production' && (!sessionSecret || sessionSecret.length < 32))
+ throw new Error ('SSR_COOKIE_SECRET must contain at least 32 characters in production')
+const sessionKey = createHash ('sha256').update (sessionSecret ?? randomBytes (32)).digest ()
+const cookieName = '__Host-btrc-session'
+const sessionSeconds = 7 * 24 * 60 * 60
+
+const encodeSession = code => {
+ const iv = randomBytes (12)
+ const cipher = createCipheriv ('aes-256-gcm', sessionKey, iv)
+ const payload = JSON.stringify ({ code, expires: Date.now () + sessionSeconds * 1000 })
+ const encrypted = Buffer.concat ([cipher.update (payload, 'utf8'), cipher.final ()])
+ return Buffer.concat ([iv, cipher.getAuthTag (), encrypted]).toString ('base64url')
+}
+
+const decodeSession = cookie => {
+ try
+ {
+ const token = cookie?.split (';').map (part => part.trim ())
+ .find (part => part.startsWith (`${cookieName}=`))?.slice (cookieName.length + 1)
+ if (!token || token.length > 2048) return undefined
+ const payload = Buffer.from (token, 'base64url')
+ const decipher = createDecipheriv ('aes-256-gcm', sessionKey, payload.subarray (0, 12))
+ decipher.setAuthTag (payload.subarray (12, 28))
+ const data = JSON.parse (Buffer.concat ([
+ decipher.update (payload.subarray (28)), decipher.final ()]).toString ('utf8'))
+ return typeof data.code === 'string' && data.expires > Date.now () ? data.code : undefined
+ }
+ catch
+ {
+ return undefined
+ }
+}
const types = {
'.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json',
'.png': 'image/png', '.gif': 'image/gif', '.jpg': 'image/jpeg',
@@ -20,8 +54,43 @@ const types = {
if (process.env.NODE_ENV === 'production' && !process.env.PUBLIC_ORIGIN)
throw new Error ('PUBLIC_ORIGIN is required in production')
-createServer (async (request, response) => {
- response.setHeader ('Cache-Control', 'no-store')
+const server = createServer (async (request, response) => {
+ response.setHeader ('Cache-Control', 'private, no-store')
+ const forwardedFor = request.headers['x-forwarded-for'] ?? request.socket.remoteAddress
+ if (request.url === '/_ssr/session')
+ {
+ if (request.method !== 'POST' || request.headers.origin !== origin)
+ {
+ response.writeHead (403).end ()
+ return
+ }
+ const code = request.headers['x-transfer-code']
+ if (typeof code !== 'string' || code.length > 512 || code.length === 0)
+ {
+ response.writeHead (400).end ()
+ return
+ }
+ try
+ {
+ const verified = await fetch (new URL ('users/session', apiBaseURL.replace (/\/?$/, '/')), {
+ headers: { 'X-Transfer-Code': code, 'X-Forwarded-For': forwardedFor },
+ signal: AbortSignal.timeout (10_000) })
+ if (!verified.ok)
+ {
+ response.writeHead (verified.status === 401 || verified.status === 403
+ ? verified.status : 503).end ()
+ return
+ }
+ response.setHeader ('Set-Cookie', `${cookieName}=${encodeSession (code)}; `
+ + `Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${sessionSeconds}`)
+ response.writeHead (204).end ()
+ }
+ catch
+ {
+ response.writeHead (503).end ()
+ }
+ return
+ }
if (!['GET', 'HEAD'].includes (request.method))
{
response.writeHead (405, { Allow: 'GET, HEAD' }).end ()
@@ -54,7 +123,8 @@ createServer (async (request, response) => {
response.writeHead (404).end ()
return
}
- const result = await render (url.pathname + url.search, origin, apiBaseURL)
+ const result = await render (url.pathname + url.search, origin, apiBaseURL, {
+ transferCode: decodeSession (request.headers.cookie), forwardedFor })
if (result?.location != null)
{
response.writeHead (result.status, { Location: result.location }).end ()
@@ -77,7 +147,13 @@ createServer (async (request, response) => {
}
catch (error)
{
- console.error (error)
- response.writeHead (error instanceof URIError ? 400 : 503).end ()
+ console.error ('[SSR]', error instanceof Error ? error.message : 'Rendering failed')
+ const denied = error?.response?.status === 401 || error?.response?.status === 403
+ response.writeHead (error instanceof URIError ? 400
+ : (denied ? error.response.status : 503)).end ()
}
-}).listen (Number (process.env.PORT ?? 5173), process.env.HOST ?? '127.0.0.1')
+})
+
+server.listen (Number (process.env.PORT ?? 5173), process.env.HOST ?? '127.0.0.1', () => {
+ console.info ('[SSR] listening on', server.address ().port)
+})
diff --git a/frontend/server.ssr.test.mjs b/frontend/server.ssr.test.mjs
new file mode 100644
index 0000000..c2e313d
--- /dev/null
+++ b/frontend/server.ssr.test.mjs
@@ -0,0 +1,308 @@
+import assert from 'node:assert/strict'
+import { createServer } from 'node:http'
+import { spawn } from 'node:child_process'
+import { after, before, test } from 'node:test'
+import { createServer as createViteServer } from 'vite'
+
+import { render } from './dist/server/entry-server.js'
+
+const origin = 'https://ssr.example.invalid'
+const date = '2026-09-15T00:00:00+09:00'
+let api, apiURL, routes, vite, node, httpOrigin
+let failAPI = false
+const requests = []
+const tag = locale => ({
+ id: 1, name: locale === 'ja' ? 'SSR タグ' : 'SSR シロシ', category: 'general',
+ deprecated_at: null, aliases: [], parents: [], children: [], post_count: 1,
+ created_at: date, updated_at: date, has_wiki: true, material_id: null,
+ has_deerjikists: false, sections: [] })
+const post = locale => ({
+ id: 1, version_no: 1, title: `SSR post ${locale}`, url: 'https://example.invalid/post',
+ thumbnail: null, thumbnail_base: null, video_ms: null, tags: [tag (locale)],
+ parent_posts: [], child_posts: [], sibling_posts: {}, viewed: false, related: [],
+ original_created_from: date, original_created_before: date,
+ created_at: date, updated_at: date, uploaded_user: null })
+const wiki = locale => ({
+ id: 1, tag_id: 1, resource_type: 'tag_description', language_code: locale === 'ja'
+ ? 'ja' : 'ain', title: tag (locale).name, body: `SSR wiki body ${locale}`,
+ created_at: date, updated_at: date, revision_id: 1, pred: null, succ: null,
+ deprecated_at: null })
+const material = locale => ({
+ id: 1, version_no: 1, tag: tag (locale), file: null, url: 'https://example.invalid',
+ thumbnail: null, thumbnail_fallback_text: 'SSR material',
+ thumbnail_fallback_kind: 'tag_name', media_kind: 'url_only', content_type: null,
+ file_byte_size: null, export_paths: {}, export_items: [], created_at: date,
+ updated_at: date, created_by_user: { id: 1, name: 'SSR' },
+ updated_by_user: { id: 1, name: 'SSR' } })
+
+const responseFor = url => {
+ const locale = url.searchParams.get ('locale') ?? 'ja'
+ const path = url.pathname
+ if (path === '/locales')
+ return { locales: [{ code: 'ja', name: '日本語' }, { code: 'ain-Kana', name: 'アイヌ' }] }
+ if (path === '/users/session') return { id: 1, name: 'SSR account', role: 'admin' }
+ if (path === '/users/settings') return { theme: 'system', wiki_editor_mode: 'split' }
+ if (path === '/users/theme_slots') return []
+ if (path === '/posts') return { posts: [post (locale)], count: 1 }
+ if (path === '/gekanator/posts') return { posts: [post (locale)] }
+ if (path === '/gekanator/questions') return { questions: [] }
+ if (path === '/posts/1') return post (locale)
+ if (path === '/tags') return { tags: [tag (locale)], count: 1 }
+ if (path === '/tags/1' || path.startsWith ('/tags/name/')) return tag (locale)
+ if (path === '/tags/1/deerjikists') return { tag: tag (locale), deerjikists: [] }
+ if (path === '/tags/with-depth') return []
+ if (path === '/tags/nico' || path === '/nico/tags') return { tags: [], count: 0 }
+ if (path.endsWith ('/versions')) return { versions: [], count: 0 }
+ if (path === '/wiki') return [wiki (locale)]
+ if (path === '/wiki/changes') return []
+ if (path.includes ('autolink')) return []
+ if (path === '/wiki/1/diff') return { title: 'SSR wiki diff', diff: [] }
+ if (path.startsWith ('/wiki/')) return wiki (locale)
+ if (path === '/materials') return { materials: [material (locale)], count: 1 }
+ if (path === '/materials/1') return material (locale)
+ if (path.includes ('suppressions')) return { suppressions: [] }
+ if (path === '/theatres/1') return { id: 1, name: 'SSR theatre', created_at: date }
+ if (path === '/theatres/1/programmes') return []
+ return null
+}
+
+before (async () => {
+ api = createServer ((request, response) => {
+ requests.push ({ method: request.method, url: request.url })
+ const url = new URL (request.url, 'http://localhost')
+ const data = responseFor (url)
+ response.writeHead (failAPI ? 503 : (data == null ? 404 : 200), {
+ 'Content-Type': 'application/json' })
+ response.end (JSON.stringify (data))
+ })
+ await new Promise (resolve => api.listen (0, '127.0.0.1', resolve))
+ apiURL = `http://127.0.0.1:${api.address ().port}`
+ node = spawn (process.execPath, ['server.mjs'], { env: {
+ ...process.env, NODE_ENV: 'production', PORT: '0', HOST: '127.0.0.1',
+ PUBLIC_ORIGIN: origin, SSR_API_BASE_URL: apiURL,
+ SSR_COOKIE_SECRET: 'automated-check-only-not-a-production-secret' },
+ stdio: ['ignore', 'pipe', 'pipe'] })
+ httpOrigin = await new Promise ((resolve, reject) => {
+ node.stdout.on ('data', data => {
+ const match = data.toString ().match (/listening on (\d+)/)
+ if (match) resolve (`http://127.0.0.1:${match[1]}`)
+ })
+ node.on ('error', reject)
+ node.on ('exit', code => reject (new Error (`SSR server exited ${code}`)))
+ })
+ vite = await createViteServer ({ server: { middlewareMode: true }, appType: 'custom' })
+ const { pageRoutes } = await vite.ssrLoadModule ('/src/App.tsx')
+ const flatten = (entries, parent = '') => entries.flatMap (route => {
+ const path = [parent, route.path].filter (Boolean).join ('/')
+ return route.children ? flatten (route.children, path) : [{ ...route, path }]
+ })
+ routes = flatten (pageRoutes (null, () => {}))
+})
+
+after (async () => {
+ await vite?.close ()
+ node?.kill ()
+ api?.closeAllConnections ()
+ await new Promise (resolve => api?.close (resolve))
+})
+
+test ('every canonical screen route returns page HTML without JavaScript', async t => {
+ for (const route of routes) {
+ for (const locale of ['ja', 'ain-Kana']) {
+ const path = route.path.replace (':identifier', 'home').replace (/:id|:title/g, '1')
+ .replace ('*', 'unknown-screen')
+ const url = `/${locale}/${path}?from=2026-09-15&to=2026-09-15`
+ await t.test (`${locale} ${route.path}`, async () => {
+ const result = await render (url, origin, apiURL, { transferCode: 'test-secret' })
+ assert.ok (result != null)
+ if (route.handle?.redirectTo) {
+ assert.equal (result.status, 308)
+ return
+ }
+ assert.ok (result.html?.length > 300, 'actual page HTML is required')
+ assert.equal (result.status, route.path === '*' ? 404 : 200)
+ assert.match (result.head, /
]*>/g, ''), /SSR[ ]theatre/)
+ if (route.path === 'wiki' || route.path === 'wiki/:title')
+ assert.match (result.html, new RegExp (`SSR wiki body ${locale}|SSR`))
+ assert.doesNotMatch (result.html, /Switched to client rendering|template id="B:/)
+ })
+ }
+ }
+})
+
+test ('parallel localized requests keep list data isolated', async () => {
+ const pages = await Promise.all (['ja', 'ain-Kana', 'ja', 'ain-Kana'].map (locale =>
+ render (`/${locale}/posts?limit=50&page=2`, origin, apiURL)))
+ for (const [index, page] of pages.entries ()) {
+ const locale = index % 2 === 0 ? 'ja' : 'ain-Kana'
+ assert.match (page.html, new RegExp (`SSR post ${locale}`))
+ assert.equal (page.status, 200)
+ const state = JSON.parse (page.bootstrap)
+ assert.equal (state.locale, locale)
+ }
+ assert.ok (requests.some (request => request.url.includes ('page=2')))
+ assert.ok (requests.every (request => request.method === 'GET'))
+})
+
+test ('unknown resources and legacy URLs have correct status', async () => {
+ assert.equal ((await render ('/ja/unknown', origin, apiURL)).status, 404)
+ assert.equal ((await render ('/ja/help/unknown', origin, apiURL)).status, 404)
+ assert.equal ((await render ('/ja/posts/999', origin, apiURL)).status, 404)
+ const redirect = await render ('/help/home?edition=2026-09-15', origin, apiURL)
+ assert.equal (redirect.status, 308)
+ assert.equal (redirect.location, '/ja/help/home?edition=2026-09-15')
+})
+
+test ('HTTP cookie migration, CSRF protection, and API failure', async () => {
+ const rejected = await fetch (`${httpOrigin}/_ssr/session`, {
+ method: 'POST', headers: { Origin: 'https://other.invalid', 'X-Transfer-Code': 'test-secret' } })
+ assert.equal (rejected.status, 403)
+ const created = await fetch (`${httpOrigin}/_ssr/session`, {
+ method: 'POST', headers: { Origin: origin, 'X-Transfer-Code': 'test-secret' } })
+ assert.equal (created.status, 204)
+ const cookie = created.headers.get ('set-cookie')
+ assert.match (cookie, /HttpOnly; Secure; SameSite=Lax/)
+ assert.doesNotMatch (cookie, /test-secret/)
+ const settings = await fetch (`${httpOrigin}/ja/users/settings`, { headers: { Cookie: cookie } })
+ assert.equal (settings.status, 200)
+ const html = await settings.text ()
+ assert.match (html, /SSR account/)
+ assert.doesNotMatch (html, /test-secret/)
+ assert.match (settings.headers.get ('cache-control'), /private, no-store/)
+ assert.equal ((await fetch (`${httpOrigin}/ja/users/settings`)).status, 401)
+ const invalid = cookie.replace ('=', '=invalid')
+ assert.equal ((await fetch (`${httpOrigin}/ja/users/settings`, {
+ headers: { Cookie: invalid } })).status, 401)
+ failAPI = true
+ try {
+ assert.equal ((await fetch (`${httpOrigin}/ja/posts`)).status, 503)
+ } finally {
+ failAPI = false
+ }
+})
+
+test ('hydrate every route with stored browser preferences', {
+ skip: !process.env.SSR_CHROME_PATH, timeout: 180_000,
+}, async t => {
+ const { default: WebSocket } = await import ('ws')
+ const chrome = spawn (process.env.SSR_CHROME_PATH, [
+ '--headless', '--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage',
+ '--remote-debugging-port=0', `--user-data-dir=/tmp/btrc-ssr-browser-${process.pid}`],
+ { stdio: ['ignore', 'ignore', 'pipe'] })
+ let ws
+ try {
+ const endpoint = await new Promise ((resolve, reject) => {
+ let output = ''
+ chrome.stderr.on ('data', data => {
+ output += data
+ const match = output.match (/DevTools listening on (ws:\/\/\S+)/)
+ if (match) resolve (match[1])
+ })
+ chrome.on ('error', reject)
+ })
+ ws = new WebSocket (endpoint)
+ await new Promise (resolve => ws.on ('open', resolve))
+ const pending = new Map ()
+ let sequence = 0, errors = []
+ const send = (method, params = {}, sessionId) => new Promise ((resolve, reject) => {
+ const id = ++sequence
+ pending.set (id, { resolve, reject })
+ ws.send (JSON.stringify ({ id, method, params, sessionId }))
+ })
+ ws.on ('message', raw => {
+ const message = JSON.parse (raw)
+ if (message.id) {
+ const entry = pending.get (message.id)
+ pending.delete (message.id)
+ if (message.error) entry?.reject (message.error)
+ else entry?.resolve (message.result)
+ } else if (message.method === 'Fetch.requestPaused') {
+ const { requestId, request } = message.params
+ const url = new URL (request.url)
+ if (url.origin === httpOrigin && url.pathname !== '/_ssr/session') {
+ void send ('Fetch.continueRequest', { requestId }, message.sessionId)
+ return
+ }
+ const data = url.pathname === '/users/verify'
+ ? { valid: true, user: { id: 1, name: 'SSR account', role: 'admin',
+ inheritance_code: 'test-secret' } }
+ : responseFor (url)
+ void send ('Fetch.fulfillRequest', { requestId,
+ responseCode: request.method === 'OPTIONS' ? 204 : 200,
+ responseHeaders: [{ name: 'Content-Type', value: 'application/json' },
+ { name: 'Access-Control-Allow-Origin', value: httpOrigin },
+ { name: 'Access-Control-Allow-Headers', value: '*' },
+ { name: 'Access-Control-Allow-Methods', value: '*' }],
+ body: Buffer.from (JSON.stringify (data ?? {})).toString ('base64') }, message.sessionId)
+ } else if (message.method === 'Runtime.exceptionThrown') {
+ errors.push (message.params.exceptionDetails.exception?.description
+ ?? message.params.exceptionDetails.text)
+ } else if (message.method === 'Runtime.consoleAPICalled'
+ && message.params.type === 'error') {
+ errors.push (message.params.args.map (arg => arg.value ?? arg.description).join (' '))
+ }
+ })
+ const { targetId } = await send ('Target.createTarget', { url: 'about:blank' })
+ const { sessionId } = await send ('Target.attachToTarget', { targetId, flatten: true })
+ await send ('Runtime.enable', {}, sessionId)
+ await send ('Page.enable', {}, sessionId)
+ await send ('Network.enable', {}, sessionId)
+ await send ('Fetch.enable', { patterns: [{ urlPattern: '*' }] }, sessionId)
+ const session = await fetch (`${httpOrigin}/_ssr/session`, {
+ method: 'POST', headers: { Origin: origin, 'X-Transfer-Code': 'test-secret' } })
+ await send ('Network.setExtraHTTPHeaders', { headers: {
+ Cookie: session.headers.get ('set-cookie').split (';')[0] } }, sessionId)
+ await send ('Page.addScriptToEvaluateOnNewDocument', { source: `
+ localStorage.setItem('user_code','test-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'}
+ }));
+ new MutationObserver(()=>{
+ const child=document.getElementById('root')?.firstElementChild;
+ if(child&&!window.__initialSSRNode)window.__initialSSRNode=child;
+ }).observe(document,{childList:true,subtree:true});` }, 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)) {
+ for (const locale of ['ja', 'ain-Kana']) {
+ await t.test (`hydrate ${locale} ${route.path}`, async () => {
+ const path = route.path.replace (':identifier', 'home').replace (/:id|:title/g, '1')
+ .replace ('*', 'unknown-screen')
+ errors = []
+ await send ('Page.navigate', {
+ url: `${httpOrigin}/${locale}/${path}?from=2026-09-15&to=2026-09-15` }, sessionId)
+ let hydrated = false
+ for (let attempt = 0; attempt < 100; ++attempt) {
+ await new Promise (resolve => setTimeout (resolve, 75))
+ try {
+ hydrated = await evaluate (`Object.keys(document.getElementById('root')??{})
+ .some(key=>key.startsWith('__reactContainer'))`)
+ } catch {}
+ if (hydrated) break
+ }
+ assert.ok (hydrated, 'hydrateRoot must run')
+ await new Promise (resolve => setTimeout (resolve, 150))
+ assert.deepEqual (errors, [])
+ assert.ok (await evaluate (`document.getElementById('root').firstElementChild
+ ===window.__initialSSRNode`), 'server DOM must be preserved')
+ assert.equal (await evaluate ('document.documentElement.lang'), locale)
+ })
+ }
+ }
+ } finally {
+ ws?.close ()
+ chrome.kill ()
+ }
+})
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 41a36b3..3cf0976 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,4 +1,4 @@
-import { useQuery } from '@tanstack/react-query'
+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 { useTranslation } from 'react-i18next'
@@ -20,7 +20,7 @@ import { apiPost, isApiError } from '@/lib/api'
import { prepareHelpPage } from '@/lib/help'
import { fetchLocales, isLocaleCode, legacyLocalePath,
switchLocalePath, unlocalisedPath } from '@/lib/locales'
-import { localesKeys } from '@/lib/queryKeys'
+import { localesKeys, sessionKeys } from '@/lib/queryKeys'
import { useLocale } from '@/lib/useLocale'
import { applyClientAnimationMode,
applyClientAppearance,
@@ -99,9 +99,11 @@ export const pageRoutes = (
user: User | null,
setUser: Dispatch>,
): PageRoute[] => [
- { path: '', element: },
+ { path: '', element: ,
+ handle: { redirectTo: '/posts' } },
{ path: 'posts', element: },
- { path: 'posts/new', element: },
+ { path: 'posts/new', element: ,
+ handle: { robots: 'noindex' } },
{ path: 'posts/search', element: },
{ path: 'posts/:id', element: },
{ path: 'posts/changes', element: },
@@ -115,22 +117,27 @@ export const pageRoutes = (
{ path: 'materials', element: , children: [
{ index: true, element: },
{ path: 'changes', element: },
- { path: 'new', element: },
- { path: 'suppressions', element: },
+ { path: 'new', element: , handle: { robots: 'noindex' } },
+ { path: 'suppressions', element: ,
+ handle: { robots: 'noindex', authentication: 'user' } },
{ path: ':id', element: }] },
{ path: 'wiki', element: },
{ path: 'wiki/:title', element: },
- { path: 'wiki/new', element: },
- { path: 'wiki/:id/edit', element: },
+ { path: 'wiki/new', element: ,
+ handle: { robots: 'noindex' } },
+ { path: 'wiki/:id/edit', element: ,
+ handle: { robots: 'noindex' } },
{ path: 'wiki/:id/diff', element: },
{ path: 'wiki/changes', element: },
{ path: 'help', element: },
{ path: 'help/changes', element: },
{ path: 'help/:identifier/diff', element: },
{ id: 'help.detail', path: 'help/:identifier', element: ,
- handle: { ssr: prepareHelpPage } },
- { path: 'users/settings', element: },
- { path: 'settings', element: },
+ handle: { prepare: prepareHelpPage } },
+ { path: 'users/settings', element: ,
+ handle: { robots: 'noindex', authentication: 'user' } },
+ { path: 'settings', element: ,
+ handle: { redirectTo: '/users/settings' } },
{ path: 'tos', element: },
{ id: 'gekanator', path: 'gekanator', element: ,
handle: { locale: 'ja' } },
@@ -168,7 +175,11 @@ const PostDetailRoute = ({ user }: { user: User | null }) => {
const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => {
- const [user, setUser] = useState (null)
+ const client = useQueryClient ()
+ const location = useLocation ()
+ const robots = resolvePageRoute (location.pathname)?.route.handle?.robots
+ const [user, setUser] = useState (
+ () => client.getQueryData (sessionKeys.current) ?? null)
const [status, setStatus] = useState (200)
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
@@ -266,7 +277,9 @@ const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => {
return (
-
+
+ {robots != null && }
+
= ({ status }) => {
+ const context = currentRenderContext ()
+ if (context != null)
+ context.status = status
const { message: translate, label } = useUITranslation ('errors')
const [message]: [string, string, string] = (() => {
switch (status)
diff --git a/frontend/src/components/PostEditForm.tsx b/frontend/src/components/PostEditForm.tsx
index 57c6593..99039d9 100644
--- a/frontend/src/components/PostEditForm.tsx
+++ b/frontend/src/components/PostEditForm.tsx
@@ -65,7 +65,7 @@ const PostEditForm: FC = ({ post, onSave }) => {
useState (post.originalCreatedFrom)
const [parentPostIds, setParentPostIds] =
useState ((post.parentPosts ?? []).map (p => p.id).join (' '))
- const [tags, setTags] = useState ('')
+ const [tags, setTags] = useState (() => tagsToStr (post.tags))
const [title, setTitle] = useState (post.title)
const videoFlg =
diff --git a/frontend/src/components/common/PageHead.tsx b/frontend/src/components/common/PageHead.tsx
index 109a8f9..ed5d837 100644
--- a/frontend/src/components/common/PageHead.tsx
+++ b/frontend/src/components/common/PageHead.tsx
@@ -1,7 +1,9 @@
import { Helmet } from 'react-helmet-async'
+import { useLocation } from 'react-router-dom'
import { useLocale } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
+import { currentRenderContext } from '@/lib/renderContext'
import type { ReactNode } from 'react'
@@ -18,19 +20,24 @@ const PageHead = (
{ title, includeSiteName = true, description: pageDescription, canonical, children }: Props,
) => {
const locale = useLocale ()
+ const location = useLocation ()
+ const origin = currentRenderContext ()?.origin
+ const pageCanonical = canonical ?? (origin == null
+ ? undefined : new URL (location.pathname + location.search, origin).href)
const { message, siteTitle } = useUITranslation ('shared')
const pageTitle = typeof title === 'string' ? { text: title, language: locale } : title
const fullTitle =
pageTitle == null
? siteTitle.text
: (includeSiteName ? `${pageTitle.text} | ${siteTitle.text}` : pageTitle.text)
- const description = pageDescription ?? message ('head.description', { brand: siteTitle.text })
+ const description = pageDescription ?? pageTitle
+ ?? message ('head.description', { brand: siteTitle.text })
return (
{fullTitle}
- {canonical != null && }
+ {pageCanonical != null && }
{children}
)
}
diff --git a/frontend/src/components/layout/SidebarComponent.tsx b/frontend/src/components/layout/SidebarComponent.tsx
index 44cef66..a1c5962 100644
--- a/frontend/src/components/layout/SidebarComponent.tsx
+++ b/frontend/src/components/layout/SidebarComponent.tsx
@@ -103,12 +103,7 @@ const SidebarComponent: FC = ({
return Math.min (maxAllowedWidth, Math.max (MIN_SIDEBAR_WIDTH, nextWidth))
}, [])
- const [width, setWidth] = useState (() => (
- typeof window === 'undefined'
- ? DEFAULT_SIDEBAR_WIDTH
- : getClampedWidth (
- readStoredSidebarWidth (sidebarKey, side),
- window.innerWidth)))
+ const [width, setWidth] = useState (DEFAULT_SIDEBAR_WIDTH)
const [resizing, setResizing] = useState (false)
const widthRef = useRef (width)
const longPressTimerRef = useRef | null> (null)
diff --git a/frontend/src/components/users/BehaviourSettingsSection.tsx b/frontend/src/components/users/BehaviourSettingsSection.tsx
index 804e251..f828b23 100644
--- a/frontend/src/components/users/BehaviourSettingsSection.tsx
+++ b/frontend/src/components/users/BehaviourSettingsSection.tsx
@@ -88,9 +88,9 @@ const BehaviourSettingsSection: FC = props => {
const { sectionClassName, onDirtyStateChange } = props
const { label } = useUITranslation ('settings')
const [savedSettings, setSavedSettings] =
- useState (() => getClientBehaviorSettings ())
+ useState ({ })
const [draftSettings, setDraftSettings] =
- useState (() => getClientBehaviorSettings ())
+ useState ({ })
useEffect (() => {
const current = getClientBehaviorSettings ()
diff --git a/frontend/src/entry-server.tsx b/frontend/src/entry-server.tsx
index 8c4e28e..f67e234 100644
--- a/frontend/src/entry-server.tsx
+++ b/frontend/src/entry-server.tsx
@@ -1,17 +1,23 @@
-import { QueryClient, QueryClientProvider, dehydrate } from '@tanstack/react-query'
-import { renderToString } from 'react-dom/server'
+import { QueryClient, QueryClientProvider, dehydrate, skipToken } from '@tanstack/react-query'
+import { PassThrough } from 'node:stream'
+import { renderToPipeableStream } from 'react-dom/server'
import { HelmetProvider } from 'react-helmet-async'
import { I18nextProvider } from 'react-i18next'
import { createStaticHandler, createStaticRouter,
StaticRouterProvider } from 'react-router-dom/server'
import { AppFrame, appRoutes, resolvePageRoute } from '@/App'
+import { apiGet, isApiError } from '@/lib/api'
import { createUII18n } from '@/lib/i18n'
import { fetchLocales, isLocaleCode, legacyLocalePath } from '@/lib/locales'
-import { localesKeys } from '@/lib/queryKeys'
+import { localesKeys, sessionKeys } from '@/lib/queryKeys'
import { withRenderContext } from '@/lib/renderContext.server'
import type { HelmetServerState } from 'react-helmet-async'
+import type { ReactNode } from 'react'
+import type { QueryObserverOptions } from '@tanstack/react-query'
+import type { RenderContext } from '@/lib/renderContext'
+import type { User } from '@/types'
type RenderedPage = {
status: number
@@ -23,44 +29,91 @@ type RenderedPage = {
type RouteResponse = { status: 404 | 308; location?: string }
+const renderDocument = (element: ReactNode): Promise =>
+ new Promise ((resolve, reject) => {
+ const output = new PassThrough ()
+ const chunks: Buffer[] = []
+ output.on ('data', chunk => chunks.push (Buffer.from (chunk)))
+ output.on ('error', reject)
+ output.on ('end', () => resolve (Buffer.concat (chunks).toString ('utf8')))
+ const timeout = setTimeout (() => {
+ stream.abort ()
+ reject (new Error ('SSR rendering timed out'))
+ }, 30_000)
+ const stream = renderToPipeableStream (element, {
+ onAllReady: () => {
+ clearTimeout (timeout)
+ stream.pipe (output)
+ },
+ onShellError: error => {
+ clearTimeout (timeout)
+ reject (error)
+ },
+ onError: error => {
+ clearTimeout (timeout)
+ reject (error)
+ } })
+ })
+
+
export const render = async (
address: string,
origin: string,
apiBaseURL: string,
-): Promise => {
+ authentication: Pick = { },
+): Promise => {
const url = new URL (address, origin)
const legacy = legacyLocalePath ({ pathname: url.pathname, search: url.search, hash: '' })
const pathname = legacy == null ? url.pathname : new URL (legacy, origin).pathname
const segment = pathname.split ('/')[1]
const known = resolvePageRoute (pathname)
- if (!(isLocaleCode (segment)) || known == null)
- return { status: 404 }
- if (legacy != null)
+ if (legacy != null && known != null)
return { status: 308, location: legacy }
- const locale = segment
- return withRenderContext ({ pathname: url.pathname, origin, apiBaseURL }, async () => {
+ const locale = isLocaleCode (segment) ? segment : 'ja'
+ if (known?.route.handle?.redirectTo != null)
+ return { status: 308,
+ location: `/${locale}${known.route.handle.redirectTo}${url.search}` }
+ const requestContext = { pathname: url.pathname, origin, apiBaseURL,
+ ...authentication, status: 200 }
+ return withRenderContext (requestContext, async () => {
const client = new QueryClient ({ defaultOptions: {
- queries: { retry: false, gcTime: Infinity } } })
+ queries: { retry: false, gcTime: Infinity, staleTime: Infinity } } })
try
{
const locales = await client.fetchQuery ({
queryKey: localesKeys.index, queryFn: fetchLocales })
const active = locales.locales.some (entry => entry.code === locale)
- if (!(active))
- return { status: 404 as const }
- const prepare = known.route.handle?.ssr
- if (prepare == null)
- return null
- const status = await prepare ({ client, params: known.params, locale,
- searchParams: url.searchParams })
+ if (authentication.transferCode != null)
+ {
+ try
+ {
+ const user = await apiGet> ('/users/session')
+ client.setQueryData (sessionKeys.current, {
+ id: user.id, name: user.name, role: user.role, inheritanceCode: '' })
+ }
+ catch (error)
+ {
+ if (!isApiError (error) || error.response?.status !== 401)
+ throw error
+ delete requestContext.transferCode
+ }
+ }
+ const prepare = known?.route.handle?.prepare
+ let status = known == null || !active || !isLocaleCode (segment) ? 404 : 200
+ if (status === 200 && known?.route.handle?.authentication != null
+ && client.getQueryData (sessionKeys.current) == null)
+ status = 401
+ if (prepare != null && known != null && status === 200)
+ status = await prepare ({ client, params: known.params, locale,
+ searchParams: url.searchParams })
const instance = createUII18n (locale)
const handler = createStaticHandler (appRoutes)
const context = await handler.query (new Request (url))
if (context instanceof Response)
throw new Error (`Unexpected router response: ${context.status}`)
const router = createStaticRouter (handler.dataRoutes, context)
- const helmetContext: { helmet?: HelmetServerState } = { }
- const html = renderToString (
+ let helmetContext: { helmet?: HelmetServerState } = { }
+ const page = () => (
@@ -70,13 +123,56 @@ export const render = async (
)
+ let html = ''
+ const deadline = Date.now () + 30_000
+ // Each pass discovers the queries used by the actual page, including dependent queries.
+ // Streaming resolves lazy components; only the last, fully prepared pass is returned.
+ for (;;)
+ {
+ if (Date.now () >= deadline)
+ throw new Error ('SSR data preparation timed out')
+ helmetContext = { }
+ requestContext.status = status
+ html = await renderDocument (page ())
+ const pending = client.getQueryCache ().getAll ().filter (query => {
+ const options = query.options as QueryObserverOptions
+ return query.state.status === 'pending'
+ && options.queryFn != null && options.queryFn !== skipToken
+ && options.enabled !== false
+ && (typeof options.enabled !== 'function' || options.enabled (query))
+ })
+ if (pending.length === 0)
+ {
+ status = requestContext.status
+ break
+ }
+ await Promise.all (pending.map (async query => {
+ try
+ {
+ await client.fetchQuery ({ ...query.options, queryKey: query.queryKey })
+ }
+ catch (error)
+ {
+ const code = isApiError (error) ? error.response?.status : null
+ if (code === 404 || code === 401 || code === 403)
+ status = code
+ else
+ throw error
+ }
+ }))
+ }
const helmet = helmetContext.helmet!
const head = [helmet.title, helmet.meta, helmet.link, helmet.style]
.map (entry => entry.toString ()).join ('')
- const state = dehydrate (client, { shouldDehydrateQuery: query =>
- query.state.status === 'success'
- && (query.queryKey[0] === 'locales'
- || (query.queryKey[0] === 'help' && query.queryKey[1] === 'article')) })
+ const dehydrated = dehydrate (client, { shouldDehydrateQuery: query =>
+ query.state.status !== 'pending'
+ && !(query.queryKey[0] === 'help' && query.queryKey[1] === 'edition') })
+ const state = { ...dehydrated, queries: dehydrated.queries.map (query => ({
+ ...query, state: { ...query.state, fetchFailureReason: null,
+ error: query.state.error == null ? null : {
+ isAxiosError: true, response: {
+ status: isApiError (query.state.error) ? query.state.error.response?.status : 500 } } }
+ })) }
const bootstrap = JSON.stringify ({ state, status, locale,
url: url.pathname + url.search })
.replace (/ {
&& { baseURL: currentRenderContext ()!.apiBaseURL, timeout: 10_000 }),
...(locale != null && { params: { locale, ...config.params } }),
headers: { 'X-Transfer-Code': typeof window === 'undefined'
- ? '' : localStorage.getItem ('user_code') ?? '',
+ ? currentRenderContext ()?.transferCode ?? ''
+ : localStorage.getItem ('user_code') ?? '',
+ ...(currentRenderContext ()?.forwardedFor != null && {
+ 'X-Forwarded-For': currentRenderContext ()!.forwardedFor }),
...(config.headers ?? { }) } }
}
@@ -37,6 +40,24 @@ const apiP = async (
body?: unknown,
opt?: Opt): Promise => {
const res = await client[method] (path, body ?? { }, withUserCode (opt))
+ if (typeof window !== 'undefined' && method === 'post'
+ && ['/users', '/users/verify', '/users/code/renew'].includes (path))
+ {
+ const code = res.data?.code ?? (res.data?.valid ? (body as { code?: string })?.code : null)
+ if (typeof code === 'string')
+ {
+ try
+ {
+ await fetch ('/_ssr/session', {
+ method: 'POST', credentials: 'same-origin',
+ headers: { 'X-Transfer-Code': code } })
+ }
+ catch
+ {
+ // Existing CSR authentication remains usable if the SSR endpoint is unavailable.
+ }
+ }
+ }
if (opt?.responseType === 'blob')
return res.data as T
return toCamel (res.data as Record, { deep: true }) as T
diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts
index 42a5a2c..98400ac 100644
--- a/frontend/src/lib/queryKeys.ts
+++ b/frontend/src/lib/queryKeys.ts
@@ -123,6 +123,8 @@ export const materialsKeys = {
export const wikiKeys = {
root: ['wiki'] as const,
+ diff: (id: string, p: { from: string | null; to: string | null }, locale: LocaleCode) =>
+ ['wiki', locale, 'diff', id, p] as const,
autolinkCandidates: (language: string, locale: LocaleCode) =>
['wiki', 'autolink', locale, language] as const,
index: (
@@ -147,3 +149,13 @@ export const helpKeys = {
diff: (identifier: string, language: string, from: string, to: string) =>
['help', 'diff', identifier, language, from, to] as const,
changes: (language: string) => ['help', 'changes', language] as const }
+
+export const sessionKeys = {
+ current: ['session', 'current'] as const,
+ settings: (id: number) => ['session', id, 'settings'] as const,
+ themeSlots: (id: number) => ['session', id, 'themeSlots'] as const }
+
+export const theatresKeys = {
+ show: (id: string, locale: LocaleCode) => ['theatres', locale, id] as const,
+ programmes: (id: string, locale: LocaleCode) =>
+ ['theatres', locale, id, 'programmes'] as const }
diff --git a/frontend/src/lib/renderContext.ts b/frontend/src/lib/renderContext.ts
index 46e5c97..0fc4954 100644
--- a/frontend/src/lib/renderContext.ts
+++ b/frontend/src/lib/renderContext.ts
@@ -1,7 +1,10 @@
export type RenderContext = {
pathname: string
origin: string
- apiBaseURL?: string }
+ apiBaseURL?: string
+ transferCode?: string
+ forwardedFor?: string
+ status?: number }
// The server build replaces this module with the AsyncLocalStorage implementation.
diff --git a/frontend/src/lib/routeLocales.ts b/frontend/src/lib/routeLocales.ts
index 7775314..8adf967 100644
--- a/frontend/src/lib/routeLocales.ts
+++ b/frontend/src/lib/routeLocales.ts
@@ -10,8 +10,11 @@ export type PagePreparation = {
searchParams: URLSearchParams }
export type PageRouteHandle = {
- ssr?: (request: PagePreparation) => Promise
+ prepare?: (request: PagePreparation) => Promise
locale?: LocaleCode
+ robots?: 'noindex'
+ authentication?: 'user'
+ redirectTo?: string
notFound?: boolean }
export type PageRoute = { handle?: PageRouteHandle } & (
diff --git a/frontend/src/lib/settings.ts b/frontend/src/lib/settings.ts
index 01470ac..98d5d4b 100644
--- a/frontend/src/lib/settings.ts
+++ b/frontend/src/lib/settings.ts
@@ -791,7 +791,8 @@ export const seedClientThemeMode = (theme: UserSettings['theme']): void => {
const prefersDarkTheme = (): boolean =>
- window.matchMedia ('(prefers-color-scheme: dark)').matches
+ typeof window !== 'undefined'
+ && window.matchMedia ('(prefers-color-scheme: dark)').matches
export const resolveThemeMode = (
diff --git a/frontend/src/lib/useHydrated.ts b/frontend/src/lib/useHydrated.ts
new file mode 100644
index 0000000..385857f
--- /dev/null
+++ b/frontend/src/lib/useHydrated.ts
@@ -0,0 +1,9 @@
+import { useSyncExternalStore } from 'react'
+
+const subscribe = () => () => { }
+const clientSnapshot = () => true
+const serverSnapshot = () => false
+
+
+export const useHydrated = (): boolean =>
+ useSyncExternalStore (subscribe, clientSnapshot, serverSnapshot)
diff --git a/frontend/src/lib/useKeyboardShortcuts.tsx b/frontend/src/lib/useKeyboardShortcuts.tsx
index 6cd80d2..7b09be3 100644
--- a/frontend/src/lib/useKeyboardShortcuts.tsx
+++ b/frontend/src/lib/useKeyboardShortcuts.tsx
@@ -99,12 +99,16 @@ export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => {
const navigate = useLocaleNavigate ()
const [keyboardSettings, setKeyboardSettingsState] =
- useState (() => getClientKeyboardSettings ())
+ useState ({ enabled: true, bindings: { } })
const [registeredHandlers, setRegisteredHandlers] =
useState> ({ })
const [shortcutHelpOpen, setShortcutHelpOpen] = useState (false)
const registrationIdRef = useRef (0)
+ useEffect (() => {
+ setKeyboardSettingsState (getClientKeyboardSettings ())
+ }, [])
+
const activeScope = useMemo (
() => resolveShortcutScope (location.pathname),
[location.pathname],
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index 61b2186..e8d240b 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -63,17 +63,23 @@ const start = () => {
if (data?.state == null || !(Array.isArray (data.state.queries)))
throw new Error ('Invalid SSR query state')
hydrate (client, data.state)
+ for (const query of data.state.queries)
+ {
+ if (query.state.status === 'error')
+ client.setQueryDefaults (query.queryKey, { retryOnMount: false })
+ }
if (client.getQueryData (localesKeys.index) == null)
throw new Error ('Missing SSR locales')
const url = new URL (window.location.href)
const match = resolvePageRoute (url.pathname)
- const prepare = match?.route.handle?.ssr
- if (match == null || prepare == null)
- throw new Error ('Missing SSR route preparation')
- const status = await prepare ({ client, params: match.params,
- locale: currentViewingLocale (), searchParams: url.searchParams })
- if (status !== data.status)
- throw new Error ('Server and browser Help versions differ')
+ const prepare = match?.route.handle?.prepare
+ if (prepare != null && match != null)
+ {
+ const status = await prepare ({ client, params: match.params,
+ locale: currentViewingLocale (), searchParams: url.searchParams })
+ if (status !== data.status)
+ throw new Error ('Server and browser page versions differ')
+ }
mount (true)
mounted = true
for (const event of ['online', 'focus', 'pageshow'])
diff --git a/frontend/src/pages/GekanatorPage.tsx b/frontend/src/pages/GekanatorPage.tsx
index f99ac4d..e37d7f3 100644
--- a/frontend/src/pages/GekanatorPage.tsx
+++ b/frontend/src/pages/GekanatorPage.tsx
@@ -1,12 +1,13 @@
import { animate, motion, useMotionTemplate, useMotionValue } from 'framer-motion'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
import { useLocale } from '@/lib/useLocale'
import PrefetchLink from '@/components/PrefetchLink'
import MainArea from '@/components/layout/MainArea'
import PageHead from '@/components/common/PageHead'
import { useUITranslation } from '@/lib/useUITranslation'
+import { useHydrated } from '@/lib/useHydrated'
import { expectedAnswerForQuestion,
fetchGekanatorExtraQuestions,
fetchGekanatorQuestions,
@@ -3679,23 +3680,32 @@ const expectedAnswerFor = (
const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
+ const hydrated = useHydrated ()
+ const initialSeed = useId ()
const { message } = useUITranslation ('gekanator')
const locale = useLocale ()
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
- const storedGame = useMemo (loadStoredGame, [])
+ const storedGame = useMemo (() => hydrated ? loadStoredGame () : null, [hydrated])
const hasStoredRestore = storedGame != null && isStoredPhase (storedGame.phase)
const queryClient = useQueryClient ()
const isAdmin = user?.role === 'admin'
const canPersistGame = user != null
const [recentGames, setRecentGames] = useState (
- () => loadRecentGames ())
+ () => hydrated ? loadRecentGames () : [])
const [backgroundMotionMode, setBackgroundMotionMode] = useState (
- () => loadBackgroundMotionMode ())
+ () => hydrated ? loadBackgroundMotionMode () : 'on')
const [prefersReducedMotion, setPrefersReducedMotion] = useState (false)
const [gameSeed, setGameSeed] = useState (
- storedGame?.gameSeed ?? createGameSeed ())
+ storedGame?.gameSeed ?? initialSeed)
const [restorePromptVisible, setRestorePromptVisible] = useState (hasStoredRestore)
+ useEffect (() => {
+ if (!hydrated)
+ return
+ setRecentGames (loadRecentGames ())
+ setBackgroundMotionMode (loadBackgroundMotionMode ())
+ setRestorePromptVisible (hasStoredRestore)
+ }, [hydrated, hasStoredRestore])
const [phase, setPhase] = useState (
hasStoredRestore ? 'intro' : storedGame?.phase ?? 'intro')
const [scores, setScores] = useState