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.
This commit is contained in:
Jacob Fletcher
2024-12-04 13:31:43 -05:00
committed by GitHub
parent 8e26824bf8
commit f12b4dc6b0
14 changed files with 57 additions and 37 deletions

View File

@@ -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

View File

@@ -36,7 +36,7 @@ export const LivePreviewView: PayloadServerReactComponent<EditViewComponent> = a
},
]
const url =
let url =
typeof livePreviewConfig?.url === 'function'
? await livePreviewConfig.url({
collectionConfig,
@@ -47,5 +47,10 @@ export const LivePreviewView: PayloadServerReactComponent<EditViewComponent> = 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 <LivePreviewClient breakpoints={breakpoints} initialData={doc} url={url} />
}

View File

@@ -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<Page>('pages')
return pages?.map(({ slug }) => slug)
} catch (error) {
} catch (_err) {
return []
}
}

View File

@@ -32,8 +32,8 @@ export const getDoc = async <T>(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')
}

View File

@@ -1,7 +1,7 @@
import config from '@payload-config'
import { getPayload } from 'payload'
import { type CollectionSlug, getPayload } from 'payload'
export const getDocs = async <T>(collection: string): Promise<T[]> => {
export const getDocs = async <T>(collection: CollectionSlug): Promise<T[]> => {
const payload = await getPayload({ config })
try {
@@ -11,10 +11,12 @@ export const getDocs = async <T>(collection: string): Promise<T[]> => {
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')
}

View File

@@ -21,7 +21,7 @@ export async function Footer() {
<img
alt="Payload Logo"
className={classes.logo}
src="https://raw.githubusercontent.com/payloadcms/payload/main/packages/payload/src/admin/assets/images/payload-logo-light.svg"
src="https://raw.githubusercontent.com/payloadcms/payload/main/packages/ui/src/assets/payload-logo-light.svg"
/>
</picture>
</Link>
@@ -30,7 +30,7 @@ export async function Footer() {
return <CMSLink key={i} {...link} />
})}
<Link href="/admin">Admin</Link>
<Link href="https://github.com/payloadcms/payload/tree/main/templates/ecommerce">
<Link href="https://github.com/payloadcms/payload/tree/main/test/live-preview">
Source Code
</Link>
<Link href="https://github.com/payloadcms/payload">Payload</Link>

View File

@@ -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<CMSLinkType> = ({
}) => {
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 (
<Link {...newTabProps} className={className} href={href || url || ''}>
<NextLink {...newTabProps} className={className} href={href || url || ''}>
{label && label}
{children && children}
</Link>
{children || null}
</NextLink>
)
}
}

View File

@@ -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<Page>('pages')
return pages?.map(({ slug }) => slug)
} catch (error) {
} catch (_err) {
return []
}
}

View File

@@ -32,8 +32,8 @@ export const getDoc = async <T>(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')
}

View File

@@ -1,7 +1,7 @@
import config from '@payload-config'
import { getPayload } from 'payload'
import { type CollectionSlug, getPayload } from 'payload'
export const getDocs = async <T>(collection: string): Promise<T[]> => {
export const getDocs = async <T>(collection: CollectionSlug): Promise<T[]> => {
const payload = await getPayload({ config })
try {
@@ -11,10 +11,12 @@ export const getDocs = async <T>(collection: string): Promise<T[]> => {
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')
}

View File

@@ -21,7 +21,7 @@ export async function Footer() {
<img
alt="Payload Logo"
className={classes.logo}
src="https://raw.githubusercontent.com/payloadcms/payload/main/packages/payload/src/admin/assets/images/payload-logo-light.svg"
src="https://raw.githubusercontent.com/payloadcms/payload/main/packages/ui/src/assets/payload-logo-light.svg"
/>
</picture>
</Link>
@@ -30,7 +30,7 @@ export async function Footer() {
return <CMSLink key={i} {...link} />
})}
<Link href="/admin">Admin</Link>
<Link href="https://github.com/payloadcms/payload/tree/main/templates/ecommerce">
<Link href="https://github.com/payloadcms/payload/tree/main/test/live-preview">
Source Code
</Link>
<Link href="https://github.com/payloadcms/payload">Payload</Link>

View File

@@ -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<CMSLinkType> = ({
}) => {
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 (
<Link {...newTabProps} className={className} href={href || url || ''}>
<NextLink {...newTabProps} className={className} href={href || url || ''}>
{label && label}
{children && children}
</Link>
{children || null}
</NextLink>
)
}
}

View File

@@ -4,7 +4,7 @@ import { postsSlug } from '../shared.js'
export const postsPage: Partial<Page> = {
title: 'Posts',
slug: 'live-preview/posts',
slug: 'posts',
meta: {
title: 'Payload Website Template',
description: 'An open-source website built with Payload and Next.js.',

View File

@@ -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) {