Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,48 @@ jobs:
- run: pnpm build
- name: Check the published file list
run: pnpm pack --dry-run
- name: Check that the demo is not published
run: |
if pnpm pack --dry-run 2>&1 | grep -q 'demo/'; then
echo 'demo/ is in the package' && exit 1
fi

e2e:
name: e2e on the demo (${{ matrix.mode }})
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
# prod: `react-router build` + `react-router-serve`, what an app ships.
# dev: `vite dev`, which uses another module graph (HMR, dependency optimizer).
mode: [prod, dev]
env:
CI: 'true'
E2E_MODE: ${{ matrix.mode }}
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: '24'
cache: pnpm
- run: pnpm install --frozen-lockfile
# The demo imports the adapter from dist/, so build the adapter first.
- run: pnpm build
- name: Install Chromium
run: pnpm --filter payload-react-router-demo exec playwright install --with-deps chromium
- name: Build the demo
if: matrix.mode == 'prod'
run: pnpm --filter payload-react-router-demo build
- name: Run the bench
run: pnpm test:e2e
- name: Upload the report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: e2e-${{ matrix.mode }}
path: |
demo/playwright-report
demo/test-results
retention-days: 14
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,7 @@ dist/
*.tsbuildinfo
*.log
.DS_Store
demo/build/
demo/.react-router/
demo/*.db
demo/*.db-*
18 changes: 12 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,17 @@ view and layout adapters, server functions); `@payloadcms/ui` is used unmodified
| Vite | `8.x` |
| Node.js | `>= 24.15` (Payload 4) |

The adapter is exercised by a demo app: SQLite, Lexical, drafts + autosave, uploads, two
locales, a global, and a website that reads content through the Local API. A 26-test Playwright
bench runs on that app against both the production build and `vite dev`, with the adapter
installed from the packed tarball. The demo app is in a private repository for now; the
scenarios and results are in [`docs/COMPAT.md`](./docs/COMPAT.md).
A complete demo app lives in [`demo/`](./demo): SQLite, Lexical, drafts and autosave, page
blocks with live preview, uploads, two locales, a global, and a website that reads content
through the Local API. A 35-test Playwright bench runs on it in CI against both the production
build and `vite dev`. Scenarios and results are in [`docs/COMPAT.md`](./docs/COMPAT.md).

```bash
pnpm install
pnpm demo:dev # http://localhost:3000/admin
pnpm demo:build && pnpm test:e2e # bench on the production build
E2E_MODE=dev pnpm test:e2e # bench on vite dev
```

## Adding Payload to a React Router app

Expand Down Expand Up @@ -261,4 +267,4 @@ A plain "SSR + loaders" React Router app cannot host the admin without changing
- The Payload CLI does not detect React Router: use `importMapFile` and `PAYLOAD_CONFIG_PATH`.
- Each admin page is rendered to completion on the server before streaming starts (needed
to turn in-view redirects into real redirects; TanStack does the same).
- Live preview and cloud storage adapters are not covered by the bench yet.
- Cloud storage adapters (S3, R2, Azure, GCS) and Postgres are not covered by the bench yet.
4 changes: 4 additions & 0 deletions demo/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/media/*
!/media/.gitkeep
/test-results/
/playwright-report/
36 changes: 36 additions & 0 deletions demo/app/components/LivePreview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
'use client'

import { useLivePreview } from '@payloadcms/live-preview-react'
import { RichText } from '@payloadcms/richtext-lexical/react'

import type { Page, Post } from '../../payload-types'

import { RenderBlocks } from './RenderBlocks'

type LiveProps<T> = { initialData: T; serverURL: string }

/**
* Renders a page. In the admin's live preview iframe, the hook replaces the
* loader data with the unsaved form state that the admin posts at each change.
*/
export function LivePage({ initialData, serverURL }: LiveProps<Page>) {
const { data: page } = useLivePreview({ depth: 1, initialData, serverURL })
return (
<article className="page">
<title>{page.title}</title>
<RenderBlocks blocks={page.layout} />
</article>
)
}

export function LivePost({ initialData, serverURL }: LiveProps<Post>) {
const { data: post } = useLivePreview({ depth: 1, initialData, serverURL })
return (
<article>
<title>{post.title}</title>
<h1>{post.title}</h1>
{post.publishedAt ? <time dateTime={post.publishedAt}>{post.publishedAt.slice(0, 10)}</time> : null}
{post.content ? <RichText data={post.content} /> : null}
</article>
)
}
51 changes: 51 additions & 0 deletions demo/app/components/RenderBlocks.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { RichText } from '@payloadcms/richtext-lexical/react'

import { isSafeLink } from '../../collections/links'
import type { Media, Page } from '../../payload-types'

type LayoutBlock = NonNullable<Page['layout']>[number]

const mediaOf = (value: unknown): Media | null =>
value && typeof value === 'object' && 'url' in value ? (value as Media) : null

function Image({ media }: { media: Media | null }) {
if (!media?.url) {
return null
}
return <img alt={media.alt} height={media.height ?? undefined} src={media.url} width={media.width ?? undefined} />
}

