このコミットが含まれているのは:
+15
-11
@@ -332,28 +332,32 @@ after (async () => {
|
|||||||
if (errors.length) throw new AggregateError (errors, 'SSR teardown failed')
|
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 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 column = resource === 'posts' ? 'title' : (locale === 'ja' ? 'name_ja' : 'name_ain')
|
||||||
const query =
|
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 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.equal (result.status, 200)
|
||||||
assert.match (result.html, /SSR count/)
|
assert.doesNotMatch (result.html, /SSR count|counts-grid|counts-nested/)
|
||||||
assert.match (result.html, new RegExp (`/${locale}/${resource}/1`))
|
assert.match (result.html, /counts-targets/)
|
||||||
|
assert.match (result.html, /<form[\s>]/)
|
||||||
assert.doesNotMatch (result.html, /counts:|columns\.changedAt|fields\.actor/)
|
assert.doesNotMatch (result.html, /counts:|columns\.changedAt|fields\.actor/)
|
||||||
assert.match (result.head, /<meta[^>]*name="robots"[^>]*content="noindex"/)
|
assert.match (result.head, /<meta[^>]*name="robots"[^>]*content="noindex"/)
|
||||||
assert.match (result.head, /<title[^>]*>[^<]*集計[^<]*\|[^<]*<\/title>/)
|
assert.match (result.head, /<title[^>]*>[^<]*集計[^<]*\|[^<]*<\/title>/)
|
||||||
assert.match (result.head, /rel="canonical"/)
|
assert.match (result.head, /rel="canonical"/)
|
||||||
assert.doesNotMatch (result.html, /private-discard|hidden-title|__counts_private__/)
|
assert.doesNotMatch (result.html, /private-discard|hidden-title|__counts_private__/)
|
||||||
|
|
||||||
assert.ok (requests.some (request => {
|
assert.ok (calls.some (url => url.pathname === `/counts/definitions/${resource}`))
|
||||||
const url = new URL (request.url, origin)
|
assert.equal (calls.filter (url => url.pathname === '/counts/query').length, 0)
|
||||||
return url.pathname === '/counts/query' && url.searchParams.get ('resource') === resource
|
assert.doesNotMatch (result.bootstrap, /SSR count|conditionLabels|pivotTotals/)
|
||||||
&& url.searchParams.get ('user_id') === '4'
|
const bootstrap = JSON.parse (result.bootstrap)
|
||||||
&& url.searchParams.get ('locale') === locale
|
assert.equal (bootstrap.state.queries.filter (query =>
|
||||||
}))
|
query.queryKey[0] === 'counts' && query.queryKey[1] === resource).length, 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -141,9 +141,11 @@ export const render = async (
|
|||||||
helmetContext = { }
|
helmetContext = { }
|
||||||
requestContext.status = status
|
requestContext.status = status
|
||||||
html = await renderDocument (page ())
|
html = await renderDocument (page ())
|
||||||
|
// Client-only queries must neither run here nor enter the bootstrap below.
|
||||||
const pending = client.getQueryCache ().getAll ().filter (query => {
|
const pending = client.getQueryCache ().getAll ().filter (query => {
|
||||||
const options = query.options as QueryObserverOptions
|
const options = query.options as QueryObserverOptions
|
||||||
return query.state.status === 'pending'
|
return query.state.status === 'pending'
|
||||||
|
&& query.meta?.clientOnly !== true
|
||||||
&& options.queryFn != null && options.queryFn !== skipToken
|
&& options.queryFn != null && options.queryFn !== skipToken
|
||||||
&& options.enabled !== false
|
&& options.enabled !== false
|
||||||
&& (typeof options.enabled !== 'function' || options.enabled (query))
|
&& (typeof options.enabled !== 'function' || options.enabled (query))
|
||||||
@@ -173,6 +175,7 @@ export const render = async (
|
|||||||
.map (entry => entry.toString ()).join ('')
|
.map (entry => entry.toString ()).join ('')
|
||||||
const dehydrated = dehydrate (client, { shouldDehydrateQuery: query =>
|
const dehydrated = dehydrate (client, { shouldDehydrateQuery: query =>
|
||||||
query.state.status !== 'pending'
|
query.state.status !== 'pending'
|
||||||
|
&& query.meta?.clientOnly !== true
|
||||||
&& !(query.queryKey[0] === 'help' && query.queryKey[1] === 'edition') })
|
&& !(query.queryKey[0] === 'help' && query.queryKey[1] === 'edition') })
|
||||||
const state = { ...dehydrated, queries: dehydrated.queries.map (query => ({
|
const state = { ...dehydrated, queries: dehydrated.queries.map (query => ({
|
||||||
...query, state: { ...query.state, fetchFailureReason: null,
|
...query, state: { ...query.state, fetchFailureReason: null,
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
|
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 { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
import CountsResourcePage from '@/pages/CountsResourcePage'
|
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 { detailResult, executionAt, postDefinition } from '@/test/fixtures/counts'
|
||||||
import { renderWithProviders } from '@/test/render'
|
import { renderWithProviders } from '@/test/render'
|
||||||
|
|
||||||
@@ -32,6 +40,50 @@ beforeEach (() => {
|
|||||||
|
|
||||||
|
|
||||||
describe ('Counts execution and URL state', () => {
|
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 = <QueryClientProvider client={client}>
|
||||||
|
<HelmetProvider><MemoryRouter initialEntries={[
|
||||||
|
'/ja/counts/posts?mode=details&timezone=UTC&resource_id=7']}>
|
||||||
|
<DialogueProvider><UnsavedChangesGuardProvider>
|
||||||
|
<KeyboardShortcutsProvider>
|
||||||
|
<CountsResourcePage resource="posts"/>
|
||||||
|
</KeyboardShortcutsProvider>
|
||||||
|
</UnsavedChangesGuardProvider></DialogueProvider>
|
||||||
|
</MemoryRouter></HelmetProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
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 () => {
|
it ('enriches the initial URL without a second query or removing the page shell', async () => {
|
||||||
renderPage ()
|
renderPage ()
|
||||||
const heading = screen.getByRole ('heading', { name: '広場集計' })
|
const heading = screen.getByRole ('heading', { name: '広場集計' })
|
||||||
@@ -44,6 +96,18 @@ describe ('Counts execution and URL state', () => {
|
|||||||
expect (currentParams ().get ('limit')).toBe ('100')
|
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',
|
it ('keeps edits local until execution then commits the server timestamp and filters together',
|
||||||
async () => {
|
async () => {
|
||||||
renderPage ()
|
renderPage ()
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ const CountsResourceContent = (
|
|||||||
resource: CountsResource; definition?: CountsDefinition; definitionError: boolean },
|
resource: CountsResource; definition?: CountsDefinition; definitionError: boolean },
|
||||||
) => {
|
) => {
|
||||||
const queryClient = useQueryClient ()
|
const queryClient = useQueryClient ()
|
||||||
|
const [mounted, setMounted] = useState (false)
|
||||||
|
useEffect (() => { setMounted (true) }, [])
|
||||||
const [source, setParams] = useSearchParams ()
|
const [source, setParams] = useSearchParams ()
|
||||||
const [timezone, setTimezone] = useState<string | null> (null)
|
const [timezone, setTimezone] = useState<string | null> (null)
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
@@ -103,10 +105,11 @@ const CountsResourceContent = (
|
|||||||
const [exporting, setExporting] = useState (false)
|
const [exporting, setExporting] = useState (false)
|
||||||
const exportInFlight = useRef (false)
|
const exportInFlight = useRef (false)
|
||||||
const [exportError, setExportError] = useState (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,
|
isPending: pending, isError: errorFlg,
|
||||||
isPlaceholderData: placeholder } = useQuery ({
|
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,
|
staleTime: params.has ('execution_at') ? 5 * 60 * 1000 : 0,
|
||||||
queryKey: countsKeys.index (resource, cacheParams.toString (), locale),
|
queryKey: countsKeys.index (resource, cacheParams.toString (), locale),
|
||||||
queryFn: ({ signal }) => fetchCounts (resource, params, locale, signal), retry: false,
|
queryFn: ({ signal }) => fetchCounts (resource, params, locale, signal), retry: false,
|
||||||
@@ -114,6 +117,8 @@ const CountsResourceContent = (
|
|||||||
(query?.queryKey[1] === resource && query?.queryKey[2] === locale
|
(query?.queryKey[1] === resource && query?.queryKey[2] === locale
|
||||||
&& queryClient.getQueryData (query.queryKey) === previous)
|
&& queryClient.getQueryData (query.queryKey) === previous)
|
||||||
? previous : undefined })
|
? previous : undefined })
|
||||||
|
// Match SSR during hydration even when a client cache already contains results.
|
||||||
|
const data = mounted ? cachedData : undefined
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
applied.current = params
|
applied.current = params
|
||||||
@@ -379,7 +384,7 @@ const CountsResourceContent = (
|
|||||||
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden"
|
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden"
|
||||||
aria-busy={waiting}>
|
aria-busy={waiting}>
|
||||||
<div inert={waiting} className="flex min-h-0 flex-1 flex-col">
|
<div inert={waiting} className="flex min-h-0 flex-1 flex-col">
|
||||||
{definition != null && data != null && <>
|
{mounted && definition != null && data != null && <>
|
||||||
<p className="my-1 flex flex-wrap items-baseline gap-x-2 text-sm">
|
<p className="my-1 flex flex-wrap items-baseline gap-x-2 text-sm">
|
||||||
<span>{label (resultMode === 'summary' ? 'outputRows' : 'outputTotal', { value: data.outputCount ?? '—' })}</span>
|
<span>{label (resultMode === 'summary' ? 'outputRows' : 'outputTotal', { value: data.outputCount ?? '—' })}</span>
|
||||||
{resultMode === 'details' && data.excludedCountScope === 'current_query'
|
{resultMode === 'details' && data.excludedCountScope === 'current_query'
|
||||||
|
|||||||
新しいイシューから参照
ユーザーをブロックする