Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
140 changes: 134 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,143 @@ 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;
idleSince: number | null;
};

const SOURCE_TRANSPORT_IDLE_TIMEOUT_MS = 30_000;
const MAX_IDLE_SOURCE_TRANSPORTS = 2;

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

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

function evictOldestIdleSourceTransport(): void {
let oldest: { id: string; transport: SharedSourceTransport } | undefined;
for (const [id, transport] of sharedSourceTransports) {
const idleSince = transport.idleSince;
if (
idleSince !== null &&
(!oldest ||
oldest.transport.idleSince === null ||
idleSince < oldest.transport.idleSince)
) {
oldest = { id, transport };
}
}
if (!oldest) {
return;
}
if (oldest.transport.idleTimer) {
clearTimeout(oldest.transport.idleTimer);
}
oldest.transport.cleanup();
sharedSourceTransports.delete(oldest.id);
}

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) {
while (
[...sharedSourceTransports.values()].filter(
(transport) => transport.idleSince !== null,
).length >= MAX_IDLE_SOURCE_TRANSPORTS
) {
evictOldestIdleSourceTransport();
}
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) {
try {
listener(event);
} catch (err) {
logger.error({ err }, "Source event listener failed");
}
}
}),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
idleSince: null,
};
sharedSourceTransports.set(id, shared);
}

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

export function useMediaSourceEvents(
Expand Down
10 changes: 10 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,14 @@ test("SPA intent prefetch and cache revisit do not duplicate route queries", asy
"/api/rpc/media/search",
),
).toBeLessThanOrEqual(1);
const sourceEventRequestCount = browserHealth.apiRequestCountPathSince(
searchCheckpoint,
"/api/rpc/sources/events",
);
expect(sourceEventRequestCount).toBeGreaterThan(0);
expect(
sourceEventRequestCount,
"Source event subscriptions must be shared across cached routes",
).toBeLessThanOrEqual(3);
Comment thread
hmjn023 marked this conversation as 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