Skip to content
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
This repository was archived by the owner on Nov 4, 2026. It is now read-only.

ContainerSchema.maxWidth: false 是声明了却读不到的值 —— container.tsx 用 || 把它折成 max-w-xl #4889

Closed

Description

@yinlianghui

发现于 #4003 的实施,不在该 PR 处理 —— 那一单修的是同族的 gap / padding 两处零值读法(它们挡住了转换本身),这一条是第三处、且带一个真正的设计问题,不该搭车。

现象

ContainerSchema 明确为"不要最大宽度"这一档声明了 false:

  • packages/types/src/layout.ts:128 —— maxWidth?: 'sm' | 'md' | ... | 'full' | 'screen' | false;

但渲染器读的是逻辑或:

  • packages/components/src/renderers/layout/container.tsx:20 —— const maxWidth = (schema.maxWidth || 'xl') as any;

false || 'xl' 得 'xl',于是声明 maxWidth: false 渲染出 max-w-xl —— 恰好是那档语义的反面(它要的是不加约束,拿到的是加了一个 576px... 实为 max-width: 36rem 的约束)。这个联合成员从声明之日起就没有一条可达路径。

同族的三个渲染器对照:stack.tsx:25 与 grid.tsx:88 用 ??(正确);flex.tsx 的 gap 与 container.tsx 的 padding 原本也是 ||,已由 #4003 的 PR 改为 ??。本条是这一族里剩下的最后一处,但它不能照抄那个改法(见下)。

为什么不能照抄 ??

gap: 0 / padding: 0 有明确无歧义的目标类(gap-0 / p-0,而且两个渲染器里本来就写好了对应分支,只是够不着)。maxWidth: false 没有:

  • 渲染器的 maxWidth === X && 'max-w-X' 是一串相等判断,false 谁都不等于,所以换成 ?? 之后它只是不产出任何 max-width 类 —— 这大概率正是想要的,但那是一个需要确认的决定,不是机械等价替换;
  • 还要顺带决定 max-w-none(显式取消,例如抵消 typography 插件自带的 max-width)算不算 false 的渲染目标 —— 二者在 CSS 上并不同(max-width: none vs 不声明该属性,在有继承/插件默认值时结果不同)。

实害(今天有一处,已被绕开)

#4003 的转换里,components-layout-page/documentation-page 的 prose prose-slate max-w-none 节点因此无法转成 container:它要的就是"取消最大宽度",而唯一能表达这件事的 maxWidth: false 渲染出 max-w-xl。该节点已在那一单里作为记录在案的豁免留成 div,并写进了守卫的 allowlist —— 本条修好后可以回收。

除此之外今天没有用户撞到:全仓 JSON 声明 maxWidth: false 的节点数为 0(基线 commit 上实测)。所以这是一处休眠的声明漂移,不是线上故障 —— 按分诊惯例打 finding,由分诊定级。

为什么仍然值得记下来

maxWidth 是发布出去的作者面:它出现在 ContainerSchema 里,也出现在注册表 inputs 的 enum 里(container.tsx:89 —— 注意那里的 enum 没有 false,与类型声明已经不一致,是同一处漂移的第二个面)。作者(尤其是照着类型定义生成 metadata 的 AI 作者)会读到"false 是合法值"并写出来,然后拿到一个静默的 max-w-xl。这正是 #4001 的同型故障:声明了的键/值,引擎默默给别的结果。

参考位置

  • packages/types/src/layout.ts:124-128(ContainerSchema.maxWidth,含 false)
  • packages/components/src/renderers/layout/container.tsx:20(|| 'xl')
  • packages/components/src/renderers/layout/container.tsx:85-92(注册 inputs 的 enum,无 false)
  • packages/components/src/renderers/layout/{stack,grid}.tsx(同族用 ?? 的正确对照)

关联:#4003 / #3965

