From 69b0c3b0a7fe9064e5a893288f3ae1e47c589b9a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 20:53:02 +0000 Subject: [PATCH 1/2] fix(fields,components): read the selectFirst gate joiner from the locale pack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dependency-gate sentence is deliberately shared between the lookup widget and the form renderer, but each caller filled its {{fields}} slot with its own hardcoded separator — ', ' in LookupField, ' / ' in the form renderer and in OptionsEmptyState — so a field gated on two parents read two different ways depending on which side produced it. A list separator is a locale property, so all three now read validation.formInvalidJoiner, the key already shipped in the ten packs for the invalid-submit toast's field list. Fixes #4026 --- .../components/src/renderers/form/form.tsx | 14 +- ...ctFirst-gate-joiner-locale-parity.test.tsx | 253 ++++++++++++++++++ packages/fields/src/widgets/LookupField.tsx | 16 +- .../fields/src/widgets/OptionsEmptyState.tsx | 9 +- .../fields/src/widgets/useFieldTranslation.ts | 13 + 5 files changed, 301 insertions(+), 4 deletions(-) create mode 100644 packages/fields/src/__tests__/selectFirst-gate-joiner-locale-parity.test.tsx diff --git a/packages/components/src/renderers/form/form.tsx b/packages/components/src/renderers/form/form.tsx index 1f00b8469f..0a270f0411 100644 --- a/packages/components/src/renderers/form/form.tsx +++ b/packages/components/src/renderers/form/form.tsx @@ -1550,9 +1550,21 @@ ComponentRegistry.register('form', // interpolates the controlling fields' LABELS, a standalone widget its // raw metadata names — so the gate can never read differently depending // on which side produced it. + // + // That invariant held for the sentence but not for its `{{fields}}` + // slot, which each caller filled with a hardcoded separator — `' / '` + // here, `', '` in the lookup's own gate — so a field gated on two + // parents still read two ways (objectui#4026). The separator is a + // property of the locale, so all of them now read + // `validation.formInvalidJoiner`: the key objectstack#5407 added for the + // invalid-submit toast a few hundred lines up, which is the same class + // of truncated-name list, rather than a second gate-only twin that would + // reintroduce the divergence. const gatedHint = optionGroupGated ? t('fields.options.selectFirst', { - fields: dependsOnFields.map((fn) => fieldLabelByName[fn] || fn).join(' / '), + fields: dependsOnFields + .map((fn) => fieldLabelByName[fn] || fn) + .join(t('validation.formInvalidJoiner')), }) : undefined; diff --git a/packages/fields/src/__tests__/selectFirst-gate-joiner-locale-parity.test.tsx b/packages/fields/src/__tests__/selectFirst-gate-joiner-locale-parity.test.tsx new file mode 100644 index 0000000000..ebd1b4b9f0 --- /dev/null +++ b/packages/fields/src/__tests__/selectFirst-gate-joiner-locale-parity.test.tsx @@ -0,0 +1,253 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The dependency-gate hint reads the SAME way whichever caller produced it, in + * every locale — objectui#4026. + * + * The gate sentence is deliberately shared: `lookup.selectFirst` and + * `fields.options.selectFirst` are one wording, and the form renderer's own + * comment says the two callers exist so "the gate can never read differently + * depending on which side produced it". That invariant held for the sentence + * and not for its `{{fields}}` slot, which each caller filled with a hardcoded + * separator — and not even the same one: + * + * LookupField Select Account, Lead Source first + * form renderer Select Account / Lead Source first + * + * A separator is a property of the LOCALE, not of the code (the finding behind + * `validation.formInvalidJoiner`, objectstack#5407), so both spellings were + * also wrong under zh/ja, which enumerate with U+3001, and under ar, which + * uses U+060C. + * + * Three call sites fill that slot, not two — `OptionsEmptyState` is the + * standalone-widget caller of `fields.options.selectFirst` that the form + * renderer's comment refers to, and it hardcoded `' / '` as well. Fixing only + * the two the card names would have left the shared sentence still reading two + * ways, so all three are asserted here together. + * + * The cross-caller assertion is EQUALITY rather than three copies of an + * expected string: a joiner that later changes wrongly-but-consistently is a + * translation bug, while one that changes inconsistently is this defect coming + * back. The per-script cases below cover the first half. + * + * This file lives in `@object-ui/fields` because it is the only package that + * can see both sides — fields depends on components, not the reverse. + */ + +import type { ReactNode } from 'react'; +import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import { ComponentRegistry } from '@object-ui/core'; +import { I18nProvider } from '@object-ui/i18n'; +// Module scope, NOT a `beforeAll` — a cold transform billed to `hookTimeout` is +// this repo's known flake generator (AGENTS.md 测试纪律 / objectui#3010). This +// import pulls in the form renderer's registration side effect. +import '@object-ui/components'; + +import { LookupField } from '../widgets/LookupField'; +import { OptionsEmptyState } from '../widgets/OptionsEmptyState'; + +/** U+3001 IDEOGRAPHIC COMMA — the CJK list separator. */ +const CJK_COMMA = '、'; +/** U+060C ARABIC COMMA. */ +const ARABIC_COMMA = '،'; + +/** The two controlling fields, named the way the user sees them on the form. */ +const PARENT_A = { name: 'crm_account', label: 'Account' }; +const PARENT_B = { name: 'lead_source', label: 'Lead Source' }; +const PARENT_LABELS = { [PARENT_A.name]: PARENT_A.label, [PARENT_B.name]: PARENT_B.label }; + +/** + * Surfaces the `emptyHint` the FORM computed, verbatim. The gate hint reaches a + * registered option widget as that prop (objectui#3231), so reading it is + * reading `gatedHint`'s output with no DOM formatting in between. + */ +let formHint: string | undefined; +function EmptyHintProbe(props: any) { + formHint = props.emptyHint; + return
; +} + +const dataSource = { find: vi.fn(async () => ({ data: [], total: 0 })) } as any; + +beforeAll(() => { + // `field:select` belongs to this package's registry index, which this file + // deliberately does not import — so the probe shadows nothing. + ComponentRegistry.register('field:select', EmptyHintProbe, { namespace: 'test' }); +}, 30000); + +beforeEach(() => { + formHint = undefined; + Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1280 }); + window.matchMedia = ((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + })) as any; +}); + +afterEach(() => cleanup()); + +const withLocale = (language: string, ui: ReactNode) => ( + + {ui} + +); + +/** `packages/fields` — the lookup widget's gate (`lookup.selectFirst`). */ +function lookupGateHint(language: string): string { + render( + withLocale( + language, + , + ), + ); + // The `title` rather than the text content: it is the whole sentence and + // nothing else, so it can be compared for EQUALITY. Its agreement with the + // visible copy is pinned in `LookupField.gateHintLabel.test.tsx`. + const title = screen.getByTestId('lookup-trigger-gated').getAttribute('title'); + cleanup(); + return String(title); +} + +/** `packages/components` — the form renderer's `gatedHint` memo. */ +function formGateHint(language: string): string { + const Form = ComponentRegistry.get('form')!; + render( + withLocale( + language, +
, + ), + ); + screen.getByTestId('form-gate-probe'); + const hint = formHint; + cleanup(); + return String(hint); +} + +/** `packages/fields` — the standalone option widget's own gate copy. */ +function optionsEmptyStateGateHint(language: string): string { + // No `emptyHint`: this is the standalone path, where the widget owns the + // sentence. It interpolates raw metadata names because it has no host to + // resolve labels — which is what the form renderer's comment describes. The + // JOINER is what is compared, so both sides are handed the same name set. + const { container } = render( + withLocale( + language, + , + ), + ); + const text = container.textContent ?? ''; + cleanup(); + return text; +} + +/** zh/ja enumerate with U+3001, ar with U+060C, Latin scripts and ko with ", ". */ +const LOCALES: Array<{ language: string; joiner: string }> = [ + { language: 'en', joiner: ', ' }, + { language: 'de', joiner: ', ' }, + { language: 'ko', joiner: ', ' }, + { language: 'zh', joiner: CJK_COMMA }, + { language: 'ja', joiner: CJK_COMMA }, + { language: 'ar', joiner: `${ARABIC_COMMA} ` }, +]; + +describe('selectFirst gate hint — one joiner, every caller (objectui#4026)', () => { + it.each(LOCALES)( + 'the lookup and the form produce the identical sentence under $language', + ({ language }) => { + const fromLookup = lookupGateHint(language); + const fromForm = formGateHint(language); + + // The defect, stated directly: same controlling fields, same locale, two + // different sentences. + expect(fromForm).toBe(fromLookup); + // Neither side is empty or a raw key — an equality that both failed the + // same way would otherwise pass. + expect(fromLookup).toContain(PARENT_A.label); + expect(fromLookup).toContain(PARENT_B.label); + }, + ); + + it.each(LOCALES)( + 'the standalone option widget joins the same way under $language', + ({ language }) => { + // The third caller of `fields.options.selectFirst`, and the one the form + // renderer's comment names. It hardcoded `' / '` too. + expect(optionsEmptyStateGateHint(language)).toBe(formGateHint(language)); + }, + ); + + it.each(LOCALES)('enumerates with the separator declared by $language', ({ language, joiner }) => { + const sentence = lookupGateHint(language); + + expect(sentence).toContain(`${PARENT_A.label}${joiner}${PARENT_B.label}`); + // The literal this replaced, asserted negatively so a re-inlined separator + // cannot pass by rendering the right thing elsewhere in the sentence. + // `' / '` appears in no pack's copy, in any locale. + expect(sentence).not.toContain(' / '); + // And where the pack does NOT use a comma+space, the other old literal + // must be gone too. + if (joiner !== ', ') { + expect(sentence).not.toContain(`${PARENT_A.label}, `); + } + }); + + it('does not leak the CJK comma into a Latin session, or a comma into zh', () => { + // The two SIDES of the locale defect, in the one place all callers meet. + expect(lookupGateHint('en')).not.toContain(CJK_COMMA); + expect(formGateHint('en')).not.toContain(CJK_COMMA); + expect(lookupGateHint('zh')).not.toContain(', '); + expect(formGateHint('zh')).not.toContain(', '); + }); +}); diff --git a/packages/fields/src/widgets/LookupField.tsx b/packages/fields/src/widgets/LookupField.tsx index 7d09864dd9..e87304c34f 100644 --- a/packages/fields/src/widgets/LookupField.tsx +++ b/packages/fields/src/widgets/LookupField.tsx @@ -294,11 +294,23 @@ export function LookupField({ value, onChange, field, readonly, error: fieldErro * not merely an untranslated word. The host form supplies the name→label map * (`dependsOnLabels`); a name it doesn't cover falls back to itself, so a * standalone widget with no host renders exactly what it did before. + * + * The separator between the names is a LOCALE property, not a code constant + * (objectui#4026, the mechanism objectstack#5407 established for the + * invalid-submit toast). It used to be a hardcoded `', '` here while the + * form renderer's copy of this same gate hardcoded `' / '`, so one shared + * sentence read two different ways depending on which side produced it — + * and under zh/ja both spellings were wrong for the script. Every caller of + * the gate sentence now reads `validation.formInvalidJoiner`, the one + * already-shipped key for exactly this kind of truncated-name list. */ const dependsOnLabelsProp = props.dependsOnLabels; const dependsOnFieldsText = useMemo( - () => dependsOn.map((d) => dependsOnLabelsProp?.[d.field] || d.field).join(', '), - [dependsOn, dependsOnLabelsProp], + () => + dependsOn + .map((d) => dependsOnLabelsProp?.[d.field] || d.field) + .join(t('validation.formInvalidJoiner')), + [dependsOn, dependsOnLabelsProp, t], ); // Resolve dependent field values from explicit prop or SchemaRendererContext.data diff --git a/packages/fields/src/widgets/OptionsEmptyState.tsx b/packages/fields/src/widgets/OptionsEmptyState.tsx index 42df3d4397..a1e6f718d4 100644 --- a/packages/fields/src/widgets/OptionsEmptyState.tsx +++ b/packages/fields/src/widgets/OptionsEmptyState.tsx @@ -79,10 +79,17 @@ export function OptionsEmptyState({ const { t } = useFieldTranslation(); // The host's hint when it computed one; otherwise this widget's own copy, // translated. Never an English literal — that was the reported defect. + // The joiner between the controlling-field names is read from the locale + // pack, not hardcoded (objectui#4026). This site and the form renderer's + // `gatedHint` are the two callers of the SAME `fields.options.selectFirst` + // sentence, so a separator baked into either one is enough to make the + // shared sentence read two ways. const hint = emptyHint || (gated - ? t('fields.options.selectFirst', { fields: dependsOnFields.join(' / ') }) + ? t('fields.options.selectFirst', { + fields: dependsOnFields.join(t('validation.formInvalidJoiner')), + }) : t('fields.options.empty')); return (
= { // renderer so both cannot drift apart in a locale. 'fields.options.empty': 'No options available', 'fields.options.selectFirst': 'Select {{fields}} first', + // objectui#4026 — the separator between the controlling-field names that + // fill `{{fields}}` of the two gate sentences above/below (`lookup. + // selectFirst`, `fields.options.selectFirst`). It is a LOCALE property, not + // a code constant: the call sites hardcoded it, and not even to the same + // value (`', '` in `LookupField`, `' / '` in `OptionsEmptyState` and the + // form renderer), so one deliberately-shared sentence read differently + // depending on which side produced it. Deliberately the SAME key + // objectstack#5407 added for the invalid-submit toast's field list rather + // than a gate-specific twin — it is the same class of truncated-name list, + // and a second key would recreate exactly the divergence being removed. + // The default here is the `en` pack's value, so a provider-less render is + // byte-identical to what `LookupField` produced before. + 'validation.formInvalidJoiner': ', ', // objectstack#3821 — sharing-rule authoring widgets (object-ref / // recipient-picker / filter-condition). The recipient placeholder is keyed // PER TYPE rather than interpolating the enum value into an English From 954e71ec47eef1f3dcc04c2e8ba4f37568520730 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 21:30:22 +0000 Subject: [PATCH 2/2] test(fields): pin the gate-hint joiner across all three callers, per locale The probe renders the hint it received rather than writing it to a module variable during render (react-hooks/globals). Adds the changeset for the fields + components patch. --- .changeset/selectfirst-gate-joiner-locale.md | 29 +++++++++++++++++++ ...ctFirst-gate-joiner-locale-parity.test.tsx | 20 +++++++------ 2 files changed, 40 insertions(+), 9 deletions(-) create mode 100644 .changeset/selectfirst-gate-joiner-locale.md diff --git a/.changeset/selectfirst-gate-joiner-locale.md b/.changeset/selectfirst-gate-joiner-locale.md new file mode 100644 index 0000000000..251fbbe908 --- /dev/null +++ b/.changeset/selectfirst-gate-joiner-locale.md @@ -0,0 +1,29 @@ +--- +'@object-ui/components': patch +'@object-ui/fields': patch +--- + +The dependency-gate hint now enumerates its controlling fields with the locale's +own list separator, and reads identically whichever caller produced it. + +`lookup.selectFirst` and `fields.options.selectFirst` are deliberately one +wording, so a field gated on two or more parents says the same thing whether the +lookup widget or the form renderer rendered it. The sentence was shared but its +`{{fields}}` slot was not: each call site joined the controlling-field names +with its own hardcoded separator, and not even the same one — `', '` in +`LookupField`, `' / '` in the form renderer's `gatedHint` and in +`OptionsEmptyState`. A field gated on Account and Lead Source read +`Select Account, Lead Source first` from one side and +`Select Account / Lead Source first` from the other. + +A list separator is a property of the locale rather than of the code, so both +spellings were also wrong for the script under zh/ja (which enumerate with +U+3001) and under ar (U+060C). All three call sites now read +`validation.formInvalidJoiner` — the key already shipped in all ten packs for +the invalid-submit toast's field list, which is the same class of truncated-name +list. One key, every caller: a second, gate-specific key would have recreated +the divergence the shared sentence exists to prevent. + +No locale pack changes, and no change to what a provider-less render produces in +English: the `@object-ui/fields` defaults table declares the joiner as `', '`, +the `en` pack's value and the literal `LookupField` previously hardcoded. diff --git a/packages/fields/src/__tests__/selectFirst-gate-joiner-locale-parity.test.tsx b/packages/fields/src/__tests__/selectFirst-gate-joiner-locale-parity.test.tsx index ebd1b4b9f0..b91c6d5cef 100644 --- a/packages/fields/src/__tests__/selectFirst-gate-joiner-locale-parity.test.tsx +++ b/packages/fields/src/__tests__/selectFirst-gate-joiner-locale-parity.test.tsx @@ -65,13 +65,17 @@ const PARENT_LABELS = { [PARENT_A.name]: PARENT_A.label, [PARENT_B.name]: PARENT /** * Surfaces the `emptyHint` the FORM computed, verbatim. The gate hint reaches a - * registered option widget as that prop (objectui#3231), so reading it is - * reading `gatedHint`'s output with no DOM formatting in between. + * registered option widget as that prop (objectui#3231), so this is + * `gatedHint`'s own output with no other widget's formatting in between. + * + * The value is RENDERED rather than captured into a module variable: writing to + * one during render is a side effect (`react-hooks/globals`), and a probe that + * renders what it received needs no such write. `String(...)` so a hint that + * failed to compute reads as `"undefined"` and fails the comparison loudly + * instead of comparing empty to empty. */ -let formHint: string | undefined; function EmptyHintProbe(props: any) { - formHint = props.emptyHint; - return
; + return
{String(props.emptyHint)}
; } const dataSource = { find: vi.fn(async () => ({ data: [], total: 0 })) } as any; @@ -83,7 +87,6 @@ beforeAll(() => { }, 30000); beforeEach(() => { - formHint = undefined; Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1280 }); window.matchMedia = ((query: string) => ({ matches: false, @@ -169,10 +172,9 @@ function formGateHint(language: string): string { />, ), ); - screen.getByTestId('form-gate-probe'); - const hint = formHint; + const hint = screen.getByTestId('form-gate-probe').textContent ?? ''; cleanup(); - return String(hint); + return hint; } /** `packages/fields` — the standalone option widget's own gate copy. */