Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
Binary file modified .yarn/install-state.gz
Binary file not shown.
37 changes: 36 additions & 1 deletion PingSampleApp/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,20 @@ import { logger } from '@ping-identity/rn-logger';
import MaterialIcon from 'react-native-vector-icons/MaterialIcons';
import { colors } from './src/styles/colors';

/** Minimal shape required to patch `defaultProps.style` on a core RN component class. */
type ComponentWithDefaultStyle = {
defaultProps?: {
style?: unknown;
[key: string]: unknown;
};
};

/**
* Navigation route map for the sample app's native stack.
*
* Each key is a screen name; the value is the params type that screen accepts
* (`undefined` means no params).
*/
export type RootStackParamList = {
Home: undefined;
Configuration: undefined;
Expand All @@ -60,15 +67,37 @@ const Stack = createNativeStackNavigator<RootStackParamList>();

/**
* Root sample app component that wires navigation and initializes demo clients.
*
* ## Structure
*
* 1. **Profile selection** — `sampleAppClientProfiles` (defined in `src/clients.ts`) lists
* pre-built client configurations. The active profile is stored as `selectedProfileKey`
* and defaults to `DEFAULT_SAMPLE_APP_CLIENT_PROFILE_KEY`.
*
* 2. **Client initialization** — whenever `selectedProfile` changes the component calls
* `journeyClient.init()` inside a guarded async effect. Init errors are surfaced as an
* inline message instead of crashing.
*
* 3. **Context providers** — `JourneyProvider` and `OidcProvider` expose the active
* clients to every screen via React context so screens do not import clients directly.
*
* 4. **Navigation** — a `NativeStackNavigator` registers all demo screens.
*
* 5. **Global SDK setup** — `configureLogger` and `configureBrowser` are called once per
Comment thread
pingidentity-gaurav marked this conversation as resolved.
Outdated
* effect cycle to set sensible defaults. The browser logger is intentionally separate
* from the global logger so browser diagnostics can be filtered independently.
*/
export default function App() {
// Dedicated browser logger keeps browser diagnostics separate from global SDK logging.
const browserLogger = useMemo(() => logger({ level: 'debug' }), []);
/** Non-null while the journey client failed to initialise after a profile switch. */
const [initError, setInitError] = useState<string | null>(null);
/** Key of the active client profile, defaults to the first configured profile. */
const [selectedProfileKey, setSelectedProfileKey] = useState<string>(
DEFAULT_SAMPLE_APP_CLIENT_PROFILE_KEY,
);

/** Resolved profile object for the current selection, falling back to the first profile. */
const selectedProfile = useMemo(
() =>
sampleAppClientProfiles.find(
Expand Down Expand Up @@ -98,7 +127,13 @@ export default function App() {
console.warn('Failed to load MaterialIcons font', error);
});

// Initialize selected Journey client at app startup and after profile switches.
/**
* Calls `journeyClient.init()` for the active profile.
*
* Wrapped in an async function so the effect callback itself stays synchronous
* (React effects must not return a Promise). The `isMounted` flag prevents
* state updates after the component unmounts or the effect re-runs.
*/
const initializeJourneyClient = async (): Promise<void> => {
try {
await Promise.resolve(selectedProfile.journeyClient.init());
Expand Down
64 changes: 64 additions & 0 deletions PingTestRunner/__tests__/integration/journey.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,9 +196,73 @@ describe('@ping-identity/rn-journey — integration', () => {
await expect(client.dispose()).resolves.not.toThrow();
expect(mock.dispose).toHaveBeenCalledTimes(1);
});

it('dispose() followed immediately by init() re-registers with native without error', async () => {
// Regression: rapid dispose → init must not reuse a stale journeyId nor
// fail because a concurrent native cleanup removed the newly registered handle.
const mock = makeMock({
configureJourney: jest.fn()
.mockResolvedValueOnce('journey-id-first')
.mockResolvedValueOnce('journey-id-second'),
});
const mod = await loadJourney(mock);
const client = mod.createJourneyClient(VALID_CONFIG);

await client.init();
expect(mock.configureJourney).toHaveBeenCalledTimes(1);

await client.dispose();
expect(mock.dispose).toHaveBeenCalledTimes(1);

// Immediately re-initialise — must call configureJourney again and return a fresh id.
const newId = await client.init();
expect(mock.configureJourney).toHaveBeenCalledTimes(2);
expect(newId).toBe('journey-id-second');
});

it('concurrent dispose() + init() calls do not leave journeyId in an inconsistent state', async () => {
Comment thread
pingidentity-gaurav marked this conversation as resolved.
// When init() is called while dispose() is still pending, init() sees the
// existing journeyId and returns it without re-configuring. Once dispose()
// resolves it nulls the internal id, so the next init() gets a fresh one.
let resolveDispose!: () => void;
const deferredDispose = new Promise<undefined>((res) => {
resolveDispose = () => res(undefined);
});
const mock = makeMock({
dispose: jest.fn(() => deferredDispose),
configureJourney: jest.fn()
.mockResolvedValueOnce('journey-id-first')
.mockResolvedValueOnce('journey-id-second'),
});
const mod = await loadJourney(mock);
const client = mod.createJourneyClient(VALID_CONFIG);

await client.init();

// Start dispose and init concurrently — init is in-flight while dispose is still pending.
const disposeP = client.dispose();
const initP = client.init();
Comment thread
pingidentity-gaurav marked this conversation as resolved.
resolveDispose(); // unblock the native dispose
await Promise.all([disposeP, initP]);

// After dispose settles it nulls the internal id. A subsequent init()
// must call configureJourney again and return a fresh id.
const freshId = await client.init();
expect(freshId).toBe('journey-id-second');
expect(mock.configureJourney).toHaveBeenCalledTimes(2);
});
});

describe('failure paths', () => {
Comment thread
pingidentity-gaurav marked this conversation as resolved.
it('init() propagates native errors', async () => {
const mock = makeMock({
configureJourney: jest.fn(async () => { throw new Error('configure failed'); }),
});
const mod = await loadJourney(mock);
const client = mod.createJourneyClient(VALID_CONFIG);
await expect(client.init()).rejects.toThrow('configure failed');
});

it('start() propagates native errors', async () => {
const mock = makeMock({
start: jest.fn(async () => { throw new Error('Journey start failed'); }),
Expand Down
48 changes: 27 additions & 21 deletions packages/journey/ios/RNPingJourneyCommon.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ private actor JourneyLifecycleCoordinator {
}
}

/// Thread-safe value box for passing results across `@Sendable` closure boundaries
/// where access is guaranteed to be sequential (write inside an awaited coordinator
/// enqueue, read after the enqueue returns).
private final class Ref<T>: @unchecked Sendable {
var value: T?
}

/// Shared iOS runtime orchestration for Journey bridge calls.
@objcMembers
public final class RNPingJourneyCommon: NSObject {
Expand Down Expand Up @@ -57,15 +64,6 @@ public final class RNPingJourneyCommon: NSObject {
/// Lifecycle coordinator ensuring ordered configure/cleanup execution.
private static let lifecycleCoordinator = JourneyLifecycleCoordinator()

/// Applies Journey callback resolver registration in serialized lifecycle order.
private static func configureAsync() async {
await lifecycleCoordinator.enqueue {
CoreRuntime.setJourneyCallbackResolver { journeyId in
stateStore.callbacks(for: journeyId)
}
}
}

/// Clears Journey runtime state in serialized lifecycle order.
private static func cleanupAsync() async {
await lifecycleCoordinator.enqueue {
Expand All @@ -75,14 +73,6 @@ public final class RNPingJourneyCommon: NSObject {
}
}

/// Initializes common runtime wiring for Journey bridge operations.
@objc
public static func configure() {
Task {
await configureAsync()
}
}

/// Releases shared runtime state.
@objc
public static func cleanup() {
Expand Down Expand Up @@ -114,14 +104,30 @@ public final class RNPingJourneyCommon: NSObject {
}

Task { @MainActor in
await configureAsync()
// Build the journey client outside the coordinator — no shared state is touched here.
let journey: Journey
do {
let journey = try await JourneyClientFactory().build(payload)
let journeyId = await journeyRegistry.register(JourneyHandle(journey: journey))
promise.resolve(journeyId)
journey = try await JourneyClientFactory().build(payload)
} catch {
promise.reject(JourneyErrorMapper.map(error, code: .initError))
return
}

// Register atomically with the callback resolver setup inside the coordinator so
// that a concurrent cleanupAsync() cannot remove the newly registered handle
// between the resolver being set and register() being called.
let idRef = Ref<String>()
await lifecycleCoordinator.enqueue {
CoreRuntime.setJourneyCallbackResolver { journeyId in
Comment thread
pingidentity-gaurav marked this conversation as resolved.
stateStore.callbacks(for: journeyId)
}
idRef.value = await journeyRegistry.register(JourneyHandle(journey: journey))
}
guard let journeyId = idRef.value else {
promise.reject(JourneyErrorMapper.state(code: .initError, message: "Failed to register Journey instance"))
return
}
promise.resolve(journeyId)
}
}

Expand Down
Loading