Skip to content

fix([DSTSUP-275]): format file sizes to reflect magnitude and locale - #5764

Open
OsamaAbdellateef wants to merge 6 commits into
mainfrom
dstsup-275_fix-filefield
Open

fix([DSTSUP-275]): format file sizes to reflect magnitude and locale#5764
OsamaAbdellateef wants to merge 6 commits into
mainfrom
dstsup-275_fix-filefield

Conversation

@OsamaAbdellateef

@OsamaAbdellateef OsamaAbdellateef commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Description

<FileField> rendered every selected file's size with a fixed megabyte divisor and two decimals ((file.size / 1024 / 1024).toFixed(2) + " MB"), so anything under ~5 kB read 0.00 MB. For consumers importing CSVs — routinely 1–50 kB — the item description carried no information at all, and there was no way to override it from the outside since <FileField> owns the file list in internal state and renders the items itself.

Sizes now step through B, kB, MB, GB, TB and pick the unit that fits the file's magnitude, formatted through Intl.NumberFormat for the active locale. The step stays at 1024 (unchanged), so a given file keeps the number it had before — only its unit and trailing zeros change.

Closes DSTSUP-275

Screenshots / Preview

No visual redesign — only the item description text changes.

File Before After
2,400-byte CSV 0.00 MB 2.34 kB
0.5 MiB image 0.50 MB 512 kB
2 MiB PDF 2.00 MB 2 MB
Same CSV, de-DE locale 0.00 MB 2,34 kB

Test Instructions

  1. Open the FileField story in Storybook and upload a small file (a few KB, e.g. a CSV) alongside a multi-MB file.
  2. Confirm the small file shows a kB size instead of 0.00 MB, and the large file shows a clean MB/GB value without a fixed two-decimal MB suffix.
  3. Wrap the story in an I18nProvider with locale="de-DE" and re-upload; confirm the number uses a comma decimal separator (e.g. 2,34 kB) while the unit stays kB.
  4. pnpm test:unit -- FileField and pnpm test:sb -- FileField to run the added/updated tests.

Breaking Changes

No

Checklist

  • Storybook preview and Marigold docs preview are available
  • Stories added/updated (with component-test tag where applicable)
  • Unit tests added/updated
  • Component documentation added/updated (if it exists)
  • Accessibility reviewed against ARIA APG (for new/changed interactive components)
  • Visual regression tests updated (for UI changes)
  • Changeset added (pnpm changeset)

@vercel

vercel Bot commented Aug 26, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
marigold-docs Ready Ready Preview Sep 2, 2026 9:52am UTC
marigold-storybook Ready Ready Preview Sep 2, 2026 9:52am UTC
1 Skipped Deployment
Project Deployment Actions Updated
marigold-production Ignored Ignored Sep 2, 2026 9:52am UTC

Request Review

@changeset-bot

changeset-bot Bot commented Aug 26, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 1655a44

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 5 packages
Name Type
@marigold/components Patch
@marigold/docs Patch
@marigold/theme-rui Patch
@marigold/system Patch
@marigold/icons Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

Copy link
Copy Markdown
Contributor

Accessibility tests executed. Download the report here.

@github-actions

github-actions Bot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for Marigold Code Coverage

Status Category Percentage Covered / Total
🔵 Lines 98.81% 3004 / 3040
🔵 Statements 97.82% 3149 / 3219
🔵 Functions 97.97% 823 / 840
🔵 Branches 91.27% 1947 / 2133
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
packages/components/src/FileField/FileField.tsx 100% 95.45% 100% 100%
packages/components/src/FileField/fileUtils.ts 94.91% 88.09% 100% 100% 32, 33, 68
Generated in workflow #23779 for commit 1655a44 by the Vitest Coverage Report Action

@OsamaAbdellateef OsamaAbdellateef changed the title fix(DSTSUP-275): format file sizes to reflect magnitude and locale fix([DSTSUP-275]): format file sizes to reflect magnitude and locale Aug 26, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Accessibility tests executed. Download the report here.

@jim761 jim761 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated Code Review

