Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 94 additions & 6 deletions apps/server/src/hooks/use-media-source-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
type UseMediaSourceEventsOptions,
useMediaSourceEvents as useMediaSourceEventsShared,
} from "@solid-imager/ui/hooks/use-media-source-events";
import { useLocation } from "@tanstack/solid-router";
import { type Accessor, mergeProps } from "solid-js";
import { orpc } from "~/infrastructure/api-clients/orpc-client";
import { logger } from "~/infrastructure/logger";
Expand All @@ -22,16 +23,103 @@ export type {

type MediaSourceEventsOptions = Omit<UseMediaSourceEventsOptions, "transport">;

type SourceEventHandler = Parameters<MediaSourceEventTransport["listen"]>[0];

type SharedSourceTransport = {
cleanup: () => void;
handlers: Set<SourceEventHandler>;
idleTimer: ReturnType<typeof setTimeout> | null;
};

const SOURCE_TRANSPORT_IDLE_TIMEOUT_MS = 30_000;

type SourceTransportGlobal = typeof globalThis & {
__solidImagerSharedSourceTransports?: Map<string, SharedSourceTransport>;
};

const sourceTransportGlobal = globalThis as SourceTransportGlobal;
const sharedSourceTransports =
sourceTransportGlobal.__solidImagerSharedSourceTransports ??
new Map<string, SharedSourceTransport>();
sourceTransportGlobal.__solidImagerSharedSourceTransports =
sharedSourceTransports;

export function createServerTransport(
mediaSourceId: Accessor<string | undefined>,
): MediaSourceEventTransport {
return createSourceEventTransport(
mediaSourceId,
(id, signal) => orpc.sources.events({ id }, { signal }),
(err, retryCount, delay) => {
logger.error({ err, retryCount, delay }, "Event stream error, retrying");
const location = useLocation();
const isActiveRoute = () => {
const id = mediaSourceId();
const pathname = location().pathname;
if (!id) {
return false;
}
if (id === "*") {
return pathname === "/search";
}
return (
pathname === `/sources/${id}` || pathname.startsWith(`/sources/${id}/`)
);
};

return {
listen(handler) {
const id = mediaSourceId();
if (!id || !isActiveRoute()) {
return () => {
/* no-op */
};
}

let shared = sharedSourceTransports.get(id);
if (!shared) {
const handlers = new Set<SourceEventHandler>();
const transport = createSourceEventTransport(
() => id,
(sourceId, signal) =>
orpc.sources.events({ id: sourceId }, { signal }),
(err, retryCount, delay) => {
logger.error(
{ err, retryCount, delay },
"Event stream error, retrying",
);
},
);
shared = {
handlers,
idleTimer: null,
cleanup: transport.listen((event) => {
for (const listener of handlers) {
listener(event);
}
}),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
};
sharedSourceTransports.set(id, shared);
}

if (shared.idleTimer) {
clearTimeout(shared.idleTimer);
shared.idleTimer = null;
}
shared.handlers.add(handler);
let isListening = true;
return () => {
if (!isListening) {
return;
}
isListening = false;
shared.handlers.delete(handler);
if (shared.handlers.size === 0) {
shared.idleTimer = setTimeout(() => {
if (shared.handlers.size === 0) {
shared.cleanup();
sharedSourceTransports.delete(id);
}
}, SOURCE_TRANSPORT_IDLE_TIMEOUT_MS);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}
};
},
);
};
}

export function useMediaSourceEvents(
Expand Down
8 changes: 8 additions & 0 deletions apps/server/src/tests/e2e/route-reload.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,7 @@ test("SPA intent prefetch and cache revisit do not duplicate route queries", asy
browserHealth,
}) => {
await page.goto("/");
await waitForAppHydration(page);

const searchCheckpoint = browserHealth.requestCheckpoint();
const searchLink = page.getByRole("link", { name: "Search", exact: true });
Expand Down Expand Up @@ -493,5 +494,12 @@ test("SPA intent prefetch and cache revisit do not duplicate route queries", asy
"/api/rpc/media/search",
),
).toBeLessThanOrEqual(1);
expect(
browserHealth.apiRequestCountPathSince(
searchCheckpoint,
"/api/rpc/sources/events",
),
"Source event subscriptions must be shared across cached routes",
).toBeLessThanOrEqual(3);
Comment thread
hmjn023 marked this conversation as resolved.
Resolved
await expectRouteHealthy(page);
});
29 changes: 19 additions & 10 deletions apps/server/src/tests/e2e/sources-source-media.responsive.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import type { Locator, Page } from "@playwright/test";
import { E2E_SOURCE_ID, E2E_SOURCE_NAME, sourcePath } from "./support/fixture";
import {
E2E_PRIMARY_FILE_NAME,
E2E_SOURCE_ID,
E2E_SOURCE_NAME,
getFixtureMediaPath,
sourcePath,
} from "./support/fixture";
import { expect, test, waitForAppHydration } from "./support/test";

