From 731f023c6dc89e5949f026e9e35b0bcf5f1cb860 Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Wed, 8 May 2024 11:08:15 -0400 Subject: [PATCH] feat: ssr live preview (#6239) --- docs/live-preview/client.mdx | 284 ++++++++++++++++++ docs/live-preview/frontend.mdx | 275 +---------------- docs/live-preview/overview.mdx | 2 +- docs/live-preview/server.mdx | 174 +++++++++++ .../src/RefreshRouteOnSave.tsx | 52 ++++ packages/live-preview-react/src/index.ts | 56 +--- .../live-preview-react/src/useLivePreview.ts | 55 ++++ packages/live-preview/src/handleMessage.d.ts | 8 - .../live-preview/src/handleMessage.d.ts.map | 1 - packages/live-preview/src/handleMessage.ts | 8 +- packages/live-preview/src/index.d.ts | 6 - packages/live-preview/src/index.d.ts.map | 1 - packages/live-preview/src/index.ts | 2 + packages/live-preview/src/isDocumentEvent.ts | 5 + .../live-preview/src/isLivePreviewEvent.ts | 5 + packages/live-preview/src/mergeData.d.ts | 26 -- packages/live-preview/src/mergeData.d.ts.map | 1 - packages/live-preview/src/subscribe.d.ts | 8 - packages/live-preview/src/subscribe.d.ts.map | 1 - packages/live-preview/src/subscribe.ts | 9 +- packages/live-preview/src/traverseFields.d.ts | 10 - .../live-preview/src/traverseFields.d.ts.map | 1 - packages/next/src/routes/index.ts | 1 + .../src/views/LivePreview/Preview/index.tsx | 20 ++ packages/ui/src/elements/Autosave/index.tsx | 30 +- .../app/(payload)/api/[...slug]/route.ts | 3 +- .../(pages)/[slug]/page.client.tsx | 3 +- .../posts-ssr/[slug]/RefreshRouteOnSave.tsx | 12 + .../(pages)/posts-ssr/[slug]/page.tsx | 94 ++++++ .../(pages)/posts/[slug]/page.client.tsx | 5 + .../(pages)/posts/[slug]/page.tsx | 8 +- .../app/live-preview/_api/fetchDoc.ts | 4 +- test/live-preview/collections/PostsSSR.ts | 107 +++++++ test/live-preview/config.ts | 9 +- test/live-preview/e2e.spec.ts | 103 +++++-- test/live-preview/seed/index.ts | 18 +- test/live-preview/shared.ts | 4 + test/live-preview/tsconfig.json | 2 +- 38 files changed, 972 insertions(+), 441 deletions(-) create mode 100644 docs/live-preview/client.mdx create mode 100644 docs/live-preview/server.mdx create mode 100644 packages/live-preview-react/src/RefreshRouteOnSave.tsx create mode 100644 packages/live-preview-react/src/useLivePreview.ts delete mode 100644 packages/live-preview/src/handleMessage.d.ts delete mode 100644 packages/live-preview/src/handleMessage.d.ts.map delete mode 100644 packages/live-preview/src/index.d.ts delete mode 100644 packages/live-preview/src/index.d.ts.map create mode 100644 packages/live-preview/src/isDocumentEvent.ts create mode 100644 packages/live-preview/src/isLivePreviewEvent.ts delete mode 100644 packages/live-preview/src/mergeData.d.ts delete mode 100644 packages/live-preview/src/mergeData.d.ts.map delete mode 100644 packages/live-preview/src/subscribe.d.ts delete mode 100644 packages/live-preview/src/subscribe.d.ts.map delete mode 100644 packages/live-preview/src/traverseFields.d.ts delete mode 100644 packages/live-preview/src/traverseFields.d.ts.map create mode 100644 test/live-preview/app/live-preview/(pages)/posts-ssr/[slug]/RefreshRouteOnSave.tsx create mode 100644 test/live-preview/app/live-preview/(pages)/posts-ssr/[slug]/page.tsx create mode 100644 test/live-preview/collections/PostsSSR.ts diff --git a/docs/live-preview/client.mdx b/docs/live-preview/client.mdx new file mode 100644 index 0000000000..4f49beac4d --- /dev/null +++ b/docs/live-preview/client.mdx @@ -0,0 +1,284 @@ +--- +title: Client-side Live Preview +label: Client-side +order: 40 +desc: Learn how to implement Live Preview in your client-side front-end application. +keywords: live preview, frontend, react, next.js, vue, nuxt.js, svelte, hook, useLivePreview +--- + + + If your front-end application is supports Server Components like the [Next.js App Router](https://nextjs.org/docs/app), etc., we suggest setting up [server-side Live Preview](./server). + + +While using Live Preview, the Admin panel emits a new `window.postMessage` event every time your document has changed. Your front-end application can listen for these events and re-render accordingly. + +If your front-end application is built with [React](#react) or [Vue](#vue), use the `useLivePreview` hooks that Payload provides. In the future, all other major frameworks like Svelte will be officially supported. If you are using any of these frameworks today, you can still integrate with Live Preview yourself using the underlying tooling that Payload provides. See [building your own hook](#building-your-own-hook) for more information. + +By default, all hooks accept the following args: + +| Path | Description | +| ------------------ | -------------------------------------------------------------------------------------- | +| **`serverURL`** \* | The URL of your Payload server. | +| **`initialData`** | The initial data of the document. The live data will be merged in as changes are made. | +| **`depth`** | The depth of the relationships to fetch. Defaults to `0`. | +| **`apiRoute`** | The path of your API route as defined in `routes.api`. Defaults to `/api`. | + +_\* An asterisk denotes that a property is required._ + +And return the following values: + +| Path | Description | +| --------------- | ---------------------------------------------------------------- | +| **`data`** | The live data of the document, merged with the initial data. | +| **`isLoading`** | A boolean that indicates whether or not the document is loading. | + + + If your front-end is tightly coupled to required fields, you should ensure that your UI does not + break when these fields are removed. For example, if you are rendering something like + `data.relatedPosts[0].title`, your page will break once you remove the first related post. To get + around this, use conditional logic, optional chaining, or default values in your UI where needed. + For example, `data?.relatedPosts?.[0]?.title`. + + + + It is important that the `depth` argument matches exactly with the depth of your initial page request. The depth property is used to populated relationships and uploads beyond their IDs. See [Depth](../getting-started/concepts#depth) for more information. + + +### React + +If your front-end application is built with client-side [React](https://react.dev) like [Next.js Pages Router](https://nextjs.org/docs/pages), you can use the `useLivePreview` hook that Payload provides. + +First, install the `@payloadcms/live-preview-react` package: + +```bash +npm install @payloadcms/live-preview-react +``` + +Then, use the `useLivePreview` hook in your React component: + +```tsx +'use client' +import { useLivePreview } from '@payloadcms/live-preview-react' +import { Page as PageType } from '@/payload-types' + +// Fetch the page in a server component, pass it to the client component, then thread it through the hook +// The hook will take over from there and keep the preview in sync with the changes you make +// The `data` property will contain the live data of the document +export const PageClient: React.FC<{ + page: { + title: string + } +}> = ({ page: initialPage }) => { + const { data } = useLivePreview({ + initialData: initialPage, + serverURL: PAYLOAD_SERVER_URL, + depth: 2, + }) + + return

{data.title}

+} +``` + +### Vue + +If your front-end application is built with [Vue 3](https://vuejs.org) or [Nuxt 3](https://nuxt.js), you can use the `useLivePreview` composable that Payload provides. + +First, install the `@payloadcms/live-preview-vue` package: + +```bash +npm install @payloadcms/live-preview-vue +``` + +Then, use the `useLivePreview` hook in your Vue component: + +```vue + + + +``` + +### Building your own hook + +No matter what front-end framework you are using, you can build your own hook using the same underlying tooling that Payload provides. + +First, install the base `@payloadcms/live-preview` package: + +```bash +npm install @payloadcms/live-preview +``` + +This package provides the following functions: + +| Path | Description | +| ------------------------ | -------------------------------------------------------------------------------------------------------------------------- | +| **`subscribe`** | Subscribes to the Admin panel's `window.postMessage` events and calls the provided callback function. | +| **`unsubscribe`** | Unsubscribes from the Admin panel's `window.postMessage` events. | +| **`ready`** | Sends a `window.postMessage` event to the Admin panel to indicate that the front-end is ready to receive messages. | +| **`isLivePreviewEvent`** | Checks if a `MessageEvent` originates from the Admin panel and is a Live Preview event, i.e. debounced form state. | + +The `subscribe` function takes the following args: + +| Path | Description | +| ------------------ | ------------------------------------------------------------------------------------------- | +| **`callback`** \* | A callback function that is called with `data` every time a change is made to the document. | +| **`serverURL`** \* | The URL of your Payload server. | +| **`initialData`** | The initial data of the document. The live data will be merged in as changes are made. | +| **`depth`** | The depth of the relationships to fetch. Defaults to `0`. | + +With these functions, you can build your own hook using your front-end framework of choice: + +```tsx +import { subscribe, unsubscribe } from '@payloadcms/live-preview' + +// To build your own hook, subscribe to Live Preview events using the `subscribe` function +// It handles everything from: +// 1. Listening to `window.postMessage` events +// 2. Merging initial data with active form state +// 3. Populating relationships and uploads +// 4. Calling the `onChange` callback with the result +// Your hook should also: +// 1. Tell the Admin panel when it is ready to receive messages +// 2. Handle the results of the `onChange` callback to update the UI +// 3. Unsubscribe from the `window.postMessage` events when it unmounts +``` + +Here is an example of what the same `useLivePreview` React hook from above looks like under the hood: + +```tsx +import { subscribe, unsubscribe, ready } from '@payloadcms/live-preview' +import { useCallback, useEffect, useState, useRef } from 'react' + +export const useLivePreview = (props: { + depth?: number + initialData: T + serverURL: string +}): { + data: T + isLoading: boolean +} => { + const { depth = 0, initialData, serverURL } = props + const [data, setData] = useState(initialData) + const [isLoading, setIsLoading] = useState(true) + const hasSentReadyMessage = useRef(false) + + const onChange = useCallback((mergedData) => { + // When a change is made, the `onChange` callback will be called with the merged data + // Set this merged data into state so that React will re-render the UI + setData(mergedData) + setIsLoading(false) + }, []) + + useEffect(() => { + // Listen for `window.postMessage` events from the Admin panel + // When a change is made, the `onChange` callback will be called with the merged data + const subscription = subscribe({ + callback: onChange, + depth, + initialData, + serverURL, + }) + + // Once subscribed, send a `ready` message back up to the Admin panel + // This will indicate that the front-end is ready to receive messages + if (!hasSentReadyMessage.current) { + hasSentReadyMessage.current = true + + ready({ + serverURL, + }) + } + + // When the component unmounts, unsubscribe from the `window.postMessage` events + return () => { + unsubscribe(subscription) + } + }, [serverURL, onChange, depth, initialData]) + + return { + data, + isLoading, + } +} +``` + + + When building your own hook, ensure that the args and return values are consistent with the ones + listed at the top of this document. This will ensure that all hooks follow the same API. + + +## Example + +For a working demonstration of this, check out the official [Live Preview Example](https://github.com/payloadcms/payload/tree/main/examples/live-preview/payload). There you will find examples of various front-end frameworks and how to integrate each one of them, including: + +- [Next.js App Router](https://github.com/payloadcms/payload/tree/main/examples/live-preview/next-app) +- [Next.js Pages Router](https://github.com/payloadcms/payload/tree/main/examples/live-preview/next-pages) + +## Troubleshooting + +#### Relationships and/or uploads are not populating + +If you are using relationships or uploads in your front-end application, and your front-end application runs on a different domain than your Payload server, you may need to configure [CORS](../configuration/overview) to allow requests to be made between the two domains. This includes sites that are running on a different port or subdomain. Similarly, if you are protecting resources behind user authentication, you may also need to configure [CSRF](../authentication/overview#csrf-protection) to allow cookies to be sent between the two domains. For example: + +```ts +// payload.config.ts +{ + // ... + // If your site is running on a different domain than your Payload server, + // This will allows requests to be made between the two domains + cors: { + [ + 'http://localhost:3001' // Your front-end application + ], + }, + // If you are protecting resources behind user authentication, + // This will allow cookies to be sent between the two domains + csrf: { + [ + 'http://localhost:3001' // Your front-end application + ], + }, +} +``` + +#### Relationships and/or uploads disappear after editing a document + +It is possible that either you are setting an improper [`depth`](../getting-started/concepts#depth) in your initial request and/or your `useLivePreview` hook, or they're mismatched. Ensure that the `depth` parameter is set to the correct value, and that it matches exactly in both places. For example: + +```tsx +// Your initial request +const { docs } = await payload.find({ + collection: 'pages', + depth: 1, // Ensure this is set to the proper depth for your application + where: { + slug: { + equals: 'home', + }, + }, +}) +``` + +```tsx +// Your hook +const { data } = useLivePreview({ + initialData: initialPage, + serverURL: PAYLOAD_SERVER_URL, + depth: 1, // Ensure this matches the depth of your initial request +}) +``` diff --git a/docs/live-preview/frontend.mdx b/docs/live-preview/frontend.mdx index 601b89e7d1..5ef11bd56f 100644 --- a/docs/live-preview/frontend.mdx +++ b/docs/live-preview/frontend.mdx @@ -1,279 +1,16 @@ --- -title: Implementing Live Preview in your app -label: Frontend Implementation +title: Implementing Live Preview in your frontend +label: Frontend order: 20 desc: Learn how to implement Live Preview in your front-end application. keywords: live preview, frontend, react, next.js, vue, nuxt.js, svelte, hook, useLivePreview --- -While using Live Preview, the Admin panel emits a new `window.postMessage` event every time a change is made to the document. Your front-end application can listen for these events and re-render accordingly. +There are two ways to use Live Preview in your own application depending on whether your front-end framework supports server components: -Wiring your front-end into Live Preview is easy. If your front-end application is built with React, Next.js, Vue or Nuxt.js, use the `useLivePreview` hook that Payload provides. In the future, all other major frameworks like Svelte will be officially supported. If you are using any of these frameworks today, you can still integrate with Live Preview yourself using the underlying tooling that Payload provides. See [building your own hook](#building-your-own-hook) for more information. - -By default, all hooks accept the following args: - -| Path | Description | -| ------------------ | -------------------------------------------------------------------------------------- | -| **`serverURL`** \* | The URL of your Payload server. | -| **`initialData`** | The initial data of the document. The live data will be merged in as changes are made. | -| **`depth`** | The depth of the relationships to fetch. Defaults to `0`. | -| **`apiRoute`** | The path of your API route as defined in `routes.api`. Defaults to `/api`. | - -_\* An asterisk denotes that a property is required._ - -And return the following values: - -| Path | Description | -| --------------- | ---------------------------------------------------------------- | -| **`data`** | The live data of the document, merged with the initial data. | -| **`isLoading`** | A boolean that indicates whether or not the document is loading. | +- [Server-side Live Preview (suggested)](./server) +- [Client-side Live Preview](./client) - If your front-end is tightly coupled to required fields, you should ensure that your UI does not - break when these fields are removed. For example, if you are rendering something like - `data.relatedPosts[0].title`, your page will break once you remove the first related post. To get - around this, use conditional logic, optional chaining, or default values in your UI where needed. - For example, `data?.relatedPosts?.[0]?.title`. + We suggest using server-side Live Preview if your framework supports it, it is both simpler to setup and more performant to run than the client-side alternative. - - - It is important that the `depth` argument matches exactly with the depth of your initial page request. The depth property is used to populated relationships and uploads beyond their IDs. See [Depth](../getting-started/concepts#depth) for more information. - - -### React - -If your front-end application is built with React or Next.js, you can use the `useLivePreview` hook that Payload provides. - -First, install the `@payloadcms/live-preview-react` package: - -```bash -npm install @payloadcms/live-preview-react -``` - -Then, use the `useLivePreview` hook in your React component: - -```tsx -'use client' -import { useLivePreview } from '@payloadcms/live-preview-react' -import { Page as PageType } from '@/payload-types' - -// Fetch the page in a server component, pass it to the client component, then thread it through the hook -// The hook will take over from there and keep the preview in sync with the changes you make -// The `data` property will contain the live data of the document -export const PageClient: React.FC<{ - page: { - title: string - } -}> = ({ page: initialPage }) => { - const { data } = useLivePreview({ - initialData: initialPage, - serverURL: PAYLOAD_SERVER_URL, - depth: 2, - }) - - return

