diff --git a/documentation/guides/integrating-the-react-web-sdk-in-a-react-app.md b/documentation/guides/integrating-the-react-web-sdk-in-a-react-app.md index f5359ca0..32fe960e 100644 --- a/documentation/guides/integrating-the-react-web-sdk-in-a-react-app.md +++ b/documentation/guides/integrating-the-react-web-sdk-in-a-react-app.md @@ -186,12 +186,16 @@ Inside the provider tree, use hooks to interact with the SDK: import { useEntryResolver, useOptimization, + useOptimizationActions, useOptimizationContext, } from '@contentful/optimization-react-web' function MyComponent() { - const { consent, identify, page, track, getFlag } = useOptimization() + const { consent, identify, page, track } = useOptimizationActions() + const optimization = useOptimization() const { resolveEntry } = useEntryResolver() + + optimization.getFlag('hero-copy') // SDK is guaranteed to be ready here } @@ -293,10 +297,10 @@ When your application policy depends on user choice, call `consent()` from the b or account settings flow that owns the user's choice: ```tsx -import { useOptimization } from '@contentful/optimization-react-web' +import { useOptimizationActions } from '@contentful/optimization-react-web' function ConsentBanner() { - const { consent } = useOptimization() + const { consent } = useOptimizationActions() return (
@@ -348,14 +352,12 @@ function ConsentStatus() { To revoke consent after it was previously accepted: ```tsx -function RevokeConsent() { - const { consent } = useOptimization() +import { useOptimizationActions } from '@contentful/optimization-react-web' - const handleRevoke = () => { - consent(false) - } +function RevokeConsent() { + const { consent } = useOptimizationActions() - return + return } ``` diff --git a/packages/web/frameworks/react-web-sdk/README.md b/packages/web/frameworks/react-web-sdk/README.md index d2ab307b..c1fdff87 100644 --- a/packages/web/frameworks/react-web-sdk/README.md +++ b/packages/web/frameworks/react-web-sdk/README.md @@ -153,14 +153,14 @@ end-user consent UI, seed accepted consent on `OptimizationRoot`: ``` When application policy depends on user choice, leave `defaults.consent` unset and call `consent()` -from the relevant control: +from `useOptimizationActions()` in the relevant control: ```tsx -import { useOptimization } from '@contentful/optimization-react-web' +import { useOptimizationActions } from '@contentful/optimization-react-web' function ConsentButton() { - const sdk = useOptimization() - return + const { consent } = useOptimizationActions() + return } ``` @@ -175,9 +175,24 @@ continuity should stay session-only. For cross-SDK consent guidance, see commit, outside render, and renders no children while the SDK is pending. In normal browser rendering this uses a layout-effect path so ready children can mount before the first visible paint. -Use `useOptimization()` when a component needs direct access to the instance for methods such as -`identify()`, `reset()`, or manual tracking. Use `useEntryResolver()` when a component needs manual -entry resolution without the `OptimizedEntry` wrapper: +Use the dedicated React SDK action hooks when components need common Optimization actions: + +```tsx +import { useOptimizationActions } from '@contentful/optimization-react-web' + +function ProductCta() { + const { track } = useOptimizationActions() + + return +} +``` + +Use `useOptimization()` when a component needs direct access to the SDK instance itself, and prefer +`useOptimizationActions()` when a component wants destructurable action methods such as `track()`, +`identify()`, `page()`, or `consent()`. + +Use `useEntryResolver()` when a component needs manual entry resolution without the `OptimizedEntry` +wrapper: `useOptimization()` returns the SDK instance itself. Keep that instance in a variable and call methods from it. Do not destructure SDK methods from the returned value because those methods rely diff --git a/packages/web/frameworks/react-web-sdk/package.json b/packages/web/frameworks/react-web-sdk/package.json index da7bd8b9..f148205c 100644 --- a/packages/web/frameworks/react-web-sdk/package.json +++ b/packages/web/frameworks/react-web-sdk/package.json @@ -121,8 +121,8 @@ "buildTools": { "bundleSize": { "gzipBudgets": { - "index.cjs": 3300, - "index.mjs": 2400 + "index.cjs": 3400, + "index.mjs": 2500 } } }, diff --git a/packages/web/frameworks/react-web-sdk/src/hooks/useOptimizationActions.ts b/packages/web/frameworks/react-web-sdk/src/hooks/useOptimizationActions.ts new file mode 100644 index 00000000..26ecbfb4 --- /dev/null +++ b/packages/web/frameworks/react-web-sdk/src/hooks/useOptimizationActions.ts @@ -0,0 +1,48 @@ +import { useMemo } from 'react' + +import type { OptimizationSdk } from '../context/OptimizationContext' +import { useOptimization } from './useOptimization' + +/** + * Bound Optimization SDK actions safe to destructure in React components. + * + * @public + */ +export interface UseOptimizationActionsResult { + readonly consent: OptimizationSdk['consent'] + readonly identify: OptimizationSdk['identify'] + readonly page: OptimizationSdk['page'] + readonly track: OptimizationSdk['track'] +} + +/** + * Returns bound Optimization SDK actions that are safe to destructure. + * + * @example + * ```tsx + * const { track, consent } = useOptimizationActions() + * await track({ event: 'purchase' }) + * consent(true) + * ``` + * + * @remarks + * This hook does not create a new SDK instance. It binds the most common + * actions from the existing SDK instance returned by `useOptimization()`. + * + * @public + */ +export function useOptimizationActions(): UseOptimizationActionsResult { + const sdk = useOptimization() + + return useMemo( + () => ({ + consent: (value) => { + sdk.consent(value) + }, + identify: async (payload) => await sdk.identify(payload), + page: async (payload) => await sdk.page(payload), + track: async (payload) => await sdk.track(payload), + }), + [sdk], + ) +} diff --git a/packages/web/frameworks/react-web-sdk/src/hooks/useOptimizationState.ts b/packages/web/frameworks/react-web-sdk/src/hooks/useOptimizationState.ts new file mode 100644 index 00000000..50e33598 --- /dev/null +++ b/packages/web/frameworks/react-web-sdk/src/hooks/useOptimizationState.ts @@ -0,0 +1,94 @@ +import { useCallback, useRef, useSyncExternalStore } from 'react' + +import type { OptimizationSdk } from '../context/OptimizationContext' +import { useOptimization } from './useOptimization' + +type OptimizationStates = OptimizationSdk['states'] +type ObservableValue = T extends { readonly current: infer V } ? V : never + +interface ObservableLike { + readonly current: T + readonly subscribe: (next: (value: T) => void) => { unsubscribe: () => void } +} + +function useObservableState(observable: ObservableLike): T { + const snapshotRef = useRef(observable.current) + const observableRef = useRef(observable) + + if (observableRef.current !== observable) { + const { current } = observable + observableRef.current = observable + snapshotRef.current = current + } + + const subscribe = useCallback( + (onStoreChange: () => void) => { + const subscription = observable.subscribe((value) => { + snapshotRef.current = value + onStoreChange() + }) + + return () => { + const { unsubscribe } = subscription + unsubscribe() + } + }, + [observable], + ) + + const getSnapshot = useCallback(() => snapshotRef.current, []) + + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot) +} + +/** + * Returns the current consent state. + * + * @public + */ +export function useConsentState(): ObservableValue { + const sdk = useOptimization() + return useObservableState(sdk.states.consent) +} + +/** + * Returns whether optimization data is currently available. + * + * @public + */ +export function useCanOptimizeState(): ObservableValue { + const sdk = useOptimization() + return useObservableState(sdk.states.canOptimize) +} + +/** + * Returns the latest emitted event payload. + * + * @public + */ +export function useEventStreamState(): ObservableValue { + const sdk = useOptimization() + return useObservableState(sdk.states.eventStream) +} + +/** + * Returns the current profile state. + * + * @public + */ +export function useProfileState(): ObservableValue { + const sdk = useOptimization() + return useObservableState(sdk.states.profile) +} + +/** + * Returns the current selected optimizations state. + * + * @public + */ +export function useSelectedOptimizationsState(): ObservableValue< + OptimizationStates['selectedOptimizations'] +> { + const sdk = useOptimization() + return useObservableState(sdk.states.selectedOptimizations) +} diff --git a/packages/web/frameworks/react-web-sdk/src/index.test.tsx b/packages/web/frameworks/react-web-sdk/src/index.test.tsx index 19cde1ca..de1274d0 100644 --- a/packages/web/frameworks/react-web-sdk/src/index.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/index.test.tsx @@ -14,11 +14,13 @@ import { useEntryResolver, useLiveUpdates, useOptimization, + useOptimizationActions, useOptimizationContext, useOptimizedEntry, type OptimizationContextValue, type OptimizationSdk, type UseEntryResolverResult, + type UseOptimizationActionsResult, } from './index' import { captureRenderError, @@ -37,7 +39,10 @@ const testConfig = { }, } -function renderClient(element: ReactElement): { unmount: () => void } { +function renderClient(element: ReactElement): { + rerender: (next: ReactElement) => void + unmount: () => void +} { const container = document.createElement('div') document.body.append(container) const root = createRoot(container) @@ -47,6 +52,11 @@ function renderClient(element: ReactElement): { unmount: () => void } { }) return { + rerender(next: ReactElement) { + act(() => { + root.render(next) + }) + }, unmount() { act(() => { root.unmount() @@ -72,6 +82,7 @@ describe('@contentful/optimization-react-web core providers', () => { expect(OptimizationRoot).toBeTypeOf('function') expect(useEntryResolver).toBeTypeOf('function') expect(useOptimization).toBeTypeOf('function') + expect(useOptimizationActions).toBeTypeOf('function') expect(useOptimizationContext).toBeTypeOf('function') expect(useOptimizedEntry).toBeTypeOf('function') expect(useLiveUpdates).toBeTypeOf('function') @@ -339,6 +350,82 @@ describe('@contentful/optimization-react-web core providers', () => { ]) }) + it('exposes bound SDK action hooks that are safe to destructure', async () => { + const consent = rs.fn(() => undefined) + const identify: OptimizationSdk['identify'] = rs.fn(async () => { + await Promise.resolve() + return undefined + }) + const page: OptimizationSdk['page'] = rs.fn(async () => { + await Promise.resolve() + return undefined + }) + const reset = rs.fn(() => undefined) + const setLocale = rs.fn(() => undefined) + const track: OptimizationSdk['track'] = rs.fn(async () => { + await Promise.resolve() + return undefined + }) + const trackClick: OptimizationSdk['trackClick'] = rs.fn(async () => { + await Promise.resolve() + return undefined + }) + const trackView: OptimizationSdk['trackView'] = rs.fn(async () => { + await Promise.resolve() + return undefined + }) + const captures: UseOptimizationActionsResult[] = [] + + function Probe(): null { + captures.push(useOptimizationActions()) + return null + } + + const sdk = createOptimizationSdk({ + consent, + identify, + page, + reset, + setLocale, + track, + trackClick, + trackView, + }) + + const rendered = renderClient( + + + , + ) + + rendered.rerender( + + + , + ) + + const [firstRender, secondRender] = captures + + expect(secondRender).toBeDefined() + if (!firstRender || !secondRender) { + throw new Error('Expected action-hook captures across renders') + } + + expect(secondRender).toBe(firstRender) + + firstRender.consent(true) + await firstRender.identify({ userId: 'user-1' }) + await firstRender.page({ properties: { title: 'Home' } }) + await firstRender.track({ event: 'purchase', properties: { revenue: 99 } }) + + expect(consent).toHaveBeenCalledWith(true) + expect(identify).toHaveBeenCalledWith({ userId: 'user-1' }) + expect(page).toHaveBeenCalledWith({ properties: { title: 'Home' } }) + expect(track).toHaveBeenCalledWith({ event: 'purchase', properties: { revenue: 99 } }) + + rendered.unmount() + }) + it('defaults liveUpdates to false in OptimizationRoot', () => { let capturedGlobalLiveUpdates = false diff --git a/packages/web/frameworks/react-web-sdk/src/index.ts b/packages/web/frameworks/react-web-sdk/src/index.ts index 30c70ed4..65689262 100644 --- a/packages/web/frameworks/react-web-sdk/src/index.ts +++ b/packages/web/frameworks/react-web-sdk/src/index.ts @@ -16,6 +16,8 @@ export { useLiveUpdates } from './hooks/useLiveUpdates' export { useMergeTagResolver } from './hooks/useMergeTagResolver' export type { UseMergeTagResolverResult } from './hooks/useMergeTagResolver' export { useOptimization, useOptimizationContext } from './hooks/useOptimization' +export { useOptimizationActions } from './hooks/useOptimizationActions' +export type { UseOptimizationActionsResult } from './hooks/useOptimizationActions' export { OptimizedEntry } from './optimized-entry/OptimizedEntry' export type { OptimizedEntryLoadingFallback, diff --git a/packages/web/frameworks/react-web-sdk/src/test/sdkTestUtils.tsx b/packages/web/frameworks/react-web-sdk/src/test/sdkTestUtils.tsx index 774d7592..adab3ed3 100644 --- a/packages/web/frameworks/react-web-sdk/src/test/sdkTestUtils.tsx +++ b/packages/web/frameworks/react-web-sdk/src/test/sdkTestUtils.tsx @@ -51,6 +51,49 @@ export function createObservable(current: T): ObservableLike { } } +export function createMutableCloningObservable(initial: T): { + emit: (value: T) => Promise + observable: ObservableLike +} { + const subscribers = new Set>() + let current = structuredClone(initial) + + const observable: ObservableLike = { + get current() { + return structuredClone(current) + }, + subscribe(next: RuntimeSubscriber) { + subscribers.add(next) + next(structuredClone(current)) + + return { + unsubscribe() { + subscribers.delete(next) + }, + } + }, + subscribeOnce(next: (value: NonNullable) => void) { + if (current !== undefined && current !== null) { + next(structuredClone(current) as NonNullable) + } + return { unsubscribe: () => undefined } + }, + } + + async function emit(value: T): Promise { + current = structuredClone(value) + + await act(async () => { + await Promise.resolve() + subscribers.forEach((subscriber) => { + subscriber(structuredClone(current)) + }) + }) + } + + return { emit, observable } +} + export function createTestEntry(id: string): TestEntry { return { fields: { title: id },