Skip to content
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ Changelog
Unreleased
----------

### Changed

- `Screen` and `PopResult` no longer extend `Parcelable` on Android. Use kotlinx-serialization with `SerializableCircuitSaver` or `ReflectiveSerializableCircuitSaver` to persist navigation state. Types saved this way no longer need `@Parcelize`.
- `ParcelableScreen` and `ParcelablePopResult` remain available for apps that use Android's `DefaultCircuitSaver`.
- `DefaultCircuitSaver` omits non-Parcelable records. Those records can be lost after activity recreation or process death.
- Apps that do not persist navigation state can use `CircuitSaver.NoOp`.
- This completes the multi-phase removal of the `Parcelable` supertypes of `Screen` and `PopResult` 🎉.

0.36.1
------

Expand All @@ -20,7 +28,7 @@ _2026-08-05_

### New

- `circuit-codegen` can now generate kotlinx serialization registrations for `Screen` and `PopResult` types. Annotate each type with `@CircuitSerializable(scope)`. The annotation supplies the default kotlinx serializer, and `circuit-codegen` contributes a registration through the selected DI framework. Pass the injected `Set<CircuitSerializerRegistration>` to `SerializableCircuitSaver`.
- `circuit-codegen` can now generate kotlinx-serialization registrations for `Screen` and `PopResult` types. Annotate each type with `@CircuitSerializable(scope)`. The annotation supplies the default kotlinx serializer, and `circuit-codegen` contributes a registration through the selected DI framework. Pass the injected `Set<CircuitSerializerRegistration>` to `SerializableCircuitSaver`.

A Metro setup looks like this:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,23 @@ package com.slack.circuit.foundation
import android.os.Bundle
import android.os.Parcel
import android.os.Parcelable
import androidx.compose.ui.test.junit4.StateRestorationTester
import androidx.compose.ui.test.junit4.v2.createComposeRule
import com.slack.circuit.backstack.rememberSaveableBackStack
import com.slack.circuit.runtime.screen.CircuitSaveable
import com.slack.circuit.runtime.screen.CircuitSaver
import com.slack.circuit.runtime.screen.DefaultCircuitSaver
import com.slack.circuit.runtime.screen.LocalCircuitSaver
import com.slack.circuit.runtime.screen.ParcelablePopResult
import com.slack.circuit.runtime.screen.ParcelableScreen
import com.slack.circuit.runtime.screen.PopResult
import com.slack.circuit.runtime.screen.ProvideCircuitSaver
import com.slack.circuit.runtime.screen.Screen
import com.slack.circuit.runtime.screen.restorePopResult
import com.slack.circuit.runtime.screen.restoreScreen
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertSame
import kotlinx.parcelize.Parcelize
import org.junit.Rule
Expand Down Expand Up @@ -45,7 +50,7 @@ class CircuitSaverAndroidTest {
}

