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