Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/host-redirect-canonical-metadata-3639.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@object-ui/console': patch
'@object-ui/app-shell': patch
---

Send the console host's legacy URL redirects straight to the canonical metadata-admin routes instead of routing them through the deprecated `component/metadata/resource` alias (objectui#3639).

`apps/console`'s `ObjectRedirect` and `MetadataRedirect` rewrote `system/objects[/:name]` and `system/metadata[/:type[/:name]]` onto `…/component/metadata/resource[/:name]?type=:type`. app-shell declares that spelling as a legacy *alias*, not a page: its route element is `LegacyMetadataRedirect`, which immediately navigates on to `…/metadata/:type[/:name]`. Every one of those URLs therefore took two `<Navigate>` hops (plus a re-render) to reach a destination the host could name directly — and it was this indirection that carried `sys-objects` into the zero-app blank screen fixed in objectui#3610, since the alias was the leg that branch did not recognise.

Both redirects now construct `…/metadata/:type[/:name]` (and `…/metadata` for the typeless directory arm) themselves. The endpoints are unchanged, byte for byte, including the alias hop's own percent-encoding of `:type` and its verbatim pass-through of `:name`; only the intermediate hop is gone. The alias routes stay declared exactly as they were — bookmarks, external links and the setup left-nav still arrive on them and are still forwarded — this change only stops the console feeding its own traffic through them.

Also corrects four docblocks that described the alias as "the engine route", in `apps/console`'s two redirects and in app-shell's `datasource` resource registration and page. That wording is not merely stale: the objectui#3610 dispatch read this chain and concluded `component/metadata/resource` was the canonical spelling, which is the exact opposite of what the route table says.
57 changes: 41 additions & 16 deletions apps/console/src/AppContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,43 +45,68 @@ const DocsLayout = lazy(() => import('./pages/DocsLayout'));
// available to every host (including framework/console).

/**
* Forwards legacy `system/objects/:objectName` URLs to the metadata-admin
* engine's edit route, preserving the active-app prefix. The engine route is
* `…/component/metadata/resource/<name>?type=object`.
* Forwards legacy `system/objects[/:objectName]` URLs to the metadata-admin
* engine, preserving the active-app prefix. The engine routes are the
* REST-style `…/metadata/object/:name` (edit) and `…/metadata/object` (list),
* declared by `DefaultAppContent` in `@object-ui/app-shell`.
*
* NOT `…/component/metadata/resource/:name?type=object` (objectui#3639).
* That spelling pre-dates the REST-style nesting and is no longer a page at
* all: app-shell declares it as a legacy *alias* whose element is
* `LegacyMetadataRedirect`, i.e. a second `<Navigate>` onto precisely the
* target built below. Emitting it here bought a redundant hop and, worse,
* documented the alias as if it were canonical. The alias route itself stays
* (bookmarks and external links still land on it) — we simply stop feeding it
* from our own code.
*/
function ObjectRedirect() {
const { objectName } = useParams<{ objectName?: string }>();
const location = useLocation();
const prefix = location.pathname.replace(/\/(system\/)?objects(\/.*)?$/, '');
const target = objectName
? `${prefix}/component/metadata/resource/${objectName}?type=object`
: `${prefix}/component/metadata/resource?type=object`;
? `${prefix}/metadata/object/${objectName}`
: `${prefix}/metadata/object`;
return <Navigate to={target} replace />;
}

