fix(ui): refetchOnWindowFocus=always so Explorer refresh ignores staleTime

Forward-fix #2 on walk-item 8 refocus path. velocity-qa VM verify on
131bf22 showed the manual refresh button worked but synthetic + real
refocus did NOT update the Explorer sidebar after a new slice folder
was created.

Root cause: `refetchOnWindowFocus: true` gates the refetch on the
react-query staleness predicate. With `staleTime: 15_000` on
useFilesList and `staleTime: 30_000` on useSlices, a refocus inside
that window returns cached data — no refetch fires. The VM operator's
refocus tests landed within seconds of the initial fetch, well inside
the stale window.

Fix: switch both hooks to `refetchOnWindowFocus: "always"`. The
'always' variant bypasses staleness and refetches on every focus
event, which is the actual intent of the walk-item 8 contract — the
operator SHOULD see slice / file folders they just created when they
come back to the tab, even if the list was fetched moments ago.

Hooks touched:
- useFilesList (the load-bearing hook for Explorer sidebar; consumed
  by ProjectTreeView via useMissionDiscovery)
- useSlices (drives the slice rail in Explorer + Project surfaces)

Regression test at packages/ui/test/use-files-list-focus-refetch.test.tsx:
- Uses react-query's focusManager.setFocused(false/true) to simulate
  blur/focus deterministically in jsdom.
- Asserts a second fetch fires on focus toggle, within the staleTime
  window. With the pre-fix `true` value, the test fails (one call
  only); with `'always'`, the test passes (two calls).

Discrimination verified by temporarily reverting both hooks back to
`true` and re-running: both tests FAIL. Restored to `'always'`, both
tests PASS. 2/2.

Full daemon suite: 3352 passed / 1 skipped / 0 failed (one of the
banked flaky-createDaemon-timeout cases hit on first run; resolved
on clean re-run pattern; not caused by this slice).
git diff --check clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-05-11 06:37:11 -07:00
1 parent 131bf22626
commit c9578b381f
3 files changed
+138 -8

No files matched your search

