Skip to content

fix(plugin-gantt): tooltip currency re-formats when the tenant currency resolves (#4542) - #4554

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-4542-gantt-currency-dep
Aug 13, 2026
Merged

fix(plugin-gantt): tooltip currency re-formats when the tenant currency resolves (#4542)#4554
yinlianghui merged 1 commit into
mainfrom
claude/issue-4542-gantt-currency-dep

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes #4542

The currency twin of #4272 / PR #4544, in the same useMemo.

The defect, measured on origin/main (479cc7b)

packages/plugin-gantt/src/ObjectGantt.tsx — the tasks memo builds every tooltip string eagerly inside its callback, and the 'currency' case resolves its code down to the tenant default:

case 'currency':
  return formatCurrency(Number(value), resolveFieldCurrency(def as any, tenantCurrency));

tenantCurrency (L432, from useLocalization()) was read but not watched — the array was [data, ganttConfig, objectSchema, displayLocale].

Independent confirmation before writing a line: ESLint on the unfixed file already reported it, and the fix removes exactly that one message (0 errors both sides, 99 warnings then 98):

line 774 | react-hooks/exhaustive-deps | React Hook useMemo has a missing dependency: 'tenantCurrency'. Either include it or remove the dependency array.

Identity-stability measurement (the STOP condition in the ruling)

The ruling said to stop rather than add a dead dependency if the memo reads currency through a reference that never changes identity. It does change:

  • LocalizationContext.tsx L45-48 memoizes the context object on [value.currency, value.locale], so it takes a new identity exactly when either primitive changes, which re-renders every consumer.
  • tenantCurrency itself is a primitive string | undefined. React compares deps with Object.is, so undefined to 'EUR' is a genuine change.

Premise holds; the dependency is live. Producer timing confirms the window: apps/console/src/LocalizationFetchProvider.tsx L89 does one async setValue after mount, because the endpoint is cosmetic and non-blocking.

Two masking paths, both measured — they shape the test

  1. The locale channel. The producer writes currency and locale from one response, so a tenant that configures BOTH re-runs the memo through PR fix(fields): the date formatter's last three en-US channels thread the display locale (#4272) #4544's displayLocale dep and the staleness never shows. It is observable on a tenant that configures a currency and no locale — the common shape, since useDisplayLocale documents the tenant locale as "frequently undefined", in which case it falls back to the unchanged UI language. The repro therefore resolves { currency: 'EUR' } alone.
  2. ganttConfig identity. getGanttConfig returns a fresh object literal on the flattened top-level (console ListView) path, which invalidates this memo every render by itself; on the schema.gantt path it returns schema.gantt by reference. Only the latter is memoized in practice, so the cases use schema.gantt with a module-constant schema and dataSource.

Red-first: predicted split, written before the run, then measured

case predicted actual pre-fix actual post-fix
re-formats when the tenant currency resolves after first paint RED then GREEN RED GREEN
plain number while the endpoint has not answered (pin) green both green green
currency resolved BEFORE first paint, byte-identical (pin) green both green green
a field's explicit currency outranks the tenant default, before and after (pin) green both green green
PR #4544's displayLocale dep still re-formats on a late tenant locale (pin) green both green green

Predicted failure text, and the verbatim failure that came back — identical:

AssertionError: expected 'Amount=1,234.50' to be 'Amount=€1,234.50' // Object.is equality

Reverse-verified after the fix by removing it with git checkout origin/main -- (not git stash) and re-running: the same one case went red, the four pins stayed green, and the restore was sha256-verified byte-identical.

Verification

  • pnpm exec vitest run --maxWorkers=2 packages/plugin-gantt — 43 files, 378 tests, all green (includes ObjectGantt.dateLocale.test.tsx, PR fix(fields): the date formatter's last three en-US channels thread the display locale (#4272) #4544's pins).
  • pnpm --filter @object-ui/plugin-gantt type-check — both passes (tsc --noEmit and tsc -p tsconfig.test.json) green.
  • ESLint: 0 errors; one warning fewer than the origin/main baseline, the exhaustive-deps one quoted above. No other rule id changed in the multiset.
  • check-control-bytes OK, changeset:check OK, changeset presence OK, check-phantom-dependencies OK.
  • .d.ts diff both ways with dist/ and tsconfig.tsbuildinfo cleared between builds: all 9 declaration files byte-identical, which is what makes patch the right bump. No consumer sweep was needed for the same reason — nothing crosses the package boundary.

Surface

ObjectGantt.tsx (the dependency array plus its comment), one new test file, one changeset. Nothing else touched.

Out of scope, filed not fixed

#4553 — the same tooltip formatter's number / currency / percent rows pass no locale to formatNumber / formatCurrency / formatPercent, so they render in the MACHINE locale right beside date rows that follow the display locale. A different defect at a different site (WHICH locale the string is built in, versus this card's WHEN it is rebuilt); this PR does not move it.


Generated by Claude Code

…cy resolves (#4542)

The `tasks` memo builds every tooltip string eagerly inside its callback, and
the `'currency'` case resolves its code down to the tenant default via
`resolveFieldCurrency(def, tenantCurrency)`. `tenantCurrency` was read but not
watched: it was missing from the memo's dependency array, which ESLint's
`react-hooks/exhaustive-deps` was already reporting on `origin/main`.

The tenant default arrives from `GET /api/v1/auth/me/localization`, which is
cosmetic and non-blocking and therefore answers AFTER first paint. The context
change re-rendered ObjectGantt, but with no dependency changed the memo handed
back its cached task array, so the tooltip kept its pre-resolution rendering.

Not covered by the `displayLocale` dependency #4272 (PR #4544) added to this
same array: the producer writes currency and locale from one response, so a
tenant configuring BOTH re-runs the memo through the locale channel — but a
tenant configuring a currency and no locale (the common shape) leaves
`displayLocale` untouched and the currency stale. The new test resolves
currency alone for exactly that reason.

Module-local: the package's nine `.d.ts` files are byte-identical across the
change, so the changeset is a patch.

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

vercel Bot commented Aug 13, 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 13, 2026 8:02am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 24.7 KB 350 KB
Entry file index-DNVcI_7B.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) 25.13KB 5.40KB
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) 38.46KB 10.17KB
auth (createAuthenticatedFetch.js) 6.34KB 2.43KB
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) 5.02KB 0.88KB
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.32KB 108.45KB
core (index.js) 3.37KB 1.34KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 158.47KB 43.13KB
fields (index.js) 230.18KB 57.13KB
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) 3.69KB 1.73KB
i18n (provider.js) 23.12KB 7.62KB
i18n (useDisplayLocale.js) 2.84KB 1.45KB
i18n (useObjectLabel.js) 27.59KB 6.63KB
i18n (useSafeTranslation.js) 7.77KB 3.13KB
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.75KB 3.80KB
plugin-calendar (index.js) 46.86KB 12.91KB
plugin-charts (index.js) 62.10KB 17.67KB
plugin-chatbot (index.js) 181.21KB 43.14KB
plugin-dashboard (index.js) 120.95KB 31.53KB
plugin-designer (index.js) 212.58KB 42.83KB
plugin-detail (index.js) 239.88KB 59.99KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 114.58KB 27.68KB
plugin-gantt (index.js) 164.27KB 40.01KB
plugin-grid (index.js) 189.34KB 50.32KB
plugin-kanban (index.js) 48.62KB 13.42KB
plugin-list (index.js) 111.13KB 27.12KB
plugin-map (index.js) 18.16KB 5.81KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 41.16KB 10.96KB
plugin-timeline (index.js) 26.68KB 7.66KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 84.08KB 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.73KB 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

Copy link
Copy Markdown
Collaborator Author

PM step-7 复核 — ACCEPT (session_017Qqyix2QcnpUC9XeYVDzx3)

  • The ruling's STOP condition was answered with a three-way measurement (primitive destructure + Object.is + the producer's documented post-mount setValue), and the pre-existing exhaustive-deps warning independently naming tenantCurrency is the kind of corroboration that ends debate — the fix removes exactly that one message and nothing else.
  • The two measured masking paths are this report's best work: the repro resolves {currency:'EUR'} ALONE because a both-configured tenant re-runs the memo through fix(fields): the date formatter's last three en-US channels thread the display locale (#4272) #4544's locale dep, and it drives the schema.gantt BY-REFERENCE path because the ListView path's fresh config object invalidates the memo every render — either wrong choice would have made the red case pass for the wrong reason. Both are documented where the next reader will look.
  • Pins complete and bidirectional: pre-first-paint currency byte-identical, field-explicit currency outranks the late tenant default on both sides, and a NEW dedicated pin guards fix(fields): the date formatter's last three en-US channels thread the display locale (#4272) #4544's locale half of the same array against this card's edit.
  • dts nine-file byte-identity → patch, correct; full gantt suite 43/378; CI 20/20 on per-job conclusions; the honest observability note (staleness confined to currency-configured/locale-unset tenants) is in the changeset where it belongs.
  • Finding [plugin-gantt] tooltip number / currency / percent rows render in the MACHINE locale, next to date rows that follow the display locale #4553 (the tooltip's numeric rows pass NO locale — inverted separators beside correctly localized dates) is filed properly and will be dispatched as the natural completion of this tooltip's one-channel work.

Auto-merge armed (squash) — landing verified per the merge-queue discipline.


Generated by Claude Code


Generated by Claude Code

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.

[plugin-gantt] the tasks useMemo omits tenantCurrency, so tooltip currency can render stale

2 participants