Merge pull request #267 from 0112020179/codex/fix-neighbor-prefetch-priority

fix(frontend): 当前股票加载后串行预取邻股
This commit is contained in:
wshy
2026-09-08 21:06:45 +08:00
committed by GitHub
3 changed files with 165 additions and 26 deletions
+24 -26
View File
@@ -8,6 +8,7 @@ import { StockDailyKChart, getDefaultRange, toOHLC } from '@/components/StockDai
import { StockIntradayChart } from '@/components/StockIntradayChart'
import { financialMetricsQueryOptions, useFinancialMetrics } from '@/lib/useFinancials'
import { useCapabilities } from '@/lib/useSharedQueries'
import { scheduleNeighborPrefetch } from '@/lib/neighborPrefetch'
import type { ChartMarker, ChartPriceLine, ChartRange } from '@/components/EChartsCandlestick'
import {
loadInfoFields,
@@ -102,7 +103,10 @@ export function StockPanel({
)
const financials = useFinancialMetrics(hasFinanceField && hasFinancialCap ? symbol : undefined)
const dateRange = externalDateRange ?? getDefaultRange()
const defaultDateRange = getDefaultRange()
const rangeStart = externalDateRange?.start ?? defaultDateRange.start
const rangeEnd = externalDateRange?.end ?? defaultDateRange.end
const dateRange = useMemo(() => ({ start: rangeStart, end: rangeEnd }), [rangeStart, rangeEnd])
// 日K查询由本组件持有 (与 StockDailyKChart 共享同一 cache key/配置, 只发一次请求)。
// 信息条直接读 query data: 切股到已预取邻股时首帧即有数据, 配合 StockInfoBar 加载态占位,
@@ -120,41 +124,35 @@ export function StockPanel({
onSelectDate?.(date)
}, [onSelectDate])
// 邻近预取: 对切股导航的左右邻股提前拉取缓存, 切股瞬间免 loading
// 当前日K就绪后, 等可见查询空闲再串行预取邻股, 避免首屏争抢公网带宽
// 日K/分时预取 staleTime 30s 防来回切换重复请求; 成为当前股后 useQuery(staleTime=0) 立即后台刷新,
// SSE 也只按焦点股精准失效, 实时性不受影响。财务指标与正式查询同 staleTime, 5min 内不重复拉取。
// prefetchKey 按内容 join: 自选页 navList 随行情 tick 重建但邻股集合通常不变, 避免 effect 每次 tick 重跑。
const qc = useQueryClient()
const prefetchKey = prefetchSymbols?.join(',') ?? ''
// 守卫快速连续切股: 旧链路上异步回来的日K不再级联预取 (避免串股/浪费)
const prefetchTickRef = useRef('')
useEffect(() => {
if (!prefetchKey) return
prefetchTickRef.current = prefetchKey
for (const s of prefetchKey.split(',')) {
if (!symbol || !prefetchKey || !kline.isSuccess || kline.isPlaceholderData) return
const tasks: Array<() => Promise<unknown>> = []
for (const s of new Set(prefetchKey.split(',').filter(Boolean))) {
if (s === symbol) continue
let lastDate: string | undefined
tasks.push(async () => {
const res = await qc.fetchQuery({ ...klineDailyQueryOptions(s, dateRange, extColumns), staleTime: 30_000 })
const date = res?.rows?.at(-1)?.date
lastDate = date ? String(date).slice(0, 10) : undefined
})
// 独立排队, 切股/关闭后的日K响应不会继续级联分钟请求。
tasks.push(async () => {
if (lastDate) await qc.prefetchQuery({ ...klineMinuteQueryOptions(s, lastDate, true), staleTime: 30_000 })
})
if (hasFinanceField && hasFinancialCap) {
qc.prefetchQuery(financialMetricsQueryOptions(s))
tasks.push(() => qc.prefetchQuery(financialMetricsQueryOptions(s)))
}
// 分时 tab 的多日分时 + 最新分时: 切股后分时图也免 loading (与日K并行预取)
qc.prefetchQuery({ ...klineMinuteRangeQueryOptions(s, intradayDays), staleTime: 30_000 })
// latest 当日分时同样 live=true: 预取与渲染同读实时源 (历史日期后端忽略 live)
qc.prefetchQuery({ ...klineMinuteQueryOptions(s, undefined, true), staleTime: 30_000 })
// 日K用 fetchQuery (返回数据) 以便级联预取分时; 邻股预取失败静默, 不影响切股。
void qc.fetchQuery({ ...klineDailyQueryOptions(s, dateRange, extColumns), staleTime: 30_000 })
.then((res) => {
if (prefetchTickRef.current !== prefetchKey) return
// 日K到货后级联预取其默认选中日的分时数据: 日K视图并排展示分时图(默认选中最后交易日)。
const lastDate = res?.rows?.at(-1)?.date
if (lastDate) {
const d = String(lastDate).slice(0, 10)
// 同上 live=true: 该日若为当日即命中实时源 (历史日期后端忽略 live)
qc.prefetchQuery({ ...klineMinuteQueryOptions(s, d, true), staleTime: 30_000 })
}
})
.catch(() => {})
tasks.push(() => qc.prefetchQuery({ ...klineMinuteRangeQueryOptions(s, intradayDays), staleTime: 30_000 }))
tasks.push(() => qc.prefetchQuery({ ...klineMinuteQueryOptions(s, undefined, true), staleTime: 30_000 }))
}
}, [prefetchKey, symbol, dateRange, extColumns, hasFinanceField, hasFinancialCap, intradayDays, qc])
return scheduleNeighborPrefetch(qc, symbol, tasks)
}, [prefetchKey, symbol, dateRange, extColumns, hasFinanceField, hasFinancialCap, intradayDays, qc, kline.isSuccess, kline.isPlaceholderData])
// symbol 变化时重置分时相关状态,避免切股后残留旧日期。
// 日K信息直接读 query data (切股到已预取邻股首帧即有), 无需清空或门控。
+106
View File
@@ -0,0 +1,106 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { QueryClient, QueryObserver } from '@tanstack/react-query'
import { scheduleNeighborPrefetch } from './neighborPrefetch.ts'
const tick = () => new Promise(resolve => setTimeout(resolve, 10))
function deferred() {
let resolve!: () => void
const promise = new Promise<void>(done => { resolve = done })
return { promise, resolve }
}
test('foreground requests finish before serial neighbor requests start', async () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } })
const foreground = deferred()
const neighbor = deferred()
const calls: string[] = []
const observer = new QueryObserver(client, {
queryKey: ['kline-minute', 'current'],
queryFn: async () => { await foreground.promise; return [] },
})
const unsubscribe = observer.subscribe(() => {})
const stop = scheduleNeighborPrefetch(client, 'current', [
async () => { calls.push('first'); await neighbor.promise },
async () => { calls.push('second') },
])
try {
await tick()
assert.deepEqual(calls, [])
foreground.resolve()
await tick()
assert.deepEqual(calls, ['first'])
neighbor.resolve()
await tick()
assert.deepEqual(calls, ['first', 'second'])
} finally { stop(); unsubscribe(); client.clear() }
})
test('new foreground work pauses the queue; errors allow it to resume', async () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } })
const neighbor = deferred()
const foreground = deferred()
const calls: string[] = []
const stop = scheduleNeighborPrefetch(client, 'current', [
async () => { calls.push('first'); await neighbor.promise },
async () => { calls.push('second'); throw new Error('prefetch failed') },
async () => { calls.push('third') },
])
await tick()
const observer = new QueryObserver(client, {
queryKey: ['financials', 'metrics', 'current'],
queryFn: async () => { await foreground.promise; throw new Error('unavailable') },
})
const unsubscribe = observer.subscribe(() => {})
try {
neighbor.resolve()
await tick()
assert.deepEqual(calls, ['first'])
foreground.resolve()
await tick(); await tick()
assert.deepEqual(calls, ['first', 'second', 'third'])
} finally { stop(); unsubscribe(); client.clear() }
})
test('switching or closing stops pending and chained work', async () => {
const client = new QueryClient()
const neighbor = deferred()
const calls: string[] = []
const stop = scheduleNeighborPrefetch(client, 'current', [
async () => { calls.push('first'); await neighbor.promise },
async () => { calls.push('second') },
])
await tick()
stop()
neighbor.resolve()
await tick()
assert.deepEqual(calls, ['first'])
const stopBeforeStart = scheduleNeighborPrefetch(client, 'next', [
async () => { calls.push('obsolete') },
])
stopBeforeStart()
await tick()
assert.deepEqual(calls, ['first'])
client.clear()
})
test('prefetch reuses a fresh cache entry without another network request', async () => {
const client = new QueryClient({ defaultOptions: { queries: { gcTime: Infinity } } })
const key = ['kline', 'neighbor']
client.setQueryData(key, { rows: [] })
let requests = 0
let completed = false
const stop = scheduleNeighborPrefetch(client, 'current', [
() => client.prefetchQuery({
queryKey: key,
staleTime: 30_000,
queryFn: async () => { requests++; return { rows: [] } },
}),
async () => { completed = true },
])
try {
await tick(); await tick()
assert.equal(completed, true)
assert.equal(requests, 0)
} finally { stop(); client.clear() }
})
+35
View File
@@ -0,0 +1,35 @@
import type { QueryClient } from '@tanstack/react-query'
/** Yield to mounted foreground queries, including charts that mount after daily K arrives. */
export function scheduleNeighborPrefetch(
client: QueryClient,
symbol: string,
tasks: Array<() => Promise<unknown>>,
): () => void {
let stopped = false
let running = false
let next = 0
let timer: ReturnType<typeof setTimeout> | undefined
const schedule = () => {
if (stopped || running || timer !== undefined || next >= tasks.length) return
timer = setTimeout(() => {
timer = undefined
if (stopped || client.isFetching({
predicate: query => query.isActive() && query.queryKey.includes(symbol),
})) return
running = true
void Promise.resolve().then(tasks[next++]).catch(() => {}).finally(() => {
running = false
schedule()
})
}, 0)
}
const unsubscribe = client.getQueryCache().subscribe(schedule)
schedule()
return () => {
stopped = true
clearTimeout(timer)
unsubscribe()
}
}