Compare commits

...
Author SHA1 Message Date
Nariman Jelveh bea7acc18e Ignore Explorer responses for a folder the window has left
Each refresh of an item container now takes an id, and only the newest
refresh may write to the container or its window. Before, a slow stat for
the folder a window had left could point the window back at it, so New
folder, Paste and Upload went to the old folder while the window showed
the new one. A listing that arrived just before navigating away added its
rows to the new folder, and returning to a folder while its first listing
was in flight rendered every row twice. The stat in update_window_path
also skips its writes once the window has moved to another path.
2026-11-06 17:49:12 -07:00
3 changed files with 134 additions and 8 deletions

No files matched your search

+4
View File
@@ -3446,6 +3446,10 @@ window.update_window_path = async function (el_window, target_path) {
else if ( target_path !== '/' ) {
try {
puter.fs.stat({ path: target_path, consistency: 'eventual' }).then(fsentry => {
// the window may have moved on while this stat was in flight
if ( $(el_window).attr('data-path') !== html_encode(target_path) ) {
return;
}
$(el_window).removeClass(`window-${ $(el_window).attr('data-uid')}`);
$(el_window).addClass(`window-${ fsentry.id}`);
$(el_window).attr('data-uid', fsentry.id);
+30 -7
View File
@@ -32,6 +32,13 @@ const refresh_item_container = function (el_item_container, options) {
options = options || {};
let container_path = $(el_item_container).attr('data-path');
// Only the newest refresh of a container may write to it or its window;
// an older one's responses belong to a folder the window has since left.
const refresh_id = ($(el_item_container).data('refreshId') ?? 0) + 1;
$(el_item_container).data('refreshId', refresh_id);
const is_current = () => $(el_item_container).data('refreshId') === refresh_id;
let el_window = $(el_item_container).closest('.window');
let el_window_head_icon = $(el_window).find('.window-head-icon');
const loading_spinner = $(el_item_container).find('.explorer-loading-spinner');
@@ -60,9 +67,7 @@ const refresh_item_container = function (el_item_container, options) {
// A timeout that will show the loading spinner if the folder is not loaded
// after 1000ms
let loading_timeout = setTimeout(function () {
// make sure the same folder is still loading
if ( $(loading_spinner).closest('.item-container').attr('data-path') !== container_path )
{
if ( ! is_current() ) {
return;
}
@@ -89,6 +94,9 @@ const refresh_item_container = function (el_item_container, options) {
}
if ( !is_shared_view ) puter.fs.stat({ path: container_path, consistency: options.consistency ?? 'eventual' }).then(fsentry => {
if ( ! is_current() ) {
return;
}
if ( el_window ) {
$(el_window).attr('data-uid', fsentry.id);
$(el_window).attr('data-sort_by', fsentry.sort_by ?? 'name');
@@ -156,10 +164,8 @@ const refresh_item_container = function (el_item_container, options) {
: puter.fs.readdir({ path: container_path, consistency: options.consistency ?? 'eventual' });
entries_promise.then(async (fsentries) => {
// Check if the same folder is still loading since el_item_container's
// data-path might have changed by other operations while waiting for the response to this `readdir`.
if ( $(el_item_container).attr('data-path') !== container_path )
{
if ( ! is_current() ) {
clearTimeout(loading_timeout);
return;
}
@@ -168,6 +174,11 @@ const refresh_item_container = function (el_item_container, options) {
// clear loading timeout
clearTimeout(loading_timeout);
// a newer refresh may have started during the spinner delay
if ( ! is_current() ) {
return;
}
// hide loading spinner
$(loading_spinner).hide();
@@ -187,6 +198,10 @@ const refresh_item_container = function (el_item_container, options) {
// add each item to window
for ( let index = 0; index < fsentries.length; index++ ) {
// item_icon() is awaited below, so a newer refresh can start mid-loop
if ( ! is_current() ) {
return;
}
const fsentry = fsentries[index];
let is_disabled = false;
@@ -258,6 +273,9 @@ const refresh_item_container = function (el_item_container, options) {
if ( $(el_item_container).hasClass('desktop') ) {
try {
const trash = await puter.fs.stat({ path: window.trash_path, consistency: options.consistency ?? 'eventual' });
if ( ! is_current() ) {
return;
}
UIItem({
appendTo: el_item_container,
uid: trash.id,
@@ -319,6 +337,11 @@ const refresh_item_container = function (el_item_container, options) {
// clear loading timeout
clearTimeout(loading_timeout);
// don't show a folder's error in the folder the window moved to
if ( ! is_current() ) {
return;
}
// hide other messages/indicators
$(loading_spinner).hide();
$(empty_message).hide();
@@ -18,7 +18,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import jQuery from '../lib/jquery-3.6.1/jquery-3.6.1.min.js';
vi.mock('../UI/UIItem.js', () => ({ default: vi.fn() }));
@@ -30,6 +30,11 @@ vi.mock('./listAllShared.js', () => ({ default: vi.fn() }));
globalThis.$ = jQuery;
globalThis.jQuery = jQuery;
globalThis.i18n = (key) => key;
globalThis.html_encode = (value) =>
jQuery('<div>')
.text(value ?? '')
.html();
globalThis.puter = { fs: { stat: vi.fn(), readdir: vi.fn() } };
jQuery.fn.removeItems = function () {
return this.remove();
};
@@ -46,6 +51,8 @@ Object.assign(window, {
check_fsentry_against_allowed_file_types_string: () => true,
sort_items: () => {},
update_explorer_footer_item_count: () => {},
update_window_layout: () => {},
update_details_layout_sort_visuals: () => {},
});
const { default: refresh_item_container } =
@@ -53,6 +60,10 @@ const { default: refresh_item_container } =
const { default: UIItem } = await import('../UI/UIItem.js');
const { default: list_all_shared } = await import('./listAllShared.js');
beforeEach(() => {
vi.clearAllMocks();
});
describe('refresh_item_container in the Shared view', () => {
it('flags rows shared through a team, and only those', async () => {
list_all_shared.mockResolvedValue([
@@ -97,3 +108,91 @@ describe('refresh_item_container in the Shared view', () => {
});
});
});
const deferred = () => {
let resolve;
const promise = new Promise((r) => {
resolve = r;
});
return { promise, resolve };
};
const fileEntry = (dir, name) => ({
uid: `${dir}/${name}`,
name,
path: `${dir}/${name}`,
is_dir: false,
});
// Points the window at a new folder the way update_window_path does, then refreshes it.
const navigate = (path) => {
$('.window').attr('data-path', path);
$('.item-container').attr('data-path', path);
refresh_item_container($('.item-container'));
};
const renderedPaths = () => UIItem.mock.calls.map(([options]) => options.path);
describe('refresh_item_container after the window moves to another folder', () => {
let pending;
beforeEach(() => {
pending = { stat: {}, readdir: {} };
puter.fs.stat.mockImplementation(({ path }) => {
pending.stat[path] = deferred();
return pending.stat[path].promise;
});
puter.fs.readdir.mockImplementation(({ path }) => {
pending.readdir[path] ??= deferred();
return pending.readdir[path].promise;
});
document.body.innerHTML =
'<div class="window"><div class="item-container"></div></div>';
});
afterEach(() => {
vi.useRealTimers();
});
it('ignores a stat for the folder it left', async () => {
navigate('/alice');
navigate('/alice/Pictures');
pending.stat['/alice/Pictures'].resolve({ id: 'pictures', name: 'Pictures' });
pending.stat['/alice'].resolve({ id: 'home', name: 'alice' });
await vi.waitFor(() => expect($('.window').attr('data-uid')).toBe('pictures'));
await new Promise((r) => setTimeout(r, 0));
expect($('.window').attr('data-path')).toBe('/alice/Pictures');
expect($('.window').attr('data-uid')).toBe('pictures');
});
it('drops a listing that arrives just before the window moves on', async () => {
vi.useFakeTimers();
navigate('/alice/Documents');
// A listing slower than 1s waits another second before rendering.
await vi.advanceTimersByTimeAsync(1100);
pending.readdir['/alice/Documents'].resolve([fileEntry('/alice/Documents', 'a.txt')]);
await vi.advanceTimersByTimeAsync(0);
navigate('/alice/Pictures');
pending.readdir['/alice/Pictures'].resolve([fileEntry('/alice/Pictures', 'b.png')]);
await vi.advanceTimersByTimeAsync(1000);
expect(renderedPaths()).toEqual(['/alice/Pictures/b.png']);
});
it('renders a folder once when it is refreshed again before the first listing arrives', async () => {
navigate('/alice/Documents');
navigate('/alice/Pictures');
navigate('/alice/Documents');
pending.readdir['/alice/Documents'].resolve([fileEntry('/alice/Documents', 'a.txt')]);
pending.readdir['/alice/Pictures'].resolve([fileEntry('/alice/Pictures', 'b.png')]);
await vi.waitFor(() => expect(UIItem).toHaveBeenCalled());
await new Promise((r) => setTimeout(r, 10));
expect(renderedPaths()).toEqual(['/alice/Documents/a.txt']);
});
});