diff --git a/src/core/engine/__tests__/mapper.fields-tables-popups.test.js b/src/core/engine/__tests__/mapper.fields-tables-popups.test.js index a7c26ce2..10a05da1 100644 --- a/src/core/engine/__tests__/mapper.fields-tables-popups.test.js +++ b/src/core/engine/__tests__/mapper.fields-tables-popups.test.js @@ -113,6 +113,50 @@ describe('mapper behavior contracts', () => { expect(onChange).toHaveBeenNthCalledWith(2, 'unknown') }) + describe('the options a mapOptions maps, kept', () => { + const records = [{ id: 'draft', label: 'Draft' }, { id: 'bound', label: 'Bound' }] + const select = (extra = {}) => ({ view: FIELD.TYPE.SELECT, name: 'status', options: records, mapOptions: { text: 'label', value: 'id' }, ...extra }) + /** The field's props, one render of its document later. */ + const fieldOf = props => { + const { unmount } = renderMapped(props) + const field = lastFieldProps() + unmount() + return field + } + + it('are the same array while they are mapped from the same options by the same mapping', () => { + const props = select({ instance: baseInstance() }) + const [first, second] = [fieldOf(props).options, fieldOf(props).options] + expect(second).toBe(first) + expect(first).toEqual([{ text: 'Draft', value: 'draft' }, { text: 'Bound', value: 'bound' }]) + }) + + it('are mapped again from other options, by another mapping, or in debug mode', () => { + const props = select({ instance: baseInstance() }) + const kept = fieldOf(props).options + const others = [ + fieldOf({ ...props, options: records.map(record => ({ ...record })) }).options, + fieldOf({ ...props, mapOptions: { text: 'label', value: 'id' } }).options, + fieldOf({ ...props, debug: true }).options, + ] + expect(others.map(options => [options === kept, options])).toEqual(others.map(() => [false, kept])) + }) + + it('are an empty list, the same one, for a field with no options', () => { + const props = select({ instance: baseInstance(), options: undefined }) + const [first, second] = [fieldOf(props).options, fieldOf(props).options] + expect([first, second === first]).toEqual([[], true]) + }) + + it('keep the stable-value onChange that searches them, while it calls the same handler', () => { + const onChange = () => {} + const props = select({ instance: baseInstance(), onChange }) + const [first, second] = [fieldOf(props).onChange, fieldOf(props).onChange] + const another = fieldOf({ ...props, onChange: () => {} }).onChange + expect([second === first, another === first]).toEqual([true, false]) + }) + }) + it('coordinates removable fields with form clearing and debounced auto-submit', () => { jest.useFakeTimers() const onChange = jest.fn() diff --git a/src/core/engine/mapper.tsx b/src/core/engine/mapper.tsx index c73e1dc3..5688cf08 100644 --- a/src/core/engine/mapper.tsx +++ b/src/core/engine/mapper.tsx @@ -119,6 +119,42 @@ function keptHandler (instance: unknown, slot: string | undefined, calls: unknow return handler } +/** + * THE OPTIONS A `mapOptions` MAPS, kept while they are mapped from the same options by the same mapping + * (2026-10-12). + * + * The mapper mapped a field's `options` through its `mapOptions` on every render, so the field was + * handed a new array each time. A Select, which takes them as a prop, rendered again on every render of + * its 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 while the options array and the + * `mapOptions` are the very ones it was mapped from. Both come from the document's meta, which is built + * again when the document's state changes (`meta` in `rules.tsx`), and a meta built again is mapped + * again. The array is shared between those renders: nothing that takes it changes it, and `Dropdown` + * keys its reset on the options' values, not on the array. + */ +const keptOptions = new WeakMap>() + +/** What a field with `mapOptions` and no `options` is handed: an empty list maps to an empty list, whatever the mapping. */ +const NO_OPTIONS: unknown[] = [] + +/** `mapProps(options, mapOptions)`, or the array it gave before for the very same options and mapping. */ +function keptMappedOptions (options: unknown, mapOptions: any, debug: unknown): unknown[] { + if (!options) return NO_OPTIONS + const source = options + if (typeof source !== 'object') return mapProps(source, mapOptions, { debug: debug as boolean }) + let byMapping = keptOptions.get(source) + if (!byMapping) { + byMapping = new Map() + keptOptions.set(source, byMapping) + } + const kept = byMapping.get(mapOptions) + if (kept && kept.debug === debug) return kept.options + const mapped = mapProps(source, mapOptions, { debug: debug as boolean }) + byMapping.set(mapOptions, { debug, options: mapped }) + return mapped +} + /** `validate` with a number field's bounds checked first, the same function while all three stay the same. */ function boundedValidator (instance: unknown, validate: FieldValidator | undefined, min: any, max: any): FieldValidator { const bounded: FieldValidator = (value: any) => { @@ -611,7 +647,7 @@ const RenderComponent = ({ if (items.length) props.children = items.map(Render) const { mapOptions, removable, autoSubmit, expanded: _1, ...input } = props const { readonly, disabled } = data || {} - if (mapOptions) input.options = mapProps(input.options || [], mapOptions, { debug }) + if (mapOptions) input.options = keptMappedOptions(input.options, mapOptions, debug) // Resolve Input name dynamic path // relativePath should be used to form input name even when relativeData === false (e.g., in popups) @@ -660,10 +696,11 @@ const RenderComponent = ({ if (view === FIELD.TYPE.SELECT && isObject(mapOptions) && mapOptions.value && mapOptions.value !== '{index}' && input.onChange) { const stableOnChange = input.onChange const mappedOptions = input.options || [] - input.onChange = (value: unknown) => { + // Kept like the handlers `autoSubmit` and `removable` add: the options it searches are the kept ones above. + input.onChange = keptHandler(instance, handlerSlot('selectIndex', input.name), [stableOnChange, mappedOptions], () => (value: unknown) => { const idx = mappedOptions.findIndex((o: any) => String(o.value) === String(value)) return stableOnChange(idx >= 0 ? String(idx) : value) - } + }) } // Auto submit on changes diff --git a/src/demo/markdowns/changelog.md b/src/demo/markdowns/changelog.md index 4c674c4c..dec7dce4 100644 --- a/src/demo/markdowns/changelog.md +++ b/src/demo/markdowns/changelog.md @@ -866,6 +866,12 @@ Nothing to do for these: #### Fixes +- **A Select outside a table no longer renders again on each change of its document.** Its options were + mapped through its `mapOptions` on every render, and many Selects have one, so it was handed a new list + each time: in a form of 100 Selects, every edit and every blur of another field rendered all 100. The + mapped list is kept now until the options or the mapping change, and so is the handler a stable-value + Select's `onChange` is wrapped in. A Select in a table cell still renders with its table, whose cells + are built anew on each render. - **A field with `autoSubmit` or `removable` no longer renders again whenever another field changes.** Each wrapped the field's own handler in a new function on every render, so such a field rendered again on every change in its document, a blur included, and three times at mount: 200 fields for one diff --git a/src/library/__tests__/select-options-budget.test.js b/src/library/__tests__/select-options-budget.test.js new file mode 100644 index 00000000..50a16e1c --- /dev/null +++ b/src/library/__tests__/select-options-budget.test.js @@ -0,0 +1,98 @@ +/** + * WHAT ANOTHER FIELD'S CHANGE COSTS A SELECT WHOSE OPTIONS ARE MAPPED, pinned (2026-10-12). + * ============================================================================================= + * + * The mapper mapped a field's `options` through its `mapOptions` on every render, so a Select was handed a + * new array each time and rendered again on every change of its document, 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 options are now kept while they are + * mapped from the same options by the same mapping (`keptMappedOptions` in `engine/mapper.tsx`), and so is + * the handler a stable-value Select's `onChange` is wrapped in, which searches them. + * + * What still renders a Select again is its document's meta being built anew, when the document's state + * changes (`meta` in `engine/rules.tsx`): here, once, for the first edit, as for a Select whose options are + * not mapped. A Select in a table cell still renders with its table too: the cell's renderer builds the + * cell's meta anew, options included, for every row on every render (`engine/transforms.ts`), which this + * does not change. + * + * Measured in jsdom on React 18 with a form of 100 such Selects, two runs: 300 renders at mount and 100 for + * each later edit or blur of another field, an edit taking 16 and 22 ms; now 200, and none, an edit taking + * 7 and 5 ms. A number here that falls is progress: lower it. One that rises is a regression. + */ +import React from 'react' +import { act, fireEvent, render } from '@testing-library/react' +import UIRender from '../main' +import { formsStorage } from '../../core/state/formRegistry' + +/** Every render of each Select's `Dropdown`, by the field's name, and the options it was last handed. */ +const mockSeen = { renders: {}, options: {} } +jest.mock('../../core/components/Dropdown', () => { + const React = require('react') + const actual = jest.requireActual('../../core/components/Dropdown') + const Dropdown = props => { + mockSeen.renders[props.name] = (mockSeen.renders[props.name] || 0) + 1 + mockSeen.options[props.name] = props.options + return React.createElement(actual.Dropdown, props) + } + return { __esModule: true, ...actual, Dropdown } +}) + +const records = [{ id: 'a', name: 'A' }, { id: 'b', name: 'B' }] +const SELECTS = { + byValue: { view: 'Select', name: 'byValue', options: records, mapOptions: { text: 'name', value: 'id' } }, + byIndex: { view: 'Select', name: 'byIndex', options: records, mapOptions: 'name' }, + plain: { view: 'Select', name: 'plain', options: [{ text: 'A', value: 'a' }, { text: 'B', value: 'b' }] }, +} +const meta = { view: 'Col', items: [...Object.values(SELECTS), { view: 'Input', name: 'other' }] } +const data = () => ({ byValue: 'a', byIndex: '0', plain: 'a', other: 'o' }) + +/** The renders of the three Selects, by name, since the last call. */ +function rendersSince () { + const counts = Object.fromEntries(Object.keys(SELECTS).map(name => [name, mockSeen.renders[name] || 0])) + mockSeen.renders = {} + return counts +} + +const settle = () => act(() => { jest.runOnlyPendingTimers() }) + +beforeEach(() => { + jest.useFakeTimers() +}) + +afterEach(() => { + jest.useRealTimers() +}) + +describe('Selects whose options are mapped', () => { + it('render no more than a Select whose options are not, and not at all for another field\'s later changes', () => { + rendersSince() + const { container } = render() + settle() + const atMount = rendersSince() + const form = [...formsStorage.values()].map(entry => entry.form).find(candidate => 'other' in candidate.getState().values) + const other = container.querySelector('input[name="other"]') + + // The first edit changes the document's state, which builds its meta anew: every Select renders once. + act(() => { form.change('other', 'first') }) + settle() + rendersSince() + act(() => { form.change('other', 'second') }) + settle() + const onEdit = rendersSince() + act(() => { fireEvent.focus(other); fireEvent.blur(other) }) + settle() + const onBlur = rendersSince() + const none = { byValue: 0, byIndex: 0, plain: 0 } + // Before 2026-10-12 the two mapped Selects read 3 at mount, and 1 for the edit and for the blur. + expect({ atMount, onEdit, onBlur }).toEqual({ atMount: { byValue: 2, byIndex: 2, plain: 2 }, onEdit: none, onBlur: none }) + }) + + it('are handed the options their mapping makes', () => { + render() + settle() + expect([mockSeen.options.byValue, mockSeen.options.byIndex]).toEqual([ + [{ text: 'A', value: 'a' }, { text: 'B', value: 'b' }], + [{ text: 'A', value: '0' }, { text: 'B', value: '1' }], + ]) + }) +})