Skip to content

fix(plugin-gantt): resolve a locale-map label in the gantt export filename - #6233

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-6052-gantt-export-filename-i18nlabel
Aug 25, 2026
Merged

fix(plugin-gantt): resolve a locale-map label in the gantt export filename#6233
yinlianghui merged 1 commit into
mainfrom
claude/issue-6052-gantt-export-filename-i18nlabel

Conversation

@yinlianghui

@yinlianghuiyinlianghui commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Fixes#6052

What was wrong

ObjectGantt builds the exportFileName it hands GanttView from a ?? chain whose
second link is schema.label, wrapped in a bare String(...). BaseSchema.label is
string | I18nLabel since #4580's revised Q1-A ruling — I18nLabel being the spec's
INLINE locale MAP — so a gantt authored as

{ "type": "object-gantt", "objectName": "task",
"label": { "en": "Shift Plan", "zh-CN": "排班计划" } }

exported [object Object]-20260825-0256.png. That filename is not a reconstruction: it is
the literal string this branch's ablation run produced (below).

Premise re-verified on the merged ref, as triage asked

The fix

resolveI18nLabel from @objectstack/spec/ui — the producer's own resolver for that
vocabulary — against the display locale already in scope. Imported aliased as
resolveInlineI18nLabel, following PR #4169's convention, because this repo has its own
resolveI18nLabel over the KEYED vocabulary and neither accepts the other's shape.