Spec match against DSTSUP-275 is good: unit-aware sizes via the ticket's own Math.log(size)/Math.log(1024) approach, plus a top-unit clamp and non-finite/negative guards the ticket didn't ask for. The one deviation — literal unit symbols instead of Intl.NumberFormat style: 'unit' — is documented in the changeset and I agree with it (340 byte next to kB in the same list reads badly).

Also correct and worth noting: useLocale comes from react-aria-components/I18nProvider, not the @react-aria/i18n shell — exactly what check-rac-first-imports.mjs guards — and pure-function cases live in fileUtils.test.ts rather than the component test.

All 28 CI checks pass. Four inline comments: one blocker (visual regression), one decision about the unit symbols, and two minor ones.


Generated with Claude Code review-pr skill

Comment thread packages/components/src/FileField/FileField.tsx Outdated
Comment thread packages/components/src/FileField/fileUtils.ts
Comment thread packages/components/src/FileField/fileUtils.ts Outdated
Comment thread packages/components/src/FileField/fileUtils.ts Outdated
@github-actions

Copy link
Copy Markdown
Contributor

Accessibility tests executed. Download the report here.

@OsamaAbdellateef

Copy link
Copy Markdown
Contributor Author

/run-chromatic

@jim761 jim761 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated Code Review

Reviewed against CLAUDE.md, DSTSUP-275, and the repo's own CI guards. Ran vitest run --project=unit-tests FileField fileUtils (54 passed) and probed formatFileSize directly across 19 boundary values in en-US / de-DE / ar-EG.

Verdict: non-blocking. Careful work — the fix matches the ticket, the rounding carry at unit boundaries is handled, and coverage is layered sensibly across the pure util, the component, and the browser. Seven notes, all nits, left inline on the relevant lines.

Spec match

DSTSUP-275 asked for "a unit-aware size, e.g. 2.35 kB / 1.2 MB / 340 B, picking the unit from the magnitude", localized via Intl.NumberFormat. Delivered exactly that. The ticket's style: 'unit' suggestion was deliberately declined and the changeset says why. The ticket's "Related, possibly a separate issue" (no onSelect on FileField) is correctly left out of scope.

What's good

  • The i18n import path dodges a real bug class. useNumberFormatter from @react-aria/i18n would have been shorter, but it reads the shell's I18nContext — the DSTSUP-261 / DST-1505 duplicate-context bug that scripts/check-rac-first-imports.mjs exists to prevent. Taking useLocale from react-aria-components/I18nProvider and building Intl.NumberFormat by hand is the right call; worth keeping if this is ever refactored.
  • The carry at unit boundaries is handled. 1048575 renders 1 MB, not 1024 kB — the Math.round(scaled * 100) / 100 >= FILE_SIZE_STEP check, with [1024 ** 2 - 1, '1 MB'] pinning it.
  • Module-level Map<string, Intl.NumberFormat> is the correct caching shape for a formatter.
  • No any, no @ts-ignore, as const on the unit tuple, explicit return types. Hook called unconditionally; no new state or effects; nothing for memo/useMemo to earn here.

One note that has no line to sit on

PR title uses fix([DSTSUP-275]): instead of fix(DSTSUP-275):. The repo's Conventional-Commits scope carries the bare ticket key — fix(DST-1675):, docs(DST-1703): in recent history. The brackets land in main's history verbatim on squash.

Visual regression

Chromatic build #244 ran on head faf191f8 and passed, reporting 3 changes accepted as baselines. Since the item description text is the only thing that changed, three diffs is the expected count — but the "Visual regression tests updated" checkbox is unticked, so worth confirming those three were the FileField stories and that accepting them was intentional.


Generated with Claude Code review-pr skill

return `${value} ${FILE_SIZE_UNITS[exponent]}`;
};

export const fileKey = (file: File): string =>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ The doc comment on fileKey was dropped.

The new size block was inserted where fileKey's explanation used to live, and took it with it:

-// Identity of a file for de-duplication and removal: two files with the same
-// name, size, and last-modified time are treated as the same file.

