From 865e1da85705af8b1338029cf65516d02eb1db33 Mon Sep 17 00:00:00 2001 From: Ravi Kumar L Date: Wed, 10 Jun 2026 13:06:20 -0400 Subject: [PATCH] =?UTF-8?q?=E2=9A=99=EF=B8=8F=20refactor:=20lazy-load=20Re?= =?UTF-8?q?act=20Query=20Devtools=20(#13639)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(client): lazy-load query devtools * fix: keep query devtools deps lazy * fix: address query devtools review findings * fix: exclude query devtools from pwa precache --------- Co-authored-by: Danny Avila --- api/server/experimental.js | 35 +++++++++++-------- api/server/index.js | 35 +++++++++++-------- api/server/index.spec.js | 26 ++++++++++++++ client/src/App.jsx | 4 +-- client/src/common/types.ts | 3 ++ client/src/components/QueryDevtoolsGate.tsx | 33 +++++++++++++++++ .../__tests__/QueryDevtoolsGate.spec.tsx | 29 +++++++++++++++ client/vite.config.ts | 15 ++++++++ packages/api/src/html/devtools.spec.ts | 34 ++++++++++++++++++ packages/api/src/html/devtools.ts | 34 ++++++++++++++++++ packages/api/src/html/index.ts | 1 + packages/api/src/index.ts | 2 ++ 12 files changed, 221 insertions(+), 30 deletions(-) create mode 100644 client/src/components/QueryDevtoolsGate.tsx create mode 100644 client/src/components/__tests__/QueryDevtoolsGate.spec.tsx create mode 100644 packages/api/src/html/devtools.spec.ts create mode 100644 packages/api/src/html/devtools.ts create mode 100644 packages/api/src/html/index.ts diff --git a/api/server/experimental.js b/api/server/experimental.js index c20f6813ca..48666a2f7d 100644 --- a/api/server/experimental.js +++ b/api/server/experimental.js @@ -16,9 +16,11 @@ const { isEnabled, apiNotFound, ErrorController, + QUERY_DEVTOOLS_HEADER, performStartupChecks, handleJsonParseError, initializeFileStorage, + maybeInjectQueryDevtoolsBootstrap, preAuthTenantMiddleware, } = require('@librechat/api'); const { connectDb, indexSync } = require('~/db'); @@ -315,6 +317,23 @@ if (cluster.isMaster) { } } + const sendIndexHtml = (req, res) => { + res.set({ + 'Cache-Control': process.env.INDEX_CACHE_CONTROL || 'no-cache, no-store, must-revalidate', + Pragma: process.env.INDEX_PRAGMA || 'no-cache', + Expires: process.env.INDEX_EXPIRES || '0', + }); + res.vary(QUERY_DEVTOOLS_HEADER); + + const lang = req.cookies.lang || req.headers['accept-language']?.split(',')[0] || 'en-US'; + const saneLang = lang.replace(/"/g, '"'); + let updatedIndexHtml = indexHTML.replace(/lang="en-US"/g, `lang="${saneLang}"`); + updatedIndexHtml = maybeInjectQueryDevtoolsBootstrap(updatedIndexHtml, req); + + res.type('html'); + res.send(updatedIndexHtml); + }; + /** Health check endpoint */ app.get('/health', (_req, res) => res.status(200).send('OK')); @@ -348,6 +367,7 @@ if (cluster.isMaster) { logger.warn('Response compression has been disabled via DISABLE_COMPRESSION.'); } + app.get('/index.html', sendIndexHtml); app.use(staticCache(appConfig.paths.dist)); app.use(staticCache(appConfig.paths.fonts)); app.use(staticCache(appConfig.paths.assets)); @@ -406,20 +426,7 @@ if (cluster.isMaster) { app.use('/api', apiNotFound); /** SPA fallback - serve index.html for all unmatched routes */ - app.use((req, res) => { - res.set({ - 'Cache-Control': process.env.INDEX_CACHE_CONTROL || 'no-cache, no-store, must-revalidate', - Pragma: process.env.INDEX_PRAGMA || 'no-cache', - Expires: process.env.INDEX_EXPIRES || '0', - }); - - const lang = req.cookies.lang || req.headers['accept-language']?.split(',')[0] || 'en-US'; - const saneLang = lang.replace(/"/g, '"'); - let updatedIndexHtml = indexHTML.replace(/lang="en-US"/g, `lang="${saneLang}"`); - - res.type('html'); - res.send(updatedIndexHtml); - }); + app.use(sendIndexHtml); /** Error handler (must be last - Express identifies error middleware by its 4-arg signature) */ app.use(ErrorController); diff --git a/api/server/index.js b/api/server/index.js index 648d099143..19ef7d533c 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -19,9 +19,11 @@ const { performStartupChecks, handleJsonParseError, GenerationJobManager, + QUERY_DEVTOOLS_HEADER, createStreamServices, initializeFileStorage, initializeDeploymentSkills, + maybeInjectQueryDevtoolsBootstrap, preAuthTenantMiddleware, setupGracefulShutdown, updateInterfacePermissions, @@ -140,6 +142,23 @@ const startServer = async () => { } } + const sendIndexHtml = (req, res) => { + res.set({ + 'Cache-Control': process.env.INDEX_CACHE_CONTROL || 'no-cache, no-store, must-revalidate', + Pragma: process.env.INDEX_PRAGMA || 'no-cache', + Expires: process.env.INDEX_EXPIRES || '0', + }); + res.vary(QUERY_DEVTOOLS_HEADER); + + const lang = req.cookies.lang || req.headers['accept-language']?.split(',')[0] || 'en-US'; + const saneLang = lang.replace(/"/g, '"'); + let updatedIndexHtml = indexHTML.replace(/lang="en-US"/g, `lang="${saneLang}"`); + updatedIndexHtml = maybeInjectQueryDevtoolsBootstrap(updatedIndexHtml, req); + + res.type('html'); + res.send(updatedIndexHtml); + }; + app.get('/health', (_req, res) => res.status(200).send('OK')); app.get('/livez', (_req, res) => res.status(200).send('OK')); app.get('/readyz', (_req, res) => { @@ -179,6 +198,7 @@ const startServer = async () => { console.warn('Response compression has been disabled via DISABLE_COMPRESSION.'); } + app.get('/index.html', sendIndexHtml); app.use(staticCache(appConfig.paths.dist)); app.use(staticCache(appConfig.paths.fonts)); app.use(staticCache(appConfig.paths.assets)); @@ -256,20 +276,7 @@ const startServer = async () => { app.use('/api', apiNotFound); /** SPA fallback - serve index.html for all unmatched routes */ - app.use((req, res) => { - res.set({ - 'Cache-Control': process.env.INDEX_CACHE_CONTROL || 'no-cache, no-store, must-revalidate', - Pragma: process.env.INDEX_PRAGMA || 'no-cache', - Expires: process.env.INDEX_EXPIRES || '0', - }); - - const lang = req.cookies.lang || req.headers['accept-language']?.split(',')[0] || 'en-US'; - const saneLang = lang.replace(/"/g, '"'); - let updatedIndexHtml = indexHTML.replace(/lang="en-US"/g, `lang="${saneLang}"`); - - res.type('html'); - res.send(updatedIndexHtml); - }); + app.use(sendIndexHtml); /** Record trace errors before the final error controller. */ if (telemetry.enabled) { diff --git a/api/server/index.spec.js b/api/server/index.spec.js index 20bc5b5a79..3e0fc07127 100644 --- a/api/server/index.spec.js +++ b/api/server/index.spec.js @@ -212,6 +212,32 @@ describe('Server Configuration', () => { expect(response.headers['content-type']).toMatch(/html/); }); + it('should gate React Query Devtools config in SPA HTML by debug header', async () => { + const defaultResponse = await request(app).get('/this/does/not/exist'); + const debugResponse = await request(app) + .get('/this/does/not/exist') + .set('x-librechat-enable-query-devtools', '1'); + const directIndexResponse = await request(app) + .get('/index.html') + .set('x-librechat-enable-query-devtools', '1'); + + expect(defaultResponse.status).toBe(200); + expect(defaultResponse.headers.vary).toContain('x-librechat-enable-query-devtools'); + expect(defaultResponse.text).not.toContain('enableQueryDevtools'); + + expect(debugResponse.status).toBe(200); + expect(debugResponse.headers.vary).toContain('x-librechat-enable-query-devtools'); + expect(debugResponse.text).toContain('window.__LIBRECHAT_CONFIG__'); + expect(debugResponse.text).toContain('data-librechat-query-devtools="true"'); + expect(debugResponse.text).toContain('"enableQueryDevtools":true'); + + expect(directIndexResponse.status).toBe(200); + expect(directIndexResponse.headers.vary).toContain('x-librechat-enable-query-devtools'); + expect(directIndexResponse.text).toContain('window.__LIBRECHAT_CONFIG__'); + expect(directIndexResponse.text).toContain('data-librechat-query-devtools="true"'); + expect(directIndexResponse.text).toContain('"enableQueryDevtools":true'); + }); + it('should return 500 for unknown errors via ErrorController', async () => { // Testing the error handling here on top of unit tests to ensure the middleware is correctly integrated diff --git a/client/src/App.jsx b/client/src/App.jsx index 975291526c..78ed8438b0 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -4,11 +4,11 @@ import { DndProvider } from 'react-dnd'; import { RouterProvider } from 'react-router-dom'; import * as RadixToast from '@radix-ui/react-toast'; import { HTML5Backend } from 'react-dnd-html5-backend'; -import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { Toast, ThemeProvider, ToastProvider } from '@librechat/client'; import { QueryClient, QueryClientProvider, QueryCache } from '@tanstack/react-query'; import { ScreenshotProvider, useApiErrorBoundary } from './hooks'; import WakeLockManager from '~/components/System/WakeLockManager'; +import QueryDevtoolsGate from '~/components/QueryDevtoolsGate'; import LanguageSync from '~/components/System/LanguageSync'; import { getThemeFromEnv } from './utils/getThemeFromEnv'; import { initializeFontSize } from '~/store/fontSize'; @@ -65,7 +65,7 @@ const App = () => { - + diff --git a/client/src/common/types.ts b/client/src/common/types.ts index 237aaacd37..9cdebffd2b 100644 --- a/client/src/common/types.ts +++ b/client/src/common/types.ts @@ -658,5 +658,8 @@ export type TThread = { id: string; createdAt: string }; declare global { interface Window { google_tag_manager?: unknown; + __LIBRECHAT_CONFIG__?: { + enableQueryDevtools?: boolean; + }; } } diff --git a/client/src/components/QueryDevtoolsGate.tsx b/client/src/components/QueryDevtoolsGate.tsx new file mode 100644 index 0000000000..ad794aca6f --- /dev/null +++ b/client/src/components/QueryDevtoolsGate.tsx @@ -0,0 +1,33 @@ +import { lazy, Suspense } from 'react'; + +interface QueryDevtoolsConfig { + enableQueryDevtools?: boolean; +} + +interface QueryDevtoolsGateProps { + config?: QueryDevtoolsConfig; + isDevelopment?: boolean; +} + +const LazyReactQueryDevtools = lazy(() => + import('@tanstack/react-query-devtools/production').then(({ ReactQueryDevtools }) => ({ + default: ReactQueryDevtools, + })), +); + +export const shouldEnableQueryDevtools = ({ + isDevelopment = import.meta.env.DEV, + config = typeof window === 'undefined' ? undefined : window.__LIBRECHAT_CONFIG__, +}: QueryDevtoolsGateProps = {}) => isDevelopment || config?.enableQueryDevtools === true; + +export default function QueryDevtoolsGate({ isDevelopment, config }: QueryDevtoolsGateProps = {}) { + if (!shouldEnableQueryDevtools({ isDevelopment, config })) { + return null; + } + + return ( + + + + ); +} diff --git a/client/src/components/__tests__/QueryDevtoolsGate.spec.tsx b/client/src/components/__tests__/QueryDevtoolsGate.spec.tsx new file mode 100644 index 0000000000..c952dcee06 --- /dev/null +++ b/client/src/components/__tests__/QueryDevtoolsGate.spec.tsx @@ -0,0 +1,29 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import QueryDevtoolsGate, { shouldEnableQueryDevtools } from '../QueryDevtoolsGate'; + +jest.mock('@tanstack/react-query-devtools/production', () => ({ + ReactQueryDevtools: () =>
, +})); + +describe('QueryDevtoolsGate', () => { + it('keeps query devtools disabled in production by default', () => { + expect(shouldEnableQueryDevtools({ isDevelopment: false, config: undefined })).toBe(false); + + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + expect(screen.queryByTestId('query-devtools')).not.toBeInTheDocument(); + }); + + it('enables query devtools in local development', async () => { + render(); + + await waitFor(() => expect(screen.getByTestId('query-devtools')).toBeInTheDocument()); + }); + + it('enables query devtools in production when the server-injected flag is true', async () => { + render(); + + await waitFor(() => expect(screen.getByTestId('query-devtools')).toBeInTheDocument()); + }); +}); diff --git a/client/vite.config.ts b/client/vite.config.ts index 47b588e989..e0559807af 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -34,6 +34,13 @@ const backendURL = process.env.HOST ? `http://${process.env.HOST}:${backendPort}` : `http://localhost:${backendPort}`; const buildSourceMap = process.env.NODE_ENV === 'development'; +const QUERY_DEVTOOLS_CHUNK_MODULES = [ + '@tanstack/react-query-devtools', + '@tanstack/match-sorter-utils', + 'node_modules/superjson', + 'node_modules/copy-anything', + 'node_modules/is-what', +]; export default defineConfig(({ command }) => ({ base: '', @@ -89,6 +96,7 @@ export default defineConfig(({ command }) => ({ 'index.html', 'assets/rum.*.js', 'assets/locale-*.js', + 'assets/query-devtools*.js', ], maximumFileSizeToCacheInBytes: 4 * 1024 * 1024, /** LibreChat mutates index.html per request for subpath and language support. */ @@ -311,6 +319,13 @@ export default defineConfig(({ command }) => ({ if (normalizedId.includes('node_modules/hast-util-raw')) { return 'markdown_large'; } + if ( + QUERY_DEVTOOLS_CHUNK_MODULES.some((moduleName) => + normalizedId.includes(moduleName), + ) + ) { + return 'query-devtools'; + } if (normalizedId.includes('@tanstack')) { return 'tanstack-vendor'; } diff --git a/packages/api/src/html/devtools.spec.ts b/packages/api/src/html/devtools.spec.ts new file mode 100644 index 0000000000..307bf52b47 --- /dev/null +++ b/packages/api/src/html/devtools.spec.ts @@ -0,0 +1,34 @@ +import type { QueryDevtoolsRequest } from './devtools'; +import { + QUERY_DEVTOOLS_HEADER, + maybeInjectQueryDevtoolsBootstrap, + shouldEnableQueryDevtools, +} from './devtools'; + +const createReq = (value?: string): QueryDevtoolsRequest => ({ + get: (header) => (header === QUERY_DEVTOOLS_HEADER ? value : undefined), +}); + +describe('query devtools HTML bootstrap', () => { + const html = + 'LibreChat'; + + it('uses the documented debug header value as the opt-in signal', () => { + expect(shouldEnableQueryDevtools(createReq('1'))).toBe(true); + expect(shouldEnableQueryDevtools(createReq('true'))).toBe(false); + expect(shouldEnableQueryDevtools(createReq())).toBe(false); + }); + + it('does not inject the production devtools flag by default', () => { + expect(maybeInjectQueryDevtoolsBootstrap(html, createReq())).toBe(html); + }); + + it('injects a server-to-client flag when the debug header is present', () => { + const updatedHtml = maybeInjectQueryDevtoolsBootstrap(html, createReq('1')); + + expect(updatedHtml).toContain('window.__LIBRECHAT_CONFIG__'); + expect(updatedHtml).toContain('data-librechat-query-devtools="true"'); + expect(updatedHtml).toContain('"enableQueryDevtools":true'); + expect(updatedHtml.indexOf('enableQueryDevtools')).toBeLessThan(updatedHtml.indexOf('')); + }); +}); diff --git a/packages/api/src/html/devtools.ts b/packages/api/src/html/devtools.ts new file mode 100644 index 0000000000..db1dad8b37 --- /dev/null +++ b/packages/api/src/html/devtools.ts @@ -0,0 +1,34 @@ +export const QUERY_DEVTOOLS_HEADER = 'x-librechat-enable-query-devtools'; + +const QUERY_DEVTOOLS_SENTINEL = 'data-librechat-query-devtools="true"'; +const QUERY_DEVTOOLS_BOOTSTRAP = ``; + +export interface QueryDevtoolsRequest { + get(header: string): string | undefined; +} + +export const shouldEnableQueryDevtools = (req: QueryDevtoolsRequest): boolean => + req.get(QUERY_DEVTOOLS_HEADER) === '1'; + +const injectQueryDevtoolsBootstrap = (html: string): string => { + if (html.includes(QUERY_DEVTOOLS_SENTINEL)) { + return html; + } + + if (html.includes('')) { + return html.replace('', `${QUERY_DEVTOOLS_BOOTSTRAP}`); + } + + return html.replace(/]*)>/i, `${QUERY_DEVTOOLS_BOOTSTRAP}`); +}; + +export const maybeInjectQueryDevtoolsBootstrap = ( + html: string, + req: QueryDevtoolsRequest, +): string => { + if (!shouldEnableQueryDevtools(req)) { + return html; + } + + return injectQueryDevtoolsBootstrap(html); +}; diff --git a/packages/api/src/html/index.ts b/packages/api/src/html/index.ts new file mode 100644 index 0000000000..9ea3e000b1 --- /dev/null +++ b/packages/api/src/html/index.ts @@ -0,0 +1 @@ +export * from './devtools'; diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index f0a9bcc48f..4b411ae6be 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -25,6 +25,8 @@ export * from './utils'; export { default as Tokenizer, countTokens } from './utils/tokenizer'; export type { EncodingName } from './utils/tokenizer'; export * from './db/utils'; +/* HTML */ +export * from './html'; /* OAuth */ export * from './oauth'; export * from './mcp/oauth/OAuthReconnectionManager';