Skip to content

Architecture refactor: decouple NavModel, renderer, and reducer pipeline#78

Open
ikarenkov wants to merge 25 commits into
devfrom
refactor/container-screen-refactoring
Open

Architecture refactor: decouple NavModel, renderer, and reducer pipeline#78
ikarenkov wants to merge 25 commits into
devfrom
refactor/container-screen-refactoring

Conversation

@ikarenkov
Copy link
Copy Markdown
Owner

@ikarenkov ikarenkov commented May 19, 2026

Architecture refactor

Before this PR the navigation layers were tightly coupled: ContainerScreen carried both a model and a NavigationRenderer, the renderer interface was public, and state updates flowed through an action-dispatch protocol where NavigationAction and ReducerAction were separate concepts (action = command; reducer = (action, state) -> State?). Together they forced model and renderer to share an Action vocabulary — hence the two-generic types.

This refactor separates the concerns cleanly:

  • NavModel is a pure-Kotlin model. MutableStateFlow<State> plus dispatch(reducer). No androidx.compose.runtime references at the declaration level (stability is inherited from NavigationContainer). Parcelable, so it survives process death on its own.
  • ComposeRenderer is an internal Compose adapter. It subscribes to a StateFlow<State> via a managed CoroutineScope and mirrors the value into Compose state. It does not own state and does not know what produced it. The public NavigationRenderer interface is gone.
  • ContainerScreen is just Screen, NavigationContainer<State> by navModel. A screen with a model glued in via delegation, plus an internal renderer. No reducer plumbing or renderer indirection.
  • One reducer concept, not two. NavigationReducer is now the single primitive: fun interface NavigationReducer<State> { fun reduce(oldState: State): State }. It replaces NavigationAction (the command-style marker) entirely — the dispatched reducer is the command. The old ReducerAction<State> survives only as a deprecated typealias to NavigationReducer<State>. Collapsing the two concepts is what lets the Action generic disappear.

Everything else in this PR — single-generic types, subtreeStateFlow, the deprecations — falls out of those new boundaries.

API changes

Generics collapsed to a single type parameter. NavigationContainer, ContainerScreen, and NavModel no longer have an Action generic. Call sites must drop the second type parameter:

  • ContainerScreen<State, Action>ContainerScreen<State>
  • NavigationContainer<State, Action>NavigationContainer<State>
  • NavModel<State, Action>NavModel<State>

Action-dispatch model replaced with pure reducers. NavigationReducer is now a single-method fun interface:

fun interface NavigationReducer<State : NavigationState> {
    fun reduce(oldState: State): State
}

dispatch(...) accepts a reducer directly; state updates are pure functions of the previous state with no action argument.

navigationState removed; state exposed as a StateFlow. NavModel holds state in a MutableStateFlow<State>, so observers and synchronous readers go through stateFlow.value.

Deep tree observation. Two new APIs let callers observe changes anywhere in the navigation subtree under a container:

  • subtreeFlow(): Flow<NavigationState> — cold flow that emits the root state on collection and re-emits whenever any descendant container dispatches.
  • subtreeStateFlow(scope, started = Eagerly): StateFlow<NavigationState> — hot variant with synchronously accessible .value. Intentionally does not deduplicate same-value emissions, so nested dispatches that produce an unchanged root reference still notify observers to re-walk the tree.

NavigationRenderer interface removed. ComposeRenderer is internal and now owns a CoroutineScope (created in init, cancelled on dispose) that collects state from the underlying flow.

NavModel is Parcelable. State now survives process death via the state-flow holder; no separate render layer is required.

Deprecations

The previous action-based / hot-flow API is retained as deprecated bindings so existing code keeps compiling under transitional builds, but most are at ERROR level — they will not run.

  • NavigationAction and its subtypes (StackAction, MultiScreenAction, ListNavigationAction, etc.) — deprecated. Replace with reducer factories (StackActions, MultiScreenActions, ListReducer, the new RemoveTabReducer, …).
  • NavigationContainer.dispatch(action: Action)deprecated. Use dispatch(reducer: NavigationReducer<State>).
  • NavigationContainer.navigationState property — removed. Read stateFlow.value.
  • NavigationContainer.navigationStateFlow() extension — deprecated at ERROR level (no runtime fallback). Migration path:
    • per-container, use stateFlow
    • subtree-wide, use subtreeFlow() or subtreeStateFlow(scope)
  • NavigationRenderer interface — removed. The renderer is internal to ComposeRenderer; downstream code should not depend on it.
  • NavigationReducer<State, Action> (two-generic form) — removed. Use the single-generic fun interface NavigationReducer<State>.

Migration cheat sheet

  1. Drop the Action generic from ContainerScreen / NavigationContainer / NavModel declarations and references.
  2. Convert custom actions to reducers: NavigationReducer { oldState -> /* compute new state */ }.
  3. container.navigationStatecontainer.stateFlow.value.
  4. container.navigationStateFlow()container.stateFlow (per-container) or container.subtreeStateFlow(scope) (deep observation).
  5. Replace any references to the removed NavigationRenderer interface with the hot/cold StateFlow APIs above.

Sample app changes

  • SampleAppSettings — DataStore-backed settings holder. Persists showNavigationTree (Boolean) and navTreeVisibleScreens (Int, default 2).
  • SettingsDialog — new dialog to toggle tree visibility and adjust depth via sliders.
  • NavigationTree — new composable that replaces the previous NavigationTreeStrip. Driven by subtreeStateFlow(), mounted at the root activity, wrapped in AnimatedContent with dynamic visibility tied to settings.
  • LifecycleEventsHistory — added maxLines support to bound the displayed event log.
  • RemoveTabReducer — extracted reducer that replaces the previous RemoveTabAction in the multi-screen sample.

Test changes

  • New ComposeRendererDisposalTest — verifies the renderer's CoroutineScope lifecycle (created on init, cancelled on dispose).
  • New DeepNavigationStateFlowTest — covers subtreeStateFlow across nested containers, including the deliberate no-op re-emission when a descendant dispatches without changing the root reference.
  • Renamed (content updated to reducer form):
    • ListNavigationActionAddScreensTestListReducerAddScreensTest
    • ListNavigationActionRemoveScreensTestListReducerRemoveScreensTest
    • ListNavigationActionSetTestListReducerSetTest

Repo meta

  • Add AGENTS.md and CLAUDE.md for AI-agent orientation.
  • Add .claude/skills/task-workflow/SKILL.md for the task-folder workflow.

Test plan

  • ./gradlew :modo-compose:test (incl. DeepNavigationStateFlowTest, ComposeRendererDisposalTest, renamed ListReducer*Tests)
  • ./gradlew build succeeds across all modules
  • Sample app: navigate stack / multi-screen / dialog containers, confirm state survives configuration change
  • Open SettingsDialog, toggle tree visibility and depth, confirm both persist across app restart
  • Verify NavigationTree shows/hides correctly from the root-activity mount and animates via AnimatedContent

…s. Removed the `NavigationRenderer` interface and simplified `NavModel` to hold state in a `MutableStateFlow`. Updated `ComposeRenderer` to collect state changes within a managed `CoroutineScope`. Changed `LocalStackNavigation` and `LocalMultiScreenNavigation` to provide screen instances directly, and updated samples and tests to reflect these changes. Added coroutine testing support to the compose module.
…nsition to a reducer-based state update model.

- Refactor `ContainerScreen`, `NavModel`, and `NavigationContainer` to remove the `Action` generic type parameter, leaving only `State`.
- Introduce `NavigationReducer` as a functional interface for state transformations (State -> State) and make it the primary mechanism for `dispatch`.
- Deprecate `NavigationAction` and container-specific action interfaces (e.g., `StackAction`, `MultiScreenAction`, `ListNavigationAction`) in favor of reducers.
- Update `StackScreen`, `MultiScreen`, and `ListNavigationContainer` to align with the single-generic parameter.
- Implement atomic dispatch for multiple reducers to ensure consistent state transitions.
- Update `Modo` utility functions and `RootScreen` to support modern Android `getParcelable` APIs (SDK 33+).
- Migrate sample applications and internal library components to use the new reducer-based `dispatch` pattern.
- Add unit tests for `ComposeRenderer` to ensure coroutine scope disposal when screens are removed.
- Rename action files to reducer files (e.g., `ListNavigationAction.kt` to `ListReducer.kt`) to match the new architecture.
…teFlow` for deep tree observation.

- Renamed `NavigationContainer.navigationStateFlow` to `stateFlow` for brevity and consistency across the library.
- Introduced `subtreeStateFlow()` extension to support observing navigation state changes across an entire hierarchy of containers.
- Added a deprecated `navigationStateFlow()` extension with `DeprecationLevel.ERROR` to provide a migration path to the new API.
- Added a `NavigationTreeStrip` component to the sample app to visualize the live navigation tree using the new subtree observation logic.
- Updated `ComposeRenderer`, tests, and sample screens to reflect the API changes.
…Dialog to configure navigation tree visibility and display limits. Refactored NavigationTreeStrip to support AnimatedContent and dynamic visibility, and moved its placement to the root activity. Added maxLines support to LifecycleEventsHistory.
…duced hot `subtreeStateFlow` with deep tree observation. Updated `NavigationTree` logic and tests accordingly.
Copy link
Copy Markdown

@github-advanced-security github-advanced-security AI left a comment

Choose a reason for hiding this comment

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

detekt found more than 20 potential problems in the proposed changes. Check the Files changed tab for more details.

Comment thread gradle/libs.versions.toml
minSdk = "21"
compileSdk = "36"
koin = "4.0.0"
datastorePreferences = "1.1.1"
Comment thread gradle/libs.versions.toml
debug-logcat = { group = "com.squareup.logcat", name = "logcat", version = "0.1" }

kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version = "1.8.1" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version = "1.8.1" }
@ikarenkov ikarenkov changed the title Refactor navigation to reducer-based state with StateFlow Switch navigation to single-generic reducer model + StateFlow tree APIs May 20, 2026
@ikarenkov ikarenkov changed the title Switch navigation to single-generic reducer model + StateFlow tree APIs Architecture refactor: decouple NavModel, renderer, and reducer pipeline May 20, 2026
Comment thread modo-compose/src/main/java/com/github/terrakok/modo/Modo.kt Fixed
@ikarenkov ikarenkov force-pushed the refactor/container-screen-refactoring branch from 81a92e4 to 7358c2b Compare May 27, 2026 08:25
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.

2 participants