payload-live-preview 1.8.1 → 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.
Files changed (98) hide show
  1. package/README.md +301 -93
  2. package/dist/adapters/astro/PreviewBoundary.astro +66 -0
  3. package/dist/adapters/astro/RichText.astro +6 -0
  4. package/dist/adapters/astro/index.d.ts +165 -134
  5. package/dist/adapters/astro/index.js +1 -1
  6. package/dist/adapters/astro/index.js.map +1 -1
  7. package/dist/adapters/astro/middleware-entry.d.ts +4 -9
  8. package/dist/adapters/astro/middleware-entry.js +1 -1
  9. package/dist/adapters/astro/middleware-entry.js.map +1 -1
  10. package/dist/adapters/nextjs/index.d.ts +13 -80
  11. package/dist/adapters/nextjs/index.js +1 -1
  12. package/dist/adapters/nextjs/index.js.map +1 -1
  13. package/dist/adapters/nuxt/index.d.ts +46 -106
  14. package/dist/adapters/nuxt/index.js +1 -1
  15. package/dist/adapters/nuxt/index.js.map +1 -1
  16. package/dist/adapters/sveltekit/index.d.ts +10 -75
  17. package/dist/adapters/sveltekit/index.js +1 -1
  18. package/dist/adapters/sveltekit/index.js.map +1 -1
  19. package/dist/client.cjs +2 -0
  20. package/dist/client.cjs.map +1 -0
  21. package/dist/client.d.cts +747 -0
  22. package/dist/client.d.ts +747 -0
  23. package/dist/client.js +2 -0
  24. package/dist/client.js.map +1 -0
  25. package/dist/codegen-astro.d.ts +5 -27
  26. package/dist/codegen-astro.js +1 -1
  27. package/dist/codegen-astro.js.map +1 -1
  28. package/dist/codegen-cli.d.ts +3 -16
  29. package/dist/codegen-cli.js +1 -1
  30. package/dist/codegen-cli.js.map +1 -1
  31. package/dist/codegen.cjs +1 -1
  32. package/dist/codegen.cjs.map +1 -1
  33. package/dist/codegen.d.cts +37 -124
  34. package/dist/codegen.d.ts +37 -124
  35. package/dist/codegen.js +1 -1
  36. package/dist/codegen.js.map +1 -1
  37. package/dist/core.cjs +1 -1
  38. package/dist/core.cjs.map +1 -1
  39. package/dist/core.d.cts +647 -1100
  40. package/dist/core.d.ts +647 -1100
  41. package/dist/core.js +1 -1
  42. package/dist/core.js.map +1 -1
  43. package/dist/doctor-cli.d.ts +3 -28
  44. package/dist/doctor-cli.js +1 -1
  45. package/dist/doctor-cli.js.map +1 -1
  46. package/dist/doctor.d.ts +16 -32
  47. package/dist/doctor.js +1 -1
  48. package/dist/doctor.js.map +1 -1
  49. package/dist/fragment.cjs +2 -0
  50. package/dist/fragment.cjs.map +1 -0
  51. package/dist/fragment.d.cts +333 -0
  52. package/dist/fragment.d.ts +333 -0
  53. package/dist/fragment.js +2 -0
  54. package/dist/fragment.js.map +1 -0
  55. package/dist/index.cjs +1 -1
  56. package/dist/index.cjs.map +1 -1
  57. package/dist/index.d.cts +885 -1531
  58. package/dist/index.d.ts +885 -1531
  59. package/dist/index.js +1 -1
  60. package/dist/index.js.map +1 -1
  61. package/dist/lexical.cjs +2 -0
  62. package/dist/lexical.cjs.map +1 -0
  63. package/dist/lexical.d.cts +90 -0
  64. package/dist/lexical.d.ts +90 -0
  65. package/dist/lexical.js +2 -0
  66. package/dist/lexical.js.map +1 -0
  67. package/dist/middleware-CEPsY1P7.d.ts +40 -0
  68. package/dist/migrate.d.ts +73 -0
  69. package/dist/migrate.js +2 -0
  70. package/dist/migrate.js.map +1 -0
  71. package/dist/options-CEzzfHJu.d.ts +197 -0
  72. package/dist/payload.cjs +1 -1
  73. package/dist/payload.cjs.map +1 -1
  74. package/dist/payload.d.cts +11 -80
  75. package/dist/payload.d.ts +11 -80
  76. package/dist/payload.js +1 -1
  77. package/dist/payload.js.map +1 -1
  78. package/dist/plugins.cjs +2 -0
  79. package/dist/plugins.cjs.map +1 -0
  80. package/dist/plugins.d.cts +509 -0
  81. package/dist/plugins.d.ts +509 -0
  82. package/dist/plugins.js +2 -0
  83. package/dist/plugins.js.map +1 -0
  84. package/dist/{probe-tMHdim5y.d.ts → probe-CYtvj_Lm.d.ts} +43 -77
  85. package/dist/server.cjs +2 -0
  86. package/dist/server.cjs.map +1 -0
  87. package/dist/server.d.cts +435 -0
  88. package/dist/server.d.ts +435 -0
  89. package/dist/server.js +2 -0
  90. package/dist/server.js.map +1 -0
  91. package/dist/structural.cjs +3 -0
  92. package/dist/structural.cjs.map +1 -0
  93. package/dist/structural.d.cts +243 -0
  94. package/dist/structural.d.ts +243 -0
  95. package/dist/structural.js +3 -0
  96. package/dist/structural.js.map +1 -0
  97. package/package.json +86 -7
  98. 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, ~20 KB gzipped) at build time — no parallel inline/class implementations to drift. |
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
- Astro **4 – 7** is the supported peer range; the current real-app browser fixture exercises Astro 7, not every supported Astro major. Node ≥ 20.19. Protocol compatibility is covered by captured-message integration tests plus a weekly `@payloadcms/live-preview` latest/canary drift check.
39
+ <!-- compat-matrix:start -->
40
40
 
