Skip to content

Gate rendering on identity, and report whether a command form is executing - #2531

Closed
woksin wants to merge 5 commits into
mainfrom
feat/imp-41-42-identity-loading-and-command-execution-state
Closed

Gate rendering on identity, and report whether a command form is executing#2531
woksin wants to merge 5 commits into
mainfrom
feat/imp-41-42-identity-loading-and-command-execution-state

Conversation

@woksin

@woksin woksin commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Added

woksin and others added 5 commits August 12, 2026 10:24
An application gating routes on roles had to hand-roll the guard, and every
hand-rolled version inherits the same flaw: isSet reads false both before the
first identity fetch answers and after it answers anonymous. Nothing could tell
those apart, so a guard either renders nothing forever for a genuinely anonymous
caller, or flashes the signed-out view at a signed-in one.

Add isLoading to the React identity context to separate the two, and a
RequireRole component that renders one of three outcomes from it. Access is
expressed as roles, a predicate over details, or both - both must pass when both
are given. The forbidden and loading outcomes are slots rather than a redirect,
because Arc.React does not depend on a router; an application passes its own
navigation element.

isLoading lives on IIdentityContext rather than IIdentity so the
framework-agnostic package keeps no React lifecycle concern.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: 49eab3a7-0743-4895-8303-bf5632b6a992
Two independent reviews of the previous commit found the loading flag was
only ever seeded, never re-raised. Nothing re-entered loading after mount, so
during a refresh after sign-in the context read "not loading, not signed in"
for the whole round-trip and a gate rendered its forbidden slot - navigating
away, for an application that put a redirect there. The flash the flag exists
to prevent was moved from first paint to refresh rather than removed.

Raise it before every identity request, and lower it again on the paths that
end one, including a refresh that rejects.

RequireRole now denies instead of admitting when it is given no access rule at
all. A renamed configuration key yields undefined, which read as "no roles
required" and rendered guarded content to any authenticated caller; the props
now require roles or a predicate at the type level, and deny with a warning at
runtime, because undefined defeats the type. A roles that is not an array, an
identity carrying no details, and a predicate that throws all deny for the same
reason: a gate that cannot evaluate its rule has not decided yes.

isLoading is optional where the provider value reaches consumers and required
where the identity is read. The value shape travels out through the exported
context, so requiring it there would stop a hand-built test or Storybook
context from compiling, while a consumer reading the identity should never have
to handle its absence. Absent reads as resolved - a hand-built context makes a
gate deny rather than admit.

Specs for refresh, for clearing the identity, and for a fetch that fails: all
three were free variables that no assertion could observe. Three existing
refresh specs turned out to exercise nothing - one had its refresh call
commented out while keeping the name - and are repaired here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: 49eab3a7-0743-4895-8303-bf5632b6a992
The guard that stops a predicate from deciding on details it does not have
tested for undefined only. A server answering with an explicit null for details
produces null, not undefined, so the null reached the predicate - and a
predicate phrased as an absence reads null as innocence and admits. An identity
the application could say nothing about therefore passed a rule meant to
inspect it.

Treat both shapes as nothing to decide on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: 49eab3a7-0743-4895-8303-bf5632b6a992
An application driving a command form had no way to know the command was in
flight, so every caller that wanted to disable a button, show a spinner, or
stop a second submit kept its own flag beside the form and set it around its
own execute call. The form already knows; it just never said.

Derive it once where the command is executed and publish it three ways: on the
form context, to a children render function, and through a handle a caller can
hold. A caller reads whichever one it is already positioned to read rather than
tracking the state a second time.

The handle is a named formRef prop rather than a forwarded ref. forwardRef
erases generic parameters, and consumers write CommandForm with explicit type
arguments, so forwarding it would cost them their typing.

Clearing happens in a finally. Command.performRequest catches transport
failures and answers with a failed result, so a broken network never rejects -
but a command whose validator throws, whose payload cannot be built, or that
overrides execute still can, and a form left reporting an execution that has
already ended can never be submitted again.

isExecuting is optional on the form context because that shape is exported and
built by hand - specs and wrappers compose one to render a field in isolation -
and a required member would stop every one of them compiling. Read it through
useIsCommandExecuting, which answers false for a context that predates it.

This reports on execution the form itself performs. A caller that takes the
command instance and executes it directly, as the dialog wrappers do, is not
executing through the form and the form does not claim to know about it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: 49eab3a7-0743-4895-8303-bf5632b6a992
@woksin

woksin commented Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

Reviewer context, kept out of the body.

Semver. Minor, and deliberately so. isLoading is optional on the provider's context value and required on IIdentityContext, bridged in useIdentity with ?? false. The value shape travels out through the exported IdentityProviderContext, so a required member there would stop a hand-built test or Storybook context from compiling — a real break with no known victim. Absent reads as resolved, which is the fail-closed direction: a hand-built context makes a gate deny rather than admit. isExecuting is optional on CommandFormContextValue for the same reason, and that one reaches further — Components' specs mock useCommandFormContext with object literals.

Access control. RequireRole denies when it is given no access rule at all. A renamed configuration key yields undefined, which would otherwise read as "no roles required" and render guarded content to any authenticated caller. A roles that is not an array, an identity carrying no details (undefined or null), and a predicate that throws all deny for the same reason: a gate that cannot evaluate its rule has not decided yes. The docs state plainly that this hides UI and does not protect data.

Verification. 208 spec files / 505 tests, tsc -b clean, eslint clean (one pre-existing Unused eslint-disable warning in commands/for_CommandScopeImplementation/FakeCommand.ts, unrelated and untouched).

Mutation evidence, each applied and restored with the restore verified:

