diff --git a/web/src/components/3d/ViewerPanel.test.tsx b/web/src/components/3d/ViewerPanel.test.tsx index d9aaeb1d0..267cd2281 100644 --- a/web/src/components/3d/ViewerPanel.test.tsx +++ b/web/src/components/3d/ViewerPanel.test.tsx @@ -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 @@ -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 ( <> - + ); @@ -71,7 +72,7 @@ describe('ViewerPanel', () => { (tab) => { webgl.answer = false; useAppStore.setState({ homeTab: tab }); - render(); + render(); expect(scenes.made).toEqual([]); expect(screen.getByText(LINE)).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Build' })).toBeInTheDocument(); @@ -82,7 +83,7 @@ describe('ViewerPanel', () => { vi.spyOn(console, 'error').mockImplementation(() => {}); scenes.throws.add('product'); useAppStore.setState({ homeTab: 'pattern' }); - render(); + render(); expect(screen.queryByTestId('scene')).not.toBeInTheDocument(); expect(screen.getByText(LINE)).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Build' })).toBeInTheDocument(); @@ -92,7 +93,7 @@ describe('ViewerPanel', () => { vi.spyOn(console, 'error').mockImplementation(() => {}); scenes.throws.add('product'); useAppStore.setState({ homeTab: 'build' }); - render(); + render(); expect(screen.getByText(LINE)).toBeInTheDocument(); act(() => useAppStore.setState({ homeTab: 'inside' })); act(() => vi.advanceTimersByTime(200)); @@ -100,9 +101,46 @@ describe('ViewerPanel', () => { 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(); + 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(); + 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(); + 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(); + expect(html).toContain('viewer-panel is-open'); + expect(renderToString()).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(); + const html = renderToString(); // 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'); @@ -111,7 +149,7 @@ describe('ViewerPanel', () => { container.innerHTML = html; document.body.appendChild(container); const recoverable = vi.fn(); - const root = await act(async () => hydrateRoot(container, , { onRecoverableError: recoverable })); + const root = await act(async () => hydrateRoot(container, , { onRecoverableError: recoverable })); expect(recoverable).not.toHaveBeenCalled(); expect(container).toHaveTextContent(LINE); expect(container.querySelector('[data-testid="scene"]')).toBeNull(); diff --git a/web/src/components/3d/ViewerPanel.tsx b/web/src/components/3d/ViewerPanel.tsx index 792880b17..56382336c 100644 --- a/web/src/components/3d/ViewerPanel.tsx +++ b/web/src/components/3d/ViewerPanel.tsx @@ -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 @@ -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 diff --git a/web/src/components/HomeView.tsx b/web/src/components/HomeView.tsx index 9f031874f..bf1505a57 100644 --- a/web/src/components/HomeView.tsx +++ b/web/src/components/HomeView.tsx @@ -23,7 +23,7 @@ export default function HomeView({ initialTab = 'hero', initialBuildId = null }:
- +