Skip to content

fix(analytics): preserve super property changes made while storage loads are in flight - #468

Open
cpruijsen wants to merge 7 commits into
mixpanel:masterfrom
cpruijsen:fix/issue-270-5114780f
Open

cpruijsen wants to merge 7 commits into
mixpanel:masterfrom
cpruijsen:fix/issue-270-5114780f

Conversation

@cpruijsen

@cpruijsen cpruijsen commented Oct 12, 2026 •

Copy link
Copy Markdown

Fixes #270. In JavaScript mode, super property changes made while an AsyncStorage load is pending are now applied in call order instead of being lost.

loadSuperProperties() assigned _superProperties[token] unconditionally when its read resolved, and the public wrappers are fire-and-forget. That caused two failures:

  • A change made during an in-flight load was overwritten when the read landed. Repeated unregisterSuperProperty calls right after init(), while the constructor's load was pending, could leave only the first removal applied.
  • A change made before the first load finished saw getSuperProperties() as undefined (unregister threw), or merged onto an empty base and persisted over the stored properties.

Changes:

  • javascript/mixpanel-persistent.js: updateSuperProperties() bumps a per-token version, and loadSuperProperties() skips its assignment if the version changed during the read. This is needed because the constructor, initialize() and reset() issue loads outside any ordered path.
  • javascript/mixpanel-main.js: a per-token queue applies registerSuperProperties, registerSuperPropertiesOnce, unregisterSuperProperty, clearSuperProperties and reset in call order while a load or earlier change is pending. When state is already loaded the queue is bypassed (queueWhenLoaded = false), so mutations stay synchronous as existing callers and tests expect. reset passes true so its storage wipe orders against later changes. A failed queued change does not block later ones, and its caller still sees the rejection.
  • Mutators load through mixpanelPersistent.loadSuperProperties() when state is absent, the same idiom getSuperProperties() uses, so early calls merge onto stored properties.
  • track() and getSuperProperties() wait on the queue. track() captures event identity before waiting, so a concurrent identify does not rewrite an earlier event. reset() clears through updateSuperProperties() so a pre-reset read cannot restore old values, and optOutTracking() resets through the same queue.

I chose the queue over a version guard alone, which covers only the reported unregister case, and over an unconditional await this.getSuperProperties(token) in each mutator. That one is shorter but makes every mutation async, which breaks three existing tests that assert synchronous behavior when loaded, and it still lets a clear or reset run out of order. Happy to change direction if you prefer one of those.

__tests__/super-properties.test.js adds 15 cases using mocked storage whose reads stay pending until released. Against master, 11 fail, including the reported three-unregister sequence ({} expected, superProp2 and superProp3 remain). The other 4 guard ordering that master already gets right, and all 15 pass with the change.

Accepting an array of names in unregisterSuperProperty, as suggested in the issue, is left out because it is an API addition.

…ads are in flight

loadSuperProperties() assigns _superProperties[token] unconditionally once its AsyncStorage read resolves, so any register/unregister/clear issued while the read was in flight was silently overwritten — e.g. calling unregisterSuperProperty for several names in a row could leave all but the first removal discarded. Calls made before the first load resolved also threw inside the async method, since getSuperProperties() still returned undefined for delete.

updateSuperProperties() now bumps a per-token version and loadSuperProperties() skips assigning a read issued before a mutation. The super property mutators await the pending/initial load via the same lazy-load path getSuperProperties() already uses before reading state. reset() clears through updateSuperProperties() so an in-flight read cannot resurrect properties after a reset.
…e first load

Mutators that waited for the initial load used the object getSuperProperties() returned, so two register calls queued behind the same load each built on that snapshot and the second discarded the first. Read the current state after the wait instead.
@cpruijsen
cpruijsen requested review from a team and ketanmixpanel October 12, 2026 13:34
@greptile-apps

greptile-apps Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 4/5

[Medium impact] The changes affect JavaScript-mode property updates, reads, event tracking, and reset.

Fix identity ordering across queued resets before merging.

Findings

  1. P1 Events keep the previous user ▶

Reviews (3) · Last reviewed commit: "test(analytics): cover the queue-tail cl..." · Reviewed by Greptile

Comment thread javascript/mixpanel-main.js
Comment thread __tests__/super-properties.test.js Outdated
…age loads

Queue register, registerOnce, unregister, clear and reset per token while a load or an earlier change is pending, and make track wait for the queue. A clear or reset that follows a pending registration now wins, and the next event includes the registration. Add tests for the registerSuperPropertiesOnce wait and the reset clear.
Comment thread javascript/mixpanel-main.js Outdated
…changes

getSuperProperties now waits for the per-token change queue like track
does, so an awaited read after a register/unregister call reflects it;
the queued mutators load through mixpanelPersistent directly so they do
not wait on their own queue entry. optOutTracking routes its reset
through reset(), and reset is queued even when properties are already
loaded, so a change issued around a reset applies in call order instead
of being undone by the in-flight wipe.
Comment on lines 149 to +155
const identityProps = {
distinct_id: this.mixpanelPersistent.getDistinctId(token),
$device_id: this.mixpanelPersistent.getDeviceId(token),
$user_id: this.mixpanelPersistent.getUserId(token),
};
if (this._superPropertiesQueue[token]) {
await this._superPropertiesQueue[token];

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Events keep the previous user

track() now copies identity before waiting for a queued reset(). With a signed-in user, calling registerSuperProperties(...); reset(); track("AfterLogout") during a pending storage read captures the old user's IDs. The event waits for the reset to finish but still sends those saved IDs, attributing activity after logout to the previous user.

Preserve identity ordering across queued resets while keeping events tracked before identify() on their original identity.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

This is the same on master. reset() replaces the in-memory IDs only after its storage removes finish, so reset(); track() without an await sends the old IDs there too. I checked it on both. This PR does not change when identity is read relative to a reset, so I am leaving it out of scope here.

This branch has not been deployed

No deployments
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.

Bug: unregisterSuperProperty only removes the first property when called multiple times

1 participant