fix: render-optimiste + timing for widget toggle handlers (#71) #73
1 changed files with 52 additions and 13 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue