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
44 changes: 44 additions & 0 deletions .changeset/7373-recovery-redirects-follow-declared-landing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
---
'@object-ui/app-shell': minor
'@object-ui/console': minor
---

The console's error-recovery exits follow the declared landing (objectui#7373).

`app.isDefault` declares where a deployment's home is, and objectui#7256 (PR #7372)
made the console chrome's four Home affordances read it through `useHomePath()`.
The "you cannot be here" exits kept naming the environment launcher literally, so a
control-plane customer who hit one landed on a screen whose "Build an app" /
"Start from a template" cards act on an environment their deployment does not have
and whose "Your apps" tiles are the control plane's own internal management apps.

Retargeted onto the existing policy — no new one was written:

- `AppContent` — the access-denied screen's "Back to home", and the bounce for a
viewer with no app to enter;
- `RequireAiSurface` — a runtime that serves no AI agent (its `redirectTo` prop
still wins when a host passes one; only the default moved);
- `AiChatPage` — the no-agent screen's Home, and the collapse-to-dock landing on a
cold deep link. `resolveCollapseToDockTarget` now takes the home path as a
required argument instead of naming one;
- `StudioDesignSurface` — eviction when the package under the editor is deleted,
and the header's Home button;
- `apps/console`'s `/studio` entry gate and the Studio front door's wordmark.

Accepting an organization invitation follows the declaration too, by a different
route and for a measured reason: it runs immediately after `switchOrganization`,
so the app list in hand there still belongs to the organization being LEFT
(`MetadataProvider` drops its cache on an org change and refetches after that line
has run). Reading the declaration in place would name the previous organization's
app. It now reloads onto the console ROOT — the shape `WorkspaceSwitcher` and
`OrganizationsPage` already use for this same transition — so
`RootLandingRedirect` resolves the landing for the organization the user has just
joined.

Every ordinary environment is unchanged: where nothing declares a landing, and
wherever the app list is not (yet) an answer, the resolved path IS `/home`.

Two sites deliberately keep the launcher: `HOME_LAUNCHER_PATH` itself (it is the
launcher, and the fallback all of the above resolve through — ADR-0075), and
`RootRedirect`, which is `/`'s landing rather than a recovery exit and has its own
resolver.
91 changes: 85 additions & 6 deletions apps/console/src/components/StudioRoute.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@
import '@testing-library/jest-dom/vitest';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import React from 'react';
import { MemoryRouter, Routes, Route, useLocation } from 'react-router-dom';
import { MetadataCtx, type MetadataContextValue } from '@object-ui/react';

/** Auth facts, swapped per test. `AuthGuard` itself stays real. */
let auth = { isAuthenticated: true, isLoading: false, user: { id: 'u1' } as unknown };
Expand Down Expand Up @@ -138,19 +140,53 @@ const pathname = () => screen.getByTestId('pathname').textContent;
* `/studio` subtree, the home a non-holder is sent to, and the login surface an
* unauthenticated visitor bounces to.
*/
function renderStudioDeepLink(url: string) {
/**
* The app list the gate's home target resolves against (objectui#7373).
* `undefined` mounts the subtree with no metadata context at all — what every
* case written before that card saw, and what `useHomePath()` answers the
* launcher for.
*/
function withMetadata(
apps: MetadataContextValue['apps'] | undefined,
children: React.ReactNode,
) {
if (!apps) return <>{children}</>;
const value: MetadataContextValue = {
apps,
objects: [], dashboards: [], reports: [], pages: [],
loading: false, error: null,
refresh: async () => {}, invalidate: () => {}, ensureType: async () => [],
getItem: async () => null, getItemsByType: () => [], getTypeStatus: () => 'ready',
};
return <MetadataCtx.Provider value={value}>{children}</MetadataCtx.Provider>;
}

function renderStudioDeepLink(url: string, apps?: MetadataContextValue['apps']) {
return render(
<MemoryRouter initialEntries={[url]}>
<LocationProbe />
<Routes>
{studioRoutes}
<Route path="/home" element={<div data-testid="home-launcher">home</div>} />
<Route path="/login" element={<div data-testid="login-page">login</div>} />
</Routes>
{withMetadata(
apps,
<Routes>
{studioRoutes}
<Route path="/home" element={<div data-testid="home-launcher">home</div>} />
<Route
path="/apps/cloud_control"
element={<div data-testid="declared-landing">declared landing</div>}
/>
<Route path="/login" element={<div data-testid="login-page">login</div>} />
</Routes>,
)}
</MemoryRouter>,
);
}

/** A control plane: the landing is declared, and it is not the launcher. */
const CONTROL_PLANE_APPS = [
{ name: 'cloud_control', label: 'Cloud', isDefault: true },
{ name: 'account', label: 'Account' },
];

/** A plain tenant org owner's real set on the measured shape — no `studio.access`. */
const TENANT_OWNER_CAPS = ['manage_org_users', 'setup.access', 'setup.write'];
/** A platform operator: the same set plus the platform-exclusive entry capability. */
Expand Down Expand Up @@ -238,6 +274,35 @@ describe('/studio/* — the entry decision, both ways', () => {
expect(designSurface).not.toHaveBeenCalled();
});

it('a non-holder lands on the DECLARED landing where there is one (objectui#7373)', async () => {
// The card's case on this gate: a control-plane customer who follows a
// `/studio` link they may not enter. Pre-#7373 `redirectTo` defaulted to the
// `/home` literal, which on that deployment is the environment launcher —
// "Build an app" / "Start from a template" cards acting on an environment
// the control plane does not have. This pin fails on that implementation.
renderStudioDeepLink('/studio/hotcrm/data', CONTROL_PLANE_APPS);

await waitFor(() => expect(pathname()).toBe('/apps/cloud_control'));
expect(screen.getByTestId('declared-landing')).toBeInTheDocument();
expect(screen.queryByTestId('home-launcher')).not.toBeInTheDocument();
// The load-bearing half is unchanged by the retarget: refusing is still
// refusing, and the builder is still never mounted.
expect(designSurface).not.toHaveBeenCalled();
});

it('keeps the launcher for an environment that declares no landing', async () => {
// The status quo, as its own case: a real app list WITHOUT a declaration
// resolves to the launcher, exactly like the no-context cases above.
renderStudioDeepLink('/studio/hotcrm/data', [
{ name: 'crm', label: 'CRM' },
{ name: 'setup', label: 'Setup' },
]);

await waitFor(() => expect(pathname()).toBe('/home'));
expect(screen.getByTestId('home-launcher')).toBeInTheDocument();
expect(designSurface).not.toHaveBeenCalled();
});

it('NEGATIVE CONTROL: a holder still gets the front door, unchanged', async () => {
// A gate that refused everyone would pass every assertion above.
answerWith(OPERATOR_CAPS);
Expand All @@ -257,6 +322,20 @@ describe('/studio/* — the entry decision, both ways', () => {
expect(pathname()).toBe('/studio/hotcrm/data');
});

it("the front door's wordmark walks back to the same home the gate bounces to", async () => {
// Two affordances one route apart — this wordmark and the pillar builder's
// header Home button — must not name two different homes; that asymmetry is
// the defect objectui#7256 measured and objectui#7373 finished removing.
answerWith(OPERATOR_CAPS);
renderStudioDeepLink('/studio/', CONTROL_PLANE_APPS);

await waitFor(() => expect(screen.getByTestId('studio-front-door')).toBeInTheDocument());
expect(screen.getByRole('link', { name: 'ObjectOS' })).toHaveAttribute(
'href',
'/apps/cloud_control',
);
});

it('NEGATIVE CONTROL: the holder is answered ONCE for the whole subtree', async () => {
answerWith(OPERATOR_CAPS);
renderStudioDeepLink('/studio/hotcrm/data');
Expand Down
21 changes: 17 additions & 4 deletions apps/console/src/components/StudioRoute.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
LoadingScreen,
StudioDesignSurface,
getProductName,
useHomePath,
} from '@object-ui/app-shell';

import { ProtectedRoute } from './ProtectedRoute';
Expand All @@ -56,12 +57,18 @@ import { holdsStudioAccess, useStudioEntry } from './studioEntry';
*/
export function RequireStudioAccess({
children,
redirectTo = '/home',
redirectTo,
}: {
children: ReactNode;
/** Where a non-holder lands. Home, not a dead end — same posture as `RequireAiSurface`. */
/**
* Where a non-holder lands. Home, not a dead end — same posture as
* `RequireAiSurface`. Defaults to the DECLARED landing (objectui#7373), which
* is the environment launcher wherever no app declares one; an explicit value
* still wins.
*/
redirectTo?: string;
}) {
const homePath = useHomePath();
const entry = useStudioEntry();

// Loading window. The builder must not mount for a single frame while the
Expand All @@ -76,7 +83,7 @@ export function RequireStudioAccess({
}

if (!holdsStudioAccess(entry.systemPermissions)) {
return <Navigate to={redirectTo} replace />;
return <Navigate to={redirectTo ?? homePath} replace />;
}

return <>{children}</>;
Expand All @@ -98,13 +105,19 @@ export function StudioRoute() {
*
* Standalone frame — the landing must never be a navigation dead end, so the
* wordmark walks back to the platform Home.
*
* Its sibling screen inside the same frame — `StudioDesignSurface`'s header
* Home button — follows the declared landing since objectui#7373, and two
* affordances one route apart must not name two different homes (the very
* defect objectui#7256 measured), so this one reads the same hook.
*/
function StudioLanding() {
const homePath = useHomePath();
return (
<div className="flex min-h-screen flex-col bg-background text-foreground">
<header className="flex shrink-0 items-center border-b px-3 py-2">
<Link
to="/home"
to={homePath}
title="返回主页"
className="inline-flex items-center gap-1.5 rounded-md px-1.5 py-0.5 text-[13px] font-semibold hover:bg-muted"
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,41 @@ function Recorder() {
return null;
}

/**
* Records the FULL-PAGE navigations the page performs (objectui#7373).
*
* The accept path leaves React Router deliberately — see the comment at that
* call site — so "where did it go" cannot be read off `seen`, which only sees
* in-router transitions. Assigning `window.location.href` for real would make
* the environment try to navigate, so the accessor is swapped for the duration
* of each case and restored exactly as it was found (own property or
* prototype accessor, whichever it was).
*/
let navigations: string[] = [];
let ownHref: PropertyDescriptor | undefined;

function captureFullPageNavigations() {
navigations = [];
ownHref = Object.getOwnPropertyDescriptor(window.location, 'href');
const inherited = Object.getOwnPropertyDescriptor(
Object.getPrototypeOf(window.location),
'href',
);
Object.defineProperty(window.location, 'href', {
configurable: true,
get: () => (ownHref?.get ?? inherited?.get)?.call(window.location) ?? '',
set: (value: string) => {
navigations.push(String(value));
},
});
}

function releaseFullPageNavigations() {
if (ownHref) Object.defineProperty(window.location, 'href', ownHref);
else delete (window.location as unknown as Record<string, unknown>).href;
ownHref = undefined;
}

/**
* Mount the page exactly as `apps/console/src/App.tsx` does: inside the
* console's `BrowserRouter`, on the real path, with NO layout wrapper.
Expand All @@ -110,6 +145,10 @@ function renderRoute({ basename = '/', lang = 'en' }: { basename?: string; lang?
<Routes>
<Route path="/accept-invitation/:invitationId" element={<DefaultAcceptInvitationPage />} />
<Route path="/login" element={<div data-testid="login-sentinel" />} />
{/* ⛔ Keep this route declared even though nothing should land on it:
the accept case asserts the sentinel is ABSENT, and a negative
assertion against a route that does not exist passes for the
wrong reason (objectui#7373). */}
<Route path="/home" element={<div data-testid="home-sentinel" />} />
<Route path="/organizations" element={<div data-testid="orgs-sentinel" />} />
</Routes>
Expand All @@ -121,6 +160,7 @@ function renderRoute({ basename = '/', lang = 'en' }: { basename?: string; lang?
beforeEach(() => {
vi.clearAllMocks();
seen.length = 0;
captureFullPageNavigations();
window.localStorage.clear();
authState = {
isAuthenticated: true,
Expand All @@ -133,6 +173,7 @@ beforeEach(() => {
});

afterEach(() => {
releaseFullPageNavigations();
window.history.pushState({}, '', '/');
});

Expand Down Expand Up @@ -196,7 +237,16 @@ describe('objectui#3811 — console routes DefaultAcceptInvitationPage', () => {
).toBeInTheDocument();
});

it('accept switches the user into the invited organization, then lands on /home', async () => {
it('accept switches the user into the invited organization, then reloads onto the console ROOT', async () => {
// objectui#7373 moved this landing. It used to be an in-router
// `navigate('/home')` — the environment launcher, reached without leaving
// the SPA, which is exactly why it could not honour the declaration: the
// app list in memory at that instant is the org the user just LEFT
// (`MetadataProvider` drops its cache on an org change, objectui#4486,
// and refetches after this line has already run). Landing on the root
// lets `RootLandingRedirect` resolve `app.isDefault` for the org the user
// just JOINED, which is the shape `WorkspaceSwitcher.handleSwitch` and
// `OrganizationsPage.handleSelect` already take for the same transition.
const user = userEvent.setup();
renderRoute();
await screen.findByTestId('accept-invitation-page');
Expand All @@ -209,7 +259,29 @@ describe('objectui#3811 — console routes DefaultAcceptInvitationPage', () => {
expect((authState.acceptInvitation as ReturnType<typeof vi.fn>).mock.invocationCallOrder[0]).toBeLessThan(
(authState.switchOrganization as ReturnType<typeof vi.fn>).mock.invocationCallOrder[0],
);
await screen.findByTestId('home-sentinel');

// A full page load, not a router transition — the whole point is that
// every data scope is re-seated on the new organization before anything
// reads the app list.
await waitFor(() => expect(navigations).toHaveLength(1));
// Asked of the RESOLVED target, the way the browser resolves it, rather
// than by string equality with the call — the shipped embeddable build's
// correct answer is a relative `'./'` (`utils/consoleBase.test.ts`), and
// a pin on the literal would forbid it while proving nothing.
const landed = new URL(navigations[0], document.baseURI);
expect(landed.pathname, 'accept must land on the console root').toBe('/');
// …and specifically NOT on the launcher this replaced. Stated separately
// because that is the regression with a name: a root that is `/home` is
// the defect objectui#7373 was filed about, wearing a page load.
expect(landed.pathname).not.toBe('/home');
expect(navigations[0]).not.toContain('/home');

// The router must NOT have handled it. `seen` records every in-router
// transition, so a `navigate()` regression shows up here even if a page
// load were also performed.
expect(screen.queryByTestId('home-sentinel')).not.toBeInTheDocument();
expect(seen).not.toContain('/home');

expect(toastSuccess).toHaveBeenCalledWith('Invitation accepted');
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@
* redirects in `console/AppContent.tsx` / `console/ConsoleShell.tsx`. Those are
* error-recovery paths, not Home affordances, and retargeting them moves a
* `/home` expectation that a dozen existing tests pin — a separate change with
* its own measurement.
* its own measurement. That change is objectui#7373, and its scan is the
* sibling file `homeRecoveryRedirectsFollowDeclaration-7373.test.ts`: the
* affordances stay this file's subject, the recovery exits are that one's.
*
* And it does not cover `apps/console`'s `/` resolver, which keeps its own
* reading of the declaration — `landingHomeParity-7256.test.ts` compares the two
Expand Down
Loading
Loading