Skip to content

fix(core): remove once listeners by identity, not stale index - #11306

Merged
NathanWalker merged 1 commit into
NativeScript:mainfrom
jkatins:fix/observable-once-stale-index-splice
Jul 17, 2026
Merged

fix(core): remove once listeners by identity, not stale index#11306
NathanWalker merged 1 commit into
NativeScript:mainfrom
jkatins:fix/observable-once-stale-index-splice

Conversation

@jkatins

@jkatins jkatins commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

PR Checklist

What is the current behavior?

Regression introduced in 1e55f0c (#10946), first released in core 9.0.0.

Observable._fireEvent iterates a copy of the observers array but passes the copy's loop index to _handleListenerEntry, which splices the live array at that position. If any listener at a lower index is removed during the same notify pass (a handler calling off() on itself, or a preceding once entry being consumed), the live array shifts left and the splice for a later once entry removes the wrong element or nothing at all. Consequences:

  1. The once listener stays subscribed and fires on every subsequent notify.
  2. An unrelated listener sitting after it can be silently unsubscribed.

The launch event is affected deterministically in every app: NativeScriptGlobalState registers _setLaunched as the first launch listener and it removes itself inside its handler, so every Application.once('launch', ...) remains permanently armed. On Android this crashes apps that bootstrap from a launch listener and keep their process alive with a foreground service: recreating the activity (e.g. back-button exit, then relaunch) fires launch again, the armed once runs the bootstrap a second time and Application.run() throws Error: Application is already started. inside NativeScriptActivity.onCreate.

Minimal repro:

const obj = new Observable();
obj.on('foo', function handler() {
	obj.off('foo', handler);
});
obj.once('foo', () => console.log('once'));

obj.notify({ eventName: 'foo', object: obj }); // logs "once"
obj.notify({ eventName: 'foo', object: obj }); // logs "once" again

Observable._fireEvent iterates a copy of the observers array but passed
the copy's loop index to _handleListenerEntry, which spliced the live
array at that position. When an earlier listener was removed during the
same notify pass (a handler calling off() on itself, or a preceding
`once` entry being consumed), the live array shifted left and the
splice for a later `once` entry removed the wrong listener or none at
all. The `once` listener then stayed subscribed and fired on every
subsequent notify, while an unrelated listener could be silently
unsubscribed.

Look up the entry's current position via indexOf before splicing and
mark it _isRemoved (matching innerRemoveEventListener), so a consumed
`once` entry is removed exactly once - still before its callback runs -
even when the array has shifted or the notify pass re-enters.
@CatchABus

Copy link
Copy Markdown
Contributor

@jkatins Nice finding and explanation!

@nx-cloud

nx-cloud Bot commented Jul 16, 2026

Copy link
Copy Markdown

View your CI Pipeline Execution ↗ for commit 9454b71

Command Status Duration Result
nx test apps-automated -c=android ✅ Succeeded 3m 54s View ↗
nx run-many --target=test --configuration=ci --... ✅ Succeeded 3s View ↗

💡 Verify your cache is correct by running tasks in a sandbox. Read docs ↗


☁️ Nx Cloud last updated this comment at 2026-07-16 17:54:32 UTC

@NathanWalker
NathanWalker merged commit 8534feb into NativeScript:main Jul 17, 2026
4 of 5 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.

3 participants