Skip to content

fix(app-shell): converge both action runtimes on a field-preserving ActionParamDialog close - #6476

Merged
os-support-ai merged 1 commit into
mainfrom
claude/issue-6431-action-runtime-param-reset
Aug 26, 2026
Merged

fix(app-shell): converge both action runtimes on a field-preserving ActionParamDialog close#6476
os-support-ai merged 1 commit into
mainfrom
claude/issue-6431-action-runtime-param-reset

Conversation

@claude

@claudeclaudeBot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Fixes#6431

app-shell mounts two action runtimes into two copies of ActionParamDialog. They agreed on open and disagreed on close: useConsoleActionRuntime wrote setParamState({ open: false, params: [] }) — replacing the whole state object, emptying params and dropping title / description / resolve — while RecordDetailView, which re-implements the runtime rather than consuming the hook, wrote setParamState(s => ({ ...s, open: false })).

The behavioural diff is one line: the console runtime moves onto the field-preserving shape. Everything else in this PR is the measurement that chose it, and the pin that keeps the two from drifting apart again.

The measurement decided the shape — it was not inherited from the confirm pair

The triage ruling was explicit that #6034's answer transfers in form but not automatically in substance, and it named the two readings that had to be taken first. Both were taken, against the real dialog.

Reading 1 — what ActionParamDialog renders off state after open flips false.DialogContent carries duration-200 data-[state=closed]:animate-out, so Radix keeps the content mounted through its exit animation and the dialog goes on rendering off state for the whole ~200ms. Driving the real dialog across that window:

headingdescriptionparam rowsinputs
openCreate environmentthe action's ownEnvironment name*, Region2
closed, blanking shapeactionDialog.titlethe generic one(none)0
closed, preserving shapeCreate environmentthe action's ownEnvironment name*, Region2

A params form the user had just filled in re-titles itself to the generic label, swaps the action's description for the generic one, and empties out — strictly more visible damage than the confirm case, which only blanked one line of text.

Reading 2 — whether anything depends on params NOT surviving the close. Nothing does, and the intuition that a params form wants its values dropped does not survive contact with the code: the typed values are not in paramState at all. They live in ActionParamDialog's own values state, which its useEffect reseeds from the param defaults on every state.open false→true edge. A full type → close → reopen round trip was run under both shapes; the reopened input is '' either way. Measured, not reasoned: reopens blank under either reset shape — so no user-visible difference outlives the fade.

So no product-semantics question emerged — nothing a user can observe beyond the fade-out frame differs between the two shapes — and this stayed a convergence rather than forking to a decision card. Field-preserving wins on this dialog's own evidence, which happens to agree with #6034.

The reason is recorded at the divergence site, per the ruling: the full rationale sits at the console runtime's close handler, with a pointer at RecordDetailView's so the line there is not "cleaned up" later. ActionResultDialog, the non-divergence the card flagged as a control, is untouched.

The instrument, and why it is not blind

jsdom has no CSS engine, so getComputedStyle(node).animationName is always 'none', Radix's Presence unmounts synchronously, and the exit window does not exist unless it is modelled — a test that skipped this would assert on an unmounted tree and read as green for the wrong reason. animationName is therefore derived from the node's own live data-state, which is exactly what the two real classes do. That detail is load-bearing: Presence suspends the unmount only when the mount-time and close-time names differ, so a constant stub silently fails to open the window (it did, on the first attempt — the dialog unmounted and every post-close assertion had nothing to read).

The model's premise is measured rather than assumed: the exit-animation window is real in production, not just modelled here reads data-[state=closed]:animate-out, data-[state=open]:animate-in and duration-200 off the shipped DialogContent. If those classes ever go, that test fails before the pins start lying.

Both runtimes are driven through their own real seams — the runtime's own onOpenChange prop, in the call order handleCancel actually uses (settle the promise, then ask for the close), so the retained resolve is pinned as inert the way production makes it inert.

Reverse verification

Predicted before the run, on the committed fix: reverting the console runtime's close handler should turn exactly three tests red — the DOM pin, the console state pin, and the cross-runtime equality — and leave the six controls green.

Mutation proven on disk in both directions (occurrence counts of the injected and the deleted text, plus the line read back off disk), never a diffstat and never an editor exit code:

pre-mutation: preserving-shape lines: 1 blank-shape lines: 0
post-mutation: injected (blank) lines: 1 deleted (preserving) lines: 0
748: if (!open) setParamState({ open: false, params: [] });

Result, as predicted:

× console runtime: the closing dialog keeps the action title, description and every param row
× useConsoleActionRuntime: the close flips `open` and keeps every other dialog-read field
× the two runtimes write the same post-close state, field for field
Tests 3 failed | 6 passed (9)

Restore proven byte-for-byte, not by an exit code — worktree blob 9f13fcfa… identical to the HEAD blob, git diff HEAD empty, counts back to blank=0 / preserving=1 — under an EXIT/INT/TERM trap holding absolute paths. The suite then returned to 9/9 green on the restored tree.

Pins that are green in both directions, named rather than left to look load-bearing

Four of the nine do not distinguish the two shapes, and each is here for a stated reason:

  • RecordDetailView (×2) — that runtime was already correct, so these cannot be evidence for the ruling. They make a failure on the console side attributable: dialog, model and harness all behave for a runtime that was never broken, so a red above is the close handler and not the rig.
  • the blanking shape … empties and re-titles the fading dialog — the negative half of the measurement, kept executable rather than written down. Green on either side of the fix by construction; it fails the day ActionParamDialog stops reading one of the fields the pin above targets, at which point that pin would be guarding nothing.
  • the close-path fixture is multi-field — the anti-degeneracy guard itself. With a single-field param state the two shapes produce the same object and every parity assertion passes without measuring anything; this applies that requirement to this fixture.

