diff --git a/platforms/javascript/web-frameworks/react-web/README.md b/platforms/javascript/web-frameworks/react-web/README.md index 7b857e2f..09cd6782 100644 --- a/platforms/javascript/web-frameworks/react-web/README.md +++ b/platforms/javascript/web-frameworks/react-web/README.md @@ -1,15 +1,14 @@ # React Web SDK -Scaffold package for `@contentful/optimization-react-web`. +React Web SDK package for `@contentful/optimization-react-web`. ## Status -This package is currently scaffold-only and pre-release. +Core root/provider primitives are implemented. -- Runtime behavior beyond the current placeholder surface is intentionally out of scope for this - phase. -- React Web API semantics and parity coverage with other SDK layers are tracked as planned follow-up - work. +- `OptimizationProvider` + `useOptimization()` context behavior +- `LiveUpdatesProvider` + `useLiveUpdates()` global live updates context +- `OptimizationRoot` provider composition and defaults ## Purpose @@ -40,5 +39,100 @@ pnpm dev - package metadata and dual module exports - `rslib`/`rsbuild`/`rstest`/TypeScript baseline aligned with Web SDK patterns -- placeholder React-facing API surface in `src/` (provider/root/personalization/analytics/hooks) +- core provider/root/context primitives in `src/` - scaffold dev dashboard harness in `dev/` for consent, identify/reset, state, events, and entries + +## Usage + +### Initialization + +Pass configuration props directly to `OptimizationRoot` (recommended) or `OptimizationProvider`. The +SDK is initialized internally by the provider. + +```tsx +import { OptimizationRoot } from '@contentful/optimization-react-web' + +function App() { + return ( + + + + ) +} +``` + +Available config props: + +| Prop | Type | Required | Description | +| --------------------------- | ----------------------------------- | -------- | ------------------------------------------------ | +| `clientId` | `string` | Yes | Your Contentful Optimization client identifier | +| `environment` | `string` | No | Contentful environment (defaults to `'main'`) | +| `analytics` | `CoreStatefulAnalyticsConfig` | No | Analytics/Insights API configuration | +| `personalization` | `CoreStatefulPersonalizationConfig` | No | Personalization/Experience API configuration | +| `app` | `App` | No | Application metadata for events | +| `autoTrackEntryInteraction` | `AutoTrackEntryInteractionOptions` | No | Automatic entry interaction tracking options | +| `logLevel` | `LogLevels` | No | Minimum log level for console output | +| `liveUpdates` | `boolean` | No | Enable global live updates (defaults to `false`) | + +### Provider Composition + +`OptimizationRoot` composition order: + +1. `OptimizationProvider` (outermost) +2. `LiveUpdatesProvider` +3. application children + +### Hooks + +- `useOptimization()` returns the current SDK instance. +- `useOptimization()` throws if used outside `OptimizationProvider`. +- `useLiveUpdates()` throws if used outside `LiveUpdatesProvider`. + +### Live Updates Resolution Semantics + +Consumers should resolve live updates behavior with: + +```ts +const isLiveUpdatesEnabled = componentLiveUpdates ?? liveUpdatesContext.globalLiveUpdates +``` + +This gives: + +- component-level `liveUpdates` prop override first +- then root-level `liveUpdates` +- then default `false` + +## Singleton Behavior + +The underlying `@contentful/optimization-web` SDK enforces a singleton pattern. Only one +`Optimization` runtime can exist at a time (attached to `window.optimization`). Attempting to +initialize a second runtime will throw an error. + +When using the config-as-props pattern, the provider uses a `useRef` to ensure the instance is only +created once, even across React re-renders or StrictMode double-rendering. + +## Testing + +When testing components that use the Optimization providers, pass test config props: + +```tsx +import { render } from '@testing-library/react' +import { OptimizationRoot } from '@contentful/optimization-react-web' + +render( + + + , +) +``` diff --git a/platforms/javascript/web-frameworks/react-web/dev/App.tsx b/platforms/javascript/web-frameworks/react-web/dev/App.tsx index dcf103d8..a3963282 100644 --- a/platforms/javascript/web-frameworks/react-web/dev/App.tsx +++ b/platforms/javascript/web-frameworks/react-web/dev/App.tsx @@ -1,4 +1,5 @@ import type { ReactElement } from 'react' +import { useLiveUpdates, useOptimization } from '../src' const sectionTitles = [ 'Consent', @@ -10,13 +11,25 @@ const sectionTitles = [ ] as const export function App(): ReactElement { + const { globalLiveUpdates } = useLiveUpdates() + const optimization = useOptimization() + return (

