Skip to content

fix(app-shell,i18n): browse search count agrees with its number at one item - #10022

Merged
os-tesla merged 1 commit into
mainfrom
claude/issue-9664-search-items-available-plural
Sep 19, 2026
Merged

os-tesla merged 1 commit into
mainfrom
claude/issue-9664-search-items-available-plural

Conversation

@os-tesla

Copy link
Copy Markdown
Collaborator

Fixes #9664

The full-page search header is one ternary with two branches. The query branch already chose its key on totalCount === 1search.resultsCount against search.resultsCountPlural. The browse branch on the very next line asked for search.itemsAvailable at every count, and that key had neither a plural family nor a sibling to fall to, so the shipped default language read 1 items available at a single searchable item. ⭐ The correct pattern was not elsewhere in the codebase; it was the line above.

⚠️ Spelling note for this body: the i18n placeholder's two brace pairs are not written literally anywhere below — a literal double-brace token is refused as an unknown token by the tooling that scans these bodies. Every pack value is therefore shown rendered at a count, which is what the defect is about anyway.

The route, and why it beat the alternatives on THIS key

search.itemsAvailableOne is added to all ten packs as the singular half, and the browse branch now picks between the two halves on allItems.length === 1 — the same device its neighbour uses, in this repo's xxxOne spelling.

This repo's two-key plural convention, not an invention: common.itemCount/itemCountOne, detail.reactionCount/reactionCountOne, collaboration.commentCount/commentCountOne. useDetailTranslation's defaults-map comment names it in those words, and the common.itemCount badge comment records the reason count labels take two keys rather than an i18next family: zh/ja/ko have no separate singular form, so those packs would legitimately omit an _one half, and full key parity reads a legitimately-absent half as a lost key.

Deliberately NOT an i18next _one/_other family, and that is a measurement of this key rather than a preference:

  • Key parity caps a family at base plus _one plus _other, so every CLDR category a pack does not spell out falls through to the base key — here the plural. ru meets that at 2 to 4 and ar at 2, 3 to 10 and 11 to 99.
  • The family's one advantage — the base key answering uncovered categories in the pack's own language — is empty on this key: ru's value states no noun to agree with, so there is nothing for _few/_many to get right that one string does not already get right.
  • It is actively worse for ar: under a family the base key serves 0, 2, 3 to 10 and 11 to 99 — the everyday counts — and the base key is the value that carried the parenthesised marker.
  • It costs 20 new pack entries against 10, and in en/de/zh/ja/ko the base key is unreachable ballast.
  • Selecting the key in the component keeps Intl.PluralRules and fallbackLng out of the path entirely: both halves exist in every pack, so no count in any language can reach English — the failure mode objectui#3863 is about.

Why the xxxOne spelling rather than the xxxPlural one (resultsCount/resultsCountPlural on the adjacent branch): xxxPlural would have meant re-pointing en's existing search.itemsAvailable at the singular. That is an en value change, and pnpm check:i18n-drift then requires the nine translations to follow — but zh/ja/ko/ru have nothing to follow here, so it would have taken a waiver in the drift ledger plus a raise of that ledger's ceiling pin, both outside this card's declared file surface. The xxxOne spelling renders identically with zero value churn: the gate prints No en value changed in this range.

⛔ objectui#9266 (landed as PR objectui#9663) was read as the worked precedent and not copied. It chose a count-invariant phrase because its call site had one key and rendered a noun beside a number; here the two-key switch is available on the adjacent branch, so English keeps a real sentence instead of giving up agreement.

The ten packs, each at exactly one item

pack before after what changed
en 1 items available 1 item available singular half added
de 1 Elemente verfügbar 1 Element verfügbar singular half added
es 1 elementos disponibles 1 elemento disponible singular half added
fr 1 éléments disponibles 1 élément disponible singular half added
pt 1 itens disponíveis 1 item disponível singular half added
ar ⚠️ 1 عنصر(عناصر) متاح(ة) 1 عنصر متاح singular half added and the plural value changed — see below
ru 1 доступно 1 доступно half added, same string: the phrasing states no noun to agree with
ja 1 件利用可能 unchanged half added, same string: the counter word holds the number
ko 1개 항목 사용 가능 unchanged half added, same string: same reason
zh 共 1 项可搜索 unchanged half added, same string: same reason

The one value edit, named rather than ridden in

ar's count-≠-1 value changed from the parenthesised-marker form to the noun pair ar's own common.itemCount/itemCountOne already uses. The reason is a consequence of this change, not a sweep: the marker existed to cover both numbers in one string, and that key can no longer be reached at one item, so its singular half became dead weight while the parentheses still rendered at every count the key does serve. ⛔ No new parenthesised marker is introduced in any pack. The key-scoped pin on preview.history.items is untouched and makes no claim about this key.

