Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,34 @@
# Changelog

## 0.9.10 - 2026-08-04

Three additions to `way`/`way-compose` that remove app-side workarounds around a `ParallelFlowNode`
region's identity and root state — all backward compatible, no breaking changes.

* Add `RegionId.resolveAbsolute(parentPath: Path?): RegionId` — resolves a schema-relative
`RegionId` (e.g. a generated `Schema.exploreFlowRegionId`) to the absolute id used as the key in
`NavigationState.regions`. This is the exact resolution `NodeHost(regionId = ...)` already
performed internally; it's now extracted and public so app code reading region state outside of
`NodeHost` (via `collectActiveNode`/`collectIsRegionAtRoot`) doesn't have to re-derive it by hand.
`NodeHost` itself now calls this shared function instead of its own private inline copy.
* Add `Region.rootPath: Path` and `NavigationState.isRegionAtRoot(regionId: RegionId): Boolean` —
a region's resolved root/initial path, captured once the first time it's resolved (whether via
`InitEvent`'s `FlowNode.initial` chain or a lazily-mounted region's first `NavigateTo`) and never
updated afterward, even as `active` keeps changing with further navigation. Lets app code (e.g. a
tab bar that should only show at each tab's root) ask "has this region navigated away from where
it started" without re-deriving a root path from generated `Target` constants and comparing
segment names by hand.
* Add `collectIsRegionAtRoot(service, regionId): State<Boolean>` (Compose) — reactive wrapper over
`isRegionAtRoot`.
* Fix: `collectActiveNode(service, regionId)` no longer restarts its underlying transition
listener when `regionId` changes. Previously `regionId` was part of the `produceState` key, so
watching a *different* region — e.g. "whichever tab is currently focused" in a `ParallelFlowNode`
tab bar, where the watched id changes on every tab switch — tore down and rebuilt the
subscription each time, briefly resetting the exposed state to its `initial = null` value. All
`collectActiveNode`/`collectIsRegionAtRoot` overloads now share one per-service `NavigationState`
subscription (keyed only on the service, never on a region id) and derive their per-region view
from it, so switching which region is being watched no longer flickers.

## 0.9.9 - 2026-07-31

* Add flavor-aware `.dot` file routing to `way-gradle-plugin`: a product flavor (or build type, or
Expand Down
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ kotlin.native.ignoreDisabledTargets=true

android.useAndroidX=true

versionName=0.9.9
versionName=0.9.10
pomGroupId=ru.kode
pomDescription=Navigation library based on statechart-like node graphs
pomUrl=https://github.com/appKODE/way
Expand Down
79 changes: 48 additions & 31 deletions way-compose/src/main/kotlin/ru/kode/way/compose/NodeHost.kt
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
Expand All @@ -35,8 +36,8 @@ import ru.kode.way.ParallelFlowNode
import ru.kode.way.Path
import ru.kode.way.Region
import ru.kode.way.RegionId
import ru.kode.way.append
import ru.kode.way.drop
import ru.kode.way.isRegionAtRoot
import ru.kode.way.resolveAbsolute
import ru.kode.way.startsWith

@ExperimentalAnimationApi
Expand Down Expand Up @@ -297,21 +298,57 @@ val defaultTransitionSpec: AnimatedContentTransitionScope<Path?>.() -> ContentTr
}
}

/**
* The single per-[service] [NavigationState] subscription every `collectActiveNode`/
* `collectIsRegionAtRoot` overload derives from. Keyed ONLY on [service] — never on a
* [RegionId] — so watching a *different* region (e.g. "whichever tab is currently focused" in a
* [ParallelFlowNode]'s tab bar) never tears down and restarts the underlying transition listener.
* A regionId-keyed subscription would otherwise reset to its `initial = null` value on every
* such switch, which briefly reads as "nothing here yet" to every derived call below — this
* shared subscription is what avoids that flicker.
*/
@Composable
private fun collectNavigationState(service: NavigationService<*>): State<NavigationState?> =
service.produceTransitionState(initial = null) { it }

