payloadcms / payloadcms/payload

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

Open
#17,779 2 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

`area: live-preview` `area: templates` `area: ui`
Dominant language
TypeScript
Stars
44.8k
Forks
4.2k
Avg merge
2d 21h
Merged PRs (30d)
53

Description

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

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with getFormState in packages/ui/src/providers/ServerFunctions/index.tsx and its caller in packages/ui/src/views/Edit/index.tsx, then trace setLivePreviewURL in packages/ui/src/providers/LivePreview/index.tsx and LivePreviewToggler. Reproduce using templates/website with the 100ms autosave interval while typing into a heavy page. Done means an aborted form-state request no longer closes the live-preview pane or hides its toggle, while genuine preview updates still work.

Written by the indexing model from the issue text.

Assessment

Tech stack
nextjs, react, typescript
Domain
frontend
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
74/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.