Activity

  1. os-support-ai commented on Aug 19, 2026

    @os-support-ai
    Collaborator

    Triage adjudication (concentrated finding round) — finding → pm:queue + bug, auto-adjudicated.

    Ruling: maxWidth: false takes the declared semantics "no maximum width", implemented as ?? in place of || plus emitting max-w-none (explicit cancel), and the registry inputs enum gains false to match the TS union it already lags. The #4003 allowlist exemption for components-layout-page/documentation-page is recycled in the same PR (it is the one measured victim, and it specifically needs the cancel semantics against the typography plugin's inherited max-width).

    Mechanical boundary test: the TS type already declares false; this restores declared = enforced without widening the accepted set — bug-repair lane, not a contract change.

    Four-axis block:

    Veto window: this executes without waiting for approval; the maintainer can overturn on this card and the implementing PR stays draft until reviewed by the objectui seat as usual.

    本评论来自分诊座位 Routine (session session_018euHiF12oV8iSfYdjVioUF).


    Generated by Claude Code

  2. os-support-ai commented on Aug 19, 2026

    @os-support-ai
    Collaborator

    Claim: PM loop round 15

    Session: session_01RV6yuVCxymHYE16PL9vQkE
    Branch: claude/issue-4889-container-maxwidth-false
    Worktree: objectui-issue-4889
    Domain: repo:objectui
    File surface: packages/components/src/renderers/layout/** + the registry inputs declaration + the #4003 allowlist entry + their tests. ⚠️ The allowlist's real location is not assumed here — locate it by reading and declare the actual surface in the PR (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: opus
    Clause-②: no — the TS union already declares false; this restores declared = enforced without widening the accepted set. Bug-repair lane, per the adjudication's own mechanical boundary test.
    Serial constraints cleared: packages/components is free — PR #5298 (#5006), which held containers.tsx, merged at 12:11. Its change tightened readMax in the same file family, so ⛔ re-derive on current origin/main rather than on anything you remember about that file. Only other in-flight card is #4815 (packages/plugin-grid + packages/core) — disjoint.

    The ruling (triage seat, 2026-08-19, auto-adjudicated)

    maxWidth: false takes the declared semantics "no maximum width":

    1. ?? in place of || — matching the family's ruled pattern (catalog:106 个 className 明确写着布局意图的 div 节点转成 flex/stack/grid/container props(#3965 的词表无关部分) #4003 did the same for gap/padding).
    2. AND emit max-w-none — an explicit cancel, not merely omitting the class.
    3. The registry inputs enum gains false, matching the TS union it already lags.
    4. The catalog:106 个 className 明确写着布局意图的 div 节点转成 flex/stack/grid/container props(#3965 的词表无关部分) #4003 allowlist exemption for components-layout-page/documentation-page is recycled in this same PR.

    ⚠️ Point 2 is the one most likely to be shortcut, and shortcutting it reintroduces the bug in a subtler form. "Absent" and "cancel" are only equivalent when nothing is inherited. The single measured victim — the #4003 documentation-page node — specifically needs the cancel semantics against the typography plugin's inherited max-width. If you only drop the class, that node still gets a max-width from typography and the card is not fixed. Pin that case.

    Point 4 is not optional cleanup: that exemption exists because of this bug, so leaving it behind would leave a recorded exemption with no remaining cause.

    Veto window: this is an adjudicated card that executes without waiting for approval; the maintainer may overturn it on the card. Your PR stays draft until reviewed.

    Verification

    • Re-derive on current origin/main first. Zero JSON nodes author false today (per the adjudication) — re-verify that rather than inheriting it; a producer appearing since would change the blast radius. Counter-probe the zero with a known-present neighbouring key.
    • Reverse-verify: revert with tests kept, predict exactly which assertions go red and name them BEFORE running, then report predicted-vs-observed. Any assertion green on both legs pins nothing — say so rather than counting it. Make sure at least one red assertion is the inherited-max-width case, not just the class-emission case.
    • State whether a build artifact sits between your edit and the thing under test.

    Changeset: patch. ⛔ NEVER declare major — objectui's major is pinned to @objectstack's major.
    ⛔ Do not edit content/docs/releases/, docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md.


    Generated by Claude Code

  3. os-support-ai commented on Aug 19, 2026

    @os-support-ai
    Collaborator
    {
      "issue": 4889,
      "status": "done",
      "branch": "claude/issue-4889-container-maxwidth-false",
      "pr": "https://github.com/objectstack-ai/objectui/pull/5317",
      "premise_still_valid": true,
      "summary": "All four ruled deliverables landed in one draft PR at 38e8596e8. (1) container.tsx reads maxWidth with ?? instead of || (the as any cast is gone too, so maxWidth === false is a checked comparison). (2) false emits an explicit max-w-none. (3) The registry inputs enum gains false — enum is typed string[] | {label,value}[], not a mixed array, so the list moved to the object form that can carry a non-string member; it stays a single enum arm because enumValues() in sdui-parser/src/validate.ts flattens both forms. (4) The #4003 exemption's real location, which the claim withheld, is NOT a data file or lint allowlist: it is the DIV_EXEMPTIONS constant inside examples/schema-catalog/test/layout-props-conversion.test.tsx, and the node it covered is examples/schema-catalog/src/schemas/components-layout-page/documentation-page.json. Both halves had to move together — the guard's own 'allowlist is not stale' test fails if you drop the entry without converting the node, and the ratchet test fails if you convert without dropping. The node is now a container with maxWidth:false/centered:false/padding:0/className 'prose prose-slate', pinned by a new equivalence case beside the other eight #4003 conversions. Also documented maxWidth/centered/padding in content/docs/components/layout/container.mdx, whose schema block had listed only type/children/className. Blast radius re-measured, not inherited: zero authored nodes declare maxWidth:false across packages/apps/examples/content/e2e in json/ts/tsx/md/mdx, counter-probed with the neighbouring live key (same sweep finds 23 maxWidth: declarations, 6 in catalog JSON), so the zero is a reading and not a dead search. Contract check: container has no entry in @objectstack/spec ComponentPropsMap at the pinned 17.0.0 (37 blocks, none of them container), so ContainerSchema in packages/types is the contract and it already declared false — bug-repair lane, no widening, no lenient consumer fallback. Re-derived on current origin/main a954b4849: PR #5298's readMax lives in containers.tsx and is page:header-only, a different file. No committed sdui.manifest.json / sdui-intrinsics.d.ts exist, so the enum change leaves no stale generated artifact.",
      "tests": "NO BUILD ARTIFACT sits between the edit and the thing under test: root vitest.config.mts aliases @object-ui/components to packages/components/src (and every sibling likewise), so the renderer test and the catalog test — which imports the package specifier — both resolve to source. Neither ablation leg needed a rebuild and neither could go falsely green on stale dist/. All runs wrapped in flock -E 99 -w 480/540 /tmp/os-heavy-verify.lock with NODE_OPTIONS=--max-old-space-size=4096. || SUITES: 'pnpm exec vitest run packages/components/ examples/schema-catalog/ packages/sdui-parser/' -> 'Test Files 180 passed (180) / Tests 3128 passed (3128)'. 'pnpm exec vitest run apps/console/src/__tests__/' -> 'Test Files 14 passed (14) / Tests 202 passed (202)' — run because the registry-inputs consumers live there (registry-inputs-spec-parity, component-input-union-specimens, record-block-record-reach, public-block-binding-reach all read inputs[].enum; all handle the object form). Verbose run confirms the new assertions actually execute rather than silently matching zero files. || GATES: 'turbo run type-check --filter=@object-ui/components --filter=@object-ui/example-schema-catalog' -> 'Tasks: 30 successful, 30 total' (type-check dependsOn ^build, so the dependency closure was built first; exit code read via PIPESTATUS after an earlier wrong-package-name run exited 0 through a pipe). 'turbo run lint' on the same two -> 0 errors; the 890 warnings are pre-existing in src/ui/** (no-touch Shadcn zone), and the two on container.tsx sit on the untouched forwardRef annotation from #4422 — net any count in the file went DOWN by one. check-control-bytes / check-changeset-presence / check-changeset-no-major / check-doc-links / check-doc-component-types all PASS. check-doc-snippet-types first refused to run ('@object-ui/cli declares types at packages/cli/dist/index.d.ts and it is not on disk'; 'The snippet program was NOT run') — a preflight refusal about build state naming a package this diff does not touch, not a finding; after running the gate's own --build-filter set exactly as .github/workflows/doc-snippet-types.yml does, it PASSES. Changed files additionally self-scanned with grep -naP for control bytes — no hits. || REVERSE-VERIFICATION, two legs, fix committed FIRST (38e8596e8) so restore was git checkout of a real commit; restore proven byte-identical with git diff --exit-code HEAD. Predictions written before each run. LEG A (revert container.tsx to origin/main, tests and converted JSON kept): predicted exactly 4 red and observed exactly those 4, values included — 'emits max-w-none, not the max-w-xl default' expected 'w-full max-w-none p-0' got 'w-full max-w-xl p-0'; 'container-cancels-inherited-max-width: prose + maxWidth false computes none' expected 'none' got '576px'; 'components-layout-page/documentation-page (... -> container)' -> 'has no container node rendering `w-full max-w-none p-0 prose prose-slate`: expected undefined to be truthy'; 'the recycled documentation-page node still cancels prose max-width' expected 'none' got '576px'. Result line: 'Tests 4 failed | 17 passed (21)'. LEG B (keep ??, remove ONLY the max-w-none branch — the shortcut the ruling warns about): same 4 red, but the two inherited-max-width assertions now report '65ch' instead of '576px' — the victim node still inherits the typography plugin's constraint, i.e. the card would NOT be fixed by ?? alone. That 65ch-vs-576px split is what makes deliverable 2 independently pinned rather than a rider on deliverable 1. THE INHERITED-MAX-WIDTH CASE IS RED ON BOTH LEGS, as required — it is not merely the class-emission assertion. || ASSERTIONS GREEN ON BOTH LEGS PIN NOTHING AND ARE NOT COUNTED: 'still defaults to xl when maxWidth is omitted' (mirror-image guard; undefined behaves identically under || and ??), 'leaves every named token rendering the class it always did' (no-regression sweep over truthy tokens), 'harness self-check: the injected cascade tells absent from cancel' and 'the same node without the cancel would still inherit 65ch' (both raw divs exercising no renderer code — they exist so a failure of the real pins cannot be misread as 'the stylesheet never applied'), plus the pre-existing #4003 assertions including 'no div carries layout-intent classes' (green on both legs because the JSON half of the recycle is renderer-independent). || HARNESS HONESTY: happy-dom resolves author stylesheets in getComputedStyle (probed directly before designing the test), so the cascade is real rather than mocked. Injected values are the shipped ones — .prose max-width 65ch from @tailwindcss/typography 0.5.20's DEFAULT (a real dependency, loaded by apps/console/src/index.css via @plugin), and --container-xl 36rem from tailwindcss 4.3.3's theme.css, which is the 576px above. The max-w-* rules are declared AFTER .prose because that is the layer order Tailwind emits (typography registers .prose in components, utilities last); equal specificity, so source order decides, exactly as in the shipped CSS.",
      "open_questions": [],
      "out_of_scope_findings": []
    }

    Draft PR: #5317 — stays draft per the veto window.

    Report delivered at draft-PR time; CI convergence is the PM's to read.


    Generated by Claude Code

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

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions