diff --git a/CHANGELOG.md b/CHANGELOG.md index b8b77d3..e9f0952 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` (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 diff --git a/gradle.properties b/gradle.properties index 4ddb0c8..56c7df0 100644 --- a/gradle.properties +++ b/gradle.properties @@ -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 diff --git a/way-compose/src/main/kotlin/ru/kode/way/compose/NodeHost.kt b/way-compose/src/main/kotlin/ru/kode/way/compose/NodeHost.kt index fe51a82..7e499ac 100644 --- a/way-compose/src/main/kotlin/ru/kode/way/compose/NodeHost.kt +++ b/way-compose/src/main/kotlin/ru/kode/way/compose/NodeHost.kt @@ -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 @@ -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 @@ -297,21 +298,57 @@ val defaultTransitionSpec: AnimatedContentTransitionScope.() -> 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 = + 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 = - service.produceTransitionState(initial = null) { s -> - s.regions.entries.firstOrNull()?.value?.toNodeWithPath() +fun collectActiveNode(service: NavigationService<*>): State { + 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 { + 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 = - service.produceTransitionState(initial = null, keys = arrayOf(regionId)) { s -> - s.regions[regionId]?.toNodeWithPath() +fun collectIsRegionAtRoot(service: NavigationService<*>, regionId: RegionId): State { + val navState by collectNavigationState(service) + return remember(navState, regionId) { + mutableStateOf(navState?.isRegionAtRoot(regionId) ?: false) } +} /** * Renders the active screen in [regionId]. @@ -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 @@ -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( diff --git a/way/src/commonMain/kotlin/ru/kode/way/GraphTransitions.kt b/way/src/commonMain/kotlin/ru/kode/way/GraphTransitions.kt index aa65d91..26a6145 100644 --- a/way/src/commonMain/kotlin/ru/kode/way/GraphTransitions.kt +++ b/way/src/commonMain/kotlin/ru/kode/way/GraphTransitions.kt @@ -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) diff --git a/way/src/commonMain/kotlin/ru/kode/way/NavigationState.kt b/way/src/commonMain/kotlin/ru/kode/way/NavigationState.kt index 8e31c79..57ea891 100644 --- a/way/src/commonMain/kotlin/ru/kode/way/NavigationState.kt +++ b/way/src/commonMain/kotlin/ru/kode/way/NavigationState.kt @@ -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 @@ -123,6 +131,7 @@ class Region internal constructor( internal var _active: Path, internal var _alive: MutableList, internal val _rootFinishTransitionBuilder: (Any) -> Transition, + internal var _rootPath: Path? = null, ) { val nodes: Map = _nodes val active: Path get() = _active @@ -131,6 +140,14 @@ class Region internal constructor( // TODO rename active -> attached/top/current, alive -> active? val alive: List 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 @@ -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)" diff --git a/way/src/commonMain/kotlin/ru/kode/way/RegionId.kt b/way/src/commonMain/kotlin/ru/kode/way/RegionId.kt index b2625b5..62daae5 100644 --- a/way/src/commonMain/kotlin/ru/kode/way/RegionId.kt +++ b/way/src/commonMain/kotlin/ru/kode/way/RegionId.kt @@ -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) +} diff --git a/way/src/commonTest/kotlin/ru/kode/way/ParallelNodeTest.kt b/way/src/commonTest/kotlin/ru/kode/way/ParallelNodeTest.kt index 9e878c3..e5f7a70 100644 --- a/way/src/commonTest/kotlin/ru/kode/way/ParallelNodeTest.kt +++ b/way/src/commonTest/kotlin/ru/kode/way/ParallelNodeTest.kt @@ -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")) diff --git a/way/src/commonTest/kotlin/ru/kode/way/RegionIdResolveAbsoluteTest.kt b/way/src/commonTest/kotlin/ru/kode/way/RegionIdResolveAbsoluteTest.kt new file mode 100644 index 0000000..7c90170 --- /dev/null +++ b/way/src/commonTest/kotlin/ru/kode/way/RegionIdResolveAbsoluteTest.kt @@ -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) + } + } +}