Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions android/src/legacy/java/com/FullStoryModule.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ public class FullStoryModule extends ReactContextBaseJavaModule {

FullStoryModule(ReactApplicationContext context) {
super(context);
FullStoryModuleImpl.initSessionListener(null);
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import android.util.Log;

import androidx.annotation.Nullable;

import com.facebook.react.bridge.Arguments;
import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.ReadableMap;
Expand All @@ -10,12 +12,17 @@
import com.fullstory.FSOnReadyListener;
import com.fullstory.FSSessionData;

import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import java.lang.reflect.Method;
public class FullStoryModuleImpl {

interface SessionStartedListener {
void onSessionStarted(WritableMap sessionData);
}

public static final String NAME = "FullStory";
private static final String TAG = "FullStoryModuleImpl";
public static final boolean reflectionSuccess;
Expand Down Expand Up @@ -65,39 +72,61 @@ public static void setUserVars(ReadableMap userVars) {
FS.setUserVars(toMap(userVars));
}

public static void onReady(Promise promise) {
if (promise == null) {
return;
}
private static final List<Promise> pendingOnReadyPromises = new ArrayList<>();

// we can only invoke the promise callback once, so create an AtomicReference
// to handle the logic
final AtomicReference<Promise> promiseOneShot = new AtomicReference(promise);
public static void initSessionListener(
@Nullable SessionStartedListener sessionStartedListener) {
synchronized (pendingOnReadyPromises) {
for (Promise p : pendingOnReadyPromises) {
// covering possible race condition only in development during hot reloading
p.reject("MODULE_RESET", "FullStory module was re-initialized");
}
pendingOnReadyPromises.clear();
}
FS.setReadyListener(new FSOnReadyListener() {
@Override
public void onReady(FSSessionData sessionData) {
// get the current value and set the new value to null
Promise promise = promiseOneShot.getAndSet(null);
if (promise == null) {
// this was already run once, so ignore
return;
}
resolveAndClearOnReadyPromises(sessionData);

WritableMap map = Arguments.createMap();

// add the replay start url
map.putString("replayStartUrl", sessionData.getCurrentSessionURL());
if (sessionStartedListener != null) {
sessionStartedListener.onSessionStarted(buildSessionMap(sessionData));
}
}
});
}

// add the replay now url
map.putString("replayNowUrl", FS.getCurrentSessionURL(true));
public static void onReady(Promise promise) {
if (promise == null) {
return;
}

// add the session id
map.putString("sessionId", FS.getCurrentSession());
synchronized (pendingOnReadyPromises) {
pendingOnReadyPromises.add(promise);
if (FS.getCurrentSession() != null) {
resolveAndClearOnReadyPromises(null);
}
}
}

// now resolve the promise
promise.resolve(map);
// Resolves and clears all pending onReady promises. Thread-safe.
private static void resolveAndClearOnReadyPromises(@Nullable FSSessionData sessionData) {
synchronized (pendingOnReadyPromises) {
WritableMap map = buildSessionMap(sessionData);
for (Promise p : pendingOnReadyPromises) {
p.resolve(map);
}
});
pendingOnReadyPromises.clear();
}
}

private static WritableMap buildSessionMap(@Nullable FSSessionData sessionData) {
WritableMap map = Arguments.createMap();
String replayStartUrl = sessionData != null ? sessionData.getCurrentSessionURL() : FS.getCurrentSessionURL();
map.putString("replayStartUrl", replayStartUrl != null ? replayStartUrl : "");
map.putString("replayNowUrl", FS.getCurrentSessionURL(true));
String sessionId = FS.getCurrentSession();
map.putString("sessionId", sessionId != null ? sessionId : "");
return map;
}

public static void getCurrentSession(Promise promise) {
Expand Down
3 changes: 3 additions & 0 deletions android/src/turbo/java/com/FullStoryModule.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ public class FullStoryModule extends NativeFullStorySpec {

FullStoryModule(ReactApplicationContext context) {
super(context);
FullStoryModuleImpl.initSessionListener(
sessionData -> emitOnSessionStarted(sessionData)
);
}

@Override
Expand Down
8 changes: 8 additions & 0 deletions ios/FullStory.h
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,19 @@
#import "FullStorySpec.h"
#endif

#ifdef RCT_NEW_ARCH_ENABLED
@interface FullStory : NativeFullStorySpecBase <FSDelegate>
@end

@interface FullStoryPrivate : NativeFullStoryPrivateSpecBase <FSDelegate>
@end
#else
@interface FullStory : NSObject <RCTBridgeModule, FSDelegate>
@end

@interface FullStoryPrivate : NSObject <RCTBridgeModule, FSDelegate>
@end
#endif

@interface FS(FSPrivate)
+ (void) _pageViewWithNonce:(NSUUID *)nonce name:(NSString *)pageName properties:(NSDictionary<NSString *, id> *)properties;
Expand Down
57 changes: 38 additions & 19 deletions ios/FullStory.mm
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,16 @@
#import "FSReactSwizzle.h"

@implementation FullStory {
RCTPromiseResolveBlock onReadyPromise;
NSMutableArray<RCTPromiseResolveBlock> *onReadyPromises;
}

- (instancetype)init {
self = [super init];
if (self) {
FS.delegate = self;
Copy link
Copy Markdown
Contributor Author

@RyanCommits RyanCommits Feb 25, 2026

Choose a reason for hiding this comment

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

We now set the delegate on app start, whereas before we set it when we call onReady

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.

Would we prefer to use load instead to be sure that this is set as early as possible, or can we be sure that this function is called at an appropriate time?

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.

On second thought, I think you're right, since we do actually use the instance variables, init makes sense.

onReadyPromises = [NSMutableArray new];
}
return self;
}

NSString *const PagesAPIError = @"Unable to access native FullStory pages API and call %@. Pages API will not function correctly. Make sure that your plugin is at least version 1.41; if the issue persists, please contact FullStory Support.";
Expand Down Expand Up @@ -170,33 +179,43 @@ - (void) getCurrentSessionURL:(RCTPromiseResolveBlock)resolve reject:(RCTPromise
}
}

- (void) fullstoryDidStartSession:(NSString *)sessionUrl {
// this method can be executed both by onReady below and by the Fullstory SDK,
// because this object is a delegate, so avoid any possible race
@synchronized (self) {
if (!onReadyPromise)
return;

// Resolves and clears all pending onReady promises.
// Must be called within @synchronized(self).
- (void) resolveOnReadyPromisesWithURL:(NSString *)sessionUrl {
if (onReadyPromises.count > 0) {
NSMutableDictionary *dict = [NSMutableDictionary new];
dict[@"replayStartUrl"] = sessionUrl;
dict[@"replayNowUrl"] = [FS currentSessionURL: true];
dict[@"sessionId"] = FS.currentSession;
onReadyPromise(dict);
dict[@"replayNowUrl"] = [FS currentSessionURL: true] ?: @"";
dict[@"sessionId"] = FS.currentSession ?: @"";
for (RCTPromiseResolveBlock p in onReadyPromises) {
p(dict);
}
[onReadyPromises removeAllObjects];
}
}

onReadyPromise = nil;
- (void) fullstoryDidStartSession:(NSString *)sessionUrl {
@synchronized (self) {
[self resolveOnReadyPromisesWithURL:sessionUrl];
}

#ifdef RCT_NEW_ARCH_ENABLED
[self emitOnSessionStarted:@{
@"replayStartUrl": sessionUrl,
@"replayNowUrl": [FS currentSessionURL: true] ?: @"",
@"sessionId": FS.currentSession ?: @"",
}];
#endif
}

- (void) onReady:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
@synchronized (self) {
onReadyPromise = [resolve copy];
}
FS.delegate = self;
[onReadyPromises addObject:[resolve copy]];

if (FS.currentSessionURL) {
/* If we already have a session running, fire the promise
* immediately. */
[self fullstoryDidStartSession:FS.currentSessionURL];
if (FS.currentSessionURL) {
// If we already have a session running, resolve all pending promises directly
[self resolveOnReadyPromisesWithURL:FS.currentSessionURL];
}
}
}

Expand Down
15 changes: 13 additions & 2 deletions src/NativeFullStory.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
import type { TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';
import { OnReadyResponse } from './fullstoryInterface';
import type { EventEmitter } from 'react-native/Libraries/Types/CodegenTypes';

// needs to be defined here, cannot be imported
export type FSSessionData = {
replayStartUrl: string;
replayNowUrl: string;
sessionId: string;
};

export interface Spec extends TurboModule {
anonymize(): void;
identify(uid: string, userVars?: Object): void;
setUserVars(userVars: Object): void;
onReady(): Promise<OnReadyResponse>;
onReady(): Promise<FSSessionData>;
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.

wont this break existing code?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

No it won't because:

  1. OnReadyResponse was never exported as a public type, so we won't break any imports
  2. FSSessionData has the same structural typing, so it's backward compatible.

getCurrentSession(): Promise<string>;
getCurrentSessionURL(): Promise<string>;
consent(userConsents: boolean): void;
Expand All @@ -18,6 +25,10 @@ export interface Spec extends TurboModule {
startPage(nonce: string, pageName: string, pageProperties?: Object): void;
endPage(uuid: string): void;
updatePage(uuid: string, pageProperties: Object): void;
// Not exposed in the public API. Must be declared here because TurboModule codegen
// requires all native event emitters to be defined on the Spec interface. Used
// internally by onReady() to support the listener-based overload.
readonly onSessionStarted: EventEmitter<FSSessionData>;
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.

Just to confirm @RyanCommits - this isn't exposed, correct? If so, can we add a comment here. Just confusing that this is an exported interface.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes exactly, it is not exposed. It's defined for the purposes of codegen in React Native, and exported for the purposes of us picking off the methods we DO want to expose with:

type SharedMethods = Pick<
  Spec,
  | 'anonymize'
  | 'identify'
  | 'onReady'
  | 'getCurrentSession'
  | 'getCurrentSessionURL'
  | 'consent'
  | 'event'
  | 'shutdown'
  | 'restart'
  | 'resetIdleTimer'
>;

}

export default TurboModuleRegistry.get<Spec>('FullStory');
34 changes: 18 additions & 16 deletions src/fullstoryInterface.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { FSSessionData, Spec } from './NativeFullStory';

declare const global: {
RN$Bridgeless?: boolean;
__turboModuleProxy?: unknown;
Expand All @@ -10,11 +12,6 @@ interface UserVars {
email?: string;
[key: string]: any;
}
export type OnReadyResponse = {
replayStartUrl: string;
replayNowUrl: string;
sessionId: string;
};

export enum LogLevel {
Log = 0, // Clamps to Debug on iOS
Expand All @@ -33,20 +30,25 @@ export type SupportedFSAttributes =
| 'dataComponent'
| 'dataSourceFile';

export declare type FullstoryStatic = {
type SharedMethods = Pick<
Spec,
| 'anonymize'
| 'identify'
| 'onReady'
| 'getCurrentSession'
| 'getCurrentSessionURL'
| 'consent'
| 'event'
| 'shutdown'
| 'restart'
| 'resetIdleTimer'
>;

export declare type FullstoryStatic = SharedMethods & {
LogLevel: typeof LogLevel;
anonymize(): void;
identify(uid: string, userVars?: UserVars): void;
setUserVars(userVars: UserVars): void;
onReady(): Promise<OnReadyResponse>;
getCurrentSession(): Promise<string>;
getCurrentSessionURL(): Promise<string>;
consent(userConsents: boolean): void;
event(eventName: string, eventProperties: Object): void;
shutdown(): void;
restart(): void;
log(logLevel: LogLevel, message: string): void;
resetIdleTimer(): void;
onReady(listener: (data: FSSessionData) => void): { remove: () => void };
};

declare module 'react' {
Expand Down
Loading