fix: render-optimiste + timing instrumentation for widget toggles (#71)

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).
This commit is contained in:
le king fu 2026-04-19 16:17:41 -04:00
parent 9cf507429a
commit af43a3f1a8

View file

@ -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<string, number>();
// Dev-only timing helper. Output goes to logcat:
// adb logcat -s ReactNativeJS | grep '\[widget\]'
async function timed<T>(label: string, fn: () => Promise<T> | T): Promise<T> {
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<ReturnType<typeof getWidgetState>>): Promise<void> {
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<void> {
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;
}