From cb0e9da665b12a62445b39e6d08fda5f53e443ca Mon Sep 17 00:00:00 2001 From: "harry.tumalewa" Date: Thu, 23 Apr 2026 18:32:13 +0700 Subject: [PATCH] refactor(runtime): Resolve scoped factory dependencies in the owning scope --- stitch/api/stitch.api | 3 ++- .../com/harrytmthy/stitch/api/Component.kt | 8 ++++-- .../com/harrytmthy/stitch/api/Module.kt | 16 +++++++----- .../stitch/api/ResolutionContext.kt | 2 +- .../com/harrytmthy/stitch/StitchTest.kt | 26 +++++++++++++++++++ .../com/harrytmthy/stitch/TestFixtures.kt | 4 ++- 6 files changed, 47 insertions(+), 12 deletions(-) diff --git a/stitch/api/stitch.api b/stitch/api/stitch.api index cadc476..90795c9 100644 --- a/stitch/api/stitch.api +++ b/stitch/api/stitch.api @@ -34,7 +34,7 @@ public final class com/harrytmthy/stitch/api/Injector$DefaultImpls { } public final class com/harrytmthy/stitch/api/Module { - public fun (ZLkotlin/jvm/functions/Function1;)V + public fun (Z)V public final fun define (Lkotlin/reflect/KClass;Lcom/harrytmthy/stitch/api/Qualifier;Lcom/harrytmthy/stitch/internal/DefinitionType;ZLjava/lang/String;Lkotlin/jvm/functions/Function1;)Lcom/harrytmthy/stitch/api/Bindable; public final fun register ()V } @@ -66,6 +66,7 @@ public final class com/harrytmthy/stitch/api/QualifierKt { public final class com/harrytmthy/stitch/api/ResolutionContext { public final fun getComponent ()Lcom/harrytmthy/stitch/api/Component; public final fun getScope ()Lcom/harrytmthy/stitch/api/Scope; + public final fun setScope (Lcom/harrytmthy/stitch/api/Scope;)V } public final class com/harrytmthy/stitch/api/Scope { diff --git a/stitch/src/commonMain/kotlin/com/harrytmthy/stitch/api/Component.kt b/stitch/src/commonMain/kotlin/com/harrytmthy/stitch/api/Component.kt index 258dcf3..10724aa 100644 --- a/stitch/src/commonMain/kotlin/com/harrytmthy/stitch/api/Component.kt +++ b/stitch/src/commonMain/kotlin/com/harrytmthy/stitch/api/Component.kt @@ -69,8 +69,12 @@ class Component internal constructor() { Registry.singletons[canonicalType]?.get(qualifierKey)?.let { return it as T } } - // Build with cycle guard, cache under canonical key - val resolving = resolutionContext ?: ResolutionContext(this, owningScope) + // Build with cycle guard, cache under canonical key. + // Factory lambdas resolve in the context of the owning scope, not the requesting scope, + // so `scoped(activityScope) { A(get()) }` resolves B within activityScope's visibility, + // not the child scope that triggered the request. + val resolving = resolutionContext?.apply { this.scope = owningScope } + ?: ResolutionContext(this, owningScope) resolving.enter(node) try { return when (node.definitionType) { diff --git a/stitch/src/commonMain/kotlin/com/harrytmthy/stitch/api/Module.kt b/stitch/src/commonMain/kotlin/com/harrytmthy/stitch/api/Module.kt index e264d28..204d474 100644 --- a/stitch/src/commonMain/kotlin/com/harrytmthy/stitch/api/Module.kt +++ b/stitch/src/commonMain/kotlin/com/harrytmthy/stitch/api/Module.kt @@ -40,7 +40,7 @@ import kotlin.reflect.KClass * @param forceEager When true, all singletons in this module are eagerly initialized on * [Stitch.register], regardless of individual `eager` flags. */ -class Module(private val forceEager: Boolean, onInit: Module.() -> Unit) { +class Module(private val forceEager: Boolean) { private val definitions = HashSet() @@ -50,10 +50,12 @@ class Module(private val forceEager: Boolean, onInit: Module.() -> Unit) { private val eagerNodes = ArrayList() - init { - onInit(this) - } - + /** + * Registers this module, making its bindings available for resolution. + * + * Equivalent to [Stitch.register] for a single module. Eager singletons are warmed up + * immediately after registration. + */ fun register() { for (node in definitions) { Registry.definitions[node.type]?.let { nodeByQualifier -> @@ -207,5 +209,5 @@ class Module(private val forceEager: Boolean, onInit: Module.() -> Unit) { * @param forceEager When true, all singletons defined in this module are eagerly initialized * on [Stitch.register]. */ -fun module(forceEager: Boolean = false, onInit: Module.() -> Unit): Module = - Module(forceEager, onInit) +inline fun module(forceEager: Boolean = false, crossinline onInit: Module.() -> Unit): Module = + Module(forceEager).also(onInit) diff --git a/stitch/src/commonMain/kotlin/com/harrytmthy/stitch/api/ResolutionContext.kt b/stitch/src/commonMain/kotlin/com/harrytmthy/stitch/api/ResolutionContext.kt index c583ce8..783ba28 100644 --- a/stitch/src/commonMain/kotlin/com/harrytmthy/stitch/api/ResolutionContext.kt +++ b/stitch/src/commonMain/kotlin/com/harrytmthy/stitch/api/ResolutionContext.kt @@ -30,7 +30,7 @@ import com.harrytmthy.stitch.internal.Node * * @throws com.harrytmthy.stitch.exception.CycleException if a dependency cycle is detected. */ -class ResolutionContext internal constructor(val component: Component, val scope: Scope?) { +class ResolutionContext internal constructor(val component: Component, var scope: Scope?) { private val stack = ArrayDeque() diff --git a/stitch/src/commonTest/kotlin/com/harrytmthy/stitch/StitchTest.kt b/stitch/src/commonTest/kotlin/com/harrytmthy/stitch/StitchTest.kt index 7f88ea4..0642884 100644 --- a/stitch/src/commonTest/kotlin/com/harrytmthy/stitch/StitchTest.kt +++ b/stitch/src/commonTest/kotlin/com/harrytmthy/stitch/StitchTest.kt @@ -913,4 +913,30 @@ class StitchTest { val fragmentScopeInstance = fragmentScope.createScope().apply { open() } assertFailsWith { fragmentScopeInstance.get() } } + + @Test + fun `get inside scoped binding definitions should resolve upstream`() { + val activityScope = scope("activity") + val fragmentScope = scope("fragment").dependsOn(activityScope) + module { + singleton { A(null) } + singleton { B(get()) } + scoped(activityScope) { A(get()) } + scoped(fragmentScope) { B(get()) } + scoped(fragmentScope) { C(get(), get()) } + }.register() + + val activityScopeInstance = activityScope.createScope().apply { open() } + val fragmentScopeInstance = fragmentScope.createScope(activityScopeInstance) + .apply { open() } + + val fragmentScopedA = fragmentScopeInstance.get() + val singletonScopedA = Stitch.get() + assertNotSame(fragmentScopedA, singletonScopedA) + + val fragmentScopedC = fragmentScopeInstance.get() + assertSame(fragmentScopedC.a, activityScopeInstance.get()) + assertSame(fragmentScopedC.b, fragmentScopeInstance.get()) + assertSame(activityScopeInstance.get(), Stitch.get()) + } } diff --git a/stitch/src/commonTest/kotlin/com/harrytmthy/stitch/TestFixtures.kt b/stitch/src/commonTest/kotlin/com/harrytmthy/stitch/TestFixtures.kt index 6f080c8..3e6e13f 100644 --- a/stitch/src/commonTest/kotlin/com/harrytmthy/stitch/TestFixtures.kt +++ b/stitch/src/commonTest/kotlin/com/harrytmthy/stitch/TestFixtures.kt @@ -30,9 +30,11 @@ class LoadUseCase(val dao: Dao) class NeedsMissing(val repo: Repo) // used to trigger MissingBindingException -class A(val b: B) +class A(val b: B?) class B(val a: A) // used to trigger CycleException +class C(val a: A, val b: B) + class UsesLazyFactory(val barLazy: Lazy) class Bar