Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions .changeset/login-meta-401-noise-4042.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
---
"@object-ui/app-shell": patch
"@object-ui/data-objectstack": patch
---

The console no longer reads `/meta/*` before it knows whether it has a session, and a failed request now says which request failed

Opening a logged-out console painted ~30 red `HTTP request failed` lines before
the login form was drawn. Two independent causes, fixed independently
(objectui#4042).

**1. Requests fired before the session was known.** `ConnectedShellInner` now
withholds the metadata tree until `GET /auth/get-session` resolves, so
`meta/object` / `meta/view` / `meta/app` are never issued blind. `useAuth()`
outside an `AuthProvider` reports `isLoading: false`, so an embed with no auth
provider is unaffected, and every protected route already sat behind an
`AuthGuard` that resolves auth first — the signed-in data flow is unchanged.

The console's landing route (`<Route path="/">`) was the actual entry point for
the burst: it mounted `ConnectedShell` with no guard above it, so simply opening
`/_console/` mounted the whole data layer as an anonymous visitor. It is now
guarded, which also means an unauthenticated visitor reaches `/login` without a
single doomed request. `examples/console-starter` had the same shape and got the
same fix.

**2. Two requests per type, per mount — not an unauthenticated artefact.**
Consumers read metadata during the FIRST render (`useActionModal` reads
`objects`, whose getter kicks `ensureType('object')` and `ensureType('view')`
from the render phase), before any effect runs. `MetadataProvider`'s preview-mode
effect then cleared the whole cache on mount, discarding those two entries while
their requests were in flight; the next render found them `idle` and refetched
both. The effect now skips its mount run — on mount the cache is empty and there
was never anything to drop; it only ever meant something on a later
`previewDrafts` change. That halved `meta/object` and `meta/view` on **every**
mount, signed in included.

A second duplicate only appeared once a read had failed: `entry.promise`
collapses callers that arrive while a request is in flight, but callers arriving
just after a failure each started a fresh attempt. A failed type now stays
un-retried for ~1s, which collapses one mount's burst of callers into a single
attempt. This is deliberately not the 5-minute `ttlMs` — later callers still
retry on their own, and `refresh()` / `invalidate()` retry immediately and
unconditionally, so no explicit recovery path changes.

**3. `HTTP request failed` now identifies the request.** `@objectstack/client`
reports every non-2xx as
`logger.error("HTTP request failed", undefined, { method, url, status, error })`,
and the console's logger forwarded that verbatim — so the identifying fields
lived only in the third argument, and anything that flattens a console record to
text rendered them `[object Object]` / `Object`. A screenful of failures could
not tell you a single URL or status. The message string now carries them:

```text
HTTP request failed: GET /api/v1/meta/object -> 401 [UNAUTHORIZED]
```

The structured bag is still passed alongside for DevTools to expand — text for
the flatteners, object for the inspectors, neither at the other's expense. The
formatter is exported as `formatHttpFailureMessage`, and `createQuietHttpLogger`
is now exported too so an app wiring its own `ObjectStackClient` gets the same
identified failures.

Nothing is newly silenced. The only demotion remains 404-on-an-optional-
collection (`sys_presence`, `sys_activity`), which is an expected outcome of a
request we still mean to make; a 401 that survives the session gate — a
mid-session expiry, say — stays a visible, fully-identified error. The cure for
doomed requests is not issuing them, never hiding them once issued.
18 changes: 17 additions & 1 deletion apps/console/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,23 @@ export function App() {
<AppContent />
</ProtectedRoute>
} />
<Route path="/" element={<ConnectedShell><RootLandingRedirect /></ConnectedShell>} />
{/*
* The landing resolver reads App METADATA, so it needs the data
* layer — which needs a session. It used to mount `ConnectedShell`
* bare, with no AuthGuard above it, so an unauthenticated visitor
* opening `/_console/` mounted the whole metadata tree and fired
* `meta/object` + `meta/view` + `meta/app` straight into 401
* before the login form was drawn (objectui#4042). Guarding it
* sends those visitors to /login without a single doomed request;
* a signed-in visitor still lands exactly where `isDefault`
* resolves. `requireOrganization={false}` because `/` only
* redirects — the org gate belongs to the destination route.
*/}
<Route path="/" element={
<ProtectedRoute requireOrganization={false}>
<RootLandingRedirect />
</ProtectedRoute>
} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</ConsoleShell>
Expand Down
11 changes: 9 additions & 2 deletions examples/console-starter/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import { AuthProvider, useAuth } from '@object-ui/auth';
import { Toaster } from 'sonner';
import {
ConsoleShell,
ConnectedShell,
AuthenticatedRoute,
RootRedirect,
SystemRedirect,
Expand Down Expand Up @@ -69,7 +68,15 @@ export function App() {
<DefaultAppContent />
</AuthenticatedRoute>
} />
<Route path="/" element={<ConnectedShell><RootRedirect /></ConnectedShell>} />
{/* `RootRedirect` resolves the landing from metadata, so it needs a
* session. Guarded (not a bare `ConnectedShell`) so an
* unauthenticated visitor goes to /login instead of firing a round
* of doomed 401 `/meta/*` reads first — objectui#4042. */}
<Route path="/" element={
<AuthenticatedRoute requireOrganization={false}>
<RootRedirect />
</AuthenticatedRoute>
} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</ConsoleShell>
Expand Down
24 changes: 23 additions & 1 deletion packages/app-shell/src/console/ConsoleShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,28 @@ const apiProviderFetch = withSettleSignal(createAuthenticatedFetch({ sameOriginO
function ConnectedShellInner({ children }: { children: ReactNode }) {
const adapter = useAdapter();
const { language } = useObjectTranslation();
// ── Session gate (objectui#4042) ──
//
// Everything below this line reads `/api/v1/meta/*`, which requires a
// session. Mounting the metadata tree while `GET /auth/get-session` is still
// in flight fires `meta/object` + `meta/view` + `meta/app` blind: on an
// unauthenticated visitor they all come back 401, and the console's landing
// route (`<Route path="/">`) mounts this shell with no AuthGuard above it, so
// simply opening `/_console/` produced a screenful of red `HTTP request
// failed` before the login form was even drawn.
//
// Waiting for auth to RESOLVE is the whole gate. We deliberately do not gate
// on `isAuthenticated`: a consumer that mounts ConnectedShell outside an
// AuthGuard still renders through once the session answer is known (unchanged
// behaviour, including a legitimate 401 that must stay visible), and the
// console's own login bounce is the route guard's job. `useAuth()` outside an
// AuthProvider reports `isLoading: false`, so a provider-less embed is
// untouched.
//
// Post-login flow is unchanged — AuthGuard already withholds every protected
// route until auth resolves, so on those routes this gate is already open by
// the time the shell mounts and the same queries fire, once each.
const { isLoading: isAuthLoading } = useAuth();

// ── Language switch → relabel without a page refresh (issue #1319) ──
//
Expand Down Expand Up @@ -216,7 +238,7 @@ function ConnectedShellInner({ children }: { children: ReactNode }) {
}
if (adapter) lastLanguage.current = language;

if (!adapter) return <LoadingFallback />;
if (!adapter || isAuthLoading) return <LoadingFallback />;
// Expose the adapter via SchemaRendererContext so descendant hooks like
// useDiscovery() (used to gate the global AI chatbot) can resolve it.
return (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/**
* objectui#4042 — the console must not read `/meta/*` before it knows whether
* it has a session.
*
* The reported symptom was ~30 red `HTTP request failed` lines on a freshly
* opened, still-logged-out console: `GET /auth/get-session` had already
* answered "no session" (and, worse, was sometimes still in flight) while
* `meta/object` / `meta/view` / `meta/app` were fired anyway, all doomed to
* 401. `ConnectedShellInner` now withholds the metadata tree until auth
* resolves.
*
* Both directions are pinned, because the fix is worth nothing if it also
* stops the signed-in console from loading:
* - auth pending → zero metadata requests;
* - auth resolved → the same three requests, once each.
*/

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';

/** Flipped per-test; read by the mocked `useAuth`. */
const authState = { isLoading: true, isAuthenticated: false };

/** Every `meta.getItems(type)` the shell issues, in order. */
const metaCalls: string[] = [];

const fakeAdapter = {
clearCache: vi.fn(),
getObjectSchema: vi.fn(async () => null),
getClient: () => ({
meta: {
getItems: (type: string) => {
metaCalls.push(type);
return Promise.resolve({ type, items: [] });
},
getItem: (type: string, name: string) => {
metaCalls.push(`${type}/${name}`);
return Promise.resolve({ item: null });
},
},
}),
};

vi.mock('@object-ui/auth', async (importOriginal) => {
const actual = await importOriginal<typeof import('@object-ui/auth')>();
return {
...actual,
useAuth: () => ({
...authState,
user: authState.isAuthenticated ? { id: 'u1', email: 'u@example.com' } : null,
session: null,
isAuthEnabled: true,
error: null,
}),
createAuthenticatedFetch: () => globalThis.fetch,
};
});

// The adapter itself is not under test — the shell's ORDERING is. Hand it a
// ready fake so `useAdapter()` never gates the render for an unrelated reason.
vi.mock('../../providers/AdapterProvider', () => ({
AdapterProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
useAdapter: () => fakeAdapter,
}));

import { ConnectedShell } from '../ConsoleShell';

function renderShell() {
return render(
<MemoryRouter>
<ConnectedShell>
<div data-testid="route-content">ROUTE CONTENT</div>
</ConnectedShell>
</MemoryRouter>,
);
}

describe('ConnectedShell session gate (objectui#4042)', () => {
beforeEach(() => {
metaCalls.length = 0;
authState.isLoading = true;
authState.isAuthenticated = false;
});

it('issues no /meta/* request while get-session is still in flight', async () => {
const { queryByTestId } = renderShell();

// Give every effect and microtask a chance to fire a request.
await new Promise((resolve) => setTimeout(resolve, 100));

expect(metaCalls).toEqual([]);
// And the gate is a WAIT, not a render-through: the metadata-backed route
// content must not be showing either (it would read empty metadata).
expect(queryByTestId('route-content')).toBeNull();
});

it('issues each /meta/* request exactly once as soon as the session resolves', async () => {
authState.isLoading = false;
authState.isAuthenticated = true;

const { getByTestId } = renderShell();

await waitFor(() => expect(metaCalls).toContain('app'));
await new Promise((resolve) => setTimeout(resolve, 100));

expect(metaCalls.filter((c) => c === 'object')).toHaveLength(1);
expect(metaCalls.filter((c) => c === 'view')).toHaveLength(1);
expect(metaCalls.filter((c) => c === 'app')).toHaveLength(1);
expect(getByTestId('route-content')).toBeTruthy();
});

it('renders through once auth resolves with NO session (a 401 is then real, not noise)', async () => {
// A consumer may mount ConnectedShell outside an AuthGuard. The gate is
// "wait for the answer", not "require a yes" — once the answer is known the
// tree renders and any resulting 401 is a genuine, loggable event rather
// than a request the console fired blind.
authState.isLoading = false;
authState.isAuthenticated = false;

const { getByTestId } = renderShell();

await waitFor(() => expect(metaCalls).toContain('app'));
expect(getByTestId('route-content')).toBeTruthy();
});
});
48 changes: 48 additions & 0 deletions packages/app-shell/src/providers/MetadataProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,25 @@ interface MetadataProviderProps {
const DEFAULT_TTL_MS = 5 * 60 * 1000;
const EAGER_TYPES = ['app', 'view'] as const;

/**
* How long a FAILED type stays un-retried (objectui#4042).
*
* `entry.promise` already collapses callers that arrive while a request is in
* flight — but callers that arrive just AFTER a failure found `status: 'error'`
* with `promise: null` and each started a fresh attempt. That is a real
* sequence, not a hypothetical: the mount effect walks EAGER_TYPES serially, so
* by the time it reaches `view` the render-phase read of `view` has already
* failed, and it re-requested it. Signed out, that was a second doomed 401 per
* type; signed in, a second doomed request on any transient failure.
*
* Deliberately ~1s and NOT `ttlMs`: this exists to collapse the burst of
* callers that start together during one mount, not to cache failures. A later
* caller (route change, remount) still retries on its own, and `refresh()` /
* `invalidate()` — which zero `fetchedAt` / reset the status — retry
* immediately and unconditionally, so no explicit recovery path is affected.
*/
const ERROR_RETRY_COOLDOWN_MS = 1000;

const TYPE_BY_STATE_KEY: Record<keyof Omit<MetadataState, 'loading' | 'error'>, string> = {
apps: 'app',
objects: 'object',
Expand Down Expand Up @@ -387,7 +406,24 @@ export function MetadataProvider({ children, adapter, ttlMs = DEFAULT_TTL_MS }:
// Entering/leaving preview swaps the entire metadata source — the published
// and draft-overlaid worlds must never mix in one cache. Drop everything and
// let consumers refetch through the new source.
//
// ⚠️ Deliberately skipped on MOUNT (objectui#4042). Consumers read metadata
// during the FIRST render — `useActionModal` reads `objects`, which kicks
// `ensureType('object')` and `ensureType('view')` from a render-phase getter,
// before any effect has run. Clearing unconditionally in this mount effect
// threw those two entries away while their requests were still in flight, so
// the very next render found them `idle` again and refetched BOTH. That is
// the "same round, `meta/object` / `meta/view` each fired twice" the card
// reported — and it is not an unauthenticated-only artefact: it doubled the
// two requests on every mount, signed in as well. There is nothing to drop on
// mount anyway (the cache is per-provider-instance and starts empty), so the
// clear only ever had meaning on a LATER `previewDrafts` change.
const previewModeMounted = useRef(false);
useEffect(() => {
if (!previewModeMounted.current) {
previewModeMounted.current = true;
return;
}
cacheRef.current.clear();
itemPromisesRef.current.clear();
bump();
Expand All @@ -413,6 +449,14 @@ export function MetadataProvider({ children, adapter, ttlMs = DEFAULT_TTL_MS }:
return Promise.resolve(entry.items);
}

// Just failed — see ERROR_RETRY_COOLDOWN_MS. `refresh()`/`invalidate()`
// zero `fetchedAt` / reset the status, so explicit retries fall straight
// through this.
if (entry.status === 'error' && Date.now() - entry.fetchedAt < ERROR_RETRY_COOLDOWN_MS) {
debug(`cache hit (recent failure) type=${type}`);
return Promise.resolve(entry.items);
}

const started = Date.now();
entry.status = 'loading';
entry.error = null;
Expand Down Expand Up @@ -455,6 +499,10 @@ export function MetadataProvider({ children, adapter, ttlMs = DEFAULT_TTL_MS }:
entry.status = 'error';
entry.error = error;
entry.promise = null;
// Stamped on failure too, so ERROR_RETRY_COOLDOWN_MS has a clock to
// measure from. `refresh()` zeroes it, which is what makes an
// explicit retry immediate.
entry.fetchedAt = Date.now();
debug(`fetch failed type=${type}`, error);
bump();
return [] as any[];
Expand Down
Loading
Loading