Skip to content

Publish CommandForm execution state to context and parents - #2538

Merged
woksin merged 1 commit into
mainfrom
fix/2497-command-form-execution-state
Aug 13, 2026
Merged

Publish CommandForm execution state to context and parents#2538
woksin merged 1 commit into
mainfrom
fix/2497-command-form-execution-state

Conversation

@woksin

@woksin woksin commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Added

A parent or card toolbar rendering an external submit control had no way
to disable or label it: execution state lived only inside the form, and
form state was unreachable from outside the provider.

Executions are counted rather than flagged, so two in flight keep
reporting executing until the last settles - a boolean cleared in a
finally reports idle for both as soon as the first one lands. The
imperative handle is created once for the component's lifetime and reads
through refs, so a callback ref attaches exactly once instead of
re-attaching on every parent render. onStateChange exists because a ref
cannot re-render a parent, so a handle alone would serve only half of
this.

children is deliberately untouched: widening it broke a wrapper
forwarding CommandFormProps, and memoizing resolved children on the
execution flag re-ran the field parse and reverted the user's typed
input on submit. Both are covered by specs here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@woksin woksin added the minor label Aug 12, 2026
@woksin

woksin commented Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

Reviewer context, kept out of the body. This replaces #2531, which was closed unmerged after review returned do-not-ship; the three confirmed blockers there are each eliminated by construction rather than by care.

Blocker 1 — typed input reverting on submit. #2531 memoized resolved children on the execution flag, which re-ran the field parse and re-seeded from currentValues. Here the children path is entirely untouched: useMemo(() => getCommandFormFields<TCommand>(props), [props.children]) is unchanged, children keeps its React.ReactNode type, and getCommandFormFields has no edits — git diff shows zero changed lines mentioning either. A control spec covers it anyway: seed name: 'seed' via currentValues, type over it, submit, assert the typed value survives. An injected setCommandValues(mergedInitialValues) inside the execute guard turns it RED, so the control is not vacuous.

Blocker 2 — unbound fields from a fragment-returning render prop. No render prop exists; children was not widened, so React.Children.toArray never meets a fragment it did not already meet on main. That also avoids the TS2322 that broke a wrapper forwarding CommandFormProps.

Blocker 3 — boolean cleared in a finally. Executions are counted. Increment and decrement sit strictly inside the typeof … execute === 'function' guard, so the throw new Error('Command instance does not have an execute method') can never leak a count; the decrement is in a finally with no catch, so a rejection restores the count and still propagates unchanged.

The formRef re-attach loop. Root cause was handleExecute carrying props in its deps while CommandForm renders <CommandFormComponent {...props} />, making props a fresh object every render. That dep array is not fixable — parents pass inline arrow callbacks. Instead the handle is created once (useImperativeHandle(…, [])) with getters reading a latestRef refreshed in a layout effect, so a callback ref fires exactly once on mount and once on unmount.

onStateChange is not redundant. An imperative handle is not reactive, so a handle alone cannot re-render a parent's button — it would serve only half of #2497.

Semver — the measurement behind required isExecuting. #2531's reviewer made it optional to protect Components' mocks, which manufactured an unspecced ?? false branch that then survived mutation. That premise is wrong: searching Cratis/Components origin/main two different ways, CommandFormContextValue has zero occurrences — 41 files reference useCommandFormContext, but as vi.mock factory literals whose return type is inferred, never checked against the module type. They already omit 16+ currently-required members and compile. So a required member cannot break them, and useIsCommandExecuting carries no fallback.

FINDING — the zero-blast-radius premise was not exact. Five sites in four files under for_asCommandFormField/ do hand-construct the context and are explicitly typed CommandFormContextValue<unknown>, so tsc checks them. The type was not weakened; isExecuting: false was added to each literal. Worth a downstream compile before release, since Arc's own typed literals broke where the survey said nothing would.

Mutation evidence — four mutations, no survivors. Each restored via trap … EXIT + cp + touch, restore confirmed by shasum -a 256 against baseline d9816226…7e8990.

  • Drop setExecutionCount after the increment → and_the_state_transitions RED (expected [false] to deeply equal [false, true, false]). SPECIFIC — this is exactly the Gate rendering on identity, and report whether a command form is executing #2531 survivor, where three "should stop reporting executing" specs passed against a flag that was never set true.
  • Count → flag (-= 1 becomes = 0) → and_two_executions_overlap RED. SPECIFIC; the other three stay green.
  • useImperativeHandle deps [][props]and_the_handle_is_held_across_a_parent_render RED with 4 attachments / 3 detachments — the loop reproduced exactly. SPECIFIC.
  • The blocker-1 control above. SPECIFIC.

All results are RED, which cannot be stale under the dist/ hazard; the green corroborating runs are not load-bearing.

Gate. yarn install; yarn lint:ci 0 errors in Arc.React and Arc.React.MVVM; npx tsc -b --force clean in Arc (upstream), Arc.React, and Arc.React.MVVM (downstream consumer, built deliberately because building only the changed package has missed a break before); vitest 517 tests in Arc.React and 14 in Arc.React.MVVM, all passing.

Scope limit, decided deliberately. CommandDialog in Cratis/Components executes the command instance directly and keeps its own local busy flag, so a form-scoped count correctly reads false there and nothing is broken today. Widening to useCommand would mean putting execution state on a mutable command instance shared across a CommandScope — a larger cross-repo change than #2497 asks for. Shipping form-scoped; the useCommand-level counter belongs in its own issue.

Not verified: rollup was not run (only the tsc half of build), so bundle and rolled-up .d.ts output is unchecked. React StrictMode double-invocation of the handle and count was not exercised. Docs were not rendered or link-checked.

@woksin woksin added minor and removed minor labels Aug 13, 2026
@woksin
woksin merged commit fd5c753 into main Aug 13, 2026
13 of 16 checks passed
@woksin
woksin deleted the fix/2497-command-form-execution-state branch August 13, 2026 07:01
Sign up for free to 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.

2 participants