diff --git a/CHANGELOG.md b/CHANGELOG.md index 5629af9ca..a728f2b18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ Changelog **Unreleased** -------------- +- **New**: Add WASM targets. + 0.20.0 ------ diff --git a/backstack/build.gradle.kts b/backstack/build.gradle.kts index 40ea083de..16dab5adb 100644 --- a/backstack/build.gradle.kts +++ b/backstack/build.gradle.kts @@ -1,6 +1,7 @@ // Copyright (C) 2024 Slack Technologies, LLC // SPDX-License-Identifier: Apache-2.0 import org.jetbrains.kotlin.gradle.targets.js.dsl.ExperimentalWasmDsl +import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask // Copyright (C) 2022 Slack Technologies, LLC // SPDX-License-Identifier: Apache-2.0 @@ -24,12 +25,23 @@ kotlin { moduleName = property("POM_ARTIFACT_ID").toString() browser() } - if (hasProperty("enableWasm")) { - @OptIn(ExperimentalWasmDsl::class) - wasmJs { - moduleName = property("POM_ARTIFACT_ID").toString() - browser() + @OptIn(ExperimentalWasmDsl::class) + wasmJs { + moduleName = property("POM_ARTIFACT_ID").toString() + browser { + testTask { + useKarma { + useChromeHeadless() + useConfigDirectory( + rootProject.projectDir + .resolve("internal-test-utils") + .resolve("karma.config.d") + .resolve("wasm") + ) + } + } } + binaries.executable() } // endregion @@ -70,13 +82,21 @@ kotlin { } } val jvmTest by getting { dependsOn(commonJvmTest) } + // We use a common folder instead of a common source set because there is no commonizer + // which exposes the browser APIs across these two targets. + jsMain { kotlin.srcDir("src/browserMain/kotlin") } + val wasmJsMain by getting { kotlin.srcDir("src/browserMain/kotlin") } } } -tasks.withType>().configureEach { +// adding it here to make sure skiko is unpacked and available in web tests +// https://github.com/JetBrains/compose-multiplatform/issues/4133 +compose.experimental { web.application {} } + +tasks.withType>().configureEach { compilerOptions { // Need to disable, due to 'duplicate library name' warning - // https://youtrack.jetbrains.com/issue/KT-51110 + // https://youtrack.jetbrains.com/issue/KT-64115 allWarningsAsErrors = false } } diff --git a/backstack/src/jsMain/kotlin/com/slack/circuit/backstack/BackStackRecordLocalProvider.js.kt b/backstack/src/browserMain/kotlin/com/slack/circuit/backstack/BackStackRecordLocalProvider.js.kt similarity index 100% rename from backstack/src/jsMain/kotlin/com/slack/circuit/backstack/BackStackRecordLocalProvider.js.kt rename to backstack/src/browserMain/kotlin/com/slack/circuit/backstack/BackStackRecordLocalProvider.js.kt diff --git a/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/SaveableStateRegistryBackStackRecordLocalProvider.kt b/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/SaveableStateRegistryBackStackRecordLocalProvider.kt index 7ca7695fb..b240d589c 100644 --- a/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/SaveableStateRegistryBackStackRecordLocalProvider.kt +++ b/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/SaveableStateRegistryBackStackRecordLocalProvider.kt @@ -55,17 +55,7 @@ public object SaveableStateRegistryBackStackRecordLocalProvider : @Composable override fun provideValues(): ImmutableList> { - remember { - object : RememberObserver { - override fun onForgotten() { - childRegistry.saveForContentLeavingComposition() - } - - override fun onRemembered() {} - - override fun onAbandoned() {} - } - } + remember { RememberObserverImpl(childRegistry) } return list } } @@ -73,6 +63,20 @@ public object SaveableStateRegistryBackStackRecordLocalProvider : } } +// Extracted to work around a WASM bug +// https://youtrack.jetbrains.com/issue/KT-66465#focus=Comments-27-9568825.0-0 +private class RememberObserverImpl( + private val childRegistry: BackStackRecordLocalSaveableStateRegistry +) : RememberObserver { + override fun onForgotten() { + childRegistry.saveForContentLeavingComposition() + } + + override fun onRemembered() {} + + override fun onAbandoned() {} +} + private class BackStackRecordLocalSaveableStateRegistry( // Note: restored is snapshot-backed because consumeRestored runs in composition // and must be rolled back if composition does not commit @@ -112,7 +116,7 @@ private class BackStackRecordLocalSaveableStateRegistry( synchronized(lock) { val list = valueProviders.remove(key) list?.remove(valueProvider) - if (list != null && list.isNotEmpty()) { + if (!list.isNullOrEmpty()) { // if there are other providers for this key return list // back to the map valueProviders[key] = list diff --git a/build.gradle.kts b/build.gradle.kts index d1637f9e3..f9a741fb0 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -24,8 +24,9 @@ import org.jetbrains.kotlin.gradle.plugin.AbstractKotlinMultiplatformPluginWrapp import org.jetbrains.kotlin.gradle.plugin.KotlinBasePlugin import org.jetbrains.kotlin.gradle.plugin.NATIVE_COMPILER_PLUGIN_CLASSPATH_CONFIGURATION_NAME import org.jetbrains.kotlin.gradle.plugin.PLUGIN_CLASSPATH_CONFIGURATION_NAME +import org.jetbrains.kotlin.gradle.targets.js.ir.DefaultIncrementalSyncTask +import org.jetbrains.kotlin.gradle.targets.js.testing.KotlinJsTest import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask -import org.jetbrains.kotlin.gradle.tasks.KotlinNativeCompile import wtf.emulator.EwExtension buildscript { dependencies { classpath(platform(libs.kotlin.plugins.bom)) } } @@ -457,13 +458,11 @@ subprojects { } } } - tasks.withType().configureEach { - notCompatibleWithConfigurationCache("https://youtrack.jetbrains.com/issue/KT-49933") - } - @Suppress("INVISIBLE_REFERENCE") - tasks.withType().configureEach { - @Suppress("INVISIBLE_MEMBER") - notCompatibleWithConfigurationCache("https://youtrack.jetbrains.com/issue/KT-49933") + + // Workaround for missing task dependency in WASM + val executableCompileSyncTasks = tasks.withType(DefaultIncrementalSyncTask::class.java) + tasks.withType(KotlinJsTest::class.java).configureEach { + mustRunAfter(executableCompileSyncTasks) } } diff --git a/circuit-foundation/build.gradle.kts b/circuit-foundation/build.gradle.kts index 0f57faf64..3d0a0e45d 100644 --- a/circuit-foundation/build.gradle.kts +++ b/circuit-foundation/build.gradle.kts @@ -23,12 +23,10 @@ kotlin { moduleName = property("POM_ARTIFACT_ID").toString() browser() } - if (hasProperty("enableWasm")) { - @OptIn(ExperimentalWasmDsl::class) - wasmJs { - moduleName = property("POM_ARTIFACT_ID").toString() - browser() - } + @OptIn(ExperimentalWasmDsl::class) + wasmJs { + moduleName = property("POM_ARTIFACT_ID").toString() + browser() } // endregion @@ -101,6 +99,10 @@ kotlin { implementation(libs.compose.ui.testing.junit) } } + // We use a common folder instead of a common source set because there is no commonizer + // which exposes the browser APIs across these two targets. + jsMain { kotlin.srcDir("src/browserMain/kotlin") } + val wasmJsMain by getting { kotlin.srcDir("src/browserMain/kotlin") } } } diff --git a/circuit-foundation/src/jsMain/kotlin/com/slack/circuit/foundation/internal/BackHandler.js.kt b/circuit-foundation/src/browserMain/kotlin/com/slack/circuit/foundation/internal/BackHandler.js.kt similarity index 100% rename from circuit-foundation/src/jsMain/kotlin/com/slack/circuit/foundation/internal/BackHandler.js.kt rename to circuit-foundation/src/browserMain/kotlin/com/slack/circuit/foundation/internal/BackHandler.js.kt diff --git a/circuit-overlay/build.gradle.kts b/circuit-overlay/build.gradle.kts index 3646d4cab..82982a46b 100644 --- a/circuit-overlay/build.gradle.kts +++ b/circuit-overlay/build.gradle.kts @@ -23,12 +23,10 @@ kotlin { moduleName = property("POM_ARTIFACT_ID").toString() browser() } - if (hasProperty("enableWasm")) { - @OptIn(ExperimentalWasmDsl::class) - wasmJs { - moduleName = property("POM_ARTIFACT_ID").toString() - browser() - } + @OptIn(ExperimentalWasmDsl::class) + wasmJs { + moduleName = property("POM_ARTIFACT_ID").toString() + browser() } // endregion diff --git a/circuit-retained/build.gradle.kts b/circuit-retained/build.gradle.kts index f61189379..e9055bd92 100644 --- a/circuit-retained/build.gradle.kts +++ b/circuit-retained/build.gradle.kts @@ -23,12 +23,10 @@ kotlin { moduleName = property("POM_ARTIFACT_ID").toString() browser() } - if (hasProperty("enableWasm")) { - @OptIn(ExperimentalWasmDsl::class) - wasmJs { - moduleName = property("POM_ARTIFACT_ID").toString() - browser() - } + @OptIn(ExperimentalWasmDsl::class) + wasmJs { + moduleName = property("POM_ARTIFACT_ID").toString() + browser() } // endregion @@ -80,6 +78,10 @@ kotlin { implementation(projects.circuitRetained) } } + // We use a common folder instead of a common source set because there is no commonizer + // which exposes the browser APIs across these two targets. + jsMain { kotlin.srcDir("src/browserMain/kotlin") } + val wasmJsMain by getting { kotlin.srcDir("src/browserMain/kotlin") } } targets.configureEach { diff --git a/circuit-retained/src/jsMain/kotlin/com/slack/circuit/retained/CanRetainChecker.js.kt b/circuit-retained/src/browserMain/kotlin/com/slack/circuit/retained/CanRetainChecker.js.kt similarity index 100% rename from circuit-retained/src/jsMain/kotlin/com/slack/circuit/retained/CanRetainChecker.js.kt rename to circuit-retained/src/browserMain/kotlin/com/slack/circuit/retained/CanRetainChecker.js.kt diff --git a/circuit-retained/src/jsMain/kotlin/com/slack/circuit/retained/Continuity.js.kt b/circuit-retained/src/browserMain/kotlin/com/slack/circuit/retained/Continuity.js.kt similarity index 100% rename from circuit-retained/src/jsMain/kotlin/com/slack/circuit/retained/Continuity.js.kt rename to circuit-retained/src/browserMain/kotlin/com/slack/circuit/retained/Continuity.js.kt diff --git a/circuit-retained/src/jsMain/kotlin/com/slack/circuit/retained/RetainedValueProvider.js.kt b/circuit-retained/src/browserMain/kotlin/com/slack/circuit/retained/RetainedValueProvider.js.kt similarity index 100% rename from circuit-retained/src/jsMain/kotlin/com/slack/circuit/retained/RetainedValueProvider.js.kt rename to circuit-retained/src/browserMain/kotlin/com/slack/circuit/retained/RetainedValueProvider.js.kt diff --git a/circuit-runtime-presenter/build.gradle.kts b/circuit-runtime-presenter/build.gradle.kts index e239c5088..73132b3e4 100644 --- a/circuit-runtime-presenter/build.gradle.kts +++ b/circuit-runtime-presenter/build.gradle.kts @@ -23,12 +23,10 @@ kotlin { moduleName = property("POM_ARTIFACT_ID").toString() browser() } - if (hasProperty("enableWasm")) { - @OptIn(ExperimentalWasmDsl::class) - wasmJs { - moduleName = property("POM_ARTIFACT_ID").toString() - browser() - } + @OptIn(ExperimentalWasmDsl::class) + wasmJs { + moduleName = property("POM_ARTIFACT_ID").toString() + browser() } // endregion diff --git a/circuit-runtime-screen/build.gradle.kts b/circuit-runtime-screen/build.gradle.kts index aa377669b..3f5047e77 100644 --- a/circuit-runtime-screen/build.gradle.kts +++ b/circuit-runtime-screen/build.gradle.kts @@ -23,18 +23,22 @@ kotlin { moduleName = property("POM_ARTIFACT_ID").toString() browser() } - if (hasProperty("enableWasm")) { - @OptIn(ExperimentalWasmDsl::class) - wasmJs { - moduleName = property("POM_ARTIFACT_ID").toString() - browser() - } + @OptIn(ExperimentalWasmDsl::class) + wasmJs { + moduleName = property("POM_ARTIFACT_ID").toString() + browser() } // endregion applyDefaultHierarchyTemplate() - sourceSets { commonMain { dependencies { api(libs.compose.runtime) } } } + sourceSets { + commonMain { dependencies { api(libs.compose.runtime) } } + // We use a common folder instead of a common source set because there is no commonizer + // which exposes the browser APIs across these two targets. + jsMain { kotlin.srcDir("src/browserMain/kotlin") } + val wasmJsMain by getting { kotlin.srcDir("src/browserMain/kotlin") } + } targets.configureEach { compilations.configureEach { diff --git a/circuit-runtime-screen/src/jsMain/kotlin/com/slack/circuit/runtime/screen/PopResult.js.kt b/circuit-runtime-screen/src/browserMain/kotlin/com/slack/circuit/runtime/screen/PopResult.js.kt similarity index 100% rename from circuit-runtime-screen/src/jsMain/kotlin/com/slack/circuit/runtime/screen/PopResult.js.kt rename to circuit-runtime-screen/src/browserMain/kotlin/com/slack/circuit/runtime/screen/PopResult.js.kt diff --git a/circuit-runtime-screen/src/jsMain/kotlin/com/slack/circuit/runtime/screen/Screen.js.kt b/circuit-runtime-screen/src/browserMain/kotlin/com/slack/circuit/runtime/screen/Screen.js.kt similarity index 100% rename from circuit-runtime-screen/src/jsMain/kotlin/com/slack/circuit/runtime/screen/Screen.js.kt rename to circuit-runtime-screen/src/browserMain/kotlin/com/slack/circuit/runtime/screen/Screen.js.kt diff --git a/circuit-runtime-ui/build.gradle.kts b/circuit-runtime-ui/build.gradle.kts index e8fad97a1..6f7cb30e4 100644 --- a/circuit-runtime-ui/build.gradle.kts +++ b/circuit-runtime-ui/build.gradle.kts @@ -23,12 +23,10 @@ kotlin { moduleName = property("POM_ARTIFACT_ID").toString() browser() } - if (hasProperty("enableWasm")) { - @OptIn(ExperimentalWasmDsl::class) - wasmJs { - moduleName = property("POM_ARTIFACT_ID").toString() - browser() - } + @OptIn(ExperimentalWasmDsl::class) + wasmJs { + moduleName = property("POM_ARTIFACT_ID").toString() + browser() } // endregion diff --git a/circuit-runtime/build.gradle.kts b/circuit-runtime/build.gradle.kts index 7f0311746..38ae11e41 100644 --- a/circuit-runtime/build.gradle.kts +++ b/circuit-runtime/build.gradle.kts @@ -23,12 +23,10 @@ kotlin { moduleName = property("POM_ARTIFACT_ID").toString() browser() } - if (hasProperty("enableWasm")) { - @OptIn(ExperimentalWasmDsl::class) - wasmJs { - moduleName = property("POM_ARTIFACT_ID").toString() - browser() - } + @OptIn(ExperimentalWasmDsl::class) + wasmJs { + moduleName = property("POM_ARTIFACT_ID").toString() + browser() } // endregion diff --git a/circuit-test/build.gradle.kts b/circuit-test/build.gradle.kts index 551e2ef3f..0dfb76416 100644 --- a/circuit-test/build.gradle.kts +++ b/circuit-test/build.gradle.kts @@ -23,12 +23,10 @@ kotlin { moduleName = property("POM_ARTIFACT_ID").toString() browser() } - if (hasProperty("enableWasm")) { - @OptIn(ExperimentalWasmDsl::class) - wasmJs { - moduleName = property("POM_ARTIFACT_ID").toString() - browser() - } + @OptIn(ExperimentalWasmDsl::class) + wasmJs { + moduleName = property("POM_ARTIFACT_ID").toString() + browser() } // endregion @@ -63,6 +61,10 @@ kotlin { } val jvmTest by getting { dependsOn(commonJvmTest) } val androidUnitTest by getting { dependsOn(commonJvmTest) } + // We use a common folder instead of a common source set because there is no commonizer + // which exposes the browser APIs across these two targets. + jsMain { kotlin.srcDir("src/browserMain/kotlin") } + val wasmJsMain by getting { kotlin.srcDir("src/browserMain/kotlin") } } } diff --git a/circuit-test/src/jsMain/kotlin/com/slack/circuit/test/TestEventSink.js.kt b/circuit-test/src/browserMain/kotlin/com/slack/circuit/test/TestEventSink.js.kt similarity index 100% rename from circuit-test/src/jsMain/kotlin/com/slack/circuit/test/TestEventSink.js.kt rename to circuit-test/src/browserMain/kotlin/com/slack/circuit/test/TestEventSink.js.kt diff --git a/circuitx/effects/build.gradle.kts b/circuitx/effects/build.gradle.kts index cf410dfbc..edd57d00f 100644 --- a/circuitx/effects/build.gradle.kts +++ b/circuitx/effects/build.gradle.kts @@ -24,12 +24,23 @@ kotlin { moduleName = property("POM_ARTIFACT_ID").toString() browser() } - if (hasProperty("enableWasm")) { - @OptIn(ExperimentalWasmDsl::class) - wasmJs { - moduleName = property("POM_ARTIFACT_ID").toString() - browser() + @OptIn(ExperimentalWasmDsl::class) + wasmJs { + moduleName = property("POM_ARTIFACT_ID").toString() + browser { + testTask { + useKarma { + useChromeHeadless() + useConfigDirectory( + rootProject.projectDir + .resolve("internal-test-utils") + .resolve("karma.config.d") + .resolve("wasm") + ) + } + } } + binaries.executable() } // endregion @@ -54,7 +65,7 @@ kotlin { } } val iosTest by getting { dependencies { dependsOn(commonTest) } } - val jsTest by getting { dependencies { dependsOn(commonTest) } } + val browserTest by creating { dependencies { dependsOn(commonTest) } } val jvmTest by getting { dependencies { dependsOn(commonTest) } } val androidUnitTest by getting { dependsOn(commonTest) @@ -65,6 +76,10 @@ kotlin { implementation(libs.androidx.compose.ui.testing.manifest) } } + // We use a common folder instead of a common source set because there is no commonizer + // which exposes the browser APIs across these two targets. + jsTest { kotlin.srcDir("src/browserTest/kotlin") } + val wasmJsTest by getting { kotlin.srcDir("src/browserTest/kotlin") } } targets.configureEach { compilations.configureEach { @@ -73,6 +88,10 @@ kotlin { } } +// adding it here to make sure skiko is unpacked and available in web tests +// https://github.com/JetBrains/compose-multiplatform/issues/4133 +compose.experimental { web.application {} } + android { namespace = "com.slack.circuitx.sideeffects" diff --git a/circuitx/effects/src/jsTest/kotlin/com/slack/circuitx/effects/ImpressionEffectTest.js.kt b/circuitx/effects/src/browserTest/kotlin/com/slack/circuitx/effects/ImpressionEffectTest.js.kt similarity index 100% rename from circuitx/effects/src/jsTest/kotlin/com/slack/circuitx/effects/ImpressionEffectTest.js.kt rename to circuitx/effects/src/browserTest/kotlin/com/slack/circuitx/effects/ImpressionEffectTest.js.kt diff --git a/circuitx/gesture-navigation/build.gradle.kts b/circuitx/gesture-navigation/build.gradle.kts index 37b863a87..cda2112cd 100644 --- a/circuitx/gesture-navigation/build.gradle.kts +++ b/circuitx/gesture-navigation/build.gradle.kts @@ -22,12 +22,10 @@ kotlin { moduleName = property("POM_ARTIFACT_ID").toString() browser() } - if (hasProperty("enableWasm")) { - @OptIn(ExperimentalWasmDsl::class) - wasmJs { - moduleName = property("POM_ARTIFACT_ID").toString() - browser() - } + @OptIn(ExperimentalWasmDsl::class) + wasmJs { + moduleName = property("POM_ARTIFACT_ID").toString() + browser() } // endregion @@ -61,6 +59,10 @@ kotlin { implementation(libs.androidx.compose.ui.testing.manifest) } } + // We use a common folder instead of a common source set because there is no commonizer + // which exposes the browser APIs across these two targets. + jsMain { kotlin.srcDir("src/browserMain/kotlin") } + val wasmJsMain by getting { kotlin.srcDir("src/browserMain/kotlin") } } } diff --git a/circuitx/gesture-navigation/src/jsMain/kotlin/com/slack/circuitx/gesturenavigation/GestureNavigationDecoration.kt b/circuitx/gesture-navigation/src/browserMain/kotlin/com/slack/circuitx/gesturenavigation/GestureNavigationDecoration.js.kt similarity index 100% rename from circuitx/gesture-navigation/src/jsMain/kotlin/com/slack/circuitx/gesturenavigation/GestureNavigationDecoration.kt rename to circuitx/gesture-navigation/src/browserMain/kotlin/com/slack/circuitx/gesturenavigation/GestureNavigationDecoration.js.kt diff --git a/circuitx/overlays/build.gradle.kts b/circuitx/overlays/build.gradle.kts index c569b1923..308424ea7 100644 --- a/circuitx/overlays/build.gradle.kts +++ b/circuitx/overlays/build.gradle.kts @@ -22,12 +22,10 @@ kotlin { moduleName = property("POM_ARTIFACT_ID").toString() browser() } - if (hasProperty("enableWasm")) { - @OptIn(ExperimentalWasmDsl::class) - wasmJs { - moduleName = property("POM_ARTIFACT_ID").toString() - browser() - } + @OptIn(ExperimentalWasmDsl::class) + wasmJs { + moduleName = property("POM_ARTIFACT_ID").toString() + browser() } // endregion @@ -50,6 +48,10 @@ kotlin { implementation(libs.androidx.compose.accompanist.systemUi) } } + // We use a common folder instead of a common source set because there is no commonizer + // which exposes the browser APIs across these two targets. + jsMain { kotlin.srcDir("src/browserMain/kotlin") } + val wasmJsMain by getting { kotlin.srcDir("src/browserMain/kotlin") } } } diff --git a/circuitx/overlays/src/jsMain/kotlin/com/slack/circuitx/overlays/FullScreenOverlay.js.kt b/circuitx/overlays/src/browserMain/kotlin/com/slack/circuitx/overlays/FullScreenOverlay.js.kt similarity index 100% rename from circuitx/overlays/src/jsMain/kotlin/com/slack/circuitx/overlays/FullScreenOverlay.js.kt rename to circuitx/overlays/src/browserMain/kotlin/com/slack/circuitx/overlays/FullScreenOverlay.js.kt diff --git a/internal-test-utils/build.gradle.kts b/internal-test-utils/build.gradle.kts index aebcad53f..e8a6f3d23 100644 --- a/internal-test-utils/build.gradle.kts +++ b/internal-test-utils/build.gradle.kts @@ -22,19 +22,17 @@ kotlin { moduleName = "internal-test-utils" browser() } - if (hasProperty("enableWasm")) { - @OptIn(ExperimentalWasmDsl::class) - wasmJs { - moduleName = property("POM_ARTIFACT_ID").toString() - browser() - } + @OptIn(ExperimentalWasmDsl::class) + wasmJs { + moduleName = "internal-test-utils" + browser() } // endregion applyDefaultHierarchyTemplate() sourceSets { - val commonMain by getting { + commonMain { dependencies { api(libs.compose.runtime) api(libs.compose.foundation) @@ -45,6 +43,10 @@ kotlin { api(libs.compose.ui) } } + // We use a common folder instead of a common source set because there is no commonizer + // which exposes the browser APIs across these two targets. + jsMain { kotlin.srcDir("src/browserMain/kotlin") } + val wasmJsMain by getting { kotlin.srcDir("src/browserMain/kotlin") } } targets.configureEach { diff --git a/internal-test-utils/karma.config.d/wasm/config.js b/internal-test-utils/karma.config.d/wasm/config.js new file mode 100644 index 000000000..22429e585 --- /dev/null +++ b/internal-test-utils/karma.config.d/wasm/config.js @@ -0,0 +1,55 @@ +// see https://kotlinlang.org/docs/js-project-setup.html#webpack-configuration-file +// This file provides karma.config.d configuration to run tests with k/wasm + +const path = require("path"); + +config.browserConsoleLogOptions.level = "debug"; + +const basePath = config.basePath; +const projectPath = path.resolve(basePath, "..", "..", "..", ".."); +const generatedAssetsPath = path.resolve(projectPath, "build", "karma-webpack-out") + +const debug = message => console.log(`[karma-config] ${message}`); + +debug(`karma basePath: ${basePath}`); +debug(`karma generatedAssetsPath: ${generatedAssetsPath}`); + +config.proxies["/"] = path.resolve(basePath, "kotlin"); + +config.files = [ + {pattern: path.resolve(generatedAssetsPath, "**/*"), included: false, served: true, watched: false}, + {pattern: path.resolve(basePath, "kotlin", "**/*.png"), included: false, served: true, watched: false}, + {pattern: path.resolve(basePath, "kotlin", "**/*.gif"), included: false, served: true, watched: false}, + {pattern: path.resolve(basePath, "kotlin", "**/*.ttf"), included: false, served: true, watched: false}, + {pattern: path.resolve(basePath, "kotlin", "**/*.txt"), included: false, served: true, watched: false}, + {pattern: path.resolve(basePath, "kotlin", "**/*.json"), included: false, served: true, watched: false}, + {pattern: path.resolve(basePath, "kotlin", "**/*.xml"), included: false, served: true, watched: false}, +].concat(config.files); + +function KarmaWebpackOutputFramework(config) { + // This controller is instantiated and set during the preprocessor phase. + const controller = config.__karmaWebpackController; + + // only if webpack has instantiated its controller + if (!controller) { + console.warn( + "Webpack has not instantiated controller yet.\n" + + "Check if you have enabled webpack preprocessor and framework before this framework" + ) + return + } + + config.files.push({ + pattern: `${controller.outputPath}/**/*`, + included: false, + served: true, + watched: false + }) +} + +const KarmaWebpackOutputPlugin = { + 'framework:webpack-output': ['factory', KarmaWebpackOutputFramework], +}; + +config.plugins.push(KarmaWebpackOutputPlugin); +config.frameworks.push("webpack-output"); \ No newline at end of file diff --git a/internal-test-utils/src/jsMain/kotlin/com/slack/circuit/internal/test/Parcelize.kt b/internal-test-utils/src/browserMain/kotlin/com/slack/circuit/internal/test/Parcelize.js.kt similarity index 100% rename from internal-test-utils/src/jsMain/kotlin/com/slack/circuit/internal/test/Parcelize.kt rename to internal-test-utils/src/browserMain/kotlin/com/slack/circuit/internal/test/Parcelize.js.kt diff --git a/internal-test-utils/src/jsMain/kotlin/com/slack/circuit/internal/test/TestContent.js.kt b/internal-test-utils/src/browserMain/kotlin/com/slack/circuit/internal/test/TestContent.js.kt similarity index 100% rename from internal-test-utils/src/jsMain/kotlin/com/slack/circuit/internal/test/TestContent.js.kt rename to internal-test-utils/src/browserMain/kotlin/com/slack/circuit/internal/test/TestContent.js.kt diff --git a/samples/counter/apps/README.md b/samples/counter/apps/README.md index 91f58dde9..c0060f8a9 100644 --- a/samples/counter/apps/README.md +++ b/samples/counter/apps/README.md @@ -6,10 +6,10 @@ This projects contains KMM apps for the multiplatform Counter sample. There are four apps: - Android (under `src/androidMain`) - Desktop (under `src/jvmMain`) -- Desktop (under `src/jsMain`) - iOS (under `iosApp`) +- WASM (under `src/wasmJsMain`) -The goal of these samples is to share presentation logic but _not_ UI. Android/JS/Desktop use Compose UI, iOS uses SwiftUI. +The goal of these samples is to share presentation logic but _not_ UI. Android/WASM/Desktop use Compose UI, iOS uses SwiftUI. ### Running the apps @@ -18,3 +18,5 @@ To run the Android app, open the project in Android Studio and run the app from To run the Desktop app, run the `main` function in `DesktopCounterCircuit` in your IDE. To run the iOS app, run the Counter iOS target in IntelliJ/Studio or open the Counter Xcode project and run the app from there. + +To run the WASM/JS app, run `./gradlew :samples:counter:apps:wasmJsBrowserRun --continuous` and it'll open automatically in your default browser. diff --git a/samples/counter/apps/build.gradle.kts b/samples/counter/apps/build.gradle.kts index 8d0d36c30..063273526 100644 --- a/samples/counter/apps/build.gradle.kts +++ b/samples/counter/apps/build.gradle.kts @@ -33,19 +33,12 @@ kotlin { jvm() iosX64() iosArm64() - js(IR) { + @OptIn(ExperimentalWasmDsl::class) + wasmJs { moduleName = "counterApp" browser { commonWebpackConfig { outputFileName = "counterApp.js" } } binaries.executable() } - if (hasProperty("enableWasm")) { - @OptIn(ExperimentalWasmDsl::class) - wasmJs { - moduleName = "counterApp" - browser { commonWebpackConfig { outputFileName = "counterApp.js" } } - binaries.executable() - } - } // endregion applyDefaultHierarchyTemplate() @@ -70,11 +63,10 @@ kotlin { implementation(libs.androidx.compose.accompanist.systemUi) } } - val jsMain by getting { + val wasmJsMain by getting { dependencies { @OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class) implementation(compose.components.resources) - implementation(compose.html.core) implementation(compose.ui) implementation(compose.runtime) } diff --git a/samples/counter/apps/src/androidMain/kotlin/com/slack/circuit/sample/counter/android/CounterActivity.kt b/samples/counter/apps/src/androidMain/kotlin/com/slack/circuit/sample/counter/android/CounterActivity.kt index 72bdc6e4f..2d16a471c 100644 --- a/samples/counter/apps/src/androidMain/kotlin/com/slack/circuit/sample/counter/android/CounterActivity.kt +++ b/samples/counter/apps/src/androidMain/kotlin/com/slack/circuit/sample/counter/android/CounterActivity.kt @@ -7,24 +7,13 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.ui.platform.LocalContext -import com.slack.circuit.foundation.Circuit -import com.slack.circuit.foundation.CircuitCompositionLocals -import com.slack.circuit.foundation.CircuitContent -import com.slack.circuit.sample.counter.CounterPresenterFactory -import com.slack.circuit.sample.counter.CounterUiFactory +import com.slack.circuit.sample.counter.CounterApp class CounterActivity : AppCompatActivity() { - private val circuit: Circuit = - Circuit.Builder() - .addPresenterFactory(CounterPresenterFactory()) - .addUiFactory(CounterUiFactory()) - .build() - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() @@ -36,9 +25,7 @@ class CounterActivity : AppCompatActivity() { } else { dynamicLightColorScheme(context) } - MaterialTheme(colorScheme = colorScheme) { - CircuitCompositionLocals(circuit) { CircuitContent(AndroidCounterScreen) } - } + CounterApp(screen = AndroidCounterScreen, colorScheme = colorScheme) } } } diff --git a/samples/counter/apps/src/jsMain/kotlin/com/slack/circuit/sample/counter/browser/main.kt b/samples/counter/apps/src/jsMain/kotlin/com/slack/circuit/sample/counter/browser/main.kt deleted file mode 100644 index 773fbf855..000000000 --- a/samples/counter/apps/src/jsMain/kotlin/com/slack/circuit/sample/counter/browser/main.kt +++ /dev/null @@ -1,55 +0,0 @@ -// Copyright (C) 2023 Slack Technologies, LLC -// SPDX-License-Identifier: Apache-2.0 -@file:Suppress("DEPRECATION_ERROR") - -package com.slack.circuit.sample.counter.browser - -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.input.InputMode -import androidx.compose.ui.input.InputModeManager -import androidx.compose.ui.platform.DefaultViewConfiguration -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalFontFamilyResolver -import androidx.compose.ui.platform.LocalInputModeManager -import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.platform.LocalViewConfiguration -import androidx.compose.ui.text.font.createFontFamilyResolver -import androidx.compose.ui.unit.Density -import androidx.compose.ui.unit.LayoutDirection -import com.slack.circuit.foundation.Circuit -import com.slack.circuit.foundation.CircuitCompositionLocals -import com.slack.circuit.foundation.CircuitContent -import com.slack.circuit.sample.counter.CounterPresenterFactory -import com.slack.circuit.sample.counter.CounterScreen -import com.slack.circuit.sample.counter.CounterUiFactory -import org.jetbrains.compose.web.renderComposable - -data object BrowserCounterScreen : CounterScreen - -fun main() { - val circuit: Circuit = - Circuit.Builder() - .addPresenterFactory(CounterPresenterFactory()) - .addUiFactory(CounterUiFactory()) - .build() - // https://github.com/JetBrains/compose-multiplatform/issues/2186 - val fontFamilyResolver = createFontFamilyResolver() - renderComposable(rootElementId = "root") { - CompositionLocalProvider( - LocalDensity provides Density(1.0f), - LocalLayoutDirection provides LayoutDirection.Ltr, - LocalViewConfiguration provides DefaultViewConfiguration(Density(1.0f)), - LocalInputModeManager provides InputModeManagerObject, - LocalFontFamilyResolver provides fontFamilyResolver, - ) { - CircuitCompositionLocals(circuit) { CircuitContent(BrowserCounterScreen) } - } - } -} - -private object InputModeManagerObject : InputModeManager { - override val inputMode = InputMode.Keyboard - - @ExperimentalComposeUiApi override fun requestInputMode(inputMode: InputMode) = false -} diff --git a/samples/counter/apps/src/jsMain/resources/index.html b/samples/counter/apps/src/jsMain/resources/index.html deleted file mode 100644 index a1584dd30..000000000 --- a/samples/counter/apps/src/jsMain/resources/index.html +++ /dev/null @@ -1,14 +0,0 @@ - - - - - Counter App - - - - -

