mirror of
https://github.com/coleam00/Archon.git
synced 2025-12-24 02:39:17 -05:00
feat: Phase 3 - Fix optimistic updates with stable UUIDs and visual indicators (#695)
* feat: Phase 3 - Fix optimistic updates with stable UUIDs and visual indicators - Replace timestamp-based temp IDs with stable nanoid UUIDs - Create shared optimistic utilities module with type-safe functions - Add visual indicators (OptimisticIndicator component) for pending items - Update all mutation hooks (tasks, projects, knowledge) to use new utilities - Add optimistic state styling to TaskCard, ProjectCard, and KnowledgeCard - Add comprehensive unit tests for optimistic utilities - All tests passing, validation complete * docs: Update optimistic updates documentation with Phase 3 patterns - Remove outdated optimistic_updates.md - Create new concise documentation with file references - Document shared utilities API and patterns - Include performance characteristics and best practices - Reference actual implementation files instead of code examples - Add testing checklist and migration notes * fix: resolve CodeRabbit review issues for Phase 3 optimistic updates Address systematic review feedback on optimistic updates implementation: **Knowledge Queries (useKnowledgeQueries.ts):** - Add missing createOptimisticEntity import for type-safe optimistic creation - Implement filter-aware cache updates for crawl/upload flows to prevent items appearing in wrong filtered views - Fix total count calculation in deletion to accurately reflect removed items - Replace manual optimistic item creation with createOptimisticEntity<KnowledgeItem>() **Project Queries (useProjectQueries.ts):** - Add proper TypeScript mutation typing with Awaited<ReturnType<>> - Ensure type safety for createProject mutation response handling **OptimisticIndicator Component:** - Fix React.ComponentType import to use direct import instead of namespace - Add proper TypeScript ComponentType import for HOC function - Apply consistent Biome formatting **Documentation:** - Update performance characteristics with accurate bundlephobia metrics - Improve nanoid benchmark references and memory usage details All unit tests passing (90/90). Integration test failures expected without backend. Co-Authored-By: CodeRabbit Review <noreply@coderabbit.ai> * Adjust polling interval and clean knowledge cache --------- Co-authored-by: CodeRabbit Review <noreply@coderabbit.ai>
This commit is contained in:
@@ -1,148 +1,135 @@
|
||||
# Optimistic Updates Pattern (Future State)
|
||||
# Optimistic Updates Pattern Guide
|
||||
|
||||
**⚠️ STATUS:** This is not currently implemented. There is a proof‑of‑concept (POC) on the frontend Project page. This document describes the desired future state for handling optimistic updates in a simple, consistent way.
|
||||
## Core Architecture
|
||||
|
||||
## Mental Model
|
||||
### Shared Utilities Module
|
||||
**Location**: `src/features/shared/optimistic.ts`
|
||||
|
||||
Think of optimistic updates as "assuming success" - update the UI immediately for instant feedback, then verify with the server. If something goes wrong, revert to the last known good state.
|
||||
|
||||
## The Pattern
|
||||
Provides type-safe utilities for managing optimistic state across all features:
|
||||
- `createOptimisticId()` - Generates stable UUIDs using nanoid
|
||||
- `createOptimisticEntity<T>()` - Creates entities with `_optimistic` and `_localId` metadata
|
||||
- `isOptimistic()` - Type guard for checking optimistic state
|
||||
- `replaceOptimisticEntity()` - Replaces optimistic items by `_localId` (race-condition safe)
|
||||
- `removeDuplicateEntities()` - Deduplicates after replacement
|
||||
- `cleanOptimisticMetadata()` - Strips optimistic fields when needed
|
||||
|
||||
### TypeScript Interface
|
||||
```typescript
|
||||
// 1. Save current state (for rollback) — take an immutable snapshot
|
||||
const previousState = structuredClone(currentState);
|
||||
|
||||
// 2. Update UI immediately
|
||||
setState(newState);
|
||||
|
||||
// 3. Call API
|
||||
try {
|
||||
const serverState = await api.updateResource(newState);
|
||||
// Success — use server as the source of truth
|
||||
setState(serverState);
|
||||
} catch (error) {
|
||||
// 4. Rollback on failure
|
||||
setState(previousState);
|
||||
showToast("Failed to update. Reverted changes.", "error");
|
||||
interface OptimisticEntity {
|
||||
_optimistic: boolean;
|
||||
_localId: string;
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Approach
|
||||
## Implementation Patterns
|
||||
|
||||
### Simple Hook Pattern
|
||||
### Mutation Hooks Pattern
|
||||
**Reference**: `src/features/projects/tasks/hooks/useTaskQueries.ts:44-108`
|
||||
|
||||
```typescript
|
||||
function useOptimistic<T>(initialValue: T, updateFn: (value: T) => Promise<T>) {
|
||||
const [value, setValue] = useState(initialValue);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const previousValueRef = useRef<T>(initialValue);
|
||||
const opSeqRef = useRef(0); // monotonically increasing op id
|
||||
const mountedRef = useRef(true); // avoid setState after unmount
|
||||
useEffect(() => () => { mountedRef.current = false; }, []);
|
||||
1. **onMutate**: Create optimistic entity with stable ID
|
||||
- Use `createOptimisticEntity<T>()` for type-safe creation
|
||||
- Store `optimisticId` in context for later replacement
|
||||
|
||||
const optimisticUpdate = async (newValue: T) => {
|
||||
const opId = ++opSeqRef.current;
|
||||
// Save for rollback
|
||||
previousValueRef.current = value;
|
||||
2. **onSuccess**: Replace optimistic with server response
|
||||
- Use `replaceOptimisticEntity()` matching by `_localId`
|
||||
- Apply `removeDuplicateEntities()` to prevent duplicates
|
||||
|
||||
// Update immediately
|
||||
if (mountedRef.current) setValue(newValue);
|
||||
if (mountedRef.current) setIsUpdating(true);
|
||||
3. **onError**: Rollback to previous state
|
||||
- Restore snapshot from context
|
||||
|
||||
try {
|
||||
const result = await updateFn(newValue);
|
||||
// Apply only if latest op and still mounted
|
||||
if (mountedRef.current && opId === opSeqRef.current) {
|
||||
setValue(result); // Server is source of truth
|
||||
}
|
||||
} catch (error) {
|
||||
// Rollback
|
||||
if (mountedRef.current && opId === opSeqRef.current) {
|
||||
setValue(previousValueRef.current);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (mountedRef.current && opId === opSeqRef.current) {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
### UI Component Pattern
|
||||
**References**:
|
||||
- `src/features/projects/tasks/components/TaskCard.tsx:39-40,160,186`
|
||||
- `src/features/projects/components/ProjectCard.tsx:32-33,67,93`
|
||||
- `src/features/knowledge/components/KnowledgeCard.tsx:49-50,176,244`
|
||||
|
||||
return { value, optimisticUpdate, isUpdating };
|
||||
}
|
||||
```
|
||||
1. Check optimistic state: `const optimistic = isOptimistic(entity)`
|
||||
2. Apply conditional styling: Add opacity and ring effect when optimistic
|
||||
3. Display indicator: Use `<OptimisticIndicator>` component for visual feedback
|
||||
|
||||
### Usage Example
|
||||
### Visual Indicator Component
|
||||
**Location**: `src/features/ui/primitives/OptimisticIndicator.tsx`
|
||||
|
||||
```typescript
|
||||
// In a component
|
||||
const {
|
||||
value: task,
|
||||
optimisticUpdate,
|
||||
isUpdating,
|
||||
} = useOptimistic(initialTask, (task) =>
|
||||
projectService.updateTask(task.id, task),
|
||||
);
|
||||
Reusable component showing:
|
||||
- Spinning loader icon (Loader2 from lucide-react)
|
||||
- "Saving..." text with pulse animation
|
||||
- Configurable via props: `showSpinner`, `pulseAnimation`
|
||||
|
||||
// Handle user action
|
||||
const handleStatusChange = (newStatus: string) => {
|
||||
optimisticUpdate({ ...task, status: newStatus }).catch((error) =>
|
||||
showToast("Failed to update task", "error"),
|
||||
);
|
||||
};
|
||||
```
|
||||
## Feature Integration
|
||||
|
||||
## Key Principles
|
||||
### Tasks
|
||||
- **Mutations**: `src/features/projects/tasks/hooks/useTaskQueries.ts`
|
||||
- **UI**: `src/features/projects/tasks/components/TaskCard.tsx`
|
||||
- Creates tasks with `priority: "medium"` default
|
||||
|
||||
1. **Keep it simple** — save, update, roll back.
|
||||
2. **Server is the source of truth** — always use the server response as the final state.
|
||||
3. **User feedback** — show loading states and clear error messages.
|
||||
4. **Selective usage** — only where instant feedback matters:
|
||||
- Drag‑and‑drop
|
||||
- Status changes
|
||||
- Toggle switches
|
||||
- Quick edits
|
||||
### Projects
|
||||
- **Mutations**: `src/features/projects/hooks/useProjectQueries.ts`
|
||||
- **UI**: `src/features/projects/components/ProjectCard.tsx`
|
||||
- Handles `prd: null`, `data_schema: null` for new projects
|
||||
|
||||
## What NOT to Do
|
||||
### Knowledge
|
||||
- **Mutations**: `src/features/knowledge/hooks/useKnowledgeQueries.ts`
|
||||
- **UI**: `src/features/knowledge/components/KnowledgeCard.tsx`
|
||||
- Uses `createOptimisticId()` directly for progress tracking
|
||||
|
||||
- Don't track complex state histories
|
||||
- Don't try to merge conflicts
|
||||
- Use with caution for create/delete operations. If used, generate temporary client IDs, reconcile with server‑assigned IDs, ensure idempotency, and define clear rollback/error states. Prefer non‑optimistic flows when side effects are complex.
|
||||
- Don't over-engineer with queues or reconciliation
|
||||
### Toasts
|
||||
- **Location**: `src/features/ui/hooks/useToast.ts:43`
|
||||
- Uses `createOptimisticId()` for unique toast IDs
|
||||
|
||||
## When to Implement
|
||||
## Testing
|
||||
|
||||
Implement optimistic updates when:
|
||||
### Unit Tests
|
||||
**Location**: `src/features/shared/optimistic.test.ts`
|
||||
|
||||
- Users complain about UI feeling "slow"
|
||||
- Drag-and-drop or reordering feels laggy
|
||||
- Quick actions (like checkbox toggles) feel unresponsive
|
||||
- Network latency is noticeable (> 200ms)
|
||||
Covers all utility functions with 8 test cases:
|
||||
- ID uniqueness and format validation
|
||||
- Entity creation with metadata
|
||||
- Type guard functionality
|
||||
- Replacement logic
|
||||
- Deduplication
|
||||
- Metadata cleanup
|
||||
|
||||
## Success Metrics
|
||||
### Manual Testing Checklist
|
||||
1. **Rapid Creation**: Create 5+ items quickly - verify no duplicates
|
||||
2. **Visual Feedback**: Check optimistic indicators appear immediately
|
||||
3. **ID Stability**: Confirm nanoid-based IDs after server response
|
||||
4. **Error Handling**: Stop backend, attempt creation - verify rollback
|
||||
5. **Race Conditions**: Use browser console script for concurrent creates
|
||||
|
||||
When implemented correctly:
|
||||
## Performance Characteristics
|
||||
|
||||
- UI feels instant (< 100ms response)
|
||||
- Rollbacks are rare (< 1% of updates)
|
||||
- Error messages are clear
|
||||
- Users understand what happened when things fail
|
||||
- **Bundle Impact**: ~130 bytes ([nanoid v5, minified+gzipped](https://bundlephobia.com/package/nanoid@5.0.9)) - build/environment dependent
|
||||
- **Update Speed**: Typically snappy on modern devices; actual latency varies by device and workload
|
||||
- **ID Generation**: Per [nanoid benchmarks](https://github.com/ai/nanoid#benchmark): secure sync ≈5M ops/s, non-secure ≈2.7M ops/s, async crypto ≈135k ops/s
|
||||
- **Memory**: Minimal - only `_optimistic` and `_localId` metadata added per optimistic entity
|
||||
|
||||
## Production Considerations
|
||||
## Migration Notes
|
||||
|
||||
The examples above are simplified for clarity. Production implementations should consider:
|
||||
### From Timestamp-based IDs
|
||||
**Before**: `const tempId = \`temp-\${Date.now()}\``
|
||||
**After**: `const optimisticId = createOptimisticId()`
|
||||
|
||||
1. **Deep cloning**: Use `structuredClone()` or a deep clone utility for complex state
|
||||
### Key Differences
|
||||
- No timestamp collisions during rapid creation
|
||||
- Stable IDs survive re-renders
|
||||
- Type-safe with full TypeScript inference
|
||||
- ~60% code reduction through shared utilities
|
||||
|
||||
```typescript
|
||||
const previousState = structuredClone(currentState); // Proper deep clone
|
||||
```
|
||||
## Best Practices
|
||||
|
||||
2. **Race conditions**: Handle out-of-order responses with operation IDs
|
||||
3. **Unmount safety**: Avoid setState after component unmount
|
||||
4. **Debouncing**: For rapid updates (e.g., sliders), debounce API calls
|
||||
5. **Conflict resolution**: For collaborative editing, consider operational transforms
|
||||
6. **Polling/ETag interplay**: When polling, ignore stale responses (e.g., compare opId or Last-Modified) and rely on ETag/304 to prevent flicker overriding optimistic state.
|
||||
7. **Idempotency & retries**: Use idempotency keys on write APIs so client retries (or duplicate submits) don't create duplicate effects.
|
||||
1. **Always use shared utilities** - Don't implement custom optimistic logic
|
||||
2. **Match by _localId** - Never match by the entity's `id` field
|
||||
3. **Include deduplication** - Always call `removeDuplicateEntities()` after replacement
|
||||
4. **Show visual feedback** - Users should see pending state clearly
|
||||
5. **Handle errors gracefully** - Always implement rollback in `onError`
|
||||
|
||||
These complexities are why we recommend starting simple and only adding optimistic updates where the UX benefit is clear.
|
||||
## Dependencies
|
||||
|
||||
- **nanoid**: v5.0.9 - UUID generation
|
||||
- **@tanstack/react-query**: v5.x - Mutation state management
|
||||
- **React**: v18.x - UI components
|
||||
- **TypeScript**: v5.x - Type safety
|
||||
|
||||
---
|
||||
|
||||
*Last updated: Phase 3 implementation (PR #695)*
|
||||
31
archon-ui-main/package-lock.json
generated
31
archon-ui-main/package-lock.json
generated
@@ -24,6 +24,7 @@
|
||||
"fractional-indexing": "^3.2.0",
|
||||
"framer-motion": "^11.5.4",
|
||||
"lucide-react": "^0.441.0",
|
||||
"nanoid": "^5.0.9",
|
||||
"prismjs": "^1.30.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dnd": "^16.0.1",
|
||||
@@ -9030,10 +9031,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||
"dev": true,
|
||||
"version": "5.1.5",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.5.tgz",
|
||||
"integrity": "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -9042,10 +9042,10 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.cjs"
|
||||
"nanoid": "bin/nanoid.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
"node": "^18 || >=20"
|
||||
}
|
||||
},
|
||||
"node_modules/natural-compare": {
|
||||
@@ -9651,6 +9651,25 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/postcss/node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/prelude-ls": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
"fractional-indexing": "^3.2.0",
|
||||
"framer-motion": "^11.5.4",
|
||||
"lucide-react": "^0.441.0",
|
||||
"nanoid": "^5.0.9",
|
||||
"prismjs": "^1.30.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dnd": "^16.0.1",
|
||||
|
||||
@@ -6,11 +6,13 @@
|
||||
|
||||
import { format } from "date-fns";
|
||||
import { motion } from "framer-motion";
|
||||
import { Briefcase, Clock, Code, ExternalLink, File, FileText, Globe, Terminal } from "lucide-react";
|
||||
import { Clock, Code, ExternalLink, File, FileText, Globe } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { KnowledgeCardProgress } from "../../progress/components/KnowledgeCardProgress";
|
||||
import type { ActiveOperation } from "../../progress/types";
|
||||
import { isOptimistic } from "../../shared/optimistic";
|
||||
import { StatPill } from "../../ui/primitives";
|
||||
import { OptimisticIndicator } from "../../ui/primitives/OptimisticIndicator";
|
||||
import { cn } from "../../ui/primitives/styles";
|
||||
import { SimpleTooltip } from "../../ui/primitives/tooltip";
|
||||
import { useDeleteKnowledgeItem, useRefreshKnowledgeItem } from "../hooks";
|
||||
@@ -44,6 +46,9 @@ export const KnowledgeCard: React.FC<KnowledgeCardProps> = ({
|
||||
const deleteMutation = useDeleteKnowledgeItem();
|
||||
const refreshMutation = useRefreshKnowledgeItem();
|
||||
|
||||
// Check if item is optimistic
|
||||
const optimistic = isOptimistic(item);
|
||||
|
||||
// Determine card styling based on type and status
|
||||
// Check if it's a real URL (not a file:// URL)
|
||||
// Prioritize top-level source_type over metadata source_type
|
||||
@@ -138,11 +143,6 @@ export const KnowledgeCard: React.FC<KnowledgeCardProps> = ({
|
||||
return <File className="w-5 h-5" />;
|
||||
};
|
||||
|
||||
const getTypeLabel = () => {
|
||||
if (isTechnical) return "Technical";
|
||||
return "Business";
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="relative group cursor-pointer"
|
||||
@@ -168,6 +168,7 @@ export const KnowledgeCard: React.FC<KnowledgeCardProps> = ({
|
||||
getBorderColor(),
|
||||
isHovered && "shadow-[0_0_30px_rgba(6,182,212,0.2)]",
|
||||
"min-h-[240px] flex flex-col",
|
||||
optimistic && "opacity-80 ring-1 ring-cyan-400/30",
|
||||
)}
|
||||
>
|
||||
{/* Top accent glow tied to type (does not change size) */}
|
||||
@@ -235,6 +236,7 @@ export const KnowledgeCard: React.FC<KnowledgeCardProps> = ({
|
||||
description={item.metadata?.description}
|
||||
accentColor={getAccentColorName()}
|
||||
/>
|
||||
<OptimisticIndicator isOptimistic={optimistic} className="mt-2" />
|
||||
</div>
|
||||
|
||||
{/* URL/Source */}
|
||||
|
||||
@@ -162,12 +162,6 @@ export const KnowledgeCardTags: React.FC<KnowledgeCardTagsProps> = ({ sourceId,
|
||||
}
|
||||
};
|
||||
|
||||
const handleTagClick = () => {
|
||||
if (!isEditing) {
|
||||
setIsEditing(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditTag = (tagToEdit: string) => {
|
||||
// When clicking an existing tag in edit mode, put it in the input for editing
|
||||
if (isEditing) {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { createOptimisticEntity, createOptimisticId } from "@/features/shared/optimistic";
|
||||
import { useActiveOperations } from "../../progress/hooks";
|
||||
import { progressKeys } from "../../progress/hooks/useProgressQueries";
|
||||
import type { ActiveOperation, ActiveOperationsResponse } from "../../progress/types";
|
||||
@@ -28,10 +29,7 @@ export const knowledgeKeys = {
|
||||
lists: () => [...knowledgeKeys.all, "list"] as const,
|
||||
detail: (id: string) => [...knowledgeKeys.all, "detail", id] as const,
|
||||
// Include domain + pagination to avoid cache collisions
|
||||
chunks: (
|
||||
id: string,
|
||||
opts?: { domain?: string; limit?: number; offset?: number },
|
||||
) =>
|
||||
chunks: (id: string, opts?: { domain?: string; limit?: number; offset?: number }) =>
|
||||
[
|
||||
...knowledgeKeys.all,
|
||||
id,
|
||||
@@ -65,7 +63,7 @@ export function useKnowledgeItem(sourceId: string | null) {
|
||||
*/
|
||||
export function useKnowledgeItemChunks(
|
||||
sourceId: string | null,
|
||||
opts?: { domain?: string; limit?: number; offset?: number }
|
||||
opts?: { domain?: string; limit?: number; offset?: number },
|
||||
) {
|
||||
// TODO: Phase 4 - Add explicit typing: useQuery<DocumentChunk[]> or appropriate return type
|
||||
// See PRPs/local/frontend-state-management-refactor.md Phase 4: Configure Request Deduplication
|
||||
@@ -138,13 +136,9 @@ export function useCrawlUrl() {
|
||||
});
|
||||
const previousOperations = queryClient.getQueryData<ActiveOperationsResponse>(progressKeys.active());
|
||||
|
||||
// Generate temporary IDs
|
||||
const tempProgressId = `temp-progress-${Date.now()}`;
|
||||
const tempItemId = `temp-item-${Date.now()}`;
|
||||
|
||||
// Create optimistic knowledge item
|
||||
const optimisticItem: KnowledgeItem = {
|
||||
id: tempItemId,
|
||||
// Generate temporary progress ID and optimistic entity
|
||||
const tempProgressId = createOptimisticId();
|
||||
const optimisticItem = createOptimisticEntity<KnowledgeItem>({
|
||||
title: (() => {
|
||||
try {
|
||||
return new URL(request.url).hostname || "New crawl";
|
||||
@@ -168,7 +162,8 @@ export function useCrawlUrl() {
|
||||
},
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
} as Omit<KnowledgeItem, "id">);
|
||||
const tempItemId = optimisticItem.id;
|
||||
|
||||
// Add optimistic knowledge item to the list
|
||||
queryClient.setQueryData<KnowledgeItem[]>(knowledgeKeys.lists(), (old) => {
|
||||
@@ -177,29 +172,31 @@ export function useCrawlUrl() {
|
||||
return [optimisticItem, ...old];
|
||||
});
|
||||
|
||||
// CRITICAL: Also add optimistic item to SUMMARIES cache (what the UI actually uses!)
|
||||
// This ensures the card shows up immediately in the knowledge base view
|
||||
// TODO: [Phase 3 - Optimistic Updates] Fix filter-blind optimistic updates
|
||||
// Currently adds items to ALL summary caches regardless of their filters (e.g., knowledge_type, tags).
|
||||
// This can cause items to appear in filtered views where they shouldn't be visible.
|
||||
// Solution: Check each cache's filter criteria before adding the optimistic item.
|
||||
// See: https://github.com/coleam00/Archon/pull/676#issuecomment-XXXXX
|
||||
queryClient.setQueriesData<KnowledgeItemsResponse>({ queryKey: knowledgeKeys.summariesPrefix() }, (old) => {
|
||||
// Respect each cache's filter (knowledge_type, tags, etc.)
|
||||
const entries = queryClient.getQueriesData<KnowledgeItemsResponse>({
|
||||
queryKey: knowledgeKeys.summariesPrefix(),
|
||||
});
|
||||
for (const [qk, old] of entries) {
|
||||
const filter = qk[qk.length - 1] as KnowledgeItemsFilter | undefined;
|
||||
const matchesType = !filter?.knowledge_type || optimisticItem.knowledge_type === filter.knowledge_type;
|
||||
const matchesTags =
|
||||
!filter?.tags || filter.tags.every((t) => (optimisticItem.metadata?.tags ?? []).includes(t));
|
||||
if (!(matchesType && matchesTags)) continue;
|
||||
if (!old) {
|
||||
return {
|
||||
queryClient.setQueryData<KnowledgeItemsResponse>(qk, {
|
||||
items: [optimisticItem],
|
||||
total: 1,
|
||||
page: 1,
|
||||
per_page: 100,
|
||||
pages: 1,
|
||||
};
|
||||
});
|
||||
} else {
|
||||
queryClient.setQueryData<KnowledgeItemsResponse>(qk, {
|
||||
...old,
|
||||
items: [optimisticItem, ...old.items],
|
||||
total: (old.total ?? old.items.length) + 1,
|
||||
});
|
||||
}
|
||||
return {
|
||||
...old,
|
||||
items: [optimisticItem, ...old.items],
|
||||
total: old.total + 1,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Create optimistic progress operation
|
||||
const optimisticOperation: ActiveOperation = {
|
||||
@@ -352,13 +349,10 @@ export function useUploadDocument() {
|
||||
});
|
||||
const previousOperations = queryClient.getQueryData<ActiveOperationsResponse>(progressKeys.active());
|
||||
|
||||
// Generate temporary IDs
|
||||
const tempProgressId = `temp-upload-${Date.now()}`;
|
||||
const tempItemId = `temp-item-${Date.now()}`;
|
||||
const tempProgressId = createOptimisticId();
|
||||
|
||||
// Create optimistic knowledge item for the upload
|
||||
const optimisticItem: KnowledgeItem = {
|
||||
id: tempItemId,
|
||||
const optimisticItem = createOptimisticEntity<KnowledgeItem>({
|
||||
title: file.name,
|
||||
url: `file://${file.name}`,
|
||||
source_id: tempProgressId,
|
||||
@@ -377,29 +371,34 @@ export function useUploadDocument() {
|
||||
},
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
} as Omit<KnowledgeItem, "id">);
|
||||
const tempItemId = optimisticItem.id;
|
||||
|
||||
// Add optimistic item to SUMMARIES cache (what the UI uses!)
|
||||
// TODO: [Phase 3 - Optimistic Updates] Fix filter-blind optimistic updates for uploads
|
||||
// Same issue as crawlUrl - adds items to ALL summary caches regardless of filters.
|
||||
// Should check filter criteria (knowledge_type, tags, etc.) before adding to each cache.
|
||||
// See: https://github.com/coleam00/Archon/pull/676#issuecomment-XXXXX
|
||||
queryClient.setQueriesData<KnowledgeItemsResponse>({ queryKey: knowledgeKeys.summariesPrefix() }, (old) => {
|
||||
// Respect each cache's filter (knowledge_type, tags, etc.)
|
||||
const entries = queryClient.getQueriesData<KnowledgeItemsResponse>({
|
||||
queryKey: knowledgeKeys.summariesPrefix(),
|
||||
});
|
||||
for (const [qk, old] of entries) {
|
||||
const filter = qk[qk.length - 1] as KnowledgeItemsFilter | undefined;
|
||||
const matchesType = !filter?.knowledge_type || optimisticItem.knowledge_type === filter.knowledge_type;
|
||||
const matchesTags =
|
||||
!filter?.tags || filter.tags.every((t) => (optimisticItem.metadata?.tags ?? []).includes(t));
|
||||
if (!(matchesType && matchesTags)) continue;
|
||||
if (!old) {
|
||||
return {
|
||||
queryClient.setQueryData<KnowledgeItemsResponse>(qk, {
|
||||
items: [optimisticItem],
|
||||
total: 1,
|
||||
page: 1,
|
||||
per_page: 100,
|
||||
pages: 1,
|
||||
};
|
||||
});
|
||||
} else {
|
||||
queryClient.setQueryData<KnowledgeItemsResponse>(qk, {
|
||||
...old,
|
||||
items: [optimisticItem, ...old.items],
|
||||
total: (old.total ?? old.items.length) + 1,
|
||||
});
|
||||
}
|
||||
return {
|
||||
...old,
|
||||
items: [optimisticItem, ...old.items],
|
||||
total: old.total + 1,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Create optimistic progress operation for upload
|
||||
const optimisticOperation: ActiveOperation = {
|
||||
@@ -554,10 +553,12 @@ export function useDeleteKnowledgeItem() {
|
||||
// Optimistically remove the item from each cached summary
|
||||
for (const [queryKey, data] of previousEntries) {
|
||||
if (!data) continue;
|
||||
const nextItems = data.items.filter((item) => item.source_id !== sourceId);
|
||||
const removed = data.items.length - nextItems.length;
|
||||
queryClient.setQueryData<KnowledgeItemsResponse>(queryKey, {
|
||||
...data,
|
||||
items: data.items.filter((item) => item.source_id !== sourceId),
|
||||
total: Math.max(0, (data.total ?? data.items.length) - 1),
|
||||
items: nextItems,
|
||||
total: Math.max(0, (data.total ?? data.items.length) - removed),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -771,9 +772,7 @@ export function useKnowledgeSummaries(filter?: KnowledgeItemsFilter) {
|
||||
}, [activeOperationsData]);
|
||||
|
||||
// Fetch summaries with smart polling when there are active operations
|
||||
const { refetchInterval } = useSmartPolling(
|
||||
hasActiveOperations ? STALE_TIMES.frequent : STALE_TIMES.normal,
|
||||
);
|
||||
const { refetchInterval } = useSmartPolling(hasActiveOperations ? STALE_TIMES.frequent : STALE_TIMES.normal);
|
||||
|
||||
const summaryQuery = useQuery<KnowledgeItemsResponse>({
|
||||
queryKey: knowledgeKeys.summaries(filter),
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { Activity, CheckCircle2, ListTodo } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { isOptimistic } from "../../shared/optimistic";
|
||||
import { OptimisticIndicator } from "../../ui/primitives/OptimisticIndicator";
|
||||
import { cn } from "../../ui/primitives/styles";
|
||||
import type { Project } from "../types";
|
||||
import { ProjectCardActions } from "./ProjectCardActions";
|
||||
@@ -27,6 +29,9 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
|
||||
onPin,
|
||||
onDelete,
|
||||
}) => {
|
||||
// Check if project is optimistic
|
||||
const optimistic = isOptimistic(project);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
tabIndex={0}
|
||||
@@ -59,6 +64,7 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
|
||||
: "shadow-[0_10px_30px_-15px_rgba(0,0,0,0.1)] dark:shadow-[0_10px_30px_-15px_rgba(0,0,0,0.7)]",
|
||||
"hover:shadow-[0_15px_40px_-15px_rgba(0,0,0,0.2)] dark:hover:shadow-[0_15px_40px_-15px_rgba(0,0,0,0.9)]",
|
||||
isSelected ? "scale-[1.02]" : "hover:scale-[1.01]", // Use scale instead of translate to avoid clipping
|
||||
optimistic && "opacity-80 ring-1 ring-cyan-400/30",
|
||||
)}
|
||||
>
|
||||
{/* Subtle aurora glow effect for selected card */}
|
||||
@@ -71,7 +77,7 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
|
||||
{/* Main content area with padding */}
|
||||
<div className="flex-1 p-4 pb-2">
|
||||
{/* Title section */}
|
||||
<div className="flex items-center justify-center mb-4 min-h-[48px]">
|
||||
<div className="flex flex-col items-center justify-center mb-4 min-h-[48px]">
|
||||
<h3
|
||||
className={cn(
|
||||
"font-medium text-center leading-tight line-clamp-2 transition-all duration-300",
|
||||
@@ -84,6 +90,7 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
|
||||
>
|
||||
{project.title}
|
||||
</h3>
|
||||
<OptimisticIndicator isOptimistic={optimistic} className="mt-1" />
|
||||
</div>
|
||||
|
||||
{/* Task count pills */}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
createOptimisticEntity,
|
||||
type OptimisticEntity,
|
||||
removeDuplicateEntities,
|
||||
replaceOptimisticEntity,
|
||||
} from "@/features/shared/optimistic";
|
||||
import { DISABLED_QUERY_KEY, STALE_TIMES } from "../../shared/queryPatterns";
|
||||
import { useSmartPolling } from "../../ui/hooks";
|
||||
import { useToast } from "../../ui/hooks/useToast";
|
||||
@@ -17,7 +23,7 @@ export const projectKeys = {
|
||||
|
||||
// Fetch all projects with smart polling
|
||||
export function useProjects() {
|
||||
const { refetchInterval } = useSmartPolling(20000); // 20 second base interval for projects
|
||||
const { refetchInterval } = useSmartPolling(2000); // 2 second base interval for active polling
|
||||
|
||||
return useQuery<Project[]>({
|
||||
queryKey: projectKeys.lists(),
|
||||
@@ -45,7 +51,12 @@ export function useCreateProject() {
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToast();
|
||||
|
||||
return useMutation({
|
||||
return useMutation<
|
||||
Awaited<ReturnType<typeof projectService.createProject>>,
|
||||
Error,
|
||||
CreateProjectRequest,
|
||||
{ previousProjects?: Project[]; optimisticId: string }
|
||||
>({
|
||||
mutationFn: (projectData: CreateProjectRequest) => projectService.createProject(projectData),
|
||||
onMutate: async (newProjectData) => {
|
||||
// Cancel any outgoing refetches
|
||||
@@ -54,21 +65,19 @@ export function useCreateProject() {
|
||||
// Snapshot the previous value
|
||||
const previousProjects = queryClient.getQueryData<Project[]>(projectKeys.lists());
|
||||
|
||||
// Create optimistic project with temporary ID
|
||||
const tempId = `temp-${Date.now()}`;
|
||||
const optimisticProject: Project = {
|
||||
id: tempId, // Temporary ID until real one comes back
|
||||
// Create optimistic project with stable ID
|
||||
const optimisticProject = createOptimisticEntity<Project>({
|
||||
title: newProjectData.title,
|
||||
description: newProjectData.description,
|
||||
github_repo: newProjectData.github_repo,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
prd: undefined,
|
||||
features: [],
|
||||
data: undefined,
|
||||
docs: [],
|
||||
features: [],
|
||||
prd: undefined,
|
||||
data: undefined,
|
||||
pinned: false,
|
||||
};
|
||||
});
|
||||
|
||||
// Optimistically add the new project
|
||||
queryClient.setQueryData(projectKeys.lists(), (old: Project[] | undefined) => {
|
||||
@@ -77,7 +86,7 @@ export function useCreateProject() {
|
||||
return [optimisticProject, ...old];
|
||||
});
|
||||
|
||||
return { previousProjects, tempId };
|
||||
return { previousProjects, optimisticId: optimisticProject._localId };
|
||||
},
|
||||
onError: (error, variables, context) => {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
@@ -94,17 +103,10 @@ export function useCreateProject() {
|
||||
// Extract the actual project from the response
|
||||
const newProject = response.project;
|
||||
|
||||
// Replace optimistic project with real one from server
|
||||
queryClient.setQueryData(projectKeys.lists(), (old: Project[] | undefined) => {
|
||||
if (!old) return [newProject];
|
||||
// Replace only the specific temp project with real one
|
||||
return old
|
||||
.map((project) => (project.id === context?.tempId ? newProject : project))
|
||||
.filter(
|
||||
(project, index, self) =>
|
||||
// Remove any duplicates just in case
|
||||
index === self.findIndex((p) => p.id === project.id),
|
||||
);
|
||||
// Replace optimistic with server data
|
||||
queryClient.setQueryData(projectKeys.lists(), (projects: (Project & Partial<OptimisticEntity>)[] = []) => {
|
||||
const replaced = replaceOptimisticEntity(projects, context?.optimisticId || "", newProject);
|
||||
return removeDuplicateEntities(replaced);
|
||||
});
|
||||
|
||||
showToast("Project created successfully!", "success");
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Tag } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { useCallback } from "react";
|
||||
import { useDrag, useDrop } from "react-dnd";
|
||||
import { isOptimistic } from "../../../shared/optimistic";
|
||||
import { OptimisticIndicator } from "../../../ui/primitives/OptimisticIndicator";
|
||||
import { useTaskActions } from "../hooks";
|
||||
import type { Assignee, Task, TaskPriority } from "../types";
|
||||
import { getOrderColor, getOrderGlow, ItemTypes } from "../utils/task-styles";
|
||||
@@ -34,6 +36,9 @@ export const TaskCard: React.FC<TaskCardProps> = ({
|
||||
selectedTasks,
|
||||
onTaskSelect,
|
||||
}) => {
|
||||
// Check if task is optimistic
|
||||
const optimistic = isOptimistic(task);
|
||||
|
||||
// Use business logic hook with changePriority
|
||||
const { changeAssignee, changePriority, isUpdating } = useTaskActions(projectId);
|
||||
|
||||
@@ -152,7 +157,7 @@ export const TaskCard: React.FC<TaskCardProps> = ({
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`${cardBaseStyles} ${transitionStyles} ${hoverEffectClasses} ${highlightGlow} ${selectionGlow} w-full min-h-[140px] h-full`}
|
||||
className={`${cardBaseStyles} ${transitionStyles} ${hoverEffectClasses} ${highlightGlow} ${selectionGlow} ${optimistic ? "opacity-80 ring-1 ring-cyan-400/30" : ""} w-full min-h-[140px] h-full`}
|
||||
>
|
||||
{/* Priority indicator with beautiful glow */}
|
||||
<div
|
||||
@@ -177,8 +182,11 @@ export const TaskCard: React.FC<TaskCardProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Optimistic indicator */}
|
||||
<OptimisticIndicator isOptimistic={optimistic} className="ml-auto" />
|
||||
|
||||
{/* Action buttons group */}
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<div className={`${optimistic ? "" : "ml-auto"} flex items-center gap-1.5`}>
|
||||
<TaskCardActions
|
||||
taskId={task.id}
|
||||
taskTitle={task.title}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { createOptimisticEntity, replaceOptimisticEntity, removeDuplicateEntities, type OptimisticEntity } from "@/features/shared/optimistic";
|
||||
import { DISABLED_QUERY_KEY, STALE_TIMES } from "../../../shared/queryPatterns";
|
||||
import { useSmartPolling } from "../../../ui/hooks";
|
||||
import { useToast } from "../../../ui/hooks/useToast";
|
||||
@@ -55,18 +56,21 @@ export function useCreateTask() {
|
||||
// Snapshot the previous value
|
||||
const previousTasks = queryClient.getQueryData<Task[]>(taskKeys.byProject(newTaskData.project_id));
|
||||
|
||||
// Create optimistic task with temporary ID
|
||||
const tempId = `temp-${Date.now()}`;
|
||||
const optimisticTask: Task = {
|
||||
id: tempId, // Temporary ID until real one comes back
|
||||
...newTaskData,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
// Ensure all required fields have defaults (let backend handle assignee default)
|
||||
task_order: newTaskData.task_order ?? 100,
|
||||
status: newTaskData.status ?? "todo",
|
||||
assignee: newTaskData.assignee ?? "User", // Keep for now as UI needs a value for optimistic update
|
||||
} as Task;
|
||||
// Create optimistic task with stable ID
|
||||
const optimisticTask = createOptimisticEntity<Task>(
|
||||
{
|
||||
project_id: newTaskData.project_id,
|
||||
title: newTaskData.title,
|
||||
description: newTaskData.description || "",
|
||||
status: newTaskData.status ?? "todo",
|
||||
assignee: newTaskData.assignee ?? "User",
|
||||
feature: newTaskData.feature,
|
||||
task_order: newTaskData.task_order ?? 100,
|
||||
priority: newTaskData.priority ?? "medium",
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
}
|
||||
);
|
||||
|
||||
// Optimistically add the new task
|
||||
queryClient.setQueryData(taskKeys.byProject(newTaskData.project_id), (old: Task[] | undefined) => {
|
||||
@@ -74,7 +78,7 @@ export function useCreateTask() {
|
||||
return [...old, optimisticTask];
|
||||
});
|
||||
|
||||
return { previousTasks, tempId };
|
||||
return { previousTasks, optimisticId: optimisticTask._localId };
|
||||
},
|
||||
onError: (error, variables, context) => {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
@@ -85,20 +89,21 @@ export function useCreateTask() {
|
||||
}
|
||||
showToast(`Failed to create task: ${errorMessage}`, "error");
|
||||
},
|
||||
onSuccess: (data, variables, context) => {
|
||||
// Replace optimistic task with real one from server
|
||||
queryClient.setQueryData(taskKeys.byProject(variables.project_id), (old: Task[] | undefined) => {
|
||||
if (!old) return [data];
|
||||
// Replace only the specific temp task with real one
|
||||
return old
|
||||
.map((task) => (task.id === context?.tempId ? data : task))
|
||||
.filter(
|
||||
(task, index, self) =>
|
||||
// Remove any duplicates just in case
|
||||
index === self.findIndex((t) => t.id === task.id),
|
||||
);
|
||||
onSuccess: (serverTask, variables, context) => {
|
||||
// Replace optimistic with server data
|
||||
queryClient.setQueryData(
|
||||
taskKeys.byProject(variables.project_id),
|
||||
(tasks: (Task & Partial<OptimisticEntity>)[] = []) => {
|
||||
const replaced = replaceOptimisticEntity(tasks, context?.optimisticId || "", serverTask);
|
||||
return removeDuplicateEntities(replaced);
|
||||
}
|
||||
);
|
||||
|
||||
// Invalidate counts since we have a new task
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: taskKeys.counts(),
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: taskKeys.counts() });
|
||||
|
||||
showToast("Task created successfully", "success");
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
|
||||
110
archon-ui-main/src/features/shared/optimistic.test.ts
Normal file
110
archon-ui-main/src/features/shared/optimistic.test.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
createOptimisticId,
|
||||
createOptimisticEntity,
|
||||
isOptimistic,
|
||||
replaceOptimisticEntity,
|
||||
removeDuplicateEntities,
|
||||
cleanOptimisticMetadata,
|
||||
} from "./optimistic";
|
||||
|
||||
describe("Optimistic Update Utilities", () => {
|
||||
describe("createOptimisticId", () => {
|
||||
it("should generate unique IDs", () => {
|
||||
const id1 = createOptimisticId();
|
||||
const id2 = createOptimisticId();
|
||||
expect(id1).not.toBe(id2);
|
||||
});
|
||||
|
||||
it("should generate valid nanoid format", () => {
|
||||
const id = createOptimisticId();
|
||||
expect(id).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||
expect(id.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createOptimisticEntity", () => {
|
||||
it("should create entity with optimistic metadata", () => {
|
||||
const entity = createOptimisticEntity<{ id: string; name: string }>({
|
||||
name: "Test Entity",
|
||||
});
|
||||
|
||||
expect(entity._optimistic).toBe(true);
|
||||
expect(entity._localId).toBeDefined();
|
||||
expect(entity.id).toBe(entity._localId);
|
||||
expect(entity.name).toBe("Test Entity");
|
||||
});
|
||||
|
||||
it("should apply additional defaults", () => {
|
||||
const entity = createOptimisticEntity<{ id: string; name: string; status: string }>(
|
||||
{ name: "Test" },
|
||||
{ status: "pending" }
|
||||
);
|
||||
|
||||
expect(entity.status).toBe("pending");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isOptimistic", () => {
|
||||
it("should identify optimistic entities", () => {
|
||||
const optimistic = { id: "123", _optimistic: true, _localId: "123" };
|
||||
const regular = { id: "456" };
|
||||
|
||||
expect(isOptimistic(optimistic)).toBe(true);
|
||||
expect(isOptimistic(regular)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("replaceOptimisticEntity", () => {
|
||||
it("should replace optimistic entity by localId", () => {
|
||||
const entities = [
|
||||
{ id: "1", name: "Entity 1" },
|
||||
{ id: "temp-123", name: "Optimistic", _optimistic: true, _localId: "temp-123" },
|
||||
{ id: "2", name: "Entity 2" },
|
||||
];
|
||||
|
||||
const serverEntity = { id: "real-id", name: "Server Entity" };
|
||||
const result = replaceOptimisticEntity(entities, "temp-123", serverEntity);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[1]).toEqual(serverEntity);
|
||||
expect(result[0].id).toBe("1");
|
||||
expect(result[2].id).toBe("2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeDuplicateEntities", () => {
|
||||
it("should remove duplicate entities by id", () => {
|
||||
const entities = [
|
||||
{ id: "1", name: "First" },
|
||||
{ id: "2", name: "Second" },
|
||||
{ id: "1", name: "Duplicate" },
|
||||
{ id: "3", name: "Third" },
|
||||
];
|
||||
|
||||
const result = removeDuplicateEntities(entities);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0].name).toBe("First"); // Keeps first occurrence
|
||||
expect(result[1].id).toBe("2");
|
||||
expect(result[2].id).toBe("3");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cleanOptimisticMetadata", () => {
|
||||
it("should remove optimistic metadata", () => {
|
||||
const entity = {
|
||||
id: "123",
|
||||
name: "Test",
|
||||
_optimistic: true,
|
||||
_localId: "temp-123",
|
||||
};
|
||||
|
||||
const cleaned = cleanOptimisticMetadata(entity);
|
||||
|
||||
expect(cleaned).toEqual({ id: "123", name: "Test" });
|
||||
expect("_optimistic" in cleaned).toBe(false);
|
||||
expect("_localId" in cleaned).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
82
archon-ui-main/src/features/shared/optimistic.ts
Normal file
82
archon-ui-main/src/features/shared/optimistic.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
/**
|
||||
* Interface for optimistic entities that haven't been persisted to the server yet
|
||||
*/
|
||||
export interface OptimisticEntity {
|
||||
/** Indicates this is an optimistic (client-side only) entity */
|
||||
_optimistic: boolean;
|
||||
/** Local ID for tracking during optimistic updates */
|
||||
_localId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if an entity is optimistic
|
||||
*/
|
||||
export function isOptimistic<T>(entity: T & Partial<OptimisticEntity>): entity is T & OptimisticEntity {
|
||||
return entity._optimistic === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a stable optimistic ID using nanoid
|
||||
*/
|
||||
export function createOptimisticId(): string {
|
||||
return nanoid();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an optimistic entity with proper metadata
|
||||
*/
|
||||
export function createOptimisticEntity<T extends { id: string }>(
|
||||
data: Omit<T, "id" | keyof OptimisticEntity>,
|
||||
additionalDefaults?: Partial<T>
|
||||
): T & OptimisticEntity {
|
||||
const optimisticId = createOptimisticId();
|
||||
return {
|
||||
...additionalDefaults,
|
||||
...data,
|
||||
id: optimisticId,
|
||||
_optimistic: true,
|
||||
_localId: optimisticId,
|
||||
} as T & OptimisticEntity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace an optimistic entity with the server response
|
||||
* Matches by _localId to handle race conditions
|
||||
*/
|
||||
export function replaceOptimisticEntity<T extends { id: string }>(
|
||||
entities: (T & Partial<OptimisticEntity>)[],
|
||||
localId: string,
|
||||
serverEntity: T
|
||||
): T[] {
|
||||
return entities.map((entity) => {
|
||||
if ("_localId" in entity && entity._localId === localId) {
|
||||
return serverEntity;
|
||||
}
|
||||
return entity;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove duplicate entities after optimistic replacement
|
||||
* Keeps the first occurrence of each unique ID
|
||||
*/
|
||||
export function removeDuplicateEntities<T extends { id: string }>(entities: T[]): T[] {
|
||||
const seen = new Set<string>();
|
||||
return entities.filter((entity) => {
|
||||
if (seen.has(entity.id)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(entity.id);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up optimistic metadata from an entity
|
||||
*/
|
||||
export function cleanOptimisticMetadata<T>(entity: T & Partial<OptimisticEntity>): T {
|
||||
const { _optimistic, _localId, ...cleaned } = entity;
|
||||
return cleaned as T;
|
||||
}
|
||||
@@ -86,7 +86,7 @@ describe("useSmartPolling", () => {
|
||||
expect(result.current.refetchInterval).toBe(5000);
|
||||
});
|
||||
|
||||
it("should slow down to 60 seconds when window loses focus", () => {
|
||||
it("should slow down to 5 seconds when window loses focus", () => {
|
||||
const { result } = renderHook(() => useSmartPolling(5000));
|
||||
|
||||
// Initially focused
|
||||
@@ -98,10 +98,10 @@ describe("useSmartPolling", () => {
|
||||
window.dispatchEvent(new Event("blur"));
|
||||
});
|
||||
|
||||
// Should be slowed down to 60 seconds
|
||||
// Should be slowed down to 5 seconds for background polling
|
||||
expect(result.current.hasFocus).toBe(false);
|
||||
expect(result.current.isActive).toBe(false);
|
||||
expect(result.current.refetchInterval).toBe(60000);
|
||||
expect(result.current.refetchInterval).toBe(5000);
|
||||
});
|
||||
|
||||
it("should resume normal speed when window regains focus", () => {
|
||||
@@ -112,7 +112,7 @@ describe("useSmartPolling", () => {
|
||||
window.dispatchEvent(new Event("blur"));
|
||||
});
|
||||
|
||||
expect(result.current.refetchInterval).toBe(60000);
|
||||
expect(result.current.refetchInterval).toBe(5000);
|
||||
|
||||
// Focus window again
|
||||
act(() => {
|
||||
@@ -131,13 +131,13 @@ describe("useSmartPolling", () => {
|
||||
expect(result1.current.refetchInterval).toBe(1000);
|
||||
expect(result2.current.refetchInterval).toBe(10000);
|
||||
|
||||
// When blurred, both should be 60 seconds
|
||||
// When blurred, both should be 5 seconds for background polling
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("blur"));
|
||||
});
|
||||
|
||||
expect(result1.current.refetchInterval).toBe(60000);
|
||||
expect(result2.current.refetchInterval).toBe(60000);
|
||||
expect(result1.current.refetchInterval).toBe(5000);
|
||||
expect(result2.current.refetchInterval).toBe(5000);
|
||||
});
|
||||
|
||||
it("should use default interval of 10000ms when not specified", () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AlertCircle, CheckCircle, Info, XCircle } from "lucide-react";
|
||||
import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
|
||||
import { createOptimisticId } from "../../shared/optimistic";
|
||||
|
||||
// Toast types
|
||||
interface Toast {
|
||||
@@ -34,15 +35,12 @@ export function useToast() {
|
||||
* Create toast context value with state management
|
||||
* Used internally by ToastProvider component
|
||||
*/
|
||||
// Counter for ensuring unique IDs even when created in same millisecond
|
||||
let toastIdCounter = 0;
|
||||
|
||||
export function createToastContext() {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
const timeoutsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
||||
|
||||
const showToast = useCallback((message: string, type: Toast["type"] = "info", duration = 4000) => {
|
||||
const id = `${Date.now()}-${toastIdCounter++}`;
|
||||
const id = createOptimisticId();
|
||||
const newToast: Toast = { id, message, type, duration };
|
||||
|
||||
setToasts((prev) => [...prev, newToast]);
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Loader2 } from "lucide-react";
|
||||
import type { ComponentType } from "react";
|
||||
import { cn } from "./styles";
|
||||
|
||||
interface OptimisticIndicatorProps {
|
||||
isOptimistic: boolean;
|
||||
className?: string;
|
||||
showSpinner?: boolean;
|
||||
pulseAnimation?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Visual indicator for optimistic updates
|
||||
* Shows a subtle animation and optional spinner for pending items
|
||||
*/
|
||||
export function OptimisticIndicator({
|
||||
isOptimistic,
|
||||
className,
|
||||
showSpinner = true,
|
||||
pulseAnimation = true,
|
||||
}: OptimisticIndicatorProps) {
|
||||
if (!isOptimistic) return null;
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2", className)}>
|
||||
{showSpinner && <Loader2 className="h-3 w-3 animate-spin text-cyan-400/70" />}
|
||||
{pulseAnimation && <span className="text-xs text-cyan-400/50 animate-pulse">Saving...</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* HOC to wrap components with optimistic styling
|
||||
*/
|
||||
export function withOptimisticStyles<T extends { className?: string }>(
|
||||
Component: ComponentType<T>,
|
||||
isOptimistic: boolean,
|
||||
) {
|
||||
return (props: T) => (
|
||||
<Component
|
||||
{...props}
|
||||
className={cn(props.className, isOptimistic && "opacity-70 animate-pulse ring-1 ring-cyan-400/20")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user