Skip to content

fix(gantt): interpolate the dialog counts through i18next, not a single-brace string replace - #4205

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-4157-gantt-interpolation
Aug 10, 2026
Merged

fix(gantt): interpolate the dialog counts through i18next, not a single-brace string replace#4205
yinlianghui merged 1 commit into
mainfrom
claude/issue-4157-gantt-interpolation

Conversation

@yinlianghui

@yinlianghui yinlianghui commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Fixes #4157

The defect

gantt.conflict.body was resolved at the render site with a literal replace on single braces, while all ten locale packs spell the placeholder the i18next way:

  • packages/plugin-gantt/src/GanttView.tsx:4984t('gantt.conflict.body').replace('{count}', String(pendingConflict.length))
  • packages/i18n/src/locales/zh.ts:726'…是否自动重新排程 {{count}} 个受影响的任务?'

"…{{count}}…".replace("{count}", "2") consumes the inner seven characters and leaves the outer pair, so every user on every loaded pack read a literal {2}. The premise held exactly as filed, re-confirmed on b1e42d09b.

The call site now passes { count } to i18next — the idiom gantt.delete.body already used — rather than downgrading the packs to single braces, so the bundled fallback table and the packs cannot re-drift.

Per-key sweep decisions

The card asked for a sweep of the sibling literal-replace call sites. Measured per key, per pack:

key pack spelling (all 10) call site verdict
gantt.conflict.body {{count}} .replace('{count}') mismatched — the reported defect
gantt.autoScheduleDlg.body {count} .replace('{count}') matched, rendered fine
gantt.autoScheduleDlg.skipped {count} .replace('{count}') matched, rendered fine
gantt.quickFilter.resultSummary {shown} / {total} .replace('{shown}') matched, deliberate, own pin test

Only conflict.body was mismatched. The two autoScheduleDlg keys are converted anyway, and that is a judgment call worth stating plainly: that split is the entire mechanism. Two write-confirmation dialogs three lines apart in one component carried two different interpolation idioms, so when conflict.body acquired the i18next spelling in the packs — which is the correct spelling, and matches every other placeholder in the bundle — the render silently broke. Leaving the auto-schedule keys on the literal-replace idiom leaves the same trap armed for the next translator. Only the braces moved; no translation was reworded.

gantt.quickFilter.resultSummary stays single-brace: its ObjectGantt call site really does resolve with a literal replace, and that convention is pinned by its own parity test. It is now the only key in the gantt namespace on that idiom, and the comments at both spellings now say so instead of pointing at autoScheduleDlg.body as precedent.

Why nothing caught it

Each existing gate was silent for its own reason, and all three reasons are the same shape — they compare relative facts, while the defect lived in the absolute relationship between a pack's spelling and the syntax the call site resolves:

  • all-locales-key-parity's placeholder check compares en against each pack. All eleven spellings agreed.
  • check:i18n-en-drift compares a pack against its own history. The packs never drifted; they were born matching.
  • The existing render test in GanttView.interactions.test.tsx:557 asserts the dialog body contains '1' — which {1} satisfies.

check:i18n-call-site-keys does run an interpolation-parity check in both directions, and it does not catch this. Verified by running the gate's own analyze() against the reverted tree: green. Mechanism is scripts/check-i18n-call-site-keys.mjs:327count is in RESERVED_OPTION_NAMES, and holes are filtered by that set before both directions are computed, so a {{count}} hole is removed and unfilled can never contain it. Re-running with count un-reserved reveals exactly one finding repo-wide, and it is this defect at this line. Filed as #4206; not touched here, per the card's scope.

Tests

Two new files, both written red-first:

  • packages/i18n/src/__tests__/gantt-count-interpolation-4157.test.ts — the absolute pack-spelling invariant across all ten packs, plus byte-exact en defaults.
  • packages/plugin-gantt/src/GanttView.countinterp.i18n.test.tsx — renders the real dialogs inside an I18nProvider under both en and zh. This has to render under a loaded pack: provider-less, useGanttTranslation serves the plugin's own bundled default, which carried the single-brace spelling and so masked the bug entirely. The auto-schedule dialog is the control, and the zh case additionally pins that the localized sentence — not the English fallback — reaches the screen, which is the live risk in passing { count }, since that is also i18next's plural selector.