Counter

-
- - - \ No newline at end of file diff --git a/samples/counter/apps/src/jvmMain/kotlin/com/slack/circuit/sample/counter/desktop/DesktopCounterCircuit.kt b/samples/counter/apps/src/jvmMain/kotlin/com/slack/circuit/sample/counter/desktop/DesktopCounterCircuit.kt index a2c8e8115..8caeb383e 100644 --- a/samples/counter/apps/src/jvmMain/kotlin/com/slack/circuit/sample/counter/desktop/DesktopCounterCircuit.kt +++ b/samples/counter/apps/src/jvmMain/kotlin/com/slack/circuit/sample/counter/desktop/DesktopCounterCircuit.kt @@ -35,7 +35,6 @@ import androidx.compose.ui.window.Window import androidx.compose.ui.window.WindowState import androidx.compose.ui.window.application import com.slack.circuit.backstack.rememberSaveableBackStack -import com.slack.circuit.foundation.Circuit import com.slack.circuit.foundation.CircuitCompositionLocals import com.slack.circuit.foundation.NavigableCircuitContent import com.slack.circuit.foundation.rememberCircuitNavigator @@ -43,10 +42,10 @@ import com.slack.circuit.runtime.CircuitContext import com.slack.circuit.runtime.screen.Screen import com.slack.circuit.runtime.ui.Ui import com.slack.circuit.runtime.ui.ui -import com.slack.circuit.sample.counter.CounterPresenterFactory import com.slack.circuit.sample.counter.CounterScreen import com.slack.circuit.sample.counter.PrimeScreen import com.slack.circuit.sample.counter.Remove +import com.slack.circuit.sample.counter.buildCircuit import kotlinx.collections.immutable.persistentListOf data object DesktopCounterScreen : CounterScreen @@ -153,20 +152,14 @@ fun main() = application { val initialBackStack = persistentListOf(DesktopCounterScreen) val backStack = rememberSaveableBackStack(initialBackStack) val navigator = rememberCircuitNavigator(backStack) { exitApplication() } - - val circuit: Circuit = - Circuit.Builder() - .addPresenterFactory(CounterPresenterFactory()) - .addUiFactory(CounterUiFactory()) - .build() + val circuit = remember { buildCircuit(uiFactory = CounterUiFactory()) } MaterialTheme { CircuitCompositionLocals(circuit) { NavigableCircuitContent( navigator = navigator, backStack = backStack, - modifier = - Modifier.backHandler(enabled = backStack.size > 1, onBack = { navigator.pop() }), + modifier = Modifier.backHandler(enabled = backStack.size > 1, onBack = navigator::pop), ) } } diff --git a/samples/counter/apps/src/wasmJsMain/kotlin/com/slack/circuit/sample/counter/browser/main.kt b/samples/counter/apps/src/wasmJsMain/kotlin/com/slack/circuit/sample/counter/browser/main.kt new file mode 100644 index 000000000..ac2a3ac20 --- /dev/null +++ b/samples/counter/apps/src/wasmJsMain/kotlin/com/slack/circuit/sample/counter/browser/main.kt @@ -0,0 +1,17 @@ +// Copyright (C) 2023 Slack Technologies, LLC +// SPDX-License-Identifier: Apache-2.0 +@file:Suppress("DEPRECATION_ERROR") + +package com.slack.circuit.sample.counter.browser + +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.window.CanvasBasedWindow +import com.slack.circuit.sample.counter.CounterApp +import com.slack.circuit.sample.counter.CounterScreen + +data object WasmCounterScreen : CounterScreen + +@OptIn(ExperimentalComposeUiApi::class) +fun main() { + CanvasBasedWindow { CounterApp(WasmCounterScreen) } +} diff --git a/samples/counter/apps/src/wasmJsMain/resources/index.html b/samples/counter/apps/src/wasmJsMain/resources/index.html new file mode 100644 index 000000000..47e923be7 --- /dev/null +++ b/samples/counter/apps/src/wasmJsMain/resources/index.html @@ -0,0 +1,11 @@ + + + + + Counter + + + + + + \ No newline at end of file diff --git a/samples/counter/build.gradle.kts b/samples/counter/build.gradle.kts index 0cd3fcda5..d273efdd1 100644 --- a/samples/counter/build.gradle.kts +++ b/samples/counter/build.gradle.kts @@ -21,12 +21,10 @@ kotlin { moduleName = "counterbrowser" browser() } - if (hasProperty("enableWasm")) { - @OptIn(ExperimentalWasmDsl::class) - wasmJs { - moduleName = "counterbrowser" - browser() - } + @OptIn(ExperimentalWasmDsl::class) + wasmJs { + moduleName = "counterbrowser" + browser() } listOf(iosX64(), iosArm64(), iosSimulatorArm64()).forEach { it.binaries.framework { baseName = "counter" } diff --git a/samples/counter/mosaic/src/commonMain/kotlin/com/slack/circuit/sample/counter/mosaic/MosaicCounter.kt b/samples/counter/mosaic/src/commonMain/kotlin/com/slack/circuit/sample/counter/mosaic/MosaicCounter.kt index 951f272e9..d54b94c12 100644 --- a/samples/counter/mosaic/src/commonMain/kotlin/com/slack/circuit/sample/counter/mosaic/MosaicCounter.kt +++ b/samples/counter/mosaic/src/commonMain/kotlin/com/slack/circuit/sample/counter/mosaic/MosaicCounter.kt @@ -6,20 +6,19 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import com.jakewharton.mosaic.MosaicScope import com.jakewharton.mosaic.runMosaic import com.jakewharton.mosaic.ui.Column import com.jakewharton.mosaic.ui.Text -import com.slack.circuit.foundation.Circuit -import com.slack.circuit.foundation.CircuitCompositionLocals -import com.slack.circuit.foundation.CircuitContent import com.slack.circuit.runtime.CircuitContext import com.slack.circuit.runtime.screen.Screen import com.slack.circuit.runtime.ui.Ui import com.slack.circuit.runtime.ui.ui -import com.slack.circuit.sample.counter.CounterPresenterFactory +import com.slack.circuit.sample.counter.CounterApp import com.slack.circuit.sample.counter.CounterScreen +import com.slack.circuit.sample.counter.buildCircuit import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.withContext import org.jline.terminal.TerminalBuilder @@ -29,7 +28,7 @@ data object MosaicCounterScreen : CounterScreen class CounterUiFactory : Ui.Factory { override fun create(screen: Screen, context: CircuitContext): Ui<*>? { return when (screen) { - MosaicCounterScreen -> counterUi() + is CounterScreen -> counterUi() else -> null } } @@ -86,12 +85,8 @@ internal suspend fun runCounterScreen(useCircuit: Boolean) = runMosaic { internal fun MosaicScope.runCircuitCounterScreen() { setContent { - val circuit = - Circuit.Builder() - .addPresenterFactory(CounterPresenterFactory()) - .addUiFactory(CounterUiFactory()) - .build() - CircuitCompositionLocals(circuit) { CircuitContent(MosaicCounterScreen) } + val circuit = remember { buildCircuit(uiFactory = CounterUiFactory()) } + CounterApp(screen = MosaicCounterScreen, circuit = circuit) } } diff --git a/samples/counter/src/commonMain/kotlin/com/slack/circuit/sample/counter/CounterApp.kt b/samples/counter/src/commonMain/kotlin/com/slack/circuit/sample/counter/CounterApp.kt new file mode 100644 index 000000000..23b70728b --- /dev/null +++ b/samples/counter/src/commonMain/kotlin/com/slack/circuit/sample/counter/CounterApp.kt @@ -0,0 +1,30 @@ +// Copyright (C) 2024 Slack Technologies, LLC +// SPDX-License-Identifier: Apache-2.0 +package com.slack.circuit.sample.counter + +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import com.slack.circuit.foundation.Circuit +import com.slack.circuit.foundation.CircuitCompositionLocals +import com.slack.circuit.foundation.CircuitContent +import com.slack.circuit.runtime.presenter.Presenter +import com.slack.circuit.runtime.ui.Ui + +@Composable +fun CounterApp( + screen: CounterScreen, + circuit: Circuit = buildCircuit(), + colorScheme: ColorScheme = MaterialTheme.colorScheme, +) { + MaterialTheme(colorScheme = colorScheme) { + CircuitCompositionLocals(circuit) { CircuitContent(screen) } + } +} + +fun buildCircuit( + presenterFactory: Presenter.Factory = CounterPresenterFactory(), + uiFactory: Ui.Factory = CounterUiFactory(), +): Circuit { + return Circuit.Builder().addPresenterFactory(presenterFactory).addUiFactory(uiFactory).build() +}