Skip to content

Commit e9e5404

Browse files
Merge pull request #1170 from objectstack-ai/copilot/fix-left-menu-prefix-issue
fix(studio): resolve router basepath at runtime for sub-path mounts
2 parents 1147e48 + 873fdc6 commit e9e5404

File tree

13 files changed

+150
-84
lines changed

13 files changed

+150
-84
lines changed

apps/server/CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# @objectstack/example-host
22

3+
## Unreleased
4+
5+
### Patch Changes
6+
7+
- **Unified Studio mount path to `/_studio/` for all deployments** (CLI embedded, Vercel, self-host).
8+
- `vercel.json`: studio SPA now serves under `/_studio/:path*` with a dedicated rewrite to `/_studio/index.html`. Root `/` and bare `/_studio` redirect to `/_studio/`. Asset caching headers scoped to `/_studio/assets/*`. `VITE_BASE=/_studio/` is set in `build.env`.
9+
- `scripts/build-vercel.sh`: studio dist is copied to `public/_studio/` (previously `public/`), so Vercel serves it under the same sub-path the CLI uses. This resolves the deep-link / sidebar-click routing failures that occurred when the Studio was mounted at the public root.
10+
311
## 4.0.4
412

513
### Patch Changes

apps/server/scripts/build-vercel.sh

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,16 @@ cd apps/server
2727
# 2. Bundle API serverless function
2828
node scripts/bundle-api.mjs
2929

30-
# 3. Copy studio dist files to public/ for UI serving
31-
echo "[build-vercel] Copying studio dist to public/..."
30+
# 3. Copy studio dist files to public/_studio/ for UI serving.
31+
# Studio is always mounted under /_studio/ (same convention as the CLI
32+
# static plugin). Vite builds with base: '/_studio/' so its asset URLs
33+
# and router basepath are already correct for this mount point.
34+
echo "[build-vercel] Copying studio dist to public/_studio/..."
3235
rm -rf public
33-
mkdir -p public
36+
mkdir -p public/_studio
3437
if [ -d "../studio/dist" ]; then
35-
cp -r ../studio/dist/* public/
36-
echo "[build-vercel] ✓ Copied studio dist to public/"
38+
cp -r ../studio/dist/. public/_studio/
39+
echo "[build-vercel] ✓ Copied studio dist to public/_studio/"
3740
else
3841
echo "[build-vercel] ⚠ Studio dist not found (skipped)"
3942
fi

apps/server/vercel.json

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
"build": {
77
"env": {
88
"VITE_RUNTIME_MODE": "server",
9-
"VITE_SERVER_URL": ""
9+
"VITE_SERVER_URL": "",
10+
"VITE_BASE": "/_studio/"
1011
}
1112
},
1213
"functions": {
@@ -17,14 +18,18 @@
1718
},
1819
"headers": [
1920
{
20-
"source": "/assets/(.*)",
21+
"source": "/_studio/assets/(.*)",
2122
"headers": [
2223
{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }
2324
]
2425
}
2526
],
27+
"redirects": [
28+
{ "source": "/", "destination": "/_studio/", "permanent": false },
29+
{ "source": "/_studio", "destination": "/_studio/", "permanent": false }
30+
],
2631
"rewrites": [
2732
{ "source": "/api/:path*", "destination": "/api/[[...route]]" },
28-
{ "source": "/((?!api/).*)", "destination": "/index.html" }
33+
{ "source": "/_studio/:path*", "destination": "/_studio/index.html" }
2934
]
3035
}

apps/studio/CHANGELOG.md

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,20 @@
44

55
### Patch Changes
66

7-
- Fix TanStack Router basepath resolution when Studio is mounted under a sub-path
8-
(e.g. `/_studio/` via the CLI `--ui` flag). Previously, routes such as
9-
`/_studio/packages` or `/_studio/:package/objects/:name` failed to match —
10-
the router treated the mount prefix as a `$package` route parameter, producing
11-
"Not Found" errors. The router now derives `basepath` from Vite's
12-
`import.meta.env.BASE_URL`, which works transparently for both root and
13-
sub-path deployments.
7+
- **Fix duplicate sidebar rendering on `/$package/objects/:name` and `/$package/metadata/:type/:name`.** Both the parent `$package.tsx` layout and its children rendered their own `<AppSidebar>` + `<main>` + `<SiteHeader>` shell. With TanStack Router's flat file routing, children render inside the parent's `<Outlet>` — producing a visible copy of the left sidebar in the right content pane instead of the metadata detail.
8+
- `$package.tsx` is now a pure layout: `<AppSidebar>` + `<main>` wrapper + `<Outlet>`. No `SiteHeader`.
9+
- New `$package.index.tsx` leaf handles the exact `/$package` URL, rendering `<SiteHeader selectedView="overview">` + `<DeveloperOverview>`.
10+
- `$package.objects.$name.tsx` and `$package.metadata.$type.$name.tsx` simplified to render only their `<SiteHeader>` + `<PluginHost>`; shell is inherited from the parent layout.
11+
- **Unified Studio mount path to `/_studio/` for all deployments.** The Vite
12+
build default is now `base: '/_studio/'` (was `'./'`), baking the correct
13+
absolute asset URLs and router basepath into every bundle. This removes the
14+
previous build-time/runtime ambiguity that required the host server to
15+
rewrite `href="/..."` URLs or inject a `window.__OBJECTSTACK_STUDIO_BASEPATH__`
16+
marker.
17+
- `resolveBasepath()` in `src/router.ts` simplified to rely solely on Vite's
18+
`import.meta.env.BASE_URL`, which now always yields `/_studio/` for
19+
production bundles and CLI dev (the CLI dev server sets
20+
`VITE_BASE=/_studio/`). Runtime `window` injection workaround removed.
1421

1522
## 4.0.4
1623

apps/studio/src/routeTree.gen.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { Route as PackagesRouteImport } from './routes/packages'
1313
import { Route as ApiConsoleRouteImport } from './routes/api-console'
1414
import { Route as PackageRouteImport } from './routes/$package'
1515
import { Route as IndexRouteImport } from './routes/index'
16+
import { Route as PackageIndexRouteImport } from './routes/$package.index'
1617
import { Route as PackageObjectsNameRouteImport } from './routes/$package.objects.$name'
1718
import { Route as PackageMetadataTypeNameRouteImport } from './routes/$package.metadata.$type.$name'
1819

@@ -36,6 +37,11 @@ const IndexRoute = IndexRouteImport.update({
3637
path: '/',
3738
getParentRoute: () => rootRouteImport,
3839
} as any)
40+
const PackageIndexRoute = PackageIndexRouteImport.update({
41+
id: '/',
42+
path: '/',
43+
getParentRoute: () => PackageRoute,
44+
} as any)
3945
const PackageObjectsNameRoute = PackageObjectsNameRouteImport.update({
4046
id: '/objects/$name',
4147
path: '/objects/$name',
@@ -52,14 +58,15 @@ export interface FileRoutesByFullPath {
5258
'/$package': typeof PackageRouteWithChildren
5359
'/api-console': typeof ApiConsoleRoute
5460
'/packages': typeof PackagesRoute
61+
'/$package/': typeof PackageIndexRoute
5562
'/$package/objects/$name': typeof PackageObjectsNameRoute
5663
'/$package/metadata/$type/$name': typeof PackageMetadataTypeNameRoute
5764
}
5865
export interface FileRoutesByTo {
5966
'/': typeof IndexRoute
60-
'/$package': typeof PackageRouteWithChildren
6167
'/api-console': typeof ApiConsoleRoute
6268
'/packages': typeof PackagesRoute
69+
'/$package': typeof PackageIndexRoute
6370
'/$package/objects/$name': typeof PackageObjectsNameRoute
6471
'/$package/metadata/$type/$name': typeof PackageMetadataTypeNameRoute
6572
}
@@ -69,6 +76,7 @@ export interface FileRoutesById {
6976
'/$package': typeof PackageRouteWithChildren
7077
'/api-console': typeof ApiConsoleRoute
7178
'/packages': typeof PackagesRoute
79+
'/$package/': typeof PackageIndexRoute
7280
'/$package/objects/$name': typeof PackageObjectsNameRoute
7381
'/$package/metadata/$type/$name': typeof PackageMetadataTypeNameRoute
7482
}
@@ -79,14 +87,15 @@ export interface FileRouteTypes {
7987
| '/$package'
8088
| '/api-console'
8189
| '/packages'
90+
| '/$package/'
8291
| '/$package/objects/$name'
8392
| '/$package/metadata/$type/$name'
8493
fileRoutesByTo: FileRoutesByTo
8594
to:
8695
| '/'
87-
| '/$package'
8896
| '/api-console'
8997
| '/packages'
98+
| '/$package'
9099
| '/$package/objects/$name'
91100
| '/$package/metadata/$type/$name'
92101
id:
@@ -95,6 +104,7 @@ export interface FileRouteTypes {
95104
| '/$package'
96105
| '/api-console'
97106
| '/packages'
107+
| '/$package/'
98108
| '/$package/objects/$name'
99109
| '/$package/metadata/$type/$name'
100110
fileRoutesById: FileRoutesById
@@ -136,6 +146,13 @@ declare module '@tanstack/react-router' {
136146
preLoaderRoute: typeof IndexRouteImport
137147
parentRoute: typeof rootRouteImport
138148
}
149+
'/$package/': {
150+
id: '/$package/'
151+
path: '/'
152+
fullPath: '/$package/'
153+
preLoaderRoute: typeof PackageIndexRouteImport
154+
parentRoute: typeof PackageRoute
155+
}
139156
'/$package/objects/$name': {
140157
id: '/$package/objects/$name'
141158
path: '/objects/$name'
@@ -154,11 +171,13 @@ declare module '@tanstack/react-router' {
154171
}
155172

156173
interface PackageRouteChildren {
174+
PackageIndexRoute: typeof PackageIndexRoute
157175
PackageObjectsNameRoute: typeof PackageObjectsNameRoute
158176
PackageMetadataTypeNameRoute: typeof PackageMetadataTypeNameRoute
159177
}
160178

161179
const PackageRouteChildren: PackageRouteChildren = {
180+
PackageIndexRoute: PackageIndexRoute,
162181
PackageObjectsNameRoute: PackageObjectsNameRoute,
163182
PackageMetadataTypeNameRoute: PackageMetadataTypeNameRoute,
164183
}

apps/studio/src/router.ts

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,13 @@ import { routeTree } from './routeTree.gen';
1313
/**
1414
* Compute the router basepath from Vite's `BASE_URL`.
1515
*
16-
* When Studio is mounted under a sub-path (e.g. `/_studio/` via the CLI `--ui`
17-
* flag, which sets `VITE_BASE=/_studio/`), TanStack Router must strip that
18-
* prefix before matching route patterns. Otherwise URLs such as
19-
* `/_studio/packages` are mis-interpreted as `/$package="_studio"/packages`.
16+
* Studio is always mounted under `/_studio/` the Vite build sets
17+
* `base: '/_studio/'` by default (see `vite.config.ts`), which makes
18+
* `import.meta.env.BASE_URL === '/_studio/'` for every production bundle
19+
* and `/_studio/` for the CLI dev server (which also sets `VITE_BASE`).
2020
*
21-
* Vite exposes the configured base as `import.meta.env.BASE_URL`:
22-
* - Root deployment: `'/'` → basepath `'/'` (no-op)
23-
* - Sub-path deployment: `'/_studio/'` → basepath `'/_studio'`
24-
*
25-
* TanStack Router expects the basepath WITHOUT a trailing slash (except for
26-
* the root `'/'`), so we normalise accordingly.
21+
* TanStack Router expects the basepath WITHOUT a trailing slash (except
22+
* for the root `'/'`), so we normalise accordingly.
2723
*/
2824
function resolveBasepath(): string {
2925
const base = (import.meta.env.BASE_URL ?? '/').trim();
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { createFileRoute } from '@tanstack/react-router';
4+
import { SiteHeader } from '@/components/site-header';
5+
import { DeveloperOverview } from '../components/DeveloperOverview';
6+
import { usePackages } from '../hooks/usePackages';
7+
8+
/**
9+
* Leaf route for the exact `/$package` URL — the package overview page.
10+
*
11+
* Owns its own `SiteHeader`; the layout shell (sidebar + main wrapper) is
12+
* provided by the parent `$package.tsx` route.
13+
*/
14+
function PackageIndexComponent() {
15+
const { packages, selectedPackage } = usePackages();
16+
17+
return (
18+
<>
19+
<SiteHeader
20+
selectedView="overview"
21+
packageLabel={selectedPackage?.manifest?.name || selectedPackage?.manifest?.id}
22+
/>
23+
<div className="flex flex-1 flex-col overflow-hidden">
24+
<DeveloperOverview
25+
packages={packages}
26+
selectedPackage={selectedPackage}
27+
/>
28+
</div>
29+
</>
30+
);
31+
}
32+
33+
export const Route = createFileRoute('/$package/')({
34+
component: PackageIndexComponent,
35+
});

apps/studio/src/routes/$package.metadata.$type.$name.tsx

Lines changed: 11 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,28 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { createFileRoute } from '@tanstack/react-router';
4-
import { AppSidebar } from '../components/app-sidebar';
54
import { SiteHeader } from '@/components/site-header';
65
import { PluginHost } from '../plugins';
76
import { usePackages } from '../hooks/usePackages';
87

98
function MetadataViewComponent() {
109
const { type, name } = Route.useParams();
11-
const { packages, selectedPackage, setSelectedPackage } = usePackages();
10+
const { selectedPackage } = usePackages();
1211

1312
return (
1413
<>
15-
<AppSidebar
16-
packages={packages}
17-
selectedPackage={selectedPackage}
18-
onSelectPackage={setSelectedPackage}
14+
<SiteHeader
15+
selectedMeta={{ type, name }}
16+
selectedView="metadata"
17+
packageLabel={selectedPackage?.manifest?.name || selectedPackage?.manifest?.id}
1918
/>
20-
<main className="flex min-w-0 flex-1 flex-col h-svh overflow-hidden bg-background">
21-
<SiteHeader
22-
selectedMeta={{ type, name }}
23-
selectedView="metadata"
24-
packageLabel={selectedPackage?.manifest?.name || selectedPackage?.manifest?.id}
19+
<div className="flex flex-1 flex-col overflow-hidden">
20+
<PluginHost
21+
metadataType={type}
22+
metadataName={name}
23+
packageId={selectedPackage?.manifest?.id}
2524
/>
26-
<div className="flex flex-1 flex-col overflow-hidden">
27-
<PluginHost
28-
metadataType={type}
29-
metadataName={name}
30-
packageId={selectedPackage?.manifest?.id}
31-
/>
32-
</div>
33-
</main>
25+
</div>
3426
</>
3527
);
3628
}

apps/studio/src/routes/$package.objects.$name.tsx

Lines changed: 11 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,28 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { createFileRoute } from '@tanstack/react-router';
4-
import { AppSidebar } from '../components/app-sidebar';
54
import { SiteHeader } from '@/components/site-header';
65
import { PluginHost } from '../plugins';
76
import { usePackages } from '../hooks/usePackages';
87

98
function ObjectViewComponent() {
109
const { name } = Route.useParams();
11-
const { packages, selectedPackage, setSelectedPackage } = usePackages();
10+
const { selectedPackage } = usePackages();
1211

1312
return (
1413
<>
15-
<AppSidebar
16-
packages={packages}
17-
selectedPackage={selectedPackage}
18-
onSelectPackage={setSelectedPackage}
14+
<SiteHeader
15+
selectedObject={name}
16+
selectedView="object"
17+
packageLabel={selectedPackage?.manifest?.name || selectedPackage?.manifest?.id}
1918
/>
20-
<main className="flex min-w-0 flex-1 flex-col h-svh overflow-hidden bg-background">
21-
<SiteHeader
22-
selectedObject={name}
23-
selectedView="object"
24-
packageLabel={selectedPackage?.manifest?.name || selectedPackage?.manifest?.id}
19+
<div className="flex flex-1 flex-col overflow-hidden">
20+
<PluginHost
21+
metadataType="object"
22+
metadataName={name}
23+
packageId={selectedPackage?.manifest?.id}
2524
/>
26-
<div className="flex flex-1 flex-col overflow-hidden">
27-
<PluginHost
28-
metadataType="object"
29-
metadataName={name}
30-
packageId={selectedPackage?.manifest?.id}
31-
/>
32-
</div>
33-
</main>
25+
</div>
3426
</>
3527
);
3628
}

apps/studio/src/routes/$package.tsx

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,19 @@
22

33
import { createFileRoute, Outlet } from '@tanstack/react-router';
44
import { AppSidebar } from '../components/app-sidebar';
5-
import { SiteHeader } from '@/components/site-header';
65
import { usePackages } from '../hooks/usePackages';
76
import { useEffect } from 'react';
87

8+
/**
9+
* Layout for every `/$package/*` route.
10+
*
11+
* Renders the persistent left `AppSidebar` and the main content frame, and
12+
* delegates the `SiteHeader` + body rendering to the child leaf routes via
13+
* `<Outlet />`. Keeping the header in the children lets each leaf (index,
14+
* object view, metadata view) provide accurate breadcrumbs without prop-
15+
* drilling. It also prevents the duplicated-shell bug that occurred when
16+
* both this layout and its children each rendered their own `AppSidebar`.
17+
*/
918
function PackageLayoutComponent() {
1019
const { package: packageId } = Route.useParams();
1120
const { packages, selectedPackage, setSelectedPackage } = usePackages();
@@ -26,13 +35,7 @@ function PackageLayoutComponent() {
2635
onSelectPackage={setSelectedPackage}
2736
/>
2837
<main className="flex min-w-0 flex-1 flex-col h-svh overflow-hidden bg-background">
29-
<SiteHeader
30-
selectedView="overview"
31-
packageLabel={selectedPackage?.manifest?.name || selectedPackage?.manifest?.id}
32-
/>
33-
<div className="flex flex-1 flex-col overflow-hidden">
34-
<Outlet />
35-
</div>
38+
<Outlet />
3639
</main>
3740
</>
3841
);

0 commit comments

Comments
 (0)