Problem
Building a custom auth form (calling setAuthState() directly instead of using the Authenticator component) silently breaks UI reactivity: AuthenticatedContent and every onAuthChange subscriber never re-render after a successful sign-in, so gated UI (dashboards, post-login views) stays hidden. There is no error, no warning, and no type signal — the code looks correct and the auth call succeeds server-side.
The root cause is that setAuthState() is a thin server-side state-machine advance that drives neither of auth-common's two notification systems. Today the Authenticator component works out of the box only because it manually pairs every setAuthState() with the internal cache update and broadcastAuthChange(). A custom form has no way to fully replicate that, because the cache-update half (updateState) is not exported — the best a diligent author can do is call broadcastAuthChange(), and even that requirement is only discoverable by reading source.
Mechanism — affected files + lines (verified against current main)
All references are packages/auth-common/src/ui.ts.
setAuthState() is declared on AuthStateApi (L107-110) and implemented by the generated API client from auth.createApi(). It advances server state and returns the new AuthState; it touches neither the shared cache nor the broadcast channel:
export interface AuthStateApi {
getAuthState(): Promise<AuthState>;
setAuthState(input: AuthActionInput): Promise<AuthState>;
}
There are two independent notify paths, and setAuthState() fires neither:
- Internal shared-state cache —
updateState(api, state) (L168). This function is module-private (not exported) and is called only from inside Authenticator.
- Cross-component broadcast —
broadcastAuthChange(user) (L191-197) posts to the BroadcastChannel (other tabs) and dispatches a same-window CustomEvent:
export function broadcastAuthChange(user: AuthUser | null): void {
const detail = { type: 'auth-change', user };
getChannel().postMessage(detail);
window.dispatchEvent(new CustomEvent(AUTH_LOCAL_EVENT, { detail }));
}
onAuthChange(api, cb) (L232-246) subscribes to exactly those two events — so it reacts only to a broadcastAuthChange() call (or another tab's):
export function onAuthChange(
api: AuthStateApi,
callback: (user: AuthUser | null) => void | Promise<void>,
): () => void {
const channelHandler = (event: MessageEvent) => {
if (event.data?.type === 'auth-change') callback(event.data.user);
};
getChannel().addEventListener('message', channelHandler);
const localHandler = (event: Event) => {
const detail = (event as CustomEvent).detail;
if (detail?.type === 'auth-change') callback(detail.user);
};
window.addEventListener(AUTH_LOCAL_EVENT, localHandler);
// ...
AuthenticatedContent(api, render) (L300-313) — the "show only when signed in" container — re-renders only via onAuthChange, i.e. only when something broadcasts:
export function AuthenticatedContent(
api: AuthStateApi,
render: (user: AuthUser) => Node,
): HTMLElement {
const container = document.createElement('div');
onAuthChange(api, (user) => {
if (user) {
container.replaceChildren(render(user));
} else {
container.replaceChildren();
}
});
return container;
}
Why the built-in path works but custom forms don't: the Authenticator (L472; doc comment L437-439: "Internal actions (no url) … calls setAuthState() … Broadcasts auth changes via broadcastAuthChange() so other components … react automatically") manually pairs every setAuthState() with updateState() + broadcastAuthChange(), guarded on retriable. E.g. the standard-fields submit (L781-797):
const newState = await api.setAuthState(
{ action: action.name, ...values } as AuthActionInput,
);
if (newState.retriable === true) {
onNewState({ ...currentState, error: newState.error || 'An error occurred' });
return;
}
// Update shared cache + notify Authenticator's subscriber
updateState(api, newState);
// Broadcast to other components (AuthenticatedContent, other tabs)
broadcastAuthChange(newState.user ?? null);
The same manual pairing appears in auto-sign-in (L496-498), the slot-override submit (L602-610), and sign-out (L1019-1021).
Root cause
A custom form that calls authApi.setAuthState({ action: 'signIn', … }) directly advances server state and returns a signedIn AuthState, but fires no window event and no BroadcastChannel message. AuthenticatedContent / onAuthChange therefore never fire, and gated UI never appears. The correct manual sequence (updateState + broadcastAuthChange, retriable-guarded) is only partially reachable by consumers, since updateState is not exported.
Recommended fix
Ship a public "submit + notify" helper in @aws-blocks/auth-common (surfaced via the canonical @aws-blocks/blocks/ui consumer import) that packages the exact, already-proven Authenticator sequence:
export async function submitAuthAction(
api: AuthStateApi,
input: AuthActionInput,
): Promise<AuthState> {
const next = await api.setAuthState(input);
// Same retriable guard the Authenticator uses (L790): a retriable signedOut
// state is inline error feedback, not an auth change — do not broadcast.
if (next.retriable !== true) {
updateState(api, next); // keep the internal shared cache correct (L795)
broadcastAuthChange(next.user ?? null); // notify AuthenticatedContent / onAuthChange / other tabs (L797)
}
return next;
}
Rationale / compatibility:
- Non-breaking — pure addition; no existing signatures change.
- No layer inversion / no Node breakage — it lives in the UI package that already owns broadcast + cache; custom forms run in the browser. (This is the reason not to move the broadcast into
setAuthState() — see below.)
- Reuses battle-tested logic — same
retriable guard and user ?? null handling the Authenticator already uses, so no spurious broadcasts on intermediate challenges / retriable errors.
- Closes a second latent gap — because
updateState is not exported, a custom form that calls only broadcastAuthChange still leaves the internal cache stale, so a separately-mounted Authenticator can show wrong state. submitAuthAction does all three correct things in one call.
- Discoverability (required to actually fix the footgun) — make
submitAuthAction the default in the custom-auth-UI docs, surface it in the @aws-blocks/blocks auth docs, and use it in the create-blocks-app custom-form / React examples. The failure is silent, so a helper that isn't the documented default won't prevent it.
Rejected alternative — auto-broadcast inside setAuthState():
- Layer inversion / Node breakage —
setAuthState is a generated API-client (data-layer) method that also runs server-side (e.g. cookie-jar/Node scripts); broadcastAuthChange uses window + BroadcastChannel.
- Double-broadcast —
Authenticator already broadcasts after every setAuthState; auto-broadcasting would double-fire every built-in flow (extra re-renders + an extra getAuthState round-trip in the cross-tab receive handler).
- Spurious broadcasts — not every transition is a user-visible auth change (resend-code, intermediate
confirmSignIn challenges, retriable errors); the Authenticator deliberately guards these out.
Impact
A repeatable, silent footgun for anyone building custom auth UI on Blocks (i.e. anyone stepping outside the Authenticator component — a very common thing to do for AuthBasic-style credential forms). Correct-looking code produces a fully broken authenticated experience with no error surfaced.
Evidence
Surfaced by an external agent benchmark (task guardian-files-safe): a hand-rolled auth form set state correctly via setAuthState() but never broadcast, so the post-auth file list stayed hidden and all authenticated end-to-end tests cascade-failed. Blocks scored 0.392 (20/51) on that task — its worst cell and an inversion of its usual lead (raw-cdk 0.725, amplify-gen2 0.706). The agent's own retrospective identified the exact cause: "with custom auth forms you must manually call broadcastAuthChange(user) after setAuthState — the onAuthChange listener relies on the BroadcastChannel, not polling."
Related
Problem
Building a custom auth form (calling
setAuthState()directly instead of using theAuthenticatorcomponent) silently breaks UI reactivity:AuthenticatedContentand everyonAuthChangesubscriber never re-render after a successful sign-in, so gated UI (dashboards, post-login views) stays hidden. There is no error, no warning, and no type signal — the code looks correct and the auth call succeeds server-side.The root cause is that
setAuthState()is a thin server-side state-machine advance that drives neither ofauth-common's two notification systems. Today theAuthenticatorcomponent works out of the box only because it manually pairs everysetAuthState()with the internal cache update andbroadcastAuthChange(). A custom form has no way to fully replicate that, because the cache-update half (updateState) is not exported — the best a diligent author can do is callbroadcastAuthChange(), and even that requirement is only discoverable by reading source.Mechanism — affected files + lines (verified against current
main)All references are
packages/auth-common/src/ui.ts.setAuthState()is declared onAuthStateApi(L107-110) and implemented by the generated API client fromauth.createApi(). It advances server state and returns the newAuthState; it touches neither the shared cache nor the broadcast channel:There are two independent notify paths, and
setAuthState()fires neither:updateState(api, state)(L168). This function is module-private (not exported) and is called only from insideAuthenticator.broadcastAuthChange(user)(L191-197) posts to theBroadcastChannel(other tabs) and dispatches a same-windowCustomEvent:onAuthChange(api, cb)(L232-246) subscribes to exactly those two events — so it reacts only to abroadcastAuthChange()call (or another tab's):AuthenticatedContent(api, render)(L300-313) — the "show only when signed in" container — re-renders only viaonAuthChange, i.e. only when something broadcasts:Why the built-in path works but custom forms don't: the
Authenticator(L472; doc comment L437-439: "Internal actions (nourl) … callssetAuthState()… Broadcasts auth changes viabroadcastAuthChange()so other components … react automatically") manually pairs everysetAuthState()withupdateState()+broadcastAuthChange(), guarded onretriable. E.g. the standard-fields submit (L781-797):The same manual pairing appears in auto-sign-in (L496-498), the slot-override submit (L602-610), and sign-out (L1019-1021).
Root cause
A custom form that calls
authApi.setAuthState({ action: 'signIn', … })directly advances server state and returns asignedInAuthState, but fires no window event and noBroadcastChannelmessage.AuthenticatedContent/onAuthChangetherefore never fire, and gated UI never appears. The correct manual sequence (updateState+broadcastAuthChange, retriable-guarded) is only partially reachable by consumers, sinceupdateStateis not exported.Recommended fix
Ship a public "submit + notify" helper in
@aws-blocks/auth-common(surfaced via the canonical@aws-blocks/blocks/uiconsumer import) that packages the exact, already-provenAuthenticatorsequence:Rationale / compatibility:
setAuthState()— see below.)retriableguard anduser ?? nullhandling theAuthenticatoralready uses, so no spurious broadcasts on intermediate challenges / retriable errors.updateStateis not exported, a custom form that calls onlybroadcastAuthChangestill leaves the internal cache stale, so a separately-mountedAuthenticatorcan show wrong state.submitAuthActiondoes all three correct things in one call.submitAuthActionthe default in the custom-auth-UI docs, surface it in the@aws-blocks/blocksauth docs, and use it in thecreate-blocks-appcustom-form / React examples. The failure is silent, so a helper that isn't the documented default won't prevent it.Rejected alternative — auto-broadcast inside
setAuthState():setAuthStateis a generated API-client (data-layer) method that also runs server-side (e.g. cookie-jar/Node scripts);broadcastAuthChangeuseswindow+BroadcastChannel.Authenticatoralready broadcasts after everysetAuthState; auto-broadcasting would double-fire every built-in flow (extra re-renders + an extragetAuthStateround-trip in the cross-tab receive handler).confirmSignInchallenges,retriableerrors); theAuthenticatordeliberately guards these out.Impact
A repeatable, silent footgun for anyone building custom auth UI on Blocks (i.e. anyone stepping outside the
Authenticatorcomponent — a very common thing to do for AuthBasic-style credential forms). Correct-looking code produces a fully broken authenticated experience with no error surfaced.Evidence
Surfaced by an external agent benchmark (task
guardian-files-safe): a hand-rolled auth form set state correctly viasetAuthState()but never broadcast, so the post-auth file list stayed hidden and all authenticated end-to-end tests cascade-failed. Blocks scored 0.392 (20/51) on that task — its worst cell and an inversion of its usual lead (raw-cdk 0.725, amplify-gen2 0.706). The agent's own retrospective identified the exact cause: "with custom auth forms you must manually callbroadcastAuthChange(user)aftersetAuthState— theonAuthChangelistener relies on the BroadcastChannel, not polling."Related
broadcastAuthChangecanonical import path (@aws-blocks/blocks/ui). Discoverability of the import; this issue is the reactivity contract + missing helper.bb-auth-oidchandleRedirectCallback()didn't driveonAuthChange). Same plumbing; this issue covers the general custom-form / direct-setAuthStatecase, which is still unaddressed.