Skip to content

onChange debounce is never cancelled on unmount, so JsonForms emits after the form is gone #2616

Description

@plmercereau

Describe the bug

JsonFormsStateProvider debounces its onChange emit by 10 ms but never cancels the pending timer when the component unmounts. A trailing edge scheduled just before unmount still fires afterwards, invoking the consumer's onChange when the form is already gone.

https://github.com/eclipsesource/jsonforms/blob/master/packages/react/src/JsonFormsContext.tsx#L258-L263

const debouncedEmit = useCallback(
  debounce((...args: any[]) => onChangeRef.current?.(...args), 10),
  []
);
useEffect(() => {
  debouncedEmit({ data: core.data, errors: core.errors });
}, [core.data, core.errors]);

There is no cleanup effect, and nothing calls debouncedEmit.cancel().

The debounce itself is well-motivated (#1150, and the comment above it explains the Chrome-autofill rerender chain). This report is only about the missing cancel on unmount.

Expected behavior

Once <JsonForms> unmounts, it should not call onChange again. A consumer that unmounts a form cannot reasonably expect a change callback for it up to 10 ms later.

Steps to reproduce the issue

  1. Render <JsonForms … onChange={spy} />.
  2. Fire a change on any control.
  3. Unmount within the 10 ms debounce window.
  4. Wait > 10 ms — spy receives one more call, after unmount.

Impact we hit

Two flavours, one benign and one not:

  • In the browser: a state update and a parent callback up to 10 ms after unmount. Usually harmless, but it is a real "write after teardown" — for us it meant a parent's onDataChange firing for a form the user had already navigated away from.
  • In tests (the expensive one): under React 19 + jsdom, the late emit reaches React's dispatchSetState after the test environment is torn down, throwing an unhandled ReferenceError: window is not defined. Vitest reports this as an unhandled error, so the run fails while every test passes — a red CI with a green test summary, which is very hard to attribute.
ReferenceError: window is not defined
  ❯ resolveUpdatePriority  react-dom-client.development.js
  ❯ dispatchSetState       react-dom-client.development.js
  ❯ <consumer onChange handler>
  ❯ packages/react/lib/jsonforms-react.cjs.js:217
  ❯ invokeFunc → trailingEdge → Timeout.timerExpired   lodash/debounce.js

Consumers can work around it by guarding their handler with an isMounted ref (that is what we did), but every consumer has to know this independently.

Suggested fix

Cancel on unmount:

useEffect(() => () => debouncedEmit.cancel(), [debouncedEmit]);

If flushing is preferred over dropping, debouncedEmit.flush() would at least run it while the tree is still alive — though for the jsdom case dropping is the safer default.

(Side note, unrelated to the bug: useCallback(debounce(...), []) constructs a new debounced function on every render and discards it, so the linter's exhaustive-deps rule is being worked around; useMemo/useRef would express the intent more directly.)

Environment

  • JSON Forms: 3.8.0 (latest) — also present on master and in 3.9.0-alpha.1, checked 2026-08-10
  • Framework: React 19.2
  • Renderer set: custom (Mantine) — but this is framework-agnostic, it is in @jsonforms/react core

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