From 01d253907bee8392fd3d820d5c0ea15117e7d589 Mon Sep 17 00:00:00 2001 From: GeekCmore <128243887+GeekCmore@users.noreply.github.com> Date: Sun, 20 Sep 2026 15:35:02 +0800 Subject: [PATCH] fix(core): admit list items under per-item text quotas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eagerly admitted list items and select options charged their aggregate text to the shared tree budget, so a picker with ~80+ data rows (the /sessions panel in a populated directory) was rejected with "Mayfly UI text exceeds 20000 characters" while larger lists passed untouched through lazy admission. Each item now validates under its own state — the same isolation lazy rows already get — keeping strict eager faults while the tree budget bounds only non-item text. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- packages/mayfly/src/core/ui-validator.ts | 12 ++++++++---- .../mayfly/tests/core/ui-validator.spec.ts | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/packages/mayfly/src/core/ui-validator.ts b/packages/mayfly/src/core/ui-validator.ts index a13cd7b..f24547e 100644 --- a/packages/mayfly/src/core/ui-validator.ts +++ b/packages/mayfly/src/core/ui-validator.ts @@ -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) @@ -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) @@ -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`) @@ -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`) diff --git a/packages/mayfly/tests/core/ui-validator.spec.ts b/packages/mayfly/tests/core/ui-validator.spec.ts index 3b97fa0..1b37613 100644 --- a/packages/mayfly/tests/core/ui-validator.spec.ts +++ b/packages/mayfly/tests/core/ui-validator.spec.ts @@ -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]