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
1 change: 0 additions & 1 deletion .buildscript/upload_archives.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ GRADLE_ARGS="-P$TARGET"
./gradlew :formula:build $GRADLE_ARGS
./gradlew :formula-android:build $GRADLE_ARGS
./gradlew :formula-test:build $GRADLE_ARGS
./gradlew :formula-android-compose:build $GRADLE_ARGS
./gradlew :formula-lint:build $GRADLE_ARGS
./gradlew :formula-rxjava3:build $GRADLE_ARGS

Expand Down
3 changes: 1 addition & 2 deletions .github/workflows/build-workflow.yml
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,6 @@ jobs:
run: |
./gradlew :formula:build
./gradlew :formula-android:build
./gradlew :formula-test:build
./gradlew :formula-android-compose:build
./gradlew :formula-test:build
./gradlew :formula-lint:build
./gradlew publishToMavenLocal --no-parallel --no-daemon
3 changes: 0 additions & 3 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,6 @@ jobs:
- name: Build formula-test module
run: ./gradlew :formula-test:build $GRADLE_ARGS

- name: Build formula-android-compose module
run: ./gradlew :formula-android-compose:build $GRADLE_ARGS

- name: Build formula-lint module
run: ./gradlew :formula-lint:build $GRADLE_ARGS

Expand Down
3 changes: 1 addition & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@
## Project Structure

- `formula/` - Core framework (state management, actions, formulas)
- `formula-android/` - Android integration (fragments, activities, views)
- `formula-android-compose/` - Jetpack Compose integration
- `formula-android/` - Android integration (fragments, activities, views, Jetpack Compose)
- `formula-android-tests/` - Android tests
- `formula-test/` - Testing utilities and test formulas
- `formula-rxjava3/` - RxJava3 interop
Expand Down
13 changes: 5 additions & 8 deletions docs/Formula-Android.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,11 @@ class CounterFeatureFactory : FeatureFactory<Any, CounterKey>() {
}
}

