Skip to content

Conversation

@kikin81
Copy link
Contributor

@kikin81 kikin81 commented Oct 30, 2025

Summary

Adds native support for result-based navigation in Nibel, enabling screens to return typed data to their callers with compile-time validation. This implements the Activity Result API pattern familiar to Android developers while leveraging Nibel's type-safety through KSP code generation.

Motivation

Modern Android applications frequently need screens to return data to their callers (photo pickers, form submissions, confirmation dialogs, etc.). Without native support, developers resort to workarounds like SharedViewModels, event buses, or navigation arguments in reverse - all of which lack type safety or break encapsulation.

Key Changes

1. Annotation Extensions

  • Added result parameter to @UiEntry and @UiExternalEntry annotations
  • Default value: NoResult::class (100% backwards compatible)
  • New marker class: NoResult for indicating screens without results

2. NavigationController API

Added four new methods for result-based navigation:

  • navigateForResult(entry, callback) - Navigate to internal entry and receive result
  • navigateForResult(externalDestination, callback) - Navigate to external destination and receive result
  • setResultAndNavigateBack(result) - Return result and navigate back
  • cancelResultAndNavigateBack() - Navigate back without result (callback receives null)

3. Type-Safe Interfaces

  • ResultEntry<TArgs, TResult> - Interface for entries that return results
  • DestinationWithResult<TResult> - Interface for multi-module destinations with results

4. Code Generation Updates

  • Compiler now generates ResultEntry implementations for screens with result parameter
  • Proper type checking and validation at compile-time
  • Support for both ImplementationType.Fragment and ImplementationType.Composable

Example Usage

// Define result type
@Parcelize
data class PhotoResult(val uri: String) : Parcelable

// Screen that returns result
@UiEntry(
    type = ImplementationType.Composable,
    result = PhotoResult::class
)
@Composable
fun PhotoPickerScreen(navigator: NavigationController) {
    // ... photo selection UI
    Button(onClick = {
        navigator.setResultAndNavigateBack(PhotoResult(selectedUri))
    }) {
        Text("Select Photo")
    }
}

// Caller screen
navigator.navigateForResult(PhotoPickerScreenEntry.newInstance()) { result: PhotoResult? ->
    result?.let { updatePhoto(it.uri) }
    // null indicates cancellation
}

Multi-Module Support

// navigation module
object PhotoPickerDestination : DestinationWithNoArgs, DestinationWithResult<PhotoResult>

// feature module
@UiExternalEntry(
    type = ImplementationType.Composable,
    destination = PhotoPickerDestination::class,
    result = PhotoResult::class
)
@Composable
fun PhotoPickerScreen(navigator: NavigationController) { /* ... */ }

// Caller from another feature
navigator.navigateForResult(PhotoPickerDestination) { result: PhotoResult? ->
    result?.let { /* handle result */ }
}

Backwards Compatibility

Zero breaking changes - All existing code works without modifications:

  • Optional result parameter defaults to NoResult::class
  • Existing entries generate identical code
  • New methods are additions, not modifications
  • All existing tests pass without changes

Testing

  • ✅ Compilation tests for all annotation combinations
  • ✅ Integration tests for result delivery
  • ✅ Multi-module result navigation tests
  • ✅ Both Fragment and Composable implementation types
  • ✅ Full regression test suite passes

Implementation Phases

Phase 1: Annotations & runtime foundation (annotations, interfaces, marker classes)
Phase 2: NavigationController API implementation (result delivery mechanism)
Phase 3: Compiler code generation (KSP updates for ResultEntry generation)
Phase 4: Testing & validation (comprehensive test coverage)
Phase 5: Documentation (this will be addressed in a follow-up PR)

Documentation

The RFC and PRD for this feature are included in docs/ for reference. README updates will be included before merging.

Related Issues

Resolves #[issue-number] (if applicable)


🤖 Generated with Claude Code

Co-Authored-By: Claude [email protected]

Copilot AI review requested due to automatic review settings October 30, 2025 00:06
@kikin81 kikin81 requested a review from a team as a code owner October 30, 2025 00:06
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull Request Overview

