Skip to content

feat(applications): warn before a download too large for the browser - #1617

Merged
dawsontoth merged 3 commits into
stagefrom
claude/download-app-size-warning
Aug 13, 2026
Merged

feat(applications): warn before a download too large for the browser#1617
dawsontoth merged 3 commits into
stagefrom
claude/download-app-size-warning

Conversation

@dawsontoth

Copy link
Copy Markdown
Contributor

Addresses the Studio half of #1591. The server half is HarperFast/harper#2152 (closes HarperFast/harper#2150).

Why the tab dies

The bytes arrive fine — the renderer dies decoding them. package_component returns the whole archive as base64 in JSON, and the modal then made four more full-size copies:

const bytes = Uint8Array.from(atob(response.payload), c => c.charCodeAt(0));
const file = new Blob([bytes], { type: 'application/gzip' });

axios buffers the response text → JSON.parse copies the base64 string → atob copies again → Uint8Array.from(str, callback) runs a JS callback per byte on the main thread → Blob copies once more. Roughly 5× the archive inside one renderer, and both strings are subject to V8's 512 MiB cap in the browser too. Hence "Something went wrong while displaying this webpage" with no explanation.

Streaming the archive end to end is harper#2150. This PR does not consume the stream — that needs a Harper build with #2152 in it to verify against, so it's deliberately left as the follow-up. What ships here works against every Harper version, today.

What this does

Says the size up front. The modal now reads About 5 MB across 159 files. before you commit to anything. It's measured from the get_components tree Studio already has cached, so it costs no extra request. That tree omits node_modules, which makes the total an exact match for the modal's default (skip_node_modules: true) and a floor once the box is ticked.

Warns past 100 MB — or whenever node modules are included, where the measured total is only a floor — with the reason and the alternative, and relabels the button to "Download anyway":

This is a large download
Studio has to hold the whole archive in browser memory before it can be saved, so the tab may run out of memory and crash partway through.
Copying the application off the host directly — scp, or the Harper CLI — is more reliable at this size.

Deliberately one tier, and deliberately not a block. A hard ceiling would have to be guessed from an uncompressed total, and a large tree of compressible source still downloads fine — so the decision stays with the user. This is the warning @jjohnson-hdb asked for in #1591, and "don't do that" really is part of the right answer at 800 MB.

Reports failures instead of hanging. Two paths previously left the "Packaging..." toast spinning forever with no verdict: a failed mutation had no onError at all, and a decode throw escaped into react-query. Both now resolve the toast into a real error.

The bug browser verification caught

calculateRootEntries rebuilds every node from scratch and was silently dropping the size the API sends. Against a real instance the modal first rendered "At least 0 B across 159 files" — 159 files found, every size gone. size was also undeclared on APIFileEntry even though Harper has long sent it. Both fixed; mtime is dropped the same way but nothing reads it, so I left it alone.

Verification

Against the Anvils stage cluster on a real application, serving this worktree on :5173:

Default About 5 MB across 159 files. — no warning, button reads "Download"
Include Node Modules …, plus node modules. — warning shown, button reads "Download anyway"
Unticked again warning clears
Actual download "Download ready!" — the happy path is intact through the new decode

Checked in light and dark. Confirmed against the raw operation response that the server really does send per-file size (which is what identified the mapping bug rather than blaming the instance).

New tests: projectPackageSize.test.ts (9) and DownloadApplicationModal.test.tsx (8), covering the size text, the two warning triggers, the button relabel, that the request is never blocked, and both failure paths. Full suite green — 287 files, 2203 passing. (The 4 vitest "errors" in the summary come from ToolCallGroup.test.tsx and reproduce on a clean stage checkout.)

Decoding now allocates once and fills in a loop rather than the per-byte callback — still O(n) on top of atob, but strictly cheaper, and it matches pemToDer in lib/crypto/envSecret.ts.

Follow-up

Once #2152 ships, Studio can request stream: true and pick the shape off the response content-type, so one code path works against both old and new instances. Two things to handle there: axios buffers (this needs fetch), and Fabric Connect caps bodies at 2 MB and can't stream at all, so proxy-mode instances need an explicit message — resolveInstanceConnection already reports mode: 'direct' | 'proxy'. harper#2150 also carries an estimate mode that would close the include-node_modules blind spot in the sizing above.

🤖 Generated with Claude Code

…1591]

Downloading a large application killed the Chrome tab with a generic
"Something went wrong while displaying this webpage" and no explanation.
The bytes arrive fine — the renderer dies decoding them. `package_component`
returns the whole archive as base64 in JSON, and the modal then made four more
full-size copies of it: axios buffers the response text, `JSON.parse` copies
the base64 string, `atob` copies again, `Uint8Array.from(str, cb)` runs a JS
callback per byte on the main thread, and `Blob` copies once more. Roughly 5x
the archive inside one renderer, with both strings also subject to V8's 512 MiB
cap.