fileKey is now the only undocumented export in this file, and it's the one whose contract (name + size + lastModified = same file) is least guessable from the body. Worth restoring those two lines.

Putting the size helpers after fileKey / dedupeFiles rather than between the accept-token helpers and fileKey would also keep the accept-matching code contiguous.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — restored fileKey's identity comment and moved the size helpers after fileKey/dedupeFiles so the accept-matching code stays contiguous. The reorder landed in 3639c794b; the comment itself got dropped from that push by mistake and is now back in 6db08bfef.

let scaled = bytes / FILE_SIZE_STEP ** exponent;
if (
exponent < FILE_SIZE_UNITS.length - 1 &&
Math.round(scaled * 100) / 100 >= FILE_SIZE_STEP

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 The magic 100 here silently mirrors maximumFractionDigits: 2 on line 66.

The two have to stay in lockstep, 30 lines apart, with nothing connecting them — change the formatter to 1 or 3 digits and this boundary carry drifts without a single test noticing (the [1024 ** 2 - 1, '1 MB'] case is exactly what would break).

const FILE_SIZE_FRACTION_DIGITS = 2;
const FILE_SIZE_ROUNDING = 10 ** FILE_SIZE_FRACTION_DIGITS;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — extracted FILE_SIZE_FRACTION_DIGITS and FILE_SIZE_ROUNDING so the formatter's maximumFractionDigits and the boundary-rounding check read from the same constant instead of drifting independently. See 3639c794b.

bytes === 0
? 0
: Math.min(
Math.floor(Math.log(bytes) / Math.log(FILE_SIZE_STEP)),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Optional: make the input guard total.

Line 77 already absorbs -1, NaN and Infinity, and fileUtils.test.ts:172 tests all three. A size in (0, 1) slips past it — Math.floor(Math.log(0.4) / Math.log(1024)) is -1, Math.min keeps it, and FILE_SIZE_UNITS[-1] renders as the literal string undefined:

formatFileSize(0.4, 'en-US')  // → "409.6 undefined"

Unreachable today, to be clear: File.size is spec'd as an integer, and formatFileSize isn't re-exported from the package index — one production caller, FileField.tsx:210, passing file.size. So this is only worth doing if you'd rather the guard cover the whole domain than depend on that caller staying the only one.

Suggested change
Math.floor(Math.log(bytes) / Math.log(FILE_SIZE_STEP)),
Math.max(Math.floor(Math.log(bytes) / Math.log(FILE_SIZE_STEP)), 0),

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — added the Math.max(..., 0) guard so the exponent can't go negative for a size in (0, 1), making the domain guard total rather than relying on File.size staying the only caller. See 3639c794b.


// Identity of a file for de-duplication and removal: two files with the same
// name, size, and last-modified time are treated as the same file.
const FILE_SIZE_UNITS = ['B', 'kB', 'MB', 'GB', 'TB'] as const;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 SI prefixes with a binary step.

2400 bytes renders 2.34 kB, but 2.34 is the kibibyte count — 2400 B is 2.4 kB in SI, or 2.34 KiB in binary.

The changeset is upfront that 1024 was kept so numbers don't shift for existing users, and the ticket's own suggested implementation did the same, so this is a knowingly-taken trade rather than a slip. Flagging it only so it stays a team decision rather than quietly becoming precedent for the next component that formats bytes: either label KiB/MiB, or move the step to 1000 and accept the number change. Fine as-is.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, no change made — same trade-off as the kB/binary-step decision above, so leaving it as-is per your note.

makeFile('report.pdf', 'application/pdf', 2 * 1024 * 1024),
]);

expect(screen.getByText('2.34 kB')).toBeInTheDocument();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 This assertion depends on the machine's locale.

There's no I18nProvider here, so 2.34 kB is asserted against whatever useLocale resolves from the environment: green in CI (en-US), red for a developer on a German-locale machine, who gets 2,34 kB. The de-DE test right below is correctly wrapped.

