Skip to content
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
This repository was archived by the owner on Nov 4, 2026. It is now read-only.

objectui: the two selectFirst gate hints join controlling-field names with hardcoded separators — and with two DIFFERENT ones #4026

Closed

Description

@os-zhuang

Migrated from objectstack-ai/objectstack#5432 under the file-at-destination ruling (objectstack-ai/objectstack#7167, maintainer 2026-08-10). Originally filed 2026-08-05T11:11:46Z. The full prior thread — including triage rulings and hold/restart conditions — remains on the source issue and MUST be read before acting on this card.


Observation-class finding, noticed while fixing objectstack#5407. Nothing a user hits today with any urgency — it needs a field gated on two or more controlling fields, which is rare — so it carries finding and no pm:queue. Recording it because objectstack-ai/objectstack#5407 has just established the mechanism that fixes it.

objectstack-ai/objectstack#5407 item 4 established that a list separator is a locale property, and added validation.formInvalidJoiner (per-pack: ", " for Latin scripts and Korean, "、" for zh/ja, U+060C + space for Arabic) after measuring Intl.ListFormat and finding its output unusable for a truncated list.

Two other sites join user-visible names with a hardcoded separator, and they do not even agree with each other:

Site Separator Sentence
packages/fields/src/widgets/LookupField.tsx ", " lookup.selectFirst
packages/components/src/renderers/form/form.tsx (option-field gate) " / " fields.options.selectFirst

Both fill the {{fields}} slot of what is deliberately one shared sentence — the form renderer's own comment says the two callers exist so "the gate can never read differently depending on which side produced it" — yet a field gated on two parents reads Select Account, Owner first from the lookup and 请先选择Account / Owner from the select. Under zh/ja both separators are wrong for the script on top of that.

Suggested fix

Reuse the mechanism objectstack-ai/objectstack#5407 added rather than inventing a second one: either read validation.formInvalidJoiner at both sites, or (if the gate hint wants its own punctuation) add one fields.gateJoiner key across the ten packs. Either way it has to be one key read by both callers — two keys would recreate the divergence the shared sentence exists to prevent.

Note for the implementer: LookupField receives the labels through the dependsOnLabels prop objectstack-ai/objectstack#5407 added, so the joining happens in dependsOnFieldsText there; the form's copy is in the gatedHint memo.