+11 -4
View File
@@ -73,10 +73,17 @@ export function useFilesList(root: string | null, path: string | null) {
queryFn: () => fetchList(root!, path ?? ""),
enabled: !!root,
staleTime: 15_000,
// V0.3.1 slice 17 founder-walk-workspace-state-correctness — walk item 8 (Explorer auto-show): refetch on window focus so new files
// / folders created while the operator was off-tab appear without a
// manual refresh click.
refetchOnWindowFocus: true,
// V0.3.1 slice 17 walk-item 8 (Explorer auto-show): refetch on
// window focus so new files / folders created while the operator
// was off-tab appear without a manual refresh click.
//
// Forward-fix #2: 'always' instead of `true`. With staleTime:
// 15_000, plain `true` gates the refetch on staleness — short
// refocus within the 15-second window observed no refetch in the
// VM proof. 'always' refetches on every focus regardless of
// staleness. This is the load-bearing hook for the Explorer
// sidebar (driven via useMissionDiscovery + ProjectTreeView).
refetchOnWindowFocus: "always",
});
}
+13 -4
View File
@@ -81,10 +81,19 @@ export function useSlices(filter: SliceFilter, boundToWorkflow: BoundToWorkflowF
],
queryFn: () => fetchSlicesList(filter, boundToWorkflow),
staleTime: 30_000,
// V0.3.1 slice 17 founder-walk-workspace-state-correctness — walk item 8 (Explorer auto-show): refetch on window focus so an operator
// who switches away to `mkdir slices/...` and comes back sees the
// new folder without manually clicking refresh.
refetchOnWindowFocus: true,
// V0.3.1 slice 17 walk-item 8 (Explorer auto-show): refetch on
// window focus so an operator who switches away to `mkdir slices/...`
// and comes back sees the new folder without manually clicking
// refresh.
//
// Forward-fix #2 (2026-05-11 velocity-qa VM verify CONCERNING):
// the value MUST be 'always' instead of plain `true`. With this
// query's local staleTime: 30_000 (30 seconds), plain `true` gates
// the refetch on the staleness predicate — short refocus tests
// within the stale window observed no refetch. The 'always'
// variant ignores staleness and refetches on every focus, which
// is the actual intent: see new folders the operator JUST created.
refetchOnWindowFocus: "always",
});
}
@@ -0,0 +1,114 @@
// V0.3.1 slice 17 walk-item 8 forward-fix #2 — focus-refetch regression test.
//
// The Explorer sidebar's auto-show behavior on window focus requires
// useFilesList + useSlices to refetch when the operator switches away
// and back, even if the last fetch was very recent. With staleTime
// configured (15s for files, 30s for slices) AND
// refetchOnWindowFocus: true (the slice 17 initial impl), react-query
// gates the refetch on staleness — a refocus within the stale window
// produces no refetch, which is exactly what velocity-qa observed in
// the VM verify proof.
//
// Fix: `refetchOnWindowFocus: 'always'` bypasses staleness. This test
// asserts the focus-triggered refetch fires regardless of staleness.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderHook, waitFor, cleanup } from "@testing-library/react";
import { QueryClient, QueryClientProvider, focusManager } from "@tanstack/react-query";
import { createElement, type ReactNode } from "react";
import { useFilesList } from "../src/hooks/useFiles.js";
import { useSlices } from "../src/hooks/useSlices.js";
const originalFetch = globalThis.fetch;
let fetchSpy: ReturnType<typeof vi.fn>;
function makeWrapper() {
// Use the same staleTime defaults the production QueryClient ships
// with so the test discriminates against the same gating predicate
// production hits.
const client = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: 5_000 } },
});
return ({ children }: { children: ReactNode }) =>
createElement(QueryClientProvider, { client }, children);
}
beforeEach(() => {
fetchSpy = vi.fn();
globalThis.fetch = fetchSpy as unknown as typeof fetch;
// Start focused so the initial query mount fires normally.
focusManager.setFocused(true);
});
afterEach(() => {
cleanup();
globalThis.fetch = originalFetch;
vi.restoreAllMocks();
});
describe("useFilesList focus-refetch (walk-item 8 forward-fix #2)", () => {
it("refetches on focus regardless of staleTime (refetchOnWindowFocus: 'always')", async () => {
fetchSpy.mockImplementation(async () =>
new Response(JSON.stringify({
root: "workspace",
path: "missions",
entries: [],
}), { status: 200, headers: { "Content-Type": "application/json" } }),
);
const { result } = renderHook(
() => useFilesList("workspace", "missions"),
{ wrapper: makeWrapper() },
);
await waitFor(() => expect(result.current.isLoading).toBe(false));
const initialCallCount = fetchSpy.mock.calls.filter(
(c) => String(c[0]).startsWith("/api/files/list"),
).length;
expect(initialCallCount).toBe(1);
// Simulate tab blur + focus WITHIN the staleTime window. With
// refetchOnWindowFocus: true (pre-fix), this would be no-op.
// With 'always' (post-fix), a second fetch fires.
focusManager.setFocused(false);
focusManager.setFocused(true);
await waitFor(() => {
const calls = fetchSpy.mock.calls.filter(
(c) => String(c[0]).startsWith("/api/files/list"),
);
expect(calls.length).toBe(2);
});
});
});
describe("useSlices focus-refetch (walk-item 8 forward-fix #2)", () => {
it("refetches on focus regardless of staleTime (refetchOnWindowFocus: 'always')", async () => {
fetchSpy.mockImplementation(async () =>
new Response(JSON.stringify({
slices: [],
totalCount: 0,
filter: "all",
}), { status: 200, headers: { "Content-Type": "application/json" } }),
);
const { result } = renderHook(
() => useSlices("all"),
{ wrapper: makeWrapper() },
);
await waitFor(() => expect(result.current.isLoading).toBe(false));
const initialCallCount = fetchSpy.mock.calls.filter(
(c) => String(c[0]).startsWith("/api/slices"),
).length;
expect(initialCallCount).toBe(1);
focusManager.setFocused(false);
focusManager.setFocused(true);
await waitFor(() => {
const calls = fetchSpy.mock.calls.filter(
(c) => String(c[0]).startsWith("/api/slices"),
);
expect(calls.length).toBe(2);
});
});
});