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
43 changes: 43 additions & 0 deletions .changeset/console-boot-request-dedup-5544.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
'@object-ui/types': patch
'@object-ui/app-shell': patch
'@object-ui/console': patch
---

The console's cold load no longer asks `/api/v1/runtime/config` or
`/auth/me/localization` twice (objectui#5544).

Two pairs of boot callers were racing each other for the same URL, with no shared
provider between them, so no guard inside either component could see the other:

- `GET /api/v1/runtime/config` — the pre-React branding script inlined in
`apps/console/index.html` (it runs during HTML parse so the tab title and
favicon are the operator's before the bundle is fetched) and
`initRuntimeConfig()`. Measured ×2 on prod and on staging. This is the
expensive one: the console `await`s `initRuntimeConfig()` before
`createRoot().render()`, so the duplicate sat on the critical path to first
paint, and at the control plane's ~0.5–1.4 s for this endpoint it also pushed
boot concurrency further past the server's pool knee.
- `GET /api/v1/auth/me/localization` — `seedTenantLanguage()` on a device's true
first visit and `LocalizationFetchProvider` on every boot. The seed keeps
running past its 500 ms race by design and the provider mounts the moment that
race resolves, so on a first visit the two overlap. Measured ×2 on staging.

`@object-ui/types` gains `sharedGetJson()`: callers that ask for the same GET
while one is already in flight join that request instead of starting another. It
shares the in-flight promise and nothing else — the entry is deleted the instant
the request settles, so there is no cache, no TTL and no stale window, and a
caller arriving after settle fetches fresh exactly as before. Rejections fan out
to every sharer with the status intact (`LocalizationFetchProvider`'s retry
policy still sees its own 503), each caller receives its own copy of the parsed
body, and only GETs are eligible — a non-GET is refused rather than quietly
rewritten.

Requests that differ in credentials mode or headers keep separate identities, so
the console's two deliberate `auth/get-session` calls — one Bearer-only with the
cookie omitted to detect a stale token, then one through the cookie — stay two
requests. Collapsing those would have destroyed the signal the first one exists
to read.

No component receives anything different: same payloads, same errors, one fewer
round trip.
60 changes: 50 additions & 10 deletions apps/console/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,59 @@
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<link rel="manifest" href="./manifest.json" />
<title></title>
<!--
Pre-React branding, and the page's ONE `GET /api/v1/runtime/config`
(objectui#5544).

This script runs during parse so the title and favicon are the operator's
before the bundle is even fetched. `initRuntimeConfig()` in
`@object-ui/app-shell` then needs the same payload — and used to fetch it a
second time, which is the `runtime/config ×2` the card measured on both
prod and staging. It is the expensive kind of duplicate: the console
`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.

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.

Falls back gracefully on any error (non-blocking).
-->
<script>
// Pre-React branding — fetch runtime config as early as possible so the
// browser title and favicon reflect the operator's branding BEFORE React
// mounts. Falls back gracefully on any error (non-blocking).
(async function applyEarlyBranding() {
const base = (window.__CONSOLE_SERVER_URL || '').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);

try {
const base = (window.__CONSOLE_SERVER_URL || '').replace(/\/+$/, '');
const res = await fetch(base + '/api/v1/runtime/config', {
credentials: 'include',
headers: { Accept: 'application/json' },
});
if (!res.ok) return;
const body = await res.json();
const body = await pending;
if (body?.branding) {
if (body.branding.productName) {
document.title = body.branding.productName;
Expand Down
14 changes: 9 additions & 5 deletions apps/console/src/LocalizationFetchProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import {
HttpFetchError,
backoffMs,
isTransientFailure,
retryAfterFrom,
sharedGetJson,
sleep,
} from '@object-ui/types';

Expand Down Expand Up @@ -67,13 +67,17 @@ export function LocalizationFetchProvider({
void (async () => {
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
const res = await fetch(endpoint, {
// Shared in flight with `seedTenantLanguage()` (objectui#5544): on a
// device's first visit that seed asks this same endpoint, and its
// request is still running when this one starts. `sharedGetJson`
// rejects with the same `HttpFetchError` a non-2xx produced here
// before — including `Retry-After` — so the retry policy below is
// untouched, and a shared 503 reaches BOTH callers rather than
// resolving one of them empty.
const json = await sharedGetJson<MeLocalizationResponse>(endpoint, {
credentials: 'include',
headers: { Accept: 'application/json' },
});
if (!res.ok) throw new HttpFetchError(res.status, retryAfterFrom(res));

const json = (await res.json()) as MeLocalizationResponse;
// Refresh the UI-language seed cache (objectui#4035) — the
// "revalidate" half of stale-while-revalidate. This boot has already
// committed to a language; what this write buys is the NEXT one, so a
Expand Down
139 changes: 139 additions & 0 deletions apps/console/src/__tests__/localizationBootDedup.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/**
* objectui#5544 — one cold load, one `GET /api/v1/auth/me/localization`.
*
* On a device's true first visit two callers ask this endpoint at once:
* `seedTenantLanguage()` (which keeps running past its 500 ms race, by design)
* and `LocalizationFetchProvider` (which mounts as soon as that race resolves).
* The card measured the pair as `me/localization ×2` on staging.
*
* The interesting half is not the request count — it is that the SECOND caller
* must still be served. `LocalizationFetchProvider` is the one with a retry
* policy, so these pin that a shared answer reaches it, that a shared 503
* reaches it as a 503 (not as an empty success), and that once the shared
* request has settled a later mount fetches again.
*/

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { useLocalization, readCachedLanguageSeed } from '@object-ui/i18n';
import { resetInflightGetsForTesting } from '@object-ui/types';
import { seedTenantLanguage } from '../languageSeed';
import { LocalizationFetchProvider } from '../LocalizationFetchProvider';

const ENDPOINT = '/api/v1/auth/me/localization';

function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((res) => {
resolve = res;
});
return { promise, resolve };
}

function jsonResponse(body: unknown, status = 200): Response {
return {
ok: status >= 200 && status < 300,
status,
json: async () => structuredClone(body),
headers: { get: () => null },
} as unknown as Response;
}

/** Reads the value the provider publishes, so a starved consumer is visible. */
function Consumer() {
const { currency, locale } = useLocalization();
return <span data-testid="value">{`${locale ?? '-'}/${currency ?? '-'}`}</span>;
}

beforeEach(() => {
resetInflightGetsForTesting();
localStorage.clear();
});

afterEach(() => {
vi.unstubAllGlobals();
resetInflightGetsForTesting();
localStorage.clear();
});

describe('first-visit localization request budget', () => {
it('serves the seed and the provider from ONE request', async () => {
const gate = deferred<Response>();
const fetchStub = vi.fn(() => gate.promise);
vi.stubGlobal('fetch', fetchStub);

// True first visit: no stored choice, no cached seed ⇒ the seed fetches and
// races a timeout. A tiny timeout keeps the test fast; the shape is the same
// one production has at 500 ms — the race resolves while the fetch runs on.
await seedTenantLanguage('', 1);
expect(fetchStub).toHaveBeenCalledTimes(1);

// The provider mounts right after the boot's `Promise.all` resolves — while
// the seed's request is still in flight.
render(
<LocalizationFetchProvider endpoint={ENDPOINT}>
<Consumer />
</LocalizationFetchProvider>,
);

gate.resolve(jsonResponse({ authenticated: true, locale: 'zh-CN', currency: 'CNY' }));

// ── the consumer is served, not starved ──
await waitFor(() => expect(screen.getByTestId('value').textContent).toBe('zh-CN/CNY'));
// ── and the seed got the same answer ──
await waitFor(() => expect(readCachedLanguageSeed()).toBe('zh-CN'));
// ── from one network call ──
expect(fetchStub).toHaveBeenCalledTimes(1);
});

it('passes a shared 503 through to the retry policy rather than resolving it empty', async () => {
const gate = deferred<Response>();
const fetchStub = vi
.fn()
.mockImplementationOnce(() => gate.promise)
.mockImplementation(async () =>
jsonResponse({ authenticated: true, locale: 'ja-JP', currency: 'JPY' }),
);
vi.stubGlobal('fetch', fetchStub);

await seedTenantLanguage('', 1);
render(
<LocalizationFetchProvider endpoint={ENDPOINT}>
<Consumer />
</LocalizationFetchProvider>,
);

// The shared request fails transiently. Both sharers see the failure; only
// the provider has a retry policy, and it must still fire — a dedup that
// handed it an empty success would leave the console with no currency and
// no way back.
gate.resolve(jsonResponse(null, 503));

await waitFor(() => expect(screen.getByTestId('value').textContent).toBe('ja-JP/JPY'), {
timeout: 5000,
});
expect(fetchStub.mock.calls.length).toBeGreaterThanOrEqual(2);
});

it('does not share once the request has settled', async () => {
const fetchStub = vi.fn(async () =>
jsonResponse({ authenticated: true, locale: 'en-US', currency: 'USD' }),
);
vi.stubGlobal('fetch', fetchStub);

// Let the seed's request settle completely before the provider mounts.
await seedTenantLanguage('', 1);
await waitFor(() => expect(readCachedLanguageSeed()).toBe('en-US'));
expect(fetchStub).toHaveBeenCalledTimes(1);

render(
<LocalizationFetchProvider endpoint={ENDPOINT}>
<Consumer />
</LocalizationFetchProvider>,
);

await waitFor(() => expect(screen.getByTestId('value').textContent).toBe('en-US/USD'));
// Two waves, two requests: this is in-flight sharing, not a cache.
expect(fetchStub).toHaveBeenCalledTimes(2);
});
});
Loading
Loading