The file already carries this assumption in its older label tests, so it's pre-existing rather than introduced by this PR — but wrapping the two new assertions in <I18nProvider locale="en-US"> would make them say what they mean, and would state the contrast with the de-DE test explicitly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — wrapped that render in <I18nProvider locale="en-US"> so the assertion states its own locale instead of depending on the machine's. See 3639c794b.

await expect(canvas.getByText('2 MB')).toBeInTheDocument();
await expect(canvas.getByText('5 MB')).toBeInTheDocument();
await expect(canvas.getByText('512 kB')).toBeInTheDocument();
await expect(canvas.getByText('2.34 kB')).toBeInTheDocument();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Same ambient-locale coupling as FileField.test.tsx:2072.34 kB, 512 kB and the two MB assertions all pin en-US number formatting without an I18nProvider to guarantee it.

Slightly more consequential here than in the unit test, since pnpm test:sb runs in a real browser and picks up the developer's actual system locale.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — rather than hardcoding a locale into the shared UploadFile story's render (which would have shadowed the de-DE wrapper other tests apply around UploadFile.Component), scoped an en-US decorator to just this .test() call. See 3639c794b.


The step stays at 1024, which is what the field has always divided by, so a given file keeps the number it had before — only its unit and trailing zeros change (`0.50 MB` is now `512 kB`, `2.00 MB` is now `2 MB`).

The number is run through `Intl.NumberFormat` for the active locale, so a German consumer gets `2,34 kB` next to the field's already-localized labels. The unit symbol is not localized: `style: 'unit'` spells bytes out in its short form (`340 byte`), which reads inconsistently next to the abbreviated `kB` a row above it in the same list, and `B`/`kB`/`MB` are the same in every locale Marigold ships messages for.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 This paragraph is implementation rationale, not a changelog entry.

Why style: 'unit' was rejected is genuinely useful and shouldn't be lost — but consumers reading release notes aren't its audience. It belongs in a comment next to fileSizeFormatterFor, where the next person to reach for style: 'unit' will actually find it.

Paragraphs 1–2 plus the "same number, different unit" note are the changelog; four paragraphs is a lot for a patch.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — trimmed the changeset to the changelog-relevant paragraphs and moved the style: 'unit' rationale into a comment next to fileSizeFormatterFor in fileUtils.ts. Changeset trim landed in 3639c794b; the comment is now in 6db08bfef.

@github-actions

Copy link
Copy Markdown
Contributor

Accessibility tests executed. Download the report here.

…tionale

Both doc comments were dropped from the previous commit before it landed.
@github-actions

Copy link
Copy Markdown
Contributor

Accessibility tests executed. Download the report here.

};

const FILE_SIZE_UNITS = ['B', 'kB', 'MB', 'GB', 'TB'] as const;
const FILE_SIZE_STEP = 1024;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Picking this up because you left it to the team ("open to revisiting if the team prefers strict precision here"), so here is a decision rather than another flag.

I do not think the rationale holds. The argument for kB + 1024 was that it is "the label every OS file manager and most consumer software already shows". That describes Windows Explorer, which is binary — and which writes KB, not kB. The other places a user would compare against are decimal:

  • macOS Finder has been base-1000 since Snow Leopard, and writes kB
  • GNOME Files is base-1000 by default, and writes kB
  • Chrome and Firefox download UIs are base-1000

So the current pairing does not match either camp. It borrows the SI symbol from the decimal group and the divisor from the binary one, and the result is that a macOS user looking at the same 2,400-byte CSV sees 2.4 kB in Finder and 2.34 kB in this field. That is a worse outcome than either convention on its own, and it is the specific failure mode the "matches what users compare against" argument was trying to avoid.

My preference, in order:

  1. Move the step to 1000, keep kB/MB/GB/TB. Correct by SI, matches Finder, GNOME and both browsers, and needs one constant changed.
  2. KiB/MiB/GiB/TiB with the step at 1024. Also correct, less familiar.

The one argument for the status quo is the changeset's "a given file keeps the number it had before" — but that is already not true, and the changeset says so itself two lines later (0.50 MB is now 512 kB). The numbers are changing in this PR regardless; this is the cheapest moment to land on a defensible pair.

