Forensic render-perf & cleanup audit (post §4 / §16) — findings report#320
Closed
Copilot wants to merge 1 commit into
Closed
Forensic render-perf & cleanup audit (post §4 / §16) — findings report#320Copilot wants to merge 1 commit into
Copilot wants to merge 1 commit into
Conversation
Copilot
AI
changed the title
[WIP] Run forensic analysis for render performance bugs
Forensic render-perf & cleanup audit (post §4 / §16) — findings report
Jun 4, 2026
Owner
|
Applied to the wrong branch |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Per the issue's instruction ("just want a thorough report… no code changes"), this PR contains no source modifications. It records the forensic survey of
src/against the anti-pattern checklist in the issue, ranked by likely impact and triage cluster.Headline findings
React.memoanywhere (grep -E 'React\.memo|memo\(' src→ 0 hits). Combined with the next item, every parent re-render walks the entire JSON tree.value={…}is a fresh object every render, with inline closures inside it. Even addingReact.memowould be neutralised by the prop-bag spreads ({...props}) cascading downCollectionNode/ValueNodeWrapper.setTimeoutcleanups), independently fixable.Real, high-impact (
Cluster A — context refactor)src/contexts/TreeStateProvider.tsx:115-150— inlinevalue={{…}}, inlinesetCollapseState/setPreviouslyEditedElement/setTabDirectionclosures, andpreviouslyEdited.current/tabDirection.currentread during render (concurrent-mode hazard).src/contexts/ThemeProvider.tsx:67-83— inlinevalue,compileStyles(theme, docRoot)runs every render (multi-pass merge), anddocRoot.style.setProperty(...)is a side effect during render.src/JsonEditor.tsx:81-95— defaultstranslations = {},customNodeDefinitions = [],collapseClickZones = ['header', 'left'], etc. allocate fresh literals per render and feeduseMemodeps that consequently never hit cache. (A prior repo memory claimed module-scopedEMPTY_*constants existed for these — verified false, downvoted.)src/JsonEditor.tsx:117-126+src/hooks/useCommon.ts:41—nodeDatarebuilt every render and threaded through every child as a prop.src/JsonEditor.tsx:131-270—onEdit/onDelete/onAdd/onMove/handleEditnotuseCallback; flow intootherProps(line 351-403) which is itself a fresh object spread into the recursive<CollectionNode {...props} />.src/ValueNodeWrapper.tsx:160-164—setStateduring render targeting a different component (setCurrentlyEditingElementfrom insideValueNodeWrapperrender). Forbidden under concurrent rendering.Missing cleanups (
Cluster B — timer leaks)src/contexts/TreeStateProvider.tsx:129—setTimeout(() => setCollapseState(null), 2000)untracked, no cleanup, multiple in flight under rapid toggles.src/hooks/useCommon.ts:51-57—showError'ssetTimeout(() => setError(null), errorMessageTimeout)is fire-and-forget; consecutive errors race.src/hooks/useCollapseTransition.ts:86-102— the 5 mssetMaxHeight(0)timer is untracked, and the longer animation timer (timerId.current) has no unmount-time cleanup.Test pin pattern (per issue):
jest.useFakeTimers()+ mount + trigger + unmount +expect(jest.getTimerCount()).toBe(0).Real but lower-impact
src/CollectionNode.tsx:293-298—Object.entries(data).map(...)+sortevery render; O(n log n) per parent re-render.src/CollectionNode.tsx:73/src/ValueNodeWrapper.tsx:66—useState(jsonStringify(data))runs the serializer eagerly; should use lazy initialiser form.src/CollectionNode.tsx:316-345— array children keyed by numeric key (== index). Causes per-node state (e.g.StringDisplay.isExpanded) to migrate to the wrong row on insert/delete/move. Author already mitigates this for cross-array moves viaarr_${originalKey}but not for in-place renders.src/ValueNodeWrapper.tsx:92-110—updateValueuseCallback([onChange])closes overvalue/nodeData.fullData; classic stale-closure vector foronChange.src/ValueNodeWrapper.tsx:128-134—allDataTypesrebuilt every render; downstreamallowedDataTypesuseMemokeys onnodeData(new each render) so the cache never hits.Already correct (worth a regression test, not a fix)
src/ValueNodes.tsx:294-329useKeyboardListener— listener add/remove balance viacurrentListenerref pattern is sound. Pin with awindow.addEventListenerspy.src/JsonEditor.tsx:110-115— eslint-disable comment is technically wrong (setCurrentlyEditingElementis the rebuilt wrapper, not a stable React setter), but excluding it from deps is the correct behaviour.Suggested triage / PR shape
setState) — coupled, ship together.React.memopass) — only meaningful after A lands.compileStylesmemo, lazy initialisers, stable list keys) — small independent wins.Out of scope, per issue: 10k-node wall-clock benchmark.
Memory hygiene
Two repo memories verified against current code and downvoted:
JsonEditor uses module-scoped EMPTY_* constants…— defaults are inline literals; no such constants.EditingProvider.startEdit()…—EditingProvider.tsxdoes not exist; equivalent logic is inTreeStateProvider.updateCurrentlyEditingElement.