Skip to content

Latest commit

 

History

History
243 lines (205 loc) · 15.9 KB

react.md

File metadata and controls

243 lines (205 loc) · 15.9 KB

React hook

useLivePreviewDocument() subscribes to the admin's updates and returns the merged document, so your components re-render with it. Same shape as Payload's own useLivePreview, with this package's merge underneath.

This is the other half of the package, not a replacement for it. The DOM runtime patches server-rendered markup; a hook gives React the new document and lets its reconciler update the client-owned tree. A stable component identity preserves component and DOM state. A changed type, key or position can cause a remount or replacement; the replaced node loses its state. Which owner to use, and why one page may use both: renderers.md and the caveat below.

Install

npm install payload-live-preview

react is an optional peer this package does not install. The ./react entry imports it at module scope, as a hook must; ./nextjs loads react, and react-dom/server for a fragment endpoint, only at the first render that needs it. The published ./react file starts with 'use client'.

The Payload admin also has to know which URL of your app to show for each document; the config plugin in payload.md sets that up.

The hook

'use client';
import { useLivePreviewDocument } from 'payload-live-preview/react';

export function PagePreview({ page }: { page: Page }) {
  const { data, isLoading, status, error } = useLivePreviewDocument<Page>({
    serverURL: process.env.NEXT_PUBLIC_PAYLOAD_URL!,
    allowedOrigins: [process.env.NEXT_PUBLIC_PAYLOAD_ADMIN_ORIGIN!],
    initialData: page,
    depth: 1,
  });

  return (
    <article>
      <h1>{data.title}</h1>
      {data.subtitle !== undefined && <p className="lede">{data.subtitle}</p>}
      {status === 'unavailable' && <p role="status">Preview paused: {error?.message}</p>}
    </article>
  );
}

Both NEXT_PUBLIC_ values ship to the browser. They are origins, not credentials; keep preview tokens and other secrets in server-only variables.

Option Default What it does
serverURL required Payload origin the update is re-fetched from. A trailing slash is fine
initialData required The document the page rendered; returned until an update merges
depth 1 Population depth for the merge
timeoutMs off Milliseconds a merge may wait for the server before the update is 'unavailable'
apiRoute /api REST route prefix
allowedOrigins — Admin origins allowed to post updates
eventSourcePolicy 'parent-or-opener' 'any' accepts a message from any window at an allowed origin
enableReferrerDetection false Trust document.referrer as an origin source
enableLocalhostMatching true Match localhost origins in development
target the global window Window to listen on
document — The document this hook previews: { collection, id } or { global }, with an optional locale; only its updates merge

A hook instance owns one document session. A fresh initialData object on an ordinary re-render does not replace data that already merged. A changed value of any other option except fetchFn (an array of allowedOrigins is compared by its entries) replaces the session with a new one that starts from the current initialData, so merged data is dropped. If the same component can move from one CMS document to another, remount it with a key that includes the document owner and id. This releases the old session and starts a new one from the new initialData. On Payload 2 that session starts without a field schema, and Payload 2.32.3 sends the schema only with its first message to the preview: the next update reports 'unavailable' and reads nothing until the admin page is loaded again (interop.md). A read the old session started can still answer after that, for example when a custom fetchFn (an option meant for tests) ignores the abort signal. The answer changes nothing: the new session keeps what it has and starts no read for it.

The return value is { data, isLoading, status, error, revision }. data and isLoading are Payload's two names. isLoading is true until an update settles, turns true again with every update it accepts, and is false once the newest one has merged or failed to. status is 'idle' before the first update, 'live' when the newest one merged, 'unavailable' when it did not; error says why, and only then. revision says which of the hook's messages data came from: 0 for initialData, unchanged when a merge fails.

status describes the merge. React paints data in its own commit, so a component that must know the editor sees a revision reads revision in an effect, which runs after that commit:

useEffect(() => {
  reportPainted(revision);
}, [revision]);

data is the document as Payload's REST API returned it. A media document's url is a path on Payload's own origin (/api/media/file/a.png) unless its config sets serverURL, so a page on another origin resolves it first: new URL(data.image.url, serverURL). The DOM runtime does this for its upload and image bindings (Media URLs); the hook leaves it to your component.

Measured against the official package

@payloadcms/live-preview is Payload's framework-independent message and merge client; @payloadcms/live-preview-react wraps it for a React app. The official wrapper is a natural choice when that app owns the document: one hook, no DOM attributes. What follows is not an argument against it. It records five client cases where this package's session and Payload's base client received the same input.

All five cases run twice in this repository — against the session this hook uses in document-session.test.ts, and against the lockfile's exact @payloadcms/live-preview 3.88.0 in payload-hook-comparison.test.ts, which imports the installed package rather than describing it. Both run in npm run test:unit, which CI runs on every push to main and every pull request. These are client-session tests, not React rendering or tarball consumer tests.

The scheduled npm run test:upstream-findings is a separate drift probe. It runs seven low-level observations against the requested registry version; its case set overlaps this table but is not identical, and it does not exercise the React wrapper.

Five where the results differ

