From e1a0240d3a2ef7890f3bb386880bdd377d29ac12 Mon Sep 17 00:00:00 2001 From: fsolla Date: Thu, 1 Oct 2026 21:58:48 +0000 Subject: [PATCH] =?UTF-8?q?fix(live-preview):=20gate=20do=20bridge=20pelo?= =?UTF-8?q?=20contexto=20de=20preview=20=E2=80=94=20desbloqueia=20o=20veri?= =?UTF-8?q?fy=20(hash=20#novidades)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- scripts/lib/e2e-affected-manifest.mjs | 8 ++++ src/components/RefreshRouteOnSave.tsx | 20 ++++++++- tests/e2e/campaignNewsletter.e2e.spec.ts | 33 ++++++++++++++ tests/unit/refreshRouteOnSave.unit.spec.tsx | 50 +++++++++++++++++++++ 4 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 tests/unit/refreshRouteOnSave.unit.spec.tsx diff --git a/scripts/lib/e2e-affected-manifest.mjs b/scripts/lib/e2e-affected-manifest.mjs index 7ef63ec3..d32109fe 100644 --- a/scripts/lib/e2e-affected-manifest.mjs +++ b/scripts/lib/e2e-affected-manifest.mjs @@ -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 diff --git a/src/components/RefreshRouteOnSave.tsx b/src/components/RefreshRouteOnSave.tsx index 88ced874..8e6ad510 100644 --- a/src/components/RefreshRouteOnSave.tsx +++ b/src/components/RefreshRouteOnSave.tsx @@ -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 router.refresh()} serverURL="http://localhost:3000" /> } diff --git a/tests/e2e/campaignNewsletter.e2e.spec.ts b/tests/e2e/campaignNewsletter.e2e.spec.ts index 9add3da3..1d9e9127 100644 --- a/tests/e2e/campaignNewsletter.e2e.spec.ts +++ b/tests/e2e/campaignNewsletter.e2e.spec.ts @@ -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((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', () => { diff --git a/tests/unit/refreshRouteOnSave.unit.spec.tsx b/tests/unit/refreshRouteOnSave.unit.spec.tsx new file mode 100644 index 00000000..d80738c2 --- /dev/null +++ b/tests/unit/refreshRouteOnSave.unit.spec.tsx @@ -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() + + 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() + + expect(livePreview.mounts).toBe(1) + expect(router.refresh).toHaveBeenCalledTimes(1) + }) +})