From 14853a8c5f96f38308df152cdbe4289213197cf9 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sat, 31 Oct 2026 20:44:28 +0900 Subject: [PATCH] #434 --- frontend/server.ssr.test.mjs | 26 ++++---- frontend/src/entry-server.tsx | 3 + .../src/pages/CountsResourcePage.test.tsx | 66 ++++++++++++++++++- frontend/src/pages/CountsResourcePage.tsx | 11 +++- 4 files changed, 91 insertions(+), 15 deletions(-) diff --git a/frontend/server.ssr.test.mjs b/frontend/server.ssr.test.mjs index 20d6ef2..d847913 100644 --- a/frontend/server.ssr.test.mjs +++ b/frontend/server.ssr.test.mjs @@ -332,28 +332,32 @@ after (async () => { if (errors.length) throw new AggregateError (errors, 'SSR teardown failed') }) -test ('counts SSR uses query/definitions, noindex and the common site title', async () => { +test ('counts SSR renders the shell without result requests or embedded results', async () => { for (const locale of ['ja', 'ain-Kana']) { - for (const resource of ['posts', 'tags']) { + for (const [resource, mode] of ['posts', 'tags'].flatMap (resource => + ['summary', 'details'].map (mode => [resource, mode]))) { const column = resource === 'posts' ? 'title' : (locale === 'ja' ? 'name_ja' : 'name_ain') const query = - `definition=history&mode=details&columns=resource_id+${ column }&user_id=4&timezone=UTC` + `definition=history&mode=${mode}&columns=resource_id+${ column }&user_id=4&timezone=UTC` + const start = requests.length const result = await renderPage (`/${locale}/counts/${resource}?${query}`, origin, apiURL) + const calls = requests.slice (start).map (request => new URL (request.url, origin)) assert.equal (result.status, 200) - assert.match (result.html, /SSR count/) - assert.match (result.html, new RegExp (`/${locale}/${resource}/1`)) + assert.doesNotMatch (result.html, /SSR count|counts-grid|counts-nested/) + assert.match (result.html, /counts-targets/) + assert.match (result.html, /]/) assert.doesNotMatch (result.html, /counts:|columns\.changedAt|fields\.actor/) assert.match (result.head, /]*name="robots"[^>]*content="noindex"/) assert.match (result.head, /]*>[^<]*集計[^<]*\|[^<]*<\/title>/) assert.match (result.head, /rel="canonical"/) assert.doesNotMatch (result.html, /private-discard|hidden-title|__counts_private__/) - assert.ok (requests.some (request => { - const url = new URL (request.url, origin) - return url.pathname === '/counts/query' && url.searchParams.get ('resource') === resource - && url.searchParams.get ('user_id') === '4' - && url.searchParams.get ('locale') === locale - })) + assert.ok (calls.some (url => url.pathname === `/counts/definitions/${resource}`)) + assert.equal (calls.filter (url => url.pathname === '/counts/query').length, 0) + assert.doesNotMatch (result.bootstrap, /SSR count|conditionLabels|pivotTotals/) + const bootstrap = JSON.parse (result.bootstrap) + assert.equal (bootstrap.state.queries.filter (query => + query.queryKey[0] === 'counts' && query.queryKey[1] === resource).length, 0) } } }) diff --git a/frontend/src/entry-server.tsx b/frontend/src/entry-server.tsx index 476e4b1..9c691ca 100644 --- a/frontend/src/entry-server.tsx +++ b/frontend/src/entry-server.tsx @@ -141,9 +141,11 @@ export const render = async ( helmetContext = { } requestContext.status = status html = await renderDocument (page ()) + // Client-only queries must neither run here nor enter the bootstrap below. const pending = client.getQueryCache ().getAll ().filter (query => { const options = query.options as QueryObserverOptions return query.state.status === 'pending' + && query.meta?.clientOnly !== true && options.queryFn != null && options.queryFn !== skipToken && options.enabled !== false && (typeof options.enabled !== 'function' || options.enabled (query)) @@ -173,6 +175,7 @@ export const render = async ( .map (entry => entry.toString ()).join ('') const dehydrated = dehydrate (client, { shouldDehydrateQuery: query => query.state.status !== 'pending' + && query.meta?.clientOnly !== true && !(query.queryKey[0] === 'help' && query.queryKey[1] === 'edition') }) const state = { ...dehydrated, queries: dehydrated.queries.map (query => ({ ...query, state: { ...query.state, fetchFailureReason: null, diff --git a/frontend/src/pages/CountsResourcePage.test.tsx b/frontend/src/pages/CountsResourcePage.test.tsx index 5b7f715..d840000 100644 --- a/frontend/src/pages/CountsResourcePage.test.tsx +++ b/frontend/src/pages/CountsResourcePage.test.tsx @@ -1,8 +1,16 @@ import { act, fireEvent, screen, waitFor } from '@testing-library/react' -import { useLocation } from 'react-router-dom' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { hydrateRoot } from 'react-dom/client' +import { renderToString } from 'react-dom/server' +import { HelmetProvider } from 'react-helmet-async' +import { MemoryRouter, useLocation } from 'react-router-dom' import { beforeEach, describe, expect, it, vi } from 'vitest' import CountsResourcePage from '@/pages/CountsResourcePage' +import DialogueProvider from '@/components/dialogues/DialogueProvider' +import { countsKeys } from '@/lib/queryKeys' +import { KeyboardShortcutsProvider } from '@/lib/useKeyboardShortcuts' +import { UnsavedChangesGuardProvider } from '@/lib/useUnsavedChangesGuard' import { detailResult, executionAt, postDefinition } from '@/test/fixtures/counts' import { renderWithProviders } from '@/test/render' @@ -32,6 +40,50 @@ beforeEach (() => { describe ('Counts execution and URL state', () => { + it ('hydrates the server shell before fetching results with the URL conditions', async () => { + const client = new QueryClient ({ + defaultOptions: { queries: { retry: false } } }) + client.setQueryData (countsKeys.definition ('posts', 'ja'), postDefinition) + const page = + + + + + + + + + const html = renderToString (page) + expect (fetchCounts).not.toHaveBeenCalled () + expect (html).toContain ('counts-targets') + expect (html).not.toContain ('Recorded title') + expect (html).not.toContain ('counts-grid') + const resultKey = client.getQueryCache ().getAll ().find (query => + query.queryKey[0] === 'counts' && query.queryKey[1] === 'posts')!.queryKey + client.setQueryData (resultKey, detailResult) + expect (renderToString (page)).not.toContain ('Recorded title') + client.removeQueries ({ queryKey: resultKey, exact: true }) + const container = document.createElement ('div') + container.innerHTML = html + document.body.appendChild (container) + const recoverable = vi.fn () + const root = hydrateRoot (container, page, { onRecoverableError: recoverable }) + try + { + await screen.findByText ('Recorded title') + expect (fetchCounts).toHaveBeenCalledTimes (1) + expect (fetchCounts.mock.calls[0][1].get ('resource_id')).toBe ('7') + expect (recoverable).not.toHaveBeenCalled () + } + finally + { + await act (async () => root.unmount ()) + container.remove () + client.clear () + } + }) + it ('enriches the initial URL without a second query or removing the page shell', async () => { renderPage () const heading = screen.getByRole ('heading', { name: '広場集計' }) @@ -44,6 +96,18 @@ describe ('Counts execution and URL state', () => { expect (currentParams ().get ('limit')).toBe ('100') }) + it ('retries an initial CSR failure with the URL filter intact', async () => { + fetchCounts.mockRejectedValueOnce (new Error ('offline')) + renderPage ('&resource_id=7') + await screen.findByRole ('alert') + expect (screen.queryByText ('Recorded title')).not.toBeInTheDocument () + expect (currentParams ().get ('resource_id')).toBe ('7') + execute () + await screen.findByText ('Recorded title') + expect (fetchCounts).toHaveBeenCalledTimes (2) + expect (fetchCounts.mock.calls[1][1].get ('resource_id')).toBe ('7') + }) + it ('keeps edits local until execution then commits the server timestamp and filters together', async () => { renderPage () diff --git a/frontend/src/pages/CountsResourcePage.tsx b/frontend/src/pages/CountsResourcePage.tsx index 8637133..8e5f6c7 100644 --- a/frontend/src/pages/CountsResourcePage.tsx +++ b/frontend/src/pages/CountsResourcePage.tsx @@ -56,6 +56,8 @@ const CountsResourceContent = ( resource: CountsResource; definition?: CountsDefinition; definitionError: boolean }, ) => { const queryClient = useQueryClient () + const [mounted, setMounted] = useState (false) + useEffect (() => { setMounted (true) }, []) const [source, setParams] = useSearchParams () const [timezone, setTimezone] = useState (null) useEffect (() => { @@ -103,10 +105,11 @@ const CountsResourceContent = ( const [exporting, setExporting] = useState (false) const exportInFlight = useRef (false) const [exportError, setExportError] = useState (false) - const { data, error: queryError, dataUpdatedAt, isFetching: fetching, + const { data: cachedData, error: queryError, dataUpdatedAt, isFetching: fetching, isPending: pending, isError: errorFlg, isPlaceholderData: placeholder } = useQuery ({ - enabled: definition != null && params.has ('timezone') && !running, + enabled: mounted && definition != null && params.has ('timezone') && !running, + meta: { clientOnly: true }, staleTime: params.has ('execution_at') ? 5 * 60 * 1000 : 0, queryKey: countsKeys.index (resource, cacheParams.toString (), locale), queryFn: ({ signal }) => fetchCounts (resource, params, locale, signal), retry: false, @@ -114,6 +117,8 @@ const CountsResourceContent = ( (query?.queryKey[1] === resource && query?.queryKey[2] === locale && queryClient.getQueryData (query.queryKey) === previous) ? previous : undefined }) + // Match SSR during hydration even when a client cache already contains results. + const data = mounted ? cachedData : undefined useEffect (() => { applied.current = params @@ -379,7 +384,7 @@ const CountsResourceContent = (
- {definition != null && data != null && <> + {mounted && definition != null && data != null && <>

{label (resultMode === 'summary' ? 'outputRows' : 'outputTotal', { value: data.outputCount ?? '—' })} {resultMode === 'details' && data.excludedCountScope === 'current_query'