What all-locales-key-parity actually requires, re-derived

Three separate invariants, all re-read on this branch rather than summarised from elsewhere:

  1. Key sets — every pack defines every en key and no pack defines a key en lacks, minus four outbound-message keys. ⇒ a new key is all ten packs or none; it is why zh/ja/ko/ru carry a half they do not linguistically need.
  2. Placeholder shape — per key, the token set must match en's, compared pack against en. ⇒ both halves carry exactly one count hole in every pack.
  3. A plural family carries a base key — any leaf whose name ends in one of the six CLDR suffixes must have the suffix-less leaf present in the same pack, because i18next resolves the one suffix a number needs and otherwise walks to the base. ⇒ this is the invariant that caps a family at base plus _one plus _other and sends every other category to the base value.

The new key touches none of the three adversely, and the suite is green.

Evidence

Control reading. The fix was committed first, then the two source surfaces were reverted to the branch base with the new tests left in place, the mutation was verified on disk in both directions (new key: 1 call-site hit and 10 packs before, 0 and 0 after; pre-fix call site back: 1 hit; ar marker back: 1 hit), and the restore was proved by an empty git diff HEAD rather than by an exit code.

run result
new tests against the branch base's behaviour 12 failed / 7 passed — including the render assertion Unable to find an element with the text: 1 item available
new tests on this branch 19 passed

Suites and gates, at b5b604daf:

run verdict
vitest run packages/i18n/ 69 files, 1145 tests passed
vitest run packages/app-shell/ 731 files, 7254 passed, 1 skipped
turbo run type-check for both packages (with the ^build closure) 31 tasks successful
turbo run lint for both packages plus the root leg 3 tasks successful, 0 errors
check:i18n-drift exit 0 — No en value changed in this range
check:i18n-keys · check:i18n-dead-keys · check:i18n-designer-parity exit 0
check:vi-mock-specifiers · check:vi-mock-inherit · check:vi-mock-override-shape · check:test-path-roots exit 0
check:control-bytes · check:new-line-citations exit 0
changeset presence · no-major · claims re-read exit 0

Declared narrowings, so the difference between a measurement and a skipped run stays legible: lint was run for the two affected packages out of the 46 that carry a lint script, which is the repo's own "only the affected packages" rule rather than a file-level filter; the repo-wide pnpm test, pnpm check and the workflow's coverage/entry-guard/port-parity/bash-floor steps are CI's run and were not taken locally — nothing in this diff reaches what they read.

⚠️ Note on what a package's type-check covers: tsc --noEmit excludes __tests__ by directory, so the new tests are checked by the second leg of the script (tsc -p tsconfig.test.json), which is part of the green above.

Acceptance notes

Two observations found while measuring this card, neither of them fixed here and neither filed by this seat:

  1. search.resultsCountPlural is wrong in Russian at 2 to 4 — and in Arabic at 2. The adjacent branch this card copies its device from is explicitly out of the fence, and it is repaired here in no way. But the same binary switch that is sufficient on itemsAvailable is not sufficient there, because ru's value on that key does carry an agreeing noun: Russian reads результатов where 2 to 4 needs результата. Named probe: Russian's category at 2 is few, Arabic's at 2 is two, and the pack defines neither. Dedupe words: resultsCountPlural · ru few category · search results plural agreement · two-key switch insufficient · binary plural Russian.
  2. The key-scoped marker guard's repetition bound is one letter short of a real marker it would meet. The class used on preview.history.items bounds the letters inside the parentheses at four; عنصر(عناصر) has five, so that marker scores absent under it while متاح(ة) scores present — both halves of the same retired value. Correct and sufficient for the key it guards today; the bound is the thing that would be false if such a marker arrived there. Measured, and pinned in the other direction in this branch's own test. Dedupe words: parenthesised plural marker · marker regex bound · Unicode property class {1,4} · Arabic broken plural five letters · preview.history.items pin.

Noted, not filed: the drift ledger's waiver ceiling is coupled to a test edit by design — that is the ratchet working, not a defect, and the next seat to touch that ledger is the one who meets it. Carrier: none needed.


Generated by Claude Code

…e item

The full-page search header is one ternary with two branches. The query branch
already picked its key on `totalCount === 1` (`search.resultsCount` against
`search.resultsCountPlural`); the browse branch beside it asked for
`search.itemsAvailable` at every count. That key had no plural family and no
sibling key, so `en` shipped `1 items available` at a single searchable item,
and `de`/`es`/`fr`/`pt` read the same way.