Activity

  1. self-assigned this
    on Aug 15, 2026
  2. yinlianghui commented on Aug 15, 2026

    @yinlianghui
    Contributor

    Dispatched by the repo:objectui PM seat (session session_01RnQd8iMMUwXQEV1crFmQiQ), branch claude/issue-4026-selectfirst-joiner.

    Adjudication. One joiner, both callers — the source-thread triage (objectstack#5432) graded this a pure-execution card and the card itself pins the constraint: two keys would recreate the divergence the shared sentence exists to prevent. Preferred route ruled here: reuse validation.formInvalidJoiner at both sites (zero locale-pack edits; the mechanism objectstack#5407 added is exactly this class of truncated-list join). If measurement turns up a concrete reason the gate hint needs its own punctuation, stop and report needs_decision — do not add a fields.gateJoiner key unilaterally.

    Mechanism assumptions (re-verified on origin/main today): packages/fields/src/widgets/LookupField.tsx:300 joins dependsOnFieldsText with hardcoded ', '; packages/components/src/renderers/form/form.tsx gatedHint memo joins with ' / '; both fill {{fields}} of the shared selectFirst sentence (lookup.selectFirst / fields.options.selectFirst, one sentence two callers per the form's own comment); validation.formInvalidJoiner exists across the ten packs with locale-correct values (、 zh/ja, U+060C ar, ', ' Latin/ko).

    Suggested route. Per-locale tests: same controlling-field set produces the identical joined sentence from both callers, and zh/ja/ar joiners are script-correct. Changeset: fields + components patch. No skip-changeset mechanism exists in objectui.


    Generated by Claude Code

  3. yinlianghui commented on Aug 15, 2026

    @yinlianghui
    Contributor
    {
      "issue": 4026,
      "branch": "claude/issue-4026-selectfirst-joiner",
      "pr_number": 4739,
      "pr": "https://github.com/objectstack-ai/objectui/pull/4739",
      "head_sha": "954e71ec47eef1f3dcc04c2e8ba4f37568520730",
      "status": "done",
      "premise_still_valid": true,
      "summary": "All callers of the selectFirst gate sentence now fill its {{fields}} slot with validation.formInvalidJoiner instead of a hardcoded separator, so a field gated on two parents reads identically from every side and enumerates with the locale's own punctuation. Zero locale-pack edits: the only new declaration is the joiner in @object-ui/fields' createSafeTranslation defaults table, valued at the en pack's ', ' so a provider-less English render is byte-identical to before. The adjudicated constraint holds - ONE key, and no fields.gateJoiner twin was added.",
      "mechanism_assumptions_checked": [
        "CONFIRMED: packages/fields/src/widgets/LookupField.tsx:300 joined dependsOnFieldsText with hardcoded ', ' - the line number was exact on origin/main.",
        "CONFIRMED: packages/components/src/renderers/form/form.tsx:1555 (gatedHint memo) joined with hardcoded ' / '.",
        "CONFIRMED: both fill {{fields}} of the shared sentence (lookup.selectFirst / fields.options.selectFirst). Additionally measured: all ten packs give those two keys BYTE-IDENTICAL wording, which is what makes a cross-caller equality assertion meaningful rather than vacuous.",
        "CONFIRMED: validation.formInvalidJoiner exists in all ten packs with locale-correct values (', ' en/es/de/fr/pt/ru/ko, U+3001 zh/ja, U+060C+space ar).",
        "CORRECTED: there are THREE call sites, not two. packages/fields/src/widgets/OptionsEmptyState.tsx:85 also joined with ' / ' filling the SAME fields.options.selectFirst key - it is the 'standalone widget' caller the form renderer's comment names. Fixing only the two named sites would have left the shared sentence still reading two ways within one key. Folded in-scope; see deviations.",
        "NEW: the fields package's createSafeTranslation defaults table did NOT carry validation.formInvalidJoiner. Without adding it, the no-provider path (fallbackT: defaults -> inline defaultValue -> the key itself) would have joined names with the literal string 'validation.formInvalidJoiner'. Added; the existing no-provider tests (LookupField.gateHintLabel, OptionsEmptyState.no-provider) stay green and pin the byte-identity.",
        "CONFIRMED: no skip-changeset label applied. Observation for the PM - the label does EXIST in this repo (open PR #4639 carries it), so 'objectui has no skip-changeset label' is not literally accurate; what is accurate is that this PR owes a changeset and has one, and check-changeset-presence passes."
      ],
      "gates": {
        "vitest packages/fields/ packages/components/": "PASS - 222 files / 2551 tests passed, at 954e71ec4",
        "type-check (fields, components)": "PASS - required building the dependency closure first in the fresh worktree",
        "eslint . --quiet (fields, components)": "PASS - 0 errors",
        "check:control-bytes": "PASS",
        "check:i18n-keys": "PASS",
        "check:i18n-drift": "PASS",
        "check:i18n-dead-keys": "PASS",
        "check:phantom-deps": "PASS",
        "check-changeset-presence": "PASS - 5 source files of 2 released packages, 1 changeset declared",
        "check-changeset-no-major": "PASS",
        "reverse_verification": "RED in the expected direction. Fix committed first, then all four source files restored from origin/main: 10 of 19 cases failed, reproducing the defect verbatim ('Select Account / Lead Source first' vs 'Select Account, Lead Source first'; zh got ', ' where U+3001 is required). Reported honestly: 9 cases stayed GREEN under ablation - the OptionsEmptyState-vs-form comparison because both hardcoded the same wrong ' / ' (they agreed while both broken, which is why lookup-vs-form equality is the load-bearing assertion), and en/de/ko because ', ' is those packs' correct value by accident. Fix restored, union re-run at final head.",
        "remote_ci": "in_progress - reported at draft-PR time per the dispatch contract; not awaited."
      },
      "deviations": [
        "SCOPE (named in the PR body): fixed a third call site the card does not name - packages/fields/src/widgets/OptionsEmptyState.tsx, which hardcoded ' / ' filling the same fields.options.selectFirst key. Meets the bounded in-place bar: same defect class, mechanical under the already-adjudicated route, same package and gate families, and no open PR touches the file (checked #4655/#4639/#4093). Leaving it out would have actively defeated the card's own invariant.",
        "Added validation.formInvalidJoiner to packages/fields/src/widgets/useFieldTranslation.ts FIELD_DEFAULTS. Not a locale-pack edit and not a new key - it is the defaults table's declared-where-read discipline, and it is load-bearing for the provider-less path.",
        "Ran the check:i18n-* family (keys / drift / dead-keys) beyond the dispatched gate list, re-derived from the actual changed paths (a new t() call site plus a defaults-table entry). All pass.",
        "TOOLCHAIN CONFLICT worth recording: the generic os-dev contract says to scope tests with `pnpm --filter <pkg> test`, but this repo's AGENTS.md 测试纪律 documents that exact form as a silent-false-green trap (objectui#3378) and it is now mechanically BLOCKED by scripts/vitest-invocation-guard.mjs. Used the repo-canonical form instead: `pnpm exec vitest run packages/<pkg>/` from the repo root. `--filter` was still used for type-check and lint, where the guard does not apply.",
        "No decision escalated. The measurement that would have justified a fields.gateJoiner twin did not appear: the gate list is a plain enumeration of field names, semantically identical to the invalid-submit toast's list. The repo does contain a precedent for a separate joiner key (setup.apps bulkFailureJoiner, '; ') and it is instructive - that one separates '{{name}} ({{reason}})' compound entries, i.e. genuinely different punctuation semantics. The gate hint has no such difference, so reuse is correct."
      ],
      "followups_filed": [],
      "out_of_scope_findings": []
    }

    Generated by Claude Code

  4. added a commit that references this issue on Aug 17, 2026
    75444e3
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions