Skip to content

Commit 00a673e

Browse files
authored
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
1 parent 5b5eaeb commit 00a673e

File tree

26 files changed

+573
-237
lines changed

26 files changed

+573
-237
lines changed

packages/next/src/utilities/initReq.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ export const initReq = async function ({
7272
cookies,
7373
headers,
7474
})
75+
7576
const i18n: I18nClient = await initI18n({
7677
config: config.i18n,
7778
context: 'client',

packages/next/src/views/Document/index.tsx

Lines changed: 10 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import type {
66
DocumentViewServerProps,
77
DocumentViewServerPropsOnly,
88
EditViewComponent,
9-
LivePreviewConfig,
109
PayloadComponent,
1110
RenderDocumentVersionsProperties,
1211
} from 'payload'
@@ -18,6 +17,7 @@ import {
1817
LivePreviewProvider,
1918
} from '@payloadcms/ui'
2019
import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
20+
import { handleLivePreview } from '@payloadcms/ui/rsc'
2121
import { isEditing as getIsEditing } from '@payloadcms/ui/shared'
2222
import { buildFormState } from '@payloadcms/ui/utilities/buildFormState'
2323
import { notFound, redirect } from 'next/navigation.js'
@@ -348,36 +348,14 @@ export const renderDocument = async ({
348348
viewType,
349349
}
350350

351-
const isLivePreviewEnabled = Boolean(
352-
config.admin?.livePreview?.collections?.includes(collectionSlug) ||
353-
config.admin?.livePreview?.globals?.includes(globalSlug) ||
354-
collectionConfig?.admin?.livePreview ||
355-
globalConfig?.admin?.livePreview,
356-
)
357-
358-
const livePreviewConfig: LivePreviewConfig = {
359-
...(isLivePreviewEnabled ? config.admin.livePreview : {}),
360-
...(collectionConfig?.admin?.livePreview || {}),
361-
...(globalConfig?.admin?.livePreview || {}),
362-
}
363-
364-
const livePreviewURL =
365-
operation !== 'create'
366-
? typeof livePreviewConfig?.url === 'function'
367-
? await livePreviewConfig.url({
368-
collectionConfig,
369-
data: doc,
370-
globalConfig,
371-
locale,
372-
req,
373-
/**
374-
* @deprecated
375-
* Use `req.payload` instead. This will be removed in the next major version.
376-
*/
377-
payload: initPageResult.req.payload,
378-
})
379-
: livePreviewConfig?.url
380-
: ''
351+
const { isLivePreviewEnabled, livePreviewConfig, livePreviewURL } = await handleLivePreview({
352+
collectionSlug,
353+
config,
354+
data: doc,
355+
globalSlug,
356+
operation,
357+
req,
358+
})
381359

382360
return {
383361
data: doc,
@@ -412,6 +390,7 @@ export const renderDocument = async ({
412390
breakpoints={livePreviewConfig?.breakpoints}
413391
isLivePreviewEnabled={isLivePreviewEnabled && operation !== 'create'}
414392
isLivePreviewing={entityPreferences?.value?.editViewType === 'live-preview'}
393+
typeofLivePreviewURL={typeof livePreviewConfig?.url as 'function' | 'string' | undefined}
415394
url={livePreviewURL}
416395
>
417396
{showHeader && !drawerSlug && (

packages/payload/src/admin/forms/Form.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,12 @@ export type BuildFormStateArgs = {
135135
*/
136136
renderAllFields?: boolean
137137
req: PayloadRequest
138+
/**
139+
* If true, will return a fresh URL for live preview based on the current form state.
140+
* Note: this will run on every form state event, so if your `livePreview.url` function is long running or expensive,
141+
* ensure it caches itself as needed.
142+
*/
143+
returnLivePreviewURL?: boolean
138144
returnLockStatus?: boolean
139145
schemaPath: string
140146
select?: SelectType

packages/payload/src/config/types.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -151,10 +151,14 @@ export type LivePreviewConfig = {
151151
width: number | string
152152
}[]
153153
/**
154-
The URL of the frontend application. This will be rendered within an `iframe` as its `src`.
155-
Payload will send a `window.postMessage()` to this URL with the document data in real-time.
156-
The frontend application is responsible for receiving the message and updating the UI accordingly.
157-
Use the `useLivePreview` hook to get started in React applications.
154+
* The URL of the frontend application. This will be rendered within an `iframe` as its `src`.
155+
* Payload will send a `window.postMessage()` to this URL with the document data in real-time.
156+
* The frontend application is responsible for receiving the message and updating the UI accordingly.
157+
* Use the `useLivePreview` hook to get started in React applications.
158+
*
159+
* Note: this function may run often if autosave is enabled with a small interval.
160+
* For performance, avoid long-running tasks or expensive operations within this function,
161+
* or if you need to do something more complex, cache your function as needed.
158162
*/
159163
url?:
160164
| ((args: {

packages/payload/src/globals/config/types.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -233,9 +233,7 @@ export type GlobalConfig<TSlug extends GlobalSlug = any> = {
233233
export interface SanitizedGlobalConfig
234234
extends Omit<DeepRequired<GlobalConfig>, 'endpoints' | 'fields' | 'slug' | 'versions'> {
235235
endpoints: Endpoint[] | false
236-
237236
fields: Field[]
238-
239237
/**
240238
* Fields in the database schema structure
241239
* Rows / collapsible / tabs w/o name `fields` merged to top, UIs are excluded

packages/ui/src/elements/LivePreview/IFrame/index.tsx

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,24 +6,17 @@ import './index.scss'
66

77
const baseClass = 'live-preview-iframe'
88

9-
type Props = {
10-
ref: React.RefObject<HTMLIFrameElement>
11-
setIframeHasLoaded: (value: boolean) => void
12-
url: string
13-
}
14-
15-
export const IFrame: React.FC<Props> = (props) => {
16-
const { ref, setIframeHasLoaded, url } = props
17-
18-
const { zoom } = useLivePreviewContext()
9+
export const IFrame: React.FC = () => {
10+
const { iframeRef, setLoadedURL, url, zoom } = useLivePreviewContext()
1911

2012
return (
2113
<iframe
2214
className={baseClass}
15+
key={url}
2316
onLoad={() => {
24-
setIframeHasLoaded(true)
17+
setLoadedURL(url)
2518
}}
26-
ref={ref}
19+
ref={iframeRef}
2720
src={url}
2821
style={{
2922
transform: typeof zoom === 'number' ? `scale(${zoom}) ` : undefined,

packages/ui/src/elements/LivePreview/Window/index.tsx

Lines changed: 34 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -12,22 +12,21 @@ import { useLivePreviewContext } from '../../../providers/LivePreview/context.js
1212
import { useLocale } from '../../../providers/Locale/index.js'
1313
import { ShimmerEffect } from '../../ShimmerEffect/index.js'
1414
import { DeviceContainer } from '../Device/index.js'
15-
import './index.scss'
1615
import { IFrame } from '../IFrame/index.js'
1716
import { LivePreviewToolbar } from '../Toolbar/index.js'
17+
import './index.scss'
1818

1919
const baseClass = 'live-preview-window'
2020

2121
export const LivePreviewWindow: React.FC<EditViewProps> = (props) => {
2222
const {
2323
appIsReady,
2424
breakpoint,
25-
iframeHasLoaded,
2625
iframeRef,
2726
isLivePreviewing,
27+
loadedURL,
2828
popupRef,
2929
previewWindowType,
30-
setIframeHasLoaded,
3130
url,
3231
} = useLivePreviewContext()
3332

@@ -38,10 +37,12 @@ export const LivePreviewWindow: React.FC<EditViewProps> = (props) => {
3837
const [formState] = useAllFormFields()
3938
const { id, collectionSlug, globalSlug } = useDocumentInfo()
4039

41-
// For client-side apps, send data through `window.postMessage`
42-
// The preview could either be an iframe embedded on the page
43-
// Or it could be a separate popup window
44-
// We need to transmit data to both accordingly
40+
/**
41+
* For client-side apps, send data through `window.postMessage`
42+
* The preview could either be an iframe embedded on the page
43+
* Or it could be a separate popup window
44+
* We need to transmit data to both accordingly
45+
*/
4546
useEffect(() => {
4647
if (!isLivePreviewing || !appIsReady) {
4748
return
@@ -79,21 +80,22 @@ export const LivePreviewWindow: React.FC<EditViewProps> = (props) => {
7980
url,
8081
collectionSlug,
8182
globalSlug,
82-
iframeHasLoaded,
8383
id,
8484
previewWindowType,
8585
popupRef,
8686
appIsReady,
8787
iframeRef,
88-
setIframeHasLoaded,
8988
mostRecentUpdate,
9089
locale,
9190
isLivePreviewing,
91+
loadedURL,
9292
])
9393

94-
// To support SSR, we transmit a `window.postMessage` event without a payload
95-
// This is because the event will ultimately trigger a server-side roundtrip
96-
// i.e., save, save draft, autosave, etc. will fire `router.refresh()`
94+
/**
95+
* To support SSR, we transmit a `window.postMessage` event without a payload
96+
* This is because the event will ultimately trigger a server-side roundtrip
97+
* i.e., save, save draft, autosave, etc. will fire `router.refresh()`
98+
*/
9799
useEffect(() => {
98100
if (!isLivePreviewing || !appIsReady) {
99101
return
@@ -114,30 +116,26 @@ export const LivePreviewWindow: React.FC<EditViewProps> = (props) => {
114116
}
115117
}, [mostRecentUpdate, iframeRef, popupRef, previewWindowType, url, isLivePreviewing, appIsReady])
116118

117-
if (previewWindowType === 'iframe') {
118-
return (
119-
<div
120-
className={[
121-
baseClass,
122-
isLivePreviewing && `${baseClass}--is-live-previewing`,
123-
breakpoint && breakpoint !== 'responsive' && `${baseClass}--has-breakpoint`,
124-
]
125-
.filter(Boolean)
126-
.join(' ')}
127-
>
128-
<div className={`${baseClass}__wrapper`}>
129-
<LivePreviewToolbar {...props} />
130-
<div className={`${baseClass}__main`}>
131-
<DeviceContainer>
132-
{url ? (
133-
<IFrame ref={iframeRef} setIframeHasLoaded={setIframeHasLoaded} url={url} />
134-
) : (
135-
<ShimmerEffect height="100%" />
136-
)}
137-
</DeviceContainer>
138-
</div>
119+
if (previewWindowType !== 'iframe') {
120+
return null
121+
}
122+
123+
return (
124+
<div
125+
className={[
126+
baseClass,
127+
isLivePreviewing && `${baseClass}--is-live-previewing`,
128+
breakpoint && breakpoint !== 'responsive' && `${baseClass}--has-breakpoint`,
129+
]
130+
.filter(Boolean)
131+
.join(' ')}
132+
>
133+
<div className={`${baseClass}__wrapper`}>
134+
<LivePreviewToolbar {...props} />
135+
<div className={`${baseClass}__main`}>
136+
<DeviceContainer>{url ? <IFrame /> : <ShimmerEffect height="100%" />}</DeviceContainer>
139137
</div>
140138
</div>
141-
)
142-
}
139+
</div>
140+
)
143141
}

packages/ui/src/exports/rsc/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export { CheckIcon } from '../../icons/Check/index.js'
99
export { copyDataFromLocaleHandler } from '../../utilities/copyDataFromLocale.js'
1010
export { getColumns } from '../../utilities/getColumns.js'
1111
export { getFolderResultsComponentAndData } from '../../utilities/getFolderResultsComponentAndData.js'
12+
export { handleLivePreview } from '../../utilities/handleLivePreview.js'
1213
export { renderFilters, renderTable } from '../../utilities/renderTable.js'
1314
export { resolveFilterOptions } from '../../utilities/resolveFilterOptions.js'
1415
export { upsertPreferences } from '../../utilities/upsertPreferences.js'

packages/ui/src/forms/Form/types.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,13 @@ export type Preferences = {
1818

1919
export type FormOnSuccess<T = unknown, C = Record<string, unknown>> = (
2020
json: T,
21-
options?: {
21+
ctx?: {
2222
/**
2323
* Arbitrary context passed to the onSuccess callback.
2424
*/
2525
context?: C
2626
/**
27-
* Form state at the time of the request used to retrieve the JSON response.
27+
* The form state that was sent with the request when retrieving the `json` arg.
2828
*/
2929
formState?: FormState
3030
},

packages/ui/src/providers/LivePreview/context.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,17 @@ export interface LivePreviewContextType {
1212
appIsReady: boolean
1313
breakpoint: LivePreviewConfig['breakpoints'][number]['name']
1414
breakpoints: LivePreviewConfig['breakpoints']
15-
iframeHasLoaded: boolean
1615
iframeRef: React.RefObject<HTMLIFrameElement | null>
1716
isLivePreviewEnabled: boolean
1817
isLivePreviewing: boolean
1918
isPopupOpen: boolean
2019
listeningForMessages?: boolean
20+
/**
21+
* The URL that has finished loading in the iframe or popup.
22+
* For example, if you set the `url`, it will begin to load into the iframe,
23+
* but `loadedURL` will not be set until the iframe's `onLoad` event fires.
24+
*/
25+
loadedURL?: string
2126
measuredDeviceSize: {
2227
height: number
2328
width: number
@@ -28,12 +33,17 @@ export interface LivePreviewContextType {
2833
setAppIsReady: (appIsReady: boolean) => void
2934
setBreakpoint: (breakpoint: LivePreviewConfig['breakpoints'][number]['name']) => void
3035
setHeight: (height: number) => void
31-
setIframeHasLoaded: (loaded: boolean) => void
3236
setIsLivePreviewing: (isLivePreviewing: boolean) => void
37+
setLoadedURL: (loadedURL: string) => void
3338
setMeasuredDeviceSize: (size: { height: number; width: number }) => void
3439
setPreviewWindowType: (previewWindowType: 'iframe' | 'popup') => void
3540
setSize: Dispatch<SizeReducerAction>
3641
setToolbarPosition: (position: { x: number; y: number }) => void
42+
/**
43+
* Sets the URL of the preview (either iframe or popup).
44+
* Will trigger a reload of the window.
45+
*/
46+
setURL: (url: string) => void
3747
setWidth: (width: number) => void
3848
setZoom: (zoom: number) => void
3949
size: {
@@ -44,6 +54,11 @@ export interface LivePreviewContextType {
4454
x: number
4555
y: number
4656
}
57+
/**
58+
* The live preview url property can be either a string or a function that returns a string.
59+
* 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.
60+
*/
61+
typeofLivePreviewURL?: 'function' | 'string'
4762
url: string | undefined
4863
zoom: number
4964
}
@@ -52,7 +67,6 @@ export const LivePreviewContext = createContext<LivePreviewContextType>({
5267
appIsReady: false,
5368
breakpoint: undefined,
5469
breakpoints: undefined,
55-
iframeHasLoaded: false,
5670
iframeRef: undefined,
5771
isLivePreviewEnabled: undefined,
5872
isLivePreviewing: false,
@@ -67,12 +81,13 @@ export const LivePreviewContext = createContext<LivePreviewContextType>({
6781
setAppIsReady: () => {},
6882
setBreakpoint: () => {},
6983
setHeight: () => {},
70-
setIframeHasLoaded: () => {},
7184
setIsLivePreviewing: () => {},
85+
setLoadedURL: () => {},
7286
setMeasuredDeviceSize: () => {},
7387
setPreviewWindowType: () => {},
7488
setSize: () => {},
7589
setToolbarPosition: () => {},
90+
setURL: () => {},
7691
setWidth: () => {},
7792
setZoom: () => {},
7893
size: {
@@ -83,6 +98,7 @@ export const LivePreviewContext = createContext<LivePreviewContextType>({
8398
x: 0,
8499
y: 0,
85100
},
101+
typeofLivePreviewURL: undefined,
86102
url: undefined,
87103
zoom: 1,
88104
})

0 commit comments

Comments
 (0)