/**
* When the schema has multiple flow regions, this returns the first one declared.
* For deterministic rendering of every region use the [NodeHost] overload that accepts a [RegionId].
*/
@Composable
fun collectActiveNode(service: NavigationService<*>): State<NodeWithPath?> =
service.produceTransitionState<NodeWithPath?>(initial = null) { s ->
s.regions.entries.firstOrNull()?.value?.toNodeWithPath()
fun collectActiveNode(service: NavigationService<*>): State<NodeWithPath?> {
val navState by collectNavigationState(service)
return remember(navState) {
mutableStateOf(navState?.regions?.entries?.firstOrNull()?.value?.toNodeWithPath())
}
}

/** The active node in [regionId], reactively. [regionId] may change freely between calls (e.g. to
* track a UI-level "focused region") without ever restarting the underlying subscription — see
* [collectNavigationState]. [regionId] must already be absolute; resolve a schema-relative one
* with [resolveAbsolute] first (the same resolution [NodeHost] applies internally). */
@Composable
fun collectActiveNode(service: NavigationService<*>, regionId: RegionId): State<NodeWithPath?> {
val navState by collectNavigationState(service)
return remember(navState, regionId) {
mutableStateOf(navState?.regions?.get(regionId)?.toNodeWithPath())
}
}

/**
* Whether [regionId]'s active node is currently its resolved root/initial screen — i.e. the
* region hasn't navigated anywhere since entering. Useful for chrome that should only show at a
* region's root (a tab bar hidden once the focused tab has drilled into a sub-screen). See
* [Region.rootPath] / [isRegionAtRoot] for the underlying comparison; [regionId] must already be
* absolute, same requirement as [collectActiveNode].
*/
@Composable
fun collectActiveNode(service: NavigationService<*>, regionId: RegionId): State<NodeWithPath?> =
service.produceTransitionState<NodeWithPath?>(initial = null, keys = arrayOf(regionId)) { s ->
s.regions[regionId]?.toNodeWithPath()
fun collectIsRegionAtRoot(service: NavigationService<*>, regionId: RegionId): State<Boolean> {
val navState by collectNavigationState(service)
return remember(navState, regionId) {
mutableStateOf(navState?.isRegionAtRoot(regionId) ?: false)
}
}

/**
* Renders the active screen in [regionId].
Expand All @@ -324,13 +361,8 @@ fun collectActiveNode(service: NavigationService<*>, regionId: RegionId): State<
* absolute path of the rendered node via [LocalNodePath]. Without that wrapping parent,
* `LocalNavigationService.current` will throw with a clear error.
*
* When [regionId] is schema-relative (i.e. its path does not already start with the parent path),
* it is resolved to the absolute [RegionId] used as the key in [NavigationState.regions], mirroring
* the runtime's `absoluteRegionRoot` logic. As a special case, length-1 relative regionIds
* (e.g. an imported non-parallel schema) cannot supply a tail to append; the guard at the
* `regionId.path.length > 1` check uses the parent path itself as the absolute path, mirroring
* `TargetResolution.absoluteRegionRoot` — without it, `Path.drop(1)` would return an empty Path
* and the init `check(segments.isNotEmpty())` would throw on first composition.
* [regionId] may be schema-relative — resolved to the absolute [RegionId] used as the key in
* [NavigationState.regions] via [resolveAbsolute]. See that function for the resolution rules.
*/
@ExperimentalAnimationApi
@Composable
Expand All @@ -341,22 +373,7 @@ fun NodeHost(
) {
val service = LocalNavigationService.current
val parentPath = LocalNodePath.current
// If we're rendered inside a parallel node's Content (parentPath != null) and the supplied
// regionId is schema-relative (i.e. its path does not already start with parentPath),
// resolve it to the absolute regionId used as the key in NavigationState.regions.
// This mirrors the runtime's absoluteRegionRoot logic.
val absoluteRegionId = remember(parentPath, regionId) {
if (parentPath != null && !regionId.path.startsWith(parentPath)) {
// Length-1 relative regionIds (e.g. an imported non-parallel schema) cannot supply a
// tail to append; in that case the parent path itself is the absolute path. Mirrors the
// guard in TargetResolution.absoluteRegionRoot — without it, Path.drop(1) returns an
// empty Path and the init `check(segments.isNotEmpty())` throws on first composition.
val tail = if (regionId.path.length > 1) regionId.path.drop(1) else null
RegionId(if (tail != null) parentPath.append(tail) else parentPath)
} else {
regionId
}
}
val absoluteRegionId = remember(parentPath, regionId) { regionId.resolveAbsolute(parentPath) }
val activeNode by collectActiveNode(service, absoluteRegionId)
val saveableStateHolder = rememberSaveableStateHolder()
NodeAnimatedContent(
Expand Down
4 changes: 4 additions & 0 deletions way/src/commonMain/kotlin/ru/kode/way/GraphTransitions.kt
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ internal fun calculateAliveNodes(
region._alive.removeAll { it !in stepsSet }
steps.forEach { if (it !in aliveSet) region._alive.add(it) }
region._active = region._alive.last()
// Set once, on this region's first-ever resolution (whether that's InitEvent's
// FlowNode.initial chain or a lazily-mounted region's first NavigateTo) — never touched again
// on subsequent navigation. See Region.rootPath.
if (region._rootPath == null) region._rootPath = region._active
}

pruneOrphanRegions(state, schema)
Expand Down
18 changes: 18 additions & 0 deletions way/src/commonMain/kotlin/ru/kode/way/NavigationState.kt
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,14 @@ class NavigationState internal constructor(
}
}

/**
* Whether [regionId]'s [Region.active] is still its [Region.rootPath] — i.e. the region hasn't
* navigated anywhere since it was first resolved. `false` for an unmaterialized/unknown
* [regionId], same as an absent region has no meaningful "root" to compare against.
*/
fun NavigationState.isRegionAtRoot(regionId: RegionId): Boolean =
regions[regionId]?.let { it.active == it.rootPath } ?: false

/**
* Pair of an intermediate [ParallelFlowNode] root and the finish-transition builder used to
* convert a `Finish(result)` returned from its `transition()` into a transition that reaches the
Expand Down Expand Up @@ -123,6 +131,7 @@ class Region internal constructor(
internal var _active: Path,
internal var _alive: MutableList<Path>,
internal val _rootFinishTransitionBuilder: (Any) -> Transition,
internal var _rootPath: Path? = null,
) {
val nodes: Map<Path, Node> = _nodes
val active: Path get() = _active
Expand All @@ -131,6 +140,14 @@ class Region internal constructor(
// TODO rename active -> attached/top/current, alive -> active?
val alive: List<Path> get() = _alive

/**
* This region's resolved root/initial path — wherever [active] pointed the very first time it
* was resolved, fixed for the region's whole lifetime regardless of later navigation. Falls
* back to the current [active] on the (in practice unreachable) chance it's read before the
* first resolution ever ran.
*/
val rootPath: Path get() = _rootPath ?: _active

// Structural copy: new map/list instances with the same Path keys and Node references.
// Node instances are SHARED between original and copy; mutations to Node state affect both.
// TODO @RemoveMutable remove if switch away from mutable collections happens
Expand All @@ -139,6 +156,7 @@ class Region internal constructor(
_active = this._active,
_alive = this._alive.toMutableList(),
_rootFinishTransitionBuilder = this._rootFinishTransitionBuilder,
_rootPath = this._rootPath,
)

override fun toString(): String = "Region(_nodes=$_nodes, _active=$_active)"
Expand Down
21 changes: 21 additions & 0 deletions way/src/commonMain/kotlin/ru/kode/way/RegionId.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,24 @@ import kotlin.jvm.JvmInline
value class RegionId(val path: Path) {
override fun toString(): String = path.toString()
}

/**
* Resolves a schema-relative [RegionId] (e.g. a generated `Schema.exploreFlowRegionId`, which
* carries only the path relative to its own schema) to the absolute [RegionId] used as the key
* in [NavigationState.regions], given the absolute path of the enclosing parallel node.
*
* Already-absolute region ids (their path already starts with [parentPath]) are returned
* unchanged. Mirrors the runtime's own `absoluteRegionRoot` resolution (see
* `TargetResolution.kt`) and the identical inline logic `NodeHost(regionId = ...)` uses — this is
* that same resolution, extracted so app code reading a region's state outside of `NodeHost`
* (e.g. via [ru.kode.way.compose.collectActiveNode]) doesn't have to re-derive it by hand.
*
* [parentPath] is `null` when there is no enclosing parallel (the schema root is not a
* [ParallelFlowNode]) — [regionId] is returned unchanged in that case, since there is nothing to
* resolve against.
*/
fun RegionId.resolveAbsolute(parentPath: Path?): RegionId {
if (parentPath == null || path.startsWith(parentPath)) return this
val tail = if (path.length > 1) path.drop(1) else null
return RegionId(if (tail != null) parentPath.append(tail) else parentPath)
}
62 changes: 62 additions & 0 deletions way/src/commonTest/kotlin/ru/kode/way/ParallelNodeTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -972,6 +972,68 @@ class ParallelNodeTest : ShouldSpec() {
}
}

should("isRegionAtRoot is true for every region right after Init") {
val sut = buildPar02Service()

sut.collectTransitions().test {
val initial = awaitItem()
val alphaRegionId = initial.regions.keys.find { it.path.lastSegment().name == "par02Alpha" }!!
val betaRegionId = initial.regions.keys.find { it.path.lastSegment().name == "par02Beta" }!!

initial.isRegionAtRoot(alphaRegionId) shouldBe true
initial.isRegionAtRoot(betaRegionId) shouldBe true

cancelAndIgnoreRemainingEvents()
}
}

should("isRegionAtRoot turns false after navigating within a region, true again once back at root") {
val sut = buildPar02Service(
alphaTransitions = listOf(
tr("screen2", Target.par02Alpha.par02AlphaScreen2),
tr("back-to-screen1", Target.par02Alpha.par02AlphaScreen1),
),
)

sut.collectTransitions().test {
val initial = awaitItem()
val alphaRegionId = initial.regions.keys.find { it.path.lastSegment().name == "par02Alpha" }!!
val betaRegionId = initial.regions.keys.find { it.path.lastSegment().name == "par02Beta" }!!
val rootPathBeforeNavigating = initial.regions[alphaRegionId]!!.rootPath

sut.sendEvent(TestEvent("screen2"))
awaitItem().apply {
isRegionAtRoot(alphaRegionId) shouldBe false
// Sibling region, untouched by the alpha-only navigation, stays at its root.
isRegionAtRoot(betaRegionId) shouldBe true
}

// Back to the flow's root screen — via an ordinary NavigateTo, not the AbsoluteTarget
// chain-resolution path exercised by the test above.
sut.sendEvent(TestEvent("back-to-screen1"))
awaitItem().apply {
isRegionAtRoot(alphaRegionId) shouldBe true
// rootPath itself never changes across this round trip — only `active` moved.
regions[alphaRegionId]!!.rootPath shouldBe rootPathBeforeNavigating
}

cancelAndIgnoreRemainingEvents()
}
}

should("isRegionAtRoot is false for a region id that does not exist") {
val sut = buildPar02Service()

sut.collectTransitions().test {
val initial = awaitItem()
val unknownRegionId = RegionId(Path("doesNotExist"))

initial.isRegionAtRoot(unknownRegionId) shouldBe false

cancelAndIgnoreRemainingEvents()
}
}

should("deepestRegion selects the region with the deepest active path") {
val alphaId = RegionId(Path("alpha"))
val betaId = RegionId(Path("beta"))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package ru.kode.way

import io.kotest.core.spec.style.ShouldSpec
import io.kotest.matchers.shouldBe

class RegionIdResolveAbsoluteTest : ShouldSpec() {
init {
should("return the regionId unchanged when parentPath is null") {
val regionId = RegionId(Path("exploreFlow"))
regionId.resolveAbsolute(null) shouldBe regionId
}

should("return the regionId unchanged when it is already absolute") {
val parentPath = Path("appFlow", "mainFlow", "homeFlow")
val regionId = RegionId(Path("appFlow", "mainFlow", "homeFlow", "exploreFlow"))
regionId.resolveAbsolute(parentPath) shouldBe regionId
}

should("prefix a multi-segment schema-relative regionId with parentPath, dropping its own first segment") {
val parentPath = Path("appFlow", "mainFlow", "homeFlow")
// Schema-relative id as generated for a region declared two levels deep in its own schema.
val regionId = RegionId(Path("homeFlow", "exploreFlow"))
regionId.resolveAbsolute(parentPath) shouldBe RegionId(Path("appFlow", "mainFlow", "homeFlow", "exploreFlow"))
}

should("resolve a length-1 schema-relative regionId to parentPath itself") {
val parentPath = Path("appFlow", "mainFlow", "homeFlow")
// A length-1 relative id (e.g. an imported non-parallel schema) has no tail to append.
val regionId = RegionId(Path("homeFlow"))
regionId.resolveAbsolute(parentPath) shouldBe RegionId(parentPath)
}
}
}
Loading