Restore KeyedSavedStateRegistryOwners just in time, before consumers can see them [UISA-95] - #1553
Conversation
|
🤖 Validation complete — marking ready for review. Evidence this fix is safe and effective, gathered against Square's android-register monorepo (where the crash was found):
Root-cause writeup is in the PR description; the underlying Compose-side issue (exception during |
…can see them WorkflowSavedStateRegistryAggregator restored its children from an observer on the parent SavedStateRegistryOwner's lifecycle. Observer dispatch order on a LifecycleRegistry follows registration order, and WorkflowLifecycleOwner lifecycles advance in a synchronous depth-first cascade on window attach - so a descendant's lifecycle observers (e.g. a nested ComposeView's WrappedComposition, which consumes the view-tree SavedStateRegistry on ON_CREATE) could run before the aggregator's restore observer ever fired. The consumer then hit an unrestored registry and threw: IllegalStateException: You can 'consumeRestoredStateForKey' only after the corresponding component has moved to the 'CREATED' state Reproduced deterministically with a workflow-hosted Compose screen containing a lazy list on androidx.compose.ui 1.9.5 (Square android-register DemoV2HomeTest#payTabAndButtonAppears_withBillPayEnabled): the exception is thrown mid-way through the LayoutNode.attach() recursion during a lazy-item subcompose, which leaves ancestor nodes half-attached (markAsAttached ran, runAttachLifecycle skipped) and detonates at composition dispose with "Must run runDetachLifecycle() once after runAttachLifecycle()". Compose 1.7.x swallowed the poisoned state; 1.9.5 crashes on it. Root cause fix, in two parts: 1. KeyedSavedStateRegistryOwner no longer delegates its lifecycle to the view's WorkflowLifecycleOwner. It now exposes its own LifecycleRegistry that mirrors the delegate's, but is clamped at INITIALIZED until the owner's registry has been restored. This enforces the androidx savedstate contract by construction: lifecycle-driven consumers cannot observe an unrestored registry, and performRestore() stays legal no matter how far the delegate lifecycle has advanced. 2. When the delegate lifecycle delivers ON_CREATE and the owner has not been restored yet, the owner synchronously asks the aggregator to restore it right then (restoreChildNow), before the local lifecycle advances. The aggregator pulls its own state from the parent registry on demand if its ordinary restore observer has not fired yet, so the just-in-time path works even when the child's lifecycle advances ahead of the aggregator's observer. Children pruned from the aggregator restore empty rather than stealing a successor's saved state. Because the owner now registers a lifecycle observer at install time, installChildRegistryOwnerOn is documented as main-thread-only (which all production container-view callers already satisfy), and ViewStateCacheTest routes its update() calls through runOnMainSync accordingly. Validated against the register repro (3/3 passes, no crash signatures in logcat), plus :workflow-ui:core-android connected tests (39/39), :workflow-ui:compose connected tests (56/56) covering save/restore integration, and new JVM regression tests for the dispatch-order race, the lifecycle clamp, the detached-parent fallback, and the pruned-child case. Also structurally fixes #570: a child past INITIALIZED can now always be restored, because its local lifecycle cannot pass INITIALIZED unrestored. UISA-95
7ce7e73 to
dcf9ba4
Compare
| // legal because localLifecycle is INITIALIZED until we advance it below. | ||
| if (event == ON_CREATE && !savedStateRegistry.isRestored) { | ||
| onRestoreNeeded(this@KeyedSavedStateRegistryOwner) | ||
| } |
There was a problem hiding this comment.
🤖 The class KDoc says the local lifecycle "is held at INITIALIZED until this owner's registry has been restored" — but that invariant is enforced by forcing restore here, not by withholding advancement. If onRestoreNeeded ever fails its contract (returns without restoring — say a future refactor breaks the children.put-before-installObserver ordering in installChildRegistryOwnerOn), the local lifecycle advances unrestored and we're back to the original downstream crash, far from the cause. A cheap check makes the clamp self-enforcing and fails loudly at the source:
suggestion
if (event == ON_CREATE && !savedStateRegistry.isRestored) {
onRestoreNeeded(this@KeyedSavedStateRegistryOwner)
check(savedStateRegistry.isRestored) {
"onRestoreNeeded contract violation: registry for key '$key' was not restored"
}
}
There was a problem hiding this comment.
🤖 Done in 74c72b3 — added the check(savedStateRegistry.isRestored) contract assertion right after the onRestoreNeeded call, plus a unit test (KeyedSavedStateRegistryOwnerTest) covering a callee that violates the contract.
| * the aggregator's own restoration observer ever fires. To close that race, when [lifecycleOwner] | ||
| * delivers `ON_CREATE` and this owner has not been restored yet, it synchronously asks the | ||
| * aggregator to restore it *now* via [onRestoreNeeded] — before the local lifecycle advances and | ||
| * any downstream consumer can see the registry. [installObserverOn] is called by the aggregator |
There was a problem hiding this comment.
🤖 Nit: broken KDoc reference — the method is installObserver, not installObserverOn.
* any downstream consumer can see the registry. [installObserver] is called by the aggregator
There was a problem hiding this comment.
🤖 Fixed in 74c72b3 — [installObserverOn] → [installObserver].
| * **This method must be called before [view] is attached to a window.** | ||
| * **This method must be called on the main thread, before [view] is attached to a window.** | ||
| * (It observes [view]'s `ViewTreeLifecycleOwner` lifecycle, and | ||
| * [androidx.lifecycle.LifecycleRegistry] requires observer registration on the main thread.) |
There was a problem hiding this comment.
🤖 Nit: ViewStateCache.update is the public entry point most custom-container authors actually call, and it now inherits this main-thread requirement (as ViewStateCacheTest's runOnMainSync change shows). Worth a sentence in its KDoc too.
There was a problem hiding this comment.
🤖 Done in 74c72b3 — ViewStateCache.update's KDoc now states the main-thread / before-attach requirement and points at installChildRegistryOwnerOn as the source of it.
| val states = states | ||
| if (states != null && children[child.key] === child) { | ||
| child.controller.performRestore(states.remove(child.key)) | ||
| } else { |
There was a problem hiding this comment.
🤖 Nit: this branch trades the old crash for silent state loss in the (theoretically unreachable in production) case where a child advances with no attached/restored parent. That's the right trade, but a debug-level log here would help diagnose any container that ever hits it.
There was a problem hiding this comment.
🤖 Done in 74c72b3 — the restore-empty fallback now logs at debug level (tag Workflow, matching the module's existing logging), including the child key and which of the two conditions was hit, so a container that reaches this branch leaves a breadcrumb for the lost state.
…restores - KeyedSavedStateRegistryOwner now check()s that its registry was actually restored after invoking onRestoreNeeded, so a broken callee fails fast at the violated contract instead of surfacing later as an androidx crash in some downstream consumer. Covered by a new unit test. - The aggregator's restore-empty fallback in restoreChildNow logs at debug level. That branch should be unreachable for containers driven by window attach, so if a container ever hits it (silently losing saved state), the log is the diagnostic breadcrumb. - Document the main-thread requirement on ViewStateCache.update, which inherits it from installChildRegistryOwnerOn. - Fix a broken KDoc reference ([installObserverOn] -> [installObserver]). UISA-95 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Problem
WorkflowSavedStateRegistryAggregatorrestores itsKeyedSavedStateRegistryOwnerchildren from an observer on the parentSavedStateRegistryOwner's lifecycle. Observer dispatch order on aLifecycleRegistryfollows registration order, andWorkflowLifecycleOwnerlifecycles advance in a synchronous depth-first cascade on window attach — so a descendant's lifecycle observers can run before the aggregator's restore observer ever fires.Concretely: a nested
ComposeView'sWrappedCompositionconsumes the view-treeSavedStateRegistryonON_CREATE. If that dispatch wins the race against the aggregator's restore, the consumer reads an unrestored registry and throws:On androidx.compose.ui 1.9.5 this is fatal in a second way: the exception is thrown mid-way through
LayoutNode.attach()'s recursion during a lazy-item/pager subcompose, which leaves ancestorLayoutNodes half-attached (markAsAttachedran,runAttachLifecycleskipped). The tree then detonates at composition dispose with:Compose 1.7.x tolerated the poisoned state; 1.9.5 crashes the process on teardown. Reproduced deterministically (4/4) in Square's android-register with a workflow-hosted Compose screen containing a lazy list (
DemoV2HomeTest#payTabAndButtonAppears_withBillPayEnabled); this crash currently blocks register's Compose 1.9.5 upgrade. (A minimal, workflow-free Compose repro of the downstream detonation exists and is being filed against androidx separately — but the state-restoration race fixed here is workflow's own bug regardless.)Fix
Two cooperating parts, both local to
workflow-ui/core-android's androidx integration:Lifecycle clamp.
KeyedSavedStateRegistryOwnerno longer delegates itsLifecycleto the view'sWorkflowLifecycleOwner. It now exposes its ownLifecycleRegistrythat mirrors the delegate's, but is held atINITIALIZEDuntil the owner's registry has been restored. This enforces the androidx savedstate contract by construction: lifecycle-driven consumers (like Compose'sDisposableSaveableStateRegistry) cannot observe an unrestored registry, andperformRestore()remains legal no matter how far the delegate lifecycle has advanced (savedstate requires lifecycle <STARTED).Just-in-time restore. When the delegate delivers
ON_CREATEand the owner has not been restored yet, the owner synchronously asks the aggregator to restore it right then (restoreChildNow) — before the local lifecycle advances and any downstream consumer can see the registry. If the aggregator's own restore observer hasn't fired yet, it pulls its state from the parent registry on demand (consumeRestoredStateForKeyonly requires the parent registry to be restored, not the parent lifecycle to beCREATED). Children pruned from the aggregator restore empty rather than stealing a successor's saved state.Because ancestors' lifecycles always advance before descendants', an owner's JIT restore can always find its parent's state — restoration order is preserved top-down and no state is lost.
This also structurally fixes #570: a child whose lifecycle is past
INITIALIZEDcan now always be restored, because its local lifecycle cannot passINITIALIZEDunrestored.API note
installChildRegistryOwnerOnnow registers a lifecycle observer at install time, whichLifecycleRegistryrequires to happen on the main thread. This was already true of every production caller (container views during render); the KDoc now documents it, andViewStateCacheTestroutes itsupdate()calls throughrunOnMainSyncaccordingly.Testing
WorkflowSavedStateRegistryAggregatorTestcovering: the dispatch-order race (child advancing before parentON_CREATEis restored just in time with saved state), the lifecycle clamp, the detached-parent fallback (restores empty without throwing), and the pruned-child case (does not consume a replacement's state).:workflow-ui:core-android:connectedDebugAndroidTest: 39/39 pass (incl.BackStackContainerPersistenceLifecycleTest,ViewStateCacheTest,WorkflowViewStubLifecycleTest).:workflow-ui:compose:connectedDebugAndroidTest: 56/56 pass (incl.ComposeViewTreeIntegrationTestsave/restore integration).🤖 Generated with Claude Code