Skip to content

fix(app-shell): converge both confirm runtimes on one close reset shape - #6432

Merged
os-support-ai merged 1 commit into
mainfrom
claude/issue-6034-confirm-runtime-close-parity
Aug 26, 2026
Merged

fix(app-shell): converge both confirm runtimes on one close reset shape#6432
os-support-ai merged 1 commit into
mainfrom
claude/issue-6034-confirm-runtime-close-parity

Conversation

@os-support-ai

Copy link
Copy Markdown
Collaborator

Fixes#6034

app-shell mounts two confirm runtimes into one ActionConfirmDialog. They agreed on open and disagreed on close. This converges the close handlers on one reset shape and extends #5835's existing parity pin to cover the close path.

Census, re-derived on origin/main9602dc820

openclose
packages/app-shell/src/hooks/useConsoleActionRuntime.tsx:193setConfirmState({ open: true, message, options, resolve }):696setConfirmState({ open: false, message: '' })blanks the whole state
packages/app-shell/src/views/RecordDetailView.tsx:518 — identical shape:2511setConfirmState(s => ({ ...s, open: false }))preserves fields

Reproduces exactly as filed. #5835's pin covered open only.

Close-path consumer enumeration — every hop, both runtimes

Not inherited; derived here. confirmState appears on exactly four lines in packages/*/src (two useState declarations, two <ActionConfirmDialog state={confirmState}> reads) and nowhere else, so the consumer chain is short and closed:

  • Hop 1 — the state variable. Read only by the JSX that mounts the dialog, one per runtime.
  • Hop 2 — ActionConfirmDialog (views/ActionConfirmDialog.tsx), one component serving both. It reads state.open, state.message, state.options?.title, state.options?.confirmText, state.options?.cancelText, state.resolve.
  • Hop 3 — Radix.state.open reaches AlertDialog; AlertDialogContent carries data-[state=closed]:animate-out … duration-200, so Presence keeps the subtree mounted through the exit animation and hop 2 keeps reading for the whole fade-out.

Mount sites, so the "fix three of four sites" trap can be checked rather than asserted away: the console runtime's dialog is mounted from four places (console/ConsoleShell.tsx:83, views/ObjectView.tsx:1060, views/DeclaredActionsBar.tsx:463, and views/PageView.tsx via ConsoleActionRuntimeProvider), plus RecordDetailView's own — five mount sites. None of them touches confirmState; each runtime owns its reset in exactly one place, so changing the hook converges four sites at once and the fifth was already the target shape. No third consumer at a second hop.

plugin-designer/src/hooks/useConfirmDialog.ts exports a name-colliding ConfirmDialogState, but it is an unrelated hook (isOpen/title/message, resolver in a ref) that never feeds ActionConfirmDialog. Not a hop.

Which shape won, and why: field-preserving

useConsoleActionRuntime now closes with setConfirmState(s => ({ ...s, open: false })), matching RecordDetailView.

The decision follows from hop 3. Because Radix holds the content mounted for the ~200 ms fade-out, the state written on close is what the user sees during the close. Blanking rewrote the dialog's visible text mid-fade — the description emptied, and the title and both button labels reverted from the caller's options to their i18n defaults. Preserving fades the dialog out intact. That is the only observable difference in the consumer set, and it points one way.

The cost of preserving is a resolve left reachable in state after close. It is inert, and provably so from the dialog's own code:

  • ActionConfirmDialog.handleConfirm / handleCancel call state.resolve?.(…)beforeonOpenChange(false), so the promise is already settled when the reset runs; a later call on a settled promise is a no-op.
  • The open path replaces the entire state object ({ open: true, message, options, resolve }, options present-but-undefined at the runner arity), so no stale field can leak into a reopen.

Secondary, same direction: the functional-updater form is also the safer one under a close/reopen race — a whole-object replacement discards whatever a newly-opened confirm has just written, including its resolve, which would strand that action's promise forever.

No close-path consumer depends on the divergent shape, so nothing needed to be reported and stopped on under that clause, and the divergence is not preserved behind a flag or an option.

The pin: extended, not duplicated

packages/app-shell/src/views/RecordDetailView.confirmRuntimeParity-5835.test.tsx gains a second describe for the close path. #5835's file keeps ownership of this relationship — a parallel pin would leave two pins that can disagree, which is the same defect class as the two runtimes.

⚠️The open-path assertions are controls here, not evidence. They stay green under either reset shape. What they establish is that both runtimes still reach the dialog at all, which is what makes a difference measured after the close attributable to the close handler. Do not read the four green open-path tests as proof that this change is correct.

Against the degenerating-pin trap, the close half:

  • opens with a multi-field state — MESSAGE plus the full { title, confirmText, cancelText } bag — so "blanked" and "preserved" are different objects;
  • asserts the surviving fields by name (Object.keys(...) against the dialog-read list, message, options, typeof resolve), not by count;
  • drives the runtime's ownonOpenChange prop, in ActionConfirmDialog.handleCancel's exact order (settle the promise, then close), so it pins the runtime's reset rather than the test's idea of it;
  • carries a non-degeneracy test that applies both shipped shapes to this fixture and asserts they disagree — on the key set and on a value. With an empty or single-field state every one of those inequalities collapses, and that test is what fails if a later edit hollows out the fixture.

Verification

All vitest runs from the repo root with root-relative paths (objectui#3378), under the container's shared heavy-verify lock.

Red before the fix — pin written first, source untouched at 9602dc820. Predicted 6 pass / 2 fail, with the console-runtime close test and the cross-runtime parity test red. Observed exactly that:

 × 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 2 failed | 6 passed (8)
AssertionError: expected [ 'message', 'open' ] to deeply equal [ 'message', 'open', 'options', …(1) ]

That assertion text is the divergence itself: blanking drops the options and resolve keys outright.

Green afterTests 8 passed (8).

Ablation, post-commit (fix reverted to the blanked shape, restored by hash): mutation confirmed on disk by grepping the injected and the removed text (0 fixed-shape hits, 1 blanked-shape hit) and by git hash-object differing from the HEAD blob; re-run went 2 failed | 6 passed; restore under trap … EXIT INT TERM with absolute paths, verified by the restored blob hash equalling the HEAD blob (fb54c32240712ba5355384823c96380ae6936fff) and git diff HEAD empty. No dist is in this path — the pin imports ../hooks/useConsoleActionRuntime relatively, so vitest loads the mutated source directly and no rebuild leg applies.

Every reading below is on final head 1dd499571:

checkverdict line
the pinTest Files 1 passed (1) / Tests 8 passed (8)
both edited files' own suites (5 files)Test Files 5 passed (5) / Tests 67 passed (67)
all RecordDetailView.*.test.tsx (17 files)Test Files 17 passed (17) / Tests 134 passed (134)
pnpm --filter @object-ui/app-shell type-checkexit 0, script echoed as tsc --noEmit && tsc -p tsconfig.test.json
check:control-bytes✅ check-control-bytes: OK (scanned 5313 tracked text file(s))
check:vi-mock-specifiers✅ check-vi-mock-specifiers: OK
check:action-forward-parityexit 0
check-changeset-presence.mjs✅ 2 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)

Type-check coverage was measured, not assumed.tsc -p tsconfig.json --listFiles contains useConsoleActionRuntime.tsx but not the .test.tsx; tsc -p tsconfig.test.json --listFiles contains both. The package's type-check script runs both programs, so the second half is what puts the edited test file under the compiler. Dependency closure built first (pnpm --filter '@object-ui/app-shell^...' build), so tsc's dist-resolved workspace deps were current.

Lint was narrowed to packages/app-shell, and the narrowing is measured: (1) the population comes from eslint's own config — the package has no local eslint.config.*, so eslint . resolves the root flat config, which is the same invocation turbo run lint uses for this package; (2) --format json reports 975 files inspected, 0 errors, 2684 pre-existing warnings, and both edited files are in that population with errors=0; (3) eslint.config.js declares no project / projectService / parserOptions.project, so type-aware linting is off and a change inside app-shell cannot move the verdict on a file in another package. CI runs the full farm regardless.

Changeset

.changeset/confirm-runtime-close-parity-6034.md, patch on @object-ui/app-shell — a real behaviour change on close, stated in words. Not major (fixed group).

Out of scope, filed

#6431ActionParamDialog carries the same close divergence between the same two runtimes (useConsoleActionRuntime.tsx:716 blanks { open: false, params: [] }; RecordDetailView.tsx:2519 preserves). Deliberately not fixed here: this card's fence is the confirm path, and the param call is not the same mechanical one — ParamDialogState carries params and in-flight collection state, so blanking on close may be the deliberate choice there rather than the mid-fade defect. That needs a ruling. The third dialog in the same pair, ActionResultDialog, does not diverge (both close with setResultDialogState({ open: false })), so the drift is two of three.

Collision check

packages/app-shell is shared, so: this PR touches src/hooks/useConsoleActionRuntime.tsx and src/views/RecordDetailView.confirmRuntimeParity-5835.test.tsx only. Open draft PR #6283 in the same package is on src/views/metadata-admin/previews/block-types.ts — no file-level overlap.

⛔ Left as draft for the PM to land — not marked ready, not self-merged, no auto-merge.


Generated by Claude Code

`app-shell` mounts two confirm runtimes into one `ActionConfirmDialog`. They
agreed on open and disagreed on close: `useConsoleActionRuntime` replaced the
whole state (`{ open: false, message: '' }`), blanking `message` and dropping
the `options` / `resolve` keys, while `RecordDetailView` flipped one flag and
kept every field.
Converge on the field-preserving shape. Radix holds `AlertDialogContent`
mounted through its exit animation (`data-[state=closed]:animate-out …
duration-200`), so `ActionConfirmDialog` goes on reading `state.message` into
the description and `state.options` into the title and both button labels for
the whole fade-out — blanking rewrote the dialog's visible text mid-fade. The
`resolve` this retains is inert: the dialog settles the promise before it asks
for the close, and the open path replaces the whole state object.
Extend the existing parity pin to the close path rather than adding a second
pin — two pins for one relationship can disagree, which is the defect class the
two runtimes had. The fixture is multi-field on purpose (message + a full
options bag) so "blanked" and "preserved" are distinguishable, and a
non-degeneracy test asserts the two shapes really do differ on this fixture.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SfZeFWrhGLHmfq61xbz4q
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3233.6 KB3266.6 KB
Main entry chunk (gzip)157.4 KB350 KB
Entry fileindex-15Mp0j5r.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.86KB114.58KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)173.18KB47.97KB
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.46KB34.48KB
plugin-designer (index.js)211.90KB42.74KB
plugin-detail (index.js)245.10KB62.31KB
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.79KB54.60KB
plugin-kanban (index.js)53.16KB14.65KB
plugin-list (index.js)112.63KB27.45KB
plugin-map (index.js)20.09KB6.62KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.49KB11.93KB
plugin-timeline (index.js)26.72KB7.71KB
plugin-tree (index.js)9.26KB3.13KB
plugin-view (index.js)84.55KB20.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)4.47KB1.63KB
react (SchemaRenderer.js)54.84KB18.43KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.35KB0.70KB
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

@os-support-ai
os-support-ai marked this pull request as ready for review August 26, 2026 00:09
@os-support-ai
os-support-ai added this pull request to the merge queueAug 26, 2026
Merged via the queue into main with commit fdefd4bAug 26, 2026
29 checks passed
@os-support-ai
os-support-ai deleted the claude/issue-6034-confirm-runtime-close-parity branch August 26, 2026 00:22
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 confirm runtimes reset confirmState differently when the dialog closes

2 participants

@os-support-ai@claude