fix: image compression toggle visible + compressed size actually shows
CI / Build & Quality Checks (push) Successful in 10m36s

Two bugs fixed:

1. Invisible toggle: index.css applies appearance:none to ALL
   input[type=checkbox], making the native checkbox invisible. Replaced
   the hidden <input type="checkbox"> 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 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 21:31:05 -04:00
parent 7df5561e98
commit c313958d19
@@ -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<Promise<void> | null>(null);
if (!isCompressible(originalFile)) return null;
const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
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 (
<Box
@@ -150,34 +183,22 @@ function CompressionCheckbox({ fileItem, metadata, setMetadata }: CompressionChe
border: `1px solid ${color.SurfaceVariant.ContainerLine}`,
}}
>
{/* Checkbox row */}
<Box alignItems="Center" gap="200">
<input
id={fileId}
type="checkbox"
checked={checked}
onChange={handleChange}
style={{ cursor: 'pointer', flexShrink: 0 }}
/>
<label
htmlFor={fileId}
style={{
cursor: 'pointer',
color: color.SurfaceVariant.OnContainer,
userSelect: 'none',
fontSize: '0.8rem',
}}
>
Compress before uploading
</label>
{compressing && (
<Text size="T200" style={{ opacity: 0.6, marginLeft: 'auto' }}>
compressing
</Text>
)}
{/* Toggle row */}
<Box alignItems="Center" gap="200" justifyContent="SpaceBetween">
<Text size="T200" style={{ color: color.SurfaceVariant.OnContainer }}>
Compress image before uploading
</Text>
<Box alignItems="Center" gap="200">
{compressing && (
<Text size="T200" style={{ opacity: 0.6 }}>
compressing
</Text>
)}
<Switch variant="Primary" value={checked} onChange={handleToggle} />
</Box>
</Box>
{/* Before / after row — always show original; show compressed when ready */}
{/* Size comparison — original always visible; compressed appears when ready */}
<Box gap="200" alignItems="Center" wrap="Wrap">
<Box
gap="100"
@@ -197,7 +218,7 @@ function CompressionCheckbox({ fileItem, metadata, setMetadata }: CompressionChe
</Text>
</Box>
{checked && !compressing && result !== undefined && (
{checked && !compressing && displayResult !== undefined && (
<>
<Text size="T200" style={{ opacity: 0.5 }}>
@@ -222,9 +243,9 @@ function CompressionCheckbox({ fileItem, metadata, setMetadata }: CompressionChe
<Text size="T200" style={{ opacity: 0.6 }}>
Compressed
</Text>
{result ? (
{displayResult ? (
<Text size="T200" style={{ fontWeight: 600 }}>
{formatFileSize(result.compressedSize)}
{formatFileSize(displayResult.compressedSize)}
{savingPct !== null && savingPct > 0 && (
<span style={{ opacity: 0.7, marginLeft: 4 }}>({savingPct}% smaller)</span>
)}