The resolver answers undefined on an absent label or a total miss, which is why it sits
inside the ?? chain rather than around it: a missing label falls through to the next
link exactly as it always did. No lenient coercion is added — the chain resolves the
declared vocabulary, it does not accept a second one (AGENTS.md #0.1).

objectSchema?.label — measured together, deliberately treated differently

The card asked for the next link to be measured with this one rather than after it. It was,
and it needs the opposite treatment, which is why it is untouched:

objectSchema is the DATA object's metadata, and the spec declares
label: z.string().optional() on ObjectSchemaBase
(packages/spec/src/data/object.zod.ts:1560) — which is a strictObject. A locale map in
that slot is rejected by the producer at parse, not something this consumer should learn
to resolve. Wrapping it in resolveI18nLabel would be accepting a second vocabulary at a
read site, i.e. the exact shape #0.1 rules out, and it would make the consumer tolerant of
metadata the producer refuses. If that slot ever needs locale maps, it widens in the spec
first and this link follows. A CONTROL test pins that the link still passes its declared
plain string through untouched.

Filename sanitisation — the chain already does it, no finding filed

The PM advisory asked whether a resolved locale map can carry filename-hostile characters.
It can, and the chain already sanitises, downstream of this call site:
GanttView.tsx:3065 strips [\\/:*?"<>|\s]+ from the name before appending the timestamp.
A resolved map entry goes through the identical strip a plain string does, so this change
adds no sanitisation and needs none. Nothing to file.

Which assertions would still pass on a revert

Stated plainly, because most of this file is deliberately revert-green. The produced name is
BASE-yyyyMMdd-HHmm.png, so the rows below name the BASE and match the stamp:

AssertionOn a revert
locale map → base 排班计划 for a zh-CN audienceFAILS[object Object]-20260825-0256.png
the same map → base Shift Plan for an en audienceFAILS[object Object]-20260825-0256.png
CONTROL: plain-string label names the file itselfstill passes
CONTROL: explicit exportFileName wins the chainstill passes
CONTROL: no label falls through to objectNamestill passes
CONTROL: no view label falls through to objectSchema.labelstill passes

Four of the six are must-not-change pins by construction: the defect only exists for a MAP,
so a plain-string pin is a phantom assertion, green on both sides. They are here so the fix
cannot pass by breaking the string path or by collapsing another link of the chain.

Verification

All at 3bc4d5852, from the repo root (the canonical Vitest invocation the repo's
vitest-invocation-guard requires).

pnpm exec vitest run packages/plugin-gantt --maxWorkers=2
Test Files 46 passed (46)
Tests 397 passed (397)
pnpm --filter @object-ui/plugin-gantt type-check # tsc --noEmit && tsc -p tsconfig.test.json
exit 0 (after pnpm --filter '@object-ui/plugin-gantt^...' build — without the
dependency closure built, tsc reports 17 phantom TS2307s)
pnpm --filter @object-ui/plugin-gantt lint
✖ 264 problems (0 errors, 264 warnings) # all pre-existing, none in changed files
check:control-bytes ✅ OK (scanned 5140 tracked text file(s))
check:vi-mock-specifiers ✅ OK
check:phantom-deps ✅ Every in-scope import is declared by the package that publishes it.
check:self-import ✅ No package names itself inside its own src/.
check-changeset-no-major ✅ No changeset declares a `major` bump.
check-changeset-presence ✅ 2 source file(s) of 1 released package(s) changed, 1 changeset

Ablation — red measured, not asserted

The fix was committed first, then the single resolved call was reverted to ?? schema.label
under a trap … EXIT INT TERM. The mutation was proved on disk before the run — the injected
text grepped present (1), the removed text grepped absent (0), landing site printed
(ObjectGantt.tsx:1536), git diff --stat showing 1 insertion(+), 1 deletion(-):

 × a locale-map label exports under the zh-CN entry, not "[object Object]"
× the same map exports under the en entry for an en audience
AssertionError: expected '[object Object]-20260825-0256.png' not to contain '[object Object]'
Test Files 1 failed (1)
Tests 2 failed | 4 passed (6)

Predicted before running: two map cases red, four controls green. That is what happened.
The trap restored the file and git diff HEAD --stat was empty afterwards.

Why the pin reads the produced filename, not the prop

The harm is the name of the downloaded file, and the exportFileName prop is one
indirection short of it. The test renders the realGanttView, clicks its real export
button, and reads download off the transient anchor downloadBlob creates — so the
assertion covers GanttView's own character strip and its -yyyyMMdd-HHmm stamp. Image
and canvas.toBlob are stubbed to the minimum the raster path reads, because happy-dom
decodes neither; the filename is under test, the pixels are not.

Scope

packages/plugin-gantt only, plus its changeset. #6051 (24 further undeclared gantt keys)
and #6050 (README basePath) are out of scope here and remain open. No out-of-scope
findings surfaced.


Generated by Claude Code

`ObjectGantt` handed `schema.label` straight to `String(...)` when building
`exportFileName`. `BaseSchema.label` is `string | I18nLabel` since #4580's
revised Q1-A ruling, so a gantt whose label was authored as the spec's inline
locale map exported `[object Object]-<stamp>.png`.
Resolve it with `resolveI18nLabel` from `@objectstack/spec/ui` — the producer's
own resolver for that vocabulary — against the display locale already in scope
via `useDisplayLocale()`. The resolver answers `undefined` on a miss, so it sits
inside the `??` chain and a missing label falls through exactly as before.
`objectSchema?.label`, the next link, is deliberately untouched: it is the DATA
object's label, declared `z.string().optional()` on the spec's
`ObjectSchemaBase` (a `strictObject`), so a locale map there is rejected by the
producer rather than resolved here.
Part of #6052
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3222.6 KB3990.2 KB
Main entry chunk (gzip)153.8 KB350 KB
Entry fileindex-Bwat6EU6.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)10.38KB3.90KB
app-shell (runtime-config.js)18.10KB6.51KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
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)505.28KB114.58KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)171.74KB47.48KB
fields (index.js)238.40KB59.89KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)23.13KB7.63KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)7.77KB3.13KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.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)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.93KB0.88KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.62KB12.83KB
plugin-charts (index.js)64.66KB18.32KB
plugin-chatbot (index.js)188.21KB44.67KB
plugin-dashboard (index.js)133.35KB34.45KB
plugin-designer (index.js)212.32KB42.81KB
plugin-detail (index.js)244.13KB61.93KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)126.07KB30.78KB
plugin-gantt (index.js)164.17KB39.89KB
plugin-grid (index.js)201.14KB54.40KB
plugin-kanban (index.js)52.89KB14.59KB
plugin-list (index.js)111.94KB27.24KB
plugin-map (index.js)20.11KB6.64KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.49KB11.93KB
plugin-timeline (index.js)26.49KB7.59KB
plugin-tree (index.js)9.26KB3.13KB
plugin-view (index.js)84.57KB20.74KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)54.84KB18.43KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.35KB0.70KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)12.13KB3.65KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)7.54KB2.63KB
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)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.49KB2.14KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
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.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
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

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ObjectGantt's export filename stringifies a label locale map to "[object Object]" — BaseSchema.label is string | I18nLabel

2 participants

@yinlianghui@os-litant