function Block({ block }: { block: LayoutBlock }) {
switch (block.blockType) {
case 'hero':
return (
<section className="block-hero">
<h1>{block.heading}</h1>
{block.subheading ? <p>{block.subheading}</p> : null}
<Image media={mediaOf(block.image)} />
</section>
)
case 'content':
return <section className="block-content">{block.body ? <RichText data={block.body} /> : null}</section>
case 'mediaText':
return (
<section className={`block-media-text block-media-text--${block.mediaPosition ?? 'left'}`}>
<Image media={mediaOf(block.media)} />
<div>{block.body ? <RichText data={block.body} /> : null}</div>
</section>
)
case 'callToAction':
return (
<section className="block-cta">
<h2>{block.heading}</h2>
{block.text ? <p>{block.text}</p> : null}
{/* The field validates the link. This check also covers rows written before the validation. */}
{isSafeLink(block.buttonLink) ? <a href={block.buttonLink}>{block.buttonLabel}</a> : null}
</section>
)
}
}

export function RenderBlocks({ blocks }: { blocks: Page['layout'] }) {
return <>{blocks?.map((block, index) => <Block block={block} key={block.id ?? index} />)}</>
}
24 changes: 24 additions & 0 deletions demo/app/lib/payload.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import config from '@payload-config'
import { getPayload } from 'payload'

/**
* The Local API: the website reads content in the same process as the admin,
* with no HTTP hop and no API token.
*/
export const getPayloadClient = () => getPayload({ config })

/**
* Reads the request's Payload session. A signed-in user sees drafts, which is
* what the live preview iframe in the admin loads. Anonymous visitors get
* published documents only, through collection access control.
*/
export async function getReader(request: Request) {
const payload = await getPayloadClient()
const { user } = await payload.auth({ headers: request.headers })
return {
draft: Boolean(user),
payload,
serverURL: new URL(request.url).origin,
user,
}
}
33 changes: 33 additions & 0 deletions demo/app/root.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { isRouteErrorResponse, Outlet } from 'react-router'
import { payloadMiddleware } from 'payload-react-router/server'

import type { Route } from './+types/root'

// Opens the request scope Payload's server adapter reads headers from and
// writes cookies to. Must be on the root route so every request goes through it.
export const middleware: Route.MiddlewareFunction[] = [payloadMiddleware]

// The site and the admin each render their own <html> from their layout route.
export function ServerComponent() {
return <Outlet />
}

export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
const title = isRouteErrorResponse(error)
? `${error.status} ${error.statusText}`
: error instanceof Error
? error.message
: 'Unknown error'

return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<title>{title}</title>
</head>
<body>
<h1>{title}</h1>
</body>
</html>
)
}
16 changes: 16 additions & 0 deletions demo/app/routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { index, layout, route, type RouteConfig } from '@react-router/dev/routes'

export default [
// The website: its own <html>, content read through Payload's Local API.
layout('routes/site/layout.tsx', [
index('routes/site/home.tsx'),
route('posts/:slug', 'routes/site/post.tsx'),
route(':slug', 'routes/site/page.tsx'),
]),
// The admin panel: Payload's <html>, providers and views.
layout('routes/payload/layout.tsx', { id: 'payload' }, [
route('admin/*', 'routes/payload/admin.tsx'),
]),
// REST + GraphQL API (resource route).
route('api/*', 'routes/payload/api.ts'),
] satisfies RouteConfig
16 changes: 16 additions & 0 deletions demo/app/routes/payload/admin.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// /admin and /admin/* — every admin view. Equivalent of
// `app/(payload)/admin/[[...segments]]/page.tsx` in a Next.js app.
import config from '@payload-config'
import { AdminPage, loadAdminPage } from 'payload-react-router/server'

import type { Route } from './+types/admin'

import { importMap } from './importMap.js'

export function loader({ params, request }: Route.LoaderArgs) {
return loadAdminPage({ config, importMap, request, splat: params['*'] })
}

export function ServerComponent({ loaderData }: Route.ComponentProps) {
return <AdminPage {...loaderData} />
}
5 changes: 5 additions & 0 deletions demo/app/routes/payload/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// /api/* — Payload REST and GraphQL. Equivalent of `app/(payload)/api/[...slug]/route.ts`.
import config from '@payload-config'
import { createAPIRoute } from 'payload-react-router/server'

export const { action, loader, middleware } = createAPIRoute({ config })
3 changes: 3 additions & 0 deletions demo/app/routes/payload/custom.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
/* Admin style overrides. Payload's own styles live in @layer payload-default. */
@layer payload {
}
4 changes: 4 additions & 0 deletions demo/app/routes/payload/importMap.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import type { ImportMap } from 'payload'

/** Generated by `pnpm generate:importmap`; see importMap.js. */
export declare const importMap: ImportMap
76 changes: 76 additions & 0 deletions demo/app/routes/payload/importMap.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 17 additions & 0 deletions demo/app/routes/payload/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// The admin document. Equivalent of `app/(payload)/layout.tsx` in a Next.js app.
import config from '@payload-config'
import '@payloadcms/ui/css/app.css'
import { PayloadAdminLayout } from 'payload-react-router/server'
import { Outlet } from 'react-router'

import './custom.css'
import { importMap } from './importMap.js'
import { serverFunction } from './server-functions'

export function ServerComponent() {
return (
<PayloadAdminLayout config={config} importMap={importMap} serverFunction={serverFunction}>
<Outlet />
</PayloadAdminLayout>
)
}
Loading
Loading