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/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..b91c6d5cef
--- /dev/null
+++ b/packages/fields/src/__tests__/selectFirst-gate-joiner-locale-parity.test.tsx
@@ -0,0 +1,255 @@
+/**
+ * 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 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.
+ */
+function EmptyHintProbe(props: any) {
+ return
{String(props.emptyHint)}
;
+}
+
+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(() => {
+ 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,
+ ,
+ ),
+ );
+ const hint = screen.getByTestId('form-gate-probe').textContent ?? '';
+ cleanup();
+ return 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