diff --git a/frontend/src/views/user/CustomPageView.vue b/frontend/src/views/user/CustomPageView.vue index 910b6dafa9..701e593169 100644 --- a/frontend/src/views/user/CustomPageView.vue +++ b/frontend/src/views/user/CustomPageView.vue @@ -160,6 +160,7 @@ const tocItems = ref([]) const tocVisible = ref(typeof window !== 'undefined' ? window.innerWidth > 768 : true) const activeHeadingId = ref('') let themeObserver: MutationObserver | null = null +let markdownRequestVersion = 0 const embedShell = ref(null) const openButton = ref(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 = `

${t('common.pageNotFound')}

` 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 = '

Failed to load page

' + if (version === markdownRequestVersion) { + renderedHtml.value = '

Failed to load page

' + } } 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 diff --git a/frontend/src/views/user/__tests__/CustomPageView.race.spec.ts b/frontend/src/views/user/__tests__/CustomPageView.race.spec.ts new file mode 100644 index 0000000000..02d9a24782 --- /dev/null +++ b/frontend/src/views/user/__tests__/CustomPageView.race.spec.ts @@ -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: '
' } })) +enableAutoUnmount(afterEach) + +function deferred() { + let resolve!: (value: T) => void + let reject!: (error: Error) => void + const promise = new Promise((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() + 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() + 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() + const current = deferred() + 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() + 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') + }) +})