Skip to content

Commit 1fa02e6

Browse files
committed
perf: Performance improvements with concurrent API calls, abstracting common utilities, and freeing up the main thread periodically
1 parent d4c193a commit 1fa02e6

File tree

9 files changed

+350
-161
lines changed

9 files changed

+350
-161
lines changed

agents.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ pnpm run test:unit # Run Vitest (add file path for specific tests) - must pas
4646
- `v-memo` on `v-for` lists to optimize re-renders
4747
- Use `defineAsyncComponent()` where appropriate
4848
- Fail fast and handle errors gracefully
49+
- This project can often deal with large datasets and needs to crawl large JSON objects
4950

5051
**Component Design:**
5152
- Single responsibility principle

src/components/Features/AuditContent.vue

Lines changed: 1 addition & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ import Btn from '../Btn.vue'
112112
import InfoBanner from '../InfoBanner.vue'
113113
import Chip from '../Chip.vue'
114114
import { auditContent } from '@/features/audit'
115+
import { pluralize, highlightPattern } from '@/utils/textNormalization'
115116
import type { AsyncReturnType } from 'type-fest'
116117
117118
const Card = defineAsyncComponent(() => import('../Card.vue'))
@@ -129,24 +130,6 @@ const patternsFound = ref<string[]>([])
129130
130131
const hasResults = computed(() => results.value.length > 0)
131132
132-
// Utility functions
133-
function pluralize(count: number, singular: string, plural: string): string {
134-
return count === 1 ? singular : plural
135-
}
136-
137-
function escapeHtml(str: string): string {
138-
const div = document.createElement('div')
139-
div.textContent = str
140-
return div.innerHTML
141-
}
142-
143-
function highlightPattern(text: string, pattern: string): string {
144-
const escapedText = escapeHtml(text)
145-
const escapedPattern = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
146-
const regex = new RegExp(`(${escapedPattern})`, 'gi')
147-
return escapedText.replace(regex, '<mark>$1</mark>')
148-
}
149-
150133
function resetAudit(): void {
151134
results.value = []
152135
failedScopes.value = []

src/components/Features/ComponentsContent.vue

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ import InfoBanner from '../InfoBanner.vue'
153153
import Chip from '../Chip.vue'
154154
import { auditComponents } from '@/features/components'
155155
import type { ComponentsResponse } from '@/features/components'
156+
import { pluralize } from '@/utils/textNormalization'
156157
157158
const Card = defineAsyncComponent(() => import('../Card.vue'))
158159
@@ -169,10 +170,6 @@ const totalScanned = ref(0)
169170
const hasResults = computed(() => results.value.length > 0)
170171
const orphanCount = computed(() => results.value.filter((r) => r.usageCount === 0).length)
171172
172-
function pluralize(count: number, singular: string, plural: string): string {
173-
return count === 1 ? singular : plural
174-
}
175-
176173
function resetAudit(): void {
177174
results.value = []
178175
failedScopes.value = []

src/components/Features/SearchContent.vue

Lines changed: 1 addition & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ import InfoBanner from '../InfoBanner.vue'
135135
import Chip from '../Chip.vue'
136136
import Toggle from '../Toggle.vue'
137137
import { searchContent } from '@/features/searchContent'
138-
import { normalizeWhitespace } from '@/utils/textNormalization'
138+
import { pluralize, highlightMatches } from '@/utils/textNormalization'
139139
import type { AsyncReturnType } from 'type-fest'
140140
141141
const Card = defineAsyncComponent(() => import('../Card.vue'))
@@ -156,58 +156,6 @@ const totalItems = ref(0)
156156
157157
const hasResults = computed(() => results.value.length > 0)
158158
159-
// Utility functions
160-
function pluralize(count: number, singular: string, plural: string): string {
161-
return count === 1 ? singular : plural
162-
}
163-
164-
function escapeHtml(str: string): string {
165-
const div = document.createElement('div')
166-
div.textContent = str
167-
return div.innerHTML
168-
}
169-
170-
/**
171-
* Creates a regex pattern that matches a character and all its normalized variants.
172-
* For example, '£' should match both '£' and '&pound;'.
173-
*/
174-
function createVariantPattern(char: string): string {
175-
const variants: Record<string, string[]> = {
176-
"'": ["'", '&apos;', '&#39;', '\u2018', '\u2019'],
177-
'"': ['"', '&quot;', '\u201C', '\u201D'],
178-
'-': ['-', '&ndash;', '&mdash;', '\u2013', '\u2014'],
179-
'£': ['£', '&pound;'],
180-
'': ['', '&euro;'],
181-
'&': ['&', '&amp;'],
182-
'<': ['<', '&lt;'],
183-
'>': ['>', '&gt;'],
184-
' ': [' ', '&nbsp;', '\u00A0'],
185-
}
186-
187-
// If this character has known variants, match any of them
188-
if (variants[char]) {
189-
const escapedVariants = variants[char].map((v) => v.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
190-
return `(?:${escapedVariants.join('|')})`
191-
}
192-
193-
// Otherwise, just escape and match the character itself
194-
return char.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
195-
}
196-
197-
function highlightMatches(text: string, searchTerm: string): string {
198-
const escapedText = escapeHtml(text)
199-
200-
// Normalize the search term to get the canonical form
201-
const normalizedSearch = normalizeWhitespace(searchTerm.trim())
202-
203-
// Build a regex pattern that matches the search term and all its variant forms
204-
const patternParts = Array.from(normalizedSearch).map(createVariantPattern)
205-
const pattern = patternParts.join('')
206-
207-
const regex = new RegExp(`(${pattern})`, 'gi')
208-
return escapedText.replace(regex, '<mark>$1</mark>')
209-
}
210-
211159
function resetSearch(): void {
212160
results.value = []
213161
failedResource.value = null

src/features/audit.ts

Lines changed: 40 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -280,56 +280,62 @@ export async function auditContent(
280280
// Build items with source type tracking
281281
const itemsWithSource: Array<{ data: unknown; sourceType: string }> = []
282282

283+
// Fan-out all data fetches concurrently for maximum throughput
284+
const fetchPromises: Promise<void>[] = []
285+
283286
// Fetch pages if any page types are selected
284287
if (selectedPageTypes.length > 0) {
285288
for (const pageType of selectedPageTypes) {
286-
try {
287-
const pages = await getAllPages({ token, pageType, preview })
288-
pages.forEach((page) => {
289-
itemsWithSource.push({ data: page, sourceType: pageType })
290-
})
291-
hasAnySuccessfulScope = true
292-
} catch (error) {
293-
console.error(`Failed to fetch page type "${pageType}":`, error)
294-
failedScopes.push(`Page Type: ${pageType}`)
295-
}
289+
fetchPromises.push(
290+
getAllPages({ token, pageType, preview })
291+
.then((pages) => {
292+
pages.forEach((page) => itemsWithSource.push({ data: page, sourceType: pageType }))
293+
hasAnySuccessfulScope = true
294+
})
295+
.catch((error) => {
296+
console.error(`Failed to fetch page type "${pageType}":`, error)
297+
failedScopes.push(`Page Type: ${pageType}`)
298+
}),
299+
)
296300
}
297301
}
298302

299303
// Fetch blog posts if enabled
300304
if (includeBlog) {
301-
try {
302-
const posts = await getAllPosts({ token, preview })
303-
posts.forEach((post) => {
304-
itemsWithSource.push({ data: post, sourceType: 'Blog' })
305-
})
306-
hasAnySuccessfulScope = true
307-
} catch (error) {
308-
console.error('Failed to fetch Blog posts:', error)
309-
failedScopes.push('Blog')
310-
}
305+
fetchPromises.push(
306+
getAllPosts({ token, preview })
307+
.then((posts) => {
308+
posts.forEach((post) => itemsWithSource.push({ data: post, sourceType: 'Blog' }))
309+
hasAnySuccessfulScope = true
310+
})
311+
.catch((error) => {
312+
console.error('Failed to fetch Blog posts:', error)
313+
failedScopes.push('Blog')
314+
}),
315+
)
311316
}
312317

313318
// Fetch collections if any collection keys are selected
314319
if (selectedCollectionKeys.length > 0) {
315320
for (const collectionKey of selectedCollectionKeys) {
316-
try {
317-
const collections = await getAllCollections({
318-
token,
319-
collectionType: collectionKey,
320-
preview,
321-
})
322-
collections.forEach((collection) => {
323-
itemsWithSource.push({ data: collection, sourceType: collectionKey })
324-
})
325-
hasAnySuccessfulScope = true
326-
} catch (error) {
327-
console.error(`Failed to fetch collection "${collectionKey}":`, error)
328-
failedScopes.push(`Collection: ${collectionKey}`)
329-
}
321+
fetchPromises.push(
322+
getAllCollections({ token, collectionType: collectionKey, preview })
323+
.then((collections) => {
324+
collections.forEach((collection) =>
325+
itemsWithSource.push({ data: collection, sourceType: collectionKey }),
326+
)
327+
hasAnySuccessfulScope = true
328+
})
329+
.catch((error) => {
330+
console.error(`Failed to fetch collection "${collectionKey}":`, error)
331+
failedScopes.push(`Collection: ${collectionKey}`)
332+
}),
333+
)
330334
}
331335
}
332336

337+
await Promise.all(fetchPromises)
338+
333339
// If all scopes failed, return error
334340
if (!hasAnySuccessfulScope) {
335341
return {

src/features/components.ts

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -114,15 +114,23 @@ export async function auditComponents(
114114
const failedScopes: string[] = []
115115
let totalScanned = 0
116116

117-
for (const pageType of selectedPageTypes) {
118-
let pages: Butter.Page[]
119-
try {
120-
pages = await getAllPages({ token, preview, pageType })
121-
} catch {
122-
failedScopes.push(pageType)
123-
continue
124-
}
117+
// Fetch all page types concurrently
118+
const fetchedPages: Array<{ pages: Butter.Page[]; pageType: string }> = []
119+
await Promise.all(
120+
selectedPageTypes.map(async (pageType) => {
121+
try {
122+
const pages = await getAllPages({ token, preview, pageType })
123+
fetchedPages.push({ pages, pageType })
124+
} catch {
125+
failedScopes.push(pageType)
126+
}
127+
}),
128+
)
125129

130+
// Process all pages sequentially with periodic main-thread yields to avoid
131+
// blocking the UI during large crawls (walkJson is a deep recursive tree-walk)
132+
let pageIndex = 0
133+
for (const { pages, pageType } of fetchedPages) {
126134
for (const page of pages) {
127135
totalScanned++
128136

@@ -148,8 +156,10 @@ export async function auditComponents(
148156
}
149157
}
150158

151-
// Yield to the main thread between pages to avoid blocking during large crawls
152-
await new Promise<void>((resolve) => setTimeout(resolve, 0))
159+
// Yield to the main thread every 50 pages to avoid blocking during large crawls
160+
if (++pageIndex % 50 === 0) {
161+
await new Promise<void>((resolve) => setTimeout(resolve, 0))
162+
}
153163
}
154164
}
155165

0 commit comments

Comments
 (0)