Skip to content

fix: Android crashes when a positional node ID changes type - #436

Open
SRWieZ wants to merge 1 commit into
NativePHP:mainfrom
SRWieZ:fix/android-node-identity
Open

SRWieZ wants to merge 1 commit into
NativePHP:mainfrom
SRWieZ:fix/android-node-identity

Conversation

@SRWieZ

@SRWieZ SRWieZ commented Sep 12, 2026

Copy link
Copy Markdown
Member

What's wrong

On Android, removing conditional siblings can crash the app when an unkeyed element's positional node ID is reused by a different element type.

NodeView keys its Compose subtree with node.id, but the native tree differ treats an ID and type change as a structural replacement. When a re-render removes elements above a pressable, the pressable can inherit the positional ID previously occupied by a text node. Compose then reuses incompatible remembered slots and crashes while collecting the pressed state:

java.lang.ClassCastException:
androidx.compose.runtime.internal.ComposableLambdaImpl
cannot be cast to androidx.compose.runtime.MutableState
    at androidx.compose.foundation.interaction.PressInteractionKt.collectIsPressedAsState
    at com.nativephp.mobile.ui.nativerender.NodeViewKt.NodeView

Minimum repro:

<native:column>
    @if ($showOptions)
        <native:text>How?</native:text>
        <native:pressable :press-scale="0.98"><native:text>Option A</native:text></native:pressable>
        <native:pressable :press-scale="0.98"><native:text>Option B</native:text></native:pressable>
    @endif

    <native:pressable :press-scale="0.98"><native:text>Save</native:text></native:pressable>
</native:column>

Re-render with $showOptions = false. The trailing pressable takes the positional ID formerly used by the text node and Android crashes.

What this does

  • Keys each Android NodeView by both node.id and node.type, matching the existing structural identity check in the native tree differ.
  • Recreates the Compose subtree when a positional ID is reused by another element type, instead of recycling incompatible remembered state.
  • Preserves state normally when both the ID and type remain stable; explicit native:key behavior is unchanged.
  • Adds an Android instrumentation regression that deterministically reproduced the original ClassCastException before the one-line fix.
  • No iOS or PHP changes.

Verification

  • Before the fix: NodeViewIdentityTest fails with the ComposableLambdaImpl cannot be cast to MutableState crash above.
  • After the fix: NodeViewIdentityTest passes on an Android API 33 emulator.
  • ./gradlew assembleDebug assembleDebugAndroidTest passes.
  • ./gradlew testDebugUnitTest passes.

No screenshot — this is a deterministic runtime crash rather than a visual rendering bug; the instrumentation regression captures the failure directly.

@simonhamp

Copy link
Copy Markdown
Member

@SRWieZ there are merge conflicts. Could you resolve?

Unkeyed EDGE nodes use positional IDs. Removing conditional siblings can therefore assign an old ID to a different element type. NodeView keyed only on ID, so Compose could reuse incompatible remembered state and crash in collectIsPressedAsState.

Include the node type in the Compose key, matching the native tree differ's structural identity check. Add an Android instrumentation regression that changes a positional node from text to a pressable with feedback and reproduces the original crash before the fix.
@SRWieZ
SRWieZ force-pushed the fix/android-node-identity branch from 714b66c to 6c85369 Compare October 3, 2026 13:39
@SRWieZ

SRWieZ commented Oct 3, 2026

Copy link
Copy Markdown
Member Author

@simonhamp conflicts resolved: rebased on main. NodeView now goes through ResolvedNodeView (breakpoints), so the key moved there: key(node.id, node.type). The test dependency was already on main.

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.

2 participants