{data.title}

-} -``` - -### Vue - -If your front-end application is built with Vue 3 or Nuxt 3, you can use the `useLivePreview` composable that Payload provides. - -First, install the `@payloadcms/live-preview-vue` package: - -```bash -npm install @payloadcms/live-preview-vue -``` - -Then, use the `useLivePreview` hook in your Vue component: - -```vue - - - -``` - -## Building your own hook - -No matter what front-end framework you are using, you can build your own hook using the same underlying tooling that Payload provides. - -First, install the base `@payloadcms/live-preview` package: - -```bash -npm install @payloadcms/live-preview -``` - -This package provides the following functions: - -| Path | Description | -| ----------------- | ------------------------------------------------------------------------------------------------------------------ | -| **`subscribe`** | Subscribes to the Admin panel's `window.postMessage` events and calls the provided callback function. | -| **`unsubscribe`** | Unsubscribes from the Admin panel's `window.postMessage` events. | -| **`ready`** | Sends a `window.postMessage` event to the Admin panel to indicate that the front-end is ready to receive messages. | - -The `subscribe` function takes the following args: - -| Path | Description | -| ------------------ | ------------------------------------------------------------------------------------------- | -| **`callback`** \* | A callback function that is called with `data` every time a change is made to the document. | -| **`serverURL`** \* | The URL of your Payload server. | -| **`initialData`** | The initial data of the document. The live data will be merged in as changes are made. | -| **`depth`** | The depth of the relationships to fetch. Defaults to `0`. | - -With these functions, you can build your own hook using your front-end framework of choice: - -```tsx -import { subscribe, unsubscribe } from '@payloadcms/live-preview' - -// To build your own hook, subscribe to Live Preview events using the`subscribe` function -// It handles everything from: -// 1. Listening to `window.postMessage` events -// 2. Merging initial data with active form state -// 3. Populating relationships and uploads -// 4. Calling the `onChange` callback with the result -// Your hook should also: -// 1. Tell the Admin panel when it is ready to receive messages -// 2. Handle the results of the `onChange` callback to update the UI -// 3. Unsubscribe from the `window.postMessage` events when it unmounts -``` - -Here is an example of what the same `useLivePreview` React hook from above looks like under the hood: - -```tsx -import { subscribe, unsubscribe, ready } from '@payloadcms/live-preview' -import { useCallback, useEffect, useState, useRef } from 'react' - -export const useLivePreview = (props: { - depth?: number - initialData: T - serverURL: string -}): { - data: T - isLoading: boolean -} => { - const { depth = 0, initialData, serverURL } = props - const [data, setData] = useState(initialData) - const [isLoading, setIsLoading] = useState(true) - const hasSentReadyMessage = useRef(false) - - const onChange = useCallback((mergedData) => { - // When a change is made, the `onChange` callback will be called with the merged data - // Set this merged data into state so that React will re-render the UI - setData(mergedData) - setIsLoading(false) - }, []) - - useEffect(() => { - // Listen for `window.postMessage` events from the Admin panel - // When a change is made, the `onChange` callback will be called with the merged data - const subscription = subscribe({ - callback: onChange, - depth, - initialData, - serverURL, - }) - - // Once subscribed, send a `ready` message back up to the Admin panel - // This will indicate that the front-end is ready to receive messages - if (!hasSentReadyMessage.current) { - hasSentReadyMessage.current = true - - ready({ - serverURL, - }) - } - - // When the component unmounts, unsubscribe from the `window.postMessage` events - return () => { - unsubscribe(subscription) - } - }, [serverURL, onChange, depth, initialData]) - - return { - data, - isLoading, - } -} -``` - - - When building your own hook, ensure that the args and return values are consistent with the ones - listed at the top of this document. This will ensure that all hooks follow the same API. - - -## Example - -For a working demonstration of this, check out the official [Live Preview Example](https://github.com/payloadcms/payload/tree/main/examples/live-preview/payload). There you will find examples of various front-end frameworks and how to integrate each one of them, including: - -- [Next.js App Router](https://github.com/payloadcms/payload/tree/main/examples/live-preview/next-app) -- [Next.js Pages Router](https://github.com/payloadcms/payload/tree/main/examples/live-preview/next-pages) - -## Troubleshooting - -#### Relationships and/or uploads are not populating - -If you are using relationships or uploads in your front-end application, and your front-end application runs on a different domain than your Payload server, you may need to configure [CORS](../configuration/overview) to allow requests to be made between the two domains. This includes sites that are running on a different port or subdomain. Similarly, if you are protecting resources behind user authentication, you may also need to configure [CSRF](../authentication/overview#csrf-protection) to allow cookies to be sent between the two domains. For example: - -```ts -// payload.config.ts -{ - // ... - // If your site is running on a different domain than your Payload server, - // This will allows requests to be made between the two domains - cors: { - [ - 'http://localhost:3001' // Your front-end application - ], - }, - // If you are protecting resources behind user authentication, - // This will allow cookies to be sent between the two domains - csrf: { - [ - 'http://localhost:3001' // Your front-end application - ], - }, -} -``` - -#### Relationships and/or uploads disappear after editing a document - -It is possible that either you are setting an improper [`depth`](../getting-started/concepts#depth) in your initial request and/or your `useLivePreview` hook, or they're mismatched. Ensure that the `depth` parameter is set to the correct value, and that it matches exactly in both places. For example: - -```tsx -// Your initial request -const { docs } = await payload.find({ - collection: 'pages', - depth: 1, // Ensure this is set to the proper depth for your application - where: { - slug: { - equals: 'home', - }, - }, -}) -``` - -```tsx -// Your hook -const { data } = useLivePreview({ - initialData: initialPage, - serverURL: PAYLOAD_SERVER_URL, - depth: 1, // Ensure this matches the depth of your initial request -}) -``` diff --git a/docs/live-preview/overview.mdx b/docs/live-preview/overview.mdx index 1fcc75f793..d43a32d406 100644 --- a/docs/live-preview/overview.mdx +++ b/docs/live-preview/overview.mdx @@ -8,7 +8,7 @@ keywords: live preview, preview, live, iframe, iframe preview, visual editing, d **With Live Preview you can render your front-end application directly within the Admin panel. As you type, your changes take effect in real-time. No need to save a draft or publish your changes.** -Live Preview works by rendering an iframe on the page that loads your front-end application. The Admin panel communicates with your app through [`window.postMessage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage) events. These events are emitted every time a change is made to the document. Your app then listens for these events and re-renders itself with the data it receives. +Live Preview works by rendering an iframe on the page that loads your front-end application. The Admin panel communicates with your app through [`window.postMessage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage) events. These events are emitted every time a change is made to the document. Your app then listens for these events and re-renders itself with the data it receives. Live Preview works in both server-side as well as client-side environments. See [Front-End](./frontend) for more details. {/* IMAGE OF LIVE PREVIEW HERE */} diff --git a/docs/live-preview/server.mdx b/docs/live-preview/server.mdx new file mode 100644 index 0000000000..698e09d405 --- /dev/null +++ b/docs/live-preview/server.mdx @@ -0,0 +1,174 @@ +--- +title: Server-side Live Preview +label: Server-side +order: 30 +desc: Learn how to implement Live Preview in your server-side front-end application. +keywords: live preview, frontend, react, next.js, vue, nuxt.js, svelte, hook, useLivePreview +--- + + + Server-side Live Preview is only for front-end frameworks that support the concept of Server Components, i.e. [React Server Components](https://react.dev/reference/rsc/server-components). If your front-end application is built with a client-side framework like the [Next.js Pages Router](https://nextjs.org/docs/pages), [React Router](https://reactrouter.com), [Vue 3](https://vuejs.org), etc., see [client-side Live Preview](./client). + + +Server-side Live Preview works by making a roundtrip to the server every time your document is saved, i.e. draft save, autosave, or publish. While using Live Preview, the Admin panel emits a new `window.postMessage` event which your front-end application can use to invoke this process. In Next.js, this means simply calling `router.refresh()` which will hydrate the HTML using new data straight from the [Local API](../local-api/overview). + + + It is recommended that you enable [Autosave](../versions/autosave) alongside Live Preview to make the experience feel more responsive. + + +If your front-end application is built with [React](#react), you can use the `RefreshRouteOnChange` function that Payload provides and give it your own router refresh function. In the future, all other major frameworks like Vue and Svelte will be officially supported. If you are using any of these frameworks today, you can still integrate with Live Preview yourself using the underlying tooling that Payload provides. See [building your own router refresh component](#building-your-own-router-refresh-component) for more information. + +### React + +If your front-end application is built with [React](https://react.dev) or [Next.js](https://nextjs.org), you can use the `RefreshRouteOnSave` component that Payload provides. + +First, install the `@payloadcms/live-preview-react` package: + +```bash +npm install @payloadcms/live-preview-react +``` + +Then, render `RefreshRouteOnSave` anywhere in your `page.tsx`. Here's an example: + +`page.tsx`: + +```tsx +import { RefreshRouteOnSave } from './RefreshRouteOnSave.tsx' +import { getPayloadHMR } from '@payloadcms/next' +import config from '../payload.config' + +export default async function Page() { + const payload = await getPayloadHMR({ config }) + const page = await payload.find({ + collection: 'pages', + draft: true + }) + + return ( + + +

