From 27534cfdbef8cd43f2e4eb4925019cb366510f09 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 15:01:53 +0000 Subject: [PATCH] fix(console): carry the deep link's query and hash across the app-root and console-root landing redirects The app root (`/apps/:appName`) redirected to the app's landing with a target built from the landing alone, so `?palette=1` and every other URL overlay param was dropped before anything read it. `AppRootLanding` now carries search and hash onto the landing, merging with a landing that has a query of its own (a key the link names replaces the landing's value). `RootLandingRedirect` at the console root carries them as well. Claude-Session: https://claude.ai/code/session_01Utp6CbjUouY1fgaJXGSQMk Co-authored-by: Claude --- .changeset/27450-app-root-deep-link-query.md | 11 + .../RootLandingRedirect.route.test.tsx | 39 +++- .../src/components/RootLandingRedirect.tsx | 10 +- .../app-shell/src/console/AppContent.tsx | 33 ++- ...ent.rootLandingCarriesQuery-27450.test.tsx | 218 ++++++++++++++++++ 5 files changed, 308 insertions(+), 3 deletions(-) create mode 100644 .changeset/27450-app-root-deep-link-query.md create mode 100644 ui/packages/app-shell/src/console/__tests__/AppContent.rootLandingCarriesQuery-27450.test.tsx 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(); + }); +});