Fix stale document viewer navigation races

Dispose the viewer query when the alias changes so navigating between
documents shows the skeleton instead of the previous document. Guard the
export completion against the current id so a slow export can't overwrite
a newer document's preview. Split the documents tab state into requested
and loaded refs so an out-of-order or failed refetch can't leave the list
showing a different tab than the toolbar.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-07-16 13:12:56 +02:00
parent 6054c92899
commit 6abe5f84f8
3 changed files with 46 additions and 10 deletions

View File

@@ -28,13 +28,16 @@ import { DocumentViewerPageSkeleton } from "./DocumentViewerPageSkeleton";
export default function DocumentViewerPageLoader() {
const { alias } = useParams();
const [queryRef, loadQuery] = useQueryLoader<DocumentViewerPageQuery>(documentViewerPageQuery);
const [queryRef, loadQuery, disposeQuery] = useQueryLoader<DocumentViewerPageQuery>(documentViewerPageQuery);
// Dispose on alias change so navigating between documents shows the skeleton
// during the transition instead of the previous document's metadata/preview.
useEffect(() => {
if (alias) {
loadQuery({ alias });
}
}, [loadQuery, alias]);
return () => disposeQuery();
}, [loadQuery, disposeQuery, alias]);
if (!queryRef) {
return <DocumentViewerPageSkeleton />;

View File

@@ -108,15 +108,35 @@ export function DocumentsPage({ queryRef }: DocumentsPageProps) {
// initial preload was in flight, this reconciles by refetching rather than
// showing the wrong slice. Refetch inside a transition so the toolbar and
// current results stay mounted (dimmed via `isRefetching`) while it loads.
const fetchedVisibility = useRef(queryRef.variables.visibility ?? null);
//
// `requestedVisibility` de-dupes in-flight requests; `loadedVisibility` only
// advances when the *latest* refetch settles, so an out-of-order or failed
// refetch can't leave the list showing a different tab than the toolbar.
const initialVisibility = queryRef.variables.visibility ?? null;
const loadedVisibility = useRef(initialVisibility);
const requestedVisibility = useRef(initialVisibility);
useEffect(() => {
const target = toQueryVariables(tab).visibility ?? null;
if (target === fetchedVisibility.current) {
if (target === requestedVisibility.current) {
return;
}
fetchedVisibility.current = target;
requestedVisibility.current = target;
startTransition(() => {
refetch(toQueryVariables(tab), { fetchPolicy: "store-or-network" });
refetch(toQueryVariables(tab), {
fetchPolicy: "store-or-network",
onComplete: (error) => {
if (requestedVisibility.current !== target) {
// A newer tab was requested; ignore this stale settle.
return;
}
if (error) {
// Allow re-selecting this tab to retry after a failed refetch.
requestedVisibility.current = loadedVisibility.current;
return;
}
loadedVisibility.current = target;
},
});
});
}, [refetch, tab]);

View File

@@ -18,7 +18,7 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { graphql } from "react-relay";
import { useMutation } from "#/lib/relay/useMutation";
@@ -77,28 +77,41 @@ export function useDocumentExport(kind: DocumentKind, id: string, enabled: boole
setDataUri(null);
}
// Track the current target so a slow export that resolves after the id
// changed cannot overwrite the preview with the previous document's bytes.
const currentId = useRef(id);
useEffect(() => {
currentId.current = id;
}, [id]);
useEffect(() => {
if (!enabled || dataUri) {
return;
}
const apply = (targetId: string, data: string) => {
if (currentId.current === targetId) {
setDataUri(data);
}
};
switch (kind) {
case "Document":
exportDocument({
variables: { input: { documentId: id } },
onCompleted: response => setDataUri(response.exportDocumentPDF.data),
onCompleted: response => apply(id, response.exportDocumentPDF.data),
}).catch(() => {});
break;
case "TrustCenterFile":
exportFile({
variables: { input: { trustCenterFileId: id } },
onCompleted: response => setDataUri(response.exportTrustCenterFile.data),
onCompleted: response => apply(id, response.exportTrustCenterFile.data),
}).catch(() => {});
break;
case "AuditReport":
exportReport({
variables: { input: { reportId: id } },
onCompleted: response => setDataUri(response.exportReportPDF.data),
onCompleted: response => apply(id, response.exportReportPDF.data),
}).catch(() => {});
break;
}