// View factory which uses XML layout resource.
class CounterViewFactory : LayoutViewFactory<CounterOutput>(R.layout.counter) {
override fun ViewInstance.create(): FeatureView<CounterOutput> {
// We use [ViewInstance.view] to access the inflated view
val counterView = CounterRenderView(view)

// We create a [FeatureView] by passing a [RenderView]
return featureView(counterView)
// View factory that renders the output with Jetpack Compose.
class CounterViewFactory : ComposeViewFactory<CounterOutput>() {
@Composable
override fun Content(model: CounterOutput) {
CounterScreen(model)
}
}
```
Expand Down
1 change: 0 additions & 1 deletion formula-android-compose/.gitignore

This file was deleted.

38 changes: 0 additions & 38 deletions formula-android-compose/build.gradle.kts

This file was deleted.

5 changes: 0 additions & 5 deletions formula-android-compose/gradle.properties

This file was deleted.

1 change: 0 additions & 1 deletion formula-android-compose/src/main/AndroidManifest.xml

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -324,16 +324,6 @@ class FormulaFragmentTest {
assertVisibleContract(TestKey())
}

@Test fun `notify fragment environment if setOutput throws an error`() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Not valid test anymore?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Yes - TestViewFactory was extending LayoutViewFactory, which propagated view binding exceptions to RouteEnvironment.onScreenError. With ComposeViewFactory, rendering happens within compose recomposition, so we need to think of an alternative mechanism to catch/report rendering errors there.

More in this comment:
#511 (comment)

val key = TestKeyWithId(1)
navigateToTaskDetail(id = key.id)

sendStateUpdate(key, "crash")
assertThat(renderCalls).isNotEmpty()

assertThat(errors).hasSize(1)
}

@Test
fun toStringContainsTagAndKey() {
val fragment = FormulaFragment.newInstance(TestKey())
Expand Down Expand Up @@ -380,6 +370,12 @@ class FormulaFragmentTest {
private fun sendStateUpdate(contract: RouteKey, update: Any) {
val flow = getOrCreateFlow(contract)
flow.tryEmit(update)
// Drain the Compose recomposer so assertions observe a settled state.
// Skipped when called from a background thread; in that case the caller
// is responsible for idling the main looper after synchronization.
if (Looper.myLooper() == Looper.getMainLooper()) {
Shadows.shadowOf(Looper.getMainLooper()).idle()
}
}

private fun stateProvider(contract: RouteKey): (CoroutineScope) -> StateFlow<Any> = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ class TestFeatureFactory<Key : RouteKey>(
) : FeatureFactory<Unit, Key>() {
override fun Params.initialize(): Feature {
return Feature(
viewFactory = TestViewFactory { _, value ->
viewFactory = TestViewFactory { value ->
render(key, value)
}
) {
Expand Down
11 changes: 9 additions & 2 deletions formula-android/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import org.jetbrains.dokka.gradle.DokkaTask

plugins {
id("com.android.library")
id("kotlin-android")
Expand All @@ -14,6 +12,14 @@ apply {
android {
namespace = "com.instacart.formula.android"

buildFeatures {
compose = true
}

composeOptions {
kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get()
}

testOptions {
unitTests.isReturnDefaultValues = true
unitTests.isIncludeAndroidResources = true
Expand All @@ -29,6 +35,7 @@ dependencies {
implementation(libs.androidx.annotation)
implementation(libs.androidx.appcompat)
implementation(libs.lifecycle.runtime.ktx)
api(libs.compose.ui)

testImplementation(libs.androidx.test.rules)
testImplementation(libs.androidx.test.runner)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.instacart.formula.android

import androidx.compose.runtime.Composable

/**
* [ViewFactory] subtype for Compose-rendered routes. Subclasses implement
* [Content] to render the route's render model, and may optionally override
* [initialModel] to supply a model rendered before the first state emission.
*
* ```
* class MyViewFactory : ComposeViewFactory<MyRenderModel>() {
* @Composable
* override fun Content(model: MyRenderModel) {
* MyScreen(model)
* }
* }
* ```
*/
abstract class ComposeViewFactory<RenderModel : Any> : ViewFactory<RenderModel> {
Comment thread
Laimiux marked this conversation as resolved.

@Composable
abstract fun Content(model: RenderModel)

/** Optional initial model rendered before the first state emission. Defaults to null. */
open fun initialModel(): RenderModel? = null
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@ package com.instacart.formula.android
* // Note: we could create our own internal dagger component here using the dependencies.
* val formula = TaskListFormula(dependencies.taskRepo())
* return Feature(
* viewFactory = ViewFactory.fromLayout(R.layout.task_list) { view ->
* val renderView = TaskListRenderView(view)
* featureView(renderView)
* }
* viewFactory = object : ComposeViewFactory<TaskListRenderModel>() {
* @Composable
* override fun Content(model: TaskListRenderModel) {
* TaskListScreen(model)
* }
* },
* ) {
* formula.runAsStateFlow(it, dependencies.taskListInput())
* }
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.fragment.app.Fragment
import com.instacart.formula.android.internal.getOrSetArguments
import java.lang.Exception
Expand All @@ -22,7 +26,7 @@ class FormulaFragment : Fragment() {
}

private val key: RouteKey by lazy(LazyThreadSafetyMode.NONE) {
requireArguments().getParcelable<RouteKey>(ARG_CONTRACT)!!
requireArguments().getParcelable(ARG_CONTRACT)!!
}

private val formulaRouteId: RouteId<*> by lazy {
Expand All @@ -37,47 +41,34 @@ class FormulaFragment : Fragment() {
private val routeDelegate: RouteEnvironment.RouteDelegate
get() = environment.routeDelegate

private var featureView: FeatureView<Any>? = null
private var output: Any? = null
private val outputState: MutableState<Any?> = mutableStateOf(null)

override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val viewFactory = navigationStore.getViewFactory(formulaRouteId) ?: run {
// No view factory, no view
return null
val viewFactory = navigationStore.getViewFactory(formulaRouteId) ?: return null
val initial: Any? = when (viewFactory) {
is ComposeViewFactory -> viewFactory.initialModel()
}
return ComposeView(requireContext()).apply {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This changes the framework's error-handling contract for render failures. routeDelegate.setOutput(...) is still wrapped in try/catch, but the actual UI work now happens later inside ComposeView.setContent { ... }, so exceptions thrown during composition/recomposition will bypass RouteEnvironment.onScreenError. The updated test suite explicitly expects that behavior, which means apps that currently rely on onScreenError for screen-level crash reporting or recovery lose it silently. Was that intended? If not, we need a way to preserve the old reporting path for render-time failures before landing this refactor.

@alexanderbezverhni alexanderbezverhni May 19, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

This actually changes nothing for the upstream app, given it was not using LayoutViewFactory. RouteEnvironment.onScreenError was meaningful in LayoutViewFactory since synchronous and imperative android.view.View setup could throw:

  • null findViewById
  • NPE on a missing field
  • adapter blowing up

ComposeViewFactory, on other hand, both before and after the changes, is just changing a MutableState variable, while the actual rendering happens later, inside Compose recomposition, outside
try/catch.

So onScreenError no longer catches the kind of error it was originally designed for. We should think of an alternative approach here, which is out of scope for this PR.

// Based-on: https://developer.android.com/develop/ui/compose/migrate/interoperability-apis/compose-in-views#compose-in-fragments
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
val output = outputState.value ?: initial
if (output != null) {
routeDelegate.Content(formulaRouteId, viewFactory, output)
}
}
Comment on lines +52 to +59

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Moving androidx.compose.ui.platform.ComposeView creation from ComposeViewFactory directly to FormulaFragment.

Alternative navigation host (Compose Nav 3) doesn't need ComposeView to render the content.

}
val params = ViewFactory.Params(
context = requireContext(),
inflater = inflater,
container = container,
)

val featureView = environment.routeDelegate.createView(
routeId = formulaRouteId,
viewFactory = viewFactory,
params = params,
)
this.featureView = featureView
return featureView.view
}

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
tryToSetState()
}

override fun onDestroyView() {
super.onDestroyView()
featureView = null
}

fun setState(state: Any) {
output = state
tryToSetState()
try {
routeDelegate.setOutput(formulaRouteId, state) { outputState.value = it }
} catch (exception: Exception) {
environment.onScreenError(key, exception)
}
}

fun currentState(): Any? {
return output
}
fun currentState(): Any? = outputState.value

fun getRouteKey(): RouteKey {
return key
Expand All @@ -86,15 +77,4 @@ class FormulaFragment : Fragment() {
override fun toString(): String {
return "${key.tag} -> $key"
}

private fun tryToSetState() {
val output = output ?: return
val view = featureView ?: return

try {
routeDelegate.setOutput(formulaRouteId, output, view.setOutput)
} catch (exception: Exception) {
environment.onScreenError(key, exception)
}
}
}
Loading
Loading