Adds `search.itemsAvailableOne` to all ten packs and picks between the two on
`allItems.length === 1` — this repo's two-key plural convention
(`common.itemCount`/`itemCountOne`, `detail.reactionCount`/`reactionCountOne`),
deliberately not an i18next `_one`/`_other` family: key parity caps a family at
base + `_one` + `_other`, so every other CLDR category falls through to the base
key, which here is the plural — what `ru` meets at 2-4 and `ar` at 2, 3-10 and
11-99. Selecting in the component keeps `Intl.PluralRules` and `fallbackLng` out
of the path.

`zh`/`ja`/`ko` repeat the string (the counter word holds the number) and so does
`ru` (its phrasing states no noun). `ar`'s value changed rather than being copied:
its parenthesised marker covered both numbers on a key that can no longer be
reached at one, so it now uses the noun pair `ar`'s own `common.itemCount` pair
already uses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018HrVaotisyhgmot9o2MLRq
@github-actions

Copy link
Copy Markdown
Contributor

changeset-claim-re-read

⚠️ 1 pending changeset(s) describe a file this change touches

Their bodies publish verbatim into the CHANGELOG at the next release, so this is a request to re-read them against your diff — addressed here because you are the one seat that can answer it without re-deriving anything.

⛔ Nothing here blocks, and nothing here is a verdict on your change. This gate exits 0, is not a required context, and judges name resolution, never meaning: it asked whether a pending body names a file you touched. "Is this sentence still true?" is the one question it will not answer, and the one you are being asked to answer.

.changeset/6661-app-launcher-nav-menu-renderers.md

  • names en.tspackages/i18n/src/locales/en.ts — edited by this change

    Three new strings — the launcher's and the menu's accessible names, and the menu's empty state — are declared under console.nav in en.ts and its nine sibling packs. An inline defaultValue alone is not a fix: it renders English at one call site and leaves the string untranslatable everywhere (objectui#3517).

Read the paragraph, not the line: both false halves of the objectui#8617 claim sat in one paragraph, and correcting either alone would have left it asserting the same wrong thing.

If a claim did go false, correct the body. That is precedented and prose-only, frontmatter untouched; check-changeset-overwrite.mjs will report the correction as its own case 2 ("correcting a declaration on purpose … legitimate"), which is the intended shape — one gate asks for the read, the other records the write.

Not covered, stated so nobody reads this as more: a born-false claim that spells no line address at all (objectui#9495 coordinated one by ORDINAL — "a grep finds that member first" — and deciding that means reading what the sentence means), a claim spelled as a symbol or a package rather than a backticked file name, and a file named ambiguously.

Compared the checked-out tree with 4dbab84d4 (merge-base with origin/main): 13 file(s) changed outside .changeset/, read against 1212 pending declaration(s) that publish a body (1771 pending in total). · run

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 329 chunks) 3056.4 KB 3104.5 KB
Main entry chunk (gzip) 145.7 KB 350 KB
Entry file index-BWX6grga.js
Status PASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 16.69KB 6.21KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 545.92KB 130.72KB
core (index.js) 8.94KB 3.59KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 216.90KB 60.15KB
fields (index.js) 249.62KB 63.02KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 8.87KB 3.64KB
i18n (index.js) 5.22KB 2.26KB
i18n (pickLocalized.js) 9.86KB 3.95KB
i18n (provider.js) 32.15KB 10.49KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.83KB 10.95KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 5.52KB 2.10KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.52KB 4.88KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.39KB 3.10KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 14.81KB 3.63KB
plugin-calendar (index.js) 49.92KB 14.22KB
plugin-charts (index.js) 71.73KB 20.08KB
plugin-chatbot (index.js) 198.20KB 47.14KB
plugin-dashboard (index.js) 132.96KB 35.17KB
plugin-designer (index.js) 215.94KB 44.33KB
plugin-detail (index.js) 255.18KB 66.49KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 139.56KB 35.40KB
plugin-gantt (index.js) 167.62KB 41.26KB
plugin-grid (index.js) 213.44KB 58.21KB
plugin-kanban (index.js) 48.71KB 15.17KB
plugin-list (index.js) 113.55KB 27.99KB
plugin-map (index.js) 21.48KB 6.99KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.41KB 11.93KB
plugin-timeline (index.js) 30.68KB 8.95KB
plugin-tree (index.js) 10.58KB 3.72KB
plugin-view (index.js) 85.18KB 21.05KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 109.04KB 36.08KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 4.25KB 2.04KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (body-dialect.js) 4.38KB 1.98KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.74KB 2.54KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (kanban-quick-add.js) 3.89KB 1.87KB
sdui-parser (parse.js) 25.28KB 7.80KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 15.71KB 5.30KB
types (ai.js) 4.11KB 2.06KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.93KB 1.49KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 4.73KB 2.28KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 14.04KB 5.36KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants