Skip to content

EPBDS-16211: a Select outside a table no longer renders again on each change of its document - #212

Merged
AlexSamBY merged 3 commits into
masterfrom
select-options
Oct 12, 2026
Merged

AlexSamBY merged 3 commits into
masterfrom
select-options

Conversation

@AlexSamBY

Copy link
Copy Markdown
Member

What

A Select outside a table rendered again on every change of its document, a blur included. The mapper mapped its options through its mapOptions on every render (mapProps), so the Select was handed a new array each time and its memoised host saw a changed prop. toOpenLConfig gives a named Select with no onChange of its own a mapOptions (by index) whenever its options are not a declared option list. A Select declared with a mapOptions has one too. So many Selects did.

  • The mapped options are kept. keptMappedOptions in mapper.tsx keeps them in a WeakMap on the options array, then by the mapping, then by debug, for as long as they are mapped from the very same options by the very same mapping.
  • Why the cache is safe. Both inputs come from the document's meta, which is cached per document state (meta in rules.tsx) and built anew when the state changes. A meta built anew is mapped anew.
  • A field with a mapping and no options gets one shared empty list. An empty list maps to an empty list, so it keeps nothing.
  • Sharing the array is safe. Nothing that takes it changes it: I searched Dropdown, Listbox, Select and modules/form for in-place changes. Dropdown's cascading reset keys on the options' values (optionValuesKey), not on the array.
  • The stable-value Select's onChange wrapper, 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 with keptHandler from 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 Dropdown that is not memoised.

"A later edit" is the second form.change of another field. The first one changes the document's state (its canSave), which builds the meta anew and renders every Select once, mapped or not, before and after.

A form (Col) of 100 Selects with mapOptions: { text, value } and a text field, two runs each:

before after
renders: mount / a later edit / a blur 300 / 100 / 100 200 / 0 / 0
a later edit 16 / 22 ms 7 / 5 ms
a blur 23 / 27 ms 14 / 10 ms
the mount 196 / 155 ms 167 / 146 ms

One Select next to a text field, over a mount, three edits and a blur of the text field:

  • Before: with mapped options, by value or by index, it rendered 3 times at mount, then once for each later edit and once for the blur.
  • After: 2 times at mount, then none, as a Select with plain options does.

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] in transforms.ts) builds the cell's meta anew for every row on every render: a cloneDeep of the cell's definition, then metaToFunctions and metaToProps. 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.js

Selects mapped by value and by index, beside a plain one, run on every React leg:

mapper.fields-tables-popups.test.js

These sit beside the stable-value Select test:

  • Kept: the same array while it is mapped from the same options by the same mapping.
  • Mapped again: from other options, by another mapping, or in debug mode.
  • No options: one shared empty list.
  • The onChange wrapper: the stable-value onChange is 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 toOpenLConfig does, in two comments and the changelog; lint:js, typecheck and build were 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

…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.
Base automatically changed from field-handler-wrappers to master October 12, 2026 11:36
@AlexSamBY
AlexSamBY merged commit 940c69e into master Oct 12, 2026
7 checks passed
@AlexSamBY
AlexSamBY deleted the select-options branch October 12, 2026 11:48
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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant