Skip to content

fix(studio): a stale project id in the URL no longer blocks editing for the tab - #4188

Open
vanceingalls wants to merge 1 commit into
mainfrom
studio-hash-project-validate
Open

vanceingalls wants to merge 1 commit into
mainfrom
studio-hash-project-validate

Conversation

@vanceingalls

@vanceingalls vanceingalls commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator

The bug

The project id in the location hash is user-supplied and outlives the project it names. Rename the folder, or open a bookmark from a project that's gone, and it points at nothing.

useServerConnection trusted it unconditionally:

if (hashProjectId) {
  setProjectId(hashProjectId);   // never checked
  setWaitingForServer(false);
}

So every later /api/projects/<id>/... request 404s for the life of the tab — including the composition read that opens the SDK session. Studio falls back to the server path for every edit, and the resolver shadow never runs either, so nothing records why.

How it was found

The stage: read reason split shipped in v0.8.51. Within 24 hours it named the cause:

version stage reason status events users
0.8.51 read http_error 404 120 5
0.8.51 read absent_or_empty 10 4

~24 events per user, none recovering — the shape of a bad hash that never gets rewritten. A missing file answers 200 + "" and shows as absent_or_empty, so these are genuinely unresolved projects, not missing compositions.

On this route a 404 has exactly one origin — adapter.resolveProject returned null. The other failure modes answer 403, and the GET doesn't set mustExist.

The fix

Validate through GET /api/projects/:id, which calls the same adapter.resolveProject the file routes use. If it's gone, fall back to the first project and rewrite the hash — the path a hashless load already takes.

Why not match against the /api/projects list (which the same code already fetches): that would be wrong twice over.

  • It omits session ids, which resolveProject resolves fine via sessions/<id>.json.
  • It skips project dirs without an index.html/<name>.html.

Either would turn a working deep link into a silent redirect.

The check is tri-state on purpose. Only a definite 404 counts as missing; a rejected request or a 5xx returns unknown and keeps the hash. A network blip must not rewrite a valid deep link out from under the user.

Before

Loading #project/deleted-project — a hash id the server cannot resolve (GET /api/projects/deleted-project → 404). The hash is left untouched, the file tree never loads, and the composition read 404s for the life of the tab: "No compositions found", 00:00/00:00.

before

After

Same URL, same server. The unresolvable id is detected, Studio falls back to the real project and rewrites the hash to #project/demo-capture — tree loaded, index.html open, 00:00/00:03.

after

Verification

  • 4 new tests: resolvable hash kept, gone hash falls back + hash rewritten, rejected check keeps hash, 5xx keeps hash.
  • Mutation-tested both directions. Disabling the fallback fails the gone-hash test; collapsing unknown into missing fails both keep-the-hash tests.
  • packages/studio: 99 files, 843 tests pass. tsc --noEmit clean, oxlint 0/0, oxfmt clean.

Note on the remaining absent_or_empty

The other 10 events are not covered here and I'm not guessing at them. The obvious cause — a stale comp path in the same URL — is already guarded: useHydrateActiveCompPathFromUrl runs normalizeStudioCompositionPath(urlPath, fileTree) against the loaded tree. So that class needs its own root-cause rather than a speculative fix.

🤖 Generated with Claude Code

@mintlify

mintlify Bot commented Sep 19, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated
hyperframes 🟢 Ready View Preview Sep 19, 2026, 8:45 PM

💡 Tip: Enable Automations to automatically generate PRs for you.

…or the tab

The project id in the location hash is user-supplied and outlives the project
it names: rename the folder, or open a bookmark from a project that is gone,
and it points at nothing. useServerConnection trusted it unconditionally, so
every later /api/projects/<id>/... request 404'd for the life of the tab —
including the composition read that opens the SDK session. Studio then fell
back to the server path for every edit, and the resolver shadow never ran
either, so nothing recorded why.

Telemetry, 24h after the read-reason split shipped in v0.8.51: 120
`stage: read` / `http_error` 404 reads across 5 users, about 24 each, none
recovering. On this route a 404 has exactly one origin — resolveProject
returned null — since the other failure modes answer 403 and the GET does not
set mustExist.

Validates through /api/projects/:id, which calls the same
adapter.resolveProject the file routes use. Matching against the
/api/projects list instead would be wrong twice over: that list omits session
ids, which resolve fine, and skips project dirs without an index.html.

The check is tri-state on purpose. Only a definite 404 counts as missing; a
rejected request or a 5xx keeps the hash, so one network blip cannot rewrite
a valid deep link out from under the user.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

1 participant