diff --git a/packages/jsonforms-renderers/src/renderers/SearchSelectControl.test.tsx b/packages/jsonforms-renderers/src/renderers/SearchSelectControl.test.tsx index 2bf80ad..f39561f 100644 --- a/packages/jsonforms-renderers/src/renderers/SearchSelectControl.test.tsx +++ b/packages/jsonforms-renderers/src/renderers/SearchSelectControl.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it } from 'vitest' -import { useState } from 'react' +import { StrictMode, useState } from 'react' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { JsonForms } from '@jsonforms/react' import { Theme } from '@radix-ui/themes' @@ -363,6 +363,49 @@ describe('SearchSelectControl displayTemplate', () => { expect((screen.getByRole('textbox') as HTMLInputElement).value).toBe('USTMR-ALTHEIMER') }) + it('shows displayTemplate for a stored id after resolve is cancelled and retried', async () => { + const formSchema = { + type: 'object', + properties: { + port: { + type: 'string', + title: 'Port', + 'x-search': { + service: 'countries', + mode: 'small-list', + displayTemplate: '{name} ({id})', + }, + }, + }, + } as unknown as JsonSchema + + const resolves: string[] = [] + render( + + + Promise.resolve({ options: [] }), + async resolve(value: string) { + resolves.push(value) + return { id: value, name: 'ALTHEIMER' } + }, + }, + }} + > + + + + , + ) + + await waitFor(() => { + expect((screen.getByRole('textbox') as HTMLInputElement).value).toBe('ALTHEIMER (USTMR)') + }) + expect(resolves).toContain('USTMR') + }) + it('writes the service id and templated label for an object-shaped field', async () => { const formSchema = { type: 'object', diff --git a/packages/jsonforms-renderers/src/renderers/SearchSelectControl.tsx b/packages/jsonforms-renderers/src/renderers/SearchSelectControl.tsx index 6e5da60..389cbc6 100644 --- a/packages/jsonforms-renderers/src/renderers/SearchSelectControl.tsx +++ b/packages/jsonforms-renderers/src/renderers/SearchSelectControl.tsx @@ -200,26 +200,36 @@ const SearchSelectControl = ({ return } if (lastResolvedRef.current?.value === currentValue && lastResolvedRef.current?.label === currentLabel) return - // mark as resolving immediately — prevents re-runs if resolve is absent, rejects, or returns undefined - lastResolvedRef.current = { value: currentValue, label: currentLabel } // object-shaped fields already carry the label from submission time — no need to re-resolve it if (isObjectMode && currentLabel) { setSelectedOption({ id: currentValue, name: currentLabel }) + lastResolvedRef.current = { value: currentValue, label: currentLabel } return } // optimistic raw-value label first, so the field isn't blank while resolving setSelectedOption({ id: currentValue, name: currentLabel ?? currentValue }) - if (!service?.resolve) return + if (!service?.resolve) { + lastResolvedRef.current = { value: currentValue, label: currentLabel } + return + } + + // Mark resolved only after this call settles. StrictMode runs the effect, + // cancels it, and runs it again; recording the id up front makes that + // second run skip resolve and leave the raw id on screen. let cancelled = false + const value = currentValue + const label = currentLabel void service - .resolve(currentValue, searchParams) + .resolve(value, searchParams) .then((opt) => { - if (!cancelled && opt) setSelectedOption(presentOption(opt, displayTemplate)) + if (cancelled) return + if (opt) setSelectedOption(presentOption(opt, displayTemplate)) + lastResolvedRef.current = { value, label } }) .catch(() => { - /* keep raw-value fallback */ + if (!cancelled) lastResolvedRef.current = { value, label } }) return () => { cancelled = true