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
34 changes: 34 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,37 @@ jobs:
:sample-compose:main-parallel:ui:assembleDebug \
:sample-compose:categories:routing:assembleDebug \
:sample-compose:permissions:routing:assembleDebug

ios-tests:
runs-on: macos-latest
timeout-minutes: 60

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'

- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4

- name: Setup Android SDK
uses: android-actions/setup-android@v3
with:
packages: platform-tools

- name: Install Android SDK packages
run: |
sdkmanager --install \
"platforms;android-36" \
"build-tools;36.0.0"

- name: Run iOS simulator tests
run: |
./gradlew --no-daemon --stacktrace \
:way:iosSimulatorArm64Test \
:way-dsl:iosSimulatorArm64Test
15 changes: 14 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Changelog

## Unreleased
## 0.13.0 - 2026-11-09

### Added

Expand All @@ -14,6 +14,19 @@
* Parallel flows anywhere a `.dot` file allows them, regions may be local flows and parallel flows.
* An included graph may have several parents in one region, `target(payload, via = parent)` picks the one to
enter it from.
* Full targets: `target(payload, shop with shopId, item with itemId)` passes the payloads of every parameterized
ancestor (a missing one throws), so they are rebuilt when they are not alive, like a generated full target.
* `ParallelRef.regionId(child)` gives the region id of a child of a parallel flow, like a generated
`<child>RegionId`, in place of `Schema.regionByName`.
* way-compose: `NodeHost(service, autoStart = false)` doesn't start the service and renders nothing until the app
calls `start(payload)`, for a start which waits for something (e.g. DataStore).

### Fixed

* Gradle plugin: the node builder of a local flow nested in a flow builds the targets of its children under its own
segment; the root segment was repeated (`settings.settings.general`) and the builder failed.
* Gradle plugin: a `history`/`deepHistory` child of a `parallelFlow` is no longer a region of the parallel flow, the
schema and the node builder failed for it. A parallel flow with only history children has no children.

## 0.12.0 - 2026-11-08

Expand Down
20 changes: 17 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -340,13 +340,27 @@ needs the parent it is entered from, the counterpart of a generated `<id>Via<Par
NavigateTo(SettingsGraph.permission.target("camera", via = SettingsGraph.profile))
```