@Test
fun defaultCircuitSaverPassesThroughParcelableValues() {
fun defaultCircuitSaverPassesThroughParcelableMarkerValues() {
val screen = TestParcelableScreen("screen")
val result = TestParcelablePopResult(42)

Expand All @@ -58,6 +63,58 @@ class CircuitSaverAndroidTest {
assertSame(result, DefaultCircuitSaver.restorePopResult<TestParcelablePopResult>(savedResult!!))
}

@Test
fun defaultCircuitSaverPassesThroughDirectParcelableValues() {
val screen = TestDirectParcelableScreen("screen")
val result = TestDirectParcelablePopResult(42)

val savedScreen = DefaultCircuitSaver.save(screen)
val savedResult = DefaultCircuitSaver.save(result)

assertSame(screen, savedScreen)
assertSame(result, savedResult)
assertSame(screen, DefaultCircuitSaver.restoreScreen<TestDirectParcelableScreen>(savedScreen!!))
assertSame(
result,
DefaultCircuitSaver.restorePopResult<TestDirectParcelablePopResult>(savedResult!!),
)
}

@Test
fun defaultCircuitSaverSkipsPlainValues() {
assertNull(DefaultCircuitSaver.save(TestPlainScreen))
assertNull(DefaultCircuitSaver.save(TestPlainPopResult))
}

@Test
fun defaultCircuitSaverRestoresPlainScreenThroughInitialValue() {
val restorationTester = StateRestorationTester(composeTestRule)
var initializations = 0
restorationTester.setContent {
rememberSaveableBackStack(TestPlainScreen) {
initializations++
}
}

restorationTester.emulateSavedInstanceStateRestore()

assertEquals(2, initializations)
}

@Test
fun defaultCircuitSaverRestoresRawValuesAndIgnoresUnrelatedInput() {
assertSame(
TestPlainScreen,
DefaultCircuitSaver.restoreScreen<Screen>(TestPlainScreen),
)
assertSame(
TestPlainPopResult,
DefaultCircuitSaver.restorePopResult<PopResult>(TestPlainPopResult),
)
assertNull(DefaultCircuitSaver.restoreScreen<Screen>(Any()))
assertNull(DefaultCircuitSaver.restorePopResult<PopResult>(Any()))
}

@Test
fun provideCircuitSaverProvidesExactInstance() {
val saver = TestCircuitSaver()
Expand Down Expand Up @@ -127,6 +184,14 @@ private inline fun <reified T : Parcelable> Bundle.parcelable(key: String): T? =

@Parcelize private data class TestParcelablePopResult(val value: Int) : ParcelablePopResult

@Parcelize private data class TestDirectParcelableScreen(val value: String) : Screen, Parcelable

@Parcelize private data class TestDirectParcelablePopResult(val value: Int) : PopResult, Parcelable

private data object TestPlainScreen : Screen

private data object TestPlainPopResult : PopResult

private class TestCircuitSaver : CircuitSaver() {
override fun save(value: CircuitSaveable): Any = value

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,17 +28,17 @@ class RememberSaveableNavStackTest {
restorationTester.setContent {
navStack =
rememberSaveableNavStack(
root = TestScreen.RootAlpha,
root = PlainRootScreen,
circuitSaver = CircuitSaver.NoOp,
)
}
navStack.push(TestScreen.ScreenA)
navStack.push(TestScreen.ScreenB)
navStack.push(PlainScreenA)
navStack.push(PlainScreenB)

restorationTester.emulateSaveAndRestore()

assertEquals(
navStackListOf<Screen>(TestScreen.RootAlpha),
navStackListOf<Screen>(PlainRootScreen),
navStack.snapshot()?.transform { it.screen },
)
}
Expand Down Expand Up @@ -129,3 +129,9 @@ class RememberSaveableNavStackTest {
assertEquals(expectedState, snapshotState)
}
}

private data object PlainRootScreen : Screen

private data object PlainScreenA : Screen

private data object PlainScreenB : Screen
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import com.slack.circuit.internal.test.TestContentTags.TAG_COUNT
import com.slack.circuit.internal.test.TestContentTags.TAG_INCREASE_COUNT
import com.slack.circuit.runtime.CircuitUiState
import com.slack.circuit.runtime.presenter.Presenter
import com.slack.circuit.runtime.screen.Screen
import com.slack.circuit.runtime.screen.ParcelableScreen
import com.slack.circuit.runtime.ui.ui
import org.junit.Rule
import org.junit.Test
Expand Down Expand Up @@ -52,7 +52,7 @@ class CircuitContentTest {
}

@Parcelize
private data class CountScreen(val count: Int) : Screen {
private data class CountScreen(val count: Int) : ParcelableScreen {
data class State(val count: Int) : CircuitUiState
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import com.slack.circuit.runtime.Navigator
import com.slack.circuit.runtime.answeringNavigationAvailable as runtimeAnsweringNavigationAvailable
import com.slack.circuit.runtime.presenter.Presenter
import com.slack.circuit.runtime.rememberAnsweringNavigator as rememberRuntimeAnsweringNavigator
import com.slack.circuit.runtime.screen.ParcelablePopResult
import com.slack.circuit.runtime.screen.PopResult
import com.slack.circuit.runtime.screen.Screen
import kotlin.test.Test
Expand Down Expand Up @@ -246,7 +247,7 @@ class AnsweringNavigatorTest {
}

@Parcelize
private open class SuperPopResult : PopResult {
private open class SuperPopResult : ParcelablePopResult {
@Parcelize class SubPopResult : SuperPopResult()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,20 @@
package com.slack.circuit.foundation

import com.slack.circuit.internal.runtime.Parcelize
import com.slack.circuit.runtime.screen.PopResult
import com.slack.circuit.runtime.screen.Screen
import com.slack.circuit.runtime.screen.ParcelablePopResult
import com.slack.circuit.runtime.screen.ParcelableScreen
import com.slack.circuit.runtime.screen.StaticScreen

@Parcelize data object TestScreen : Screen
@Parcelize data object TestScreen : ParcelableScreen

@Parcelize data object TestScreen2 : Screen
@Parcelize data object TestScreen2 : ParcelableScreen

@Parcelize data object TestScreen3 : Screen
@Parcelize data object TestScreen3 : ParcelableScreen

@Parcelize data object TestStaticScreen : StaticScreen
@Parcelize data object TestStaticScreen : StaticScreen, ParcelableScreen

@Parcelize data object TestPopResult : PopResult
@Parcelize data object TestPopResult : ParcelablePopResult

@Parcelize data class TestValuePopResult(val value: String) : PopResult
@Parcelize data class TestValuePopResult(val value: String) : ParcelablePopResult

@Parcelize data object OtherPopResult : PopResult
@Parcelize data object OtherPopResult : ParcelablePopResult
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,12 @@
// SPDX-License-Identifier: Apache-2.0
package com.slack.circuit.runtime.screen

public actual val DefaultCircuitSaver: CircuitSaver = PassThroughCircuitSaver
import android.os.Parcelable

public actual val DefaultCircuitSaver: CircuitSaver = AndroidDefaultCircuitSaver

private object AndroidDefaultCircuitSaver : CircuitSaver() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Think I'm almost fine with this staying as PassThroughCircuitSaver?

Seeing how dropping Parcelable from Screen is a breaking change, and folks will need to opt back in by adding Parcelable or ParcelableScreen, it might be fine to require folks to explicitly set a ParcelableCircuitSaver?

override fun save(value: CircuitSaveable): Any? = value.takeIf { it is Parcelable }

override fun restore(saved: Any): CircuitSaveable? = saved as? CircuitSaveable
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
// SPDX-License-Identifier: Apache-2.0
package com.slack.circuit.runtime.screen

import android.os.Parcelable
import androidx.compose.runtime.Immutable

@Immutable public actual interface PopResult : CircuitSaveable, Parcelable
@Immutable public actual interface PopResult : CircuitSaveable
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,8 @@
// SPDX-License-Identifier: Apache-2.0
package com.slack.circuit.runtime.screen

import android.os.Parcelable
import androidx.compose.runtime.Immutable

@Immutable public actual interface Screen : CircuitSaveable, Parcelable
@Immutable public actual interface Screen : CircuitSaveable

@Immutable public actual interface StaticScreen : Screen
Original file line number Diff line number Diff line change
Expand Up @@ -7,27 +7,14 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.ProvidableCompositionLocal
import androidx.compose.runtime.Stable
import androidx.compose.runtime.staticCompositionLocalOf
import com.slack.circuit.runtime.screen.CircuitSaver.Companion.NoOp

/**
* Converts [Screen]s and [PopResult]s to and from representations that can be stored in a Compose
* `SaveableStateRegistry`.
*
* Circuit's saveable back/nav stack implementations use this to persist navigation state across
* configuration changes and process death. On Android, [Screen] and [PopResult] still require
* `Parcelable` in 0.35. [CircuitSaver] implementations choose the representation that is actually
* stored; a future release removes the Android `Parcelable` supertype requirement.
*
* Available strategies include
* - Android `Parcelable` (the Android default)
* - kotlinx-serialization (via the `circuit-serialization` artifact)
* - no persistence at all ([NoOp]).
*
* Returned values must be storable in the platform's `SaveableStateRegistry`. On Android that means
* Bundle-supported types like `Parcelable` or `SavedState`. Other platforms hold saved state in
* memory and accept any value, so a saver only matters there if the host app wires its
* `SaveableStateRegistry` to durable storage. Apps that do should use a serializing saver like
* `SerializableCircuitSaver` so the stored values are actually encodable.
* Circuit's saveable back and nav stack implementations use this to persist navigation state.
* Implementations choose the stored representation. Returned values must be supported by the
* platform's `SaveableStateRegistry`.
*/
@Stable
public abstract class CircuitSaver protected constructor() {
Expand Down Expand Up @@ -109,18 +96,16 @@ public inline fun <reified T : PopResult> CircuitSaver.restorePopResult(
/**
* The default [CircuitSaver] for the current platform.
*
* On Android, screens and results pass through unchanged and are persisted via their `Parcelable`
* implementations. Other platforms hold saved state in memory, so values also pass through
* unchanged.
* On Android, `Parcelable` screens and results pass through unchanged and other values are omitted.
* Other platforms pass values through unchanged.
*/
public expect val DefaultCircuitSaver: CircuitSaver

/**
* The [CircuitSaver] used by Circuit's saveable back stack implementations when one is not passed
* explicitly. Defaults to [DefaultCircuitSaver].
*
* Provide this at the app root (see [ProvideCircuitSaver]) so it reaches back stacks created
* anywhere in the composition, including ones created outside `CircuitCompositionLocals`.
* explicitly. Defaults to [DefaultCircuitSaver]. Provide this at the app root (see
* [ProvideCircuitSaver]) so it reaches back stacks created anywhere in the composition, including
* ones created outside `CircuitCompositionLocals`.
*/
public val LocalCircuitSaver: ProvidableCompositionLocal<CircuitSaver> = staticCompositionLocalOf {
DefaultCircuitSaver
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ import androidx.compose.runtime.Immutable
/**
* A [Screen] that is also `android.os.Parcelable` on Android.
*
* Use this for common-code screens that persist via Parcelable on android, since common code cannot
* reference `android.os.Parcelable` directly. On all other platforms this is just a [Screen].
* Use this with [DefaultCircuitSaver] to persist a screen through Android saved state. Common code
* cannot reference `android.os.Parcelable` directly. On all other platforms this is just a
* [Screen].
*
* ```
* @Parcelize // Or your own `@CommonParcelize` annotation
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,40 @@ package com.slack.circuit.runtime.screen

import androidx.compose.runtime.Immutable

/**
* A result returned while calling `Navigator.pop()`.
*
* PopResults can be simple sentinel `data object` types or data classes with information to share.
*
* ```
* @Serializable
* data class ModalResult(
* val accepted: Boolean,
* ) : PopResult
* ```
*
* Results are then passed as arguments to `Navigator.pop()` to pass them.
*
* ```
* navigator.pop(
* ModalResult(
* accepted = true
* )
* )
* ```
*
* These are only retrievable when a given presenter has navigated for a result via
* `rememberAnsweringNavigator`.
*
* ```
* val answeringNavigator = rememberAnsweringNavigator<ModalResult>(navigator) { result ->
* // ...
* }
* answeringNavigator.goTo(ModalScreen())
* ```
*
* Note that `@Serializable` is not strictly required, and you may bring your own serialization or
* use [ParcelablePopResult] on Android if you rather. You may also opt for no serialization at all
* if you do not need it!
*/
@Immutable public expect interface PopResult : CircuitSaveable
Loading
Loading