Skip to content
Merged
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
12 changes: 10 additions & 2 deletions packages/adapter-angular/src/inject-live-preview-experience.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { type Signal, afterNextRender, computed, effect, signal } from '@angular

import {
createLivePreviewClient,
sendPreviewStatus,
type PreviewSessionOptions,
} from '@contentful/experiences-live-preview';
import type { ExperiencePayload } from '@contentful/experiences-sdk-core';
Expand Down Expand Up @@ -48,13 +49,20 @@ export function injectLivePreviewExperience(
if (!browserReady()) return;

const options = connectionOptions();
if (options === undefined) return;
if (options === undefined) {
sendPreviewStatus('static');
return;
}

const client = createLivePreviewClient(options);
const unsubscribeStatus = client.subscribeStatus(sendPreviewStatus);
const unsubscribe = client.subscribe(() => {
currentData.set(client.getSnapshot());
});
onCleanup(unsubscribe);
onCleanup(() => {
unsubscribeStatus();
unsubscribe();
});
});

return { data };
Expand Down
83 changes: 83 additions & 0 deletions packages/adapter-angular/src/live-preview-experience.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import {
type FakeSocket = {
readonly url: string;
readonly close: ReturnType<typeof vi.fn>;
emitOpen(): void;
emitClose(event: { code: number; reason: string }): void;
emitMessage(data: unknown): void;
};

Expand All @@ -25,6 +27,7 @@ const sockets: FakeSocket[] = [];
class FakeWebSocket {
readonly url: string;
readonly close = vi.fn();
onopen: ((event: { type: string }) => void) | null = null;
onclose: ((event: { code: number; reason: string }) => void) | null = null;
onmessage: ((event: { data: unknown }) => void) | null = null;

Expand All @@ -33,6 +36,14 @@ class FakeWebSocket {
sockets.push(this);
}

emitOpen(): void {
this.onopen?.({ type: 'open' });
}

emitClose(event: { code: number; reason: string }): void {
this.onclose?.(event);
}

emitMessage(data: unknown): void {
this.onmessage?.({ data });
}
Expand Down Expand Up @@ -146,6 +157,8 @@ beforeEach(() => {
});

afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
vi.unstubAllGlobals();
});

Expand Down Expand Up @@ -177,6 +190,7 @@ describe('injectLivePreviewExperience', () => {
});

