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
84 changes: 84 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,89 @@
# Changelog

## 0.11.0 - 2026-10-06

### Added

* Gradle plugin: a flavor (or build type) `.dot` file can extend a schema instead of replacing it. A file with the
`mode = "extend"` graph attribute holds only the added nodes and edges; they are added to the schema with the same
graph id, so the schema does not have to be copied into the flavor's source set. A file without the attribute
replaces the lower-priority one as before. See "Flavor-Specific Routing" in the README.
* `Path.endsWith(target: Target)`: `path.endsWith(Target.appFlow.login)` instead of a comparison of the last
segment's name with a string.
* `ParallelFlowNode.initialRegions`: the regions which start together with the node (all of them by default). The
other ones have no nodes until `startRegion(regionId)`, or a `NavigateTo` with a target inside of them,
starts them: tabs which load on the first selection need no placeholder nodes.
`isRegionStarted(regionId)` tells whether a region has nodes. A region of the root parallel which starts later
through `startRegion` gets the payload the service was started with, as the ones started at once do. Back which
is sent with `DispatchBackTo` to a region which is not started is ignored.
* `way-compose`: `FocusedRegionHost(regionId)` renders only one region of a parallel node (the selected tab) and keeps
the saved state of the other ones, `contentModifier` goes to the content of the region;
`collectActiveNode(regionId)` and `collectIsRegionAtRoot(regionId)` take a schema-relative region id.
* `ParallelFlowNode.isRegionAtRoot(regionId)`: the state of a region is readable in `transition()`, so Back can be
sent to an overlay region only when it shows something, without a listener which copies the state into the node.
* `BaseParallelFlowNode`: a parallel flow node with hooks, the counterpart of `BaseFlowNode`.
* `way-compose`: `rememberTransitionSpec()` with `pushTransition()`, `popTransition()`, `fadeTransition()` and
`noTransition()`, a spec which slides forward when the target path continues the source one and back in the
reverse case.

### Changed

* `way-compose`: `NodeHost(regionId)`, `collectActiveNode` and `collectIsRegionAtRoot` start from the current state of
the service, not from an empty one for the first frame: a host which enters composition later (a tab, an overlay)
shows its node at once. `NavigationService.currentState()` returns that state.
* The methods of `ServiceExtensionPoint`, `NodeExtensionPoint`, `FlowNodeHook` and `ScreenNodeHook` have empty default
implementations.
* `ParallelFlowNode` has new members `initialRegions`, `startRegion`, `isRegionStarted` and `isRegionAtRoot`: a
subclass which declares a member with one of these names has to rename it.
* The regions of a parallel flow which is declared inside of a region of the root schema start when this parallel
is entered and are removed when it is left. They were started together with the service and never removed.
* `NodeBuilder` has new members `snapshotCache` and `restoreCache` with default implementations, the generated
builders override them. A hand-written builder which caches its child builders should override them too.
* A node which is entered is built once per transition: the node which is asked about its `initial` target or
`initialRegions` is the one which is entered. A child flow node was built twice.

### Fixed

* `cleanDispose()` called `onDispose` twice on a parallel flow which was entered by `NavigateTo`: such a node
is alive both as a node of the calling region and as an intermediate parallel.
* `RegionId.resolveAbsolute` returned a wrong path for a region of a parallel which is declared deeper than the
root of an imported schema, so `DispatchBackTo` with a generated region id of such a parallel fell back to the
deepest region.
* A relative target from a node of a parallel flow into one of its regions changed the region of the parallel
itself, not the target one.
* A flow which has both a nested parallel flow and a screen of its own failed with "path must have at least one
segment" on a relative target to this screen.
* A parallel flow which is left got its `onExit` before the nodes of its regions did. Now the nodes of the regions
exit first, an inner parallel exits before the outer one.
* A parallel flow declared inside of a region of another parallel which is left stayed mounted together with its
regions.
* A schema whose root is a flow with a parallel flow declared under it failed at start with "parallel-flow root
must be a ParallelFlowNode". The root flow is the first region of such a schema now: the generated region ids
get an entry for it, and `<Root>ChildFinishRequest` is generated. The region enum keeps the regions of the parallel
only.
* Back was delivered to every region of a parallel flow declared under a flow inside of a region of the root schema,
each of them handled it. Now it goes into one region, chosen by the parallel, as it does for any other parallel.
* The regions of a parallel flow declared deeper inside of an imported parallel schema were started together with
this schema, before their parallel was entered, and never got `onExit`.
* A parallel flow which is entered by a `NavigateTo` with a target inside of it got its `onEntry` before the flows
above it, and before the nodes which the same transition leaves got their `onExit`.
* A transition which fails restores the state first and only then calls `onEntry` of the nodes which had got
`onExit`: such a node saw the half-changed state.
* Gradle plugin: a flow or a parallel flow declared inside of a flow which is not the root of its schema failed with
"illegal path build requested": the node builder of the root asked the outer flow's builder to build it.
* A parallel flow which an initial target has entered was built and entered once more by the first `NavigateTo`
with a target inside of it, and the first node never got its `onExit`.
* A parallel flow declared under a flow got `transition(Event.Back)` twice for one Back.
* A node whose `onEntry` throws gets `onExit` when the failed transition is rolled back, as the nodes entered before
it do. It was left entered half-way.
* A node whose `onPreExit` hook throws before its `onExit` is reached is not entered back when the failed transition
is rolled back.
* An event which a node has sent from `onEntry` or `onExit` while a failed transition was rolled back stayed in the
queue and was handled after the next event.
* A transition which fails restores the child node builders as they were before it. A builder which it had created
for a parameterized schema stayed cached, so the next transition built the nodes of this schema with the argument
of the failed one.

