Skip to content

feat: [SDK-5242] wait for init in login and logout - #1529

Open
sherwinski wants to merge 2 commits into
mainfrom
sherwin/sdk-5242
Open

sherwinski wants to merge 2 commits into
mainfrom
sherwin/sdk-5242

Conversation

@sherwinski

@sherwinski sherwinski commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Description

1 Line Summary

login and logout wait for init while init runs, throw before init, then check consent.

Details

Opened against main for the team to decide on. The pre-init window is older than Identity Verification, and nothing in the IV branch depends on this wait. The IV branch adds updateUserJwt, which needs the same guard; that comes with the merge of main into feat/identity-verification.

Problem. A direct call to login or logout before init dereferences OneSignal._coreDirector, which init creates, and fails with an undefined dereference. The OneSignalDeferred array runs init before later callbacks, but a direct window.OneSignal.login() can come first.

Fix. A boolean OneSignal._coreReady turns true once init created the user model and loaded the stored consent. The 2 methods call _awaitCore(method) only while the flag is false:

  • Init in progress: the call waits on _coreReadyPromise, with a debug log. Calls that wait resume in call order, because each one takes the same number of microtask hops.
  • Init not called yet: the call throws Must call 'init' before '<method>', as on Android (OneSignalImp.kt:531-608). A wait would never end when the call runs inside the OneSignalDeferred array ahead of init, because the array runs its callbacks one at a time. The deferred loop logs the error and moves on to init.
  • Init stopped or threw before the user model (Safari with no web ID, IndexedDB unavailable, config fetch failure): the finally in init wakes the waiters, the flag stays false, and the call logs <method> skipped: init did not complete and returns.
  • After the wait, the call checks isConsentRequiredButNotGiven(). The check before the wait runs only when the flag is true, because OneSignal._consentGiven is not loaded before init reads the stored consent.

Open question for the team. Throw or wait before init? Android throws IllegalStateException when init was not started and waits when init runs. This PR follows Android. The other option, a wait with no end, blocks the OneSignalDeferred loop when a login callback is pushed ahead of init.

The after-init path stays synchronous. The guard reads the flag before any await. Callers rely on OneSignal.login(id); OneSignal.User.addTag(...) without await to enqueue the login operation first, and the existing test should preserve operations order without needing await pins that.

History: #1513 was the first version, on the IV stack. The review there asked for a ready flag set after the consent load, ready only on init success, and a pinned order between a waiting call and the calls after it. All 3 are in this version.

Follow-up outside this repo: document the rule in the SDK reference (mintlify-docs).

Bundle size

Measured with size-limit against main (79f0f525):

Bundle Base This branch Delta
OneSignalSDK.page.es6.js 43.08 kB 43.25 kB +171 B
OneSignalSDK.sw.js 12.47 kB 12.47 kB 0

The last commit raises the page limit to 43.30 kB.

Systems Affected

  • WebSDK
  • Backend
  • Dashboard

Validation

Tests

Info

  • vp test run — 63 files, 568 tests pass.
  • vp check — 0 errors, 5 pre-existing warnings.
  • vp run build:prod — passes with the raised limit.

Tests in OneSignal.test.ts, block OneSignal - Before Init:

  • login and logout throw when init was not called
  • login and logout wait for init, then run, with a debug log
  • a call waits until init loads the consent, even when the user model exists
  • calls that wait for init run in call order
  • login obeys a consent requirement that the init config carries
  • login does not read the consent state before init loads it
  • login is skipped with a warning when init stopped before the user model

Test in pageSdkInit3.test.ts with a real init: a login ahead of init in OneSignalDeferred fails with the init error, init still runs, a login that starts during init proceeds once init loads the stored consent, and it runs before a login made after await init.

Checklist

  • All the automated tests pass or I explained why that is not possible
  • I have personally tested this on my machine or explained why that is not possible
  • I have included test coverage for these changes or explained why they are not needed

Programming Checklist
Interfaces:

  • Don't use default export
  • New interfaces are in model files

Functions:

  • Don't use default export
  • All function signatures have return types
  • Helpers should not access any data but rather be given the data to operate on.

Typescript:

  • No Typescript warnings
  • Avoid silencing null/undefined warnings with the exclamation point

Other:

  • Iteration: refrain from using elem of array syntax. Prefer forEach or use map
  • Avoid using global OneSignal accessor for context if possible. Instead, we can pass it to function/constructor so that we don't call OneSignal.context

Screenshots

Info

Not needed: no UI change.

Checklist

  • I have included screenshots/recordings of the intended results or explained why they are not needed

Related Tickets


A direct call to login or logout before init dereferenced the user
model that init creates. The OneSignalDeferred array runs init before
later callbacks, but a direct window.OneSignal.login() can come first.

A boolean OneSignal._coreReady turns true once init created the user
model and loaded the stored consent. While the flag is false, the 2
methods wait for init when init runs, as the Android SDK does. A call
before init throws "Must call 'init' before '<method>'", because a
wait inside the OneSignalDeferred array ahead of init would never end.
When init stops or throws before the user model, the finally block
wakes the waiters without the flag and the call logs a warning.

The consent check runs after the wait, because the init config and the
stored consent are not loaded before that point. A call after init
stays synchronous, so the operations of the calls that follow it
without an await keep their order in the queue.
The init wait in login and logout adds 171 B gzip to the page bundle.
The worker bundle is unchanged.
@sherwinski
sherwinski changed the base branch from feat/identity-verification to main October 12, 2026 23:31
@sherwinski sherwinski changed the title feat: [SDK-5242] wait for init in login, logout, and updateUserJwt feat: [SDK-5242] wait for init in login and logout Oct 12, 2026

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Multi-model review

Independent reviews from Claude Opus 5.5, GPT 5.6 Sol, and Grok 4.7. The wait / throw-before-init / post-wait consent sequencing looks sound and well tested. After-init stays synchronous as intended.

Act on

  • Class static block (Opus). static {} in OneSignal.ts is the first one in src/. The page bundle targets es2022 and does not downlevel syntax; static blocks parse only in Safari 16.4+ (Chrome 94+, Firefox 93+). Safari 16.0–16.3 still loads the ES6 bundle via isPushNotificationsSupported(). A field initializer (static _coreReadyPromise = new Promise(...)) avoids the parse break and still assigns _resolveCoreReady without a later field define resetting it.

Consider

  • Failed-init and consent-blocked calls resolve successfully (Opus + Grok). _awaitCore returns false and the public methods return undefined. That matches the PR write-up, but await OneSignal.login(...) then looks like a successful login and an updateUserJwt token is dropped. Same quiet return after the post-wait consent check. If this is the contract, a sentence on the public methods would help; otherwise reject when init will never complete.
  • User.* after a waiting login still no-ops (Grok). While init is in progress, OneSignal.User is still the placeholder new UserNamespace(false). login(); User.addTag(...) without await drops the tag; login later succeeds. Out of this PR’s stated scope, but the new wait makes that pattern look safe when it is not.

Noted / dismissed

  • GPT 5.6 Sol reported no findings.
  • Settling _coreReady before _delayedInit / initSaveState is intentional (user model + consent, not full session init). The app-id-change race is real but narrow.
  • Tests stub _settleCoreReady / _resolveCoreReady instead of a real early-return init; the call-order test does not record updateUserJwt in the sequence. Neither blocks merge.
  • _pendingInit starting true is pre-existing.
Open in Web View Automation 

Sent by Cursor Automation: PR Reviews

}

static Notifications = new NotificationsNamespace();
static Slidedown = new SlidedownNamespace();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Act on (Opus): This is the first class static block in src/. The page bundle is target: 'es2022' and terser does not lower syntax, so this ships as written. Static blocks parse only in Safari 16.4+; Safari 16.0–16.3 still loads OneSignalSDK.page.es6.js via isPushNotificationsSupported() and would SyntaxError the whole SDK.

A field initializer keeps the same “no later field define can reset the resolver” property:

static _coreReadyPromise = new Promise<void>((resolve) => {
  OneSignal._resolveCoreReady = resolve;
});

@@ -154,8 +195,10 @@ export default class OneSignal {
if (!idb) return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Consider (Opus + Grok): After a stopped or thrown init, waiters wake with _coreReady still false and the public method resolves successfully. Same quiet return after the post-wait consent check. Documented in the PR, but await login() / await updateUserJwt() cannot tell a real success from a skip, and a retry cannot wait — errorIfInitAlreadyCalled blocks a second init, and a settled resolver ignores another call. Worth rejecting when init will never complete, and leaving only the consent path as a non-throwing return.

@sherwinski sherwinski changed the title feat: [SDK-5242] wait for init in login and logout feat: [SDK-5242] wait for init in login and logout Oct 13, 2026

@abdulraqeeb33 abdulraqeeb33 left a comment

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.

Login waits only while init is already running, and a login ahead of init throws, so the deferred array still reaches init. Stored consent is loaded before that wait ends. The static block does not parse on Safari versions that still receive this bundle.

// `declare` emits no field define, so nothing can run after the block and reset these.
declare static _coreReadyPromise: Promise<void>;
declare static _resolveCoreReady: () => void;
static {

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.

static { } is a parse error on Safari 16.0 through 16.3. Those versions still load OneSignalSDK.page.es6.js through the legacy push check, and target: 'es2022' does not lower this. The rest of this file already runs there. A field initializer keeps the resolver:

static _coreReadyPromise = new Promise<void>((resolve) => {
  OneSignal._resolveCoreReady = resolve;
});

@fadi-george fadi-george left a comment

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.

Unsure about having this change

@sherwinski

Copy link
Copy Markdown
Contributor Author

Unsure about having this change

Yeah, I need to think this one through a bit more. Will revisit after all of the other Web work is done.

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