From f12b4dc6b02cce17661495600eeba9401db81ef3 Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Wed, 4 Dec 2024 13:31:43 -0500 Subject: [PATCH] feat(live-preview): supports relative urls for dynamic preview deployments (#9746) When deploying to Vercel, preview deployment URLs are dynamically generated. This breaks Live Preview within those deployments because there is no mechanism by which we can detect and set that URL within Payload. Although Vercel provides various environment variables at our disposal, they provide no concrete identifier for exactly _which_ URL is being currently previewed (you an access the same deployment from a number of different URLs). The fix is to support _relative_ live preview URLs, that way Payload can prepend the application's top-level domain dynamically at render-time in order to create a fully qualified URL. So when you visit a Vercel preview deployment, for example, that deployment's unique URL is used to load the iframe of the preview window, instead of the application's root/production domain. Note: this does not fix multi-tenancy single-domain setups, as those still require a static top-level domain for each tenant. --- docs/live-preview/overview.mdx | 2 ++ packages/next/src/views/LivePreview/index.tsx | 7 ++++++- .../app/live-preview/(pages)/[slug]/page.tsx | 4 +++- .../live-preview/app/live-preview/_api/getDoc.ts | 4 ++-- .../app/live-preview/_api/getDocs.ts | 12 +++++++----- .../live-preview/_components/Footer/index.tsx | 4 ++-- .../app/live-preview/_components/Link/index.tsx | 16 +++++++++------- .../app/live-preview/(pages)/[slug]/page.tsx | 4 +++- .../prod/app/live-preview/_api/getDoc.ts | 4 ++-- .../prod/app/live-preview/_api/getDocs.ts | 12 +++++++----- .../live-preview/_components/Footer/index.tsx | 4 ++-- .../app/live-preview/_components/Link/index.tsx | 16 +++++++++------- test/live-preview/seed/posts-page.ts | 2 +- .../utilities/formatLivePreviewURL.ts | 3 ++- 14 files changed, 57 insertions(+), 37 deletions(-) diff --git a/docs/live-preview/overview.mdx b/docs/live-preview/overview.mdx index 26991bdf7c..7900e0db80 100644 --- a/docs/live-preview/overview.mdx +++ b/docs/live-preview/overview.mdx @@ -54,6 +54,8 @@ _\* An asterisk denotes that a property is required._ The `url` property is a string that points to your front-end application. This value is used as the `src` attribute of the iframe rendering your front-end. Once loaded, the Admin Panel will communicate directly with your app through `window.postMessage` events. +This can be an absolute URL or a relative path. If you are using a relative path, Payload will resolve it relative to the application's origin URL. This is useful for Vercel preview deployments, for example, where URLs are not known ahead of time. + To set the URL, use the `admin.livePreview.url` property in your [Payload Config](../configuration/overview): ```ts diff --git a/packages/next/src/views/LivePreview/index.tsx b/packages/next/src/views/LivePreview/index.tsx index d33e3fa587..399cec0e09 100644 --- a/packages/next/src/views/LivePreview/index.tsx +++ b/packages/next/src/views/LivePreview/index.tsx @@ -36,7 +36,7 @@ export const LivePreviewView: PayloadServerReactComponent = a }, ] - const url = + let url = typeof livePreviewConfig?.url === 'function' ? await livePreviewConfig.url({ collectionConfig, @@ -47,5 +47,10 @@ export const LivePreviewView: PayloadServerReactComponent = a }) : livePreviewConfig?.url + // Support relative URLs by prepending the origin, if necessary + if (url && url.startsWith('/')) { + url = `${initPageResult.req.protocol}//${initPageResult.req.host}${url}` + } + return } diff --git a/test/live-preview/app/live-preview/(pages)/[slug]/page.tsx b/test/live-preview/app/live-preview/(pages)/[slug]/page.tsx index c127ae89ae..ceb912059d 100644 --- a/test/live-preview/app/live-preview/(pages)/[slug]/page.tsx +++ b/test/live-preview/app/live-preview/(pages)/[slug]/page.tsx @@ -1,3 +1,4 @@ +/* eslint-disable no-restricted-exports */ import { notFound } from 'next/navigation.js' import React from 'react' @@ -32,10 +33,11 @@ export default async function Page({ params: paramsPromise }: Args) { export async function generateStaticParams() { process.env.PAYLOAD_DROP_DATABASE = 'false' + try { const pages = await getDocs('pages') return pages?.map(({ slug }) => slug) - } catch (error) { + } catch (_err) { return [] } } diff --git a/test/live-preview/app/live-preview/_api/getDoc.ts b/test/live-preview/app/live-preview/_api/getDoc.ts index bc2a05441a..08e7a206f2 100644 --- a/test/live-preview/app/live-preview/_api/getDoc.ts +++ b/test/live-preview/app/live-preview/_api/getDoc.ts @@ -32,8 +32,8 @@ export const getDoc = async (args: { return docs[0] as T } } catch (err) { - console.log('Error getting doc', err) + throw new Error(`Error getting doc: ${err.message}`) } - throw new Error('Error getting doc') + throw new Error('No doc found') } diff --git a/test/live-preview/app/live-preview/_api/getDocs.ts b/test/live-preview/app/live-preview/_api/getDocs.ts index 4d8fc2a442..fc2296508e 100644 --- a/test/live-preview/app/live-preview/_api/getDocs.ts +++ b/test/live-preview/app/live-preview/_api/getDocs.ts @@ -1,7 +1,7 @@ import config from '@payload-config' -import { getPayload } from 'payload' +import { type CollectionSlug, getPayload } from 'payload' -export const getDocs = async (collection: string): Promise => { +export const getDocs = async (collection: CollectionSlug): Promise => { const payload = await getPayload({ config }) try { @@ -11,10 +11,12 @@ export const getDocs = async (collection: string): Promise => { limit: 100, }) - return docs as T[] + if (docs) { + return docs as T[] + } } catch (err) { - console.error(err) + throw new Error(`Error getting docs: ${err.message}`) } - throw new Error('Error getting docs') + throw new Error('No docs found') } diff --git a/test/live-preview/app/live-preview/_components/Footer/index.tsx b/test/live-preview/app/live-preview/_components/Footer/index.tsx index 32a96b2432..426c049f66 100644 --- a/test/live-preview/app/live-preview/_components/Footer/index.tsx +++ b/test/live-preview/app/live-preview/_components/Footer/index.tsx @@ -21,7 +21,7 @@ export async function Footer() { Payload Logo @@ -30,7 +30,7 @@ export async function Footer() { return })} Admin - + Source Code Payload diff --git a/test/live-preview/app/live-preview/_components/Link/index.tsx b/test/live-preview/app/live-preview/_components/Link/index.tsx index a940a83e4c..d5d273133e 100644 --- a/test/live-preview/app/live-preview/_components/Link/index.tsx +++ b/test/live-preview/app/live-preview/_components/Link/index.tsx @@ -1,4 +1,4 @@ -import LinkWithDefault from 'next/link.js' +import NextLinkImport from 'next/link.js' import React from 'react' import type { Page, Post } from '../../../../payload-types.js' @@ -6,7 +6,7 @@ import type { Props as ButtonProps } from '../Button/index.js' import { Button } from '../Button/index.js' -const Link = (LinkWithDefault.default || LinkWithDefault) as typeof LinkWithDefault.default +const NextLink = (NextLinkImport.default || NextLinkImport) as typeof NextLinkImport.default type CMSLinkType = { appearance?: ButtonProps['appearance'] @@ -36,20 +36,22 @@ export const CMSLink: React.FC = ({ }) => { const href = type === 'reference' && typeof reference?.value === 'object' && reference.value.slug - ? `/${reference.value.slug}` + ? `/live-preview/${reference.value.slug}` : url - if (!href) return null + if (!href) { + return null + } if (!appearance) { const newTabProps = newTab ? { rel: 'noopener noreferrer', target: '_blank' } : {} if (href || url) { return ( - + {label && label} - {children && children} - + {children || null} + ) } } diff --git a/test/live-preview/prod/app/live-preview/(pages)/[slug]/page.tsx b/test/live-preview/prod/app/live-preview/(pages)/[slug]/page.tsx index 69d394d4d1..ea055c68e6 100644 --- a/test/live-preview/prod/app/live-preview/(pages)/[slug]/page.tsx +++ b/test/live-preview/prod/app/live-preview/(pages)/[slug]/page.tsx @@ -1,3 +1,4 @@ +/* eslint-disable no-restricted-exports */ import { notFound } from 'next/navigation.js' import React from 'react' @@ -32,10 +33,11 @@ export default async function Page({ params: paramsPromise }: Args) { export async function generateStaticParams() { process.env.PAYLOAD_DROP_DATABASE = 'false' + try { const pages = await getDocs('pages') return pages?.map(({ slug }) => slug) - } catch (error) { + } catch (_err) { return [] } } diff --git a/test/live-preview/prod/app/live-preview/_api/getDoc.ts b/test/live-preview/prod/app/live-preview/_api/getDoc.ts index bc2a05441a..08e7a206f2 100644 --- a/test/live-preview/prod/app/live-preview/_api/getDoc.ts +++ b/test/live-preview/prod/app/live-preview/_api/getDoc.ts @@ -32,8 +32,8 @@ export const getDoc = async (args: { return docs[0] as T } } catch (err) { - console.log('Error getting doc', err) + throw new Error(`Error getting doc: ${err.message}`) } - throw new Error('Error getting doc') + throw new Error('No doc found') } diff --git a/test/live-preview/prod/app/live-preview/_api/getDocs.ts b/test/live-preview/prod/app/live-preview/_api/getDocs.ts index 4d8fc2a442..fc2296508e 100644 --- a/test/live-preview/prod/app/live-preview/_api/getDocs.ts +++ b/test/live-preview/prod/app/live-preview/_api/getDocs.ts @@ -1,7 +1,7 @@ import config from '@payload-config' -import { getPayload } from 'payload' +import { type CollectionSlug, getPayload } from 'payload' -export const getDocs = async (collection: string): Promise => { +export const getDocs = async (collection: CollectionSlug): Promise => { const payload = await getPayload({ config }) try { @@ -11,10 +11,12 @@ export const getDocs = async (collection: string): Promise => { limit: 100, }) - return docs as T[] + if (docs) { + return docs as T[] + } } catch (err) { - console.error(err) + throw new Error(`Error getting docs: ${err.message}`) } - throw new Error('Error getting docs') + throw new Error('No docs found') } diff --git a/test/live-preview/prod/app/live-preview/_components/Footer/index.tsx b/test/live-preview/prod/app/live-preview/_components/Footer/index.tsx index 32a96b2432..426c049f66 100644 --- a/test/live-preview/prod/app/live-preview/_components/Footer/index.tsx +++ b/test/live-preview/prod/app/live-preview/_components/Footer/index.tsx @@ -21,7 +21,7 @@ export async function Footer() { Payload Logo @@ -30,7 +30,7 @@ export async function Footer() { return })} Admin - + Source Code Payload diff --git a/test/live-preview/prod/app/live-preview/_components/Link/index.tsx b/test/live-preview/prod/app/live-preview/_components/Link/index.tsx index 25ae59b673..5b60e5a654 100644 --- a/test/live-preview/prod/app/live-preview/_components/Link/index.tsx +++ b/test/live-preview/prod/app/live-preview/_components/Link/index.tsx @@ -1,4 +1,4 @@ -import LinkWithDefault from 'next/link.js' +import NextLinkImport from 'next/link.js' import React from 'react' import type { Page, Post } from '../../../../../payload-types.js' @@ -6,7 +6,7 @@ import type { Props as ButtonProps } from '../Button/index.js' import { Button } from '../Button/index.js' -const Link = (LinkWithDefault.default || LinkWithDefault) as typeof LinkWithDefault.default +const NextLink = (NextLinkImport.default || NextLinkImport) as typeof NextLinkImport.default type CMSLinkType = { appearance?: ButtonProps['appearance'] @@ -36,20 +36,22 @@ export const CMSLink: React.FC = ({ }) => { const href = type === 'reference' && typeof reference?.value === 'object' && reference.value.slug - ? `/${reference.value.slug}` + ? `/live-preview/${reference.value.slug}` : url - if (!href) return null + if (!href) { + return null + } if (!appearance) { const newTabProps = newTab ? { rel: 'noopener noreferrer', target: '_blank' } : {} if (href || url) { return ( - + {label && label} - {children && children} - + {children || null} + ) } } diff --git a/test/live-preview/seed/posts-page.ts b/test/live-preview/seed/posts-page.ts index 998919229a..7cdf2ab627 100644 --- a/test/live-preview/seed/posts-page.ts +++ b/test/live-preview/seed/posts-page.ts @@ -4,7 +4,7 @@ import { postsSlug } from '../shared.js' export const postsPage: Partial = { title: 'Posts', - slug: 'live-preview/posts', + slug: 'posts', meta: { title: 'Payload Website Template', description: 'An open-source website built with Payload and Next.js.', diff --git a/test/live-preview/utilities/formatLivePreviewURL.ts b/test/live-preview/utilities/formatLivePreviewURL.ts index d7ee07b8fe..0e710f57e0 100644 --- a/test/live-preview/utilities/formatLivePreviewURL.ts +++ b/test/live-preview/utilities/formatLivePreviewURL.ts @@ -5,7 +5,7 @@ export const formatLivePreviewURL: LivePreviewConfig['url'] = async ({ collectionConfig, payload, }) => { - let baseURL = 'http://localhost:3000/live-preview' + let baseURL = '/live-preview' // You can run async requests here, if needed // For example, multi-tenant apps may need to lookup additional data @@ -25,6 +25,7 @@ export const formatLivePreviewURL: LivePreviewConfig['url'] = async ({ .then((res) => res?.docs?.[0]) if (fullTenant?.clientURL) { + // Note: appending a fully-qualified URL here won't work for preview deployments on Vercel baseURL = `${fullTenant.clientURL}/live-preview` } } catch (e) {