Symptom

Kraken’s nightly architectural scan flagged NodeChip.kt (dataSourceOrValueLabel, NodeChipScaffold) and NodeItem.kt for calling collectAsState() directly in a composable body, without wrapping in remember/LaunchedEffect or keying — claiming this causes a new collector to launch on every recomposition without cancelling the previous one, accumulating collectors and leaking memory.

Root cause

Mostly a false positive, with one real minor inefficiency underneath it.

collectAsState() is Compose’s own produceState/LaunchedEffect wrapper: it keys its internal collector coroutine on the flow instance, and Compose’s LaunchedEffect keying already cancels the previous collector before launching a new one when the key changes. Calling it directly in a composable body — no extra remember needed — is the documented, idiomatic pattern. NodeItem.kt’s screenCore.editMode / editFirstSelection / editSecondSelection are stable StateFlow properties held by a Koin-singleton ScreenCoreImpl, so their instance never changes across recompositions — no churn, no leak.

The one real gap: ClientNodeManager.readNodeStateOrNull(id) (used by NodeChip.kt:34,75) returned a fresh MutableStateFlow(null) on every call when id was null or the node wasn’t yet available (e.g. an unresolved cross-server @DataSource input). Since collectAsState() keys on flow identity, every recomposition while a node was unresolved forced Compose to tear down and relaunch the collector instead of reusing the remembered one — real churn, but never an actual leak (the old collector was always properly cancelled by LaunchedEffect’s keying), and bounded by how often those composables recompose while a source is unloaded.

Fix

Prevention

When Kraken (or anyone) flags collectAsState() used directly in a composable body as an “unkeyed leak,” check whether the underlying flow is a stable instance across recompositions first (a class-level StateFlow property, or a map-cached MutableStateFlow keyed by id) — if so, Compose’s own keying already prevents accumulation and no fix is needed. The only real risk is a factory function that allocates a new flow instance per call for the same logical “no value” case; fix that by returning a shared sentinel instance instead of wrapping every collectAsState() call site in remember.