Skip to content

Commit 99d008a

Browse files
committed
Move theme init for page view transitions to critical code path, update tests
1 parent 9ecb163 commit 99d008a

7 files changed

Lines changed: 80 additions & 125 deletions

File tree

‎src/components/Head/ThemeInit.astro‎

Lines changed: 43 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -38,52 +38,79 @@ const { defaultThemeIdJson, darkThemeIdJson, metaColorsJson } = themeData()
3838
const darkThemeId = JSON.parse(darkThemeIdJson)
3939
const themeMetaColors = JSON.parse(metaColorsJson)
4040

41-
// Read theme preference from localStorage (set by user's previous selection)
41+
/** 1. Read theme preference from localStorage (set by user's previous selection) */
4242
const stored = localStorage.getItem('theme')
4343

4444
if (stored && stored !== defaultThemeId) {
45-
// User explicitly chose a theme - apply it immediately
46-
// <html> element
45+
/** User explicitly chose a theme - apply it immediately */
46+
/** <html> element */
4747
document.documentElement.dataset['theme'] = stored
4848
} else if (!stored) {
49-
// No stored preference - use system preference
49+
/** No stored preference - use system preference */
5050
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
51-
// <html> element
51+
/** <html> element */
5252
document.documentElement.dataset['theme'] = prefersDark ? darkThemeId : defaultThemeId
5353
}
5454

55-
// 2. Turn <body> visible. It's set to hidden in BaseLayout.astro to avoid FOUC. Do
56-
// here to make sure it gets turned on, in case the <meta> element selector throws.
55+
/** 2. Turn <body> visible. It's set to hidden in BaseLayout.astro to avoid FOUC. Do
56+
* here to make sure it gets turned on, in case the <meta> element selector throws.
57+
*/
5758
document.body.classList.remove('invisible')
5859

59-
// 3. Add theme entries from src/content/themes.json to the window.metaColors object
60+
/** 3. Add theme entries from src/content/themes.json to the window.metaColors object */
6061
window.metaColors = window.metaColors || {}
6162
Object.assign(window.metaColors, themeMetaColors)
6263

63-
// 4. Update meta theme-color used for PWAs
64+
/** 4. Update meta theme-color used for PWAs */
6465
const metaElement = document.querySelector('meta[name="theme-color"]')
6566
if (stored && stored !== defaultThemeId && metaElement && window.metaColors) {
66-
// User explicitly chose a theme - apply it immediately
67+
/** User explicitly chose a theme - apply it immediately */
6768
metaElement.setAttribute('content', window.metaColors[stored] || '')
6869
} else if (!stored && metaElement && window.metaColors) {
69-
// No stored preference - use system preference
70+
/** No stored preference - use system preference */
7071
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
7172
metaElement.setAttribute(
7273
'content',
7374
window.metaColors[prefersDark ? darkThemeId : defaultThemeId] || ''
7475
)
7576
}
7677

77-
// 5. If stored === defaultThemeId, BaseLayout.astro already set it on <html> data-theme attribute
78-
// and Meta.astro already set it on <meta name="theme-color">, so nothing to do
78+
/**
79+
* 5. If stored === defaultThemeId, BaseLayout.astro already set it
80+
* on <html> data-theme attribute and Meta.astro already set it on
81+
* <meta name="theme-color">, so nothing to do
82+
*/
7983

