Skip to content

fix(hooks): stop calling translation hooks inside try/catch (#2879) - #2881

Merged
os-zhuang merged 1 commit into
mainfrom
claude/custom-system-page-i18n-6ni3f8
Jul 28, 2026
Merged

fix(hooks): stop calling translation hooks inside try/catch (#2879)#2881
os-zhuang merged 1 commit into
mainfrom
claude/custom-system-page-i18n-6ni3f8

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Closes#2879.

The bug

Eleven call sites wrapped a React hook in try/catch to make it "provider-safe". useObjectTranslation and useObjectLabel already are — they read context optionally (context?.language || i18n.language || 'en') and fall back to react-i18next's global instance. They never throw. Compare useI18nContext immediately below, which does throw.

So the catch bought nothing and cost correctness: a throw after the hook ran desyncs hook order on the next render, because React matches hooks positionally.

This is not a new discovery — #2595/#2596 fixed exactly this in @object-ui/i18n's createSafeTranslation, and its test file says so explicitly:

Locks the fallback semantics of the safe-translation hooks AFTER the try/catch-around-hook removal … via the testKey probe / per-key detection, not via a caught throw.

Nine plugin-local re-implementations kept their own copy of the bug. plugin-detail's was even named useSafeTranslation — the canonical name, minus the fix.

What changed

TreatmentFiles
Delegate to createSafeTranslationplugin-detail, plugin-timeline, plugin-list, plugin-calendar, plugin-grid/ObjectGrid, plugin-designer, components/data-table
Keep local hook, drop only the try/catchplugin-gantt, plugin-grid/ImportWizard
Call hook directly + probe return valueplugin-timeline/ObjectTimeline, plugin-view/ObjectView

Gantt and ImportWizard are deliberately not consolidated. They fall back per key, not on a single probe — so a host dictionary that translates the common keys but lags on newer ones still resolves what it has. A single-probe factory can't express that, and both files document the reasoning. Consolidating them would have been a silent behaviour regression.

createSafeTranslation now returns language alongside t (purely additive) so data-table's Intl.DateTimeFormat consumer doesn't need a second hook call.

Two things this surfaced that a grep could not

1. Two more violations I'd missed. My initial grep found 9; the lint rule found 11. ObjectView had an inline eslint-disable-next-line react-hooks/rules-of-hooks with the rationale "always the first call; catch only guards a missing provider" — which is the exact misconception the canonical comment refutes.

2. The try/catch was masking a broken test mock. With it removed, ObjectTimeline.test.tsx failed: its vi.mock('@object-ui/react') omits useObjectLabel. The catch had been absorbing that — which is precisely the class of real breakage a swallowed throw hides. Mock completed.

The lint rule

object-ui/no-try-catch-around-hook, error-level, so a twelfth copy fails CI.

My first draft was too broad and I only found out by running it repo-wide — it flagged vi.useRealTimers() (a vitest helper matching use* by coincidence) and renderHook(() => useCondition(…)) inside a try (the hook runs during React's render of the harness, not in the try's flow). Both were real code in this repo. The rule now matches only use* names, accepts member calls solely on React, and resets its try-depth inside nested functions. Both false positives are pinned as valid cases in its tests.

eslint-rules/**/*.test.js matched no vitest project glob, so the local plugin's specs — including the pre-existing no-synthetic-event-trigger and no-inline-spec-config ones — had never run in CI. Now included; all three files pass (25 tests).

Verification

  • Full suite: 7604 passed, 1 skipped, 0 failed
  • ESLint: repo-wide error count identical to baseline (25, all pre-existing); 0 hits for the new rule
  • turbo type-check: clean except a pre-existing maplibre-gl default-export error in plugin-map, verified identical on a stashed baseline
  • Rule verified by construction: fails on the bad pattern, passes on the good one, and on both false-positive shapes

One nuance worth flagging: imports go to @object-ui/i18ndirectly, not via @object-ui/react. I initially routed them through the react barrel, which tripped a module-init cycle — data-table threw at import time in the combined suite while passing in isolation. Importing directly matches what the three existing components consumers already do.

🤖 Generated with Claude Code

https://claude.ai/code/session_01TubWYdWquVkS9dj733sDmC


Generated by Claude Code

Eleven call sites wrapped a React hook in try/catch to make it "provider-safe".
`useObjectTranslation` and `useObjectLabel` already are — they read context
optionally and fall back to react-i18next's global instance, and never throw.
The catch bought nothing and cost correctness: a throw AFTER the hook ran
desyncs hook order on the next render, since React matches hooks positionally.
#2595/#2596 fixed exactly this in `@object-ui/i18n`'s `createSafeTranslation`;
nine plugin-local re-implementations kept their own copy of the bug. Two more
(`ObjectTimeline`, `ObjectView`) were found by the new lint rule and had been
missed by a grep — `ObjectView` had suppressed `react-hooks/rules-of-hooks`
inline to keep its version.
- Six exact re-implementations now delegate to `createSafeTranslation`.
- `data-table` also delegates; `createSafeTranslation` now returns `language`
alongside `t` (purely additive) so date-formatting consumers need only one
hook call.
- `plugin-gantt` and `ImportWizard` KEEP their local hooks: they fall back per
key, which a single-probe factory cannot express and which their comments
justify. Only the try/catch is removed.
- All imports go to `@object-ui/i18n` directly, matching the existing
convention. Routing them through `@object-ui/react` tripped a module-init
cycle — `data-table` blew up at import time in the combined suite while
passing alone.
Adds `object-ui/no-try-catch-around-hook` (error) so a twelfth copy fails CI.
The first draft was too broad; it now matches only `use*` names, accepts member
calls solely on `React` (so `vi.useRealTimers()` is not a hook), and resets its
try-depth inside nested functions (so `renderHook(() => useThing())` in a try is
fine). Both false positives were real code here and are pinned in its tests.
`eslint-rules/**/*.test.js` matched no vitest project glob, so the local
plugin's specs had never run in CI. Now included; all three pass.
`ObjectTimeline`'s mock of `@object-ui/react` omitted `useObjectLabel` — the
removed try/catch had been absorbing that gap, which is precisely the kind of
real breakage a swallowed throw hides. Mock completed.
Full suite 7604 passed; type-check clean except a pre-existing maplibre-gl
default-export error in plugin-map, identical on baseline.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TubWYdWquVkS9dj733sDmC
@vercel

vercelBot commented Jul 28, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectuiIgnoredIgnoredJul 28, 2026 1:27am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Main entry (gzip)27.9 KB350 KB
Entry fileindex-CAJCU47a.js
StatusPASS

📦 Bundle Size Report

PackageSizeGzipped
app-shell (index.js)8.20KB2.97KB
app-shell (runtime-config.js)7.42KB2.32KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)7.57KB2.97KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)22.10KB4.37KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.12KB3.41KB
auth (LoginForm.js)17.86KB5.29KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.43KB2.09KB
auth (SocialSignInButtons.js)9.60KB3.89KB
auth (UserMenu.js)3.40KB1.22KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)35.76KB9.11KB
auth (createAuthenticatedFetch.js)4.37KB1.69KB
auth (index.js)1.83KB0.79KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)4.91KB0.87KB
auth (useIsWorkspaceAdmin.js)1.61KB0.85KB
collaboration (CommentThread.js)18.38KB4.49KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)3.65KB1.42KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.25KB0.53KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)450.34KB98.03KB
core (index.js)2.12KB0.77KB
create-plugin (index.js)9.28KB2.98KB
data-objectstack (index.js)127.78KB32.15KB
fields (index.js)218.37KB53.54KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.32KB1.77KB
i18n (index.js)2.46KB0.96KB
i18n (pickLocalized.js)1.70KB0.83KB
i18n (provider.js)5.37KB1.72KB
i18n (useObjectLabel.js)25.17KB5.80KB
i18n (useSafeTranslation.js)3.26KB1.44KB
layout (index.js)38.45KB10.67KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.74KB
mobile (index.js)1.50KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)4.42KB1.27KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.71KB0.42KB
mobile (useResponsiveConfig.js)1.36KB0.63KB
mobile (useSpecGesture.js)1.77KB0.77KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)6.84KB2.42KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)3.67KB1.12KB
permissions (evaluator.js)4.00KB1.23KB
permissions (index.js)0.91KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.52KB
permissions (usePermissions.js)1.55KB0.71KB
plugin-ai (index.js)15.71KB3.79KB
plugin-calendar (index.js)44.90KB12.35KB
plugin-charts (index.js)47.36KB13.42KB
plugin-chatbot (index.js)179.53KB42.79KB
plugin-dashboard (index.js)109.60KB28.33KB
plugin-designer (index.js)210.56KB42.56KB
plugin-detail (index.js)214.86KB52.39KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)103.47KB25.10KB
plugin-gantt (index.js)162.26KB39.53KB
plugin-grid (index.js)177.88KB46.63KB
plugin-kanban (index.js)47.82KB13.18KB
plugin-list (index.js)98.48KB23.23KB
plugin-map (index.js)16.80KB5.24KB
plugin-markdown (index.js)13.65KB4.67KB
plugin-report (index.js)37.07KB9.81KB
plugin-timeline (index.js)25.03KB7.11KB
plugin-tree (index.js)8.36KB2.81KB
plugin-view (index.js)85.67KB20.85KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.55KB0.67KB
providers (UploadProvider.js)11.71KB3.53KB
providers (index.js)0.44KB0.22KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)3.19KB1.38KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)18.70KB6.09KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.00KB0.55KB
sdui-parser (codegen.js)4.09KB1.74KB
sdui-parser (index.js)2.16KB0.94KB
sdui-parser (parse.js)10.04KB2.82KB
sdui-parser (types.js)0.29KB0.24KB
sdui-parser (validate.js)4.69KB1.48KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)0.20KB0.18KB
types (crud.js)0.20KB0.18KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)0.77KB0.41KB
types (disclosure.js)0.20KB0.18KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (index.js)1.86KB0.91KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)0.20KB0.18KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.04KB1.93KB
types (system-fields.js)2.39KB1.17KB
types (theme.js)0.20KB0.18KB
types (ui-action.js)0.75KB0.46KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

@os-zhuang
os-zhuang marked this pull request as ready for review July 28, 2026 01:32
@os-zhuang
os-zhuang merged commit 7d46648 into mainJul 28, 2026
14 checks passed
@os-zhuang
os-zhuang deleted the claude/custom-system-page-i18n-6ni3f8 branch July 28, 2026 01:32
Sign up for freeto 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.

9 个文件复制了 createSafeTranslation,并保留了 #2595/#2596 已修复的 try/catch-around-hook 违规

2 participants

@os-zhuang@claude