Skip to content

Commit c0c5522

Browse files
schramm-joboomanuelkiessling
authored andcommitted
Issue #86
- Steady silent polling in case images have been deleted on the remote server
1 parent ea268c0 commit c0c5522

3 files changed

Lines changed: 236 additions & 14 deletions

File tree

src/RemoteContentAssets/Presentation/Controller/RemoteAssetsController.php

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,10 @@ public function list(string $projectId): JsonResponse
6868
$project->remoteContentAssetsManifestUrls
6969
);
7070

71-
return $this->json(['urls' => $urls]);
71+
return $this->json([
72+
'urls' => $urls,
73+
'revision' => $this->buildUrlsRevision($urls),
74+
]);
7275
}
7376

7477
/**
@@ -164,4 +167,14 @@ public function upload(string $projectId, Request $request): JsonResponse
164167
return $this->json(['error' => 'Upload failed: ' . $e->getMessage()], Response::HTTP_INTERNAL_SERVER_ERROR);
165168
}
166169
}
170+
171+
/**
172+
* @param list<string> $urls
173+
*/
174+
private function buildUrlsRevision(array $urls): string
175+
{
176+
sort($urls, SORT_STRING);
177+
178+
return hash('sha256', implode("\n", $urls));
179+
}
167180
}

src/RemoteContentAssets/Presentation/Resources/assets/controllers/remote_asset_browser_controller.ts

