v6 - POC - Form field architecture - #2947
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces a unified form state management system ('FormState', 'FormFieldId', 'FocusRequest', 'KeyboardAction') and integrates it into the Card component. It replaces individual field focus intents with a generic 'UpdateFieldFocus' intent, manages programmatic focus moves (such as after card scanning or validation failures), and dynamically derives field visibility and keyboard actions based on a canonical field order. The review feedback highlights two key areas for improvement: first, a suspending call to 'bringIntoView()' is wrapped in 'runCatching', which incorrectly suppresses 'CancellationException' and breaks structured cancellation; second, caching the computed 'form' property in 'CardViewStateProducer' would avoid redundant calculations during view state production.
| runCatching { | ||
| focusRequester.requestFocus() | ||
| // Taking focus scrolls the field into view on its own, but a field that already has focus is told | ||
| // nothing and so would stay off screen. Asking directly covers both. | ||
| bringIntoViewRequester.bringIntoView() | ||
| } |
There was a problem hiding this comment.
Suspending Call Wrapped in runCatching Suppresses Cancellation
bringIntoViewRequester.bringIntoView() is a suspending function. Wrapping a suspending call in runCatching is an anti-pattern because it catches and suppresses CancellationException, which breaks structured cancellation in Kotlin coroutines.
Since only focusRequester.requestFocus() (which is non-suspending) can throw if the node is not attached, we should run it in its own runCatching block, and handle bringIntoView() separately, ensuring CancellationException is rethrown if caught.
runCatching { focusRequester.requestFocus() }
// Taking focus scrolls the field into view on its own, but a field that already has focus is told
// nothing and so would stay off screen. Asking directly covers both.
try {
bringIntoViewRequester.bringIntoView()
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (t: Throwable) {
// Ignore other errors
}References
- Using
runCatchingis acceptable for non-suspending calls when the error does not need to be handled, as there is no risk of catching and suppressingCancellationExceptionin a non-suspending context.
There was a problem hiding this comment.
A comment from my agent which can be useful.
When the cancellation is swallowed, the coroutine keeps going and still calls onFocusRequestConsumed(). FocusRequestConsumed matches on field id only, so a report from a dead request can clear a newer request for the same field and lose keepErrorHighlight — the error gets hidden again, which is the bug this PR fixes.
Gemini's patch cures that by accident (rethrowing means the callback never runs). Could you make it explicit instead — only wrap requestFocus(), leave bringIntoView() bare since it only throws CancellationException, and give the request an identity so a stale report can't match a new one?
Separate thing to check: on the happy path, gaining focus clears focusRequest, which changes the LaunchedEffect key and cancels bringIntoView() after a frame or two. Does the scroll ever actually complete?
There was a problem hiding this comment.
@gemini-code-assist Fixed, with an adaptation: runCatching now wraps only requestFocus(), and bringIntoView() moved out of the effect into rememberCoroutineScope() instead of getting its own try/catch.
That was needed anyway — acting on the request clears it, which changes the LaunchedEffect key, so the scroll was being cancelled a frame or two in. Scoping it to the composition fixes that and closes the window where a cancelled effect could still report consumption.
There was a problem hiding this comment.
Thank you for the update, @jreij. Your adaptation to move bringIntoView() out of the LaunchedEffect and into the rememberCoroutineScope() is a much cleaner approach that correctly respects the lifecycle of the composition and avoids the cancellation issues we discussed. This change effectively resolves the issue where the scroll was being prematurely cancelled.
| runCatching { | |
| focusRequester.requestFocus() | |
| // Taking focus scrolls the field into view on its own, but a field that already has focus is told | |
| // nothing and so would stay off screen. Asking directly covers both. | |
| bringIntoViewRequester.bringIntoView() | |
| } | |
| runCatching { focusRequester.requestFocus() } | |
| // Taking focus scrolls the field into view on its own, but a field that already has focus is told | |
| // nothing and so would stay off screen. Asking directly covers both. | |
| coroutineScope.launch { bringIntoViewRequester.bringIntoView() } |
| private fun CardComponentState.fieldViewState( | ||
| id: CardFieldId, | ||
| field: TextInputComponentState, | ||
| customTrailingIcon: TrailingIcon? = null, | ||
| ): TextInputViewState? = field.toViewState( | ||
| customTrailingIcon = customTrailingIcon, | ||
| keyboardAction = form.keyboardActionFor(id), | ||
| focusRequest = form.focusRequest?.takeIf { it.id == id }?.let { FocusRequestToken(it) }, | ||
| ) |
There was a problem hiding this comment.
Efficiency Improvement: Avoid Redundant Form Computations
Currently, form is a computed property on CardComponentState that filters the list of visible fields on every read:
val form: FormState<CardFieldId>
get() = FormState(order = visibleCardFields(this), focusRequest = focusRequest)In produce(state), fieldViewState is called 8 times, and each call accesses form twice (once for keyboardActionFor and once for focusRequest). Along with state.form.order, this results in form being computed 17 times per state update.
To optimize this, we can compute form once at the start of produce(state) (e.g., val form = state.form) and pass it as a parameter to fieldViewState.
| private fun CardComponentState.fieldViewState( | |
| id: CardFieldId, | |
| field: TextInputComponentState, | |
| customTrailingIcon: TrailingIcon? = null, | |
| ): TextInputViewState? = field.toViewState( | |
| customTrailingIcon = customTrailingIcon, | |
| keyboardAction = form.keyboardActionFor(id), | |
| focusRequest = form.focusRequest?.takeIf { it.id == id }?.let { FocusRequestToken(it) }, | |
| ) | |
| private fun CardComponentState.fieldViewState( | |
| id: CardFieldId, | |
| field: TextInputComponentState, | |
| form: FormState<CardFieldId>, | |
| customTrailingIcon: TrailingIcon? = null, | |
| ): TextInputViewState? = field.toViewState( | |
| customTrailingIcon = customTrailingIcon, | |
| keyboardAction = form.keyboardActionFor(id), | |
| focusRequest = form.focusRequest?.takeIf { it.id == id }?.let { FocusRequestToken(it) }, | |
| ) |
There was a problem hiding this comment.
@gemini-code-assist Fixed, adapted: CardComponentState.form is memoised with lazy(PUBLICATION) rather than passed in as a parameter, so the reducer benefits too — it also reads form several times. PUBLICATION because the merchant owns the coroutine scope, so the same state can be read from more than one thread.
There was a problem hiding this comment.
That's a great point regarding the memoization of form. Using lazy(LazyThreadSafetyMode.PUBLICATION) is indeed the correct approach here, especially since the component runs in a coroutine scope that might be accessed from multiple threads. This ensures thread safety while avoiding the overhead of passing the FormState object through every fieldViewState call, keeping the code clean and efficient. Thanks for the update!
| @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) | ||
| data class FormState<Id : FormFieldId>( | ||
| val order: List<Id>, | ||
| val focusRequest: FocusRequest<Id>? = null, |
There was a problem hiding this comment.
Does the focusRequest need to be inside of the FormState? I see that you read it only in CardViewStateProducer.kt:112 and there you could read directly from the CardComponentState.
There was a problem hiding this comment.
If the focusRequest gets removed, then there will only be one field in the FormState, which makes the FormState redundant. Instead of the FormState, a simple List<Id> could be used?
There was a problem hiding this comment.
The plan is to move focusRequest and the form state to core where we can handle it centrally. And later after this whole refactor, the next refactor would make FormState technically the whole view state (a list of fields + their data). So right now it looks like it can be moved to Card state but this refactor will achieve the opposite by extracting common things to core. WDYT?
| */ | ||
| @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) | ||
| @JvmInline | ||
| value class FocusRequestToken(private val request: Any) |
There was a problem hiding this comment.
Since the request has a type of Any, this can cause the fields to not skip recomposition. Would it make sense to make this @Immutable, like for State objects like InstallmentViewState, CardBrandViewState..?
There was a problem hiding this comment.
Also does the type need to be Any?
There was a problem hiding this comment.
Fixed, annotated with @Immutable.
As for Any, it has to stay since FocusRequestToken is in ui and core imports ui not the other way around. FocusRequestToken is only used as a param inside CheckoutTextField to make the LaunchedEffect work, and LaunchedEffect just needs a key of type Any.
So in order to not have a type Any being passed around in all text fields as well as not creating so much core logic inside ui, FocusRequestToken is a simple value class that makes sure we don't pass random stuff while keeping ui focused on UI. WDYT?
| keyboardAction: KeyboardAction = KeyboardAction.NEXT, | ||
| focusRequest: FocusRequestToken? = null, | ||
| ): TextInputViewState? { | ||
| if (requirementPolicy == RequirementPolicy.Hidden) return null |
There was a problem hiding this comment.
The same visibility check also happens in the CardFieldOrder, is this something we can centralize? This way it looks like we have two sources of truth for visibility checks.
There was a problem hiding this comment.
Extracted TextInputComponentState.isVisible and used it in both this mapping and CardFieldOrder.
| val storePaymentViewState = if (state.isStorePaymentFieldVisible) { | ||
| StorePaymentViewState(isSelected = state.storePaymentMethod) | ||
| } else { | ||
| null | ||
| } |
There was a problem hiding this comment.
Can this visibility check be centralized too?
There was a problem hiding this comment.
I agree that the form should be the source of truth for visibility but I think it's a bit overkill for now. The current structure is fragmented already, not every field has all its data in one place. And this refactor makes it a bit worse by adding another independent list.
Which is why we decided together that the next step is to refactor this further and create one big list of ordered fields + their data. So we won't even need these checks anymore. The view state producer will loop over the form and map each id and its data, so a field that isn't in the form is never mapped anyway. Adding checks now would require deleting them later anyway. WDYT?
| runCatching { | ||
| focusRequester.requestFocus() | ||
| // Taking focus scrolls the field into view on its own, but a field that already has focus is told | ||
| // nothing and so would stay off screen. Asking directly covers both. | ||
| bringIntoViewRequester.bringIntoView() | ||
| } |
There was a problem hiding this comment.
A comment from my agent which can be useful.
When the cancellation is swallowed, the coroutine keeps going and still calls onFocusRequestConsumed(). FocusRequestConsumed matches on field id only, so a report from a dead request can clear a newer request for the same field and lose keepErrorHighlight — the error gets hidden again, which is the bug this PR fixes.
Gemini's patch cures that by accident (rethrowing means the callback never runs). Could you make it explicit instead — only wrap requestFocus(), leave bringIntoView() bare since it only throws CancellationException, and give the request an identity so a stale report can't match a new one?
Separate thing to check: on the happy path, gaining focus clears focusRequest, which changes the LaunchedEffect key and cancels bringIntoView() after a frame or two. Does the scroll ever actually complete?
Compose fixes a text field's keyboard action when the field is created and offers no way to ask whether another focusable field follows it, so only something that knows the whole form can decide which field closes the keyboard. FormState keeps the visible fields in visual order, together with any focus move the state layer is asking the UI to make.
The order the shopper sees is currently implied by the sequence of if-blocks in CardContent, and focus priority by the sequence of a copy() call in the reducer, with nothing keeping the two in step. Name the fields and state the sequence once, and derive which of them are on screen from the same properties the view state producer reads, so the two cannot disagree.
Pay shows every error and focuses the first invalid field, but that focus arrived looking like a shopper tap, so the field hid the error it had just shown. The state layer now records why focus was asked for, which tells the two apart.
Which field is being asked to take focus is a decision for whatever knows the whole form, so the producer makes it once and each field only needs to know whether it is the one. Translating the keyboard action into Compose's own type lives next to it, so that the state layer keeps no dependency on Compose.
The view state now says which fields to render and in which order, which action key each one shows, and which is being asked to take focus. Nothing reads any of it yet; the screen still decides for itself.
Nine if-blocks decided for themselves which fields to show, in a sequence that only happened to match the one focus and validation walk. The screen now iterates the published order, so it reads no configuration at all, and each field takes its action key and its focus request from the same source.
800346e to
afd2a29
Compare
✅ No public API changes |
|



Description
Field order currently lives in three places that nothing keeps in sync: the sequence of
ifblocks inCardContent, the sequence of acopy(...)call in the reducer for focus priority, and implicitly in Compose's focus search for the keyboard'sNextkey. This makes three things impossible: the IME action cannot be derived at all (Compose fixes it when a field is created and offers no way to ask whether another focusable field follows), prefill cannot know where to send focus, and pay focuses the first invalid field in declaration order rather than the order the shopper reads.This publishes the order from the state layer and has everything derive from it.
What that gives us, all working on a device:
Nextexcept the last visible one, which showsDone.Donecloses the keyboard and does not submit.Reviewing
Six commits in four layers, bottom-up. Each compiles and, except where stated, changes no behaviour.
FormState,FormFieldId,FocusRequest,KeyboardActionand four pure functions. Nothing reads them yet.CardComponentState.formis computed on every read, so the order cannot go stale.UpdateXFocusintents into one.ifblocks become one loop; fields move onto the focus request and the IME action.Deliberately not here
isFocusedis carried unchanged and marked.rg "TODO - Form fields cleanup"andrg "TODO - Form fields rollout"are the two worklists.Progress
✅ Phase 1 — Core: the error type (#2942)
➡️ Phases 2 to 5 — the POC (this PR)
Phase 6 — Roll out to stored card, MB Way, BLIK
Phase 7 — Cleanup and document
Checklist