Skip to content

Commit 9d1c503

Browse files
authored
fix: markdown thumbnail capture race condition — use MutationObserver instead of setTimeout (#236)
DomCapture used setTimeout(500ms) to wait for ReactMarkdown to render before capturing. This was a race condition — rendering could take longer, producing blank thumbnails. Replace with a MutationObserver that waits for actual rendered elements (p, h1-h3, li, pre, blockquote, table) to appear in the DOM, then captures after one requestAnimationFrame for layout to settle. SVG content (set synchronously via dangerouslySetInnerHTML) is caught by an initial check before the observer is attached. Also add mock PUT /assets/:id/thumbnail handlers so thumbnail capture can be tested end-to-end in dev mode with MSW.
1 parent a6ebb94 commit 9d1c503

File tree

2 files changed

+38
-3
lines changed

2 files changed

+38
-3
lines changed

ui/src/components/ThumbnailGenerator.tsx

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -194,9 +194,26 @@ function DomCapture({
194194
}, [assetId, onCaptured, onFailed]);
195195

196196
useEffect(() => {
197-
// Wait for render to complete
198-
const timer = setTimeout(doCapture, 500);
199-
return () => clearTimeout(timer);
197+
const el = containerRef.current;
198+
if (!el) return;
199+
200+
// If content is already rendered (SVG via dangerouslySetInnerHTML), capture
201+
// after one animation frame so layout settles.
202+
if (el.querySelector("svg, p, h1, h2, h3, li, pre, blockquote, table")) {
203+
const raf = requestAnimationFrame(() => void doCapture());
204+
return () => cancelAnimationFrame(raf);
205+
}
206+
207+
// Otherwise wait for ReactMarkdown to render child nodes.
208+
const observer = new MutationObserver(() => {
209+
if (el.querySelector("p, h1, h2, h3, li, pre, blockquote, table")) {
210+
observer.disconnect();
211+
// One more frame to let layout settle after the DOM mutation.
212+
requestAnimationFrame(() => void doCapture());
213+
}
214+
});
215+
observer.observe(el, { childList: true, subtree: true });
216+
return () => observer.disconnect();
200217
}, [doCapture]);
201218

202219
// Timeout: if capture hasn't completed, give up

ui/src/mocks/handlers.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -679,6 +679,15 @@ export const handlers = [
679679
return HttpResponse.json(portalAssets[idx]);
680680
}),
681681

682+
http.put(`${ADMIN_BASE}/assets/:id/thumbnail`, ({ params }) => {
683+
const asset = portalAssets.find((a) => a.id === params.id && !a.deleted_at);
684+
if (!asset) {
685+
return HttpResponse.json({ detail: "Not found" }, { status: 404 });
686+
}
687+
asset.thumbnail_s3_key = `thumbnails/${asset.id}.png`;
688+
return new HttpResponse(null, { status: 204 });
689+
}),
690+
682691
http.delete(`${ADMIN_BASE}/assets/:id`, ({ params }) => {
683692
const idx = portalAssets.findIndex(
684693
(a) => a.id === params.id && !a.deleted_at,
@@ -807,6 +816,15 @@ export const handlers = [
807816
return HttpResponse.json(portalAssets[idx]);
808817
}),
809818

819+
http.put(`${PORTAL_BASE}/assets/:id/thumbnail`, ({ params }) => {
820+
const asset = portalAssets.find((a) => a.id === params.id && !a.deleted_at);
821+
if (!asset) {
822+
return HttpResponse.json({ detail: "Not found" }, { status: 404 });
823+
}
824+
asset.thumbnail_s3_key = `thumbnails/${asset.id}.png`;
825+
return new HttpResponse(null, { status: 204 });
826+
}),
827+
810828
http.delete(`${PORTAL_BASE}/assets/:id`, ({ params }) => {
811829
const idx = portalAssets.findIndex(
812830
(a) => a.id === params.id && !a.deleted_at,

0 commit comments

Comments
 (0)