From 30e62675bfaae6f25690384beac3b0a8542cd531 Mon Sep 17 00:00:00 2001 From: hmjn Date: Sat, 18 Jul 2026 01:22:07 +0900 Subject: [PATCH] fix(media): refresh details after navigation Track active route params reactively, remount detail content for new media, and disable stale media response caching. Add browser regressions for source and search navigation. --- .../src/components/media/media-viewer.tsx | 2 +- .../api/sources.$mediaSourceId.$mediaId.ts | 5 +- .../sources/$mediaSourceId/$mediaId/index.tsx | 32 +++++-- ...a-detail-manager-config.responsive.spec.ts | 95 ++++++++++++++++++- .../src/components/media/media-viewer.tsx | 2 +- .../sources/$mediaSourceId/$mediaId/index.tsx | 21 ++-- packages/ui/src/media-viewer.tsx | 1 + .../ui/src/screens/media-detail-screen.tsx | 50 +++++----- 8 files changed, 166 insertions(+), 42 deletions(-) diff --git a/apps/server/src/components/media/media-viewer.tsx b/apps/server/src/components/media/media-viewer.tsx index 816926de4..f34b508ec 100644 --- a/apps/server/src/components/media/media-viewer.tsx +++ b/apps/server/src/components/media/media-viewer.tsx @@ -42,7 +42,7 @@ class ApiMediaSource implements MediaSource { async getUrl() { const url = `/api/sources/${this.media.mediaSourceId}/${this.media.id}`; - const response = await fetch(url); + const response = await fetch(url, { cache: "no-store" }); if (!response.ok) { throw new Error(`Failed to fetch media: ${response.status}`); } diff --git a/apps/server/src/routes/api/sources.$mediaSourceId.$mediaId.ts b/apps/server/src/routes/api/sources.$mediaSourceId.$mediaId.ts index 37bbf1eac..5d916c3a7 100644 --- a/apps/server/src/routes/api/sources.$mediaSourceId.$mediaId.ts +++ b/apps/server/src/routes/api/sources.$mediaSourceId.$mediaId.ts @@ -49,7 +49,10 @@ export const Route = createFileRoute("/api/sources/$mediaSourceId/$mediaId")({ const contentType = getContentTypeFromExtension(media.fileName); return new Response(file, { - headers: { "Content-Type": contentType }, + headers: { + "Cache-Control": "no-store", + "Content-Type": contentType, + }, }); }, }, diff --git a/apps/server/src/routes/sources/$mediaSourceId/$mediaId/index.tsx b/apps/server/src/routes/sources/$mediaSourceId/$mediaId/index.tsx index bdb773e49..82aea38fa 100644 --- a/apps/server/src/routes/sources/$mediaSourceId/$mediaId/index.tsx +++ b/apps/server/src/routes/sources/$mediaSourceId/$mediaId/index.tsx @@ -1,7 +1,11 @@ import { RouteDataPendingScreen } from "@solid-imager/ui/router-status"; import { MediaDetailScreen } from "@solid-imager/ui/screens/media-detail-screen"; -import { ClientOnly, createFileRoute, useParams } from "@tanstack/solid-router"; -import { createSignal, onMount, Show } from "solid-js"; +import { + ClientOnly, + createFileRoute, + useRouterState, +} from "@tanstack/solid-router"; +import { type Accessor, createSignal, onMount, Show } from "solid-js"; import { MediaSidebar } from "~/components/media/media-sidebar"; import { MediaViewer } from "~/components/media/media-viewer"; import { createServerTransport } from "~/hooks/use-media-source-events"; @@ -27,6 +31,10 @@ export const Route = createFileRoute("/sources/$mediaSourceId/$mediaId/")({ context.queryClient.prefetchQuery(allIpsQueryOptions()), context.queryClient.prefetchQuery(allCharactersQueryOptions()), ]); + return { + mediaId: params.mediaId, + mediaSourceId: params.mediaSourceId, + }; }, pendingComponent: MediaRouteFallback, pendingMinMs: 0, @@ -59,17 +67,25 @@ function MediaRouteFallback() { } function MediaRouteContent() { - const params = useParams({ from: "/sources/$mediaSourceId/$mediaId/" }); - const mediaSourceId = () => params().mediaSourceId; - const mediaId = () => params().mediaId; + const routeData = Route.useLoaderData(); + const currentParams = useRouterState({ + select: (state) => + state.matches.find((match) => match.routeId === Route.id)?.params, + }); + const mediaSourceId = () => + currentParams()?.mediaSourceId ?? routeData().mediaSourceId; + const mediaId = () => currentParams()?.mediaId ?? routeData().mediaId; return ( }> - + ); } -function MediaContent(props: { mediaId: string; mediaSourceId: string }) { +function MediaContent(props: { + mediaId: Accessor; + mediaSourceId: Accessor; +}) { return ( )} renderMediaViewer={(media) => } - transport={createServerTransport(() => props.mediaSourceId)} + transport={createServerTransport(props.mediaSourceId)} /> ); } diff --git a/apps/server/src/tests/e2e/media-detail-manager-config.responsive.spec.ts b/apps/server/src/tests/e2e/media-detail-manager-config.responsive.spec.ts index 7b8edc914..2de65a153 100644 --- a/apps/server/src/tests/e2e/media-detail-manager-config.responsive.spec.ts +++ b/apps/server/src/tests/e2e/media-detail-manager-config.responsive.spec.ts @@ -1,5 +1,12 @@ import type { Page } from "@playwright/test"; -import { E2E_PRIMARY_FILE_NAME, mediaPath } from "./support/fixture"; +import { + E2E_PRIMARY_FILE_NAME, + E2E_PRIMARY_MEDIA_ID, + E2E_SIMILAR_FILE_NAME, + E2E_SIMILAR_MEDIA_ID, + mediaPath, + sourcePath, +} from "./support/fixture"; import { expect, test, waitForAppHydration } from "./support/test"; const mobileProjects = ["responsive-320", "responsive-375"]; @@ -13,6 +20,92 @@ async function expectNoHorizontalOverflow(page: Page): Promise { expect(overflow).toBeLessThanOrEqual(1); } +async function sampleImagePixel( + page: Page, + accessibleName: string, +): Promise { + const image = page.locator("img.object-contain").and( + page.getByRole("img", { + name: accessibleName, + exact: true, + }), + ); + await expect(image).toBeVisible(); + return image.evaluate((element) => { + if (!(element instanceof HTMLImageElement)) { + throw new Error("Expected an image element"); + } + const canvas = document.createElement("canvas"); + canvas.width = element.naturalWidth; + canvas.height = element.naturalHeight; + const context = canvas.getContext("2d"); + if (!context) { + throw new Error("Failed to create a 2D canvas context"); + } + context.drawImage(element, 0, 0); + return [...context.getImageData(128, 64, 1, 1).data]; + }); +} + +test("media detail follows the second thumbnail after returning to the list", async ({ + page, +}) => { + await page.goto(sourcePath()); + await waitForAppHydration(page); + + const primaryResponse = page.waitForResponse( + (response) => + response.url().endsWith(mediaPath(E2E_PRIMARY_MEDIA_ID)) && + response.request().resourceType() === "fetch", + ); + await page.locator(`[data-media-id="${E2E_PRIMARY_MEDIA_ID}"]`).click(); + await expect(page).toHaveURL(mediaPath(E2E_PRIMARY_MEDIA_ID)); + expect((await primaryResponse).headers()["cache-control"]).toBe("no-store"); + await expect( + page.getByRole("img", { name: E2E_PRIMARY_FILE_NAME, exact: true }), + ).toBeVisible(); + const primaryPixel = await sampleImagePixel(page, E2E_PRIMARY_FILE_NAME); + + await page.goBack(); + await expect(page).toHaveURL(sourcePath()); + const similarResponse = page.waitForResponse( + (response) => + response.url().endsWith(mediaPath(E2E_SIMILAR_MEDIA_ID)) && + response.request().resourceType() === "fetch", + ); + await page.locator(`[data-media-id="${E2E_SIMILAR_MEDIA_ID}"]`).click(); + + await expect(page).toHaveURL(mediaPath(E2E_SIMILAR_MEDIA_ID)); + expect((await similarResponse).headers()["cache-control"]).toBe("no-store"); + await expect( + page.getByRole("img", { name: E2E_SIMILAR_FILE_NAME, exact: true }), + ).toBeVisible(); + const similarPixel = await sampleImagePixel(page, E2E_SIMILAR_FILE_NAME); + expect(similarPixel).not.toEqual(primaryPixel); +}); + +test("media detail follows the second search result after returning to search", async ({ + page, +}) => { + await page.goto("/search"); + await waitForAppHydration(page); + await expect( + page.locator(`[data-media-id="${E2E_PRIMARY_MEDIA_ID}"]`), + ).toBeVisible(); + + await page.locator(`[data-media-id="${E2E_PRIMARY_MEDIA_ID}"]`).click(); + await expect(page).toHaveURL(mediaPath(E2E_PRIMARY_MEDIA_ID)); + const primaryPixel = await sampleImagePixel(page, E2E_PRIMARY_FILE_NAME); + + await page.goBack(); + await expect(page).toHaveURL("/search"); + await page.locator(`[data-media-id="${E2E_SIMILAR_MEDIA_ID}"]`).click(); + + await expect(page).toHaveURL(mediaPath(E2E_SIMILAR_MEDIA_ID)); + const similarPixel = await sampleImagePixel(page, E2E_SIMILAR_FILE_NAME); + expect(similarPixel).not.toEqual(primaryPixel); +}); + test("media detail, manager, and settings remain usable on narrow screens", async ({ page, }, testInfo) => { diff --git a/apps/tauri/src/components/media/media-viewer.tsx b/apps/tauri/src/components/media/media-viewer.tsx index 425cdcff4..3703eb6b7 100644 --- a/apps/tauri/src/components/media/media-viewer.tsx +++ b/apps/tauri/src/components/media/media-viewer.tsx @@ -44,7 +44,7 @@ class ApiMediaSource implements MediaSource { async getUrl() { const url = buildMediaContentUrl(this.media.mediaSourceId, this.media.id); - const response = await getApiFetch()(url); + const response = await getApiFetch()(url, { cache: "no-store" }); if (!response.ok) { throw new Error(`Failed to fetch media: ${response.status}`); } diff --git a/apps/tauri/src/routes/sources/$mediaSourceId/$mediaId/index.tsx b/apps/tauri/src/routes/sources/$mediaSourceId/$mediaId/index.tsx index a89270be0..dc937c607 100644 --- a/apps/tauri/src/routes/sources/$mediaSourceId/$mediaId/index.tsx +++ b/apps/tauri/src/routes/sources/$mediaSourceId/$mediaId/index.tsx @@ -3,7 +3,7 @@ import { projectsQueryKeys } from "@solid-imager/ui/query-options"; import { RouteDataPendingScreen } from "@solid-imager/ui/router-status"; import { MediaDetailScreen } from "@solid-imager/ui/screens/media-detail-screen"; import { useQueryClient } from "@tanstack/solid-query"; -import { createFileRoute, useParams } from "@tanstack/solid-router"; +import { createFileRoute, useRouterState } from "@tanstack/solid-router"; import { MediaSidebar } from "~/components/media/media-sidebar"; import { MediaViewer } from "~/components/media/media-viewer"; import { createTauriTransport } from "~/hooks/use-media-source-events"; @@ -15,6 +15,10 @@ export const Route = createFileRoute("/sources/$mediaSourceId/$mediaId/")({ void context.queryClient.prefetchQuery( mediaDetailsQueryOptions(params.mediaSourceId, params.mediaId), ); + return { + mediaId: params.mediaId, + mediaSourceId: params.mediaSourceId, + }; }, pendingComponent: () => ( + state.matches.find((match) => match.routeId === Route.id)?.params, + }); const queryClient = useQueryClient(); - const mediaSourceId = () => params().mediaSourceId; - const mediaId = () => params().mediaId; + const mediaSourceId = () => + currentParams()?.mediaSourceId ?? routeData().mediaSourceId; + const mediaId = () => currentParams()?.mediaId ?? routeData().mediaId; const sourceRootPathResolver = useSourceRootPath(mediaSourcesQueryOptions); return ( { await queryClient.invalidateQueries({ queryKey: projectsQueryKeys.forMedia(mediaId()), diff --git a/packages/ui/src/media-viewer.tsx b/packages/ui/src/media-viewer.tsx index ae859fff5..d62517e6d 100644 --- a/packages/ui/src/media-viewer.tsx +++ b/packages/ui/src/media-viewer.tsx @@ -27,6 +27,7 @@ export function MediaViewer(props: MediaViewerProps) { const source = props.source; let currentUrl: string | null = null; let disposed = false; + setMediaUrl(null); void (async () => { try { diff --git a/packages/ui/src/screens/media-detail-screen.tsx b/packages/ui/src/screens/media-detail-screen.tsx index c387ac290..ac929ed22 100644 --- a/packages/ui/src/screens/media-detail-screen.tsx +++ b/packages/ui/src/screens/media-detail-screen.tsx @@ -5,7 +5,7 @@ import type { ThumbnailGeneratedEvent, } from "@solid-imager/core/domain/sources/events"; import { createQuery, useQueryClient } from "@tanstack/solid-query"; -import { type JSX, Match, Switch } from "solid-js"; +import { type Accessor, type JSX, Match, Show, Switch } from "solid-js"; import { ErrorState, OfflineState, QueryStatus } from "../async-state"; import { type MediaSourceEventTransport, @@ -15,8 +15,8 @@ import { toQueryUiState } from "../query-state"; import { LoadingRegion, MediaDetailSkeleton } from "../skeleton"; export type MediaDetailScreenProps = { - mediaSourceId: string; - mediaId: string; + mediaSourceId: Accessor; + mediaId: Accessor; // biome-ignore lint/suspicious/noExplicitAny: library type mismatch between oRPC and solid-query mediaDetailsQueryOptions: (mediaSourceId: string, mediaId: string) => any; sourceRootPath?: string; @@ -38,7 +38,7 @@ export function MediaDetailScreen(props: MediaDetailScreenProps) { const queryClient = useQueryClient(); const mediaDetails = createQuery(() => - props.mediaDetailsQueryOptions(props.mediaSourceId, props.mediaId), + props.mediaDetailsQueryOptions(props.mediaSourceId(), props.mediaId()), ); const state = () => toQueryUiState(mediaDetails); const errorMessage = () => { @@ -51,8 +51,8 @@ export function MediaDetailScreen(props: MediaDetailScreenProps) { const handleUpdate = async () => { await queryClient.invalidateQueries({ queryKey: props.mediaDetailsQueryOptions( - props.mediaSourceId, - props.mediaId, + props.mediaSourceId(), + props.mediaId(), ).queryKey, }); if (props.onAdditionalInvalidate) { @@ -64,7 +64,7 @@ export function MediaDetailScreen(props: MediaDetailScreenProps) { transport: props.transport, onMediaDeleted: (data: MediaDeletedEvent) => { if ( - data.mediaId === props.mediaId || + data.mediaId === props.mediaId() || data.filePath === mediaDetails.data?.filePath ) { void handleUpdate(); @@ -72,14 +72,14 @@ export function MediaDetailScreen(props: MediaDetailScreenProps) { }, onMediaChanged: (data: MediaChangedEvent) => { if ( - data.mediaId === props.mediaId || + data.mediaId === props.mediaId() || data.filePath === mediaDetails.data?.filePath ) { void handleUpdate(); } }, onThumbnailGenerated: (data: ThumbnailGeneratedEvent) => { - if (data.mediaId === props.mediaId) { + if (data.mediaId === props.mediaId()) { void handleUpdate(); } }, @@ -94,22 +94,24 @@ export function MediaDetailScreen(props: MediaDetailScreenProps) { updatingLabel="メディア情報を更新中..." /> - - {(details) => ( -
-
- {props.renderMediaViewer(details(), props.sourceRootPath)} + + + {(details) => ( +
+
+ {props.renderMediaViewer(details, props.sourceRootPath)} +
+
+ {props.renderMediaSidebar( + details, + mediaDetails.isRefetching, + handleUpdate, + props.sourceRootPath, + )} +
-
- {props.renderMediaSidebar( - details(), - mediaDetails.isRefetching, - handleUpdate, - props.sourceRootPath, - )} -
-
- )} + )} +