Skip to content

Live preview closes itself mid-edit: aborted form-state request clears livePreviewURL #17779

Description

@TrimiB

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.

  1. 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.

  2. 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
  3. 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
    }
  4. 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)
    }
  5. 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

  1. pnpx create-payload-app@latest -t website
  2. Open any Page in the admin and enable live preview.
  3. 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.
  4. 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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions