fix/apps-corner-cases - #877
Merged
Merged
Conversation
navigateToView early-returns when the apps view is already showing, so the sidebar item was a no-op while an app was open. Reset the initial folder and bump the version (mirrors onOpenBgTasks); AppsView drops its selection on a null folder. Also key AppFrame by folder so switching apps remounts the frame, and rename the header title from 'Mini Apps' to 'Apps'.
Opening an app whose dist/index.html is missing rendered a raw
{"error":{"code":"not_found"}} blob: the entry has an .html extension, so the
extensionless SPA-fallback branch never caught it and the request fell through
to the asset error. Catch the entry explicitly. Same for a deleted or renamed
app folder — a document navigation (the frame reloading) now gets the error
page while asset/XHR requests keep the JSON error.
cleanInstallTmp was exported but never called, so every cancelled URL-install preview and every failed download left an app-install-*/app-update-* dir with a partial bundle.zip in ~/.rowboat/tmp forever. Call it once at startup, fire-and-forget so it can never block or fail launch.
prakhar1605
force-pushed
the
fix/apps-corner-cases
branch
from
August 20, 2026 19:27
7706773 to
9dcb5c9
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A pass over the Apps section fixing corner cases — situations that don't come up
in the happy path, so they were never exercised: navigating back to the grid
from inside an app, switching between apps, apps whose files are missing or
half-written, apps whose own markup collides with what the server injects,
installs that lose the network mid-download, and staging directories that were
never cleaned up.
These were found by working through the Apps section manually rather than from a
single bug report. Each commit is independent and can be reviewed on its own.
This PR is a draft — more fixes from the same pass are still landing. The list of
what's still outstanding is at the bottom.
1. Sidebar "Apps" was a no-op while an app was open
What was wrong. With an app open, clicking "Apps" in the sidebar did nothing.
The only way back to the grid was the toolbar's "← Apps" button. A person who
reached for the sidebar just saw a dead click.
Why.
isAppsOpenis already true in that state, socurrentViewStateisalready
{ type: 'apps' }.navigateToViewcompares withviewStatesEqual,finds them equal, and early-returns —
applyViewStatenever runs,AppsViewnever receives new props, and its internal
selectedFolderkeeps pointing at theopen app. The bg-tasks view doesn't have this problem because
onOpenBgTasksalready clears its slug and bumps a version counter.
How it's fixed. A sibling helper
openAppsGridmirrors that pattern: clearappInitialId, bumpappIdVersion, then callopenAppsView. Used at the threecall sites that mean "show me the grid" — the sidebar item,
navigateToNamedView's'apps'case, and the product tour. Theopen-apppathand
onOpenAppare deliberately untouched; those target a specific app andalready work.
AppsView's adjust-during-render block now doessetSelectedFolder(initialAppFolder ?? null)instead ofif (initialAppFolder) setSelectedFolder(initialAppFolder), so a null folderactually clears the selection rather than being ignored.
Also in this commit.
AppFrameis now keyed bykey={selected.folder}.Without it, switching from app A to app B (via a pinned sidebar entry or
open-app) reused the same component instance, soshowDetail,showPublishand
reloadNoncecarried over — with the publish dialog open on app A, switchingto B left it open and pointed at B. The header title for the apps view also said
"Mini Apps" while every other surface said "Apps".
Files:
apps/renderer/src/App.tsx,apps/renderer/src/components/apps/apps-view.tsx2. A raw JSON error blob where the app should be
What was wrong. Opening an app whose
dist/index.htmlwas missing rendered{"error":{"code":"not_found","message":"asset not found"}}as plain text in theapp pane. Same class of failure after an app's folder was deleted or renamed
while it was open — the frame reloaded and showed a JSON blob for a second or two
before the grid took over.
Why this matters more than it looks. This is the visible face of a
half-written app. The copilot writes
rowboat-app.jsonand createsdist/, thenfails before writing the entry — and the person opens the app and gets an error
blob. From the outside it reads as "the app didn't get built", which is exactly
the shape of the failure reports we've had.
Why it happened.
handleStaticmaps/to/${manifest.entry}, i.e.index.html. The friendly "App entry not found" page lives behindif (!path.extname(resolved))— the SPA fallback branch for extensionless paths.index.htmlhas an extension, so that branch is skipped and the request fallsthrough to the JSON asset error at the end of the function. The friendly page
existed but was unreachable for the one case it was written for: you could only
see it by requesting an extensionless path.
How it's fixed. Two changes in
server.ts:handleStatic, before the finalsendError, the resolved path is comparedagainst the manifest entry. If they match, the entry itself is missing and the
friendly page is returned regardless of extension.
GETwithAccept: text/html) gets an "App not found" page explaining the folder mayhave been deleted or renamed. Asset and XHR requests keep the JSON error — app
code calling
fetchstill needs the machine-readable shape.Deciding by
Acceptrather than by path keeps the two audiences separate: thebrowser frame gets a page, the app's own JavaScript gets JSON.
File:
packages/core/src/apps/server.ts3. Losing the network mid-install hung forever
What was wrong. Turning off Wi-Fi during an install left the UI on
"Installing…" indefinitely — no error, no timeout, no way out except restarting
the app.
Why.
downloadBundlehad no timeout anywhere.fetchitself can stall onconnect, and the streaming read loop is worse: the socket stays half-open, so
reader.read()never settles and the loop parks on it forever at whatever bytecount it had reached.
How it's fixed. Two bounds, because one value can't serve both purposes — a
short one would kill legitimate large bundles on slow links, a long one would
leave a dead connection hanging for minutes:
DOWNLOAD_CONNECT_TIMEOUT_MS(20s) for the initial response.DOWNLOAD_TOTAL_TIMEOUT_MS(180s) for the whole transfer.A single
AbortControllerdrives both; on abort the reader is cancelled, thepartial
bundle.zipis removed, and anInstallError('download_failed', …)surfaces in the catalog's error banner. Hashing, the compressed-size cap and the
write stream are unchanged.
File:
packages/core/src/apps/installer.ts4. Install stagings were never cleaned up
What was wrong.
~/.rowboat/tmp/app-install-*andapp-update-*directoriesaccumulated across launches, each holding a bundle zip.
Why.
cleanInstallTmpwas written and exported but never called fromanywhere.
installFromRegistryandupdateAppclean their own staging in afinally, but the URL-install flow retains its staging by design — it istwo-phase, and preview must survive until the person confirms. Cancel the preview
and that directory is orphaned; the in-memory
urlStagingsmap is gone on thenext launch, so nothing can ever find it again. A download that fails or times
out (see #3) leaves the same residue.
How it's fixed.
cleanInstallTmp()is called once at startup, immediatelybefore
initAppsServer(), fire-and-forget with a logged catch so it can neverblock or fail launch. It only removes entries matching the two known prefixes,
never the whole tmp directory.
File:
apps/main/src/main.ts5. The live-reload bootstrap landed in the middle of the page
What was wrong. An app whose markup contains a literal
</body>before thedocument end — an HTML template inside a
<textarea>, an HTML string inside a<script>— got the injected reload bootstrap spliced into that text. The scriptsource showed up as visible junk on the page, and live reload stopped working for
that app entirely.
Why.
injectBootstrapusedString.replacewith a non-global regex, whichreplaces the first match. For every normal app the first
</body>is also thelast one, so this was invisible until an app quoted HTML in its own content.
How it's fixed. Scan for the last
</body>and splice there; fall back toappending when the document has none. Uses a
/giregex loop rather thantoLowerCase()+lastIndexOf— case mapping can change string length andmisalign the index.
File:
packages/core/src/apps/server.ts6. Symlinked app folders were invisible in the grid
What was wrong. An app folder that is a symlink (linking a dev checkout into
~/.rowboat/apps) never appeared in the grid, while the server happily served it—
curlagainst its origin returned 200 and the app rendered. An app you couldopen by URL but never see.
Why.
listAppsfilters onentry.isDirectory(), and aDirentreports asymlink as a symlink, not a directory, so the entry was skipped before it was
ever summarized. The server takes a different path — it
existsSynces theresolved directory — so the two disagreed.
How it's fixed. When the entry is a symlink,
statthe target and treat it asa directory if it resolves to one. Everything downstream (slug validation,
manifest parsing, the
dist/guard) is unchanged, and a broken link still failsclosed.
File:
packages/core/src/apps/indexer.ts7. Any file change in the app folder forced a full page reload
The watcher bucketed everything not under
data/as "dist", soREADME.md,.rowboat-install.json,.rowboat-publish.json(rewritten at every publishstep),
agents/and.previous/all reloaded the open app — discardingwhatever the person had typed, for files the app never reads. Now only
dist/,data/and the manifest trigger a reload. Verified by hand: a README append nolonger reloads, a
dist/edit and adata/write still do.File:
packages/core/src/apps/server.tsHow to test
Build and run:
cd apps/x && npm run deps && npm run devNavigation
grid should come back.
detail panel with the "i" button, then click app B in the sidebar → B opens
with a fresh panel. Repeat with the Publish dialog open on A → it should close
rather than follow you to B.
<name>app" → still opens that app directly.