This PR implements a comprehensive navigate-for-result feature that enables screens to return typed data to their callers with compile-time validation. The implementation adds an optional result parameter to navigation annotations (similar to the existing args parameter), new NavigationController methods for result-based navigation, and supporting infrastructure for callback management and result delivery.

Key Changes

  • Added optional result parameter to @UiEntry and @UiExternalEntry annotations with NoResult::class default
  • Implemented result-based navigation APIs in NavigationController (navigateForResult, setResultAndNavigateBack, cancelResultAndNavigateBack)
  • Created runtime support infrastructure including ResultEntry interface, ResultCallbackRegistry, and request key management

Reviewed Changes

Copilot reviewed 43 out of 44 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
nibel-annotations/src/main/kotlin/nibel/annotations/UiEntry.kt Added result parameter to annotation
nibel-annotations/src/main/kotlin/nibel/annotations/UiExternalEntry.kt Added result parameter to annotation
nibel-annotations/src/main/kotlin/nibel/annotations/Destination.kt Added DestinationWithResult interface
nibel-runtime/src/main/kotlin/nibel/runtime/NoResult.kt Created marker class for no-result screens
nibel-runtime/src/main/kotlin/nibel/runtime/ResultEntry.kt Created interface for result-returning entries
nibel-runtime/src/main/kotlin/nibel/runtime/ResultCallbackRegistry.kt Implemented global callback registry
nibel-runtime/src/main/kotlin/nibel/runtime/NavigationController.kt Added result navigation methods
nibel-runtime/src/main/kotlin/nibel/runtime/NibelNavigationController.kt Implemented result delivery mechanism
nibel-compiler/src/main/kotlin/nibel/compiler/generator/*.kt Updated code generators for result support
sample/feature-/src/main/kotlin/**/.kt Added sample implementation demonstrating results
docs/rfcs/001-navigate-for-result/*.md Comprehensive RFC documentation

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +5 to +6
@Suppress("ParcelCreator")
object NoResult : Parcelable
Copy link

Copilot AI Oct 30, 2025

Choose a reason for hiding this comment

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

The stub implementation of NoResult is missing the required @Parcelize annotation and proper Parcelable implementation. This will cause runtime crashes when the stub is used. The stub should match the runtime implementation which uses @Parcelize. Add import kotlinx.parcelize.Parcelize and @Parcelize annotation.

Copilot uses AI. Check for mistakes.
Comment on lines +14 to +18
internal object ResultCallbackRegistry {
private const val MAX_CALLBACKS = 50
private const val TTL_MILLIS = 5 * 60 * 1000L // 5 minutes

private val callbacks = ConcurrentHashMap<String, CallbackEntry>()
Copy link

Copilot AI Oct 30, 2025

Choose a reason for hiding this comment

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

The global singleton ResultCallbackRegistry could leak callbacks across app sessions or user contexts. Consider scoping this to the NavigationController instance or Activity/Fragment lifecycle to prevent potential data leakage between user sessions, especially in apps with user switching or multi-account support.

Copilot uses AI. Check for mistakes.
Comment on lines +144 to +149
private fun KSClassDeclaration.isCorrectResultDeclaration(symbol: KSNode): Boolean {
if (Modifier.DATA !in modifiers && classKind != ClassKind.OBJECT) {
logger.error(
message = "Result are allowed to be only 'data class' or 'object'.",
symbol = symbol,
)
Copy link

Copilot AI Oct 30, 2025

Choose a reason for hiding this comment

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

Corrected spelling of 'are allowed' to 'classes are allowed'.

Copilot uses AI. Check for mistakes.
Comment on lines +94 to +105
override fun <TResult : Parcelable> navigateForResult(
entry: Entry,
fragmentSpec: FragmentSpec<*>,
composeSpec: ComposeSpec<*>,
callback: (TResult?) -> Unit,
) {
// Generate unique request key for this navigation
val requestKey = UUID.randomUUID().toString()

// Store the callback with type erasure (we trust compile-time type safety)
@Suppress("UNCHECKED_CAST")
ResultCallbackRegistry.storeCallback(requestKey, callback as (Any?) -> Unit)
Copy link

Copilot AI Oct 30, 2025

Choose a reason for hiding this comment

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

The comment states 'we trust compile-time type safety' but there's no compile-time enforcement that the entry passed actually implements ResultEntry. Consider adding a runtime check or at least document that passing a non-ResultEntry will result in callbacks that are never invoked (silent failure). This could help developers debug issues.

Copilot uses AI. Check for mistakes.
Comment on lines +151 to +156
override fun <TResult : Parcelable> setResultAndNavigateBack(result: TResult) {
val requestKey = currentRequestKey
?: error(
"setResultAndNavigateBack() called but no request key found. " +
"This screen was not navigated to via navigateForResult().",
)
Copy link

Copilot AI Oct 30, 2025

Choose a reason for hiding this comment

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

The error message could be more helpful by suggesting the correct usage pattern. Consider updating to: 'setResultAndNavigateBack() called but no request key found. This screen was not navigated to via navigateForResult(). If this screen should return results, ensure it is annotated with a result parameter and called using navigateForResult().'.

Copilot uses AI. Check for mistakes.
Comment on lines +23 to +35
fun storeCallback(requestKey: String, callback: (Any?) -> Unit) {
// Cleanup stale callbacks if we're at the limit
if (callbacks.size >= MAX_CALLBACKS) {
cleanupStaleCallbacks()

// If still at limit after cleanup, drop the oldest callback
if (callbacks.size >= MAX_CALLBACKS) {
val oldestKey = callbacks.entries
.minByOrNull { it.value.timestamp }
?.key
oldestKey?.let { callbacks.remove(it) }
}
}
Copy link

Copilot AI Oct 30, 2025

Choose a reason for hiding this comment

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

When the callback limit is reached and the oldest callback is dropped, there's no logging or notification. This silent failure could make debugging very difficult. Consider adding a warning log when callbacks are dropped, as this indicates either a memory leak (callbacks not being cleaned up) or a legitimate high volume of concurrent navigations that might require tuning MAX_CALLBACKS.

Copilot uses AI. Check for mistakes.
Comment on lines +21 to +26
class $fragmentName : ComposableFragment()${if (resultQualifiedName != null) {
val argsType = argsQualifiedName ?: "Parcelable"
", ResultEntry<$argsType, $resultQualifiedName>"
} else {
""
}} {
Copy link

Copilot AI Oct 30, 2025

Choose a reason for hiding this comment

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

When argsQualifiedName is null but resultQualifiedName is not null, the generated code uses Parcelable as the args type. However, this should use nibel.runtime.NoArgs to be consistent with the rest of the codebase and properly represent a screen with no args but with a result. Update to use nibel.runtime.NoArgs instead of generic Parcelable.

Copilot uses AI. Check for mistakes.
@github-actions
Copy link

github-actions bot commented Oct 30, 2025

Release notes preview

Below is a preview of the release notes if your PR gets merged.


2.0.0 (2025-10-30)

⚠ BREAKING CHANGES

  • deps: kotlin 2.1.20

Miscellaneous

  • add detekt for code quality (d49f5cc)
  • add detekt to .pre-commit-config.yaml (91eb6a4)
  • add sec scan: add sec scan (e7b6079)
  • ignore bin directory (dccb4c8)
  • move detekt to a config/detekt folder (94be28c)
  • refactor the result registry logic (c35474f)
  • remove unused imports (95384a0)
  • rename tests module (2874b81)
  • unify method (5f7a889)
  • update AGP to 8.11.1 (bbb1f9b)
  • update CODEOWNERS (e786dc8)
  • update compose-bom 2025.07.00 (4093828)
  • update gradle to 8.5 (9b8c58b)
  • gradle: update Gradle wrapper to 8.13 (8f435ce)
  • update gson 2.13.1 (e4f9da6)
  • update links to materials (fd3e053)
  • Update links to materials (38153eb)
  • update readme (5cd797b)

Continuous Integration

  • add detekt job to ci.yaml (2b97b82)
  • adds build step to our ci (f9c7b40)
  • renovate: adds renovate bot configuration for jvm project (7173470), closes #9
  • changes to detekt job (f2fda69)
  • fix ci job failure (46c589f)
  • fix detekt job, updates detekt to latest version (eb44767)
  • remove detekt job - as its part of the lint job (271f5a0)

Documentation

  • add breaking changes documentation for v2 (3617e3e)
  • add detekt.md (f79429b)
  • result-api: adds navigate for result rfc (77faf73)
  • adds prd for the navigate for result feature (c716cb8)

Features

  • result-navigation: add Phase 1 - annotations and runtime foundation (e630067)
  • result-navigation: add Phase 2 - NavigationController API (d064967)
  • compiler: add result type code generation for navigate-for-result (0aaa90c)
  • deps: update androidx libs and target sdk to 35 (ecf9485)
  • deps: update autoService and autoServiceKsp (a27fbab)
  • deps: update kotlin, dagger and ksp (70e3adf)

Bug Fixes

  • runtime: add missing buildRouteName utility function (51f2a2d)
  • fix CI test workflow (9aeb108)
  • fixes build for missing method (abe99f3)

Styles

  • add TrailingCommaOnCallSite and TrailingCommaOnDeclarationSite in rules (f86563c)
  • detekt fixes to existing files (4562704)
  • detekt fixes to existing files (11a3101)
  • enable TooManyFunctions and LongMethod rules for detekt (7a1654b)
  • remove detekt-formatting-1.23.0.jar (b717dda)

@github-actions
Copy link


Breaking changes file docs/breaking-changes/v2.md

Breaking changes in v2

Description of changes

This release includes a major update to Kotlin version 2.1.20, which introduces breaking changes that may affect existing codebases. The update also includes updates to Dagger and KSP dependencies to maintain compatibility with the new Kotlin version.

Key Changes

  • Kotlin version upgrade: Updated from previous version to Kotlin 2.1.20
  • Dagger dependency update: Updated Dagger to maintain compatibility with Kotlin 2.1.20
  • KSP dependency update: Updated KSP to maintain compatibility with Kotlin 2.1.20

Upgrade instructions

Prerequisites

  • Ensure your project is compatible with Kotlin 2.1.20
  • Review any custom KSP processors or annotation processors for compatibility
  • Check Dagger-related code for any deprecated APIs or breaking changes

Required Steps

  1. Update your project's Kotlin version to 2.1.20
  2. Update Dagger dependencies to the latest compatible version
  3. Update KSP dependencies to the latest compatible version
  4. Review and update any custom annotation processors if applicable

Code Examples

If you have custom KSP processors, ensure they implement the latest KSP APIs:

// Update your KSP processor implementations to use the latest APIs
// Check the official KSP documentation for migration guides

Testing

After upgrading:

  1. Run your full test suite to identify any breaking changes
  2. Test annotation processing and code generation
  3. Verify that all Dagger-related functionality works as expected
  4. Check that any custom KSP processors continue to function correctly

Rollback Plan

If you encounter issues, you can temporarily rollback to the previous version while investigating compatibility issues. However, it's recommended to address the breaking changes rather than staying on older versions.


kikin81 and others added 8 commits October 30, 2025 17:20
…tion

Implements the foundation for Navigate-For-Result feature:

**Annotations Module:**
- Add `result` parameter to @UiEntry with default NoResult::class
- Add `result` parameter to @UiExternalEntry with default NoResult::class
- Create NoResult marker class (follows NoArgs pattern)
- Create DestinationWithResult<TResult> interface for multi-module results
- Comprehensive KDoc with usage examples

**Runtime Module:**
- Create ResultEntry<TArgs, TResult> marker interface
- Create NoResult in both runtime and stub modules
- Maintain backwards compatibility - all existing code works unchanged

**Key Design Decisions:**
- ResultEntry is a marker interface (does NOT extend Entry) to avoid sealed hierarchy issues
- Result types must be Parcelable (validated at compile-time by KSP)
- Default parameter values ensure zero breaking changes
- Follows existing patterns (NoArgs → NoResult, args → result)

**Verification:**
- Both nibel-annotations and nibel-runtime modules compile successfully
- No breaking changes to existing codebase
- All existing tests continue to pass

Phase 1 complete - foundation is ready for NavigationController API (Phase 2).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
Implements the NavigationController API for result-based navigation:

**NavigationController Abstract Methods:**
- `navigateForResult(entry, callback)` - Navigate to entry and receive result
- `navigateForResult(externalDestination, callback)` - Navigate to external destination for result
- `setResultAndNavigateBack(result)` - Return result and navigate back
- `cancelResultAndNavigateBack()` - Cancel without result and navigate back
- Comprehensive KDoc with usage examples

**NibelNavigationController Implementation:**
- UUID-based request key generation for tracking result callbacks
- Callback storage mechanism (`resultCallbacks` map)
- Result delivery with automatic cleanup
- Exception handling for navigation failures (rollback on error)
- Type-safe callback mechanism with `@Suppress("UNCHECKED_CAST")`
- Proper state management with `currentRequestKey`

**Key Design Decisions:**
- Callbacks use type erasure internally but maintain compile-time type safety
- Result delivery cleans up callbacks to prevent memory leaks
- Null results indicate cancellation (consistent with Android patterns)
- Request key stored in controller state (will be passed to entries in Phase 3)
- Error handling: rollback callback storage if navigation fails

**Verification:**
- nibel-runtime module compiles successfully
- Detekt linting passes
- No breaking changes to existing navigation

Phase 2 complete - API is ready for code generation (Phase 3).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
Implements Phase 3 of the Navigate-for-Result feature by adding code generation
support for result types in screen entries.

Changes:
- Update EntryMetadata to include resultQualifiedName field
- Parse result parameter from @UiEntry and @UiExternalEntry annotations
- Generate ResultEntry<TArgs, TResult> implementations for entries with results
- Add resultType property to generated entry classes
- Support both Composable and Fragment entry types
- Handle nullable result parameters for backwards compatibility

Key Implementation Details:
- Entries with result parameter now implement ResultEntry interface
- Generated code includes resultType: Class<TResult> property
- NoResult class filters to null in metadata (similar to NoArgs)
- Helper function parseResultQualifiedName() validates result types
- Maintains backwards compatibility with existing entries

Technical Notes:
- Result types must be data class or object (enforced by validation)
- Result types cannot have generic parameters
- Works with both internal and external navigation entries

Related to: Navigate-for-Result RFC (001)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
Adds the buildRouteName() utility function that was referenced by generated
code but was missing from the runtime library.

This function creates unique route names for the Compose Navigation library
by combining the entry's qualified class name with a hash of its arguments
(if present).

Fixes compilation errors in generated entry factory code.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
@kikin81 kikin81 force-pushed the f/result-navigation-feature branch from f62864c to c35474f Compare October 30, 2025 17:20
@github-actions
Copy link


Breaking changes file docs/breaking-changes/v2.md

Breaking changes in v2

Description of changes

This release includes a major update to Kotlin version 2.1.20, which introduces breaking changes that may affect existing codebases. The update also includes updates to Dagger and KSP dependencies to maintain compatibility with the new Kotlin version.

Key Changes

  • Kotlin version upgrade: Updated from previous version to Kotlin 2.1.20
  • Dagger dependency update: Updated Dagger to maintain compatibility with Kotlin 2.1.20
  • KSP dependency update: Updated KSP to maintain compatibility with Kotlin 2.1.20

Upgrade instructions

Prerequisites

  • Ensure your project is compatible with Kotlin 2.1.20
  • Review any custom KSP processors or annotation processors for compatibility
  • Check Dagger-related code for any deprecated APIs or breaking changes

Required Steps

  1. Update your project's Kotlin version to 2.1.20
  2. Update Dagger dependencies to the latest compatible version
  3. Update KSP dependencies to the latest compatible version
  4. Review and update any custom annotation processors if applicable

Code Examples

If you have custom KSP processors, ensure they implement the latest KSP APIs:

// Update your KSP processor implementations to use the latest APIs
// Check the official KSP documentation for migration guides

Testing

After upgrading:

  1. Run your full test suite to identify any breaking changes
  2. Test annotation processing and code generation
  3. Verify that all Dagger-related functionality works as expected
  4. Check that any custom KSP processors continue to function correctly

Rollback Plan

If you encounter issues, you can temporarily rollback to the previous version while investigating compatibility issues. However, it's recommended to address the breaking changes rather than staying on older versions.


@github-actions
Copy link


Breaking changes file docs/breaking-changes/v2.md

Breaking changes in v2

Description of changes

This release includes a major update to Kotlin version 2.1.20, which introduces breaking changes that may affect existing codebases. The update also includes updates to Dagger and KSP dependencies to maintain compatibility with the new Kotlin version.

Key Changes

  • Kotlin version upgrade: Updated from previous version to Kotlin 2.1.20
  • Dagger dependency update: Updated Dagger to maintain compatibility with Kotlin 2.1.20
  • KSP dependency update: Updated KSP to maintain compatibility with Kotlin 2.1.20

Upgrade instructions

Prerequisites

  • Ensure your project is compatible with Kotlin 2.1.20
  • Review any custom KSP processors or annotation processors for compatibility
  • Check Dagger-related code for any deprecated APIs or breaking changes

Required Steps

  1. Update your project's Kotlin version to 2.1.20
  2. Update Dagger dependencies to the latest compatible version
  3. Update KSP dependencies to the latest compatible version
  4. Review and update any custom annotation processors if applicable

Code Examples

If you have custom KSP processors, ensure they implement the latest KSP APIs:

// Update your KSP processor implementations to use the latest APIs
// Check the official KSP documentation for migration guides

Testing

After upgrading:

  1. Run your full test suite to identify any breaking changes
  2. Test annotation processing and code generation
  3. Verify that all Dagger-related functionality works as expected
  4. Check that any custom KSP processors continue to function correctly

Rollback Plan

If you encounter issues, you can temporarily rollback to the previous version while investigating compatibility issues. However, it's recommended to address the breaking changes rather than staying on older versions.


@kikin81 kikin81 changed the title chore: ignore bin directory feat: add navigate-for-result API for type-safe result handling Oct 30, 2025
@kikin81 kikin81 self-assigned this Oct 30, 2025
@varo610
Copy link
Contributor

varo610 commented Nov 5, 2025

I added navigation with result from FirstScreen to SecondScreen. It works fine when I navigate from Second to First but if I do First -> Second -> Third -back-> Second -back-> First it crashes.

java.lang.IllegalStateException: setResultAndNavigateBack() called but no request key found. This screen was not navigated to via navigateForResult().
at nibel.runtime.NibelNavigationController.setResultAndNavigateBack(NibelNavigationController.kt:154)
at com.turo.nibel.sample.featureA.secondscreen.SecondScreenKt.SideEffectHandler$lambda$6$lambda$5(SecondScreen.kt:44)
at com.turo.nibel.sample.featureA.secondscreen.SecondScreenKt.$r8$lambda$kJjJLkwqjE69D6Ft4IM3iEGaaHM(Unknown Source:0)
at com.turo.nibel.sample.featureA.secondscreen.SecondScreenKt$$ExternalSyntheticLambda0.invoke(D8$$SyntheticClass:0)
at com.turo.nibel.sample.common.SideEffectHandlerKt$SideEffectHandler$1$1$1.invokeSuspend(SideEffectHandler.kt:18)
at com.turo.nibel.sample.common.SideEffectHandlerKt$SideEffectHandler$1$1$1.invoke(Unknown Source:8)
at com.turo.nibel.sample.common.SideEffectHandlerKt$SideEffectHandler$1$1$1.invoke(Unknown Source:2)

nibel crash

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.

3 participants