Describe the Bug
With live preview enabled and autosave on a short interval, the live-preview pane closes itself while the user is typing and its toggle button disappears with it. It does not come back without a full page reload. Nothing is logged — no client error, no server error, every request is a 200 — which makes it very hard to attribute.
The cause is an aborted form-state request being applied as though it were a successful response.
-
Every onSave begins by aborting the previous one:
// packages/ui/src/views/Edit/index.tsx
const controller = handleAbortRef(abortOnSaveRef)
So a save that lands while the previous save's form-state request is still in flight aborts that request.
-
The aborted call resolves to { state: null } — note the abort is checked, not thrown, so nothing reaches the catch and nothing is logged:
// packages/ui/src/providers/ServerFunctions/index.tsx — getFormState
if (!remoteSignal?.aborted) {
const result = await serverFunction({ name: 'form-state', args: { ... } })
if (!remoteSignal?.aborted) {
return result
}
}
// ...
return { state: null } // no `livePreviewURL` key
-
The Edit view destructures livePreviewURL off that result and applies it unconditionally:
// packages/ui/src/views/Edit/index.tsx
const { livePreviewURL, previewURL, state } = await getFormState({ /* ... */ signal: controller.signal })
if (isLivePreviewEnabled && typeofLivePreviewURL === 'function') {
setLivePreviewURL(livePreviewURL) // undefined on an aborted request
}
-
The provider treats a falsy URL as "turn live preview off":
// packages/ui/src/providers/LivePreview/index.tsx — setLivePreviewURL
if (!incomingURL) {
setIsLivePreviewing(false)
}
if (incomingURL !== url) {
setAppIsReady(false)
setURL(incomingURL)
}
-
And LivePreviewToggler hides itself when the context URL is falsy, so the user loses the button as well as the pane:
// packages/ui/src/elements/LivePreview/Toggler/index.tsx
if (!livePreviewURL) {
return null
}
Nothing sets isLivePreviewing back to true, so the only recovery is reloading the page.
An aborted request means "this result is stale, ignore it", but step 3 reads it as "the server says there is no preview URL". Guarding the apply on a successful response — or having getFormState signal abortion distinguishably from a real empty result — would fix it.
Server-side is not the trigger. I instrumented our livePreview.url function: it returns a valid string on every invocation, including the ones immediately before the drop-out. buildFormState also only assigns res.livePreviewURL when truthy, so a url function returning null correctly leaves the previous URL in place. It is specifically the client applying an aborted result that clears it.
Link to the code that reproduces this issue
https://github.com/payloadcms/payload/tree/main/templates/website
The official website template reproduces this unmodified — it ships a 100ms autosave interval on both Pages and Posts:
https://github.com/payloadcms/payload/blob/main/templates/website/src/collections/Pages/index.ts#L133-L139
versions: {
drafts: {
autosave: {
interval: 100, // We set this interval for optimal live preview
},
schedulePublish: true,
},
That comment is worth flagging on its own: at 100ms the interval reliably breaks the feature it says it is optimising. Form-state on our site takes 20–460ms, so saves overlap more or less continuously.
Reproduction Steps
pnpx create-payload-app@latest -t website
- Open any Page in the admin and enable live preview.
- Add enough blocks that a form-state round-trip takes longer than the autosave interval — a heavy page makes this immediate, but it can also be forced with DevTools network throttling or by adding an artificial delay to a field hook.
- Type continuously into a field for a few seconds.
Expected: the live-preview pane stays open and keeps refreshing.
Actual: the pane closes on its own and the toggle button disappears. No console or server error. It stays gone until the page is reloaded.
A reliable tell that it has happened, if you are watching the server log: POST /api/payload-preferences/collection-<slug> fires — from the provider's useEffect on isLivePreviewing writing editViewType: 'default' — at exactly the point the preview's page refreshes stop.
Raising the interval (we moved to 2000) makes the race very unlikely but does not close it: any two saves that overlap a slow enough form-state still hit it.
Which area(s) are affected?
area: live-preview, area: ui, area: templates
Environment Info
Binaries:
Node: 22.22.3
npm: 10.9.8
pnpm: 10.29.3
Relevant Packages:
payload: 3.85.1
next: 16.2.6
@payloadcms/db-mongodb: 3.85.1
@payloadcms/live-preview: 3.85.1
@payloadcms/live-preview-react: 3.85.1
@payloadcms/next/utilities: 3.85.1
@payloadcms/ui/shared: 3.85.1
react: 19.2.6
react-dom: 19.2.6
Operating System:
Platform: darwin
Arch: arm64
Available memory (MB): 65536
Available CPU cores: 10
Describe the Bug
With live preview enabled and autosave on a short interval, the live-preview pane closes itself while the user is typing and its toggle button disappears with it. It does not come back without a full page reload. Nothing is logged — no client error, no server error, every request is a
200— which makes it very hard to attribute.The cause is an aborted form-state request being applied as though it were a successful response.
Every
onSavebegins by aborting the previous one:So a save that lands while the previous save's form-state request is still in flight aborts that request.
The aborted call resolves to
{ state: null }— note the abort is checked, not thrown, so nothing reaches thecatchand nothing is logged:The Edit view destructures
livePreviewURLoff that result and applies it unconditionally:The provider treats a falsy URL as "turn live preview off":
And
LivePreviewTogglerhides itself when the context URL is falsy, so the user loses the button as well as the pane:Nothing sets
isLivePreviewingback totrue, so the only recovery is reloading the page.An aborted request means "this result is stale, ignore it", but step 3 reads it as "the server says there is no preview URL". Guarding the apply on a successful response — or having
getFormStatesignal abortion distinguishably from a real empty result — would fix it.Server-side is not the trigger. I instrumented our
livePreview.urlfunction: it returns a valid string on every invocation, including the ones immediately before the drop-out.buildFormStatealso only assignsres.livePreviewURLwhen truthy, so aurlfunction returningnullcorrectly leaves the previous URL in place. It is specifically the client applying an aborted result that clears it.Link to the code that reproduces this issue
https://github.com/payloadcms/payload/tree/main/templates/website
The official website template reproduces this unmodified — it ships a 100ms autosave interval on both
PagesandPosts:https://github.com/payloadcms/payload/blob/main/templates/website/src/collections/Pages/index.ts#L133-L139
That comment is worth flagging on its own: at 100ms the interval reliably breaks the feature it says it is optimising. Form-state on our site takes 20–460ms, so saves overlap more or less continuously.
Reproduction Steps
pnpx create-payload-app@latest -t websiteExpected: the live-preview pane stays open and keeps refreshing.
Actual: the pane closes on its own and the toggle button disappears. No console or server error. It stays gone until the page is reloaded.
A reliable tell that it has happened, if you are watching the server log:
POST /api/payload-preferences/collection-<slug>fires — from the provider'suseEffectonisLivePreviewingwritingeditViewType: 'default'— at exactly the point the preview's page refreshes stop.Raising the interval (we moved to
2000) makes the race very unlikely but does not close it: any two saves that overlap a slow enough form-state still hit it.Which area(s) are affected?
area: live-preview,area: ui,area: templatesEnvironment Info