Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
7f96a9b
Improve review thumbnail scaling and loading UX.
ghengeveld Jul 1, 2026
4aa28d0
Gate embed remeasure requests until the story has frozen.
ghengeveld Jul 1, 2026
9dcabb9
Unify preview thumbnail loading into a load-generation settle model.
ghengeveld Jul 1, 2026
ab43286
Gate embed UI style injection on freeze=finished thumbnails.
ghengeveld Jul 1, 2026
de84ebc
Remove duplicate JS thumbnail scale logic; CSS owns scaling.
ghengeveld Jul 1, 2026
709ad5d
Add CollectionGrid story tests for preview loading lifecycle.
ghengeveld Jul 1, 2026
776a6f9
Drop unused live-observer path from embed resize broadcast.
ghengeveld Jul 1, 2026
ac47eaf
Extract embed query helpers from resize broadcast module.
ghengeveld Jul 1, 2026
bb463c1
Merge remote-tracking branch 'origin/next' into ghengeveld/review-thu…
ghengeveld Jul 1, 2026
20aaf27
Formatting
ghengeveld Jul 1, 2026
0e60b6f
Restore auto-resize for non-freeze embed previews.
ghengeveld Jul 1, 2026
806b17f
Use default Loader status role for review thumbnails.
ghengeveld Jul 1, 2026
83f535a
Fix review thumbnail frame sizing inside grid cells.
ghengeveld Jul 1, 2026
c7aa8e4
Merge remote-tracking branch 'origin/next' into ghengeveld/review-thu…
ghengeveld Jul 1, 2026
8baa2cc
Address review feedback after merging next.
ghengeveld Jul 1, 2026
7c10439
Show review preview spinner while queued for a concurrency slot.
ghengeveld Jul 1, 2026
b1572af
Preserve review summary UI when another tab replays the cached review.
ghengeveld Jul 1, 2026
f9f37a4
Add viewport metadata to iframe.resize and scale review thumbnails by…
ghengeveld Jul 1, 2026
af10ec3
Merge pull request #35335 from storybookjs/ghengeveld/review-thumbnai…
ghengeveld Jul 1, 2026
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
22 changes: 22 additions & 0 deletions code/core/src/manager/components/review/ReviewPage.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -583,3 +583,25 @@ export const NotificationClickFromStoryNavigatesAndDismisses = meta.story({
expect(addNotificationMock).not.toHaveBeenCalled();
},
});

export const SummaryStateSurvivesReviewReplay = meta.story({
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
applyReviewState();

const collapseButton = await canvas.findByRole('button', {
name: 'Collapse collection Settings',
});
await userEvent.click(collapseButton);
await expect(
await canvas.findByRole('button', { name: 'Expand collection Settings' })
).toHaveAttribute('aria-expanded', 'false');

// Another tab's REQUEST_REVIEW replays the cached review to every open tab.
emitMock(EVENTS.DISPLAY_REVIEW, { ...reviewState });

await expect(
canvas.getByRole('button', { name: 'Expand collection Settings' })
).toHaveAttribute('aria-expanded', 'false');
},
});
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import React from 'react';
import React, { type FC } from 'react';

import { expect, waitFor, within } from 'storybook/test';
import { expect, userEvent, waitFor, within } from 'storybook/test';

import preview from '../../../../../../.storybook/preview.tsx';
import {
IFRAME_RESIZE_CONTEXT,
type IframeResizeViewport,
} from '../../../../shared/constants/iframe-resize.ts';
import { RESPONSIVE_VIEWPORT_VALUE } from '../../../../viewport/constants.ts';
import { IconSymbols } from '../../sidebar/IconSymbols.tsx';
import type { StoryInfo } from '../review-types.ts';
import { CollectionGrid } from './CollectionGrid.tsx';
import { CollectionGrid, type CollectionGridProps } from './CollectionGrid.tsx';

// 40 unique story IDs drawn from real internal stories.
const fortyStoryIds = [
Expand Down Expand Up @@ -103,7 +108,154 @@ const meta = preview.meta({
},
});

export const Default = meta.story({});
const previewHref = (storyId: string) =>
`iframe.html?id=${encodeURIComponent(storyId)}&viewMode=story&embed=true&freeze=finished`;

const dispatchIframeResize = (
cell: HTMLElement,
width: number,
height: number,
viewport?: IframeResizeViewport
) => {
const contentWindow = cell.querySelector('iframe')?.contentWindow;
if (!contentWindow) {
throw new Error('Preview iframe has no contentWindow');
}
window.dispatchEvent(
new MessageEvent('message', {
data: JSON.stringify({
context: IFRAME_RESIZE_CONTEXT,
width,
height,
...(viewport ? { viewport } : {}),
}),
source: contentWindow,
})
);
};

/** Loader cleared after iframe src is assigned and resize is applied. */
const waitForCellPreviewSettled = async (
cell: HTMLElement,
dimensions: {
width: number;
height: number;
viewport?: IframeResizeViewport;
} = { width: 320, height: 240 }
) => {
await waitFor(() => {
expect(cell.querySelector('iframe')?.getAttribute('src')).toContain('embed=true');
});
dispatchIframeResize(cell, dimensions.width, dimensions.height, dimensions.viewport);
await waitFor(() => {
expect(within(cell).queryByTestId('review-preview-loading')).not.toBeInTheDocument();
expect(Number(cell.querySelector('iframe')?.getAttribute('data-content-width'))).toBe(
dimensions.width
);
});
};

export const Default = meta.story({
play: async ({ canvasElement }) => {
const cells = await within(canvasElement).findAllByTestId('review-collection-grid-cell');
await waitFor(() => {
for (const cell of cells) {
const cellWidth = cell.getBoundingClientRect().width;
const frame = cell.querySelector<HTMLElement>(
'[data-testid="review-collection-grid-frame"]'
);
expect(frame).toBeTruthy();
expect(frame!.getBoundingClientRect().width).toBeLessThanOrEqual(cellWidth + 1);
}
});
},
});

export const QueuedPreviewShowsLoader = meta.story({
args: {
storyIds: [
'manager-main--default',
'manager-settings-aboutscreen--default',
'manager-sidebar-sidebar--simple',
'button-component--base',
'button-component--variants',
],
showAll: true,
},
globals: { viewport: { value: 'desktop' } },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const cells = await canvas.findAllByTestId('review-collection-grid-cell');
expect(cells.length).toBe(5);

await waitFor(() => {
const started = cells.filter((cell) => cell.querySelector('iframe[src]'));
expect(started.length).toBeGreaterThanOrEqual(3);
});

const queuedCells = cells.filter((cell) => !cell.querySelector('iframe[src]'));
expect(queuedCells.length).toBeGreaterThan(0);
for (const cell of queuedCells) {
expect(within(cell).getByTestId('review-preview-loading')).toBeInTheDocument();
}
},
});

export const PreviewLoadingSettle = meta.story({
args: {
storyIds: ['manager-main--default'],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const cell = await canvas.findByTestId('review-collection-grid-cell');
await waitForCellPreviewSettled(cell);

// Duplicate iframe.resize payloads should not leave the loader stuck.
dispatchIframeResize(cell, 320, 240);
expect(within(cell).queryByTestId('review-preview-loading')).not.toBeInTheDocument();
},
});

const StorySwapHarness: FC<Partial<CollectionGridProps>> = () => {
const [storyIds, setStoryIds] = React.useState(['manager-main--default']);
const storyInfo: Record<string, StoryInfo> = {
'manager-main--default': demoStoryInfo['manager-main--default'],
'manager-settings-aboutscreen--default': demoStoryInfo['manager-settings-aboutscreen--default'],
};

return (
<div>
<button
type="button"
data-testid="swap-preview-story"
onClick={() => setStoryIds(['manager-settings-aboutscreen--default'])}
>
Swap story
</button>
<CollectionGrid storyIds={storyIds} storyInfo={storyInfo} getStoryPreviewHref={previewHref} />
</div>
);
};

export const PreviewRemountOnStoryChange = meta.story({
render: () => <StorySwapHarness />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const cell = await canvas.findByTestId('review-collection-grid-cell');
await waitForCellPreviewSettled(cell);

expect(cell.querySelector('iframe')?.title).toBe('manager-main--default');

await userEvent.click(canvas.getByTestId('swap-preview-story'));

let nextCell: HTMLElement | undefined;
await waitFor(async () => {
nextCell = await canvas.findByTestId('review-collection-grid-cell');
expect(nextCell.querySelector('iframe')?.title).toBe('manager-settings-aboutscreen--default');
});
await waitForCellPreviewSettled(nextCell!, { width: 280, height: 180 });
},
});

