Skip to content

Custom auth forms silently lose UI reactivity: setAuthState() doesn't drive onAuthChange/AuthenticatedContent unless you also call broadcastAuthChange() — add a submitAuthAction() helper #185

Description

@hfurkanbozkurt

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:

  1. Internal shared-state cacheupdateState(api, state) (L168). This function is module-private (not exported) and is called only from inside Authenticator.
  2. Cross-component broadcastbroadcastAuthChange(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():

  1. Layer inversion / Node breakagesetAuthState is a generated API-client (data-layer) method that also runs server-side (e.g. cookie-jar/Node scripts); broadcastAuthChange uses window + BroadcastChannel.
  2. Double-broadcastAuthenticator 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).
  3. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingjavascriptPull requests that update javascript codepending-community-responseWaiting on a response from the author/community

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions