Skip to content

Commit b761968

Browse files
fix(console): an app-root deep link keeps its query when it redirects to the landing page (#27641)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wg7gczRvt4v7U6F1vGJkYJdM)_ Fixes #27450 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:打开 `/apps/APP?palette=1` 这类应用根深链时,应用根重定向到默认页会保留查询串和 hash,命令面板照常打开;`?recordId=`、`?form=` 等其它 URL 浮层参数同样保留。控制台根 `/?palette=1` 也一样。 - **为什么改**:应用根的重定向只用默认页地址拼目标,把查询串丢了,浮层参数在被读取之前就没了。分诊结论是「重定向要把查询带过去」。 - **风险与回滚**:只改两处前端重定向的目标拼接;不带查询的应用根行为字节不变。默认页自身带查询(页面 `params`、对象 `filters`/`runAction`)时两者合并,同名键以链接为准、不重复。回滚即 revert 本 PR。 - **先看**:`ui/packages/app-shell/src/console/AppContent.tsx` 的 `carryQueryOntoLanding` 与 `AppRootLanding` 里那行 `Navigate`;然后 `ui/apps/console/src/components/RootLandingRedirect.tsx` 末尾的重定向;最后新测试 `AppContent.rootLandingCarriesQuery-27450.test.tsx`。 - **两分钟验证**:`pnpm --dir ui exec vitest run packages/app-shell/src/console/__tests__/AppContent.rootLandingCarriesQuery-27450.test.tsx apps/console/src/components/RootLandingRedirect.route.test.tsx`,预期 2 个文件 22 个用例全绿。 - **需要你做的**:无,席位落地。 ## What changes Two redirects that land a user on a page rebuilt their target without the URL's query, so the URL overlays (`?palette=1`, `?recordId=`, `?form=`, the constants in `urlParams.ts`) were dropped before `useUrlOverlay` read them. - **App root** (`/apps/APP`, `AppRootLanding` in `AppContent.tsx`): the `Navigate` to the app's landing now goes through a new local helper, `carryQueryOntoLanding(landing, location.search, location.hash)`. With nothing arriving the target is unchanged. With no query on the landing the arriving search is appended as written. A landing can carry a query of its own (a `page` entry's `params`, an `object` entry's `filters` or `runAction`, built by `resolveHref`): then the two merge through `URLSearchParams`, and a key the arriving URL names replaces the landing's values for that key instead of doubling it (the link is the more specific ask). The arriving hash wins over a landing hash; `resolveHref` emits none today. - **Console root** (`/`, `RootLandingRedirect` in `ui/apps/console`): the `RedirectWithSplash` target now appends `location.search` and `location.hash`. Its landing is a bare `/apps/SEGMENT` or `/home`, so there is nothing to merge; the app root it lands on carries the query on in turn. The fix is generic query carriage, not a palette special case. The other redirects on these paths were measured and left as they are: - Already carrying search and hash: the name-alias move to the package id (#26183 effect in `AppContent`), `LegacyMetadataRedirect` directory mode, `ShorthandRecordRedirect`, `SystemRedirect` and `SetupRedirect` in `ConsoleShell.tsx`. - Left alone on purpose: the no-active-app bounce to `homePath` in `AppContent` (`RedirectWithSplash to={homePath}`). It is a refusal, not a landing on the requested page, and a record-overlay param there would address an app the caller cannot open. `LegacyMetadataRedirect` resource mode deliberately consumes its `?type=` into the path; unchanged. `metadata/package/*` forwards to a fixed packages page; unchanged. Changeset: `@object-ui/app-shell` patch, `@object-ui/console` patch. ## Tests - New `ui/packages/app-shell/src/console/__tests__/AppContent.rootLandingCarriesQuery-27450.test.tsx`, on the `AppContent.nameAliasRedirect-26183` harness (a real `AppContent` mount; the router's location is the observation point). Its `ConsoleLayout` stand-in keeps the real `CommandPaletteProvider` the shipped layout mounts, so "the palette is open" is that provider's own answer from the landed URL. Cases: the card's URL (`/apps/showcase_app?palette=1`, through the name-alias move too) lands on the default page with `?palette=1` kept, `REPLACE`, palette open; a record-drawer param and a hash are carried; a landing with its own `?runAction=` merges with `?palette=1`; a key both name is the link's value once; CONTROL: a bare app root lands bare with the palette closed. - `ui/apps/console/src/components/RootLandingRedirect.route.test.tsx`: three cases added (default app with query and hash, the launcher with query, CONTROL bare `/`), and its location probe now also renders the hash. Ablation, each via `scripts/ablation-replace.mjs` (anchor hit 1 to 0, restored to the HEAD blob with `git diff HEAD` empty): - `AppContent.tsx` target reverted to `to={landing}`: 4 of 5 new cases red, the CONTROL green. - `RootLandingRedirect.tsx` target reverted to `to={landing}`: the 2 new carrying cases red, the other 15 in the file green. ## Acceptance notes - `/home?palette=1` from the console root keeps the query, but the home frame mounts no `CommandPaletteProvider` (`useCommandPalette` documents the home and orgs frames as palette-less), so the palette does not open there. The card names the app root; this is noted, not changed. - An unauthenticated `/?palette=1` goes to login first; whether `LoginRedirect` captures a bare `/` with a query is outside this card and unchanged. - Browser reproduction of the card's steps on a running showcase was not done in this container; the acceptance is pinned by the mounted `AppContent` test above. ## Verification <details><summary>Targeted tests, ablation and typecheck (head 27534cf)</summary> - New and edited files: `pnpm --dir ui exec vitest run --maxWorkers=2 --reporter=verbose packages/app-shell/src/console/__tests__/AppContent.rootLandingCarriesQuery-27450.test.tsx apps/console/src/components/RootLandingRedirect.route.test.tsx`: Test Files 2 passed (2), Tests 22 passed (22). - Neighbouring landing and redirect suites (every `AppContent.*` file on landing, alias, boot redirects, pseudo-routes, the Setup and System redirect targets, and the console's `RootLandingRedirect`, `landingHomeParity-7256` and `SetupRoute` tests): Test Files 20 passed (20), Tests 193 passed (193). - Ablation 1, `AppContent.tsx` anchor `to={carryQueryOntoLanding(landing, location.search, location.hash)}` to `to={landing}`: Tests 4 failed, 1 passed (5); restored, blob == HEAD. - Ablation 2, `RootLandingRedirect.tsx` anchor on the template-literal target to `to={landing}`: Tests 2 failed, 15 passed (17); restored, blob == HEAD. - `pnpm --filter @object-ui/app-shell run type-check`: exit 0; the new test file is in `tsconfig.test.json`'s program (`--listFilesOnly` count 1). - `pnpm --filter @object-ui/console run type-check`: exit 0 (after building the five plugin dists the console imports); the route test is in its program (`--listFilesOnly` count 1). - `eslint` on the four touched files: 0 errors; no warning on a touched line. - `check:new-line-citations` (ui): 0 new citations. </details> <details><summary>Derived gates</summary> At head `27534cfd`, `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 34 derived; each run and recorded as `COMMAND :: exit CODE`, all 34 exit 0, reconciled with `--ran`: "34 derived famil(ies) accounted for — 34 run, 0 NOT-MEASURED". `node scripts/check-sdui-manifest.mjs` and `pnpm check:nul-bytes` are among them. `main` moved 4 commits since the base `77e993ea`, none touching these files; no merge taken. Every other family is CI's, on this PR's check-runs. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CbjUouY1fgaJXGSQMk)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent d220b9b commit b761968

5 files changed

Lines changed: 308 additions & 3 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/console': patch
4+
---
5+
6+
fix(console): an app-root or console-root deep link keeps its query and hash when it redirects to the landing page
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
Opening `/apps/APP?palette=1` on an app whose root redirects to its first navigation entry landed on that page with no query string, so the command palette never opened; the same held for every URL overlay param (`?recordId=`, `?form=`) and for the hash, and for `/?palette=1` at the console root. Both redirects now carry search and hash across. When the landing entry has a query of its own (a `page` entry's `params`, an object entry's `filters` or `runAction`), the two merge: a key the link names replaces the landing's value for that key instead of doubling it.

‎ui/apps/console/src/components/RootLandingRedirect.route.test.tsx‎

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,12 @@ const { RootLandingRedirect } = await import('./RootLandingRedirect');
135135
/** Reports where the router ended up, so assertions read the real location. */
136136
function LocationProbe() {
137137
const location = useLocation();
138-
return <div data-testid="location">{location.pathname + location.search}</div>;
138+
return (
139+
<>
140+
<div data-testid="location">{location.pathname + location.search}</div>
141+
<div data-testid="location-hash">{location.hash}</div>
142+
</>
143+
);
139144
}
140145

141146
/**
@@ -353,3 +358,35 @@ describe('redirect capture — a real deep link is honored, a bare entry is not
353358
expect(location()).toBe('/login?redirect=%2Fapps%2Fcrm_app%2Frecord%2F1%3Ftab%3Dhistory');
354359
});
355360
});
361+
362+
// ── objectstack-ai/objectstack#27450 — a deep link's query survives `/` ────
363+
describe('the landing carries the query and hash `/` arrived with', () => {
364+
it('lands on the default app with `?palette=1` and the hash kept', () => {
365+
auth.value.isAuthenticated = true;
366+
metadata.value.apps = [OTHER_APP, DEFAULT_APP];
367+
368+
renderAt('/?palette=1#top');
369+
370+
expect(location()).toBe('/apps/crm_app?palette=1');
371+
expect(screen.getByTestId('location-hash').textContent).toBe('#top');
372+
});
373+
374+
it('lands on the launcher with the query kept too', () => {
375+
auth.value.isAuthenticated = true;
376+
metadata.value.apps = [OTHER_APP, { name: 'ops_app' }];
377+
378+
renderAt('/?palette=1');
379+
380+
expect(location()).toBe('/home?palette=1');
381+
});
382+
383+
it('CONTROL: a bare `/` still lands on the bare path', () => {
384+
auth.value.isAuthenticated = true;
385+
metadata.value.apps = [DEFAULT_APP];
386+
387+
renderAt('/');
388+
389+
expect(location()).toBe('/apps/crm_app');
390+
expect(screen.getByTestId('location-hash').textContent).toBe('');
391+
});
392+
});

‎ui/apps/console/src/components/RootLandingRedirect.tsx‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
*/
3535

3636
import { useEffect, useRef } from 'react';
37+
import { useLocation } from 'react-router-dom';
3738
import {
3839
useMetadata,
3940
LoadingFallback,
@@ -181,6 +182,7 @@ const APP_LIST_RETRY_DELAY_MS = 1500;
181182

182183
export function RootLandingRedirect() {
183184
const { apps, loading, getTypeStatus, refresh } = useMetadata();
185+
const location = useLocation();
184186

185187
// "Not yet" (`loading`) and "asked and failed" (`status === 'error'`) are
186188
// both "no answer" — they differ only in whether waiting will help.
@@ -210,5 +212,11 @@ export function RootLandingRedirect() {
210212
// while the app list loads, and handing off to a null-rendering redirect left
211213
// `#root` empty for 147 ms while `/home` rendered at transition priority. The
212214
// splash is unchanged across the handoff, so the boot reads as one screen.
213-
return <RedirectWithSplash to={resolveLandingPath(apps as LandingApp[] | undefined)} replace />;
215+
//
216+
// Search and hash carry over (objectstack-ai/objectstack#27450): the URL
217+
// overlays (`?palette=1`, `?recordId=`) live in the query, and the landing is
218+
// a bare `/apps/<segment>` or `/home`, so there is nothing to merge with. The
219+
// app root it lands on carries them on to the app's own landing in turn.
220+
const landing = resolveLandingPath(apps as LandingApp[] | undefined);
221+
return <RedirectWithSplash to={`${landing}${location.search}${location.hash}`} replace />;
214222
}

‎ui/packages/app-shell/src/console/AppContent.tsx‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1692,6 +1692,34 @@ function resolveLandingRoute(navigation: NavigationItem[], ctx?: NavTemplateCont
16921692
return findFirstRoute(navigation, ctx);
16931693
}
16941694

1695+
/**
1696+
* objectstack-ai/objectstack#27450 — the landing target with the search and
1697+
* hash the app-root URL arrived with carried onto it. The URL overlays
1698+
* (`?palette=1`, `?recordId=`, `?form=`, `urlParams.ts`) live in the query, so
1699+
* a redirect that rebuilt the URL from the landing alone dropped the overlay a
1700+
* deep link asked for before anything read it.
1701+
*
1702+
* A landing that has a query of its own (a `page` entry's `params`, an
1703+
* `object` entry's `filters`) keeps it, and the two MERGE: a key the arriving
1704+
* URL names replaces the landing's values for that key, never doubles them —
1705+
* the link is the more specific ask. With nothing arriving the target is
1706+
* returned untouched; with no query on the landing the arriving search is
1707+
* appended as written.
1708+
*/
1709+
function carryQueryOntoLanding(target: string, search: string, hash: string): string {
1710+
const hashAt = target.indexOf('#');
1711+
const beforeHash = hashAt >= 0 ? target.slice(0, hashAt) : target;
1712+
const carriedHash = hash || (hashAt >= 0 ? target.slice(hashAt) : '');
1713+
if (!search || search === '?') return `${beforeHash}${carriedHash}`;
1714+
const queryAt = beforeHash.indexOf('?');
1715+
if (queryAt < 0) return `${beforeHash}${search}${carriedHash}`;
1716+
const params = new URLSearchParams(beforeHash.slice(queryAt + 1));
1717+
const arriving = new URLSearchParams(search);
1718+
for (const key of new Set(arriving.keys())) params.delete(key);
1719+
arriving.forEach((value, key) => params.append(key, value));
1720+
return `${beforeHash.slice(0, queryAt)}?${params.toString()}${carriedHash}`;
1721+
}
1722+
16951723
/**
16961724
* The app root (`/apps/:appName`): navigates to the app's landing, or says the
16971725
* member has nothing here.
@@ -1731,6 +1759,7 @@ function AppRootLanding({
17311759
setupPath: string | null;
17321760
}) {
17331761
const gates = useNavMemberGates();
1762+
const location = useLocation();
17341763
// objectstack#25338 — an app that names objects, none of which this member
17351764
// may read, has nothing for them: its dashboards and pages would answer a
17361765
// grid of "You don't have access" tiles. Say so once, before any landing is
@@ -1763,7 +1792,9 @@ function AppRootLanding({
17631792
</div>
17641793
);
17651794
}
1766-
if (landing) return <Navigate to={landing} replace />;
1795+
// objectstack-ai/objectstack#27450 — search and hash carry over, as on the
1796+
// other URL-rewrite redirects in this file.
1797+
if (landing) return <Navigate to={carryQueryOntoLanding(landing, location.search, location.hash)} replace />;
17671798
// objectui#12079 — an app that shows this member NO navigation (every entry
17681799
// gated away from them, or none served at all) is an app they have no access
17691800
// in yet. Say so, and offer Setup to a caller who can grant access: the
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectstack-ai/objectstack#27450 — an app-root deep link keeps its query.
11+
*
12+
* The URL overlays (`?palette=1`, `?recordId=`, `?form=`) live in the query.
13+
* The app root (`/apps/:appName`) redirects to the app's landing, and that
14+
* redirect built its target from the landing alone, so
15+
* `/apps/showcase_app?palette=1` settled on the landing page with no query and
16+
* the command palette never opened. The redirect now carries search and hash
17+
* across, merging with a landing that has a query of its own.
18+
*
19+
* Measured from a real `AppContent` mount (the `AppContent.nameAliasRedirect
20+
* -26183` harness); the router's location is the observation point, and the
21+
* real `CommandPaletteProvider` (which `ConsoleLayout` mounts in the shipped
22+
* shell) reads the landed URL, so "the palette is open" is the provider's own
23+
* answer.
24+
*
25+
* Direction, written before the run: on the pre-fix wiring every case that
26+
* arrives with a query is RED (the landing has none); the bare-root control
27+
* is GREEN on both sides.
28+
*/
29+
30+
import '@testing-library/jest-dom/vitest';
31+
import { describe, it, expect, vi, afterEach } from 'vitest';
32+
import React, { useEffect } from 'react';
33+
import { render, cleanup, waitFor, screen } from '@testing-library/react';
34+
import { MemoryRouter, Routes, Route, useLocation, useNavigationType } from 'react-router-dom';
35+
import { createI18n, I18nProvider } from '@object-ui/i18n';
36+
37+
// The route's recent-items tracker runs; this file does not observe it.
38+
vi.mock('../../context/RecentItemsProvider', async (importOriginal) => ({
39+
...(await importOriginal<Record<string, unknown>>()),
40+
useRecentItems: () => ({ recentItems: [], addRecentItem: () => {}, clearRecentItems: () => {} }),
41+
}));
42+
43+
vi.mock('@object-ui/plugin-form', async (importOriginal) => ({
44+
...(await importOriginal<Record<string, unknown>>()),
45+
ModalForm: () => null,
46+
}));
47+
48+
vi.mock('@object-ui/plugin-designer', async (importOriginal) => ({
49+
...(await importOriginal<typeof import('@object-ui/plugin-designer')>()),
50+
CreateAppPage: () => <div data-testid="create-app-page" />,
51+
EditAppPage: () => <div data-testid="edit-app-page" />,
52+
DashboardDesignPage: () => <div data-testid="dashboard-design-page" />,
53+
}));
54+
55+
// The shipped `ConsoleLayout` mounts the real `CommandPaletteProvider` around
56+
// the route slot; this stand-in keeps that provider and drops the chrome.
57+
vi.mock('../../layout/ConsoleLayout', async () => {
58+
const { CommandPaletteProvider, useCommandPalette } = await import('../../context/CommandPaletteProvider');
59+
function PaletteProbe() {
60+
const { open } = useCommandPalette();
61+
return open ? <div data-testid="palette-open" /> : null;
62+
}
63+
return {
64+
ConsoleLayout: ({ children }: { children?: React.ReactNode }) => (
65+
<CommandPaletteProvider>
66+
<PaletteProbe />
67+
<div data-testid="console-layout">{children}</div>
68+
</CommandPaletteProvider>
69+
),
70+
};
71+
});
72+
vi.mock('../../chrome/CommandPalette', () => ({ CommandPalette: () => null }));
73+
vi.mock('../../chrome/KeyboardShortcutsDialog', () => ({ KeyboardShortcutsDialog: () => null }));
74+
vi.mock('../../chrome/OnboardingWalkthrough', () => ({ OnboardingWalkthrough: () => null }));
75+
vi.mock('../../views/ObjectView', () => ({ ObjectView: () => <div data-testid="object-view" /> }));
76+
vi.mock('../../views/RecordDetailView', () => ({ RecordDetailView: () => null }));
77+
78+
vi.mock('@object-ui/auth', async (importOriginal) => ({
79+
...(await importOriginal<Record<string, unknown>>()),
80+
useAuth: () => ({
81+
user: { id: 'u1', name: 'Ada', email: 'ada@example.com', role: 'user' },
82+
getAuthConfig: async () => ({ features: {} }),
83+
activeOrganization: null,
84+
}),
85+
useWorkspaceAdminStatus: () => ({ isAdmin: true, isResolved: true }),
86+
}));
87+
88+
const dataSourceStub = {
89+
onConnectionStateChange: () => () => {},
90+
getConnectionState: () => 'connected',
91+
};
92+
vi.mock('../../providers/AdapterProvider', async (importOriginal) => ({
93+
...(await importOriginal<Record<string, unknown>>()),
94+
useAdapter: () => dataSourceStub,
95+
}));
96+
97+
/** The showcase app as the catalog serves it: a name AND a package id. */
98+
const SHOWCASE = {
99+
name: 'showcase_app',
100+
label: 'Showcase',
101+
_packageId: 'com.example.showcase',
102+
isDefault: true,
103+
navigation: [{ id: 'tasks', type: 'object', objectName: 'showcase_task', label: 'Tasks' }],
104+
};
105+
106+
/** The same app whose landing entry carries a query of its own (`?runAction=`). */
107+
const SHOWCASE_LANDING_WITH_QUERY = {
108+
...SHOWCASE,
109+
navigation: [
110+
{ id: 'tasks', type: 'object', objectName: 'showcase_task', viewName: 'all', runAction: 'import', label: 'Tasks' },
111+
],
112+
};
113+
114+
const OBJECTS = [{ name: 'showcase_task', label: 'Task', pluralLabel: 'Tasks', fields: { title: { type: 'text' } } }];
115+
116+
/** The catalog a test serves. One value per test: the route's effects key on its members. */
117+
const { metadataRef } = vi.hoisted(() => ({ metadataRef: { current: null as unknown } }));
118+
function serve(apps: unknown[]): void {
119+
metadataRef.current = {
120+
apps,
121+
objects: OBJECTS,
122+
loading: false,
123+
ensureType: undefined,
124+
error: null,
125+
refresh: async () => {},
126+
};
127+
}
128+
vi.mock('../../providers/MetadataProvider', async (importOriginal) => ({
129+
...(await importOriginal<Record<string, unknown>>()),
130+
useMetadata: () => metadataRef.current,
131+
}));
132+
133+
const ACTION_RUNNER = { execute: () => {}, runner: { registerHandler: () => {}, getContext: () => ({}) } };
134+
vi.mock('@object-ui/react', async (importOriginal) => ({
135+
...(await importOriginal<Record<string, unknown>>()),
136+
useActionRunner: () => ACTION_RUNNER,
137+
useGlobalUndo: () => {},
138+
useMutationInvalidationBridge: () => {},
139+
}));
140+
141+
import { AppContent } from '../AppContent';
142+
import { COMMAND_PALETTE_PARAM, RECORD_DRAWER_PARAM } from '../../urlParams';
143+
144+
/** The router's location as the last render saw it. */
145+
const seen: { pathname?: string; search?: string; hash?: string; type?: string } = {};
146+
function LocationProbe() {
147+
const location = useLocation();
148+
const type = useNavigationType();
149+
useEffect(() => {
150+
Object.assign(seen, { pathname: location.pathname, search: location.search, hash: location.hash, type });
151+
}, [location, type]);
152+
return null;
153+
}
154+
155+
afterEach(cleanup);
156+
157+
/** Mount the console's app router at `entry`; the landing's object list renders. */
158+
async function visit(entry: string): Promise<void> {
159+
const i18n = createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false });
160+
render(
161+
<I18nProvider instance={i18n}>
162+
<MemoryRouter initialEntries={[entry]}>
163+
<Routes>
164+
<Route path="/apps/:appName/*" element={<AppContent />} />
165+
</Routes>
166+
<LocationProbe />
167+
</MemoryRouter>
168+
</I18nProvider>,
169+
);
170+
await screen.findByTestId('object-view', undefined, { timeout: 5000 });
171+
}
172+
173+
describe('the app-root redirect carries the deep link\'s query (objectstack-ai/objectstack#27450)', () => {
174+
it('THE CARD: `/apps/showcase_app?palette=1` lands on the default page with `?palette=1` kept and the palette open', async () => {
175+
serve([SHOWCASE]);
176+
await visit(`/apps/showcase_app?${COMMAND_PALETTE_PARAM}=1`);
177+
await waitFor(() => expect(seen.pathname).toBe('/apps/com.example.showcase/showcase_task'));
178+
expect(seen.search).toBe(`?${COMMAND_PALETTE_PARAM}=1`);
179+
expect(seen.type).toBe('REPLACE');
180+
expect(await screen.findByTestId('palette-open')).toBeInTheDocument();
181+
});
182+
183+
it('carries any overlay param and the hash, not the palette alone', async () => {
184+
serve([SHOWCASE]);
185+
await visit(`/apps/com.example.showcase?${RECORD_DRAWER_PARAM}=rec_123456#notes`);
186+
await waitFor(() => expect(seen.pathname).toBe('/apps/com.example.showcase/showcase_task'));
187+
expect(seen.search).toBe(`?${RECORD_DRAWER_PARAM}=rec_123456`);
188+
expect(seen.hash).toBe('#notes');
189+
});
190+
191+
it('merges with a landing that has a query of its own', async () => {
192+
serve([SHOWCASE_LANDING_WITH_QUERY]);
193+
await visit(`/apps/com.example.showcase?${COMMAND_PALETTE_PARAM}=1`);
194+
await waitFor(() => expect(seen.pathname).toBe('/apps/com.example.showcase/showcase_task/view/all'));
195+
const params = new URLSearchParams(seen.search);
196+
expect(params.getAll('runAction')).toEqual(['import']);
197+
expect(params.getAll(COMMAND_PALETTE_PARAM)).toEqual(['1']);
198+
expect(await screen.findByTestId('palette-open')).toBeInTheDocument();
199+
});
200+
201+
it('a key both name is the link\'s value once, never doubled', async () => {
202+
serve([SHOWCASE_LANDING_WITH_QUERY]);
203+
await visit(`/apps/com.example.showcase?runAction=export&${COMMAND_PALETTE_PARAM}=1`);
204+
await waitFor(() => expect(seen.pathname).toBe('/apps/com.example.showcase/showcase_task/view/all'));
205+
const params = new URLSearchParams(seen.search);
206+
expect(params.getAll('runAction')).toEqual(['export']);
207+
expect(params.getAll(COMMAND_PALETTE_PARAM)).toEqual(['1']);
208+
});
209+
210+
it('CONTROL: a bare app root lands on the bare landing, palette closed', async () => {
211+
serve([SHOWCASE]);
212+
await visit('/apps/com.example.showcase');
213+
await waitFor(() => expect(seen.pathname).toBe('/apps/com.example.showcase/showcase_task'));
214+
expect(seen.search).toBe('');
215+
expect(seen.hash).toBe('');
216+
expect(screen.queryByTestId('palette-open')).toBeNull();
217+
});
218+
});

0 commit comments

Comments
 (0)