fix(live-preview): gate do bridge pelo contexto de preview — desbloqueia o verify (hash #novidades)

O bridge do Payload live preview montava em toda página pública e disparava
router.refresh() no mount; quando o RSC chegava depois do clique no CTA do
hero, o HistoryUpdater do App Router regravava a URL canônica sem hash.
Agora o bridge só monta em iframe/popup (window.parent/opener), com unit de
regressão, e2e determinístico (segura o _rsc e libera depois do clique) e
entrada no manifesto de afetados.
This commit is contained in:
fsolla 2026-10-01 21:58:48 +00:00
parent 0a8b0774d3
commit e1a0240d3a
4 changed files with 110 additions and 1 deletions

View file

@ -93,6 +93,14 @@ export const E2E_AFFECTED_MANIFEST = [
prefixes: ['src/app/(frontend)'],
specs: ['frontend'],
},
{
// The Payload live-preview bridge mounts on every `(frontend)` page and
// owns when a soft refresh may run; a diff there (or in the layout that
// mounts it) can silently drop the in-page anchors, so it wakes the hero
// owner (`frontend`) and the novidades anchor spec (`campaignNewsletter`).
prefixes: ['src/components/RefreshRouteOnSave.tsx', 'src/app/(frontend)/layout.tsx'],
specs: ['frontend', 'campaignNewsletter'],
},
{
// S19/S29 — the share-link surface: the `[type]` branch serves the OG card,
// the instant handoff and the announcement page (`.ics` included), and the

View file

@ -1,10 +1,28 @@
'use client'
import { RefreshRouteOnSave as PayloadLivePreview } from '@payloadcms/live-preview-react'
import { useRouter } from 'next/navigation.js'
import React from 'react'
import React, { useEffect, useState } from 'react'
/**
* The Payload live-preview bridge belongs to the preview surface the admin
* opens (an iframe in the edit view, or the popup variant). The package fires
* `router.refresh()` as soon as it mounts to pick up the saved data, and the
* App Router answers a refresh by rewriting the canonical URL — which drops
* any `#hash` applied between the click and the refresh (the hero CTA →
* `#novidades` deploy-verify race). A standalone public load must not pay that
* refresh, so the bridge mounts only when there is a preview parent
* (`window.parent` for the iframe, `window.opener` for the popup); the render
* stays `null` on every other page view.
*/
export const RefreshRouteOnSave: React.FC = () => {
const router = useRouter()
const [previewing, setPreviewing] = useState(false)
useEffect(() => {
setPreviewing(window.parent !== window || window.opener != null)
}, [])
if (!previewing) return null
return <PayloadLivePreview refresh={() => router.refresh()} serverURL="http://localhost:3000" />
}

View file

@ -241,6 +241,39 @@ test.describe('Campaign home novidades hash scroll', () => {
await page.goto(`/?e2e=scroll-${scrollSuffix}#novidades`)
await expect(page.locator('[data-home-section="newsletter"]')).toBeInViewport()
})
test('hero CTA keeps the hash when an on-mount refresh lands late', async ({ page }) => {
// The Payload live-preview bridge used to call `router.refresh()` on every
// standalone load; when the soft refresh landed after the CTA click, the
// App Router rewrote the canonical URL and dropped `#novidades` (the
// deploy-verify race). Hold the home soft refresh until after the click so
// the race is deterministic: outside the admin preview there must be no
// refresh at all, and the hash survives.
let releaseRefresh: (() => void) | undefined
const refreshGate = new Promise<void>((resolve) => {
releaseRefresh = resolve
})
await page.route(
(url) => url.searchParams.has('_rsc'),
async (route) => {
if (new URL(route.request().url()).pathname !== '/') {
await route.continue()
return
}
await refreshGate
await route.continue()
},
)
await page.goto(`/?e2e=refresh-${scrollSuffix}`)
await page.locator('[data-cta="secondary"]').click()
await expect(page).toHaveURL(/#novidades/)
releaseRefresh?.()
await page.waitForTimeout(1_000)
await expect(page).toHaveURL(/#novidades/)
})
})
test.describe('Campaign home novidades hash scroll — desktop', () => {

View file

@ -0,0 +1,50 @@
import { cleanup, render } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const router = vi.hoisted(() => ({ refresh: vi.fn() }))
vi.mock('next/navigation.js', () => ({ useRouter: () => router }))
const livePreview = vi.hoisted(() => ({ mounts: 0 }))
vi.mock('@payloadcms/live-preview-react', () => ({
RefreshRouteOnSave: ({ refresh }: { refresh: () => void }) => {
livePreview.mounts += 1
refresh()
return null
},
}))
import { RefreshRouteOnSave } from '@/components/RefreshRouteOnSave'
const setParentWindow = (value: Window) => {
Object.defineProperty(window, 'parent', { configurable: true, value })
}
afterEach(() => {
cleanup()
setParentWindow(window)
})
beforeEach(() => {
router.refresh.mockReset()
livePreview.mounts = 0
})
describe('RefreshRouteOnSave', () => {
it('does not mount the preview bridge (nor refresh) on a standalone public load', () => {
render(<RefreshRouteOnSave />)
expect(livePreview.mounts).toBe(0)
expect(router.refresh).not.toHaveBeenCalled()
})
it('mounts the bridge and refreshes inside the admin preview iframe', () => {
setParentWindow({} as Window)
render(<RefreshRouteOnSave />)
expect(livePreview.mounts).toBe(1)
expect(router.refresh).toHaveBeenCalledTimes(1)
})
})