Skip to content

Commit a717025

Browse files
V48 Gate 3 (implementation-only): stop deposit file-tree pickers from flickering empty on treeRef thrash
Abort + generation-guard in-flight tree fetches so a late empty/error response from a prior branch package cannot wipe a successful commit-package load.
1 parent a757cee commit a717025

2 files changed

Lines changed: 186 additions & 29 deletions

File tree

uapi/components/base/bitcode/vcs/VCSFileTreePicker.tsx

Lines changed: 107 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
* given conflict label. Square theme, no card-in-card chrome.
1212
*/
1313

14-
import React, { useCallback, useEffect, useMemo, useState } from 'react';
14+
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
1515
import { ChevronDown, ChevronRight, FileText, Folder } from 'lucide-react';
1616
import type { VCSProviderType } from '@bitcode/vcs-core';
1717

@@ -45,6 +45,27 @@ function baseName(path: string) {
4545
return index === -1 ? trimmed : trimmed.slice(index + 1);
4646
}
4747

48+
function normalizeTreeItems(raw: unknown): VCSFileTreePickerTreeItem[] {
49+
if (!Array.isArray(raw)) return [];
50+
return raw
51+
.filter(
52+
(item: { path?: unknown; type?: unknown }) =>
53+
typeof item?.path === 'string' &&
54+
(item?.type === 'blob' || item?.type === 'tree'),
55+
)
56+
.map((item: { path: string; type: 'blob' | 'tree' }) => ({
57+
path: item.path,
58+
type: item.type,
59+
}))
60+
.sort((a, b) =>
61+
a.type === b.type
62+
? a.path.localeCompare(b.path)
63+
: a.type === 'tree'
64+
? -1
65+
: 1,
66+
);
67+
}
68+
4869
export function VCSFileTreePicker({
4970
provider,
5071
repositoryFullName,
@@ -63,6 +84,13 @@ export function VCSFileTreePicker({
6384
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
6485
const [loadError, setLoadError] = useState<string | null>(null);
6586

87+
// Bumped on every tree-identity change (repo/ref) and on unmount so
88+
// in-flight fetches from a prior package cannot write empty/error results
89+
// over a later successful load (the flicker-to-empty race). Same pattern
90+
// DepositSourceSelection uses with its disposed flags.
91+
const loadGenerationRef = useRef(0);
92+
const abortControllerRef = useRef<AbortController | null>(null);
93+
6694
const [owner, repo] = useMemo(() => {
6795
const [ownerPart, repoPart] = (repositoryFullName || '').split('/');
6896
return [ownerPart || null, repoPart || null];
@@ -75,9 +103,15 @@ export function VCSFileTreePicker({
75103
const selectedSet = useMemo(() => new Set(selectedPaths), [selectedPaths]);
76104

77105
const loadDirectory = useCallback(
78-
async (path: string) => {
106+
async (path: string, generation: number, signal: AbortSignal) => {
79107
if (!owner || !repo) return;
80-
setChildrenByPath((previous) => ({ ...previous, [path]: previous[path] ?? null }));
108+
// Only mark loading when we don't already have items for this path —
109+
// keeps the tree painted during a soft re-fetch of an expanded dir.
110+
setChildrenByPath((previous) =>
111+
previous[path] === undefined
112+
? { ...previous, [path]: null }
113+
: previous,
114+
);
81115
try {
82116
const params = new URLSearchParams({
83117
resource: 'tree',
@@ -87,49 +121,93 @@ export function VCSFileTreePicker({
87121
});
88122
if (path) params.set('path', path);
89123
if (treeRef) params.set('ref', treeRef);
90-
const response = await fetch(`/api/vcs?${params.toString()}`);
91-
const payload = response?.ok ? await response.json().catch(() => null) : null;
92-
const items: VCSFileTreePickerTreeItem[] = Array.isArray(payload?.items)
93-
? payload.items
94-
.filter(
95-
(item: { path?: unknown; type?: unknown }) =>
96-
typeof item?.path === 'string' &&
97-
(item?.type === 'blob' || item?.type === 'tree'),
98-
)
99-
.sort(
100-
(a: VCSFileTreePickerTreeItem, b: VCSFileTreePickerTreeItem) =>
101-
a.type === b.type
102-
? a.path.localeCompare(b.path)
103-
: a.type === 'tree'
104-
? -1
105-
: 1,
106-
)
107-
: [];
124+
const response = await fetch(`/api/vcs?${params.toString()}`, {
125+
signal,
126+
});
127+
if (generation !== loadGenerationRef.current || signal.aborted) return;
128+
const payload = response?.ok
129+
? await response.json().catch(() => null)
130+
: null;
131+
if (generation !== loadGenerationRef.current || signal.aborted) return;
132+
if (!payload) {
133+
// Failed read: keep any previously-good paint; only write empty
134+
// when we never had items (null/undefined). Avoids the
135+
// success→stale-failure→Empty directory flicker.
136+
setChildrenByPath((previous) => {
137+
const existing = previous[path];
138+
if (Array.isArray(existing) && existing.length > 0) return previous;
139+
return { ...previous, [path]: [] };
140+
});
141+
setLoadError('Unable to read the repository file tree.');
142+
return;
143+
}
144+
const items = normalizeTreeItems(payload.items);
108145
setChildrenByPath((previous) => ({ ...previous, [path]: items }));
109-
if (!payload) setLoadError('Unable to read the repository file tree.');
110-
} catch {
111-
setChildrenByPath((previous) => ({ ...previous, [path]: [] }));
146+
setLoadError(null);
147+
} catch (error) {
148+
const aborted =
149+
signal.aborted ||
150+
(error instanceof Error && error.name === 'AbortError');
151+
if (generation !== loadGenerationRef.current || aborted) {
152+
return;
153+
}
154+
setChildrenByPath((previous) => {
155+
const existing = previous[path];
156+
if (Array.isArray(existing) && existing.length > 0) return previous;
157+
return { ...previous, [path]: [] };
158+
});
112159
setLoadError('Unable to read the repository file tree.');
113160
}
114161
},
115162
[owner, provider, repo, treeRef],
116163
);
117164

118-
// Reset + load the root whenever the source package changes.
165+
// Reset + load the root whenever the source package (owner/repo/ref) changes.
166+
// treeRef thrashing is expected: DepositSourceSelection publishes branch
167+
// first, then head commit once commits load — without abort + generation
168+
// guards the branch-fetch can land after the commit-fetch and wipe it.
119169
useEffect(() => {
120-
setChildrenByPath({});
170+
const generation = loadGenerationRef.current + 1;
171+
loadGenerationRef.current = generation;
172+
abortControllerRef.current?.abort();
173+
const controller = new AbortController();
174+
abortControllerRef.current = controller;
175+
121176
setExpanded({});
122177
setLoadError(null);
123-
if (!owner || !repo) return;
124-
void loadDirectory('');
178+
if (!owner || !repo) {
179+
setChildrenByPath({});
180+
return () => {
181+
controller.abort();
182+
};
183+
}
184+
185+
// Root reloads to Loading (null), not Empty ([]). Keeping a prior tree
186+
// painted during ref hops would flash the wrong revision, so clear —
187+
// but never seed with [] (that is the "Empty directory" state).
188+
setChildrenByPath({ '': null });
189+
void loadDirectory('', generation, controller.signal);
190+
191+
return () => {
192+
// Invalidate every in-flight response for this package.
193+
if (loadGenerationRef.current === generation) {
194+
loadGenerationRef.current = generation + 1;
195+
}
196+
controller.abort();
197+
};
125198
}, [loadDirectory, owner, repo]);
126199

127200
const toggleExpanded = (path: string) => {
128201
setExpanded((previous) => {
129202
const next = { ...previous, [path]: !previous[path] };
130203
return next;
131204
});
132-
if (childrenByPath[path] === undefined) void loadDirectory(path);
205+
if (childrenByPath[path] === undefined) {
206+
const controller = abortControllerRef.current;
207+
if (controller && !controller.signal.aborted) {
208+
void loadDirectory(path, loadGenerationRef.current, controller.signal);
209+
}
210+
}
133211
};
134212

135213
const toggleSelected = (selectionPath: string) => {

uapi/tests/vcsFileTreePicker.test.tsx

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,4 +177,83 @@ describe('VCSFileTreePicker', () => {
177177
fireEvent.click(screen.getByRole('button', { name: 'Clear all' }));
178178
expect(onChange).toHaveBeenCalledWith([]);
179179
});
180+
181+
it('ignores a stale tree response when treeRef hops (no empty flicker)', async () => {
182+
// Reproduce the deposit source-selection thrash: branch name is published
183+
// first, then the head commit once commits load. A slow branch response
184+
// that lands AFTER the commit package is active must not wipe the tree
185+
// to "Empty directory".
186+
let resolveBranch: ((value: unknown) => void) | null = null;
187+
let resolveCommit: ((value: unknown) => void) | null = null;
188+
global.fetch = jest.fn((input: unknown) => {
189+
const url = String(input);
190+
const params = new URLSearchParams(url.split('?')[1] || '');
191+
const ref = params.get('ref') || '';
192+
if (ref === 'main') {
193+
return new Promise((resolve) => {
194+
resolveBranch = resolve;
195+
});
196+
}
197+
if (ref === 'abc123') {
198+
return new Promise((resolve) => {
199+
resolveCommit = resolve;
200+
});
201+
}
202+
return Promise.resolve({
203+
ok: true,
204+
status: 200,
205+
json: async () => ({ items: [] }),
206+
});
207+
}) as unknown as typeof fetch;
208+
209+
const { rerender } = render(
210+
<VCSFileTreePicker
211+
provider="github"
212+
repositoryFullName="engineeredsoftware/ENGI"
213+
treeRef="main"
214+
selectedPaths={[]}
215+
onChange={jest.fn()}
216+
/>,
217+
);
218+
219+
// Tree identity hops to the head commit before the branch fetch settles.
220+
rerender(
221+
<VCSFileTreePicker
222+
provider="github"
223+
repositoryFullName="engineeredsoftware/ENGI"
224+
treeRef="abc123"
225+
selectedPaths={[]}
226+
onChange={jest.fn()}
227+
/>,
228+
);
229+
230+
// Commit package wins with real items.
231+
expect(resolveCommit).toBeTruthy();
232+
resolveCommit!({
233+
ok: true,
234+
status: 200,
235+
json: async () => ({
236+
items: [
237+
{ path: 'src', type: 'tree', sha: 't1' },
238+
{ path: 'README.md', type: 'blob', sha: 'b1', size: 10 },
239+
],
240+
}),
241+
});
242+
await waitFor(() => expect(screen.getByText('src/')).toBeInTheDocument());
243+
expect(screen.getByText('README.md')).toBeInTheDocument();
244+
expect(screen.queryByText('Empty directory')).not.toBeInTheDocument();
245+
246+
// Stale branch response arrives late — empty / failed. Must not overwrite.
247+
expect(resolveBranch).toBeTruthy();
248+
resolveBranch!({
249+
ok: false,
250+
status: 404,
251+
json: async () => ({ error: 'not found' }),
252+
});
253+
// Give the stale promise a tick to misbehave if the guard is broken.
254+
await new Promise((resolve) => setTimeout(resolve, 0));
255+
expect(screen.getByText('src/')).toBeInTheDocument();
256+
expect(screen.getByText('README.md')).toBeInTheDocument();
257+
expect(screen.queryByText('Empty directory')).not.toBeInTheDocument();
258+
});
180259
});

0 commit comments

Comments
 (0)