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
58 changes: 48 additions & 10 deletions web/src/components/3d/ViewerPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { hydrateRoot } from 'react-dom/client';
import { renderToString } from 'react-dom/server';
import { act, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useAppStore } from '@/store/useAppStore';
import { useAppStore, type HomeTabType } from '@/store/useAppStore';
import ViewerPanel from './ViewerPanel';

// The home page's 3D view is the build globe (hero, Inside) or the product
Expand Down Expand Up @@ -45,11 +45,12 @@ afterEach(() => {
vi.useRealTimers();
});

// The tabs beside the view, which must outlive it.
function Page() {
// The tabs beside the view, which must outlive it. The page passes the tab
// it was made for, as HomeView does.
function Page({ tab = 'hero' }: { tab?: HomeTabType }) {
return (
<>
<ViewerPanel />
<ViewerPanel initialTab={tab} />
<button type="button">Build</button>
</>
);
Expand All @@ -71,7 +72,7 @@ describe('ViewerPanel', () => {
(tab) => {
webgl.answer = false;
useAppStore.setState({ homeTab: tab });
render(<Page />);
render(<Page tab={tab} />);
expect(scenes.made).toEqual([]);
expect(screen.getByText(LINE)).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Build' })).toBeInTheDocument();
Expand All @@ -82,7 +83,7 @@ describe('ViewerPanel', () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
scenes.throws.add('product');
useAppStore.setState({ homeTab: 'pattern' });
render(<Page />);
render(<Page tab="pattern" />);
expect(screen.queryByTestId('scene')).not.toBeInTheDocument();
expect(screen.getByText(LINE)).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Build' })).toBeInTheDocument();
Expand All @@ -92,17 +93,54 @@ describe('ViewerPanel', () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
scenes.throws.add('product');
useAppStore.setState({ homeTab: 'build' });
render(<Page />);
render(<Page tab="build" />);
expect(screen.getByText(LINE)).toBeInTheDocument();
act(() => useAppStore.setState({ homeTab: 'inside' }));
act(() => vi.advanceTimersByTime(200));
expect(screen.getByTestId('scene')).toHaveTextContent('globe');
expect(screen.queryByText(LINE)).not.toBeInTheDocument();
});

it('sends the page as it was from the server, and the browser without WebGL takes it up without a mismatch', async () => {
// The store says hero until RightPanel's effect tells it otherwise. Read
// from it, /build opened on the globe and kept it through the fade: long
// enough to mount it and send for every pin's photos.
it.each(['build', 'pattern'] as const)('opens /%s on the product and never makes the globe', (tab) => {
render(<Page tab={tab} />);
expect(screen.getByTestId('scene')).toHaveTextContent('product');
// RightPanel's effect, telling the store the tab the page opened on.
act(() => useAppStore.getState().setHomeTab(tab));
act(() => vi.advanceTimersByTime(200));
expect(scenes.made).not.toContain('globe');
expect(screen.getByTestId('scene')).toHaveTextContent('product');
});

it('opens /pattern on the product when the store still holds Inside from an earlier visit', () => {
useAppStore.setState({ homeTab: 'inside' });
render(<Page tab="pattern" />);
act(() => useAppStore.getState().setHomeTab('pattern'));
act(() => vi.advanceTimersByTime(200));
expect(scenes.made).not.toContain('globe');
});

it('follows the store once it changes, from whichever tab the page opened on', () => {
render(<Page tab="build" />);
act(() => useAppStore.getState().setHomeTab('build'));
act(() => useAppStore.getState().setHomeTab('inside'));
act(() => vi.advanceTimersByTime(200));
expect(screen.getByTestId('scene')).toHaveTextContent('globe');
act(() => useAppStore.getState().setHomeTab('hero'));
expect(screen.getByTestId('scene')).toHaveTextContent('globe');
});

it('drops down on a phone from the first render of /build, as the panel beside it does', () => {
const html = renderToString(<Page tab="build" />);
expect(html).toContain('viewer-panel is-open');
expect(renderToString(<Page />)).not.toContain('is-open');
});

it.each(['hero', 'build'] as const)('sends %s as it was from the server, and the browser without WebGL takes it up without a mismatch', async (tab) => {
webgl.answer = false;
const html = renderToString(<Page />);
const html = renderToString(<Page tab={tab} />);
// The server cannot ask: it answers yes and never calls the probe.
expect(webgl.asked).toBe(0);
expect(html).not.toContain('draw the 3D view');
Expand All @@ -111,7 +149,7 @@ describe('ViewerPanel', () => {
container.innerHTML = html;
document.body.appendChild(container);
const recoverable = vi.fn();
const root = await act(async () => hydrateRoot(container, <Page />, { onRecoverableError: recoverable }));
const root = await act(async () => hydrateRoot(container, <Page tab={tab} />, { onRecoverableError: recoverable }));
expect(recoverable).not.toHaveBeenCalled();
expect(container).toHaveTextContent(LINE);
expect(container.querySelector('[data-testid="scene"]')).toBeNull();
Expand Down
14 changes: 11 additions & 3 deletions web/src/components/3d/ViewerPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import dynamic from 'next/dynamic';
import { Component, useEffect, useState, useSyncExternalStore, type ReactNode } from 'react';
import HeroScene from './HeroScene';
import { useAppStore } from '@/store/useAppStore';
import { useAppStore, type HomeTabType } from '@/store/useAppStore';
import { hasWebGL } from '@/lib/webgl';

// The hero and the Inside section show the build globe; Build and Pattern
Expand Down Expand Up @@ -41,8 +41,16 @@ class SceneGuard extends Component<{ children: ReactNode }, { failed: boolean }>
// On desktop the panel is always shown (sticky left column). On mobile it stays
// hidden while the hero is full-screen and only "drops down" once a section tab
// (Build / Pattern / Inside) is opened from the bottom nav.
export default function ViewerPanel() {
const homeTab = useAppStore((state) => state.homeTab);
export default function ViewerPanel({ initialTab = 'hero' }: { initialTab?: HomeTabType }) {
// The store starts on the hero on every page and hears the page's tab from
// RightPanel's effect, after this panel has rendered once. Read from the
// store, /build and /pattern opened on the globe for the length of the
// fade, long enough to mount it and send for every pin's photos. So the
// panel starts on the tab the page was made for and follows the store from
// its first change. This effect runs before RightPanel's: siblings mount in
// order, and HomeView puts this panel first.
const [homeTab, setHomeTab] = useState(initialTab);
useEffect(() => useAppStore.subscribe((state) => setHomeTab(state.homeTab)), []);
const isOpen = homeTab !== 'hero';
// The server cannot ask for a context, so it answers yes and sends the page
// as it was. The client asks while hydrating, before either scene's canvas
Expand Down
2 changes: 1 addition & 1 deletion web/src/components/HomeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export default function HomeView({ initialTab = 'hero', initialBuildId = null }:
<div className="mobile-page-links">
<HeroJournalLink />
</div>
<ViewerPanel />
<ViewerPanel initialTab={initialTab} />
<RightPanel
initialTab={initialTab}
buildContent={buildContent}
Expand Down
54 changes: 54 additions & 0 deletions web/src/components/sections/InsideGlobe/GlobeViewer.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { act, render } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useAppStore } from '@/store/useAppStore';
import { builds, CARD_TILES, STRIP_TILES } from './builds';
import GlobeViewer from './GlobeViewer';

// The globe warms each pin's opening photos so a pin opens with them. Below
// 901px the 3D view is hidden on the hero, yet the globe there still sent for
// every one of them, in the large variant a 3× phone picks: 65 photos and
// 8.3 MB on 22 October, for a map nobody saw.

vi.mock('./Globe', () => ({ default: () => <div data-testid="globe" /> }));

const media = vi.hoisted(() => ({ wide: false }));
const TILES = builds.flatMap((build) => (build.images ?? []).slice(0, Math.max(STRIP_TILES, CARD_TILES)));

beforeEach(() => {
vi.stubGlobal('matchMedia', (query: string) => ({
media: query,
matches: query === '(min-width: 901px)' && media.wide,
addEventListener: () => {},
removeEventListener: () => {},
}));
useAppStore.setState({ homeTab: 'hero', selectedBuildId: null, insideFilter: 'all' });
});

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

const warmed = (container: HTMLElement) => container.querySelectorAll('.preload img').length;

describe('GlobeViewer', () => {
it('warms no photo for the hidden globe of a phone’s hero', () => {
media.wide = false;
const { container, getByTestId } = render(<GlobeViewer />);
expect(getByTestId('globe')).toBeInTheDocument();
expect(warmed(container)).toBe(0);
});

it('warms every pin’s opening photos once a phone opens Inside', () => {
media.wide = false;
const { container } = render(<GlobeViewer />);
act(() => useAppStore.getState().setHomeTab('inside'));
expect(TILES.length).toBeGreaterThan(0);
expect(warmed(container)).toBe(TILES.length);
});

it('warms them on the hero from 901px up, where the globe stands beside the page', () => {
media.wide = true;
const { container } = render(<GlobeViewer />);
expect(warmed(container)).toBe(TILES.length);
});
});
29 changes: 20 additions & 9 deletions web/src/components/sections/InsideGlobe/GlobeViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import PinLink from './PinLink';
import { builds, formatBuildDate, CARD_TILES, STRIP_TILES } from './builds';
import { useBuildSelection } from './useBuildSelection';
import { useAppStore } from '@/store/useAppStore';
import { useMediaQuery } from '@/lib/hooks/useMediaQuery';
import styles from './GlobeViewer.module.css';

// The Inside section's viewer: an interactive globe with the picked build's
Expand All @@ -22,7 +23,15 @@ export default function GlobeViewer() {
const { selectedId, select: selectBuild, visibleBuilds } = useBuildSelection();
// On the hero the globe stands in for the product shot: no filters, and a
// pin opens the Inside section rather than a detail card beside hero copy.
const onHero = useAppStore((state) => state.homeTab) === 'hero';
const homeTab = useAppStore((state) => state.homeTab);
const onHero = homeTab === 'hero';
// Below 901px the 3D view is hidden on the hero (globals.css) and drops
// down only for a tab, so a phone shows the globe on Inside alone. The
// photos below are warmed only where the globe is seen: a phone's hidden
// hero globe sent for all of them, in the large variant a 3× screen picks.
// The query reads false until measured, so a phone never renders them.
const wide = useMediaQuery('(min-width: 901px)');
const warm = wide || homeTab === 'inside';
const [galleryIndex, setGalleryIndex] = useState<number | null>(null);
const [hasSelected, setHasSelected] = useState(false);

Expand Down Expand Up @@ -70,17 +79,19 @@ export default function GlobeViewer() {
return (
<div className={styles.viewer}>
{/* Offscreen preloader: fetch each pin's opening tiles (the same optimized
variant the strip and card use) eagerly the moment the globe mounts, so
variant the strip and card use) eagerly the moment the globe is seen, so
opening a pin shows its photos instantly instead of lazy-loading. Low
fetch priority so they queue behind the globe itself rather than
competing with it for bandwidth on first paint. */}
<div className={styles.preload} aria-hidden>
{allImages.map((image) => (
<span key={image.src} className={styles.preloadBox}>
<Image src={image.src} alt="" fill sizes="280px" loading="eager" fetchPriority="low" />
</span>
))}
</div>
{warm && (
<div className={styles.preload} aria-hidden>
{allImages.map((image) => (
<span key={image.src} className={styles.preloadBox}>
<Image src={image.src} alt="" fill sizes="280px" loading="eager" fetchPriority="low" />
</span>
))}
</div>
)}

<Globe entries={visibleBuilds} selectedBuildId={selectedId ?? undefined} onSelectBuild={select} />
{!onHero && <BuildFilters />}
Expand Down
Loading