Repository navigation
EPBDS-16211: a Select outside a table no longer renders again on each change of its document - #212
Merged
Merged
Conversation
…gain whenever another field changes mapper.tsx wrapped a field's own handler in a new function on every render for `autoSubmit` (its onChange) and for `removable` (its onClickIcon). A field takes them as props, so the field host's memo saw a changed prop on every render of the document, and such a field rendered again on every change in it, a blur included, and three times at mount. Each wrapper is now kept per document and field name (`keptHandler`) while everything it calls stays the same, compared by identity: the own onChange and the instance's cached submit for autoSubmit; the form, the name, onRemove, whether it submits, the onChange and onClickIcon for removable. What they do is unchanged; removable reads the name and the onChange when it is made rather than when it is clicked, which is what the render's `input` held then. The third wrapper of the kind, a stable-value Select's onChange, is left as it is: measured, it is not what re-renders a Select. Its `options` array is built anew, on a change of any value without mapOptions and on every render with them, and the wrapper closes over that array. field-handlers-budget.test.js pins it beside a plain field: renders at mount as without the handlers (1 each; were 3), none for another field's edit or blur (were 1 each), and that the fields still submit on a change and clear. Measured in jsdom on React 18 with 200 rows and another column edited, two runs: removable 200 renders per edit -> 0, 50/51 -> 31/28 ms; autoSubmit 200 -> 0, 47/47 -> 26/27 ms.
… change of its document mapper.tsx mapped a field's `options` through its `mapOptions` on every render, so a Select was handed a new array each time and its memoised host rendered again on every render of the document: on each change of any other field, a blur included. toOpenLConfig gives a named Select with no onChange of its own a mapOptions whenever its options are not a declared option list, so many Selects did. The mapped array is now kept (`keptMappedOptions`, a WeakMap on the options array, then the mapping) while it is mapped from the very same options by the very same mapping. Both come from the document's meta, which is built anew when the document's state changes, and a meta built anew is mapped anew. Nothing that takes the array changes it, and Dropdown keys its reset on the options' values. A field with a mapping and no options gets one shared empty list. The stable-value Select's onChange wrapper, which searches those options, is now kept like the handlers autoSubmit and removable add (#211), since what it closes over is stable now. Not changed: a Select in a table cell still renders with its table. The cell's renderer (transforms.ts) builds the cell's meta anew, options included, for every row on every render. select-options-budget.test.js pins it: Selects with mapped options render at mount as one without (2; were 3) and not at all for another field's later edit or blur (were 1 each); and they are handed the options their mapping makes. Measured in jsdom on React 18, a form of 100 such Selects, two runs: renders 300/100/100 -> 200/0/0, a later edit 16/22 -> 7/5 ms, a blur 23/27 -> 14/10 ms.
AlexSamBY
added a commit
that referenced
this pull request
Oct 12, 2026
… render of its table (#213) ## What A field in a table cell rendered again on every render of its table whenever it took an object or a function as a prop. The table calls its `renderCell` (a list calls its `renderItem`) for every row on every render. The renderer that `metaToProps` makes for a `render*` field definition (`transforms.ts`) built the cell anew on every call: 1. a `cloneDeep` of the definition; 2. its handlers, through `metaToFunctions`, where `getFunctionFromString` makes a new closure for a handler with arguments, such as the `setState,<name>` that `toOpenLConfig` gives every named Select; 3. its props, through another `cloneDeep` and `metaToProps`. So a Select's options were new on every render, and so was such a handler. #212 kept a Select's mapped options for as long as its options array stays the same object; in a cell, it never did. A row's cell is now kept (`BuiltCell`: one map per renderer, keyed by row index) for as long as the row is called with the very same things: - **the value** (`Object.is`), the row index, and the path the cell is given; - **the caller's props**, compared key by key, since the table passes a new `{className, style, expanded}` on each call; - **the column label**, since the table passes a new `{label}` on each call; - **the caller methods** that stand in for handler names a definition gives as strings, compared key by key. The table's methods are new on every render, so such a cell is built again every time, as before. What the build reads besides those belongs to the meta: the definition, the document's data, its config and handler registry, and the instance's state through `{state.x}` and the currency. The meta is cached per document state and built anew, with these renderers and their maps, when the state changes (`meta` in `rules.tsx`). The element is still made anew from the kept cell on every call. The cell therefore still renders, and whatever it reads at render (a `showIf`, a field's value through final-form) is read as before. ### A handler's `mapArgs` were filled in place `mapFunctionArgs` filled the objects and arrays of a handler's `mapArgs` in place. From its second call on, a handler therefore received its first call's values instead of its own. This already affected the main meta, whose handlers live as long as the meta does. A cell's handlers now live as long as the row's cell, which would also have made it visible on a row's second click. It now fills a copy. The existing test of the mapping used such an object (`{ code: '{1.code}' }`) but called the handler only once. ## Measured jsdom, React 18. Renders are counted through a wrapper of `Dropdown` that is not memoised. "A later edit" is the second `form.change` of a text cell. The first one changes the document's state (its `canSave`), which builds the meta anew and renders every Select once, before and after. Two runs each: | 200 rows, a Select column and a text column | before | after | |---|---|---| | Select renders: mount / a later edit / a blur | 600 / 200 / 200 | 400 / 0 / 0 | | a later edit | 62 / 51 ms | 26 / 28 ms | | a blur | 57 / 62 ms | 31 / 31 ms | | the mount | 618 / 625 ms | 589 / 596 ms | A 200-row table of two text columns measured about the same before and after: an edit took 24 to 25 ms and a blur 25 to 30 ms. Its fields take no object, and building its cells was not where an edit spent its time. Rendered output is unchanged. The 38 snapshots of the examples' DOM contract, and every other suite, pass as they were. ## Contracts ### `src/library/__tests__/table-cells-budget.test.js` A 50-row table with Select, text and button columns, run on every React leg: - **Renders:** each Select renders twice at mount (was 3 times), and none renders for another column's later edit or blur (all 50 did, for each). On master's `transforms.ts` this fails with 150 / 50 / 50. - **Handlers:** each row's button opens the popup of its own row, render after render: "Row 2", then after an edit "Row 4", then "Row 2" again. This passes on master too. ### `transforms.rendering-and-actions.test.js` - **`mapArgs`:** a handler's argument templates are filled afresh on every call. This fails on master, where the second call got `{ code: 'first' }` and `'a'`. - **Kept cells:** 1. the same cell while the row is called with the same things; 2. built again for another value, index, props or column label; 3. props told apart by their keys, and from no props at all; 4. one cell per row; 5. built again when a caller method standing in for a handler is another. The first and the fourth fail on master. `transforms.ts` keeps its 100 % coverage threshold on all four measures. ## Gates All 22 gates and the manifest contract exit 0 on HEAD, on Node 24.14.0, with no jest worker dying of SIGSEGV, the browser legs included (Chromium on React 18 and 19, and the packed tarball on React 16.14 to 19). The suite has 2866 tests: all pass on React 18 and 19; on 16.14 and 17, 2862 pass and #207's 4 transition tests are skipped, as before. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
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.
What
A Select outside a table rendered again on every change of its document, a blur included. The mapper mapped its
optionsthrough itsmapOptionson every render (mapProps), so the Select was handed a new array each time and its memoised host saw a changed prop.toOpenLConfiggives a named Select with noonChangeof its own amapOptions(by index) whenever its options are not a declared option list. A Select declared with amapOptionshas one too. So many Selects did.keptMappedOptionsinmapper.tsxkeeps them in aWeakMapon the options array, then by the mapping, then bydebug, for as long as they are mapped from the very same options by the very same mapping.metainrules.tsx) and built anew when the state changes. A meta built anew is mapped anew.Dropdown,Listbox,Selectandmodules/formfor in-place changes.Dropdown's cascading reset keys on the options' values (optionValuesKey), not on the array.onChangewrapper, which EPBDS-16211: a field with autoSubmit or removable no longer renders again whenever another field changes #211 left because it closes over these options, is now kept withkeptHandlerfrom EPBDS-16211: a field with autoSubmit or removable no longer renders again whenever another field changes #211, since its options are stable now.Measured
jsdom, React 18. Renders are counted through a wrapper of
Dropdownthat is not memoised."A later edit" is the second
form.changeof another field. The first one changes the document's state (itscanSave), which builds the meta anew and renders every Select once, mapped or not, before and after.A form (
Col) of 100 Selects withmapOptions: { text, value }and a text field, two runs each:One Select next to a text field, over a mount, three edits and a blur of the text field:
Not changed: a Select in a table cell
A Select in a table cell still renders with its table. In a 200-row table it renders 200 times for each later edit of another column, before and after, with an edit taking 49 to 78 ms either way over two runs each.
The cell's renderer (
meta[attribute]intransforms.ts) builds the cell's meta anew for every row on every render: acloneDeepof the cell's definition, thenmetaToFunctionsandmetaToProps. The options it maps are therefore new objects each time, and no cache keyed on them can hold. Keeping the built cell of an unchanged row is a change of its own, to the table's renderer.Contracts
src/library/__tests__/select-options-budget.test.jsSelects mapped by value and by index, beside a plain one, run on every React leg:
mapper.tsxthis test fails with those numbers.mapper.tsxtoo.mapper.fields-tables-popups.test.jsThese sit beside the stable-value Select test:
onChangewrapper: the stable-valueonChangeis kept while it calls the same handler.Three of these fail on #211's
mapper.tsx.Base
This is built on #211 and uses its
keptHandler, so it targets #211's branch and its diff shows only this change. Once #211 is merged, GitHub retargets it to master, and I will bring it up to date with master before it is merged.Gates
All 22 gates and the manifest contract exit 0 on HEAD before a last wording fix, on Node 24.14.0, with no jest worker dying of SIGSEGV. The fix replaced an unverifiable "most Selects" with what
toOpenLConfigdoes, in two comments and the changelog;lint:js,typecheckandbuildwere run again after it and exit 0. The suite has 2858 tests: all pass on React 18 and 19; on 16.14 and 17, 2854 pass and #207's 4 transition tests are skipped, as before.🤖 Generated with Claude Code