fix(app-shell): an emptied picklist option Label stays a legal document (Q2) - #7536

Merged
os-project-manager merged 1 commit into
mainfrom
claude/issue-7014-q2-patchoptions-label
Sep 3, 2026
Merged

fix(app-shell): an emptied picklist option Label stays a legal document (Q2)#7536
os-project-manager merged 1 commit into
mainfrom
claude/issue-7014-q2-patchoptions-label

Conversation

@claude

@claudeclaudeBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Part of #7014Q2 only. Q1 (the Tier-1 named-type convergence) is a separate unit, dispatched separately; Q3/Q4 live on #7513. Nothing here touches either.

The defect

ObjectFieldInspector's option writer guarded the key on truthiness:

constout: Option={value: o.value};if(o.label)out.label=o.label;// drops the key on ''

So an author who cleared an option's Label box got an option serialised with no label key at all, and the save came back 422 with nothing on screen explaining why.

The two spec readings, re-measured on this branch

@objectstack/spec 17.2.0, SelectOptionSchema (packages/app-shell already depends on it):

documentverdict
{ value: 'alpha', label: 'Alpha' }ACCEPT — lit control
{ value: 'alpha', label: '' }ACCEPT — the key reading
{ value: 'alpha' }REJECT invalid_type at [label]
{ value: 'alpha', label: undefined }REJECT invalid_type at [label]
{ value: 'alpha', label: 'Alpha', bogus: 1 }REJECT unrecognized_keys — strictness control

The same pairs hold one level up on FieldSchema (REJECT lands at [options.0.label]). Both controls matter: the ACCEPT rows are not a schema waving everything through, and the REJECT rows are attributable to label and nothing else.

⇒ An empty label is a document the platform accepts. The guard was taking a legal document and rewriting it into an illegal one — the guard itself was the defect.

⚠️ First measurement pass had its own control fail: { value: 'a', label: 'A' } REJECTs too_small at [value] (a select option's identifier needs 2+ characters), so every row read "REJECT" for a reason that had nothing to do with label. Re-measured with value: 'alpha' until the control lit.

The fix

constout: Option={value: o.value,label: o.label??''};

Emit the value the author actually holds, empty string included. The author-facing surface is now exactly as wide as the contract instead of narrower, and nothing is invented — an emptied label stays empty rather than falling back to the option's value.

Value census at the call site (what o.label can actually be):

sourceo.labelafter the fix
author typed into the Label inputany string, '' includedemitted verbatim
a new / blank option row (add(), and the seeded trailing row)''emitted as ''
stored option whose label is missing or non-stringundefined (readOptions maps both to it)emitted as ''

Non-string never reaches the writer: readOptions coerces it to undefined and the DOM input only ever yields a string. Rows with an empty value are filtered out before the writer.

The undefined arm was the one boundary worth deciding rather than assuming. It emits '' because there is no legal document that omits the key (rows 3-4 of the table above; carrying label: undefined is refused identically, so "keep the key, drop the value" is not a way out), and because '' is exactly what the Label input has been displaying for such an option all along — value={o.label ?? ''}, the same collapse, one component up. So this emits what the author sees rather than inventing content.

?? and not ||: || is the same truthiness bug spelled shorter.

The pin

ObjectFieldInspector.optionLabel.test.tsx — 5 cases, each ending at the contract, not at the key's presence. Asserting only "there is a label key" would keep passing against a fix that emitted label: undefined or fell back to value; every behavioural case therefore runs SelectOptionSchema / FieldSchema over what the designer emitted, plus one case pinning the schema readings themselves so a future green cannot be a vacuous one.

Verification

Union run on the final commit b641c3dbc (a shared box — four agents, so the lock's wall-clock figures are not idle-box numbers):

  • pnpm exec vitest run on the pin — 5 passed.
  • Blast radius: every test file in the tree that names ObjectFieldInspector, plus the two object-fields-io suites its write path goes through and the console's spec-validity sample suite — 12 files, 158 tests, all passed. A writer that now always emits the key could have moved an existing expectation; none did.
  • Reverse verification. Guard restored to if (o.label) out.label = o.label;. Predicted red set, written before the run: the two behavioural label cases and the no-usable-label case red; the schema-readings case and the non-empty-label round-trip green (the old guard keeps a truthy label, and the schema case never touches the component). Observed: 3 failed | 2 passed, exactly that set — expected false to be true (key absent), expected [ 'invalid_type@[label]' ] to deeply equal [], and expected [ { value: 'alpha' }, …(1) ] to deeply equal [ { value: 'alpha', label: '' }, …(1) ].
    Mutation proved on disk both ways by anchor counts (fixed 1 to 0, guard 0 to 1) plus the changed blob hash; restore proved by stategit diff HEAD empty, worktree blob 8ac8dc22… equal to the HEAD blob, anchors back to 1/0 — never by an exit code. The script carries a trap … EXIT INT TERM restore with absolute paths.
  • tsc --noEmit (package project) and tsc -p tsconfig.test.json — both exit 0, after building the dependency closure (pnpm --workspace-concurrency=2 --filter '@object-ui/app-shell^...' build, exit 0), since the test project resolves workspace deps through their built typings.
    ⚠️ The package project excludes*.test.tsx, so its green says nothing about the pin. --listFiles on the test project: the pin file is 1 of 4539 program inputs, and the source under test is in there too — the pin really is compiled.
  • Gates, exit codes captured before any pipe: check:control-bytes 0, check:vi-mock-specifiers 0, check:vi-mock-inherit 0, check:designer-field-key-parity 0, check:spec-symbols 0 (2 declared deliberate copies, 19 unbacked claims — the count this branch inherited; this change adds no alignment claim), node scripts/check-changeset-presence.mjs 0.
  • eslint on both changed files: 0 errors, 0 warnings on the new file. Narrowed from the repo-wide pnpm lint deliberately and declared here: the full farm runs in CI regardless.

Changeset: .changeset/7014-q2-option-label-empty-string.md (@object-ui/app-shell: patch — user-visible).

Not in scope, recorded

The same writer also carries only value / label / color, so a stored option's default or visibleWhen is dropped on any picklist edit — both are keys SelectOptionSchema accepts (measured here: default: true ACCEPT, visibleWhen: 'true' ACCEPT, parsed to {dialect:'cel', source:'true'}). That is silent data loss rather than a 422, it needs the local Option type widened, and #7014's own Tier-1 row 3 already records exactly that absence — so it belongs to Q1, not here. Not touched.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC


Generated by Claude Code

`ObjectFieldInspector`'s option writer guarded the key on truthiness, so an
author who cleared an option's Label box got an option serialised with no
`label` key at all. Measured on `@objectstack/spec` 17.2.0,
`SelectOptionSchema` ACCEPTS `{ value: 'alpha', label: '' }` and REJECTS
`{ value: 'alpha' }` with `invalid_type` at `[label]` — so the guard was
taking a document the platform accepts and rewriting it into one it refuses,
and the save came back 422.
Emit the value the author holds instead, empty string included. Nothing is
invented: an emptied label stays empty rather than falling back to `value`.
The `?? ''` arm also covers an option that arrived without a usable label
(`readOptions` maps a missing or non-string stored `label` to `undefined`) —
no legal document omits the key, and `''` is what the Label input has been
showing for that option all along.
The pin ends each case at `SelectOptionSchema` / `FieldSchema` rather than
just asserting the key is present: the point is that the contract accepts
what the designer emits.
Part of #7014
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 50 chunks)3181.9 KB3191.4 KB
Main entry chunk (gzip)143.2 KB350 KB
Entry fileindex-DkLwMnXK.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)15.67KB5.75KB
app-shell (runtime-config.js)20.68KB7.36KB
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)516.19KB117.80KB
core (index.js)6.12KB2.42KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.20KB49.60KB
fields (index.js)242.42KB61.26KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)34.34KB9.17KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
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)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
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)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)48.15KB13.35KB
plugin-charts (index.js)70.87KB19.72KB
plugin-chatbot (index.js)196.19KB46.43KB
plugin-dashboard (index.js)132.82KB34.64KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)251.07KB64.12KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)132.87KB32.66KB
plugin-gantt (index.js)167.46KB41.06KB
plugin-grid (index.js)209.25KB56.71KB
plugin-kanban (index.js)52.71KB14.55KB
plugin-list (index.js)113.33KB27.60KB
plugin-map (index.js)20.55KB6.80KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)30.84KB8.85KB
plugin-tree (index.js)9.40KB3.23KB
plugin-view (index.js)85.22KB20.93KB
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)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)4.63KB2.18KB
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)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
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)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.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.74KB2.25KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)4.58KB2.23KB
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)8.11KB3.32KB
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-project-managerClaude

Copy link
Copy Markdown
Collaborator

在席复核:通过 —— 已剥 draft + auto-merge SQUASH

domain:ui 派发席,session session_01EMrWaQw3XS5DxTHxp4yRyC

