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
5 changes: 5 additions & 0 deletions .changeset/12808-console-boot.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@object-ui/console': patch
---

The console's startup sends one `get-session` and one `runtime/config` request instead of two of each: the session check and the early-branding request now hand their answers to the app instead of having them fetched again before the first render.
7 changes: 7 additions & 0 deletions .changeset/12808-entitlements-one-request.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@object-ui/app-shell': patch
---

Opening the Cloud pricing or Welcome page now asks for the organisation's environment entitlements once. The plan cards, the onboarding next step and the workspace-timezone notice share one request per organisation instead of each sending their own, so the page no longer waits for three of them in a row. The answer is re-read when the organisation changes, when the environment list refreshes, and on every later visit.

The console's startup also reads `/api/v1/runtime/config` once: the answer the page fetched for its early branding is reused instead of being fetched again before the first render.
5 changes: 5 additions & 0 deletions .changeset/12808-session-handoff.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@object-ui/auth': patch
---

`AuthProvider` no longer asks `get-session` a second time when the console has just validated the stored session before rendering. The validated answer becomes the provider's first state, once per page load; signing in or out, switching organisation and refreshing the session read it from the server as before.
37 changes: 15 additions & 22 deletions apps/console/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -27,20 +27,21 @@
`await`s that second call before `createRoot().render()`, so it sits on the
critical path to first paint.

The fix is in-flight SHARING, not caching. We publish this request's parsed
-body promise into the registry `@object-ui/types` keeps on `globalThis`
(`Symbol.for('objectui.inflightGet')`), and delete the entry the moment it
settles. `sharedGetJson()` finds it and joins, so app-shell inherits a
request that started hundreds of ms earlier instead of starting its own; if
it ever arrives after settle it simply fetches, exactly as before.
The fix is a HANDOFF of this page load's answer (objectui#12808). This
script leaves its parsed-body promise on `globalThis` under
`Symbol.for('objectui.earlyRuntimeConfig')`, together with the URL it
asked, and keeps it for the page's lifetime. `initRuntimeConfig()` takes
it on its FIRST call when the URL matches: still in flight, it joins;
already settled, it reads the body. Hundreds of ms usually separate the
two, so an in-flight-only share (the earlier fix, objectui#5544) found
nothing to join and the bundle always asked again. A failed early fetch is
not reused: app-shell then fetches as it always did. Every later call
re-fetches, so the repeat-call contract is unchanged, and the slot holds
one answer of this page load, never one from an earlier page.

Why the key is spelled out by hand here: a classic inline script cannot
import, and it must stay classic (see the crypto shim's comment below for
the measurement that rules out `type="module"` ordering). The format is
owned by `inflightGetKey()` in `packages/types/src/http-inflight.ts`, and
`src/__tests__/runtimeConfigBootDedup.test.ts` executes THIS script's text
against that function — so a drift between the two spellings fails a test
rather than silently restoring the duplicate.
against the real `initRuntimeConfig()`, so a drift between the slot's two
spellings fails a test rather than silently restoring the duplicate.

The ORIGIN is the other half of that contract (objectui#5660). Sharing
keys on the full URL, so the two callers must agree on the server origin
Expand Down Expand Up @@ -71,22 +72,14 @@
const base = (injected.charAt(0) === '%' ? '' : injected).replace(/\/+$/, '');
const url = base + '/api/v1/runtime/config';
const init = { credentials: 'include', headers: { Accept: 'application/json' } };
// Must equal inflightGetKey(url, init) — see the comment above.
const key = 'GET\ninclude\n' + url + '\naccept=application/json';

const registryKey = Symbol.for('objectui.inflightGet');
const registry = globalThis[registryKey] || (globalThis[registryKey] = new Map());

const pending = (async function () {
const res = await fetch(url, init);
if (!res.ok) throw new Error('runtime/config ' + res.status);
return res.json();
})();
registry.set(key, pending);
const drop = function () {
if (registry.get(key) === pending) registry.delete(key);
};
pending.then(drop, drop);
// Kept for the page's lifetime, never dropped on settle — see above.
globalThis[Symbol.for('objectui.earlyRuntimeConfig')] = { url: url, body: pending };

try {
const body = await pending;
Expand Down
67 changes: 51 additions & 16 deletions apps/console/src/__tests__/runtimeConfigBootDedup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,18 @@
* before `createRoot().render()`, so it is on the critical path to first paint).
* Neither can see the other, which is why no per-component guard closes it.
*
* objectui#12808 measured it twice again: the first fix shared the request only
* while it was IN FLIGHT, and the pre-boot request settles hundreds of ms before
* the bundle asks, so there was never anything to join. The script now keeps its
* answer for the page's lifetime and `initRuntimeConfig()` takes it once.
*
* These tests grade THE SHIPPED ARTIFACT, the same way
* `insecure-origin-crypto.test.ts` does: the inline script's text is extracted
* from `index.html` and executed. That is what keeps the request key the script
* spells out by hand — a classic script cannot import `inflightGetKey()` — from
* drifting away from the function that owns the format. A drift would not break
* anything visibly; it would just quietly restore the duplicate, which is
* exactly the failure a hand-copied contract produces.
* from `index.html` and executed. That is what keeps the slot the script spells
* out by hand — a classic script cannot import — from drifting away from the
* reader in `runtime-config.ts`. A drift would not break anything visibly; it
* would just quietly restore the duplicate, which is exactly the failure a
* hand-copied contract produces.
*/

import indexHtml from '../../index.html?raw';
Expand Down Expand Up @@ -81,7 +86,14 @@ function jsonResponse(body: unknown, status = 200): Response {
} as unknown as Response;
}

/** The slot the pre-boot script leaves its answer in (see `index.html`). */
const EARLY_SLOT = Symbol.for('objectui.earlyRuntimeConfig');

/** Let the pre-boot request settle, as it does long before the bundle asks. */
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));