Measured with @payloadcms/live-preview 3.88.0 This hook
serverURL https://cms.example.com/, message from https://cms.example.com no request at all; every callback hands back the document the page started on POST https://cms.example.com/api/pages/1
Two messages, the first answered after 40 ms, the second after 5 ms callbacks in the order NEW, OLD — the older document is the one that stays data is the newer one, and still is after the slow answer lands
HTTP 403 with Payload's error body, {"errors":[{"message":…}]} the callback receives that object; the document's fields are gone data unchanged, status: 'unavailable', error names the 403
Two previews on one page, documents 1 and 2 the second one's merge fetches pages/1 — previousData is module-level the merge for 2 fetches pages/2; each hook keeps its own state, requests and errors (ADR 0002), and both follow the document the admin sends unless each names its document
fetch rejecting with TypeError: Failed to fetch the rejection escapes the async listener last good document kept, status: 'unavailable', error names it

3.0 requires document (ADR 0007 ledger, row 26). Without it, both follow the document the admin sends: a hook takes every update from an allowed admin window, whichever document its initialData came from. On a page that previews document 1, a second hook started from document 2 shows document 1 after the first update. Given document: { collection: 'pages', id: 2 }, that hook leaves the update for pages/1 alone: it sends no request and keeps its state. { global: 'header' } names a global, and locale also leaves alone an update the admin sends for another locale. An update that does not say which document it is for is left alone as well, with one warning per hook in development: Payload 2 sends no collection or global slug, so leave document out on Payload 2, and a create screen has no id yet. A changed document replaces the session, like the other options. Without document, give the hook to the component that renders the document being edited, and render other documents from the data your server loaded.

The failed update is not retried: this hook reports it and catches up on the next message. Without timeoutMs, a merge request that never answers is not a failure: it has no deadline, and the hook stays isLoading with its last document. With timeoutMs, the update becomes unavailable once that many milliseconds pass, and an answer that comes later is not read (options.md). On Payload 3 the hook sends one merge request per accepted message, at any typing pace: 18 requests for an 18-keystroke burst. The DOM runtime asks less. It sends none for an edit that moves no populated value (the first message on a page that reads one, and the first value of a field that held none, still ask); for the others it sends one request when a burst of updates starts and one when it ends, a burst being updates less than debounceMs (50 ms by default) apart. npm run test:interaction records 0 requests for a plain text field and 2 for rich text and for a relationship over 18 keystrokes, 30 ms apart.

Payload 3 uses a POST to the REST API with X-Payload-HTTP-Method-Override: GET. It returns the stored document with the unsaved values applied and relationships populated.

Payload 2 sends fieldSchemaJSON and raw form values. The session retains that accepted schema for later messages that omit it. Scalars settle without a request; raw relationship IDs are grouped into bounded collection GET requests with the configured depth and message locale. Those reads include the editor's credentials and never send the method-override POST. An invalid or incomplete population keeps the last good document and reports 'unavailable', and so does a read that Payload refuses. When the editor's session ends in the middle of a preview, Payload 2.32.3 answers the next read with 403: error names it, data and revision stay, and the first update after a new sign-in is 'live' again. The DOM runtime uses the same merger (ADR 0010). The real-admin runs of the hook and the composable (Payload 2.32.3 and 3.92.1) use development bundles; a production build of both is server-rendered, hydrated and taken through its lifecycle against the mock admin (tests/e2e/specs/react-vue-tarball.spec.ts).

Origin trust is this package's, not the admin's word: a message is accepted only from an allowed origin, and by default only from the window that framed or opened the page (authorization.md for the wider model).

The caveat that decides which one you want

A render is not a remount. React preserves component state and usually the underlying DOM node while its type, key and position remain stable. It can still write controlled values, and a conditional branch, changed key or changed type can replace the node. A remount loses state held by that component or DOM node, including an uncontrolled value, focus or an inner scroll position.

The DOM runtime has a different ownership model. A scalar binding writes the existing element directly. A fragment or route render is morphed: compatible, paired live nodes retain their properties, while incompatible or unpaired nodes are replaced and lose them. Direct patches cannot create markup the page did not render; React can render conditional markup from the new document. The two approaches are complementary:

  • Bindings (data-payload-field) for server-rendered regions.
  • Fragments (data-payload-fragment) when a region needs its own logic rendered on the server (hybrid.md).
  • This hook inside a client component that owns its subtree anyway — mark its root data-payload-island so the runtime leaves it alone (interop.md).

Lending the runtime your router

<LivePreviewRouteRefresh refresh={...} /> hands the DOM runtime the host router's refresh, so a route update is a re-render React performs rather than server HTML this package morphs over the reconciler's nodes. It renders nothing, registers while it is mounted, and gives the registration back on unmount; without it the route strategy fetches and morphs as before. Wiring for Next's App Router: nextjs.md. Outside React the same seam is registerRouteRefresh(): give it a function whose promise settles once the host has committed the fresh render, as the SvelteKit and Nuxt recipes do. A function that returns nothing is taken as already committed; with debug the runtime says once that it could not wait (LP0810), and 3.0 requires the promise (ADR 0028).

Vue

The same composable, the same session, the same five cases: vue.md.

Server rendering

useLivePreviewDocument renders initialData on the server: there is no window and no message, so the first client render matches it and hydration is quiet. The subscription starts after mount.