Skip to content

[SDK-717] Fix stale customAction replay when handler returns false - #1089

Open
joaodordio wants to merge 7 commits into
masterfrom
SDK-717-fix-stale-custom-action-replay
Open

joaodordio wants to merge 7 commits into
masterfrom
SDK-717-fix-stale-custom-action-replay

Conversation

@joaodordio

@joaodordio joaodordio commented Sep 10, 2026

Copy link
Copy Markdown
Member

📝 Summary

Fix stale IterableCustomActionHandler replay when handler returns false, a regression present in 3.6.5–3.10.1.

🎟️ Jira Ticket: SDK-717

📖 Description

The IterableCustomActionHandler interface documents its boolean return value as "Reserved for future use". Clients commonly return false (Kotlin default, follows the javadoc), and the React Native SDK wrapper always does too. Since the SDK-307 fix (3.6.5, PR #975), returning false was treated as "action not consumed", leaving pendingAction alive and causing the handler to fire again on every foreground and every initialize() call.

Three replay paths:

  • onForeground() calls processPendingAction on every app foreground
  • initialize() called it twice — before and after loadLastSavedConfiguration()
  • The trampoline's super.onResume() triggers onActivityResumed → onForeground → processPendingAction on the stale action before handlePushAction sets the new one

IterableActionRunner.javacallCustomActionIfSpecified now invokes the handler and always returns true (consumed). Returns false only when the handler is null (SDK not yet initialized), keeping the SDK-307 retry intent intact.

IterableApi.java — removed the duplicate processPendingAction() call before loadLastSavedConfiguration() in initialize(). One call after full initialization is enough.

🧪 How to test?

Added testCustomActionHandlerReturnFalseDoesNotReplayOnForeground to IterablePushActionReceiverTest: sends a push with a handler that returns false, simulates a foreground event via processPendingAction, and asserts the handler fired exactly once.

Existing testBackgroundCustomActionProcessedAfterSDKInit continues to cover the SDK-307 scenario (handler null at push time, retried after initialize).

🧾 Changelog

Added a Fixed entry to CHANGELOG.md under [Unreleased] calling out affected versions 3.6.5–3.10.1.

📹 Loom recording if applicable

N/A

🐞 Github Issues solved

N/A

📚 Docs PR if applicable

N/A

The IterableCustomActionHandler interface documents its boolean return
value as 'Reserved for future use'. Clients routinely return false, as
does the React Native SDK wrapper. Since the SDK-307 fix (3.6.5),
returning false from the handler left pendingAction alive, causing the
handler to replay on every app foreground and every initialize() call.

Fix: treat invocation of the handler as consumed regardless of return
value. Only keep pendingAction alive when the handler is null (SDK not
yet initialized) — the original SDK-307 intent.

Also remove the duplicate processPendingAction() call early in
initialize(), before loadLastSavedConfiguration() runs. A single call
after full initialization is sufficient.

Affected versions: 3.6.5–3.10.1.
@joaodordio
joaodordio requested a review from a team as a code owner September 10, 2026 14:40
@joaodordio joaodordio self-assigned this Sep 10, 2026
The previous approach always returned true from callCustomActionIfSpecified
when a handler was present. That broke the openApp fallback in
handlePushAction: 'if (openApp && !handled)' never fired, so the launcher
activity was not started for push taps where the handler returned false.

Move the fix to processPendingAction instead. Before dispatching, capture
whether customActionHandler was present. After dispatching, clear
pendingAction if handled=true OR if the handler was present — invocation
itself is treated as consumed. The handler's return value is preserved
as-is so the openApp fallback in handlePushAction continues to work.
@franco-zalamena-iterable

franco-zalamena-iterable commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

This solution makes the openApp fallback work correctly, but customHandlerPresent does not necessarily mean the custom handler was invoked. From what i checked, an unhandled openUrl action is also cleared when an unrelated custom-action handler is configured, preventing its foreground retry.

I though about adding improving readability with something internal like this

enum ActionDispatchResult {
NOT_DISPATCHED,
DISPATCHED_WITH_FALLBACK,
HANDLED
}

This would make it easier to distinguish when to clear the pending action or when to run it.

I think having this boolean when it actually can represent more things can be a bit confusing to debug and to understand what is the intended behavior (like the bug itself)

Replace the customHandlerPresent boolean with a typed enum that cleanly
separates the two concerns a single boolean cannot express:
  - whether pendingAction should be cleared
  - whether the openApp launcher fallback should fire

NOT_DISPATCHED  — handler was null, keep pendingAction for retry (SDK-307)
DISPATCHED_WITH_FALLBACK — handler ran but returned false, or URL failed;
                           clear pendingAction, allow openApp fallback
HANDLED         — handler returned true or URL opened; clear pendingAction,
                  suppress openApp fallback

Also fixes Franco's callout: the previous customHandlerPresent check would
incorrectly clear a failed openUrl action whenever an unrelated custom
action handler was configured. dispatchPendingAction now checks handler
presence only for non-URL actions, so URL and custom action paths are
independently reasoned about.
@franco-zalamena-iterable

Copy link
Copy Markdown
Contributor

I was checking this PR with some tests and realized that we introduced a new problem, currently if the app is closed and a push url is passed, we silently fail it.

Currently on the initializeForPush we just pass the android context, not the iterable config.
Previously, we would return false in this case and then when the app was fully initialized it would be processed, but now it is returning DISPATCHED_WITH_FALLBACK
This is more specific to the openApp=false config.

We could rename NOT_DISPATCHED to RETRY_LATER and return it instead in this case, so a failed URL before full initialization should produce RETRY_LATER, not DISPATCHED_WITH_FALLBACK.

When only initializeForPush ran (_apiKey null), the SDK lacks the full
config (urlHandler, deep link handlers). A URL push action would fall
through dispatchPendingAction to DISPATCHED_WITH_FALLBACK, clearing
pendingAction and silently losing the action before initialize() ran.

Fix: URL actions now check _apiKey == null and return RETRY_LATER, so
they are deferred alongside custom actions when the SDK is not yet
fully initialized.

Also renames NOT_DISPATCHED -> RETRY_LATER throughout — clearer intent.

Adds testBackgroundUrlActionDeferredUntilSDKInit and a matching fixture
to cover the openApp=false + URL action + pre-init path.
The customActionHandler != null check was too narrow — when the SDK was
fully initialized but had no customActionHandler configured, dispatch
returned RETRY_LATER and never called IterableActionRunner.executeAction.
This broke testTrackPushOpenWithCustomAction and testPushActionWithTextInput
(zero mock interactions) and in production would strand pendingAction alive
indefinitely for any app without a customActionHandler.

The correct signal for 'not ready to dispatch' is _apiKey == null (only
initializeForPush ran, not the full initialize()). When the SDK is fully
initialized, always dispatch — IterableActionRunner returns false naturally
when no handler is configured, which correctly produces DISPATCHED_WITH_FALLBACK
and allows the openApp fallback to fire.

Also moves tracking after the RETRY_LATER guard to prevent double
trackPushOpen calls on subsequent retries.
// customActionHandler). When the SDK is fully initialized, always dispatch and let
// IterableActionRunner return false naturally if no handler is configured — that
// correctly allows the openApp fallback to fire without keeping pendingAction alive.
if (IterableApi.sharedInstance._apiKey == null) {

@franco-zalamena-iterable franco-zalamena-iterable Sep 16, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I'm a bit reluctant of changing this to here, this will change the behavior on many calls, even if they are changing to something "more correct" i think we should stick to fixing the reported bug and add visibility and readability with this PR. Let me know what you think, i pushed a sibling branch with a similar solution but less "breaking". Let me know what you think

proposal branch

Move ActionDispatchResult into IterableActionRunner where the dispatch
logic lives. Add dispatchAction() alongside executeAction() (kept as a
boolean adapter for existing callers).

Three states:
  NOT_HANDLED         — no handler or URL failed; preserve retry behavior
  DISPATCHED_UNHANDLED — handler invoked, returned false; consumed, allow openApp fallback
  HANDLED             — handler returned true or URL opened; consumed, suppress fallback

processPendingAction clears pendingAction on anything != NOT_HANDLED,
so a failed URL action is no longer prematurely cleared just because
an unrelated customActionHandler happens to be configured.

Tests: update mock stubs and verifies to dispatchAction; replace
verbose test comments with Franco's cleaner versions; add
testCustomActionHandlerDoesNotConsumeFailedUrlAction to cover the
URL/customActionHandler independence case; remove the now-unnecessary
background URL fixture and test.
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