beforeEach(() => {
delete (globalThis as unknown as Record<symbol, unknown>)[EARLY_SLOT];
resetInflightGetsForTesting();
resetRuntimeConfigForTesting();
document.title = '';
Expand All @@ -96,7 +108,7 @@ describe('the shipped pre-boot script is findable', () => {
it('is exactly one inline script in index.html', () => {
const source = extractEarlyBrandingSource();
expect(source).toContain('/api/v1/runtime/config');
expect(source).toContain("Symbol.for('objectui.inflightGet')");
expect(source).toContain("Symbol.for('objectui.earlyRuntimeConfig')");
});

it('fails loudly if the script is gone rather than passing vacuously', () => {
Expand Down Expand Up @@ -126,7 +138,23 @@ describe('one cold load, one runtime/config request', () => {
expect(document.title).toBe('Acme Ops');
});

it('still fetches on its own when nothing is in flight — sharing, not caching', async () => {
it('reads the pre-boot answer even after it settled — the real gap (objectui#12808)', async () => {
const fetchStub = vi.fn(async (_url: string, _init?: RequestInit) => jsonResponse(CONFIG_BODY));
vi.stubGlobal('fetch', fetchStub);

runEarlyBranding();
// The bundle arrives well after the parse-time request has settled.
await settle();
expect(document.title).toBe('Acme Ops');
await initRuntimeConfig('');

// Before the fix this was 2: the in-flight share had nothing left to join.
expect(fetchStub).toHaveBeenCalledTimes(1);
expect(getRuntimeConfig().cloudUrl).toBe('https://cloud.example.test');
expect(getRuntimeConfig().branding.productName).toBe('Acme Ops');
});

it('still fetches on its own when the page has no pre-boot script', async () => {
const fetchStub = vi.fn(async () => jsonResponse(CONFIG_BODY));
vi.stubGlobal('fetch', fetchStub);

Expand All @@ -146,36 +174,43 @@ describe('one cold load, one runtime/config request', () => {
expect(fetchStub).toHaveBeenCalledTimes(1);

// `initRuntimeConfig` is documented as safe to call again, and a second call
// must re-fetch and re-merge. The registry entry is gone at settle, so it
// does — there is no window in which a stale body could be replayed.
// must re-fetch and re-merge. The pre-boot answer is taken once, so it
// does — a later call never replays the boot body.
await initRuntimeConfig('');
expect(fetchStub).toHaveBeenCalledTimes(2);
});

it('keeps defaults when the shared request fails, and never leaves the failure cached', async () => {
it('CONTROL: a failed pre-boot request still lets app-shell fetch once', async () => {
const fetchStub = vi
.fn()
.mockResolvedValueOnce(jsonResponse(null, 503))
.mockResolvedValueOnce(jsonResponse(CONFIG_BODY));
vi.stubGlobal('fetch', fetchStub);

runEarlyBranding();
// The rejection reaches app-shell, which absorbs it exactly as it absorbed a
// non-2xx before — defaults intact, boot uninterrupted.
await settle();
// The failure is not reused: app-shell asks the server itself, as it did
// before there was any handoff.
await initRuntimeConfig('');
expect(fetchStub).toHaveBeenCalledTimes(1);
expect(getRuntimeConfig().branding.productName).toBe('ObjectOS');
expect(fetchStub).toHaveBeenCalledTimes(2);
expect(getRuntimeConfig().branding.productName).toBe('Acme Ops');
});

it('CONTROL: a failure on both asks keeps the defaults, boot uninterrupted', async () => {
const fetchStub = vi.fn(async () => jsonResponse(null, 503));
vi.stubGlobal('fetch', fetchStub);

runEarlyBranding();
await initRuntimeConfig('');
expect(fetchStub).toHaveBeenCalledTimes(2);
expect(getRuntimeConfig().branding.productName).toBe('Acme Ops');
expect(getRuntimeConfig().branding.productName).toBe('ObjectOS');
});
});

/**
* objectui#5660 — one page, one server origin.
*
* Sharing keys on the FULL URL (`inflightGetKey`), so the dedup above only ever
* Sharing keys on the FULL URL (the slot carries the URL it asked), so the dedup above only ever
* fires while both callers agree on the origin. They did not: this script read
* `window.__CONSOLE_SERVER_URL`, which nothing in the repository sets, while
* `main.tsx` reads `import.meta.env.VITE_SERVER_URL`. Same-origin production
Expand Down
143 changes: 143 additions & 0 deletions apps/console/src/__tests__/sessionBootDedup.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
/**
* objectui#12808 — one console open, one `GET /api/v1/auth/get-session`.
*
* The card measured two before the first app render: the pre-React preflight
* (`lib/auth-preflight.ts`, awaited in `main.tsx`) validating the stored Bearer,
* then `AuthProvider`'s own mount asking the same question with the same Bearer.
* The preflight now hands its answer on, and `AuthProvider` takes it, once, for
* its first state.
*
* These run the REAL preflight and the REAL `AuthProvider` with its own
* better-auth client — no injected client double — over one stubbed `fetch`, so
* the count is of requests that would reach the wire, and the slot the two
* sides spell by hand is exercised from both ends.
*/

import React, { useLayoutEffect } from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor, act, cleanup } from '@testing-library/react';
import { AuthProvider, useAuth } from '@object-ui/auth';
import { preflightAuth } from '../lib/auth-preflight';

const AUTH_URL = '/api/v1/auth';
const TOKEN = 'tok_valid';

const SESSION_BODY = {
user: { id: 'u_1', name: 'Ada', email: 'ada@example.test' },
session: { id: 's_1', userId: 'u_1', token: TOKEN, expiresAt: '2099-01-01T00:00:00.000Z' },
};

function json(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' },
});
}

let sessionAnswer: () => unknown = () => SESSION_BODY;
const fetchStub = vi.fn(async (input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (url.includes('/get-session')) return json(sessionAnswer());
if (url.includes('/sign-out')) return json({ success: true });
if (url.includes('/sign-in/email')) return json({ user: SESSION_BODY.user, token: TOKEN });
if (url.includes('/organization/')) return json([]);
return json(null);
});

function getSessionCalls(): number {
return fetchStub.mock.calls.filter(([input]) => String(input instanceof Request ? input.url : input).includes('/get-session')).length;
}

const probe: { current: ReturnType<typeof useAuth> | null } = { current: null };
function Probe({ sink }: { sink: typeof probe }) {
const value = useAuth();
useLayoutEffect(() => {
sink.current = value;
});
return <span data-testid="who">{value.isLoading ? 'loading' : value.user?.name ?? 'nobody'}</span>;
}

function mountConsole() {
return render(
<React.StrictMode>
<AuthProvider authUrl={AUTH_URL}>
<Probe sink={probe} />
</AuthProvider>
</React.StrictMode>,
);
}

beforeEach(() => {
vi.stubGlobal('fetch', fetchStub);
fetchStub.mockClear();
sessionAnswer = () => SESSION_BODY;
localStorage.clear();
delete (globalThis as unknown as Record<symbol, unknown>)[Symbol.for('objectui.authPreflightSession')];
probe.current = null;
});

afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});

describe('one console open, one get-session (objectui#12808)', () => {
it('the preflight plus AuthProvider\'s first render ask once', async () => {
localStorage.setItem('auth-session-token', TOKEN);

// The real boot order: `main.tsx` awaits the preflight, then renders.
await preflightAuth(AUTH_URL);
expect(getSessionCalls()).toBe(1);

mountConsole();
expect(await screen.findByText('Ada')).toBeTruthy();
// Let every mount effect (and StrictMode's replay of them) run out.
await act(async () => { await new Promise((r) => setTimeout(r, 0)); });

expect(getSessionCalls()).toBe(1);
expect(probe.current?.isAuthenticated).toBe(true);
// Parsed as `getSession()` parses the wire body: dates revived.
expect(probe.current?.session?.expiresAt).toBeInstanceOf(Date);
});

it('CONTROL: a sign-out then sign-in reads the session again', async () => {
localStorage.setItem('auth-session-token', TOKEN);
await preflightAuth(AUTH_URL);
mountConsole();
await screen.findByText('Ada');
expect(getSessionCalls()).toBe(1);

await act(async () => { await probe.current!.signOut(); });
expect(screen.getByTestId('who').textContent).toBe('nobody');
await act(async () => { await probe.current!.signIn('ada@example.test', 'pw'); });
await act(async () => { await probe.current!.refreshSession(); });

// The handoff was the first state only: the next read went to the server.
expect(getSessionCalls()).toBe(2);
expect(screen.getByTestId('who').textContent).toBe('Ada');
});

it('CONTROL: the answer is consumed once — a second provider asks the server', async () => {
localStorage.setItem('auth-session-token', TOKEN);
await preflightAuth(AUTH_URL);
const first = mountConsole();
await screen.findByText('Ada');
first.unmount();

mountConsole();
await screen.findByText('Ada');
await waitFor(() => expect(getSessionCalls()).toBeGreaterThanOrEqual(2));
});

it('CONTROL: a stale token hands nothing on, and AuthProvider asks itself', async () => {
localStorage.setItem('auth-session-token', 'tok_stale');
sessionAnswer = () => null;

await preflightAuth(AUTH_URL);
expect(localStorage.getItem('auth-session-token')).toBeNull();

mountConsole();
await waitFor(() => expect(screen.getByTestId('who').textContent).toBe('nobody'));
expect(getSessionCalls()).toBeGreaterThanOrEqual(2);
});
});
24 changes: 23 additions & 1 deletion apps/console/src/lib/auth-preflight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,17 @@
*
* Idempotent, runs once per page load, < 50 ms when the token is valid
* (single round-trip), no UI flicker (happens before render).
*
* # The answer is handed on (objectui#12808)
*
* When the token is valid this request already IS the session `AuthProvider`
* would ask for next: the server prefers the Bearer over the cookie, so adding
* the cookie cannot change the answer. The body is left on `globalThis`
* under `Symbol.for('objectui.authPreflightSession')`, with the auth URL and the
* token it was asked with, and `AuthProvider` takes it, once, for its first
* state (`takePreflightSession` in `@object-ui/auth`'s `createAuthClient.ts`)
* instead of issuing a second `get-session`. A stale or unanswered token leaves
* nothing behind, and every later session read asks the server as before.
*/

const TOKEN_KEY = 'auth-session-token';
Expand All @@ -43,6 +54,8 @@ const TOKEN_KEY = 'auth-session-token';
* component closure into the eager entry chunk.
*/
const ACTIVE_ORG_KEY_BASE = 'auth-active-organization-id';
/** Where the valid answer is handed to `AuthProvider` — see the header. */
const PREFLIGHT_SESSION_KEY = Symbol.for('objectui.authPreflightSession');

/**
* Remove the stale active-organization id under every spelling — the bare
Expand Down Expand Up @@ -98,7 +111,16 @@ export async function preflightAuth(authBaseUrl: string): Promise<void> {
if (res.ok) {
const body = await res.json().catch(() => null);
const user = body?.user ?? body?.data?.user ?? null;
if (user) return; // token valid — leave localStorage alone
if (user) {
// Token valid — leave localStorage alone, and hand the answer on, as
// JSON text so the taker parses it exactly as it parses the wire body.
(globalThis as unknown as Record<symbol, unknown>)[PREFLIGHT_SESSION_KEY] = {
authUrl: authBaseUrl,
token,
body: JSON.stringify(body),
};
return;
}
}

// 401, 4xx, or 200-with-null — token is stale. Purge.
Expand Down
Loading
Loading