Both dialogs are gated on a write handler (canDrag for the drag path, autoSchedule && onTaskUpdate for the toolbar wand), so the harness supplies onTaskUpdate by default; without it every case fails on "the dialog did not open" instead of on the placeholder residue it means to pin.

Reverse verification

Reverted the three fix files to origin/main with git checkout origin/main -- …, keeping the new tests. Predicted: conflict cases red with the filing's signature, auto-schedule control cases green (both sides were single-brace on main). Both confirmed — Tests 2 failed | 3 passed:

AssertionError: rendered the reported literal {1}: expected
'排程冲突此次移动与依赖约束冲突。是否自动重新排程 {1} 个受影响的任务?…' not to contain '{1}'

That is the reported symptom character-for-character (the card's {2} at count 2; here count is 1). Restored with git checkout HEAD -- ….

Gate ladder

pnpm exec vitest run packages/i18n/           → Test Files 37 passed, Tests 650 passed
pnpm exec vitest run packages/plugin-gantt/   → Test Files 40 passed, Tests 355 passed
node scripts/check-i18n-call-site-keys.mjs    → green (2277 call sites compared)
node scripts/check-i18n-en-drift.mjs          → green: 2 en values changed, 18 pack values followed
node scripts/check-control-bytes.mjs          → OK (3894 files)
type-check (i18n + plugin-gantt)              → Done
lint (i18n + plugin-gantt)                    → 0 errors

check:i18n-drift's count is the exact shape of this change: 2 en values changed (the two autoScheduleDlg keys), each followed by all nine translation packs.


Generated by Claude Code

…le-brace string replace (#4157)

`gantt.conflict.body` was resolved with `t(key).replace('{count}', n)` while
all ten packs spell the placeholder `{{count}}`. The replace consumed the inner
seven characters and left the outer pair, so every loaded pack rendered a
literal `{2}` in the conflict dialog. The call site now passes `{ count }` to
i18next, the idiom `gantt.delete.body` already used.

The two sibling keys (`autoScheduleDlg.body`, `.skipped`) were not broken --
pack and call site both used single braces -- but they are converted with it:
two write-confirmation dialogs three lines apart carrying two interpolation
idioms is the mechanism that let `conflict.body` drift in the first place. Only
the braces moved; no translation was reworded. `quickFilter.resultSummary`
stays single-brace by design and is now the sole key on that idiom.

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

vercel Bot commented Aug 10, 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 10, 2026 11:27pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

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

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 8.88KB 3.25KB
app-shell (runtime-config.js) 7.42KB 2.32KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 7.57KB 2.97KB
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) 486.25KB 107.52KB
core (index.js) 3.04KB 1.15KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 140.66KB 36.25KB
fields (index.js) 226.96KB 56.30KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.32KB 1.77KB
i18n (index.js) 2.65KB 1.06KB
i18n (pickLocalized.js) 1.70KB 0.83KB
i18n (provider.js) 9.48KB 3.27KB
i18n (useObjectLabel.js) 27.59KB 6.63KB
i18n (useSafeTranslation.js) 4.52KB 1.96KB
layout (index.js) 38.87KB 10.80KB
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) 61.52KB 17.49KB
plugin-chatbot (index.js) 180.33KB 42.79KB
plugin-dashboard (index.js) 118.52KB 30.68KB
plugin-designer (index.js) 210.51KB 42.51KB
plugin-detail (index.js) 237.80KB 59.48KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 114.58KB 27.68KB
plugin-gantt (index.js) 162.77KB 39.66KB
plugin-grid (index.js) 188.04KB 49.91KB
plugin-kanban (index.js) 48.60KB 13.41KB
plugin-list (index.js) 110.04KB 26.67KB
plugin-map (index.js) 17.00KB 5.32KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 40.58KB 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.95KB
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 (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) 2.71KB 1.34KB
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 10, 2026 23:45
@yinlianghui
yinlianghui added this pull request to the merge queue Aug 10, 2026
Merged via the queue into main with commit 828549a Aug 10, 2026
21 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-4157-gantt-interpolation branch August 10, 2026 23:45
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.

[gantt] Conflict dialog renders count placeholder as {2}: locale pack uses {{count}} but render code does .replace('{count}')

2 participants