Skip to content

fix(plugin-form): decline the row load and save of a line-items panel with no childObject - #6359

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-6194-line-items-decline-row-fetch
Aug 25, 2026
Merged

fix(plugin-form): decline the row load and save of a line-items panel with no childObject#6359
os-warren merged 1 commit into
mainfrom
claude/issue-6194-line-items-decline-row-fetch

Conversation

@os-warren

@os-warren os-warren commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Fixes #6194

The second of LineItemsPanel's two reads of schema.childObject. #6188 guarded the first (the child-schema cache); the row load still asked the data layer to find an object literally named undefined, scoped by { [relationshipField]: parentId }.

Re-anchored on my own base ed859aa35 — the card cites :133-137, the site was at :175.

Sanitizer note. The angle-bracket fragment naming the grid element was stripped out of the first version of this body, exactly as it was stripped out of #6194's own body and the dispatch comment — all three quote the render branch as … : !parentId ? "Save the record first…" : with the third arm missing. Written in words below rather than in angle brackets.

The judgement this card existed for

Declining the fetch is not safe on its own, and the card was right to make that its real question. load owns loading, and the panel branched loading ? "Loading…" : !parentId ? "Save the record first…" : the line-items grid element. The moment the row fetch declines, an unresolvable panel with a parentId bound falls to the third branch: an empty editable grid with an Add button, over an object that does not exist.

I landed on the config-hint branch — triage's default, and it is load-bearing rather than cosmetic. An unresolvable panel now renders its own branch naming the key, checked ahead of loading because nothing is pending: the schema itself already says the panel can never resolve, so there is no honest moment at which "Loading…" is true. loading is never left true to hide the state.

Two precedents, and I followed the shape of the first while deliberately not copying its copy:

The save path — measured, not assumed, and the card's open question closes

The card left this explicitly unmeasured: "The Save button is disabled until the grid is dirty, so reaching it requires editing rows of the nonexistent object first — plausible but not measured."

Measured on the pre-fix component, this fixture, childObject unset:

grid rendered            = true
Add button               = true
Save disabled at t0      = true
--- after ONE keystroke in the always-present ghost row ---
Save disabled            = false
reached the adapter      = batchTransaction([
                             { object: undefined, action: 'create',
                               data: { qty: 3, invoice: 'inv-1' } } ])

So it was reachable, and the hedge was too generous: it took one keystroke, not a deliberate campaign of "editing rows of the nonexistent object" — and it reached a write, through the very affordance the empty grid advertises ("No items yet — click Add to begin"). The bad read was one keystroke from a bad write.

Removing the grid removes the only producer of dirty, which is the only thing that enables Save — so the render branch closes that route structurally. I folded the remaining one-line guard into save rather than filing it, on the PM's stated condition ("fold it only if it is the same one-line guard on the same code path — argue it"). The argument: it is one token on the existing if (!dataSource || !parentId) return; at the component's other data-layer entry point, the same defect class, the same gate family, no new verification surface. Depending on a render branch to keep an undefined object name out of the data layer is the same reasoning that left this second site unguarded in the first place; a write contract is not the render tree's to keep. It also covers the one route the render branch cannot — a schema edited to drop childObject while rows are already dirty (a live designer edit), which is what the third new test exercises.

The pin: flipped, not deleted

Per its own annotation. Red against the old expectation, green against the new one — both on this branch:

AssertionError: expected [] to deeply equal [ 'find(undefined)' ]
- Expected            + Received
- [ "find(undefined)" ]   + []
  packages/plugin-form/src/LineItemsPanel.childObjectDecline.test.tsx:126:19

It is now expect(calls).toEqual([]), still an exact-list assertion — the only shape that catches a third unguarded read of this key, which is how the second one arrived. Three tests added alongside it, each keeping a direction honest:

  • the unresolvable panel shows the hint and not the grid / Add button / cell input, is not a permanent spinner, and writes nothing;
  • a panel that names its child object still renders the editable grid (without this, a fix that showed the hint unconditionally would pass everything above);
  • dropping childObject from a panel with dirty rows leaves Save enabled and still writes nothing — the only test that exercises the save guard.

Gates

Run on the committed head 69c7e6cc5, exit codes captured by redirect before any pipe, each quoted from the gate's own verdict line.