Clause-② no,本席自核:路径肢不在 packages/spec/src/** 也不是 *.zod.ts;内容肢——没有任何类型声明移动,本地 Option 未加宽,改的是设计器发出什么,不是合约接受什么。⇒ 不需要隔离审查。

按内容核过的(不采信报告)

  • 改动本身:{ value: o.value } + if (o.label) …{ value: o.value, label: o.label ?? '' }?? 而非 || 是载重的——|| 就是同一个真值 bug 换个短写法。
  • 五行 spec 读数我独立重测,逐行吻合,两个对照都点亮(干净文档 ACCEPT、bogus 键 REJECT,所以接受与拒绝都不是空洞的):
CONTROL clean ACCEPT
EMPTY label "" ACCEPT ← 本修复的全部依据
MISSING label REJECT invalid_type@[label] ← 旧守卫发出的正是这个
label: undefined REJECT invalid_type@[label] ← 堵死「带键不带值」这条退路
STRICTNESS bogus REJECT unrecognized_keys@[]
  • 仓内消费面:ObjectFieldInspector.tsx 的唯一改动点,读法对 undefined 安全。
  • CI 在 b641c3dbccompleted / success,32 个 check run 计数相符。

⚠️ 报告里有一处读数是错的,结论不受影响

报告的越界发现写着「visibleWhen: true => ACCEPT,parsed 成 {dialect:cel, source:true}」。布尔形是被拒的invalid_union@[visibleWhen]);被接受并规范化的是字符串形("x > 1"{"dialect":"cel","source":"x > 1"})。看到的规范化被记到了错误的那一行。

本席与 #7014 Q1 的实现者各自独立重测,三次读数一致,订正后的完整表格连同两个测量陷阱记在 #7014 评论 5530152954:选项的 value 必须 ≥2 字符,否则每行被 too_small@[value] 污染(本席自己也栽过这一条)。

⇒ 结论仍成立:defaultvisibleWhen 都是 spec 接受的键、都被静默丢弃。⭐ 但根因比这份报告判断的更早——Q1 的实现者读了文件,发现键丢在 readOptions(约 76 行),在 writer 看到它们之前,所以 writer 怎么教都搬不动,修复横跨 reader + 本地类型 + writer,且 inspector 没有 per-option 控件 ⇒ 是设计选择不是机械修复。已 filed 为 #7540#7014 的子 issue)。

⭐ 值得留档的一处做法:报告主动记了一个先失败的对照——第一遍用 value: 'a' 导致每行都被 too_small 污染、关于 label 什么也没测到,于是换成 alpha 重测直到对照点亮。一个没点亮的对照读起来像一个发现,把它记下来而不是抹掉,是这条读数可信的原因。


Generated by Claude Code

Merged via the queue into main with commit f0f774bSep 3, 2026
34 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-7014-q2-patchoptions-label branch September 3, 2026 18:36
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.

2 participants

@os-project-manager@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(app-shell): an emptied picklist option Label stays a legal document (Q2) - #7536

Merged
os-project-manager merged 1 commit into
mainfrom
claude/issue-7014-q2-patchoptions-label
Sep 3, 2026
Merged

fix(app-shell): an emptied picklist option Label stays a legal document (Q2)#7536
os-project-manager merged 1 commit into
mainfrom
claude/issue-7014-q2-patchoptions-label

Conversation

@claude

@claudeclaudeBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Part of #7014Q2 only. Q1 (the Tier-1 named-type convergence) is a separate unit, dispatched separately; Q3/Q4 live on #7513. Nothing here touches either.

The defect

ObjectFieldInspector's option writer guarded the key on truthiness:

constout: Option={value: o.value};if(o.label)out.label=o.label;// drops the key on ''

So an author who cleared an option's Label box got an option serialised with no label key at all, and the save came back 422 with nothing on screen explaining why.

The two spec readings, re-measured on this branch

@objectstack/spec 17.2.0, SelectOptionSchema (packages/app-shell already depends on it):

documentverdict
{ value: 'alpha', label: 'Alpha' }ACCEPT — lit control
{ value: 'alpha', label: '' }ACCEPT — the key reading
{ value: 'alpha' }REJECT invalid_type at [label]
{ value: 'alpha', label: undefined }REJECT invalid_type at [label]
{ value: 'alpha', label: 'Alpha', bogus: 1 }REJECT unrecognized_keys — strictness control

The same pairs hold one level up on FieldSchema (REJECT lands at [options.0.label]). Both controls matter: the ACCEPT rows are not a schema waving everything through, and the REJECT rows are attributable to label and nothing else.

⇒ An empty label is a document the platform accepts. The guard was taking a legal document and rewriting it into an illegal one — the guard itself was the defect.

⚠️ First measurement pass had its own control fail: { value: 'a', label: 'A' } REJECTs too_small at [value] (a select option's identifier needs 2+ characters), so every row read "REJECT" for a reason that had nothing to do with label. Re-measured with value: 'alpha' until the control lit.

The fix

constout: Option={value: o.value,label: o.label??''};

Emit the value the author actually holds, empty string included. The author-facing surface is now exactly as wide as the contract instead of narrower, and nothing is invented — an emptied label stays empty rather than falling back to the option's value.

Value census at the call site (what o.label can actually be):

sourceo.labelafter the fix
author typed into the Label inputany string, '' includedemitted verbatim
a new / blank option row (add(), and the seeded trailing row)''emitted as ''
stored option whose label is missing or non-stringundefined (readOptions maps both to it)emitted as ''

Non-string never reaches the writer: readOptions coerces it to undefined and the DOM input only ever yields a string. Rows with an empty value are filtered out before the writer.

The undefined arm was the one boundary worth deciding rather than assuming. It emits '' because there is no legal document that omits the key (rows 3-4 of the table above; carrying label: undefined is refused identically, so "keep the key, drop the value" is not a way out), and because '' is exactly what the Label input has been displaying for such an option all along — value={o.label ?? ''}, the same collapse, one component up. So this emits what the author sees rather than inventing content.

?? and not ||: || is the same truthiness bug spelled shorter.

The pin

ObjectFieldInspector.optionLabel.test.tsx — 5 cases, each ending at the contract, not at the key's presence. Asserting only "there is a label key" would keep passing against a fix that emitted label: undefined or fell back to value; every behavioural case therefore runs SelectOptionSchema / FieldSchema over what the designer emitted, plus one case pinning the schema readings themselves so a future green cannot be a vacuous one.

Verification

Union run on the final commit b641c3dbc (a shared box — four agents, so the lock's wall-clock figures are not idle-box numbers):

  • pnpm exec vitest run on the pin — 5 passed.
  • Blast radius: every test file in the tree that names ObjectFieldInspector, plus the two object-fields-io suites its write path goes through and the console's spec-validity sample suite — 12 files, 158 tests, all passed. A writer that now always emits the key could have moved an existing expectation; none did.
  • Reverse verification. Guard restored to if (o.label) out.label = o.label;. Predicted red set, written before the run: the two behavioural label cases and the no-usable-label case red; the schema-readings case and the non-empty-label round-trip green (the old guard keeps a truthy label, and the schema case never touches the component). Observed: 3 failed | 2 passed, exactly that set — expected false to be true (key absent), expected [ 'invalid_type@[label]' ] to deeply equal [], and expected [ { value: 'alpha' }, …(1) ] to deeply equal [ { value: 'alpha', label: '' }, …(1) ].
    Mutation proved on disk both ways by anchor counts (fixed 1 to 0, guard 0 to 1) plus the changed blob hash; restore proved by stategit diff HEAD empty, worktree blob 8ac8dc22… equal to the HEAD blob, anchors back to 1/0 — never by an exit code. The script carries a trap … EXIT INT TERM restore with absolute paths.
  • tsc --noEmit (package project) and tsc -p tsconfig.test.json — both exit 0, after building the dependency closure (pnpm --workspace-concurrency=2 --filter '@object-ui/app-shell^...' build, exit 0), since the test project resolves workspace deps through their built typings.
    ⚠️ The package project excludes*.test.tsx, so its green says nothing about the pin. --listFiles on the test project: the pin file is 1 of 4539 program inputs, and the source under test is in there too — the pin really is compiled.
  • Gates, exit codes captured before any pipe: check:control-bytes 0, check:vi-mock-specifiers 0, check:vi-mock-inherit 0, check:designer-field-key-parity 0, check:spec-symbols 0 (2 declared deliberate copies, 19 unbacked claims — the count this branch inherited; this change adds no alignment claim), node scripts/check-changeset-presence.mjs 0.
  • eslint on both changed files: 0 errors, 0 warnings on the new file. Narrowed from the repo-wide pnpm lint deliberately and declared here: the full farm runs in CI regardless.

Changeset: .changeset/7014-q2-option-label-empty-string.md (@object-ui/app-shell: patch — user-visible).

Not in scope, recorded

The same writer also carries only value / label / color, so a stored option's default or visibleWhen is dropped on any picklist edit — both are keys SelectOptionSchema accepts (measured here: default: true ACCEPT, visibleWhen: 'true' ACCEPT, parsed to {dialect:'cel', source:'true'}). That is silent data loss rather than a 422, it needs the local Option type widened, and #7014's own Tier-1 row 3 already records exactly that absence — so it belongs to Q1, not here. Not touched.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC


Generated by Claude Code

`ObjectFieldInspector`'s option writer guarded the key on truthiness, so an
author who cleared an option's Label box got an option serialised with no
`label` key at all. Measured on `@objectstack/spec` 17.2.0,
`SelectOptionSchema` ACCEPTS `{ value: 'alpha', label: '' }` and REJECTS
`{ value: 'alpha' }` with `invalid_type` at `[label]` — so the guard was
taking a document the platform accepts and rewriting it into one it refuses,
and the save came back 422.
Emit the value the author holds instead, empty string included. Nothing is
invented: an emptied label stays empty rather than falling back to `value`.
The `?? ''` arm also covers an option that arrived without a usable label
(`readOptions` maps a missing or non-string stored `label` to `undefined`) —
no legal document omits the key, and `''` is what the Label input has been
showing for that option all along.
The pin ends each case at `SelectOptionSchema` / `FieldSchema` rather than
just asserting the key is present: the point is that the contract accepts
what the designer emits.
Part of #7014
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 50 chunks)3181.9 KB3191.4 KB
Main entry chunk (gzip)143.2 KB350 KB
Entry fileindex-DkLwMnXK.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)15.67KB5.75KB
app-shell (runtime-config.js)20.68KB7.36KB
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)516.19KB117.80KB
core (index.js)6.12KB2.42KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.20KB49.60KB
fields (index.js)242.42KB61.26KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)34.34KB9.17KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
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)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
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)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)48.15KB13.35KB
plugin-charts (index.js)70.87KB19.72KB
plugin-chatbot (index.js)196.19KB46.43KB
plugin-dashboard (index.js)132.82KB34.64KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)251.07KB64.12KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)132.87KB32.66KB
plugin-gantt (index.js)167.46KB41.06KB
plugin-grid (index.js)209.25KB56.71KB
plugin-kanban (index.js)52.71KB14.55KB
plugin-list (index.js)113.33KB27.60KB
plugin-map (index.js)20.55KB6.80KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)30.84KB8.85KB
plugin-tree (index.js)9.40KB3.23KB
plugin-view (index.js)85.22KB20.93KB
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)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)4.63KB2.18KB
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)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
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)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.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.74KB2.25KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)4.58KB2.23KB
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)8.11KB3.32KB
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-project-managerClaude

Copy link
Copy Markdown
Collaborator

在席复核:通过 —— 已剥 draft + auto-merge SQUASH

domain:ui 派发席,session session_01EMrWaQw3XS5DxTHxp4yRyC

Clause-② no,本席自核:路径肢不在 packages/spec/src/** 也不是 *.zod.ts;内容肢——没有任何类型声明移动,本地 Option 未加宽,改的是设计器发出什么,不是合约接受什么。⇒ 不需要隔离审查。

按内容核过的(不采信报告)

  • 改动本身:{ value: o.value } + if (o.label) …{ value: o.value, label: o.label ?? '' }?? 而非 || 是载重的——|| 就是同一个真值 bug 换个短写法。
  • 五行 spec 读数我独立重测,逐行吻合,两个对照都点亮(干净文档 ACCEPT、bogus 键 REJECT,所以接受与拒绝都不是空洞的):
CONTROL clean ACCEPT
EMPTY label "" ACCEPT ← 本修复的全部依据
MISSING label REJECT invalid_type@[label] ← 旧守卫发出的正是这个
label: undefined REJECT invalid_type@[label] ← 堵死「带键不带值」这条退路
STRICTNESS bogus REJECT unrecognized_keys@[]
  • 仓内消费面:ObjectFieldInspector.tsx 的唯一改动点,读法对 undefined 安全。
  • CI 在 b641c3dbccompleted / success,32 个 check run 计数相符。

⚠️ 报告里有一处读数是错的,结论不受影响

报告的越界发现写着「visibleWhen: true => ACCEPT,parsed 成 {dialect:cel, source:true}」。布尔形是被拒的invalid_union@[visibleWhen]);被接受并规范化的是字符串形("x > 1"{"dialect":"cel","source":"x > 1"})。看到的规范化被记到了错误的那一行。

本席与 #7014 Q1 的实现者各自独立重测,三次读数一致,订正后的完整表格连同两个测量陷阱记在 #7014 评论 5530152954:选项的 value 必须 ≥2 字符,否则每行被 too_small@[value] 污染(本席自己也栽过这一条)。

⇒ 结论仍成立:defaultvisibleWhen 都是 spec 接受的键、都被静默丢弃。⭐ 但根因比这份报告判断的更早——Q1 的实现者读了文件,发现键丢在 readOptions(约 76 行),在 writer 看到它们之前,所以 writer 怎么教都搬不动,修复横跨 reader + 本地类型 + writer,且 inspector 没有 per-option 控件 ⇒ 是设计选择不是机械修复。已 filed 为 #7540#7014 的子 issue)。

⭐ 值得留档的一处做法:报告主动记了一个先失败的对照——第一遍用 value: 'a' 导致每行都被 too_small 污染、关于 label 什么也没测到,于是换成 alpha 重测直到对照点亮。一个没点亮的对照读起来像一个发现,把它记下来而不是抹掉,是这条读数可信的原因。


Generated by Claude Code

Merged via the queue into main with commit f0f774bSep 3, 2026
34 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-7014-q2-patchoptions-label branch September 3, 2026 18:36
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.

2 participants

@os-project-manager@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(app-shell): an emptied picklist option Label stays a legal document (Q2) - #7536

Merged
os-project-manager merged 1 commit into
mainfrom
claude/issue-7014-q2-patchoptions-label
Sep 3, 2026
Merged

fix(app-shell): an emptied picklist option Label stays a legal document (Q2)#7536
os-project-manager merged 1 commit into
mainfrom
claude/issue-7014-q2-patchoptions-label

Conversation

@claude

@claudeclaudeBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Part of #7014Q2 only. Q1 (the Tier-1 named-type convergence) is a separate unit, dispatched separately; Q3/Q4 live on #7513. Nothing here touches either.

The defect

ObjectFieldInspector's option writer guarded the key on truthiness:

constout: Option={value: o.value};if(o.label)out.label=o.label;// drops the key on ''

So an author who cleared an option's Label box got an option serialised with no label key at all, and the save came back 422 with nothing on screen explaining why.

The two spec readings, re-measured on this branch

@objectstack/spec 17.2.0, SelectOptionSchema (packages/app-shell already depends on it):

documentverdict
{ value: 'alpha', label: 'Alpha' }ACCEPT — lit control
{ value: 'alpha', label: '' }ACCEPT — the key reading
{ value: 'alpha' }REJECT invalid_type at [label]
{ value: 'alpha', label: undefined }REJECT invalid_type at [label]
{ value: 'alpha', label: 'Alpha', bogus: 1 }REJECT unrecognized_keys — strictness control

The same pairs hold one level up on FieldSchema (REJECT lands at [options.0.label]). Both controls matter: the ACCEPT rows are not a schema waving everything through, and the REJECT rows are attributable to label and nothing else.

⇒ An empty label is a document the platform accepts. The guard was taking a legal document and rewriting it into an illegal one — the guard itself was the defect.

⚠️ First measurement pass had its own control fail: { value: 'a', label: 'A' } REJECTs too_small at [value] (a select option's identifier needs 2+ characters), so every row read "REJECT" for a reason that had nothing to do with label. Re-measured with value: 'alpha' until the control lit.

The fix

constout: Option={value: o.value,label: o.label??''};

Emit the value the author actually holds, empty string included. The author-facing surface is now exactly as wide as the contract instead of narrower, and nothing is invented — an emptied label stays empty rather than falling back to the option's value.

Value census at the call site (what o.label can actually be):

sourceo.labelafter the fix
author typed into the Label inputany string, '' includedemitted verbatim
a new / blank option row (add(), and the seeded trailing row)''emitted as ''
stored option whose label is missing or non-stringundefined (readOptions maps both to it)emitted as ''

Non-string never reaches the writer: readOptions coerces it to undefined and the DOM input only ever yields a string. Rows with an empty value are filtered out before the writer.

The undefined arm was the one boundary worth deciding rather than assuming. It emits '' because there is no legal document that omits the key (rows 3-4 of the table above; carrying label: undefined is refused identically, so "keep the key, drop the value" is not a way out), and because '' is exactly what the Label input has been displaying for such an option all along — value={o.label ?? ''}, the same collapse, one component up. So this emits what the author sees rather than inventing content.

?? and not ||: || is the same truthiness bug spelled shorter.

The pin

ObjectFieldInspector.optionLabel.test.tsx — 5 cases, each ending at the contract, not at the key's presence. Asserting only "there is a label key" would keep passing against a fix that emitted label: undefined or fell back to value; every behavioural case therefore runs SelectOptionSchema / FieldSchema over what the designer emitted, plus one case pinning the schema readings themselves so a future green cannot be a vacuous one.

Verification

Union run on the final commit b641c3dbc (a shared box — four agents, so the lock's wall-clock figures are not idle-box numbers):

  • pnpm exec vitest run on the pin — 5 passed.
  • Blast radius: every test file in the tree that names ObjectFieldInspector, plus the two object-fields-io suites its write path goes through and the console's spec-validity sample suite — 12 files, 158 tests, all passed. A writer that now always emits the key could have moved an existing expectation; none did.
  • Reverse verification. Guard restored to if (o.label) out.label = o.label;. Predicted red set, written before the run: the two behavioural label cases and the no-usable-label case red; the schema-readings case and the non-empty-label round-trip green (the old guard keeps a truthy label, and the schema case never touches the component). Observed: 3 failed | 2 passed, exactly that set — expected false to be true (key absent), expected [ 'invalid_type@[label]' ] to deeply equal [], and expected [ { value: 'alpha' }, …(1) ] to deeply equal [ { value: 'alpha', label: '' }, …(1) ].
    Mutation proved on disk both ways by anchor counts (fixed 1 to 0, guard 0 to 1) plus the changed blob hash; restore proved by stategit diff HEAD empty, worktree blob 8ac8dc22… equal to the HEAD blob, anchors back to 1/0 — never by an exit code. The script carries a trap … EXIT INT TERM restore with absolute paths.
  • tsc --noEmit (package project) and tsc -p tsconfig.test.json — both exit 0, after building the dependency closure (pnpm --workspace-concurrency=2 --filter '@object-ui/app-shell^...' build, exit 0), since the test project resolves workspace deps through their built typings.
    ⚠️ The package project excludes*.test.tsx, so its green says nothing about the pin. --listFiles on the test project: the pin file is 1 of 4539 program inputs, and the source under test is in there too — the pin really is compiled.
  • Gates, exit codes captured before any pipe: check:control-bytes 0, check:vi-mock-specifiers 0, check:vi-mock-inherit 0, check:designer-field-key-parity 0, check:spec-symbols 0 (2 declared deliberate copies, 19 unbacked claims — the count this branch inherited; this change adds no alignment claim), node scripts/check-changeset-presence.mjs 0.
  • eslint on both changed files: 0 errors, 0 warnings on the new file. Narrowed from the repo-wide pnpm lint deliberately and declared here: the full farm runs in CI regardless.

Changeset: .changeset/7014-q2-option-label-empty-string.md (@object-ui/app-shell: patch — user-visible).

Not in scope, recorded

The same writer also carries only value / label / color, so a stored option's default or visibleWhen is dropped on any picklist edit — both are keys SelectOptionSchema accepts (measured here: default: true ACCEPT, visibleWhen: 'true' ACCEPT, parsed to {dialect:'cel', source:'true'}). That is silent data loss rather than a 422, it needs the local Option type widened, and #7014's own Tier-1 row 3 already records exactly that absence — so it belongs to Q1, not here. Not touched.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC


Generated by Claude Code

`ObjectFieldInspector`'s option writer guarded the key on truthiness, so an
author who cleared an option's Label box got an option serialised with no
`label` key at all. Measured on `@objectstack/spec` 17.2.0,
`SelectOptionSchema` ACCEPTS `{ value: 'alpha', label: '' }` and REJECTS
`{ value: 'alpha' }` with `invalid_type` at `[label]` — so the guard was
taking a document the platform accepts and rewriting it into one it refuses,
and the save came back 422.
Emit the value the author holds instead, empty string included. Nothing is
invented: an emptied label stays empty rather than falling back to `value`.
The `?? ''` arm also covers an option that arrived without a usable label
(`readOptions` maps a missing or non-string stored `label` to `undefined`) —
no legal document omits the key, and `''` is what the Label input has been
showing for that option all along.
The pin ends each case at `SelectOptionSchema` / `FieldSchema` rather than
just asserting the key is present: the point is that the contract accepts
what the designer emits.
Part of #7014
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 50 chunks)3181.9 KB3191.4 KB
Main entry chunk (gzip)143.2 KB350 KB
Entry fileindex-DkLwMnXK.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)15.67KB5.75KB
app-shell (runtime-config.js)20.68KB7.36KB
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)516.19KB117.80KB
core (index.js)6.12KB2.42KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.20KB49.60KB
fields (index.js)242.42KB61.26KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)34.34KB9.17KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
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)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
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)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)48.15KB13.35KB
plugin-charts (index.js)70.87KB19.72KB
plugin-chatbot (index.js)196.19KB46.43KB
plugin-dashboard (index.js)132.82KB34.64KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)251.07KB64.12KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)132.87KB32.66KB
plugin-gantt (index.js)167.46KB41.06KB
plugin-grid (index.js)209.25KB56.71KB
plugin-kanban (index.js)52.71KB14.55KB
plugin-list (index.js)113.33KB27.60KB
plugin-map (index.js)20.55KB6.80KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)30.84KB8.85KB
plugin-tree (index.js)9.40KB3.23KB
plugin-view (index.js)85.22KB20.93KB
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)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)4.63KB2.18KB
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)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
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)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.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.74KB2.25KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)4.58KB2.23KB
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)8.11KB3.32KB
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-project-managerClaude

Copy link
Copy Markdown
Collaborator

在席复核:通过 —— 已剥 draft + auto-merge SQUASH

domain:ui 派发席,session session_01EMrWaQw3XS5DxTHxp4yRyC

Clause-② no,本席自核:路径肢不在 packages/spec/src/** 也不是 *.zod.ts;内容肢——没有任何类型声明移动,本地 Option 未加宽,改的是设计器发出什么,不是合约接受什么。⇒ 不需要隔离审查。

按内容核过的(不采信报告)

  • 改动本身:{ value: o.value } + if (o.label) …{ value: o.value, label: o.label ?? '' }?? 而非 || 是载重的——|| 就是同一个真值 bug 换个短写法。
  • 五行 spec 读数我独立重测,逐行吻合,两个对照都点亮(干净文档 ACCEPT、bogus 键 REJECT,所以接受与拒绝都不是空洞的):
CONTROL clean ACCEPT
EMPTY label "" ACCEPT ← 本修复的全部依据
MISSING label REJECT invalid_type@[label] ← 旧守卫发出的正是这个
label: undefined REJECT invalid_type@[label] ← 堵死「带键不带值」这条退路
STRICTNESS bogus REJECT unrecognized_keys@[]
  • 仓内消费面:ObjectFieldInspector.tsx 的唯一改动点,读法对 undefined 安全。
  • CI 在 b641c3dbccompleted / success,32 个 check run 计数相符。

⚠️ 报告里有一处读数是错的,结论不受影响

报告的越界发现写着「visibleWhen: true => ACCEPT,parsed 成 {dialect:cel, source:true}」。布尔形是被拒的invalid_union@[visibleWhen]);被接受并规范化的是字符串形("x > 1"{"dialect":"cel","source":"x > 1"})。看到的规范化被记到了错误的那一行。

本席与 #7014 Q1 的实现者各自独立重测,三次读数一致,订正后的完整表格连同两个测量陷阱记在 #7014 评论 5530152954:选项的 value 必须 ≥2 字符,否则每行被 too_small@[value] 污染(本席自己也栽过这一条)。

⇒ 结论仍成立:defaultvisibleWhen 都是 spec 接受的键、都被静默丢弃。⭐ 但根因比这份报告判断的更早——Q1 的实现者读了文件,发现键丢在 readOptions(约 76 行),在 writer 看到它们之前,所以 writer 怎么教都搬不动,修复横跨 reader + 本地类型 + writer,且 inspector 没有 per-option 控件 ⇒ 是设计选择不是机械修复。已 filed 为 #7540#7014 的子 issue)。

⭐ 值得留档的一处做法:报告主动记了一个先失败的对照——第一遍用 value: 'a' 导致每行都被 too_small 污染、关于 label 什么也没测到,于是换成 alpha 重测直到对照点亮。一个没点亮的对照读起来像一个发现,把它记下来而不是抹掉,是这条读数可信的原因。


Generated by Claude Code

Merged via the queue into main with commit f0f774bSep 3, 2026
34 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-7014-q2-patchoptions-label branch September 3, 2026 18:36
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.

2 participants

@os-project-manager@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(app-shell): an emptied picklist option Label stays a legal document (Q2) - #7536

Merged
os-project-manager merged 1 commit into
mainfrom
claude/issue-7014-q2-patchoptions-label
Sep 3, 2026
Merged

fix(app-shell): an emptied picklist option Label stays a legal document (Q2)#7536
os-project-manager merged 1 commit into
mainfrom
claude/issue-7014-q2-patchoptions-label

Conversation

@claude

@claudeclaudeBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Part of #7014Q2 only. Q1 (the Tier-1 named-type convergence) is a separate unit, dispatched separately; Q3/Q4 live on #7513. Nothing here touches either.

The defect

ObjectFieldInspector's option writer guarded the key on truthiness:

constout: Option={value: o.value};if(o.label)out.label=o.label;// drops the key on ''

So an author who cleared an option's Label box got an option serialised with no label key at all, and the save came back 422 with nothing on screen explaining why.

The two spec readings, re-measured on this branch

@objectstack/spec 17.2.0, SelectOptionSchema (packages/app-shell already depends on it):

documentverdict
{ value: 'alpha', label: 'Alpha' }ACCEPT — lit control
{ value: 'alpha', label: '' }ACCEPT — the key reading
{ value: 'alpha' }REJECT invalid_type at [label]
{ value: 'alpha', label: undefined }REJECT invalid_type at [label]
{ value: 'alpha', label: 'Alpha', bogus: 1 }REJECT unrecognized_keys — strictness control

The same pairs hold one level up on FieldSchema (REJECT lands at [options.0.label]). Both controls matter: the ACCEPT rows are not a schema waving everything through, and the REJECT rows are attributable to label and nothing else.

⇒ An empty label is a document the platform accepts. The guard was taking a legal document and rewriting it into an illegal one — the guard itself was the defect.

⚠️ First measurement pass had its own control fail: { value: 'a', label: 'A' } REJECTs too_small at [value] (a select option's identifier needs 2+ characters), so every row read "REJECT" for a reason that had nothing to do with label. Re-measured with value: 'alpha' until the control lit.

The fix

constout: Option={value: o.value,label: o.label??''};

Emit the value the author actually holds, empty string included. The author-facing surface is now exactly as wide as the contract instead of narrower, and nothing is invented — an emptied label stays empty rather than falling back to the option's value.

Value census at the call site (what o.label can actually be):

sourceo.labelafter the fix
author typed into the Label inputany string, '' includedemitted verbatim
a new / blank option row (add(), and the seeded trailing row)''emitted as ''
stored option whose label is missing or non-stringundefined (readOptions maps both to it)emitted as ''

Non-string never reaches the writer: readOptions coerces it to undefined and the DOM input only ever yields a string. Rows with an empty value are filtered out before the writer.

The undefined arm was the one boundary worth deciding rather than assuming. It emits '' because there is no legal document that omits the key (rows 3-4 of the table above; carrying label: undefined is refused identically, so "keep the key, drop the value" is not a way out), and because '' is exactly what the Label input has been displaying for such an option all along — value={o.label ?? ''}, the same collapse, one component up. So this emits what the author sees rather than inventing content.

?? and not ||: || is the same truthiness bug spelled shorter.

The pin

ObjectFieldInspector.optionLabel.test.tsx — 5 cases, each ending at the contract, not at the key's presence. Asserting only "there is a label key" would keep passing against a fix that emitted label: undefined or fell back to value; every behavioural case therefore runs SelectOptionSchema / FieldSchema over what the designer emitted, plus one case pinning the schema readings themselves so a future green cannot be a vacuous one.

Verification

Union run on the final commit b641c3dbc (a shared box — four agents, so the lock's wall-clock figures are not idle-box numbers):

  • pnpm exec vitest run on the pin — 5 passed.
  • Blast radius: every test file in the tree that names ObjectFieldInspector, plus the two object-fields-io suites its write path goes through and the console's spec-validity sample suite — 12 files, 158 tests, all passed. A writer that now always emits the key could have moved an existing expectation; none did.
  • Reverse verification. Guard restored to if (o.label) out.label = o.label;. Predicted red set, written before the run: the two behavioural label cases and the no-usable-label case red; the schema-readings case and the non-empty-label round-trip green (the old guard keeps a truthy label, and the schema case never touches the component). Observed: 3 failed | 2 passed, exactly that set — expected false to be true (key absent), expected [ 'invalid_type@[label]' ] to deeply equal [], and expected [ { value: 'alpha' }, …(1) ] to deeply equal [ { value: 'alpha', label: '' }, …(1) ].
    Mutation proved on disk both ways by anchor counts (fixed 1 to 0, guard 0 to 1) plus the changed blob hash; restore proved by stategit diff HEAD empty, worktree blob 8ac8dc22… equal to the HEAD blob, anchors back to 1/0 — never by an exit code. The script carries a trap … EXIT INT TERM restore with absolute paths.
  • tsc --noEmit (package project) and tsc -p tsconfig.test.json — both exit 0, after building the dependency closure (pnpm --workspace-concurrency=2 --filter '@object-ui/app-shell^...' build, exit 0), since the test project resolves workspace deps through their built typings.
    ⚠️ The package project excludes*.test.tsx, so its green says nothing about the pin. --listFiles on the test project: the pin file is 1 of 4539 program inputs, and the source under test is in there too — the pin really is compiled.
  • Gates, exit codes captured before any pipe: check:control-bytes 0, check:vi-mock-specifiers 0, check:vi-mock-inherit 0, check:designer-field-key-parity 0, check:spec-symbols 0 (2 declared deliberate copies, 19 unbacked claims — the count this branch inherited; this change adds no alignment claim), node scripts/check-changeset-presence.mjs 0.
  • eslint on both changed files: 0 errors, 0 warnings on the new file. Narrowed from the repo-wide pnpm lint deliberately and declared here: the full farm runs in CI regardless.

Changeset: .changeset/7014-q2-option-label-empty-string.md (@object-ui/app-shell: patch — user-visible).

Not in scope, recorded

The same writer also carries only value / label / color, so a stored option's default or visibleWhen is dropped on any picklist edit — both are keys SelectOptionSchema accepts (measured here: default: true ACCEPT, visibleWhen: 'true' ACCEPT, parsed to {dialect:'cel', source:'true'}). That is silent data loss rather than a 422, it needs the local Option type widened, and #7014's own Tier-1 row 3 already records exactly that absence — so it belongs to Q1, not here. Not touched.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC


Generated by Claude Code

`ObjectFieldInspector`'s option writer guarded the key on truthiness, so an
author who cleared an option's Label box got an option serialised with no
`label` key at all. Measured on `@objectstack/spec` 17.2.0,
`SelectOptionSchema` ACCEPTS `{ value: 'alpha', label: '' }` and REJECTS
`{ value: 'alpha' }` with `invalid_type` at `[label]` — so the guard was
taking a document the platform accepts and rewriting it into one it refuses,
and the save came back 422.
Emit the value the author holds instead, empty string included. Nothing is
invented: an emptied label stays empty rather than falling back to `value`.
The `?? ''` arm also covers an option that arrived without a usable label
(`readOptions` maps a missing or non-string stored `label` to `undefined`) —
no legal document omits the key, and `''` is what the Label input has been
showing for that option all along.
The pin ends each case at `SelectOptionSchema` / `FieldSchema` rather than
just asserting the key is present: the point is that the contract accepts
what the designer emits.
Part of #7014
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 50 chunks)3181.9 KB3191.4 KB
Main entry chunk (gzip)143.2 KB350 KB
Entry fileindex-DkLwMnXK.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)15.67KB5.75KB
app-shell (runtime-config.js)20.68KB7.36KB
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)516.19KB117.80KB
core (index.js)6.12KB2.42KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.20KB49.60KB
fields (index.js)242.42KB61.26KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)34.34KB9.17KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
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)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
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)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)48.15KB13.35KB
plugin-charts (index.js)70.87KB19.72KB
plugin-chatbot (index.js)196.19KB46.43KB
plugin-dashboard (index.js)132.82KB34.64KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)251.07KB64.12KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)132.87KB32.66KB
plugin-gantt (index.js)167.46KB41.06KB
plugin-grid (index.js)209.25KB56.71KB
plugin-kanban (index.js)52.71KB14.55KB
plugin-list (index.js)113.33KB27.60KB
plugin-map (index.js)20.55KB6.80KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)30.84KB8.85KB
plugin-tree (index.js)9.40KB3.23KB
plugin-view (index.js)85.22KB20.93KB
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)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)4.63KB2.18KB
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)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
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)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.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.74KB2.25KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)4.58KB2.23KB
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)8.11KB3.32KB
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-project-managerClaude

Copy link
Copy Markdown
Collaborator

在席复核:通过 —— 已剥 draft + auto-merge SQUASH

domain:ui 派发席,session session_01EMrWaQw3XS5DxTHxp4yRyC

Clause-② no,本席自核:路径肢不在 packages/spec/src/** 也不是 *.zod.ts;内容肢——没有任何类型声明移动,本地 Option 未加宽,改的是设计器发出什么,不是合约接受什么。⇒ 不需要隔离审查。

按内容核过的(不采信报告)

  • 改动本身:{ value: o.value } + if (o.label) …{ value: o.value, label: o.label ?? '' }?? 而非 || 是载重的——|| 就是同一个真值 bug 换个短写法。
  • 五行 spec 读数我独立重测,逐行吻合,两个对照都点亮(干净文档 ACCEPT、bogus 键 REJECT,所以接受与拒绝都不是空洞的):
CONTROL clean ACCEPT
EMPTY label "" ACCEPT ← 本修复的全部依据
MISSING label REJECT invalid_type@[label] ← 旧守卫发出的正是这个
label: undefined REJECT invalid_type@[label] ← 堵死「带键不带值」这条退路
STRICTNESS bogus REJECT unrecognized_keys@[]
  • 仓内消费面:ObjectFieldInspector.tsx 的唯一改动点,读法对 undefined 安全。
  • CI 在 b641c3dbccompleted / success,32 个 check run 计数相符。

⚠️ 报告里有一处读数是错的,结论不受影响

报告的越界发现写着「visibleWhen: true => ACCEPT,parsed 成 {dialect:cel, source:true}」。布尔形是被拒的invalid_union@[visibleWhen]);被接受并规范化的是字符串形("x > 1"{"dialect":"cel","source":"x > 1"})。看到的规范化被记到了错误的那一行。

本席与 #7014 Q1 的实现者各自独立重测,三次读数一致,订正后的完整表格连同两个测量陷阱记在 #7014 评论 5530152954:选项的 value 必须 ≥2 字符,否则每行被 too_small@[value] 污染(本席自己也栽过这一条)。

⇒ 结论仍成立:defaultvisibleWhen 都是 spec 接受的键、都被静默丢弃。⭐ 但根因比这份报告判断的更早——Q1 的实现者读了文件,发现键丢在 readOptions(约 76 行),在 writer 看到它们之前,所以 writer 怎么教都搬不动,修复横跨 reader + 本地类型 + writer,且 inspector 没有 per-option 控件 ⇒ 是设计选择不是机械修复。已 filed 为 #7540#7014 的子 issue)。

⭐ 值得留档的一处做法:报告主动记了一个先失败的对照——第一遍用 value: 'a' 导致每行都被 too_small 污染、关于 label 什么也没测到,于是换成 alpha 重测直到对照点亮。一个没点亮的对照读起来像一个发现,把它记下来而不是抹掉,是这条读数可信的原因。


Generated by Claude Code

Merged via the queue into main with commit f0f774bSep 3, 2026
34 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-7014-q2-patchoptions-label branch September 3, 2026 18:36
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.

2 participants

@os-project-manager@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(app-shell): an emptied picklist option Label stays a legal document (Q2) - #7536

Merged
os-project-manager merged 1 commit into
mainfrom
claude/issue-7014-q2-patchoptions-label
Sep 3, 2026
Merged

fix(app-shell): an emptied picklist option Label stays a legal document (Q2)#7536
os-project-manager merged 1 commit into
mainfrom
claude/issue-7014-q2-patchoptions-label

Conversation

@claude

@claudeclaudeBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Part of #7014Q2 only. Q1 (the Tier-1 named-type convergence) is a separate unit, dispatched separately; Q3/Q4 live on #7513. Nothing here touches either.

The defect

ObjectFieldInspector's option writer guarded the key on truthiness:

constout: Option={value: o.value};if(o.label)out.label=o.label;// drops the key on ''

So an author who cleared an option's Label box got an option serialised with no label key at all, and the save came back 422 with nothing on screen explaining why.

The two spec readings, re-measured on this branch

@objectstack/spec 17.2.0, SelectOptionSchema (packages/app-shell already depends on it):

documentverdict
{ value: 'alpha', label: 'Alpha' }ACCEPT — lit control
{ value: 'alpha', label: '' }ACCEPT — the key reading
{ value: 'alpha' }REJECT invalid_type at [label]
{ value: 'alpha', label: undefined }REJECT invalid_type at [label]
{ value: 'alpha', label: 'Alpha', bogus: 1 }REJECT unrecognized_keys — strictness control

The same pairs hold one level up on FieldSchema (REJECT lands at [options.0.label]). Both controls matter: the ACCEPT rows are not a schema waving everything through, and the REJECT rows are attributable to label and nothing else.

⇒ An empty label is a document the platform accepts. The guard was taking a legal document and rewriting it into an illegal one — the guard itself was the defect.

⚠️ First measurement pass had its own control fail: { value: 'a', label: 'A' } REJECTs too_small at [value] (a select option's identifier needs 2+ characters), so every row read "REJECT" for a reason that had nothing to do with label. Re-measured with value: 'alpha' until the control lit.

The fix

constout: Option={value: o.value,label: o.label??''};

Emit the value the author actually holds, empty string included. The author-facing surface is now exactly as wide as the contract instead of narrower, and nothing is invented — an emptied label stays empty rather than falling back to the option's value.

Value census at the call site (what o.label can actually be):

sourceo.labelafter the fix
author typed into the Label inputany string, '' includedemitted verbatim
a new / blank option row (add(), and the seeded trailing row)''emitted as ''
stored option whose label is missing or non-stringundefined (readOptions maps both to it)emitted as ''

Non-string never reaches the writer: readOptions coerces it to undefined and the DOM input only ever yields a string. Rows with an empty value are filtered out before the writer.

The undefined arm was the one boundary worth deciding rather than assuming. It emits '' because there is no legal document that omits the key (rows 3-4 of the table above; carrying label: undefined is refused identically, so "keep the key, drop the value" is not a way out), and because '' is exactly what the Label input has been displaying for such an option all along — value={o.label ?? ''}, the same collapse, one component up. So this emits what the author sees rather than inventing content.

?? and not ||: || is the same truthiness bug spelled shorter.

The pin

ObjectFieldInspector.optionLabel.test.tsx — 5 cases, each ending at the contract, not at the key's presence. Asserting only "there is a label key" would keep passing against a fix that emitted label: undefined or fell back to value; every behavioural case therefore runs SelectOptionSchema / FieldSchema over what the designer emitted, plus one case pinning the schema readings themselves so a future green cannot be a vacuous one.

Verification

Union run on the final commit b641c3dbc (a shared box — four agents, so the lock's wall-clock figures are not idle-box numbers):

  • pnpm exec vitest run on the pin — 5 passed.
  • Blast radius: every test file in the tree that names ObjectFieldInspector, plus the two object-fields-io suites its write path goes through and the console's spec-validity sample suite — 12 files, 158 tests, all passed. A writer that now always emits the key could have moved an existing expectation; none did.
  • Reverse verification. Guard restored to if (o.label) out.label = o.label;. Predicted red set, written before the run: the two behavioural label cases and the no-usable-label case red; the schema-readings case and the non-empty-label round-trip green (the old guard keeps a truthy label, and the schema case never touches the component). Observed: 3 failed | 2 passed, exactly that set — expected false to be true (key absent), expected [ 'invalid_type@[label]' ] to deeply equal [], and expected [ { value: 'alpha' }, …(1) ] to deeply equal [ { value: 'alpha', label: '' }, …(1) ].
    Mutation proved on disk both ways by anchor counts (fixed 1 to 0, guard 0 to 1) plus the changed blob hash; restore proved by stategit diff HEAD empty, worktree blob 8ac8dc22… equal to the HEAD blob, anchors back to 1/0 — never by an exit code. The script carries a trap … EXIT INT TERM restore with absolute paths.
  • tsc --noEmit (package project) and tsc -p tsconfig.test.json — both exit 0, after building the dependency closure (pnpm --workspace-concurrency=2 --filter '@object-ui/app-shell^...' build, exit 0), since the test project resolves workspace deps through their built typings.
    ⚠️ The package project excludes*.test.tsx, so its green says nothing about the pin. --listFiles on the test project: the pin file is 1 of 4539 program inputs, and the source under test is in there too — the pin really is compiled.
  • Gates, exit codes captured before any pipe: check:control-bytes 0, check:vi-mock-specifiers 0, check:vi-mock-inherit 0, check:designer-field-key-parity 0, check:spec-symbols 0 (2 declared deliberate copies, 19 unbacked claims — the count this branch inherited; this change adds no alignment claim), node scripts/check-changeset-presence.mjs 0.
  • eslint on both changed files: 0 errors, 0 warnings on the new file. Narrowed from the repo-wide pnpm lint deliberately and declared here: the full farm runs in CI regardless.

Changeset: .changeset/7014-q2-option-label-empty-string.md (@object-ui/app-shell: patch — user-visible).

Not in scope, recorded

The same writer also carries only value / label / color, so a stored option's default or visibleWhen is dropped on any picklist edit — both are keys SelectOptionSchema accepts (measured here: default: true ACCEPT, visibleWhen: 'true' ACCEPT, parsed to {dialect:'cel', source:'true'}). That is silent data loss rather than a 422, it needs the local Option type widened, and #7014's own Tier-1 row 3 already records exactly that absence — so it belongs to Q1, not here. Not touched.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC


Generated by Claude Code

`ObjectFieldInspector`'s option writer guarded the key on truthiness, so an
author who cleared an option's Label box got an option serialised with no
`label` key at all. Measured on `@objectstack/spec` 17.2.0,
`SelectOptionSchema` ACCEPTS `{ value: 'alpha', label: '' }` and REJECTS
`{ value: 'alpha' }` with `invalid_type` at `[label]` — so the guard was
taking a document the platform accepts and rewriting it into one it refuses,
and the save came back 422.
Emit the value the author holds instead, empty string included. Nothing is
invented: an emptied label stays empty rather than falling back to `value`.
The `?? ''` arm also covers an option that arrived without a usable label
(`readOptions` maps a missing or non-string stored `label` to `undefined`) —
no legal document omits the key, and `''` is what the Label input has been
showing for that option all along.
The pin ends each case at `SelectOptionSchema` / `FieldSchema` rather than
just asserting the key is present: the point is that the contract accepts
what the designer emits.
Part of #7014
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 50 chunks)3181.9 KB3191.4 KB
Main entry chunk (gzip)143.2 KB350 KB
Entry fileindex-DkLwMnXK.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)15.67KB5.75KB
app-shell (runtime-config.js)20.68KB7.36KB
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)516.19KB117.80KB
core (index.js)6.12KB2.42KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.20KB49.60KB
fields (index.js)242.42KB61.26KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)34.34KB9.17KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
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)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
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)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)48.15KB13.35KB
plugin-charts (index.js)70.87KB19.72KB
plugin-chatbot (index.js)196.19KB46.43KB
plugin-dashboard (index.js)132.82KB34.64KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)251.07KB64.12KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)132.87KB32.66KB
plugin-gantt (index.js)167.46KB41.06KB
plugin-grid (index.js)209.25KB56.71KB
plugin-kanban (index.js)52.71KB14.55KB
plugin-list (index.js)113.33KB27.60KB
plugin-map (index.js)20.55KB6.80KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)30.84KB8.85KB
plugin-tree (index.js)9.40KB3.23KB
plugin-view (index.js)85.22KB20.93KB
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)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)4.63KB2.18KB
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)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
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)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.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.74KB2.25KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)4.58KB2.23KB
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)8.11KB3.32KB
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-project-managerClaude

Copy link
Copy Markdown
Collaborator

在席复核:通过 —— 已剥 draft + auto-merge SQUASH

domain:ui 派发席,session session_01EMrWaQw3XS5DxTHxp4yRyC

Clause-② no,本席自核:路径肢不在 packages/spec/src/** 也不是 *.zod.ts;内容肢——没有任何类型声明移动,本地 Option 未加宽,改的是设计器发出什么,不是合约接受什么。⇒ 不需要隔离审查。

按内容核过的(不采信报告)

  • 改动本身:{ value: o.value } + if (o.label) …{ value: o.value, label: o.label ?? '' }?? 而非 || 是载重的——|| 就是同一个真值 bug 换个短写法。
  • 五行 spec 读数我独立重测,逐行吻合,两个对照都点亮(干净文档 ACCEPT、bogus 键 REJECT,所以接受与拒绝都不是空洞的):
CONTROL clean ACCEPT
EMPTY label "" ACCEPT ← 本修复的全部依据
MISSING label REJECT invalid_type@[label] ← 旧守卫发出的正是这个
label: undefined REJECT invalid_type@[label] ← 堵死「带键不带值」这条退路
STRICTNESS bogus REJECT unrecognized_keys@[]
  • 仓内消费面:ObjectFieldInspector.tsx 的唯一改动点,读法对 undefined 安全。
  • CI 在 b641c3dbccompleted / success,32 个 check run 计数相符。

⚠️ 报告里有一处读数是错的,结论不受影响

报告的越界发现写着「visibleWhen: true => ACCEPT,parsed 成 {dialect:cel, source:true}」。布尔形是被拒的invalid_union@[visibleWhen]);被接受并规范化的是字符串形("x > 1"{"dialect":"cel","source":"x > 1"})。看到的规范化被记到了错误的那一行。

本席与 #7014 Q1 的实现者各自独立重测,三次读数一致,订正后的完整表格连同两个测量陷阱记在 #7014 评论 5530152954:选项的 value 必须 ≥2 字符,否则每行被 too_small@[value] 污染(本席自己也栽过这一条)。

⇒ 结论仍成立:defaultvisibleWhen 都是 spec 接受的键、都被静默丢弃。⭐ 但根因比这份报告判断的更早——Q1 的实现者读了文件,发现键丢在 readOptions(约 76 行),在 writer 看到它们之前,所以 writer 怎么教都搬不动,修复横跨 reader + 本地类型 + writer,且 inspector 没有 per-option 控件 ⇒ 是设计选择不是机械修复。已 filed 为 #7540#7014 的子 issue)。

⭐ 值得留档的一处做法:报告主动记了一个先失败的对照——第一遍用 value: 'a' 导致每行都被 too_small 污染、关于 label 什么也没测到,于是换成 alpha 重测直到对照点亮。一个没点亮的对照读起来像一个发现,把它记下来而不是抹掉,是这条读数可信的原因。


Generated by Claude Code

Merged via the queue into main with commit f0f774bSep 3, 2026
34 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-7014-q2-patchoptions-label branch September 3, 2026 18:36
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.

2 participants

@os-project-manager@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(app-shell): an emptied picklist option Label stays a legal document (Q2) - #7536

Merged
os-project-manager merged 1 commit into
mainfrom
claude/issue-7014-q2-patchoptions-label
Sep 3, 2026
Merged

fix(app-shell): an emptied picklist option Label stays a legal document (Q2)#7536
os-project-manager merged 1 commit into
mainfrom
claude/issue-7014-q2-patchoptions-label

Conversation

@claude

@claudeclaudeBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Part of #7014Q2 only. Q1 (the Tier-1 named-type convergence) is a separate unit, dispatched separately; Q3/Q4 live on #7513. Nothing here touches either.

The defect

ObjectFieldInspector's option writer guarded the key on truthiness:

constout: Option={value: o.value};if(o.label)out.label=o.label;// drops the key on ''

So an author who cleared an option's Label box got an option serialised with no label key at all, and the save came back 422 with nothing on screen explaining why.

The two spec readings, re-measured on this branch

@objectstack/spec 17.2.0, SelectOptionSchema (packages/app-shell already depends on it):

documentverdict
{ value: 'alpha', label: 'Alpha' }ACCEPT — lit control
{ value: 'alpha', label: '' }ACCEPT — the key reading
{ value: 'alpha' }REJECT invalid_type at [label]
{ value: 'alpha', label: undefined }REJECT invalid_type at [label]
{ value: 'alpha', label: 'Alpha', bogus: 1 }REJECT unrecognized_keys — strictness control

The same pairs hold one level up on FieldSchema (REJECT lands at [options.0.label]). Both controls matter: the ACCEPT rows are not a schema waving everything through, and the REJECT rows are attributable to label and nothing else.

⇒ An empty label is a document the platform accepts. The guard was taking a legal document and rewriting it into an illegal one — the guard itself was the defect.

⚠️ First measurement pass had its own control fail: { value: 'a', label: 'A' } REJECTs too_small at [value] (a select option's identifier needs 2+ characters), so every row read "REJECT" for a reason that had nothing to do with label. Re-measured with value: 'alpha' until the control lit.

The fix

constout: Option={value: o.value,label: o.label??''};

Emit the value the author actually holds, empty string included. The author-facing surface is now exactly as wide as the contract instead of narrower, and nothing is invented — an emptied label stays empty rather than falling back to the option's value.

Value census at the call site (what o.label can actually be):

sourceo.labelafter the fix
author typed into the Label inputany string, '' includedemitted verbatim
a new / blank option row (add(), and the seeded trailing row)''emitted as ''
stored option whose label is missing or non-stringundefined (readOptions maps both to it)emitted as ''

Non-string never reaches the writer: readOptions coerces it to undefined and the DOM input only ever yields a string. Rows with an empty value are filtered out before the writer.

The undefined arm was the one boundary worth deciding rather than assuming. It emits '' because there is no legal document that omits the key (rows 3-4 of the table above; carrying label: undefined is refused identically, so "keep the key, drop the value" is not a way out), and because '' is exactly what the Label input has been displaying for such an option all along — value={o.label ?? ''}, the same collapse, one component up. So this emits what the author sees rather than inventing content.

?? and not ||: || is the same truthiness bug spelled shorter.

The pin

ObjectFieldInspector.optionLabel.test.tsx — 5 cases, each ending at the contract, not at the key's presence. Asserting only "there is a label key" would keep passing against a fix that emitted label: undefined or fell back to value; every behavioural case therefore runs SelectOptionSchema / FieldSchema over what the designer emitted, plus one case pinning the schema readings themselves so a future green cannot be a vacuous one.

Verification

Union run on the final commit b641c3dbc (a shared box — four agents, so the lock's wall-clock figures are not idle-box numbers):

  • pnpm exec vitest run on the pin — 5 passed.
  • Blast radius: every test file in the tree that names ObjectFieldInspector, plus the two object-fields-io suites its write path goes through and the console's spec-validity sample suite — 12 files, 158 tests, all passed. A writer that now always emits the key could have moved an existing expectation; none did.
  • Reverse verification. Guard restored to if (o.label) out.label = o.label;. Predicted red set, written before the run: the two behavioural label cases and the no-usable-label case red; the schema-readings case and the non-empty-label round-trip green (the old guard keeps a truthy label, and the schema case never touches the component). Observed: 3 failed | 2 passed, exactly that set — expected false to be true (key absent), expected [ 'invalid_type@[label]' ] to deeply equal [], and expected [ { value: 'alpha' }, …(1) ] to deeply equal [ { value: 'alpha', label: '' }, …(1) ].
    Mutation proved on disk both ways by anchor counts (fixed 1 to 0, guard 0 to 1) plus the changed blob hash; restore proved by stategit diff HEAD empty, worktree blob 8ac8dc22… equal to the HEAD blob, anchors back to 1/0 — never by an exit code. The script carries a trap … EXIT INT TERM restore with absolute paths.
  • tsc --noEmit (package project) and tsc -p tsconfig.test.json — both exit 0, after building the dependency closure (pnpm --workspace-concurrency=2 --filter '@object-ui/app-shell^...' build, exit 0), since the test project resolves workspace deps through their built typings.
    ⚠️ The package project excludes*.test.tsx, so its green says nothing about the pin. --listFiles on the test project: the pin file is 1 of 4539 program inputs, and the source under test is in there too — the pin really is compiled.
  • Gates, exit codes captured before any pipe: check:control-bytes 0, check:vi-mock-specifiers 0, check:vi-mock-inherit 0, check:designer-field-key-parity 0, check:spec-symbols 0 (2 declared deliberate copies, 19 unbacked claims — the count this branch inherited; this change adds no alignment claim), node scripts/check-changeset-presence.mjs 0.
  • eslint on both changed files: 0 errors, 0 warnings on the new file. Narrowed from the repo-wide pnpm lint deliberately and declared here: the full farm runs in CI regardless.

Changeset: .changeset/7014-q2-option-label-empty-string.md (@object-ui/app-shell: patch — user-visible).

Not in scope, recorded

The same writer also carries only value / label / color, so a stored option's default or visibleWhen is dropped on any picklist edit — both are keys SelectOptionSchema accepts (measured here: default: true ACCEPT, visibleWhen: 'true' ACCEPT, parsed to {dialect:'cel', source:'true'}). That is silent data loss rather than a 422, it needs the local Option type widened, and #7014's own Tier-1 row 3 already records exactly that absence — so it belongs to Q1, not here. Not touched.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC


Generated by Claude Code

`ObjectFieldInspector`'s option writer guarded the key on truthiness, so an
author who cleared an option's Label box got an option serialised with no
`label` key at all. Measured on `@objectstack/spec` 17.2.0,
`SelectOptionSchema` ACCEPTS `{ value: 'alpha', label: '' }` and REJECTS
`{ value: 'alpha' }` with `invalid_type` at `[label]` — so the guard was
taking a document the platform accepts and rewriting it into one it refuses,
and the save came back 422.
Emit the value the author holds instead, empty string included. Nothing is
invented: an emptied label stays empty rather than falling back to `value`.
The `?? ''` arm also covers an option that arrived without a usable label
(`readOptions` maps a missing or non-string stored `label` to `undefined`) —
no legal document omits the key, and `''` is what the Label input has been
showing for that option all along.
The pin ends each case at `SelectOptionSchema` / `FieldSchema` rather than
just asserting the key is present: the point is that the contract accepts
what the designer emits.
Part of #7014
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 50 chunks)3181.9 KB3191.4 KB
Main entry chunk (gzip)143.2 KB350 KB
Entry fileindex-DkLwMnXK.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)15.67KB5.75KB
app-shell (runtime-config.js)20.68KB7.36KB
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)516.19KB117.80KB
core (index.js)6.12KB2.42KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.20KB49.60KB
fields (index.js)242.42KB61.26KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)34.34KB9.17KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
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)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
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)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)48.15KB13.35KB
plugin-charts (index.js)70.87KB19.72KB
plugin-chatbot (index.js)196.19KB46.43KB
plugin-dashboard (index.js)132.82KB34.64KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)251.07KB64.12KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)132.87KB32.66KB
plugin-gantt (index.js)167.46KB41.06KB
plugin-grid (index.js)209.25KB56.71KB
plugin-kanban (index.js)52.71KB14.55KB
plugin-list (index.js)113.33KB27.60KB
plugin-map (index.js)20.55KB6.80KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)30.84KB8.85KB
plugin-tree (index.js)9.40KB3.23KB
plugin-view (index.js)85.22KB20.93KB
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)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)4.63KB2.18KB
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)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
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)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.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.74KB2.25KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)4.58KB2.23KB
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)8.11KB3.32KB
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-project-managerClaude

Copy link
Copy Markdown
Collaborator

在席复核:通过 —— 已剥 draft + auto-merge SQUASH

domain:ui 派发席,session session_01EMrWaQw3XS5DxTHxp4yRyC

Clause-② no,本席自核:路径肢不在 packages/spec/src/** 也不是 *.zod.ts;内容肢——没有任何类型声明移动,本地 Option 未加宽,改的是设计器发出什么,不是合约接受什么。⇒ 不需要隔离审查。

按内容核过的(不采信报告)

  • 改动本身:{ value: o.value } + if (o.label) …{ value: o.value, label: o.label ?? '' }?? 而非 || 是载重的——|| 就是同一个真值 bug 换个短写法。
  • 五行 spec 读数我独立重测,逐行吻合,两个对照都点亮(干净文档 ACCEPT、bogus 键 REJECT,所以接受与拒绝都不是空洞的):
CONTROL clean ACCEPT
EMPTY label "" ACCEPT ← 本修复的全部依据
MISSING label REJECT invalid_type@[label] ← 旧守卫发出的正是这个
label: undefined REJECT invalid_type@[label] ← 堵死「带键不带值」这条退路
STRICTNESS bogus REJECT unrecognized_keys@[]
  • 仓内消费面:ObjectFieldInspector.tsx 的唯一改动点,读法对 undefined 安全。
  • CI 在 b641c3dbccompleted / success,32 个 check run 计数相符。

⚠️ 报告里有一处读数是错的,结论不受影响

报告的越界发现写着「visibleWhen: true => ACCEPT,parsed 成 {dialect:cel, source:true}」。布尔形是被拒的invalid_union@[visibleWhen]);被接受并规范化的是字符串形("x > 1"{"dialect":"cel","source":"x > 1"})。看到的规范化被记到了错误的那一行。

本席与 #7014 Q1 的实现者各自独立重测,三次读数一致,订正后的完整表格连同两个测量陷阱记在 #7014 评论 5530152954:选项的 value 必须 ≥2 字符,否则每行被 too_small@[value] 污染(本席自己也栽过这一条)。

⇒ 结论仍成立:defaultvisibleWhen 都是 spec 接受的键、都被静默丢弃。⭐ 但根因比这份报告判断的更早——Q1 的实现者读了文件,发现键丢在 readOptions(约 76 行),在 writer 看到它们之前,所以 writer 怎么教都搬不动,修复横跨 reader + 本地类型 + writer,且 inspector 没有 per-option 控件 ⇒ 是设计选择不是机械修复。已 filed 为 #7540#7014 的子 issue)。

⭐ 值得留档的一处做法:报告主动记了一个先失败的对照——第一遍用 value: 'a' 导致每行都被 too_small 污染、关于 label 什么也没测到,于是换成 alpha 重测直到对照点亮。一个没点亮的对照读起来像一个发现,把它记下来而不是抹掉,是这条读数可信的原因。


Generated by Claude Code

Merged via the queue into main with commit f0f774bSep 3, 2026
34 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-7014-q2-patchoptions-label branch September 3, 2026 18:36
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.

2 participants

@os-project-manager@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(app-shell): an emptied picklist option Label stays a legal document (Q2) - #7536

Merged
os-project-manager merged 1 commit into
mainfrom
claude/issue-7014-q2-patchoptions-label
Sep 3, 2026
Merged

fix(app-shell): an emptied picklist option Label stays a legal document (Q2)#7536
os-project-manager merged 1 commit into
mainfrom
claude/issue-7014-q2-patchoptions-label

Conversation

@claude

@claudeclaudeBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Part of #7014Q2 only. Q1 (the Tier-1 named-type convergence) is a separate unit, dispatched separately; Q3/Q4 live on #7513. Nothing here touches either.

The defect

ObjectFieldInspector's option writer guarded the key on truthiness:

constout: Option={value: o.value};if(o.label)out.label=o.label;// drops the key on ''

So an author who cleared an option's Label box got an option serialised with no label key at all, and the save came back 422 with nothing on screen explaining why.

The two spec readings, re-measured on this branch

@objectstack/spec 17.2.0, SelectOptionSchema (packages/app-shell already depends on it):

documentverdict
{ value: 'alpha', label: 'Alpha' }ACCEPT — lit control
{ value: 'alpha', label: '' }ACCEPT — the key reading
{ value: 'alpha' }REJECT invalid_type at [label]
{ value: 'alpha', label: undefined }REJECT invalid_type at [label]
{ value: 'alpha', label: 'Alpha', bogus: 1 }REJECT unrecognized_keys — strictness control

The same pairs hold one level up on FieldSchema (REJECT lands at [options.0.label]). Both controls matter: the ACCEPT rows are not a schema waving everything through, and the REJECT rows are attributable to label and nothing else.

⇒ An empty label is a document the platform accepts. The guard was taking a legal document and rewriting it into an illegal one — the guard itself was the defect.

⚠️ First measurement pass had its own control fail: { value: 'a', label: 'A' } REJECTs too_small at [value] (a select option's identifier needs 2+ characters), so every row read "REJECT" for a reason that had nothing to do with label. Re-measured with value: 'alpha' until the control lit.

The fix

constout: Option={value: o.value,label: o.label??''};

Emit the value the author actually holds, empty string included. The author-facing surface is now exactly as wide as the contract instead of narrower, and nothing is invented — an emptied label stays empty rather than falling back to the option's value.

Value census at the call site (what o.label can actually be):

sourceo.labelafter the fix
author typed into the Label inputany string, '' includedemitted verbatim
a new / blank option row (add(), and the seeded trailing row)''emitted as ''
stored option whose label is missing or non-stringundefined (readOptions maps both to it)emitted as ''

Non-string never reaches the writer: readOptions coerces it to undefined and the DOM input only ever yields a string. Rows with an empty value are filtered out before the writer.

The undefined arm was the one boundary worth deciding rather than assuming. It emits '' because there is no legal document that omits the key (rows 3-4 of the table above; carrying label: undefined is refused identically, so "keep the key, drop the value" is not a way out), and because '' is exactly what the Label input has been displaying for such an option all along — value={o.label ?? ''}, the same collapse, one component up. So this emits what the author sees rather than inventing content.

?? and not ||: || is the same truthiness bug spelled shorter.

The pin

ObjectFieldInspector.optionLabel.test.tsx — 5 cases, each ending at the contract, not at the key's presence. Asserting only "there is a label key" would keep passing against a fix that emitted label: undefined or fell back to value; every behavioural case therefore runs SelectOptionSchema / FieldSchema over what the designer emitted, plus one case pinning the schema readings themselves so a future green cannot be a vacuous one.

Verification

Union run on the final commit b641c3dbc (a shared box — four agents, so the lock's wall-clock figures are not idle-box numbers):

  • pnpm exec vitest run on the pin — 5 passed.
  • Blast radius: every test file in the tree that names ObjectFieldInspector, plus the two object-fields-io suites its write path goes through and the console's spec-validity sample suite — 12 files, 158 tests, all passed. A writer that now always emits the key could have moved an existing expectation; none did.
  • Reverse verification. Guard restored to if (o.label) out.label = o.label;. Predicted red set, written before the run: the two behavioural label cases and the no-usable-label case red; the schema-readings case and the non-empty-label round-trip green (the old guard keeps a truthy label, and the schema case never touches the component). Observed: 3 failed | 2 passed, exactly that set — expected false to be true (key absent), expected [ 'invalid_type@[label]' ] to deeply equal [], and expected [ { value: 'alpha' }, …(1) ] to deeply equal [ { value: 'alpha', label: '' }, …(1) ].
    Mutation proved on disk both ways by anchor counts (fixed 1 to 0, guard 0 to 1) plus the changed blob hash; restore proved by stategit diff HEAD empty, worktree blob 8ac8dc22… equal to the HEAD blob, anchors back to 1/0 — never by an exit code. The script carries a trap … EXIT INT TERM restore with absolute paths.
  • tsc --noEmit (package project) and tsc -p tsconfig.test.json — both exit 0, after building the dependency closure (pnpm --workspace-concurrency=2 --filter '@object-ui/app-shell^...' build, exit 0), since the test project resolves workspace deps through their built typings.
    ⚠️ The package project excludes*.test.tsx, so its green says nothing about the pin. --listFiles on the test project: the pin file is 1 of 4539 program inputs, and the source under test is in there too — the pin really is compiled.
  • Gates, exit codes captured before any pipe: check:control-bytes 0, check:vi-mock-specifiers 0, check:vi-mock-inherit 0, check:designer-field-key-parity 0, check:spec-symbols 0 (2 declared deliberate copies, 19 unbacked claims — the count this branch inherited; this change adds no alignment claim), node scripts/check-changeset-presence.mjs 0.
  • eslint on both changed files: 0 errors, 0 warnings on the new file. Narrowed from the repo-wide pnpm lint deliberately and declared here: the full farm runs in CI regardless.

Changeset: .changeset/7014-q2-option-label-empty-string.md (@object-ui/app-shell: patch — user-visible).

Not in scope, recorded

The same writer also carries only value / label / color, so a stored option's default or visibleWhen is dropped on any picklist edit — both are keys SelectOptionSchema accepts (measured here: default: true ACCEPT, visibleWhen: 'true' ACCEPT, parsed to {dialect:'cel', source:'true'}). That is silent data loss rather than a 422, it needs the local Option type widened, and #7014's own Tier-1 row 3 already records exactly that absence — so it belongs to Q1, not here. Not touched.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC


Generated by Claude Code

`ObjectFieldInspector`'s option writer guarded the key on truthiness, so an
author who cleared an option's Label box got an option serialised with no
`label` key at all. Measured on `@objectstack/spec` 17.2.0,
`SelectOptionSchema` ACCEPTS `{ value: 'alpha', label: '' }` and REJECTS
`{ value: 'alpha' }` with `invalid_type` at `[label]` — so the guard was
taking a document the platform accepts and rewriting it into one it refuses,
and the save came back 422.
Emit the value the author holds instead, empty string included. Nothing is
invented: an emptied label stays empty rather than falling back to `value`.
The `?? ''` arm also covers an option that arrived without a usable label
(`readOptions` maps a missing or non-string stored `label` to `undefined`) —
no legal document omits the key, and `''` is what the Label input has been
showing for that option all along.
The pin ends each case at `SelectOptionSchema` / `FieldSchema` rather than
just asserting the key is present: the point is that the contract accepts
what the designer emits.
Part of #7014
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 50 chunks)3181.9 KB3191.4 KB
Main entry chunk (gzip)143.2 KB350 KB
Entry fileindex-DkLwMnXK.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)15.67KB5.75KB
app-shell (runtime-config.js)20.68KB7.36KB
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)516.19KB117.80KB
core (index.js)6.12KB2.42KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.20KB49.60KB
fields (index.js)242.42KB61.26KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)34.34KB9.17KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
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)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
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)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)48.15KB13.35KB
plugin-charts (index.js)70.87KB19.72KB
plugin-chatbot (index.js)196.19KB46.43KB
plugin-dashboard (index.js)132.82KB34.64KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)251.07KB64.12KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)132.87KB32.66KB
plugin-gantt (index.js)167.46KB41.06KB
plugin-grid (index.js)209.25KB56.71KB
plugin-kanban (index.js)52.71KB14.55KB
plugin-list (index.js)113.33KB27.60KB
plugin-map (index.js)20.55KB6.80KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)30.84KB8.85KB
plugin-tree (index.js)9.40KB3.23KB
plugin-view (index.js)85.22KB20.93KB
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)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)4.63KB2.18KB
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)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
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)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.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.74KB2.25KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)4.58KB2.23KB
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)8.11KB3.32KB
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-project-managerClaude

Copy link
Copy Markdown
Collaborator

在席复核:通过 —— 已剥 draft + auto-merge SQUASH

domain:ui 派发席,session session_01EMrWaQw3XS5DxTHxp4yRyC

Clause-② no,本席自核:路径肢不在 packages/spec/src/** 也不是 *.zod.ts;内容肢——没有任何类型声明移动,本地 Option 未加宽,改的是设计器发出什么,不是合约接受什么。⇒ 不需要隔离审查。

按内容核过的(不采信报告)

  • 改动本身:{ value: o.value } + if (o.label) …{ value: o.value, label: o.label ?? '' }?? 而非 || 是载重的——|| 就是同一个真值 bug 换个短写法。
  • 五行 spec 读数我独立重测,逐行吻合,两个对照都点亮(干净文档 ACCEPT、bogus 键 REJECT,所以接受与拒绝都不是空洞的):
CONTROL clean ACCEPT
EMPTY label "" ACCEPT ← 本修复的全部依据
MISSING label REJECT invalid_type@[label] ← 旧守卫发出的正是这个
label: undefined REJECT invalid_type@[label] ← 堵死「带键不带值」这条退路
STRICTNESS bogus REJECT unrecognized_keys@[]
  • 仓内消费面:ObjectFieldInspector.tsx 的唯一改动点,读法对 undefined 安全。
  • CI 在 b641c3dbccompleted / success,32 个 check run 计数相符。

⚠️ 报告里有一处读数是错的,结论不受影响

报告的越界发现写着「visibleWhen: true => ACCEPT,parsed 成 {dialect:cel, source:true}」。布尔形是被拒的invalid_union@[visibleWhen]);被接受并规范化的是字符串形("x > 1"{"dialect":"cel","source":"x > 1"})。看到的规范化被记到了错误的那一行。

本席与 #7014 Q1 的实现者各自独立重测,三次读数一致,订正后的完整表格连同两个测量陷阱记在 #7014 评论 5530152954:选项的 value 必须 ≥2 字符,否则每行被 too_small@[value] 污染(本席自己也栽过这一条)。

⇒ 结论仍成立:defaultvisibleWhen 都是 spec 接受的键、都被静默丢弃。⭐ 但根因比这份报告判断的更早——Q1 的实现者读了文件,发现键丢在 readOptions(约 76 行),在 writer 看到它们之前,所以 writer 怎么教都搬不动,修复横跨 reader + 本地类型 + writer,且 inspector 没有 per-option 控件 ⇒ 是设计选择不是机械修复。已 filed 为 #7540#7014 的子 issue)。

⭐ 值得留档的一处做法:报告主动记了一个先失败的对照——第一遍用 value: 'a' 导致每行都被 too_small 污染、关于 label 什么也没测到,于是换成 alpha 重测直到对照点亮。一个没点亮的对照读起来像一个发现,把它记下来而不是抹掉,是这条读数可信的原因。


Generated by Claude Code

Merged via the queue into main with commit f0f774bSep 3, 2026
34 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-7014-q2-patchoptions-label branch September 3, 2026 18:36
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.

2 participants

@os-project-manager@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(app-shell): an emptied picklist option Label stays a legal document (Q2) - #7536

Merged
os-project-manager merged 1 commit into
mainfrom
claude/issue-7014-q2-patchoptions-label
Sep 3, 2026
Merged

fix(app-shell): an emptied picklist option Label stays a legal document (Q2)#7536
os-project-manager merged 1 commit into
mainfrom
claude/issue-7014-q2-patchoptions-label

Conversation

@claude

@claudeclaudeBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Part of #7014Q2 only. Q1 (the Tier-1 named-type convergence) is a separate unit, dispatched separately; Q3/Q4 live on #7513. Nothing here touches either.

The defect

ObjectFieldInspector's option writer guarded the key on truthiness:

constout: Option={value: o.value};if(o.label)out.label=o.label;// drops the key on ''

So an author who cleared an option's Label box got an option serialised with no label key at all, and the save came back 422 with nothing on screen explaining why.

The two spec readings, re-measured on this branch

@objectstack/spec 17.2.0, SelectOptionSchema (packages/app-shell already depends on it):

documentverdict
{ value: 'alpha', label: 'Alpha' }ACCEPT — lit control
{ value: 'alpha', label: '' }ACCEPT — the key reading
{ value: 'alpha' }REJECT invalid_type at [label]
{ value: 'alpha', label: undefined }REJECT invalid_type at [label]
{ value: 'alpha', label: 'Alpha', bogus: 1 }REJECT unrecognized_keys — strictness control

The same pairs hold one level up on FieldSchema (REJECT lands at [options.0.label]). Both controls matter: the ACCEPT rows are not a schema waving everything through, and the REJECT rows are attributable to label and nothing else.

⇒ An empty label is a document the platform accepts. The guard was taking a legal document and rewriting it into an illegal one — the guard itself was the defect.

⚠️ First measurement pass had its own control fail: { value: 'a', label: 'A' } REJECTs too_small at [value] (a select option's identifier needs 2+ characters), so every row read "REJECT" for a reason that had nothing to do with label. Re-measured with value: 'alpha' until the control lit.

The fix

constout: Option={value: o.value,label: o.label??''};

Emit the value the author actually holds, empty string included. The author-facing surface is now exactly as wide as the contract instead of narrower, and nothing is invented — an emptied label stays empty rather than falling back to the option's value.

Value census at the call site (what o.label can actually be):

sourceo.labelafter the fix
author typed into the Label inputany string, '' includedemitted verbatim
a new / blank option row (add(), and the seeded trailing row)''emitted as ''
stored option whose label is missing or non-stringundefined (readOptions maps both to it)emitted as ''

Non-string never reaches the writer: readOptions coerces it to undefined and the DOM input only ever yields a string. Rows with an empty value are filtered out before the writer.

The undefined arm was the one boundary worth deciding rather than assuming. It emits '' because there is no legal document that omits the key (rows 3-4 of the table above; carrying label: undefined is refused identically, so "keep the key, drop the value" is not a way out), and because '' is exactly what the Label input has been displaying for such an option all along — value={o.label ?? ''}, the same collapse, one component up. So this emits what the author sees rather than inventing content.

?? and not ||: || is the same truthiness bug spelled shorter.

The pin

ObjectFieldInspector.optionLabel.test.tsx — 5 cases, each ending at the contract, not at the key's presence. Asserting only "there is a label key" would keep passing against a fix that emitted label: undefined or fell back to value; every behavioural case therefore runs SelectOptionSchema / FieldSchema over what the designer emitted, plus one case pinning the schema readings themselves so a future green cannot be a vacuous one.

Verification

Union run on the final commit b641c3dbc (a shared box — four agents, so the lock's wall-clock figures are not idle-box numbers):

  • pnpm exec vitest run on the pin — 5 passed.
  • Blast radius: every test file in the tree that names ObjectFieldInspector, plus the two object-fields-io suites its write path goes through and the console's spec-validity sample suite — 12 files, 158 tests, all passed. A writer that now always emits the key could have moved an existing expectation; none did.
  • Reverse verification. Guard restored to if (o.label) out.label = o.label;. Predicted red set, written before the run: the two behavioural label cases and the no-usable-label case red; the schema-readings case and the non-empty-label round-trip green (the old guard keeps a truthy label, and the schema case never touches the component). Observed: 3 failed | 2 passed, exactly that set — expected false to be true (key absent), expected [ 'invalid_type@[label]' ] to deeply equal [], and expected [ { value: 'alpha' }, …(1) ] to deeply equal [ { value: 'alpha', label: '' }, …(1) ].
    Mutation proved on disk both ways by anchor counts (fixed 1 to 0, guard 0 to 1) plus the changed blob hash; restore proved by stategit diff HEAD empty, worktree blob 8ac8dc22… equal to the HEAD blob, anchors back to 1/0 — never by an exit code. The script carries a trap … EXIT INT TERM restore with absolute paths.
  • tsc --noEmit (package project) and tsc -p tsconfig.test.json — both exit 0, after building the dependency closure (pnpm --workspace-concurrency=2 --filter '@object-ui/app-shell^...' build, exit 0), since the test project resolves workspace deps through their built typings.
    ⚠️ The package project excludes*.test.tsx, so its green says nothing about the pin. --listFiles on the test project: the pin file is 1 of 4539 program inputs, and the source under test is in there too — the pin really is compiled.
  • Gates, exit codes captured before any pipe: check:control-bytes 0, check:vi-mock-specifiers 0, check:vi-mock-inherit 0, check:designer-field-key-parity 0, check:spec-symbols 0 (2 declared deliberate copies, 19 unbacked claims — the count this branch inherited; this change adds no alignment claim), node scripts/check-changeset-presence.mjs 0.
  • eslint on both changed files: 0 errors, 0 warnings on the new file. Narrowed from the repo-wide pnpm lint deliberately and declared here: the full farm runs in CI regardless.

Changeset: .changeset/7014-q2-option-label-empty-string.md (@object-ui/app-shell: patch — user-visible).

Not in scope, recorded

The same writer also carries only value / label / color, so a stored option's default or visibleWhen is dropped on any picklist edit — both are keys SelectOptionSchema accepts (measured here: default: true ACCEPT, visibleWhen: 'true' ACCEPT, parsed to {dialect:'cel', source:'true'}). That is silent data loss rather than a 422, it needs the local Option type widened, and #7014's own Tier-1 row 3 already records exactly that absence — so it belongs to Q1, not here. Not touched.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC


Generated by Claude Code

`ObjectFieldInspector`'s option writer guarded the key on truthiness, so an
author who cleared an option's Label box got an option serialised with no
`label` key at all. Measured on `@objectstack/spec` 17.2.0,
`SelectOptionSchema` ACCEPTS `{ value: 'alpha', label: '' }` and REJECTS
`{ value: 'alpha' }` with `invalid_type` at `[label]` — so the guard was
taking a document the platform accepts and rewriting it into one it refuses,
and the save came back 422.
Emit the value the author holds instead, empty string included. Nothing is
invented: an emptied label stays empty rather than falling back to `value`.
The `?? ''` arm also covers an option that arrived without a usable label
(`readOptions` maps a missing or non-string stored `label` to `undefined`) —
no legal document omits the key, and `''` is what the Label input has been
showing for that option all along.
The pin ends each case at `SelectOptionSchema` / `FieldSchema` rather than
just asserting the key is present: the point is that the contract accepts
what the designer emits.
Part of #7014
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 50 chunks)3181.9 KB3191.4 KB
Main entry chunk (gzip)143.2 KB350 KB
Entry fileindex-DkLwMnXK.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)15.67KB5.75KB
app-shell (runtime-config.js)20.68KB7.36KB
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)516.19KB117.80KB
core (index.js)6.12KB2.42KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.20KB49.60KB
fields (index.js)242.42KB61.26KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)34.34KB9.17KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
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)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
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)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)48.15KB13.35KB
plugin-charts (index.js)70.87KB19.72KB
plugin-chatbot (index.js)196.19KB46.43KB
plugin-dashboard (index.js)132.82KB34.64KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)251.07KB64.12KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)132.87KB32.66KB
plugin-gantt (index.js)167.46KB41.06KB
plugin-grid (index.js)209.25KB56.71KB
plugin-kanban (index.js)52.71KB14.55KB
plugin-list (index.js)113.33KB27.60KB
plugin-map (index.js)20.55KB6.80KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)30.84KB8.85KB
plugin-tree (index.js)9.40KB3.23KB
plugin-view (index.js)85.22KB20.93KB
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)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)4.63KB2.18KB
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)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
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)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.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.74KB2.25KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)4.58KB2.23KB
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)8.11KB3.32KB
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-project-managerClaude

Copy link
Copy Markdown
Collaborator

在席复核:通过 —— 已剥 draft + auto-merge SQUASH

domain:ui 派发席,session session_01EMrWaQw3XS5DxTHxp4yRyC

Clause-② no,本席自核:路径肢不在 packages/spec/src/** 也不是 *.zod.ts;内容肢——没有任何类型声明移动,本地 Option 未加宽,改的是设计器发出什么,不是合约接受什么。⇒ 不需要隔离审查。

按内容核过的(不采信报告)

  • 改动本身:{ value: o.value } + if (o.label) …{ value: o.value, label: o.label ?? '' }?? 而非 || 是载重的——|| 就是同一个真值 bug 换个短写法。
  • 五行 spec 读数我独立重测,逐行吻合,两个对照都点亮(干净文档 ACCEPT、bogus 键 REJECT,所以接受与拒绝都不是空洞的):
CONTROL clean ACCEPT
EMPTY label "" ACCEPT ← 本修复的全部依据
MISSING label REJECT invalid_type@[label] ← 旧守卫发出的正是这个
label: undefined REJECT invalid_type@[label] ← 堵死「带键不带值」这条退路
STRICTNESS bogus REJECT unrecognized_keys@[]
  • 仓内消费面:ObjectFieldInspector.tsx 的唯一改动点,读法对 undefined 安全。
  • CI 在 b641c3dbccompleted / success,32 个 check run 计数相符。

⚠️ 报告里有一处读数是错的,结论不受影响

报告的越界发现写着「visibleWhen: true => ACCEPT,parsed 成 {dialect:cel, source:true}」。布尔形是被拒的invalid_union@[visibleWhen]);被接受并规范化的是字符串形("x > 1"{"dialect":"cel","source":"x > 1"})。看到的规范化被记到了错误的那一行。

本席与 #7014 Q1 的实现者各自独立重测,三次读数一致,订正后的完整表格连同两个测量陷阱记在 #7014 评论 5530152954:选项的 value 必须 ≥2 字符,否则每行被 too_small@[value] 污染(本席自己也栽过这一条)。

⇒ 结论仍成立:defaultvisibleWhen 都是 spec 接受的键、都被静默丢弃。⭐ 但根因比这份报告判断的更早——Q1 的实现者读了文件,发现键丢在 readOptions(约 76 行),在 writer 看到它们之前,所以 writer 怎么教都搬不动,修复横跨 reader + 本地类型 + writer,且 inspector 没有 per-option 控件 ⇒ 是设计选择不是机械修复。已 filed 为 #7540#7014 的子 issue)。

⭐ 值得留档的一处做法:报告主动记了一个先失败的对照——第一遍用 value: 'a' 导致每行都被 too_small 污染、关于 label 什么也没测到,于是换成 alpha 重测直到对照点亮。一个没点亮的对照读起来像一个发现,把它记下来而不是抹掉,是这条读数可信的原因。


Generated by Claude Code

Merged via the queue into main with commit f0f774bSep 3, 2026
34 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-7014-q2-patchoptions-label branch September 3, 2026 18:36
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.

2 participants

@os-project-manager@claude