import { GeneratedFolder, ProjectUIDL } from '@teleporthq/teleport-types' import { PageTransition } from '@teleporthq/teleport-shared' import uidlSample from '../../../examples/test-samples/project-sample.json' import { createNextProjectGenerator } from '../src' import NextTemplate from '../src/project-template' import { pageTransitionCover, pageTransitionCoverColor, pageTransitionSkipPattern, pageTransitionVariants, resolvePageTransitionOptions, reversePageTransitionPreset, } from '../src/page-transition/page-transition-variants' import { revealOnScreen } from '../src/page-transition/reveal-on-screen' import { REVEAL_ON_SCREEN_SOURCES } from '../src/page-transition/reveal-on-screen-source.generated' const template = () => JSON.parse(JSON.stringify(NextTemplate)) as GeneratedFolder const buildUidl = (pageTransition?: { preset: string duration: number easing: string skipRoutes?: string[] options?: Record custom?: { arrive: Record; leave: Record } flyingPictures?: boolean }) => { const uidl = JSON.parse(JSON.stringify(uidlSample)) as ProjectUIDL if (pageTransition) { ;(uidl.globals.settings as { pageTransition?: unknown }).pageTransition = pageTransition } return uidl } const findFile = (folder: GeneratedFolder, folderName: string, fileName: string) => folder.subFolders .find((sub) => sub.name === folderName) ?.files.find((file) => file.name === fileName) describe('Next generator page transition', () => { const generator = createNextProjectGenerator() it('leaves a project without a transition exactly as before', async () => { const output = await generator.generateProject(buildUidl(), template()) expect(findFile(output, 'components', 'tq-page-transition')).toBeUndefined() const app = findFile(output, 'pages', '_app') expect(app?.content).toContain('') expect(app?.content).not.toContain('TqPageTransition') const pkg = output.files.find((file) => file.name === 'package') expect(pkg?.content).not.toContain('framer-motion') }) it('wraps the page mount and ships the wrapper when the editor chose one', async () => { const output = await generator.generateProject( buildUidl({ preset: 'slide-up', duration: 0.25, easing: 'ease-out' }), template() ) const app = findFile(output, 'pages', '_app') expect(app?.content).toContain( "import TqPageTransition from '../components/tq-page-transition'" ) expect(app?.content).toContain( '' ) const wrapper = findFile(output, 'components', 'tq-page-transition') expect(wrapper?.content).toContain("const PRESET = 'slide-up'") expect(wrapper?.content).toContain('function reversePageTransitionPreset(') expect(wrapper?.content).toContain('router.beforePopState(') expect(wrapper?.content).toContain("window.addEventListener('pointerdown'") expect(wrapper?.content).toContain('const DURATION = 0.25') expect(wrapper?.content).toContain('const EASE = [0, 0, 0.58, 1]') expect(wrapper?.content).toContain('function pageTransitionVariants(') expect(wrapper?.content).toContain( "AnimatePresence mode={context.morph || !OVERLAP ? 'wait' : 'sync'} initial={false}" ) expect(wrapper?.content).toContain("router.events.on('routeChangeStart'") const pkg = output.files.find((file) => file.name === 'package') expect(pkg?.content).toContain('"framer-motion"') expect(pkg?.content).toContain('"react": "^18.3.1"') }) // The server cannot know that a visitor asks for less motion. A wrapper that // rendered the bare page for them made React throw the server's page away on // every load (error 418) and draw it again in the browser. it('renders the same page for every visitor and gives less motion each page at once', async () => { const output = await generator.generateProject( buildUidl({ preset: 'slide-up', duration: 0.25, easing: 'ease-out' }), template() ) const wrapper = findFile(output, 'components', 'tq-page-transition')?.content || '' expect(wrapper).not.toContain('useReducedMotion') expect(wrapper).toContain('if (!variants) {\n return children\n }') expect(wrapper).toContain('const skip = prefersLessMotion() || isSkippedRoute(routeKeyOf(url))') }) it('ignores a transition it does not know and a preset of none', async () => { const output = await generator.generateProject( buildUidl({ preset: 'none', duration: 0.35, easing: 'ease-out' }), template() ) expect(findFile(output, 'components', 'tq-page-transition')).toBeUndefined() expect(findFile(output, 'pages', '_app')?.content).not.toContain('TqPageTransition') }) it('falls back to the site curve when the easing name is unknown', async () => { const output = await generator.generateProject( buildUidl({ preset: 'fade', duration: 0.45, easing: 'wobble' }), template() ) expect(findFile(output, 'components', 'tq-page-transition')?.content).toContain( 'const EASE = [0, 0, 0.58, 1]' ) }) // Hero photographs of 3000px missed a 300 ms wait: the page faded in on an // empty hero and the photograph snapped in over it in one frame. it('plays an arriving page once the pictures on its first screen are decoded, longer while the leaving page holds the screen', async () => { const output = await generator.generateProject( buildUidl({ preset: 'fade', duration: 0.35, easing: 'ease-out' }), template() ) const wrapper = findFile(output, 'components', 'tq-page-transition')?.content || '' expect(wrapper).toContain('const PICTURE_WAIT_MS = 1200') expect(wrapper).toContain('const BARE_PICTURE_WAIT_MS = 300') expect(wrapper).toContain('image.decode().catch(() => {})') // the long wait only where the leaving page is still on screen: overlapping, or pictured by the browser expect(wrapper).toContain( 'const held = !!landing || !!(overlap && overlap.arrivingKey === key)\n' + ' Promise.all([picturesReady(node, held ? PICTURE_WAIT_MS : BARE_PICTURE_WAIT_MS), twinReady]).then(([loading]) => {' ) // the first page is ready at once; a later one waits at its starting state expect(wrapper).toContain('const [readyKey, setReadyKey] = React.useState(routeKey)') expect(wrapper).toContain("animate={readyKey === routeKey ? 'animate' : 'initial'}") // a page that arrived after it takes the turn: a late answer changes nothing expect(wrapper).toContain('if (readyKeyRef.current === key) {') }) describe('a picture that misses the wait', () => { const latePictures = async () => { const output = await generator.generateProject( buildUidl({ preset: 'fade', duration: 0.35, easing: 'ease-out' }), template() ) const wrapper = findFile(output, 'components', 'tq-page-transition')?.content || '' const runtime = new Function( 'window', [ declarationOf(wrapper, 'DURATION'), declarationOf(wrapper, 'LATE_PICTURE_WAIT_MS'), declarationOf(wrapper, 'LATE_PICTURE_ATTR'), declarationOf(wrapper, 'picturesReady'), declarationOf(wrapper, 'fadeInWhenLoaded'), 'return { picturesReady, fadeInWhenLoaded }', ].join('\n') )({ innerHeight: 800 }) jest.useFakeTimers() return { wrapper, ...runtime } } const pictureAt = ( top: number, loaded: boolean, decode = () => new Promise(() => undefined) ) => { const attributes = new Map() const listeners = new Map void>>() const picture = { complete: loaded, attributes, decode, getBoundingClientRect: () => ({ top, bottom: top + 400 }), setAttribute: (name: string, value: string) => attributes.set(name, value), removeAttribute: (name: string) => attributes.delete(name), addEventListener: (type: string, listener: (event: { type: string }) => void) => listeners.set(type, (listeners.get(type) || new Set()).add(listener)), removeEventListener: (type: string, listener: (event: { type: string }) => void) => listeners.get(type)?.delete(listener), listening: () => [...listeners.values()].reduce((sum, set) => sum + set.size, 0), fire: (type: string) => { picture.complete = true ;[...(listeners.get(type) || [])].forEach((listener) => listener({ type })) }, } return picture } const ATTR = 'data-tq-late-picture' afterEach(() => jest.useRealTimers()) it('waits unseen and fades in once it has loaded, the way its page did', async () => { const { wrapper, picturesReady, fadeInWhenLoaded } = await latePictures() const decoded = pictureAt(0, true, () => Promise.resolve()) const hero = pictureAt(100, false) const belowTheFold = pictureAt(1200, false) const ready = picturesReady({ querySelectorAll: () => [decoded, hero, belowTheFold] }, 1200) jest.advanceTimersByTime(1200) // settled by the wait, with the first-screen pictures still loading expect(await ready).toEqual([hero]) fadeInWhenLoaded(hero) expect(hero.attributes.get(ATTR)).toBe('loading') hero.fire('load') expect(hero.attributes.get(ATTR)).toBe('in') jest.advanceTimersByTime(350 + 100) expect(hero.attributes.has(ATTR)).toBe(false) expect(hero.listening()).toBe(0) // unseen while loading, then faded in with the page's own duration and curve expect(wrapper).toContain( 'const LATE_PICTURE_CSS = ' + JSON.stringify( `[${ATTR}="loading"] { opacity: 0 !important; }\n` + `[${ATTR}="in"] { transition: opacity 0.35s cubic-bezier(0, 0, 0.58, 1) !important; }` ) ) expect(wrapper).toContain('