Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 93 additions & 38 deletions src/runtime/composables/useDrupalCe/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof useNuxtApp>) => {
if (import.meta.server) return

const watcherInitialized = useState<boolean>('drupal-ce-watcher-init', () => false)

if (watcherInitialized.value) return

watcherInitialized.value = true

const router = useRouter()
const currentPageKey = useState<string>('drupal-ce-current-page-key', () => '')
const pendingPageKey = useState<string>('drupal-ce-pending-page-key', () => '')
const requestVersion = useState<number>('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
*
Expand All @@ -209,6 +267,12 @@ export const useDrupalCe = () => {
const fetchPage = async (path: string, useFetchOptions: UseFetchOptions<any> = {}, overrideErrorHandler?: (error?: any) => void, skipDrupalCeApiProxy: boolean = false): Promise<Ref<DrupalCePage>> => {
const nuxtApp = useNuxtApp()
const currentPageKey = useState<string>('drupal-ce-current-page-key')
const pendingPageKey = useState<string>('drupal-ce-pending-page-key', () => '')

initializePageKeySync(nuxtApp)
const requestVersion = useState<number>('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
Expand Down Expand Up @@ -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, [
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -403,44 +485,10 @@ export const useDrupalCe = () => {
const getPage = (customKey?: string): Ref<DrupalCePage> => {
const nuxtApp = useNuxtApp()
const currentPageKey = useState<string>('drupal-ce-current-page-key', () => '')
const pendingPageKey = useState<string>('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<boolean>('drupal-ce-watcher-init', () => false)
const pendingPageKey = useState<string>('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
Expand All @@ -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()
})
Expand Down
47 changes: 47 additions & 0 deletions test/e2e/currentPageCommit.test.ts
Original file line number Diff line number Diff line change
@@ -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() }
})
})
13 changes: 13 additions & 0 deletions test/fixtures/page-commit/app.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<script setup>
const current = useDrupalCe().getPage()
</script>

<template>
<div>
<p id="shared-title">{{ current.title }}</p>
<NuxtLink to="/origin">Origin</NuxtLink>
<NuxtLink to="/slow">Slow</NuxtLink>
<NuxtLink to="/fast">Fast</NuxtLink>
<NuxtPage :keepalive="{ max: 5 }" />
</div>
</template>
12 changes: 12 additions & 0 deletions test/fixtures/page-commit/nuxt.config.ts
Original file line number Diff line number Diff line change
@@ -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,
},
})
13 changes: 13 additions & 0 deletions test/fixtures/page-commit/pages/[slug].vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<script setup>
const route = useRoute()
const page = await useDrupalCe().fetchPage(route.path)
if (import.meta.client && route.path === '/slow') {
// Hold Suspense after the API resolves, using a deterministic test gate.
await new Promise((resolve) => { window.releaseSlowPage = resolve })
}
definePageMeta({ key: route => route.path })
</script>

<template>
<h1 id="page-title">{{ page.title }}</h1>
</template>
4 changes: 4 additions & 0 deletions test/fixtures/page-commit/server/routes/ce-api/[...path].ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export default defineEventHandler(event => ({
title: getRouterParam(event, 'path'),
content: {},
}))
Loading
Loading