Skip to content

Commit de1916d

Browse files
committed
fix(status.app): improve mobile core web vitals
1 parent ccef237 commit de1916d

8 files changed

Lines changed: 151 additions & 52 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'status.app': patch
3+
---
4+
5+
fix(status.app): restore static rendering and defer the help search index

apps/status.app/src/app/(website)/(content)/_components/fullscreen-search-dialog.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ export const FullscreenSearchDialog = (props: Props) => {
3737

3838
const tc = useTranslations('common')
3939
const t = useTranslations('help')
40-
const { query, results } = useSearchEngine(type)
40+
const { query, results } = useSearchEngine(type, { enabled: open })
4141
const [value, setValue] = useState('')
4242

4343
useEffect(() => {

apps/status.app/src/app/(website)/(content)/_hooks/use-search-engine.ts

Lines changed: 97 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback, useState } from 'react'
1+
import { useCallback, useEffect, useRef, useState } from 'react'
22

33
import MiniSearch from 'minisearch'
44

@@ -49,58 +49,108 @@ type SearchDoc = {
4949

5050
const SEARCH_RESULTS_LIMIT = 50
5151

52-
export const useSearchEngine = (
53-
type: SearchType,
54-
limit = SEARCH_RESULTS_LIMIT
55-
) => {
56-
const [engine] = useState<Promise<MiniSearch<SearchDoc>>>(async () => {
57-
const miniSearch = new MiniSearch<SearchDoc>({
58-
fields: ['title', 'heading', 'text'], // fields to index for full-text search
59-
storeFields: ['title', 'heading', 'text', 'path'], // fields to return with search results
60-
searchOptions: {
61-
boost: { title: 2 },
62-
fuzzy: 0.2,
63-
prefix: true,
64-
},
65-
})
66-
67-
if (typeof window === 'undefined') {
68-
return miniSearch
69-
}
52+
/**
53+
* Deadline for the idle callback that warms the index, so the build still
54+
* happens promptly on browsers that stay busy after the dialog opens.
55+
*/
56+
const ENGINE_WARMUP_TIMEOUT_MS = 500
57+
58+
const loadDocIndex = async (type: SearchType): Promise<DocIndex[]> => {
59+
switch (type) {
60+
case 'help':
61+
return (await import('../../../../../.contentlayer/en.json'))
62+
.default as unknown as DocIndex[]
63+
case 'specs':
64+
return (await import('../../../../../.contentlayer/specs.en.json'))
65+
.default as unknown as DocIndex[]
66+
}
67+
}
7068

71-
let docIndex: DocIndex[]
72-
switch (type) {
73-
case 'help':
74-
docIndex = (await import('../../../../../.contentlayer/en.json'))
75-
.default as unknown as DocIndex[]
76-
break
77-
case 'specs':
78-
docIndex = (await import('../../../../../.contentlayer/specs.en.json'))
79-
.default as unknown as DocIndex[]
80-
break
81-
}
69+
const createSearchEngine = async (
70+
type: SearchType
71+
): Promise<MiniSearch<SearchDoc>> => {
72+
const miniSearch = new MiniSearch<SearchDoc>({
73+
fields: ['title', 'heading', 'text'], // fields to index for full-text search
74+
storeFields: ['title', 'heading', 'text', 'path'], // fields to return with search results
75+
searchOptions: {
76+
boost: { title: 2 },
77+
fuzzy: 0.2,
78+
prefix: true,
79+
},
80+
})
8281

83-
const docs: SearchDoc[] = []
84-
let id = 0
82+
if (typeof window === 'undefined') {
83+
return miniSearch
84+
}
8585

86-
for (const item of docIndex!) {
87-
for (const [heading, texts] of Object.entries(item.content)) {
88-
for (const text of texts) {
89-
docs.push({
90-
id: id++,
91-
title: item.title,
92-
path: item.path,
93-
heading,
94-
text,
95-
})
96-
}
86+
const docIndex = await loadDocIndex(type)
87+
88+
const docs: SearchDoc[] = []
89+
let id = 0
90+
91+
for (const item of docIndex) {
92+
for (const [heading, texts] of Object.entries(item.content)) {
93+
for (const text of texts) {
94+
docs.push({
95+
id: id++,
96+
title: item.title,
97+
path: item.path,
98+
heading,
99+
text,
100+
})
97101
}
98102
}
103+
}
99104

100-
miniSearch.addAll(docs)
105+
miniSearch.addAll(docs)
101106

102-
return miniSearch
107+
return miniSearch
108+
}
109+
110+
const whenIdle = (callback: () => void): (() => void) => {
111+
if (typeof window.requestIdleCallback !== 'function') {
112+
const timeoutId = window.setTimeout(callback, 0)
113+
return () => window.clearTimeout(timeoutId)
114+
}
115+
116+
const handle = window.requestIdleCallback(callback, {
117+
timeout: ENGINE_WARMUP_TIMEOUT_MS,
103118
})
119+
return () => window.cancelIdleCallback(handle)
120+
}
121+
122+
type Options = {
123+
/**
124+
* Whether the index may be built. The doc index is ~540KB and indexing it
125+
* blocks the main thread for hundreds of milliseconds on mobile, so callers
126+
* enable it only once the user reaches for search — never on page load.
127+
*/
128+
enabled?: boolean
129+
limit?: number
130+
}
131+
132+
export const useSearchEngine = (type: SearchType, options: Options = {}) => {
133+
const { enabled = true, limit = SEARCH_RESULTS_LIMIT } = options
134+
135+
const engineRef = useRef<Promise<MiniSearch<SearchDoc>> | null>(null)
136+
137+
const loadEngine = useCallback((): Promise<MiniSearch<SearchDoc>> => {
138+
engineRef.current ??= createSearchEngine(type)
139+
140+
return engineRef.current
141+
}, [type])
142+
143+
// Warm the index once search is reachable, but off the interaction that
144+
// opened it, so building it never delays the dialog's first paint.
145+
useEffect(() => {
146+
if (!enabled) {
147+
return
148+
}
149+
150+
return whenIdle(() => {
151+
void loadEngine()
152+
})
153+
}, [enabled, loadEngine])
104154

105155
const [results, setResults] = useState<Result[]>([])
106156

@@ -113,7 +163,7 @@ export const useSearchEngine = (
113163
return
114164
}
115165

116-
const searchResults = (await engine)
166+
const searchResults = (await loadEngine())
117167
.search(normalizedTerm)
118168
.slice(0, limit) as SearchResult[]
119169

@@ -220,7 +270,7 @@ export const useSearchEngine = (
220270

221271
setResults(results)
222272
},
223-
[engine, limit]
273+
[loadEngine, limit]
224274
)
225275

226276
return { results, query } as const

apps/status.app/src/app/(website)/(content)/help/(sidebar)/[...slug]/page.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
} from '~/utils/structured-data'
1515
import { Metadata } from '~app/_metadata'
1616
import { formatDate } from '~app/_utils/format-time'
17+
import { getGithubAvatarUrl } from '~app/_utils/github-avatar'
1718
import { isHelpDocWorkInProgress } from '~app/_utils/help-doc'
1819
import { Icon } from '~components/assets'
1920
import { Breadcrumbs } from '~components/breadcrumbs'
@@ -83,6 +84,8 @@ type Props = {
8384
}
8485

8586
const MAX_VISIBLE_AUTHORS = 4
87+
/** Matches the `size="20"` token the author avatars render at. */
88+
const AVATAR_SIZE = 20
8689

8790
export default async function HelpDetailPage(props: Props) {
8891
const { params } = props
@@ -162,7 +165,7 @@ export default async function HelpDetailPage(props: Props) {
162165
type="user"
163166
size="20"
164167
name={mainAuthor || ''}
165-
src={`https://github.com/${mainAuthor}.png`}
168+
src={getGithubAvatarUrl(mainAuthor, AVATAR_SIZE)}
166169
/>
167170
<Text size={15} weight="semibold">
168171
{mainAuthor}
@@ -193,7 +196,7 @@ export default async function HelpDetailPage(props: Props) {
193196
type="user"
194197
size="20"
195198
name={author}
196-
src={`https://github.com/${author}.png`}
199+
src={getGithubAvatarUrl(author, AVATAR_SIZE)}
197200
/>
198201
</div>
199202
</Link>
@@ -299,7 +302,10 @@ export default async function HelpDetailPage(props: Props) {
299302
type="user"
300303
size="20"
301304
name={doc.lastEditedAuthor.githubUsername}
302-
src={`https://github.com/${doc.lastEditedAuthor.githubUsername}.png`}
305+
src={getGithubAvatarUrl(
306+
doc.lastEditedAuthor.githubUsername,
307+
AVATAR_SIZE
308+
)}
303309
/>
304310
<Text size={15} weight="semibold">
305311
{doc.lastEditedAuthor.githubUsername}

apps/status.app/src/app/[locale]/layout.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { notFound } from 'next/navigation'
22
import { hasLocale } from 'next-intl'
3+
import { setRequestLocale } from 'next-intl/server'
34

45
import { routing } from '~/i18n/routing'
56

@@ -21,5 +22,9 @@ export default async function LocaleLayout({ children, params }: Props) {
2122
notFound()
2223
}
2324

25+
// Keeps next-intl on the route param instead of `headers()` so this subtree
26+
// can be prerendered. why: https://next-intl.dev/docs/getting-started/app-router
27+
setRequestLocale(locale)
28+
2429
return children
2530
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/**
2+
* GitHub serves `https://github.com/<user>.png` at full resolution — around
3+
* 45KB per avatar — no matter how small it is rendered. Asking for an explicit
4+
* size brings that down to a couple of KB, which matters on article pages where
5+
* several author avatars load eagerly and compete with the largest paint.
6+
*/
7+
export const getGithubAvatarUrl = (
8+
username: string,
9+
renderedSize: number
10+
): string => {
11+
const url = new URL(`https://github.com/${username}.png`)
12+
// Request 2x so the avatar stays sharp on high-density screens.
13+
url.searchParams.set('size', String(renderedSize * 2))
14+
15+
return url.toString()
16+
}

apps/status.app/src/app/layout.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import { Analytics } from '@vercel/analytics/next'
55
import { Inter } from 'next/font/google'
66
import Script from 'next/script'
77
import { NextIntlClientProvider } from 'next-intl'
8-
import { getLocale, getMessages } from 'next-intl/server'
8+
import { getLocale, getMessages, setRequestLocale } from 'next-intl/server'
9+
10+
import { routing } from '~/i18n/routing'
911

1012
import { PlatformDetector } from './_components/platform-detector'
1113
import { Metadata } from './_metadata'
@@ -56,6 +58,13 @@ type Props = {
5658
}
5759

5860
export default async function RootLayout({ children }: Props) {
61+
// This layout sits above the `[locale]` segment, so it has no locale param to
62+
// hand next-intl. Without a pinned locale next-intl falls back to `headers()`,
63+
// which opts every route out of static rendering: nothing is prerendered and
64+
// each response is served `no-store`, so the CDN can't cache any HTML.
65+
// Safe while `en` is the only locale and detection is off, see `~/i18n/routing`.
66+
setRequestLocale(routing.defaultLocale)
67+
5968
const [locale, messages] = await Promise.all([getLocale(), getMessages()])
6069

6170
return (
@@ -72,6 +81,9 @@ export default async function RootLayout({ children }: Props) {
7281
>
7382
<head>
7483
<link rel="preconnect" href="https://res.cloudinary.com" />
84+
{/* The analytics script is already preloaded by `next/script`, but the
85+
connection to its origin is only opened once the preload resolves. */}
86+
<link rel="preconnect" href="https://umami.bi.status.im" />
7587
<link
7688
rel="alternate"
7789
type="application/atom+xml"

apps/status.app/src/i18n/routing.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ export const routing = defineRouting({
99
// NEXT_LOCALE cookie) that contributes to cloaking false-positives and aligns
1010
// with get.status.app. why: https://github.com/status-im/status-web/issues/1236
1111
localeDetection: false,
12+
// The cookie can't influence anything while `en` is the only locale and
13+
// detection is off, and a `Set-Cookie` on an HTML response makes CDNs treat
14+
// it as per-user and skip the cache — which would waste the prerendering the
15+
// `setRequestLocale` calls buy us.
16+
localeCookie: false,
1217
})
1318

1419
/**

0 commit comments

Comments
 (0)