eclipsesource / eclipsesource/jsonforms

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

Open
#2,616 1 comment 0 reactions 0 assignees View on GitHub
react
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

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.