Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,6 @@ npm-debug.log*

# Metro
metro-cache/
branch_structure.json
temp_auto_push.bat
temp_interactive_push.bat
2 changes: 1 addition & 1 deletion babel.config.js

Large diffs are not rendered by default.

64 changes: 63 additions & 1 deletion src/__tests__/TaskDetailScreen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import renderer, { act } from 'react-test-renderer';
import { Text, TouchableOpacity } from 'react-native';
import TaskDetailScreen from '../screens/TaskDetailScreen';
import { fetchTaskById } from '../services/api';
import { TaskDetailSkeleton } from '../components/LoadingSkeleton';
import { useTaskStore } from '../store/taskStore';
import { Task } from '../types';

Expand Down Expand Up @@ -93,7 +94,7 @@ describe('TaskDetailScreen', () => {
mockNavigate.mockClear();
mockGoBack.mockClear();
mockFetchTaskById.mockReset();
useTaskStore.setState({ selectedTask: null, selectedAt: null });
useTaskStore.setState({ tasks: [], selectedTask: null, selectedAt: null });
});

afterEach(() => {
Expand Down Expand Up @@ -168,4 +169,65 @@ describe('TaskDetailScreen', () => {
rewardToken: 'ECO',
});
});

describe('taskStore cache', () => {
it('renders a cached task immediately with zero loading delay', async () => {
useTaskStore.setState({ tasks: [task] });
// Never resolves during this test, proving the initial render does not
// wait on the network for a task that is already in the store.
mockFetchTaskById.mockReturnValue(new Promise<Task>(() => {}));

tree = await renderScreen();

expect(tree.root.findAllByType(TaskDetailSkeleton)).toHaveLength(0);
expect(textValues(tree)).toContain('Plant a tree');
});

it('revalidates a cache hit in the background and updates the view when the task changed', async () => {
useTaskStore.setState({ tasks: [task] });
let resolveFetch: (value: Task) => void = () => {};
const fetchPromise = new Promise<Task>(resolve => {
resolveFetch = resolve;
});
mockFetchTaskById.mockReturnValue(fetchPromise);

tree = await renderScreen();

// Cache hit renders instantly, before the background fetch resolves.
expect(textValues(tree)).toContain('Plant a tree');

const updated: Task = { ...task, title: 'Plant a whole forest' };
await act(async () => {
resolveFetch(updated);
await fetchPromise;
});

expect(textValues(tree)).toContain('Plant a whole forest');
expect(useTaskStore.getState().tasks).toContainEqual(updated);
});

it('keeps showing the cached task when the background revalidation fails offline', async () => {
useTaskStore.setState({ tasks: [task] });
mockFetchTaskById.mockRejectedValue(new Error('Network unreachable'));

tree = await renderScreen();
// Let the rejected background revalidation settle.
await act(async () => {
await Promise.resolve().then(() => Promise.resolve());
});

expect(textValues(tree)).toContain('Plant a tree');
expect(textValues(tree)).not.toContain('Network unreachable');
});

it('falls back to the loading skeleton and a full fetch when the store is empty', async () => {
useTaskStore.setState({ tasks: [] });
mockFetchTaskById.mockResolvedValue(task);

tree = await renderScreen();

expect(mockFetchTaskById).toHaveBeenCalledWith('t1');
expect(textValues(tree)).toContain('Plant a tree');
});
});
});
55 changes: 49 additions & 6 deletions src/screens/TaskDetailScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { View, Text, TouchableOpacity, ScrollView } from 'react-native';
import { useRoute, RouteProp } from '@react-navigation/native';
import { colors, spacing } from '../utils/theme';
Expand All @@ -23,27 +23,70 @@ export default function TaskDetailScreen() {
const navigation = useTaskStackNavigation();
const { taskId } = route.params;
const selectTask = useTaskStore(s => s.selectTask);
const updateTask = useTaskStore(s => s.updateTask);

const [task, setTask] = useState<Task | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
void loadTask();
}, [taskId]); // eslint-disable-line react-hooks/exhaustive-deps
// Guards async fetches: only the effect run for the *current* taskId may
// apply its result, so a slow response for a task we've navigated away from
// can't clobber what's on screen now.
const latestTaskIdRef = useRef(taskId);

async function loadTask() {
setLoading(true);
setError(null);
try {
setTask(await fetchTaskById(taskId));
const fresh = await fetchTaskById(taskId);
if (latestTaskIdRef.current !== taskId) {
return;
}
updateTask(fresh);
setTask(fresh);
} catch (err) {
if (latestTaskIdRef.current !== taskId) {
return;
}
setError(err instanceof Error ? err.message : 'Failed to load task');
} finally {
setLoading(false);
if (latestTaskIdRef.current === taskId) {
setLoading(false);
}
}
}

useEffect(() => {
latestTaskIdRef.current = taskId;

const cached = useTaskStore.getState().tasks.find(t => t.id === taskId);
if (!cached) {
void loadTask();
return;
}

// Cache hit: show it immediately, no loading state, then revalidate in
// the background. A failed revalidation (e.g. offline) is silently
// ignored β€” the cached task is still valid to display.
setTask(cached);
setLoading(false);
setError(null);

void (async () => {
try {
const fresh = await fetchTaskById(taskId);
if (latestTaskIdRef.current !== taskId) {
return;
}
updateTask(fresh);
const merged = useTaskStore.getState().tasks.find(t => t.id === taskId);
setTask(merged ?? fresh);
} catch {
// Keep showing the cached task.
}
})();
}, [taskId]); // eslint-disable-line react-hooks/exhaustive-deps

if (loading) {
return (
<View style={{ flex: 1, backgroundColor: colors.background }}>
Expand Down
22 changes: 22 additions & 0 deletions src/store/taskStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@ interface TaskState {
hasMore: boolean;
setTasks: (tasks: Task[]) => void;
appendTasks: (tasks: Task[]) => void;
/**
* Merges a single freshly-fetched task into `tasks` (used by TaskDetailScreen's
* background revalidation). Inserts it if absent. Never downgrades a task away
* from 'closed' β€” a stale/racy revalidation response must not reopen a task
* whose activity has already been confirmed locally.
*/
updateTask: (task: Task) => void;
selectTask: (task: Task | null) => void;
setLoading: (loading: boolean) => void;
setError: (error: string | null) => void;
Expand Down Expand Up @@ -130,6 +137,21 @@ export const useTaskStore = create<TaskState>()(
),
],
})),
updateTask: task =>
set(s => {
const existing = s.tasks.find(t => t.id === task.id);
if (!existing) {
return { tasks: [...s.tasks, task] };
}
if (existing.status === 'closed' && task.status !== 'closed') {
return {};
}
return {
tasks: s.tasks.map(t =>
t.id === task.id ? { ...existing, ...task } : t,
),
};
}),
selectTask: task =>
set({
selectedTask: task,
Expand Down