Compose effect APIs: LaunchedEffect / DisposableEffect / SideEffect#128
Conversation
There was a problem hiding this comment.
Pull request overview
This PR implements Compose's three side-effect entry points (SideEffect, DisposableEffect, LaunchedEffect) as both static methods on Compose and tree-syntax ComposableNode wrappers, closing #57 and delivering Phase 4 of #66 (#102). Since Xamarin.AndroidX.Compose.Runtime.Android fully binds EffectsKt, the only raw JNI needed is for kotlin.ResultKt.createFailure (stripped due to @JvmInline value class mangling). The LaunchedEffect bridge is the most complex piece — it maps a C# Func<CancellationToken, Task> onto Kotlin's suspend (CoroutineScope) -> Unit coroutine protocol, with an Interlocked.CompareExchange gate preventing double-resume races and a JobCompletionHandler JCW wiring Kotlin Job cancellation into the managed CancellationTokenSource.
Changes:
- Adds static
Compose.SideEffect,Compose.DisposableEffect(1/2/3-key), andCompose.LaunchedEffect(1/2/3-key) entry points plus matchingComposableNodewrappers for collection-initializer tree syntax. - Introduces
LaunchedEffectBody,DisposableEffectBody,JobCompletionHandlerJCWs andEffectsBridges.cs(KotlinResultFailure,BoxKey) to bridge the coroutine/suspend protocol and box effect keys for Javaequalscomparison. - Adds an "Effects" tab to the sample app demonstrating all three APIs with a ticking counter, disposal counter, and logcat output.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
src/ComposeNet.Compose/Compose.cs |
Adds SideEffect, DisposableEffect (×3), LaunchedEffect (×3) static entry points using ComposeContext.Current and bound EffectsKt APIs. |
src/ComposeNet.Compose/EffectsBridges.cs |
Partial ComposeBridges extension with KotlinResultFailure (raw JNI), BoxKey/BoxKeyArray (key boxing), and unused KotlinUnit property. |
src/ComposeNet.Compose/LaunchedEffectBody.cs |
JCW implementing IFunction2 for the suspend lambda; bridges C# Task ↔ Kotlin continuation with ResumeOnceGate and ResumeContext. |
src/ComposeNet.Compose/JobCompletionHandler.cs |
JCW implementing IFunction1 registered on the coroutine Job to cancel the C# CancellationTokenSource. |
src/ComposeNet.Compose/DisposableEffectBody.cs |
JCW implementing IFunction1 for the disposable-effect body; wraps the onDispose Action in ComposableLambda0. |
src/ComposeNet.Compose/SideEffect.cs |
Tree-syntax ComposableNode wrapper delegating to Compose.SideEffect. |
src/ComposeNet.Compose/LaunchedEffect.cs |
Tree-syntax ComposableNode wrapper with 1/2/3-key constructor overloads. |
src/ComposeNet.Compose/DisposableEffect.cs |
Tree-syntax ComposableNode wrapper with 1/2/3-key constructor overloads. |
src/ComposeNet.Compose/PublicAPI.Unshipped.txt |
Registers all new public types, constructors, and static methods. |
src/ComposeNet.Sample/MainActivity.cs |
Adds "Effects" tab demonstrating LaunchedEffect tick loop, DisposableEffect cleanup counter, and SideEffect logcat output. |
Two issues flagged by Copilot reviewer on PR #128: 1. EffectsBridges.cs header claimed it caches `IntrinsicsKt.COROUTINE_SUSPENDED` for `LaunchedEffectBody.Invoke`, but no such caching exists here — `LaunchedEffectBody.Invoke` returns `IntrinsicsKt.COROUTINE_SUSPENDED` directly through the binding. (The caching the reviewer is thinking of lives in `SuspendBridge.cs` and is for raw-handle sentinel comparison.) Drop the bullet from the header. 2. The `KotlinUnit` property was unused — `LaunchedEffectBody.ResumeContext.Resume` uses `global::Kotlin.Unit.Instance!` directly. The doc also claimed it was "Cached" but it was a computed `=>` property that re-evaluated every call. Remove. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Implements issue #57 / phase 4 of #66 (#102): bind Compose's three side-effect entry points onto the C# facade. Public surface (in `Compose` static class + matching `ComposableNode` subclasses for tree syntax): - `Compose.SideEffect(Action)` / `new SideEffect(Action)` — fire-and-forget callback after every successful recomposition. - `Compose.DisposableEffect(key1[, key2[, key3]], scope => onDispose)` / `new DisposableEffect(key, ...)` — setup + cleanup tied to key changes / leaving composition. - `Compose.LaunchedEffect(key1[, key2[, key3]], async ct => Task)` / `new LaunchedEffect(key, body)` — async work tied to the composition's coroutine scope. The C# `CancellationToken` is signalled when Compose cancels the underlying Kotlin Job. `Xamarin.AndroidX.Compose.Runtime.Android` already binds `EffectsKt` fully, so the entry points call the binding directly — no `[ComposeBridge]` shims needed. Hand-written raw JNI is limited to: - `kotlin.ResultKt.createFailure(Throwable)` — for resuming Kotlin continuations with `Result.Failure` when a C# Task faults (mangled-name fallout from dotnet/java-interop#1440). - `BoxKey(object?)` — boxes C# primitives / strings into the `Java.Lang.Object` keys EffectsKt expects. Cancellation flow for `LaunchedEffect`: 1. Allocate a `CancellationTokenSource`. 2. Get `IJob` from the Continuation's context, register a `JobCompletionHandler` via `InvokeOnCompletion(onCancelling: true, invokeImmediately: true, ...)` — the 3-arg overload is required; the 1-arg one only fires on full completion and misses the cancelling state. 3. Invoke the user's `Func<CancellationToken, Task>`. 4. Always `ContinueWith` to resume the Kotlin Continuation. Use a `ResumeOnceGate` (Interlocked CAS) to keep racing completion-handler firings + Task completions from double-resuming. 5. Faulted Task → `Result.Failure(throwable)`. Cancelled Task → `Result.Failure(CancellationException)` (kotlinx's `CancellationException` is a typealias for the j.u.c. one on JVM, so the simpler Java type is fine). `rememberCoroutineScope` is intentionally deferred to a follow-up issue — `LaunchedEffect(key=Unit)` with key bumped from a button onClick covers the common "kick off async work on click" case. Sample app: new "Effects" tab demonstrates all three APIs, with a ticking counter (LaunchedEffect), a cleanup counter (DisposableEffect), and adb-logcat output for SideEffect. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Previously `EffectsBridges.cs` cached `kotlin/ResultKt` (to construct `Result.Failure` via `createFailure`) while `SuspendBridge.cs` separately cached `kotlin/Result$Failure` (to read the `exception` field on a failed resume). Both files duplicated the dotnet/java-interop#1440 explanation and lived in places that had nothing else to do with `kotlin.Result`. Fold both halves into a single internal `KotlinResult` static class with three operations: - `CreateFailure(Throwable)` — boxes a Throwable into Result.Failure for `Continuation.resumeWith` (used by `LaunchedEffectBody`). - `IsFailure(IntPtr)` — predicate over a resumed Result handle (used by `SuspendBridge`). - `ExtractException(Java.Lang.Object)` — reads `Result.Failure.exception` (used by `SuspendBridge`). One copy of the `dotnet/java-interop#1440` comment, one `FindClass("kotlin/ResultKt")` call, one `FindClass("kotlin/Result$Failure")` call, and the create-vs-read symmetry is now visible from a single file. When #1440 lands the entire file collapses to direct calls into the bound `ResultKt`. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Kotlin hands LaunchedEffect's Function2 a Continuation whose runtime class is an anonymous synthetic (e.g. `IntrinsicsKt__IntrinsicsJvmKt$createCoroutineUnintercepted$$inlined$createCoroutineFromSuspendFunction$IntrinsicsKt__IntrinsicsJvmKt$4`) that implements `kotlin.coroutines.Continuation` at the JVM level but isn't in Mono.Android's peer registry. A plain `as IContinuation` cast returns null and we threw before ever invoking the body, which was crashing the Effects tab on first render. DisposableEffect's `DisposableEffectScope` arg has the same shape — the runtime instance is Kotlin's `internal object InternalDisposableEffectScope`, again not in the peer registry. Switch both call sites to `p.JavaCast<T>()`, which synthesizes an interface peer from the raw handle rather than relying on the peer cache. Verified working on device: LaunchedEffect tick loop runs at 1 Hz, SideEffect fires on every recomposition, DisposableEffect cleanup fires on every key change (effectKey=19 across 19 taps with no crash). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Two issues flagged by Copilot reviewer on PR #128: 1. EffectsBridges.cs header claimed it caches `IntrinsicsKt.COROUTINE_SUSPENDED` for `LaunchedEffectBody.Invoke`, but no such caching exists here — `LaunchedEffectBody.Invoke` returns `IntrinsicsKt.COROUTINE_SUSPENDED` directly through the binding. (The caching the reviewer is thinking of lives in `SuspendBridge.cs` and is for raw-handle sentinel comparison.) Drop the bullet from the header. 2. The `KotlinUnit` property was unused — `LaunchedEffectBody.ResumeContext.Resume` uses `global::Kotlin.Unit.Instance!` directly. The doc also claimed it was "Cached" but it was a computed `=>` property that re-evaluated every call. Remove. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
75284ee to
d739c5d
Compare
Many tracked issues have shipped since these docs were last touched — update README, docs/architecture.md, samples/README.md, and the Jetchat sample README to reflect what's actually in the repo today. README.md: - Soften the "27 lines is the entire MainActivity.cs" claim — the sample is now a tabbed kitchen-sink demo. Point readers at samples/Jetchat for a single-screen real-app example. - Rebuild the "What's wrapped today" table: add Pager / FlowRow / BoxWithConstraints / LazyStaggeredGrid, Carousels, PullToRefreshBox, full Button + IconButton + FAB variants, Text styling primitives, SecureTextField, ExposedDropdownMenuBox, DockedSearchBar, NavHost / NavController, Animation (AnimatedVisibility / AnimatedContent / Crossfade), Effects (LaunchedEffect / DisposableEffect / SideEffect), expanded Modifier surface, Color value type + parameterized MaterialTheme + theme reads, SuspendBridge async path, full state-holder list. - Update Status to mention the kitchen-sink demo and theme params. docs/architecture.md: - Add ComposeFacadeGenerator alongside ComposeBridgeGenerator and ComposeDefaultsGenerator (third generator). - New "Compose value types" section covering Color / Dp / Sp / FontWeight / TextAlign and the ComposeValueTypes registry. - Rewrite Known issues to call out theming reads (#61), effects (#57/#128), suspend bridges (#97), Compose Navigation (#60) as shipped, with the remaining caveats. Add a "Still missing (tracked)" list pointing at the open issues (#59 CompositionLocal, #64 drawing, #69 WindowInsets, #54/#103 Expressive M3, drawer Open/Close suspend bridges). samples/README.md: - Drop closed issues (#51 / #53 / #58 / #61 / #62 / #63 / #65 / #70) from "Tracked facade gaps" and list them as a one-line "previously appeared here" summary instead. Add #59 CompositionLocal to the open list. - Restore the accidentally-truncated Attribution paragraph. samples/Jetchat/README.md: - Recast the "Hard-coded colors/typography" implementation notes as "feasible — not yet wired" now that MaterialTheme.CurrentColorScheme/Typography(composer) reads exist (#61 / PR #133). - Update the hamburger-nav-icon note to reflect that the DrawerStateHolder wrapper landed (PR #131, Phase 10) and only the suspend Open()/Close() bridges remain. - Wire the user-profile-screen omission to NavHost / NavController (now bound via #60). - Move the asymmetric RoundedCornerShape omission from #65 to the still-open #64 drawing primitives. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Closes #57. Implements phase 4 of #66 (#102).
What
Bind Compose's three side-effect entry points onto the C# facade — with both static call shape (consistent with
Compose.Remember/Compose.RememberSaveable) and node call shape (drop-in inside collection-initializer trees):Each effect has 1/2/3-key overloads (matching Kotlin's
LaunchedEffect(key1)/LaunchedEffect(key1, key2)/LaunchedEffect(key1, key2, key3)). Keys can be any Java peer, string, char, bool, or .NET primitive — boxed once per call viaBoxKey.Why no
[ComposeBridge]Xamarin.AndroidX.Compose.Runtime.Android1.11.2.1 fully bindsEffectsKt.SideEffect/LaunchedEffect/DisposableEffect(their Kotlin signatures don't carry@JvmInline value classparameters, so they don't trip dotnet/java-interop#1440). The only raw JNI in this PR is forkotlin.ResultKt.createFailure(Throwable)— avalue classmember the binder still strips — used to surface C# Task faults as KotlinResult.Failureboxes.How
LaunchedEffectbridges C# Task ↔ Kotlin suspendThe shape Kotlin's
LaunchedEffectexpects issuspend (CoroutineScope) -> Unit, which the binder surfaces asIFunction2<CoroutineScope, Continuation<Unit>, Object?>plus theCOROUTINE_SUSPENDEDsentinel protocol.LaunchedEffectBody.Invoke:CancellationTokenSourcefor this invocation.IJobout ofcontinuation.Contextand registers aJobCompletionHandlerviaInvokeOnCompletion(onCancelling: true, invokeImmediately: true, …). The 3-arg overload is required — the 1-arg form only fires after full completion, which is too late to cancel a still-suspended C#Task.Delay.Func<CancellationToken, Task>.Task.ContinueWiththat resumes the Kotlin continuation:Result.Failure(toThrowable(ex))(fullex.ToString()preserved in the message).Result.Failure(java.util.concurrent.CancellationException)(kotlinx.coroutines.CancellationExceptionis a typealias for the j.u.c. one on JVM).Kotlin.Unit.Instance.Interlocked.CompareExchangeonce-gate keeps a racing job-cancellation handler firing and a lateTaskcompletion from double-resuming the continuation.IntrinsicsKt.COROUTINE_SUSPENDEDso Kotlin awaits the deferred resume.A synchronous fault from
body(ct)(rare — async lambdas typically wrap their work inasync) is rethrown as aJava.Lang.RuntimeExceptionthat includesex.ToString(), so coroutine machinery records the failure on the Job andInvokeOnCompletionstill fires.What's not included
rememberCoroutineScope— deferred. The common "kick off async work on click" pattern is covered byLaunchedEffect("once", body)plus a key bump from the button'sonClick. Filing as a follow-up issue.Object[]allocation.BoxKeyArrayis wired up internally for when we add the public variadic overload.Sample
MainActivity.csgains an "⚡ Effects" tab. Ticking counter is incremented by aLaunchedEffect, "Restart effects" bumps the key (cancels the previous Task, fires the previousDisposableEffect's cleanup, re-runs both with the new key), andSideEffectwrites a line to logcat (adb logcat -s ComposeNet.Sample:D) on every recomposition of the tab.Validation
dotnet build src/ComposeNet.Compose— clean.dotnet build src/ComposeNet.Sample— clean.dotnet test src/ComposeNet.SourceGenerators.Tests— 90/90 pass (the source generators don't touch effects).Rubber-duck pass
Interlocked.CompareExchange, withJobCompletionHandlerfiring → CTS.Cancel → user code observesct→ ContinueWith → resume. Synchronous-completion path goes through the same ContinueWith so there's a single resume entry point.LaunchedEffectBody/DisposableEffectBody/JobCompletionHandlerare kept alive by Compose's slot table (Function2/Function1) and the Job's internal handler list (Function1).ResumeContextis held alive by TPL through thestatearg ofContinueWith.BoxKeyrejects unsupported types withNotSupportedExceptionrather than silently identity-hashing them — boxed value types likeGuid/DateTime/ records would otherwise change identity per-render and infinitely restart the effect.