Skip to content

fix(sdui-parser): keep the space between a text run and an adjacent inline element - #5722

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-5661-sdui-parser-inline-whitespace
Aug 22, 2026
Merged

fix(sdui-parser): keep the space between a text run and an adjacent inline element#5722
os-zhuang merged 2 commits into
mainfrom
claude/issue-5661-sdui-parser-inline-whitespace

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes#5661

The defect

packages/sdui-parser/src/parse.ts, in parseChildren's children loop (line numbers
re-derived from the tree at origin/main190fbd01d, not taken from the card — the
card's :177 citation is off by one for the first of the two lines):

// :176consttext=this.readTextRun();// :177consttrimmed=text.replace(/\s+/g,' ').trim();if(trimmed)children.push(trimmed);

The collapse (\s+ → one space) is correct and is HTML's own whitespace model. The
.trim() is not: HTML collapses a whitespace run to one space, it does not delete it.
What it deleted is exactly the space separating a text run from an adjacent inline
sibling, so every authored sentence carrying emphasis or a link in the tier the guide
recommends by default rendered with its words run together — silently, since the page
rendered, the structure was right, and no diagnostic fired.

The rule implemented — triage's option (b)

Collapse the run, then keep one leading space when a sibling precedes the run and
one trailing space when a sibling element follows it. The parent's own start
and end still drop their edge space, so <p> hi </p> is unchanged.

Deliberately mechanical: it invents no block/inline boundary taxonomy for a schema
tree that has none — that was the rejected option (a), a design project rather than
this fix.

The bounded cost, pinned rather than asserted

Under this rule a whitespace-only run between two siblings survives as a single
space, so a pretty-printed <ul> gains one ' ' string child per gap between its
<li>s. The <ul> control in the new suite states the bound as the things that could
have gone wrong instead:

  • each inter-item string child is exactly ' ' — never the source's newline and
    indentation;
  • there is no string child before the first item or after the last (the container's
    own edges still trim);
  • each <li>'s own text is byte-identical to what was authored.

A ' ' child renders as a text node between list items — the same node a browser
puts in the DOM for the same HTML source, and one <ul> layout ignores.

Evidence

New suite packages/sdui-parser/src/__tests__/inline-whitespace.test.tsx, 10 tests.
It pins the three cases the card measured, the collapse itself, the parent-edge
behaviour that must not change, and the <ul> control. Two of its cases render
through the real SchemaRenderer and assert textContent, because that is the term
the bug was reported in — a parse-tree assertion alone cannot say the words stopped
running together on screen.

Reverse verification (fix committed first, then parse.ts alone reverted to
origin/main, suite re-run, then restored): 9 of the 10 tests go red, and the one
that stays green is still drops the edge space at the parent's own start and end
i.e. exactly the half of .trim() that was already correct. The render case reproduces
the reported string verbatim:

AssertionError: expected 'Akind:\'html\'page — native HTML tags.' to be 'A kind:\'html\' page — native HTML ta…'
Expected: "A kind:'html' page — native HTML tags."
Received: "Akind:'html'page — native HTML tags."

Snapshot sweep (triage asked for it): packages/sdui-parser holds no snapshots at
all, and the repo has exactly two .snap files, both in packages/components
(snapshot.test.tsx.snap, snapshot-critical.test.tsx.snap). Both tests were
executed in the runs below and both passed with the files unmodified — git status
stayed clean, so no snapshot changed and none was updated. Nothing in the repo
depended on the old trimming.

Test union, all green, run under this container's shared verify lock:

scopefilestests
packages/sdui-parser/ packages/layout/ packages/types/ apps/console/1401486
packages/components/src/__tests__/104946
packages/components/src/renderers/ src/ui/ src/notifications/78692

That set is the parser's reachable blast radius: every package that imports
@object-ui/sdui-parser, plus every test that renders a kind:'html' page (whose
sourcepackages/components/src/renderers/layout/page.tsx compiles at render time).
packages/plugin-grid and examples/schema-catalog import only manifestFromConfigs
/ validateTree and never parse; no test under packages/app-shell parses an
html-tier source (measured by grep for sdui-parser, kind:'html' and compile(
across its test files).

Twin check (required by triage)

The twin is real.objectstack-ai/objectstackpackages/sdui-parser/src/parse.ts
carries the identical statement at :167/:168 on its origin/main (acb4dbc).
Not fixed here, per triage — filed as
objectstack-ai/objectstack#11148.

The two files have drifted and are not a byte copy: diffed in full, their only
difference is objectui's html-tier provenance stamp (objectui#4000) — one import plus
a 9-line block at the node-construction site, which is precisely the offset between
:176/:177 here and :167/:168 there. The defective statement itself is
byte-identical. The twin card says so rather than implying a cherry-pick will apply.

Out of scope, established rather than assumed

The second .trim() in this file (:275, const trimmed = raw.trim()) is not the
same defect. It sits in interpretBrace(raw), normalising the source text of a braced
attribute
before JSON.parse / before it is kept as an { $expr } marker. That is
expression syntax, not the rendered content model, and no space it removes was ever
separating anything. Untouched.

Gates

Run at the head of this branch unless noted; all green.

gateverdict
turbo run type-check lint --filter=@object-ui/sdui-parserTasks: 9 successful, 9 total — type-check clean; lint 0 errors, 7 pre-existing warnings, none in a changed file
pnpm check:control-bytesOK (scanned 4770 tracked text file(s); skipped 85 binary)
pnpm check:phantom-depsEvery in-scope import is declared by the package that publishes it.
pnpm check:self-importNo package names itself inside its own src/.
pnpm check:esm-specifiersno un-ledgered package emits an extensionless relative specifier
pnpm changeset:checkfixed group OK · privatePackages declared · No changeset declares a major bump.
pnpm lint:coverage46/46 packages linted, 0 with outstanding errors
pnpm type-check:coverage45/46 via type-check · 41/41 packages compile their tests

pnpm lint was narrowed to this package deliberately: lint is turbo run lint, i.e.
each package's own eslint . plus //#lint:root (which ran here as a dependency, 0
errors), only one package has changed files, and eslint.config.js configures no
type-aware linting — so no untouched package's verdict can move. pnpm check:eager-closure
was not run: it reads apps/console/dist/eager-closure.json and reports a broken gauge
without a console vite build; this diff adds no import anywhere in that closure.

A patch changeset for @object-ui/sdui-parser is included (the rendered output of
every existing kind:'html' page changes).


Generated by Claude Code

…ibling element
`parseChildren` collapsed each text run's whitespace to a single space — correct,
that is HTML's own whitespace model — and then `.trim()`ed it, which is not: HTML
collapses a whitespace run to ONE space, it does not delete it. The deleted space
is the one that separates a run from an adjacent inline sibling, so every authored
sentence with emphasis or a link in the `kind:'html'` tier rendered with its words
run together: `A <strong>x</strong> page` came out as `Axpage`.
Keep one leading space when a sibling precedes the run and one trailing space when
a sibling element follows it; the parent's own start/end still drop theirs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EuPCi56cnGyykygi3z9w4m
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3914.7 KB3990.2 KB
Main entry chunk (gzip)152.3 KB350 KB
Entry fileindex-gvGRSgz2.js
StatusPASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)10.04KB3.72KB
app-shell (runtime-config.js)12.80KB4.47KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)29.34KB7.05KB
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)9.63KB3.74KB
auth (index.js)2.77KB1.22KB
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.02KB0.89KB
auth (useIsWorkspaceAdmin.js)3.04KB1.45KB
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)508.30KB114.17KB
core (index.js)4.92KB1.97KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)160.15KB44.52KB
fields (index.js)238.40KB59.89KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)23.13KB7.63KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)7.77KB3.13KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.93KB0.88KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.62KB12.83KB
plugin-charts (index.js)64.65KB18.32KB
plugin-chatbot (index.js)181.41KB43.22KB
plugin-dashboard (index.js)128.41KB32.95KB
plugin-designer (index.js)212.30KB42.80KB
plugin-detail (index.js)242.34KB60.98KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)125.63KB30.64KB
plugin-gantt (index.js)164.10KB39.87KB
plugin-grid (index.js)200.79KB54.26KB
plugin-kanban (index.js)52.93KB14.60KB
plugin-list (index.js)111.80KB27.20KB
plugin-map (index.js)20.06KB6.62KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.49KB11.93KB
plugin-timeline (index.js)26.68KB7.66KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.61KB20.74KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)43.66KB14.77KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.33KB0.69KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (index.js)4.77KB2.16KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)12.13KB3.65KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)6.92KB2.40KB
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)0.20KB0.18KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (index.js)3.59KB1.79KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)0.20KB0.18KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-zhuang
os-zhuang marked this pull request as ready for review August 22, 2026 22:45
@os-zhuang
os-zhuang added this pull request to the merge queueAug 22, 2026
Merged via the queue into main with commit 305205aAug 22, 2026
23 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-5661-sdui-parser-inline-whitespace branch August 22, 2026 22:45
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.

kind:'html' page source loses the space between a text run and an adjacent inline element — A <strong>x</strong> page renders as Axpage

2 participants

@os-zhuang@claude