Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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 (
<div>
Expand Down Expand Up @@ -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 <button onClick={handleRevoke}>Revoke Consent</button>
return <button onClick={() => consent(false)}>Revoke Consent</button>
}
```

Expand Down
29 changes: 22 additions & 7 deletions packages/web/frameworks/react-web-sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <button onClick={() => sdk.consent(true)}>Accept</button>
const { consent } = useOptimizationActions()
return <button onClick={() => consent(true)}>Accept</button>
}
```

Expand All @@ -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 <button onClick={() => track({ event: 'purchase' })}>Buy now</button>
}
```

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
Expand Down
4 changes: 2 additions & 2 deletions packages/web/frameworks/react-web-sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -121,8 +121,8 @@
"buildTools": {
"bundleSize": {
"gzipBudgets": {
"index.cjs": 3300,
"index.mjs": 2400
"index.cjs": 3400,
"index.mjs": 2500
Comment on lines +124 to +125

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.

Because of the recent changes to React hooks, this was not sufficient, so I increased it slightly. I'm not sure if that is okay; otherwise, I can find another solution.

}
}
},
Expand Down
Original file line number Diff line number Diff line change
@@ -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<UseOptimizationActionsResult>(
() => ({
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],
)
}
Original file line number Diff line number Diff line change
@@ -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> = T extends { readonly current: infer V } ? V : never

interface ObservableLike<T> {
readonly current: T
readonly subscribe: (next: (value: T) => void) => { unsubscribe: () => void }
}

function useObservableState<T>(observable: ObservableLike<T>): T {
const snapshotRef = useRef<T>(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<OptimizationStates['consent']> {
const sdk = useOptimization()
return useObservableState(sdk.states.consent)
}

/**
* Returns whether optimization data is currently available.
*
* @public
*/
export function useCanOptimizeState(): ObservableValue<OptimizationStates['canOptimize']> {
const sdk = useOptimization()
return useObservableState(sdk.states.canOptimize)
}

/**
* Returns the latest emitted event payload.
*
* @public
*/
export function useEventStreamState(): ObservableValue<OptimizationStates['eventStream']> {
const sdk = useOptimization()
return useObservableState(sdk.states.eventStream)
}

/**
* Returns the current profile state.
*
* @public
*/
export function useProfileState(): ObservableValue<OptimizationStates['profile']> {
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)
}
89 changes: 88 additions & 1 deletion packages/web/frameworks/react-web-sdk/src/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,13 @@ import {
useEntryResolver,
useLiveUpdates,
useOptimization,
useOptimizationActions,
useOptimizationContext,
useOptimizedEntry,
type OptimizationContextValue,
type OptimizationSdk,
type UseEntryResolverResult,
type UseOptimizationActionsResult,
} from './index'
import {
captureRenderError,
Expand All @@ -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)
Expand All @@ -47,6 +52,11 @@ function renderClient(element: ReactElement): { unmount: () => void } {
})

return {
rerender(next: ReactElement) {
act(() => {
root.render(next)
})
},
unmount() {
act(() => {
root.unmount()
Expand All @@ -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')
Expand Down Expand Up @@ -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(
<OptimizationContext.Provider value={{ sdk, isReady: true, error: undefined }}>
<Probe />
</OptimizationContext.Provider>,
)

rendered.rerender(
<OptimizationContext.Provider value={{ sdk, isReady: true, error: undefined }}>
<Probe />
</OptimizationContext.Provider>,
)

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

Expand Down
2 changes: 2 additions & 0 deletions packages/web/frameworks/react-web-sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading