Skip to content

Compose effect APIs: LaunchedEffect / DisposableEffect / SideEffect#128

Merged
jonathanpeppers merged 4 commits into
mainfrom
jonathanpeppers/compose-effects
Jun 7, 2026
Merged

Compose effect APIs: LaunchedEffect / DisposableEffect / SideEffect#128
jonathanpeppers merged 4 commits into
mainfrom
jonathanpeppers/compose-effects

Conversation

@jonathanpeppers

Copy link
Copy Markdown
Owner

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):

// Static shape — call from inside any composition body
Compose.SideEffect(() => Log.Info("rendered"));
Compose.DisposableEffect(_sensorId, scope =>
{
    var registration = SensorRegistry.Subscribe(_sensorId, OnTick);
    return () => registration.Dispose();
});
Compose.LaunchedEffect("once", async ct =>
{
    while (!ct.IsCancellationRequested)
    {
        await Task.Delay(1000, ct);
        _ticks.Value++;
    }
});

// Node shape — inside any container's collection-initializer
new Column
{
    new Text(_count.ToString()),
    new SideEffect(() => _logger.Trace("recomposed")),
    new LaunchedEffect(_sessionId, async ct => await SyncAsync(ct)),
    new DisposableEffect(_sensorId, scope => /* ... */),
}

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 via BoxKey.

Why no [ComposeBridge]

Xamarin.AndroidX.Compose.Runtime.Android 1.11.2.1 fully binds EffectsKt.SideEffect / LaunchedEffect / DisposableEffect (their Kotlin signatures don't carry @JvmInline value class parameters, so they don't trip dotnet/java-interop#1440). The only raw JNI in this PR is for kotlin.ResultKt.createFailure(Throwable) — a value class member the binder still strips — used to surface C# Task faults as Kotlin Result.Failure boxes.

How LaunchedEffect bridges C# Task ↔ Kotlin suspend

The shape Kotlin's LaunchedEffect expects is suspend (CoroutineScope) -> Unit, which the binder surfaces as IFunction2<CoroutineScope, Continuation<Unit>, Object?> plus the COROUTINE_SUSPENDED sentinel protocol.

LaunchedEffectBody.Invoke:

  1. Allocates a CancellationTokenSource for this invocation.
  2. Pulls the IJob out of continuation.Context and registers a JobCompletionHandler via InvokeOnCompletion(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.
  3. Calls the user's Func<CancellationToken, Task>.
  4. Hooks a Task.ContinueWith that resumes the Kotlin continuation:
    • Faulted Task → Result.Failure(toThrowable(ex)) (full ex.ToString() preserved in the message).
    • Cancelled Task → Result.Failure(java.util.concurrent.CancellationException) (kotlinx.coroutines.CancellationException is a typealias for the j.u.c. one on JVM).
    • Successful Task → Kotlin.Unit.Instance.
    • An Interlocked.CompareExchange once-gate keeps a racing job-cancellation handler firing and a late Task completion from double-resuming the continuation.
  5. Returns IntrinsicsKt.COROUTINE_SUSPENDED so Kotlin awaits the deferred resume.

A synchronous fault from body(ct) (rare — async lambdas typically wrap their work in async) is rethrown as a Java.Lang.RuntimeException that includes ex.ToString(), so coroutine machinery records the failure on the Job and InvokeOnCompletion still fires.

What's not included

  • rememberCoroutineScope — deferred. The common "kick off async work on click" pattern is covered by LaunchedEffect("once", body) plus a key bump from the button's onClick. Filing as a follow-up issue.
  • vararg-key overloads — the 1/2/3-key overloads cover the common case without per-render Object[] allocation. BoxKeyArray is wired up internally for when we add the public variadic overload.

Sample

MainActivity.cs gains an "⚡ Effects" tab. Ticking counter is incremented by a LaunchedEffect, "Restart effects" bumps the key (cancels the previous Task, fires the previous DisposableEffect's cleanup, re-runs both with the new key), and SideEffect writes 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

  • Resume-once race: gated by Interlocked.CompareExchange, with JobCompletionHandler firing → CTS.Cancel → user code observes ct → ContinueWith → resume. Synchronous-completion path goes through the same ContinueWith so there's a single resume entry point.
  • JCW lifetime: LaunchedEffectBody / DisposableEffectBody / JobCompletionHandler are kept alive by Compose's slot table (Function2/Function1) and the Job's internal handler list (Function1). ResumeContext is held alive by TPL through the state arg of ContinueWith.
  • BoxKey rejects unsupported types with NotSupportedException rather than silently identity-hashing them — boxed value types like Guid / DateTime / records would otherwise change identity per-render and infinitely restart the effect.

Copilot AI review requested due to automatic review settings June 7, 2026 01:30

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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), and Compose.LaunchedEffect (1/2/3-key) entry points plus matching ComposableNode wrappers for collection-initializer tree syntax.
  • Introduces LaunchedEffectBody, DisposableEffectBody, JobCompletionHandler JCWs and EffectsBridges.cs (KotlinResultFailure, BoxKey) to bridge the coroutine/suspend protocol and box effect keys for Java equals comparison.
  • 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.

Comment thread src/ComposeNet.Compose/EffectsBridges.cs Outdated
Comment thread src/ComposeNet.Compose/EffectsBridges.cs Outdated
jonathanpeppers added a commit that referenced this pull request Jun 7, 2026
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>
jonathanpeppers and others added 4 commits June 6, 2026 22:04
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>
@jonathanpeppers jonathanpeppers force-pushed the jonathanpeppers/compose-effects branch from 75284ee to d739c5d Compare June 7, 2026 03:05
@jonathanpeppers jonathanpeppers merged commit e1bc195 into main Jun 7, 2026
1 check passed
@jonathanpeppers jonathanpeppers deleted the jonathanpeppers/compose-effects branch June 7, 2026 03:56
jonathanpeppers added a commit that referenced this pull request Jun 7, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add Compose effects API: LaunchedEffect, DisposableEffect, SideEffect, rememberCoroutineScope

2 participants