Skip to content

Commit cb05327

Browse files
authored
Merge pull request #1202 from square/ray/view-state-is-trash
Stop pretending everyone implements view state right.
2 parents bc51102 + d1f55b3 commit cb05327

File tree

5 files changed

+117
-17
lines changed

5 files changed

+117
-17
lines changed

workflow-ui/core-android/src/androidTest/java/com/squareup/workflow1/ui/navigation/ViewStateCacheTest.kt

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,38 @@ internal class ViewStateCacheTest {
106106
assertThat(firstViewRestored.viewState).isEqualTo("hello world")
107107
}
108108

109+
@Test fun doesnt_restore_child_states_on_error() {
110+
val cache = ViewStateCache()
111+
val firstRendering = NamedScreen(content = AScreen, name = "first")
112+
val secondRendering = NamedScreen(content = AScreen, name = "second")
113+
// Android requires ID to be set for view hierarchy to be saved or restored.
114+
val firstView = createTestView(firstRendering, id = 1)
115+
val secondView = createTestView(secondRendering)
116+
117+
// Set some state on the first view that will be saved.
118+
firstView.viewState = "hello world"
119+
120+
// Show the first screen.
121+
cache.update(retainedRenderings = emptyList(), oldHolderMaybe = null, newHolder = firstView)
122+
123+
// "Navigate" to the second screen, saving the first screen.
124+
cache.update(
125+
retainedRenderings = listOf(firstRendering),
126+
oldHolderMaybe = firstView,
127+
newHolder = secondView
128+
)
129+
130+
// "Navigate" back to the first screen, restoring state.
131+
val firstViewRestored = createTestView(firstRendering, id = 1, crashOnRestore = true)
132+
firstViewRestored.viewState = "should not be clobbered"
133+
134+
cache.update(listOf(), oldHolderMaybe = secondView, newHolder = firstViewRestored)
135+
// We didn't crash, that's already a good sign.
136+
137+
// Check that the "hard coded" view state is in tact due to the swallowed exception.
138+
assertThat(firstViewRestored.viewState).isEqualTo("should not be clobbered")
139+
}
140+
109141
@Test fun doesnt_restore_state_when_restored_view_id_is_different() {
110142
val cache = ViewStateCache()
111143
val firstRendering = NamedScreen(content = AScreen, name = "first")
@@ -198,9 +230,10 @@ internal class ViewStateCacheTest {
198230

199231
private fun createTestView(
200232
firstRendering: NamedScreen<*>,
201-
id: Int? = null
233+
id: Int? = null,
234+
crashOnRestore: Boolean = false
202235
): ScreenViewHolder<NamedScreen<*>> {
203-
val view = ViewStateTestView(instrumentation.context).also { view ->
236+
val view = ViewStateTestView(instrumentation.context, crashOnRestore).also { view ->
204237
id?.let { view.id = id }
205238
WorkflowLifecycleOwner.installOn(view, fakeOnBack)
206239
}

workflow-ui/core-android/src/androidTest/java/com/squareup/workflow1/ui/navigation/fixtures/ViewStateTestView.kt

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@ import com.squareup.workflow1.ui.navigation.fixtures.BackStackContainerLifecycle
1313
* [onSaveInstanceState] and [onRestoreInstanceState] methods.
1414
*/
1515
@OptIn(WorkflowUiExperimentalApi::class)
16-
internal class ViewStateTestView(context: Context) : LeafView<LeafRendering>(context) {
16+
internal class ViewStateTestView(
17+
context: Context,
18+
private val crashOnRestore: Boolean = false
19+
) : LeafView<LeafRendering>(context) {
1720

1821
var viewState: String = ""
1922

@@ -23,6 +26,7 @@ internal class ViewStateTestView(context: Context) : LeafView<LeafRendering>(con
2326
}
2427

2528
override fun onRestoreInstanceState(state: Parcelable?) {
29+
if (crashOnRestore) throw IllegalArgumentException("Crashing on restore.")
2630
(state as? SavedState)?.let {
2731
viewState = it.viewState
2832
super.onRestoreInstanceState(state.superState)
@@ -43,7 +47,10 @@ internal class ViewStateTestView(context: Context) : LeafView<LeafRendering>(con
4347
viewState = source.readString()!!
4448
}
4549

46-
override fun writeToParcel(out: Parcel, flags: Int) {
50+
override fun writeToParcel(
51+
out: Parcel,
52+
flags: Int
53+
) {
4754
super.writeToParcel(out, flags)
4855
out.writeString(viewState)
4956
}

workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/WorkflowLayout.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import android.os.Parcel
66
import android.os.Parcelable
77
import android.os.Parcelable.Creator
88
import android.util.AttributeSet
9+
import android.util.Log
910
import android.util.SparseArray
1011
import android.widget.FrameLayout
1112
import androidx.lifecycle.Lifecycle
@@ -65,7 +66,11 @@ public class WorkflowLayout(
6566
showing.show(rootScreen, environment.withOnBackDispatcher())
6667
restoredChildState?.let { restoredState ->
6768
restoredChildState = null
68-
showing.actual.restoreHierarchyState(restoredState)
69+
try {
70+
showing.actual.restoreHierarchyState(restoredState)
71+
} catch (e: Exception) {
72+
Log.w("Workflow", "WorkflowLayout failed to restore view state", e)
73+
}
6974
}
7075
}
7176

workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/navigation/ViewStateCache.kt

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import android.os.Build.VERSION_CODES
55
import android.os.Parcel
66
import android.os.Parcelable
77
import android.os.Parcelable.Creator
8+
import android.util.Log
89
import android.util.SparseArray
910
import android.view.View
1011
import androidx.annotation.VisibleForTesting
@@ -57,8 +58,8 @@ internal constructor(
5758

5859
/**
5960
* @param retainedRenderings the renderings to be considered hidden after this update. Any
60-
* associated view state will be retained in the cache, possibly to be restored to [newView]
61-
* on a succeeding call to his method. Any other cached view state will be dropped.
61+
* associated view state will be retained in the cache, possibly to be restored to the view
62+
* of [newHolder] on a succeeding call to his method. Any other cached view state will be dropped.
6263
*
6364
* @param oldHolderMaybe the view that is being removed, if any, which is expected to be showing
6465
* a [NamedScreen] rendering. If that rendering is
@@ -90,7 +91,13 @@ internal constructor(
9091
stateRegistryAggregator.installChildRegistryOwnerOn(newHolder.view, newKey)
9192

9293
viewStates.remove(newKey)
93-
?.let { newHolder.view.restoreHierarchyState(it.viewState) }
94+
?.let {
95+
try {
96+
newHolder.view.restoreHierarchyState(it.viewState)
97+
} catch (e: Exception) {
98+
Log.w("Workflow", "ViewStateCache failed to restore view state for $newKey", e)
99+
}
100+
}
94101

95102
// Save both the view state and state registry of the view that's going away, as long as it's
96103
// still in the backstack.

workflow-ui/core-android/src/test/java/com/squareup/workflow1/ui/WorkflowLayoutTest.kt

Lines changed: 57 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@ import android.os.Bundle
55
import android.os.Parcelable
66
import android.util.SparseArray
77
import android.view.View
8+
import androidx.activity.OnBackPressedDispatcherOwner
89
import androidx.lifecycle.Lifecycle
910
import androidx.lifecycle.testing.TestLifecycleOwner
1011
import androidx.test.core.app.ApplicationProvider
1112
import com.google.common.truth.Truth.assertThat
13+
import com.squareup.workflow1.ui.androidx.OnBackPressedDispatcherOwnerKey
1214
import com.squareup.workflow1.ui.navigation.WrappedScreen
1315
import kotlinx.coroutines.ExperimentalCoroutinesApi
1416
import kotlinx.coroutines.flow.flowOf
@@ -29,23 +31,49 @@ internal class WorkflowLayoutTest {
2931
private val workflowLayout = WorkflowLayout(context).apply { id = 42 }
3032

3133
@Test fun ignoresAlienViewState() {
32-
var saved = false
33-
val weirdView = object : View(context) {
34-
override fun onSaveInstanceState(): Parcelable {
35-
saved = true
36-
super.onSaveInstanceState()
37-
return Bundle()
38-
}
39-
}.apply { id = 42 }
34+
val weirdView = BundleSavingView(context)
4035

4136
val viewState = SparseArray<Parcelable>()
4237
weirdView.saveHierarchyState(viewState)
43-
assertThat(saved).isTrue()
38+
assertThat(weirdView.saved).isTrue()
4439

4540
workflowLayout.restoreHierarchyState(viewState)
4641
// No crash, no bug.
4742
}
4843

44+
@Test fun ignoresNestedViewStateMistakes() {
45+
class AScreen : AndroidScreen<AScreen> {
46+
override val viewFactory =
47+
ScreenViewFactory.fromCode<AScreen> { _, initialEnvironment, context, _ ->
48+
ScreenViewHolder(initialEnvironment, BundleSavingView(context)) { _, _ -> }
49+
}
50+
}
51+
52+
class BScreen : AndroidScreen<BScreen> {
53+
override val viewFactory =
54+
ScreenViewFactory.fromCode<BScreen> { _, initialEnvironment, context, _ ->
55+
ScreenViewHolder(initialEnvironment, SaveStateSavingView(context)) { _, _ -> }
56+
}
57+
}
58+
59+
val onBack = object : OnBackPressedDispatcherOwner {
60+
override fun getOnBackPressedDispatcher() = error("nope")
61+
override val lifecycle: Lifecycle get() = error("also nope")
62+
}
63+
64+
val env = ViewEnvironment.EMPTY + (OnBackPressedDispatcherOwnerKey to onBack)
65+
66+
val original = WorkflowLayout(context).apply { id = 1 }
67+
original.show(AScreen(), env)
68+
69+
val viewState = SparseArray<Parcelable>()
70+
original.saveHierarchyState(viewState)
71+
72+
val unoriginal = WorkflowLayout(context).apply { id = 1 }
73+
unoriginal.restoreHierarchyState(viewState)
74+
unoriginal.show(BScreen(), env)
75+
}
76+
4977
@Test fun usesLifecycleDispatcher() {
5078
val lifecycleDispatcher = UnconfinedTestDispatcher()
5179
val collectionContext: CoroutineContext = UnconfinedTestDispatcher()
@@ -62,4 +90,24 @@ internal class WorkflowLayoutTest {
6290

6391
// No crash then we safely removed the dispatcher.
6492
}
93+
94+
private class BundleSavingView(context: Context) : View(context) {
95+
var saved = false
96+
97+
init {
98+
id = 42
99+
}
100+
101+
override fun onSaveInstanceState(): Parcelable {
102+
saved = true
103+
super.onSaveInstanceState()
104+
return Bundle()
105+
}
106+
}
107+
108+
private class SaveStateSavingView(context: Context) : View(context) {
109+
init {
110+
id = 42
111+
}
112+
}
65113
}

0 commit comments

Comments
 (0)