Skip to content

Commit 9999083

Browse files
authored
fix(ui): defer live preview iframe rendering (#15999)
When navigating to a document with live preview enabled, the respective site is optimistically loaded regardless of whether the user is actively previewing it. This creates the following problems: 1. If the user's live preview URL is set up to proxy through an endpoint, e.g. to enter draft mode before rendering the page, this will be triggered preemptively, causing "draft mode" to persist for their remaining session. For example, the user may revisit their site after editing a page without live preview, and unexpectedly see draft content. 2. There are unnecessary performance and network costs of rendering the iframe preemptively. Although the admin panel only dispatches events to visible preview windows, the initial page load consumes resources despite not being visible. For example, the underlying page is still downloaded and rendered (if uncached), its db queries are still hit (if dynamic), etc. Now, the iframe window is conditionally mounted only when the user toggles into live preview mode. To maintain fast loads thereafter, the iframe doesn't unmount when toggled off. Live preview events themselves are paused, but the window itself remains dormant until reactivated. In the future we may also want to consider adding callbacks to the live preview toggle event. That way you could reverse what your live preview endpoint has done, e.g. exit draft mode. Before: https://github.com/user-attachments/assets/853708b4-3e53-4e7d-b848-c5879661d84c After: https://github.com/user-attachments/assets/491981dc-acae-420b-8b79-0aa8a763d4cc Unrelated: - Deflakes the `toggleLivePreview` test helper by making is event-driven, e.g. it intercepts preference requests to ensure they are fulfilled before moving on. --- - To see the specific tasks where the Asana app for GitHub is being used, see below: - https://app.asana.com/0/0/1213727956884077
1 parent 843306c commit 9999083

13 files changed

Lines changed: 121 additions & 30 deletions

File tree

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export const LivePreviewWindow: React.FC<EditViewProps> = (props) => {
2727
loadedURL,
2828
popupRef,
2929
previewWindowType,
30+
shouldRenderIframe,
3031
url,
3132
} = useLivePreviewContext()
3233

@@ -133,7 +134,9 @@ export const LivePreviewWindow: React.FC<EditViewProps> = (props) => {
133134
<div className={`${baseClass}__wrapper`}>
134135
<LivePreviewToolbar {...props} />
135136
<div className={`${baseClass}__main`}>
136-
<DeviceContainer>{url ? <IFrame /> : <ShimmerEffect height="100%" />}</DeviceContainer>
137+
<DeviceContainer>
138+
{url && shouldRenderIframe ? <IFrame /> : <ShimmerEffect height="100%" />}
139+
</DeviceContainer>
137140
</div>
138141
</div>
139142
</div>

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,14 +42,19 @@ export interface LivePreviewContextType {
4242
setPreviewWindowType: (previewWindowType: 'iframe' | 'popup') => void
4343
setSize: Dispatch<SizeReducerAction>
4444
setToolbarPosition: (position: { x: number; y: number }) => void
45-
4645
/**
4746
* Sets the URL of the preview (either iframe or popup).
4847
* Will trigger a reload of the window.
4948
*/
5049
setURL: (url: string) => void
5150
setWidth: (width: number) => void
5251
setZoom: (zoom: number) => void
52+
/**
53+
* Do not render the iframe until the user is actively live previewing. This will:
54+
* 1. Prevent running through URL proxies set up on their `admin.livePreview.url` endpoint, e.g. to enter Next.js draft mode.
55+
* 2. Avoid unnecessary performance and network costs of rendering the iframe before it's needed.
56+
*/
57+
shouldRenderIframe?: boolean
5358
size: {
5459
height: number
5560
width: number
@@ -97,6 +102,7 @@ export const LivePreviewContext = createContext<LivePreviewContextType>({
97102
setURL: () => {},
98103
setWidth: () => {},
99104
setZoom: () => {},
105+
shouldRenderIframe: undefined,
100106
size: {
101107
height: 0,
102108
width: 0,

packages/ui/src/providers/LivePreview/index.tsx

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,27 @@ export const LivePreviewProvider: React.FC<LivePreviewProviderProps> = ({
4545
url: urlFromProps,
4646
}) => {
4747
const [previewWindowType, setPreviewWindowType] = useState<'iframe' | 'popup'>('iframe')
48-
const [isLivePreviewing, setIsLivePreviewing] = useState(incomingIsLivePreviewing)
48+
49+
const [isLivePreviewing, _setIsLivePreviewing] =
50+
useState<LivePreviewContextType['isLivePreviewing']>(incomingIsLivePreviewing)
51+
52+
const [shouldRenderIframe, setShouldRenderIframe] =
53+
useState<LivePreviewContextType['shouldRenderIframe']>(isLivePreviewing)
54+
55+
/**
56+
* Rendering the iframe is a one-way event, e.g. defer load and never unmount.
57+
* This way, subsequent toggles will appear to load instantly.
58+
*/
59+
const setIsLivePreviewing = useCallback<LivePreviewContextType['setIsLivePreviewing']>(
60+
(livePreviewing) => {
61+
if (livePreviewing) {
62+
setShouldRenderIframe(true)
63+
}
64+
65+
_setIsLivePreviewing(livePreviewing)
66+
},
67+
[],
68+
)
4969

5070
const breakpoints: LivePreviewConfig['breakpoints'] = useMemo(
5171
() => [
@@ -281,6 +301,7 @@ export const LivePreviewProvider: React.FC<LivePreviewProviderProps> = ({
281301
setURL: setLivePreviewURL,
282302
setWidth,
283303
setZoom,
304+
shouldRenderIframe,
284305
size,
285306
toolbarPosition: position,
286307
typeofLivePreviewURL,
Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1-
import type { Page } from '@playwright/test'
1+
import type { Page, Route } from '@playwright/test'
22

33
import { expect } from '@playwright/test'
44

5+
const endpoint = '**/api/payload-preferences/**'
6+
57
export const toggleLivePreview = async (
68
page: Page,
79
options?: {
@@ -15,15 +17,41 @@ export const toggleLivePreview = async (
1517
el.classList.contains('live-preview-toggler--active'),
1618
)
1719

18-
if (isActive && (options?.targetState === 'off' || !options?.targetState)) {
19-
await toggler.click()
20-
await expect(toggler).not.toHaveClass(/live-preview-toggler--active/)
21-
await expect(page.locator('iframe.live-preview-iframe')).toBeHidden()
20+
let hasSavedPrefs = false
21+
let hasClickedToggler = false
22+
23+
const onRoute = async (route: Route) => {
24+
const request = route.request()
25+
const response = await route.fetch()
26+
27+
if (request.method() === 'POST' && response.status() === 200) {
28+
hasSavedPrefs = true
29+
}
30+
31+
await route.fulfill({ response })
2232
}
2333

24-
if (!isActive && (options?.targetState === 'on' || !options?.targetState)) {
25-
await toggler.click()
26-
await expect(toggler).toHaveClass(/live-preview-toggler--active/)
27-
await expect(page.locator('iframe.live-preview-iframe')).toBeVisible()
34+
await page.route(endpoint, onRoute)
35+
36+
try {
37+
if (isActive && (options?.targetState === 'off' || !options?.targetState)) {
38+
await toggler.click()
39+
hasClickedToggler = true
40+
await expect(toggler).not.toHaveClass(/live-preview-toggler--active/)
41+
await expect(page.locator('iframe.live-preview-iframe')).toBeHidden()
42+
}
43+
44+
if (!isActive && (options?.targetState === 'on' || !options?.targetState)) {
45+
await toggler.click()
46+
hasClickedToggler = true
47+
await expect(toggler).toHaveClass(/live-preview-toggler--active/)
48+
await expect(page.locator('iframe.live-preview-iframe')).toBeVisible()
49+
}
50+
51+
if (hasClickedToggler) {
52+
await expect.poll(() => hasSavedPrefs).toBeTruthy()
53+
}
54+
} finally {
55+
await page.unroute(endpoint, onRoute)
2856
}
2957
}

test/_community/payload-types.ts

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -96,9 +96,6 @@ export interface Config {
9696
menu: MenuSelect<false> | MenuSelect<true>;
9797
};
9898
locale: null;
99-
widgets: {
100-
collections: CollectionsWidget;
101-
};
10299
user: User;
103100
jobs: {
104101
tasks: unknown;
@@ -438,16 +435,6 @@ export interface MenuSelect<T extends boolean = true> {
438435
createdAt?: T;
439436
globalType?: T;
440437
}
441-
/**
442-
* This interface was referenced by `Config`'s JSON-Schema
443-
* via the `definition` "collections_widget".
444-
*/
445-
export interface CollectionsWidget {
446-
data?: {
447-
[k: string]: unknown;
448-
};
449-
width: 'full';
450-
}
451438
/**
452439
* This interface was referenced by `Config`'s JSON-Schema
453440
* via the `definition` "auth".

test/live-preview/app/live-preview/(pages)/ssr/[slug]/page.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ type Args = {
1919

2020
export default async function SSRPage({ params: paramsPromise }: Args) {
2121
const { slug = ' ' } = await paramsPromise
22+
2223
const data = await getDoc<Page>({
2324
slug,
2425
collection: ssrPagesSlug,

test/live-preview/app/live-preview/_components/Media/Image/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ export const Image: React.FC<MediaProps> = (props) => {
4949

5050
const filename = fullFilename
5151

52-
src = `${PAYLOAD_SERVER_URL}/api/media/file/${filename}`
52+
src = `/api/media/file/${filename}`
5353
}
5454

5555
if (!src) return null

test/live-preview/collections/Pages.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ export const Pages: CollectionConfig = {
1919
delete: () => true,
2020
},
2121
admin: {
22+
description:
23+
'This collections does not use drafts or autosave. Changes are sent to the iframe window in real-time to use for fully client-side rendering.',
2224
useAsTitle: 'title',
2325
defaultColumns: ['id', 'title', 'slug', 'createdAt'],
2426
preview: (doc) => `/live-preview/${doc?.slug}`,

test/live-preview/collections/SSR.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ export const SSR: CollectionConfig = {
2020
delete: () => true,
2121
},
2222
admin: {
23+
description:
24+
'This collections has drafts enabled, but not autosave. Changes need to be saved to trigger a full router refresh, which fetches draft content on the server.',
2325
useAsTitle: 'title',
2426
defaultColumns: ['id', 'title', 'slug', 'createdAt'],
2527
preview: (doc) => `/live-preview/ssr/${doc?.slug}`,

test/live-preview/collections/SSRAutosave.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ export const SSRAutosave: CollectionConfig = {
2727
},
2828
},
2929
admin: {
30+
description:
31+
'This collections has drafts and autosave enabled. Changes will automatically trigger a full router refresh, which fetches draft content on the server.',
3032
useAsTitle: 'title',
3133
defaultColumns: ['id', 'title', 'slug', 'createdAt'],
3234
preview: (doc) => `/live-preview/ssr-autosave/${doc?.slug}`,

0 commit comments

Comments
 (0)