A full target carries the payloads of parameterized ancestors, so they are rebuilt when they are not alive (a cold
start, a flow's `initial`, a jump into another branch), the counterpart of a generated full target. As there, it
needs the payload of every parameterized ancestor below the root of the graph, a missing one throws:

```kotlin
NavigateTo(ShopGraph.promo.target(ShopGraph.shop with "s1", ShopGraph.item with 5))
```

The region id of a child of a parallel flow is relative to the parallel's schema, the counterpart of a generated
`<child>RegionId`:

```kotlin
override val initialRegions = setOf(TabsGraph.tabs.regionId(TabsGraph.home))
```

The graph is checked when its `schema` or `nodeBuilder` is first used, with the rules of a `.dot` file: one root which
is a flow or a parallel flow, no cycles, children under a root flow and under every parallel flow, several parents
only for an included graph, no children under an included graph, history nodes in place, a factory for every node
but a history one. Segments are `<node id>@<graph id>`, so graph ids have to be unique among the graphs of a service.

Not supported, unlike `.dot` files: screens as regions of a parallel flow (wrap them in a flow), full targets (with
the payloads of ancestors, use `AbsoluteTarget`), typed region accessors (use `Schema.regionByName`) and a separate node
Not supported, unlike `.dot` files: screens as regions of a parallel flow (wrap them in a flow) and a separate node
builder per local flow (include a graph instead). The target of a node under a parallel flow starts at its closest
flow, not at the flow above the parallel one as in generated code; both resolve to the same node.

Expand Down Expand Up @@ -690,7 +704,7 @@ a flow node extends `BaseFlowNode`.
- `ComposableNode` interface with `@Composable fun Content(modifier: Modifier)`
- A `ParallelFlowNode` renders by implementing `ComposableNode` — its `Content()` lays out the parallel and calls `NodeHost(regionId)` inside it to render each sub-region's screen stack.
- `LocalNavigationService` — `CompositionLocal<NavigationService<*>>` provided by `NodeHost(service)`. Available inside any `Content()` for reading state.
- `NodeHost(service)` composable — auto-starts service, observes root region's active node, renders `ComposableNode.Content()`, applies animated transitions.
- `NodeHost(service)` composable — auto-starts service (`autoStart = false` renders nothing until the app calls `start`), 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.
Expand Down
8 changes: 0 additions & 8 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,6 @@ hand-write an `AbsoluteTarget`. `AbsoluteTarget` stays as the load-bearing engin
- **Value:** removes cross-schema navigation boilerplate for consumers.
- **Trigger:** first app site that would otherwise hand-write an `AbsoluteTarget`.

### `NodeHost` auto-start opt-out
`NodeHost` calls `service.start()` without a payload when the service isn't started yet
(`way-compose/.../NodeHost.kt`), so an app that must read something (e.g. DataStore) before
`start(payload)` has to gate `NodeHost` behind its own "started" flag.
- **Approach:** `autoStart: Boolean = true` parameter on `NodeHost`; with `false`, render nothing until
`start()` is called.
- **Trigger:** first app that gates `NodeHost` behind its own "started" flag because of an async start.

## Feature extensions (on demand)

### History under imported (cross-schema) flows
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.12.0
versionName=0.13.0
pomGroupId=ru.kode
pomDescription=Navigation library based on statechart-like node graphs
pomUrl=https://github.com/appKODE/way
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,20 @@ class EventSinkTest {
rule.waitForIdle()
}

@Test
fun `host without auto start renders nothing until the service is started`() {
val fixture = FlowFixture()
rule.setContent { NodeHost(fixture.service, autoStart = false) }
rule.waitForIdle()
assertEquals(false, fixture.service.isStarted())
assertTrue(fixture.screens.isEmpty())

rule.runOnIdle { fixture.service.start() }
rule.waitForIdle()

assertTrue("main was never rendered", fixture.screen("main").sinks.isNotEmpty())
}

@Test
fun `sink stays the same instance across recompositions of a node`() {
val fixture = FlowFixture().host()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,20 @@ import ru.kode.way.isRegionAtRoot
import ru.kode.way.resolveAbsolute
import ru.kode.way.startsWith

/**
* Renders the root of [service]. With [autoStart] (default) it starts a service which is not started yet without a
* payload; with `false` it renders nothing until the app calls `service.start(payload)`, e.g. after an async read of
* the start payload.
*/
@ExperimentalAnimationApi
@Composable
fun NodeHost(
service: NavigationService<*>,
transitionSpec: AnimatedContentTransitionScope<Path?>.() -> ContentTransform = defaultTransitionSpec,
autoStart: Boolean = true,
) {
LaunchedEffect(service) {
if (!service.isStarted()) {
LaunchedEffect(service, autoStart) {
if (autoStart && !service.isStarted()) {
service.start()
}
}
Expand Down
64 changes: 57 additions & 7 deletions way-dsl/src/commonMain/kotlin/ru/kode/way/dsl/Graph.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import ru.kode.way.HistoryTarget
import ru.kode.way.NodeBuilder
import ru.kode.way.NullPayload
import ru.kode.way.Path
import ru.kode.way.RegionId
import ru.kode.way.Schema
import ru.kode.way.ScreenTarget
import ru.kode.way.Segment
Expand Down Expand Up @@ -261,21 +262,56 @@ sealed class NodeRef(
}
return Path(segments)
}

/**
* [FlowTarget.ancestorPayloads] / [ScreenTarget.ancestorPayloads] of a full target: [ancestors] must be the payloads
* of every parameterized ancestor of this node (entered through [via]) below the root of the graph, which is always
* alive, as the arguments of a generated full target are. No [ancestors] is a short target.
*/
internal fun ancestorPayloads(ancestors: Array<out AncestorPayload>, via: NodeRef? = null): Map<Segment, Any?> {
if (ancestors.isEmpty()) return emptyMap()
val required = generateSequence(via ?: graph.parentsOf(this).firstOrNull(), graph::parentOf)
.filter { it.parameterized && graph.parentsOf(it).isNotEmpty() }
.toList()
val payloads = ancestors.associate {
require(it.ref in required) { "'${it.ref.id}' is not a parameterized ancestor of '$id'" }
it.ref.segment to it.value
}
val missing = required.filter { it.segment !in payloads }
require(missing.isEmpty()) {
"full target of '$id' has no payloads for parameterized ancestors: ${missing.asReversed().joinToString { it.id }}"
}
return payloads
}
}

/** A payload of a parameterized ancestor in a full target, made with [with]. */
class AncestorPayload internal constructor(internal val ref: NodeRef, internal val value: Any?)

/** The [payload] of this parameterized flow when it is an ancestor in a full target. */
infix fun <P> FlowNodeRef<P, *>.with(payload: P): AncestorPayload = AncestorPayload(this, payload)

/** The [payload] of this parameterized screen when it is an ancestor in a full target. */
infix fun <P> ParameterizedScreenRef<P>.with(payload: P): AncestorPayload = AncestorPayload(this, payload)

/** A flow or a parallel flow with a parameter of type [P] (`Unit` for none) and a result of type [R]. */
sealed class FlowNodeRef<P, R : Any>(graph: Graph, id: String, parameterized: Boolean, nullable: Boolean) :
NodeRef(graph, id, parameterized, nullable) {
/**
* A target which starts this flow with [payload]. An included graph with several parents needs [via], the parent
* it is entered from (a generated `<id>Via<Parent>` target).
* it is entered from (a generated `<id>Via<Parent>` target). Payloads of parameterized [ancestors]
* (`shop with shopId`) make it a full target, which rebuilds them when they are not alive, like a generated full target.
*/
fun target(payload: P, via: NodeRef? = null): FlowTarget =
FlowTarget(targetPath(via), if (parameterized) payload ?: NullPayload else null)
fun target(payload: P, vararg ancestors: AncestorPayload, via: NodeRef? = null): FlowTarget = FlowTarget(
targetPath(via),
if (parameterized) payload ?: NullPayload else null,
ancestorPayloads(ancestors, via),
)
}

/** A target of a flow without a parameter, see [FlowNodeRef.target]. */
fun FlowNodeRef<Unit, *>.target(via: NodeRef? = null): FlowTarget = target(Unit, via)
fun FlowNodeRef<Unit, *>.target(vararg ancestors: AncestorPayload, via: NodeRef? = null): FlowTarget =
target(Unit, *ancestors, via = via)

class FlowRef<P, R : Any> internal constructor(
graph: Graph,
Expand All @@ -290,15 +326,29 @@ class ParallelRef<P, R : Any> internal constructor(
id: String,
parameterized: Boolean,
nullable: Boolean,
) : FlowNodeRef<P, R>(graph, id, parameterized, nullable)
) : FlowNodeRef<P, R>(graph, id, parameterized, nullable) {
/**
* Id of the region which starts at [child], relative to the schema of this parallel flow, like a generated
* `<child>RegionId`. Use it in `initialRegions`, `startRegion`, `DispatchBackTo` and `NodeHost(regionId = ...)`.
*/
fun regionId(child: FlowNodeRef<*, *>): RegionId {
graph.schema // validates and freezes the graph
require(this in graph.parentsOf(child)) { "'${child.id}' is not a region of parallel '$id'" }
return RegionId(Path(listOf(segment, child.segment)))
}
}

class ScreenRef internal constructor(graph: Graph, id: String) : NodeRef(graph, id, parameterized = false) {
fun target(): ScreenTarget = ScreenTarget(targetPath())
/** A target of this screen, a full one with payloads of parameterized [ancestors], see [FlowNodeRef.target]. */
fun target(vararg ancestors: AncestorPayload): ScreenTarget =
ScreenTarget(targetPath(), ancestorPayloads = ancestorPayloads(ancestors))
}

class ParameterizedScreenRef<P> internal constructor(graph: Graph, id: String) :
NodeRef(graph, id, parameterized = true) {
fun target(payload: P): ScreenTarget = ScreenTarget(targetPath(), payload ?: NullPayload)
/** A target of this screen, a full one with payloads of parameterized [ancestors], see [FlowNodeRef.target]. */
fun target(payload: P, vararg ancestors: AncestorPayload): ScreenTarget =
ScreenTarget(targetPath(), payload ?: NullPayload, ancestorPayloads(ancestors))
}

/**
Expand Down
20 changes: 0 additions & 20 deletions way-dsl/src/commonTest/kotlin/ru/kode/way/TestNodeBuilder.kt

This file was deleted.

Loading
Loading