diff --git a/docs/6-rendering-react.md b/docs/6-rendering-react.md index 43e7ce13..7717483c 100644 --- a/docs/6-rendering-react.md +++ b/docs/6-rendering-react.md @@ -111,16 +111,20 @@ Your entire file should look like this: ```tsx import { GraphClient } from '@optimizely/cms-sdk'; -import { OptimizelyComponent } from '@optimizely/cms-sdk/react/server'; +import { + OptimizelyComponent, + withAppContext, +} from '@optimizely/cms-sdk/react/server'; import React from 'react'; type Props = { params: Promise<{ slug: string[]; }>; + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; }; -export default async function Page({ params }: Props) { +export async function Page({ params }: Props) { const { slug } = await params; const client = new GraphClient(process.env.OPTIMIZELY_GRAPH_SINGLE_KEY!, { @@ -130,9 +134,71 @@ export default async function Page({ params }: Props) { return ; } + +export default withAppContext(Page); ``` -Go again to http://localhost:3000/en. You should see your page +Go again to . You should see your page + +### Understanding `withAppContext` + +The `withAppContext` HOC wraps your page component to provide request-scoped context: + +```tsx +export async function Page({ params }: Props) { + // ... component logic +} + +export default withAppContext(Page); +``` + +**What it does:** + +**Initializes context storage** - Sets up isolated, request-scoped storage for context data. This is required when using the context system in React Server Components. + +**When do you need it:** + +Since `getPreviewContent` automatically populates context with preview data, `withAppContext` is **optional for preview pages**. Use it when: + +- You need context initialized before fetching content (e.g., for manual `setContextData` calls) +- You're using context for non-preview data +- You want to ensure context is available throughout the component tree + +**Benefits:** + +- **Request isolation** - Each request gets its own context storage (critical for server components) +- **No prop drilling** - Access context data anywhere in your component tree +- **Framework-agnostic** - Works with any React Server Components framework + +### Accessing Context in Components + +Any component can access the context data without props: + +```tsx +import { getContext } from '@optimizely/cms-sdk/react/server'; + +export function MyComponent() { + const context = getContext(); + + // Access preview token, locale, etc. + const locale = context?.locale ?? 'en-US'; + const isPreview = !!context?.preview_token; + + return
Locale: {locale}
; +} +``` + +**How context is populated:** + +Context data can be populated in two ways: + +1. **Automatically by `getPreviewContent`** - This method automatically sets preview_token, locale, key, version, and ctx in the context +2. **Manually via `setContextData()`** - You can explicitly set context data when needed + +This is particularly useful for: + +- Displaying locale-specific formatting +- Getting content version and key for manual queries and rendering ## Next steps diff --git a/docs/7-live-preview.md b/docs/7-live-preview.md index df344a41..86cd861b 100644 --- a/docs/7-live-preview.md +++ b/docs/7-live-preview.md @@ -17,7 +17,10 @@ First, create a dedicated route for handling preview requests. In Next.js, creat ```tsx import { GraphClient, type PreviewParams } from '@optimizely/cms-sdk'; -import { OptimizelyComponent } from '@optimizely/cms-sdk/react/server'; +import { + OptimizelyComponent, + withAppContext, +} from '@optimizely/cms-sdk/react/server'; import { PreviewComponent } from '@optimizely/cms-sdk/react/client'; import Script from 'next/script'; @@ -25,7 +28,7 @@ type Props = { searchParams: Promise<{ [key: string]: string | string[] | undefined }>; }; -export default async function Page({ searchParams }: Props) { +export async function Page({ searchParams }: Props) { const client = new GraphClient(process.env.OPTIMIZELY_GRAPH_SINGLE_KEY!, { graphUrl: process.env.OPTIMIZELY_GRAPH_GATEWAY, }); @@ -44,10 +47,28 @@ export default async function Page({ searchParams }: Props) { ); } + +export default withAppContext(Page); ``` Let's break down what's happening here: +### Wrapping with `withAppContext` (Optional) + +```tsx +export default withAppContext(Page); +``` + +The `withAppContext` HOC initializes request-scoped context storage and is useful in both preview and regular mode: + +**In Preview Mode:** + +- Makes preview data (`preview_token`, `key`, `locale`, `version`) available throughout your component tree via the SDK +- Enables access to these values for custom querying or rendering scenarios +- Allows any nested component to retrieve preview context using `getContextData()` + +See [Rendering (with React)](./6-rendering-react.md#understanding-withappcontext) for details. + ### GraphClient Setup ```tsx @@ -68,6 +89,8 @@ const response = await client.getPreviewContent( The `getPreviewContent` method handles all the complexity of fetching the right content version based on the preview parameters sent from the CMS. These parameters are automatically included in the URL when an editor clicks "Preview" in the CMS. +**Automatic Context Population**: The `getPreviewContent` method automatically populates the global context with preview parameters (`preview_token`, `locale`, `key`, `version`, `ctx`). This means any component in your tree can access preview data via `getContextData()` without manual extraction. The `withAppContext` HOC is optional when using `getPreviewContent` - context is automatically set by the method itself. + ### Rendering Preview Content ```tsx @@ -222,3 +245,53 @@ export default function AboutUs({ content }: AboutUsProps) { > [!NOTE] > Apply `pa()` to all content properties to enable the full on-page editing experience. This allows editors to click elements in the preview and jump directly to the corresponding field in the CMS. + +## Accessing Context Data in Components + +When you wrap your preview route with `withAppContext`, it initializes request-scoped context for the incoming request. The `GraphClient.getPreviewContent()` call then extracts preview parameters from the URL and populates this context (via `setContext()`). Any component in your tree can access this data using `getContextData()`. + +### Example: Custom Preview Banner + +```tsx +import { getContextData } from '@optimizely/cms-sdk/react/server'; + +export function PreviewBanner() { + const preview_token = getContextData('preview_token'); + const locale = getContextData('locale'); + + // Check if we're in preview mode + if (!preview_token) { + return null; + } + + return ( +
+

Preview Mode - Locale: {locale ?? 'default'}

+
+ ); +} +``` + +### Available Context Properties + +The context automatically includes: + +- `preview_token` - Preview/edit mode authentication token +- `locale` - Content locale from `loc` parameter +- `key` - Content key identifier +- `version` - Content version from `ver` parameter + +### Example: Locale-Aware Component + +```tsx +import { getContextData } from '@optimizely/cms-sdk/react/server'; + +export function DateDisplay({ date }: { date: Date }) { + const locale = getContextData('locale') ?? 'en'; + + return ; +} +``` + +> [!TIP] +> The `RichText` component automatically uses `preview_token` from context to append preview tokens to image URLs, so you don't need to manually handle this. diff --git a/packages/optimizely-cms-sdk/src/context/__test__/config.test.ts b/packages/optimizely-cms-sdk/src/context/__test__/config.test.ts new file mode 100644 index 00000000..b8987657 --- /dev/null +++ b/packages/optimizely-cms-sdk/src/context/__test__/config.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, test, beforeEach, afterEach, vi } from 'vitest'; +import { + configureAdapter, + getAdapter, + getContext, + setContext, + initializeRequestContext, +} from '../config.js'; +import type { ContextAdapter, ContextData } from '../baseContext.js'; + +describe('Context Configuration', () => { + // Mock adapter for testing + class MockAdapter implements ContextAdapter { + private data: ContextData = {}; + + initializeContext(): void { + this.data = {}; + } + + getData(): ContextData | undefined { + return this.data; + } + + setData(value: Partial): void { + Object.assign(this.data, value); + } + + set(key: K, value: ContextData[K]): void { + this.data[key] = value; + } + + get(key: K): ContextData[K] | undefined { + return this.data[key]; + } + + clear(): void { + this.data = {}; + } + } + + let mockAdapter: MockAdapter; + + beforeEach(() => { + mockAdapter = new MockAdapter(); + }); + + describe('configureAdapter()', () => { + test('should set the storage adapter', () => { + configureAdapter(mockAdapter); + expect(getAdapter()).toBe(mockAdapter); + }); + + test('should allow adapter replacement', () => { + const adapter1 = new MockAdapter(); + const adapter2 = new MockAdapter(); + + configureAdapter(adapter1); + expect(getAdapter()).toBe(adapter1); + + configureAdapter(adapter2); + expect(getAdapter()).toBe(adapter2); + }); + }); + + describe('getAdapter()', () => { + afterEach(() => { + // Restore valid adapter after tests that set it to null + configureAdapter(mockAdapter); + }); + + test('should return configured adapter', () => { + configureAdapter(mockAdapter); + const adapter = getAdapter(); + expect(adapter).toBe(mockAdapter); + }); + + test('should throw error when adapter not configured', () => { + // Reset adapter to simulate unconfigured state + configureAdapter(null as unknown as MockAdapter); + + expect(() => getAdapter()).toThrow( + 'Context adapter not configured', + ); + }); + + test('error message should provide helpful guidance', () => { + configureAdapter(null as unknown as MockAdapter); + + expect(() => getAdapter()).toThrow(/For React.*import from/); + expect(() => getAdapter()).toThrow(/For other frameworks.*call configureAdapter/); + }); + }); + + describe('initializeRequestContext()', () => { + test('should call adapter initializeContext()', () => { + const initSpy = vi.spyOn(mockAdapter, 'initializeContext'); + configureAdapter(mockAdapter); + + initializeRequestContext(); + + expect(initSpy).toHaveBeenCalledOnce(); + }); + + test('should clear existing context data', () => { + configureAdapter(mockAdapter); + mockAdapter.setData({ preview_token: 'test-token', locale: 'en' }); + + initializeRequestContext(); + + const data = mockAdapter.getData(); + expect(data).toEqual({}); + }); + }); + + describe('getContext()', () => { + test('should return data from adapter', () => { + configureAdapter(mockAdapter); + mockAdapter.setData({ preview_token: 'test-token' }); + + const data = getContext(); + + expect(data).toEqual({ preview_token: 'test-token' }); + }); + + test('should return empty object when context is initialized but empty', () => { + configureAdapter(mockAdapter); + mockAdapter.initializeContext(); + + const data = getContext(); + + expect(data).toEqual({}); + }); + }); + + describe('setContext()', () => { + test('should set context data through adapter', () => { + configureAdapter(mockAdapter); + + setContext({ preview_token: 'test-token' }); + + expect(mockAdapter.getData()).toEqual({ preview_token: 'test-token' }); + }); + + test('should merge with existing data', () => { + configureAdapter(mockAdapter); + mockAdapter.setData({ preview_token: 'token1' }); + + setContext({ locale: 'en' }); + + expect(mockAdapter.getData()).toEqual({ + preview_token: 'token1', + locale: 'en', + }); + }); + + test('should override existing values', () => { + configureAdapter(mockAdapter); + mockAdapter.setData({ preview_token: 'old-token' }); + + setContext({ preview_token: 'new-token' }); + + expect(mockAdapter.getData()).toEqual({ preview_token: 'new-token' }); + }); + + test('should handle all ContextData properties', () => { + configureAdapter(mockAdapter); + + const fullContext: Partial = { + version: '1.0', + currentContent: { id: '123' }, + preview_token: 'token', + ctx: 'edit', + locale: 'en-US', + key: 'content-key', + }; + + setContext(fullContext); + + expect(mockAdapter.getData()).toEqual(fullContext); + }); + }); + + describe('Integration flow', () => { + test('should support typical request lifecycle', () => { + configureAdapter(mockAdapter); + + // Initialize request + initializeRequestContext(); + expect(getContext()).toEqual({}); + + // Set initial data + setContext({ preview_token: 'token123', locale: 'en' }); + expect(getContext()).toEqual({ + preview_token: 'token123', + locale: 'en', + }); + + // Add more data + setContext({ key: 'page-key' }); + expect(getContext()).toEqual({ + preview_token: 'token123', + locale: 'en', + key: 'page-key', + }); + + // Initialize new request (clears data) + initializeRequestContext(); + expect(getContext()).toEqual({}); + }); + }); +}); diff --git a/packages/optimizely-cms-sdk/src/context/__test__/reactContextAdapter.test.ts b/packages/optimizely-cms-sdk/src/context/__test__/reactContextAdapter.test.ts new file mode 100644 index 00000000..76c3a810 --- /dev/null +++ b/packages/optimizely-cms-sdk/src/context/__test__/reactContextAdapter.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, test } from 'vitest'; +import { ReactContextAdapter } from '../reactContextAdapter.js'; + +/** + * IMPORTANT: React.cache() doesn't work in test environments. + * + * In React Server Components, React.cache() provides request-scoped memoization + * that persists across function calls within the same request. In test environments, + * React.cache() returns a NEW empty object on each call, making it impossible to + * test data persistence. + * + * These tests verify: + * 1. The adapter implements the required interface + * 2. Methods don't throw errors + * 3. The adapter is correctly configured in the global context system + * + * Actual functionality testing happens through integration tests that use the + * global context configuration (getContextData/setContextData) which work correctly + * in both test and RSC environments. + */ +describe('ReactContextAdapter', () => { + describe('API Contract', () => { + test('should implement ContextAdapter interface', () => { + const adapter = new ReactContextAdapter(); + + expect(adapter).toBeDefined(); + expect(typeof adapter.initializeContext).toBe('function'); + expect(typeof adapter.getData).toBe('function'); + expect(typeof adapter.setData).toBe('function'); + expect(typeof adapter.clear).toBe('function'); + }); + + test('should not throw when calling methods', () => { + const adapter = new ReactContextAdapter(); + + expect(() => adapter.initializeContext()).not.toThrow(); + expect(() => adapter.getData()).not.toThrow(); + expect(() => adapter.setData({ preview_token: 'test' })).not.toThrow(); + expect(() => adapter.clear()).not.toThrow(); + }); + + test('should return object from getData()', () => { + const adapter = new ReactContextAdapter(); + const data = adapter.getData(); + + expect(typeof data).toBe('object'); + expect(data).not.toBeNull(); + }); + }); + + describe('Implementation Note', () => { + test('should document React.cache() limitation in tests', () => { + // This test exists to document why we can't test data persistence: + // + // Problem: In test environments, React.cache() returns a NEW empty object + // on each call, so: + // adapter.setData({ foo: 'bar' }) // Modifies object A + // adapter.getData() // Returns NEW object B (empty) + // + // This makes it impossible to test that data persists between calls. + // + // Solution: Use the global context system (getContextData/setContextData) + // which is tested in contextWrapper.test.tsx and works in both environments. + + expect(true).toBe(true); + }); + }); + + describe('Real-world usage', () => { + test('should be used via global context configuration', () => { + // In actual usage: + // 1. ReactContextAdapter is configured automatically in contextWrapper.tsx + // 2. Components use getContext() and setContext() from config.ts + // 3. Those functions delegate to the configured adapter + // 4. In RSC, React.cache() ensures request-scoped isolation + // + // See contextWrapper.test.tsx for integration tests that verify the + // complete workflow works correctly. + + const adapter = new ReactContextAdapter(); + expect(adapter).toBeInstanceOf(ReactContextAdapter); + }); + }); +}); diff --git a/packages/optimizely-cms-sdk/src/context/baseContext.ts b/packages/optimizely-cms-sdk/src/context/baseContext.ts new file mode 100644 index 00000000..142a869b --- /dev/null +++ b/packages/optimizely-cms-sdk/src/context/baseContext.ts @@ -0,0 +1,68 @@ +/** + * Context data shape stored per request. + * Used for preview mode, localization, and other request-specific data. + */ +export interface ContextData { + version?: string; + type?: string; + currentContent?: unknown; + preview_token?: string; + ctx?: string; + locale?: string; + key?: string; +} + +/** + * Context Adapter interface defining the contract for framework-specific context storage. + * + * Different frameworks should implement this shape (typically as static methods) to provide + * their own storage mechanisms: + * - React: Uses React.cache() for direct request-scoped storage + * - Vue: Can use Composition API or AsyncLocalStorage-based solution + * - Svelte: Can use context API or other Svelte-specific mechanism + */ +export interface ContextAdapter { + /** + * Initialize a new request context. + * Should generate and store a unique identifier for this request internally, + * if the adapter implementation uses one. + */ + initializeContext(): void; + + /** + * Get context data for the current request. + * + * @returns Context data for the current request, or undefined if no context exists + */ + getData(): ContextData | undefined; + + /** + * Set/merge context data for the current request. + * Should merge the provided data with existing context. + * + * @param value - Partial context data to merge into the current context + */ + setData(value: Partial): void; + + /** + * Set a specific piece of context data by key. + * + * @param key - The key to set in the context + * @param value - The value to set for the specified key + */ + set(key: K, value: ContextData[K]): void; + + /** + * Get a specific piece of context data by key. + * + * @param key - The key to retrieve from the context + * @returns The value for the specified key, or undefined if not found + */ + get(key: K): ContextData[K] | undefined; + + /** + * Clear context data for the current request. + * Optional method for explicit cleanup. + */ + clear?(): void; +} diff --git a/packages/optimizely-cms-sdk/src/context/config.ts b/packages/optimizely-cms-sdk/src/context/config.ts new file mode 100644 index 00000000..84d0e4e2 --- /dev/null +++ b/packages/optimizely-cms-sdk/src/context/config.ts @@ -0,0 +1,119 @@ +import { ContextData, ContextAdapter } from './baseContext.js'; + +/** + * Global storage adapter instance. + * Defaults to React adapter, but can be configured for other frameworks. + */ +let storageAdapter: ContextAdapter = null as unknown as ContextAdapter; + +/** + * Configure the storage adapter for the context system. + * + * @param adapter - The storage adapter instance to use + * + * @example + * ```ts + * import { configureAdapter, ReactContextAdapter } from '@optimizely/cms-sdk/react/server'; + * configureAdapter(new ReactContextAdapter()); + * + * // Future: Using Vue (hypothetical) + * import { configureAdapter, VueContextAdapter } from '@optimizely/cms-sdk/vue'; + * configureAdapter(new VueContextAdapter()); + */ +export function configureAdapter(adapter: ContextAdapter): void { + storageAdapter = adapter; +} + +/** + * Get the current storage adapter. + * Use this to access context data in your components. + * + * @throws {Error} If the adapter has not been configured + * + * @example + * ```ts + * import { getAdapter } from '@optimizely/cms-sdk/react/server'; + * + * const adapter = getAdapter(); + * const previewToken = adapter.getData()?.preview_token; + * const locale = adapter.getData()?.locale; + * ``` + */ +export function getAdapter(): ContextAdapter { + if (!storageAdapter) { + throw new Error( + 'Context adapter not configured. ' + + 'For React: import from "@optimizely/cms-sdk/react/server" to auto-configure the React adapter. ' + + 'For other frameworks: call configureAdapter() with your custom adapter before using context features.', + ); + } + return storageAdapter; +} + +/** + * Check if a context adapter is currently configured. + * + * @returns true if an adapter has been configured, false otherwise + * @internal + */ +export function hasAdapter(): boolean { + return !!storageAdapter; +} + +/** + * Initialize the request context using the configured adapter. + * Clears any existing context data to start fresh for a new request. + * Typically called by the withContext HOC in React applications. + * + * @internal + */ +export const initializeRequestContext = (): void => { + getAdapter().initializeContext(); +}; + +/** + * Retrieve current context data for this request. + * + * @returns Context data for the current request, or undefined if no context exists + * @internal + */ +export const getContext = (): ContextData | undefined => { + return getAdapter().getData(); +}; + +/** + * Update/merge context data for the current request. + * + * @param value - Partial context data to merge into the current context + * @internal + */ +export const setContext = (value: Partial): void => { + return getAdapter().setData(value); +}; + +/** + * Set a specific piece of context data by key. + * + * @param key - The key to set in the context + * @param value - The value to set for the specified key + * @internal + */ +export const setContextData = ( + key: K, + value: ContextData[K], +): void => { + return getAdapter().set(key, value); +}; + +/** + * Get a specific piece of context data by key. + * + * @param key - The key to retrieve from the context + * @returns The value for the specified key, or undefined if not found + * @internal + */ +export const getContextData = ( + key: K, +): ContextData[K] | undefined => { + return getAdapter().get(key); +}; diff --git a/packages/optimizely-cms-sdk/src/context/reactContextAdapter.ts b/packages/optimizely-cms-sdk/src/context/reactContextAdapter.ts new file mode 100644 index 00000000..b8d6909a --- /dev/null +++ b/packages/optimizely-cms-sdk/src/context/reactContextAdapter.ts @@ -0,0 +1,61 @@ +import { cache } from 'react'; +import { ContextData, ContextAdapter } from './baseContext.js'; + +// Module-level cache - React.cache() ensures this is request-scoped in server components +// All calls within the same request share this cached data +const getContextData = cache((): ContextData => ({})); + +/** + * React Context Adapter using React.cache() for request-scoped storage. + * + * This adapter is designed for React Server Components and provides + * automatic request isolation through React's cache mechanism. + * All static methods operate on the same cached data within a request. + */ +export class ReactContextAdapter implements ContextAdapter { + /** + * Initialize context (clears existing data) + */ + initializeContext() { + const data = getContextData(); + Object.keys(data).forEach((key) => delete data[key as keyof ContextData]); + } + + /** + * Set a specific piece of context data by key + */ + set(key: K, value: ContextData[K]): void { + getContextData()[key] = value; + } + + /** + * Get a specific piece of context data by key + */ + get(key: K): ContextData[K] | undefined { + return getContextData()[key]; + } + + /** + * Get all context data + */ + getData(): ContextData | undefined { + return getContextData(); + } + + /** + * Set/merge context data + */ + setData(value: Partial): void { + Object.assign(getContextData(), value); + } + + /** + * Clear all context data + */ + clear(): void { + const data = getContextData(); + Object.keys(data).forEach((key) => delete data[key as keyof ContextData]); + } +} + +export default ReactContextAdapter; diff --git a/packages/optimizely-cms-sdk/src/graph/__test__/context.test.ts b/packages/optimizely-cms-sdk/src/graph/__test__/context.test.ts new file mode 100644 index 00000000..cd2c03ca --- /dev/null +++ b/packages/optimizely-cms-sdk/src/graph/__test__/context.test.ts @@ -0,0 +1,273 @@ +import { describe, expect, test, beforeEach, vi, afterEach } from 'vitest'; +import { removeTypePrefix, GraphClient } from '../index.js'; +import { + configureAdapter, + getContext, + initializeRequestContext, +} from '../../context/config.js'; +import type { ContextAdapter, ContextData } from '../../context/baseContext.js'; +import { contentType, initContentTypeRegistry } from '../../index.js'; + +describe('removeTypePrefix()', () => { + test('basic functionality', () => { + const input = { + __typename: 'T', + T__p1: 'p1', + T__p2: 42, + T__p3: ['p3', 32], + T__p4: { nested: 'p4' }, + T__p5: { nested: ['p5'] }, + ShouldBeKept__p6: 'p6', + }; + const expected = { + __typename: 'T', + p1: 'p1', + p2: 42, + p3: ['p3', 32], + p4: { nested: 'p4' }, + p5: { nested: ['p5'] }, + ShouldBeKept__p6: 'p6', + }; + expect(removeTypePrefix(input)).toStrictEqual(expected); + }); + + test('should remove prefixes only in the same level', () => { + const input = { + __typename: 'T', + T__p1: { T_shouldBeKept: 'shouldBeKept' }, + }; + const expected = { + __typename: 'T', + p1: { T_shouldBeKept: 'shouldBeKept' }, + }; + expect(removeTypePrefix(input)).toStrictEqual(expected); + }); + + test('should work for nested objects', () => { + const input = { + __typename: 'T', + T__p1: { + __typename: 'U', + U__p1: 'p1', + U__p2: { + __typename: 'V', + V__p1: 'p1', + }, + }, + T__p2: [{ __typename: 'U', U__p1: 'p1' }], + }; + const expected = { + __typename: 'T', + p1: { + __typename: 'U', + p1: 'p1', + p2: { + __typename: 'V', + p1: 'p1', + }, + }, + p2: [{ __typename: 'U', p1: 'p1' }], + }; + expect(removeTypePrefix(input)).toStrictEqual(expected); + }); + + test('should not do anything if __typename is not found', () => { + const input = { + T__p1: 'hello', + T__p2: 42, + T__p3: ['hello', 32], + T__p4: { nested: 'nested' }, + T__p5: { nested: ['hello'] }, + }; + + expect(removeTypePrefix(input)).toStrictEqual(input); + }); +}); + +describe('GraphClient - Context Integration', () => { + // Mock adapter for testing + class MockAdapter implements ContextAdapter { + private data: ContextData = {}; + + initializeContext(): void { + this.data = {}; + } + + getData(): ContextData | undefined { + return this.data; + } + + setData(value: Partial): void { + Object.assign(this.data, value); + } + + set(key: K, value: ContextData[K]): void { + this.data[key] = value; + } + + get(key: K): ContextData[K] | undefined { + return this.data[key]; + } + + clear(): void { + this.data = {}; + } + } + + // Define a test content type + const TestPageContentType = contentType({ + key: 'TestPage', + baseType: '_page', + properties: { + title: { type: 'string' }, + }, + }); + + let mockAdapter: MockAdapter; + let client: GraphClient; + let originalFetch: typeof global.fetch; + + beforeEach(() => { + // Register the test content type + initContentTypeRegistry([TestPageContentType]); + + mockAdapter = new MockAdapter(); + configureAdapter(mockAdapter); + initializeRequestContext(); + client = new GraphClient('test-key'); + + // Mock fetch globally + originalFetch = global.fetch; + global.fetch = vi.fn(); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + describe('getPreviewContent', () => { + test('should populate global context with preview params', async () => { + // Mock successful responses + (global.fetch as any).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: { + _Content: { + item: { + _metadata: { + types: ['TestPage'], + }, + }, + }, + damAssetType: null, + }, + }), + }); + + (global.fetch as any).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: { + _Content: { + item: { + __typename: 'TestPage', + TestPage__title: 'Test', + }, + }, + }, + }), + }); + + const previewParams = { + preview_token: 'token-123', + key: 'page-key', + ctx: 'edit', + ver: '1.0', + loc: 'en-US', + }; + + await client.getPreviewContent(previewParams); + + const contextData = getContext(); + expect(contextData).toEqual({ + preview_token: 'token-123', + locale: 'en-US', + key: 'page-key', + version: '1.0', + type: 'TestPage', + ctx: 'edit', + }); + }); + + test('should throw error if adapter is broken', async () => { + // Configure with a broken adapter that throws + const brokenAdapter: ContextAdapter = { + initializeContext: () => { + throw new Error('Adapter error'); + }, + getData: () => { + throw new Error('Adapter error'); + }, + setData: () => { + throw new Error('Adapter error'); + }, + set: () => { + throw new Error('Adapter error'); + }, + get: () => { + throw new Error('Adapter error'); + }, + clear: () => { + throw new Error('Adapter error'); + }, + }; + + configureAdapter(brokenAdapter); + const testClient = new GraphClient('test-key'); + + // Mock successful responses + (global.fetch as any).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: { + _Content: { + item: { + _metadata: { + types: ['TestPage'], + }, + }, + }, + damAssetType: null, + }, + }), + }); + + (global.fetch as any).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: { + _Content: { + item: { + __typename: 'TestPage', + TestPage__title: 'Test', + }, + }, + }, + }), + }); + + const previewParams = { + preview_token: 'token-123', + key: 'page-key', + ctx: 'edit', + ver: '1.0', + loc: 'en-US', + }; + + // Should throw when trying to populate context with broken adapter + await expect(testClient.getPreviewContent(previewParams)).rejects.toThrow( + 'Adapter error', + ); + }); + }); +}); diff --git a/packages/optimizely-cms-sdk/src/graph/__test__/index.test.ts b/packages/optimizely-cms-sdk/src/graph/__test__/index.test.ts deleted file mode 100644 index a5d3882a..00000000 --- a/packages/optimizely-cms-sdk/src/graph/__test__/index.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { describe, expect, test } from 'vitest'; -import { removeTypePrefix } from '../index.js'; - -describe('removeTypePrefix()', () => { - test('basic functionality', () => { - const input = { - __typename: 'T', - T__p1: 'p1', - T__p2: 42, - T__p3: ['p3', 32], - T__p4: { nested: 'p4' }, - T__p5: { nested: ['p5'] }, - ShouldBeKept__p6: 'p6', - }; - const expected = { - __typename: 'T', - p1: 'p1', - p2: 42, - p3: ['p3', 32], - p4: { nested: 'p4' }, - p5: { nested: ['p5'] }, - ShouldBeKept__p6: 'p6', - }; - expect(removeTypePrefix(input)).toStrictEqual(expected); - }); - - test('should remove prefixes only in the same level', () => { - const input = { - __typename: 'T', - T__p1: { T_shouldBeKept: 'shouldBeKept' }, - }; - const expected = { - __typename: 'T', - p1: { T_shouldBeKept: 'shouldBeKept' }, - }; - expect(removeTypePrefix(input)).toStrictEqual(expected); - }); - - test('should work for nested objects', () => { - const input = { - __typename: 'T', - T__p1: { - __typename: 'U', - U__p1: 'p1', - U__p2: { - __typename: 'V', - V__p1: 'p1', - }, - }, - T__p2: [{ __typename: 'U', U__p1: 'p1' }], - }; - const expected = { - __typename: 'T', - p1: { - __typename: 'U', - p1: 'p1', - p2: { - __typename: 'V', - p1: 'p1', - }, - }, - p2: [{ __typename: 'U', p1: 'p1' }], - }; - expect(removeTypePrefix(input)).toStrictEqual(expected); - }); - - test('should not do anything if __typename is not found', () => { - const input = { - T__p1: 'hello', - T__p2: 42, - T__p3: ['hello', 32], - T__p4: { nested: 'nested' }, - T__p5: { nested: ['hello'] }, - }; - - expect(removeTypePrefix(input)).toStrictEqual(input); - }); -}); diff --git a/packages/optimizely-cms-sdk/src/graph/index.ts b/packages/optimizely-cms-sdk/src/graph/index.ts index 96e15160..c0687ad7 100644 --- a/packages/optimizely-cms-sdk/src/graph/index.ts +++ b/packages/optimizely-cms-sdk/src/graph/index.ts @@ -1,4 +1,3 @@ -import { cache } from 'react'; import { createSingleContentQuery, ItemsResponse, @@ -17,6 +16,7 @@ import { GraphVariationInput, localeFilter, } from './filters.js'; +import { setContext } from '../context/config.js'; /** Options for Graph */ type GraphOptions = { @@ -468,6 +468,17 @@ export class GraphClient { { request: { variables: input, query: GET_CONTENT_METADATA_QUERY } }, ); } + + // Auto-populate context with preview parameters + setContext({ + preview_token: params.preview_token, + version: params.ver, + locale: params.loc, + type: contentTypeName, + key: params.key, + ctx: params.ctx, + }); + const query = createSingleContentQuery( contentTypeName, damEnabled, diff --git a/packages/optimizely-cms-sdk/src/react/context/__test__/contextWrapper.test.tsx b/packages/optimizely-cms-sdk/src/react/context/__test__/contextWrapper.test.tsx new file mode 100644 index 00000000..f35bdad1 --- /dev/null +++ b/packages/optimizely-cms-sdk/src/react/context/__test__/contextWrapper.test.tsx @@ -0,0 +1,148 @@ +import { describe, expect, test, beforeEach } from 'vitest'; +import { render } from '@testing-library/react'; +import { withAppContext } from '../contextWrapper.js'; +import { getContext } from '../../../context/config.js'; +import ReactContextAdapter from '../../../context/reactContextAdapter.js'; +import { configureAdapter } from '../../../context/config.js'; + +// Ensure React adapter is configured for tests +beforeEach(() => { + configureAdapter(new ReactContextAdapter()); +}); + +describe('withAppContext', () => { + describe('Basic HOC functionality', () => { + test('should wrap component and render it', async () => { + const TestComponent = ({ testProp }: { testProp: string }) => ( +
{testProp}
+ ); + + const WrappedComponent = withAppContext(TestComponent); + const { findByTestId } = render( + await WrappedComponent({ testProp: 'test-value' }), + ); + + const element = await findByTestId('test-component'); + expect(element).toBeDefined(); + expect(element.textContent).toBe('test-value'); + }); + + test('should pass through props to wrapped component', async () => { + type Props = { name: string; age: number; active: boolean }; + const TestComponent = ({ name, age, active }: Props) => ( +
+ {name}-{age}-{active.toString()} +
+ ); + + const WrappedComponent = withAppContext(TestComponent); + const { container } = render( + await WrappedComponent({ name: 'John', age: 30, active: true }), + ); + + expect(container.textContent).toBe('John-30-true'); + }); + }); + + describe('Context initialization', () => { + test('should initialize empty context', async () => { + const TestComponent = () => { + const data = getContext(); + return
{JSON.stringify(data)}
; + }; + + const WrappedComponent = withAppContext(TestComponent); + const { findByTestId } = render(await WrappedComponent({})); + + const element = await findByTestId('data'); + expect(JSON.parse(element.textContent || '{}')).toEqual({}); + }); + + test('should provide fresh context for each wrapped component', async () => { + const TestComponent = () => { + const data = getContext(); + return
{data?.preview_token || 'empty'}
; + }; + + const WrappedComponent = withAppContext(TestComponent); + + // First render + const { findByTestId: findByTestId1 } = render( + await WrappedComponent({}), + ); + const element1 = await findByTestId1('result'); + expect(element1.textContent).toBe('empty'); + }); + }); + + describe('Context usage pattern', () => { + test('should initialize empty context for components to use', async () => { + const TestComponent = () => { + const data = getContext(); + return ( +
+ + {Object.keys(data || {}).length === 0 ? 'empty' : 'has-data'} + +
+ ); + }; + + const WrappedComponent = withAppContext(TestComponent); + const { findByTestId } = render(await WrappedComponent({})); + + // withAppContext initializes empty context + expect((await findByTestId('is-empty')).textContent).toBe('empty'); + }); + + test('should demonstrate getContext is accessible', async () => { + const TestComponent = () => { + // Components can call getContext() + const data = getContext(); + return ( +
+ {typeof data === 'object' ? 'object' : 'undefined'} +
+ ); + }; + + const WrappedComponent = withAppContext(TestComponent); + const { findByTestId } = render(await WrappedComponent({})); + + // Context is accessible as an object + expect((await findByTestId('context-type')).textContent).toBe('object'); + }); + }); + + describe('Real-world usage note', () => { + test('should document that context is populated by getPreviewContent', async () => { + // Note: In actual usage, the workflow is: + // 1. withAppContext() initializes empty context storage + // 2. getPreviewContent() populates context with preview data + // 3. Components access context via getContext() + // + // React.cache() in React Server Components ensures request-scoped + // isolation, but this doesn't work the same way in test environments. + // The reactContextAdapter tests verify the adapter functionality directly. + + const InfoComponent = () => { + const data = getContext(); + return ( +
+ {data?.preview_token + ? 'preview mode' + : 'getPreviewContent not called'} +
+ ); + }; + + const WrappedComponent = withAppContext(InfoComponent); + const { findByTestId } = render(await WrappedComponent({})); + + // Without getPreviewContent being called, no preview data exists + expect((await findByTestId('info')).textContent).toBe( + 'getPreviewContent not called', + ); + }); + }); +}); diff --git a/packages/optimizely-cms-sdk/src/react/context/contextWrapper.tsx b/packages/optimizely-cms-sdk/src/react/context/contextWrapper.tsx new file mode 100644 index 00000000..6e4a13be --- /dev/null +++ b/packages/optimizely-cms-sdk/src/react/context/contextWrapper.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { + configureAdapter, + hasAdapter, + initializeRequestContext, +} from '../../context/config.js'; +import ReactContextAdapter from '../../context/reactContextAdapter.js'; + +// Configure the React adapter only if no custom adapter has been set +// This allows users to configure their own adapter before importing from react/server +if (!hasAdapter()) { + configureAdapter(new ReactContextAdapter()); +} + +/** + * Higher-Order Component that initializes context storage. + * + * This HOC is designed for React Server Components and uses the configured + * context adapter (default: React.cache()) for request-scoped storage. + * + * NOTE: `getPreviewContent` automatically populates context with preview data. + * You may not need this HOC if you're only using that method. + * + * The HOC is useful for: + * - Initializing context before any content fetching + * - Ensuring context is available throughout the component tree + * - Using context for non-preview data + * + * Components can access context data using `getContext()`: + * + * @param Component - The React component to wrap + * + * @example + * ```tsx + * import { getContext, setContext } from '@optimizely/cms-sdk/react/server'; + * + * async function MyPage({ params }) { + * // Context is initialized by withAppContext + * // You can manually set data if needed + * setContext({ locale: 'en-US' }); + * + * const context = getContext(); + * return
Locale: {context?.locale}
; + * } + * + * export default withAppContext(MyPage); + * ``` + */ +export function withAppContext

( + Component: React.ComponentType

, +) { + return async function WrappedWithContext(props: P) { + // Initialize context for this request + initializeRequestContext(); + + return React.createElement(Component, props); + }; +} diff --git a/packages/optimizely-cms-sdk/src/react/richText/lib.ts b/packages/optimizely-cms-sdk/src/react/richText/lib.ts index 25565a09..59d63728 100644 --- a/packages/optimizely-cms-sdk/src/react/richText/lib.ts +++ b/packages/optimizely-cms-sdk/src/react/richText/lib.ts @@ -14,20 +14,20 @@ import { type TableElement, type TableCellElement, } from '../../components/richText/renderer.js'; +import { appendToken } from '../../util/preview.js'; +import { getContextData } from '../../context/config.js'; /** * React-specific element renderer props (extends shared props with React children) */ export interface ElementRendererProps - extends BaseElementRendererProps, - PropsWithChildren {} + extends BaseElementRendererProps, PropsWithChildren {} /** * React-specific props for link elements with type safety */ export interface LinkElementProps - extends Omit, - PropsWithChildren { + extends Omit, PropsWithChildren { element: LinkElement; } @@ -35,8 +35,7 @@ export interface LinkElementProps * React-specific props for image elements with type safety */ export interface ImageElementProps - extends Omit, - PropsWithChildren { + extends Omit, PropsWithChildren { element: ImageElement; } @@ -44,8 +43,7 @@ export interface ImageElementProps * React-specific props for table elements with type safety */ export interface TableElementProps - extends Omit, - PropsWithChildren { + extends Omit, PropsWithChildren { element: TableElement; } @@ -53,8 +51,7 @@ export interface TableElementProps * React-specific props for table cell elements with type safety */ export interface TableCellElementRendererProps - extends Omit, - PropsWithChildren { + extends Omit, PropsWithChildren { element: TableCellElement; } @@ -67,8 +64,7 @@ export type ElementProps = ElementRendererProps; * React-specific leaf renderer props (extends shared props with React children) */ export interface LeafRendererProps - extends BaseLeafRendererProps, - PropsWithChildren {} + extends BaseLeafRendererProps, PropsWithChildren {} /** * Prop type used for custom Leaf components @@ -120,7 +116,8 @@ export type LeafMap = BaseLeafMap; * React-specific RichText props */ export interface RichTextProps - extends RichTextPropsBase, + extends + RichTextPropsBase, Omit, 'content'> {} /** @@ -415,7 +412,7 @@ const HTML_ATTRIBUTE_ELEMENTS = new Set(['table', 'img', 'input', 'canvas']); */ export function toReactProps( attributes: Record, - elementType?: string + elementType?: string, ): Record { const reactProps: Record = {}; const styleProps: Record = {}; @@ -504,7 +501,7 @@ function parseStyleString(styleString: string): Record { */ export function createHtmlComponent( tag: T, - config: HtmlComponentConfig = {} + config: HtmlComponentConfig = {}, ): ElementRenderer { const Component: ElementRenderer = ({ children, attributes, element }) => { // Convert to React props and merge with config, passing element type for context @@ -534,7 +531,7 @@ export function createHtmlComponent( */ export function createLinkComponent( tag: T = 'a' as T, - config: HtmlComponentConfig = {} + config: HtmlComponentConfig = {}, ): LinkElementRenderer { const Component: LinkElementRenderer = ({ children, @@ -573,7 +570,7 @@ export function createLinkComponent( */ export function createImageComponent( tag: T = 'img' as T, - config: HtmlComponentConfig = {} + config: HtmlComponentConfig = {}, ): ImageElementRenderer { const Component: ImageElementRenderer = ({ children, @@ -583,9 +580,21 @@ export function createImageComponent( // Convert to React props and merge with config const reactProps = toReactProps(attributes || {}, tag as string); + // Get preview token from context (React.cache ensures same data per request) + let previewToken: string | undefined; + + try { + previewToken = getContextData('preview_token'); + } catch { + // If no context adapter is configured, render without a preview token + previewToken = undefined; + } + + const imageSource = appendToken(element.url, previewToken); + // Type-safe access to image properties const imageProps = { - src: element.url, + src: imageSource, alt: element.alt, title: element.title, width: element.width, @@ -615,7 +624,7 @@ export function createImageComponent( */ export function createTableComponent( tag: T = 'table' as T, - config: HtmlComponentConfig = {} + config: HtmlComponentConfig = {}, ): TableElementRenderer { const Component: TableElementRenderer = ({ children, @@ -645,7 +654,7 @@ export function createTableComponent( */ export function createTableCellComponent( tag: T, - config: HtmlComponentConfig = {} + config: HtmlComponentConfig = {}, ): TableCellElementRenderer { const Component: TableCellElementRenderer = ({ children, @@ -675,7 +684,7 @@ export function createTableCellComponent( */ export function createLeafComponent( tag: T, - config: HtmlComponentConfig = {} + config: HtmlComponentConfig = {}, ): LeafRenderer { const Component: LeafRenderer = ({ children, attributes }) => { // Convert to React props and merge with config @@ -707,37 +716,37 @@ export function generateDefaultElements(): ElementMap { case 'link': elementMap[type] = createLinkComponent( 'a', - config.config + config.config, ) as ElementRenderer; break; case 'image': elementMap[type] = createImageComponent( 'img', - config.config + config.config, ) as ElementRenderer; break; case 'table': elementMap[type] = createTableComponent( 'table', - config.config + config.config, ) as ElementRenderer; break; case 'td': elementMap[type] = createTableCellComponent( 'td', - config.config + config.config, ) as ElementRenderer; break; case 'th': elementMap[type] = createTableCellComponent( 'th', - config.config + config.config, ) as ElementRenderer; break; default: elementMap[type] = createHtmlComponent( config.tag as keyof JSX.IntrinsicElements, - config.config + config.config, ); break; } diff --git a/packages/optimizely-cms-sdk/src/react/richText/renderer.ts b/packages/optimizely-cms-sdk/src/react/richText/renderer.ts index b4a0fdd9..3c64ab39 100644 --- a/packages/optimizely-cms-sdk/src/react/richText/renderer.ts +++ b/packages/optimizely-cms-sdk/src/react/richText/renderer.ts @@ -6,7 +6,6 @@ import { import { type RenderNode, type Node, - type Element, createElementData, } from '../../components/richText/renderer.js'; import { @@ -46,7 +45,7 @@ export class ReactRichTextRenderer extends BaseRichTextRenderer< Object.entries(config.elements).map(([key, value]) => [ key.toLowerCase(), value, - ]) + ]), ) : {}; @@ -61,7 +60,7 @@ export class ReactRichTextRenderer extends BaseRichTextRenderer< Object.entries(config.leafs).map(([key, value]) => [ key.toLowerCase(), value, - ]) + ]), ) : {}; @@ -85,7 +84,7 @@ export class ReactRichTextRenderer extends BaseRichTextRenderer< protected createElement( node: RenderNode, children: ReactNode[], - index: number + index: number, ): ReactNode { // Normalize element type to lowercase for consistent lookup const normalizedElementType = node.elementType!.toLowerCase(); @@ -96,7 +95,7 @@ export class ReactRichTextRenderer extends BaseRichTextRenderer< const elementData = createElementData( normalizedElementType, - node.attributes + node.attributes, ); // Extract text content from render nodes @@ -113,7 +112,7 @@ export class ReactRichTextRenderer extends BaseRichTextRenderer< text: textContent, key: `element-${normalizedElementType}-${index}`, // Unique key for each element }, - ...children + ...children, ); } @@ -153,7 +152,7 @@ export class ReactRichTextRenderer extends BaseRichTextRenderer< text: decodedText, key: `leaf-${normalizedMark}-${index}-${markIndex}`, // Use normalized mark for key }, - element + element, ); } @@ -202,7 +201,7 @@ export class ReactRichTextRenderer extends BaseRichTextRenderer< * Factory function to create a React renderer */ export function createReactRenderer( - config?: Partial + config?: Partial, ): ReactRichTextRenderer { return new ReactRichTextRenderer(config); } diff --git a/packages/optimizely-cms-sdk/src/react/server.tsx b/packages/optimizely-cms-sdk/src/react/server.tsx index 3a979dec..e4e12151 100644 --- a/packages/optimizely-cms-sdk/src/react/server.tsx +++ b/packages/optimizely-cms-sdk/src/react/server.tsx @@ -11,7 +11,6 @@ import { DisplaySettingsType, ExperienceCompositionNode, InferredContentReference, - ContentProps, } from '../infer.js'; import { isComponentNode } from '../util/baseTypeUtil.js'; import { parseDisplaySettings } from '../model/displayTemplates.js'; @@ -19,6 +18,17 @@ import { getDisplayTemplateTag } from '../model/displayTemplateRegistry.js'; import { isDev } from '../util/environment.js'; import { appendToken } from '../util/preview.js'; import { OptimizelyReactError } from './error.js'; +export { withAppContext } from './context/contextWrapper.js'; +export { + getContext, + setContext, + getContextData, + setContextData, + configureAdapter, + getAdapter, +} from '../context/config.js'; +export { ReactContextAdapter } from '../context/reactContextAdapter.js'; +export type { ContextAdapter, ContextData } from '../context/baseContext.js'; type ComponentType = React.ComponentType; diff --git a/templates/alloy-template/src/app/[...slug]/page.tsx b/templates/alloy-template/src/app/[...slug]/page.tsx index 8b4fcd14..e54980da 100644 --- a/templates/alloy-template/src/app/[...slug]/page.tsx +++ b/templates/alloy-template/src/app/[...slug]/page.tsx @@ -1,7 +1,10 @@ import Footer from '@/components/base/Footer'; import Header from '@/components/base/Header'; import { GraphClient } from '@optimizely/cms-sdk'; -import { OptimizelyComponent } from '@optimizely/cms-sdk/react/server'; +import { + OptimizelyComponent, + withAppContext, +} from '@optimizely/cms-sdk/react/server'; import { notFound } from 'next/navigation'; import React from 'react'; import { SidebarNav } from '@/components/base/SidebarNav'; @@ -12,7 +15,7 @@ type Props = { }>; }; -export default async function Page({ params }: Props) { +export async function Page({ params }: Props) { const { slug } = await params; const client = new GraphClient(process.env.OPTIMIZELY_GRAPH_SINGLE_KEY!, { @@ -79,3 +82,5 @@ export default async function Page({ params }: Props) { ); } + +export default withAppContext(Page); diff --git a/templates/alloy-template/src/app/preview/page.tsx b/templates/alloy-template/src/app/preview/page.tsx index be924b8b..13534fbc 100644 --- a/templates/alloy-template/src/app/preview/page.tsx +++ b/templates/alloy-template/src/app/preview/page.tsx @@ -1,5 +1,8 @@ import { GraphClient, type PreviewParams } from '@optimizely/cms-sdk'; -import { OptimizelyComponent } from '@optimizely/cms-sdk/react/server'; +import { + OptimizelyComponent, + withAppContext, +} from '@optimizely/cms-sdk/react/server'; import { PreviewComponent } from '@optimizely/cms-sdk/react/client'; import Script from 'next/script'; import Header from '@/components/base/Header'; @@ -10,7 +13,7 @@ type Props = { searchParams: Promise<{ [key: string]: string | string[] | undefined }>; }; -export default async function Page({ searchParams }: Props) { +export async function Page({ searchParams }: Props) { const client = new GraphClient(process.env.OPTIMIZELY_GRAPH_SINGLE_KEY!, { graphUrl: process.env.OPTIMIZELY_GRAPH_GATEWAY, }); @@ -85,3 +88,5 @@ export default async function Page({ searchParams }: Props) { ); } + +export default withAppContext(Page);