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
75 changes: 57 additions & 18 deletions packages/ts-client/src/hooks/useNormalizedQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -549,6 +549,17 @@ export function updateBatchResources<O>(

// Apply client-side filtering (safety fallback)
const filteredResources = filterBatchResources(resources, options);
const filteredResourceIds = new Set(
filteredResources.map((resource) => resource.id).filter(Boolean),
);
const outOfScopeResourceIds = new Set<string>(
resources
.filter(
(resource) =>
resource.id && !filteredResourceIds.has(resource.id),
)
.map((resource) => resource.id),
);

const wireMethod = toWireMethod(options.query);
if (options.debug) {
Expand All @@ -558,51 +569,79 @@ export function updateBatchResources<O>(
}
}

// If all resources were filtered out, they may have moved OUT of scope
// Remove them from cache if they exist (handles file moves out of current view)
if (filteredResources.length === 0) {
for (const resource of resources) {
if (resource.id) {
deleteResource(resource.id, queryKey, queryClient);
}
}
return;
}

queryClient.setQueryData<O>(queryKey, (oldData: any) => {
if (options.debug) {
console.log(`[useNormalizedQuery] ${wireMethod} setQueryData: oldData has`, Array.isArray(oldData) ? oldData.length : Object.keys(oldData || {}).join(','), `adding ${filteredResources.length} resources`);
}

// Atomic server batches are forwarded when any resource matches the scope.
// Prune resources that moved out before merging those that still match.
const scopedOldData = removeResourcesFromCacheData(
oldData,
outOfScopeResourceIds,
);

// If the query hasn't returned yet, seed the cache with the event data.
// This handles the race where the subscription's buffer replay delivers
// events before the initial query response arrives. Without this, the
// events would be silently dropped and the UI stays empty.
if (!oldData) {
if (!scopedOldData) {
if (filteredResources.length === 0) {
return scopedOldData;
}
return { files: filteredResources, total_count: filteredResources.length, has_more: false } as O;
}

// Handle array responses
if (Array.isArray(oldData)) {
if (Array.isArray(scopedOldData)) {
return updateArrayCache(
oldData,
scopedOldData,
filteredResources,
noMergeFields,
) as O;
}

// Handle wrapped responses { files: [...] }
if (oldData && typeof oldData === "object") {
if (typeof scopedOldData === "object") {
return updateWrappedCache(
oldData,
scopedOldData,
filteredResources,
noMergeFields,
) as O;
}

return oldData;
return scopedOldData;
});
}

function removeResourcesFromCacheData(
oldData: any,
resourceIds: ReadonlySet<string>,
): any {
if (!oldData || resourceIds.size === 0) return oldData;

if (Array.isArray(oldData)) {
return oldData.filter((item: any) => !resourceIds.has(item.id));
}

if (typeof oldData === "object") {
const arrayField = Object.keys(oldData).find((key) =>
Array.isArray(oldData[key]),
);

if (arrayField) {
return {
...oldData,
[arrayField]: oldData[arrayField].filter(
(item: any) => !resourceIds.has(item.id),
),
};
}
}

return oldData;
}

Comment thread
OldFriendWenjianjian marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
/**
* Delete a resource from cache
*
Expand Down Expand Up @@ -874,4 +913,4 @@ export function safeMerge(
}

return result;
}
}
162 changes: 162 additions & 0 deletions packages/ts-client/tests/useNormalizedQuery.mixed-batch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import {describe, expect, test} from 'bun:test';
import {QueryClient} from '@tanstack/react-query';
import {
filterBatchResources,
updateBatchResources,
type UseNormalizedQueryOptions
} from '../src/hooks/useNormalizedQuery';

const queryKey = ['query:files.directory_listing', 'library-id', {}];

function file(id: string, path: string, name = id) {
return {
id,
name,
sd_path: {
Physical: {
device_slug: 'device',
path
}
}
};
}

function options(path: string): UseNormalizedQueryOptions<any> {
return {
query: 'files.directory_listing',
resourceType: 'file',
pathScope: {
Physical: {
device_slug: 'device',
path
}
},
includeDescendants: false
};
}

function updateCache(
initialFiles: ReturnType<typeof file>[],
resources: ReturnType<typeof file>[],
pathScope: string
) {
const queryClient = new QueryClient();
queryClient.setQueryData(queryKey, {
files: initialFiles,
total_count: initialFiles.length,
has_more: false
});

updateBatchResources(
resources,
null,
options(pathScope),
queryKey,
queryClient
);

return queryClient.getQueryData(queryKey) as {
files: ReturnType<typeof file>[];
total_count: number;
has_more: boolean;
};
}

describe('updateBatchResources scoped batches', () => {
test('removes out-of-scope IDs and merges in-scope resources in one mixed batch', () => {
const result = updateCache(
[
file('moved-out', 'C:\\Users\\Test\\Current\\moved.txt'),
file(
'staying',
'C:\\Users\\Test\\Current\\staying.txt',
'old-name'
),
file('untouched', 'C:\\Users\\Test\\Current\\untouched.txt')
],
[
file('moved-out', 'C:/Users/Test/Other/moved.txt'),
file(
'staying',
'c:/users/test/current/staying.txt',
'new-name'
),
file('moved-in', 'C:/USERS/TEST/CURRENT/moved-in.txt')
],
'C:\\Users\\Test\\Current\\'
);

expect(result.files.map(({id}) => id)).toEqual([
'staying',
'untouched',
'moved-in'
]);
expect(result.files.find(({id}) => id === 'staying')?.name).toBe(
'new-name'
);
});

test('applies the same mixed-batch update to direct array caches', () => {
const queryClient = new QueryClient();
queryClient.setQueryData(queryKey, [
file('moved-out', '/current/moved.txt'),
file('staying', '/current/staying.txt', 'old-name')
]);

updateBatchResources(
[
file('moved-out', '/other/moved.txt'),
file('staying', '/current/staying.txt', 'new-name')
],
null,
options('/current'),
queryKey,
queryClient
);

expect(queryClient.getQueryData(queryKey)).toEqual([
file('staying', '/current/staying.txt', 'new-name')
]);
});

test('removes every cached resource when the entire batch moves out of scope', () => {
const result = updateCache(
[
file('first', '/current/first.txt'),
file('second', '/current/second.txt')
],
[
file('first', '/other/first.txt'),
file('second', '/other/second.txt')
],
'/current'
);

expect(result.files).toEqual([]);
});

test('merges every resource when the entire batch remains in scope', () => {
const result = updateCache(
[file('existing', '/current/existing.txt', 'old-name')],
[
file('existing', '/current/existing.txt', 'new-name'),
file('new', '/current/new.txt')
],
'/current/'
);

expect(result.files.map(({id}) => id)).toEqual(['existing', 'new']);
expect(result.files[0]?.name).toBe('new-name');
});

test('keeps POSIX path matching case-sensitive', () => {
const resources = [
file('matching', '/Users/Test/Current/file.txt'),
file('different-case', '/users/test/current/file.txt')
];

expect(
filterBatchResources(resources, options('/Users/Test/Current'))
).toEqual([resources[0]]);
});
});