refactor: Phase 5 - Remove manual cache invalidations (#707)
* chore, cleanup leftovers of tanstack refactoring * refactor: Complete Phase 5 - Remove manual cache invalidations - Removed all manual cache invalidations from knowledge queries - Updated task queries to rely on backend consistency - Fixed optimistic update utilities to handle edge cases - Cleaned up unused imports and test utilities - Fixed minor TypeScript issues in UI components Backend now ensures data consistency through proper transaction handling, eliminating the need for frontend cache coordination. * docs: Enhance TODO comment for knowledge optimistic update issue - Added comprehensive explanation of the query key mismatch issue - Documented current behavior and impact on user experience - Listed potential solutions with tradeoffs - Created detailed PRP story in PRPs/local/ for future implementation - References specific line numbers and implementation details This documents a known limitation where optimistic updates to knowledge items are invisible because mutations update the wrong query cache.
This commit is contained in:
parent
1b272ed2af
commit
37994191fc
2
.gitignore
vendored
2
.gitignore
vendored
@ -10,3 +10,5 @@ PRPs/stories/
|
|||||||
tmp/
|
tmp/
|
||||||
temp/
|
temp/
|
||||||
UAT/
|
UAT/
|
||||||
|
|
||||||
|
.DS_Store
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { createOptimisticEntity, createOptimisticId } from "@/features/shared/optimistic";
|
import { createOptimisticEntity, createOptimisticId } from "@/features/shared/optimistic";
|
||||||
import { useActiveOperations } from "../../progress/hooks";
|
import { useActiveOperations } from "../../progress/hooks";
|
||||||
import { progressKeys } from "../../progress/hooks/useProgressQueries";
|
import { progressKeys } from "../../progress/hooks/useProgressQueries";
|
||||||
@ -65,7 +65,6 @@ export function useKnowledgeItemChunks(
|
|||||||
sourceId: string | null,
|
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
|
// See PRPs/local/frontend-state-management-refactor.md Phase 4: Configure Request Deduplication
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: sourceId ? knowledgeKeys.chunks(sourceId, opts) : DISABLED_QUERY_KEY,
|
queryKey: sourceId ? knowledgeKeys.chunks(sourceId, opts) : DISABLED_QUERY_KEY,
|
||||||
@ -116,21 +115,29 @@ export function useCrawlUrl() {
|
|||||||
>({
|
>({
|
||||||
mutationFn: (request: CrawlRequest) => knowledgeService.crawlUrl(request),
|
mutationFn: (request: CrawlRequest) => knowledgeService.crawlUrl(request),
|
||||||
onMutate: async (request) => {
|
onMutate: async (request) => {
|
||||||
// TODO: Phase 3 - Fix optimistic updates writing to wrong cache
|
|
||||||
// knowledgeKeys.lists() is never queried - actual data comes from knowledgeKeys.summaries(filter)
|
|
||||||
// This makes all optimistic updates invisible. Should either:
|
|
||||||
// 1. Remove optimistic updates for knowledge items
|
|
||||||
// 2. Update all summary caches with optimistic data
|
|
||||||
// 3. Create a real query that uses lists()
|
|
||||||
// See: PRPs/local/frontend-state-management-refactor.md Phase 3
|
|
||||||
|
|
||||||
// Cancel any outgoing refetches to prevent race conditions
|
// Cancel any outgoing refetches to prevent race conditions
|
||||||
await queryClient.cancelQueries({ queryKey: knowledgeKeys.lists() });
|
|
||||||
await queryClient.cancelQueries({ queryKey: knowledgeKeys.summariesPrefix() });
|
await queryClient.cancelQueries({ queryKey: knowledgeKeys.summariesPrefix() });
|
||||||
await queryClient.cancelQueries({ queryKey: progressKeys.active() });
|
await queryClient.cancelQueries({ queryKey: progressKeys.active() });
|
||||||
|
|
||||||
|
// TODO: Fix invisible optimistic updates
|
||||||
|
// ISSUE: Optimistic updates are applied to knowledgeKeys.summaries(filter) queries,
|
||||||
|
// but the UI component (KnowledgeView) queries with dynamic filters that we don't have access to here.
|
||||||
|
// This means optimistic updates only work if the filter happens to match what's being viewed.
|
||||||
|
//
|
||||||
|
// CURRENT BEHAVIOR:
|
||||||
|
// - We update all cached summaries queries (lines 158-179 below)
|
||||||
|
// - BUT if the user changes filters after mutation starts, they won't see the optimistic update
|
||||||
|
// - AND we have no way to know what filter the user is currently viewing
|
||||||
|
//
|
||||||
|
// PROPER FIX requires one of:
|
||||||
|
// 1. Pass current filter from KnowledgeView to mutation hooks (prop drilling)
|
||||||
|
// 2. Create KnowledgeFilterContext to share filter state
|
||||||
|
// 3. Restructure to have a single source of truth query key like other features
|
||||||
|
//
|
||||||
|
// IMPACT: Users don't see immediate feedback when adding knowledge items - items only
|
||||||
|
// appear after the server responds (usually 1-3 seconds later)
|
||||||
|
|
||||||
// Snapshot the previous values for rollback
|
// Snapshot the previous values for rollback
|
||||||
const previousKnowledge = queryClient.getQueryData<KnowledgeItem[]>(knowledgeKeys.lists());
|
|
||||||
const previousSummaries = queryClient.getQueriesData<KnowledgeItemsResponse>({
|
const previousSummaries = queryClient.getQueriesData<KnowledgeItemsResponse>({
|
||||||
queryKey: knowledgeKeys.summariesPrefix(),
|
queryKey: knowledgeKeys.summariesPrefix(),
|
||||||
});
|
});
|
||||||
@ -165,14 +172,7 @@ export function useCrawlUrl() {
|
|||||||
} as Omit<KnowledgeItem, "id">);
|
} as Omit<KnowledgeItem, "id">);
|
||||||
const tempItemId = optimisticItem.id;
|
const tempItemId = optimisticItem.id;
|
||||||
|
|
||||||
// Add optimistic knowledge item to the list
|
// Update all summaries caches with optimistic data, respecting each cache's filter
|
||||||
queryClient.setQueryData<KnowledgeItem[]>(knowledgeKeys.lists(), (old) => {
|
|
||||||
if (!old) return [optimisticItem];
|
|
||||||
// Add at the beginning for visibility
|
|
||||||
return [optimisticItem, ...old];
|
|
||||||
});
|
|
||||||
|
|
||||||
// Respect each cache's filter (knowledge_type, tags, etc.)
|
|
||||||
const entries = queryClient.getQueriesData<KnowledgeItemsResponse>({
|
const entries = queryClient.getQueriesData<KnowledgeItemsResponse>({
|
||||||
queryKey: knowledgeKeys.summariesPrefix(),
|
queryKey: knowledgeKeys.summariesPrefix(),
|
||||||
});
|
});
|
||||||
@ -229,28 +229,12 @@ export function useCrawlUrl() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Return context for rollback and replacement
|
// Return context for rollback and replacement
|
||||||
return { previousKnowledge, previousSummaries, previousOperations, tempProgressId, tempItemId };
|
return { previousSummaries, previousOperations, tempProgressId, tempItemId };
|
||||||
},
|
},
|
||||||
onSuccess: (response, _variables, context) => {
|
onSuccess: (response, _variables, context) => {
|
||||||
// Replace temporary IDs with real ones from the server
|
// Replace temporary IDs with real ones from the server
|
||||||
if (context) {
|
if (context) {
|
||||||
// Update knowledge item with real source_id if we get it
|
// Update summaries cache with real progress ID
|
||||||
queryClient.setQueryData<KnowledgeItem[]>(knowledgeKeys.lists(), (old) => {
|
|
||||||
if (!old) return old;
|
|
||||||
return old.map((item) => {
|
|
||||||
if (item.id === context.tempItemId) {
|
|
||||||
// Update with real progress ID, but keep the optimistic item
|
|
||||||
// The real item will come through polling/invalidation
|
|
||||||
return {
|
|
||||||
...item,
|
|
||||||
source_id: response.progressId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return item;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Also update summaries cache with real progress ID
|
|
||||||
queryClient.setQueriesData<KnowledgeItemsResponse>({ queryKey: knowledgeKeys.summariesPrefix() }, (old) => {
|
queryClient.setQueriesData<KnowledgeItemsResponse>({ queryKey: knowledgeKeys.summariesPrefix() }, (old) => {
|
||||||
if (!old) return old;
|
if (!old) return old;
|
||||||
return {
|
return {
|
||||||
@ -289,7 +273,6 @@ export function useCrawlUrl() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Invalidate to get fresh data
|
// Invalidate to get fresh data
|
||||||
queryClient.invalidateQueries({ queryKey: knowledgeKeys.lists() });
|
|
||||||
queryClient.invalidateQueries({ queryKey: progressKeys.active() });
|
queryClient.invalidateQueries({ queryKey: progressKeys.active() });
|
||||||
|
|
||||||
showToast(`Crawl started: ${response.message}`, "success");
|
showToast(`Crawl started: ${response.message}`, "success");
|
||||||
@ -299,9 +282,6 @@ export function useCrawlUrl() {
|
|||||||
},
|
},
|
||||||
onError: (error, _variables, context) => {
|
onError: (error, _variables, context) => {
|
||||||
// Rollback optimistic updates on error
|
// Rollback optimistic updates on error
|
||||||
if (context?.previousKnowledge) {
|
|
||||||
queryClient.setQueryData(knowledgeKeys.lists(), context.previousKnowledge);
|
|
||||||
}
|
|
||||||
if (context?.previousSummaries) {
|
if (context?.previousSummaries) {
|
||||||
// Rollback all summary queries
|
// Rollback all summary queries
|
||||||
for (const [queryKey, data] of context.previousSummaries) {
|
for (const [queryKey, data] of context.previousSummaries) {
|
||||||
@ -473,12 +453,9 @@ export function useUploadDocument() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invalidate queries to get fresh data - with a short delay for fast uploads
|
// Only invalidate progress to start tracking the new operation
|
||||||
setTimeout(() => {
|
// The lists/summaries will refresh automatically via polling when operations are active
|
||||||
queryClient.invalidateQueries({ queryKey: knowledgeKeys.lists() });
|
|
||||||
queryClient.invalidateQueries({ queryKey: knowledgeKeys.summariesPrefix() });
|
|
||||||
queryClient.invalidateQueries({ queryKey: progressKeys.active() });
|
queryClient.invalidateQueries({ queryKey: progressKeys.active() });
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
// Don't show success here - upload is just starting in background
|
// Don't show success here - upload is just starting in background
|
||||||
// Success/failure will be shown via progress polling
|
// Success/failure will be shown via progress polling
|
||||||
@ -514,7 +491,6 @@ export function useStopCrawl() {
|
|||||||
},
|
},
|
||||||
onError: (error, progressId) => {
|
onError: (error, progressId) => {
|
||||||
// If it's a 404, the operation might have already completed or been cancelled
|
// If it's a 404, the operation might have already completed or been cancelled
|
||||||
// TODO: Phase 4 - Improve error type safety, create proper error interface instead of 'as any'
|
|
||||||
// See PRPs/local/frontend-state-management-refactor.md Phase 4: Configure Request Deduplication
|
// See PRPs/local/frontend-state-management-refactor.md Phase 4: Configure Request Deduplication
|
||||||
const is404Error =
|
const is404Error =
|
||||||
(error as any)?.statusCode === 404 ||
|
(error as any)?.statusCode === 404 ||
|
||||||
@ -705,8 +681,7 @@ export function useUpdateKnowledgeItem() {
|
|||||||
|
|
||||||
// Invalidate all related queries
|
// Invalidate all related queries
|
||||||
queryClient.invalidateQueries({ queryKey: knowledgeKeys.detail(sourceId) });
|
queryClient.invalidateQueries({ queryKey: knowledgeKeys.detail(sourceId) });
|
||||||
queryClient.invalidateQueries({ queryKey: knowledgeKeys.lists() });
|
queryClient.invalidateQueries({ queryKey: knowledgeKeys.summariesPrefix() });
|
||||||
queryClient.invalidateQueries({ queryKey: knowledgeKeys.summariesPrefix() }); // Add summaries cache
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -726,10 +701,8 @@ export function useRefreshKnowledgeItem() {
|
|||||||
// Remove the item from cache as it's being refreshed
|
// Remove the item from cache as it's being refreshed
|
||||||
queryClient.removeQueries({ queryKey: knowledgeKeys.detail(sourceId) });
|
queryClient.removeQueries({ queryKey: knowledgeKeys.detail(sourceId) });
|
||||||
|
|
||||||
// Invalidate list after a delay
|
// Invalidate summaries immediately - backend is consistent after refresh initiation
|
||||||
setTimeout(() => {
|
queryClient.invalidateQueries({ queryKey: knowledgeKeys.summariesPrefix() });
|
||||||
queryClient.invalidateQueries({ queryKey: knowledgeKeys.lists() });
|
|
||||||
}, 5000);
|
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
@ -746,8 +719,6 @@ export function useRefreshKnowledgeItem() {
|
|||||||
* Only polls when there are active operations that we started
|
* Only polls when there are active operations that we started
|
||||||
*/
|
*/
|
||||||
export function useKnowledgeSummaries(filter?: KnowledgeItemsFilter) {
|
export function useKnowledgeSummaries(filter?: KnowledgeItemsFilter) {
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
// Track active crawl IDs locally - only set when we start a crawl/refresh
|
// Track active crawl IDs locally - only set when we start a crawl/refresh
|
||||||
const [activeCrawlIds, setActiveCrawlIds] = useState<string[]>([]);
|
const [activeCrawlIds, setActiveCrawlIds] = useState<string[]>([]);
|
||||||
|
|
||||||
@ -783,37 +754,8 @@ export function useKnowledgeSummaries(filter?: KnowledgeItemsFilter) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// When operations complete, remove them from tracking
|
// When operations complete, remove them from tracking
|
||||||
useEffect(() => {
|
// Trust smart polling to handle eventual consistency - no manual invalidation needed
|
||||||
const completedOps = activeOperations.filter(
|
// Active operations are already tracked and polling handles updates when operations complete
|
||||||
(op) => op.status === "completed" || op.status === "failed" || op.status === "error",
|
|
||||||
);
|
|
||||||
|
|
||||||
if (completedOps.length > 0) {
|
|
||||||
// Remove completed operations from tracking
|
|
||||||
setActiveCrawlIds((prev) => prev.filter((id) => !completedOps.some((op) => op.progressId === id)));
|
|
||||||
|
|
||||||
// Check if any completed operations are uploads (they complete faster)
|
|
||||||
const hasCompletedUpload = completedOps.some((op) => op.operation_type === "upload" || op.type === "upload");
|
|
||||||
|
|
||||||
// Use shorter delay for uploads (1s) vs crawls (5s) to handle fast operations
|
|
||||||
const delay = hasCompletedUpload ? 1000 : 5000;
|
|
||||||
|
|
||||||
// Invalidate after a delay to allow backend database to become consistent
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
// Invalidate all summaries regardless of filter
|
|
||||||
queryClient.invalidateQueries({ queryKey: knowledgeKeys.summariesPrefix() });
|
|
||||||
// Also invalidate lists for consistency
|
|
||||||
queryClient.invalidateQueries({ queryKey: knowledgeKeys.lists() });
|
|
||||||
|
|
||||||
// For uploads, also refetch immediately to ensure UI shows the item
|
|
||||||
if (hasCompletedUpload) {
|
|
||||||
queryClient.refetchQueries({ queryKey: knowledgeKeys.summariesPrefix() });
|
|
||||||
}
|
|
||||||
}, delay);
|
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}, [activeOperations, queryClient]);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...summaryQuery,
|
...summaryQuery,
|
||||||
|
|||||||
@ -1,5 +1,10 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { createOptimisticEntity, replaceOptimisticEntity, removeDuplicateEntities, type OptimisticEntity } from "@/features/shared/optimistic";
|
import {
|
||||||
|
createOptimisticEntity,
|
||||||
|
replaceOptimisticEntity,
|
||||||
|
removeDuplicateEntities,
|
||||||
|
type OptimisticEntity,
|
||||||
|
} from "@/features/shared/optimistic";
|
||||||
import { DISABLED_QUERY_KEY, STALE_TIMES } from "../../../shared/queryPatterns";
|
import { DISABLED_QUERY_KEY, STALE_TIMES } from "../../../shared/queryPatterns";
|
||||||
import { useSmartPolling } from "../../../ui/hooks";
|
import { useSmartPolling } from "../../../ui/hooks";
|
||||||
import { useToast } from "../../../ui/hooks/useToast";
|
import { useToast } from "../../../ui/hooks/useToast";
|
||||||
@ -58,8 +63,7 @@ export function useCreateTask() {
|
|||||||
const previousTasks = queryClient.getQueryData<Task[]>(taskKeys.byProject(newTaskData.project_id));
|
const previousTasks = queryClient.getQueryData<Task[]>(taskKeys.byProject(newTaskData.project_id));
|
||||||
|
|
||||||
// Create optimistic task with stable ID
|
// Create optimistic task with stable ID
|
||||||
const optimisticTask = createOptimisticEntity<Task>(
|
const optimisticTask = createOptimisticEntity<Task>({
|
||||||
{
|
|
||||||
project_id: newTaskData.project_id,
|
project_id: newTaskData.project_id,
|
||||||
title: newTaskData.title,
|
title: newTaskData.title,
|
||||||
description: newTaskData.description || "",
|
description: newTaskData.description || "",
|
||||||
@ -70,8 +74,7 @@ export function useCreateTask() {
|
|||||||
priority: newTaskData.priority ?? "medium",
|
priority: newTaskData.priority ?? "medium",
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
updated_at: new Date().toISOString(),
|
updated_at: new Date().toISOString(),
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
// Optimistically add the new task
|
// Optimistically add the new task
|
||||||
queryClient.setQueryData(taskKeys.byProject(newTaskData.project_id), (old: Task[] | undefined) => {
|
queryClient.setQueryData(taskKeys.byProject(newTaskData.project_id), (old: Task[] | undefined) => {
|
||||||
@ -99,7 +102,7 @@ export function useCreateTask() {
|
|||||||
(tasks: (Task & Partial<OptimisticEntity>)[] = []) => {
|
(tasks: (Task & Partial<OptimisticEntity>)[] = []) => {
|
||||||
const replaced = replaceOptimisticEntity(tasks, context?.optimisticId || "", serverTask);
|
const replaced = replaceOptimisticEntity(tasks, context?.optimisticId || "", serverTask);
|
||||||
return removeDuplicateEntities(replaced);
|
return removeDuplicateEntities(replaced);
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Invalidate counts since we have a new task
|
// Invalidate counts since we have a new task
|
||||||
|
|||||||
@ -38,7 +38,7 @@ describe("Optimistic Update Utilities", () => {
|
|||||||
it("should apply additional defaults", () => {
|
it("should apply additional defaults", () => {
|
||||||
const entity = createOptimisticEntity<{ id: string; name: string; status: string }>(
|
const entity = createOptimisticEntity<{ id: string; name: string; status: string }>(
|
||||||
{ name: "Test" },
|
{ name: "Test" },
|
||||||
{ status: "pending" }
|
{ status: "pending" },
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(entity.status).toBe("pending");
|
expect(entity.status).toBe("pending");
|
||||||
|
|||||||
@ -29,7 +29,7 @@ export function createOptimisticId(): string {
|
|||||||
*/
|
*/
|
||||||
export function createOptimisticEntity<T extends { id: string }>(
|
export function createOptimisticEntity<T extends { id: string }>(
|
||||||
data: Omit<T, "id" | keyof OptimisticEntity>,
|
data: Omit<T, "id" | keyof OptimisticEntity>,
|
||||||
additionalDefaults?: Partial<T>
|
additionalDefaults?: Partial<T>,
|
||||||
): T & OptimisticEntity {
|
): T & OptimisticEntity {
|
||||||
const optimisticId = createOptimisticId();
|
const optimisticId = createOptimisticId();
|
||||||
return {
|
return {
|
||||||
@ -48,7 +48,7 @@ export function createOptimisticEntity<T extends { id: string }>(
|
|||||||
export function replaceOptimisticEntity<T extends { id: string }>(
|
export function replaceOptimisticEntity<T extends { id: string }>(
|
||||||
entities: (T & Partial<OptimisticEntity>)[],
|
entities: (T & Partial<OptimisticEntity>)[],
|
||||||
localId: string,
|
localId: string,
|
||||||
serverEntity: T
|
serverEntity: T,
|
||||||
): T[] {
|
): T[] {
|
||||||
return entities.map((entity) => {
|
return entities.map((entity) => {
|
||||||
if ("_localId" in entity && entity._localId === localId) {
|
if ("_localId" in entity && entity._localId === localId) {
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
export interface ClipboardResult {
|
export interface ClipboardResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
method: 'clipboard-api' | 'execCommand' | 'failed';
|
method: "clipboard-api" | "execCommand" | "failed";
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -16,26 +16,22 @@ export interface ClipboardResult {
|
|||||||
*/
|
*/
|
||||||
export const copyToClipboard = async (text: string): Promise<ClipboardResult> => {
|
export const copyToClipboard = async (text: string): Promise<ClipboardResult> => {
|
||||||
// Try modern clipboard API first with SSR-safe guards
|
// Try modern clipboard API first with SSR-safe guards
|
||||||
if (
|
if (typeof navigator !== "undefined" && navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
typeof navigator !== 'undefined' &&
|
|
||||||
navigator.clipboard &&
|
|
||||||
navigator.clipboard.writeText
|
|
||||||
) {
|
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(text);
|
await navigator.clipboard.writeText(text);
|
||||||
return { success: true, method: 'clipboard-api' };
|
return { success: true, method: "clipboard-api" };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Clipboard API failed, trying fallback:', error);
|
console.warn("Clipboard API failed, trying fallback:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to document.execCommand for older browsers or insecure contexts
|
// Fallback to document.execCommand for older browsers or insecure contexts
|
||||||
// Add SSR guards for document access
|
// Add SSR guards for document access
|
||||||
if (typeof document === 'undefined') {
|
if (typeof document === "undefined") {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
method: 'failed',
|
method: "failed",
|
||||||
error: 'Running in server-side environment - clipboard not available'
|
error: "Running in server-side environment - clipboard not available",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -45,41 +41,41 @@ export const copyToClipboard = async (text: string): Promise<ClipboardResult> =>
|
|||||||
if (!document.body) {
|
if (!document.body) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
method: 'failed',
|
method: "failed",
|
||||||
error: 'document.body is not available'
|
error: "document.body is not available",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
textarea = document.createElement('textarea');
|
textarea = document.createElement("textarea");
|
||||||
textarea.value = text;
|
textarea.value = text;
|
||||||
textarea.style.position = 'fixed';
|
textarea.style.position = "fixed";
|
||||||
textarea.style.top = '-9999px';
|
textarea.style.top = "-9999px";
|
||||||
textarea.style.left = '-9999px';
|
textarea.style.left = "-9999px";
|
||||||
textarea.style.opacity = '0';
|
textarea.style.opacity = "0";
|
||||||
textarea.style.pointerEvents = 'none';
|
textarea.style.pointerEvents = "none";
|
||||||
textarea.setAttribute('readonly', '');
|
textarea.setAttribute("readonly", "");
|
||||||
textarea.setAttribute('aria-hidden', 'true');
|
textarea.setAttribute("aria-hidden", "true");
|
||||||
|
|
||||||
document.body.appendChild(textarea);
|
document.body.appendChild(textarea);
|
||||||
textarea.select();
|
textarea.select();
|
||||||
textarea.setSelectionRange(0, text.length);
|
textarea.setSelectionRange(0, text.length);
|
||||||
|
|
||||||
const success = document.execCommand('copy');
|
const success = document.execCommand("copy");
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
return { success: true, method: 'execCommand' };
|
return { success: true, method: "execCommand" };
|
||||||
} else {
|
} else {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
method: 'failed',
|
method: "failed",
|
||||||
error: 'execCommand copy returned false'
|
error: "execCommand copy returned false",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
method: 'failed',
|
method: "failed",
|
||||||
error: error instanceof Error ? error.message : 'Unknown error'
|
error: error instanceof Error ? error.message : "Unknown error",
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
// Always clean up the textarea element if it was created and added to DOM
|
// Always clean up the textarea element if it was created and added to DOM
|
||||||
@ -88,7 +84,7 @@ export const copyToClipboard = async (text: string): Promise<ClipboardResult> =>
|
|||||||
document.body.removeChild(textarea);
|
document.body.removeChild(textarea);
|
||||||
} catch (cleanupError) {
|
} catch (cleanupError) {
|
||||||
// Ignore cleanup errors - element may have already been removed
|
// Ignore cleanup errors - element may have already been removed
|
||||||
console.warn('Failed to cleanup textarea element:', cleanupError);
|
console.warn("Failed to cleanup textarea element:", cleanupError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -101,20 +97,17 @@ export const copyToClipboard = async (text: string): Promise<ClipboardResult> =>
|
|||||||
export const isClipboardSupported = (): boolean => {
|
export const isClipboardSupported = (): boolean => {
|
||||||
// Check modern clipboard API with proper SSR guards
|
// Check modern clipboard API with proper SSR guards
|
||||||
if (
|
if (
|
||||||
typeof navigator !== 'undefined' &&
|
typeof navigator !== "undefined" &&
|
||||||
typeof navigator.clipboard !== 'undefined' &&
|
typeof navigator.clipboard !== "undefined" &&
|
||||||
typeof navigator.clipboard.writeText === 'function'
|
typeof navigator.clipboard.writeText === "function"
|
||||||
) {
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check execCommand fallback with SSR guards
|
// Check execCommand fallback with SSR guards
|
||||||
if (
|
if (typeof document !== "undefined" && typeof document.queryCommandSupported === "function") {
|
||||||
typeof document !== 'undefined' &&
|
|
||||||
typeof document.queryCommandSupported === 'function'
|
|
||||||
) {
|
|
||||||
try {
|
try {
|
||||||
return document.queryCommandSupported('copy');
|
return document.queryCommandSupported("copy");
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -129,11 +122,9 @@ export const isClipboardSupported = (): boolean => {
|
|||||||
* @returns string - Description of current security context
|
* @returns string - Description of current security context
|
||||||
*/
|
*/
|
||||||
export const getSecurityContext = (): string => {
|
export const getSecurityContext = (): string => {
|
||||||
if (typeof window === 'undefined') return 'server';
|
if (typeof window === "undefined") return "server";
|
||||||
if (window.isSecureContext) return 'secure';
|
if (window.isSecureContext) return "secure";
|
||||||
if (window.location.protocol === 'https:') return 'https';
|
if (window.location.protocol === "https:") return "https";
|
||||||
if (window.location.hostname === 'localhost' ||
|
if (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1") return "localhost";
|
||||||
window.location.hostname === '127.0.0.1') return 'localhost';
|
return "insecure";
|
||||||
return 'insecure';
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -11,10 +11,7 @@ import { TooltipProvider } from "../ui/primitives/tooltip";
|
|||||||
*/
|
*/
|
||||||
export function renderWithProviders(
|
export function renderWithProviders(
|
||||||
ui: React.ReactElement,
|
ui: React.ReactElement,
|
||||||
{
|
{ queryClient = createTestQueryClient(), ...renderOptions } = {},
|
||||||
queryClient = createTestQueryClient(),
|
|
||||||
...renderOptions
|
|
||||||
} = {},
|
|
||||||
) {
|
) {
|
||||||
function Wrapper({ children }: { children: React.ReactNode }) {
|
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -247,11 +247,11 @@ export const ComboBox = React.forwardRef<HTMLButtonElement, ComboBoxProps>(
|
|||||||
aria-autocomplete="list"
|
aria-autocomplete="list"
|
||||||
aria-activedescendant={
|
aria-activedescendant={
|
||||||
open
|
open
|
||||||
? (hasCustomOption && highlightedIndex === filteredOptions.length
|
? hasCustomOption && highlightedIndex === filteredOptions.length
|
||||||
? `${listboxId}-custom`
|
? `${listboxId}-custom`
|
||||||
: highlightedIndex < filteredOptions.length
|
: highlightedIndex < filteredOptions.length
|
||||||
? `${listboxId}-opt-${highlightedIndex}`
|
? `${listboxId}-opt-${highlightedIndex}`
|
||||||
: undefined)
|
: undefined
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
value={search}
|
value={search}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user