From 89ba8cba49f0d954ef8360ac43ab66a39faad30b Mon Sep 17 00:00:00 2001 From: Daniel Castro <2968031+odanieldcs@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:11:48 -0300 Subject: [PATCH 1/4] feat(motion): fade the page in only after the first load (ENG-154) Keep the first document paint opaque so the LCP stays the hero or cover, and run the 200ms fade on later client navigations. --- app/(en)/en/template.tsx | 3 ++- app/(pt)/template.tsx | 3 ++- components/layout/page-enter.test.tsx | 27 +++++++++++++++++++++ components/layout/page-enter.tsx | 26 ++++++++++++++++++++ e2e/page-enter.spec.ts | 34 +++++++++++++++++++++++++++ 5 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 components/layout/page-enter.test.tsx create mode 100644 components/layout/page-enter.tsx create mode 100644 e2e/page-enter.spec.ts diff --git a/app/(en)/en/template.tsx b/app/(en)/en/template.tsx index 6e1357b..a77b70b 100644 --- a/app/(en)/en/template.tsx +++ b/app/(en)/en/template.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from 'react' +import { PageEnter } from '@/components/layout/page-enter' export default function Template({ children }: { children: ReactNode }) { - return
{children}
+ return {children} } diff --git a/app/(pt)/template.tsx b/app/(pt)/template.tsx index 6e1357b..a77b70b 100644 --- a/app/(pt)/template.tsx +++ b/app/(pt)/template.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from 'react' +import { PageEnter } from '@/components/layout/page-enter' export default function Template({ children }: { children: ReactNode }) { - return
{children}
+ return {children} } diff --git a/components/layout/page-enter.test.tsx b/components/layout/page-enter.test.tsx new file mode 100644 index 0000000..24cfc9f --- /dev/null +++ b/components/layout/page-enter.test.tsx @@ -0,0 +1,27 @@ +import { cleanup, render } from '@testing-library/react' +import { renderToString } from 'react-dom/server' +import { afterEach, expect, test } from 'vitest' +import { PageEnter, willAnimateNextEnter } from './page-enter' + +afterEach(() => { + cleanup() +}) + +test('animates only on mounts after the first one in the module', () => { + expect(willAnimateNextEnter()).toBe(false) + + const html = renderToString(documento) + expect(html).not.toContain('animate-page-in') + expect(willAnimateNextEnter()).toBe(false) + + const first = render(home) + const firstEnter = first.container.querySelector('[data-page-enter]') + expect(firstEnter).not.toBeNull() + expect(firstEnter?.className ?? '').not.toContain('animate-page-in') + expect(willAnimateNextEnter()).toBe(true) + first.unmount() + + const second = render(about) + const secondEnter = second.container.querySelector('[data-page-enter]') + expect(secondEnter?.className).toContain('motion-safe:animate-page-in') +}) diff --git a/components/layout/page-enter.tsx b/components/layout/page-enter.tsx new file mode 100644 index 0000000..b79c58a --- /dev/null +++ b/components/layout/page-enter.tsx @@ -0,0 +1,26 @@ +'use client' + +import { type ReactNode, useEffect, useState } from 'react' + +let seenFirstMount = false + +export function willAnimateNextEnter() { + return seenFirstMount +} + +export function PageEnter({ children }: { children: ReactNode }) { + const [animate] = useState(seenFirstMount) + + useEffect(() => { + seenFirstMount = true + }, []) + + return ( +
+ {children} +
+ ) +} diff --git a/e2e/page-enter.spec.ts b/e2e/page-enter.spec.ts new file mode 100644 index 0000000..62ad7b0 --- /dev/null +++ b/e2e/page-enter.spec.ts @@ -0,0 +1,34 @@ +import { expect, test } from '@playwright/test' + +const postPath = '/blog/postgresql-e-pgadmin-com-docker-compose' + +for (const path of ['/', postPath] as const) { + test(`document load on ${path} does not run page-in`, async ({ page }) => { + await page.goto(path) + await expect(page.locator('[data-page-enter]')).toHaveCSS( + 'animation-name', + 'none', + ) + }) +} + +test('client navigation fades the page in', async ({ page }) => { + await page.goto('/') + await page.getByRole('banner').getByRole('link', { name: 'Sobre' }).click() + await expect(page).toHaveURL('/about') + await expect(page.locator('[data-page-enter]')).toHaveCSS( + 'animation-name', + 'page-in', + ) +}) + +test('reduced motion skips the navigation fade', async ({ page }) => { + await page.emulateMedia({ reducedMotion: 'reduce' }) + await page.goto('/') + await page.getByRole('banner').getByRole('link', { name: 'Sobre' }).click() + await expect(page).toHaveURL('/about') + await expect(page.locator('[data-page-enter]')).toHaveCSS( + 'animation-name', + 'none', + ) +}) From e3e454b402f46400d5fbcaac4c42e766081922a0 Mon Sep 17 00:00:00 2001 From: Daniel Castro <2968031+odanieldcs@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:11:48 -0300 Subject: [PATCH 2/4] fix(motion): keep the loading bar up through the page fade (ENG-154) Show the bar on click when the next page will fade, and wait one second before the loading message. --- .../layout/navigation-progress.test.tsx | 135 +++++++++++++++++- components/layout/navigation-progress.tsx | 95 ++++++++++-- 2 files changed, 215 insertions(+), 15 deletions(-) diff --git a/components/layout/navigation-progress.test.tsx b/components/layout/navigation-progress.test.tsx index e42e08a..0868f0b 100644 --- a/components/layout/navigation-progress.test.tsx +++ b/components/layout/navigation-progress.test.tsx @@ -1,8 +1,65 @@ -import { expect, test } from 'vitest' -import { isInternalPageNavigation } from './navigation-progress' +import { act, cleanup, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, expect, test, vi } from 'vitest' +import { InterfaceLanguageProvider } from '@/components/interface-language-provider' +import { + isInternalPageNavigation, + NavigationProgress, +} from './navigation-progress' const here = 'http://localhost:3000/blog' +const navigation = vi.hoisted(() => ({ + pathname: '/', + animateNext: false, +})) + +vi.mock('next/navigation', () => ({ + usePathname: () => navigation.pathname, + useSearchParams: () => new URLSearchParams(), +})) + +vi.mock('@/components/layout/page-enter', () => ({ + willAnimateNextEnter: () => navigation.animateNext, +})) + +beforeEach(() => { + navigation.pathname = '/' + navigation.animateNext = false + vi.stubGlobal( + 'matchMedia', + (query: string) => + ({ + matches: false, + media: query, + addEventListener: () => {}, + removeEventListener: () => {}, + }) as MediaQueryList, + ) +}) + +afterEach(() => { + cleanup() + document.body.replaceChildren() + vi.useRealTimers() +}) + +function renderProgress() { + return render( + + + , + ) +} + +function clickAbout() { + const anchor = document.createElement('a') + anchor.href = '/about' + document.body.append(anchor) + anchor.addEventListener('click', (event) => event.preventDefault()) + anchor.click() + anchor.remove() +} + test('tracks another internal page and a new search', () => { expect(isInternalPageNavigation('/about', here)).toBe(true) expect(isInternalPageNavigation('/blog?page=2', here)).toBe(true) @@ -19,3 +76,77 @@ test('ignores the current url, hash-only jumps, and other sites', () => { expect(isInternalPageNavigation('/blog#logs', here)).toBe(false) expect(isInternalPageNavigation('https://linkedin.com', here)).toBe(false) }) + +test('shows the bar on click and keeps the message until the page has been loading for one second', () => { + vi.useFakeTimers() + navigation.animateNext = true + renderProgress() + + act(() => { + clickAbout() + }) + + expect(screen.queryByRole('status')).toBeNull() + expect(document.querySelector('[data-phase="loading"]')).not.toBeNull() + + act(() => { + vi.advanceTimersByTime(999) + }) + expect(screen.queryByRole('status')).toBeNull() + + act(() => { + vi.advanceTimersByTime(1) + }) + expect(screen.getByRole('status').textContent).toContain( + 'O conteúdo está sendo carregado', + ) +}) + +test('hides the bar when the entering page finishes fading in', () => { + navigation.animateNext = true + const view = renderProgress() + + act(() => { + clickAbout() + }) + + const enter = document.createElement('div') + enter.dataset.pageEnter = '' + enter.className = 'motion-safe:animate-page-in' + document.body.append(enter) + navigation.pathname = '/about' + view.rerender( + + + , + ) + + expect(screen.queryByRole('status')).toBeNull() + + act(() => { + const event = new Event('animationend') + Object.defineProperty(event, 'animationName', { value: 'page-in' }) + enter.dispatchEvent(event) + }) + + expect(screen.queryByRole('status')).toBeNull() + expect(document.querySelector('[data-phase="complete"]')).not.toBeNull() +}) + +test('keeps a fast navigation quiet when the page will not fade', () => { + vi.useFakeTimers() + renderProgress() + + act(() => { + clickAbout() + }) + + expect(screen.queryByRole('status')).toBeNull() + expect(document.querySelector('[data-phase="loading"]')).toBeNull() + + act(() => { + vi.advanceTimersByTime(99) + }) + + expect(document.querySelector('[data-phase="loading"]')).toBeNull() +}) diff --git a/components/layout/navigation-progress.tsx b/components/layout/navigation-progress.tsx index f46eb40..6341aa7 100644 --- a/components/layout/navigation-progress.tsx +++ b/components/layout/navigation-progress.tsx @@ -7,10 +7,12 @@ import { LoadingBar, LoadingStatus, } from '@/components/layout/loading-indicator' +import { willAnimateNextEnter } from '@/components/layout/page-enter' const SHOW_AFTER_MS = 100 -const MESSAGE_AFTER_MS = 2000 +const MESSAGE_AFTER_MS = 1000 const HIDE_AFTER_MS = 320 +const ENTER_HOLD_MS = 400 type Phase = 'idle' | 'loading' | 'complete' type TimerRef = { current: number | null } @@ -31,6 +33,13 @@ function clearTimer(timerRef: TimerRef) { timerRef.current = null } +function navigationFadesIn() { + return ( + willAnimateNextEnter() && + !window.matchMedia('(prefers-reduced-motion: reduce)').matches + ) +} + function NavigationProgressIndicator() { const pathname = usePathname() const searchParams = useSearchParams() @@ -43,37 +52,96 @@ function NavigationProgressIndicator() { const showTimerRef = useRef(null) const messageTimerRef = useRef(null) const hideTimerRef = useRef(null) + const holdTimerRef = useRef(null) // biome-ignore lint/correctness/useExhaustiveDependencies: routeKey is the commit signal; the effect reads refs useEffect(() => { if (!pendingRef.current) return - pendingRef.current = false + const wasVisible = visibleRef.current clearTimer(showTimerRef) - clearTimer(messageTimerRef) - setShowMessage(false) - const wasVisible = visibleRef.current - visibleRef.current = false - if (!wasVisible) return + let cancelled = false + + const settle = () => { + if (cancelled) return + cancelled = true + pendingRef.current = false + visibleRef.current = false + clearTimer(holdTimerRef) + clearTimer(messageTimerRef) + setShowMessage(false) + if (!wasVisible) return + + setPhase('complete') + hideTimerRef.current = window.setTimeout(() => { + setPhase('idle') + }, HIDE_AFTER_MS) + } + + if (!wasVisible || !navigationFadesIn()) { + settle() + return + } - setPhase('complete') - hideTimerRef.current = window.setTimeout(() => { - setPhase('idle') - }, HIDE_AFTER_MS) + let detach = () => {} + let frame = 0 + const started = performance.now() + + const scan = () => { + if (cancelled) return + const enter = document.querySelector('[data-page-enter]') + if ( + enter instanceof HTMLElement && + enter.classList.contains('motion-safe:animate-page-in') + ) { + const onEnd = (event: AnimationEvent) => { + if (event.target !== enter || event.animationName !== 'page-in') { + return + } + settle() + } + enter.addEventListener('animationend', onEnd) + detach = () => enter.removeEventListener('animationend', onEnd) + holdTimerRef.current = window.setTimeout(settle, ENTER_HOLD_MS) + return + } + if (performance.now() - started > ENTER_HOLD_MS) { + settle() + return + } + frame = window.requestAnimationFrame(scan) + } + + scan() + + return () => { + cancelled = true + window.cancelAnimationFrame(frame) + detach() + clearTimer(holdTimerRef) + } }, [routeKey]) useEffect(() => { const begin = () => { clearTimer(hideTimerRef) + clearTimer(holdTimerRef) pendingRef.current = true setShowMessage(false) clearTimer(showTimerRef) clearTimer(messageTimerRef) - showTimerRef.current = window.setTimeout(() => { + + if (navigationFadesIn()) { visibleRef.current = true setPhase('loading') - }, SHOW_AFTER_MS) + } else { + showTimerRef.current = window.setTimeout(() => { + visibleRef.current = true + setPhase('loading') + }, SHOW_AFTER_MS) + } + messageTimerRef.current = window.setTimeout(() => { setShowMessage(true) }, MESSAGE_AFTER_MS) @@ -100,6 +168,7 @@ function NavigationProgressIndicator() { clearTimer(showTimerRef) clearTimer(messageTimerRef) clearTimer(hideTimerRef) + clearTimer(holdTimerRef) } }, []) From 9cef5d75243bbe39d146c645797b9678fa025636 Mon Sep 17 00:00:00 2001 From: Daniel Castro <2968031+odanieldcs@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:39:33 -0300 Subject: [PATCH 3/4] fix(motion): type the matchMedia stub in the loading bar test (ENG-154) The partial MediaQueryList cast failed tsc, which also took down the production build and e2e. --- components/layout/navigation-progress.test.tsx | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/components/layout/navigation-progress.test.tsx b/components/layout/navigation-progress.test.tsx index 0868f0b..22c233b 100644 --- a/components/layout/navigation-progress.test.tsx +++ b/components/layout/navigation-progress.test.tsx @@ -25,16 +25,12 @@ vi.mock('@/components/layout/page-enter', () => ({ beforeEach(() => { navigation.pathname = '/' navigation.animateNext = false - vi.stubGlobal( - 'matchMedia', - (query: string) => - ({ - matches: false, - media: query, - addEventListener: () => {}, - removeEventListener: () => {}, - }) as MediaQueryList, - ) + vi.stubGlobal('matchMedia', ((query: string) => ({ + matches: false, + media: query, + addEventListener: () => {}, + removeEventListener: () => {}, + })) as unknown as typeof window.matchMedia) }) afterEach(() => { From e7eb37a32d6fe492fff68c1cea60a02148237fcc Mon Sep 17 00:00:00 2001 From: Daniel Castro <2968031+odanieldcs@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:35:41 -0300 Subject: [PATCH 4/4] fix(motion): drop the loading bar when the page does not fade (ENG-154) Hold the bar only while page-in is actually running, so a reused template settles on the next frame instead of waiting out the failsafe. --- .../layout/navigation-progress.test.tsx | 79 ++++++++++++++++--- components/layout/navigation-progress.tsx | 45 ++++++----- 2 files changed, 91 insertions(+), 33 deletions(-) diff --git a/components/layout/navigation-progress.test.tsx b/components/layout/navigation-progress.test.tsx index 22c233b..9953f3c 100644 --- a/components/layout/navigation-progress.test.tsx +++ b/components/layout/navigation-progress.test.tsx @@ -37,6 +37,7 @@ afterEach(() => { cleanup() document.body.replaceChildren() vi.useRealTimers() + vi.unstubAllGlobals() }) function renderProgress() { @@ -56,6 +57,49 @@ function clickAbout() { anchor.remove() } +function installAnimationFrames() { + const queued = new Map() + let nextId = 1 + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + const id = nextId + nextId += 1 + queued.set(id, callback) + return id + }) + vi.stubGlobal('cancelAnimationFrame', (id: number) => { + queued.delete(id) + }) + return () => { + const pending = [...queued.values()] + queued.clear() + act(() => { + for (const callback of pending) callback(0) + }) + } +} + +function mountPageEnter(playState: AnimationPlayState) { + const enter = document.createElement('div') + enter.dataset.pageEnter = '' + enter.className = 'motion-safe:animate-page-in' + enter.getAnimations = () => + [{ animationName: 'page-in', playState }] as unknown as Animation[] + document.body.append(enter) + return enter +} + +function commitRoute( + view: ReturnType, + pathname: string, +) { + navigation.pathname = pathname + view.rerender( + + + , + ) +} + test('tracks another internal page and a new search', () => { expect(isInternalPageNavigation('/about', here)).toBe(true) expect(isInternalPageNavigation('/blog?page=2', here)).toBe(true) @@ -100,24 +144,19 @@ test('shows the bar on click and keeps the message until the page has been loadi test('hides the bar when the entering page finishes fading in', () => { navigation.animateNext = true + const flushFrame = installAnimationFrames() const view = renderProgress() act(() => { clickAbout() }) - const enter = document.createElement('div') - enter.dataset.pageEnter = '' - enter.className = 'motion-safe:animate-page-in' - document.body.append(enter) - navigation.pathname = '/about' - view.rerender( - - - , - ) + const enter = mountPageEnter('running') + commitRoute(view, '/about') + flushFrame() expect(screen.queryByRole('status')).toBeNull() + expect(document.querySelector('[data-phase="loading"]')).not.toBeNull() act(() => { const event = new Event('animationend') @@ -129,6 +168,26 @@ test('hides the bar when the entering page finishes fading in', () => { expect(document.querySelector('[data-phase="complete"]')).not.toBeNull() }) +test('settles on the next frame when the template does not remount', () => { + navigation.animateNext = true + const flushFrame = installAnimationFrames() + const view = renderProgress() + + act(() => { + clickAbout() + }) + + mountPageEnter('finished') + commitRoute(view, '/blog/postgresql-e-pgadmin-com-docker-compose') + + expect(document.querySelector('[data-phase="loading"]')).not.toBeNull() + + flushFrame() + + expect(document.querySelector('[data-phase="loading"]')).toBeNull() + expect(document.querySelector('[data-phase="complete"]')).not.toBeNull() +}) + test('keeps a fast navigation quiet when the page will not fade', () => { vi.useFakeTimers() renderProgress() diff --git a/components/layout/navigation-progress.tsx b/components/layout/navigation-progress.tsx index 6341aa7..83a92d9 100644 --- a/components/layout/navigation-progress.tsx +++ b/components/layout/navigation-progress.tsx @@ -40,6 +40,16 @@ function navigationFadesIn() { ) } +function hasRunningPageIn(enter: Element) { + return enter.getAnimations().some((animation) => { + return ( + 'animationName' in animation && + animation.animationName === 'page-in' && + animation.playState === 'running' + ) + }) +} + function NavigationProgressIndicator() { const pathname = usePathname() const searchParams = useSearchParams() @@ -85,35 +95,24 @@ function NavigationProgressIndicator() { } let detach = () => {} - let frame = 0 - const started = performance.now() - - const scan = () => { + const frame = window.requestAnimationFrame(() => { if (cancelled) return const enter = document.querySelector('[data-page-enter]') - if ( - enter instanceof HTMLElement && - enter.classList.contains('motion-safe:animate-page-in') - ) { - const onEnd = (event: AnimationEvent) => { - if (event.target !== enter || event.animationName !== 'page-in') { - return - } - settle() - } - enter.addEventListener('animationend', onEnd) - detach = () => enter.removeEventListener('animationend', onEnd) - holdTimerRef.current = window.setTimeout(settle, ENTER_HOLD_MS) - return - } - if (performance.now() - started > ENTER_HOLD_MS) { + if (!(enter instanceof HTMLElement) || !hasRunningPageIn(enter)) { settle() return } - frame = window.requestAnimationFrame(scan) - } - scan() + const onEnd = (event: AnimationEvent) => { + if (event.target !== enter || event.animationName !== 'page-in') { + return + } + settle() + } + enter.addEventListener('animationend', onEnd) + detach = () => enter.removeEventListener('animationend', onEnd) + holdTimerRef.current = window.setTimeout(settle, ENTER_HOLD_MS) + }) return () => { cancelled = true