## 0.10.3 - 2026-10-06

### Fixed
Expand Down
107 changes: 94 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ The Compose integration (`:way-compose`) renders active nodes with `NodeHost`.
- `:sample` - JVM sample (KMP multi-target support is not yet released).
- `:sample-compose:*` - Android sample split into feature modules.
- `:sample-compose:main-parallel:*` - tab-bar example using `ParallelFlowNode` (see [Compose Integration](#compose-integration)).
- `:sample-compose:app:routing` - `google`/`huawei` product flavors with per-flavor `.dot` overrides (see [Flavor-Specific Routing](#flavor-specific-routing)).
- `:sample-compose:app:routing` - `google`/`huawei` product flavors with a per-flavor `.dot` extension (see [Flavor-Specific Routing](#flavor-specific-routing)).

## Requirements

Expand Down Expand Up @@ -88,6 +88,8 @@ digraph App {
- `package` - output package for generated code.
- `schemaFileName` - override generated schema file/class base name.
- `targetsFileName` - override generated targets file/class base name.
- `mode = "extend"` - the file adds nodes and edges to the schema with the same graph id instead of declaring one
(see [Flavor-Specific Routing](#flavor-specific-routing)).

### Supported node attributes

Expand Down Expand Up @@ -159,37 +161,65 @@ none of them can be a parallel node.
### Flavor-Specific Routing

Android product flavors can each get their own `.dot` file for the same schema — e.g. a step only
some flavors need. Resolution works exactly like Android resource merging: a file at
`src/<flavor>/way/<name>.dot` **fully replaces** the file at the same relative path in a
lower-priority source set (never merged). Priority, low to high:
some flavors need. Priority of the source sets, low to high:
`main` < flavor < build type < flavor+buildType variant-specific.

A flavor file either **extends** the schema or **replaces** it.

**Extend.** A file with the `mode = "extend"` graph attribute holds only what the flavor adds. Its nodes and
edges are added to the schema with the same graph id, and the result is validated as a whole:

```
src/main/way/app-flow.dot # app -> main
src/google/way/app-flow.dot # extension: app -> login (only google has a login step)
```
src/main/way/app-flow.dot # base: app -> login -> main
src/huawei/way/app-flow.dot # override: app -> main (huawei skips login)

```dot
digraph App {
mode = "extend"

login [type=schema, resultType = "ru.kode.way.sample.compose.login.routing.LoginFlowResult"]

app -> login
}
```

- The extended schema is found by the graph id (`digraph App`), so the file may have any name. With the name of
the base file it sits next to it in the flavor's source set, as above.
- An extension is meant to add: it can't remove a node or an edge, declare the type of a node of the schema anew,
or set `package`, `schemaFileName`, `targetsFileName`. Attributes it gives to an untyped node of the schema (a
screen's parameter) do overwrite the schema's ones, as a repeated declaration in one file does.
- Segment ids of the nodes — the added ones too — are built from the path of the base file, so the nodes common
to all flavors have the same ids in every flavor.
- Several extensions of one schema (a flavor's and a build type's) are all applied.

**Replace.** A file without the attribute at `src/<flavor>/way/<name>.dot` **fully replaces** the file at the
same relative path in a lower-priority source set (together with the extensions at this path), exactly like
Android resource merging. Use it when a flavor has to drop something.

The plugin registers one `generate<Variant>WayClasses` task per Android variant
(`generateGoogleDebugWayClasses`, `generateHuaweiDebugWayClasses`, ...), each writing to
`build/generated/way/code/<variant>/`. A flavor that doesn't override anything simply reuses
`build/generated/way/code/<variant>/`. A flavor without files of its own simply reuses
`main`'s file as its own input — there is no `generated/way/code/main/`, since `main` is a
contributing source set, never a buildable variant on its own.

**The gotcha: hand-written code can't straddle flavors.** `.dot` overrides use full-file
*replacement*; ordinary Kotlin/Java source sets (`src/main/kotlin`, `src/google/kotlin`,
**The gotcha: hand-written code can't straddle flavors.** Whichever way a flavor changes a schema, its generated
code differs, while ordinary Kotlin/Java source sets (`src/main/kotlin`, `src/google/kotlin`,
`src/huawei/kotlin`) use plain Gradle *union* — every flavor's compilation always includes
`main/kotlin`. So if one flavor's `.dot` file declares a node another flavor's doesn't, the
`main/kotlin`. So if one flavor's schema has a node another flavor's doesn't, the
flavor missing it won't generate the matching class member (e.g. `AppChildFinishRequest.Login`),
and hand-written code in `src/main/kotlin` referencing it fails with `Unresolved reference` for
that flavor.

Rule: anything touching a symbol that isn't in **every** flavor's generated output must live in
that flavor's own Kotlin source set (`src/google/kotlin`, `src/huawei/kotlin`, ...), not
`src/main/kotlin`. Code that stays in `main` may only reference the intersection of what every
flavor generates.
flavor generates. The node factory is such code: the generated `Factory` interface has a `create…` method
for every node, so a flavor with an added node needs its own factory class (the shared part can stay in `main`
as an abstract class).

Worked example: `sample-compose/app/routing` (`google` / `huawei` flavors) —
`src/huawei/way/app-flow.dot` overrides `src/main/way/app-flow.dot` to drop a Google-only login
`src/google/way/app-flow.dot` extends `src/main/way/app-flow.dot` with a Google-only login
step, and the hand-written `AppFlow.kt` / `AppFlowNode.kt` / `di/AppFlowComponent.kt` are
duplicated per flavor under `src/google/kotlin` / `src/huawei/kotlin` accordingly.
`src/test/kotlin/.../AppFlowNodeTest.kt` is a shared test that only touches the symbols common to
Expand Down Expand Up @@ -432,7 +462,7 @@ class MainTabsNode : ParallelFlowNode<Unit>(), ComposableNode {
}
```

Pass a `RegionId` from `NavigationState.regions` keys or a generated `<Schema>.<child>RegionId` constant. A `DispatchBackTo` naming a region that is no longer alive is **not** an error — Back soft-falls-back to the deepest active region, so the back button never crashes.
Pass a `RegionId` from `NavigationState.regions` keys or a generated `<Schema>.<child>RegionId` constant. A `DispatchBackTo` naming a region that is no longer alive is **not** an error — Back soft-falls-back to the deepest active region, so the back button never crashes. Back to a region which is not started yet (see `initialRegions` below) is ignored.

Transitions:
- `NavigateTo(targets)`
Expand Down Expand Up @@ -480,6 +510,55 @@ also drops any intermediate screens.
- Supports node/service extension points.
- Handles queued events one-by-one after each transition.

### Regions which start later

All regions of a parallel node start together with it. To start some of them later (a tab which loads on its first
selection), list the ones which start at once in `initialRegions` and start the others with `startRegion`:

```kotlin
class HomeNode : ParallelFlowNode<Unit>(), ComposableNode {
private var focused by mutableStateOf(schema.mainTabRegionId)

override val initialRegions get() = setOf(schema.mainTabRegionId)

override fun transition(event: Event): FlowTransition<Unit> = when (event) {
is TabSelected -> {
focused = event.regionId
startRegion(event.regionId) // Stay if the region is already started
}
Event.Back -> DispatchBackTo(focused)
else -> Ignore
}

@Composable
override fun Content(modifier: Modifier) {
FocusedRegionHost(focused, modifier)
}
}
```

A region which is not started has no nodes and receives no events. A `NavigateTo` with a target inside of it
starts it too. A started region lives as long as its parallel node: the regions of a
parallel nested in a flow start when this parallel is entered and are removed when it is left. A region of the root
parallel which `startRegion` starts gets the payload of `service.start(payload)`.
`initialRegions` must name at least one region, and a region whose root is itself a parallel flow cannot be started
later.

`FocusedRegionHost` (`way-compose`) composes only the given region: the other ones cannot show a dialog or a sheet,
and their saved state is restored when they get focus again. Its `modifier` goes to the container which switches
the regions, `contentModifier` to the content of the shown one.

Inside of `transition` a parallel node can ask about its regions: `isRegionStarted(regionId)` and
`isRegionAtRoot(regionId)` (the region rests on its first node, nothing is open above it). The second one picks the
region for Back when one of the regions is an overlay:

```kotlin
Event.Back -> DispatchBackTo(if (isRegionAtRoot(schema.fullScreenRegionId)) focused else schema.fullScreenRegionId)
```

A parallel node which needs hooks (for example a coroutine scope hook) extends `BaseParallelFlowNode`, the same way
a flow node extends `BaseFlowNode`.

## Compose Integration

`way-compose` provides:
Expand All @@ -489,6 +568,8 @@ also drops any intermediate screens.
- `NodeHost(service)` composable — auto-starts service, observes root region's active node, renders `ComposableNode.Content()`, applies animated transitions.
- `LocalEventSink` — the `EventSink` of the node being rendered, provided by `NodeHost` to every node's `Content()`. Always use it in UI: an event sent from a screen's `Content()` starts at that screen, one sent from a parallel's `Content()` at the active children of its regions; it then reaches every parallel enclosing the node and bubbles up through each parallel's ancestors, like `service.send`, and a tap on a screen which is animating out after Back is dropped instead of being applied to the new screen. Outside any node (but under `LocalNavigationService`) it is the service itself, the root sink.
- `NodeHost(regionId)` composable — renders the active screen in a specific sub-region. Call from a parallel node's `Content()` for each sub-region. Requires a parent `NodeHost(service)` to have provided `LocalNavigationService`.
- `FocusedRegionHost(regionId)` composable — renders only the given sub-region and keeps the saved state of the other ones.
- `rememberTransitionSpec()` — a transition spec which slides on push and pop (by comparing the paths) and asks `ambiguousTransitionResolver` for the rest; `pushTransition()`, `popTransition()`, `fadeTransition()`, `noTransition()` are its parts.

Sending screen events:

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.10.3
versionName=0.11.0
pomGroupId=ru.kode
pomDescription=Navigation library based on statechart-like node graphs
pomUrl=https://github.com/appKODE/way
Expand Down
7 changes: 7 additions & 0 deletions sample-compose/app/routing/src/google/way/app-flow.dot
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
digraph App {
mode = "extend"

login [type=schema, resultType = "ru.kode.way.sample.compose.login.routing.LoginFlowResult"]

app -> login
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import ru.kode.way.Ignore
import ru.kode.way.Target
import javax.inject.Inject

// Huawei flavor's app-flow.dot (src/huawei/way/app-flow.dot) drops the Google-only login step,
// The login step is added by the google flavor's schema extension (src/google/way/app-flow.dot),
// so this flow starts directly at "main" instead of "login".
class AppFlowNode @Inject constructor() : FlowNode<Unit> {
override val initial: Target = Target.app.main
Expand Down
10 changes: 0 additions & 10 deletions sample-compose/app/routing/src/huawei/way/app-flow.dot

This file was deleted.

2 changes: 0 additions & 2 deletions sample-compose/app/routing/src/main/way/app-flow.dot
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,9 @@ digraph App {
package = "ru.kode.way.sample.compose.app.routing"

app [type=flow]
login [type=schema, resultType = "ru.kode.way.sample.compose.login.routing.LoginFlowResult"]
main [type=schema, resultType = "ru.kode.way.sample.compose.main.routing.MainFlowResult"]
mainParallel [type=schema]

app -> login
app -> main
app -> mainParallel
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import io.kotest.core.spec.style.ShouldSpec
import io.kotest.matchers.shouldBe
import ru.kode.way.Target

// Target.app.login only exists because google's app-flow.dot (src/main/way) declares a "login"
// node — a huawei test referencing it would fail to compile, which is itself part of the proof
// that flavor dot-file overrides produce genuinely different generated schemas.
// Target.app.login only exists because google's schema extension (src/google/way/app-flow.dot) adds
// a "login" node — a huawei test referencing it would fail to compile, which is itself part of the
// proof that a flavor's dot file produces a genuinely different generated schema.
class AppFlowNodeGoogleTest :
ShouldSpec({
should("start at login in the google flavor's app flow") {
Expand Down
Loading
Loading