Streaming the archive is HarperFast/harper#2150. Until that lands (and for
older instances after it does) the honest fix is to say the size up front:

- Show the package size and file count in the modal before anything is
  packaged, measured from the `get_components` tree Studio already has cached.
  That tree omits `node_modules`, so the total is an exact match for the
  default `skip_node_modules: true` and a floor once the box is ticked.
- Warn past 100 MB — or whenever node modules are included, where the measured
  total is only a floor — explaining why the tab may die and pointing at scp /
  the Harper CLI. The button becomes "Download anyway"; nothing is blocked,
  since a large tree of compressible source can still download fine.
- Report the two failures that previously left the "Packaging..." toast
  spinning forever with no verdict: a failed mutation had no `onError` at all,
  and a decode throw escaped into react-query.

`calculateRootEntries` rebuilds every node from scratch and was dropping the
`size` the API sends, which is why this reported "0 B" against a real instance
until fixed — carry it through. `size` was likewise undeclared on
`APIFileEntry`.

Decoding now allocates once and fills in a loop instead of the per-byte
callback. Still O(n) on top of `atob`, but strictly cheaper, and it matches
`pemToDer` in lib/crypto/envSecret.ts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@dawsontoth
dawsontoth requested a review from a team as a code owner August 12, 2026 19:22
@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 55.07% 6606 / 11994
🔵 Statements 55.68% 7106 / 12762
🔵 Functions 46.92% 1610 / 3431
🔵 Branches 48.82% 4557 / 9333
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/features/instance/applications/components/ApplicationsSidebar/calculateRootEntries.ts 100% 91.66% 100% 100%
src/features/instance/applications/lib/projectPackageSize.ts 100% 100% 100% 100%
src/features/instance/applications/modals/DownloadApplicationModal.tsx 97.61% 87.87% 100% 97.56% 66
src/integrations/api/instance/applications/getComponents.ts 0% 100% 0% 0% 33-42
Generated in workflow #1713 for commit ee0c657 by the Vitest Coverage Report Action

@gemini-code-assist gemini-code-assist Bot 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.

Code Review

This pull request introduces pre-download size estimation and warnings for the Download Application flow to prevent browser crashes on large packages. It adds a utility to measure project package sizes from the cached file tree, displays estimated sizes and warnings in the download modal, improves base64 decoding performance, and adds robust error handling for packaging and decoding failures. Comprehensive unit and integration tests have been added to verify these changes. I have no feedback to provide as there are no review comments.

The coverage report flagged `calculateRootEntries.ts` at 0% — the one file in
this branch that had a real bug in it. It rebuilds every node from scratch
rather than spreading the API entry, so any field it doesn't name is silently
dropped, which is how `size` went missing and made the download modal report
"At least 0 B across 159 files" against an instance that was sending a size on
every file. Nothing about that is visible from the types, since the field is
optional, so it needs a test to stay fixed. Verified non-vacuous: removing
`size: node.size` fails the first case.

Also cover the two wording branches the modal picks between — "At least" when a
file carries no size, and the singular "1 file" — the first being exactly what
a caller sees against an instance that doesn't report sizes.

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

@kriszyp kriszyp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Sounds good!

Comment thread src/features/instance/applications/modals/DownloadApplicationModal.tsx Outdated
…rge [#1591]

Review feedback (kriszyp): the warning keyed off `measured.bytes >
LARGE_PACKAGE_BYTES`, which quietly treats an unmeasurable project as a safe
one. An instance that reports no file sizes measures to `{ bytes: 0, exact:
false }`, so an 800 MB application showed "At least 0 B", no warning, and a
plain "Download" button — reproducing the exact tab crash this change exists to
prevent, on precisely the older instances least likely to have the streamed
download from HarperFast/harper#2150. That state is reachable in practice: it
is what the modal rendered against a live cluster before the size carry-through
was fixed earlier in this branch.

Replace the boolean with `packageCaution()`, which returns 'large',
'unmeasured', or undefined. Absence of a measurement now earns its own caution
state — "This download's size is unknown", explaining that the instance didn't
report sizes — rather than suppressing the warning. Unmeasured outranks large,
since "we don't know" is the honest message even when a partial total happens
to clear the threshold.

Also stop rendering "0 B": quoting a total of zero dresses up a complete
absence of information as a reassuringly small number. The file count is real,
so an unmeasured project reads "159 files of unreported size".

Verified against a live cluster with sizes stripped from the response: the new
state renders as intended and the button reads "Download anyway". All four new
tests fail without the guard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@dawsontoth
dawsontoth added this pull request to the merge queue Aug 13, 2026
Merged via the queue into stage with commit db31bb0 Aug 13, 2026
2 checks passed
@dawsontoth
dawsontoth deleted the claude/download-app-size-warning branch August 13, 2026 17:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

package_component: stream the tarball instead of returning it as base64 JSON

2 participants