Skip to content

fix(components): a dependent lookup clears when its dependsOn parent changes or clears (objectui#11631) - #11644

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-11631-lookup-cascade-clear
Oct 5, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-11631-lookup-cascade-clear

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #11631

Clause-②: no

What changed

A lookup whose field-level dependsOn names a sibling field now drops its selection when that sibling changes or is cleared. Before, the picker re-scoped its candidate query to the new parent but kept the record already picked. So the form saved an invoice whose contact belongs to a different account, and the server, which checks only that a reference exists, accepted it.

@object-ui/components, renderers/form/form.tsx:

  • dependentLookupParents (module scope, not exported) answers which sibling fields scope a lookup. It reads the same slot LookupField scopes its query by: the field prop this renderer hands the widget (field.field || field), one nested field unwrapped when it carries reference or type, and only the array shape the spec declares at field level. The family is EXPANDABLE_FIELD_TYPES, the set needsDataSourceWiring already feeds dependentValues to. CASCADE_OPTION_WIDGET_TYPES is untouched.
  • A form.watch subscription (keyed on the primitive dependentLookupsKey, established only when the form has a dependent lookup) clears a lookup when any parent takes a different value. It writes null, or [] for a multi-value lookup, the same sentinels the two existing clears write, so an edit overwrites the stored value instead of leaving it. A lookup holding nothing is never written to. A cleared lookup that scopes another lookup clears that one in the same pass. The field whose change started a pass is never cleared by it, so two lookups that name each other cannot wipe the pick the user just made.
  • What does not count as a parent change: a defaultValues reset (a record landing after first paint, a recordId swap in a mounted drawer, the input the reset carries across, read inside resetInFlightRef), and a notification that names no field (the bare form.reset() that resetOnSubmit and Cancel call). Those re-base the comparison and clear nothing, so an existing record opens with its saved pair intact.

The Clause-② declaration holds: no export, prop, type member or i18n key is added to any @object-ui/* package. resolveDependsOnFields was already exported by @object-ui/core and is only imported here.

Landing site: form.tsx, beside the reset, not inside the cascade-clear effect

The card names the cascade-clear effect. That effect is the right file but the wrong mechanism for a lookup, measured both ways:

  • It skips every field whose normalized type is not in CASCADE_OPTION_WIDGET_TYPES and every field with no static options. A lookup fails both checks (A0 below: all seven clear rows red on the unchanged file).
  • It is a passive effect over ruleRecord, and it validates a value against an option set. A lookup has no option set to test, so its rule has to be a transition (the parent moved). A passive effect after the commit sees two value sets and no record of which operation moved them. The defaultValues reset that fills a parent and its lookup together runs inside the synchronous resetInFlightRef window, and only a subscriber notified inside that window can tell it from an edit. That is the same signal the onAction / onChange value channels in this file already read, so the new subscription sits beside them. The option effect's comment now names it.

Measured

Unit pins (form-dependent-lookup-clear-11631.test.tsx, 14 rows)

Clear rows: create, parent switched; edit, parent emptied (explicit null survives JSON.stringify); hand-written field carrying dependsOn itself; multi-value lookup to []; two-level chain; two lookups naming each other.
Keep rows: edit with the pair present at mount; record landing after first paint (then a real switch still clears); recordId swap; input carried across a defaults reset; resetOnSubmit; unrelated field change; empty lookup not written to; a bare-name dependsOn the picker does not scope by.

Reverse verification and ablations (fix committed first; every leg restored from HEAD and proved by blob hash and empty git diff HEAD)

Direction predicted before running: the clear rows go red in the STALE direction and the keep rows stay green; each guard ablation reds exactly the row that pins it. Observed exactly that:

Leg Mutation Red rows
A0 form.tsx at the pre-fix base 7: the six clear rows plus the after-paint row's live-afterwards half, every failure a retained value (prospect-18, b1 or the two-id list) where an empty value was expected
A1 subscription never established the same 7
A2 drop the resetInFlightRef re-base 1: carried input
A3 drop the unnamed-notification re-base 1: resetOnSubmit
A4 drop the origin guard 1: lookups naming each other
A5 drop the empty-lookup skip 1: empty lookup not written to
A6 accept a non-array dependsOn 1: the bare-name boundary

Mutations went through ablation-replace.mjs (anchor hit 1 to 0, blob changed); the run sits on the report comment.

Live, card repro: before and after

Backend: objectstack main at 75ddcd1b, showcase app, built in a private worktree, --fresh database on a private port. "Before" is the console from this branch's base c4c506b; "after" is the console from this branch. Same backend, same browser script (Chromium, headless).

  • Served metadata: /meta/object/showcase_invoice serves contact with dependsOn: ["account"] (field level). The contact candidate query under Northwind is filter=["account","=",NORTHWIND_ID].
  • New invoice, Account Northwind, Contact "Prospect 18", switch Account to Contoso, Status Draft, Create:
    • before: the chip stays "Prospect 18"; the create body carries Contoso's id and Prospect 18's id; read back: Contoso beside Prospect 18, whose own account is Northwind.
    • after: the chip clears on the switch; the create body carries "contact": null; read back: contact null.
  • Same, but remove the Account chip instead of switching: before shows "Prospect 18" beside "Select Account first"; after shows only "Select Account first".
  • Edit an existing Northwind / Prospect 18 invoice, change only Tax Rate, Save (after): the contact shows on open, the update body is {"tax_rate":5}, and the read-back keeps the pair.
  • Edit the same invoice and switch Account to Contoso: before, the update body is the account alone and the stored contact stays Northwind's; after, the update body carries "contact": null and the read-back is null.

Other form paths: not changed here

Every host that draws a type: 'form' schema shares this renderer: ObjectForm and its drawer, modal, split, tabbed and wizard variants in @object-ui/plugin-form. Two other paths keep the defect, by reading:

  • apps/console/src/components/FormPage.tsx (the form-view page, /forms/:name and /f/:slug) is a second renderer with its own values state. It feeds lookups dependentValues but never clears one. Not measured live: the only dependent lookup in the examples is showcase_invoice.contact, and no form view renders it.
  • packages/app-shell/src/views/ActionParamDialog.tsx: a lookup parameter with dependsOn is scoped by the dialog's values, and updateValue writes only the changed parameter. Not measured live.

Acceptance notes

  • Keeping a selection the new scope still admits was left out, as the triage grade allows. Answering it needs a server query per switch, and the gain is narrow.
  • A view-level dependsOn on a lookup (the form-view FormField.dependsOn that @object-ui/plugin-form copies onto the runtime field's top level) does not scope LookupField when object metadata is stashed, so it does not drive this clear either. The clear follows the picker's scope by construction, and the boundary row pins the form side of that.
  • Inline single-field edits (the record detail inline editor, the grid's inline row edit) save a changed parent without touching its dependent. That is a different interaction, noted only.
  • No toast names the cleared lookup: the chip vanishes next to the control the user just changed, and naming it would need a new i18n key, which this change may not add.

Gates

On the final head fea2341 (after the merge of main): pnpm --filter @object-ui/components type-check (its test project lists the new file), pnpm --filter @object-ui/components lint (0 errors, no warning in the added hunks), pnpm exec vitest run packages/components/src/renderers/form/, the A0 to A6 legs above, and the root checks check:new-line-citations, check:control-bytes, check:test-path-roots, check:vi-mock-specifiers, check:vi-mock-inherit, check:vi-mock-override-shape, check:changeset-claims, check:pending-changeset-literals, check:shell-escape-residue, check:handler-key-reads, check:i18n-keys, check:unreferenced-sources, check-changeset-presence.mjs and check-changeset-no-major.mjs.

Declared narrowing: the whole-package pnpm exec vitest run packages/components/ ran green at a3e25ff. After it came a types-only commit (the metadata carrier read as a record of unknown values instead of any, the probe's props declared) and the merge, whose diff touches only @object-ui/app-shell and a changeset. The final head reran the form directory instead. Repo-wide pnpm lint and the full pnpm test are CI's.

Changeset: .changeset/11631-lookup-cascade-clear.md, @object-ui/components patch.

Implemented in session https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL (dispatched by the domain:ui seat 1 claim on the card).


Generated by Claude Code

claude added 5 commits October 5, 2026 04:08
…changes or clears (objectui#11631)

The form's cascade clear (#2284) tests a value against a static option
set, so it never covered the reference family: a lookup scoped by
`dependsOn` re-scoped its candidate query when the parent moved, but kept
the selection already made, and the form saved an invoice whose contact
belongs to a different account.

A form.watch subscription now clears a dependent lookup (null, or [] for a
multi-value lookup) when any parent that scopes it takes a different value.
The parents are read from the same slot LookupField scopes by. A
defaultValues reset (record landing, recordId swap, resetOnSubmit, cancel)
re-bases the comparison instead of clearing, so a saved pair survives the
record opening.

Claude-Session: https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL
Co-authored-by: Claude <noreply@anthropic.com>
…ot a parent switch (objectui#11631)

Claude-Session: https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL
Co-authored-by: Claude <noreply@anthropic.com>
…p clear (objectui#11631)

Claude-Session: https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL
Co-authored-by: Claude <noreply@anthropic.com>
…est probe without any (objectui#11631)

No behaviour change: the metadata carrier is read as Record<string, unknown>
and the test probe declares its props.

Claude-Session: https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 331 chunks) 3316.9 KB 3330.4 KB
Main entry chunk (gzip) 151.6 KB 350 KB
Entry file index-CKZ1sWFb.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) 17.22KB 6.37KB
app-shell (runtime-config.js) 22.52KB 7.86KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.11KB 3.87KB
auth (ActiveOrganizationStorage.js) 27.95KB 10.04KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.22KB 10.61KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.17KB 5.40KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.72KB 2.24KB
auth (SocialSignInButtons.js) 9.70KB 3.93KB
auth (UserMenu.js) 3.39KB 1.21KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.70KB 10.94KB
auth (createAuthenticatedFetch.js) 8.54KB 3.46KB
auth (index.js) 3.63KB 1.64KB
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) 11.08KB 4.58KB
collaboration (CommentThread.js) 27.11KB 7.97KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.28KB 2.60KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.50KB 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) 574.98KB 137.97KB
core (index.js) 10.00KB 3.96KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 232.57KB 64.51KB
fields (index.js) 262.75KB 66.62KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 2.59KB 1.22KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 8.87KB 3.64KB
i18n (index.js) 5.24KB 2.27KB
i18n (pickLocalized.js) 9.86KB 3.95KB
i18n (provider.js) 39.35KB 12.88KB
i18n (translateFn.js) 0.20KB 0.18KB
i18n (useDisplayLocale.js) 3.52KB 1.76KB
i18n (useObjectLabel.js) 35.66KB 9.49KB
i18n (useSafeTranslation.js) 7.14KB 2.92KB
layout (index.js) 39.47KB 11.25KB
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.99KB 0.87KB
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) 6.62KB 2.45KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 5.52KB 2.10KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.86KB 5.00KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.52KB 2.26KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.33KB 3.07KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 16.04KB 3.92KB
plugin-calendar (index.js) 53.17KB 15.46KB
plugin-charts (index.js) 84.26KB 23.05KB
plugin-chatbot (index.js) 198.22KB 46.97KB
plugin-dashboard (index.js) 143.54KB 38.79KB
plugin-designer (index.js) 231.41KB 48.84KB
plugin-detail (index.js) 245.97KB 64.67KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 177.09KB 45.89KB
plugin-gantt (index.js) 179.16KB 45.06KB
plugin-grid (index.js) 235.92KB 64.87KB
plugin-kanban (index.js) 50.06KB 15.74KB
plugin-list (index.js) 116.72KB 29.10KB
plugin-map (index.js) 25.60KB 8.62KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 44.12KB 12.29KB
plugin-timeline (index.js) 38.80KB 11.71KB
plugin-tree (index.js) 14.51KB 5.15KB
plugin-view (index.js) 90.23KB 22.73KB
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.81KB 3.58KB
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) 120.63KB 39.56KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.50KB 2.06KB
react (schema-input.js) 4.31KB 2.07KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (body-dialect.js) 4.50KB 1.99KB
sdui-parser (codegen.js) 9.45KB 3.76KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 7.30KB 3.12KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 25.28KB 7.80KB
sdui-parser (provenance.js) 3.84KB 1.90KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 23.87KB 7.83KB
types (ai.js) 4.39KB 2.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 4.12KB 1.61KB
types (authoring-nodes.js) 0.20KB 0.19KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (cloud.js) 0.20KB 0.18KB
types (complex.js) 4.44KB 2.07KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (dashboard-widget-layout.js) 2.06KB 0.96KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 1.13KB 0.65KB
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) 5.78KB 2.70KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 5.00KB 2.39KB
types (navigation.js) 0.20KB 0.18KB
types (node-slots.js) 7.18KB 2.34KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 2.52KB 1.31KB
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 (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 4.99KB 1.96KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 19.93KB 7.25KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
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

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.

console(form): a lookup with dependsOn keeps its value when the parent changes — an invoice saves a contact from a different account

2 participants