it('does not open a socket when preview credentials are incomplete', async () => {
const postMessage = vi.spyOn(window.parent, 'postMessage').mockImplementation(() => undefined);
const fixture = createFixture(LivePreviewExperienceProbe, (probe) => {
probe.options.set({
...livePreviewOptions(payload('initial')),
Expand All @@ -192,6 +206,75 @@ describe('injectLivePreviewExperience', () => {
expect(fixture.componentInstance.livePreview.data()?.nodes[0]?.contentProperties?.title).toBe(
'initial'
);
expect(postMessage).toHaveBeenCalledWith(
{
source: 'experiences/live-preview',
type: 'status',
status: 'static',
},
'*'
);
fixture.destroy();
});

it('sends live status after the session socket opens', async () => {
const postMessage = vi.spyOn(window.parent, 'postMessage').mockImplementation(() => undefined);
const fixture = createFixture(LivePreviewExperienceProbe);
await vi.waitFor(() => expect(sockets).toHaveLength(1));

expect(postMessage).not.toHaveBeenCalled();
sockets[0]?.emitOpen();

expect(postMessage).toHaveBeenCalledWith(
{
source: 'experiences/live-preview',
type: 'status',
status: 'live',
},
'*'
);
fixture.destroy();
});

it('does not send static status while the session socket reconnects', async () => {
const postMessage = vi.spyOn(window.parent, 'postMessage').mockImplementation(() => undefined);
const fixture = createFixture(LivePreviewExperienceProbe);
await vi.waitFor(() => expect(sockets).toHaveLength(1));

vi.useFakeTimers();
sockets[0]?.emitOpen();
sockets[0]?.emitClose({ code: 1006, reason: 'network' });
vi.advanceTimersByTime(100);
sockets[1]?.emitOpen();

expect(postMessage).toHaveBeenCalledTimes(1);
expect(postMessage).toHaveBeenLastCalledWith(
{
source: 'experiences/live-preview',
type: 'status',
status: 'live',
},
'*'
);
fixture.destroy();
});

it('sends static status without Preview Session options', async () => {
const postMessage = vi.spyOn(window.parent, 'postMessage').mockImplementation(() => undefined);
const fixture = createFixture(LivePreviewExperienceProbe, (probe) => {
probe.options.set({ initialPayload: payload('initial') });
});
await fixture.whenStable();

expect(sockets).toHaveLength(0);
expect(postMessage).toHaveBeenCalledWith(
{
source: 'experiences/live-preview',
type: 'status',
status: 'static',
},
'*'
);
fixture.destroy();
});

Expand Down
13 changes: 12 additions & 1 deletion packages/adapter-react/src/use-live-preview-experience.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
'use client';

import { useMemo, useSyncExternalStore } from 'react';
import { useEffect, useMemo, useSyncExternalStore } from 'react';

import {
createLivePreviewClient,
sendPreviewStatus,
type PreviewSessionOptions,
} from '@contentful/experiences-live-preview';
import type { ExperiencePayload } from '@contentful/experiences-sdk-core';
Expand Down Expand Up @@ -43,5 +44,15 @@ export function useLivePreviewExperience(
);
const source = client ?? emptySource;
const data = useSyncExternalStore(source.subscribe, source.getSnapshot, source.getSnapshot);

useEffect(() => {
if (client === undefined) {
sendPreviewStatus('static');
return;
}

return client.subscribeStatus(sendPreviewStatus);
}, [client]);

return { data };
}
172 changes: 172 additions & 0 deletions packages/adapter-react/src/use-live-preview.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,35 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import type { ExperiencePayload, PortableRenderPlan } from '@contentful/experiences-sdk-core';

import {
useLivePreviewExperience,
type UseLivePreviewExperienceOptions,
} from './use-live-preview-experience';
import { useLivePreview, type UseLivePreviewOptions } from './use-live-preview';

class FakeWebSocket {
static instances: FakeWebSocket[] = [];

readonly url: string;
readonly close = vi.fn();
onopen: ((event: { type: string }) => void) | null = null;
onclose: ((event: { code: number; reason: string }) => void) | null = null;
onmessage: ((event: { data: unknown }) => void) | null = null;

constructor(url: string) {
this.url = url;
FakeWebSocket.instances.push(this);
}

emitOpen(): void {
this.onopen?.({ type: 'open' });
}

emitMessage(data: unknown): void {
this.onmessage?.({ data });
}
}

const payload = (title: string): ExperiencePayload => ({
nodes: [
{
Expand Down Expand Up @@ -50,6 +77,30 @@ const initialPlan: PortableRenderPlan = {
diagnostics: [],
};

const previewSessionOptions = {
environmentId: 'environment-id',
previewToken: 'preview-token',
sessionHost: 'wss://preview-session.example.test',
sessionId: 'session-id',
spaceId: 'space-id',
};

const rawOptions = (
overrides: Partial<UseLivePreviewExperienceOptions> = {}
): UseLivePreviewExperienceOptions => ({
previewSessionOptions,
...overrides,
});

function RawLivePreviewProbe({
options,
}: {
options: UseLivePreviewExperienceOptions;
}): ReactElement {
const { data } = useLivePreviewExperience(options);
return <output>{data?.nodes[0]?.contentProperties?.title ?? ''}</output>;
}

function LivePreviewProbe({ options }: { options: UseLivePreviewOptions }): ReactElement {
const { data } = useLivePreview(options);
return <output>{data?.nodes[0]?.props.content.title ?? ''}</output>;
Expand All @@ -67,12 +118,16 @@ describe('useLivePreview', () => {
let container: HTMLElement | undefined;

beforeEach(() => {
FakeWebSocket.instances = [];
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
vi.stubGlobal('WebSocket', FakeWebSocket);
});

afterEach(() => {
if (root) act(() => root?.unmount());
container?.remove();
vi.useRealTimers();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});

Expand All @@ -92,4 +147,121 @@ describe('useLivePreview', () => {

expect(container.textContent).toBe('initial');
});

it('sends static status when a session credential is missing', async () => {
const postMessage = vi.spyOn(window.parent, 'postMessage').mockImplementation(() => undefined);
const initialPayload = payload('initial');
({ container, root } = renderRoot());

await act(async () => {
root.render(
<RawLivePreviewProbe
options={rawOptions({
initialPayload,
previewSessionOptions: { ...previewSessionOptions, sessionId: undefined },
})}
/>
);
});

expect(container.textContent).toBe('initial');
expect(FakeWebSocket.instances).toHaveLength(0);
expect(postMessage).toHaveBeenCalledWith(
{
source: 'experiences/live-preview',
type: 'status',
status: 'static',
},
'*'
);
});

it('sends live status after the session socket opens', async () => {
const postMessage = vi.spyOn(window.parent, 'postMessage').mockImplementation(() => undefined);
({ root } = renderRoot());

await act(async () => {
root.render(<RawLivePreviewProbe options={rawOptions()} />);
});

expect(postMessage).not.toHaveBeenCalled();

await act(async () => {
FakeWebSocket.instances[0]?.emitOpen();
});

expect(postMessage).toHaveBeenCalledWith(
{
source: 'experiences/live-preview',
type: 'status',
status: 'live',
},
'*'
);
});

it('does not send static status while the session socket reconnects', async () => {
vi.useFakeTimers();
const postMessage = vi.spyOn(window.parent, 'postMessage').mockImplementation(() => undefined);
({ root } = renderRoot());

await act(async () => {
root.render(<RawLivePreviewProbe options={rawOptions()} />);
FakeWebSocket.instances[0]?.emitOpen();
});

await act(async () => {
FakeWebSocket.instances[0]?.onclose?.({ code: 1006, reason: 'network' });
vi.advanceTimersByTime(100);
FakeWebSocket.instances[1]?.emitOpen();
});

expect(postMessage).toHaveBeenCalledTimes(1);
expect(postMessage).toHaveBeenLastCalledWith(
{
source: 'experiences/live-preview',
type: 'status',
status: 'live',
},
'*'
);
});

it('sends a new status when the live-preview configuration changes', async () => {
const postMessage = vi.spyOn(window.parent, 'postMessage').mockImplementation(() => undefined);
({ root } = renderRoot());

await act(async () => {
root.render(
<RawLivePreviewProbe
options={rawOptions({
previewSessionOptions: { ...previewSessionOptions, sessionId: undefined },
})}
/>
);
});

await act(async () => {
root.render(<RawLivePreviewProbe options={rawOptions()} />);
});
await act(async () => {
FakeWebSocket.instances[0]?.emitOpen();
});

await act(async () => {
root.render(
<RawLivePreviewProbe
options={rawOptions({
previewSessionOptions: { ...previewSessionOptions, sessionId: undefined },
})}
/>
);
});

expect(postMessage.mock.calls.map(([message]) => message)).toEqual([
{ source: 'experiences/live-preview', type: 'status', status: 'static' },
{ source: 'experiences/live-preview', type: 'status', status: 'live' },
{ source: 'experiences/live-preview', type: 'status', status: 'static' },
]);
});
});
Loading