Worth settling properly because grep -rn "1024" across packages/ and docs/ finds no other byte formatting anywhere in the repo. Whatever ships here is the precedent, exactly as jim761 predicted.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taken option 1 — FILE_SIZE_STEP is now 1000 with kB/MB/GB/TB kept. You're right that the "matches what users compare against" argument pointed at the decimal camp all along, and that the "keeps the number it had before" property was already spent by this PR, so there was nothing left holding the pairing together.

What moved with it:

  • formatFileSize docblock and the constants comment now say why the step is 1000 (SI symbols, Finder/GNOME/browser download UIs)
  • fileUtils.test.ts cases re-derived on the decimal ladder: 2400 → 2.4 kB, 999 B/1 kB at the first boundary, and the rounding-boundary case is now [1000 ** 2 - 1, '1 MB']
  • FileField.test.tsx and the UploadFile story test use decimal fixture sizes (2_000_000, 512_000) so the expected strings stay clean; de-DE now asserts 2,4 kB
  • the changeset's "step stays at 1024" paragraph is replaced by the SI rationale, and states plainly that numbers shift against the old output (0.50 MB524.29 kB)

See 1655a44ab.

const FILE_SIZE_FRACTION_DIGITS = 2;
const FILE_SIZE_ROUNDING = 10 ** FILE_SIZE_FRACTION_DIGITS;

const fileSizeFormatters = new Map<string, Intl.NumberFormat>();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 This came out of jim761's "a new Intl.NumberFormat per file per render", which was a fair catch, but there is a smaller answer than a hand-rolled cache.

@react-aria/i18n exports useNumberFormatter, which memoizes formatters internally and reads the locale straight from context. This repo already uses it, in packages/system/src/components/Formatters/NumericFormat.tsx:39, so it is the established idiom rather than a new dependency.

Passing the formatter in keeps formatFileSize a pure, directly testable function while deleting fileSizeFormatters, fileSizeFormatterFor and the useLocale() import:

export const formatFileSize = (size: number, formatter: Intl.NumberFormat): string => {

and in the component:

const sizeFormatter = useNumberFormatter({ maximumFractionDigits: 2 });

The fileUtils tests then build a formatter per case instead of passing a locale string, which is barely more code and drops an unbounded module-level Map from the bundle. Entirely optional, the current version is correct.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — useNumberFormatter it is. fileSizeFormatters/fileSizeFormatterFor and the useLocale import are gone; the component does

const sizeFormatter = useNumberFormatter(FILE_SIZE_FORMAT_OPTIONS);

and formatFileSize(size, formatter) is now pure. Following the NumericFormat.tsx precedent rather than hand-rolling a cache was the right call.

One wrinkle worth naming: the boundary-rounding check inside formatFileSize derives FILE_SIZE_ROUNDING from maximumFractionDigits, so moving the formatter to the call site would have re-opened exactly the "magic 100 mirrors line 66" coupling from the earlier review, now across two files. So the options object is exported as FILE_SIZE_FORMAT_OPTIONS and the component passes that to useNumberFormatter — one source of truth, and a module-level constant keeps the identity stable for the hook's memo. The tests build their formatter from the same constant.

See 1655a44ab.

`kB`/`MB`/`GB`/`TB` are SI symbols, so pairing them with a 1024 step matched
neither convention: a macOS user saw `2.4 kB` in Finder and `2.34 kB` in the
field for the same CSV. The step moves to 1000, which also matches GNOME Files
and the browser download UIs.

`formatFileSize` now takes an `Intl.NumberFormat` instead of a locale string,
so the component can hand it `useNumberFormatter(FILE_SIZE_FORMAT_OPTIONS)` —
the idiom already used in `NumericFormat` — and the module-level formatter
cache plus the `useLocale` import are gone. The options object is exported so
`maximumFractionDigits` and the boundary-rounding check stay in lockstep.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Accessibility tests executed. Download the report here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type:feature New feature or component

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants