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
12 changes: 8 additions & 4 deletions packages/mayfly/src/core/ui-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,11 @@ function field(value: unknown, path: string, state: ValidationState): MayflyFiel
}))
}

function listItem(value: unknown, path: string, state: ValidationState): MayflyListItem {
function listItem(value: unknown, path: string): MayflyListItem {
// Item collections are unbounded data rows: each admits under its own
// quota — the same isolation lazy list admission already applies — so the
// aggregate row text of a large picker cannot exhaust the tree budget.
const state = validationState()
return enter(value, path, state, object => {
const disabledValue = own(object, 'disabled', path)
const detailSpansValue = own(object, 'detailSpans', path)
Expand Down Expand Up @@ -461,7 +465,7 @@ function lazyListItems(value: unknown, path: string): readonly MayflyListItem[]
}
let admitted: MayflyListItem
try {
admitted = listItem(raw(index), `${path}[${String(index)}]`, validationState())
admitted = listItem(raw(index), `${path}[${String(index)}]`)
const owner = owners.get(admitted.id)
if (owner !== undefined && owner !== index) invalid(`${path} contains duplicate ids`)
owners.set(admitted.id, index)
Expand Down Expand Up @@ -754,7 +758,7 @@ function formField(value: unknown, path: string, state: ValidationState): Mayfly
}
if (kind === 'select' || kind === 'multiselect') {
const raw = required(object, 'value', path)
const options = collection(required(object, 'options', path), `${path}.options`).map((item, index) => listItem(item, `${path}.options[${String(index)}]`, state))
const options = collection(required(object, 'options', path), `${path}.options`).map((item, index) => listItem(item, `${path}.options[${String(index)}]`))
uniqueIds(options, `${path}.options`)
if (kind === 'multiselect') return { kind, ...common, value: parseValue(raw, `${path}.value`) as readonly string[], options, ...selectionBounds(object, path) }
if (raw !== null && typeof raw !== 'string') invalid(`${path}.value must be a string or null`)
Expand Down Expand Up @@ -906,7 +910,7 @@ function node(value: unknown, path: string, state: ValidationState, depth: numbe
: 0
const items = itemCount > MAYFLY_UI_MAX_COLLECTION
? lazyListItems(itemsValue, `${path}.items`)
: collection(itemsValue, `${path}.items`).map((item, index) => listItem(item, `${path}.items[${String(index)}]`, state))
: collection(itemsValue, `${path}.items`).map((item, index) => listItem(item, `${path}.items[${String(index)}]`))
if (itemCount <= MAYFLY_UI_MAX_COLLECTION) uniqueIds(items, `${path}.items`)
const selectedIds = collection(required(object, 'selectedIds', path), `${path}.selectedIds`).map((item, index) => text(item, `${path}.selectedIds[${String(index)}]`, state))
if (new Set(selectedIds).size !== selectedIds.length) invalid(`${path}.selectedIds contains duplicate ids`)
Expand Down
19 changes: 19 additions & 0 deletions packages/mayfly/tests/core/ui-validator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,25 @@ describe('validateMayflyUiNode', () => {
expect(result.value.items[0]).toMatchObject({ id: '0' })
})

it('bounds each admitted list item and select option by its own text quota', () => {
const row = (index: number) => ({
id: `session-${String(index)}`,
label: `Session title ${String(index)}`,
detail: 'd'.repeat(80),
searchText: 's'.repeat(80),
})
// 120 rows × ~190 chars ≈ 23k aggregate — over the shared tree budget but
// each item far under it; per-item quotas admit the picker (the /sessions
// regression: a populated directory rejected its own session list).
const items = Array.from({ length: 120 }, (_, index) => row(index))
expect(validateMayflyUiNode(ui.list({ role: 'choose', id: 'sessions', selectedIds: [], items })).ok).toBe(true)
const options = Array.from({ length: 120 }, (_, index) => row(index))
expect(validateMayflyUiNode({ kind: 'form', id: 'f', fields: [{ kind: 'select', id: 's', label: 'S', value: null, options }] }).ok).toBe(true)
// The per-item quota still applies to the item itself.
const oversized = [{ id: 'a', label: 'A'.repeat(MAYFLY_UI_MAX_TEXT + 1) }]
expect(validateMayflyUiNode(ui.list({ role: 'choose', id: 'x', selectedIds: [], items: oversized }))).toMatchObject({ ok: false, code: 'MAYFLY_LIMIT_EXCEEDED' })
})

it('contains sparse, accessor, throwing, and subclassed large list inputs', () => {
const sparse = Array.from({ length: 201 }, (_, index) => ({ id: String(index), label: 'ok' }))
delete sparse[200]
Expand Down
Loading