/**
* Forwards legacy `system/metadata/:metadataType[/:itemName]` URLs to the
* metadata-admin engine. The legacy page-based editor was removed once the
* server's `/api/v1/meta` endpoint started emitting JSON Schema per type,
* letting the engine render every type generically.
* Forwards legacy `system/metadata[/:metadataType[/:itemName]]` URLs to the
* metadata-admin engine's own REST-style routes: `…/metadata` (directory),
* `…/metadata/:type` (one type's list), `…/metadata/:type/:name` (one item).
* The legacy page-based editor was removed once the server's `/api/v1/meta`
* endpoint started emitting JSON Schema per type, letting the engine render
* every type generically.
*
* Those really are the engine's routes now — unlike the `component/metadata/…`
* spelling this used to emit, which is itself a legacy alias that only
* `<Navigate>`s onto the same destinations (objectui#3639). Two notes on
* staying byte-identical to what that alias hop produced: `:type` is
* percent-encoded (the alias carried it through `?type=` and the alias hop
* encoded it back into the path), while `:name` is passed through verbatim
* (the alias hop forwarded its path tail untouched).
*/
function MetadataRedirect() {
const { metadataType, itemName } = useParams<{ metadataType?: string; itemName?: string }>();
const location = useLocation();
// Strip an optional leading `system/` too — legacy nav uses
// `…/system/metadata/:type`, and the engine route lives at the app root
// (`…/component/metadata/resource`), not under `system/`.
// `…/system/metadata/:type`, and the engine routes live at the app root
// (`…/metadata/…`), not under `system/`.
const prefix = location.pathname.replace(/\/(system\/)?metadata(\/.*)?$/, '');
const base = `${prefix}/component/metadata/resource`;
const base = `${prefix}/metadata`;
const target = !metadataType
? `${prefix}/component/metadata/directory`
? base
: itemName
? `${base}/${itemName}?type=${metadataType}`
: `${base}?type=${metadataType}`;
? `${base}/${encodeURIComponent(metadataType)}/${itemName}`
: `${base}/${encodeURIComponent(metadataType)}`;
return <Navigate to={target} replace />;
}

