From cdbfc9132a9c743cd8763fa135caec66d164f66d Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Tue, 17 Oct 2023 12:42:31 -0400 Subject: [PATCH] chore: optimizes live preview (#3713) --- .../views/LivePreview/Context/context.ts | 20 ++++ .../views/LivePreview/Context/index.tsx | 89 ++++++++++++-- .../views/LivePreview/Preview/index.tsx | 111 ++++++------------ .../LivePreview/Toolbar/Controls/index.tsx | 22 ++-- .../views/LivePreview/Toolbar/index.tsx | 12 +- .../components/views/LivePreview/index.tsx | 108 ++++++++++------- .../views/LivePreview/usePopupWindow.ts | 34 +----- test/live-preview/collections/Users.ts | 10 ++ test/live-preview/config.ts | 103 +--------------- test/live-preview/{ => seed}/image-1.jpg | Bin test/live-preview/seed/index.ts | 88 ++++++++++++++ 11 files changed, 325 insertions(+), 272 deletions(-) create mode 100644 test/live-preview/collections/Users.ts rename test/live-preview/{ => seed}/image-1.jpg (100%) create mode 100644 test/live-preview/seed/index.ts diff --git a/packages/payload/src/admin/components/views/LivePreview/Context/context.ts b/packages/payload/src/admin/components/views/LivePreview/Context/context.ts index e3ecd9f90..4cd14fe9e 100644 --- a/packages/payload/src/admin/components/views/LivePreview/Context/context.ts +++ b/packages/payload/src/admin/components/views/LivePreview/Context/context.ts @@ -3,21 +3,31 @@ import type { Dispatch } from 'react' import { createContext, useContext } from 'react' import type { LivePreviewConfig } from '../../../../../exports/config' +import type { fieldSchemaToJSON } from '../../../../../utilities/fieldSchemaToJSON' +import type { usePopupWindow } from '../usePopupWindow' import type { SizeReducerAction } from './sizeReducer' export interface LivePreviewContextType { + appIsReady: boolean breakpoint: LivePreviewConfig['breakpoints'][number]['name'] breakpoints: LivePreviewConfig['breakpoints'] + fieldSchemaJSON?: ReturnType iframeHasLoaded: boolean iframeRef: React.RefObject + isPopupOpen: boolean measuredDeviceSize: { height: number width: number } + openPopupWindow: ReturnType['openPopupWindow'] + popupRef?: React.MutableRefObject + previewWindowType: 'iframe' | 'popup' + setAppIsReady: (appIsReady: boolean) => void setBreakpoint: (breakpoint: LivePreviewConfig['breakpoints'][number]['name']) => void setHeight: (height: number) => void setIframeHasLoaded: (loaded: boolean) => void setMeasuredDeviceSize: (size: { height: number; width: number }) => void + setPreviewWindowType: (previewWindowType: 'iframe' | 'popup') => void setSize: Dispatch setToolbarPosition: (position: { x: number; y: number }) => void setWidth: (width: number) => void @@ -30,22 +40,31 @@ export interface LivePreviewContextType { x: number y: number } + url: string | undefined zoom: number } export const LivePreviewContext = createContext({ + appIsReady: false, breakpoint: undefined, breakpoints: undefined, + fieldSchemaJSON: undefined, iframeHasLoaded: false, iframeRef: undefined, + isPopupOpen: false, measuredDeviceSize: { height: 0, width: 0, }, + openPopupWindow: () => {}, + popupRef: undefined, + previewWindowType: 'iframe', + setAppIsReady: () => {}, setBreakpoint: () => {}, setHeight: () => {}, setIframeHasLoaded: () => {}, setMeasuredDeviceSize: () => {}, + setPreviewWindowType: () => {}, setSize: () => {}, setToolbarPosition: () => {}, setWidth: () => {}, @@ -58,6 +77,7 @@ export const LivePreviewContext = createContext({ x: 0, y: 0, }, + url: undefined, zoom: 1, }) diff --git a/packages/payload/src/admin/components/views/LivePreview/Context/index.tsx b/packages/payload/src/admin/components/views/LivePreview/Context/index.tsx index b268e1014..8b5035c7e 100644 --- a/packages/payload/src/admin/components/views/LivePreview/Context/index.tsx +++ b/packages/payload/src/admin/components/views/LivePreview/Context/index.tsx @@ -1,39 +1,48 @@ import { DndContext } from '@dnd-kit/core' -import React, { useCallback, useEffect } from 'react' +import React, { useCallback, useEffect, useRef, useState } from 'react' import type { LivePreviewConfig } from '../../../../../exports/config' +import type { Field } from '../../../../../fields/config/types' import type { EditViewProps } from '../../types' import type { usePopupWindow } from '../usePopupWindow' +import { fieldSchemaToJSON } from '../../../../../utilities/fieldSchemaToJSON' import { customCollisionDetection } from './collisionDetection' import { LivePreviewContext } from './context' import { sizeReducer } from './sizeReducer' -export type ToolbarProviderProps = EditViewProps & { +export type LivePreviewProviderProps = EditViewProps & { + appIsReady?: boolean breakpoints?: LivePreviewConfig['breakpoints'] children: React.ReactNode deviceSize?: { height: number width: number } - popupState: ReturnType + isPopupOpen?: boolean + openPopupWindow?: ReturnType['openPopupWindow'] + popupRef?: React.MutableRefObject url?: string } -export const LivePreviewProvider: React.FC = (props) => { - const { breakpoints, children } = props +export const LivePreviewProvider: React.FC = (props) => { + const { breakpoints, children, isPopupOpen, openPopupWindow, popupRef, url } = props + + const [previewWindowType, setPreviewWindowType] = useState<'iframe' | 'popup'>('iframe') + + const [appIsReady, setAppIsReady] = useState(false) const iframeRef = React.useRef(null) - const [iframeHasLoaded, setIframeHasLoaded] = React.useState(false) + const [iframeHasLoaded, setIframeHasLoaded] = useState(false) - const [zoom, setZoom] = React.useState(1) + const [zoom, setZoom] = useState(1) - const [position, setPosition] = React.useState({ x: 0, y: 0 }) + const [position, setPosition] = useState({ x: 0, y: 0 }) const [size, setSize] = React.useReducer(sizeReducer, { height: 0, width: 0 }) - const [measuredDeviceSize, setMeasuredDeviceSize] = React.useState({ + const [measuredDeviceSize, setMeasuredDeviceSize] = useState({ height: 0, width: 0, }) @@ -41,6 +50,22 @@ export const LivePreviewProvider: React.FC = (props) => { const [breakpoint, setBreakpoint] = React.useState('responsive') + const [fieldSchemaJSON] = useState(() => { + let fields: Field[] + + if ('collection' in props) { + const { collection } = props + fields = collection.fields + } + + if ('global' in props) { + const { global } = props + fields = global.fields + } + + return fieldSchemaToJSON(fields) + }) + // 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 @@ -94,24 +119,70 @@ export const LivePreviewProvider: React.FC = (props) => { } }, [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 + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + const data = JSON.parse(event.data) + + if (url.startsWith(event.origin) && data.type === 'payload-live-preview' && data.ready) { + setAppIsReady(true) + } + } + + window.addEventListener('message', handleMessage) + + return () => { + window.removeEventListener('message', handleMessage) + } + }, [url]) + + const handleWindowChange = useCallback( + (type: 'iframe' | 'popup') => { + setAppIsReady(false) + setPreviewWindowType(type) + if (type === 'popup') openPopupWindow() + }, + [openPopupWindow], + ) + + // when the user closes the popup window, switch back to the iframe + // the `usePopupWindow` reports the `isPopupOpen` state for us to use here + useEffect(() => { + if (!isPopupOpen) { + handleWindowChange('iframe') + } + }, [isPopupOpen, handleWindowChange]) + return ( diff --git a/packages/payload/src/admin/components/views/LivePreview/Preview/index.tsx b/packages/payload/src/admin/components/views/LivePreview/Preview/index.tsx index fe80c0ea6..302a680ab 100644 --- a/packages/payload/src/admin/components/views/LivePreview/Preview/index.tsx +++ b/packages/payload/src/admin/components/views/LivePreview/Preview/index.tsx @@ -1,14 +1,9 @@ -import React, { useEffect, useState } from 'react' +import React, { useEffect } from 'react' -import type { LivePreviewConfig } from '../../../../../exports/config' -import type { Field } from '../../../../../fields/config/types' import type { EditViewProps } from '../../types' -import type { usePopupWindow } from '../usePopupWindow' -import { fieldSchemaToJSON } from '../../../../../utilities/fieldSchemaToJSON' import { useAllFormFields } from '../../../forms/Form/context' import reduceFieldsToValues from '../../../forms/Form/reduceFieldsToValues' -import { LivePreviewProvider } from '../Context' import { useLivePreviewContext } from '../Context/context' import { DeviceContainer } from '../Device' import { IFrame } from '../IFrame' @@ -17,93 +12,81 @@ import './index.scss' const baseClass = 'live-preview-window' -const Preview: React.FC< - EditViewProps & { - popupState: ReturnType - url?: string - } -> = (props) => { +export const LivePreview: React.FC = (props) => { const { - popupState: { isPopupOpen, popupHasLoaded, popupRef }, + appIsReady, + iframeHasLoaded, + iframeRef, + popupRef, + previewWindowType, + setIframeHasLoaded, url, - } = props + } = useLivePreviewContext() - const { iframeHasLoaded, iframeRef, setIframeHasLoaded } = useLivePreviewContext() + const { breakpoint, fieldSchemaJSON } = useLivePreviewContext() - const { breakpoint } = useLivePreviewContext() + const prevWindowType = + React.useRef['previewWindowType']>() const [fields] = useAllFormFields() - const [fieldSchemaJSON] = useState(() => { - let fields: Field[] - - if ('collection' in props) { - const { collection } = props - fields = collection.fields - } - - if ('global' in props) { - const { global } = props - fields = global.fields - } - - return fieldSchemaToJSON(fields) - }) - // 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 (fields && window && 'postMessage' in window) { + // For performance, do no reduce fields to values until after the iframe or popup has loaded + if (fields && window && 'postMessage' in window && appIsReady) { const values = reduceFieldsToValues(fields, true) - // TODO: only send `fieldSchemaToJSON` one time + // To reduce on large `postMessage` payloads, only send `fieldSchemaToJSON` one time + // To do this, the underlying JS function maintains a cache of this value + // So we need to send it through each time the window type changes + // But only once per window type change, not on every render, because this is a potentially large obj + const shouldSendSchema = + !prevWindowType.current || prevWindowType.current !== previewWindowType + + prevWindowType.current = previewWindowType + const message = JSON.stringify({ data: values, - fieldSchemaJSON, + fieldSchemaJSON: shouldSendSchema ? fieldSchemaJSON : undefined, type: 'payload-live-preview', }) - // external window - if (isPopupOpen) { - setIframeHasLoaded(false) - if (popupRef.current) { - popupRef.current.postMessage(message, url) - } + // Post message to external popup window + if (previewWindowType === 'popup' && popupRef.current) { + popupRef.current.postMessage(message, url) } - // embedded iframe - if (!isPopupOpen) { - if (iframeHasLoaded && iframeRef.current) { - iframeRef.current.contentWindow?.postMessage(message, url) - } + // Post message to embedded iframe + if (previewWindowType === 'iframe' && iframeRef.current) { + iframeRef.current.contentWindow?.postMessage(message, url) } } }, [ fields, url, iframeHasLoaded, - isPopupOpen, + previewWindowType, popupRef, - popupHasLoaded, + appIsReady, iframeRef, setIframeHasLoaded, fieldSchemaJSON, ]) - if (!isPopupOpen) { + if (previewWindowType === 'iframe') { return (
- +