Skip to content

Flow editor: the node-detail push is debounced without maxWait, so the wrapper's own 4000 ms autosave cap can never fire while the operator keeps typing #1154

Description

@rawdaymx

Summary

The flow editor saves through two chained debouncers:

Where Debounce
editor → canvas nodes/editor.tsx:311-314createDebouncedFn(pushNodeDetailsToFlow, 700) 700 ms, no maxWait
canvas → server react-flow-wrapper.tsx:162-171useDebouncedCallback(…, 1500, 4000) 1500 ms, maxWait 4000

The second one is deliberately bounded: whatever happens, a pending draft write goes out within 4 s. But it only runs when nodes changes, and nodes only changes when the first one fires. Because the first has no bound, continuous typing keeps rescheduling it forever, the canvas never sees the text, and the 4000 ms cap downstream never gets the chance to mean anything.

Measured: 10 s of typing at one keystroke per 200 ms produces zero pushes, so zero autosaves. There is no upper bound on that window — it is however long the operator types without a 700 ms gap.

There is a second, independent mechanism in the same area that we think is worth knowing about, described below: the pushToFlow.flush() on editor unmount does not rescue a page-level navigation, because the value it writes goes into React Flow's controlled-mode batch queue, which is drained by a layout effect that needs a render the unmount never provides.

Environment

Measured against upstream/main @ 96032013e2313891f816fcf8400412b44cf158a2
How vitest probes against the repository's own createDebouncedFn, plus reading @xyflow/react@12.11.0 in node_modules. Not measured against a deployed instance
Node / pnpm / vitest v24.11.0 / 10.33.2 / 4.1.8
Date 2026-09-11

Defect A — the unbounded push

createDebouncedFn is pure and framework-free, so this is measurable directly. Three runs, delay = 700, 10 s of simulated typing:

CONTROL   one keystroke every 800 ms, no maxWait  ->  11 invocations
DEFECT    one keystroke every 200 ms, no maxWait  ->   0 invocations
FIXED     one keystroke every 200 ms, maxWait 2000 -> 4 invocations

The control is the point: at 800 ms — slower than the delay — the debouncer behaves normally and fires 11 times. At 200 ms it never fires at all. The zero is not an artefact of the harness.

The chain that then does not run:

  • pushNodeDetailsToFlow (editor.tsx:291-302) is the only writer from the form to the canvas; it calls updateNodeData.
  • react-flow-wrapper.tsx:324-333 is the autosave effect; it depends on [handleChanges, nodes, edges, onDraftDirtiedChange] and early-returns when the serialized content is unchanged.
  • No push ⇒ no nodes change ⇒ the effect early-returns ⇒ handleChanges — the one carrying maxWait: 4000 — is never called.

Defect B — the unmount flush does not cover page-level navigation

editor.tsx:333-338 looks like the safety net for the above:

useEffect(
  () => () => {
    pushToFlow.flush()
  },
  [pushToFlow],
)

flush() itself works. It is correct and already pinned by your own test (packages/ui/src/hooks/__tests__/create-debounced-fn.test.ts:52-65: "flush() invokes the pending callback immediately with the last args"), and during continuous typing the timer is always pending, so flush() does invoke with the latest value. We verified that with a probe as well.

The problem is what updateNodeData does with it. In @xyflow/react@12.11.0, controlled mode does not write through:

  • updateNodeData (dist/esm/index.js:1182) ends in batchContext.nodeQueue.push(...) (:1213).
  • useQueue (:898-922) stores items in a ref, and bumps a serial state to get React to process them. The library's own comment says so: "Because we're using a ref above, we need some way to let React know when to actually process the queue. We increment this number any time we mutate the queue, creating a new state to trigger the layout effect below."
  • The queue is drained in useIsomorphicLayoutEffect(..., [serial]) (:916-922), which calls nodeQueueHandler, which calls onNodesChange?.(changes) (:971) — the prop the wrapper maps to its setNodes.

So the value only reaches the wrapper's nodes after BatchProvider re-renders. When the operator closes the node panel, the provider is still mounted and that render happens. When the operator navigates away from the flow page, ReactFlowProvider, BatchProvider, the wrapper and the editor unmount in the same commit — the flush pushes into a queue nobody will drain.

This is not inference on our part. On our fork we wrote a deterministic reproduction that mounts the real ReactFlowWrapper and the real node editor as siblings and exercises both unmount orders with fake timers and a stubbed draft action. With maxWait already applied — so defect A was not the cause — it failed 18 of 46 scenes, separating the two mechanisms explicitly (its M1 is "leaving with the push pending, 0–699 ms, writes nothing"). After we changed the exit path it passes 46/46.

We are not proposing our solution here, because the shape of the right fix is yours to choose. But we are happy to contribute the reproduction test alone — it is one self-contained file and it turns this from an argument into a red bar.

Why we think this is a defect rather than the intended design

For A, the strongest argument is internal consistency: handleChanges in the same feature is created with useDebouncedCallback(…, 1500, 4000). Someone decided the draft write needs a ceiling. The push that feeds it has none, which makes that ceiling unreachable under exactly the input pattern it was meant to guard — sustained typing. And createDebouncedFn already takes maxWait (packages/ui/src/hooks/create-debounced-fn.ts:20, read at :26-29) and already implements it (:56-63, clamping the next timeout with Math.min(delay, maxWaitRemaining)). The capability is present, tested, used elsewhere in this very feature, and simply not passed at this call site.

For B, the useEffect cleanup calling flush() shows the intent was for a pending edit to survive the editor going away. It does survive the panel closing. It does not survive the page going away, and the difference is invisible from the call site.

Suggested fix

A — one argument, using the parameter the helper already accepts:

   const pushToFlow = useMemo(
-    () => createDebouncedFn(pushNodeDetailsToFlow, 700),
+    () => createDebouncedFn(pushNodeDetailsToFlow, 700, 2000),
     [pushNodeDetailsToFlow],
   )

2000 ms stays under the wrapper's own 4000 ms cap, so the two debouncers compose into a bounded worst case instead of an unbounded one. We are happy to open this PR.

B — the fix has to get the pending value to the save path without depending on a render that is not going to happen. Reading the live form at unmount rather than routing through updateNodeData is one way; there are others, and you know this code better than we do. What we can usefully contribute is the failing test.

What we did not verify

  • We did not reproduce either defect against a running instance of the app. Defect A is measured on the pure helper; defect B is read from @xyflow/react's source plus the red→green test on our fork. Neither is an observation of a browser losing an operator's text.
  • We built a standalone probe for the unmount ordering and it did not produce a trustworthy result — the panel-close case behaved unexpectedly in two attempts and we could not resolve whether that was the mechanism or an act() timing artefact. We are reporting the xyflow source reading and the fork test instead, and explicitly not that probe.
  • The 18-of-46 figure comes from our fork's test at a commit where our own earlier maxWait change was already applied. It is evidence that B is independent of A; it is not a measurement of stock upstream/main.
  • We did not check whether other createDebouncedFn call sites in the repo have the same unbounded shape. This is not searched, not proven absent.

Related issues

We searched open and closed issues and PRs for autosave, debounce autosave, flow editor lost changes and maxWait; nothing describes this. #702 (add undo/redo history to the flow editor) is the only maxWait hit and is where the helper's option came from.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions