Skip to content

Restore KeyedSavedStateRegistryOwners just in time, before consumers can see them [UISA-95] - #1553

Merged
zach-klippenstein merged 2 commits into
mainfrom
zachklipp/uisa-95/jit-restore-keyed-savedstate-owners
Jul 10, 2026
Merged

Restore KeyedSavedStateRegistryOwners just in time, before consumers can see them [UISA-95]#1553
zach-klippenstein merged 2 commits into
mainfrom
zachklipp/uisa-95/jit-restore-keyed-savedstate-owners

Conversation

@zach-klippenstein

Copy link
Copy Markdown
Collaborator

Problem

WorkflowSavedStateRegistryAggregator restores its KeyedSavedStateRegistryOwner 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 can run before the aggregator's restore observer ever fires.

Concretely: a nested ComposeView's WrappedComposition consumes the view-tree SavedStateRegistry on ON_CREATE. If that dispatch wins the race against the aggregator's restore, the consumer reads an unrestored registry and throws:

IllegalStateException: You can 'consumeRestoredStateForKey' only after the corresponding
component has moved to the 'CREATED' state

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 ancestor LayoutNodes half-attached (markAsAttached ran, runAttachLifecycle skipped). The tree then detonates at composition dispose with:

IllegalStateException: Must run runDetachLifecycle() once after runAttachLifecycle() and before markAsDetached()

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:

  1. Lifecycle clamp. KeyedSavedStateRegistryOwner no longer delegates its Lifecycle to the view's WorkflowLifecycleOwner. It now exposes its own LifecycleRegistry that mirrors the delegate's, but is held at INITIALIZED until the owner's registry has been restored. This enforces the androidx savedstate contract by construction: lifecycle-driven consumers (like Compose's DisposableSaveableStateRegistry) cannot observe an unrestored registry, and performRestore() remains legal no matter how far the delegate lifecycle has advanced (savedstate requires lifecycle < STARTED).

  2. Just-in-time restore. When the delegate 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 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 (consumeRestoredStateForKey only requires the parent registry to be restored, not the parent lifecycle to be CREATED). 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 INITIALIZED can now always be restored, because its local lifecycle cannot pass INITIALIZED unrestored.

API note

installChildRegistryOwnerOn now registers a lifecycle observer at install time, which LifecycleRegistry requires to happen on the main thread. This was already true of every production caller (container views during render); the KDoc now documents it, and ViewStateCacheTest routes its update() calls through runOnMainSync accordingly.

Testing

  • New JVM regression tests in WorkflowSavedStateRegistryAggregatorTest covering: the dispatch-order race (child advancing before parent ON_CREATE is 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. ComposeViewTreeIntegrationTest save/restore integration).
  • Validated against the android-register repro via composite build: previously 4/4 crash, now 3/3 pass with zero occurrences of either crash signature in logcat.

🤖 Generated with Claude Code

@zach-klippenstein

Copy link
Copy Markdown
Collaborator Author

🤖 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):

  1. Original crash repro: DemoV2HomeTest#payTabAndButtonAppears_withBillPayEnabled crashed 4/4 before this change (Must run runDetachLifecycle() once after runAttachLifecycle() and before markAsDetached() at teardown, Compose 1.9.5); with this branch it passes, along with the rest of its suite (6/6, composite build against register green-master).
  2. No state-restoration regressions: workflow-ui:core-android device suites pass (ViewStateCacheTest, WorkflowSavedStateRegistryAggregatorTest instrumented: 39/39 and 56/56), plus the 4 new JVM regression tests in WorkflowSavedStateRegistryAggregatorTest covering the dispatch-order race, the lifecycle clamp, detached-parent fallback, and pruned-child restore.
  3. Full register CI: a snapshot build of this branch (1.28.1-SNAPSHOT) ran register's entire suite — 1575/1576 parts green, zero test failures (the one red part is register's policy gate that forbids SNAPSHOT dependency versions, by design). Validation PR: squareup/android-register#159654.

Root-cause writeup is in the PR description; the underlying Compose-side issue (exception during LayoutNode.attach() recursion permanently corrupting the node tree) is being filed upstream separately.

@zach-klippenstein
zach-klippenstein marked this pull request as ready for review July 10, 2026 05:59
@zach-klippenstein
zach-klippenstein requested a review from a team as a code owner July 10, 2026 05:59
…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
@zach-klippenstein
zach-klippenstein force-pushed the zachklipp/uisa-95/jit-restore-keyed-savedstate-owners branch from 7ce7e73 to dcf9ba4 Compare July 10, 2026 14:24
// legal because localLifecycle is INITIALIZED until we advance it below.
if (event == ON_CREATE && !savedStateRegistry.isRestored) {
onRestoreNeeded(this@KeyedSavedStateRegistryOwner)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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"
   }
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Nit: broken KDoc reference — the method is installObserver, not installObserverOn.

* any downstream consumer can see the registry. [installObserver] is called by the aggregator

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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.)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Done in 74c72b3ViewStateCache.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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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>
@zach-klippenstein
zach-klippenstein merged commit eb3d7e8 into main Jul 10, 2026
74 checks passed
@zach-klippenstein
zach-klippenstein deleted the zachklipp/uisa-95/jit-restore-keyed-savedstate-owners branch July 10, 2026 18:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Occasional "IllegalStateException: Restarter must be created only during owner's initialization stage"

3 participants