41
- **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.
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, isPreviewRequest } from 'payload-live-preview/astro';
185
- import { verifyAppPreviewSession } from './lib/server/preview-auth'; // your server code
287
+ import { createLivePreviewMiddleware } from 'payload-live-preview/astro';
288
+ import { authorizePreviewRequest } from 'payload-live-preview';
186
289
 
187
- const previewMiddleware = createLivePreviewMiddleware({
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: [process.env.PAYLOAD_ADMIN_ORIGIN!],
263
- serverURL: process.env.PAYLOAD_ADMIN_ORIGIN!,
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 3
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
- // Astro example — in your page/loader code. The middleware example
312
- // above stored this application-owned, request-scoped authorization.
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 authorization = Astro.locals.previewAuthorization ?? null;
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
- draft: authorization !== null, // published for unauthorised/normal traffic
321
- depth: 1, // keep equal to mergeDepth!
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
- `isPreviewRequest()` is intentionally retained as the 1.x compatibility name, but it 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.
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 | Example |
331
- | ------------------------------ | --------------------------------------------------------------------------------------------- | -------------------------------------------------- |
332
- | `data-payload-field` | Bind element to a Payload field path | `data-payload-field="hero.title"` |
333
- | `data-payload-type` | Force a specific renderer | `data-payload-type="image"` |
334
- | `data-payload-attribute` | Write the value into an attribute instead of content (unsafe attributes and URLs are refused) | `data-payload-attribute="datetime"` |
335
- | `data-payload-href` | Read `href` from a sibling field | `data-payload-href="ctaUrl"` |
336
- | `data-payload-src` | Read `src` from a sibling field | `data-payload-src="hero.url"` |
337
- | `data-payload-alt` | Read `alt` from a sibling field | `data-payload-alt="hero.alt"` |
338
- | `data-payload-richtext` | Force Lexical rendering (usually auto-detected) | `data-payload-richtext` |
339
- | `data-payload-html` | Render value as sanitised HTML | `data-payload-html` |
340
- | `data-payload-array` | Treat value as an array | `data-payload-array` |
341
- | `data-payload-array-template` | HTML template per array item | `data-payload-array-template="<li>{{title}}</li>"` |
342
- | `data-payload-array-separator` | Separator for primitive arrays | `data-payload-array-separator=" · "` |
343
- | `data-payload-structural` | Use diff-based structural updates | `data-payload-structural` |
344
- | `data-payload-locale` | Override locale for this element | `data-payload-locale="de-AT"` |
345
- | `data-payload-owner` | Document this subtree belongs to (see below) | `data-payload-owner="global:homepage"` |
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 | Meaning |
544
- | -------------------------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
545
- | `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. |
546
- | `disableVisibilityGate` | `false` | Apply updates to off-screen elements immediately. |
547
- | `visibilityGateThreshold` | `50` | Cache size above which off-screen updates are queued. |
548
- | `scopeBindingsByOwner` | `false` | Restrict each update to bindings owned by the document it names (`data-payload-owner`). Unowned bindings stop updating. |
549
- | `intersectionRootMargin` | `'200px'` | Pre-render margin for the visibility gate. |
550
- | `disableReferrerDetection` | `false` | Opt out of `document.referrer` origin auto-detection. |
551
- | `disableLocalhostMatching` | `false` | Opt out of dev-mode localhost origin matching. |
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: `import … from 'payload-live-preview/core'` is a lighter entry without the built-in plugin constructors, inline generator/runtime source, or framework adapters. It still includes the built-in field renderers used by `LivePreviewClient`, including Lexical rendering. Hot-path timings live in [docs/benchmarks.md](docs/benchmarks.md).
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. **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.
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
- | `origins.locked` | The origin the runtime locked onto after its first accepted update. Every other origin is refused from then on. |
622
- | `bindings.ownerScoped` with `bindings.owners` | Whether owner scoping is on, and which documents the page declares. Under scoping, an unowned binding receives nothing. |
623
- | `protocol.negotiated` | The version both sides share, which caps the capabilities in `protocol.capabilities`. |
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 | What to do |
674
- | -------- | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
675
- | `LP0101` | No trusted origin configured in production | Set `PAYLOAD_ADMIN_ORIGIN` or pass `allowedOrigins`. Nothing is accepted until you do. |
676
- | `LP0102` | Origin trust rests on `document.referrer` | Any framing site is trusted. Set `allowedOrigins` and a `frame-ancestors` CSP. |
677
- | `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. |
678
- | `LP0202` | Owner scoping is on and the update names no document | The message carries neither a global slug nor a collection slug plus id. |
679
- | `LP0301` | The visibility gate held offscreen writes back | Raise `visibilityGateThreshold`, or accept that below-the-fold updates wait for a scroll. |
680
- | `LP0401` | A value was refused as an unsafe attribute write | The attribute or the value is not safe to write; see the security model. |
681
- | `LP0402` | A text element has structured children | Move `data-payload-field` to the value element, or add `data-payload-text`. |
682
- | `LP0403` | A structural container has no array template | Add `data-payload-array-template`. |
683
- | `LP0501` | A message was rejected before the update pipeline | Reason is one of origin, shape, type, token. Visible with `debug: true`. |
684
- | `LP0502` | A preview token was rejected | Also reported when your `validateToken` throws — a throwing validator fails closed. |
685
- | `LP0601` | A consumer event handler threw | Your `on(...)` handler; the runtime continued. |
686
- | `LP0602` | A consumer transform threw | The original value was kept. |
687
- | `LP0603` | A renderer threw while writing | That one write was abandoned. |
688
- | `LP0605` | Runtime startup failed | |
689
- | `LP0606` | Sending the ready handshake failed | |
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