80-
// 6. Success!
84+
/** 6. Success! */
8185
console.log('🎨 Theme init on "DOMContentLoaded" executed')
8286
} catch (error) {
83-
// localStorage access can fail (privacy mode, etc.)
84-
// Fall back to BaseLayout's data-theme="light", make sure the page is visible
87+
/** localStorage access can fail (privacy mode, etc.) */
88+
/** Fall back to BaseLayout's data-theme="light", make sure the page is visible */
8589
document.body.classList.remove('invisible')
8690
console.error('❌ Theme init on "DOMContentLoaded" failed with errors:', error)
8791
}
8892
}, { once: true })
93+
94+
/**
95+
* Add the fast theme setting logic for Astro View Transition API navigation events.
96+
*
97+
* The DOMContentLoaded event does not fire on subsequent page transitions (soft loads).
98+
*/
99+
document.addEventListener("astro:before-swap", (event) => {
100+
try {
101+
/** 1. Read theme preference from localStorage (set by user's previous selection) */
102+
const stored = localStorage.getItem('theme')
103+
/** 2. <html> element */
104+
event.newDocument.documentElement.dataset['theme'] = stored
105+
/** 3. Turn <body> visible. It's set to hidden in BaseLayout.astro to avoid FOUC */
106+
event.newDocument.body.classList.remove('invisible')
107+
/** 4. Success! */
108+
console.log('🎨 Theme init on "astro:before-swap" executed')
109+
} catch (error) {
110+
/** localStorage access can fail (privacy mode, etc.) */
111+
/** Fall back to BaseLayout's data-theme="light", make sure the page is visible */
112+
event.newDocument.body.classList.remove('invisible')
113+
console.error('❌ Theme init on "astro:before-swap" failed with errors:', error)
114+
}
115+
})
89116
</script>

‎src/components/scripts/bootstrap/__tests__/index.spec.ts‎

