From af43a3f1a804927c64baac459f116cbad8b014b1 Mon Sep 17 00:00:00 2001 From: le king fu Date: Sun, 19 Apr 2026 16:17:41 -0400 Subject: [PATCH] fix: render-optimiste + timing instrumentation for widget toggles (#71) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Widget tap-to-expand felt slow (several seconds). Inverts the order in all three click handlers so the widget re-renders BEFORE awaiting the AsyncStorage write — the user sees the change immediately, persistence finishes in the background. - TOGGLE_COMPLETE / TOGGLE_EXPAND / TOGGLE_SUBTASK : render before persist - EXPAND_DEBOUNCE_MS 2000 -> 600 (still blocks accidental double-taps, no longer feels laggy when collapsing right after expanding) - persistState() wraps setWidgetState in try/catch — on failure the next handler call re-reads the prior state from AsyncStorage, UI self-heals - Dev-only timed() helper logs each step to logcat for measurement: adb logcat -s ReactNativeJS | grep '\[widget\]' Out of scope: cold start of the Android headless task service (suspected main contributor to perceived slowness, unfixable from JS). --- src/widgets/widgetTaskHandler.ts | 65 +++++++++++++++++++++++++------- 1 file changed, 52 insertions(+), 13 deletions(-) diff --git a/src/widgets/widgetTaskHandler.ts b/src/widgets/widgetTaskHandler.ts index 1e3ab43..86380a0 100644 --- a/src/widgets/widgetTaskHandler.ts +++ b/src/widgets/widgetTaskHandler.ts @@ -4,9 +4,21 @@ import { TaskListWidget } from './TaskListWidget'; import { getWidgetState, setWidgetState, WIDGET_NAMES, type WidgetTask } from '../services/widgetSync'; import { isValidUUID } from '../lib/validation'; -const EXPAND_DEBOUNCE_MS = 2000; +const EXPAND_DEBOUNCE_MS = 600; const lastExpandTimes = new Map(); +// Dev-only timing helper. Output goes to logcat: +// adb logcat -s ReactNativeJS | grep '\[widget\]' +async function timed(label: string, fn: () => Promise | T): Promise { + if (!__DEV__) return await fn(); + const start = Date.now(); + try { + return await fn(); + } finally { + console.log(`[widget] ${label}: ${Date.now() - start}ms`); + } +} + function renderWithState( renderWidget: WidgetTaskHandlerProps['renderWidget'], widgetInfo: WidgetTaskHandlerProps['widgetInfo'], @@ -44,17 +56,30 @@ async function forceWidgetRefresh( } } +// Best-effort persist: failure leaves AsyncStorage stale, but the next +// handler call's getWidgetState() returns the prior state and re-renders +// from it, so the UI self-heals on the next interaction. +async function persistState(state: Awaited>): Promise { + try { + await setWidgetState(state); + } catch { + if (__DEV__) console.log('[widget] setWidgetState failed (state will resync on next sync push)'); + } +} + export async function widgetTaskHandler( props: WidgetTaskHandlerProps ): Promise { const { widgetAction, widgetInfo, renderWidget } = props; + const handlerStart = __DEV__ ? Date.now() : 0; switch (widgetAction) { case 'WIDGET_ADDED': case 'WIDGET_UPDATE': case 'WIDGET_RESIZED': { - const state = await getWidgetState(); + const state = await timed(`${widgetAction} getState`, getWidgetState); renderWithState(renderWidget, widgetInfo, state.tasks, state.isDark, state.expandedTaskIds); + if (__DEV__) console.log(`[widget] ${widgetAction} total: ${Date.now() - handlerStart}ms`); break; } @@ -66,31 +91,36 @@ export async function widgetTaskHandler( const taskId = props.clickActionData?.taskId; if (!isValidUUID(taskId)) break; - const state = await getWidgetState(); + const state = await timed('TOGGLE_COMPLETE getState', getWidgetState); state.tasks = state.tasks.filter((t) => t.id !== taskId); - await setWidgetState(state); + // Render first so the user sees the row disappear immediately, + // then persist + run the DB write. renderWithState(renderWidget, widgetInfo, state.tasks, state.isDark, state.expandedTaskIds); + await timed('TOGGLE_COMPLETE setState', () => persistState(state)); try { const { toggleComplete } = await import('../db/repository/tasks'); - await toggleComplete(taskId); + await timed('TOGGLE_COMPLETE db', () => toggleComplete(taskId)); } catch { // DB might not be available in headless mode } + + if (__DEV__) console.log(`[widget] TOGGLE_COMPLETE total: ${Date.now() - handlerStart}ms`); } if (props.clickAction === 'TOGGLE_EXPAND') { const taskId = props.clickActionData?.taskId as string | undefined; if (!taskId) break; - // Debounce: ignore rapid double-taps on the same task + // Anti-double-tap. Short enough to not feel laggy when the user + // genuinely wants to expand-then-collapse. const now = Date.now(); const lastTime = lastExpandTimes.get(taskId) ?? 0; if (now - lastTime < EXPAND_DEBOUNCE_MS) break; lastExpandTimes.set(taskId, now); - const state = await getWidgetState(); + const state = await timed('TOGGLE_EXPAND getState', getWidgetState); const expandedSet = new Set(state.expandedTaskIds); if (expandedSet.has(taskId)) { @@ -99,9 +129,11 @@ export async function widgetTaskHandler( expandedSet.add(taskId); } state.expandedTaskIds = [...expandedSet]; - await setWidgetState(state); renderWithState(renderWidget, widgetInfo, state.tasks, state.isDark, state.expandedTaskIds); + await timed('TOGGLE_EXPAND setState', () => persistState(state)); + + if (__DEV__) console.log(`[widget] TOGGLE_EXPAND total: ${Date.now() - handlerStart}ms`); } if (props.clickAction === 'TOGGLE_SUBTASK') { @@ -109,9 +141,8 @@ export async function widgetTaskHandler( const parentId = props.clickActionData?.parentId as string | undefined; if (!isValidUUID(subtaskId) || !parentId) break; - const state = await getWidgetState(); + const state = await timed('TOGGLE_SUBTASK getState', getWidgetState); - // Update subtask state in cached data const parent = state.tasks.find((t) => t.id === parentId); if (parent) { const sub = parent.subtasks?.find((s) => s.id === subtaskId); @@ -120,16 +151,24 @@ export async function widgetTaskHandler( parent.subtaskDoneCount = (parent.subtasks ?? []).filter((s) => s.completed).length; } } - await setWidgetState(state); - await forceWidgetRefresh(state.tasks, state.isDark, state.expandedTaskIds); + // forceWidgetRefresh fans out to all 3 widget sizes (state changed + // affects every widget on the home screen). Run before persist for + // immediate visual feedback; the data passed in is the in-memory + // mutated state, not re-read from AsyncStorage. + await timed('TOGGLE_SUBTASK render', () => + forceWidgetRefresh(state.tasks, state.isDark, state.expandedTaskIds) + ); + await timed('TOGGLE_SUBTASK setState', () => persistState(state)); try { const { toggleComplete } = await import('../db/repository/tasks'); - await toggleComplete(subtaskId); + await timed('TOGGLE_SUBTASK db', () => toggleComplete(subtaskId)); } catch { // DB might not be available in headless mode } + + if (__DEV__) console.log(`[widget] TOGGLE_SUBTASK total: ${Date.now() - handlerStart}ms`); } break; }