Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
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 { configureLogger, 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((profile) => profile.key === selectedProfileKey) ??
Expand Down Expand Up @@ -96,7 +125,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
51 changes: 51 additions & 0 deletions PingTestRunner/__tests__/integration/journey.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,57 @@ 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.
// Ensure that if dispose and a subsequent init are issued back-to-back (no await
// between them), the client ends up in a valid configured state.
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 but don't await — then immediately start init.
const disposePromise = client.dispose();
resolveDispose(); // unblock the native dispose
await disposePromise;

const newId = await client.init();
expect(typeof newId).toBe('string');
expect(mock.configureJourney).toHaveBeenCalledTimes(2);
});
});

describe('failure paths', () => {
Comment thread
pingidentity-gaurav marked this conversation as resolved.
Expand Down
27 changes: 23 additions & 4 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 @@ -114,14 +121,26 @@ 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 configureAsync() returning 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))
}
promise.resolve(idRef.value!)
Comment thread
pingidentity-gaurav marked this conversation as resolved.
Outdated
}
}

Expand Down
Loading