Skip to content

fix(console): search the Applications page through the row's own label resolver (#4343) - #4346

Merged
yinlianghui merged 2 commits into
mainfrom
claude/issue-4343-search-filter-keyed-label
Aug 11, 2026
Merged

fix(console): search the Applications page through the row's own label resolver (#4343)#4346
yinlianghui merged 2 commits into
mainfrom
claude/issue-4343-search-filter-keyed-label

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes #4343

Filed out of #4307 / PR #4344, which keyed this page's chrome and introduced the appTitle helper the rows name an app by. That card deliberately left this line alone — it is a different defect class — and recorded it unmeasured for triage.

(Angle brackets are spaced below — Record< string, string > — so GitHub's sanitizer does not eat the type unions. The same precaution PR #4344's body took.)

Gate

PR #4344 was confirmed merged and its squash confirmed as the tip of origin/main734d186a02dc3e2ce74d46c83b7ec3f21bce6112before this worktree was created; the branch is based on that exact commit. The resolveKeyedI18nLabel(label, t) seam this PR reuses is the one #4344 established, not a re-derivation.

The defect

apps/console/src/pages/system/AppManagementPage.tsx, the search filter:

(app.label || '').toLowerCase().includes(q) ||
(app.description || '').toLowerCase().includes(q)

An object is truthy, so || '' never fired for one and .toLowerCase() received the object:

TypeError: (app.label || "").toLowerCase is not a function

Thrown inside filter during render, so it takes the page out rather than degrading search. And invisible until someone types, because if (!searchQuery) return true returns before either read — the page mounts perfectly with the very metadata that kills it one character later. That asymmetry is now pinned as its own case.

Reachability — measured, because it grades the severity

The issue left this open and said so. Measured against the vendored @objectstack/spec 17.0.0-rc.6:

