Switching the expanded VIEW face directly from a Project.Diagram node to a node of
a different type (e.g. a DataPoint) — without closing the first one — threw a
ClassCastException and put a modal Java error dialog over the whole app:
1
2
class krill.zone.shared.krillapp.datapoint.DataPointMetaData cannot be cast to class
krill.zone.shared.krillapp.project.diagram.DiagramMetaData
Reachable via ScreenCore.viewExpanded() (the view:<id> demo-control path in
main.kt, or the equivalent UI action), which fires from a background thread with no
synchronization against the Compose recomposition it triggers.
KrillScreenContent() (composeApp/.../startup/KrillScreen.kt) routes the expanded
face by the currently-selected node’s type. Its Project.Diagram and
Project.Camera branches each read the selected node and hard-cast its meta:
1
val meta = n.meta as DiagramMetaData // and, in the Camera branch: as CameraMetaData
Every other place in the codebase that reads one of these metadata shapes
(DiagramScreen, MiniDiagram, CameraScreen, EditCamera, …) already used a safe
cast (as?) with a graceful fallback — these two routing-layer reads were the
outliers. A fast switch between two expanded nodes of different types can produce,
for one recomposition, a read where the node’s type still routes into the old
branch while its meta already reflects the newly-selected node — the exact
“torn” shape the hard cast had no defense against. NodeSummaryAndEditor.kt already
carried a key(n.id, n.type) wrapper specifically to force full subtree recreation
on this kind of switch (its own comment: “force full recreation when switching
between different node types”) — the equivalent routing block in
KrillScreenContent never got the same treatment.
KrillScreen.kt: both hard casts became safe casts (n.meta as? DiagramMetaData ?:
return@let, as? CameraMetaData ?: return@let), matching the pattern used
everywhere else these types are read. A mismatched read now renders nothing for
that composition instead of crashing.when (nodeType) { ... } block (everything KrillScreenContent
renders once it decides not to show ClientScreen) is now wrapped in
key(selectedNodeId.value, nodeType), mirroring NodeSummaryAndEditor’s existing
key(n.id, n.type). This forces Compose to fully discard and recreate the subtree
whenever the selected node’s id or type changes, rather than risking a
recomposition that carries over state from the previous node/type.Node.meta should always be as? with a graceful
fallback, never a hard as. The type/meta pairing is a runtime convention (backed
by KrillApp.meta() and the node processors), not something the compiler enforces
— nothing prevents a Node from momentarily carrying a type/meta pair that
don’t match during a fast selection change, and a crash is a strictly worse outcome
than a dropped frame of the wrong content. grep -rn "as \(Diagram\|Camera\|.*\)MetaData" --include="*.kt"
(excluding as?) is a quick way to spot new hard casts creeping back in.id/type
pair (not just an inner sub-composable) should wrap that content in
key(id, type) if a different part of the same file already needed that pattern
for the same reason — the need doesn’t stay contained to one call site.KrillScreenTest (composeApp/src/desktopTest/.../startup/KrillScreenTest.kt)
reproducing the exact crash signature deterministically: a Node whose type is
Project.Diagram/Project.Camera but whose meta is a mismatched
DataPointMetaData, seeded directly (no thread races, so the test can’t flake),
routed through ScreenCore.selectNode() + viewExpanded(), then rendered via the
real KrillScreen() composable. Verified red before the fix (both cases failed
with the exact ClassCastException at the old cast sites) and green after.