diff --git a/src/runtime/composables/useDrupalCe/index.ts b/src/runtime/composables/useDrupalCe/index.ts index 910b5ea5..e8dafff5 100644 --- a/src/runtime/composables/useDrupalCe/index.ts +++ b/src/runtime/composables/useDrupalCe/index.ts @@ -194,6 +194,64 @@ export const useDrupalCe = () => { return properKey } + /** + * Keep getPage() aligned with the page Nuxt has actually committed. + * + * NuxtPage keeps the outgoing page visible while the destination resolves in + * Suspense. Page data can finish fetching during that overlap, so exposing it + * immediately makes shared layout and Drupal components combine the outgoing + * route with destination page data. Hold the fetched key as pending and only + * promote it after Nuxt commits the destination page. + */ + const initializePageKeySync = (nuxtApp: ReturnType) => { + if (import.meta.server) return + + const watcherInitialized = useState('drupal-ce-watcher-init', () => false) + + if (watcherInitialized.value) return + + watcherInitialized.value = true + + const router = useRouter() + const currentPageKey = useState('drupal-ce-current-page-key', () => '') + const pendingPageKey = useState('drupal-ce-pending-page-key', () => '') + const requestVersion = useState('drupal-ce-page-request-version', () => 0) + const skipProxy = !config.serverApiProxy + + const promotePendingPage = () => { + const key = pendingPageKey.value + + const page = key && nuxtApp.payload.data[key] + + if (page) { + if (!page.redirect) currentPageKey.value = key + pendingPageKey.value = '' + } + } + + pendingPageKey.value = computePageKey(skipProxy, nuxtApp) + + router.afterEach((to, from, failure) => { + // Failed and hash-only navigations do not replace the active page. + if (failure || to.fullPath.split('#')[0] === from.fullPath.split('#')[0]) return + requestVersion.value++ + pendingPageKey.value = computePageKey(skipProxy, nuxtApp) + }) + + nuxtApp.hook('page:finish', promotePendingPage) + nuxtApp.hook('app:error', () => { + requestVersion.value++ + pendingPageKey.value = '' + }) + + // Hydration already represents a committed page. A client-only initial + // boot has no outgoing page to preserve, so existing payload data is also + // safe to expose immediately. + if (nuxtApp.isHydrating || !currentPageKey.value) { + promotePendingPage() + } + } + /** * Fetches page data from Drupal, handles redirects, errors and messages * @@ -209,6 +267,12 @@ export const useDrupalCe = () => { const fetchPage = async (path: string, useFetchOptions: UseFetchOptions = {}, overrideErrorHandler?: (error?: any) => void, skipDrupalCeApiProxy: boolean = false): Promise> => { const nuxtApp = useNuxtApp() const currentPageKey = useState('drupal-ce-current-page-key') + const pendingPageKey = useState('drupal-ce-pending-page-key', () => '') + + initializePageKeySync(nuxtApp) + const requestVersion = useState('drupal-ce-page-request-version', () => 0) + const version = import.meta.client ? ++requestVersion.value : requestVersion.value + const requestRoute = import.meta.client ? nuxtApp.$router.currentRoute.value.fullPath.split('#')[0] : '' // Build cache key from current route's fullPath (without hash) if not already provided // Callers can optionally provide a custom key via useFetchOptions.key @@ -246,12 +310,22 @@ export const useDrupalCe = () => { error = result.error.value } + // Superseded requests may populate their own cache, but must not change + // the active navigation, display messages or follow a stale redirect. + if (import.meta.client && (version !== requestVersion.value + || requestRoute !== nuxtApp.$router.currentRoute.value.fullPath.split('#')[0])) { + if (pageRef.value) pageRef.value.key = useFetchOptions.key + return pageRef.value ? pageRef : ref(createEmptyPage()) + } + // Process messages if (pageRef.value?.messages) { pushMessagesToState(pageRef.value.messages) } // Handle redirect + const hasRedirect = Boolean(pageRef.value?.redirect) + if (pageRef.value?.redirect) { const redirect = pageRef.value.redirect await callWithNuxt(nuxtApp, navigateTo, [ @@ -301,8 +375,16 @@ export const useDrupalCe = () => { // Add key to page pageRef.value.key = useFetchOptions.key - // Store the current page key for getPage() lookup - currentPageKey.value = useFetchOptions.key + if (!hasRedirect) { + // Server rendering has already committed this page. In the browser, hold + // the destination until NuxtPage emits page:finish after Suspense resolves. + if (import.meta.server) { + currentPageKey.value = useFetchOptions.key + } + else { + pendingPageKey.value = useFetchOptions.key + } + } return pageRef } @@ -403,44 +485,10 @@ export const useDrupalCe = () => { const getPage = (customKey?: string): Ref => { const nuxtApp = useNuxtApp() const currentPageKey = useState('drupal-ce-current-page-key', () => '') + const pendingPageKey = useState('drupal-ce-pending-page-key', () => '') - // Set up route watcher to keep currentPageKey in sync (for KeepAlive scenarios) - // Only needed when using default key (not custom key) if (!customKey && import.meta.client) { - const watcherInitialized = useState('drupal-ce-watcher-init', () => false) - const pendingPageKey = useState('drupal-ce-pending-page-key', () => '') - - if (!watcherInitialized.value) { - watcherInitialized.value = true - try { - const router = useRouter() - - // Determine proxy mode based on config (same logic as fetchPage) - const skipProxy = !config.serverApiProxy - - // Track the initial key without switching current until data exists - pendingPageKey.value = computePageKey(skipProxy, nuxtApp) - - // Use router.afterEach to update the pending key after navigation completes - router.afterEach(() => { - pendingPageKey.value = computePageKey(skipProxy, nuxtApp) - }) - - // Promote pending key to current key once payload data is present - watch( - () => pendingPageKey.value && nuxtApp.payload.data[pendingPageKey.value], - (page) => { - if (page && pendingPageKey.value) { - currentPageKey.value = pendingPageKey.value - } - }, - { immediate: true }, - ) - } - catch { - // Silently skip if not in proper Nuxt context (e.g., unit tests) - } - } + initializePageKeySync(nuxtApp) } // Return computed ref that looks up the page data in the reactive Nuxt payload @@ -450,6 +498,13 @@ export const useDrupalCe = () => { if (key && nuxtApp.payload.data[key]) { return nuxtApp.payload.data[key] } + // Nuxt purges the outgoing page's data when its component unmounts, just + // before page:finish commits the destination. Bridge that gap with the + // fetched destination instead of an empty page. + const pending = !customKey && pendingPageKey.value && nuxtApp.payload.data[pendingPageKey.value] + if (pending && !pending.redirect) { + return pending + } // Return empty page data if no page has been fetched yet return createEmptyPage() }) diff --git a/test/e2e/currentPageCommit.test.ts b/test/e2e/currentPageCommit.test.ts new file mode 100644 index 00000000..91c331c0 --- /dev/null +++ b/test/e2e/currentPageCommit.test.ts @@ -0,0 +1,47 @@ +import { fileURLToPath } from 'node:url' +import { describe, it, expect } from 'vitest' +import { setup, createPage, url } from '@nuxt/test-utils/e2e' + +describe('Drupal page state follows real Nuxt Suspense commits', async () => { + await setup({ + rootDir: fileURLToPath(new URL('../fixtures/page-commit', import.meta.url)), + port: 3041, + browser: true, + }) + + it('keeps shared data on the outgoing page until the destination commits', async () => { + const page = await createPage() + try { + await page.goto(url('/origin'), { waitUntil: 'hydration' }) + await page.click('a[href="/slow"]') + await page.waitForFunction(() => typeof window.releaseSlowPage === 'function') + expect(await page.textContent('#page-title')).toBe('origin') + expect(await page.textContent('#shared-title')).toBe('origin') + await page.evaluate(() => window.releaseSlowPage()) + await page.waitForFunction(() => document.querySelector('#shared-title')?.textContent === 'slow') + expect(await page.textContent('#page-title')).toBe('slow') + await page.click('a[href="/origin"]') + await page.waitForFunction(() => document.querySelector('#shared-title')?.textContent === 'origin') + expect(await page.textContent('#page-title')).toBe('origin') + } + finally { await page.close() } + }) + + it('does not commit a superseded Suspense destination', async () => { + const page = await createPage() + try { + await page.goto(url('/origin'), { waitUntil: 'hydration' }) + await page.click('a[href="/slow"]') + await page.waitForFunction(() => typeof window.releaseSlowPage === 'function') + await page.click('a[href="/fast"]') + await page.waitForFunction(() => document.querySelector('#shared-title')?.textContent === 'fast') + await page.evaluate(async () => { + window.releaseSlowPage() + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))) + }) + expect(await page.textContent('#shared-title')).toBe('fast') + expect(await page.textContent('#page-title')).toBe('fast') + } + finally { await page.close() } + }) +}) diff --git a/test/fixtures/page-commit/app.vue b/test/fixtures/page-commit/app.vue new file mode 100644 index 00000000..9ce5bbfe --- /dev/null +++ b/test/fixtures/page-commit/app.vue @@ -0,0 +1,13 @@ + + + diff --git a/test/fixtures/page-commit/nuxt.config.ts b/test/fixtures/page-commit/nuxt.config.ts new file mode 100644 index 00000000..99d2f946 --- /dev/null +++ b/test/fixtures/page-commit/nuxt.config.ts @@ -0,0 +1,12 @@ +import { defineNuxtConfig } from 'nuxt/config' +import DrupalCe from '../../../src/module' + +export default defineNuxtConfig({ + modules: [DrupalCe], + compatibilityDate: '2026-09-07', + drupalCe: { + drupalBaseUrl: 'http://127.0.0.1:3041', + ceApiEndpoint: '/ce-api', + enableComponentPreview: false, + }, +}) diff --git a/test/fixtures/page-commit/pages/[slug].vue b/test/fixtures/page-commit/pages/[slug].vue new file mode 100644 index 00000000..8f0daf6f --- /dev/null +++ b/test/fixtures/page-commit/pages/[slug].vue @@ -0,0 +1,13 @@ + + + diff --git a/test/fixtures/page-commit/server/routes/ce-api/[...path].ts b/test/fixtures/page-commit/server/routes/ce-api/[...path].ts new file mode 100644 index 00000000..a5bd829f --- /dev/null +++ b/test/fixtures/page-commit/server/routes/ce-api/[...path].ts @@ -0,0 +1,4 @@ +export default defineEventHandler(event => ({ + title: getRouterParam(event, 'path'), + content: {}, +})) diff --git a/test/nuxt/currentPageCommit.test.ts b/test/nuxt/currentPageCommit.test.ts new file mode 100644 index 00000000..c705a6cc --- /dev/null +++ b/test/nuxt/currentPageCommit.test.ts @@ -0,0 +1,191 @@ +// @vitest-environment nuxt +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { registerEndpoint } from '@nuxt/test-utils/runtime' +import { useDrupalCe } from '../../src/runtime/composables/useDrupalCe' +import { useNuxtApp, useRouter, useState } from '#imports' + +describe('committed Drupal page state', () => { + beforeEach(() => { + const nuxtApp = useNuxtApp() + + registerEndpoint('/api/drupal-ce/destination', () => ({ + title: 'Destination page', + content: { title: 'Destination content' }, + })) + + nuxtApp.payload.data['page-origin-proxy'] = { + title: 'Origin page', + content: { title: 'Origin content' }, + } + useState('drupal-ce-current-page-key').value = 'page-origin-proxy' + useState('drupal-ce-pending-page-key').value = '' + }) + + it('keeps the rendered page current until Nuxt commits the destination', async () => { + const nuxtApp = useNuxtApp() + const { fetchPage, getPage } = useDrupalCe() + const currentPage = getPage() + + const destination = await fetchPage('/destination', { + key: 'page-destination-proxy', + }) + + expect(destination.value.title).toBe('Destination page') + expect(currentPage.value.title).toBe('Origin page') + expect(useState('drupal-ce-pending-page-key').value).toBe('page-destination-proxy') + + await nuxtApp.callHook('page:finish') + + expect(currentPage.value.title).toBe('Destination page') + expect(useState('drupal-ce-pending-page-key').value).toBe('') + }) + + it('shows the destination when Nuxt purges the outgoing page before the commit', async () => { + const nuxtApp = useNuxtApp() + const { fetchPage, getPage } = useDrupalCe() + const currentPage = getPage() + + await fetchPage('/destination', { + key: 'page-destination-proxy', + }) + delete nuxtApp.payload.data['page-origin-proxy'] + + expect(currentPage.value.title).toBe('Destination page') + + await nuxtApp.callHook('page:finish') + + expect(currentPage.value.title).toBe('Destination page') + }) + + it('does not promote a destination when navigation errors', async () => { + const nuxtApp = useNuxtApp() + const { fetchPage, getPage } = useDrupalCe() + const currentPage = getPage() + + await fetchPage('/destination', { + key: 'page-failed-destination-proxy', + }) + await nuxtApp.callHook('app:error', new Error('Navigation failed')) + await nuxtApp.callHook('page:finish') + + expect(currentPage.value.title).toBe('Origin page') + expect(useState('drupal-ce-pending-page-key').value).toBe('') + }) + + it('preserves a pending page when a later navigation fails', async () => { + const nuxtApp = useNuxtApp() + const router = useRouter() + const { fetchPage, getPage } = useDrupalCe() + const currentPage = getPage() + + await router.push('/destination') + await fetchPage('/destination', { + key: 'page-destination-proxy', + }) + + expect(useState('drupal-ce-pending-page-key').value).toBe('page-destination-proxy') + + await router.push('/destination') + await nuxtApp.callHook('page:finish') + + expect(currentPage.value.title).toBe('Destination page') + expect(useState('drupal-ce-pending-page-key').value).toBe('') + }) + + it('does not let a slower request replace the latest pending page', async () => { + const nuxtApp = useNuxtApp() + let release!: () => void + const gate = new Promise((resolve) => { release = resolve }) + let started = false + registerEndpoint('/api/drupal-ce/slow-page', async () => { + started = true + await gate + return { title: 'Slow page', content: {} } + }) + const { fetchPage, getPage } = useDrupalCe() + const currentPage = getPage() + const slow = fetchPage('/slow-page', { key: 'slow-request' }) + await vi.waitFor(() => expect(started).toBe(true)) + await fetchPage('/destination', { key: 'latest-request' }) + release() + await slow + await nuxtApp.callHook('page:finish') + expect(currentPage.value.title).toBe('Destination page') + }) + + it('does not restore pending state when an in-flight request finishes after app:error', async () => { + const nuxtApp = useNuxtApp() + let release!: () => void + const gate = new Promise((resolve) => { release = resolve }) + let started = false + registerEndpoint('/api/drupal-ce/error-late-page', async () => { + started = true + await gate + return { title: 'Late page', content: {} } + }) + const { fetchPage, getPage } = useDrupalCe() + const currentPage = getPage() + const pending = fetchPage('/error-late-page', { key: 'error-late-request' }) + await vi.waitFor(() => expect(started).toBe(true)) + await nuxtApp.callHook('app:error', new Error('Navigation failed')) + release() + await pending + await nuxtApp.callHook('page:finish') + expect(currentPage.value.title).toBe('Origin page') + }) + + it('never promotes a cached redirect payload', async () => { + const nuxtApp = useNuxtApp() + const { getPage } = useDrupalCe() + const currentPage = getPage() + nuxtApp.payload.data['cached-redirect'] = { redirect: { url: '/destination', statusCode: 302 } } + useState('drupal-ce-pending-page-key').value = 'cached-redirect' + await nuxtApp.callHook('page:finish') + expect(currentPage.value.title).toBe('Origin page') + }) + + + it('preserves a custom pending key through hash-only navigation', async () => { + const nuxtApp = useNuxtApp() + const router = useRouter() + await router.push('/destination') + const { fetchPage, getPage } = useDrupalCe() + const currentPage = getPage() + await fetchPage('/destination', { key: 'custom-hash-page' }) + await router.push('/destination#section') + await nuxtApp.callHook('page:finish') + expect(currentPage.value.title).toBe('Destination page') + expect(currentPage.value.key).toBe('custom-hash-page') + }) + + it('preserves the pending page when a guard aborts another navigation', async () => { + const nuxtApp = useNuxtApp() + const router = useRouter() + await router.push('/destination') + const { fetchPage, getPage } = useDrupalCe() + const currentPage = getPage() + await fetchPage('/destination', { key: 'guard-pending-page' }) + const removeGuard = router.beforeEach(to => to.path === '/blocked' ? false : undefined) + try { + await router.push('/blocked') + await nuxtApp.callHook('page:finish') + expect(currentPage.value.title).toBe('Destination page') + } + finally { + removeGuard() + } + }) + + it('promotes cached pages on return without another fetch', async () => { + const nuxtApp = useNuxtApp() + const router = useRouter() + const { getPage } = useDrupalCe() + const currentPage = getPage() + nuxtApp.payload.data['page-/cached-return-proxy'] = { title: 'Cached page', content: {} } + await router.push('/cached-return') + expect(currentPage.value.title).toBe('Origin page') + await nuxtApp.callHook('page:finish') + expect(currentPage.value.title).toBe('Cached page') + }) + +})