diff --git a/.changeset/12809-splash-critical-path.md b/.changeset/12809-splash-critical-path.md new file mode 100644 index 0000000000..7649108a28 --- /dev/null +++ b/.changeset/12809-splash-critical-path.md @@ -0,0 +1,10 @@ +--- +'@object-ui/auth': minor +'@object-ui/app-shell': minor +--- + +The console splash no longer waits for requests the first screen does not need (objectui#12809). + +- **The organization chain runs in parallel.** When the `get-session` answer carries the organization plugin's `activeOrganizationId`, `AuthProvider` names the active organization from that id and the `organization/list` answer, and asks for the member row (`get-active-member`) at the same time as the list. It no longer asks for `get-full-organization` on boot: nothing the console reads uses the members or invitations that answer adds. An id the list does not carry names no organization, and the stored id is cleared, as before. A session answer without the key (a sign-in response, or a server without the organization plugin) takes the old path. Switching organization, the single-membership repair, sign-in and sign-out are unchanged. +- **`AuthClientSession.activeOrganizationId`** is a new optional member: the id better-auth's organization plugin puts on the session, or `null` when the session holds none. +- **The console waits only for the metadata the current route reads.** `AppContent` used to hold the splash until the object, dashboard, report and page lists had all loaded, on every route. It now waits for the lists the current route reads (an object page waits for objects; a dashboard for dashboards and objects). After the first screen, opening a route whose lists have not loaded shows a skeleton where the page will be, inside the console, instead of an empty list or a "not found". A route a host adds through `extraRoutes` still waits for all four lists. diff --git a/packages/app-shell/src/console/AppContent.tsx b/packages/app-shell/src/console/AppContent.tsx index f208362e5d..cf0123d4e9 100644 --- a/packages/app-shell/src/console/AppContent.tsx +++ b/packages/app-shell/src/console/AppContent.tsx @@ -54,6 +54,8 @@ import { AppNoAccessEmptyState } from './AppNoAccessEmptyState.js'; import { appServesNoNavigation } from './appServesNoNavigation.js'; import { useNavMemberGates, navEntriesShownToMember } from '../hooks/useNavMemberGates.js'; import { resolveSetupAppPath } from './ConsoleShell.js'; +import { collectRoutePaths, metadataReadsForAppRoute, type LazyMetadataType } from './appRouteMetadataReads.js'; +import { SkeletonDashboard, SkeletonDetail, SkeletonGrid } from '../skeletons/index.js'; // Route-based code splitting — lazy-load less-frequently-used routes const RecordDetailView = lazy(() => import('../views/RecordDetailView.js').then(m => ({ default: m.RecordDetailView }))); @@ -194,7 +196,7 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps = const navigate = useNavigate(); const location = useLocation(); const { appName } = useParams(); - const { apps, objects: allObjects, loading: metadataLoading, ensureType, error: metadataError, refresh: refreshMetadata } = useMetadata(); + const { apps, objects: allObjects, loading: metadataLoading, ensureType, getTypeStatus, error: metadataError, refresh: refreshMetadata } = useMetadata(); // objectui#7373 — where this file's two "you cannot be here" exits land. Both // sit BELOW the readiness gate further down (`metadataLoading` &c), so the // list they resolve against has settled; an app list that failed to load is @@ -205,26 +207,29 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps = const { t } = useObjectTranslation(); const { objectLabel, objectPluralLabel } = useObjectLabel(); - // Preload the metadata buckets that the routes under /apps/:appName/* assume - // are fully loaded by render time (the lazy MetadataProvider only eagerly - // loads `app`). - const [scopeMetaReady, setScopeMetaReady] = useState(!ensureType); - useEffect(() => { - if (!ensureType) { - setScopeMetaReady(true); - return; - } - let cancelled = false; - Promise.all([ - ensureType('object'), - ensureType('dashboard'), - ensureType('report'), - ensureType('page'), - ]).finally(() => { - if (!cancelled) setScopeMetaReady(true); - }); - return () => { cancelled = true; }; - }, [ensureType]); + // objectui#12809 — the lazy metadata buckets the CURRENT route reads at + // render (`appRouteMetadataReads.ts` declares them per route). This used to + // be a preload of all four (`object`, `dashboard`, `report`, `page`) that held + // the splash on every route; now the splash holds only for the current + // route's, and a later navigation onto a route whose buckets have not landed + // shows a skeleton in the route slot (below) instead of an empty list or a + // "not found". The record-form overlay (`?form=`) edits a record of an object, + // so it reads `object` on any route. + const hostRoutePaths = useMemo( + () => [...collectRoutePaths(extraRoutes), ...collectRoutePaths(extraRoutesNoApp)], + [extraRoutes, extraRoutesNoApp], + ); + const appSubPath = location.pathname.split('/').filter(Boolean).slice(2).join('/'); + const routeReads = metadataReadsForAppRoute(appSubPath, hostRoutePaths); + const routeReadsKey = [ + ...routeReads, + ...(searchParamsHasRecordForm(location.search) && !routeReads.includes('object') ? ['object'] : []), + ].join(','); + const routeReadsSettled = useLazyMetadataSettled(routeReadsKey, ensureType, getTypeStatus); + // Set once the first frame past the splash has rendered. After it, a route + // waiting on its buckets draws its skeleton inside the console chrome rather + // than taking the whole console back to the splash. + const firstPaintDone = useRef(false); // Hidden apps (`App.hidden`) are excluded from app-listing surfaces // (sidebar switcher, home grid, app switcher). The active app for the @@ -813,7 +818,16 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps = // organization stamped) waits exactly zero extra frames. What does wait is // the viewer whose verdict genuinely is not known yet — and for them a // LoadingScreen is the honest frame, not a screen built on a guess. - if (!dataSource || metadataLoading || !scopeMetaReady || !isWorkspaceAdminResolved) return ; + // + // objectui#12809 — of the lazy metadata, the splash waits only for what the + // current route reads (`routeReadsSettled`), and only until the first paint. + if ( + !dataSource || + metadataLoading || + !isWorkspaceAdminResolved || + (!firstPaintDone.current && !routeReadsSettled) + ) return ; + firstPaintDone.current = true; // ADR-0037 — preview mode renders its OWN empty/error states and never // falls through to the generic "No Apps Configured" guard below: inside a @@ -1062,6 +1076,9 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps = ); if (!activeApp && (isCreateAppRoute || isSystemRoute || isMetadataRoute)) { + // objectui#12809 — no chrome on this branch, so a route still waiting on + // its metadata keeps the splash, as its Suspense fallback does. + if (!routeReadsSettled) return ; return ( }> @@ -1148,6 +1165,9 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps = }> + {/* objectui#12809 — the route's metadata has not landed: its + skeleton, never its empty or "not found" state. */} + {!routeReadsSettled ? : ( } /> + )} @@ -1532,3 +1553,61 @@ function RouteNotFound() { ); } + +/** + * objectui#12809 — whether the record-form overlay is open in the URL. Its + * object definition comes from the `object` bucket on every route. + */ +function searchParamsHasRecordForm(search: string): boolean { + const sp = new URLSearchParams(search); + return sp.has(RECORD_FORM_PARAM) || sp.has(RECORD_FORM_OBJECT_PARAM); +} + +/** + * objectui#12809 — whether every bucket in `readsKey` (comma-joined) has + * answered: ready, or failed (a failure is an answer the route renders). + * + * A bucket that answered once and is now refetching (a `refresh()`, a lapsed + * TTL) keeps its items while it loads, so it still counts: the route stays + * mounted instead of dropping to a skeleton. A bucket back at `idle` (an + * organization switch or an `invalidate` dropped its items) waits again. + * + * A provider without `ensureType` / `getTypeStatus` (test doubles, hosts on an + * older context) has nothing to wait on: settled, as the old preload read it. + */ +function useLazyMetadataSettled( + readsKey: string, + ensureType: ((type: string) => Promise) | undefined, + getTypeStatus: ((type: string) => string) | undefined, +): boolean { + const answered = useRef(new Set()); + const types = readsKey ? readsKey.split(',') : []; + const statuses = types.map((type) => getTypeStatus?.(type) ?? 'idle'); + // Asked from an effect, never from render (the provider bumps its context + // when an answer lands). Re-asked whenever one of them is back at `idle`. + const idleKey = types.filter((_, i) => statuses[i] === 'idle').join(','); + useEffect(() => { + if (!ensureType) return; + for (const type of readsKey.split(',')) if (type) void ensureType(type); + }, [readsKey, idleKey, ensureType]); + + if (!ensureType || !getTypeStatus || types.length === 0) return true; + let settled = true; + types.forEach((type, i) => { + const status = statuses[i]; + if (status === 'ready' || status === 'error') answered.current.add(type); + else if (status === 'idle') answered.current.delete(type); + if (!answered.current.has(type)) settled = false; + }); + return settled; +} + +/** The route slot while its metadata loads, shaped like what will land there. */ +function RouteMetadataSkeleton({ reads }: { reads: string }) { + const types = reads.split(',') as LazyMetadataType[]; + return ( +
+ {types[0] === 'dashboard' ? : types.includes('page') ? : } +
+ ); +} diff --git a/packages/app-shell/src/console/__tests__/AppContent.splashCriticalPath-12809.test.tsx b/packages/app-shell/src/console/__tests__/AppContent.splashCriticalPath-12809.test.tsx new file mode 100644 index 0000000000..3deacd92de --- /dev/null +++ b/packages/app-shell/src/console/__tests__/AppContent.splashCriticalPath-12809.test.tsx @@ -0,0 +1,266 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#12809 — what the console splash (`data-boot-splash`) waits for. + * + * The critical path is the session, the membership answer the admin verdict + * needs, and the metadata the CURRENT route reads. Measured before this card: + * `organization/list` → `get-full-organization` → `get-active-member` ran one + * after another, and `AppContent` held the splash until the `object`, + * `dashboard`, `report` and `page` buckets had all loaded, whatever the route. + * + * The harness is the real `AuthProvider` (with a server double) and the real + * `useWorkspaceAdminStatus` under the real `AppContent`. The server double's + * `getActiveOrganization` (`get-full-organization`) never answers, and every + * metadata bucket answers only when the test settles it. So each step below is + * an observation of what the splash waited for, not an inference. + * + * `me/permissions` is the console host's (`ConsolePermissionsScope` in + * `apps/console`), mounted above `AppContent`; it stays on the critical path + * and is outside this harness. + * + * CONTROL: after the first paint, a route whose buckets have not landed draws + * its skeleton inside the console chrome, then its content; and a bucket that + * refetches keeps the route mounted. + */ + +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import React, { useEffect } from 'react'; +import { act, cleanup, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter, Routes, Route, useNavigate, type NavigateFunction } from 'react-router-dom'; +import { AuthProvider, ActiveOrganizationStorage } from '@object-ui/auth'; +import type { AuthClient, AuthOrganization } from '@object-ui/auth'; + +vi.mock('@object-ui/plugin-designer', async (importOriginal) => ({ + ...(await importOriginal()), + CreateAppPage: () => null, + EditAppPage: () => null, + DashboardDesignPage: () => null, +})); +vi.mock('../../layout/ConsoleLayout', () => ({ + 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: ({ objects }: { objects: Array<{ name: string }> }) => ( +
{objects.map((o) => o.name).join(',')}
+ ), +})); +vi.mock('../../views/ReportView', () => ({ ReportView: () =>
})); +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useObjectTranslation: () => ({ + t: (key: string, options?: Record) => String(options?.defaultValue ?? key), + }), + useObjectLabel: () => ({ objectLabel: ({ label }: { label?: string }) => label, objectPluralLabel: () => '' }), +})); + +const dataSourceStub = { + onConnectionStateChange: () => () => {}, + getConnectionState: () => 'connected', +}; +vi.mock('../../providers/AdapterProvider', async (importOriginal) => ({ + ...(await importOriginal>()), + useAdapter: () => dataSourceStub, +})); + +/** + * A metadata cache whose lazy buckets answer only when the test says so. Same + * status words and read semantics as `MetadataProvider`: `idle` until asked, + * `loading` while in flight, items `[]` until `ready`. + */ +const meta = vi.hoisted(() => ({ + status: {} as Record, + items: {} as Record, + version: 0, + listeners: new Set<() => void>(), + ensureType: null as unknown as ReturnType, +})); +function bump() { + meta.version += 1; + meta.listeners.forEach((l) => l()); +} +vi.mock('../../providers/MetadataProvider', async (importOriginal) => { + const react = await import('react'); + return { + ...(await importOriginal>()), + useMetadata: () => { + react.useSyncExternalStore( + (l: () => void) => { meta.listeners.add(l); return () => meta.listeners.delete(l); }, + () => meta.version, + ); + const read = (type: string) => (meta.status[type] === 'ready' || meta.status[type] === 'loading' ? meta.items[type] ?? [] : []); + return { + apps: [{ name: 'crm', label: 'CRM', navigation: [] }], + get objects() { return read('object'); }, + get dashboards() { return read('dashboard'); }, + get reports() { return read('report'); }, + get pages() { return read('page'); }, + loading: false, + error: null, + ensureType: meta.ensureType, + getTypeStatus: (type: string) => meta.status[type] ?? 'idle', + refresh: vi.fn(async () => {}), + }; + }, + }; +}); + +const actionRunnerStub = { registerHandler: vi.fn(), getContext: () => ({}) }; +vi.mock('@object-ui/react', async (importOriginal) => ({ + ...(await importOriginal>()), + useActionRunner: () => ({ execute: vi.fn(), runner: actionRunnerStub }), + useGlobalUndo: () => {}, + useMutationInvalidationBridge: () => {}, +})); + +import { AppContent } from '../AppContent'; + +const USER = 'u_12809'; +const ORG: AuthOrganization = { id: 'org_a', name: 'Org A', slug: 'a' }; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((r) => { resolve = r; }); + return { promise, resolve }; +} + +function createServer() { + const list = deferred(); + const member = deferred(); + const client = { + getSession: vi.fn(async () => ({ + // `role: 'user'`, no positions: the verdict needs the member row. + user: { id: USER, name: 'Ada', email: 'ada@test.com', role: 'user' }, + session: { token: 'tok-12809', activeOrganizationId: ORG.id }, + })), + signOut: vi.fn(async () => undefined), + getConfig: vi.fn(async () => ({ features: {} })), + listOrganizations: vi.fn(() => list.promise), + // `get-full-organization`: never answers. The splash must not wait on it. + getActiveOrganization: vi.fn(() => new Promise(() => {})), + setActiveOrganization: vi.fn(async () => ORG), + getActiveMember: vi.fn(() => member.promise), + }; + return { + client: client as unknown as AuthClient & typeof client, + answerList: () => act(async () => { list.resolve([ORG]); }), + answerMember: () => act(async () => { + member.resolve({ id: 'm1', organizationId: ORG.id, userId: USER, role: 'member' }); + }), + }; +} + +const settle = (type: string, items: unknown[] = []) => act(async () => { + meta.items[type] = items; + meta.status[type] = 'ready'; + bump(); +}); + +const nav: { current: NavigateFunction | null } = { current: null }; +function NavProbe() { + const navigate = useNavigate(); + useEffect(() => { nav.current = navigate; }); + return null; +} + +function boot(client: AuthClient, url: string) { + render( + + + + + } /> + + + , + ); +} + +const splashUp = () => document.querySelector('[data-boot-splash]') !== null; +const askedTypes = () => new Set(meta.ensureType.mock.calls.map((c) => c[0] as string)); + +beforeEach(() => { + meta.status = {}; + meta.items = {}; + meta.version = 0; + meta.ensureType = vi.fn((type: string) => { + if ((meta.status[type] ?? 'idle') === 'idle') meta.status[type] = 'loading'; + return Promise.resolve(meta.items[type] ?? []); + }); + localStorage.clear(); + ActiveOrganizationStorage.clear(); +}); + +afterEach(() => { + cleanup(); + ActiveOrganizationStorage.clear(); +}); + +describe('the console splash waits for the critical path only (objectui#12809)', () => { + it('leaves after the session, the member row and the route\'s own bucket — not get-full-organization or the other buckets', async () => { + const { client, answerList, answerMember } = createServer(); + boot(client, '/apps/crm/account'); + + await waitFor(() => expect(client.listOrganizations).toHaveBeenCalledTimes(1)); + // The member row is asked for beside the list, not after it. + expect(client.getActiveMember).toHaveBeenCalledTimes(1); + expect(splashUp()).toBe(true); + + await answerList(); + expect(splashUp()).toBe(true); // the member row has not answered + await answerMember(); + expect(splashUp()).toBe(true); // the route's `object` bucket has not answered + + await settle('object', [{ name: 'account' }]); + await waitFor(() => expect(screen.getByTestId('object-view')).toHaveTextContent('account')); + expect(splashUp()).toBe(false); + + // What the splash did NOT wait for. + expect(client.getActiveOrganization).not.toHaveBeenCalled(); + expect(askedTypes()).toEqual(new Set(['object'])); + for (const type of ['dashboard', 'report', 'page']) expect(meta.status[type]).toBeUndefined(); + }); + + it('CONTROL: a later route whose bucket has not landed shows its skeleton inside the chrome, then its content', async () => { + const { client, answerList, answerMember } = createServer(); + boot(client, '/apps/crm/account'); + await answerList(); + await answerMember(); + await settle('object', [{ name: 'account' }]); + await waitFor(() => expect(screen.getByTestId('object-view')).toBeInTheDocument()); + + await act(async () => { nav.current!('/apps/crm/report/pipeline'); }); + + expect(screen.getByTestId('console-layout')).toBeInTheDocument(); + expect(screen.getByTestId('route-metadata-skeleton')).toBeInTheDocument(); + expect(screen.queryByTestId('report-view')).not.toBeInTheDocument(); + expect(splashUp()).toBe(false); + expect(askedTypes().has('report')).toBe(true); + + await settle('report', [{ name: 'pipeline' }]); + await waitFor(() => expect(screen.getByTestId('report-view')).toBeInTheDocument()); + expect(screen.queryByTestId('route-metadata-skeleton')).not.toBeInTheDocument(); + }); + + it('CONTROL: a bucket that refetches keeps the route mounted; one dropped back to idle waits again', async () => { + const { client, answerList, answerMember } = createServer(); + boot(client, '/apps/crm/account'); + await answerList(); + await answerMember(); + await settle('object', [{ name: 'account' }]); + await waitFor(() => expect(screen.getByTestId('object-view')).toBeInTheDocument()); + + await act(async () => { meta.status.object = 'loading'; bump(); }); + expect(screen.getByTestId('object-view')).toBeInTheDocument(); + + await act(async () => { meta.status.object = 'idle'; meta.items.object = []; bump(); }); + expect(screen.getByTestId('route-metadata-skeleton')).toBeInTheDocument(); + expect(screen.queryByTestId('object-view')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/app-shell/src/console/__tests__/appRouteMetadataReads-12809.test.ts b/packages/app-shell/src/console/__tests__/appRouteMetadataReads-12809.test.ts new file mode 100644 index 0000000000..b745939fd6 --- /dev/null +++ b/packages/app-shell/src/console/__tests__/appRouteMetadataReads-12809.test.ts @@ -0,0 +1,134 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#12809 — the enumeration of route readers of the lazy metadata + * buckets. + * + * `AppContent` no longer preloads `object`, `dashboard`, `report` and `page` + * before every route. Each route waits for the buckets + * `APP_ROUTE_METADATA_READS` declares for it, so a route missing from that + * table would render against an empty bucket: an empty list or a "not found" + * where a record should be. This file reads the `` table out of + * `AppContent.tsx` itself (both branches, nested routes joined) and fails on + * any route the reads table does not declare, and on any row that names no + * route. It also pins how a URL is matched to its row, ranked as `` + * ranks it, host routes included. + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import React from 'react'; +import { Route } from 'react-router-dom'; +import ts from 'typescript'; +import { + APP_ROUTE_METADATA_READS, + ALL_LAZY_METADATA_TYPES, + collectRoutePaths, + metadataReadsForAppRoute, +} from '../appRouteMetadataReads'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const APP_CONTENT = readFileSync(join(HERE, '..', 'AppContent.tsx'), 'utf8'); +// Read from the source rather than imported: `AppContent` pulls the whole +// console module graph, and this pin only needs the route table's text. +const RECORD_NAMED_PAGE_ROUTE = /export const RECORD_NAMED_PAGE_ROUTE = '([^']+)'/.exec(APP_CONTENT)?.[1] ?? 'NOT FOUND'; + +/** Every `` path `AppContent.tsx` declares, nested paths joined. */ +function declaredRoutePaths(source: string): string[] { + const file = ts.createSourceFile('AppContent.tsx', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + const out: string[] = []; + const attr = (attrs: ts.JsxAttributes, name: string) => + attrs.properties.find((p): p is ts.JsxAttribute => ts.isJsxAttribute(p) && p.name.getText(file) === name); + const pathOf = (attrs: ts.JsxAttributes): string | null => { + const a = attr(attrs, 'path'); + if (!a?.initializer) return null; + if (ts.isStringLiteral(a.initializer)) return a.initializer.text; + const expr = ts.isJsxExpression(a.initializer) ? a.initializer.expression : undefined; + if (expr && ts.isIdentifier(expr) && expr.text === 'RECORD_NAMED_PAGE_ROUTE') return RECORD_NAMED_PAGE_ROUTE; + throw new Error(`unreadable at ${a.getText(file)}`); + }; + const visit = (node: ts.Node, parent: string) => { + let opening: ts.JsxOpeningLikeElement | null = null; + if (ts.isJsxElement(node)) opening = node.openingElement; + else if (ts.isJsxSelfClosingElement(node)) opening = node; + if (opening && opening.tagName.getText(file) === 'Route') { + const own = pathOf(opening.attributes); + const isIndex = !!attr(opening.attributes, 'index'); + const path = own ? (parent ? `${parent}/${own}` : own) : parent; + if (own || isIndex) out.push(path === '/' || path === '' ? '/' : path); + if (ts.isJsxElement(node)) node.children.forEach((c) => visit(c, path)); + return; + } + ts.forEachChild(node, (c) => visit(c, parent)); + }; + visit(file, ''); + return out; +} + +const tablePaths = new Set(APP_ROUTE_METADATA_READS.map((r) => r.path)); + +describe('every route AppContent declares has its metadata reads declared (objectui#12809)', () => { + const declared = declaredRoutePaths(APP_CONTENT); + + it('the source read finds the route table (positive control)', () => { + expect(declared).toContain(':objectName'); + expect(declared).toContain('metadata/:type/:name/history'); + expect(declared).toContain('/'); + expect(declared).toContain(RECORD_NAMED_PAGE_ROUTE); + }); + + it('no route is missing from APP_ROUTE_METADATA_READS', () => { + expect([...new Set(declared)].filter((p) => !tablePaths.has(p))).toEqual([]); + }); + + it('no row names a route AppContent does not declare', () => { + const declaredSet = new Set(declared); + expect([...tablePaths].filter((p) => !declaredSet.has(p))).toEqual([]); + }); +}); + +describe('a URL is matched to the reads of the route that renders it (objectui#12809)', () => { + it.each([ + ['', ['object']], + ['account', ['object']], + ['account/view/all', ['object']], + ['account/record/r1', ['object', 'page']], + ['account/record/r1/page/print', ['object', 'page']], + ['account/record/r1/edit', ['object']], + ['dashboard/sales', ['dashboard', 'object']], + ['report/pipeline', ['report', 'object']], + ['page/welcome', ['page', 'object']], + ['metadata/object/account', ['object']], + ['metadata/package/x', []], + ['system/marketplace', []], + ['account/abc123def', []], + ['component/ns/name/deep', ALL_LAZY_METADATA_TYPES], + ])('%s → %j', (subPath, reads) => { + expect(metadataReadsForAppRoute(subPath as string)).toEqual(reads); + }); + + it('a host route waits for every bucket, ranked above the generic object routes', () => { + const host = collectRoutePaths( + React.createElement( + React.Fragment, + null, + React.createElement(Route, { path: 'system/settings' }), + React.createElement( + Route, + { path: 'docs' }, + React.createElement(Route, { index: true }), + React.createElement(Route, { path: ':slug' }), + ), + ), + ); + expect(new Set(host)).toEqual(new Set(['system/settings', 'docs', 'docs/:slug'])); + // Without the host paths, `system/settings` would read as the record-id + // redirect and `docs` as an object list. + expect(metadataReadsForAppRoute('system/settings')).toEqual([]); + expect(metadataReadsForAppRoute('system/settings', host)).toEqual(ALL_LAZY_METADATA_TYPES); + expect(metadataReadsForAppRoute('docs', host)).toEqual(ALL_LAZY_METADATA_TYPES); + expect(metadataReadsForAppRoute('docs/intro', host)).toEqual(ALL_LAZY_METADATA_TYPES); + }); +}); diff --git a/packages/app-shell/src/console/appRouteMetadataReads.ts b/packages/app-shell/src/console/appRouteMetadataReads.ts new file mode 100644 index 0000000000..89179c771e --- /dev/null +++ b/packages/app-shell/src/console/appRouteMetadataReads.ts @@ -0,0 +1,118 @@ +/** + * Which lazy metadata buckets each route under `/apps/:appName/*` reads at + * render (objectui#12809). + * + * `MetadataProvider` loads `app` and `view` eagerly. `object`, `dashboard`, + * `report` and `page` load on first read, and until they land a reader sees + * `[]`: for most route elements that is a "not found" or an empty state, not a + * skeleton. `AppContent` used to hold the splash until all four had loaded, + * whatever the route. It now waits only for the buckets the CURRENT route + * reads: the boot splash holds for them, and a later navigation to a route + * whose buckets are not in yet shows a skeleton in the route slot. + * + * ⛔ A route `AppContent` declares that is missing from + * {@link APP_ROUTE_METADATA_READS} fails `appRouteMetadataReads.test.ts`, + * which reads the route table out of `AppContent.tsx` itself. So a new route + * cannot skip its wait by being forgotten here; declare what its element reads. + * + * A route the host adds (`extraRoutes`, `extraRoutesNoApp`) is not known here, + * so it waits for all four, the console's behaviour before this table. + */ + +import { Children, Fragment, isValidElement, type ReactNode } from 'react'; +import { matchRoutes } from 'react-router-dom'; + +export type LazyMetadataType = 'object' | 'dashboard' | 'report' | 'page'; + +/** Every lazy bucket: what an unknown route waits for. */ +export const ALL_LAZY_METADATA_TYPES: readonly LazyMetadataType[] = ['object', 'dashboard', 'report', 'page']; + +const NONE: readonly LazyMetadataType[] = []; +const OBJECT: readonly LazyMetadataType[] = ['object']; + +/** + * One row per `` `AppContent` declares, in both of its branches + * (paths relative to `/apps/:appName`, nested routes joined). `reads` is what + * the route's element reads on first paint, the children it renders included. + */ +export const APP_ROUTE_METADATA_READS: ReadonlyArray<{ path: string; reads: readonly LazyMetadataType[] }> = [ + // AppRootLanding: the nav prune of `useNavMemberGates` reads `objects`. + { path: '/', reads: OBJECT }, + // Metadata admin: the position-holder section and the form-screen preview + // read `objects`. + { path: 'metadata', reads: OBJECT }, + { path: 'metadata/_diagnostics', reads: OBJECT }, + { path: 'metadata/:type', reads: OBJECT }, + { path: 'metadata/:type/new', reads: OBJECT }, + { path: 'metadata/:type/:name', reads: OBJECT }, + { path: 'metadata/:type/:name/history', reads: OBJECT }, + { path: 'metadata/package/*', reads: NONE }, + // ObjectView, ObjectDataPage, RecordFormPage: the object definition. + { path: ':objectName', reads: OBJECT }, + { path: ':objectName/new', reads: OBJECT }, + { path: ':objectName/view/:viewId', reads: OBJECT }, + { path: ':objectName/data', reads: OBJECT }, + { path: ':objectName/record/:recordId/edit', reads: OBJECT }, + // RecordDetailView: the object definition and its page assignment. + { path: ':objectName/record/:recordId', reads: ['object', 'page'] }, + { path: ':objectName/record/:recordId/page/:pageName', reads: ['object', 'page'] }, + // DashboardView, ReportView, PageView: their own bucket and the objects + // their widgets, columns and record actions resolve against. + { path: 'dashboard/:dashboardName', reads: ['dashboard', 'object'] }, + { path: 'report/:reportName', reads: ['report', 'object'] }, + { path: 'page/:pageName', reads: ['page', 'object'] }, + // ComponentNavView renders whatever component the registry holds. + { path: 'component/:ns/:name/*', reads: ALL_LAZY_METADATA_TYPES }, + { path: 'component/metadata/directory', reads: NONE }, + { path: 'component/metadata/resource/*', reads: NONE }, + { path: 'design/dashboard/:dashboardName', reads: ['dashboard'] }, + // SearchResultsPage: record-search targets, and nav labels for dashboards. + { path: 'search', reads: ['object', 'dashboard'] }, + // The app designer's object pickers. + { path: 'create-app', reads: OBJECT }, + { path: 'edit-app/:editAppName', reads: OBJECT }, + { path: 'system/marketplace', reads: NONE }, + { path: 'system/marketplace/installed', reads: NONE }, + { path: 'system/marketplace/:packageId', reads: NONE }, + // A redirect: the route it lands on carries its own row. + { path: ':objectName/:maybeRecordId', reads: NONE }, + { path: '*', reads: NONE }, +]; + +/** + * The `path` of every `` in a route fragment, nested paths joined and + * an index route spelled as its parent's path. + */ +export function collectRoutePaths(node: ReactNode, parent = ''): string[] { + const out: string[] = []; + Children.forEach(node, (child) => { + if (!isValidElement(child)) return; + const props = child.props as { path?: string; index?: boolean; children?: ReactNode }; + if (child.type === Fragment) { + out.push(...collectRoutePaths(props.children, parent)); + return; + } + const own = typeof props.path === 'string' ? props.path : ''; + const path = own ? (parent ? `${parent}/${own}` : own) : parent; + if (props.index || own) out.push(path || '/'); + if (props.children) out.push(...collectRoutePaths(props.children, path)); + }); + return out; +} + +/** + * The buckets the route at `subPath` (the path below `/apps/:appName`) reads, + * ranked the way `` ranks them. `hostPaths` are the paths of the host's + * extra routes, which wait for every bucket. + */ +export function metadataReadsForAppRoute( + subPath: string, + hostPaths: readonly string[] = [], +): readonly LazyMetadataType[] { + const routes = [ + ...APP_ROUTE_METADATA_READS, + ...hostPaths.map((path) => ({ path, reads: ALL_LAZY_METADATA_TYPES })), + ]; + const matches = matchRoutes(routes, `/${subPath.replace(/^\/+/, '')}`); + return matches ? matches[matches.length - 1].route.reads : ALL_LAZY_METADATA_TYPES; +} diff --git a/packages/auth/src/AuthProvider.tsx b/packages/auth/src/AuthProvider.tsx index 407c90f3b9..845dd05ac5 100644 --- a/packages/auth/src/AuthProvider.tsx +++ b/packages/auth/src/AuthProvider.tsx @@ -230,6 +230,20 @@ export function AuthProvider({ // stale, previous-organization data over the newer one (see // `switchOrganization`). const sessionLoadGeneration = useRef(0); + // objectui#12809 — the member row asked for at the same time as + // `organization/list`, when the session already names its active + // organization. `get-active-member` answers for the session's active + // organization, so it does not have to wait for the list. Held with the org + // and user it was asked for and taken once, by the first + // `refreshActiveMember` for that same organization; anything else drops it + // (a switch, a sign-out, an active organization the list did not confirm). + // Not a cache: the answer is used for the moment it was asked in, never for + // a later question. + const bootMemberRef = useRef<{ + orgId: string; + userId: string; + answer: Promise; + } | null>(null); /** * The ONE session loader. Runs on mount (below) and on every later @@ -436,6 +450,7 @@ export function AuthProvider({ // this file: drop the in-memory reference and the stored id together. setOrganizations([]); setActiveOrganization(null); + bootMemberRef.current = null; } catch (err) { setError(err instanceof Error ? err : new Error(String(err))); } finally { @@ -651,7 +666,20 @@ export function AuthProvider({ // --- Organization methods --- - const refreshOrganizations = useCallback(async (isCancelled?: () => boolean) => { + /** + * `sessionActiveOrgId` — objectui#12809. Passed only by the load that follows + * a `get-session` answer (the effect below), and only when that answer + * carries the organization plugin's `activeOrganizationId`. The session + * already names the server's active organization, so it is named from that + * id and the list answer: no `get-full-organization` round trip, whose extra + * fields (members, invitations) nothing the console reads. Every other caller + * (a mutation, `refreshOrganizations()` on the context) asks the server, as + * before: the session in hand may predate what that mutation changed. + */ + const refreshOrganizations = useCallback(async ( + isCancelled?: () => boolean, + sessionActiveOrgId?: string | null, + ) => { if (!enabled || isPreviewMode) return; setIsOrganizationsLoading(true); try { @@ -661,11 +689,17 @@ export function AuthProvider({ // If no active org is set but orgs exist, try to get active from server if (orgs.length > 0 && !activeOrganization) { let active: AuthOrganization | null = null; - try { - active = await client.getActiveOrganization(); - } catch { - // No active org set — that's fine - active = null; + if (sessionActiveOrgId !== undefined) { + // An id the list does not carry is no membership of this user's: the + // server's full-organization read refuses it the same way. + active = (sessionActiveOrgId && orgs.find((o) => o.id === sessionActiveOrgId)) || null; + } else { + try { + active = await client.getActiveOrganization(); + } catch { + // No active org set — that's fine + active = null; + } } // Single-membership repair (ADR-0081): a session created before the // server-side active-org stamp existed carries no active org even @@ -680,6 +714,9 @@ export function AuthProvider({ } } if (isCancelled?.()) return orgs; + if (bootMemberRef.current && bootMemberRef.current.orgId !== active?.id) { + bootMemberRef.current = null; + } if (active) { setActiveOrganization(active); ActiveOrganizationStorage.set(active.id); @@ -745,8 +782,12 @@ export function AuthProvider({ // A switch re-opens the question: until `getActiveMember()` answers for the // NEW org, `activeMember` still holds the OLD org's row. setActiveMemberResolved(false); + const boot = bootMemberRef.current; + bootMemberRef.current = null; try { - const member = await client.getActiveMember(); + const member = boot && boot.orgId === activeOrganization.id + ? await boot.answer + : await client.getActiveMember(); setActiveMember(member); } catch (err) { console.warn('[AuthProvider] Failed to load active member:', err); @@ -792,10 +833,26 @@ export function AuthProvider({ }, [refreshActiveMember]); // Load organizations once user is authenticated + // + // objectui#12809 — a session answer that carries the organization plugin's + // `activeOrganizationId` already names the server's active organization, so + // the member row is asked for now, beside `organization/list`, instead of + // after the active organization lands. A StrictMode effect replay finds the + // same question already asked and does not ask again. useEffect(() => { if (!(user && enabled && !isPreviewMode)) return; let cancelled = false; - refreshOrganizations(() => cancelled); + const sessionActiveOrgId = session?.activeOrganizationId; + if (typeof sessionActiveOrgId === 'string' && !activeOrganization) { + const asked = bootMemberRef.current; + if (!asked || asked.orgId !== sessionActiveOrgId || asked.userId !== user.id) { + const answer = client.getActiveMember(); + // Taken later, or dropped: either way it must not surface as unhandled. + answer.catch(() => undefined); + bootMemberRef.current = { orgId: sessionActiveOrgId, userId: user.id, answer }; + } + } + refreshOrganizations(() => cancelled, sessionActiveOrgId); return () => { cancelled = true; }; }, [user, enabled, isPreviewMode]); // eslint-disable-line react-hooks/exhaustive-deps @@ -840,6 +897,7 @@ export function AuthProvider({ async (orgId: string) => { setError(null); const previousOrgId = activeOrgIdRef.current; + bootMemberRef.current = null; sessionLoadInFlight.current = true; try { const org = await client.setActiveOrganization(orgId); diff --git a/packages/auth/src/__tests__/orgChainParallel-12809.test.tsx b/packages/auth/src/__tests__/orgChainParallel-12809.test.tsx new file mode 100644 index 0000000000..c8020ddbb5 --- /dev/null +++ b/packages/auth/src/__tests__/orgChainParallel-12809.test.tsx @@ -0,0 +1,217 @@ +/** + * 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. + */ + +/** + * objectui#12809 — the organization chain on console open is no longer serial. + * + * Before: `organization/list`, then `get-full-organization` (to learn the + * active organization), then `get-active-member` (only once the active + * organization had landed): three round trips one after another on every boot. + * + * Now, when the `get-session` answer carries the organization plugin's + * `activeOrganizationId`: + * - the active organization is named from that id and the list answer, so + * `get-full-organization` is not asked at all; + * - the member row is asked for at the same time as the list, not after it. + * + * The server below is a double whose list answer is held open by the test, so + * "asked before the list answered" is an observation, not an inference. + * CONTROLs: a switch still asks for the new organization's member row; a + * session answer without the key takes the old path; the stale-id clear + * (objectui#12698) and the single-membership repair (ADR-0081) hold on the new + * path too. + */ + +import React, { StrictMode, useEffect } from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { act, cleanup, render, waitFor } from '@testing-library/react'; +import { AuthProvider } from '../AuthProvider'; +import { useAuth } from '../useAuth'; +import { ActiveOrganizationStorage, SessionUserScope } from '../ActiveOrganizationStorage'; +import type { AuthContextValue } from '../AuthContext'; +import type { AuthClient, AuthOrganization, AuthOrganizationMember } from '../types'; + +const USER = 'u_12809'; +const ORG_A: AuthOrganization = { id: 'org_a', name: 'Org A', slug: 'a' }; +const ORG_B: AuthOrganization = { id: 'org_b', name: 'Org B', slug: 'b' }; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((r) => { resolve = r; }); + return { promise, resolve }; +} + +function memberOf(orgId: string): AuthOrganizationMember { + return { id: `m_${orgId}`, organizationId: orgId, userId: USER, role: 'org_admin' }; +} + +/** + * A server that holds its own active organization. `sessionKey: false` answers + * `get-session` the way a server without the organization plugin does. + */ +function createServer( + memberships: AuthOrganization[], + initiallyActive: string | null, + { sessionKey = true }: { sessionKey?: boolean } = {}, +) { + let active: string | null = initiallyActive; + const list = deferred(); + const client = { + getSession: vi.fn(async () => ({ + user: { id: USER, name: 'Ada', email: 'ada@test.com' }, + session: sessionKey ? { token: 'tok-12809', activeOrganizationId: active } : { token: 'tok-12809' }, + })), + signOut: vi.fn().mockResolvedValue(undefined), + listOrganizations: vi.fn(() => list.promise), + getActiveOrganization: vi.fn(async () => memberships.find((o) => o.id === active) ?? null), + setActiveOrganization: vi.fn(async (orgId: string) => { + const target = memberships.find((o) => o.id === orgId); + if (!target) { + active = null; + throw Object.assign(new Error('not a member'), { status: 403 }); + } + active = target.id; + return target; + }), + getActiveMember: vi.fn(async () => (active && memberships.some((o) => o.id === active) ? memberOf(active) : null)), + }; + return { + client: client as unknown as AuthClient & typeof client, + answerList: () => act(async () => { list.resolve(memberships); }), + }; +} + +const authRef: { current: AuthContextValue | null } = { current: null }; + +function Probe() { + const auth = useAuth(); + useEffect(() => { + authRef.current = auth; + }); + return null; +} + +function boot(client: AuthClient, { strict = false } = {}) { + const tree = ( + + + + ); + render(strict ? {tree} : tree); +} + +beforeEach(() => { + authRef.current = null; + localStorage.clear(); + ActiveOrganizationStorage.clear(); + SessionUserScope._resetForTests(); +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + ActiveOrganizationStorage.clear(); + SessionUserScope._resetForTests(); +}); + +describe('the organization chain on console open (objectui#12809)', () => { + it('asks for the member row while organization/list is still in flight, and never for the full organization', async () => { + const { client, answerList } = createServer([ORG_A, ORG_B], ORG_A.id); + boot(client); + + await waitFor(() => expect(client.listOrganizations).toHaveBeenCalledTimes(1)); + // The list has not answered, and the member row is already asked for. + expect(client.getActiveMember).toHaveBeenCalledTimes(1); + + await answerList(); + await waitFor(() => expect(authRef.current?.isMembershipResolved).toBe(true)); + + // Named from session.activeOrganizationId plus the list answer. + expect(authRef.current?.activeOrganization).toBe(ORG_A); + expect(authRef.current?.activeMember).toEqual(memberOf(ORG_A.id)); + expect(ActiveOrganizationStorage.get()).toBe(ORG_A.id); + expect(client.getActiveOrganization).not.toHaveBeenCalled(); + // The early answer is the one used: no second member read. + expect(client.getActiveMember).toHaveBeenCalledTimes(1); + }); + + it('a StrictMode effect replay does not ask for the member row twice', async () => { + const { client, answerList } = createServer([ORG_A, ORG_B], ORG_A.id); + boot(client, { strict: true }); + + await waitFor(() => expect(client.listOrganizations).toHaveBeenCalled()); + await answerList(); + await waitFor(() => expect(authRef.current?.isMembershipResolved).toBe(true)); + + expect(authRef.current?.activeMember).toEqual(memberOf(ORG_A.id)); + expect(client.getActiveMember).toHaveBeenCalledTimes(1); + expect(client.getActiveOrganization).not.toHaveBeenCalled(); + }); + + it('CONTROL: a switch asks for the NEW organization\'s member row', async () => { + const { client, answerList } = createServer([ORG_A, ORG_B], ORG_A.id); + boot(client); + await waitFor(() => expect(client.listOrganizations).toHaveBeenCalled()); + await answerList(); + await waitFor(() => expect(authRef.current?.activeMember).toEqual(memberOf(ORG_A.id))); + expect(client.getActiveMember).toHaveBeenCalledTimes(1); + + await act(async () => { + await authRef.current!.switchOrganization(ORG_B.id); + }); + await waitFor(() => expect(authRef.current?.activeMember).toEqual(memberOf(ORG_B.id))); + + expect(authRef.current?.activeOrganization).toBe(ORG_B); + expect(client.getActiveMember).toHaveBeenCalledTimes(2); + }); + + it('CONTROL: a session answer without the key asks the server for the active organization, as before', async () => { + const { client, answerList } = createServer([ORG_A, ORG_B], ORG_A.id, { sessionKey: false }); + boot(client); + await waitFor(() => expect(client.listOrganizations).toHaveBeenCalled()); + // Nothing is asked ahead of the list on this path. + expect(client.getActiveMember).not.toHaveBeenCalled(); + + await answerList(); + await waitFor(() => expect(authRef.current?.activeMember).toEqual(memberOf(ORG_A.id))); + + expect(client.getActiveOrganization).toHaveBeenCalledTimes(1); + expect(authRef.current?.activeOrganization?.id).toBe(ORG_A.id); + expect(client.getActiveMember).toHaveBeenCalledTimes(1); + }); + + it('CONTROL (objectui#12698): an active id the list does not carry names no organization and clears the stored id', async () => { + localStorage.setItem('auth-session-user-id', USER); + localStorage.setItem(`auth-active-organization-id:u:${USER}`, 'org_gone'); + const { client, answerList } = createServer([ORG_A, ORG_B], 'org_gone'); + boot(client); + await waitFor(() => expect(client.listOrganizations).toHaveBeenCalled()); + await answerList(); + await waitFor(() => expect(authRef.current?.isMembershipResolved).toBe(true)); + + expect(authRef.current?.activeOrganization).toBeNull(); + expect(authRef.current?.activeMember).toBeNull(); + expect(ActiveOrganizationStorage.get()).toBeNull(); + expect(client.setActiveOrganization).not.toHaveBeenCalled(); + expect(client.getActiveOrganization).not.toHaveBeenCalled(); + }); + + it('CONTROL (ADR-0081): one membership and no active id is repaired once, then its member row is read', async () => { + const { client, answerList } = createServer([ORG_A], null); + boot(client); + await waitFor(() => expect(client.listOrganizations).toHaveBeenCalled()); + expect(client.getActiveMember).not.toHaveBeenCalled(); + await answerList(); + await waitFor(() => expect(authRef.current?.activeMember).toEqual(memberOf(ORG_A.id))); + + expect(client.setActiveOrganization).toHaveBeenCalledTimes(1); + expect(client.setActiveOrganization).toHaveBeenCalledWith(ORG_A.id); + expect(authRef.current?.activeOrganization).toBe(ORG_A); + expect(client.getActiveOrganization).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/auth/src/types.ts b/packages/auth/src/types.ts index 251bc188b6..961cf8bc72 100644 --- a/packages/auth/src/types.ts +++ b/packages/auth/src/types.ts @@ -128,6 +128,17 @@ export interface AuthClientSession { * session, which is why the banner costs a normal session nothing. */ impersonatedBy?: string; + /** + * objectui#12809 — set by better-auth's organization plugin on the + * `GET /auth/get-session` answer: the id of the organization this session is + * active in, or `null` when it holds none. `AuthProvider` names the active + * organization from it and the `organization/list` answer, and asks for the + * member row at the same time. Absent when the answer did not come from + * `get-session` (a sign-in response) or the server has no organization + * plugin; the provider then asks the server for the active organization, as + * before. + */ + activeOrganizationId?: string | null; } /** Authentication state */