question answer evidence
Can app.label be a non-string today? Yes AppSchema.label: z.ZodUnion< [z.ZodString, z.ZodRecord< z.ZodString, z.ZodString > ] >dist/app.zod-CH7IEmsS.d.ts:637. description is the same union, optional.
Which object dialect is spec-legal? The inline locale map ({ en: 'Storefront', 'zh-CN': '店面' }) I18nLabelSchema = z.union([z.string(), InlineLocaleMapSchema]), objectstack packages/spec/src/ui/i18n.zod.ts:173
Is objectui's keyed form ({ key, defaultValue }) spec-legal here? No — retired InlineLocaleMapSchema keys are regex-constrained to BCP-47 tags or default, and the rejection message names the retired form verbatim: "not by key/defaultValue, which was the retired key-reference form (#5055) and resolves to nothing"
Does any shipping app author a non-string label? No All six first-party apps are plain strings: crm, showcase, todo (examples), setup, account, studio (packages/platform-objects/src/apps/*.app.ts)
Is the map form a delivered capability or a dead declaration? Delivered 31 inline maps ship on three published platform pages (sys-organization.page.ts and siblings); the spec's own comment calls it "a delivered capability, not a convention the runtime ignores"

Grading: reachable through authored metadata today, not live in first-party deployments. A partner or operator authoring label: { en: 'Storefront', 'zh-CN': '店面' } gets a green defineApp parse and then loses the Applications page on the first keystroke. So this is not latent-by-construction — nothing rejects the input — it is simply not exercised by the apps we ship. Changeset graded patch accordingly.

Note the dialect inversion this measurement turned up, which the issue could not have known: the issue names the keyed form as the trigger, and that form is exactly the one the spec rejects for an app label. The reachable trigger is the map form. It does not change the fix — .toLowerCase() on any object throws identically, and both dialects are covered — but it does change which case is the realistic one, so both are tested.

The fix

Both reads now go through the resolver the rows already render with. No second dialect, no String(label):

(app.name || '').toLowerCase().includes(q) ||
appTitle(app).toLowerCase().includes(q) ||
(resolveKeyedI18nLabel(app.description, t) || '').toLowerCase().includes(q)

appTitle is #4344's own one-display-name-per-row helper (it is resolveKeyedI18nLabel(app?.label, t) with the heading's || app.name fallback); the description term is the identical call the description paragraph makes twelve lines below. Three consequences, in order of how much they matter:

  1. The crash is gone for every object shape, which is the issue.
  2. Search matches what the operator can see — the pack's answer for a keyed label rather than its authoring defaultValue, and app.name wherever the heading itself falls back.
  3. Search cannot drift out of step again. The map form is resolved by neither path today (the heading falls back to the name, and search now matches on exactly that), so when I18nLabel now admits an inline per-locale map — audit every read the compiler cannot see, and give Studio a way to author one #4163 widens the resolver, display and search gain the map form in the same commit instead of this filter being left behind a second time.

Point 3 is why the label term routes through appTitle rather than through a bare resolver call: one helper, one meaning of "this app's name".

Same-shape sweep

Swept apps/console/src/pages/system/ for a string method applied directly to an I18nLabel-typed slot:

$ grep -rnE "(label|description)[^,;)]{0,30}\.(toLowerCase|toUpperCase|trim|includes|startsWith|
    endsWith|slice|charAt|split|replace|substring|padStart|localeCompare)\(" \
    apps/console/src/pages/system/*.tsx
   (only the two lines fixed here)

Both hits are in this file and both are fixed. The five sibling pages (AiPendingActionsPage, ApprovalsInboxPage, AuditLogPage, ProfilePage, SystemHubPage) have no same-shape read. Nothing different-shaped was found that needed filing; the one adjacent gap — that neither path resolves the inline map — is #4163's existing audit, cited rather than duplicated.

Tests

AppManagementPage.search.test.tsx (new, 7 cases). #4344's two files are untouched — no edit, and both stay green — which the card required and which is also the honest split: they pin the writes and the keying, not the filter.

The pack mock answers in a real language whose words share no substring with the call site's defaultValue (app.crm.labelVertrieb, never CRM). That is what makes each keyed case two-sided: the resolved text matches and the authoring defaultValue does not, which pins the t argument rather than merely pinning that some resolver was called. Dropping t would resolve the label to a perfectly harmless string and only that case would notice.

One mock detail is reproduced rather than invented: i18next returns '' for a null key (translate(): if (keys == null) return '';, i18next 26.3.6 dist/cjs/i18next.js:544). That is precisely the call resolveKeyedI18nLabel makes when handed a map with no key, so the map case describes the real page instead of the mock.

$ pnpm --workspace-concurrency=2 --filter '@object-ui/console^...' build
   (build closure first — Done)

$ pnpm exec vitest run --maxWorkers=2 apps/console/src/pages/system/__tests__/
   Test Files  5 passed (5)
        Tests  41 passed (41)

$ pnpm --workspace-concurrency=2 --filter '@object-ui/console' type-check
   apps/console type-check$ tsc --noEmit && tsc -b tsconfig.node.json --force   Done

Both of the package's tsc invocations, repo-root vitest (objectui#3378), under flock /tmp/os-heavy-verify.lock with NODE_OPTIONS=--max-old-space-size=4096.

ESLint on the two changed files: 0 errors, 10 warnings — all the pre-existing no-explicit-any convention this page and its sister tests already carry for app records. check:control-bytes: OK (4079 files).

Reverse verification — the prediction was wrong once, and that is the useful part

Direction predicted before running: reverting only the page turns the keyed and map cases red by the TypeError, and leaves the plain-string control green.

First run: 3 red, not the 4 predicted.

The miss was real, not a miscount. The filter is a chain of ||, so a query the NAME satisfies short-circuits before the label is ever read — the map case searched mapped against mapped_app, matched on the name term, and passed on the unfixed page having never reached the throwing expression. It was coverage-shaped and empty, the exact failure the repo's rejection-class rule warns about in the other direction.

Fixed by handing that case a query no earlier term can answer (zzz), which is the only way the label term is evaluated at all. Second run, with the corrected case:

 ×  filters a KEYED label on the text the pack returned
 ×  matches the RESOLVED label, not the authoring `defaultValue`
 ×  filters a KEYED description the same way — the line below the defect
 ×  survives the spec-legal INLINE LOCALE MAP form on a query that MISSES

TypeError: (app.label || "").toLowerCase is not a function
TypeError: (app.description || "").toLowerCase is not a function

 Test Files  1 failed | 4 passed (5)
      Tests  4 failed | 37 passed (41)

All four red by the TypeError itself, with the control, the empty-query case and #4344's 21 cases green — that asymmetry is the whole claim. The rule is now recorded in the file header so the next case written here does not repeat it: a case that matches on the name is testing the name.

Taken out with git checkout origin/main -- < path > and restored with git checkout < branch > -- < path >; never git stash.


Generated by Claude Code

claude added 2 commits August 11, 2026 18:37
…l resolver (#4343)

`AppManagementPage`'s filter read `(app.label || '').toLowerCase()`. `label`
and `description` are `I18nLabel` in `AppSchema` (`string | Record<string,
string>` in @objectstack/spec 17.0.0-rc.6), so a non-string label is spec-legal
metadata — and an object is truthy, so the `|| ''` guard never fired and
`.toLowerCase()` got the object. The TypeError was thrown inside `filter`
during render, taking the page out rather than degrading search, and only once
someone typed: `if (!searchQuery) return true` short-circuits the empty case.

Both reads now go through the resolver the rows already render with — `appTitle`
for the label (the one display-name helper #4307 introduced in this file) and
the identical `resolveKeyedI18nLabel(…, t)` call the description paragraph
makes — so search matches what the operator can see, and a future widening of
the resolver reaches display and search together.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3
Reverse verification predicted four red cases and produced three. The filter is
a chain of `||`, so a query the NAME satisfies short-circuits before the label
is read: the map case searched `mapped` against `mapped_app` and passed on the
UNFIXED page, never reaching the throwing term. It now searches a query that
misses every app, which is the only way that term is evaluated, and the header
records the rule so the next case written here does not repeat it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3
@vercel

vercel Bot commented Aug 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectui Ignored Ignored Aug 11, 2026 7:00pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 29.6 KB 350 KB
Entry file index-Djpc87rx.js
Status PASS

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 9.56KB 3.59KB
app-shell (runtime-config.js) 7.42KB 2.32KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 8.92KB 3.41KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 22.10KB 4.37KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.13KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.64KB 2.21KB
auth (SocialSignInButtons.js) 9.60KB 3.89KB
auth (UserMenu.js) 3.40KB 1.22KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 35.76KB 9.11KB
auth (createAuthenticatedFetch.js) 4.37KB 1.69KB
auth (index.js) 2.35KB 1.07KB
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) 4.91KB 0.87KB
auth (useIsWorkspaceAdmin.js) 1.61KB 0.85KB
collaboration (CommentThread.js) 26.07KB 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.65KB 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) 489.12KB 108.41KB
core (index.js) 3.04KB 1.15KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 150.04KB 39.79KB
fields (index.js) 228.37KB 56.62KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.32KB 1.77KB
i18n (index.js) 3.35KB 1.38KB
i18n (pickLocalized.js) 1.70KB 0.83KB
i18n (provider.js) 23.12KB 7.62KB
i18n (useDisplayLocale.js) 2.33KB 1.20KB
i18n (useObjectLabel.js) 27.59KB 6.63KB
i18n (useSafeTranslation.js) 4.52KB 1.96KB
layout (index.js) 38.98KB 10.85KB
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.74KB
mobile (index.js) 1.50KB 0.62KB
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.71KB 0.42KB
mobile (useResponsiveConfig.js) 1.36KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 8.75KB 3.06KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 3.67KB 1.12KB
permissions (evaluator.js) 4.41KB 1.44KB
permissions (index.js) 0.91KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.52KB
permissions (usePermissions.js) 1.55KB 0.71KB
plugin-ai (index.js) 15.71KB 3.79KB
plugin-calendar (index.js) 45.23KB 12.45KB
plugin-charts (index.js) 62.18KB 17.67KB
plugin-chatbot (index.js) 180.33KB 42.79KB
plugin-dashboard (index.js) 121.56KB 31.56KB
plugin-designer (index.js) 210.91KB 42.67KB
plugin-detail (index.js) 238.95KB 59.76KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 114.58KB 27.68KB
plugin-gantt (index.js) 164.14KB 39.98KB
plugin-grid (index.js) 188.00KB 49.94KB
plugin-kanban (index.js) 48.60KB 13.41KB
plugin-list (index.js) 110.10KB 26.74KB
plugin-map (index.js) 18.05KB 5.80KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 40.60KB 10.58KB
plugin-timeline (index.js) 26.21KB 7.52KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 84.03KB 20.55KB
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.71KB 3.53KB
providers (index.js) 0.44KB 0.22KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.67KB 2.37KB
react (LazyPluginLoader.js) 3.77KB 1.33KB
react (SchemaRenderer.js) 23.71KB 7.96KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.23KB 0.66KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 4.09KB 1.74KB
sdui-parser (index.js) 4.47KB 2.03KB
sdui-parser (parse.js) 10.04KB 2.82KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 4.69KB 1.48KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 0.20KB 0.18KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-retry.js) 4.32KB 2.02KB
types (index.js) 3.05KB 1.52KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
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 (spec-report.js) 5.05KB 1.93KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 0.20KB 0.18KB
types (ui-action.js) 3.40KB 1.71KB
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

@yinlianghui
yinlianghui marked this pull request as ready for review August 11, 2026 19:13
@yinlianghui
yinlianghui added this pull request to the merge queue Aug 11, 2026
Merged via the queue into main with commit 3b4d78e Aug 11, 2026
21 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-4343-search-filter-keyed-label branch August 11, 2026 19:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

AppManagementPage's search filter calls .toLowerCase() on app.label, which throws when the label is the keyed I18nLabel form

2 participants