Skip to content

Commit c35474f

Browse files
committed
chore: refactor the result registry logic
1 parent 51f2a2d commit c35474f

File tree

14 files changed

+201
-19
lines changed

14 files changed

+201
-19
lines changed

nibel-runtime/src/main/kotlin/nibel/runtime/ComposableFragment.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,13 @@ abstract class ComposableFragment : Fragment() {
3434
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
3535
@Suppress("DEPRECATION")
3636
val args = arguments?.get(Nibel.argsKey) as? Parcelable?
37+
val requestKey = arguments?.getRequestKey()
3738

3839
setContent {
3940
Nibel.RootDelegate.Content {
4041
CompositionLocalProvider(
4142
LocalImplementationType provides ImplementationType.Fragment,
43+
LocalRequestKey provides requestKey,
4244
) {
4345
Nibel.NavigationDelegate.Content(rootArgs = args) {
4446
ComposableContent()

nibel-runtime/src/main/kotlin/nibel/runtime/ComposeNavigationDelegate.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,12 @@ open class ComposeNavigationDelegate : NavigationDelegate<NavigationControllerAr
5656
),
5757
)
5858

59+
// Set request key from composition local if available (for Fragment-based navigation)
60+
val requestKey = LocalRequestKey.current
61+
if (navController is NibelNavigationController) {
62+
navController.setRequestKeyFromFragment(requestKey)
63+
}
64+
5965
CompositionLocalProvider(LocalNavigationController provides navController) {
6066
NavHost(internalNavController, startDestination = ROOT_ROUTE) {
6167
registerComposable(

nibel-runtime/src/main/kotlin/nibel/runtime/CompositionLocals.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,10 @@ val LocalImplementationType = compositionLocalOf<ImplementationType?> {
2525
val LocalArgs = compositionLocalOf<Parcelable?> {
2626
null
2727
}
28+
29+
/**
30+
* Provides request key for result-based navigation or `null` if not navigated via navigateForResult.
31+
*/
32+
val LocalRequestKey = compositionLocalOf<String?> {
33+
null
34+
}

nibel-runtime/src/main/kotlin/nibel/runtime/NibelArgs.kt

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,33 @@ import androidx.lifecycle.SavedStateHandle
1111
*/
1212
const val NIBEL_ARGS = "nibel_args"
1313

14+
/**
15+
* Key for request ID used in result-based navigation.
16+
*/
17+
const val NIBEL_REQUEST_KEY = "nibel_request_key"
18+
1419
/**
1520
* Convert [Parcelable] args to a [Bundle].
1621
*/
1722
fun Parcelable.asNibelArgs() = Bundle().apply { putParcelable(Nibel.argsKey, this@asNibelArgs) }
1823

24+
/**
25+
* Add request key to a [Bundle] for result-based navigation.
26+
*/
27+
fun Bundle.putRequestKey(key: String?) {
28+
key?.let { putString(NIBEL_REQUEST_KEY, it) }
29+
}
30+
1931
/**
2032
* Retrieve args from a [SavedStateHandle].
2133
*/
2234
fun <A : Parcelable> SavedStateHandle.getNibelArgs(): A? = get<A>(Nibel.argsKey)
2335

36+
/**
37+
* Retrieve request key from a [SavedStateHandle].
38+
*/
39+
fun SavedStateHandle.getRequestKey(): String? = get<String>(NIBEL_REQUEST_KEY)
40+
2441
/**
2542
* Retrieve args from a [Bundle].
2643
*/
@@ -31,3 +48,8 @@ inline fun <reified A : Parcelable> Bundle.getNibelArgs(): A? =
3148
@Suppress("DEPRECATION")
3249
getParcelable(Nibel.argsKey)
3350
}
51+
52+
/**
53+
* Retrieve request key from a [Bundle].
54+
*/
55+
fun Bundle.getRequestKey(): String? = getString(NIBEL_REQUEST_KEY)

nibel-runtime/src/main/kotlin/nibel/runtime/NibelNavigationController.kt

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -28,18 +28,23 @@ open class NibelNavigationController(
2828
protected val composeNavigationContext =
2929
ComposeNavigationContext(internalNavController, exploredEntries)
3030

31-
/**
32-
* Storage for result callbacks, keyed by unique request ID.
33-
* Callbacks are invoked when a screen returns a result or cancels.
34-
*/
35-
private val resultCallbacks = mutableMapOf<String, (Any?) -> Unit>()
31+
// Note: Result callbacks are now stored in ResultCallbackRegistry singleton
32+
// to persist across Fragment/NavigationController instance recreation
3633

3734
/**
3835
* Storage for the current screen's request key (if navigated via navigateForResult).
3936
* Used to deliver results back to the caller.
4037
*/
4138
private var currentRequestKey: String? = null
4239

40+
/**
41+
* Sets the request key for this navigation controller instance.
42+
* Used when a Fragment is navigated to via navigateForResult.
43+
*/
44+
fun setRequestKeyFromFragment(key: String?) {
45+
currentRequestKey = key
46+
}
47+
4348
override fun navigateBack() {
4449
onBackPressedDispatcher.onBackPressed()
4550
}
@@ -97,25 +102,32 @@ open class NibelNavigationController(
97102

98103
// Store the callback with type erasure (we trust compile-time type safety)
99104
@Suppress("UNCHECKED_CAST")
100-
resultCallbacks[requestKey] = callback as (Any?) -> Unit
105+
ResultCallbackRegistry.storeCallback(requestKey, callback as (Any?) -> Unit)
101106

102107
// Store request key so the callee can use it to return results
103-
// Note: This will be passed through SavedStateHandle for Fragment entries
104-
// and through the entry itself for Composable entries
108+
// For Fragment entries, pass it through Bundle arguments
109+
// For Composable entries, store it in currentRequestKey
105110
val previousRequestKey = currentRequestKey
106111
currentRequestKey = requestKey
107112

113+
// Inject request key into Fragment arguments for result-based navigation
114+
if (entry is FragmentEntry) {
115+
val args = entry.fragment.arguments ?: android.os.Bundle()
116+
args.putRequestKey(requestKey)
117+
entry.fragment.arguments = args
118+
}
119+
108120
try {
109121
// Perform the navigation
110122
navigateTo(entry, fragmentSpec, composeSpec)
111123
} catch (e: IllegalStateException) {
112124
// If navigation fails, clean up and restore state
113-
resultCallbacks.remove(requestKey)
125+
ResultCallbackRegistry.removeCallback(requestKey)
114126
currentRequestKey = previousRequestKey
115127
throw e
116128
} catch (e: IllegalArgumentException) {
117129
// If navigation fails, clean up and restore state
118-
resultCallbacks.remove(requestKey)
130+
ResultCallbackRegistry.removeCallback(requestKey)
119131
currentRequestKey = previousRequestKey
120132
throw e
121133
}
@@ -167,10 +179,9 @@ open class NibelNavigationController(
167179
* Cleans up the callback after delivery.
168180
*/
169181
private fun deliverResult(requestKey: String, result: Any?) {
170-
val callback = resultCallbacks.remove(requestKey)
171-
if (callback != null) {
172-
callback(result)
173-
}
182+
val callback = ResultCallbackRegistry.removeCallback(requestKey)
183+
callback?.invoke(result)
184+
174185
// Clear current request key if it matches
175186
if (currentRequestKey == requestKey) {
176187
currentRequestKey = null
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package nibel.runtime
2+
3+
import java.util.concurrent.ConcurrentHashMap
4+
5+
/**
6+
* Global registry for result callbacks that persists across Fragment/Activity recreation.
7+
* This ensures callbacks survive navigation and configuration changes.
8+
*
9+
* Production-hardened with:
10+
* - TTL-based cleanup (5-minute expiry) to prevent memory leaks
11+
* - Max callback limit (50) to prevent DoS attacks
12+
* - Thread-safe implementation using ConcurrentHashMap
13+
*/
14+
internal object ResultCallbackRegistry {
15+
private const val MAX_CALLBACKS = 50
16+
private const val TTL_MILLIS = 5 * 60 * 1000L // 5 minutes
17+
18+
private val callbacks = ConcurrentHashMap<String, CallbackEntry>()
19+
20+
/**
21+
* Stores a callback with timestamp for TTL-based cleanup.
22+
*/
23+
fun storeCallback(requestKey: String, callback: (Any?) -> Unit) {
24+
// Cleanup stale callbacks if we're at the limit
25+
if (callbacks.size >= MAX_CALLBACKS) {
26+
cleanupStaleCallbacks()
27+
28+
// If still at limit after cleanup, drop the oldest callback
29+
if (callbacks.size >= MAX_CALLBACKS) {
30+
val oldestKey = callbacks.entries
31+
.minByOrNull { it.value.timestamp }
32+
?.key
33+
oldestKey?.let { callbacks.remove(it) }
34+
}
35+
}
36+
37+
callbacks[requestKey] = CallbackEntry(callback, System.currentTimeMillis())
38+
}
39+
40+
/**
41+
* Removes and returns a callback, or null if not found.
42+
*/
43+
fun removeCallback(requestKey: String): ((Any?) -> Unit)? {
44+
return callbacks.remove(requestKey)?.callback
45+
}
46+
47+
/**
48+
* Checks if a callback exists for the given request key.
49+
*/
50+
fun hasCallback(requestKey: String): Boolean {
51+
return callbacks.containsKey(requestKey)
52+
}
53+
54+
/**
55+
* Clears all callbacks. Should only be used in tests or emergency cleanup.
56+
*/
57+
fun clear() {
58+
callbacks.clear()
59+
}
60+
61+
/**
62+
* Removes callbacks older than TTL_MILLIS to prevent memory leaks
63+
* from abandoned navigation flows.
64+
*/
65+
private fun cleanupStaleCallbacks() {
66+
val now = System.currentTimeMillis()
67+
val staleKeys = callbacks.filterValues { entry ->
68+
now - entry.timestamp > TTL_MILLIS
69+
}.keys
70+
staleKeys.forEach { callbacks.remove(it) }
71+
}
72+
73+
/**
74+
* Internal data class to track callback with creation timestamp.
75+
*/
76+
private data class CallbackEntry(
77+
val callback: (Any?) -> Unit,
78+
val timestamp: Long,
79+
)
80+
}

sample/feature-A/src/main/kotlin/com/turo/nibel/sample/featureA/secondscreen/SecondScreen.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,10 @@ private fun SideEffectHandler(sideEffects: Flow<SecondSideEffect>) {
4343
is SecondSideEffect.NavigateBack -> navigateBack()
4444
is SecondSideEffect.NavigateToThirdScreen -> {
4545
val args = ThirdArgs(it.inputText)
46-
navigateTo(ThirdScreenDestination(args))
46+
navigateForResult(
47+
externalDestination = ThirdScreenDestination(args),
48+
callback = it.callback,
49+
)
4750
}
4851
}
4952
}
Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
package com.turo.nibel.sample.featureA.secondscreen
22

3+
import com.turo.nibel.sample.navigation.ThirdScreenResult
4+
35
sealed interface SecondSideEffect {
46

57
object NavigateBack : SecondSideEffect
68

7-
data class NavigateToThirdScreen(val inputText: String) : SecondSideEffect
9+
data class NavigateToThirdScreen(
10+
val inputText: String,
11+
val callback: (ThirdScreenResult?) -> Unit,
12+
) : SecondSideEffect
813
}

sample/feature-A/src/main/kotlin/com/turo/nibel/sample/featureA/secondscreen/SecondViewModel.kt

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,28 @@ class SecondViewModel @Inject constructor(
2828

2929
fun onContinue(nextButton: SecondNextButton) {
3030
when (nextButton) {
31-
SecondNextButton.SecondScreen ->
32-
_sideEffects.tryEmit(SecondSideEffect.NavigateToThirdScreen(state.value.inputText))
31+
SecondNextButton.SecondScreen -> {
32+
_sideEffects.tryEmit(
33+
SecondSideEffect.NavigateToThirdScreen(
34+
inputText = state.value.inputText,
35+
callback = { result ->
36+
// Handle result from ThirdScreen
37+
result?.let {
38+
onResultFromThirdScreen(it.inputText)
39+
}
40+
},
41+
),
42+
)
43+
}
3344
}
3445
}
3546

3647
fun onInputTextChanged(inputText: String) {
3748
_state.value = _state.value.copy(inputText = inputText)
3849
}
50+
51+
private fun onResultFromThirdScreen(inputText: String) {
52+
// Update state with the result from ThirdScreen
53+
_state.value = _state.value.copy(inputText = inputText)
54+
}
3955
}

sample/feature-B/src/main/kotlin/com/turo/nibel/sample/featureB/thirdscreen/ThirdScreen.kt

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import com.turo.nibel.sample.navigation.FirstScreenDestination
1010
import com.turo.nibel.sample.navigation.FourthArgs
1111
import com.turo.nibel.sample.navigation.FourthScreenDestination
1212
import com.turo.nibel.sample.navigation.ThirdScreenDestination
13+
import com.turo.nibel.sample.navigation.ThirdScreenResult
1314
import kotlinx.coroutines.flow.Flow
1415
import nibel.annotations.ImplementationType
1516
import nibel.annotations.UiExternalEntry
@@ -18,6 +19,7 @@ import nibel.runtime.LocalImplementationType
1819
@UiExternalEntry(
1920
type = ImplementationType.Fragment,
2021
destination = ThirdScreenDestination::class,
22+
result = ThirdScreenResult::class,
2123
)
2224
@Composable
2325
fun ThirdScreen(viewModel: ThirdViewModel = hiltViewModel()) {
@@ -40,14 +42,23 @@ fun ThirdScreen(viewModel: ThirdViewModel = hiltViewModel()) {
4042
private fun SideEffectHandler(sideEffects: Flow<ThirdSideEffect>) {
4143
SideEffectHandler(sideEffects) {
4244
when (it) {
43-
is ThirdSideEffect.NavigateBack -> navigateBack()
45+
is ThirdSideEffect.NavigateBack -> {
46+
cancelResultAndNavigateBack()
47+
}
48+
4449
is ThirdSideEffect.NavigateToFourthScreen -> {
4550
val args = FourthArgs(it.inputText)
4651
navigateTo(FourthScreenDestination(args))
4752
}
4853

4954
is ThirdSideEffect.NavigateToFirstScreen ->
5055
navigateTo(FirstScreenDestination)
56+
57+
is ThirdSideEffect.SetResultAndNavigateBack ->
58+
setResultAndNavigateBack(it.result)
59+
60+
is ThirdSideEffect.CancelResultAndNavigateBack ->
61+
cancelResultAndNavigateBack()
5162
}
5263
}
5364
}

0 commit comments

Comments
 (0)