Skip to content

fix: propagate composable menu DSL state changes to the native menu - #435

Merged
kdroidFilter merged 1 commit into
masterfrom
fix/composable-menu-reactivity
Sep 1, 2026
Merged

fix: propagate composable menu DSL state changes to the native menu#435
kdroidFilter merged 1 commit into
masterfrom
fix/composable-menu-reactivity

Conversation

@kdroidFilter

Copy link
Copy Markdown
Collaborator

Fixes #434

Root cause

The report blamed the label pipeline, but the plain TrayMenuBuilder path is fine — the bug is specific to the composable-DSL overloads (ComposableTray.kt), which is what trailing-lambda callers actually resolve to.

rememberRecordedMenuContent invokes the user's @Composable ComposableTrayMenuScope.() -> Unit lambda and records it into a plain mutableListOf. When state read inside that lambda changes (e.g. label = if (dashboardOpen) … else …), Compose restarts only the lambda's own recompose scope: the DSL re-records into the old, abandoned RecordingComposableScope, while the outer function — snapshot(), structuralFingerprint(), remember(fingerprint), and the underlying Tray's LaunchedEffect — never re-runs. The native menu keeps the stale text until something else forces a full rebuild, which is exactly the key(...) workaround from the issue.

Diagnosed with a timed repro (label flip every 2 s + stack traces): the menu lambda re-ran on every flip via RecomposeScopeImpl.compose (lambda-only restart), while Tray never recomposed and no native update was queued.

Fix

Back the recorded ops with mutableStateListOf. Now a lambda-only restart writes snapshot state that the Tray wrapper reads through snapshot(), so the wrapper is invalidated, re-records the whole menu with a fresh scope, computes a new fingerprint, and the native menu rebuilds. Submenus chain the same way (child list read by the parent scope).

One line of behavior, verified on Windows 11 with the issue's repro shape:

  • each flip → re-record with new label → new menuHashLaunchedEffect relaunch → WindowsTrayManager processes the update
  • exactly one extra recomposition per state change, no recomposition loop (quiet between flips)
  • covers label, isEnabled, and checked alike (all in the fingerprint)

Testing

  • Timed repro run before/after (before: zero native updates after init; after: one per flip).
  • compileKotlinJvm (lib, tray-app, demo), ktlintCheck, detekt, jvmTest all pass.

rememberRecordedMenuContent recorded the user's @composable menu lambda
into a plain mutableListOf. When state read inside the lambda changed,
Compose restarted only the lambda's own recompose scope: it re-recorded
into the abandoned RecordingComposableScope while the outer function
(fingerprint, replay block, the underlying Tray's LaunchedEffect) never
re-ran, so label/enabled/checked changes never reached the native menu
unless callers disposed the tray via key().

Backing the recorded ops with mutableStateListOf makes the lambda-only
restart write snapshot state that the Tray wrapper reads via snapshot();
the wrapper is invalidated, re-records the whole menu with a fresh
scope, computes a new fingerprint, and the native menu rebuilds.
Verified with a timed repro on Windows: each state flip now produces a
menu update with the new label, with no recomposition loop.

Fixes #434
@kdroidFilter
kdroidFilter merged commit 0663f51 into master Sep 1, 2026
4 checks passed
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.

Item labels are not reactive (Open/Close text stays stale after state change)

1 participant