Lines changed: 0 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ vi.mock('@components/scripts/store', () => ({
2525
initConsentFromCookies: vi.fn(),
2626
initConsentSideEffects: vi.fn(),
2727
initAnimationLifecycle: vi.fn(),
28-
addViewTransitionThemeInitListener: vi.fn(),
2928
exposeStoreActionsForTesting: vi.fn(),
3029
$hasFunctionalConsent: {
3130
subscribe: vi.fn(),
@@ -55,7 +54,6 @@ import {
5554
initConsentFromCookies,
5655
initConsentSideEffects,
5756
initAnimationLifecycle,
58-
addViewTransitionThemeInitListener,
5957
exposeStoreActionsForTesting,
6058
} from '@components/scripts/store'
6159
import { SentryBootstrap } from '@components/scripts/sentry/client'
@@ -77,7 +75,6 @@ const testWindow = window as TestWindow
7775
const consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation(() => {})
7876

7977
const mockSuccessfulInit = () => {
80-
vi.mocked(addViewTransitionThemeInitListener).mockReturnValue(undefined)
8178
vi.mocked(initConsentFromCookies).mockReturnValue(undefined)
8279
vi.mocked(initConsentSideEffects).mockReturnValue(undefined)
8380
vi.mocked(initAnimationLifecycle).mockReturnValue(undefined)
@@ -105,27 +102,13 @@ describe('AppBootstrap', () => {
105102
})
106103

107104
describe('Successful initialization', () => {
108-
it('should call addViewTransitionThemeInitListener, initConsentFromCookies and initialize side effects', () => {
109-
AppBootstrap.init()
110-
111-
expect(addViewTransitionThemeInitListener).toHaveBeenCalledTimes(1)
112-
expect(initAnimationLifecycle).toHaveBeenCalledTimes(1)
113-
expect(initConsentFromCookies).toHaveBeenCalledTimes(1)
114-
expect(initConsentSideEffects).toHaveBeenCalledTimes(1)
115-
expect(exposeStoreActionsForTesting).toHaveBeenCalledTimes(1)
116-
})
117-
118105
it('should add breadcrumbs for successful initialization', () => {
119106
AppBootstrap.init()
120107

121108
expect(addScriptBreadcrumb).toHaveBeenCalledWith({
122109
scriptName: 'AppBootstrap',
123110
operation: 'init'
124111
})
125-
expect(addScriptBreadcrumb).toHaveBeenCalledWith({
126-
scriptName: 'AppBootstrap',
127-
operation: 'addViewTransitionThemeInitListener'
128-
})
129112
expect(addScriptBreadcrumb).toHaveBeenCalledWith({
130113
scriptName: 'AppBootstrap',
131114
operation: 'initAnimationLifecycle'
@@ -157,7 +140,6 @@ describe('AppBootstrap', () => {
157140
})
158141

159142
it('should add breadcrumb before throwing error', () => {
160-
vi.mocked(addViewTransitionThemeInitListener).mockReturnValue(undefined)
161143
const testError = new TestError('Cookie initialization failed')
162144
vi.mocked(initConsentFromCookies).mockImplementation(() => {
163145
throw testError
@@ -173,10 +155,6 @@ describe('AppBootstrap', () => {
173155
scriptName: 'AppBootstrap',
174156
operation: 'init'
175157
})
176-
expect(addScriptBreadcrumb).toHaveBeenCalledWith({
177-
scriptName: 'AppBootstrap',
178-
operation: 'addViewTransitionThemeInitListener'
179-
})
180158
expect(addScriptBreadcrumb).toHaveBeenCalledWith({
181159
scriptName: 'AppBootstrap',
182160
operation: 'initAnimationLifecycle'
@@ -221,7 +199,6 @@ describe('AppBootstrap', () => {
221199
AppBootstrap.init()
222200
AppBootstrap.init()
223201

224-
expect(addViewTransitionThemeInitListener).toHaveBeenCalledTimes(2)
225202
expect(initAnimationLifecycle).toHaveBeenCalledTimes(2)
226203
expect(initConsentFromCookies).toHaveBeenCalledTimes(2)
227204
expect(initConsentSideEffects).toHaveBeenCalledTimes(2)

‎src/components/scripts/bootstrap/index.ts‎

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
*/
66
import { addScriptBreadcrumb, ClientScriptError } from '@components/scripts/errors'
77
import {
8-
addViewTransitionThemeInitListener,
98
initAnimationLifecycle,
109
initConsentFromCookies,
1110
initConsentSideEffects,
@@ -46,23 +45,19 @@ export class AppBootstrap {
4645
console.info('🔧 Sentry disabled in development mode')
4746
}
4847

49-
// 1. Add event listener to set theme on Astro View Transitions API page navigation
50-
addScriptBreadcrumb({ scriptName: 'AppBootstrap', operation: 'addViewTransitionThemeInitListener' })
51-
addViewTransitionThemeInitListener()
52-
53-
// 2. Initialize animation lifecycle listeners
48+
// 1. Initialize animation lifecycle listeners
5449
addScriptBreadcrumb({ scriptName: 'AppBootstrap', operation: 'initAnimationLifecycle' })
5550
initAnimationLifecycle()
5651

57-
// 3. Initialize consent from cookies
52+
// 2. Initialize consent from cookies
5853
addScriptBreadcrumb({ scriptName: 'AppBootstrap', operation: 'initConsentFromCookies' })
5954
initConsentFromCookies()
6055

61-
// 4. Setup all module-specific side effects (runs once per page load)
56+
// 3. Setup all module-specific side effects (runs once per page load)
6257
addScriptBreadcrumb({ scriptName: 'AppBootstrap', operation: 'initConsentSideEffects' })
6358
initConsentSideEffects()
6459

65-
// 5. Expose limited store actions for Playwright-driven E2E tests
60+
// 4. Expose limited store actions for Playwright-driven E2E tests
6661
exposeStoreActionsForTesting()
6762
} catch (error: unknown) {
6863
const scriptError = new ClientScriptError(error)

‎src/components/scripts/store/__tests__/themes.spec.ts‎

Lines changed: 0 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ import {
1515
closeThemePicker,
1616
toggleThemePicker,
1717
themeKeyChangeSideEffectsListener,
18-
addViewTransitionThemeInitListener,
1918
type ThemeId,
2019
} from '@components/scripts/store/themes'
2120
import { handleScriptError } from '@components/scripts/errors/handler'
@@ -240,53 +239,3 @@ describe('themeKeyChangeSideEffectsListener', () => {
240239
document.head.removeChild(meta)
241240
})
242241
})
243-
244-
describe('addViewTransitionThemeInitListener', () => {
245-
beforeEach(() => {
246-
localStorage.clear()
247-
document.body.className = ''
248-
})
249-
250-
it('applies stored theme to new documents and reveals the body', () => {
251-
localStorage.setItem('theme', 'dark')
252-
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
253-
const newDocument = document.implementation.createHTMLDocument('next')
254-
newDocument.body.classList.add('invisible')
255-
256-
addViewTransitionThemeInitListener()
257-
258-
const event = new CustomEvent('astro:before-swap') as CustomEvent & {
259-
newDocument: Document
260-
}
261-
event.newDocument = newDocument
262-
document.dispatchEvent(event)
263-
264-
expect(newDocument.documentElement.dataset['theme']).toBe('dark')
265-
expect(newDocument.body.classList.contains('invisible')).toBe(false)
266-
expect(logSpy).toHaveBeenCalledWith('🎨 Theme init on "astro:before-swap" executed')
267-
})
268-
269-
it('falls back gracefully when localStorage access fails', () => {
270-
const error = new TestError('denied')
271-
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
272-
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
273-
throw error
274-
})
275-
const newDocument = document.implementation.createHTMLDocument('fallback')
276-
newDocument.body.classList.add('invisible')
277-
278-
addViewTransitionThemeInitListener()
279-
280-
const event = new CustomEvent('astro:before-swap') as CustomEvent & {
281-
newDocument: Document
282-
}
283-
event.newDocument = newDocument
284-
document.dispatchEvent(event)
285-
286-
expect(newDocument.body.classList.contains('invisible')).toBe(false)
287-
expect(errorSpy).toHaveBeenCalledWith(
288-
'❌ Theme init on "astro:before-swap" failed with errors:',
289-
error,
290-
)
291-
})
292-
})

‎src/components/scripts/store/index.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,6 @@ export {
7272

7373
// Re-export themes
7474
export {
75-
addViewTransitionThemeInitListener,
7675
closeThemePicker,
7776
createThemeController,
7877
createThemePickerOpenController,

‎src/components/scripts/store/themes.ts‎

Lines changed: 0 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -237,28 +237,3 @@ export function themeKeyChangeSideEffectsListener(): void {
237237
}
238238
})
239239
}
240-
241-
/**
242-
* Add the fast theme setting logic for Astro View Transition API navigation events.
243-
*
244-
* The DOMContentLoaded event does not fire on subsequent page transitions (soft loads).
245-
*/
246-
export function addViewTransitionThemeInitListener(): void {
247-
document.addEventListener("astro:before-swap", (event) => {
248-
try {
249-
// 1. Read theme preference from localStorage (set by user's previous selection)
250-
const stored = localStorage.getItem('theme')!
251-
// <html> element
252-
event.newDocument.documentElement.dataset['theme'] = stored
253-
// 4. Turn <body> visible. It's set to hidden in BaseLayout.astro to avoid FOUC
254-
event.newDocument.body.classList.remove('invisible')
255-
// 5. Success!
256-
console.log('🎨 Theme init on "astro:before-swap" executed')
257-
} catch (error) {
258-
// localStorage access can fail (privacy mode, etc.)
259-
// Fall back to BaseLayout's data-theme="light", make sure the page is visible
260-
event.newDocument.body.classList.remove('invisible')
261-
console.error('❌ Theme init on "astro:before-swap" failed with errors:', error)
262-
}
263-
}, { once: true })
264-
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/**
2+
* Regression Tests for Astro View Transitions - Body Visibility
3+
*
4+
* Ensures the "invisible" class used to prevent FOUC on initial load is removed
5+
* from the swapped document's <body> whenever Astro View Transitions performs
6+
* client-side navigation. This guards against blank pages during soft reloads.
7+
*
8+
* Related:
9+
* - src/layouts/BaseLayout.astro (initial invisible class)
10+
* - src/components/ThemePicker/ThemeInit.astro (runtime theme init logic)
11+
*/
12+
13+
import { BasePage, expect, test } from '@test/e2e/helpers'
14+
15+
test.describe('View Transitions - body visibility reset', () => {
16+
test('removes invisible class from swapped document body', async ({ page: playwrightPage }) => {
17+
const page = await BasePage.init(playwrightPage)
18+
19+
await page.goto('/')
20+
await page.waitForLoadState('networkidle')
21+
22+
const astroBeforeSwapLog = page.consoleMssgPromise('Theme init on "astro:before-swap" executed')
23+
24+
await page.navigateToPage('/about')
25+
await page.waitForPageLoad()
26+
27+
const logMessage = await astroBeforeSwapLog
28+
expect(logMessage.text()).toContain('Theme init on "astro:before-swap" executed')
29+
30+
const bodyHasInvisibleAfterNav = await page.evaluate(() => document.body.classList.contains('invisible'))
31+
expect(bodyHasInvisibleAfterNav).toBe(false)
32+
})
33+
})

0 commit comments

Comments
 (0)