diff --git a/.changeset/27450-app-root-deep-link-query.md b/.changeset/27450-app-root-deep-link-query.md
new file mode 100644
index 00000000000..72d2e09cc71
--- /dev/null
+++ b/.changeset/27450-app-root-deep-link-query.md
@@ -0,0 +1,11 @@
+---
+'@object-ui/app-shell': patch
+'@object-ui/console': patch
+---
+
+fix(console): an app-root or console-root deep link keeps its query and hash when it redirects to the landing page
+
+Clause-②: no
+Metadata-protocol: none
+
+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.
diff --git a/ui/apps/console/src/components/RootLandingRedirect.route.test.tsx b/ui/apps/console/src/components/RootLandingRedirect.route.test.tsx
index da0e2c9ca63..b295d426668 100644
--- a/ui/apps/console/src/components/RootLandingRedirect.route.test.tsx
+++ b/ui/apps/console/src/components/RootLandingRedirect.route.test.tsx
@@ -135,7 +135,12 @@ const { RootLandingRedirect } = await import('./RootLandingRedirect');
/** Reports where the router ended up, so assertions read the real location. */
function LocationProbe() {
const location = useLocation();
- return
{location.pathname + location.search}
;
+ return (
+ <>
+ {location.pathname + location.search}
+ {location.hash}
+ >
+ );
}
/**
@@ -353,3 +358,35 @@ describe('redirect capture — a real deep link is honored, a bare entry is not
expect(location()).toBe('/login?redirect=%2Fapps%2Fcrm_app%2Frecord%2F1%3Ftab%3Dhistory');
});
});
+
+// ── objectstack-ai/objectstack#27450 — a deep link's query survives `/` ────
+describe('the landing carries the query and hash `/` arrived with', () => {
+ it('lands on the default app with `?palette=1` and the hash kept', () => {
+ auth.value.isAuthenticated = true;
+ metadata.value.apps = [OTHER_APP, DEFAULT_APP];
+
+ renderAt('/?palette=1#top');
+
+ expect(location()).toBe('/apps/crm_app?palette=1');
+ expect(screen.getByTestId('location-hash').textContent).toBe('#top');
+ });
+
+ it('lands on the launcher with the query kept too', () => {
+ auth.value.isAuthenticated = true;
+ metadata.value.apps = [OTHER_APP, { name: 'ops_app' }];
+
+ renderAt('/?palette=1');
+
+ expect(location()).toBe('/home?palette=1');
+ });
+
+ it('CONTROL: a bare `/` still lands on the bare path', () => {
+ auth.value.isAuthenticated = true;
+ metadata.value.apps = [DEFAULT_APP];
+
+ renderAt('/');
+
+ expect(location()).toBe('/apps/crm_app');
+ expect(screen.getByTestId('location-hash').textContent).toBe('');
+ });
+});
diff --git a/ui/apps/console/src/components/RootLandingRedirect.tsx b/ui/apps/console/src/components/RootLandingRedirect.tsx
index 487e397a08f..c128700964f 100644
--- a/ui/apps/console/src/components/RootLandingRedirect.tsx
+++ b/ui/apps/console/src/components/RootLandingRedirect.tsx
@@ -34,6 +34,7 @@
*/
import { useEffect, useRef } from 'react';
+import { useLocation } from 'react-router-dom';
import {
useMetadata,
LoadingFallback,
@@ -181,6 +182,7 @@ const APP_LIST_RETRY_DELAY_MS = 1500;
export function RootLandingRedirect() {
const { apps, loading, getTypeStatus, refresh } = useMetadata();
+ const location = useLocation();
// "Not yet" (`loading`) and "asked and failed" (`status === 'error'`) are
// both "no answer" — they differ only in whether waiting will help.
@@ -210,5 +212,11 @@ export function RootLandingRedirect() {
// while the app list loads, and handing off to a null-rendering redirect left
// `#root` empty for 147 ms while `/home` rendered at transition priority. The
// splash is unchanged across the handoff, so the boot reads as one screen.
- return ;
+ //
+ // Search and hash carry over (objectstack-ai/objectstack#27450): the URL
+ // overlays (`?palette=1`, `?recordId=`) live in the query, and the landing is
+ // a bare `/apps/` or `/home`, so there is nothing to merge with. The
+ // app root it lands on carries them on to the app's own landing in turn.
+ const landing = resolveLandingPath(apps as LandingApp[] | undefined);
+ return ;
}
diff --git a/ui/packages/app-shell/src/console/AppContent.tsx b/ui/packages/app-shell/src/console/AppContent.tsx
index b1fda270bd2..d7094fbe6ab 100644
--- a/ui/packages/app-shell/src/console/AppContent.tsx
+++ b/ui/packages/app-shell/src/console/AppContent.tsx
@@ -1692,6 +1692,34 @@ function resolveLandingRoute(navigation: NavigationItem[], ctx?: NavTemplateCont
return findFirstRoute(navigation, ctx);
}
+/**
+ * objectstack-ai/objectstack#27450 — the landing target with the search and
+ * hash the app-root URL arrived with carried onto it. The URL overlays
+ * (`?palette=1`, `?recordId=`, `?form=`, `urlParams.ts`) live in the query, so
+ * a redirect that rebuilt the URL from the landing alone dropped the overlay a
+ * deep link asked for before anything read it.
+ *
+ * A landing that has a query of its own (a `page` entry's `params`, an
+ * `object` entry's `filters`) keeps it, and the two MERGE: a key the arriving
+ * URL names replaces the landing's values for that key, never doubles them —
+ * the link is the more specific ask. With nothing arriving the target is
+ * returned untouched; with no query on the landing the arriving search is
+ * appended as written.
+ */
+function carryQueryOntoLanding(target: string, search: string, hash: string): string {
+ const hashAt = target.indexOf('#');
+ const beforeHash = hashAt >= 0 ? target.slice(0, hashAt) : target;
+ const carriedHash = hash || (hashAt >= 0 ? target.slice(hashAt) : '');
+ if (!search || search === '?') return `${beforeHash}${carriedHash}`;
+ const queryAt = beforeHash.indexOf('?');
+ if (queryAt < 0) return `${beforeHash}${search}${carriedHash}`;
+ const params = new URLSearchParams(beforeHash.slice(queryAt + 1));
+ const arriving = new URLSearchParams(search);
+ for (const key of new Set(arriving.keys())) params.delete(key);
+ arriving.forEach((value, key) => params.append(key, value));
+ return `${beforeHash.slice(0, queryAt)}?${params.toString()}${carriedHash}`;
+}
+
/**
* The app root (`/apps/:appName`): navigates to the app's landing, or says the
* member has nothing here.
@@ -1731,6 +1759,7 @@ function AppRootLanding({
setupPath: string | null;
}) {
const gates = useNavMemberGates();
+ const location = useLocation();
// objectstack#25338 — an app that names objects, none of which this member
// may read, has nothing for them: its dashboards and pages would answer a
// grid of "You don't have access" tiles. Say so once, before any landing is
@@ -1763,7 +1792,9 @@ function AppRootLanding({
);
}
- if (landing) return ;
+ // objectstack-ai/objectstack#27450 — search and hash carry over, as on the
+ // other URL-rewrite redirects in this file.
+ if (landing) return ;
// objectui#12079 — an app that shows this member NO navigation (every entry
// gated away from them, or none served at all) is an app they have no access
// in yet. Say so, and offer Setup to a caller who can grant access: the
diff --git a/ui/packages/app-shell/src/console/__tests__/AppContent.rootLandingCarriesQuery-27450.test.tsx b/ui/packages/app-shell/src/console/__tests__/AppContent.rootLandingCarriesQuery-27450.test.tsx
new file mode 100644
index 00000000000..e200cf6edee
--- /dev/null
+++ b/ui/packages/app-shell/src/console/__tests__/AppContent.rootLandingCarriesQuery-27450.test.tsx
@@ -0,0 +1,218 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+/**
+ * objectstack-ai/objectstack#27450 — an app-root deep link keeps its query.
+ *
+ * The URL overlays (`?palette=1`, `?recordId=`, `?form=`) live in the query.
+ * The app root (`/apps/:appName`) redirects to the app's landing, and that
+ * redirect built its target from the landing alone, so
+ * `/apps/showcase_app?palette=1` settled on the landing page with no query and
+ * the command palette never opened. The redirect now carries search and hash
+ * across, merging with a landing that has a query of its own.
+ *
+ * Measured from a real `AppContent` mount (the `AppContent.nameAliasRedirect
+ * -26183` harness); the router's location is the observation point, and the
+ * real `CommandPaletteProvider` (which `ConsoleLayout` mounts in the shipped
+ * shell) reads the landed URL, so "the palette is open" is the provider's own
+ * answer.
+ *
+ * Direction, written before the run: on the pre-fix wiring every case that
+ * arrives with a query is RED (the landing has none); the bare-root control
+ * is GREEN on both sides.
+ */
+
+import '@testing-library/jest-dom/vitest';
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import React, { useEffect } from 'react';
+import { render, cleanup, waitFor, screen } from '@testing-library/react';
+import { MemoryRouter, Routes, Route, useLocation, useNavigationType } from 'react-router-dom';
+import { createI18n, I18nProvider } from '@object-ui/i18n';
+
+// The route's recent-items tracker runs; this file does not observe it.
+vi.mock('../../context/RecentItemsProvider', async (importOriginal) => ({
+ ...(await importOriginal>()),
+ useRecentItems: () => ({ recentItems: [], addRecentItem: () => {}, clearRecentItems: () => {} }),
+}));
+
+vi.mock('@object-ui/plugin-form', async (importOriginal) => ({
+ ...(await importOriginal>()),
+ ModalForm: () => null,
+}));
+
+vi.mock('@object-ui/plugin-designer', async (importOriginal) => ({
+ ...(await importOriginal()),
+ CreateAppPage: () => ,
+ EditAppPage: () => ,
+ DashboardDesignPage: () => ,
+}));
+
+// The shipped `ConsoleLayout` mounts the real `CommandPaletteProvider` around
+// the route slot; this stand-in keeps that provider and drops the chrome.
+vi.mock('../../layout/ConsoleLayout', async () => {
+ const { CommandPaletteProvider, useCommandPalette } = await import('../../context/CommandPaletteProvider');
+ function PaletteProbe() {
+ const { open } = useCommandPalette();
+ return open ? : null;
+ }
+ return {
+ ConsoleLayout: ({ children }: { children?: React.ReactNode }) => (
+
+
+ {children}
+
+ ),
+ };
+});
+vi.mock('../../chrome/CommandPalette', () => ({ CommandPalette: () => null }));
+vi.mock('../../chrome/KeyboardShortcutsDialog', () => ({ KeyboardShortcutsDialog: () => null }));
+vi.mock('../../chrome/OnboardingWalkthrough', () => ({ OnboardingWalkthrough: () => null }));
+vi.mock('../../views/ObjectView', () => ({ ObjectView: () => }));
+vi.mock('../../views/RecordDetailView', () => ({ RecordDetailView: () => null }));
+
+vi.mock('@object-ui/auth', async (importOriginal) => ({
+ ...(await importOriginal>()),
+ useAuth: () => ({
+ user: { id: 'u1', name: 'Ada', email: 'ada@example.com', role: 'user' },
+ getAuthConfig: async () => ({ features: {} }),
+ activeOrganization: null,
+ }),
+ useWorkspaceAdminStatus: () => ({ isAdmin: true, isResolved: true }),
+}));
+
+const dataSourceStub = {
+ onConnectionStateChange: () => () => {},
+ getConnectionState: () => 'connected',
+};
+vi.mock('../../providers/AdapterProvider', async (importOriginal) => ({
+ ...(await importOriginal>()),
+ useAdapter: () => dataSourceStub,
+}));
+
+/** The showcase app as the catalog serves it: a name AND a package id. */
+const SHOWCASE = {
+ name: 'showcase_app',
+ label: 'Showcase',
+ _packageId: 'com.example.showcase',
+ isDefault: true,
+ navigation: [{ id: 'tasks', type: 'object', objectName: 'showcase_task', label: 'Tasks' }],
+};
+
+/** The same app whose landing entry carries a query of its own (`?runAction=`). */
+const SHOWCASE_LANDING_WITH_QUERY = {
+ ...SHOWCASE,
+ navigation: [
+ { id: 'tasks', type: 'object', objectName: 'showcase_task', viewName: 'all', runAction: 'import', label: 'Tasks' },
+ ],
+};
+
+const OBJECTS = [{ name: 'showcase_task', label: 'Task', pluralLabel: 'Tasks', fields: { title: { type: 'text' } } }];
+
+/** The catalog a test serves. One value per test: the route's effects key on its members. */
+const { metadataRef } = vi.hoisted(() => ({ metadataRef: { current: null as unknown } }));
+function serve(apps: unknown[]): void {
+ metadataRef.current = {
+ apps,
+ objects: OBJECTS,
+ loading: false,
+ ensureType: undefined,
+ error: null,
+ refresh: async () => {},
+ };
+}
+vi.mock('../../providers/MetadataProvider', async (importOriginal) => ({
+ ...(await importOriginal>()),
+ useMetadata: () => metadataRef.current,
+}));
+
+const ACTION_RUNNER = { execute: () => {}, runner: { registerHandler: () => {}, getContext: () => ({}) } };
+vi.mock('@object-ui/react', async (importOriginal) => ({
+ ...(await importOriginal>()),
+ useActionRunner: () => ACTION_RUNNER,
+ useGlobalUndo: () => {},
+ useMutationInvalidationBridge: () => {},
+}));
+
+import { AppContent } from '../AppContent';
+import { COMMAND_PALETTE_PARAM, RECORD_DRAWER_PARAM } from '../../urlParams';
+
+/** The router's location as the last render saw it. */
+const seen: { pathname?: string; search?: string; hash?: string; type?: string } = {};
+function LocationProbe() {
+ const location = useLocation();
+ const type = useNavigationType();
+ useEffect(() => {
+ Object.assign(seen, { pathname: location.pathname, search: location.search, hash: location.hash, type });
+ }, [location, type]);
+ return null;
+}
+
+afterEach(cleanup);
+
+/** Mount the console's app router at `entry`; the landing's object list renders. */
+async function visit(entry: string): Promise {
+ const i18n = createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false });
+ render(
+
+
+
+ } />
+
+
+
+ ,
+ );
+ await screen.findByTestId('object-view', undefined, { timeout: 5000 });
+}
+
+describe('the app-root redirect carries the deep link\'s query (objectstack-ai/objectstack#27450)', () => {
+ it('THE CARD: `/apps/showcase_app?palette=1` lands on the default page with `?palette=1` kept and the palette open', async () => {
+ serve([SHOWCASE]);
+ await visit(`/apps/showcase_app?${COMMAND_PALETTE_PARAM}=1`);
+ await waitFor(() => expect(seen.pathname).toBe('/apps/com.example.showcase/showcase_task'));
+ expect(seen.search).toBe(`?${COMMAND_PALETTE_PARAM}=1`);
+ expect(seen.type).toBe('REPLACE');
+ expect(await screen.findByTestId('palette-open')).toBeInTheDocument();
+ });
+
+ it('carries any overlay param and the hash, not the palette alone', async () => {
+ serve([SHOWCASE]);
+ await visit(`/apps/com.example.showcase?${RECORD_DRAWER_PARAM}=rec_123456#notes`);
+ await waitFor(() => expect(seen.pathname).toBe('/apps/com.example.showcase/showcase_task'));
+ expect(seen.search).toBe(`?${RECORD_DRAWER_PARAM}=rec_123456`);
+ expect(seen.hash).toBe('#notes');
+ });
+
+ it('merges with a landing that has a query of its own', async () => {
+ serve([SHOWCASE_LANDING_WITH_QUERY]);
+ await visit(`/apps/com.example.showcase?${COMMAND_PALETTE_PARAM}=1`);
+ await waitFor(() => expect(seen.pathname).toBe('/apps/com.example.showcase/showcase_task/view/all'));
+ const params = new URLSearchParams(seen.search);
+ expect(params.getAll('runAction')).toEqual(['import']);
+ expect(params.getAll(COMMAND_PALETTE_PARAM)).toEqual(['1']);
+ expect(await screen.findByTestId('palette-open')).toBeInTheDocument();
+ });
+
+ it('a key both name is the link\'s value once, never doubled', async () => {
+ serve([SHOWCASE_LANDING_WITH_QUERY]);
+ await visit(`/apps/com.example.showcase?runAction=export&${COMMAND_PALETTE_PARAM}=1`);
+ await waitFor(() => expect(seen.pathname).toBe('/apps/com.example.showcase/showcase_task/view/all'));
+ const params = new URLSearchParams(seen.search);
+ expect(params.getAll('runAction')).toEqual(['export']);
+ expect(params.getAll(COMMAND_PALETTE_PARAM)).toEqual(['1']);
+ });
+
+ it('CONTROL: a bare app root lands on the bare landing, palette closed', async () => {
+ serve([SHOWCASE]);
+ await visit('/apps/com.example.showcase');
+ await waitFor(() => expect(seen.pathname).toBe('/apps/com.example.showcase/showcase_task'));
+ expect(seen.search).toBe('');
+ expect(seen.hash).toBe('');
+ expect(screen.queryByTestId('palette-open')).toBeNull();
+ });
+});