Skip to content

Commit b4465f8

Browse files
feat(perf): Admin cache page lazy loading (#553)
Co-authored-by: me <me@kentcdodds.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent e20869c commit b4465f8

1 file changed

Lines changed: 190 additions & 53 deletions

File tree

  • packages/workshop-app/app/routes/admin+

packages/workshop-app/app/routes/admin+/cache.tsx

Lines changed: 190 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,25 @@ import {
3232
} from '#app/utils/misc.tsx'
3333
import { type Route } from './+types/cache.ts'
3434

35+
function normalizeSearchQuery(query: string) {
36+
return query.trim().toLowerCase()
37+
}
38+
39+
function entryValueMatches(value: unknown, query: string) {
40+
if (!query) return true
41+
if (value === null || value === undefined) return false
42+
if (typeof value === 'string') {
43+
return value.toLowerCase().includes(query)
44+
}
45+
try {
46+
const serialized = JSON.stringify(value)
47+
if (!serialized) return false
48+
return serialized.toLowerCase().includes(query)
49+
} catch {
50+
return false
51+
}
52+
}
53+
3554
export async function loader({ request }: Route.LoaderArgs) {
3655
ensureUndeployed()
3756
const currentWorkshopId = getEnv().EPICSHOP_WORKSHOP_INSTANCE_ID
@@ -56,6 +75,7 @@ export async function loader({ request }: Route.LoaderArgs) {
5675
currentWorkshopId,
5776
...(globalDirExists ? ['global'] : []),
5877
]
78+
const normalizedQuery = normalizeSearchQuery(filterQuery)
5979

6080
// Filter caches based on search query and selected workshops
6181
const filteredCaches = allCaches
@@ -67,24 +87,36 @@ export async function loader({ request }: Route.LoaderArgs) {
6787
.map((workshopCache) => ({
6888
...workshopCache,
6989
caches: workshopCache.caches
70-
.map((cache) => ({
71-
...cache,
72-
entries: cache.entries.filter(
73-
(entry) =>
74-
filterQuery === '' ||
75-
entry.key.toLowerCase().includes(filterQuery.toLowerCase()) ||
76-
cache.name.toLowerCase().includes(filterQuery.toLowerCase()),
77-
),
78-
}))
79-
.filter((cache) => cache.entries.length > 0 || filterQuery === ''),
90+
.map((cache) => {
91+
const cacheNameMatches =
92+
normalizedQuery === ''
93+
? true
94+
: cache.name.toLowerCase().includes(normalizedQuery)
95+
const entries = cache.entries
96+
.filter((entry) => {
97+
if (normalizedQuery === '') return true
98+
if (cacheNameMatches) return true
99+
if (entry.key.toLowerCase().includes(normalizedQuery)) return true
100+
return entryValueMatches(entry.entry.value, normalizedQuery)
101+
})
102+
.map((entry) => ({
103+
key: entry.key,
104+
filename: entry.filename,
105+
size: entry.size,
106+
filepath: entry.filepath,
107+
metadata: entry.entry.metadata,
108+
}))
109+
return { ...cache, entries }
110+
})
111+
.filter((cache) => cache.entries.length > 0 || normalizedQuery === ''),
80112
}))
81113
.filter(
82-
(workshopCache) => workshopCache.caches.length > 0 || filterQuery === '',
114+
(workshopCache) =>
115+
workshopCache.caches.length > 0 || normalizedQuery === '',
83116
)
84117

85118
return {
86119
currentWorkshopId,
87-
allWorkshopCaches: allCaches,
88120
filteredCaches,
89121
filterQuery,
90122
selectedWorkshops,
@@ -270,23 +302,75 @@ function InlineEntryEditor({
270302
workshopId,
271303
cacheName,
272304
filename,
273-
currentValue,
274305
entryKey,
275306
}: {
276307
workshopId: string
277308
cacheName: string
278309
filename: string
279-
currentValue: any
280310
entryKey: string
281311
}) {
282-
const fetcher = useFetcher<typeof action>()
283-
const [editValue, setEditValue] = useState(
284-
JSON.stringify(currentValue, null, 2),
285-
)
312+
const entryFetcher = useFetcher<{
313+
entry: {
314+
value: unknown
315+
metadata: {
316+
createdTime: number
317+
ttl?: number | null
318+
swr?: number
319+
}
320+
} | null
321+
}>()
322+
const updateFetcher = useFetcher<typeof action>()
323+
const [editValue, setEditValue] = useState<string | null>(null)
324+
const [baselineValue, setBaselineValue] = useState<string | null>(null)
286325
const [hasChanges, setHasChanges] = useState(false)
326+
const [hasRequested, setHasRequested] = useState(false)
327+
const [hasStartedLoading, setHasStartedLoading] = useState(false)
328+
const submittedValueRef = useRef<string | null>(null)
329+
const prevUpdateStateRef = useRef<string>(updateFetcher.state)
330+
331+
const entryPath = `${workshopId}/${cacheName}/${filename}`
332+
const entryValue = entryFetcher.data?.entry?.value
333+
const isEntryLoading = entryFetcher.state === 'loading'
334+
const hasEntry = Boolean(entryFetcher.data?.entry)
335+
const entryMissing = entryFetcher.data?.entry === null
336+
const entryFetchFailed =
337+
hasRequested &&
338+
hasStartedLoading &&
339+
!isEntryLoading &&
340+
entryFetcher.data === undefined &&
341+
entryFetcher.state === 'idle'
342+
343+
useEffect(() => {
344+
if (entryFetcher.state !== 'idle') {
345+
setHasStartedLoading(true)
346+
}
347+
}, [entryFetcher.state])
348+
349+
useEffect(() => {
350+
if (hasEntry) {
351+
const formattedValue = JSON.stringify(entryValue, null, 2) ?? ''
352+
setEditValue(formattedValue)
353+
setBaselineValue(formattedValue)
354+
setHasChanges(false)
355+
}
356+
}, [hasEntry, entryValue])
357+
358+
useEffect(() => {
359+
const currentUpdateState = updateFetcher.state
360+
const wasSubmitting = prevUpdateStateRef.current !== 'idle'
361+
const nowIdle = currentUpdateState === 'idle'
362+
363+
if (wasSubmitting && nowIdle && updateFetcher.data?.status === 'success') {
364+
setBaselineValue(submittedValueRef.current ?? '')
365+
setHasChanges(false)
366+
}
367+
prevUpdateStateRef.current = currentUpdateState
368+
}, [updateFetcher.state, updateFetcher.data?.status])
287369

288370
const handleSave = () => {
289-
void fetcher.submit(
371+
if (editValue === null) return
372+
submittedValueRef.current = editValue
373+
void updateFetcher.submit(
290374
{
291375
intent: 'update-entry',
292376
workshopId,
@@ -296,52 +380,106 @@ function InlineEntryEditor({
296380
},
297381
{ method: 'POST' },
298382
)
299-
setHasChanges(false)
300383
}
301384

302385
const handleChange = (value: string) => {
303386
setEditValue(value)
304-
setHasChanges(value !== JSON.stringify(currentValue, null, 2))
387+
if (baselineValue === null) {
388+
setHasChanges(false)
389+
return
390+
}
391+
setHasChanges(value !== baselineValue)
305392
}
306393

307394
const handleReset = () => {
308-
setEditValue(JSON.stringify(currentValue, null, 2))
395+
if (baselineValue === null) return
396+
setEditValue(baselineValue)
309397
setHasChanges(false)
310398
}
311399

400+
const handleToggle = (event: React.SyntheticEvent<HTMLDetailsElement>) => {
401+
if (event.currentTarget.open && !hasRequested) {
402+
void entryFetcher.load(
403+
href('/admin/cache/*', {
404+
'*': entryPath,
405+
}),
406+
)
407+
setHasRequested(true)
408+
} else if (!event.currentTarget.open) {
409+
// Reset on close to allow retry
410+
setHasRequested(false)
411+
setHasStartedLoading(false)
412+
}
413+
}
414+
415+
const handleRetry = () => {
416+
void entryFetcher.load(
417+
href('/admin/cache/*', {
418+
'*': entryPath,
419+
}),
420+
)
421+
}
422+
312423
return (
313-
<details className="mt-2">
424+
<details className="mt-2" onToggle={handleToggle}>
314425
<summary className="text-muted-foreground hover:text-foreground cursor-pointer text-sm">
315426
Edit entry details
316427
</summary>
317428
<div className="border-border bg-muted mt-2 space-y-3 rounded border p-3">
318-
<div>
319-
<label className="mb-1 block text-sm font-medium">Key:</label>
320-
<code className="bg-background rounded border px-2 py-1 text-sm">
321-
{entryKey}
322-
</code>
323-
</div>
324-
<div>
325-
<label className="mb-1 block text-sm font-medium">Value:</label>
326-
<textarea
327-
value={editValue}
328-
onChange={(e) => handleChange(e.target.value)}
329-
className="resize-vertical border-border bg-background text-foreground focus:ring-ring h-32 w-full rounded border p-2 font-mono text-sm focus:ring-2 focus:outline-none"
330-
placeholder="Enter JSON value..."
331-
/>
332-
</div>
333-
<div className="flex gap-2">
334-
<Button
335-
varient="primary"
336-
onClick={handleSave}
337-
disabled={!hasChanges || fetcher.state !== 'idle'}
338-
>
339-
{fetcher.state !== 'idle' ? 'Saving...' : 'Save'}
340-
</Button>
341-
<Button varient="mono" onClick={handleReset} disabled={!hasChanges}>
342-
Reset
343-
</Button>
344-
</div>
429+
{isEntryLoading ? (
430+
<p className="text-muted-foreground text-sm">Loading entry...</p>
431+
) : entryFetchFailed ? (
432+
<div className="space-y-2">
433+
<p className="text-destructive text-sm">
434+
Failed to load entry details.
435+
</p>
436+
<Button varient="mono" onClick={handleRetry}>
437+
Retry
438+
</Button>
439+
</div>
440+
) : entryMissing ? (
441+
<p className="text-destructive text-sm">
442+
Entry details were not found.
443+
</p>
444+
) : hasEntry ? (
445+
<>
446+
<div>
447+
<label className="mb-1 block text-sm font-medium">Key:</label>
448+
<code className="bg-background rounded border px-2 py-1 text-sm">
449+
{entryKey}
450+
</code>
451+
</div>
452+
<div>
453+
<label className="mb-1 block text-sm font-medium">Value:</label>
454+
<textarea
455+
value={editValue ?? ''}
456+
onChange={(e) => handleChange(e.target.value)}
457+
className="resize-vertical border-border bg-background text-foreground focus:ring-ring h-32 w-full rounded border p-2 font-mono text-sm focus:ring-2 focus:outline-none"
458+
placeholder="Enter JSON value..."
459+
/>
460+
</div>
461+
<div className="flex gap-2">
462+
<Button
463+
varient="primary"
464+
onClick={handleSave}
465+
disabled={!hasChanges || updateFetcher.state !== 'idle'}
466+
>
467+
{updateFetcher.state !== 'idle' ? 'Saving...' : 'Save'}
468+
</Button>
469+
<Button
470+
varient="mono"
471+
onClick={handleReset}
472+
disabled={!hasChanges}
473+
>
474+
Reset
475+
</Button>
476+
</div>
477+
</>
478+
) : (
479+
<p className="text-muted-foreground text-sm">
480+
Open to load entry details.
481+
</p>
482+
)}
345483
</div>
346484
</details>
347485
)
@@ -609,7 +747,7 @@ export default function CacheManagement({ loaderData }: Route.ComponentProps) {
609747

610748
<div className="space-y-2">
611749
{cache.entries.map(
612-
({ key, entry, filename, size, filepath }) => (
750+
({ key, metadata, filename, size, filepath }) => (
613751
<div
614752
key={key}
615753
className="border-border bg-background rounded border p-3"
@@ -632,7 +770,7 @@ export default function CacheManagement({ loaderData }: Route.ComponentProps) {
632770
</span>
633771
) : null}
634772
</div>
635-
<CacheMetadata metadata={entry.metadata} />
773+
<CacheMetadata metadata={metadata} />
636774
</div>
637775
<div className="ml-4 flex shrink-0 gap-1">
638776
<a
@@ -679,7 +817,6 @@ export default function CacheManagement({ loaderData }: Route.ComponentProps) {
679817
workshopId={workshopCache.workshopId}
680818
cacheName={cache.name}
681819
filename={filename}
682-
currentValue={entry.value}
683820
entryKey={key}
684821
/>
685822
</div>

0 commit comments

Comments
 (0)