const systemRoutes = (
// Exported for `__tests__/AppContent.legacyRedirects.test.tsx`, which mounts
// this exact fragment in a bare `MemoryRouter` to measure the redirect chain.
// Transcribing the routes into the test instead would let the copy drift from
// the original — which is precisely how the `component/metadata/resource`
// spelling survived here long after it stopped being canonical (objectui#3639).
export const systemRoutes = (
<>
<Route path="system" element={<Suspense fallback={<LoadingScreen />}><SystemHubPage /></Suspense>} />
<Route path="system/apps" element={<Suspense fallback={<LoadingScreen />}><AppManagementPage /></Suspense>} />
Expand Down
180 changes: 180 additions & 0 deletions apps/console/src/__tests__/AppContent.legacyRedirects.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The console host's legacy URL redirects land on the CANONICAL metadata-admin
* routes in one hop, never on the deprecated alias (objectui#3639).
*
* ## What was wrong
*
* `ObjectRedirect` and `MetadataRedirect` rewrote onto
* `…/component/metadata/resource[/:name]?type=:type`. app-shell declares that
* spelling as a legacy *alias* — its route element is `LegacyMetadataRedirect`,
* which immediately `<Navigate>`s onto `…/metadata/:type[/:name]`. So every
* `system/metadata/*` and `system/objects/*` URL took two hops to a place the
* host could name directly, and both docblocks called the alias "the engine
* route", which is how the #3610 dispatch came to believe `component/…` was
* canonical (it is the opposite).
*
* ## What these tests measure
*
* `ChainRecorder` records every distinct location the router settles on, so the
* assertion is about the *chain*, not only its endpoint. Endpoint-only
* assertions cannot tell a one-hop rewrite from a two-hop one — both finish at
* `…/metadata/object` — which is exactly the property this issue is about.
*
* The route table below declares the canonical routes AND the alias routes as
* terminal probes. That is what makes the direction falsifiable: restore either
* redirect's old target and the alias probe renders, the canonical probe does
* not, and the recorded chain ends on `component/metadata/resource`.
*
* `systemRoutes` is imported from the module under test rather than transcribed
* here on purpose. app-shell's own `AppContent.noAppComponentRoutes.test.tsx`
* had to copy `MetadataRedirect` (it cannot import across Vitest projects) and
* annotated the copy "keep identical to the original" — a copy is exactly what
* this file must not become.
*
* ## Scope
*
* This measures the HOST's rewrite only. What app-shell then does with the
* alias (`LegacyMetadataRedirect`) is pinned by app-shell's own tests; the
* alias route is deliberately kept alive for bookmarks and external links, and
* nothing here asks for its removal.
*/

import '@testing-library/jest-dom/vitest';
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { MemoryRouter, Routes, Route, useLocation, useParams } from 'react-router-dom';

import { systemRoutes } from '../AppContent';

/**
* Records every distinct location the router settles on. `<Navigate replace>`
* re-renders the tree once per hop, so a two-hop chain shows up as three
* entries and a one-hop chain as two.
*/
function ChainRecorder({ sink }: { sink: string[] }) {
const location = useLocation();
const here = `${location.pathname}${location.search}`;
if (sink[sink.length - 1] !== here) sink.push(here);
return null;
}

/** Terminal probe: reports which route matched and with which params. */
function Probe({ id }: { id: string }) {
const params = useParams();
return <div data-testid={id}>{JSON.stringify(params)}</div>;
}

/**
* The destinations both spellings compete for. `metadata/*` mirrors app-shell's
* canonical metadata-admin routes; `component/metadata/*` mirrors the legacy
* aliases it declares alongside them. Here both are terminal — this file asks
* *which one the host aims at*, not what the alias does afterwards.
*/
function renderAt(url: string): string[] {
const chain: string[] = [];
render(
<MemoryRouter initialEntries={[url]}>
<ChainRecorder sink={chain} />
<Routes>
<Route path="/apps/:appName">
{systemRoutes}
<Route path="metadata" element={<Probe id="canonical-directory" />} />
<Route path="metadata/:type" element={<Probe id="canonical-list" />} />
<Route path="metadata/:type/:name" element={<Probe id="canonical-item" />} />
<Route path="component/metadata/directory" element={<Probe id="alias-directory" />} />
<Route path="component/metadata/resource/*" element={<Probe id="alias-resource" />} />
<Route path="component/metadata/resource" element={<Probe id="alias-resource" />} />
</Route>
<Route path="*" element={<Probe id="unmatched" />} />
</Routes>
</MemoryRouter>,
);
return chain;
}

/** No alias route may appear anywhere in the chain, not merely at its end. */
function expectNoAliasAnywhere(chain: string[]) {
expect(chain.some((entry) => entry.includes('component/metadata'))).toBe(false);
expect(screen.queryByTestId('alias-resource')).not.toBeInTheDocument();
expect(screen.queryByTestId('alias-directory')).not.toBeInTheDocument();
}

describe('console host legacy redirects → canonical metadata routes (objectui#3639)', () => {
it('system/metadata/:type reaches metadata/:type in ONE hop', () => {
const chain = renderAt('/apps/x/system/metadata/object');

// The whole point: two entries, i.e. a single `<Navigate>`. Before the fix
// this was three, with the alias in the middle.
expect(chain).toEqual(['/apps/x/system/metadata/object', '/apps/x/metadata/object']);
expect(screen.getByTestId('canonical-list')).toHaveTextContent('"type":"object"');
expectNoAliasAnywhere(chain);
});

it('system/metadata/:type/:name reaches metadata/:type/:name in ONE hop', () => {
const chain = renderAt('/apps/x/system/metadata/object/account');

expect(chain).toEqual([
'/apps/x/system/metadata/object/account',
'/apps/x/metadata/object/account',
]);
const probe = screen.getByTestId('canonical-item');
expect(probe).toHaveTextContent('"type":"object"');
expect(probe).toHaveTextContent('"name":"account"');
expectNoAliasAnywhere(chain);
});

it('bare system/metadata reaches the metadata directory in ONE hop', () => {
// The typeless arm used to aim at `component/metadata/directory`, the
// second alias — a different route from the resource one, same detour.
const chain = renderAt('/apps/x/system/metadata');

expect(chain).toEqual(['/apps/x/system/metadata', '/apps/x/metadata']);
expect(screen.getByTestId('canonical-directory')).toBeInTheDocument();
expectNoAliasAnywhere(chain);
});

it('system/objects/:objectName reaches metadata/object/:name in ONE hop', () => {
const chain = renderAt('/apps/x/system/objects/account');

expect(chain).toEqual(['/apps/x/system/objects/account', '/apps/x/metadata/object/account']);
const probe = screen.getByTestId('canonical-item');
expect(probe).toHaveTextContent('"type":"object"');
expect(probe).toHaveTextContent('"name":"account"');
expectNoAliasAnywhere(chain);
});

it('bare system/objects reaches the object list in ONE hop', () => {
const chain = renderAt('/apps/x/system/objects');

expect(chain).toEqual(['/apps/x/system/objects', '/apps/x/metadata/object']);
expect(screen.getByTestId('canonical-list')).toHaveTextContent('"type":"object"');
expectNoAliasAnywhere(chain);
});

it('the app prefix is preserved, not just stripped to the root', () => {
// `prefix` is cut with a regex off `location.pathname`; a mistake there is
// invisible in the single-segment cases above.
const chain = renderAt('/apps/my-app/system/metadata/datasource');

expect(chain).toEqual([
'/apps/my-app/system/metadata/datasource',
'/apps/my-app/metadata/datasource',
]);
expectNoAliasAnywhere(chain);
});

it('MEASUREMENT: the endpoint is byte-identical to what the old two-hop produced', () => {
// Equivalence, not improvement: `LegacyMetadataRedirect` dropped
// `location.search` on its resource arm and the host never forwarded the
// incoming query either, so the old chain also arrived with a bare path.
// Preserving query/hash would be a real behaviour change and is NOT part of
// this issue — pinned here so a future reader can see the omission was
// measured rather than overlooked.
const chain = renderAt('/apps/x/system/metadata/object?keep=me#frag');

expect(chain[chain.length - 1]).toBe('/apps/x/metadata/object');
expect(chain).toHaveLength(2);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,13 @@
/**
* DatasourceResourcePage — the `datasource` metadata type's custom ListPage,
* registered into the metadata-admin engine (`registerMetadataResource`) and
* reached via the engine route `…/component/metadata/resource?type=datasource`
* (and the setup left-nav "Datasources" item → `system/metadata/datasource`).
* reached via the engine route `…/metadata/datasource`.
*
* Two legacy spellings still arrive here, both as redirects onto that engine
* route rather than routes of their own (objectui#3639): the setup left-nav
* "Datasources" item points at `…/component/metadata/resource?type=datasource`
* (rewritten by `LegacyMetadataRedirect`), and the console host forwards
* `…/system/metadata/datasource` (rewritten by its own `MetadataRedirect`).
*
* datasource is a *side-effectful* metadata type: its records are managed by
* the framework `datasource-admin` service (secret encryption + connection-pool
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,13 @@
* metadata-admin engine. datasource is a *side-effectful* type (secret +
* connection pool + introspection), so it ships a bespoke ListPage that talks
* to the framework `datasource-admin` REST — but it lives inside the engine
* (engine route + registry slot + shell), reachable from the setup left-nav
* "Datasources" item and the `…/component/metadata/resource?type=datasource`
* route, instead of a separate hand-written System page.
* (engine route + registry slot + shell), reachable at the engine route
* `…/metadata/datasource`, instead of a separate hand-written System page.
*
* The setup left-nav "Datasources" item still points at the older
* `…/component/metadata/resource?type=datasource` spelling. That is a legacy
* *alias*, not this engine route: it renders `LegacyMetadataRedirect`, which
* 302s onto `…/metadata/datasource` (objectui#3639).
*/

import { registerMetadataResource } from '../registry';
Expand Down
Loading