Skip to content

Commit

Permalink
JS Support (#801)
Browse files Browse the repository at this point in the history
Resolves #649

This introduces JS support to the core Circuit artifacts (via
`nodejs()`) and also adds a browser app to the `counter` sample.

Some things along the way
- Adding an expect/actual indirection for the `TestEventSink` supertype
to support JS as JS compilation doesn't allow implementation of function
types.
- Consolidating around recommending `Screen` object types be `data
object` for better toString()-ability. We can't use
`Screen::class.simpleName` in common code due to JS reflection
limitations, so using this as an opportunity to lean into `data object`
more since the lack of a toString() on objects was initially why we
didn't do that.
- Extract the Counter UI in the counter sample to `common`. Will
eventually port Desktop to this too, but saving that for a later PR.
- Moved `SwiftSupport` in counter sample to iOS source set.



https://github.com/slackhq/circuit/assets/1361086/a47ef192-c70e-4958-a989-790c4010561f

---------

Co-authored-by: Jake Wharton <[email protected]>
  • Loading branch information
ZacSweers and JakeWharton authored Aug 17, 2023
1 parent cef9eb0 commit caef35a
Show file tree
Hide file tree
Showing 48 changed files with 3,137 additions and 108 deletions.
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
**/snapshots/**/*.png filter=lfs diff=lfs merge=lfs -text
**/generated/baselineProfiles/** linguist-generated=true
**/yarn.lock linguist-generated=true
4 changes: 4 additions & 0 deletions backstack/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ kotlin {
jvm()
ios()
iosSimulatorArm64()
js {
moduleName = property("POM_ARTIFACT_ID").toString()
nodejs()
}
// endregion

sourceSets {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.slack.circuit.backstack

import androidx.compose.runtime.ProvidableCompositionLocal
import androidx.compose.runtime.compositionLocalOf

internal actual val LocalBackStackRecordLocalProviders:
ProvidableCompositionLocal<List<BackStackRecordLocalProvider<BackStack.Record>>>
get() = compositionLocalOf { emptyList() }
4 changes: 4 additions & 0 deletions circuit-foundation/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ kotlin {
jvm()
ios()
iosSimulatorArm64()
js {
moduleName = property("POM_ARTIFACT_ID").toString()
nodejs()
}
// endregion

sourceSets {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner

@Parcelize private object TestScreen : Screen
@Parcelize private data object TestScreen : Screen

@Parcelize private object TestScreen2 : Screen
@Parcelize private data object TestScreen2 : Screen

@Parcelize private object TestScreen3 : Screen
@Parcelize private data object TestScreen3 : Screen

@RunWith(RobolectricTestRunner::class)
class NavigatorTest {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ public class Circuit private constructor(builder: Builder) {
private val UnavailableContent: @Composable (screen: Screen, modifier: Modifier) -> Unit =
{ screen, modifier ->
BasicText(
"Route not available: ${screen::class.qualifiedName}",
"Route not available: $screen",
modifier.background(Color.Red),
style = TextStyle(color = Color.Yellow)
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// Copyright (C) 2023 Slack Technologies, LLC
// SPDX-License-Identifier: Apache-2.0
package com.slack.circuit.foundation.internal

import androidx.compose.runtime.Composable

@Composable
public actual fun BackHandler(enabled: Boolean, onBack: () -> Unit) {
// No-op
}
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ class EventListenerTest {
}
}

private object TestScreen : Screen
private data object TestScreen : Screen

private data class StringState(val value: String) : CircuitUiState

Expand Down
4 changes: 4 additions & 0 deletions circuit-overlay/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ kotlin {
jvm()
ios()
iosSimulatorArm64()
js {
moduleName = property("POM_ARTIFACT_ID").toString()
nodejs()
}
// endregion

sourceSets {
Expand Down
4 changes: 4 additions & 0 deletions circuit-retained/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ kotlin {
jvm()
ios()
iosSimulatorArm64()
js {
moduleName = property("POM_ARTIFACT_ID").toString()
nodejs()
}
// endregion

sourceSets {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Copyright (C) 2023 Slack Technologies, LLC
// SPDX-License-Identifier: Apache-2.0
package com.slack.circuit.retained

import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember

/** Checks whether or not we can retain in the current composable context. */
@Composable
internal actual fun rememberCanRetainChecker(): () -> Boolean {
return remember { { false } }
}
4 changes: 4 additions & 0 deletions circuit-runtime-presenter/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ kotlin {
jvm()
ios()
iosSimulatorArm64()
js {
moduleName = property("POM_ARTIFACT_ID").toString()
nodejs()
}
// endregion

sourceSets {
Expand Down
4 changes: 4 additions & 0 deletions circuit-runtime-ui/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ kotlin {
jvm()
ios()
iosSimulatorArm64()
js {
moduleName = property("POM_ARTIFACT_ID").toString()
nodejs()
}
// endregion

sourceSets {
Expand Down
4 changes: 4 additions & 0 deletions circuit-runtime/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ kotlin {
jvm()
ios()
iosSimulatorArm64()
js {
moduleName = property("POM_ARTIFACT_ID").toString()
nodejs()
}
// endregion

sourceSets {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// Copyright (C) 2023 Slack Technologies, LLC
// SPDX-License-Identifier: Apache-2.0
package com.slack.circuit.runtime

import androidx.compose.runtime.Immutable

@Immutable public actual interface Screen
6 changes: 6 additions & 0 deletions circuit-test/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ kotlin {
jvm()
ios()
iosSimulatorArm64()
js {
moduleName = property("POM_ARTIFACT_ID").toString()
nodejs()
}
// endregion

sourceSets {
Expand All @@ -37,6 +41,8 @@ kotlin {
}
}
with(getByName("androidUnitTest")) { dependsOn(jvmTest) }
val iosMain by getting
val iosSimulatorArm64Main by getting { dependsOn(iosMain) }
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Copyright (C) 2023 Slack Technologies, LLC
// SPDX-License-Identifier: Apache-2.0
package com.slack.circuit.test

public actual sealed interface BaseTestEventSinkType<UiEvent> : (UiEvent) -> Unit
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ class FakeNavigatorTest {
}
}

@Parcelize private object TestScreen1 : Screen
@Parcelize private data object TestScreen1 : Screen

@Parcelize private object TestScreen2 : Screen
@Parcelize private data object TestScreen2 : Screen

@Parcelize private object TestScreen3 : Screen
@Parcelize private data object TestScreen3 : Screen
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@ import kotlin.time.Duration
import kotlin.time.DurationUnit
import kotlin.time.toDuration

/**
* Base `expect` type for [TestEventSink]. This layer of indirection is necessary because Kotlin/JS
* does not allow extension of function types. To work around it, we make this interface extend
* `(UiEvent) -> Unit` on all platforms except JS, and then expose an `asEventSinkFunction()`
* extension function on [TestEventSink] in JS that returns a function wrapper around it.
*
* This type is _not_ intended to be used directly or implemented by consumers of this library.
*/
public expect sealed interface BaseTestEventSinkType<UiEvent> {
public operator fun invoke(event: UiEvent)
}

/**
* A test event sink that records events from a Circuit UI and allows making assertions about them.
*
Expand All @@ -15,15 +27,15 @@ import kotlin.time.toDuration
*
* @see CircuitUiEvent
*/
public class TestEventSink<UiEvent : CircuitUiEvent> : (UiEvent) -> Unit {
public class TestEventSink<UiEvent : CircuitUiEvent> : BaseTestEventSinkType<UiEvent> {
private val receivedEvents = mutableListOf<UiEvent>()

/**
* Sends the specified [event] to this TestEventSink.
*
* @param event the [UiEvent] being added to the sink
*/
public override fun invoke(event: UiEvent) {
public override operator fun invoke(event: UiEvent) {
receivedEvents.add(event)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Copyright (C) 2023 Slack Technologies, LLC
// SPDX-License-Identifier: Apache-2.0
package com.slack.circuit.test

public actual sealed interface BaseTestEventSinkType<UiEvent> : (UiEvent) -> Unit
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// Copyright (C) 2023 Slack Technologies, LLC
// SPDX-License-Identifier: Apache-2.0
package com.slack.circuit.test

import com.slack.circuit.runtime.CircuitUiEvent

public actual sealed interface BaseTestEventSinkType<UiEvent> {
public actual operator fun invoke(event: UiEvent)
}

/**
* A helper function for creating a function wrapper around this [TestEventSink] for use in tests as
* an event sink function. We have to do this workaround in JS due to Kotlin/JS not allowing
* function type extension directly.
*/
public fun <UiEvent : CircuitUiEvent> TestEventSink<UiEvent>.asEventSinkFunction():
(UiEvent) -> Unit = this::invoke
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Copyright (C) 2023 Slack Technologies, LLC
// SPDX-License-Identifier: Apache-2.0
package com.slack.circuit.test

public actual sealed interface BaseTestEventSinkType<UiEvent> : (UiEvent) -> Unit
4 changes: 4 additions & 0 deletions circuitx/overlays/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ kotlin {
jvm()
ios()
iosSimulatorArm64()
js {
moduleName = property("POM_ARTIFACT_ID").toString()
nodejs()
}
// endregion

sourceSets {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// Copyright (C) 2023 Slack Technologies, LLC
// SPDX-License-Identifier: Apache-2.0
package com.slack.circuitx.overlays

import com.slack.circuit.overlay.OverlayHost
import com.slack.circuit.runtime.Screen

public actual suspend fun OverlayHost.showFullScreenOverlay(screen: Screen) {
show(FullScreenOverlay(screen))
}
10 changes: 5 additions & 5 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,14 @@ There’s some glue code missing from this example that's covered in the [Code G

```kotlin
@Parcelize
object CounterScreen : Screen {
data object CounterScreen : Screen {
data class CounterState(
val count: Int,
val eventSink: (CounterEvent) -> Unit,
) : CircuitUiState
sealed interface CounterEvent : CircuitUiEvent {
object Increment : CounterEvent
object Decrement : CounterEvent
data object Increment : CounterEvent
data object Decrement : CounterEvent
}
}

Expand All @@ -63,8 +63,8 @@ fun CounterPresenter(): CounterState {

return CounterState(count) { event ->
when (event) {
is CounterEvent.Increment -> count++
is CounterEvent.Decrement -> count--
CounterEvent.Increment -> count++
CounterEvent.Decrement -> count--
}
}
}
Expand Down
7 changes: 5 additions & 2 deletions docs/screen.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,13 @@ The core `Screen` interface is this:
interface Screen : Parcelable
```

These types are `Parcelable` for saveability in our backstack and easy deeplinking. A `Screen` can
be a simple marker object type or a data object with information to pass on.
These types are `Parcelable` on Android for saveability in our backstack and easy deeplinking. A
`Screen` can be a simple marker `data object` or a `data class` with information to pass on.

```kotlin
@Parcelize
data object HomeScreen : Screen

@Parcelize
data class AddFavoritesScreen(val externalId: UUID) : Screen
```
Expand Down
6 changes: 3 additions & 3 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,10 @@ Testing a Circuit Presenter and UI is a breeze! Consider the following example:
data class Favorite(id: Long, ...)

@Parcelable
object FavoritesScreen : Screen {
data object FavoritesScreen : Screen {
sealed interface State : CircuitUiState {
object Loading : State
object NoFavorites : State
data object Loading : State
data object NoFavorites : State
data class Results(
val list: List<Favorite>,
val eventSink: (Event) -> Unit
Expand Down
2 changes: 2 additions & 0 deletions gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ kotlin.mpp.androidGradlePluginCompatibility.nowarn=true

# Enable for Compose iOS
org.jetbrains.compose.experimental.uikit.enabled=true
# Enable for Compose Web
org.jetbrains.compose.experimental.jscanvas.enabled=true

# New Kotlin IC flags
kotlin.compiler.suppressExperimentalICOptimizationsWarning=true
Expand Down
Loading

0 comments on commit caef35a

Please sign in to comment.