Hello, world!

+
+ ) +} +``` + +`RefreshRouteOnSave.tsx`: + +```tsx +'use client' +import { RefreshRouteOnSave as PayloadLivePreview } from '@payloadcms/live-preview-react' +import { useRouter } from 'next/navigation.js' +import React from 'react' + +export const RefreshRouteOnSave: React.FC = () => { + const router = useRouter() + return +} +``` + +## Building your own router refresh component + +No matter what front-end framework you are using, you can build your own component using the same underlying tooling that Payload provides. + +First, install the base `@payloadcms/live-preview` package: + +```bash +npm install @payloadcms/live-preview +``` + +This package provides the following functions: + +| Path | Description | +| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| **`ready`** | Sends a `window.postMessage` event to the Admin panel to indicate that the front-end is ready to receive messages. | +| **`isDocumentEvent`** | Checks if a `MessageEvent` originates from the Admin panel and is a document-level event, i.e. draft save, autosave, publish, etc. | + +With these functions, you can build your own hook using your front-end framework of choice: + +```tsx +import { ready, isDocumentEvent } from '@payloadcms/live-preview' + +// To build your own component: +// 1. Listen for document-level `window.postMessage` events sent from the Admin panel +// 2. Tell the Admin panel when it is ready to receive messages +// 3. Refresh the route every time a new document-level event is received +// 4. Unsubscribe from the `window.postMessage` events when it unmounts +``` + +Here is an example of what the same `RefreshRouteOnSave` React component from above looks like under the hood: + +```tsx +'use client' + +import type React from 'react' + +import { isDocumentEvent, ready } from '@payloadcms/live-preview' +import { useCallback, useEffect, useRef } from 'react' + +export const RefreshRouteOnSave: React.FC<{ + apiRoute?: string + depth?: number + refresh: () => void + serverURL: string +}> = (props) => { + const { apiRoute, depth, refresh, serverURL } = props + const hasSentReadyMessage = useRef(false) + + const onMessage = useCallback( + (event: MessageEvent) => { + if (isDocumentEvent(event, serverURL)) { + if (typeof refresh === 'function') { + refresh() + } + } + }, + [refresh, serverURL], + ) + + useEffect(() => { + if (typeof window !== 'undefined') { + window.addEventListener('message', onMessage) + } + + if (!hasSentReadyMessage.current) { + hasSentReadyMessage.current = true + + ready({ + serverURL, + }) + } + + return () => { + if (typeof window !== 'undefined') { + window.removeEventListener('message', onMessage) + } + } + }, [serverURL, onMessage, depth, apiRoute]) + + return null +} +``` + +## Example + +{/* TODO: add example once beta has been release and an example can be created */} + +## Troubleshooting + +#### Updates do not appear as fast as client-side Live Preview + +If you are noticing that updates feel less snappy than client-side Live Preview (i.e. the `useLivePreview` hook), this is because of how the two differ in how they work—instead of emitting events against form state as you type, server-side Live Preview refreshes the route after a new document is saved. You can use autosave to mimic this effect. Try decreasing the value of `versions.autoSave.interval` to make the experience feel more responsive: + +```ts +// collection.ts +{ + versions: { + drafts: { + autosave: { + interval: 10, + }, + }, + }, +} +``` diff --git a/packages/live-preview-react/src/RefreshRouteOnSave.tsx b/packages/live-preview-react/src/RefreshRouteOnSave.tsx new file mode 100644 index 0000000000..2c4274935e --- /dev/null +++ b/packages/live-preview-react/src/RefreshRouteOnSave.tsx @@ -0,0 +1,52 @@ +'use client' + +import type React from 'react' + +import { isDocumentEvent, ready } from '@payloadcms/live-preview' +import { useCallback, useEffect, useRef } from 'react' + +export const RefreshRouteOnSave: React.FC<{ + apiRoute?: string + depth?: number + refresh: () => void + serverURL: string +}> = (props) => { + const { apiRoute, depth, refresh, serverURL } = props + const hasSentReadyMessage = useRef(false) + + const onMessage = useCallback( + (event: MessageEvent) => { + if (isDocumentEvent(event, serverURL)) { + if (typeof refresh === 'function') { + refresh() + } else { + // eslint-disable-next-line no-console + console.error('You must provide a refresh function to `RefreshRouteOnSave`') + } + } + }, + [refresh, serverURL], + ) + + useEffect(() => { + if (typeof window !== 'undefined') { + window.addEventListener('message', onMessage) + } + + if (!hasSentReadyMessage.current) { + hasSentReadyMessage.current = true + + ready({ + serverURL, + }) + } + + return () => { + if (typeof window !== 'undefined') { + window.removeEventListener('message', onMessage) + } + } + }, [serverURL, onMessage, depth, apiRoute]) + + return null +} diff --git a/packages/live-preview-react/src/index.ts b/packages/live-preview-react/src/index.ts index 173dbc24db..1fcc5057a8 100644 --- a/packages/live-preview-react/src/index.ts +++ b/packages/live-preview-react/src/index.ts @@ -1,54 +1,2 @@ -import { ready, subscribe, unsubscribe } from '@payloadcms/live-preview' -import { useCallback, useEffect, useRef, useState } from 'react' - -// To prevent the flicker of missing data on initial load, -// you can pass in the initial page data from the server -// To prevent the flicker of stale data while the post message is being sent, -// you can conditionally render loading UI based on the `isLoading` state - -export const useLivePreview = (props: { - apiRoute?: string - depth?: number - initialData: T - serverURL: string -}): { - data: T - isLoading: boolean -} => { - const { apiRoute, depth, initialData, serverURL } = props - const [data, setData] = useState(initialData) - const [isLoading, setIsLoading] = useState(true) - const hasSentReadyMessage = useRef(false) - - const onChange = useCallback((mergedData) => { - setData(mergedData) - setIsLoading(false) - }, []) - - useEffect(() => { - const subscription = subscribe({ - apiRoute, - callback: onChange, - depth, - initialData, - serverURL, - }) - - if (!hasSentReadyMessage.current) { - hasSentReadyMessage.current = true - - ready({ - serverURL, - }) - } - - return () => { - unsubscribe(subscription) - } - }, [serverURL, onChange, depth, initialData, apiRoute]) - - return { - data, - isLoading, - } -} +export { RefreshRouteOnSave } from './RefreshRouteOnSave.js' +export { useLivePreview } from './useLivePreview.js' diff --git a/packages/live-preview-react/src/useLivePreview.ts b/packages/live-preview-react/src/useLivePreview.ts new file mode 100644 index 0000000000..fbbb0b200b --- /dev/null +++ b/packages/live-preview-react/src/useLivePreview.ts @@ -0,0 +1,55 @@ +'use client' +import { ready, subscribe, unsubscribe } from '@payloadcms/live-preview' +import { useCallback, useEffect, useRef, useState } from 'react' + +// To prevent the flicker of missing data on initial load, +// you can pass in the initial page data from the server +// To prevent the flicker of stale data while the post message is being sent, +// you can conditionally render loading UI based on the `isLoading` state + +export const useLivePreview = (props: { + apiRoute?: string + depth?: number + initialData: T + serverURL: string +}): { + data: T + isLoading: boolean +} => { + const { apiRoute, depth, initialData, serverURL } = props + const [data, setData] = useState(initialData) + const [isLoading, setIsLoading] = useState(true) + const hasSentReadyMessage = useRef(false) + + const onChange = useCallback((mergedData) => { + setData(mergedData) + setIsLoading(false) + }, []) + + useEffect(() => { + const subscription = subscribe({ + apiRoute, + callback: onChange, + depth, + initialData, + serverURL, + }) + + if (!hasSentReadyMessage.current) { + hasSentReadyMessage.current = true + + ready({ + serverURL, + }) + } + + return () => { + unsubscribe(subscription) + } + }, [serverURL, onChange, depth, initialData, apiRoute]) + + return { + data, + isLoading, + } +} diff --git a/packages/live-preview/src/handleMessage.d.ts b/packages/live-preview/src/handleMessage.d.ts deleted file mode 100644 index 85c68a0d67..0000000000 --- a/packages/live-preview/src/handleMessage.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -export declare const handleMessage: (args: { - apiRoute?: string - depth?: number - event: MessageEvent - initialData: T - serverURL: string -}) => Promise -//# sourceMappingURL=handleMessage.d.ts.map diff --git a/packages/live-preview/src/handleMessage.d.ts.map b/packages/live-preview/src/handleMessage.d.ts.map deleted file mode 100644 index b9436b60f5..0000000000 --- a/packages/live-preview/src/handleMessage.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"handleMessage.d.ts","sourceRoot":"","sources":["handleMessage.ts"],"names":[],"mappings":"AAYA,eAAO,MAAM,aAAa;eACb,MAAM;YACT,MAAM;WACP,YAAY;;eAER,MAAM;gBAyClB,CAAA"} \ No newline at end of file diff --git a/packages/live-preview/src/handleMessage.ts b/packages/live-preview/src/handleMessage.ts index 8911bb103f..2c2061ee57 100644 --- a/packages/live-preview/src/handleMessage.ts +++ b/packages/live-preview/src/handleMessage.ts @@ -1,3 +1,4 @@ +import { isLivePreviewEvent } from './isLivePreviewEvent.js' import { mergeData } from './mergeData.js' // For performance reasons, `fieldSchemaJSON` will only be sent once on the initial message @@ -19,12 +20,7 @@ export const handleMessage = async (args: { }): Promise => { const { apiRoute, depth, event, initialData, serverURL } = args - if ( - event.origin === serverURL && - event.data && - typeof event.data === 'object' && - event.data.type === 'payload-live-preview' - ) { + if (isLivePreviewEvent(event, serverURL)) { const { data, externallyUpdatedRelationship, fieldSchemaJSON } = event.data if (!payloadLivePreviewFieldSchema && fieldSchemaJSON) { diff --git a/packages/live-preview/src/index.d.ts b/packages/live-preview/src/index.d.ts deleted file mode 100644 index 28d7623a7d..0000000000 --- a/packages/live-preview/src/index.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { handleMessage } from './handleMessage.js' -export { mergeData } from './mergeData.js' -export { ready } from './ready.js' -export { subscribe } from './subscribe.js' -export { unsubscribe } from './unsubscribe.js' -//# sourceMappingURL=index.d.ts.map diff --git a/packages/live-preview/src/index.d.ts.map b/packages/live-preview/src/index.d.ts.map deleted file mode 100644 index 9040a8fb2f..0000000000 --- a/packages/live-preview/src/index.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAA;AAClD,OAAO,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAA;AAC1C,OAAO,EAAE,KAAK,EAAE,MAAM,YAAY,CAAA;AAClC,OAAO,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAA;AAC1C,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAA"} \ No newline at end of file diff --git a/packages/live-preview/src/index.ts b/packages/live-preview/src/index.ts index 77707a23f8..5f6fe5ad4a 100644 --- a/packages/live-preview/src/index.ts +++ b/packages/live-preview/src/index.ts @@ -1,4 +1,6 @@ export { handleMessage } from './handleMessage.js' +export { isDocumentEvent } from './isDocumentEvent.js' +export { isLivePreviewEvent } from './isLivePreviewEvent.js' export { mergeData } from './mergeData.js' export { ready } from './ready.js' export { subscribe } from './subscribe.js' diff --git a/packages/live-preview/src/isDocumentEvent.ts b/packages/live-preview/src/isDocumentEvent.ts new file mode 100644 index 0000000000..b60e4c4969 --- /dev/null +++ b/packages/live-preview/src/isDocumentEvent.ts @@ -0,0 +1,5 @@ +export const isDocumentEvent = (event: MessageEvent, serverURL: string): boolean => + event.origin === serverURL && + event.data && + typeof event.data === 'object' && + event.data.type === 'payload-document-event' diff --git a/packages/live-preview/src/isLivePreviewEvent.ts b/packages/live-preview/src/isLivePreviewEvent.ts new file mode 100644 index 0000000000..b10422c668 --- /dev/null +++ b/packages/live-preview/src/isLivePreviewEvent.ts @@ -0,0 +1,5 @@ +export const isLivePreviewEvent = (event: MessageEvent, serverURL: string): boolean => + event.origin === serverURL && + event.data && + typeof event.data === 'object' && + event.data.type === 'payload-live-preview' diff --git a/packages/live-preview/src/mergeData.d.ts b/packages/live-preview/src/mergeData.d.ts deleted file mode 100644 index 881b9be9f3..0000000000 --- a/packages/live-preview/src/mergeData.d.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { fieldSchemaToJSON } from 'payload/utilities' -import type { UpdatedDocument } from './types.js' -export declare const mergeData: (args: { - apiRoute?: string - collectionPopulationRequestHandler?: ({ - apiPath, - endpoint, - serverURL, - }: { - apiPath: string - endpoint: string - serverURL: string - }) => Promise - depth?: number - externallyUpdatedRelationship?: UpdatedDocument - fieldSchema: ReturnType - incomingData: Partial - initialData: T - returnNumberOfRequests?: boolean - serverURL: string -}) => Promise< - T & { - _numberOfRequests?: number - } -> -//# sourceMappingURL=mergeData.d.ts.map diff --git a/packages/live-preview/src/mergeData.d.ts.map b/packages/live-preview/src/mergeData.d.ts.map deleted file mode 100644 index 16aba4761e..0000000000 --- a/packages/live-preview/src/mergeData.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"mergeData.d.ts","sourceRoot":"","sources":["mergeData.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAA;AAE1D,OAAO,KAAK,EAA2B,eAAe,EAAE,MAAM,YAAY,CAAA;AAc1E,eAAO,MAAM,SAAS;eACT,MAAM;;iBAMN,MAAM;kBACL,MAAM;mBACL,MAAM;UACb,QAAQ,QAAQ,CAAC;YACf,MAAM;oCACkB,eAAe;iBAClC,WAAW,wBAAwB,CAAC;;;6BAGxB,OAAO;eACrB,MAAM;;wBAGK,MAAM;EA6D7B,CAAA"} \ No newline at end of file diff --git a/packages/live-preview/src/subscribe.d.ts b/packages/live-preview/src/subscribe.d.ts deleted file mode 100644 index b4f207ce86..0000000000 --- a/packages/live-preview/src/subscribe.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -export declare const subscribe: (args: { - apiRoute?: string - callback: (data: T) => void - depth?: number - initialData: T - serverURL: string -}) => (event: MessageEvent) => void -//# sourceMappingURL=subscribe.d.ts.map diff --git a/packages/live-preview/src/subscribe.d.ts.map b/packages/live-preview/src/subscribe.d.ts.map deleted file mode 100644 index 835927e2c5..0000000000 --- a/packages/live-preview/src/subscribe.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"subscribe.d.ts","sourceRoot":"","sources":["subscribe.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,SAAS;eACT,MAAM;2BACM,IAAI;YACnB,MAAM;;eAEH,MAAM;cACN,YAAY,KAAK,IAa7B,CAAA"} \ No newline at end of file diff --git a/packages/live-preview/src/subscribe.ts b/packages/live-preview/src/subscribe.ts index b1586de627..3fa41c7891 100644 --- a/packages/live-preview/src/subscribe.ts +++ b/packages/live-preview/src/subscribe.ts @@ -10,7 +10,14 @@ export const subscribe = (args: { const { apiRoute, callback, depth, initialData, serverURL } = args const onMessage = async (event: MessageEvent) => { - const mergedData = await handleMessage({ apiRoute, depth, event, initialData, serverURL }) + const mergedData = await handleMessage({ + apiRoute, + depth, + event, + initialData, + serverURL, + }) + callback(mergedData) } diff --git a/packages/live-preview/src/traverseFields.d.ts b/packages/live-preview/src/traverseFields.d.ts deleted file mode 100644 index 7eca67d89f..0000000000 --- a/packages/live-preview/src/traverseFields.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { fieldSchemaToJSON } from 'payload/utilities' -import type { PopulationsByCollection, UpdatedDocument } from './types.js' -export declare const traverseFields: (args: { - externallyUpdatedRelationship?: UpdatedDocument - fieldSchema: ReturnType - incomingData: T - populationsByCollection: PopulationsByCollection - result: T -}) => void -//# sourceMappingURL=traverseFields.d.ts.map diff --git a/packages/live-preview/src/traverseFields.d.ts.map b/packages/live-preview/src/traverseFields.d.ts.map deleted file mode 100644 index 772a3f8801..0000000000 --- a/packages/live-preview/src/traverseFields.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"traverseFields.d.ts","sourceRoot":"","sources":["traverseFields.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAA;AAE1D,OAAO,KAAK,EAAE,uBAAuB,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AAI1E,eAAO,MAAM,cAAc;oCACO,eAAe;iBAClC,WAAW,wBAAwB,CAAC;;6BAExB,uBAAuB;;MAE9C,IA4QH,CAAA"} \ No newline at end of file diff --git a/packages/next/src/routes/index.ts b/packages/next/src/routes/index.ts index 2731fa77a0..aeb9ce9697 100644 --- a/packages/next/src/routes/index.ts +++ b/packages/next/src/routes/index.ts @@ -3,6 +3,7 @@ export { GRAPHQL_PLAYGROUND_GET, GRAPHQL_POST } from './graphql/index.js' export { DELETE as REST_DELETE, GET as REST_GET, + OPTIONS as REST_OPTIONS, PATCH as REST_PATCH, POST as REST_POST, } from './rest/index.js' diff --git a/packages/next/src/views/LivePreview/Preview/index.tsx b/packages/next/src/views/LivePreview/Preview/index.tsx index 5cb99c4af1..2b9e041395 100644 --- a/packages/next/src/views/LivePreview/Preview/index.tsx +++ b/packages/next/src/views/LivePreview/Preview/index.tsx @@ -36,6 +36,7 @@ export const LivePreview: React.FC = (props) => { const [fields] = useAllFormFields() + // 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 @@ -83,6 +84,25 @@ export const LivePreview: React.FC = (props) => { mostRecentUpdate, ]) + // 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(() => { + const message = { + type: 'payload-document-event', + } + + // Post message to external popup window + if (previewWindowType === 'popup' && popupRef.current) { + popupRef.current.postMessage(message, url) + } + + // Post message to embedded iframe + if (previewWindowType === 'iframe' && iframeRef.current) { + iframeRef.current.contentWindow?.postMessage(message, url) + } + }, [mostRecentUpdate, iframeRef, popupRef, previewWindowType, url]) + if (previewWindowType === 'iframe') { return (
= ({ serverURL, } = useConfig() const { docConfig, getVersions, versions } = useDocumentInfo() + const { reportUpdate } = useDocumentEvents() const versionsConfig = docConfig?.versions const [fields] = useAllFormFields() @@ -74,14 +76,17 @@ export const Autosave: React.FC = ({ let url: string let method: string + let entitySlug: string if (collection && id) { - url = `${serverURL}${api}/${collection.slug}/${id}?draft=true&autosave=true&locale=${localeRef.current}` + entitySlug = collection.slug + url = `${serverURL}${api}/${entitySlug}/${id}?draft=true&autosave=true&locale=${localeRef.current}` method = 'PATCH' } if (globalDoc) { - url = `${serverURL}${api}/globals/${globalDoc.slug}?draft=true&autosave=true&locale=${localeRef.current}` + entitySlug = globalDoc.slug + url = `${serverURL}${api}/globals/${entitySlug}?draft=true&autosave=true&locale=${localeRef.current}` method = 'POST' } @@ -103,7 +108,13 @@ export const Autosave: React.FC = ({ }) if (res.status === 200) { - setLastSaved(new Date().getTime()) + const newDate = new Date() + setLastSaved(newDate.getTime()) + reportUpdate({ + id, + entitySlug, + updatedAt: newDate.toISOString(), + }) void getVersions() } } @@ -115,7 +126,18 @@ export const Autosave: React.FC = ({ } void autosave() - }, [i18n, debouncedFields, modified, serverURL, api, collection, globalDoc, id, getVersions]) + }, [ + i18n, + debouncedFields, + modified, + serverURL, + api, + collection, + globalDoc, + reportUpdate, + id, + getVersions, + ]) useEffect(() => { if (versions?.docs?.[0]) { diff --git a/test/live-preview/app/(payload)/api/[...slug]/route.ts b/test/live-preview/app/(payload)/api/[...slug]/route.ts index eacae29614..52caec96ad 100644 --- a/test/live-preview/app/(payload)/api/[...slug]/route.ts +++ b/test/live-preview/app/(payload)/api/[...slug]/route.ts @@ -1,9 +1,10 @@ /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ /* DO NOT MODIFY it because it could be re-written at any time. */ import config from '@payload-config' -import { REST_DELETE, REST_GET, REST_PATCH, REST_POST } from '@payloadcms/next/routes/index.js' +import { REST_DELETE, REST_GET, REST_OPTIONS, REST_PATCH, REST_POST } from '@payloadcms/next/routes' export const GET = REST_GET(config) export const POST = REST_POST(config) export const DELETE = REST_DELETE(config) export const PATCH = REST_PATCH(config) +export const OPTIONS = REST_OPTIONS(config) diff --git a/test/live-preview/app/live-preview/(pages)/[slug]/page.client.tsx b/test/live-preview/app/live-preview/(pages)/[slug]/page.client.tsx index 8bc88906f2..ad17f3278c 100644 --- a/test/live-preview/app/live-preview/(pages)/[slug]/page.client.tsx +++ b/test/live-preview/app/live-preview/(pages)/[slug]/page.client.tsx @@ -5,6 +5,7 @@ import React from 'react' import type { Page as PageType } from '../../../../payload-types.js' +import { renderedPageTitleID } from '../../../../shared.js' import { PAYLOAD_SERVER_URL } from '../../_api/serverURL.js' import { Blocks } from '../../_components/Blocks/index.js' import { Gutter } from '../../_components/Gutter/index.js' @@ -22,7 +23,7 @@ export const PageClient: React.FC<{ return ( -

{data.title}

+
{data.title}
{ + const router = useRouter() + return router.refresh()} serverURL={PAYLOAD_SERVER_URL} /> +} diff --git a/test/live-preview/app/live-preview/(pages)/posts-ssr/[slug]/page.tsx b/test/live-preview/app/live-preview/(pages)/posts-ssr/[slug]/page.tsx new file mode 100644 index 0000000000..f5fbbfc935 --- /dev/null +++ b/test/live-preview/app/live-preview/(pages)/posts-ssr/[slug]/page.tsx @@ -0,0 +1,94 @@ +/* eslint-disable no-restricted-exports */ +import { Gutter } from '@payloadcms/ui/elements/Gutter' +import { notFound } from 'next/navigation.js' +import React, { Fragment } from 'react' + +import type { Post } from '../../../../../payload-types.js' + +import { ssrPostsSlug } from '../../../../../shared.js' +import { renderedPageTitleID } from '../../../../../shared.js' +import { fetchDoc } from '../../../_api/fetchDoc.js' +import { fetchDocs } from '../../../_api/fetchDocs.js' +import { Blocks } from '../../../_components/Blocks/index.js' +import { PostHero } from '../../../_heros/PostHero/index.js' +import { RefreshRouteOnSave } from './RefreshRouteOnSave.js' + +export default async function SSRPost({ params: { slug = '' } }) { + let data: Post | null = null + + try { + data = await fetchDoc({ + slug, + collection: ssrPostsSlug, + draft: true, + }) + } catch (error) { + console.error(error) // eslint-disable-line no-console + } + + if (!data) { + notFound() + } + + return ( + + + +
{data.title}
+
+ + + +
+ ) +} + +export async function generateStaticParams() { + process.env.PAYLOAD_DROP_DATABASE = 'false' + try { + const ssrPosts = await fetchDocs(ssrPostsSlug) + return ssrPosts?.map(({ slug }) => slug) + } catch (error) { + return [] + } +} diff --git a/test/live-preview/app/live-preview/(pages)/posts/[slug]/page.client.tsx b/test/live-preview/app/live-preview/(pages)/posts/[slug]/page.client.tsx index 0d8182945b..72fa036ca7 100644 --- a/test/live-preview/app/live-preview/(pages)/posts/[slug]/page.client.tsx +++ b/test/live-preview/app/live-preview/(pages)/posts/[slug]/page.client.tsx @@ -1,10 +1,12 @@ 'use client' import { useLivePreview } from '@payloadcms/live-preview-react' +import { Gutter } from '@payloadcms/ui/elements/Gutter' import React from 'react' import type { Post as PostType } from '../../../../../payload-types.js' +import { renderedPageTitleID } from '../../../../../shared.js' import { PAYLOAD_SERVER_URL } from '../../../_api/serverURL.js' import { Blocks } from '../../../_components/Blocks/index.js' import { PostHero } from '../../../_heros/PostHero/index.js' @@ -20,6 +22,9 @@ export const PostClient: React.FC<{ return ( + +
{data.title}
+
({ slug, - collection: 'posts', + collection: postsSlug, }) } catch (error) { console.error(error) // eslint-disable-line no-console @@ -29,8 +31,8 @@ export default async function Post({ params: { slug = '' } }) { export async function generateStaticParams() { process.env.PAYLOAD_DROP_DATABASE = 'false' try { - const posts = await fetchDocs('posts') - return posts?.map(({ slug }) => slug) + const ssrPosts = await fetchDocs(postsSlug) + return ssrPosts?.map(({ slug }) => slug) } catch (error) { return [] } diff --git a/test/live-preview/app/live-preview/_api/fetchDoc.ts b/test/live-preview/app/live-preview/_api/fetchDoc.ts index 829b3fca69..8f00ab1ead 100644 --- a/test/live-preview/app/live-preview/_api/fetchDoc.ts +++ b/test/live-preview/app/live-preview/_api/fetchDoc.ts @@ -6,10 +6,11 @@ import { getPayloadHMR } from '@payloadcms/next/utilities/getPayloadHMR.js' export const fetchDoc = async (args: { collection: string depth?: number + draft?: boolean slug?: string }): Promise => { const payload = await getPayloadHMR({ config }) - const { slug, collection, depth = 2 } = args || {} + const { slug, collection, depth = 2, draft } = args || {} const where: Where = {} @@ -24,6 +25,7 @@ export const fetchDoc = async (args: { collection, depth, where, + draft, }) if (docs[0]) return docs[0] as T diff --git a/test/live-preview/collections/PostsSSR.ts b/test/live-preview/collections/PostsSSR.ts new file mode 100644 index 0000000000..f1a1fdebf4 --- /dev/null +++ b/test/live-preview/collections/PostsSSR.ts @@ -0,0 +1,107 @@ +import type { CollectionConfig } from 'payload/types' + +import { Archive } from '../blocks/ArchiveBlock/index.js' +import { CallToAction } from '../blocks/CallToAction/index.js' +import { Content } from '../blocks/Content/index.js' +import { MediaBlock } from '../blocks/MediaBlock/index.js' +import { hero } from '../fields/hero.js' +import { ssrPostsSlug, tenantsSlug } from '../shared.js' + +export const PostsSSR: CollectionConfig = { + slug: ssrPostsSlug, + labels: { + singular: 'SSR Post', + plural: 'SSR Posts', + }, + access: { + read: () => true, + create: () => true, + update: () => true, + delete: () => true, + }, + versions: { + drafts: { + autosave: { + interval: 10, + }, + }, + }, + admin: { + useAsTitle: 'title', + defaultColumns: ['id', 'title', 'slug', 'createdAt'], + }, + fields: [ + { + name: 'slug', + type: 'text', + required: true, + admin: { + position: 'sidebar', + }, + }, + { + name: 'tenant', + type: 'relationship', + relationTo: tenantsSlug, + admin: { + position: 'sidebar', + }, + }, + { + name: 'title', + type: 'text', + required: true, + }, + { + type: 'tabs', + tabs: [ + { + label: 'Hero', + fields: [hero], + }, + { + label: 'Content', + fields: [ + { + name: 'layout', + type: 'blocks', + blocks: [CallToAction, Content, MediaBlock, Archive], + }, + { + name: 'relatedPosts', + type: 'relationship', + relationTo: 'posts', + hasMany: true, + filterOptions: ({ id }) => { + return { + id: { + not_in: [id], + }, + } + }, + }, + ], + }, + ], + }, + { + name: 'meta', + type: 'group', + fields: [ + { + name: 'title', + type: 'text', + }, + { + name: 'description', + type: 'textarea', + }, + { + name: 'image', + type: 'upload', + relationTo: 'media', + }, + ], + }, + ], +} diff --git a/test/live-preview/config.ts b/test/live-preview/config.ts index 139f8bb416..e4396d0105 100644 --- a/test/live-preview/config.ts +++ b/test/live-preview/config.ts @@ -2,13 +2,14 @@ import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js' import Categories from './collections/Categories.js' import { Media } from './collections/Media.js' import { Pages } from './collections/Pages.js' -import { Posts } from './collections/Posts.js' +import { Posts, postsSlug } from './collections/Posts.js' +import { PostsSSR } from './collections/PostsSSR.js' import { Tenants } from './collections/Tenants.js' import { Users } from './collections/Users.js' import { Footer } from './globals/Footer.js' import { Header } from './globals/Header.js' import { seed } from './seed/index.js' -import { mobileBreakpoint } from './shared.js' +import { mobileBreakpoint, pagesSlug, ssrPostsSlug } from './shared.js' import { formatLivePreviewURL } from './utilities/formatLivePreviewURL.js' export default buildConfigWithDefaults({ @@ -18,13 +19,13 @@ export default buildConfigWithDefaults({ // The Live Preview config cascades from the top down, properties are inherited from here url: formatLivePreviewURL, breakpoints: [mobileBreakpoint], - collections: ['pages', 'posts'], + collections: [pagesSlug, postsSlug, ssrPostsSlug], globals: ['header', 'footer'], }, }, cors: ['http://localhost:3000', 'http://localhost:3001'], csrf: ['http://localhost:3000', 'http://localhost:3001'], - collections: [Users, Pages, Posts, Tenants, Categories, Media], + collections: [Users, Pages, Posts, PostsSSR, Tenants, Categories, Media], globals: [Header, Footer], onInit: seed, }) diff --git a/test/live-preview/e2e.spec.ts b/test/live-preview/e2e.spec.ts index edd1127031..43864d812e 100644 --- a/test/live-preview/e2e.spec.ts +++ b/test/live-preview/e2e.spec.ts @@ -14,7 +14,7 @@ import { import { AdminUrlUtil } from '../helpers/adminUrlUtil.js' import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js' import { POLL_TOPASS_TIMEOUT, TEST_TIMEOUT_LONG } from '../playwright.config.js' -import { mobileBreakpoint } from './shared.js' +import { mobileBreakpoint, pagesSlug, renderedPageTitleID, ssrPostsSlug } from './shared.js' const filename = fileURLToPath(import.meta.url) const dirname = path.dirname(filename) @@ -23,16 +23,18 @@ const { beforeAll, describe } = test describe('Live Preview', () => { let page: Page let serverURL: string - let url: AdminUrlUtil - const goToDoc = async (page: Page) => { - await page.goto(url.list) - await page.waitForURL(url.list) + let pagesURLUtil: AdminUrlUtil + let ssrPostsURLUtil: AdminUrlUtil + + const goToDoc = async (page: Page, urlUtil: AdminUrlUtil) => { + await page.goto(urlUtil.list) + await page.waitForURL(urlUtil.list) await navigateToListCellLink(page) } - const goToCollectionPreview = async (page: Page): Promise => { - await goToDoc(page) + const goToCollectionPreview = async (page: Page, urlUtil: AdminUrlUtil): Promise => { + await goToDoc(page, urlUtil) await page.goto(`${page.url()}/preview`) await page.waitForURL(`**/preview`) } @@ -47,7 +49,10 @@ describe('Live Preview', () => { beforeAll(async ({ browser }, testInfo) => { testInfo.setTimeout(TEST_TIMEOUT_LONG) ;({ serverURL } = await initPayloadE2ENoConfig({ dirname })) - url = new AdminUrlUtil(serverURL, 'pages') + + pagesURLUtil = new AdminUrlUtil(serverURL, pagesSlug) + ssrPostsURLUtil = new AdminUrlUtil(serverURL, ssrPostsSlug) + const context = await browser.newContext() page = await context.newPage() @@ -57,7 +62,7 @@ describe('Live Preview', () => { }) test('collection — has tab', async () => { - await goToDoc(page) + await goToDoc(page, pagesURLUtil) const livePreviewTab = page.locator('.doc-tab', { hasText: exactText('Live Preview'), @@ -75,46 +80,82 @@ describe('Live Preview', () => { }) test('collection — has route', async () => { - await goToDoc(page) - await goToCollectionPreview(page) - + await goToCollectionPreview(page, pagesURLUtil) await expect(page.locator('.live-preview')).toBeVisible() }) test('collection — renders iframe', async () => { - await goToCollectionPreview(page) + await goToCollectionPreview(page, pagesURLUtil) const iframe = page.locator('iframe.live-preview-iframe') await expect(iframe).toBeVisible() }) - test('collection — can edit fields and can preview updated value', async () => { - await goToCollectionPreview(page) - const titleValue = 'Title 1' - const field = page.locator('#field-title') + test('collection — re-renders iframe client-side when form state changes', async () => { + await goToCollectionPreview(page, pagesURLUtil) + + const titleField = page.locator('#field-title') const frame = page.frameLocator('iframe.live-preview-iframe').first() - await expect(field).toBeVisible() + await expect(titleField).toBeVisible() - // Forces the test to wait for the nextjs route to render before we try editing a field - await expect(() => expect(frame.locator('#page-title')).toBeVisible()).toPass({ + const renderedPageTitleLocator = `#${renderedPageTitleID}` + + // Forces the test to wait for the Next.js route to render before we try editing a field + await expect(() => expect(frame.locator(renderedPageTitleLocator)).toBeVisible()).toPass({ timeout: POLL_TOPASS_TIMEOUT, }) - await field.fill(titleValue) + await expect(frame.locator(renderedPageTitleLocator)).toHaveText('Home') - await expect(() => expect(frame.locator('#page-title')).toHaveText(titleValue)).toPass({ + const newTitleValue = 'Home (Edited)' + + await titleField.fill(newTitleValue) + + await expect(() => + expect(frame.locator(renderedPageTitleLocator)).toHaveText(newTitleValue), + ).toPass({ timeout: POLL_TOPASS_TIMEOUT, }) await saveDocAndAssert(page) }) - test('collection — should show live-preview view level action in live-preview view', async () => { - await goToCollectionPreview(page) + test('collection — re-render iframe server-side when autosave is made', async () => { + await goToCollectionPreview(page, ssrPostsURLUtil) + + const titleField = page.locator('#field-title') + const frame = page.frameLocator('iframe.live-preview-iframe').first() + + await expect(titleField).toBeVisible() + + const renderedPageTitleLocator = `#${renderedPageTitleID}` + + // Forces the test to wait for the Next.js route to render before we try editing a field + await expect(() => expect(frame.locator(renderedPageTitleLocator)).toBeVisible()).toPass({ + timeout: POLL_TOPASS_TIMEOUT, + }) + + await expect(frame.locator(renderedPageTitleLocator)).toHaveText('SSR Post 1') + + const newTitleValue = 'SSR Post 1 (Edited)' + + await titleField.fill(newTitleValue) + + await expect(() => + expect(frame.locator(renderedPageTitleLocator)).toHaveText(newTitleValue), + ).toPass({ + timeout: POLL_TOPASS_TIMEOUT, + }) + + await saveDocAndAssert(page) + }) + + test('collection — should show live-preview view-level action in live-preview view', async () => { + await goToCollectionPreview(page, pagesURLUtil) await expect(page.locator('.app-header .collection-live-preview-button')).toHaveCount(1) }) - test('global — should show live-preview view level action in live-preview view', async () => { + test('global — should show live-preview view-level action in live-preview view', async () => { await goToGlobalPreview(page, 'footer') await expect(page.locator('.app-header .global-live-preview-button')).toHaveCount(1) }) @@ -164,13 +205,13 @@ describe('Live Preview', () => { }) test('properly measures iframe and displays size', async () => { - await page.goto(url.create) - await page.waitForURL(url.create) + await page.goto(pagesURLUtil.create) + await page.waitForURL(pagesURLUtil.create) await page.locator('#field-title').fill('Title 3') await page.locator('#field-slug').fill('slug-3') await saveDocAndAssert(page) - await goToCollectionPreview(page) + await goToCollectionPreview(page, pagesURLUtil) const iframe = page.locator('iframe') @@ -213,13 +254,13 @@ describe('Live Preview', () => { }) test('resizes iframe to specified breakpoint', async () => { - await page.goto(url.create) - await page.waitForURL(url.create) + await page.goto(pagesURLUtil.create) + await page.waitForURL(pagesURLUtil.create) await page.locator('#field-title').fill('Title 4') await page.locator('#field-slug').fill('slug-4') await saveDocAndAssert(page) - await goToCollectionPreview(page) + await goToCollectionPreview(page, pagesURLUtil) // Check that the breakpoint select is present const breakpointSelector = page.locator( diff --git a/test/live-preview/seed/index.ts b/test/live-preview/seed/index.ts index 4621d50750..3eda7706f6 100644 --- a/test/live-preview/seed/index.ts +++ b/test/live-preview/seed/index.ts @@ -6,7 +6,7 @@ import { fileURLToPath } from 'url' import { devUser } from '../../credentials.js' import removeFiles from '../../helpers/removeFiles.js' import { postsSlug } from '../collections/Posts.js' -import { pagesSlug, tenantsSlug } from '../shared.js' +import { pagesSlug, ssrPostsSlug, tenantsSlug } from '../shared.js' import { footer } from './footer.js' import { header } from './header.js' import { home } from './home.js' @@ -61,6 +61,22 @@ export const seed: Config['onInit'] = async (payload) => { ), }) + await payload.create({ + collection: ssrPostsSlug, + data: { + ...JSON.parse( + JSON.stringify(post1) + .replace(/"\{\{IMAGE\}\}"/g, mediaID) + .replace(/"\{\{TENANT_1_ID\}\}"/g, tenantID), + ), + title: 'SSR Post 1', + meta: { + title: 'SSR Post 1', + description: 'This is the first SSR post.', + }, + }, + }) + const post2Doc = await payload.create({ collection: postsSlug, data: JSON.parse( diff --git a/test/live-preview/shared.ts b/test/live-preview/shared.ts index 1ced8ab600..54f3d9598a 100644 --- a/test/live-preview/shared.ts +++ b/test/live-preview/shared.ts @@ -2,9 +2,13 @@ export const pagesSlug = 'pages' export const tenantsSlug = 'tenants' +export const ssrPostsSlug = 'posts-ssr' + export const mobileBreakpoint = { label: 'Mobile', name: 'mobile', width: 375, height: 667, } + +export const renderedPageTitleID = 'rendered-page-title' diff --git a/test/live-preview/tsconfig.json b/test/live-preview/tsconfig.json index b85fda49c8..c5b6c9c880 100644 --- a/test/live-preview/tsconfig.json +++ b/test/live-preview/tsconfig.json @@ -35,7 +35,7 @@ "@payloadcms/ui/utilities/*": ["../../packages/ui/src/utilities/*.ts"], "@payloadcms/ui/scss": ["../../packages/ui/src/scss.scss"], "@payloadcms/ui/scss/app.scss": ["../../packages/ui/src/scss/app.scss"], - "payload/types": ["../../packages/payload/src/exports/types/index.ts"], + "payload/types": ["../../packages/payload/src/exports/types.ts"], "@payloadcms/next/*": ["../../packages/next/src/*"], "@payloadcms/next": ["../../packages/next/src/exports/*"], }