());
+ 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 */