payload-live-preview 1.8.0 → 2.0.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +301 -93
- package/dist/adapters/astro/PreviewBoundary.astro +66 -0
- package/dist/adapters/astro/RichText.astro +6 -0
- package/dist/adapters/astro/index.d.ts +165 -134
- package/dist/adapters/astro/index.js +1 -1
- package/dist/adapters/astro/index.js.map +1 -1
- package/dist/adapters/astro/middleware-entry.d.ts +4 -9
- package/dist/adapters/astro/middleware-entry.js +1 -1
- package/dist/adapters/astro/middleware-entry.js.map +1 -1
- package/dist/adapters/nextjs/index.d.ts +13 -80
- package/dist/adapters/nextjs/index.js +1 -1
- package/dist/adapters/nextjs/index.js.map +1 -1
- package/dist/adapters/nuxt/index.d.ts +46 -106
- package/dist/adapters/nuxt/index.js +1 -1
- package/dist/adapters/nuxt/index.js.map +1 -1
- package/dist/adapters/sveltekit/index.d.ts +10 -75
- package/dist/adapters/sveltekit/index.js +1 -1
- package/dist/adapters/sveltekit/index.js.map +1 -1
- package/dist/client.cjs +2 -0
- package/dist/client.cjs.map +1 -0
- package/dist/client.d.cts +747 -0
- package/dist/client.d.ts +747 -0
- package/dist/client.js +2 -0
- package/dist/client.js.map +1 -0
- package/dist/codegen-astro.d.ts +5 -27
- package/dist/codegen-astro.js +1 -1
- package/dist/codegen-astro.js.map +1 -1
- package/dist/codegen-cli.d.ts +3 -16
- package/dist/codegen-cli.js +1 -1
- package/dist/codegen-cli.js.map +1 -1
- package/dist/codegen.cjs +1 -1
- package/dist/codegen.cjs.map +1 -1
- package/dist/codegen.d.cts +37 -124
- package/dist/codegen.d.ts +37 -124
- package/dist/codegen.js +1 -1
- package/dist/codegen.js.map +1 -1
- package/dist/core.cjs +1 -1
- package/dist/core.cjs.map +1 -1
- package/dist/core.d.cts +647 -1100
- package/dist/core.d.ts +647 -1100
- package/dist/core.js +1 -1
- package/dist/core.js.map +1 -1
- package/dist/doctor-cli.d.ts +3 -28
- package/dist/doctor-cli.js +1 -1
- package/dist/doctor-cli.js.map +1 -1
- package/dist/doctor.d.ts +16 -32
- package/dist/doctor.js +1 -1
- package/dist/doctor.js.map +1 -1
- package/dist/fragment.cjs +2 -0
- package/dist/fragment.cjs.map +1 -0
- package/dist/fragment.d.cts +333 -0
- package/dist/fragment.d.ts +333 -0
- package/dist/fragment.js +2 -0
- package/dist/fragment.js.map +1 -0
- package/dist/index.cjs +1 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +885 -1531
- package/dist/index.d.ts +885 -1531
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/lexical.cjs +2 -0
- package/dist/lexical.cjs.map +1 -0
- package/dist/lexical.d.cts +90 -0
- package/dist/lexical.d.ts +90 -0
- package/dist/lexical.js +2 -0
- package/dist/lexical.js.map +1 -0
- package/dist/middleware-CEPsY1P7.d.ts +40 -0
- package/dist/migrate.d.ts +73 -0
- package/dist/migrate.js +2 -0
- package/dist/migrate.js.map +1 -0
- package/dist/options-CEzzfHJu.d.ts +197 -0
- package/dist/payload.cjs +1 -1
- package/dist/payload.cjs.map +1 -1
- package/dist/payload.d.cts +11 -80
- package/dist/payload.d.ts +11 -80
- package/dist/payload.js +1 -1
- package/dist/payload.js.map +1 -1
- package/dist/plugins.cjs +2 -0
- package/dist/plugins.cjs.map +1 -0
- package/dist/plugins.d.cts +509 -0
- package/dist/plugins.d.ts +509 -0
- package/dist/plugins.js +2 -0
- package/dist/plugins.js.map +1 -0
- package/dist/{probe-tMHdim5y.d.ts → probe-CYtvj_Lm.d.ts} +43 -77
- package/dist/server.cjs +2 -0
- package/dist/server.cjs.map +1 -0
- package/dist/server.d.cts +435 -0
- package/dist/server.d.ts +435 -0
- package/dist/server.js +2 -0
- package/dist/server.js.map +1 -0
- package/dist/structural.cjs +3 -0
- package/dist/structural.cjs.map +1 -0
- package/dist/structural.d.cts +243 -0
- package/dist/structural.d.ts +243 -0
- package/dist/structural.js +3 -0
- package/dist/structural.js.map +1 -0
- package/package.json +86 -7
- package/dist/middleware-DD4Hwomt.d.ts +0 -189
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ Under the hood it's framework-agnostic — the same runtime drives SvelteKit, Nu
|
|
|
18
18
|
|
|
19
19
|
| | |
|
|
20
20
|
| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
21
|
-
| **Single source of truth** | One TypeScript runtime compiled to a self-contained IIFE (~64 KB raw, ~
|
|
21
|
+
| **Single source of truth** | One TypeScript runtime compiled to a self-contained IIFE (~64 KB raw, ~21 KB gzipped) at build time — no parallel inline/class implementations to drift. |
|
|
22
22
|
| **Payload 3.x native** | Optional REST data merging (`serverURL`) re-fetches populated documents exactly like the official client, so relationship and upload fields render as content, not as bare IDs. |
|
|
23
23
|
| **Complete Lexical renderer** | 16 node types incl. `upload`, `relationship`, `block`, `autolink`, `tab`, indent, RTL — plus automatic rich-text detection, so `data-payload-field` alone is enough. |
|
|
24
24
|
| **Intent-gated delivery** | Server adapters can limit runtime delivery to requests carrying preview-intent signals (`?preview=true`, `Sec-Fetch-Dest: iframe`, admin referer). Those client-controlled signals are an optimisation, never authorization. |
|
|
@@ -36,9 +36,26 @@ Under the hood it's framework-agnostic — the same runtime drives SvelteKit, Nu
|
|
|
36
36
|
| Relationship / upload population | ✅ (admin merges client-side) | ✅ with `serverURL` (REST merge) |
|
|
37
37
|
| Schema-driven field typing (`fieldSchemaJSON`) | ✅ | — (3.x removed it; DOM heuristics + Lexical auto-detection take over) |
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
<!-- compat-matrix:start -->
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
| Framework | Supported | Tested in CI on every push (version, browsers) |
|
|
42
|
+
| --------- | --------------------- | --------------------------------------------------------------------------------- |
|
|
43
|
+
| Astro | >=4.0.0 <8.0.0 | 7.2.1 (chromium, firefox, webkit); 6.x (chromium); 5.x (chromium); 4.x (chromium) |
|
|
44
|
+
| Next.js | App Router, 15 and 16 | 16.3.0 (chromium, firefox, webkit) |
|
|
45
|
+
| SvelteKit | 2.x | 2.70.2 (chromium, firefox, webkit) |
|
|
46
|
+
| Nuxt | 3.x | 3.21.11 (chromium, firefox, webkit) |
|
|
47
|
+
|
|
48
|
+
Node >=20.19.0; the unit and integration suites run on Node 20, 22, 24, 26. Every version in the table is what the fixture lockfile or the matrix job installs, checked by `npm run compat:check`.
|
|
49
|
+
|
|
50
|
+
- Payload 2.x: captured-message integration tests and fieldSchemaJSON typing.
|
|
51
|
+
- Payload 3.85.0: wire corpus captured from a real admin, replayed in tests/integration/wire-corpus.test.ts.
|
|
52
|
+
- Payload 3.88.0: real admin E2E (examples/payload-backend) on every push, plus a wire corpus captured from it.
|
|
53
|
+
- Payload latest: weekly protocol watch executes @payloadcms/live-preview@latest against the corpus.
|
|
54
|
+
- Payload 4.0 pre-releases: weekly protocol watch against @payloadcms/live-preview@canary, early warning only.
|
|
55
|
+
|
|
56
|
+
<!-- compat-matrix:end -->
|
|
57
|
+
|
|
58
|
+
**When to use the official packages instead:** for a client-rendered React or Vue app, [`@payloadcms/live-preview-react`](https://payloadcms.com/docs/live-preview/client) / `-vue` re-render your real component tree and are maintained in lockstep with Payload — that is the better tool there. This package exists for everything the official hooks cannot cover: Astro, static/SSR pages, SvelteKit/Nuxt server-rendered markup, plain HTML — anywhere there is no client framework to re-render the page. The longer comparison — what is shared, what is not, and running both on one page — is in [docs/interop.md](docs/interop.md).
|
|
42
59
|
|
|
43
60
|
## Install
|
|
44
61
|
|
|
@@ -46,6 +63,25 @@ Astro **4 – 7** is the supported peer range; the current real-app browser fixt
|
|
|
46
63
|
npm install payload-live-preview
|
|
47
64
|
```
|
|
48
65
|
|
|
66
|
+
### Package entries
|
|
67
|
+
|
|
68
|
+
The root import carries everything. The focused entries below ship the same
|
|
69
|
+
code as smaller, self-contained bundles — each with ESM and CommonJS builds,
|
|
70
|
+
its own type declarations, size budget and API report:
|
|
71
|
+
|
|
72
|
+
| Entry | Contents |
|
|
73
|
+
| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
|
|
74
|
+
| `payload-live-preview` | Everything: client, inline script generator, renderers, plugins, adapters' shared helpers. |
|
|
75
|
+
| `payload-live-preview/core` | The client and runtime without the built-in plugin constructors, generator or adapters. |
|
|
76
|
+
| `payload-live-preview/client` | `LivePreviewClient` and `initLivePreview()` alone. |
|
|
77
|
+
| `payload-live-preview/structural` | The structural array renderer, the keyed morph (ADR 0008) and the `data-payload-depends` helpers. |
|
|
78
|
+
| `payload-live-preview/lexical` | `lexicalToHtml()`, `lexicalToPlainText()`, node and block renderer registries. |
|
|
79
|
+
| `payload-live-preview/plugins` | `PluginManager`, plugin types and the built-in plugins. |
|
|
80
|
+
| `payload-live-preview/fragment` | `createFragmentStrategy()`: the browser half of server-rendered fragment boundaries (ADR 0011). |
|
|
81
|
+
| `payload-live-preview/server` | `definePreview()` and `authorizePreviewRequest()` for server code. |
|
|
82
|
+
| `payload-live-preview/{astro,nextjs,sveltekit,nuxt}` | One framework adapter each. |
|
|
83
|
+
| `payload-live-preview/codegen` | Type generation from a Payload config. |
|
|
84
|
+
|
|
49
85
|
## Configure Payload
|
|
50
86
|
|
|
51
87
|
Enable live preview in `payload.config.ts` — the `url` callback maps the edited document to the frontend URL shown in the preview iframe. `buildLivePreviewUrl` replaces the usual lookup-table boilerplate:
|
|
@@ -78,6 +114,69 @@ export default buildConfig({
|
|
|
78
114
|
});
|
|
79
115
|
```
|
|
80
116
|
|
|
117
|
+
### Authorized preview URLs: a token per page, for a few minutes
|
|
118
|
+
|
|
119
|
+
The URL above says `?preview=true`, which is intent, not proof. To let the
|
|
120
|
+
frontend verify the iframe request without a session cookie crossing
|
|
121
|
+
origins, mint a short-lived token on the Payload side and verify it in the
|
|
122
|
+
adapter with the `signed-token` strategy. The token is bound to the site,
|
|
123
|
+
the path, the locale and ten minutes; a leaked one is worth that much.
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
// payload.config.ts
|
|
127
|
+
import { buildLivePreviewUrl } from 'payload-live-preview/payload';
|
|
128
|
+
import { issuePreviewToken } from 'payload-live-preview';
|
|
129
|
+
|
|
130
|
+
const toPreviewUrl = buildLivePreviewUrl({
|
|
131
|
+
baseUrl: process.env.FRONTEND_URL!,
|
|
132
|
+
collections: { posts: ({ data }) => `/blog/${String(data.slug ?? '')}` },
|
|
133
|
+
globals: { homepage: '/' },
|
|
134
|
+
fallback: '/',
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
export default buildConfig({
|
|
138
|
+
admin: {
|
|
139
|
+
livePreview: {
|
|
140
|
+
url: async (args) => {
|
|
141
|
+
const url = new URL(toPreviewUrl(args));
|
|
142
|
+
const locale = typeof args.locale === 'string' ? args.locale : args.locale?.code;
|
|
143
|
+
url.searchParams.set(
|
|
144
|
+
'previewToken',
|
|
145
|
+
await issuePreviewToken(
|
|
146
|
+
{
|
|
147
|
+
audience: url.origin,
|
|
148
|
+
path: url.pathname,
|
|
149
|
+
...(locale ? { locale } : {}),
|
|
150
|
+
ttlMs: 10 * 60_000,
|
|
151
|
+
},
|
|
152
|
+
{ secret: process.env.PREVIEW_TOKEN_SECRET! }, // ≥ 32 bytes, shared with the frontend
|
|
153
|
+
),
|
|
154
|
+
);
|
|
155
|
+
return url.toString();
|
|
156
|
+
},
|
|
157
|
+
// …breakpoints, collections, globals as above
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
// frontend middleware — the matching side
|
|
165
|
+
authorizePreview: (request) =>
|
|
166
|
+
authorizePreviewRequest(request, {
|
|
167
|
+
type: 'signed-token',
|
|
168
|
+
secret: import.meta.env.PREVIEW_TOKEN_SECRET,
|
|
169
|
+
audience: import.meta.env.SITE_ORIGIN,
|
|
170
|
+
locale: (request) => new URL(request.url).pathname.split('/')[1],
|
|
171
|
+
}),
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Keep the query parameter out of access-log formats and error-reporter URLs,
|
|
175
|
+
set `Referrer-Policy: no-referrer` on preview responses, and hand the
|
|
176
|
+
adapter a replay store if a replay inside the ten minutes matters to you —
|
|
177
|
+
[ADR 0006](docs/architecture/0006-authorized-preview-context.md) lists the
|
|
178
|
+
channels and what each binding closes.
|
|
179
|
+
|
|
81
180
|
The helper appends `?preview=true` automatically so the adapters detect preview intent. That query parameter is client-controlled and does not authorize draft access or response changes. A hand-written `url: ({ data, locale, collectionConfig, globalConfig }) => string` callback works exactly the same — see the [official docs](https://payloadcms.com/docs/live-preview/overview) for the full contract.
|
|
82
181
|
|
|
83
182
|
**Documents without a preview target.** Not every document has a reachable
|
|
@@ -117,6 +216,8 @@ export default defineConfig({
|
|
|
117
216
|
allowedOrigins: [import.meta.env.PUBLIC_PAYLOAD_ADMIN_ORIGIN],
|
|
118
217
|
// Payload 3.x: populate relationship/upload fields via REST merge
|
|
119
218
|
serverURL: import.meta.env.PUBLIC_PAYLOAD_ADMIN_ORIGIN,
|
|
219
|
+
// Required alongside serverURL: the population depth, 0 for none.
|
|
220
|
+
mergeDepth: 1,
|
|
120
221
|
}),
|
|
121
222
|
],
|
|
122
223
|
});
|
|
@@ -166,12 +267,14 @@ The trade is one extra request the first time an editor opens a preview.
|
|
|
166
267
|
Under a strict CSP, the asset itself is unremarkable: a same-origin script with an `integrity` attribute, needing no `'unsafe-inline'`. The bootstrap does need covering, though — a static build has no request, so there is no per-request nonce to attach and Astro emits it as a plain inline `<script>`. Its content is deterministic for a given package version and configuration, so the practical answer is a `'sha256-…'` source expression rather than `'unsafe-inline'`. `mode: 'middleware'` is the option that can carry a real nonce, because there a request exists to derive one from.
|
|
167
268
|
|
|
168
269
|
For SSR projects (`output: 'server'`), request-time injection can limit the bytes to requests carrying preview intent:
|
|
270
|
+
continue
|
|
169
271
|
|
|
170
272
|
```ts
|
|
171
273
|
livePreview({
|
|
172
274
|
mode: 'middleware',
|
|
173
275
|
allowedOrigins: [import.meta.env.PUBLIC_PAYLOAD_ADMIN_ORIGIN],
|
|
174
276
|
serverURL: import.meta.env.PUBLIC_PAYLOAD_ADMIN_ORIGIN,
|
|
277
|
+
mergeDepth: 1,
|
|
175
278
|
}),
|
|
176
279
|
```
|
|
177
280
|
|
|
@@ -181,34 +284,24 @@ If draft data or response changes are privileged, use one application-owned serv
|
|
|
181
284
|
|
|
182
285
|
```ts
|
|
183
286
|
// src/middleware.ts
|
|
184
|
-
import { createLivePreviewMiddleware
|
|
185
|
-
import {
|
|
287
|
+
import { createLivePreviewMiddleware } from 'payload-live-preview/astro';
|
|
288
|
+
import { authorizePreviewRequest } from 'payload-live-preview';
|
|
186
289
|
|
|
187
|
-
const
|
|
290
|
+
export const onRequest = createLivePreviewMiddleware({
|
|
188
291
|
allowedOrigins: [import.meta.env.PUBLIC_PAYLOAD_ADMIN_ORIGIN],
|
|
292
|
+
strict: true,
|
|
293
|
+
// Runs only on requests with preview intent. A refusal leaves the response
|
|
294
|
+
// exactly as rendered: no runtime, no CSP change, no nonce in Astro.locals.
|
|
295
|
+
authorizePreview: (request) =>
|
|
296
|
+
authorizePreviewRequest(request, {
|
|
297
|
+
type: 'payload-session',
|
|
298
|
+
serverURL: import.meta.env.PAYLOAD_URL,
|
|
299
|
+
}),
|
|
189
300
|
});
|
|
190
|
-
|
|
191
|
-
export const onRequest = async (context, next) => {
|
|
192
|
-
const hasPreviewIntent = isPreviewRequest(context.request, {
|
|
193
|
-
adminOrigins: [import.meta.env.PUBLIC_PAYLOAD_ADMIN_ORIGIN],
|
|
194
|
-
});
|
|
195
|
-
const authorization = hasPreviewIntent ? await verifyAppPreviewSession(context.request) : null;
|
|
196
|
-
|
|
197
|
-
if (authorization === null) return next();
|
|
198
|
-
|
|
199
|
-
// Reuse this request-scoped decision in the page's draft fetch.
|
|
200
|
-
context.locals.previewAuthorization = authorization;
|
|
201
|
-
const response = await previewMiddleware(context, next);
|
|
202
|
-
const headers = new Headers(response.headers);
|
|
203
|
-
headers.set('cache-control', 'private, no-store');
|
|
204
|
-
return new Response(response.body, {
|
|
205
|
-
status: response.status,
|
|
206
|
-
statusText: response.statusText,
|
|
207
|
-
headers,
|
|
208
|
-
});
|
|
209
|
-
};
|
|
210
301
|
```
|
|
211
302
|
|
|
303
|
+
The same verdict gates the page: read `Astro.locals.livePreviewNonce` for your own scripts, and pass the context to `fetchPreviewDocument({ authorization })` and `createPreviewBindings({ authorization })` so draft selection and `data-payload-*` emission follow it. Three strategies exist — `payload-session`, `signed-token` (issued by `issuePreviewToken()` on the Payload side, bound to site, path, locale and a few minutes) and `verifier` (your own function). [ADR 0006](docs/architecture/0006-authorized-preview-context.md) is the threat model.
|
|
304
|
+
|
|
212
305
|
### Next.js (App Router)
|
|
213
306
|
|
|
214
307
|
> **Client-rendered React app?** Use the official [`@payloadcms/live-preview-react`](https://payloadcms.com/docs/live-preview/client) (`useLivePreview`) instead — it re-renders your real component tree, so conditional sections and custom components update with full fidelity, and it ships in lockstep with Payload. This package's DOM patching targets **server-rendered/static markup**; a hydrating React tree can revert its patches on re-render. For React Server Components, Payload's `RefreshRouteOnSave` is the save-triggered equivalent.
|
|
@@ -222,6 +315,7 @@ import { generateInlineScript } from 'payload-live-preview';
|
|
|
222
315
|
const previewScript = generateInlineScript({
|
|
223
316
|
allowedOrigins: [process.env.PAYLOAD_ADMIN_ORIGIN!],
|
|
224
317
|
serverURL: process.env.PAYLOAD_ADMIN_ORIGIN!,
|
|
318
|
+
mergeDepth: 1,
|
|
225
319
|
});
|
|
226
320
|
|
|
227
321
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
@@ -240,10 +334,18 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|
|
240
334
|
// middleware.ts — frame-ancestors on requests carrying preview intent
|
|
241
335
|
import { NextResponse, type NextRequest } from 'next/server';
|
|
242
336
|
import { createLivePreviewMiddleware } from 'payload-live-preview/nextjs';
|
|
337
|
+
import { authorizePreviewRequest } from 'payload-live-preview';
|
|
243
338
|
|
|
244
339
|
const livePreview = createLivePreviewMiddleware({
|
|
245
340
|
allowedOrigins: [process.env.PAYLOAD_ADMIN_ORIGIN!],
|
|
246
341
|
autoInject: false,
|
|
342
|
+
// Required under the 2.0 defaults: `strict` is on, and without this hook the
|
|
343
|
+
// adapter refuses rather than gating a response on client-controlled intent.
|
|
344
|
+
authorizePreview: (request) =>
|
|
345
|
+
authorizePreviewRequest(request, {
|
|
346
|
+
type: 'payload-session',
|
|
347
|
+
serverURL: process.env.PAYLOAD_ADMIN_ORIGIN!,
|
|
348
|
+
}),
|
|
247
349
|
});
|
|
248
350
|
|
|
249
351
|
export async function middleware(request: NextRequest) {
|
|
@@ -257,25 +359,45 @@ A static build cannot make a request-time authorization decision, so the inline
|
|
|
257
359
|
|
|
258
360
|
```ts
|
|
259
361
|
// src/hooks.server.ts
|
|
362
|
+
import { env } from '$env/dynamic/private';
|
|
260
363
|
import { livePreviewHandle } from 'payload-live-preview/sveltekit';
|
|
364
|
+
import { authorizePreviewRequest } from 'payload-live-preview';
|
|
365
|
+
const adminOrigin = env.PAYLOAD_ADMIN_ORIGIN ?? '';
|
|
261
366
|
export const handle = livePreviewHandle({
|
|
262
|
-
allowedOrigins: [
|
|
263
|
-
serverURL:
|
|
367
|
+
allowedOrigins: [adminOrigin],
|
|
368
|
+
serverURL: adminOrigin,
|
|
369
|
+
mergeDepth: 1,
|
|
370
|
+
// Required under the 2.0 defaults; without it the handle refuses and every
|
|
371
|
+
// route, not only the preview, fails.
|
|
372
|
+
authorizePreview: (request) =>
|
|
373
|
+
authorizePreviewRequest(request, { type: 'payload-session', serverURL: adminOrigin }),
|
|
264
374
|
});
|
|
265
375
|
```
|
|
266
376
|
|
|
377
|
+
`$env/dynamic/private` rather than `process.env`: a SvelteKit project carries no
|
|
378
|
+
Node globals in its types unless it adds `@types/node`, so the `process.env`
|
|
379
|
+
form does not type-check in a fresh one.
|
|
380
|
+
|
|
267
381
|
This shorthand performs intent-gated delivery, not authentication. For protected preview responses, wrap it and invoke it only after an application-owned server verifier succeeds; reuse that authorization for draft data and cache policy.
|
|
268
382
|
|
|
269
|
-
### Nuxt
|
|
383
|
+
### Nuxt
|
|
270
384
|
|
|
271
385
|
```ts
|
|
272
386
|
// server/plugins/live-preview.ts
|
|
273
387
|
import { livePreviewNitroPlugin } from 'payload-live-preview/nuxt';
|
|
388
|
+
import { authorizePreviewRequest } from 'payload-live-preview';
|
|
274
389
|
|
|
275
390
|
export default defineNitroPlugin(
|
|
276
391
|
livePreviewNitroPlugin({
|
|
277
392
|
allowedOrigins: [process.env.NUXT_PUBLIC_PAYLOAD_ADMIN_ORIGIN!],
|
|
278
393
|
serverURL: process.env.NUXT_PUBLIC_PAYLOAD_ADMIN_ORIGIN!,
|
|
394
|
+
mergeDepth: 1,
|
|
395
|
+
// Required under the 2.0 defaults; without it the plugin refuses.
|
|
396
|
+
authorizePreview: (request) =>
|
|
397
|
+
authorizePreviewRequest(request, {
|
|
398
|
+
type: 'payload-session',
|
|
399
|
+
serverURL: process.env.NUXT_PUBLIC_PAYLOAD_ADMIN_ORIGIN!,
|
|
400
|
+
}),
|
|
279
401
|
}),
|
|
280
402
|
);
|
|
281
403
|
```
|
|
@@ -291,6 +413,7 @@ import { generateInlineScript, wrapWithScriptTag } from 'payload-live-preview';
|
|
|
291
413
|
const script = generateInlineScript({
|
|
292
414
|
allowedOrigins: ['https://admin.example.com'],
|
|
293
415
|
serverURL: 'https://admin.example.com',
|
|
416
|
+
mergeDepth: 1,
|
|
294
417
|
});
|
|
295
418
|
// Inject via `<script>${script}</script>` — or wrapWithScriptTag(script, { nonce }).
|
|
296
419
|
```
|
|
@@ -308,48 +431,88 @@ Requirements: the preview page must be able to reach the Payload API with the ed
|
|
|
308
431
|
Live preview patches the DOM **after** the page has loaded — the initial server render is your job. If you use Payload drafts, fetch draft content only after authorizing the preview request; otherwise editors see stale published content until their first keystroke. `fetchPreviewDocument` / `fetchPreviewGlobal` build the REST query but deliberately do not authenticate it. Their 1.x `draft` default remains `true` for compatibility, so set it explicitly from your verified decision:
|
|
309
432
|
|
|
310
433
|
```ts
|
|
311
|
-
//
|
|
312
|
-
|
|
313
|
-
import { fetchPreviewDocument } from 'payload-live-preview';
|
|
434
|
+
// src/lib/preview.ts — server-only
|
|
435
|
+
import { definePreview } from 'payload-live-preview/server';
|
|
314
436
|
|
|
315
|
-
const
|
|
316
|
-
const page = await fetchPreviewDocument<Page>({
|
|
437
|
+
export const preview = definePreview({
|
|
317
438
|
serverURL: import.meta.env.PAYLOAD_URL,
|
|
439
|
+
depth: 1, // one number for the initial read and the runtime merge
|
|
440
|
+
});
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
```astro
|
|
444
|
+
---
|
|
445
|
+
import { preview } from '../lib/preview';
|
|
446
|
+
|
|
447
|
+
const result = await preview.fetchDocument<Page>({
|
|
318
448
|
collection: 'pages',
|
|
319
449
|
where: { slug: { equals: Astro.params.slug } },
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
...(authorization === null ? {} : { headers: authorization.payloadHeaders }),
|
|
450
|
+
authorization: Astro.locals.livePreviewAuthorization ?? null, // draft only with a verified context
|
|
451
|
+
signal: Astro.request.signal,
|
|
323
452
|
});
|
|
453
|
+
if (!result.ok) return new Response(null, { status: 503 }); // or log result.reason
|
|
454
|
+
const page = result.data;
|
|
455
|
+
---
|
|
324
456
|
```
|
|
325
457
|
|
|
326
|
-
`
|
|
458
|
+
Spread `preview.runtimeOptions` (`serverURL`, `apiRoute`, `mergeDepth`) into the adapter so the runtime merges at the depth the page was read with. The root-entry `fetchPreviewDocument()` / `fetchPreviewGlobal()` keep their 1.x behaviour (draft by default, `null` on failure) and are deprecated.
|
|
459
|
+
|
|
460
|
+
`hasPreviewIntent()` (and its deprecated alias `isPreviewRequest()`) detects intent only. Query parameters, iframe navigation, and referrers are not credentials. Do not attach a long-lived API key or service token based on its boolean result; the verifier should forward only the minimum request-scoped session material or validate a short-lived, scoped signature.
|
|
327
461
|
|
|
328
462
|
## Data-attribute reference
|
|
329
463
|
|
|
330
|
-
| Attribute | Purpose
|
|
331
|
-
| ------------------------------ |
|
|
332
|
-
| `data-payload-field` | Bind element to a Payload field path
|
|
333
|
-
| `data-payload-type` | Force a specific renderer
|
|
334
|
-
| `data-payload-attribute` | Write the value into an attribute instead of content (unsafe attributes and URLs are refused)
|
|
335
|
-
| `data-payload-href` | Read `href` from a sibling field
|
|
336
|
-
| `data-payload-src` | Read `src` from a sibling field
|
|
337
|
-
| `data-payload-alt` | Read `alt` from a sibling field
|
|
338
|
-
| `data-payload-richtext` | Force Lexical rendering (usually auto-detected)
|
|
339
|
-
| `data-payload-html` | Render value as sanitised HTML
|
|
340
|
-
| `data-payload-array` | Treat value as an array
|
|
341
|
-
| `data-payload-array-template` | HTML template per array item
|
|
342
|
-
| `data-payload-array-separator` | Separator for primitive arrays
|
|
343
|
-
| `data-payload-structural` | Use diff-based structural updates
|
|
344
|
-
| `data-payload-locale` | Override locale for this element
|
|
345
|
-
| `data-payload-owner` | Document this subtree belongs to (see below)
|
|
464
|
+
| Attribute | Purpose | Example |
|
|
465
|
+
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- |
|
|
466
|
+
| `data-payload-field` | Bind element to a Payload field path | `data-payload-field="hero.title"` |
|
|
467
|
+
| `data-payload-type` | Force a specific renderer | `data-payload-type="image"` |
|
|
468
|
+
| `data-payload-attribute` | Write the value into an attribute instead of content (unsafe attributes and URLs are refused) | `data-payload-attribute="datetime"` |
|
|
469
|
+
| `data-payload-href` | Read `href` from a sibling field | `data-payload-href="ctaUrl"` |
|
|
470
|
+
| `data-payload-src` | Read `src` from a sibling field | `data-payload-src="hero.url"` |
|
|
471
|
+
| `data-payload-alt` | Read `alt` from a sibling field | `data-payload-alt="hero.alt"` |
|
|
472
|
+
| `data-payload-richtext` | Force Lexical rendering (usually auto-detected) | `data-payload-richtext` |
|
|
473
|
+
| `data-payload-html` | Render value as sanitised HTML | `data-payload-html` |
|
|
474
|
+
| `data-payload-array` | Treat value as an array | `data-payload-array` |
|
|
475
|
+
| `data-payload-array-template` | HTML template per array item | `data-payload-array-template="<li>{{title}}</li>"` |
|
|
476
|
+
| `data-payload-array-separator` | Separator for primitive arrays | `data-payload-array-separator=" · "` |
|
|
477
|
+
| `data-payload-structural` | Use diff-based structural updates | `data-payload-structural` |
|
|
478
|
+
| `data-payload-locale` | Override locale for this element | `data-payload-locale="de-AT"` |
|
|
479
|
+
| `data-payload-owner` | Document this subtree belongs to (see below) | `data-payload-owner="global:homepage"` |
|
|
480
|
+
| `data-payload-depends` | Fields whose change re-applies this binding under `skipUnchanged`, e.g. `data-payload-depends="price currency"` | `data-payload-depends="price"` |
|
|
481
|
+
| `data-payload-strategy` | Delivery strategy: `patch` (default), `fragment` (server-rendered boundary) or `route` (whole-route refresh); anything else is left alone with `LP0407`. Without it: inside a fragment boundary → fragment, in `<head>` → route, else patch | `data-payload-strategy="route"` |
|
|
482
|
+
| `data-payload-fragment` | A server-rendered boundary; the value is a registry id the endpoint may render ([docs/hybrid.md](docs/hybrid.md)) | `data-payload-fragment="hero"` |
|
|
483
|
+
| `data-payload-fragment-key` | Distinguishes several boundaries of one id on a page | `data-payload-fragment-key="a"` |
|
|
484
|
+
| `data-payload-boundary` | A stable anchor for a possibly-empty field: hidden while empty, shown when filled (`PreviewBoundary.astro` renders it) | `data-payload-boundary hidden` |
|
|
485
|
+
| `data-payload-island` | A hydrated framework root: never patched or morphed into (see docs/renderers.md, islands) | `data-payload-island` |
|
|
486
|
+
| `data-payload-owned` | A subtree the site scripts itself: the morph retains it whole | `data-payload-owned` |
|
|
487
|
+
|
|
488
|
+
The `{{field}}` mustache in `data-payload-array-template` is read by this
|
|
489
|
+
package, not by your framework — but Svelte reads `{...}` and Vue reads
|
|
490
|
+
`{{ ... }}` as their own interpolation, so writing the template inline in a
|
|
491
|
+
`.svelte` or `.vue` file is a compile error or a silently empty value. Bind it
|
|
492
|
+
as a string instead:
|
|
493
|
+
|
|
494
|
+
```svelte
|
|
495
|
+
<script lang="ts">
|
|
496
|
+
const template = '<li><a data-payload-href="url">{{title}}</a></li>';
|
|
497
|
+
</script>
|
|
498
|
+
<ul data-payload-field="posts" data-payload-array-template={template}></ul>
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
```vue
|
|
502
|
+
<script setup lang="ts">
|
|
503
|
+
const template = '<li><a data-payload-href="url">{{title}}</a></li>';
|
|
504
|
+
</script>
|
|
505
|
+
<template>
|
|
506
|
+
<ul data-payload-field="posts" :data-payload-array-template="template"></ul>
|
|
507
|
+
</template>
|
|
508
|
+
```
|
|
346
509
|
|
|
347
510
|
Binding metadata is live: changing any of these attributes (including inferred
|
|
348
511
|
type markers and an input's native `type`) rebuilds the affected cache snapshot
|
|
349
512
|
after the mutation debounce. Locale-specific bindings resolve their own suffixed
|
|
350
513
|
field value, and stale work prepared for a previous element locale is discarded.
|
|
351
514
|
|
|
352
|
-
**Empty-field gotcha:** the runtime can only patch elements that exist. If your template renders a binding only when the field is non-empty, editing a previously-empty field has nowhere to land. Render the anchor unconditionally:
|
|
515
|
+
**Empty-field gotcha:** the runtime can only patch elements that exist. `PreviewBoundary.astro` (`payload-live-preview/astro/PreviewBoundary.astro`) renders a hidden anchor for an empty field and the runtime unhides it when the editor fills the field. If your template renders a binding only when the field is non-empty, editing a previously-empty field has nowhere to land. Render the anchor unconditionally:
|
|
353
516
|
|
|
354
517
|
```astro
|
|
355
518
|
<div data-payload-field="subtitle">{subtitle ?? ''}</div>
|
|
@@ -453,7 +616,7 @@ marker of your own that has nothing to do with preview state.
|
|
|
453
616
|
|
|
454
617
|
`text` · `textarea` · `richText` · `html` · `email` · `number` · `checkbox` · `date` · `select` · `radio` · `relationship` · `upload` · `image` · `url` · `array` · `blocks` · `structural-array`
|
|
455
618
|
|
|
456
|
-
Custom renderers register via the plugin system:
|
|
619
|
+
Custom renderers register via the plugin system (see [docs/renderers.md](docs/renderers.md) for what a renderer receives, namespaced custom keys such as `acme:money`, the `resolveRenderer` and `renderRichText` client options, and the plugin ownership contract):
|
|
457
620
|
|
|
458
621
|
```ts
|
|
459
622
|
import { LivePreviewClient } from 'payload-live-preview';
|
|
@@ -475,6 +638,22 @@ await client.use({
|
|
|
475
638
|
});
|
|
476
639
|
```
|
|
477
640
|
|
|
641
|
+
## Hybrid preview: fragments
|
|
642
|
+
|
|
643
|
+
Patching edits what the page already renders. A `data-payload-fragment`
|
|
644
|
+
boundary is rendered by **your server** from the unsaved form state instead —
|
|
645
|
+
conditional sections, derived values, custom blocks, the component's own
|
|
646
|
+
logic — and morphed in with focus and visitor state intact. The runtime posts
|
|
647
|
+
the fields to a same-origin endpoint you build with `createFragmentEndpoint()`
|
|
648
|
+
(Astro; registry of components, the same authorization strategies as the
|
|
649
|
+
page, no code or paths cross the wire), and patches the boundary's own
|
|
650
|
+
bindings if the server cannot render. Opt in per boundary; a page without
|
|
651
|
+
`fragments` carries no fragment client. [docs/hybrid.md](docs/hybrid.md) has
|
|
652
|
+
the markup, the endpoint, the deployment requirements and the abuse model
|
|
653
|
+
(ADR 0011). A binding in `<head>` or one marked `data-payload-strategy="route"`
|
|
654
|
+
refreshes the whole route once per revision instead, with scroll and focus
|
|
655
|
+
kept and the unsaved state re-applied on the fresh markup.
|
|
656
|
+
|
|
478
657
|
## Typed bindings and codegen
|
|
479
658
|
|
|
480
659
|
Generate interfaces straight from your Payload config:
|
|
@@ -510,7 +689,9 @@ client.events.on('beforeUpdate', (e) => {
|
|
|
510
689
|
client.events.on('documentSave', () => location.reload());
|
|
511
690
|
```
|
|
512
691
|
|
|
513
|
-
Events: `init` · `connect` · `disconnect` · `beforeUpdate` · `afterUpdate` · `elementUpdate` · `documentSave` · `cacheRefresh` · `error` · `destroy`.
|
|
692
|
+
Events: `init` · `connect` · `disconnect` · `beforeUpdate` · `afterUpdate` · `elementUpdate` · `documentSave` · `relationshipUpdate` · `cacheRefresh` · `error` · `destroy`. `relationshipUpdate` fires when an update carries `externallyUpdatedRelationship` (a related document edited in an admin drawer); that update re-renders every bound field even under `skipUnchanged`, because populated values may have changed while the form values did not.
|
|
693
|
+
|
|
694
|
+
Since 1.1.0 `beforeUpdate`, `afterUpdate` and `elementUpdate` also carry `receivedAt` (when the runtime accepted the message, Unix ms) and `source` (`'patch'` for an admin postMessage; fragment strategies will name themselves here), next to `revision`.
|
|
514
695
|
|
|
515
696
|
Plugin resources are registration-scoped. `client.unuse(name)` revokes that registration's event listeners, transforms, renderer layers, and `registerCleanup()` callbacks without touching another plugin or the built-in renderer; registering it again starts with a fresh scope. Resources remain staged until `init()` succeeds, and a failed `init()` rolls them back without exposing a partial registration. Renderers form a per-type stack (last active registration wins, removal reveals the previous layer). Synchronous transforms see the merged field value and run in registration order while each revision-bound binding entry is prepared, immediately before that entry is scheduled. The transformed value is fixed in the entry and later passes through the normal attribute or renderer dispatch. A thrown error or Promise/thenable result emits a lifecycle `error` and stores the original merged value as fallback, which still passes through the normal security checks.
|
|
516
697
|
|
|
@@ -540,15 +721,18 @@ Options accepted by `generateInlineScript`, every adapter, and `LivePreviewClien
|
|
|
540
721
|
Additional runtime options accepted by `generateInlineScript` and `LivePreviewClient`
|
|
541
722
|
(not by the adapter option objects):
|
|
542
723
|
|
|
543
|
-
| Option | Default
|
|
544
|
-
| -------------------------- |
|
|
545
|
-
| `enableA11y` | `true`
|
|
546
|
-
| `disableVisibilityGate` | `false`
|
|
547
|
-
| `visibilityGateThreshold` | `50`
|
|
548
|
-
| `scopeBindingsByOwner` | `false`
|
|
549
|
-
| `
|
|
550
|
-
| `
|
|
551
|
-
| `
|
|
724
|
+
| Option | Default | Meaning |
|
|
725
|
+
| -------------------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
726
|
+
| `enableA11y` | `true` | Shared `aria-live` region announcing connections, applied updates, and heartbeat-timeout disconnects while mounted; destroy releases it synchronously without promising a final audible announcement. |
|
|
727
|
+
| `disableVisibilityGate` | `false` | Apply updates to off-screen elements immediately. |
|
|
728
|
+
| `visibilityGateThreshold` | `50` | Cache size above which off-screen updates are queued. |
|
|
729
|
+
| `scopeBindingsByOwner` | `false` | Restrict each update to bindings owned by the document it names (`data-payload-owner`). Unowned bindings stop updating. |
|
|
730
|
+
| `skipUnchanged` | `false` | Skip a binding whose value is identical to the one it last applied, so a keystroke re-renders only what changed. Renderers and `elementUpdate` then stop seeing repeats; `inspect().revisions.skippedUnchanged` counts the skips. |
|
|
731
|
+
| `sanitizerPolicy` | `'compat'` | `'strict'` (the 2.0 default, set by `defaults: 'v2'`) strips `id`, `name` and `data-payload-*` from rich-text/HTML writes and passes other `data-*` only by list — see docs/security.md §3. |
|
|
732
|
+
| `dependencies` | `{}` | Fields whose change re-applies other bindings even when their own value did not change: `{ price: ['priceLabel'] }`. Used only with `skipUnchanged`. |
|
|
733
|
+
| `intersectionRootMargin` | `'200px'` | Pre-render margin for the visibility gate. |
|
|
734
|
+
| `disableReferrerDetection` | `false` | Opt out of `document.referrer` origin auto-detection. |
|
|
735
|
+
| `disableLocalhostMatching` | `false` | Opt out of dev-mode localhost origin matching. |
|
|
552
736
|
|
|
553
737
|
`LivePreviewClient` additionally accepts the programmatic-only `mergeFetch`,
|
|
554
738
|
`a11yLocale`, `root`, `autoStart`, and `validateToken` options. `validateToken` is a
|
|
@@ -565,7 +749,16 @@ does not suppress CSP handling. Astro additionally accepts `mode` (`'inline'` /
|
|
|
565
749
|
per-request `nonce`; `generateInlineScript()` itself returns a script body, so pass
|
|
566
750
|
the nonce to `wrapWithScriptTag()` when embedding it manually.
|
|
567
751
|
|
|
568
|
-
Bundle-size note:
|
|
752
|
+
Bundle-size note: the root barrel tree-shakes, and that is measured rather
|
|
753
|
+
than declared — `npm run test:treeshake` bundles one-symbol consumers with
|
|
754
|
+
Vite against the built package and holds each to a budget. Importing
|
|
755
|
+
`escapeHtml` from the root ships 220 B gzip, `lexicalToHtml` 4.3 KB,
|
|
756
|
+
`initLivePreview` 30.5 KB (the client with its built-in renderers, Lexical
|
|
757
|
+
included), `generateInlineScript` 24.8 KB (the inline runtime source and
|
|
758
|
+
nothing of the client). The focused entries under [Package entries](#package-entries)
|
|
759
|
+
give a bundler less to look through and a reader a smaller surface; the
|
|
760
|
+
bytes are the same. Hot-path timings and the tree-shaking table live in
|
|
761
|
+
[docs/benchmarks.md](docs/benchmarks.md).
|
|
569
762
|
|
|
570
763
|
The four real-app browser fixtures in `examples/` cover Astro 7, Next.js 16, SvelteKit 2, and Nuxt 3 in Chromium, Firefox, and WebKit. The Astro 4–7 peer range is broader than the single Astro-major browser fixture.
|
|
571
764
|
|
|
@@ -573,7 +766,7 @@ The four real-app browser fixtures in `examples/` cover Astro 7, Next.js 16, Sve
|
|
|
573
766
|
|
|
574
767
|
1. **Full running-Payload E2E** (`tests/real-payload/`, `npm run test:e2e:real-payload`) boots an **actual Payload 3.x admin** — `examples/payload-backend`, a self-contained SQLite Payload + Next.js server, seeded and auto-logged-in — opens its **real** Live Preview panel, types into real form fields, and asserts the cross-origin Astro preview iframe (our injected runtime) patches the DOM. No mock, no fixture, no stub: `real admin → real form → real postMessage → real iframe → runtime → DOM`, driven by Payload's own admin code.
|
|
575
768
|
2. **Browser E2E** (`tests/e2e/`) drives a real browser + real iframe across Chromium, Firefox and WebKit: `postMessage → runtime → DOM`. Its `/admin` page _emulates_ the Payload admin, so it can exercise edge cases (XSS, origin spoofing, every field type) faster than booting a full server.
|
|
576
|
-
3. **
|
|
769
|
+
3. **Wire corpus** (`tests/fixtures/wire-corpus/`, one file per Payload version, recorded from a real admin by `tests/real-payload/record-wire-corpus.spec.ts` with `PLP_RECORD_CORPUS=1`) is replayed through the real runtime by `tests/integration/wire-corpus.test.ts`: every capture must validate, render, and demonstrate exactly the capabilities the runtime then reports. The protocol watch also checks that the official client still recognises every captured message. The original **real-message contract test** (`tests/integration/real-payload-protocol.test.ts`) feeds a message **captured verbatim from a running Payload 3.85 admin** through the real `MessageBus` + runtime — the envelope quirks included: `collectionSlug` absent on a global, `externallyUpdatedRelationship: null`, `_status`/`id` alongside real fields.
|
|
577
770
|
4. **Weekly protocol-watch** (`.github/workflows/protocol-watch.yml`) **executes** the real `@payloadcms/live-preview@latest` **and `@canary`** (Payload 4.0 pre-releases) and asserts their actual behaviour — the `ready` handshake, event discriminators, and `mergeData` REST request — still matches our runtime's invariants.
|
|
578
771
|
|
|
579
772
|
Together these span the whole spectrum: tier 1 proves the real thing works end to end, tier 2 exhausts edge cases quickly, tier 3 pins the exact wire shape Payload emits, and tier 4 catches drift the moment Payload ships it.
|
|
@@ -613,14 +806,19 @@ console.log(client.inspect());
|
|
|
613
806
|
|
|
614
807
|
The snapshot answers the questions that are otherwise guesswork:
|
|
615
808
|
|
|
616
|
-
| Reading | What it tells you
|
|
617
|
-
| ---------------------------------------------------------- |
|
|
618
|
-
| `bindings.orphanFields` | Field names that arrived but matched no element. A name here is a markup problem; a name in neither this nor `bindings.fieldNames` was never sent.
|
|
619
|
-
| `scheduler.deferred` with `scheduler.visibilityGateActive` | Updates the visibility gate is holding until the element scrolls into view. On a page nobody scrolls, that is "never" — the symptom is a preview that stops updating below the fold.
|
|
620
|
-
| `revisions.superseded` | Updates abandoned because a newer one arrived. Tracking `accepted` closely is normal for fast typing; it only matters when the _last_ update is among them.
|
|
621
|
-
| `
|
|
622
|
-
| `
|
|
623
|
-
| `
|
|
809
|
+
| Reading | What it tells you |
|
|
810
|
+
| ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
811
|
+
| `bindings.orphanFields` | Field names that arrived but matched no element. A name here is a markup problem; a name in neither this nor `bindings.fieldNames` was never sent. |
|
|
812
|
+
| `scheduler.deferred` with `scheduler.visibilityGateActive` | Updates the visibility gate is holding until the element scrolls into view. On a page nobody scrolls, that is "never" — the symptom is a preview that stops updating below the fold. |
|
|
813
|
+
| `revisions.superseded` | Updates abandoned because a newer one arrived. Tracking `accepted` closely is normal for fast typing; it only matters when the _last_ update is among them. |
|
|
814
|
+
| `revisions.completed` | Updates whose flush ran — their writes reached the DOM, or there was nothing to write. `accepted − superseded − completed` is in flight or cancelled. |
|
|
815
|
+
| `origins.locked` | The origin the runtime locked onto after its first accepted update. Every other origin is refused from then on. |
|
|
816
|
+
| `bindings.ownerScoped` with `bindings.owners` | Whether owner scoping is on, and which documents the page declares. Under scoping, an unowned binding receives nothing. |
|
|
817
|
+
| `protocol.negotiated` | The version both sides share, which caps the capabilities in `protocol.capabilities`. |
|
|
818
|
+
| `protocol.observed` | Capabilities seen on the wire rather than granted by version — the stock admin announces no version, so this is how its abilities become known (`locale`, `schema-json`, `document-events`, `relationship-events`, `preview-token`). Each capability declares a fallback; see `CAPABILITY_DOCUMENTATION`. |
|
|
819
|
+
| `protocol.profile` | What the observed capabilities imply: `payload-2` (a schema on the wire; the admin populates relationships itself, so no REST merge), `payload-3` (document or relationship events seen), or `unknown` (treated like 3.x for merging). |
|
|
820
|
+
| `fragments` | Server-rendered boundaries: `handler` (a fragment client is configured), `inFlight`, `rendered`, `failed`, `superseded`. |
|
|
821
|
+
| `route` | Route refreshes: `handler`, `refreshes`, `failed`, `loopStopped` (second requests for one revision, refused with `LP0805`). |
|
|
624
822
|
|
|
625
823
|
This is deliberately not gated to development builds. A snapshot discloses
|
|
626
824
|
nothing that is not already on the page — the trusted origins are inside the
|
|
@@ -670,23 +868,33 @@ Every message the runtime reports carries a stable code. Prose gets reworded; a
|
|
|
670
868
|
code does not — so a log filter, an alert rule, or a bug report that names
|
|
671
869
|
`LP0301` still means the same thing after the sentence around it changes.
|
|
672
870
|
|
|
673
|
-
| Code | Meaning
|
|
674
|
-
| -------- |
|
|
675
|
-
| `LP0101` | No trusted origin configured in production
|
|
676
|
-
| `LP0102` | Origin trust rests on `document.referrer`
|
|
677
|
-
| `LP0201` | An update named a field with no binding on the page
|
|
678
|
-
| `LP0202` | Owner scoping is on and the update names no document
|
|
679
|
-
| `LP0301` | The visibility gate held offscreen writes back
|
|
680
|
-
| `LP0401` | A value was refused as an unsafe attribute write
|
|
681
|
-
| `LP0402` | A text element has structured children
|
|
682
|
-
| `LP0403` | A structural container has no array template
|
|
683
|
-
| `
|
|
684
|
-
| `
|
|
685
|
-
| `
|
|
686
|
-
| `
|
|
687
|
-
| `
|
|
688
|
-
| `
|
|
689
|
-
| `
|
|
871
|
+
| Code | Meaning | What to do |
|
|
872
|
+
| -------- | -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
|
|
873
|
+
| `LP0101` | No trusted origin configured in production | Set `PAYLOAD_ADMIN_ORIGIN` or pass `allowedOrigins`. Nothing is accepted until you do. |
|
|
874
|
+
| `LP0102` | Origin trust rests on `document.referrer` | Any framing site is trusted. Set `allowedOrigins` and a `frame-ancestors` CSP. |
|
|
875
|
+
| `LP0201` | An update named a field with no binding on the page | Render the binding anchor unconditionally so edits to an initially-empty field have somewhere to land. |
|
|
876
|
+
| `LP0202` | Owner scoping is on and the update names no document | The message carries neither a global slug nor a collection slug plus id. |
|
|
877
|
+
| `LP0301` | The visibility gate held offscreen writes back | Raise `visibilityGateThreshold`, or accept that below-the-fold updates wait for a scroll. |
|
|
878
|
+
| `LP0401` | A value was refused as an unsafe attribute write | The attribute or the value is not safe to write; see the security model. |
|
|
879
|
+
| `LP0402` | A text element has structured children | Move `data-payload-field` to the value element, or add `data-payload-text`. |
|
|
880
|
+
| `LP0403` | A structural container has no array template | Add `data-payload-array-template`. |
|
|
881
|
+
| `LP0404` | A structural item has no `id` | Give items a stable `id`; without one they pair by position and an insert re-renders every row after it. |
|
|
882
|
+
| `LP0405` | Two structural items share a key | Make `id` unique per item; later duplicates pair by position. |
|
|
883
|
+
| `LP0406` | Every structural key changed at once | The source generates keys per message; the morph cannot retain nodes across such updates. |
|
|
884
|
+
| `LP0407` | A binding asks for a strategy this release does not have | Only `patch` and `fragment` exist; the element is left unchanged. |
|
|
885
|
+
| `LP0801` | A fragment request failed | Network, timeout or a 5xx from the endpoint; the boundary was patched from the same revision instead. |
|
|
886
|
+
| `LP0802` | A fragment response was invalid | Wrong content type, shape, size or boundary; patched instead. |
|
|
887
|
+
| `LP0803` | The fragment endpoint refused the preview | 401/403 — the page's authorization did not hold for the endpoint; patched instead. |
|
|
888
|
+
| `LP0804` | A late fragment response was discarded | It belonged to a superseded revision; nothing was applied. |
|
|
889
|
+
| `LP0805` | A route refresh loop was stopped | The same revision asked for a second refresh; the guard refused it. |
|
|
890
|
+
| `LP0806` | A fragment boundary has no fragment client | Configure `fragments` (the endpoint) to render it on the server; until then it is patched. |
|
|
891
|
+
| `LP0501` | A message was rejected before the update pipeline | Reason is one of origin, shape, type, token. Visible with `debug: true`. |
|
|
892
|
+
| `LP0502` | A preview token was rejected | Also reported when your `validateToken` throws — a throwing validator fails closed. |
|
|
893
|
+
| `LP0601` | A consumer event handler threw | Your `on(...)` handler; the runtime continued. |
|
|
894
|
+
| `LP0602` | A consumer transform threw | The original value was kept. |
|
|
895
|
+
| `LP0603` | A renderer threw while writing | That one write was abandoned. |
|
|
896
|
+
| `LP0605` | Runtime startup failed | |
|
|
897
|
+
| `LP0606` | Sending the ready handshake failed | |
|
|
690
898
|
|
|
691
899
|
Codes on the `error` event can be branched on directly:
|
|
692
900
|
|