프로젝트 편집 화면에서 활동 유형이나 제목을 한 글자 치면 화면이 통째로 죽었다 —
"Cannot read properties of null (reading 'value')".
setState 업데이터는 핸들러가 끝난 뒤 다음 렌더에 실행된다. 그때 React 는 이미
`event.currentTarget` 을 null 로 되돌려 놓았으므로, 업데이터 안에서 그것을 읽으면
반드시 터진다. 타입 검사도 lint 도 이것을 잡지 못했고, 첫 입력에서야 드러났다.
값은 핸들러가 도는 동안 지역 변수로 꺼내 두고 업데이터에는 그 값을 넘긴다.
같은 실수를 다시 못 하도록 lint 규칙을 세운다: `setXxx(...)` 에 곧바로 넘기는 화살표
함수 안에서는 `currentTarget` 을 읽을 수 없다. 처음 쓴 선택자는 동기 핸들러까지 잡아
(map 콜백, 즉시 호출되는 update 등 아홉 자리) 너무 넓었으므로, 실제로 위험한 setter
업데이터만 겨냥하도록 좁혔다. 결함을 되돌려 규칙이 잡는 것을 확인했다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
`node:assert` builds its `AssertionError` eagerly, running `util.inspect`
over both operands with `depth: 1000`, `getters: true` and
`maxArrayLength: Infinity`. A React-rendered DOM element carries
`__reactFiber$*` / `__reactProps$*` as own enumerable properties, and that
fiber graph re-expands once per traversal path, so inspecting a single
rendered element allocates without bound. Measured on the Asset Library
heading: depth 6 = 1.3MB, 8 = 7.8MB, 10 = 36MB, 12 = 135MB -- at Node's
depth 1000 the worker dies before any `AssertionError` exists.
The damage is not the crash, it is the disguise. Equality assertions only
inspect their operands on failure, so these sites stayed invisible while
green and detonated exactly when the behaviour they guard regressed --
reporting as `worker exited unexpectedly` with a truncated count
(`8 passed (13)`) and no failing test named. Breaking the delete-path focus
restoration in `asset-library.tsx` reproduced it: 29.5GB anon-rss and the
system OOM killer, or a V8 heap abort in 3s under a 512MB cap. The same
regression now fails in 1.15s with `expect(element).toHaveFocus()` naming
both the expected heading and the `<body>` that took focus instead.
`expect` is not affected -- vitest prints and diffs DOM nodes through
pretty-format's DOM plugin, which reads tag/attributes/children and never
touches the fiber -- so every unsafe site converts to a matcher:
`toHaveFocus()` for the three focus comparisons, `not.toBeInTheDocument()`
for the sixteen `assert.equal(queryBy..., null)` absence checks, which are
equally lethal (proved separately: element-vs-null inspects the element).
Three layers so this cannot come back:
- the 20 live sites in asset-library/asset-picker now use matchers;
- `test-assertion-boundary/no-element-operand-equality` fails `pnpm lint`
when a DOM-element expression reaches `node:assert` equality, resolving
local bindings and exempting the forms that cannot fail with an element
in hand (`assert.notEqual(el, null)`, `el.textContent`);
- a 2048MB worker old-space ceiling in `vitest.config.ts` bounds any future
runaway to a legible `Reached heap limit` abort in seconds instead of an
OOM-killed machine (heaviest suite peaks near 1.3GB RSS).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>