From d5a0f0156a4a9601edc208e8014380d9a60752dc Mon Sep 17 00:00:00 2001 From: Olly Dutton Date: Thu, 23 Oct 2025 09:46:26 +1000 Subject: [PATCH 1/3] chore(performance): optimise field updates with large dropdown options --- src/field/schema.ts | 43 ++++++++++++++++++++++++++++++++++++++++++- src/utils.ts | 7 +++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/field/schema.ts b/src/field/schema.ts index e26868316..6c734a8d4 100644 --- a/src/field/schema.ts +++ b/src/field/schema.ts @@ -142,6 +142,35 @@ You can fix the json schema or skip this error by calling createHeadlessForm(sch return getInputTypeFromSchema(type || schema.type || 'string', schema) } +// Cache converted options by content hash (works even when schemas are cloned) +const optionsByContent = new Map>() + +// Create content hash from options array (uses length + sample for speed) +function hashOptions(opts: JsfSchema[]): string { + if (!opts.length) { + return '0' + } + + // Extract the const value from a schema option for hashing + const extractValue = (option: JsfSchema) => { + return (typeof option === 'object' && option !== null) ? option.const : option + } + + const length = opts.length + const start = opts[0] + const middle = opts[Math.floor(length / 2)] + const end = opts[length - 1] + + // Sample first, middle, last options for a lightweight but reliable hash + const sampledValues = [ + extractValue(start), + extractValue(middle), + extractValue(end), + ] + + return `${length}:${JSON.stringify(sampledValues)}` +} + /** * Convert options to the required format * This is used when we have a oneOf or anyOf schema property @@ -153,7 +182,15 @@ You can fix the json schema or skip this error by calling createHeadlessForm(sch * If it doesn't, we skip the option. */ function convertToOptions(nodeOptions: JsfSchema[]): Array { - return nodeOptions + // Check cache by content hash (works even when schemas are cloned) + const hash = hashOptions(nodeOptions) + const cached = optionsByContent.get(hash) + if (cached) { + return cached + } + + // Convert options + const converted = nodeOptions .filter((option): option is NonBooleanJsfSchema => option !== null && typeof option === 'object' && option.const !== null, ) @@ -176,6 +213,10 @@ function convertToOptions(nodeOptions: JsfSchema[]): Array { return { ...result, ...presentation, ...rest } }) + + // Cache for future use + optionsByContent.set(hash, converted) + return converted } /** diff --git a/src/utils.ts b/src/utils.ts index 4e971bced..00be64929 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -99,6 +99,13 @@ export function deepMergeSchemas>(schema1: T, sche // If the value is an array, cycle through it and merge values if they're different (take objects into account) else if (schema1Value && Array.isArray(schema2Value)) { const originalArray = schema1Value + + // For 'options' arrays, just replace the whole array (they're immutable and cached) + if (key === 'options') { + schema1[key as keyof T] = schema2Value as T[keyof T] + continue + } + // If the destiny value exists and it's an array, cycle through the incoming values and merge if they're different (take objects into account) for (const item of schema2Value) { if (item && typeof item === 'object') { From 1d6a9ebe1f876d5cda347e8346c8151a2e03181b Mon Sep 17 00:00:00 2001 From: Olly Dutton Date: Thu, 23 Oct 2025 09:59:19 +1000 Subject: [PATCH 2/3] improve code comments --- src/field/schema.ts | 20 ++++++++++---------- src/utils.ts | 3 ++- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/src/field/schema.ts b/src/field/schema.ts index 6c734a8d4..8583ffe4d 100644 --- a/src/field/schema.ts +++ b/src/field/schema.ts @@ -142,16 +142,19 @@ You can fix the json schema or skip this error by calling createHeadlessForm(sch return getInputTypeFromSchema(type || schema.type || 'string', schema) } -// Cache converted options by content hash (works even when schemas are cloned) -const optionsByContent = new Map>() +const optionsMap = new Map>() -// Create content hash from options array (uses length + sample for speed) +/** + * Create a hash from options array for caching + * @param opts - The options to hash + * @returns The hash + */ function hashOptions(opts: JsfSchema[]): string { if (!opts.length) { return '0' } - // Extract the const value from a schema option for hashing + // Extract the const value from an option const extractValue = (option: JsfSchema) => { return (typeof option === 'object' && option !== null) ? option.const : option } @@ -161,7 +164,7 @@ function hashOptions(opts: JsfSchema[]): string { const middle = opts[Math.floor(length / 2)] const end = opts[length - 1] - // Sample first, middle, last options for a lightweight but reliable hash + // Sample 3 parts of the array for a reliable hash const sampledValues = [ extractValue(start), extractValue(middle), @@ -182,14 +185,12 @@ function hashOptions(opts: JsfSchema[]): string { * If it doesn't, we skip the option. */ function convertToOptions(nodeOptions: JsfSchema[]): Array { - // Check cache by content hash (works even when schemas are cloned) const hash = hashOptions(nodeOptions) - const cached = optionsByContent.get(hash) + const cached = optionsMap.get(hash) if (cached) { return cached } - // Convert options const converted = nodeOptions .filter((option): option is NonBooleanJsfSchema => option !== null && typeof option === 'object' && option.const !== null, @@ -214,8 +215,7 @@ function convertToOptions(nodeOptions: JsfSchema[]): Array { return { ...result, ...presentation, ...rest } }) - // Cache for future use - optionsByContent.set(hash, converted) + optionsMap.set(hash, converted) return converted } diff --git a/src/utils.ts b/src/utils.ts index 00be64929..28b792d72 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -100,7 +100,8 @@ export function deepMergeSchemas>(schema1: T, sche else if (schema1Value && Array.isArray(schema2Value)) { const originalArray = schema1Value - // For 'options' arrays, just replace the whole array (they're immutable and cached) + // For 'options' arrays, just replace the whole array (they're immutable and cached) rather + // than recursively deep merging them if (key === 'options') { schema1[key as keyof T] = schema2Value as T[keyof T] continue From d455fe5d5c2a397b74d1bb88539d025ab687b959 Mon Sep 17 00:00:00 2001 From: Olly Dutton Date: Thu, 30 Oct 2025 10:54:55 +1000 Subject: [PATCH 3/3] stringify entire options --- src/field/schema.ts | 19 +---- test/fields/options.test.ts | 157 ++++++++++++++++++++++++++++++++++++ 2 files changed, 158 insertions(+), 18 deletions(-) create mode 100644 test/fields/options.test.ts diff --git a/src/field/schema.ts b/src/field/schema.ts index 8583ffe4d..c4e52f6ff 100644 --- a/src/field/schema.ts +++ b/src/field/schema.ts @@ -154,24 +154,7 @@ function hashOptions(opts: JsfSchema[]): string { return '0' } - // Extract the const value from an option - const extractValue = (option: JsfSchema) => { - return (typeof option === 'object' && option !== null) ? option.const : option - } - - const length = opts.length - const start = opts[0] - const middle = opts[Math.floor(length / 2)] - const end = opts[length - 1] - - // Sample 3 parts of the array for a reliable hash - const sampledValues = [ - extractValue(start), - extractValue(middle), - extractValue(end), - ] - - return `${length}:${JSON.stringify(sampledValues)}` + return JSON.stringify(opts) } /** diff --git a/test/fields/options.test.ts b/test/fields/options.test.ts new file mode 100644 index 000000000..595e36188 --- /dev/null +++ b/test/fields/options.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from '@jest/globals' +import { createHeadlessForm } from '../../src/form' + +describe('Select field options', () => { + it('should return cached options based on content hash', () => { + // Create two separate oneOf arrays with identical content + const options1 = [ + { const: 'value_1', title: 'Option 1' }, + { const: 'value_2', title: 'Option 2' }, + { const: 'value_3', title: 'Option 3' }, + ] + + const options2 = [ + { const: 'value_1', title: 'Option 1' }, + { const: 'value_2', title: 'Option 2' }, + { const: 'value_3', title: 'Option 3' }, + ] + + // Different object references but same content + expect(options1).not.toBe(options2) + expect(options1).toEqual(options2) + + const schema = { + type: 'object' as const, + properties: { + field1: { + type: 'string' as const, + oneOf: options1, + }, + field2: { + type: 'string' as const, + oneOf: options2, + }, + field3: { + type: 'string' as const, + }, + }, + } + + const form = createHeadlessForm(schema) + + // Verify both fields have correct options + const field1Options = form.fields.find(f => f.name === 'field1')?.options + const field2Options = form.fields.find(f => f.name === 'field2')?.options + + expect(field1Options?.length).toBe(3) + expect(field2Options?.length).toBe(3) + expect(field1Options?.[0]).toEqual({ label: 'Option 1', value: 'value_1' }) + expect(field2Options?.[2]).toEqual({ label: 'Option 3', value: 'value_3' }) + + // Same cached array reference returned for identical content + expect(field1Options).toBe(field2Options) + }) + + it('should maintain options correctly across validations', () => { + // Create a small options array for testing + const options = [ + { label: 'Option 1', value: 'value_1' }, + { label: 'Option 2', value: 'value_2' }, + { label: 'Option 3', value: 'value_3' }, + ] + + const schemaWithOptions = { + type: 'object' as const, + properties: { + field1: { + 'type': 'string' as const, + 'x-jsf-presentation': { + inputType: 'select' as const, + options, + }, + }, + field2: { + 'type': 'string' as const, + 'x-jsf-presentation': { + inputType: 'select' as const, + options, // Same options array reference + }, + }, + otherField: { + type: 'string' as const, + }, + }, + } + + const form = createHeadlessForm(schemaWithOptions) + + // After validation, options should still be present and correct + form.handleValidation({ + field1: 'value_1', + field2: 'value_2', + otherField: 'test', + }) + + const field1Options = form.fields.find(f => f.name === 'field1')?.options + const field2Options = form.fields.find(f => f.name === 'field2')?.options + + // Options should still be present with correct content + expect(field1Options).toBeDefined() + expect(field2Options).toBeDefined() + expect(field1Options?.length).toBe(3) + expect(field2Options?.length).toBe(3) + + expect(field1Options?.[0]).toEqual({ label: 'Option 1', value: 'value_1' }) + expect(field1Options?.[2]).toEqual({ label: 'Option 3', value: 'value_3' }) + }) + + it('should return different references for options arrays with same length but different content', () => { + // Create two options arrays with same length but different content + const options1 = [ + { label: 'Option A', value: 'value_a' }, + { label: 'Option B', value: 'value_b' }, + { label: 'Option C', value: 'value_c' }, + ] + + const options2 = [ + { label: 'Option A', value: 'value_a' }, + { label: 'Option B', value: 'value_b' }, + { label: 'Option D', value: 'value_d' }, + ] + + const schema = { + type: 'object' as const, + properties: { + field1: { + 'type': 'string' as const, + 'x-jsf-presentation': { + inputType: 'select' as const, + options: options1, + }, + }, + field2: { + 'type': 'string' as const, + 'x-jsf-presentation': { + inputType: 'select' as const, + options: options2, + }, + }, + }, + } + + const form = createHeadlessForm(schema) + + const field1Options = form.fields.find(f => f.name === 'field1')?.options + const field2Options = form.fields.find(f => f.name === 'field2')?.options + + expect(field1Options?.length).toBe(3) + expect(field2Options?.length).toBe(3) + + // Should return different references due to different content + expect(field1Options).not.toBe(field2Options) + + // Verify the content is different + expect(field1Options?.[2]).toEqual({ label: 'Option C', value: 'value_c' }) + expect(field2Options?.[2]).toEqual({ label: 'Option D', value: 'value_d' }) + }) +})