From c313958d195c8030a0d6a3dc24b1dc426a0ddf7c Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Fri, 5 Jun 2026 21:31:05 -0400 Subject: [PATCH] fix: image compression toggle visible + compressed size actually shows Two bugs fixed: 1. Invisible toggle: index.css applies appearance:none to ALL input[type=checkbox], making the native checkbox invisible. Replaced the hidden with the folds Switch component which is properly styled and visible in both TDS and non-TDS themes. 2. Compressed size never appeared (stale closure bug): handleChange was async. After the first setMetadata() call the fileItem in selectedFiles was replaced with a new object. The .then() callback still held the OLD fileItem reference, so its setMetadata() call silently failed to find the item (REPLACE action couldn't match the stale ref). Fix: compression now runs in a useEffect triggered by the checked/compressible state. fileItemRef and metadataRef are updated each render so the async callback always writes to the current item. A stable fileKey string (name + size) is used as the dep instead of the File object reference so the effect doesn't re-run on every metadata update. Co-Authored-By: Claude Sonnet 4.6 --- .../upload-card/UploadCardRenderer.tsx | 127 ++++++++++-------- 1 file changed, 74 insertions(+), 53 deletions(-) diff --git a/src/app/components/upload-card/UploadCardRenderer.tsx b/src/app/components/upload-card/UploadCardRenderer.tsx index e17b656a4..95de16949 100644 --- a/src/app/components/upload-card/UploadCardRenderer.tsx +++ b/src/app/components/upload-card/UploadCardRenderer.tsx @@ -1,5 +1,5 @@ import React, { ReactNode, useEffect, useRef, useState } from 'react'; -import { Box, Chip, Icon, IconButton, Icons, Text, color, config, toRem } from 'folds'; +import { Box, Chip, Icon, IconButton, Icons, Switch, Text, color, config, toRem } from 'folds'; import { UploadCard, UploadCardError, UploadCardProgress } from './UploadCard'; import { UploadStatus, UploadSuccess, useBindUploadAtom } from '../../state/upload'; import { useMatrixClient } from '../../hooks/useMatrixClient'; @@ -105,38 +105,71 @@ type CompressionCheckboxProps = { }; function CompressionCheckbox({ fileItem, metadata, setMetadata }: CompressionCheckboxProps) { const originalFile = fileItem.originalFile as File | Blob; + const compressible = isCompressible(originalFile); + + // Local compression result — avoids the stale-closure bug where the async + // .then() would call setMetadata with an outdated fileItem reference. + const [localResult, setLocalResult] = useState< + import('../../utils/imageCompression').CompressionResult | null | undefined + >(undefined); const [compressing, setCompressing] = useState(false); - const compressPromiseRef = useRef | null>(null); - if (!isCompressible(originalFile)) return null; - - const handleChange = async (e: React.ChangeEvent) => { - const checked = e.target.checked; - if (!checked) { - setMetadata(fileItem, { ...metadata, compressImage: false, compressionResult: undefined }); - return; - } - - setMetadata(fileItem, { ...metadata, compressImage: true, compressionResult: undefined }); - setCompressing(true); - - const p = compressImage(originalFile).then((result) => { - setCompressing(false); - setMetadata(fileItem, { ...metadata, compressImage: true, compressionResult: result }); - }); - compressPromiseRef.current = p; - }; + // Always-fresh refs so the async callback never captures stale values. + const fileItemRef = useRef(fileItem); + const metadataRef = useRef(metadata); + fileItemRef.current = fileItem; + metadataRef.current = metadata; const checked = !!metadata.compressImage; - const result = metadata.compressionResult; + // Stable file identity key — the File object reference changes when metadata is + // updated (fileItem is replaced), but the underlying file never changes. + const fileKey = `${(originalFile as File).name ?? ''}-${originalFile.size}`; + + // Run compression whenever the toggle is turned on; cancel on uncheck / unmount. + useEffect(() => { + if (!checked || !compressible) return undefined; + let cancelled = false; + setCompressing(true); + setLocalResult(undefined); + compressImage(originalFile).then((result) => { + if (cancelled) return; + setCompressing(false); + setLocalResult(result); + // Persist to metadata using fresh refs so the REPLACE action finds the + // correct fileItem even after a previous setMetadata call has swapped it. + setMetadata(fileItemRef.current, { + ...metadataRef.current, + compressImage: true, + compressionResult: result, + }); + }); + return () => { + cancelled = true; + }; + // fileKey is a stable primitive derived from the file; setMetadata is a stable useCallback. + // Using these instead of the object refs avoids re-running on every metadata update. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [checked, compressible, fileKey, setMetadata]); + + if (!compressible) return null; + + const handleToggle = (value: boolean) => { + if (!value) setLocalResult(undefined); + setMetadata(fileItem, { ...metadata, compressImage: value, compressionResult: undefined }); + }; + + const displayResult = localResult ?? metadata.compressionResult; const savingPct = - result && result.originalSize > 0 - ? Math.round(((result.originalSize - result.compressedSize) / result.originalSize) * 100) + displayResult && displayResult.originalSize > 0 + ? Math.round( + ((displayResult.originalSize - displayResult.compressedSize) / + displayResult.originalSize) * + 100, + ) : null; const originalSize = formatFileSize(originalFile.size); - const fileId = `compress-${(originalFile as File).name ?? ''}-${originalFile.size}`; return ( - {/* Checkbox row */} - - - - {compressing && ( - - compressing… - - )} + {/* Toggle row */} + + + Compress image before uploading + + + {compressing && ( + + compressing… + + )} + + - {/* Before / after row — always show original; show compressed when ready */} + {/* Size comparison — original always visible; compressed appears when ready */} - {checked && !compressing && result !== undefined && ( + {checked && !compressing && displayResult !== undefined && ( <> → @@ -222,9 +243,9 @@ function CompressionCheckbox({ fileItem, metadata, setMetadata }: CompressionChe Compressed - {result ? ( + {displayResult ? ( - {formatFileSize(result.compressedSize)} + {formatFileSize(displayResult.compressedSize)} {savingPct !== null && savingPct > 0 && ( ({savingPct}% smaller) )}