Skip to content
Open
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
17 changes: 13 additions & 4 deletions frontend/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import { api, useApiStore } from './state/backend-api';
import { useUIStateStore } from './state/ui-state';
import { AppFeatures, getBasePath } from './utils/env';
import { getEmbeddedAvailableRoutes } from './utils/route-utils';
import { getRegisteredTokenRefreshInterceptor } from './utils/token-refresh-interceptor';

declare const __webpack_public_path__: string;

Expand Down Expand Up @@ -206,19 +207,27 @@ const setConfig = ({
}: SetConfigArguments) => {
const assetsUrl =
urlOverride?.assets === 'WEBPACK' ? String(__webpack_public_path__).removeSuffix('/') : urlOverride?.assets;
const configuredFetch = fetch ?? window.fetch.bind(window);
const tokenRefreshInterceptor = getRegisteredTokenRefreshInterceptor();

// instantiate the client once, if we need to add more clients you can add them in here, ideally only one transport is necessary
const dataplaneTransport = createConnectTransport({
baseUrl: getGrpcBasePath(urlOverride?.grpc),
interceptors: [addBearerTokenInterceptor, checkExpiredLicenseInterceptor],
fetch: configuredFetch,
interceptors: [
addBearerTokenInterceptor,
...(tokenRefreshInterceptor ? [tokenRefreshInterceptor] : []),
checkExpiredLicenseInterceptor,
],
jsonOptions: {
registry: protobufRegistry,
},
});

const controlplaneTransport = createConnectTransport({
baseUrl: config.controlplaneUrl,
interceptors: [addBearerTokenInterceptor],
fetch: configuredFetch,
interceptors: [addBearerTokenInterceptor, ...(tokenRefreshInterceptor ? [tokenRefreshInterceptor] : [])],
jsonOptions: {
registry: protobufRegistry,
},
Expand Down Expand Up @@ -250,7 +259,7 @@ const setConfig = ({
restBasePath: getRestBasePath(urlOverride?.rest),
grpcBasePath: getGrpcBasePath(urlOverride?.grpc),
controlplaneUrl: config.controlplaneUrl,
fetch: fetch ?? window.fetch.bind(window),
fetch: configuredFetch,
assetsPath: assetsUrl ?? getBasePath(),
authenticationClient: authenticationGrpcClient,
licenseClient: licenseGrpcClient,
Expand Down Expand Up @@ -352,7 +361,7 @@ setTimeout(() => {
updateSidebarItems();
}
});
} catch (error) {
} catch (_error) {
// Ignore errors in test environments where stores might not be properly initialized
// This setTimeout runs globally when config.ts is imported
}
Expand Down
94 changes: 94 additions & 0 deletions frontend/src/embedded-app.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { render } from '@testing-library/react';
import type { ReactNode } from 'react';

const { mockCreateConnectTransport, mockCreateRouter, mockFetch, mockSetup } = vi.hoisted(() => ({
mockCreateConnectTransport: vi.fn((options) => options),
mockCreateRouter: vi.fn(() => ({
invalidate: vi.fn().mockResolvedValue(undefined),
})),
mockFetch: vi.fn(),
mockSetup: vi.fn(),
}));

vi.mock('@connectrpc/connect-query', () => ({
TransportProvider: ({ children }: { children?: ReactNode }) => <>{children}</>,
}));

vi.mock('@connectrpc/connect-web', () => ({
createConnectTransport: mockCreateConnectTransport,
}));

vi.mock('@redpanda-data/ui', () => ({
ChakraProvider: ({ children }: { children?: ReactNode }) => <>{children}</>,
redpandaTheme: {},
redpandaToastOptions: {},
}));

vi.mock('@tanstack/react-query', async () => {
const actual = await vi.importActual('@tanstack/react-query');
return {
...actual,
QueryClientProvider: ({ children }: { children?: ReactNode }) => <>{children}</>,
};
});

vi.mock('@tanstack/react-router', () => ({
createRouter: mockCreateRouter,
RouterProvider: () => <div data-testid="router-provider" />,
}));

vi.mock('custom-feature-flag-provider', () => ({
CustomFeatureFlagProvider: ({ children }: { children?: ReactNode }) => <>{children}</>,
}));

vi.mock('protobuf-registry', () => ({
protobufRegistry: {},
}));

vi.mock('./components/misc/not-found-page', () => ({
NotFoundPage: () => <div>Not Found</div>,
}));

vi.mock('./config', () => ({
addBearerTokenInterceptor: vi.fn((next) => next),
checkExpiredLicenseInterceptor: vi.fn((next) => next),
getGrpcBasePath: vi.fn(() => 'http://localhost:9090'),
setup: mockSetup,
}));

vi.mock('./routeTree.gen', () => ({
routeTree: {},
}));

vi.mock('./state/app-global', () => ({
appGlobal: {
historyLocation: vi.fn(() => ({ pathname: '/topics' })),
historyPush: vi.fn(),
},
}));

vi.mock('./state/backend-api', () => ({
api: {
refreshUserData: vi.fn().mockResolvedValue(undefined),
},
}));

import EmbeddedApp from './embedded-app';

describe('EmbeddedApp', () => {
beforeEach(() => {
vi.clearAllMocks();
});

test('uses the host-provided fetch for the root dataplane transport', () => {
render(<EmbeddedApp fetch={mockFetch} isConsoleReadyToMount={true} />);

expect(mockSetup).toHaveBeenCalled();
expect(mockCreateConnectTransport).toHaveBeenCalledWith(
expect.objectContaining({
baseUrl: 'http://localhost:9090',
fetch: mockFetch,
})
);
});
});
50 changes: 39 additions & 11 deletions frontend/src/embedded-app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ import {
} from './config';
import { routeTree } from './routeTree.gen';
import { appGlobal } from './state/app-global';
import { api } from './state/backend-api';
import {
getRegisteredTokenRefreshInterceptor,
TokenRefreshInterceptorProvider,
} from './utils/token-refresh-interceptor';
import { useEmbeddedAuthPrewarm } from './utils/use-embedded-auth-prewarm';

// Regex for normalizing paths by removing trailing slashes
const TRAILING_SLASH_REGEX = /\/+$/;
Expand Down Expand Up @@ -86,6 +92,10 @@ export interface EmbeddedProps extends SetConfigArguments {
}

function EmbeddedApp({ basePath = '', ...p }: EmbeddedProps) {
const tokenRefreshInterceptor = getRegisteredTokenRefreshInterceptor();
const defaultFetch = useMemo(() => window.fetch.bind(window), []);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we please provide more comments on how these fetch mechanism works across apps? It was never clear to me and it's a bit confusing.

const configuredFetch = p.fetch ?? defaultFetch;

useEffect(() => {
const shellNavigationHandler = (event: Event) => {
const pathname = (event as CustomEvent<string>).detail;
Expand Down Expand Up @@ -115,12 +125,17 @@ function EmbeddedApp({ basePath = '', ...p }: EmbeddedProps) {
() =>
createConnectTransport({
baseUrl: getGrpcBasePath(p.urlOverride?.grpc),
interceptors: [addBearerTokenInterceptor, checkExpiredLicenseInterceptor],
fetch: configuredFetch,
interceptors: [
addBearerTokenInterceptor,
...(tokenRefreshInterceptor ? [tokenRefreshInterceptor] : []),
checkExpiredLicenseInterceptor,
],
jsonOptions: {
registry: protobufRegistry,
},
}),
[p.urlOverride?.grpc]
[configuredFetch, p.urlOverride?.grpc, tokenRefreshInterceptor]
);

// Create router with dynamic basePath
Expand All @@ -139,20 +154,33 @@ function EmbeddedApp({ basePath = '', ...p }: EmbeddedProps) {
[basePath, dataplaneTransport]
);

useEmbeddedAuthPrewarm({
enabled: Boolean(p.isConsoleReadyToMount),
prewarm: async () => {
await api.refreshUserData().catch(() => {
// Best-effort prewarm only; embedded hosts handle any hard auth failures.
});

await Promise.allSettled([queryClient.invalidateQueries(), router.invalidate()]);
},
});

if (!p.isConsoleReadyToMount) {
return null;
}

return (
<CustomFeatureFlagProvider initialFlags={p.featureFlags}>
<ChakraProvider resetCSS={false} theme={redpandaTheme} toastOptions={redpandaToastOptions}>
<TransportProvider transport={dataplaneTransport}>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</TransportProvider>
</ChakraProvider>
</CustomFeatureFlagProvider>
<TokenRefreshInterceptorProvider value={tokenRefreshInterceptor}>
Copy link
Copy Markdown
Contributor Author

@malinskibeniamin malinskibeniamin Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

module federation v1 vs v2. token refresh interceptor provider needs to work in both

<CustomFeatureFlagProvider initialFlags={p.featureFlags}>
<ChakraProvider resetCSS={false} theme={redpandaTheme} toastOptions={redpandaToastOptions}>
<TransportProvider transport={dataplaneTransport}>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</TransportProvider>
</ChakraProvider>
</CustomFeatureFlagProvider>
</TokenRefreshInterceptorProvider>
);
}

Expand Down
69 changes: 63 additions & 6 deletions frontend/src/federation/console-app.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@
* by the Apache License, Version 2.0
*/

import { render, waitFor } from '@testing-library/react';
import { act, render, waitFor } from '@testing-library/react';
import type { ReactNode } from 'react';

const tokenManagerInstances: Array<{ refresh: ReturnType<typeof vi.fn> }> = [];

// Mock TanStack Router before any imports
vi.mock('@tanstack/react-router', async () => {
Expand All @@ -19,6 +22,7 @@ vi.mock('@tanstack/react-router', async () => {
createRouter: vi.fn(() => ({
subscribe: vi.fn(() => vi.fn()), // Returns unsubscribe function
load: vi.fn().mockResolvedValue(undefined),
invalidate: vi.fn().mockResolvedValue(undefined),
state: { location: { pathname: '/topics' } },
navigate: vi.fn(),
})),
Expand All @@ -28,12 +32,22 @@ vi.mock('@tanstack/react-router', async () => {
push: vi.fn(),
replace: vi.fn(),
})),
RouterProvider: ({ children }: { children?: React.ReactNode }) => (
<div data-testid="router-provider">{children}</div>
),
RouterProvider: ({ children }: { children?: ReactNode }) => <div data-testid="router-provider">{children}</div>,
};
});

vi.mock('@redpanda-data/ui', () => ({
ChakraProvider: ({ children }: { children?: ReactNode }) => <>{children}</>,
createStandaloneToast: vi.fn(() => ({
ToastContainer: () => null,
toast: {
closeAll: vi.fn(),
},
})),
redpandaTheme: {},
redpandaToastOptions: {},
}));

vi.mock('config', () => ({
config: {
jwt: undefined as string | undefined,
Expand All @@ -56,6 +70,16 @@ vi.mock('../components/misc/not-found-page', () => ({
NotFoundPage: () => <div>Not Found</div>,
}));

vi.mock('../state/backend-api', () => ({
api: {
refreshUserData: vi.fn().mockResolvedValue(undefined),
},
}));

vi.mock('./federated-providers', () => ({
FederatedProviders: ({ children }: { children?: ReactNode }) => <>{children}</>,
}));

vi.mock('./token-manager', () => ({
TokenManager: class MockTokenManager {
private getToken: () => Promise<string>;
Expand All @@ -70,6 +94,7 @@ vi.mock('./token-manager', () => ({
this.setGetAccessToken.mockImplementation((fn: () => Promise<string>) => {
this.getToken = fn;
});
tokenManagerInstances.push(this);
}
},
}));
Expand Down Expand Up @@ -99,16 +124,27 @@ describe('ConsoleApp', () => {

beforeEach(() => {
vi.clearAllMocks();
tokenManagerInstances.length = 0;
mockGetAccessToken.mockResolvedValue('test-token-123');
// Reset config.jwt
config.jwt = undefined;
});

afterEach(() => {
vi.useRealTimers();
});

test('shows nothing while loading', () => {
const { container } = render(<ConsoleApp {...defaultProps} />);
const tokenPromise = new Promise<string>(() => {
// Intentionally unresolved to keep the component in its loading state.
});
mockGetAccessToken.mockReturnValueOnce(tokenPromise);

const { container, unmount } = render(<ConsoleApp {...defaultProps} />);

// Component returns null while initializing
expect(container.firstChild).toBeNull();
unmount();
});

test('calls getAccessToken on mount', async () => {
Expand Down Expand Up @@ -154,7 +190,9 @@ describe('ConsoleApp', () => {

test('stays in loading state while token refresh is pending', async () => {
// Create a deferred promise that never resolves during this test
const tokenPromise = new Promise<string>(() => {});
const tokenPromise = new Promise<string>(() => {
// Intentionally unresolved to keep the component in its loading state.
});
mockGetAccessToken.mockReturnValueOnce(tokenPromise);

const { container } = render(<ConsoleApp {...defaultProps} />);
Expand Down Expand Up @@ -224,6 +262,25 @@ describe('ConsoleApp', () => {
unmount();
});

test('prewarms auth once when focus and visibility events arrive in a burst', async () => {
render(<ConsoleApp {...defaultProps} />);

await waitFor(() => {
expect(mockGetAccessToken).toHaveBeenCalledTimes(1);
});

expect(tokenManagerInstances).toHaveLength(1);
vi.useFakeTimers();

await act(async () => {
document.dispatchEvent(new Event('visibilitychange'));
window.dispatchEvent(new Event('focus'));
await vi.advanceTimersByTimeAsync(200);
});

expect(tokenManagerInstances[0]?.refresh).toHaveBeenCalledTimes(2);
});

describe('Feature Flags', () => {
test('passes feature flags to setup()', async () => {
const flags = { schemaRegistry: true, enableKnowledgeBaseInConsoleUi: false };
Expand Down
Loading
Loading