diff --git a/example/src/components/Embedded/Embedded.tsx b/example/src/components/Embedded/Embedded.tsx index 189293d37..7af8bfc18 100644 --- a/example/src/components/Embedded/Embedded.tsx +++ b/example/src/components/Embedded/Embedded.tsx @@ -1,3 +1,5 @@ +import { useIsFocused } from '@react-navigation/native'; +import { useCallback, useState } from 'react'; import { Alert, Modal, @@ -7,15 +9,16 @@ import { TouchableOpacity, View, } from 'react-native'; -import { useCallback, useState } from 'react'; +import { SafeAreaView } from 'react-native-safe-area-context'; + import { + EmbeddedSessionManager, Iterable, - type IterableEmbeddedMessage, - type IterableEmbeddedViewConfig, IterableEmbeddedView, IterableEmbeddedViewType, + type IterableEmbeddedMessage, + type IterableEmbeddedViewConfig, } from '@iterable/react-native-sdk'; -import { SafeAreaView } from 'react-native-safe-area-context'; import styles from './Embedded.styles'; @@ -23,6 +26,7 @@ const DEFAULT_CONFIG_JSON = `{ }`; export const Embedded = () => { + const isFocused = useIsFocused(); const [placementIdsInput, setPlacementIdsInput] = useState(''); const [embeddedMessages, setEmbeddedMessages] = useState< IterableEmbeddedMessage[] @@ -44,18 +48,6 @@ export const Embedded = () => { const idsToFetch = parsedPlacementIds.length > 0 ? parsedPlacementIds : null; - const syncEmbeddedMessages = useCallback(() => { - Iterable.embeddedManager.syncMessages(); - }, []); - - const startEmbeddedSession = useCallback(() => { - Iterable.embeddedManager.startSession(); - }, []); - - const endEmbeddedSession = useCallback(() => { - Iterable.embeddedManager.endSession(); - }, []); - const getEmbeddedMessages = useCallback(() => { Iterable.embeddedManager .getMessages(idsToFetch) @@ -97,9 +89,6 @@ export const Embedded = () => { )} - - Enter placement IDs to fetch embedded messages - Select View Type: @@ -164,15 +153,6 @@ export const Embedded = () => { - - Sync messages - - - Start session - - - End session - Set view config @@ -230,14 +210,16 @@ export const Embedded = () => { - {embeddedMessages.map((message) => ( - - ))} + + {embeddedMessages.map((message) => ( + + ))} + diff --git a/src/core/classes/Iterable.ts b/src/core/classes/Iterable.ts index 983fab49b..3a5cc0172 100644 --- a/src/core/classes/Iterable.ts +++ b/src/core/classes/Iterable.ts @@ -1108,6 +1108,8 @@ export class Iterable { } ); } + + Iterable.embeddedManager.syncMessages(); } } diff --git a/src/embedded/components/EmbeddedSessionDevWarning/EmbeddedSessionDevWarning.styles.ts b/src/embedded/components/EmbeddedSessionDevWarning/EmbeddedSessionDevWarning.styles.ts new file mode 100644 index 000000000..1b175b538 --- /dev/null +++ b/src/embedded/components/EmbeddedSessionDevWarning/EmbeddedSessionDevWarning.styles.ts @@ -0,0 +1,23 @@ +import { StyleSheet } from 'react-native'; + +const ERROR_BANNER_BACKGROUND = '#FEF2F2'; +const ERROR_BANNER_BORDER = '#FECACA'; +const ERROR_BANNER_TEXT = '#991B1B'; + +export const styles = StyleSheet.create({ + banner: { + alignSelf: 'stretch', + backgroundColor: ERROR_BANNER_BACKGROUND, + borderColor: ERROR_BANNER_BORDER, + borderWidth: 1, + marginBottom: 8, + paddingHorizontal: 12, + paddingVertical: 8, + }, + text: { + color: ERROR_BANNER_TEXT, + fontSize: 12, + fontWeight: '600', + lineHeight: 16, + }, +}); diff --git a/src/embedded/components/EmbeddedSessionDevWarning/EmbeddedSessionDevWarning.test.tsx b/src/embedded/components/EmbeddedSessionDevWarning/EmbeddedSessionDevWarning.test.tsx new file mode 100644 index 000000000..8f19bf8e0 --- /dev/null +++ b/src/embedded/components/EmbeddedSessionDevWarning/EmbeddedSessionDevWarning.test.tsx @@ -0,0 +1,20 @@ +import { render } from '@testing-library/react-native'; + +import { EmbeddedSessionDevWarning } from './EmbeddedSessionDevWarning'; + +describe('EmbeddedSessionDevWarning', () => { + it('renders nothing when not visible', () => { + const { queryByRole } = render( + + ); + expect(queryByRole('alert')).toBeNull(); + }); + + it('renders warning text when visible', () => { + const { getByText } = render( + + ); + expect(getByText(/IterableEmbeddedBanner/)).toBeTruthy(); + expect(getByText(/EmbeddedSessionManager/)).toBeTruthy(); + }); +}); diff --git a/src/embedded/components/EmbeddedSessionDevWarning/EmbeddedSessionDevWarning.tsx b/src/embedded/components/EmbeddedSessionDevWarning/EmbeddedSessionDevWarning.tsx new file mode 100644 index 000000000..756e3edab --- /dev/null +++ b/src/embedded/components/EmbeddedSessionDevWarning/EmbeddedSessionDevWarning.tsx @@ -0,0 +1,34 @@ +import { Text, View } from 'react-native'; + +import { styles } from './EmbeddedSessionDevWarning.styles'; + +interface EmbeddedSessionDevWarningProps { + /** When true, shows the dev-only banner. */ + visible: boolean; + /** Name of the component (for the message). */ + componentName: string; +} + +/** + * Banner when embedded views are not wrapped in EmbeddedSessionManager`. + */ +export const EmbeddedSessionDevWarning = ({ + visible, + componentName, +}: EmbeddedSessionDevWarningProps) => { + if (!visible) { + return null; + } + + const message = `[Iterable] ${componentName}: wrap this component in so embedded session tracking works correctly.`; + + return ( + + {message} + + ); +}; diff --git a/src/embedded/components/EmbeddedSessionDevWarning/index.ts b/src/embedded/components/EmbeddedSessionDevWarning/index.ts new file mode 100644 index 000000000..1f2bce97f --- /dev/null +++ b/src/embedded/components/EmbeddedSessionDevWarning/index.ts @@ -0,0 +1,2 @@ +export * from './EmbeddedSessionDevWarning'; +export { EmbeddedSessionDevWarning as default } from './EmbeddedSessionDevWarning'; diff --git a/src/embedded/components/EmbeddedSessionManager.test.tsx b/src/embedded/components/EmbeddedSessionManager.test.tsx new file mode 100644 index 000000000..c47161e13 --- /dev/null +++ b/src/embedded/components/EmbeddedSessionManager.test.tsx @@ -0,0 +1,98 @@ +import { render } from '@testing-library/react-native'; +import { Text } from 'react-native'; + +import { Iterable } from '../../core/classes/Iterable'; +import { EmbeddedSessionManager } from './EmbeddedSessionManager'; + +describe('EmbeddedSessionManager', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(Iterable.embeddedManager, 'startSession').mockImplementation( + () => {} + ); + jest.spyOn(Iterable.embeddedManager, 'endSession').mockImplementation( + () => {} + ); + }); + + it('renders its children', () => { + const { getByTestId } = render( + + hello + + ); + + expect(getByTestId('embedded-child')).toBeTruthy(); + }); + + it('starts session on mount and ends session on unmount', () => { + const { unmount } = render( + + child + + ); + + expect(Iterable.embeddedManager.startSession).toHaveBeenCalledTimes(1); + expect(Iterable.embeddedManager.endSession).not.toHaveBeenCalled(); + + unmount(); + + expect(Iterable.embeddedManager.endSession).toHaveBeenCalledTimes(1); + }); + + it('does not double track session for nested wrappers', () => { + const { unmount } = render( + + + nested child + + + ); + + expect(Iterable.embeddedManager.startSession).toHaveBeenCalledTimes(1); + + unmount(); + + expect(Iterable.embeddedManager.endSession).toHaveBeenCalledTimes(1); + }); + + it('does not start session when isActive is false', () => { + const { unmount } = render( + + child + + ); + + expect(Iterable.embeddedManager.startSession).not.toHaveBeenCalled(); + + unmount(); + + expect(Iterable.embeddedManager.endSession).not.toHaveBeenCalled(); + }); + + it('ends session when isActive becomes false and restarts when true again', () => { + const { rerender } = render( + + child + + ); + + expect(Iterable.embeddedManager.startSession).toHaveBeenCalledTimes(1); + + rerender( + + child + + ); + + expect(Iterable.embeddedManager.endSession).toHaveBeenCalledTimes(1); + + rerender( + + child + + ); + + expect(Iterable.embeddedManager.startSession).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/embedded/components/EmbeddedSessionManager.tsx b/src/embedded/components/EmbeddedSessionManager.tsx new file mode 100644 index 000000000..6c9fe280d --- /dev/null +++ b/src/embedded/components/EmbeddedSessionManager.tsx @@ -0,0 +1,49 @@ +import type { PropsWithChildren, ReactNode } from 'react'; +import { useContext, useEffect } from 'react'; + +import { Iterable } from '../../core/classes/Iterable'; +import { EmbeddedSessionContext } from '../context/EmbeddedSessionContext'; + +export interface EmbeddedSessionManagerProps { + children?: ReactNode; + /** + * When `false`, this wrapper does not start an embedded session (e.g. host + * screen not focused). Defaults to `true`. + * + * This is not necessary to use. It is only useful if you want to avoid + * starting the session when the screen is hidden but still technically there. + */ + isActive?: boolean; +} + +/** + * Wraps embedded content and tracks an embedded session for its lifecycle. + * + * If nested, only the top-most wrapper starts and ends the session. + * + * There should only be one EmbeddedSessionManager per screen. + */ +export const EmbeddedSessionManager = ({ + children, + isActive = true, +}: PropsWithChildren) => { + const hasActiveParentSession = useContext(EmbeddedSessionContext); + + useEffect(() => { + if (hasActiveParentSession || !isActive) { + return; + } + + Iterable.embeddedManager.startSession(); + + return () => { + Iterable.embeddedManager.endSession(); + }; + }, [hasActiveParentSession, isActive]); + + return ( + + {children} + + ); +}; diff --git a/src/embedded/components/IterableEmbeddedView.test.tsx b/src/embedded/components/IterableEmbeddedView.test.tsx index 4bcc47dcf..49f610869 100644 --- a/src/embedded/components/IterableEmbeddedView.test.tsx +++ b/src/embedded/components/IterableEmbeddedView.test.tsx @@ -1,6 +1,8 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import type { ReactElement } from 'react'; import { render } from '@testing-library/react-native'; +import { EmbeddedSessionContext } from '../context/EmbeddedSessionContext'; import { IterableEmbeddedViewType } from '../enums/IterableEmbeddedViewType'; import { IterableEmbeddedView } from './IterableEmbeddedView'; import { IterableEmbeddedBanner } from './IterableEmbeddedBanner'; @@ -20,6 +22,12 @@ jest.mock('./IterableEmbeddedNotification', () => ({ IterableEmbeddedNotification: jest.fn(() => null), })); +function renderWithEmbeddedSession(ui: ReactElement) { + return render( + {ui} + ); +} + describe('IterableEmbeddedView', () => { const mockMessage = { metadata: { @@ -46,7 +54,7 @@ describe('IterableEmbeddedView', () => { describe('View Type Rendering', () => { it('should render IterableEmbeddedCard when viewType is Card', () => { - render( + renderWithEmbeddedSession( { }); it('should render IterableEmbeddedNotification when viewType is Notification', () => { - render( + renderWithEmbeddedSession( { }); it('should render IterableEmbeddedBanner when viewType is Banner', () => { - render( + renderWithEmbeddedSession( { }); it('should render null for invalid viewType', () => { - render( + renderWithEmbeddedSession( { }); it('should render null for undefined viewType', () => { - render( + renderWithEmbeddedSession( { describe('Props Passing', () => { it('should pass message prop to Card component', () => { - render( + renderWithEmbeddedSession( { }); it('should pass message prop to Banner component', () => { - render( + renderWithEmbeddedSession( { }); it('should pass message prop to Notification component', () => { - render( + renderWithEmbeddedSession( { }); it('should pass config prop to child component', () => { - render( + renderWithEmbeddedSession( { }); it('should pass onButtonClick prop to child component', () => { - render( + renderWithEmbeddedSession( { }); it('should pass all props to child component', () => { - render( + renderWithEmbeddedSession( { describe('Component Memoization', () => { it('should memoize component selection based on viewType', () => { - const { rerender } = render( + const { rerender } = renderWithEmbeddedSession( { }; rerender( - + + + ); // Should still render Card component (memoization means same component reference) @@ -243,7 +253,7 @@ describe('IterableEmbeddedView', () => { }); it('should update component when viewType changes', () => { - const { rerender } = render( + const { rerender } = renderWithEmbeddedSession( { // Re-render with different viewType rerender( - + + + ); expect(IterableEmbeddedBanner).toHaveBeenCalledTimes(1); @@ -269,7 +281,7 @@ describe('IterableEmbeddedView', () => { describe('Edge Cases', () => { it('should handle null config gracefully', () => { - render( + renderWithEmbeddedSession( { }); it('should handle undefined config gracefully', () => { - render( + renderWithEmbeddedSession( { }); it('should handle missing onButtonClick gracefully', () => { - render( + renderWithEmbeddedSession( { it('should handle numeric viewType values correctly', () => { // Test with numeric value 0 (Banner) - render(); + renderWithEmbeddedSession(); expect(IterableEmbeddedBanner).toHaveBeenCalledTimes(1); jest.clearAllMocks(); // Test with numeric value 1 (Card) - render(); + renderWithEmbeddedSession(); expect(IterableEmbeddedCard).toHaveBeenCalledTimes(1); jest.clearAllMocks(); // Test with numeric value 2 (Notification) - render(); + renderWithEmbeddedSession(); expect(IterableEmbeddedNotification).toHaveBeenCalledTimes(1); }); }); @@ -337,7 +349,7 @@ describe('IterableEmbeddedView', () => { describe('Component Type Verification', () => { it('should render correct component type for each enum value', () => { // Verify Banner enum value - const bannerResult = render( + const bannerResult = renderWithEmbeddedSession( { jest.clearAllMocks(); // Verify Card enum value - const cardResult = render( + const cardResult = renderWithEmbeddedSession( { jest.clearAllMocks(); // Verify Notification enum value - render( + renderWithEmbeddedSession( { + const showEmbeddedSessionWarning = + useWarnIfOutsideEmbeddedSession(COMPONENT_NAME); + const Cmp = useMemo(() => { switch (viewType) { case IterableEmbeddedViewType.Card: @@ -42,5 +49,11 @@ export const IterableEmbeddedView = ({ } }, [viewType]); + if (showEmbeddedSessionWarning) { + return ( + + ); + } + return Cmp ? : null; }; diff --git a/src/embedded/components/index.ts b/src/embedded/components/index.ts index 15af78aba..174436b33 100644 --- a/src/embedded/components/index.ts +++ b/src/embedded/components/index.ts @@ -1,3 +1,4 @@ +export * from './EmbeddedSessionManager'; export * from './IterableEmbeddedBanner'; export * from './IterableEmbeddedCard'; export * from './IterableEmbeddedNotification'; diff --git a/src/embedded/context/EmbeddedSessionContext.ts b/src/embedded/context/EmbeddedSessionContext.ts new file mode 100644 index 000000000..ee6322684 --- /dev/null +++ b/src/embedded/context/EmbeddedSessionContext.ts @@ -0,0 +1,6 @@ +import { createContext } from 'react'; + +/** + * `true` when the tree is under `EmbeddedSessionManager`. + */ +export const EmbeddedSessionContext = createContext(false); diff --git a/src/embedded/context/index.ts b/src/embedded/context/index.ts new file mode 100644 index 000000000..732647039 --- /dev/null +++ b/src/embedded/context/index.ts @@ -0,0 +1 @@ +export * from './EmbeddedSessionContext'; diff --git a/src/embedded/hooks/index.ts b/src/embedded/hooks/index.ts index cbca753d9..4c0859369 100644 --- a/src/embedded/hooks/index.ts +++ b/src/embedded/hooks/index.ts @@ -1 +1,2 @@ export * from './useEmbeddedView'; +export * from './useWarnIfOutsideEmbeddedSession'; diff --git a/src/embedded/hooks/useWarnIfOutsideEmbeddedSession.test.tsx b/src/embedded/hooks/useWarnIfOutsideEmbeddedSession.test.tsx new file mode 100644 index 000000000..e783561aa --- /dev/null +++ b/src/embedded/hooks/useWarnIfOutsideEmbeddedSession.test.tsx @@ -0,0 +1,53 @@ +import type { ReactNode } from 'react'; +import { act, renderHook } from '@testing-library/react-native'; + +import { EmbeddedSessionContext } from '../context/EmbeddedSessionContext'; +import { useWarnIfOutsideEmbeddedSession } from './useWarnIfOutsideEmbeddedSession'; + +describe('useWarnIfOutsideEmbeddedSession', () => { + it('logs a warning when not under EmbeddedSessionManager', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + const { result } = renderHook(() => + useWarnIfOutsideEmbeddedSession('TestComponent') + ); + + await act(async () => { + await Promise.resolve(); + }); + + expect(result.current).toBe(true); + expect(warnSpy).toHaveBeenCalledTimes(1); + const firstArg = warnSpy.mock.calls[0]?.[0]; + expect(firstArg).toBeDefined(); + const message = String(firstArg); + expect(message).toContain('TestComponent'); + expect(message).toContain('EmbeddedSessionManager'); + + warnSpy.mockRestore(); + }); + + it('does not log when under EmbeddedSessionManager', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + + const { result } = renderHook( + () => useWarnIfOutsideEmbeddedSession('TestComponent'), + { wrapper } + ); + + await act(async () => { + await Promise.resolve(); + }); + + expect(result.current).toBe(false); + expect(warnSpy).not.toHaveBeenCalled(); + + warnSpy.mockRestore(); + }); +}); diff --git a/src/embedded/hooks/useWarnIfOutsideEmbeddedSession.ts b/src/embedded/hooks/useWarnIfOutsideEmbeddedSession.ts new file mode 100644 index 000000000..09df7eff1 --- /dev/null +++ b/src/embedded/hooks/useWarnIfOutsideEmbeddedSession.ts @@ -0,0 +1,24 @@ +import { useContext, useEffect } from 'react'; + +import { EmbeddedSessionContext } from '../context/EmbeddedSessionContext'; + +/** + * When the embedded UI component is not under `EmbeddedSessionManager`, logs + * `console.warn` and returns `true` so callers can show on-screen dev text. + */ +export function useWarnIfOutsideEmbeddedSession( + componentName: string +): boolean { + const isInsideEmbeddedSession = useContext(EmbeddedSessionContext); + const isOutsideEmbeddedSession = !isInsideEmbeddedSession; + + useEffect(() => { + if (isOutsideEmbeddedSession) { + console.warn( + `[Iterable] ${componentName} should be rendered inside so embedded session tracking works correctly.` + ); + } + }, [componentName, isOutsideEmbeddedSession]); + + return isOutsideEmbeddedSession; +} diff --git a/src/index.tsx b/src/index.tsx index b4ba8f5cf..ee622a0c2 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -33,9 +33,12 @@ export type { IterableRetryPolicy, } from './core/types'; export { + EmbeddedSessionManager, IterableEmbeddedManager, IterableEmbeddedView, IterableEmbeddedViewType, + useEmbeddedView, + type EmbeddedSessionManagerProps, type IterableEmbeddedComponentProps, type IterableEmbeddedMessage, type IterableEmbeddedMessageElements,