@contentful/optimization-react-web

-

Scaffold dashboard for React Web SDK development.

+

Minimal live integration with OptimizationRoot config props.

+
+
+

SDK Wiring

+

OptimizationRoot: Active

+

{`Optimization SDK: ${optimization.constructor.name}`}

+

{`Global liveUpdates: ${globalLiveUpdates ? 'ON' : 'OFF'}`}

+
+
+
{sectionTitles.map((title) => (
diff --git a/platforms/javascript/web-frameworks/react-web/dev/main.tsx b/platforms/javascript/web-frameworks/react-web/dev/main.tsx index 4f4dffc3..a29976c2 100644 --- a/platforms/javascript/web-frameworks/react-web/dev/main.tsx +++ b/platforms/javascript/web-frameworks/react-web/dev/main.tsx @@ -1,9 +1,32 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' +import { OptimizationRoot } from '../src' import { App } from './App' import './styles.css' +const DEFAULT_CLIENT_ID = 'mock-client-id' +const DEFAULT_ENVIRONMENT = 'main' +const DEFAULT_INSIGHTS_BASE_URL = 'http://localhost:8000/insights/' +const DEFAULT_EXPERIENCE_BASE_URL = 'http://localhost:8000/experience/' + +function getEnvString(key: string): string | undefined { + const value: unknown = Reflect.get(import.meta.env as object, key) + + if (typeof value !== 'string') { + return undefined + } + + const normalized = value.trim() + return normalized.length > 0 ? normalized : undefined +} + +const clientId = getEnvString('PUBLIC_NINETAILED_CLIENT_ID') ?? DEFAULT_CLIENT_ID +const environment = getEnvString('PUBLIC_NINETAILED_ENVIRONMENT') ?? DEFAULT_ENVIRONMENT +const insightsBaseUrl = getEnvString('PUBLIC_INSIGHTS_API_BASE_URL') ?? DEFAULT_INSIGHTS_BASE_URL +const experienceBaseUrl = + getEnvString('PUBLIC_EXPERIENCE_API_BASE_URL') ?? DEFAULT_EXPERIENCE_BASE_URL + const rootElement = document.getElementById('root') if (!rootElement) { @@ -12,6 +35,14 @@ if (!rootElement) { createRoot(rootElement).render( - + + + , ) diff --git a/platforms/javascript/web-frameworks/react-web/src/context/LiveUpdatesContext.tsx b/platforms/javascript/web-frameworks/react-web/src/context/LiveUpdatesContext.tsx new file mode 100644 index 00000000..d2eed27c --- /dev/null +++ b/platforms/javascript/web-frameworks/react-web/src/context/LiveUpdatesContext.tsx @@ -0,0 +1,7 @@ +import { createContext } from 'react' + +export interface LiveUpdatesContextValue { + readonly globalLiveUpdates: boolean +} + +export const LiveUpdatesContext = createContext(null) diff --git a/platforms/javascript/web-frameworks/react-web/src/context/OptimizationContext.tsx b/platforms/javascript/web-frameworks/react-web/src/context/OptimizationContext.tsx new file mode 100644 index 00000000..114e594b --- /dev/null +++ b/platforms/javascript/web-frameworks/react-web/src/context/OptimizationContext.tsx @@ -0,0 +1,9 @@ +import { createContext } from 'react' + +import type { OptimizationWebSdk } from '../types' + +export interface OptimizationContextValue { + readonly instance: OptimizationWebSdk +} + +export const OptimizationContext = createContext(null) diff --git a/platforms/javascript/web-frameworks/react-web/src/hooks/useLiveUpdates.ts b/platforms/javascript/web-frameworks/react-web/src/hooks/useLiveUpdates.ts new file mode 100644 index 00000000..77219861 --- /dev/null +++ b/platforms/javascript/web-frameworks/react-web/src/hooks/useLiveUpdates.ts @@ -0,0 +1,12 @@ +import { useContext } from 'react' +import { LiveUpdatesContext, type LiveUpdatesContextValue } from '../context/LiveUpdatesContext' + +export function useLiveUpdates(): LiveUpdatesContextValue { + const context = useContext(LiveUpdatesContext) + + if (!context) { + throw new Error('useLiveUpdates must be used within a LiveUpdatesProvider') + } + + return context +} diff --git a/platforms/javascript/web-frameworks/react-web/src/hooks/useOptimization.ts b/platforms/javascript/web-frameworks/react-web/src/hooks/useOptimization.ts index bfd10394..a5653489 100644 --- a/platforms/javascript/web-frameworks/react-web/src/hooks/useOptimization.ts +++ b/platforms/javascript/web-frameworks/react-web/src/hooks/useOptimization.ts @@ -1,14 +1,17 @@ -import type { OptimizationWebSdkOrNull } from '../types' +import { useContext } from 'react' -export interface UseOptimizationResult { - readonly optimization: OptimizationWebSdkOrNull - readonly isReady: boolean -} +import { OptimizationContext } from '../context/OptimizationContext' +import type { OptimizationWebSdk } from '../types' + +export function useOptimization(): OptimizationWebSdk { + const context = useContext(OptimizationContext) -export function useOptimization(): UseOptimizationResult { - // Scaffold placeholder: reads from provider context will be added later. - return { - optimization: null, - isReady: false, + if (!context) { + throw new Error( + 'useOptimization must be used within an OptimizationProvider. ' + + 'Make sure to wrap your component tree with .', + ) } + + return context.instance } diff --git a/platforms/javascript/web-frameworks/react-web/src/index.test.ts b/platforms/javascript/web-frameworks/react-web/src/index.test.ts deleted file mode 100644 index c30a4891..00000000 --- a/platforms/javascript/web-frameworks/react-web/src/index.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { - OptimizationProvider, - OptimizationRoot, - useAnalytics, - useOptimization, - usePersonalization, -} from './index' - -describe('@contentful/optimization-react-web scaffold', () => { - it('exports scaffold API symbols', () => { - expect(OptimizationProvider).toBeTypeOf('function') - expect(OptimizationRoot).toBeTypeOf('function') - expect(useOptimization).toBeTypeOf('function') - expect(usePersonalization).toBeTypeOf('function') - expect(useAnalytics).toBeTypeOf('function') - }) - - it('returns inert placeholder values', async () => { - const optimization = useOptimization() - const personalization = usePersonalization() - const analytics = useAnalytics() - - expect(optimization.isReady).toBe(false) - expect(optimization.optimization).toBeNull() - expect(personalization.resolveEntry({ id: 'entry-1' })).toEqual({ id: 'entry-1' }) - - await expect(analytics.identify('user-1')).resolves.toBeUndefined() - await expect(analytics.track({ event: 'view' })).resolves.toBeUndefined() - await expect(analytics.reset()).resolves.toBeUndefined() - }) -}) diff --git a/platforms/javascript/web-frameworks/react-web/src/index.test.tsx b/platforms/javascript/web-frameworks/react-web/src/index.test.tsx new file mode 100644 index 00000000..21267900 --- /dev/null +++ b/platforms/javascript/web-frameworks/react-web/src/index.test.tsx @@ -0,0 +1,224 @@ +import Optimization from '@contentful/optimization-web' +import type { ReactElement } from 'react' +import { renderToString } from 'react-dom/server' +import { + LiveUpdatesProvider, + OptimizationContext, + OptimizationProvider, + OptimizationRoot, + useAnalytics, + useLiveUpdates, + useOptimization, + usePersonalization, +} from './index' + +const testConfig = { + clientId: 'test-client-id', + environment: 'main', + analytics: { baseUrl: 'http://localhost:8000/insights/' }, + personalization: { baseUrl: 'http://localhost:8000/experience/' }, +} + +function cleanupGlobalInstance(): void { + if (typeof window !== 'undefined' && window.optimization) { + window.optimization.destroy() + } +} + +describe('@contentful/optimization-react-web core providers', () => { + void beforeEach(() => { + cleanupGlobalInstance() + }) + + void afterEach(() => { + cleanupGlobalInstance() + }) + + it('exports core API symbols', () => { + expect(OptimizationContext).toBeDefined() + expect(LiveUpdatesProvider).toBeTypeOf('function') + expect(OptimizationProvider).toBeTypeOf('function') + expect(OptimizationRoot).toBeTypeOf('function') + expect(useOptimization).toBeTypeOf('function') + expect(useLiveUpdates).toBeTypeOf('function') + expect(usePersonalization).toBeTypeOf('function') + expect(useAnalytics).toBeTypeOf('function') + }) + + it('creates optimization instance from config props via OptimizationProvider', () => { + let capturedInstance: Optimization | null = null + + function Probe(): null { + capturedInstance = useOptimization() + return null + } + + renderToString( + + + , + ) + + expect(capturedInstance).toBeInstanceOf(Optimization) + }) + + it('provides optimization and live updates from OptimizationRoot', () => { + let capturedInstance: Optimization | null = null + let capturedGlobalLiveUpdates: boolean | null = null + + function Probe(): null { + capturedInstance = useOptimization() + const { globalLiveUpdates } = useLiveUpdates() + capturedGlobalLiveUpdates = globalLiveUpdates + return null + } + + renderToString( + + + , + ) + + expect(capturedInstance).toBeInstanceOf(Optimization) + expect(capturedGlobalLiveUpdates).toBe(true) + }) + + it('throws actionable error when useOptimization is called outside provider', () => { + function BrokenProbe(): null { + useOptimization() + return null + } + + let capturedError: unknown = null + + try { + renderToString() + } catch (error: unknown) { + capturedError = error + } + + expect(capturedError).toBeInstanceOf(Error) + if (!(capturedError instanceof Error)) { + throw new Error('Expected useOptimization to throw an Error') + } + + expect(capturedError.message).toContain( + 'useOptimization must be used within an OptimizationProvider', + ) + expect(capturedError.message).toContain('') + }) + + it('defaults liveUpdates to false in OptimizationRoot', () => { + let capturedGlobalLiveUpdates = false + + function Probe(): null { + const { globalLiveUpdates } = useLiveUpdates() + capturedGlobalLiveUpdates = globalLiveUpdates + return null + } + + renderToString( + + + , + ) + + expect(capturedGlobalLiveUpdates).toBe(false) + }) + + it('throws actionable error when useLiveUpdates is called outside provider', () => { + function BrokenProbe(): null { + useLiveUpdates() + return null + } + + let capturedError: unknown = null + + try { + renderToString() + } catch (error: unknown) { + capturedError = error + } + + expect(capturedError).toBeInstanceOf(Error) + if (!(capturedError instanceof Error)) { + throw new Error('Expected useLiveUpdates to throw an Error') + } + + expect(capturedError.message).toContain( + 'useLiveUpdates must be used within a LiveUpdatesProvider', + ) + }) + + it('supports live updates fallback semantics for dependent components', () => { + const results: boolean[] = [] + + function Probe({ liveUpdates }: { liveUpdates?: boolean }): null { + const context = useLiveUpdates() + const isLive = liveUpdates ?? context.globalLiveUpdates + results.push(isLive) + return null + } + + function FirstScenario(): ReactElement { + return ( + + + + + ) + } + + function SecondScenario(): ReactElement { + return ( + + + + ) + } + + renderToString() + cleanupGlobalInstance() + renderToString() + + expect(results).toEqual([true, false, true]) + }) + + it('keeps non-core hooks inert placeholders for now', async () => { + const personalization = usePersonalization() + const analytics = useAnalytics() + + expect(personalization.resolveEntry({ id: 'entry-1' })).toEqual({ id: 'entry-1' }) + await expect(analytics.identify('user-1')).resolves.toBeUndefined() + await expect(analytics.track({ event: 'view' })).resolves.toBeUndefined() + await expect(analytics.reset()).resolves.toBeUndefined() + }) +}) diff --git a/platforms/javascript/web-frameworks/react-web/src/index.ts b/platforms/javascript/web-frameworks/react-web/src/index.ts index 26d1e543..ecf3eca0 100644 --- a/platforms/javascript/web-frameworks/react-web/src/index.ts +++ b/platforms/javascript/web-frameworks/react-web/src/index.ts @@ -1,9 +1,15 @@ export { useAnalytics } from './analytics/useAnalytics' export type { UseAnalyticsResult } from './analytics/useAnalytics' +export { LiveUpdatesContext } from './context/LiveUpdatesContext' +export type { LiveUpdatesContextValue } from './context/LiveUpdatesContext' +export { OptimizationContext } from './context/OptimizationContext' +export type { OptimizationContextValue } from './context/OptimizationContext' +export { useLiveUpdates } from './hooks/useLiveUpdates' export { useOptimization } from './hooks/useOptimization' -export type { UseOptimizationResult } from './hooks/useOptimization' export { usePersonalization } from './personalization/usePersonalization' export type { UsePersonalizationResult } from './personalization/usePersonalization' +export { LiveUpdatesProvider } from './provider/LiveUpdatesProvider' +export type { LiveUpdatesProviderProps } from './provider/LiveUpdatesProvider' export { OptimizationProvider } from './provider/OptimizationProvider' export type { OptimizationProviderProps } from './provider/OptimizationProvider' export { OptimizationRoot } from './root/OptimizationRoot' diff --git a/platforms/javascript/web-frameworks/react-web/src/provider/LiveUpdatesProvider.tsx b/platforms/javascript/web-frameworks/react-web/src/provider/LiveUpdatesProvider.tsx new file mode 100644 index 00000000..54cba874 --- /dev/null +++ b/platforms/javascript/web-frameworks/react-web/src/provider/LiveUpdatesProvider.tsx @@ -0,0 +1,17 @@ +import type { PropsWithChildren, ReactElement } from 'react' +import { LiveUpdatesContext } from '../context/LiveUpdatesContext' + +export interface LiveUpdatesProviderProps extends PropsWithChildren { + readonly globalLiveUpdates?: boolean +} + +export function LiveUpdatesProvider({ + children, + globalLiveUpdates = false, +}: LiveUpdatesProviderProps): ReactElement { + return ( + + {children} + + ) +} diff --git a/platforms/javascript/web-frameworks/react-web/src/provider/OptimizationProvider.tsx b/platforms/javascript/web-frameworks/react-web/src/provider/OptimizationProvider.tsx index ae8beae9..e17b8463 100644 --- a/platforms/javascript/web-frameworks/react-web/src/provider/OptimizationProvider.tsx +++ b/platforms/javascript/web-frameworks/react-web/src/provider/OptimizationProvider.tsx @@ -1,12 +1,19 @@ -import type { PropsWithChildren, ReactElement } from 'react' +import Optimization, { type OptimizationWebConfig } from '@contentful/optimization-web' +import { useRef, type PropsWithChildren, type ReactElement } from 'react' -import type { OptimizationWebSdkOrNull } from '../types' +import { OptimizationContext } from '../context/OptimizationContext' -export interface OptimizationProviderProps extends PropsWithChildren { - readonly optimization?: OptimizationWebSdkOrNull -} +export interface OptimizationProviderProps extends PropsWithChildren {} + +export function OptimizationProvider(props: OptimizationProviderProps): ReactElement { + const { children, ...config } = props + const instanceRef = useRef(null) + + instanceRef.current ??= new Optimization(config) -export function OptimizationProvider({ children }: OptimizationProviderProps): ReactElement { - // Scaffold placeholder: context wiring will be implemented in follow-up tickets. - return <>{children} + return ( + + {children} + + ) } diff --git a/platforms/javascript/web-frameworks/react-web/src/root/OptimizationRoot.tsx b/platforms/javascript/web-frameworks/react-web/src/root/OptimizationRoot.tsx index 68fa8c1f..b006b369 100644 --- a/platforms/javascript/web-frameworks/react-web/src/root/OptimizationRoot.tsx +++ b/platforms/javascript/web-frameworks/react-web/src/root/OptimizationRoot.tsx @@ -1,12 +1,23 @@ -import type { PropsWithChildren, ReactElement } from 'react' +import type { ReactElement } from 'react' -import { OptimizationProvider } from '../provider/OptimizationProvider' -import type { OptimizationWebSdkOrNull } from '../types' +import { LiveUpdatesProvider } from '../provider/LiveUpdatesProvider' +import { + OptimizationProvider, + type OptimizationProviderProps, +} from '../provider/OptimizationProvider' -export interface OptimizationRootProps extends PropsWithChildren { - readonly optimization?: OptimizationWebSdkOrNull +export type OptimizationRootProps = OptimizationProviderProps & { + readonly liveUpdates?: boolean } -export function OptimizationRoot({ children, optimization }: OptimizationRootProps): ReactElement { - return {children} +export function OptimizationRoot({ + children, + liveUpdates = false, + ...providerProps +}: OptimizationRootProps): ReactElement { + return ( + + {children} + + ) }