Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions src/core/engine/__tests__/mapper.fields-tables-popups.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
43 changes: 40 additions & 3 deletions src/core/engine/mapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<object, Map<unknown, { debug: unknown, options: unknown[] }>>()

/** 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) => {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions src/demo/markdowns/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
98 changes: 98 additions & 0 deletions src/library/__tests__/select-options-budget.test.js
Original file line number Diff line number Diff line change
@@ -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(<UIRender meta={meta} data={data()} initialValues={data()} />)
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(<UIRender meta={meta} data={data()} initialValues={data()} />)
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' }],
])
})
})
Loading