Lines changed: 109 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { Controller } from "@hotwired/stimulus";
1515
export default class extends Controller {
1616
static readonly MANIFEST_WAIT_POLL_INTERVAL_MS: number = 2000;
1717
static readonly MANIFEST_WAIT_MAX_ATTEMPTS: number = 30;
18+
static BACKGROUND_SYNC_INTERVAL_MS: number = 300000;
1819

1920
static values = {
2021
fetchUrl: String,
@@ -80,10 +81,38 @@ export default class extends Controller {
8081
private itemHeight: number = 80;
8182
private isLoading: boolean = false;
8283
private isUploading: boolean = false;
84+
private isConnected: boolean = false;
85+
private backgroundSyncTimeoutId: ReturnType<typeof setTimeout> | null = null;
86+
private latestManifestRevision: string | null = null;
87+
private isBackgroundSyncEnabled: boolean = false;
88+
private readonly focusHandler = (): void => {
89+
void this.checkForManifestUpdates();
90+
};
91+
private readonly visibilityChangeHandler = (): void => {
92+
if (document.visibilityState === "visible") {
93+
void this.checkForManifestUpdates();
94+
}
95+
};
8396

8497
connect(): void {
85-
this.fetchAssets();
98+
this.isConnected = true;
99+
void this.fetchAssets();
86100
this.setupDropzone();
101+
this.isBackgroundSyncEnabled = this.getBackgroundSyncIntervalMs() > 0;
102+
if (this.isBackgroundSyncEnabled) {
103+
this.startBackgroundSync();
104+
window.addEventListener("focus", this.focusHandler);
105+
document.addEventListener("visibilitychange", this.visibilityChangeHandler);
106+
}
107+
}
108+
109+
disconnect(): void {
110+
this.isConnected = false;
111+
this.stopBackgroundSync();
112+
if (this.isBackgroundSyncEnabled) {
113+
window.removeEventListener("focus", this.focusHandler);
114+
document.removeEventListener("visibilitychange", this.visibilityChangeHandler);
115+
}
87116
}
88117

89118
/**
@@ -307,7 +336,7 @@ export default class extends Controller {
307336
setTimeout(() => this.showUploadStatus("none"), 5000);
308337
}
309338

310-
private async fetchAssets(): Promise<string[] | null> {
339+
private async fetchAssets(): Promise<{ urls: string[]; revision: string } | null> {
311340
if (this.isLoading || !this.fetchUrlValue) {
312341
return null;
313342
}
@@ -324,9 +353,10 @@ export default class extends Controller {
324353
throw new Error(`HTTP ${response.status}`);
325354
}
326355

327-
const data = (await response.json()) as { urls?: string[] };
328-
const manifestUrls = data.urls ?? [];
329-
this.urls = manifestUrls;
356+
const data = (await response.json()) as { urls?: string[]; revision?: string };
357+
const manifestData = this.normalizeManifestData(data);
358+
this.urls = manifestData.urls;
359+
this.latestManifestRevision = manifestData.revision;
330360

331361
this.showLoading(false);
332362

@@ -339,7 +369,7 @@ export default class extends Controller {
339369
this.filter();
340370
this.showEmpty(this.filteredUrls.length === 0);
341371
}
342-
return manifestUrls;
372+
return manifestData;
343373
} catch {
344374
this.showLoading(false);
345375
this.showEmpty(true);
@@ -362,11 +392,11 @@ export default class extends Controller {
362392
const maxAttempts = this.getManifestWaitMaxAttempts();
363393

364394
for (let attempt = 0; attempt < maxAttempts; attempt++) {
365-
const manifestUrls = await this.fetchManifestUrlsSilently();
366-
if (manifestUrls !== null) {
367-
const manifestUrlSet = new Set(manifestUrls);
395+
const manifestData = await this.fetchManifestUrlsSilently();
396+
if (manifestData !== null) {
397+
const manifestUrlSet = new Set(manifestData.urls);
368398
const manifestFileNameSet = new Set(
369-
manifestUrls.map((url) => this.extractFilename(url)).filter((fileName) => fileName !== ""),
399+
manifestData.urls.map((url) => this.extractFilename(url)).filter((fileName) => fileName !== ""),
370400
);
371401
pendingUrls.forEach((url) => {
372402
const fileName = this.extractFilename(url);
@@ -390,7 +420,7 @@ export default class extends Controller {
390420
return false;
391421
}
392422

393-
private async fetchManifestUrlsSilently(): Promise<string[] | null> {
423+
private async fetchManifestUrlsSilently(): Promise<{ urls: string[]; revision: string } | null> {
394424
if (!this.fetchUrlValue) {
395425
return null;
396426
}
@@ -403,13 +433,79 @@ export default class extends Controller {
403433
return null;
404434
}
405435

406-
const data = (await response.json()) as { urls?: string[] };
407-
return data.urls ?? [];
436+
const data = (await response.json()) as { urls?: string[]; revision?: string };
437+
return this.normalizeManifestData(data);
408438
} catch {
409439
return null;
410440
}
411441
}
412442

443+
private startBackgroundSync(): void {
444+
const intervalMs = this.getBackgroundSyncIntervalMs();
445+
if (intervalMs <= 0) {
446+
return;
447+
}
448+
449+
this.stopBackgroundSync();
450+
this.scheduleNextBackgroundSync(intervalMs);
451+
}
452+
453+
private stopBackgroundSync(): void {
454+
if (this.backgroundSyncTimeoutId !== null) {
455+
clearTimeout(this.backgroundSyncTimeoutId);
456+
this.backgroundSyncTimeoutId = null;
457+
}
458+
}
459+
460+
private scheduleNextBackgroundSync(intervalMs: number): void {
461+
if (!this.isConnected) {
462+
return;
463+
}
464+
this.backgroundSyncTimeoutId = setTimeout(() => {
465+
void this.runBackgroundSyncTick();
466+
}, intervalMs);
467+
}
468+
469+
private async runBackgroundSyncTick(): Promise<void> {
470+
await this.checkForManifestUpdates();
471+
if (this.isConnected) {
472+
this.scheduleNextBackgroundSync(this.getBackgroundSyncIntervalMs());
473+
}
474+
}
475+
476+
private async checkForManifestUpdates(): Promise<void> {
477+
if (this.isUploading) {
478+
return;
479+
}
480+
481+
const currentRevision = this.latestManifestRevision;
482+
const manifestData = await this.fetchManifestUrlsSilently();
483+
if (manifestData === null) {
484+
return;
485+
}
486+
487+
if (currentRevision === null || manifestData.revision !== currentRevision) {
488+
await this.fetchAssets();
489+
}
490+
}
491+
492+
private normalizeManifestData(data: { urls?: string[]; revision?: string }): { urls: string[]; revision: string } {
493+
const urls = data.urls ?? [];
494+
const revision = data.revision ?? this.computeManifestRevision(urls);
495+
496+
return { urls, revision };
497+
}
498+
499+
private computeManifestRevision(urls: string[]): string {
500+
const normalized = [...urls].sort((a, b) => a.localeCompare(b));
501+
return normalized.join("|");
502+
}
503+
504+
private getBackgroundSyncIntervalMs(): number {
505+
const ctor = this.constructor as typeof Controller & { BACKGROUND_SYNC_INTERVAL_MS?: number };
506+
return ctor.BACKGROUND_SYNC_INTERVAL_MS ?? 30000;
507+
}
508+
413509
private getManifestWaitPollIntervalMs(): number {
414510
const ctor = this.constructor as typeof Controller & { MANIFEST_WAIT_POLL_INTERVAL_MS?: number };
415511
return ctor.MANIFEST_WAIT_POLL_INTERVAL_MS ?? 2000;

tests/frontend/unit/RemoteContentAssets/remote_asset_browser_controller.test.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,30 @@ import RemoteAssetBrowserController from "../../../../src/RemoteContentAssets/Pr
44

55
describe("RemoteAssetBrowserController", () => {
66
let application: Application;
7+
let originalBackgroundSyncInterval: number;
78

89
beforeEach(() => {
910
document.body.innerHTML = "";
1011
application = Application.start();
1112
application.register("remote-asset-browser", RemoteAssetBrowserController);
1213
vi.stubGlobal("fetch", vi.fn());
14+
15+
const ctor = RemoteAssetBrowserController as unknown as {
16+
BACKGROUND_SYNC_INTERVAL_MS: number;
17+
};
18+
originalBackgroundSyncInterval = ctor.BACKGROUND_SYNC_INTERVAL_MS;
19+
// Disable periodic polling by default to keep tests deterministic.
20+
ctor.BACKGROUND_SYNC_INTERVAL_MS = 0;
1321
});
1422

1523
afterEach(() => {
1624
application.stop();
25+
26+
const ctor = RemoteAssetBrowserController as unknown as {
27+
BACKGROUND_SYNC_INTERVAL_MS: number;
28+
};
29+
ctor.BACKGROUND_SYNC_INTERVAL_MS = originalBackgroundSyncInterval;
30+
1731
vi.restoreAllMocks();
1832
});
1933

@@ -465,6 +479,105 @@ describe("RemoteAssetBrowserController", () => {
465479
);
466480
});
467481

482+
it("does not re-render list on focus when manifest is unchanged", async () => {
483+
const ctor = RemoteAssetBrowserController as unknown as {
484+
BACKGROUND_SYNC_INTERVAL_MS: number;
485+
};
486+
const originalInterval = ctor.BACKGROUND_SYNC_INTERVAL_MS;
487+
ctor.BACKGROUND_SYNC_INTERVAL_MS = 60000;
488+
489+
const mockFetch = vi
490+
.fn()
491+
// Initial fetch in connect()
492+
.mockResolvedValueOnce({
493+
ok: true,
494+
json: () => Promise.resolve({ urls: ["https://example.com/a.jpg"] }),
495+
})
496+
// Silent check on focus (same urls)
497+
.mockResolvedValueOnce({
498+
ok: true,
499+
json: () => Promise.resolve({ urls: ["https://example.com/a.jpg"] }),
500+
});
501+
vi.stubGlobal("fetch", mockFetch);
502+
503+
await createControllerElement();
504+
await new Promise((resolve) => setTimeout(resolve, 100));
505+
506+
window.dispatchEvent(new Event("focus"));
507+
await new Promise((resolve) => setTimeout(resolve, 100));
508+
509+
expect(mockFetch).toHaveBeenCalledTimes(2);
510+
const listEl = document.querySelector('[data-remote-asset-browser-target="list"]') as HTMLElement;
511+
expect(listEl.querySelectorAll('button[title="Add to chat"]').length).toBe(1);
512+
513+
ctor.BACKGROUND_SYNC_INTERVAL_MS = originalInterval;
514+
});
515+
516+
it("stops background polling on disconnect", async () => {
517+
const ctor = RemoteAssetBrowserController as unknown as {
518+
BACKGROUND_SYNC_INTERVAL_MS: number;
519+
};
520+
const originalInterval = ctor.BACKGROUND_SYNC_INTERVAL_MS;
521+
ctor.BACKGROUND_SYNC_INTERVAL_MS = 50;
522+
523+
try {
524+
const mockFetch = vi.fn().mockResolvedValue({
525+
ok: true,
526+
json: () => Promise.resolve({ urls: ["https://example.com/a.jpg"] }),
527+
});
528+
vi.stubGlobal("fetch", mockFetch);
529+
530+
await createControllerElement();
531+
await new Promise((resolve) => setTimeout(resolve, 220));
532+
533+
const callsBeforeStop = mockFetch.mock.calls.length;
534+
application.stop();
535+
await new Promise((resolve) => setTimeout(resolve, 120));
536+
const callsSoonAfterStop = mockFetch.mock.calls.length;
537+
await new Promise((resolve) => setTimeout(resolve, 120));
538+
const callsLaterAfterStop = mockFetch.mock.calls.length;
539+
540+
expect(callsSoonAfterStop).toBeGreaterThanOrEqual(callsBeforeStop);
541+
expect(callsLaterAfterStop).toBeLessThanOrEqual(callsBeforeStop + 5);
542+
} finally {
543+
ctor.BACKGROUND_SYNC_INTERVAL_MS = originalInterval;
544+
}
545+
});
546+
547+
it("checks manifest when tab becomes visible", async () => {
548+
const ctor = RemoteAssetBrowserController as unknown as {
549+
BACKGROUND_SYNC_INTERVAL_MS: number;
550+
};
551+
const originalInterval = ctor.BACKGROUND_SYNC_INTERVAL_MS;
552+
ctor.BACKGROUND_SYNC_INTERVAL_MS = 60000;
553+
554+
const mockFetch = vi
555+
.fn()
556+
// Initial fetch in connect()
557+
.mockResolvedValueOnce({
558+
ok: true,
559+
json: () => Promise.resolve({ urls: ["https://example.com/a.jpg"] }),
560+
})
561+
// Silent check on visibilitychange
562+
.mockResolvedValueOnce({
563+
ok: true,
564+
json: () => Promise.resolve({ urls: ["https://example.com/a.jpg"] }),
565+
});
566+
vi.stubGlobal("fetch", mockFetch);
567+
568+
await createControllerElement();
569+
await new Promise((resolve) => setTimeout(resolve, 100));
570+
571+
const visibilitySpy = vi.spyOn(document, "visibilityState", "get").mockReturnValue("visible");
572+
document.dispatchEvent(new Event("visibilitychange"));
573+
await new Promise((resolve) => setTimeout(resolve, 100));
574+
575+
expect(mockFetch.mock.calls.length).toBeGreaterThanOrEqual(2);
576+
visibilitySpy.mockRestore();
577+
578+
ctor.BACKGROUND_SYNC_INTERVAL_MS = originalInterval;
579+
});
580+
468581
describe("upload functionality", () => {
469582
const createControllerElementWithUpload = async (): Promise<HTMLElement> => {
470583
const html = `

0 commit comments

Comments
 (0)