function usesMobileControls(projectName: string): boolean {
Expand Down Expand Up @@ -123,28 +129,31 @@ test("source media exposes mobile filters and touch selection", async ({
const addMediaButton = page.getByRole("button", { name: "Add media" });
await expectTouchTarget(addMediaButton);
await expectInsideViewport(page, addMediaButton);
const fileChooser = page.waitForEvent("filechooser");
await addMediaButton.click();
await (await fileChooser).setFiles(
getFixtureMediaPath(E2E_PRIMARY_FILE_NAME),
);
const uploadDialog = page.getByRole("dialog");
await expect(
uploadDialog.getByRole("heading", {
name: "メディアをアップロード",
exact: true,
}),
).toBeVisible();
await uploadDialog
.getByRole("button", { name: "アップロード", exact: true })
.click();
await expect(
uploadDialog.getByText("アップロードするファイルがありません。"),
).toBeVisible();
const filenameInput = uploadDialog.getByLabel("ファイル名", { exact: true });
await expect(filenameInput).toHaveValue(E2E_PRIMARY_FILE_NAME);
await filenameInput.fill("temporary-name.png");
await uploadDialog
.getByRole("button", { name: "キャンセル", exact: true })
.click();
await expect(uploadDialog).toBeHidden();
const reopenedFileChooser = page.waitForEvent("filechooser");
await addMediaButton.click();
await expect(
uploadDialog.getByText("アップロードするファイルがありません。"),
).toHaveCount(0);
await (await reopenedFileChooser).setFiles(
getFixtureMediaPath(E2E_PRIMARY_FILE_NAME),
);
await expect(filenameInput).toHaveValue(E2E_PRIMARY_FILE_NAME);
await page.keyboard.press("Escape");
await expect(uploadDialog).toBeHidden();

Expand Down
1 change: 1 addition & 0 deletions apps/server/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,7 @@ const devOrpcNodeMiddlewarePlugin = (): Plugin => ({
});

export default defineConfig({
root: __dirname,
cacheDir: viteCacheDir,
server: isolatedDevConfig
? {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
"dghs-imgutils-rs",
"sharp"
],
"packageManager": "npm@11.11.1",
"packageManager": "bun@1.3.14",
"overrides": {
"vitest": "npm:@voidzero-dev/vite-plus-test@^0.1.24",
"nitro": "npm:nitro-nightly@3.0.1-20260601-173724-a2597363"
Expand Down
14 changes: 13 additions & 1 deletion packages/ui/src/source-form-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -245,9 +245,13 @@ export function SourceFormModal(props: SourceFormModalProps) {
}));

let wasOpen = false;
let restoreFocusElement: HTMLElement | undefined;
createEffect(() => {
const isOpen = props.isOpen;
if (isOpen && !wasOpen) {
const activeElement = document.activeElement;
restoreFocusElement =
activeElement instanceof HTMLElement ? activeElement : undefined;
form.reset(defaultValues());
}
wasOpen = isOpen;
Expand All @@ -264,7 +268,15 @@ export function SourceFormModal(props: SourceFormModalProps) {
onOpenChange={(open) => !open && props.onClose()}
open={props.isOpen}
>
<DialogContent class="sm:max-w-[500px]">
<DialogContent
class="sm:max-w-[500px]"
onCloseAutoFocus={(event) => {
if (restoreFocusElement?.isConnected) {
event.preventDefault();
restoreFocusElement.focus();
}
}}
>
<DialogHeader>
<DialogTitle>
{props.editingSource ? "Edit Source" : "Add New Source"}
Expand Down
37 changes: 21 additions & 16 deletions packages/ui/src/upload-media-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -236,22 +236,27 @@ export function UploadMediaModalContent(props: UploadMediaModalContentProps) {
updatePreview(nextFiles[0] ?? null);
};

createEffect(() => {
if (!props.isOpen) {
return;
}
const initialFiles = props.initialFile ? [props.initialFile] : [];
setSelectedFiles(initialFiles);
props.onFilesSelected(initialFiles);
updatePreview(props.initialFile);
setLastFetchedUrl(null);
form.reset({
...EMPTY_UPLOAD_FORM,
filename: props.initialFile?.name ?? "",
sourceUrl: props.pastedUrl ?? "",
});
setAsyncError(null);
});
createEffect(
on(
() => props.isOpen,
(isOpen) => {
if (!isOpen) {
return;
}
const initialFiles = props.initialFile ? [props.initialFile] : [];
setSelectedFiles(initialFiles);
props.onFilesSelected(initialFiles);
updatePreview(props.initialFile);
setLastFetchedUrl(null);
form.reset({
...EMPTY_UPLOAD_FORM,
filename: props.initialFile?.name ?? "",
sourceUrl: props.pastedUrl ?? "",
});
setAsyncError(null);
},
),
);

onCleanup(() => {
const currentPreview = previewUrl();
Expand Down