// On a narrow (mobile) container the grid drops to a single column and caps at
// two rows, so eight stories overflow into the "Review all" affordance.
Expand Down Expand Up @@ -134,12 +286,24 @@ export const FewStories = meta.story({
canvasElement.querySelector<HTMLButtonElement>('[data-review-all] button')
).not.toBeVisible();

const frames = Array.from(cells).map((cell) => cell.firstElementChild as HTMLElement | null);
const frames = Array.from(cells).map((cell) =>
cell.querySelector<HTMLElement>('[data-testid="review-collection-grid-frame"]')
);
await waitFor(() => {
expect(frames.every((frame) => (frame?.clientHeight ?? 0) > 0)).toBe(true);
for (const cell of cells) {
const cellWidth = (cell as HTMLElement).getBoundingClientRect().width;
const frame = cell.querySelector<HTMLElement>(
'[data-testid="review-collection-grid-frame"]'
);
expect(frame).toBeTruthy();
expect(frame!.getBoundingClientRect().width).toBeLessThanOrEqual(cellWidth + 1);
}
});
const [firstHeight, secondHeight] = frames.map((frame) => frame?.clientHeight ?? 0);
expect(firstHeight).toBe(secondHeight);
const [firstHeight, secondHeight] = frames.map(
(frame) => frame?.getBoundingClientRect().height ?? 0
);
expect(Math.abs(firstHeight - secondHeight)).toBeLessThanOrEqual(2);
},
});

Expand Down Expand Up @@ -192,3 +356,74 @@ export const SingleCellClamped = meta.story({
await expect((cell as HTMLElement).getBoundingClientRect().width).toBeLessThanOrEqual(401);
},
});

export const FrameFitsCellAfterResize = meta.story({
args: {
storyIds: ['manager-main--default'],
},
globals: { viewport: { value: 'desktop' } },
play: async ({ canvasElement }) => {
const cell = await within(canvasElement).findByTestId('review-collection-grid-cell');
await waitForCellPreviewSettled(cell, {
width: 1280,
height: 800,
viewport: { name: 'Desktop', value: 'desktop', width: 1280, height: 1024 },
});
await waitFor(() => {
const cellWidth = cell.getBoundingClientRect().width;
const frame = cell.querySelector<HTMLElement>('[data-testid="review-collection-grid-frame"]');
expect(frame).toBeTruthy();
expect(frame!.getBoundingClientRect().width).toBeLessThanOrEqual(cellWidth + 1);
});
},
});

export const ViewportAspectRatio = meta.story({
args: {
storyIds: ['manager-main--default'],
},
play: async ({ canvasElement }) => {
const cell = await within(canvasElement).findByTestId('review-collection-grid-cell');
await waitForCellPreviewSettled(cell, {
width: 120,
height: 48,
viewport: { name: 'Small mobile', value: 'mobile1', width: 320, height: 568 },
});

const shell = cell.firstElementChild as HTMLElement;
const frame = cell.querySelector<HTMLElement>('[data-testid="review-collection-grid-frame"]');
expect(frame?.hasAttribute('data-viewport-fill')).toBe(true);

const shellRect = shell.getBoundingClientRect();
const frameRect = frame!.getBoundingClientRect();
expect(frameRect.width).toBeCloseTo(shellRect.width, 0);
expect(frameRect.height).toBeCloseTo(shellRect.height, 0);

const previewScale = frame?.querySelector('[data-preview-scale]') as HTMLElement | null;
expect(previewScale).toBeTruthy();
expect(previewScale!.getBoundingClientRect().height).toBeGreaterThan(frameRect.height);
},
});

export const ResponsiveViewportFillsCell = meta.story({
args: {
storyIds: ['manager-main--default'],
},
play: async ({ canvasElement }) => {
const cell = await within(canvasElement).findByTestId('review-collection-grid-cell');
await waitForCellPreviewSettled(cell, {
width: 320,
height: 240,
viewport: { name: 'Responsive', value: RESPONSIVE_VIEWPORT_VALUE, width: 800, height: 600 },
});

const shell = cell.firstElementChild as HTMLElement;
const frame = cell.querySelector<HTMLElement>('[data-testid="review-collection-grid-frame"]');
expect(frame?.hasAttribute('data-viewport-fill')).toBe(false);

const shellRect = shell.getBoundingClientRect();
const frameRect = frame!.getBoundingClientRect();
expect(frameRect.width).toBeCloseTo(shellRect.width, 0);
expect(frameRect.height).toBeCloseTo(shellRect.height, 0);
},
});
Loading
Loading