The ghost-assertion shape ("params were cleared" passing because nothing ever rendered) is closed by targeting the specific post-close state and only reaching it after await screen.findByLabelText(/Environment name/) confirms a real render — a zero-render dialog times out there instead of sailing through.

Verification

Local list, at final commit fc9eb5558 (worktree clean at the time of the run):

checkverdict line
new suiteTest Files 1 passed (1) / Tests 9 passed (9)
sibling suites (8 files over these two + the dialog)Test Files 8 passed (8) / Tests 133 passed (133)
@object-ui/app-shelltype-checkexit 0, 0error TS lines
check:vi-mock-specifiers✅ check-vi-mock-specifiers: OK (… 694 relative specifier(s) resolved …)
check:control-bytes✅ check-control-bytes: OK (scanned 5346 tracked text file(s); skipped 85 binary)
changeset presence / fixed / no-major✅ 3 source file(s) … declares 1 changeset(s), ✅ All workspace packages are in the changeset fixed group, ✅ No changeset declares a major bump

Two notes on how those were read:

  • The typecheck genuinely covers the new test file. A package type-check that excludes **/*.test.ts is a true statement about nothing; tsc -p tsconfig.test.json --listFiles puts paramRuntimeParity-6431.test.tsx in the program (3240 files total). The first typecheck run failed with TS2307: Cannot find module '@object-ui/fields' — the unbuilt dependency closure in a fresh worktree, not this diff; pnpm --workspace-concurrency=2 --filter '@object-ui/app-shell^...' build (exit 0) then took it to exit 0.
  • Lint was not narrowed.eslint . --format json was run over the wholeapp-shell package: 976 files selected by eslint's own config, 0 errors, 2716 warnings (pre-existing; no-explicit-any and react-refresh/only-export-components are warn-level across this package). The three touched files carry 0 errors; the new test file's 32 warnings are all no-explicit-any, matching the sibling confirmRuntimeParity-5835.test.tsx style verbatim. Nothing outside that package can move: eslint.config.js uses tseslint.configs.recommended with noparserOptions.project / projectService, and no rule in eslint-rules/ reads the filesystem — every rule is single-file-scoped, so a diff confined to app-shell cannot change the verdict on a file it did not touch.

Public surface

No public surface change. No **/src/index.ts is touched, the diff adds and removes no export line, and no published type gains a member — the behavioural change is one call site inside a component body. Stated explicitly because it is the answer the contract-review tier turns on.

Out of scope, filed not fixed

There is a thirdActionParamDialog consumer the card did not know about: views/metadata-admin/MetadataTypeActions.tsx holds its own paramState and still closes with the blanking shape. It is outside this card's declared file face, and converging it is not the same mechanical call — its close handler also calls paramState.resolve?.(null) itself, a second settle on an already-settled promise, and whether that line stays is a decision this card's measurement does not imply. Filed unassigned as #6473 with the evidence; deliberately not touched here.


Generated by Claude Code

…ctionParamDialog close
`useConsoleActionRuntime` closed the param dialog with
`setParamState({ open: false, params: [] })` — replacing the whole state,
emptying `params` and dropping `title` / `description` / `resolve` — while
`RecordDetailView`, which mounts a second runtime into the same dialog, closed
with `setParamState(s => ({ ...s, open: false }))`.
Radix holds `DialogContent` mounted through its 200ms exit animation, so the
dialog renders off `state` for the whole fade-out: under the blanking shape a
just-filled params form re-titled itself to the generic `actionDialog.title`,
swapped in the generic description and dropped every param row. The console
runtime moves onto the field-preserving shape.
Re-measured on this dialog rather than inherited from the confirm pair
(objectui#6034): a params form could plausibly want its values dropped, but the
typed values are not in `paramState` at all — they live in the dialog's own
`values`, reseeded on every open — so a reopen starts blank either way and
nothing a user can observe outlives the fade.
Part of #6431
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3233.8 KB3266.6 KB
Main entry chunk (gzip)157.4 KB350 KB
Entry fileindex-CFgIcKu0.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)11.30KB4.28KB
app-shell (runtime-config.js)18.10KB6.51KB
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)505.99KB114.64KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)173.10KB47.96KB
fields (index.js)238.89KB60.02KB
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)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
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.66KB12.84KB
plugin-charts (index.js)64.66KB18.32KB
plugin-chatbot (index.js)188.60KB44.82KB
plugin-dashboard (index.js)133.48KB34.49KB
plugin-designer (index.js)211.90KB42.74KB
plugin-detail (index.js)245.26KB62.38KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)131.78KB32.19KB
plugin-gantt (index.js)164.14KB39.87KB
plugin-grid (index.js)201.66KB54.57KB
plugin-kanban (index.js)53.16KB14.65KB
plugin-list (index.js)112.74KB27.50KB
plugin-map (index.js)20.09KB6.62KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)26.72KB7.71KB
plugin-tree (index.js)9.26KB3.13KB
plugin-view (index.js)84.82KB20.79KB
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)56.69KB19.03KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)2.05KB1.04KB
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)12.13KB3.65KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)7.54KB2.63KB
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.72KB2.24KB
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 (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
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

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.

[finding] app-shell's two action runtimes reset paramState differently when ActionParamDialog closes — the #6034 divergence, one dialog over

2 participants

@os-support-ai@claude