mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
Merge pull request #7818 from ouchihao/codex/bug-custom-page-request-race
fix(custom-pages): ignore obsolete Markdown responses
This commit is contained in:
@@ -160,6 +160,7 @@ const tocItems = ref<TocItem[]>([])
|
||||
const tocVisible = ref(typeof window !== 'undefined' ? window.innerWidth > 768 : true)
|
||||
const activeHeadingId = ref('')
|
||||
let themeObserver: MutationObserver | null = null
|
||||
let markdownRequestVersion = 0
|
||||
|
||||
const embedShell = ref<HTMLElement | null>(null)
|
||||
const openButton = ref<HTMLAnchorElement | null>(null)
|
||||
@@ -290,6 +291,7 @@ function buildPageImageUrl(slug: string, src: string): string {
|
||||
}
|
||||
|
||||
async function fetchAndRenderMarkdown(slug: string) {
|
||||
const version = ++markdownRequestVersion
|
||||
loading.value = true
|
||||
tocItems.value = []
|
||||
activeHeadingId.value = ''
|
||||
@@ -297,11 +299,13 @@ async function fetchAndRenderMarkdown(slug: string) {
|
||||
const resp = await fetch(buildApiUrl(`/pages/${encodeURIComponent(slug)}`), {
|
||||
headers: authStore.token ? { Authorization: `Bearer ${authStore.token}` } : {},
|
||||
})
|
||||
if (version !== markdownRequestVersion) return
|
||||
if (!resp.ok) {
|
||||
renderedHtml.value = `<p class="text-red-500">${t('common.pageNotFound')}</p>`
|
||||
return
|
||||
}
|
||||
let raw = await resp.text()
|
||||
if (version !== markdownRequestVersion) return
|
||||
|
||||
raw = raw.replace(
|
||||
/!\[([^\]]*)\]\(([^)]+)\)/g,
|
||||
@@ -331,12 +335,16 @@ async function fetchAndRenderMarkdown(slug: string) {
|
||||
renderedHtml.value = withIds
|
||||
tocItems.value = toc
|
||||
} catch {
|
||||
renderedHtml.value = '<p class="text-red-500">Failed to load page</p>'
|
||||
if (version === markdownRequestVersion) {
|
||||
renderedHtml.value = '<p class="text-red-500">Failed to load page</p>'
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
injectCopyButtons()
|
||||
if (version === markdownRequestVersion) {
|
||||
loading.value = false
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
if (version === markdownRequestVersion) injectCopyButtons()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -406,6 +414,8 @@ watch(markdownSlug, (slug) => {
|
||||
if (slug) {
|
||||
fetchAndRenderMarkdown(slug)
|
||||
} else {
|
||||
markdownRequestVersion++
|
||||
loading.value = false
|
||||
renderedHtml.value = ''
|
||||
tocItems.value = []
|
||||
}
|
||||
@@ -434,6 +444,7 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
markdownRequestVersion++
|
||||
if (themeObserver) {
|
||||
themeObserver.disconnect()
|
||||
themeObserver = null
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { reactive, nextTick } from 'vue'
|
||||
import CustomPageView from '../CustomPageView.vue'
|
||||
|
||||
const route = reactive({ params: { id: 'old' } })
|
||||
const fetchPage = vi.fn()
|
||||
vi.mock('vue-router', () => ({ useRoute: () => route }))
|
||||
vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key, locale: { value: 'en' } }) }))
|
||||
vi.mock('@/stores', () => ({ useAppStore: () => ({
|
||||
publicSettingsLoaded: true,
|
||||
cachedPublicSettings: { custom_menu_items: [
|
||||
{ id: 'old', url: 'md:old' }, { id: 'new', url: 'md:new' },
|
||||
{ id: 'external', url: 'https://example.com/docs' }
|
||||
] }
|
||||
}) }))
|
||||
vi.mock('@/stores/auth', () => ({ useAuthStore: () => ({ isAdmin: false, user: { id: 1 }, token: 'test' }) }))
|
||||
vi.mock('@/stores/adminSettings', () => ({ useAdminSettingsStore: () => ({ customMenuItems: [] }) }))
|
||||
vi.mock('@/api/client', () => ({ buildApiUrl: (path: string) => `/api/v1${path}` }))
|
||||
vi.mock('@/components/layout/AppLayout.vue', () => ({ default: { template: '<div><slot /></div>' } }))
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (error: Error) => void
|
||||
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no })
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
const response = (markdown: string) => ({ ok: true, text: async () => markdown }) as Response
|
||||
const mountPage = () => mount(CustomPageView, { global: { stubs: { Icon: true } } })
|
||||
|
||||
beforeEach(() => {
|
||||
route.params.id = 'old'
|
||||
fetchPage.mockReset()
|
||||
vi.stubGlobal('fetch', fetchPage)
|
||||
vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} })
|
||||
})
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
describe('custom Markdown page request ordering', () => {
|
||||
it.each(['success', 'http error', 'network error'])('ignores an old %s after switching pages', async (outcome) => {
|
||||
const old = deferred<Response>()
|
||||
fetchPage.mockReturnValueOnce(old.promise).mockResolvedValueOnce(response('# New page'))
|
||||
const wrapper = mountPage()
|
||||
route.params.id = 'new'
|
||||
await flushPromises()
|
||||
expect(wrapper.get('.markdown-page-content h1').text()).toBe('New page')
|
||||
if (outcome === 'network error') old.reject(new Error('offline'))
|
||||
else old.resolve(outcome === 'http error' ? { ok: false } as Response : response('# Old page'))
|
||||
await flushPromises()
|
||||
expect(wrapper.get('.markdown-page-content h1').text()).toBe('New page')
|
||||
expect(wrapper.get('.toc-item').text()).toBe('New page')
|
||||
})
|
||||
|
||||
it('ignores an old body that finishes reading after the new page', async () => {
|
||||
const body = deferred<string>()
|
||||
fetchPage.mockResolvedValueOnce({ ok: true, text: () => body.promise })
|
||||
.mockResolvedValueOnce(response('# New page'))
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
route.params.id = 'new'
|
||||
await flushPromises()
|
||||
body.resolve('# Old page')
|
||||
await flushPromises()
|
||||
expect(wrapper.get('.markdown-page-content h1').text()).toBe('New page')
|
||||
})
|
||||
|
||||
it('keeps the current loading state when the old request completes', async () => {
|
||||
const old = deferred<Response>()
|
||||
const current = deferred<Response>()
|
||||
fetchPage.mockReturnValueOnce(old.promise).mockReturnValueOnce(current.promise)
|
||||
const wrapper = mountPage()
|
||||
route.params.id = 'new'
|
||||
await nextTick()
|
||||
old.resolve(response('# Old page'))
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.animate-spin').exists()).toBe(true)
|
||||
expect(wrapper.find('.markdown-page-content').exists()).toBe(false)
|
||||
current.resolve(response('# New page'))
|
||||
await flushPromises()
|
||||
expect(wrapper.get('.markdown-page-content h1').text()).toBe('New page')
|
||||
})
|
||||
|
||||
it('shows an embedded page immediately after leaving a pending Markdown page', async () => {
|
||||
const old = deferred<Response>()
|
||||
fetchPage.mockReturnValueOnce(old.promise)
|
||||
const wrapper = mountPage()
|
||||
route.params.id = 'external'
|
||||
await nextTick()
|
||||
expect(wrapper.get('iframe').attributes('src')).toContain('https://example.com/docs')
|
||||
old.resolve(response('# Old page'))
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.markdown-page-content').exists()).toBe(false)
|
||||
expect(wrapper.get('iframe').attributes('src')).toContain('https://example.com/docs')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user