diff --git a/src/field/schema.ts b/src/field/schema.ts index e2686831..c4e52f6f 100644 --- a/src/field/schema.ts +++ b/src/field/schema.ts @@ -142,6 +142,21 @@ You can fix the json schema or skip this error by calling createHeadlessForm(sch return getInputTypeFromSchema(type || schema.type || 'string', schema) } +const optionsMap = new Map>() + +/** + * 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' + } + + return JSON.stringify(opts) +} + /** * Convert options to the required format * This is used when we have a oneOf or anyOf schema property @@ -153,7 +168,13 @@ 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 + const hash = hashOptions(nodeOptions) + const cached = optionsMap.get(hash) + if (cached) { + return cached + } + + const converted = nodeOptions .filter((option): option is NonBooleanJsfSchema => option !== null && typeof option === 'object' && option.const !== null, ) @@ -176,6 +197,9 @@ function convertToOptions(nodeOptions: JsfSchema[]): Array { return { ...result, ...presentation, ...rest } }) + + optionsMap.set(hash, converted) + return converted } /** diff --git a/src/utils.ts b/src/utils.ts index 4e971bce..28b792d7 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -99,6 +99,14 @@ 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) rather + // than recursively deep merging them + 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') { diff --git a/test/fields/options.test.ts b/test/fields/options.test.ts new file mode 100644 index 00000000..595e3618 --- /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' }) + }) +})