Mutation Result
seed isLoading false instead of true 2 assertions, specific
delete the anonymous guard survived at first — every spec supplied roles, and an anonymous caller fails a role check anyway. Two specs added for the configurations that actually reach it (no rule at all; a predicate phrased as an absence). Then 4 assertions, specific
roles/allow conjunction flipped to disjunction 8 assertions, specific
details === undefined instead of covering null 1 assertion, specific
remove the finally around execute survived at firstCommand.performRequest catches transport failures and answers with a failed result, so a broken network never rejects and the spec named for it passed on the normal path. A command that rejects on its own was added; then 1 assertion, specific
never set isExecuting true 3 assertions — render prop, handle, context
memo dependency back to props.children 1, render prop only
drop isExecuting from the context value 1, context only

The last two killing exactly one channel each is what shows the three publications are independently observed rather than one covering for the others.

Repaired along the way. Three existing for_IdentityProvider/when_refresh_is_called/* specs exercised nothing — one had its refresh() call commented out while keeping the name — and when_refreshing_identity.ts duplicated when_initial_fetch_succeeds.ts. Eleven assertions were strictly implied by a sibling and were removed rather than left padding the count.

Known limitation, not fixed here. CommandDialog in Cratis/Components executes the command instance directly rather than through the form, so isExecuting reads false for those dialog sites. Reporting on execution the form itself performs is the scope of #2497; widening it to the command belongs in its own change.

@woksin woksin added the minor label Aug 12, 2026
@woksin
woksin marked this pull request as draft August 12, 2026 19:56
@woksin

woksin commented Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

Moved to draft. Independent review returned do-not-ship with three confirmed blockers, two of them regressions this branch introduces. Recording them here rather than quietly fixing, because the first and third are consequences of the render-prop design rather than slips in it.

The render prop reverts the user's input on submit, when the form also uses currentValues. resolvedChildren is memoized on [props.children, isExecuting], so flipping the flag re-runs the function child, which returns new elements, which makes getCommandFormFields re-run, which gives initialValuesFromFields a new identity, which gives mergedInitialValues a new identity, which fires the reactive re-seed. Probed: seed name: 'seed', user types typed-by-user, submits — the form and the live command instance revert to seed. The same setup with plain children keeps the typed value, so this is introduced here, not pre-existing.

A render prop returning a fragment silently unbinds every field. React.Children.toArray does not flatten fragments, so <>…</> arrives as one non-field element: the inputs still render, with no label, no binding, no propertyDescriptor, and an empty initialValuesFromFields. Returning an array works, and so does a CommandForm.Column — but a fragment is the idiomatic thing to return from a render prop, and nothing warns.

Overlapping submits report idle while a command is still running. The finally clears unconditionally instead of counting, so with two executions in flight the first to settle reports isExecuting === false for both. That defeats the case this feature exists for — a disabled button re-enables mid-flight.

Also confirmed: formRef detaches and re-attaches on every parent render, because handleExecute carries props in its dependencies; a callback ref — which the prop's type explicitly accepts — is therefore an infinite render loop. And the new public surface ships undocumented: Documentation/frontend/react/command-form/ is untouched.

The minor label is wrong. Proven by compiling one consumer against dist built from main and from this branch: TS2741 because IIdentityContext.isLoading is required, and TS2322 because children widened to a union, which breaks a wrapper that forwards CommandFormProps and renders props.children as a node. My own argument for keeping isExecuting and the provider's isLoading optional — that something built by hand would stop compiling — applies verbatim to IIdentityContext, and I did not apply it there.

Four mutations survived out of eighteen. Three of them — the ?? false fallback on useIsCommandExecuting, the same fallback in useIdentity, and allow(...) === true rejecting a truthy non-boolean — are exactly the safety properties argued at length in the commit messages and doc comments, with no spec behind any of them. The three "should stop reporting that it is executing" specs also survive a mutation that never sets the flag true at all: none asserts the false → true → false transition.

Not merging this. The identity work in d9bf3fd1/0ee0da39/9ac432e8 is sound and reviewed; the execution-state commit needs redesign rather than patching, and the two are separable.

@woksin

woksin commented Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

Closing this without merging. The identity half shipped on its own in #2536; the command-form execution state needs redesign rather than patching, so it is not worth keeping a stale branch open in front of it.

What #2536 took: RequireRole, isLoading on the identity context, the fail-closed guards, and the null-details fix. Cratis/Arc#2496 is closed by it.

What remains open, with the evidence, is Cratis/Arc#2497. Independent review confirmed three blockers in the approach taken here, two of them regressions this branch would have introduced:

  • Memoizing the resolved children on the execution flag makes the field parse re-run whenever the flag changes, which re-seeds a form that also uses currentValues — the user's typed input reverts on submit.
  • React.Children.toArray does not flatten fragments, so a render prop returning <>…</> renders fields that are silently unbound: no label, no binding, no descriptor.
  • The flag is a boolean cleared in a finally rather than a count, so with two executions in flight the first to settle reports idle for both — which defeats the case the feature exists for.

Also found: formRef detaches and re-attaches on every parent render because handleExecute carries props in its dependencies, making a callback ref an infinite loop; and widening children to a union breaks a wrapper that forwards CommandFormProps and renders props.children as a node.

The shape that avoids all of it, for whoever picks up #2497: publish the state through the form context and an imperative handle only, leave children alone, and count executions instead of flagging them. useIsCommandExecuting was the part of this that carried its weight.

@woksin woksin closed this Aug 12, 2026
@woksin
woksin deleted the feat/imp-41-42-identity-loading-and-command-execution-state branch August 12, 2026 22:03
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.

1 participant