eclipsesource / eclipsesource/jsonforms
onChange debounce is never cancelled on unmount, so JsonForms emits after the form is gone
- Dominant language
- TypeScript
- Stars
- 2.7k
- Forks
- 424
- Avg merge
- 17d 8h
- Merged PRs (30d)
- 1
Description
### 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
```tsx
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 `` 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 ``.
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
❯
❯ 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:
```tsx
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
Contributor guide
Assessment
This issue has not been assessed yet.