feat(next): regenerate live preview url on save (#13631)

Closes #12785.

Although your live preview URL can be dynamic based on document data, it
is never recalculated after initial mount. This means if your URL is
dependent of document data that was just changed, such as a "slug"
field, the URL of the iframe does not reflect that change as expected
until the window is refreshed or you navigate back.

This also means that server-side live preview will crash when your
front-end attempts to query using a slug that no longer exists. Here's
the general flow: slug changes, autosave runs, iframe refreshes (url has
old slug), 404.

Now, we execute your live preview function on submit within form state,
and the window responds to the new URL as expected, refreshing itself
without losing its connection.

Here's the result:


https://github.com/user-attachments/assets/7dd3b147-ab6c-4103-8b2f-14d6bc889625

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1211094211063140
This commit is contained in:
Jacob Fletcher
2025-09-23 09:37:15 -04:00
committed by GitHub
parent 5b5eaebfdf
commit 00a673e491
26 changed files with 573 additions and 237 deletions

View File

@@ -72,6 +72,7 @@ export const initReq = async function ({
cookies,
headers,
})
const i18n: I18nClient = await initI18n({
config: config.i18n,
context: 'client',

View File

@@ -6,7 +6,6 @@ import type {
DocumentViewServerProps,
DocumentViewServerPropsOnly,
EditViewComponent,
LivePreviewConfig,
PayloadComponent,
RenderDocumentVersionsProperties,
} from 'payload'
@@ -18,6 +17,7 @@ import {
LivePreviewProvider,
} from '@payloadcms/ui'
import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
import { handleLivePreview } from '@payloadcms/ui/rsc'
import { isEditing as getIsEditing } from '@payloadcms/ui/shared'
import { buildFormState } from '@payloadcms/ui/utilities/buildFormState'
import { notFound, redirect } from 'next/navigation.js'
@@ -348,36 +348,14 @@ export const renderDocument = async ({
viewType,
}
const isLivePreviewEnabled = Boolean(
config.admin?.livePreview?.collections?.includes(collectionSlug) ||
config.admin?.livePreview?.globals?.includes(globalSlug) ||
collectionConfig?.admin?.livePreview ||
globalConfig?.admin?.livePreview,
)
const livePreviewConfig: LivePreviewConfig = {
...(isLivePreviewEnabled ? config.admin.livePreview : {}),
...(collectionConfig?.admin?.livePreview || {}),
...(globalConfig?.admin?.livePreview || {}),
}
const livePreviewURL =
operation !== 'create'
? typeof livePreviewConfig?.url === 'function'
? await livePreviewConfig.url({
collectionConfig,
const { isLivePreviewEnabled, livePreviewConfig, livePreviewURL } = await handleLivePreview({
collectionSlug,
config,
data: doc,
globalConfig,
locale,
globalSlug,
operation,
req,
/**
* @deprecated
* Use `req.payload` instead. This will be removed in the next major version.
*/
payload: initPageResult.req.payload,
})
: livePreviewConfig?.url
: ''
return {
data: doc,
@@ -412,6 +390,7 @@ export const renderDocument = async ({
breakpoints={livePreviewConfig?.breakpoints}
isLivePreviewEnabled={isLivePreviewEnabled && operation !== 'create'}
isLivePreviewing={entityPreferences?.value?.editViewType === 'live-preview'}
typeofLivePreviewURL={typeof livePreviewConfig?.url as 'function' | 'string' | undefined}
url={livePreviewURL}
>
{showHeader && !drawerSlug && (

View File

@@ -135,6 +135,12 @@ export type BuildFormStateArgs = {
*/
renderAllFields?: boolean
req: PayloadRequest
/**
* If true, will return a fresh URL for live preview based on the current form state.
* Note: this will run on every form state event, so if your `livePreview.url` function is long running or expensive,
* ensure it caches itself as needed.
*/
returnLivePreviewURL?: boolean
returnLockStatus?: boolean
schemaPath: string
select?: SelectType

View File

@@ -151,10 +151,14 @@ export type LivePreviewConfig = {
width: number | string
}[]
/**
The URL of the frontend application. This will be rendered within an `iframe` as its `src`.
Payload will send a `window.postMessage()` to this URL with the document data in real-time.
The frontend application is responsible for receiving the message and updating the UI accordingly.
Use the `useLivePreview` hook to get started in React applications.
* The URL of the frontend application. This will be rendered within an `iframe` as its `src`.
* Payload will send a `window.postMessage()` to this URL with the document data in real-time.
* The frontend application is responsible for receiving the message and updating the UI accordingly.
* Use the `useLivePreview` hook to get started in React applications.
*
* Note: this function may run often if autosave is enabled with a small interval.
* For performance, avoid long-running tasks or expensive operations within this function,
* or if you need to do something more complex, cache your function as needed.
*/
url?:
| ((args: {

View File

@@ -233,9 +233,7 @@ export type GlobalConfig<TSlug extends GlobalSlug = any> = {
export interface SanitizedGlobalConfig
extends Omit<DeepRequired<GlobalConfig>, 'endpoints' | 'fields' | 'slug' | 'versions'> {
endpoints: Endpoint[] | false
fields: Field[]
/**
* Fields in the database schema structure
* Rows / collapsible / tabs w/o name `fields` merged to top, UIs are excluded

View File

@@ -6,24 +6,17 @@ import './index.scss'
const baseClass = 'live-preview-iframe'
type Props = {
ref: React.RefObject<HTMLIFrameElement>
setIframeHasLoaded: (value: boolean) => void
url: string
}
export const IFrame: React.FC<Props> = (props) => {
const { ref, setIframeHasLoaded, url } = props
const { zoom } = useLivePreviewContext()
export const IFrame: React.FC = () => {
const { iframeRef, setLoadedURL, url, zoom } = useLivePreviewContext()
return (
<iframe
className={baseClass}
key={url}
onLoad={() => {
setIframeHasLoaded(true)
setLoadedURL(url)
}}
ref={ref}
ref={iframeRef}
src={url}
style={{
transform: typeof zoom === 'number' ? `scale(${zoom}) ` : undefined,

View File

@@ -12,9 +12,9 @@ import { useLivePreviewContext } from '../../../providers/LivePreview/context.js
import { useLocale } from '../../../providers/Locale/index.js'
import { ShimmerEffect } from '../../ShimmerEffect/index.js'
import { DeviceContainer } from '../Device/index.js'
import './index.scss'
import { IFrame } from '../IFrame/index.js'
import { LivePreviewToolbar } from '../Toolbar/index.js'
import './index.scss'
const baseClass = 'live-preview-window'
@@ -22,12 +22,11 @@ export const LivePreviewWindow: React.FC<EditViewProps> = (props) => {
const {
appIsReady,
breakpoint,
iframeHasLoaded,
iframeRef,
isLivePreviewing,
loadedURL,
popupRef,
previewWindowType,
setIframeHasLoaded,
url,
} = useLivePreviewContext()
@@ -38,10 +37,12 @@ export const LivePreviewWindow: React.FC<EditViewProps> = (props) => {
const [formState] = useAllFormFields()
const { id, collectionSlug, globalSlug } = useDocumentInfo()
// For client-side apps, send data through `window.postMessage`
// The preview could either be an iframe embedded on the page
// Or it could be a separate popup window
// We need to transmit data to both accordingly
/**
* For client-side apps, send data through `window.postMessage`
* The preview could either be an iframe embedded on the page
* Or it could be a separate popup window
* We need to transmit data to both accordingly
*/
useEffect(() => {
if (!isLivePreviewing || !appIsReady) {
return
@@ -79,21 +80,22 @@ export const LivePreviewWindow: React.FC<EditViewProps> = (props) => {
url,
collectionSlug,
globalSlug,
iframeHasLoaded,
id,
previewWindowType,
popupRef,
appIsReady,
iframeRef,
setIframeHasLoaded,
mostRecentUpdate,
locale,
isLivePreviewing,
loadedURL,
])
// To support SSR, we transmit a `window.postMessage` event without a payload
// This is because the event will ultimately trigger a server-side roundtrip
// i.e., save, save draft, autosave, etc. will fire `router.refresh()`
/**
* To support SSR, we transmit a `window.postMessage` event without a payload
* This is because the event will ultimately trigger a server-side roundtrip
* i.e., save, save draft, autosave, etc. will fire `router.refresh()`
*/
useEffect(() => {
if (!isLivePreviewing || !appIsReady) {
return
@@ -114,7 +116,10 @@ export const LivePreviewWindow: React.FC<EditViewProps> = (props) => {
}
}, [mostRecentUpdate, iframeRef, popupRef, previewWindowType, url, isLivePreviewing, appIsReady])
if (previewWindowType === 'iframe') {
if (previewWindowType !== 'iframe') {
return null
}
return (
<div
className={[
@@ -128,16 +133,9 @@ export const LivePreviewWindow: React.FC<EditViewProps> = (props) => {
<div className={`${baseClass}__wrapper`}>
<LivePreviewToolbar {...props} />
<div className={`${baseClass}__main`}>
<DeviceContainer>
{url ? (
<IFrame ref={iframeRef} setIframeHasLoaded={setIframeHasLoaded} url={url} />
) : (
<ShimmerEffect height="100%" />
)}
</DeviceContainer>
<DeviceContainer>{url ? <IFrame /> : <ShimmerEffect height="100%" />}</DeviceContainer>
</div>
</div>
</div>
)
}
}

View File

@@ -9,6 +9,7 @@ export { CheckIcon } from '../../icons/Check/index.js'
export { copyDataFromLocaleHandler } from '../../utilities/copyDataFromLocale.js'
export { getColumns } from '../../utilities/getColumns.js'
export { getFolderResultsComponentAndData } from '../../utilities/getFolderResultsComponentAndData.js'
export { handleLivePreview } from '../../utilities/handleLivePreview.js'
export { renderFilters, renderTable } from '../../utilities/renderTable.js'
export { resolveFilterOptions } from '../../utilities/resolveFilterOptions.js'
export { upsertPreferences } from '../../utilities/upsertPreferences.js'

View File

@@ -18,13 +18,13 @@ export type Preferences = {
export type FormOnSuccess<T = unknown, C = Record<string, unknown>> = (
json: T,
options?: {
ctx?: {
/**
* Arbitrary context passed to the onSuccess callback.
*/
context?: C
/**
* Form state at the time of the request used to retrieve the JSON response.
* The form state that was sent with the request when retrieving the `json` arg.
*/
formState?: FormState
},

View File

@@ -12,12 +12,17 @@ export interface LivePreviewContextType {
appIsReady: boolean
breakpoint: LivePreviewConfig['breakpoints'][number]['name']
breakpoints: LivePreviewConfig['breakpoints']
iframeHasLoaded: boolean
iframeRef: React.RefObject<HTMLIFrameElement | null>
isLivePreviewEnabled: boolean
isLivePreviewing: boolean
isPopupOpen: boolean
listeningForMessages?: boolean
/**
* The URL that has finished loading in the iframe or popup.
* For example, if you set the `url`, it will begin to load into the iframe,
* but `loadedURL` will not be set until the iframe's `onLoad` event fires.
*/
loadedURL?: string
measuredDeviceSize: {
height: number
width: number
@@ -28,12 +33,17 @@ export interface LivePreviewContextType {
setAppIsReady: (appIsReady: boolean) => void
setBreakpoint: (breakpoint: LivePreviewConfig['breakpoints'][number]['name']) => void
setHeight: (height: number) => void
setIframeHasLoaded: (loaded: boolean) => void
setIsLivePreviewing: (isLivePreviewing: boolean) => void
setLoadedURL: (loadedURL: string) => void
setMeasuredDeviceSize: (size: { height: number; width: number }) => void
setPreviewWindowType: (previewWindowType: 'iframe' | 'popup') => void
setSize: Dispatch<SizeReducerAction>
setToolbarPosition: (position: { x: number; y: number }) => void
/**
* Sets the URL of the preview (either iframe or popup).
* Will trigger a reload of the window.
*/
setURL: (url: string) => void
setWidth: (width: number) => void
setZoom: (zoom: number) => void
size: {
@@ -44,6 +54,11 @@ export interface LivePreviewContextType {
x: number
y: number
}
/**
* The live preview url property can be either a string or a function that returns a string.
* It is important to know which one it is, so that we can opt in/out of certain behaviors, e.g. calling the server to get the URL.
*/
typeofLivePreviewURL?: 'function' | 'string'
url: string | undefined
zoom: number
}
@@ -52,7 +67,6 @@ export const LivePreviewContext = createContext<LivePreviewContextType>({
appIsReady: false,
breakpoint: undefined,
breakpoints: undefined,
iframeHasLoaded: false,
iframeRef: undefined,
isLivePreviewEnabled: undefined,
isLivePreviewing: false,
@@ -67,12 +81,13 @@ export const LivePreviewContext = createContext<LivePreviewContextType>({
setAppIsReady: () => {},
setBreakpoint: () => {},
setHeight: () => {},
setIframeHasLoaded: () => {},
setIsLivePreviewing: () => {},
setLoadedURL: () => {},
setMeasuredDeviceSize: () => {},
setPreviewWindowType: () => {},
setSize: () => {},
setToolbarPosition: () => {},
setURL: () => {},
setWidth: () => {},
setZoom: () => {},
size: {
@@ -83,6 +98,7 @@ export const LivePreviewContext = createContext<LivePreviewContextType>({
x: 0,
y: 0,
},
typeofLivePreviewURL: undefined,
url: undefined,
zoom: 1,
})

View File

@@ -4,9 +4,12 @@ import type { CollectionPreferences, LivePreviewConfig } from 'payload'
import { DndContext } from '@dnd-kit/core'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { LivePreviewContextType } from './context.js'
import { usePopupWindow } from '../../hooks/usePopupWindow.js'
import { useDocumentInfo } from '../../providers/DocumentInfo/index.js'
import { usePreferences } from '../../providers/Preferences/index.js'
import { formatAbsoluteURL } from '../../utilities/formatAbsoluteURL.js'
import { customCollisionDetection } from './collisionDetection.js'
import { LivePreviewContext } from './context.js'
import { sizeReducer } from './sizeReducer.js'
@@ -21,23 +24,15 @@ export type LivePreviewProviderProps = {
}
isLivePreviewEnabled?: boolean
isLivePreviewing: boolean
url: string
}
const getAbsoluteUrl = (url) => {
try {
return new URL(url, window.location.origin).href
} catch {
return url
}
}
} & Pick<LivePreviewContextType, 'typeofLivePreviewURL' | 'url'>
export const LivePreviewProvider: React.FC<LivePreviewProviderProps> = ({
breakpoints: incomingBreakpoints,
children,
isLivePreviewEnabled,
isLivePreviewing: incomingIsLivePreviewing,
url: incomingUrl,
typeofLivePreviewURL,
url: urlFromProps,
}) => {
const [previewWindowType, setPreviewWindowType] = useState<'iframe' | 'popup'>('iframe')
const [isLivePreviewing, setIsLivePreviewing] = useState(incomingIsLivePreviewing)
@@ -57,19 +52,6 @@ export const LivePreviewProvider: React.FC<LivePreviewProviderProps> = ({
const [url, setURL] = useState<string>('')
// This needs to be done in a useEffect to prevent hydration issues
// as the URL may not be absolute when passed in as a prop,
// and getAbsoluteUrl requires the window object to be available
useEffect(
() =>
setURL(
incomingUrl?.startsWith('http://') || incomingUrl?.startsWith('https://')
? incomingUrl
: getAbsoluteUrl(incomingUrl),
),
[incomingUrl],
)
const { isPopupOpen, openPopupWindow, popupRef } = usePopupWindow({
eventType: 'payload-live-preview',
url,
@@ -86,7 +68,7 @@ export const LivePreviewProvider: React.FC<LivePreviewProviderProps> = ({
const iframeRef = React.useRef<HTMLIFrameElement>(null)
const [iframeHasLoaded, setIframeHasLoaded] = useState(false)
const [loadedURL, setLoadedURL] = useState<string>()
const [zoom, setZoom] = useState(1)
@@ -102,6 +84,31 @@ export const LivePreviewProvider: React.FC<LivePreviewProviderProps> = ({
const [breakpoint, setBreakpoint] =
React.useState<LivePreviewConfig['breakpoints'][0]['name']>('responsive')
/**
* A "middleware" callback fn that does some additional work before `setURL`.
* This is what we provide through context, bc it:
* - ensures the URL is absolute
* - resets `appIsReady` to `false` while the new URL is loading
*/
const setLivePreviewURL = useCallback<LivePreviewContextType['setURL']>(
(_incomingURL) => {
const incomingURL = formatAbsoluteURL(_incomingURL)
if (incomingURL !== url) {
setAppIsReady(false)
setURL(incomingURL)
}
},
[url],
)
/**
* `url` needs to be relative to the window, which cannot be done on initial render.
*/
useEffect(() => {
setURL(formatAbsoluteURL(urlFromProps))
}, [urlFromProps])
// The toolbar needs to freely drag and drop around the page
const handleDragEnd = (ev) => {
// only update position if the toolbar is completely within the preview area
@@ -155,10 +162,12 @@ export const LivePreviewProvider: React.FC<LivePreviewProviderProps> = ({
}
}, [breakpoint, breakpoints])
// Receive the `ready` message from the popup window
// This indicates that the app is ready to receive `window.postMessage` events
// This is also the only cross-origin way of detecting when a popup window has loaded
// Unlike iframe elements which have an `onLoad` handler, there is no way to access `window.open` on popups
/**
* Receive the `ready` message from the popup window
* This indicates that the app is ready to receive `window.postMessage` events
* This is also the only cross-origin way of detecting when a popup window has loaded
* Unlike iframe elements which have an `onLoad` handler, there is no way to access `window.open` on popups
*/
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
if (
@@ -224,12 +233,12 @@ export const LivePreviewProvider: React.FC<LivePreviewProviderProps> = ({
appIsReady,
breakpoint,
breakpoints,
iframeHasLoaded,
iframeRef,
isLivePreviewEnabled,
isLivePreviewing,
isPopupOpen,
listeningForMessages,
loadedURL,
measuredDeviceSize,
openPopupWindow,
popupRef,
@@ -237,16 +246,18 @@ export const LivePreviewProvider: React.FC<LivePreviewProviderProps> = ({
setAppIsReady,
setBreakpoint,
setHeight,
setIframeHasLoaded,
setIsLivePreviewing,
setLoadedURL,
setMeasuredDeviceSize,
setPreviewWindowType: handleWindowChange,
setSize,
setToolbarPosition: setPosition,
setURL: setLivePreviewURL,
setWidth,
setZoom,
size,
toolbarPosition: position,
typeofLivePreviewURL,
url,
zoom,
}}

View File

@@ -16,6 +16,7 @@ import { getClientConfig } from './getClientConfig.js'
import { getClientSchemaMap } from './getClientSchemaMap.js'
import { getSchemaMap } from './getSchemaMap.js'
import { handleFormStateLocking } from './handleFormStateLocking.js'
import { handleLivePreview } from './handleLivePreview.js'
export type LockedState = {
isLocked: boolean
@@ -27,11 +28,13 @@ type BuildFormStateSuccessResult = {
clientConfig?: ClientConfig
errors?: never
indexPath?: string
livePreviewURL?: string
lockedState?: LockedState
state: FormState
}
type BuildFormStateErrorResult = {
livePreviewURL?: never
lockedState?: never
state?: never
} & (
@@ -95,6 +98,7 @@ export const buildFormState = async (
payload,
payload: { config },
},
returnLivePreviewURL,
returnLockStatus,
schemaPath = collectionSlug || globalSlug,
select,
@@ -229,8 +233,26 @@ export const buildFormState = async (
})
}
return {
const res: BuildFormStateSuccessResult = {
lockedState: lockedStateResult,
state: formStateResult,
}
if (returnLivePreviewURL) {
const { livePreviewURL } = await handleLivePreview({
collectionSlug,
config,
data,
globalSlug,
req,
})
// Important: only set this when not undefined,
// Otherwise it will travel through the network as `$undefined`
if (livePreviewURL) {
res.livePreviewURL = livePreviewURL
}
}
return res
}

View File

@@ -0,0 +1,18 @@
const getAbsoluteUrl = (url) => {
try {
return new URL(url, window.location.origin).href
} catch {
return url
}
}
/**
* Ensures the provided URL is absolute. If not, it converts it to an absolute URL based
* on the current window location.
* Note: This MUST be called within the client environment as it relies on the `window` object
* to determine the absolute URL.
*/
export const formatAbsoluteURL = (incomingURL: string) =>
incomingURL?.startsWith('http://') || incomingURL?.startsWith('https://')
? incomingURL
: getAbsoluteUrl(incomingURL)

View File

@@ -0,0 +1,137 @@
import type {
CollectionConfig,
GlobalConfig,
LivePreviewConfig,
Operation,
PayloadRequest,
SanitizedConfig,
} from 'payload'
export const getLivePreviewConfig = ({
collectionConfig,
config,
globalConfig,
isLivePreviewEnabled,
}: {
collectionConfig?: CollectionConfig
config: SanitizedConfig
globalConfig?: GlobalConfig
isLivePreviewEnabled: boolean
}) => ({
...(isLivePreviewEnabled ? config.admin.livePreview : {}),
...(collectionConfig?.admin?.livePreview || {}),
...(globalConfig?.admin?.livePreview || {}),
})
/**
* Multi-level check to determine whether live preview is enabled on a collection or global.
* For example, live preview can be enabled at both the root config level, or on the entity's config.
* If a collectionConfig/globalConfig is provided, checks if it is enabled at the root level,
* or on the entity's own config.
*/
export const isLivePreviewEnabled = ({
collectionConfig,
config,
globalConfig,
}: {
collectionConfig?: CollectionConfig
config: SanitizedConfig
globalConfig?: GlobalConfig
}): boolean => {
if (globalConfig) {
return Boolean(
config.admin?.livePreview?.globals?.includes(globalConfig.slug) ||
globalConfig.admin?.livePreview,
)
}
if (collectionConfig) {
return Boolean(
config.admin?.livePreview?.collections?.includes(collectionConfig.slug) ||
collectionConfig.admin?.livePreview,
)
}
}
/**
* 1. Looks up the relevant live preview config, which could have been enabled:
* a. At the root level, e.g. `collections: ['posts']`
* b. On the collection or global config, e.g. `admin: { livePreview: { ... } }`
* 2. Determines if live preview is enabled, and if not, early returns.
* 3. Merges the config with the root config, if necessary.
* 4. Executes the `url` function, if necessary.
*
* Notice: internal function only. Subject to change at any time. Use at your own discretion.
*/
export const handleLivePreview = async ({
collectionSlug,
config,
data,
globalSlug,
operation,
req,
}: {
collectionSlug?: string
config: SanitizedConfig
data: Record<string, unknown>
globalSlug?: string
operation?: Operation
req: PayloadRequest
}): Promise<{
isLivePreviewEnabled?: boolean
livePreviewConfig?: LivePreviewConfig
livePreviewURL?: string
}> => {
const collectionConfig = collectionSlug
? req.payload.collections[collectionSlug]?.config
: undefined
const globalConfig = globalSlug ? config.globals.find((g) => g.slug === globalSlug) : undefined
const enabled = isLivePreviewEnabled({
collectionConfig,
config,
globalConfig,
})
if (!enabled) {
return {}
}
const livePreviewConfig = getLivePreviewConfig({
collectionConfig,
config,
globalConfig,
isLivePreviewEnabled: enabled,
})
let livePreviewURL: string | undefined
if (typeof livePreviewConfig?.url === 'string') {
livePreviewURL = livePreviewConfig.url
}
if (typeof livePreviewConfig?.url === 'function' && operation !== 'create') {
try {
const result = await livePreviewConfig.url({
collectionConfig,
data,
globalConfig,
locale: { code: req.locale, label: '' },
payload: req.payload,
req,
})
if (typeof result === 'string') {
livePreviewURL = result
}
} catch (err) {
req.payload.logger.error({
err,
msg: `There was an error executing the live preview URL function for ${collectionSlug || globalSlug}`,
})
}
}
return { isLivePreviewEnabled: enabled, livePreviewConfig, livePreviewURL }
}

View File

@@ -134,7 +134,13 @@ export function DefaultEditView({
const { resetUploadEdits } = useUploadEdits()
const { getFormState } = useServerFunctions()
const { startRouteTransition } = useRouteTransition()
const { isLivePreviewEnabled, isLivePreviewing, previewWindowType } = useLivePreviewContext()
const {
isLivePreviewEnabled,
isLivePreviewing,
previewWindowType,
setURL: setLivePreviewURL,
typeofLivePreviewURL,
} = useLivePreviewContext()
const abortOnChangeRef = useRef<AbortController>(null)
const abortOnSaveRef = useRef<AbortController>(null)
@@ -263,8 +269,8 @@ export function DefaultEditView({
])
const onSave: FormOnSuccess<any, OnSaveContext> = useCallback(
async (json, options) => {
const { context, formState } = options || {}
async (json, ctx) => {
const { context, formState } = ctx || {}
const controller = handleAbortRef(abortOnSaveRef)
@@ -272,12 +278,6 @@ export function DefaultEditView({
const updatedAt = document?.updatedAt || new Date().toISOString()
reportUpdate({
id,
entitySlug,
updatedAt,
})
// If we're editing the doc of the logged-in user,
// Refresh the cookie to get new permissions
if (user && collectionSlug === userSlug && id === user.id) {
@@ -328,7 +328,7 @@ export function DefaultEditView({
if (id || globalSlug) {
const docPreferences = await getDocPreferences()
const { state } = await getFormState({
const { livePreviewURL, state } = await getFormState({
id,
collectionSlug,
data: document,
@@ -338,6 +338,7 @@ export function DefaultEditView({
globalSlug,
operation,
renderAllFields: false,
returnLivePreviewURL: isLivePreviewEnabled && typeofLivePreviewURL === 'function',
returnLockStatus: false,
schemaPath: schemaPathSegments.join('.'),
signal: controller.signal,
@@ -349,6 +350,16 @@ export function DefaultEditView({
setDocumentIsLocked(false)
}
if (isLivePreviewEnabled) {
setLivePreviewURL(livePreviewURL)
}
reportUpdate({
id,
entitySlug,
updatedAt,
})
abortOnSaveRef.current = null
return state
@@ -367,7 +378,7 @@ export function DefaultEditView({
isEditing,
depth,
redirectAfterCreate,
getDocPermissions,
setLivePreviewURL,
globalSlug,
refreshCookieAsync,
incrementVersionCount,
@@ -376,10 +387,13 @@ export function DefaultEditView({
startRouteTransition,
router,
resetUploadEdits,
getDocPermissions,
getDocPreferences,
getFormState,
docPermissions,
operation,
isLivePreviewEnabled,
typeofLivePreviewURL,
schemaPathSegments,
isLockingEnabled,
setDocumentIsLocked,
@@ -432,17 +446,17 @@ export function DefaultEditView({
return state
},
[
id,
collectionSlug,
editSessionStartTime,
isLockingEnabled,
getDocPreferences,
getFormState,
id,
collectionSlug,
docPermissions,
globalSlug,
handleDocumentLocking,
isLockingEnabled,
operation,
schemaPathSegments,
docPermissions,
editSessionStartTime,
handleDocumentLocking,
],
)

View File

@@ -20,7 +20,11 @@ export default withBundleAnalyzer(
ignoreBuildErrors: true,
},
images: {
domains: ['localhost'],
remotePatterns: [
{
hostname: 'localhost',
},
],
},
env: {
PAYLOAD_CORE_DEV: 'true',

View File

@@ -0,0 +1,3 @@
export { selectLivePreviewBreakpoint } from './selectLivePreviewBreakpoint.js'
export { selectLivePreviewZoom } from './selectLivePreviewZoom.js'
export { toggleLivePreview } from './toggleLivePreview.js'

View File

@@ -0,0 +1,29 @@
import type { Page } from 'playwright'
import { POLL_TOPASS_TIMEOUT } from 'playwright.config.js'
import { expect } from 'playwright/test'
export const selectLivePreviewBreakpoint = async (page: Page, breakpointLabel: string) => {
const breakpointSelector = page.locator(
'.live-preview-toolbar-controls__breakpoint button.popup-button',
)
await expect(() => expect(breakpointSelector).toBeTruthy()).toPass({
timeout: POLL_TOPASS_TIMEOUT,
})
await breakpointSelector.first().click()
await page
.locator(`.live-preview-toolbar-controls__breakpoint button.popup-button-list__button`)
.filter({ hasText: breakpointLabel })
.click()
await expect(breakpointSelector).toContainText(breakpointLabel)
const option = page.locator(
'.live-preview-toolbar-controls__breakpoint button.popup-button-list__button--selected',
)
await expect(option).toHaveText(breakpointLabel)
}

View File

@@ -0,0 +1,33 @@
import type { Page } from 'playwright'
import { exactText } from 'helpers.js'
import { POLL_TOPASS_TIMEOUT } from 'playwright.config.js'
import { expect } from 'playwright/test'
export const selectLivePreviewZoom = async (page: Page, zoomLabel: string) => {
const zoomSelector = page.locator('.live-preview-toolbar-controls__zoom button.popup-button')
await expect(() => expect(zoomSelector).toBeTruthy()).toPass({
timeout: POLL_TOPASS_TIMEOUT,
})
await zoomSelector.first().click()
const zoomOption = page.locator(
'.live-preview-toolbar-controls__zoom button.popup-button-list__button',
{
hasText: exactText(zoomLabel),
},
)
expect(zoomOption).toBeTruthy()
await zoomOption.click()
await expect(zoomSelector).toContainText(zoomLabel)
const option = page.locator(
'.live-preview-toolbar-controls__zoom button.popup-button-list__button--selected',
)
await expect(option).toHaveText(zoomLabel)
}

View File

@@ -0,0 +1,29 @@
import type { Page } from 'playwright'
import { expect } from 'playwright/test'
export const toggleLivePreview = async (
page: Page,
options?: {
targetState?: 'off' | 'on'
},
): Promise<void> => {
const toggler = page.locator('#live-preview-toggler')
await expect(toggler).toBeVisible()
const isActive = await toggler.evaluate((el) =>
el.classList.contains('live-preview-toggler--active'),
)
if (isActive && (options?.targetState === 'off' || !options?.targetState)) {
await toggler.click()
await expect(toggler).not.toHaveClass(/live-preview-toggler--active/)
await expect(page.locator('iframe.live-preview-iframe')).toBeHidden()
}
if (!isActive && (options?.targetState === 'on' || !options?.targetState)) {
await toggler.click()
await expect(toggler).toHaveClass(/live-preview-toggler--active/)
await expect(page.locator('iframe.live-preview-iframe')).toBeVisible()
}
}

View File

@@ -19,6 +19,7 @@ type Args = {
export default async function SSRAutosavePage({ params: paramsPromise }: Args) {
const { slug = '' } = await paramsPromise
const data = await getDoc<Page>({
slug,
collection: ssrAutosavePagesSlug,

View File

@@ -27,6 +27,7 @@ export const Pages: CollectionConfig = {
{
name: 'slug',
type: 'text',
unique: true,
required: true,
admin: {
position: 'sidebar',

View File

@@ -36,6 +36,7 @@ export const SSRAutosave: CollectionConfig = {
name: 'slug',
type: 'text',
required: true,
unique: true,
admin: {
position: 'sidebar',
},

View File

@@ -11,6 +11,11 @@ import type { PayloadTestSDK } from '../helpers/sdk/index.js'
import { devUser } from '../credentials.js'
import { ensureCompilationIsDone, initPageConsoleErrorCatch, saveDocAndAssert } from '../helpers.js'
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
import {
selectLivePreviewBreakpoint,
selectLivePreviewZoom,
toggleLivePreview,
} from '../helpers/e2e/live-preview/index.js'
import { navigateToDoc, navigateToTrashedDoc } from '../helpers/e2e/navigateToDoc.js'
import { deletePreferences } from '../helpers/e2e/preferences.js'
import { waitForAutoSaveToRunAndComplete } from '../helpers/e2e/waitForAutoSaveToRunAndComplete.js'
@@ -23,9 +28,6 @@ import {
goToCollectionLivePreview,
goToGlobalLivePreview,
goToTrashedLivePreview,
selectLivePreviewBreakpoint,
selectLivePreviewZoom,
toggleLivePreview,
} from './helpers.js'
import {
collectionLevelConfigSlug,
@@ -50,7 +52,7 @@ describe('Live Preview', () => {
let pagesURLUtil: AdminUrlUtil
let postsURLUtil: AdminUrlUtil
let ssrPagesURLUtil: AdminUrlUtil
let ssrAutosavePostsURLUtil: AdminUrlUtil
let ssrAutosavePagesURLUtil: AdminUrlUtil
let payload: PayloadTestSDK<Config>
let user: any
@@ -61,7 +63,7 @@ describe('Live Preview', () => {
pagesURLUtil = new AdminUrlUtil(serverURL, pagesSlug)
postsURLUtil = new AdminUrlUtil(serverURL, postsSlug)
ssrPagesURLUtil = new AdminUrlUtil(serverURL, ssrPagesSlug)
ssrAutosavePostsURLUtil = new AdminUrlUtil(serverURL, ssrAutosavePagesSlug)
ssrAutosavePagesURLUtil = new AdminUrlUtil(serverURL, ssrAutosavePagesSlug)
const context = await browser.newContext()
page = await context.newPage()
@@ -105,7 +107,7 @@ describe('Live Preview', () => {
await expect(page.locator('iframe.live-preview-iframe')).toBeHidden()
})
test('collection - does not enable live preview is collections that are not configured', async () => {
test('collection - does not enable live preview in collections that are not configured', async () => {
const usersURL = new AdminUrlUtil(serverURL, 'users')
await navigateToDoc(page, usersURL)
const toggler = page.locator('#live-preview-toggler')
@@ -159,9 +161,10 @@ describe('Live Preview', () => {
await goToCollectionLivePreview(page, pagesURLUtil)
const iframe = page.locator('iframe.live-preview-iframe')
await expect(iframe).toBeVisible()
await expect.poll(async () => iframe.getAttribute('src')).toMatch(/\/live-preview/)
})
test('collection csr — re-renders iframe client-side when form state changes', async () => {
test('collection csr — iframe reflects form state on change', async () => {
await goToCollectionLivePreview(page, pagesURLUtil)
const titleField = page.locator('#field-title')
@@ -242,7 +245,65 @@ describe('Live Preview', () => {
})
})
test('collection ssr — re-render iframe when save is made', async () => {
test('collection csr — retains live preview connection after iframe src has changed', async () => {
const initialTitle = 'This is a test'
const testDoc = await payload.create({
collection: pagesSlug,
data: {
title: initialTitle,
slug: 'csr-test',
hero: {
type: 'none',
},
},
})
await page.goto(pagesURLUtil.edit(testDoc.id))
await toggleLivePreview(page, {
targetState: 'on',
})
const titleField = page.locator('#field-title')
const iframe = page.locator('iframe.live-preview-iframe')
await expect(iframe).toBeVisible()
const pattern1 = new RegExp(`/live-preview/${testDoc.slug}`)
await expect.poll(async () => iframe.getAttribute('src')).toMatch(pattern1)
const slugField = page.locator('#field-slug')
const newSlug = `${testDoc.slug}-2`
await slugField.fill(newSlug)
await saveDocAndAssert(page)
// expect the iframe to have a new src that reflects the updated slug
await expect(iframe).toBeVisible()
const pattern2 = new RegExp(`/live-preview/${newSlug}`)
await expect.poll(async () => iframe.getAttribute('src')).toMatch(pattern2)
const frame = page.frameLocator('iframe.live-preview-iframe').first()
const renderedPageTitleLocator = `#${renderedPageTitleID}`
await expect(() =>
expect(frame.locator(renderedPageTitleLocator)).toHaveText(`For Testing: ${initialTitle}`),
).toPass({
timeout: POLL_TOPASS_TIMEOUT,
})
// edit the title and check the iframe updates
const newTitle = `${initialTitle} (Edited)`
await titleField.fill(newTitle)
await expect(() =>
expect(frame.locator(renderedPageTitleLocator)).toHaveText(`For Testing: ${newTitle}`),
).toPass({
timeout: POLL_TOPASS_TIMEOUT,
})
})
test('collection ssr — iframe reflects form state on save', async () => {
await goToCollectionLivePreview(page, ssrPagesURLUtil)
const titleField = page.locator('#field-title')
@@ -305,9 +366,7 @@ describe('Live Preview', () => {
targetState: 'off',
})
await toggleLivePreview(page, {
targetState: 'on',
})
await toggleLivePreview(page)
// The iframe should still be showing the updated title
await expect(frame.locator(renderedPageTitleLocator)).toHaveText(
@@ -326,8 +385,63 @@ describe('Live Preview', () => {
})
})
test('collection ssr — re-render iframe when autosave is made', async () => {
await goToCollectionLivePreview(page, ssrAutosavePostsURLUtil)
test('collection ssr — retains live preview connection after iframe src has changed', async () => {
const initialTitle = 'This is a test'
const testDoc = await payload.create({
collection: ssrAutosavePagesSlug,
data: {
title: initialTitle,
slug: 'ssr-test',
hero: {
type: 'none',
},
},
})
await page.goto(ssrAutosavePagesURLUtil.edit(testDoc.id))
await toggleLivePreview(page, {
targetState: 'on',
})
const titleField = page.locator('#field-title')
const iframe = page.locator('iframe.live-preview-iframe')
const slugField = page.locator('#field-slug')
const newSlug = `${testDoc.slug}-2`
await slugField.fill(newSlug)
await waitForAutoSaveToRunAndComplete(page)
// expect the iframe to have a new src that reflects the updated slug
await expect(iframe).toBeVisible()
const pattern = new RegExp(`/live-preview/${ssrAutosavePagesSlug}/${newSlug}`)
await expect.poll(async () => iframe.getAttribute('src')).toMatch(pattern)
const frame = page.frameLocator('iframe.live-preview-iframe').first()
const renderedPageTitleLocator = `#${renderedPageTitleID}`
await expect(() =>
expect(frame.locator(renderedPageTitleLocator)).toHaveText(`For Testing: ${initialTitle}`),
).toPass({
timeout: POLL_TOPASS_TIMEOUT,
})
// edit the title and check the iframe updates
const newTitle = `${initialTitle} (Edited)`
await titleField.fill(newTitle)
await waitForAutoSaveToRunAndComplete(page)
await expect(() =>
expect(frame.locator(renderedPageTitleLocator)).toHaveText(`For Testing: ${newTitle}`),
).toPass({
timeout: POLL_TOPASS_TIMEOUT,
})
})
test('collection ssr — iframe reflects form state on autosave', async () => {
await goToCollectionLivePreview(page, ssrAutosavePagesURLUtil)
const titleField = page.locator('#field-title')
const frame = page.frameLocator('iframe.live-preview-iframe').first()
@@ -407,8 +521,6 @@ describe('Live Preview', () => {
test('global — can edit fields', async () => {
await goToGlobalLivePreview(page, 'header', serverURL)
const field = page.locator('input#field-navItems__0__link__newTab') //field-navItems__0__link__newTab
await expect(field).toBeVisible()
await expect(field).toBeEnabled()
await field.check()
await saveDocAndAssert(page)
})

View File

@@ -2,37 +2,11 @@ import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { exactText } from '../helpers.js'
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
import { toggleLivePreview } from '../helpers/e2e/live-preview/toggleLivePreview.js'
import { navigateToDoc, navigateToTrashedDoc } from '../helpers/e2e/navigateToDoc.js'
import { POLL_TOPASS_TIMEOUT } from '../playwright.config.js'
export const toggleLivePreview = async (
page: Page,
options?: {
targetState?: 'off' | 'on'
},
): Promise<void> => {
const toggler = page.locator('#live-preview-toggler')
await expect(toggler).toBeVisible()
const isActive = await toggler.evaluate((el) =>
el.classList.contains('live-preview-toggler--active'),
)
if (isActive && (options?.targetState === 'off' || !options?.targetState)) {
await toggler.click()
await expect(toggler).not.toHaveClass(/live-preview-toggler--active/)
await expect(page.locator('iframe.live-preview-iframe')).toBeHidden()
}
if (!isActive && (options?.targetState === 'on' || !options?.targetState)) {
await toggler.click()
await expect(toggler).toHaveClass(/live-preview-toggler--active/)
await expect(page.locator('iframe.live-preview-iframe')).toBeVisible()
}
}
export const goToCollectionLivePreview = async (
page: Page,
urlUtil: AdminUrlUtil,
@@ -65,59 +39,6 @@ export const goToGlobalLivePreview = async (
})
}
export const selectLivePreviewBreakpoint = async (page: Page, breakpointLabel: string) => {
const breakpointSelector = page.locator(
'.live-preview-toolbar-controls__breakpoint button.popup-button',
)
await expect(() => expect(breakpointSelector).toBeTruthy()).toPass({
timeout: POLL_TOPASS_TIMEOUT,
})
await breakpointSelector.first().click()
await page
.locator(`.live-preview-toolbar-controls__breakpoint button.popup-button-list__button`)
.filter({ hasText: breakpointLabel })
.click()
await expect(breakpointSelector).toContainText(breakpointLabel)
const option = page.locator(
'.live-preview-toolbar-controls__breakpoint button.popup-button-list__button--selected',
)
await expect(option).toHaveText(breakpointLabel)
}
export const selectLivePreviewZoom = async (page: Page, zoomLabel: string) => {
const zoomSelector = page.locator('.live-preview-toolbar-controls__zoom button.popup-button')
await expect(() => expect(zoomSelector).toBeTruthy()).toPass({
timeout: POLL_TOPASS_TIMEOUT,
})
await zoomSelector.first().click()
const zoomOption = page.locator(
'.live-preview-toolbar-controls__zoom button.popup-button-list__button',
{
hasText: exactText(zoomLabel),
},
)
expect(zoomOption).toBeTruthy()
await zoomOption.click()
await expect(zoomSelector).toContainText(zoomLabel)
const option = page.locator(
'.live-preview-toolbar-controls__zoom button.popup-button-list__button--selected',
)
await expect(option).toHaveText(zoomLabel)
}
export const ensureDeviceIsCentered = async (page: Page) => {
const main = page.locator('.live-preview-window__main')
const iframe = page.locator('iframe.live-preview-iframe')

View File

@@ -45,7 +45,11 @@ export default withBundleAnalyzer(
]
},
images: {
domains: ['localhost'],
remotePatterns: [
{
hostname: 'localhost',
},
],
},
webpack: (webpackConfig) => {
webpackConfig.resolve.extensionAlias = {