From 0f92f2bff64039b6d755dc82750fbd8200cb4ce3 Mon Sep 17 00:00:00 2001 From: Glyn Gray Date: Mon, 31 Aug 2026 19:44:46 -0700 Subject: [PATCH 1/4] fix(navigation): align current page with Nuxt commits --- src/runtime/composables/useDrupalCe/index.ts | 112 ++++++++++++------- test/nuxt/currentPageCommit.test.ts | 58 ++++++++++ 2 files changed, 132 insertions(+), 38 deletions(-) create mode 100644 test/nuxt/currentPageCommit.test.ts diff --git a/src/runtime/composables/useDrupalCe/index.ts b/src/runtime/composables/useDrupalCe/index.ts index 910b5ea5..dab4ee8a 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 + + try { + const router = useRouter() + const currentPageKey = useState('drupal-ce-current-page-key', () => '') + const pendingPageKey = useState('drupal-ce-pending-page-key', () => '') + const skipProxy = !config.serverApiProxy + + const promotePendingPage = () => { + const key = pendingPageKey.value + + if (key && nuxtApp.payload.data[key]) { + currentPageKey.value = key + pendingPageKey.value = '' + } + } + + pendingPageKey.value = computePageKey(skipProxy, nuxtApp) + + router.afterEach((_to, _from, failure) => { + pendingPageKey.value = failure + ? '' + : computePageKey(skipProxy, nuxtApp) + }) + + nuxtApp.hook('page:finish', promotePendingPage) + nuxtApp.hook('app:error', () => { + 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() + } + } + catch { + // Silently skip if not in proper Nuxt context (e.g., unit tests). + } + } + /** * Fetches page data from Drupal, handles redirects, errors and messages * @@ -209,6 +267,9 @@ 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) // Build cache key from current route's fullPath (without hash) if not already provided // Callers can optionally provide a custom key via useFetchOptions.key @@ -252,6 +313,8 @@ export const useDrupalCe = () => { } // Handle redirect + const hasRedirect = Boolean(pageRef.value?.redirect) + if (pageRef.value?.redirect) { const redirect = pageRef.value.redirect await callWithNuxt(nuxtApp, navigateTo, [ @@ -301,8 +364,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 } @@ -404,43 +475,8 @@ export const useDrupalCe = () => { const nuxtApp = useNuxtApp() const currentPageKey = useState('drupal-ce-current-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 diff --git a/test/nuxt/currentPageCommit.test.ts b/test/nuxt/currentPageCommit.test.ts new file mode 100644 index 00000000..d27c33fb --- /dev/null +++ b/test/nuxt/currentPageCommit.test.ts @@ -0,0 +1,58 @@ +// @vitest-environment nuxt +import { beforeEach, describe, expect, it } from 'vitest' +import { registerEndpoint } from '@nuxt/test-utils/runtime' +import { useDrupalCe } from '../../src/runtime/composables/useDrupalCe' +import { useNuxtApp, 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 = '' + useState('drupal-ce-watcher-init').value = false + }) + + 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('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('') + }) +}) From f801be7c5683dd11e75c92b44685963fd5e89ca0 Mon Sep 17 00:00:00 2001 From: Glyn Gray Date: Mon, 31 Aug 2026 22:27:00 -0700 Subject: [PATCH 2/4] fix(navigation): preserve pending page on duplicate failure --- src/runtime/composables/useDrupalCe/index.ts | 6 +++--- test/nuxt/currentPageCommit.test.ts | 22 +++++++++++++++++++- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/runtime/composables/useDrupalCe/index.ts b/src/runtime/composables/useDrupalCe/index.ts index dab4ee8a..2a262a85 100644 --- a/src/runtime/composables/useDrupalCe/index.ts +++ b/src/runtime/composables/useDrupalCe/index.ts @@ -230,9 +230,9 @@ export const useDrupalCe = () => { pendingPageKey.value = computePageKey(skipProxy, nuxtApp) router.afterEach((_to, _from, failure) => { - pendingPageKey.value = failure - ? '' - : computePageKey(skipProxy, nuxtApp) + if (!failure) { + pendingPageKey.value = computePageKey(skipProxy, nuxtApp) + } }) nuxtApp.hook('page:finish', promotePendingPage) diff --git a/test/nuxt/currentPageCommit.test.ts b/test/nuxt/currentPageCommit.test.ts index d27c33fb..4c3b179d 100644 --- a/test/nuxt/currentPageCommit.test.ts +++ b/test/nuxt/currentPageCommit.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it } from 'vitest' import { registerEndpoint } from '@nuxt/test-utils/runtime' import { useDrupalCe } from '../../src/runtime/composables/useDrupalCe' -import { useNuxtApp, useState } from '#imports' +import { useNuxtApp, useRouter, useState } from '#imports' describe('committed Drupal page state', () => { beforeEach(() => { @@ -55,4 +55,24 @@ describe('committed Drupal page state', () => { 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('') + }) }) From b2673397288d0a91b9b523ac17a6e1121285a5f6 Mon Sep 17 00:00:00 2001 From: Glyn Gray Date: Mon, 7 Sep 2026 09:07:42 -0700 Subject: [PATCH 3/4] fix(navigation): guard page commits against superseded requests --- src/runtime/composables/useDrupalCe/index.ts | 73 +++++++------ test/e2e/currentPageCommit.test.ts | 47 ++++++++ test/fixtures/page-commit/app.vue | 13 +++ test/fixtures/page-commit/nuxt.config.ts | 12 +++ test/fixtures/page-commit/pages/[slug].vue | 13 +++ .../server/routes/ce-api/[...path].ts | 4 + test/nuxt/currentPageCommit.test.ts | 100 +++++++++++++++++- 7 files changed, 229 insertions(+), 33 deletions(-) create mode 100644 test/e2e/currentPageCommit.test.ts create mode 100644 test/fixtures/page-commit/app.vue create mode 100644 test/fixtures/page-commit/nuxt.config.ts create mode 100644 test/fixtures/page-commit/pages/[slug].vue create mode 100644 test/fixtures/page-commit/server/routes/ce-api/[...path].ts diff --git a/src/runtime/composables/useDrupalCe/index.ts b/src/runtime/composables/useDrupalCe/index.ts index 2a262a85..040b1ca6 100644 --- a/src/runtime/composables/useDrupalCe/index.ts +++ b/src/runtime/composables/useDrupalCe/index.ts @@ -212,43 +212,43 @@ export const useDrupalCe = () => { watcherInitialized.value = true - try { - const router = useRouter() - const currentPageKey = useState('drupal-ce-current-page-key', () => '') - const pendingPageKey = useState('drupal-ce-pending-page-key', () => '') - const skipProxy = !config.serverApiProxy - - const promotePendingPage = () => { - const key = pendingPageKey.value - - if (key && nuxtApp.payload.data[key]) { - currentPageKey.value = key - pendingPageKey.value = '' - } - } + 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 - pendingPageKey.value = computePageKey(skipProxy, nuxtApp) + const promotePendingPage = () => { + const key = pendingPageKey.value - router.afterEach((_to, _from, failure) => { - if (!failure) { - pendingPageKey.value = computePageKey(skipProxy, nuxtApp) - } - }) + const page = key && nuxtApp.payload.data[key] - nuxtApp.hook('page:finish', promotePendingPage) - nuxtApp.hook('app:error', () => { + if (page) { + if (!page.redirect) currentPageKey.value = key 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() } } - catch { - // Silently skip if not in proper Nuxt context (e.g., unit tests). + + 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() } } @@ -270,6 +270,9 @@ export const useDrupalCe = () => { 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 @@ -307,6 +310,14 @@ 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) 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 index 4c3b179d..614a1e3f 100644 --- a/test/nuxt/currentPageCommit.test.ts +++ b/test/nuxt/currentPageCommit.test.ts @@ -1,5 +1,5 @@ // @vitest-environment nuxt -import { beforeEach, describe, expect, it } from 'vitest' +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' @@ -19,7 +19,6 @@ describe('committed Drupal page state', () => { } useState('drupal-ce-current-page-key').value = 'page-origin-proxy' useState('drupal-ce-pending-page-key').value = '' - useState('drupal-ce-watcher-init').value = false }) it('keeps the rendered page current until Nuxt commits the destination', async () => { @@ -75,4 +74,101 @@ describe('committed Drupal page state', () => { 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') + }) + }) From 7984b8ac63554df6a6df8d6fe2edecd6fe3bbcd3 Mon Sep 17 00:00:00 2001 From: Glyn Gray Date: Fri, 25 Sep 2026 19:44:13 -0700 Subject: [PATCH 4/4] fix(navigation): keep getPage() filled while Nuxt swaps pages Nuxt 4 purges the outgoing page's data when its page component unmounts, one tick before page:finish commits the destination key. getPage() still pointed at the outgoing key and returned an empty page for that tick. Rendering never saw it, but watchers in a persistent layout did: an editor toolbar that derived its account menu key from the page cleared and refetched the menu on every navigation. getPage() now returns the fetched destination in that gap, never a redirect payload. Co-Authored-By: Claude Opus 5.5 --- src/runtime/composables/useDrupalCe/index.ts | 8 ++++++++ test/nuxt/currentPageCommit.test.ts | 17 +++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/src/runtime/composables/useDrupalCe/index.ts b/src/runtime/composables/useDrupalCe/index.ts index 040b1ca6..e8dafff5 100644 --- a/src/runtime/composables/useDrupalCe/index.ts +++ b/src/runtime/composables/useDrupalCe/index.ts @@ -485,6 +485,7 @@ 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', () => '') if (!customKey && import.meta.client) { initializePageKeySync(nuxtApp) @@ -497,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/nuxt/currentPageCommit.test.ts b/test/nuxt/currentPageCommit.test.ts index 614a1e3f..c705a6cc 100644 --- a/test/nuxt/currentPageCommit.test.ts +++ b/test/nuxt/currentPageCommit.test.ts @@ -40,6 +40,23 @@ describe('committed Drupal page state', () => { 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()