gate verdict
vitest run packages/plugin-form/ Test Files 65 passed (65) / Tests 653 passed (653)
plugin-form run type-check exit 0 (tsc --noEmit && tsc -p tsconfig.test.json)
plugin-form run lint 674 problems (0 errors, 674 warnings) — 94 files, 0 errors; every warning pre-existing, none on a line this diff adds
check-changeset-presence.mjs ✅ 2 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)
check-changeset-no-major.mjs ✅ No changeset declares a major bump.
check-changeset-fixed.mjs ✅ All workspace packages are in the changeset fixed group.
check-control-bytes.mjs ✅ OK (scanned 5230 tracked text file(s); skipped 85 binary)
check-entry-guard.mjs exit 0
check-phantom-dependencies.mjs ✅ Every in-scope import is declared by the package that publishes it.
check-vi-mock-specifiers.mjs ✅ OK
check-i18n-call-site-keys / -en-drift / -dead-keys exit 0 — No en value changed in this range.
check-shell-escape-residue.mjs ✅ OK

typecheck covers the edit is asserted, not assumed: tsc --listFiles puts LineItemsPanel.tsx in the main project (1 hit) and LineItemsPanel.childObjectDecline.test.tsx in the test project (1 hit, correctly 0 in the main one).

Declared narrowing — repo-wide pnpm lint (turbo run lint). I ran @object-ui/plugin-form's own leg in full, which is the exact command turbo runs for this package, and not the other packages' legs. Three readings make that a measurement rather than a skip: ① the population comes from eslint's own config (files: ['**/*.{ts,tsx}']) over the whole package, not from my guess about which files count; ② the file count is --format json's own — 94 files, 0 errors, 674 warnings, matching the human-format verdict line; ③ eslint.config.js sets no parserOptions.project, no projectService and no type-checked preset, so linting is not type-aware and this diff cannot move the verdict of any file it does not touch — in this package or another. CI runs the full farm regardless.

Not measured, and not failures. check-readme-exports.mjs (exit 1) and check-eager-closure-budget.mjs (exit 2) both report a missing build artifact — "its type entry ./dist/index.d.ts is not on disk — run pnpm build first" and "This is a broken gauge, not a passing budget" — and scripts/pm/check-half-states.mjs exits 3 with "it is no reading at all" (the container holds the proxy placeholder, not a GitHub credential). None is implicated by this diff: no README, no export, no console bundle, no board sweep. Recorded as prerequisite-not-met rather than as passes.

Out-of-scope finding, filed not fixed

While reading #5940's precedent I found that MasterDetailForm's own "config hint" branch renders the string "Loading columns…" for a detail whose childObject never resolved — a spinner-shaped message that can never stop being wrong, since the decline above it guarantees the columns never arrive. That is the exact outcome this card told me not to reproduce here, which is why this PR follows the shape of #5940's precedent and not its copy. Filed separately; not touched in this PR.

Scope

packages/plugin-form/** only, as dispatched — nothing near .npmrc or workflow images. No edit to content/docs/releases/.


Generated by Claude Code

… with no childObject

`LineItemsPanel` read `schema.childObject` at two sites. objectui#6188 guarded
the child-schema fetch; the row load still asked the data layer to `find` an
object literally named `undefined`, scoped by `{ [relationshipField]: parentId }`.

Declining that fetch is not safe on its own. `load` owns `loading`, and the
panel branched `loading ? "Loading…" : !parentId ? "Save the record first…" :
<grid>`, so a declining panel with a parent id bound fell to the third branch
and showed an empty EDITABLE grid with an Add button over an object that does
not exist. Measured on the pre-fix component: one keystroke in the grid's
always-present ghost row materialised a row, enabling Save, which reached
`batchTransaction([{ object: undefined, action: 'create', … }])` — so the bad
read was one keystroke from a bad write.

An unresolvable panel now gets its own render branch, a config hint naming the
key, checked ahead of `loading` because nothing is pending. `save` takes the
same one-line guard for the route the render branch cannot close: a schema
edited to drop `childObject` while rows are already dirty.

The pinned expectation in `LineItemsPanel.childObjectDecline.test.tsx` is
flipped to `toEqual([])` per its own annotation, and the render branch and the
closed save path are pinned alongside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CZgmFFzqA9cX8tBMhvpFe
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 52 chunks) 3223.7 KB 3266.6 KB
Main entry chunk (gzip) 154.2 KB 350 KB
Entry file index-CtZFBS9R.js
Status PASS

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

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 10.96KB 4.16KB
app-shell (runtime-config.js) 18.10KB 6.51KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 5.13KB 2.35KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 506.08KB 114.67KB
core (index.js) 5.30KB 2.13KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 171.74KB 47.48KB
fields (index.js) 238.89KB 60.02KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 4.28KB 1.75KB
i18n (index.js) 3.44KB 1.39KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 33.40KB 8.71KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.95KB 10.97KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.55KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useResponsiveConfig.js) 1.37KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 9.53KB 3.38KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 4.64KB 1.50KB
permissions (evaluator.js) 5.12KB 1.74KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 1.93KB 0.88KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 46.62KB 12.83KB
plugin-charts (index.js) 64.66KB 18.32KB
plugin-chatbot (index.js) 188.21KB 44.67KB
plugin-dashboard (index.js) 133.35KB 34.45KB
plugin-designer (index.js) 211.95KB 42.75KB
plugin-detail (index.js) 245.10KB 62.31KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 127.52KB 31.01KB
plugin-gantt (index.js) 164.14KB 39.87KB
plugin-grid (index.js) 201.21KB 54.43KB
plugin-kanban (index.js) 52.83KB 14.55KB
plugin-list (index.js) 111.94KB 27.24KB
plugin-map (index.js) 20.09KB 6.62KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 43.49KB 11.93KB
plugin-timeline (index.js) 26.49KB 7.59KB
plugin-tree (index.js) 9.26KB 3.13KB
plugin-view (index.js) 84.55KB 20.74KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 54.84KB 18.43KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.35KB 0.70KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 5.41KB 2.34KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 4.93KB 2.24KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 12.13KB 3.65KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 7.54KB 2.63KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.74KB 1.41KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.49KB 2.14KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 3.40KB 1.71KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

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

@os-warren
os-warren marked this pull request as ready for review August 25, 2026 15:38
@os-warren
os-warren added this pull request to the merge queue Aug 25, 2026
Merged via the queue into main with commit 5f19b92 Aug 25, 2026
28 checks passed
@os-warren
os-warren deleted the claude/issue-6194-line-items-decline-row-fetch branch August 25, 2026 15:50
akarma-synetal pushed a commit to akarma-synetal/objectui that referenced this pull request Sep 1, 2026
…ail (objectstack-ai#6374)

`MasterDetailForm` already declines to fetch the schema of a detail collection
whose `childObject` never resolved (objectui#5940) and returns the entry
unresolved. The render branch it then fell into read
`!d.columns?.length ? <p>Loading columns…</p>`, so the author was shown a
spinner-shaped message that can never end — the decline is precisely the
guarantee that those columns will not arrive — and that never named the key
they had to set.

The `!d.childObject` case now takes its own branch, checked BEFORE the columns
arm because nothing is pending: there is no first paint where "loading" is
honest. Copy and structure are `LineItemsPanel`'s, which took the same branch
for the same key in objectui#6194 / PR objectstack-ai#6359; the two components had disagreed
about what an author sees for the identical authoring mistake. The hint carries
its own `data-testid` (`md-detail-no-child-object`).

Two source comments claimed "the grid card shows a config hint". Both were
false. The one at the decline is now true and records that it was not. The one
at the resolver's `catch` is corrected rather than made true: an entry whose
schema fetch threw DOES name a child object, so it skips the new branch and
still lands on `Loading columns…`. Separating that from "still in flight" needs
per-entry error state this resolver does not keep, filed as objectui#6372.

Extends `MasterDetailForm.detailChildObjectDecline.test.tsx`, which covered the
fetch half only, with the render half. Both directions are pinned, matching the
file's existing discipline: the declined detail shows the hint and no
`Loading columns…`, and a detail that names its child object shows neither the
hint nor anything but `Loading columns…` while it resolves — so deleting the
loading branch outright cannot pass.

Claude-Session: https://claude.ai/code/session_011SfZeFWrhGLHmfq61xbz4q

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

record:line_items also issues find(undefined, …) for a panel whose childObject is unset — the sibling of #6188 in the same component

2 participants