Skip to content

Commit 5e51f39

Browse files
fix(frontend): stabilize ScheduleJobDialog render loop and improve accessibility
Resolve a "Maximum update depth exceeded" render loop in ScheduleJobDialog while prompt templates are loading, and add accessibility labels across the frontend. Also unregister the service worker in dev to avoid stale caches. - ScheduleJobDialog: use a stable EMPTY_TEMPLATES constant and split template matching into its own effect keyed on [templates, job, open] instead of running inside the init effect that depends on templates - Add ScheduleJobDialog.loading.test.tsx regression test for the loading state - serviceWorker: unregister existing registrations and clear caches in DEV so stale workers do not serve outdated assets locally - App: replace shell div with semantic <main> - SettingsDialog: add sr-only DialogTitle for screen readers - Add aria-labels to icon-only buttons in back-button, combobox, header, GlobalSchedules, and Repos - index.html: add mobile-web-app-capable meta
1 parent 59fd516 commit 5e51f39

11 files changed

Lines changed: 123 additions & 9 deletions

File tree

‎frontend/index.html‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
2424
<meta name="theme-color" content="#0a0a0a" />
2525
<meta name="description" content="AI-powered coding assistant interface" />
26+
<meta name="mobile-web-app-capable" content="yes" />
2627
<meta name="apple-mobile-web-app-capable" content="yes" />
2728
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
2829
<meta name="apple-mobile-web-app-title" content="OpenCode" />

‎frontend/src/App.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,9 +159,9 @@ function AppShell() {
159159
<EventProvider>
160160
<div ref={rootRef} className="flex h-dvh w-full min-w-0">
161161
<DesktopSidebar />
162-
<div className="flex-1 min-w-0 min-h-0 flex flex-col">
162+
<main className="flex-1 min-w-0 min-h-0 flex flex-col">
163163
<Outlet />
164-
</div>
164+
</main>
165165
</div>
166166
<MobileTabBar />
167167
<MobileSheetHost />
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest'
2+
import { render, screen, waitFor } from '@testing-library/react'
3+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
4+
import { ScheduleJobDialog } from './ScheduleJobDialog'
5+
6+
// jsdom does not implement scrollIntoView
7+
Element.prototype.scrollIntoView = vi.fn()
8+
9+
// Reproduces the "Maximum update depth exceeded" crash: while the prompt
10+
// templates query is loading, the hook returns `data: undefined`. The
11+
// component's `= EMPTY_TEMPLATES` fallback must stay referentially stable so
12+
// the init effect does not re-fire every render.
13+
vi.mock('@/hooks/usePromptTemplates', () => ({
14+
usePromptTemplates: () => ({ data: undefined, isLoading: true }),
15+
useCreatePromptTemplate: () => ({ mutate: vi.fn(), isPending: false }),
16+
useUpdatePromptTemplate: () => ({ mutate: vi.fn(), isPending: false }),
17+
useDeletePromptTemplate: () => ({ mutate: vi.fn(), isPending: false }),
18+
}))
19+
20+
vi.mock('@/api/providers', () => ({
21+
getProvidersWithModels: () => Promise.resolve([]),
22+
}))
23+
24+
vi.mock('@/api/opencode', () => ({
25+
createOpenCodeClient: () => ({
26+
listAgents: () => Promise.resolve([]),
27+
getConfig: () => Promise.resolve(null),
28+
}),
29+
}))
30+
31+
vi.mock('@/api/settings', () => ({
32+
settingsApi: {
33+
listManagedSkills: () => Promise.resolve([]),
34+
},
35+
}))
36+
37+
vi.mock('@/api/repos', () => ({
38+
listRepos: () => Promise.resolve([]),
39+
listBranches: () => Promise.resolve({ branches: [], status: { ahead: 0, behind: 0 } }),
40+
}))
41+
42+
function createWrapper() {
43+
const queryClient = new QueryClient({
44+
defaultOptions: {
45+
queries: { retry: false },
46+
mutations: { retry: false },
47+
},
48+
})
49+
return ({ children }: { children: React.ReactNode }) => (
50+
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
51+
)
52+
}
53+
54+
describe('ScheduleJobDialog — templates loading', () => {
55+
beforeEach(() => {
56+
vi.clearAllMocks()
57+
})
58+
59+
it('renders without an infinite render loop while templates are loading', async () => {
60+
render(
61+
<ScheduleJobDialog
62+
open
63+
onOpenChange={vi.fn()}
64+
showRepoSelector
65+
repoId={undefined}
66+
onRepoChange={vi.fn()}
67+
onSubmit={vi.fn()}
68+
isSaving={false}
69+
/>,
70+
{ wrapper: createWrapper() },
71+
)
72+
73+
await waitFor(() => {
74+
expect(screen.getByText('New schedule')).toBeInTheDocument()
75+
})
76+
})
77+
})

‎frontend/src/components/schedules/ScheduleJobDialog.tsx‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ import { TimingTab } from './TimingTab'
2929
import { PromptTab } from './PromptTab'
3030
import { SkillsTab } from './SkillsTab'
3131

32+
const EMPTY_TEMPLATES: PromptTemplate[] = []
33+
3234
type ScheduleJobDialogProps = {
3335
open: boolean
3436
onOpenChange: (open: boolean) => void
@@ -68,7 +70,7 @@ export function ScheduleJobDialog({ open, onOpenChange, job, isSaving, onSubmit,
6870
const [editingTemplate, setEditingTemplate] = useState<PromptTemplate | undefined>(undefined)
6971
const [deletingTemplateId, setDeletingTemplateId] = useState<number | null>(null)
7072

71-
const { data: templates = [] } = usePromptTemplates()
73+
const { data: templates = EMPTY_TEMPLATES } = usePromptTemplates()
7274
const deleteTemplateMutation = useDeletePromptTemplate()
7375

7476
const { data: providerModels = [] } = useQuery({
@@ -212,8 +214,6 @@ export function ScheduleJobDialog({ open, onOpenChange, job, isSaving, onSubmit,
212214
setAgentSlug(job?.agentSlug ?? '')
213215
setModel(job?.model ?? '')
214216
setPrompt(job?.prompt ?? '')
215-
const matchingTemplate = templates.find((template) => template.prompt === (job?.prompt ?? ''))
216-
setSelectedPromptTemplateId(matchingTemplate ? matchingTemplate.id : null)
217217
const initialSkillSlugs = job?.skillMetadata?.skillSlugs ?? []
218218
const initialSkillNotes = job?.skillMetadata?.notes ?? ''
219219
setSkillSlugs(initialSkillSlugs)
@@ -223,7 +223,15 @@ export function ScheduleJobDialog({ open, onOpenChange, job, isSaving, onSubmit,
223223
setBranch(job?.branch ?? '')
224224
setAllowExternalDirectory(job?.permissionConfig?.allowExternalDirectory ?? false)
225225
setBashDenyPatterns(job?.permissionConfig?.bashDenyPatterns ?? [...DEFAULT_DESTRUCTIVE_BASH_PATTERNS])
226-
}, [job, open, templates])
226+
}, [job, open])
227+
228+
useEffect(() => {
229+
if (!open) {
230+
return
231+
}
232+
const matchingTemplate = templates.find((template) => template.prompt === (job?.prompt ?? ''))
233+
setSelectedPromptTemplateId(matchingTemplate ? matchingTemplate.id : null)
234+
}, [templates, job, open])
227235

228236
const applyPromptTemplate = (template: PromptTemplate) => {
229237
setSelectedPromptTemplateId(template.id)

‎frontend/src/components/settings/SettingsDialog.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { AccountSettings } from '@/components/settings/AccountSettings'
1212
import { VoiceSettings } from '@/components/settings/VoiceSettings'
1313
import { NotificationSettings } from '@/components/settings/NotificationSettings'
1414
import { VersionSelectDialog } from '@/components/settings/VersionSelectDialog'
15-
import { Dialog, DialogContent } from '@/components/ui/dialog'
15+
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
1616
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
1717
import { Settings2, Keyboard, Code, ChevronLeft, Key, GitBranch, User, Volume2, Bell, X } from 'lucide-react'
1818
import { Button } from '@/components/ui/button'
@@ -109,6 +109,7 @@ export function SettingsDialog() {
109109
onFocusOutside={(e) => e.preventDefault()}
110110
onPointerDownOutside={(e) => e.preventDefault()}
111111
>
112+
<DialogTitle className="sr-only">Settings</DialogTitle>
112113
<div className="hidden sm:flex sm:flex-col sm:h-full sm:min-h-0">
113114
<div className="sticky top-0 z-10 bg-gradient-to-b from-background via-background to-transparent border-b border-border backdrop-blur-sm px-6 py-4 flex-shrink-0 flex items-center justify-between">
114115
<h2 className="text-2xl font-semibold bg-gradient-to-r from-foreground to-muted-foreground bg-clip-text text-transparent">

‎frontend/src/components/ui/back-button.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export function BackButton({ to = "/", className = "" }: BackButtonProps) {
1616
return (
1717
<button
1818
onClick={handleBack}
19+
aria-label="Go back"
1920
className={`text-zinc-400 hover:text-zinc-100 transition-all duration-200 hover:scale-105 text-sm md:text-md border border-zinc-700 rounded-md px-3 py-1.5 hover ${className}`}
2021
>
2122
<ArrowLeft className="w-4 h-4" />

‎frontend/src/components/ui/combobox.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@ export function Combobox({
186186
{showClear && value && (
187187
<button
188188
type="button"
189+
aria-label="Clear"
189190
onClick={() => {
190191
if (!disabled) {
191192
onChange('')
@@ -201,6 +202,7 @@ export function Combobox({
201202
)}
202203
<button
203204
type="button"
205+
aria-label="Toggle options"
204206
onClick={() => {
205207
if (!disabled) {
206208
setIsOpen(!isOpen)

‎frontend/src/components/ui/header.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ function HeaderSettingsButton() {
169169
variant="ghost"
170170
size="icon"
171171
onClick={open}
172+
aria-label="Settings"
172173
className="text-muted-foreground hover:text-foreground hover:bg-accent transition-all duration-200 h-8 w-8"
173174
>
174175
<Settings className="w-4 h-4" />

‎frontend/src/lib/serviceWorker.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,27 @@ export function offServiceWorkerUpdate(): void {
3232

3333
const UPDATE_CHECK_INTERVAL_MS = 60 * 1000;
3434

35+
async function unregisterServiceWorkerAndClearCaches(): Promise<void> {
36+
try {
37+
const registrations = await navigator.serviceWorker.getRegistrations();
38+
await Promise.all(registrations.map((registration) => registration.unregister()));
39+
if ("caches" in window) {
40+
const keys = await caches.keys();
41+
await Promise.all(keys.map((key) => caches.delete(key)));
42+
}
43+
} catch {
44+
return;
45+
}
46+
}
47+
3548
export function registerServiceWorker(): void {
3649
if (!("serviceWorker" in navigator)) return;
3750

51+
if (import.meta.env.DEV) {
52+
void unregisterServiceWorkerAndClearCaches();
53+
return;
54+
}
55+
3856
const hadController = !!navigator.serviceWorker.controller;
3957

4058
navigator.serviceWorker.addEventListener("message", (event) => {

‎frontend/src/pages/GlobalSchedules.tsx‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,7 @@ export function GlobalSchedules() {
325325
<Button
326326
onClick={() => { openNewJob(); setSelectedRepoId(undefined) }}
327327
size="sm"
328+
aria-label="New Schedule"
328329
className="sm:hidden h-10 w-10 p-0"
329330
>
330331
<Plus className="w-5 h-5" />
@@ -361,7 +362,7 @@ export function GlobalSchedules() {
361362
/>
362363
<DropdownMenu>
363364
<DropdownMenuTrigger asChild>
364-
<Button variant="outline" size="icon" className="sm:hidden h-8 w-8 shrink-0 relative">
365+
<Button variant="outline" size="icon" aria-label="Filters" className="sm:hidden h-8 w-8 shrink-0 relative">
365366
<SlidersHorizontal className="h-3.5 w-3.5" />
366367
{(statusFilter !== 'all' || scheduleModeFilter !== 'all') && (
367368
<span className="absolute -top-1 -right-1 h-2 w-2 rounded-full bg-primary" />
@@ -615,6 +616,7 @@ export function GlobalSchedules() {
615616
variant="outline"
616617
size="sm"
617618
className="h-8 w-8 p-0"
619+
aria-label={job.enabled ? 'Pause schedule' : 'Enable schedule'}
618620
onClick={(e) => {
619621
e.stopPropagation()
620622
handleToggleEnabled(job)
@@ -630,6 +632,7 @@ export function GlobalSchedules() {
630632
variant="outline"
631633
size="sm"
632634
className="h-8 w-8 p-0"
635+
aria-label="Edit schedule"
633636
onClick={(e) => {
634637
e.stopPropagation()
635638
handleEdit(job)
@@ -641,6 +644,7 @@ export function GlobalSchedules() {
641644
variant="outline"
642645
size="sm"
643646
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
647+
aria-label="Delete schedule"
644648
onClick={(e) => {
645649
e.stopPropagation()
646650
openDeleteJob(job.id)
@@ -670,7 +674,7 @@ export function GlobalSchedules() {
670674
/>
671675
<DropdownMenu>
672676
<DropdownMenuTrigger asChild>
673-
<Button variant="outline" size="icon" className="sm:hidden h-8 w-8 shrink-0 relative">
677+
<Button variant="outline" size="icon" aria-label="Filters" className="sm:hidden h-8 w-8 shrink-0 relative">
674678
<SlidersHorizontal className="h-3.5 w-3.5" />
675679
{(runStatusFilter !== 'all' || runTriggerFilter !== 'all' || runSortOption !== 'startedAt') && (
676680
<span className="absolute -top-1 -right-1 h-2 w-2 rounded-full bg-primary" />

0 commit comments

Comments
 (0)