From b35565bdfde413e8709f68c5e305b91d2f3ad91b Mon Sep 17 00:00:00 2001 From: JerryWu <409187100@qq.com> Date: Sun, 29 Mar 2026 19:29:04 +0800 Subject: [PATCH 1/5] feat(performance): add performance overview and component details - Introduced ComponentCard for displaying individual component performance metrics. - Added HookDetailsPanel to show detailed hook performance data. - Created PerformanceOverview to summarize overall performance metrics. - Implemented StatCard for displaying individual statistics in a card format. - Added utility function formatMs for formatting milliseconds into human-readable strings. - Enhanced RenderTree with new components for better state and code visualization. - Introduced filtering capabilities for hooks in the RenderTree. - Created utility functions for determining code language and filtering hook trees. - Refactored existing components to improve code organization and readability. --- {.cursor => .agent}/skills/qwik/AGENTS.md | 0 {.cursor => .agent}/skills/qwik/SKILL.md | 0 .gitignore | 4 +- packages/kit/src/client.ts | 13 +- packages/kit/src/constants.ts | 97 ++-- packages/kit/src/context.ts | 46 +- packages/kit/src/global-store.ts | 14 + packages/kit/src/rpc-core.ts | 24 + packages/kit/src/server.ts | 13 +- packages/kit/src/types.ts | 80 ++- packages/playgrounds/vite.config.mts | 2 +- packages/plugin/package.json | 3 +- packages/plugin/src/parse/componentBodies.ts | 108 ++++ packages/plugin/src/parse/hookTracker.ts | 120 ++-- packages/plugin/src/parse/initInjector.ts | 54 +- packages/plugin/src/parse/parse.ts | 8 +- packages/plugin/src/parse/sourceEdits.ts | 27 + packages/plugin/src/parse/traverse.ts | 114 +--- packages/plugin/src/parse/types.ts | 12 +- packages/plugin/src/plugin/index.ts | 2 + packages/plugin/src/plugin/statistics.ts | 282 +--------- .../plugin/src/plugin/statistics/constants.ts | 6 + .../src/plugin/statistics/sourceTransforms.ts | 122 ++++ .../plugin/statistics/ssrPerfMiddleware.ts | 113 ++++ packages/plugin/src/rpc/index.ts | 2 + .../DevtoolsButton/DevtoolsButton.tsx | 2 +- .../DevtoolsPanel/DevtoolsPanel.tsx | 523 +++++++++++++++++- packages/ui/src/components/Icons/Icons.tsx | 58 ++ .../ThemeToggle/QwikThemeToggle.tsx | 2 +- .../{themToggle.css => themeToggle.css} | 0 packages/ui/src/devtools.tsx | 261 +-------- packages/ui/src/devtools/DevtoolsContent.tsx | 112 ++++ packages/ui/src/devtools/DevtoolsSidebar.tsx | 25 + packages/ui/src/devtools/QwikDevtools.tsx | 36 ++ packages/ui/src/devtools/rpc.ts | 103 ++++ packages/ui/src/devtools/state.ts | 55 ++ packages/ui/src/devtools/tabs.tsx | 67 +++ .../features/BuildAnalysis/BuildAnalysis.tsx | 159 ++++++ .../CodeBreak.tsx} | 4 +- .../{CodeBreack => CodeBreak}/HtmlParser.tsx | 0 .../{CodeBreack => CodeBreak}/StateParser.tsx | 0 .../ui/src/features/Overview/Overview.tsx | 2 +- .../src/features/Performance/Performance.tsx | 163 +----- .../Performance/components/ComponentCard.tsx | 46 ++ .../components/HookDetailsPanel.tsx | 68 +++ .../components/PerformanceOverview.tsx | 30 + .../Performance/components/StatCard.tsx | 21 + .../features/Performance/utils/formatMs.ts | 6 + .../ui/src/features/RenderTree/RenderTree.tsx | 258 +++------ .../components/HighlightedCodeList.tsx | 30 + .../RenderTree/components/HookFiltersCard.tsx | 81 +++ .../RenderTree/components/RenderTreeTabs.tsx | 39 ++ .../components/StateTreeNodeLabel.tsx | 34 ++ packages/ui/src/features/RenderTree/types.ts | 6 + .../RenderTree/utils/filterHookTree.ts | 11 + .../RenderTree/utils/getCodeLanguage.ts | 7 + .../RenderTree/utils/getValueColorClass.ts | 22 + packages/ui/src/index.ts | 2 +- packages/ui/src/root.tsx | 2 +- packages/ui/src/types/state.ts | 29 +- pnpm-lock.yaml | 57 ++ 61 files changed, 2356 insertions(+), 1231 deletions(-) rename {.cursor => .agent}/skills/qwik/AGENTS.md (100%) rename {.cursor => .agent}/skills/qwik/SKILL.md (100%) create mode 100644 packages/kit/src/global-store.ts create mode 100644 packages/kit/src/rpc-core.ts create mode 100644 packages/plugin/src/parse/componentBodies.ts create mode 100644 packages/plugin/src/parse/sourceEdits.ts create mode 100644 packages/plugin/src/plugin/statistics/constants.ts create mode 100644 packages/plugin/src/plugin/statistics/sourceTransforms.ts create mode 100644 packages/plugin/src/plugin/statistics/ssrPerfMiddleware.ts rename packages/ui/src/components/ThemeToggle/{themToggle.css => themeToggle.css} (100%) create mode 100644 packages/ui/src/devtools/DevtoolsContent.tsx create mode 100644 packages/ui/src/devtools/DevtoolsSidebar.tsx create mode 100644 packages/ui/src/devtools/QwikDevtools.tsx create mode 100644 packages/ui/src/devtools/rpc.ts create mode 100644 packages/ui/src/devtools/state.ts create mode 100644 packages/ui/src/devtools/tabs.tsx create mode 100644 packages/ui/src/features/BuildAnalysis/BuildAnalysis.tsx rename packages/ui/src/features/{CodeBreack/CodeBreack.tsx => CodeBreak/CodeBreak.tsx} (94%) rename packages/ui/src/features/{CodeBreack => CodeBreak}/HtmlParser.tsx (100%) rename packages/ui/src/features/{CodeBreack => CodeBreak}/StateParser.tsx (100%) create mode 100644 packages/ui/src/features/Performance/components/ComponentCard.tsx create mode 100644 packages/ui/src/features/Performance/components/HookDetailsPanel.tsx create mode 100644 packages/ui/src/features/Performance/components/PerformanceOverview.tsx create mode 100644 packages/ui/src/features/Performance/components/StatCard.tsx create mode 100644 packages/ui/src/features/Performance/utils/formatMs.ts create mode 100644 packages/ui/src/features/RenderTree/components/HighlightedCodeList.tsx create mode 100644 packages/ui/src/features/RenderTree/components/HookFiltersCard.tsx create mode 100644 packages/ui/src/features/RenderTree/components/RenderTreeTabs.tsx create mode 100644 packages/ui/src/features/RenderTree/components/StateTreeNodeLabel.tsx create mode 100644 packages/ui/src/features/RenderTree/utils/filterHookTree.ts create mode 100644 packages/ui/src/features/RenderTree/utils/getCodeLanguage.ts create mode 100644 packages/ui/src/features/RenderTree/utils/getValueColorClass.ts diff --git a/.cursor/skills/qwik/AGENTS.md b/.agent/skills/qwik/AGENTS.md similarity index 100% rename from .cursor/skills/qwik/AGENTS.md rename to .agent/skills/qwik/AGENTS.md diff --git a/.cursor/skills/qwik/SKILL.md b/.agent/skills/qwik/SKILL.md similarity index 100% rename from .cursor/skills/qwik/SKILL.md rename to .agent/skills/qwik/SKILL.md diff --git a/.gitignore b/.gitignore index c67ca9f..427a958 100644 --- a/.gitignore +++ b/.gitignore @@ -66,4 +66,6 @@ Thumbs.db .vite-inspect .pnpm-store/* qwik/* -.cursor/skills/* \ No newline at end of file +.cursor/skills/* +**/build-analysis/**/* +related-folder/** \ No newline at end of file diff --git a/packages/kit/src/client.ts b/packages/kit/src/client.ts index 886db85..3257067 100644 --- a/packages/kit/src/client.ts +++ b/packages/kit/src/client.ts @@ -1,20 +1,17 @@ -import SuperJSON from 'superjson'; import { ClientFunctions, ServerFunctions } from './types'; -import { createBirpc } from 'birpc'; import { DEVTOOLS_VITE_MESSAGING_EVENT } from './constants'; import { getViteClientContext, setViteClientRpc } from './context'; +import { createSerializedRpc } from './rpc-core'; export function createClientRpc(functions: ClientFunctions) { const client = getViteClientContext(); - const rpc = createBirpc(functions, { - post: (data) => - client.send(DEVTOOLS_VITE_MESSAGING_EVENT, SuperJSON.stringify(data)), - on: (fn) => + const rpc = createSerializedRpc(functions, { + post: (data) => client.send(DEVTOOLS_VITE_MESSAGING_EVENT, data), + on: (handler) => client.on(DEVTOOLS_VITE_MESSAGING_EVENT, (data) => { - fn(SuperJSON.parse(data)); + handler(data); }), - timeout: 120_000, }); setViteClientRpc(rpc); diff --git a/packages/kit/src/constants.ts b/packages/kit/src/constants.ts index 5864b9e..33861af 100644 --- a/packages/kit/src/constants.ts +++ b/packages/kit/src/constants.ts @@ -1,59 +1,42 @@ export const DEVTOOLS_VITE_MESSAGING_EVENT = 'qwik_tools:vite_messaging_event'; -export const USE_HOOK_LIST = [ - 'useAsyncComputed', - 'useComputed', - 'useConstant', - 'useContext', - 'useContextProvider', - 'useErrorBoundary', - 'useId', - 'useOn', - 'useOnDocument', - 'useOnWindow', - 'useResource', - 'useSerializer', - 'useServerData', - 'useSignal', - 'useStore', - 'useStyles', - 'useStylesScoped', - 'useTask', - 'useVisibleTask', - 'useLocation', - 'useNavigate', - 'usePreventNavigate', - 'useContent', - 'useDocumentHead', -] as const - - -export const VARIABLE_DECLARATION_LIST = [ - 'useStore', - 'useSignal', - 'useComputed', - 'useAsyncComputed', - 'useContext', - 'useId', - 'useStyles', - 'useStylesScoped', - 'useConstant', - 'useErrorBoundary', - 'useSerializer', - 'useServerData', - 'useLocation', - 'useNavigate', - 'useContent', - 'useDocumentHead', -] as const - -export const EXPRESSION_STATEMENT_LIST = [ - 'useVisibleTask', - 'useTask', - 'useResource', - 'useContextProvider', - 'usePreventNavigate', -] as const +const HOOK_GROUPS = { + variableDeclaration: [ + 'useStore', + 'useSignal', + 'useComputed', + 'useAsyncComputed', + 'useContext', + 'useId', + 'useStyles', + 'useStylesScoped', + 'useConstant', + 'useErrorBoundary', + 'useSerializer', + 'useServerData', + 'useLocation', + 'useNavigate', + 'useContent', + 'useDocumentHead', + ] as const, + expressionStatement: [ + 'useVisibleTask', + 'useTask', + 'useResource', + 'useContextProvider', + 'usePreventNavigate', + ] as const, + listener: ['useOn', 'useOnDocument', 'useOnWindow'] as const, + noReturn: ['useVisibleTask', 'useTask'] as const, +} as const; + +export const VARIABLE_DECLARATION_LIST = HOOK_GROUPS.variableDeclaration; +export const EXPRESSION_STATEMENT_LIST = HOOK_GROUPS.expressionStatement; +export const USE_HOOK_LIST = [ + ...VARIABLE_DECLARATION_LIST, + ...HOOK_GROUPS.listener, + ...EXPRESSION_STATEMENT_LIST, +] as const; export const QSEQ = 'q:seq'; export const QPROPS = 'q:props'; @@ -62,13 +45,13 @@ export const QTYPE = 'q:type'; export const VIRTUAL_QWIK_DEVTOOLS_KEY = 'virtual-qwik-devtools.ts'; -export const INNER_USE_HOOK= 'useCollectHooks' +export const INNER_USE_HOOK = 'useCollectHooks'; -export const QWIK_DEVTOOLS_GLOBAL_STATE = 'QWIK_DEVTOOLS_GLOBAL_STATE' +export const QWIK_DEVTOOLS_GLOBAL_STATE = 'QWIK_DEVTOOLS_GLOBAL_STATE'; export const QRL_KEY = '$qrl$'; export const COMPUTED_QRL_KEY = '$computeQrl$'; export const CHUNK_KEY = '$chunk$'; export const CAPTURE_REF_KEY = '$captureRef$'; -export const NORETURN_HOOK = [ 'useVisibleTask', 'useTask'] as const \ No newline at end of file +export const NORETURN_HOOK = HOOK_GROUPS.noReturn; diff --git a/packages/kit/src/context.ts b/packages/kit/src/context.ts index b259079..b496c71 100644 --- a/packages/kit/src/context.ts +++ b/packages/kit/src/context.ts @@ -1,4 +1,3 @@ -import { target } from './shared'; import { ViteClientContext, CLIENT_CTX, @@ -8,38 +7,23 @@ import { CLIENT_RPC, } from './globals'; import { ServerRpc, ClientRpc } from './types'; +import { createGlobalAccessor } from './global-store'; -type GlobalTarget = Record; -const t = target as unknown as GlobalTarget; +const clientContextAccessor = + createGlobalAccessor(CLIENT_CTX); +const serverContextAccessor = + createGlobalAccessor(SERVER_CTX); +const serverRpcAccessor = createGlobalAccessor(SERVER_RPC); +const clientRpcAccessor = createGlobalAccessor(CLIENT_RPC); -export function getViteClientContext(): ViteClientContext { - return t[CLIENT_CTX] as ViteClientContext; -} +export const getViteClientContext = clientContextAccessor.get; +export const setViteClientContext = clientContextAccessor.set; -export function setViteClientContext(ctx: ViteClientContext) { - t[CLIENT_CTX] = ctx; -} +export const getViteServerContext = serverContextAccessor.get; +export const setViteServerContext = serverContextAccessor.set; -export function getViteServerContext() { - return t[SERVER_CTX] as ViteServerContext; -} +export const getViteServerRpc = serverRpcAccessor.get; +export const setViteServerRpc = serverRpcAccessor.set; -export function setViteServerContext(ctx: ViteServerContext) { - t[SERVER_CTX] = ctx; -} - -export function getViteServerRpc() { - return t[SERVER_RPC] as ServerRpc; -} - -export function setViteServerRpc(rpc: ServerRpc) { - t[SERVER_RPC] = rpc; -} - -export function getViteClientRpc() { - return t[CLIENT_RPC] as ClientRpc; -} - -export function setViteClientRpc(rpc: ClientRpc) { - t[CLIENT_RPC] = rpc; -} +export const getViteClientRpc = clientRpcAccessor.get; +export const setViteClientRpc = clientRpcAccessor.set; diff --git a/packages/kit/src/global-store.ts b/packages/kit/src/global-store.ts new file mode 100644 index 0000000..b5cc015 --- /dev/null +++ b/packages/kit/src/global-store.ts @@ -0,0 +1,14 @@ +import { target } from './shared'; + +type GlobalStore = Record; + +const globalStore = target as unknown as GlobalStore; + +export function createGlobalAccessor(key: string) { + return { + get: () => globalStore[key] as T, + set: (value: T) => { + globalStore[key] = value; + }, + }; +} diff --git a/packages/kit/src/rpc-core.ts b/packages/kit/src/rpc-core.ts new file mode 100644 index 0000000..f587114 --- /dev/null +++ b/packages/kit/src/rpc-core.ts @@ -0,0 +1,24 @@ +import SuperJSON from 'superjson'; +import { createBirpc } from 'birpc'; + +interface RpcChannel { + post: (serialized: string) => void; + on: (handler: (serialized: string) => void) => void; +} + +const RPC_TIMEOUT = 120_000; + +function parseRpcPayload(payload: unknown) { + return SuperJSON.parse(String(payload)); +} + +export function createSerializedRpc< + RemoteFunctions extends object, + LocalFunctions extends object, +>(functions: LocalFunctions, channel: RpcChannel) { + return createBirpc(functions, { + post: (data) => channel.post(SuperJSON.stringify(data)), + on: (handler) => channel.on((data) => handler(parseRpcPayload(data))), + timeout: RPC_TIMEOUT, + }); +} diff --git a/packages/kit/src/server.ts b/packages/kit/src/server.ts index 25b8d79..7357242 100644 --- a/packages/kit/src/server.ts +++ b/packages/kit/src/server.ts @@ -1,20 +1,17 @@ -import SuperJSON from 'superjson'; import { ClientFunctions, ServerFunctions } from './types'; -import { createBirpc } from 'birpc'; import { DEVTOOLS_VITE_MESSAGING_EVENT } from './constants'; import { setViteServerRpc, getViteServerContext } from './context'; +import { createSerializedRpc } from './rpc-core'; export function createServerRpc(functions: ServerFunctions) { const server = getViteServerContext(); - const rpc = createBirpc(functions, { - post: (data) => - server.ws.send(DEVTOOLS_VITE_MESSAGING_EVENT, SuperJSON.stringify(data)), - on: (fn) => + const rpc = createSerializedRpc(functions, { + post: (data) => server.ws.send(DEVTOOLS_VITE_MESSAGING_EVENT, data), + on: (handler) => server.ws.on(DEVTOOLS_VITE_MESSAGING_EVENT, (data: any) => { - fn(SuperJSON.parse(data)); + handler(data); }), - timeout: 120_000, }); setViteServerRpc(rpc); diff --git a/packages/kit/src/types.ts b/packages/kit/src/types.ts index bb27cd8..1b28ae4 100644 --- a/packages/kit/src/types.ts +++ b/packages/kit/src/types.ts @@ -1,12 +1,49 @@ import { BirpcReturn } from 'birpc'; import { type Dree } from 'dree'; -import { VARIABLE_DECLARATION_LIST, EXPRESSION_STATEMENT_LIST } from './constants'; +import { + VARIABLE_DECLARATION_LIST, + EXPRESSION_STATEMENT_LIST, +} from './constants'; + export { Type as RouteType } from 'dree'; export interface ClientFunctions { healthCheck(): boolean; } +export interface DependenciesStatus { + phase: 'idle' | 'phase1' | 'phase2' | 'done' | 'error'; + loaded: number; + total: number; + startedAt: number | null; + finishedAt: number | null; + error?: string; +} + +export interface PackageInstallResult { + success: boolean; + error?: string; +} + +export interface BuildAnalysisStatus { + exists: boolean; + reportPath: string; + buildCommand: string | null; +} + +export interface BuildAnalysisRunResult { + success: boolean; + error?: string; +} + +export interface ModuleLookupResult { + pathId: string; + modules: any; + error?: string; +} + +export type ParsedQwikCodeResult = Omit[]; + export interface ServerFunctions { healthCheck(): boolean; getAssetsFromPublicDir: () => Promise; @@ -14,25 +51,18 @@ export interface ServerFunctions { getRoutes: () => any; getQwikPackages: () => Promise<[string, string][]>; getAllDependencies: () => Promise; - getDependenciesStatus: () => Promise<{ - phase: 'idle' | 'phase1' | 'phase2' | 'done' | 'error'; - loaded: number; - total: number; - startedAt: number | null; - finishedAt: number | null; - error?: string; - }>; + getDependenciesStatus: () => Promise; refreshDependencies: () => Promise; installPackage: ( packageName: string, isDev?: boolean, - ) => Promise<{ success: boolean; error?: string }>; - getModulesByPathIds: (pathIds: string | string[]) => Promise<{ - pathId: string; - modules: any; - error?: string; - }[]>; - parseQwikCode: (code: string) => Promise[]>; + ) => Promise; + getBuildAnalysisStatus: () => Promise; + buildBuildAnalysisReport: () => Promise; + getModulesByPathIds: ( + pathIds: string | string[], + ) => Promise; + parseQwikCode: (code: string) => Promise; } export type ServerRpc = BirpcReturn; @@ -75,17 +105,19 @@ export interface Component { file: string; } -export type Category = 'variableDeclaration' | 'expressionStatement' | 'listener' +export type Category = + | 'variableDeclaration' + | 'expressionStatement' + | 'listener'; export type HookType = | (typeof VARIABLE_DECLARATION_LIST)[number] | (typeof EXPRESSION_STATEMENT_LIST)[number] - | 'customhook' + | 'customhook'; export interface ParsedStructure { - variableName: string - hookType: HookType - category: Category - __start__?: number - data?: any + variableName: string; + hookType: HookType; + category: Category; + __start__?: number; + data?: any; } - diff --git a/packages/playgrounds/vite.config.mts b/packages/playgrounds/vite.config.mts index a8cd0a5..d04980b 100644 --- a/packages/playgrounds/vite.config.mts +++ b/packages/playgrounds/vite.config.mts @@ -24,7 +24,7 @@ export default defineConfig(({ command, mode }): UserConfig => { plugins: [qwikRouter(), qwikVite(), tsconfigPaths(), qwikDevtools()], build: { rollupOptions: { - external: ['path'], + external: ['path', '@qwik.dev/router/service-worker'], }, }, optimizeDeps: { diff --git a/packages/plugin/package.json b/packages/plugin/package.json index c03a060..3da5a79 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -21,7 +21,8 @@ "test": "vitest --run" }, "dependencies": { - "oxc-parser": "^0.120.0" + "oxc-parser": "^0.120.0", + "rollup-plugin-visualizer": "^6.0.3" }, "devDependencies": { "@babel/types": "^7.29.0", diff --git a/packages/plugin/src/parse/componentBodies.ts b/packages/plugin/src/parse/componentBodies.ts new file mode 100644 index 0000000..465e05c --- /dev/null +++ b/packages/plugin/src/parse/componentBodies.ts @@ -0,0 +1,108 @@ +import { traverseProgram } from './traverse'; + +export interface ComponentBodyRange { + insertPos: number; + bodyStart: number; + bodyEnd: number; + exportName?: string; +} + +export function findAllComponentBodyRangesFromProgram( + program: unknown, +): ComponentBodyRange[] { + const ranges: ComponentBodyRange[] = []; + + traverseProgram(program, { + enter: (path) => { + const range = getComponentBodyRange(path.node, path.parent); + if (range) { + ranges.push(range); + } + }, + }); + + return deduplicateRanges(ranges); +} + +function getComponentBodyRange( + node: any, + parent: any, +): ComponentBodyRange | null { + if (!isComponentCall(node)) { + return null; + } + + const body = getComponentFunctionBody(node); + if (!body?.range) { + return null; + } + + const [bodyStart, bodyEnd] = body.range as [number, number]; + + return { + insertPos: bodyStart + 1, + bodyStart, + bodyEnd, + exportName: detectExportName(parent), + }; +} + +function isComponentCall(node: any): boolean { + return ( + node?.type === 'CallExpression' && + node.callee?.type === 'Identifier' && + node.callee.name === 'component$' + ); +} + +function getComponentFunctionBody( + node: any, +): { range?: [number, number] } | null { + const componentFactory = node.arguments?.[0]; + const isFunctionFactory = + componentFactory?.type === 'ArrowFunctionExpression' || + componentFactory?.type === 'FunctionExpression'; + + if (!isFunctionFactory) { + return null; + } + + return componentFactory.body?.type === 'BlockStatement' + ? componentFactory.body + : null; +} + +function detectExportName(parent: any): string | undefined { + if (!parent) return undefined; + + if (parent.type === 'ExportDefaultDeclaration') { + return ''; + } + + if (parent.type === 'VariableDeclarator') { + const identifier = parent.id; + if ( + identifier?.type === 'Identifier' && + typeof identifier.name === 'string' + ) { + return identifier.name; + } + } + + return undefined; +} + +function deduplicateRanges(ranges: ComponentBodyRange[]): ComponentBodyRange[] { + const seen = new Set(); + + return ranges + .filter((range) => { + if (seen.has(range.insertPos)) { + return false; + } + + seen.add(range.insertPos); + return true; + }) + .sort((left, right) => left.insertPos - right.insertPos); +} diff --git a/packages/plugin/src/parse/hookTracker.ts b/packages/plugin/src/parse/hookTracker.ts index f6708b4..c15fc91 100644 --- a/packages/plugin/src/parse/hookTracker.ts +++ b/packages/plugin/src/parse/hookTracker.ts @@ -20,6 +20,7 @@ import { } from './helpers'; import { INNER_USE_HOOK } from '@devtools/kit'; import type { InjectionTask } from './types'; +import { applySourceEdits } from './sourceEdits'; // ============================================================================ // Main Entry @@ -55,7 +56,7 @@ export function injectHookTrackers(code: string): string { }, }); - return applyTasks(code, tasks); + return applySourceEdits(code, tasks); } // ============================================================================ @@ -84,7 +85,13 @@ function processVariableDeclarator( // Custom hook if (isCustomHook(normalizedName)) { if (hasCollecthookAfterByVariableId(code, declEnd, variableId)) return null; - const payload = buildCollecthookPayload(indent, variableId, 'customhook', 'VariableDeclarator', variableId); + const payload = buildCollecthookPayload( + indent, + variableId, + 'customhook', + 'VariableDeclarator', + variableId, + ); return { kind: 'insert', pos: declEnd, text: '\n' + payload }; } @@ -92,7 +99,13 @@ function processVariableDeclarator( if (!isKnownHook(normalizedName)) return null; if (hasCollecthookAfterByVariableId(code, declEnd, variableId)) return null; - const payload = buildCollecthookPayload(indent, variableId, normalizedName, 'VariableDeclarator', variableId); + const payload = buildCollecthookPayload( + indent, + variableId, + normalizedName, + 'VariableDeclarator', + variableId, + ); return { kind: 'insert', pos: declEnd, text: '\n' + payload }; } @@ -123,8 +136,15 @@ function processExpressionStatement( // Known hook (expression form) if (isKnownHook(normalizedName)) { - if (hasCollecthookAfterByVariableName(code, stmtEnd, normalizedName)) return null; - const payload = buildCollecthookPayload(indent, normalizedName, normalizedName, 'expressionStatement', 'undefined'); + if (hasCollecthookAfterByVariableName(code, stmtEnd, normalizedName)) + return null; + const payload = buildCollecthookPayload( + indent, + normalizedName, + normalizedName, + 'expressionStatement', + 'undefined', + ); return { task: { kind: 'insert', pos: stmtEnd, text: '\n' + payload }, newIndex: currentIndex, @@ -133,7 +153,13 @@ function processExpressionStatement( // Custom hook (expression form) - convert to variable declaration if (isCustomHook(normalizedName)) { - return convertToVariableDeclaration(code, stmtStart, stmtEnd, indent, currentIndex); + return convertToVariableDeclaration( + code, + stmtStart, + stmtEnd, + indent, + currentIndex, + ); } return null; @@ -152,10 +178,21 @@ function convertToVariableDeclaration( const callSource = code.slice(stmtStart, stmtEnd); const variableName = `_customhook_${currentIndex}`; const declLine = `${indent}let ${variableName} = ${trimStatementSemicolon(callSource)};\n`; - const payload = buildCollecthookPayload(indent, variableName, 'customhook', 'VariableDeclarator', variableName); + const payload = buildCollecthookPayload( + indent, + variableName, + 'customhook', + 'VariableDeclarator', + variableName, + ); return { - task: { kind: 'replace', start: stmtStart, end: stmtEnd, text: declLine + payload }, + task: { + kind: 'replace', + start: stmtStart, + end: stmtEnd, + text: declLine + payload, + }, newIndex: currentIndex + 1, }; } @@ -171,32 +208,34 @@ interface HookInfo { } function extractHookInfo(node: any): HookInfo | null { - const init = node.init; - if (!isAstNodeLike(init) || init.type !== 'CallExpression') return null; - - const callee = (init as any).callee; - if (!isAstNodeLike(callee) || callee.type !== 'Identifier') return null; - - const hookName = normalizeHookName((callee as any).name as string); - const normalizedName = normalizeQrlHookName(hookName); const variableId = getVariableIdentifierName(node.id); - if (!variableId) return null; - return { hookName, normalizedName, variableId }; + const hookCall = extractHookCall(node.init); + if (!hookCall) return null; + + return { ...hookCall, variableId }; +} + +function extractExpressionHookInfo( + node: any, +): { hookName: string; normalizedName: string } | null { + return extractHookCall(node.expression); } -function extractExpressionHookInfo(node: any): { hookName: string; normalizedName: string } | null { - const expr = node.expression; - if (!isAstNodeLike(expr) || expr.type !== 'CallExpression') return null; +function extractHookCall( + node: unknown, +): { hookName: string; normalizedName: string } | null { + if (!isAstNodeLike(node) || node.type !== 'CallExpression') return null; - const callee = (expr as any).callee; + const callee = (node as any).callee; if (!isAstNodeLike(callee) || callee.type !== 'Identifier') return null; const hookName = normalizeHookName((callee as any).name as string); - const normalizedName = normalizeQrlHookName(hookName); - - return { hookName, normalizedName }; + return { + hookName, + normalizedName: normalizeQrlHookName(hookName), + }; } // ============================================================================ @@ -209,35 +248,12 @@ function getParentRange(parent: any): [number, number] | null { return [range[0], range[1]]; } -function getPositionInfo(code: string, range: [number, number]): { declStart: number; declEnd: number; indent: string } { +function getPositionInfo( + code: string, + range: [number, number], +): { declStart: number; declEnd: number; indent: string } { const [declStart, declEnd] = range; const lineStart = findLineStart(code, declStart); const indent = readIndent(code, lineStart); return { declStart, declEnd, indent }; } - -// ============================================================================ -// Task Application -// ============================================================================ - -function applyTasks(code: string, tasks: InjectionTask[]): string { - if (tasks.length === 0) return code; - - // Sort from last to first to keep positions stable - tasks.sort((a, b) => { - const aPos = a.kind === 'insert' ? a.pos : a.start; - const bPos = b.kind === 'insert' ? b.pos : b.start; - return bPos - aPos; - }); - - let result = code; - for (const task of tasks) { - if (task.kind === 'insert') { - result = result.slice(0, task.pos) + task.text + result.slice(task.pos); - } else { - result = result.slice(0, task.start) + task.text + result.slice(task.end); - } - } - return result; -} - diff --git a/packages/plugin/src/parse/initInjector.ts b/packages/plugin/src/parse/initInjector.ts index b033c57..c46068c 100644 --- a/packages/plugin/src/parse/initInjector.ts +++ b/packages/plugin/src/parse/initInjector.ts @@ -3,10 +3,11 @@ * Injects collecthook setup and render stats at the beginning of each component body */ -import { ComponentBodyRange } from './traverse'; +import type { ComponentBodyRange } from './componentBodies'; import { readIndent } from './helpers'; import { INNER_USE_HOOK } from '@devtools/kit'; -import type { InjectOptions, InitTask } from './types'; +import type { InjectOptions, InsertTask } from './types'; +import { applySourceEdits } from './sourceEdits'; // ============================================================================ // Main Entry @@ -20,7 +21,7 @@ export function injectInitHooks( bodies: ComponentBodyRange[], options?: InjectOptions, ): string { - const tasks: InitTask[] = []; + const tasks: InsertTask[] = []; for (const body of bodies) { const task = createInitTask(code, body, options); @@ -29,7 +30,7 @@ export function injectInitHooks( } } - return applyTasks(code, tasks); + return applySourceEdits(code, tasks); } // ============================================================================ @@ -43,7 +44,7 @@ function createInitTask( code: string, body: ComponentBodyRange, options?: InjectOptions, -): InitTask | null { +): InsertTask | null { const { insertPos, exportName } = body; // Skip if already has collecthook initialization @@ -52,18 +53,17 @@ function createInitTask( } // Calculate insertion position - const { insertIndex, prefixNewline } = calculateInsertPosition(code, insertPos); + const { insertIndex, prefixNewline } = calculateInsertPosition( + code, + insertPos, + ); const indent = readIndent(code, insertIndex); // Build initialization code const componentArg = buildComponentArg(options?.path, exportName); const initLine = `${prefixNewline}${indent}const collecthook = ${INNER_USE_HOOK}(${componentArg})\n`; - return { - start: insertIndex, - end: insertIndex, - text: initLine, - }; + return { kind: 'insert', pos: insertIndex, text: initLine }; } // ============================================================================ @@ -75,7 +75,10 @@ function hasExistingCollecthook(code: string, insertPos: number): boolean { return /const\s+collecthook\s*=\s*useCollectHooks\s*\(/.test(lookahead); } -function calculateInsertPosition(code: string, insertPos: number): { +function calculateInsertPosition( + code: string, + insertPos: number, +): { insertIndex: number; prefixNewline: string; } { @@ -95,14 +98,20 @@ function calculateInsertPosition(code: string, insertPos: number): { /** * Builds the component argument string for collecthook initialization */ -function buildComponentArg(path: string | undefined, exportName: string | undefined): string { +function buildComponentArg( + path: string | undefined, + exportName: string | undefined, +): string { const rawArg = String(path ?? ''); const baseArg = rawArg.split('?')[0].split('#')[0]; const suffix = buildComponentSuffix(baseArg, exportName); return JSON.stringify(`${baseArg}${suffix}`); } -function buildComponentSuffix(baseArg: string, exportName: string | undefined): string { +function buildComponentSuffix( + baseArg: string, + exportName: string | undefined, +): string { if (exportName && typeof exportName === 'string') { return `_${exportName}`; } @@ -117,20 +126,3 @@ function buildComponentSuffix(baseArg: string, exportName: string | undefined): const name = file.replace(/\.[^.]+$/, ''); return name ? `_${name.replace(/-/g, '_')}` : ''; } - - -// ============================================================================ -// Task Application -// ============================================================================ - -function applyTasks(code: string, tasks: InitTask[]): string { - // Sort from last to first to keep positions stable - tasks.sort((a, b) => b.start - a.start); - - let result = code; - for (const task of tasks) { - result = result.slice(0, task.start) + task.text + result.slice(task.end); - } - return result; -} - diff --git a/packages/plugin/src/parse/parse.ts b/packages/plugin/src/parse/parse.ts index 1120e13..f7041d7 100644 --- a/packages/plugin/src/parse/parse.ts +++ b/packages/plugin/src/parse/parse.ts @@ -3,7 +3,8 @@ * Transforms Qwik component code by injecting devtools hooks */ -import { parseProgram, findAllComponentBodyRangesFromProgram } from './traverse'; +import { parseProgram } from './traverse'; +import { findAllComponentBodyRangesFromProgram } from './componentBodies'; import { injectInitHooks } from './initInjector'; import { injectHookTrackers } from './hookTracker'; @@ -15,7 +16,10 @@ export type { InjectOptions } from './types'; * Phase 1: Inject initialization hooks (collecthook setup + render stats) * Phase 2: Inject collecthook calls for individual hooks */ -export function parseQwikCode(code: string, options?: { path?: string}): string { +export function parseQwikCode( + code: string, + options?: { path?: string }, +): string { const program = parseProgram(code); const componentBodies = findAllComponentBodyRangesFromProgram(program); diff --git a/packages/plugin/src/parse/sourceEdits.ts b/packages/plugin/src/parse/sourceEdits.ts new file mode 100644 index 0000000..9ccf12a --- /dev/null +++ b/packages/plugin/src/parse/sourceEdits.ts @@ -0,0 +1,27 @@ +import type { SourceEdit } from './types'; + +export function applySourceEdits(code: string, edits: SourceEdit[]): string { + if (edits.length === 0) { + return code; + } + + const orderedEdits = [...edits].sort( + (left, right) => getEditStart(right) - getEditStart(left), + ); + let result = code; + + for (const edit of orderedEdits) { + if (edit.kind === 'insert') { + result = result.slice(0, edit.pos) + edit.text + result.slice(edit.pos); + continue; + } + + result = result.slice(0, edit.start) + edit.text + result.slice(edit.end); + } + + return result; +} + +function getEditStart(edit: SourceEdit): number { + return edit.kind === 'insert' ? edit.pos : edit.start; +} diff --git a/packages/plugin/src/parse/traverse.ts b/packages/plugin/src/parse/traverse.ts index 45200d8..cd1272b 100644 --- a/packages/plugin/src/parse/traverse.ts +++ b/packages/plugin/src/parse/traverse.ts @@ -24,13 +24,6 @@ export type Visitor = { [type: string]: VisitFn | VisitObj | undefined; }; -export interface ComponentBodyRange { - insertPos: number; - bodyStart: number; - bodyEnd: number; - exportName?: string; -} - // ============================================================================ // Parser // ============================================================================ @@ -68,7 +61,10 @@ function callVisitor( if (specific) specific(path); // Call type-specific handler - const handler = visitor[path.node && (path.node as any).type] as VisitFn | VisitObj | undefined; + const handler = visitor[path.node && (path.node as any).type] as + | VisitFn + | VisitObj + | undefined; if (!handler) return; if (typeof handler === 'function' && hook === 'enter') { @@ -85,7 +81,11 @@ function callVisitor( /** * Traverses an AST program with a visitor pattern */ -export function traverseProgram(program: unknown, visitor: Visitor, state?: any): void { +export function traverseProgram( + program: unknown, + visitor: Visitor, + state?: any, +): void { let shouldStopAll = false; function traverse( @@ -105,8 +105,12 @@ export function traverseProgram(program: unknown, visitor: Visitor, state?: any) key, index, state, - stop: () => { shouldStopAll = true; }, - skip: () => { shouldSkipChildren = true; }, + stop: () => { + shouldStopAll = true; + }, + skip: () => { + shouldSkipChildren = true; + }, }; // Enter phase @@ -137,89 +141,11 @@ export function traverseProgram(program: unknown, visitor: Visitor, state?: any) /** * Parses code and traverses the resulting AST */ -export function traverseQwik(code: string, visitor: Visitor, state?: any): void { +export function traverseQwik( + code: string, + visitor: Visitor, + state?: any, +): void { const program = parseProgram(code); traverseProgram(program, visitor, state); } - -// ============================================================================ -// Component Body Detection -// ============================================================================ - -/** - * Finds all component$ function bodies in the AST and returns their positions - */ -export function findAllComponentBodyRangesFromProgram(program: unknown): ComponentBodyRange[] { - const ranges: ComponentBodyRange[] = []; - - traverseProgram(program, { - enter: (path) => { - const node: any = path.node; - if (!node || node.type !== 'CallExpression') return; - - // Check if this is a component$ call - const callee = node.callee; - if (!callee || callee.type !== 'Identifier' || callee.name !== 'component$') return; - - // Get the function argument - const firstArg = node.arguments?.[0]; - const isFunction = - firstArg?.type === 'ArrowFunctionExpression' || - firstArg?.type === 'FunctionExpression'; - if (!isFunction) return; - - // Get the function body block - const body = firstArg.body; - if (body?.type !== 'BlockStatement' || !Array.isArray(body.range)) return; - - const start = body.range[0] as number; - const end = body.range[1] as number; - - // Detect export name from parent context - const exportName = detectExportName(path.parent); - - ranges.push({ - insertPos: start + 1, - bodyStart: start, - bodyEnd: end, - exportName, - }); - }, - }); - - // De-duplicate and sort by position ascending - return deduplicateAndSort(ranges); -} - -/** - * Detects the export name from the parent node context - */ -function detectExportName(parent: any): string | undefined { - if (!parent) return undefined; - - if (parent.type === 'ExportDefaultDeclaration') { - return ''; - } - - if (parent.type === 'VariableDeclarator') { - const id = parent.id; - if (id?.type === 'Identifier' && typeof id.name === 'string') { - return id.name; - } - } - - return undefined; -} - -/** - * Removes duplicate ranges and sorts by insertPos ascending - */ -function deduplicateAndSort(ranges: ComponentBodyRange[]): ComponentBodyRange[] { - const seen = new Set(); - const unique = ranges.filter((r) => { - if (seen.has(r.insertPos)) return false; - seen.add(r.insertPos); - return true; - }); - return unique.sort((a, b) => a.insertPos - b.insertPos); -} diff --git a/packages/plugin/src/parse/types.ts b/packages/plugin/src/parse/types.ts index b47832c..f9c1684 100644 --- a/packages/plugin/src/parse/types.ts +++ b/packages/plugin/src/parse/types.ts @@ -7,7 +7,11 @@ export interface InjectOptions { } export type InsertTask = { kind: 'insert'; pos: number; text: string }; -export type ReplaceTask = { kind: 'replace'; start: number; end: number; text: string }; -export type InjectionTask = InsertTask | ReplaceTask; -export type InitTask = { start: number; end: number; text: string }; - +export type ReplaceTask = { + kind: 'replace'; + start: number; + end: number; + text: string; +}; +export type SourceEdit = InsertTask | ReplaceTask; +export type InjectionTask = SourceEdit; diff --git a/packages/plugin/src/plugin/index.ts b/packages/plugin/src/plugin/index.ts index b322f8f..420ea4d 100644 --- a/packages/plugin/src/plugin/index.ts +++ b/packages/plugin/src/plugin/index.ts @@ -1,5 +1,6 @@ import { type Plugin } from 'vite'; import VueInspector from 'vite-plugin-inspect'; +import { createBuildAnalysisPlugins } from '../build-analysis'; import { devtoolsPlugin } from './devtools'; import { statisticsPlugin } from './statistics'; @@ -14,6 +15,7 @@ export function qwikDevtools(): Plugin[] { devtoolsPlugin(), { ...VueInspector(), apply: 'serve' }, statisticsPlugin(), + ...createBuildAnalysisPlugins(), // Add more plugins here as needed ]; } diff --git a/packages/plugin/src/plugin/statistics.ts b/packages/plugin/src/plugin/statistics.ts index cb758a4..13a157e 100644 --- a/packages/plugin/src/plugin/statistics.ts +++ b/packages/plugin/src/plugin/statistics.ts @@ -1,288 +1,46 @@ import type { Plugin } from 'vite'; -import { debug } from 'debug'; -import perfLazyWrapperPreamble from '../virtualmodules/perfLazyWrapperPreamble'; -import { isVirtualId, normalizeId } from '../virtualmodules/virtualModules'; +import { normalizeId } from '../virtualmodules/virtualModules'; +import { attachSsrPerfInjectorMiddleware } from './statistics/ssrPerfMiddleware'; +import { + findQwikLazyComponentExports, + isPerfVirtualModuleId, + rewriteComponentQrlImport, + shouldTransformStatisticsSource, + wrapQwikLazyComponentExports, +} from './statistics/sourceTransforms'; /** * Statistics plugin: collect Qwik render performance. * - * Responsibilities (kept similar to `plugin/devtools.ts` structure): - * - Transform: rewrite `componentQrl` imports to a virtual module - * - Transform: wrap Qwik-generated lazy render modules (`_component_`) to record perf entries - * - Dev SSR: inject SSR perf snapshot into final HTML - * - * The virtual module source for `componentQrl` is registered in: - * - `packages/plugin/src/virtualmodules/qwikComponentProxy.ts` - * and is served by the core devtools plugin (`plugin/devtools.ts`) via `virtualmodules/virtualModules.ts`. - * - * Data sinks: - * - **CSR**: `window.__QWIK_PERF__ = { ssr: [], csr: [] }` - * - **SSR**: stored on `process` (preferred) or `globalThis` as `__QWIK_SSR_PERF__` + * Responsibilities: + * - Rewrite `componentQrl` imports to a proxy virtual module + * - Wrap generated lazy component modules so CSR perf can be recorded + * - Inject SSR perf snapshots into dev HTML responses */ - -// ============================================================================ -// Constants -// ============================================================================ - -const PERF_VIRTUAL_ID = 'virtual:qwik-component-proxy'; -const log = debug('qwik:devtools:perf'); - -type AnyRecord = Record; - -// ============================================================================ -// Shared env helpers -// ============================================================================ - -function getStoreForSSR(): AnyRecord { - // NOTE: Vite SSR module-runner may execute in an isolated context; `globalThis` may not be - // the same as the dev server's `globalThis`. Using `process` is usually cross-context. - return typeof process !== 'undefined' && process - ? (process as unknown as AnyRecord) - : (globalThis as AnyRecord); -} - -function isFromNodeModules(cleanId: string): boolean { - return cleanId.includes('/node_modules/') || cleanId.includes('\\node_modules\\'); -} - -function isUiLibBuildOutput(cleanId: string): boolean { - // Avoid rewriting the already-built UI library (`.qwik.mjs` etc). - return cleanId.includes('/packages/ui/lib/') || cleanId.includes('\\packages\\ui\\lib\\'); -} - -function shouldTransformSource(id: string): boolean { - const cleanId = normalizeId(id); - // We intentionally do NOT skip all virtual modules: Qwik (and Vite) generate many `\0` ids, - // and SSR instrumentation needs to cover them. We only skip third-party deps / build outputs. - return !isFromNodeModules(cleanId) && !isUiLibBuildOutput(cleanId); -} - -function isPerfVirtualModuleId(id: string): boolean { - return isVirtualId(id, PERF_VIRTUAL_ID); -} - -// ============================================================================ -// Transform: rewrite componentQrl import -// ============================================================================ - -function rewriteComponentQrlImport(code: string, id: string): { code: string; changed: boolean } { - if (!code.includes('@qwik.dev/core') || !code.includes('componentQrl')) { - return { code, changed: false }; - } - - // Match: `import { ... componentQrl ... } from '@qwik.dev/core'` - const importRe = /import\s*\{([^}]*)\}\s*from\s*['"]@qwik\.dev\/core['"]/g; - let changed = false; - const next = code.replace(importRe, (match, imports) => { - const importList = String(imports) - .split(',') - .map((s) => s.trim()) - .filter(Boolean); - - const hasComponentQrl = importList.some( - (imp) => imp === 'componentQrl' || imp.startsWith('componentQrl ') - ); - if (!hasComponentQrl) return match; - - changed = true; - log('rewrite componentQrl import %O', { - id, - isVirtual: normalizeId(id).startsWith('\0'), - }); - - // Filter out `componentQrl` (including `componentQrl as alias`) - const filteredImports = importList.filter( - (imp) => imp !== 'componentQrl' && !imp.startsWith('componentQrl ') - ); - - if (filteredImports.length === 0) { - // Only `componentQrl` was imported: fully replace it - return `import { componentQrl } from '${PERF_VIRTUAL_ID}'`; - } - - // Keep other imports and add the virtual `componentQrl` - return `import { ${filteredImports.join( - ', ' - )} } from '@qwik.dev/core';\nimport { componentQrl } from '${PERF_VIRTUAL_ID}'`; - }); - - return { code: next, changed }; -} - -// ============================================================================ -// Transform: wrap Qwik lazy render modules (`_component_`) -// ============================================================================ - -function findQwikLazyComponentExports(code: string): string[] { - // Match: `export const XXX_component_HASH = ...` - const exportRe = /export\s+const\s+(\w+_component_\w+)\s*=/g; - const exports: string[] = []; - let match: RegExpExecArray | null; - - while ((match = exportRe.exec(code)) !== null) { - exports.push(match[1]); - } - - return exports; -} - -function replaceExportWithOriginal(code: string, exportName: string): string { - return code.replace( - new RegExp(`export\\s+const\\s+${exportName}\\s*=`), - `const __original_${exportName}__ =` - ); -} - -function appendWrappedExport(code: string, exportName: string, id: string): string { - return ( - code + - ` -export const ${exportName} = __qwik_wrap__(__original_${exportName}__, '${exportName}', '${id}'); -` - ); -} - -function wrapQwikLazyComponentExports(params: { - code: string; - id: string; - exports: string[]; -}): { code: string; changed: boolean } { - const { exports, id } = params; - if (exports.length === 0) return { code: params.code, changed: false }; - - log('wrap _component_ exports %O', { id, count: exports.length }); - - let modifiedCode = perfLazyWrapperPreamble + params.code; - - // Replace each export by wrapping the original function - for (const exportName of exports) { - modifiedCode = replaceExportWithOriginal(modifiedCode, exportName); - modifiedCode = appendWrappedExport(modifiedCode, exportName, id); - } - - return { code: modifiedCode, changed: true }; -} - -// ============================================================================ -// Dev SSR: inject SSR perf snapshot into HTML -// ============================================================================ - -function createSsrPerfInjectionScript(entries: unknown[]): string { - // Inject SSR perf data into the final HTML page so the UI can display it. - return ` -`; -} - -type MiddlewareNext = (err?: unknown) => void; -type MinimalMiddlewareReq = { headers: Record; url?: string }; -type MinimalMiddlewareRes = { - write: (...args: any[]) => any; - end: (...args: any[]) => any; - setHeader: (name: string, value: any) => void; -}; - -function attachSsrPerfInjectorMiddleware(server: any) { - // In dev SSR, Qwik streams HTML; `transformIndexHtml` often runs before SSR rendering finishes, - // causing `__QWIK_SSR_PERF__` to still be empty at injection time. - // Instead, intercept the final HTML response and inject after SSR completes. - server.middlewares.use((req: MinimalMiddlewareReq, res: MinimalMiddlewareRes, next: MiddlewareNext) => { - const accept = req.headers.accept || ''; - if (!accept.includes('text/html')) return next(); - - // The SSR collector uses "global accumulation + de-dupe". - // Do NOT clear it per-request, otherwise we may inject empty data if sampling hasn't occurred yet. - const store = getStoreForSSR() as unknown as Record; - - const originalWrite = res.write.bind(res); - const originalEnd = res.end.bind(res); - - let body = ''; - - res.write = function ( - chunk: unknown, - encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void), - callback?: (error?: Error | null) => void - ): boolean { - if (chunk) { - body += typeof chunk === 'string' ? chunk : Buffer.from(chunk as any).toString(); - } - // Don't flush immediately; inject on `end` (in dev it's OK to sacrifice streaming) - if (typeof encodingOrCallback === 'function') encodingOrCallback(); - if (typeof callback === 'function') callback(); - return true; - } as typeof res.write; - - res.end = function ( - chunk?: unknown, - encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void), - callback?: (error?: Error | null) => void - ): typeof res { - if (chunk) { - body += typeof chunk === 'string' ? chunk : Buffer.from(chunk as any).toString(); - } - - if (body.includes('')) { - const rawArr = store.__QWIK_SSR_PERF__; - const entries = Array.isArray(rawArr) ? rawArr : []; - log('inject ssr perf %O', { url: req.url, total: entries.length }); - const script = createSsrPerfInjectionScript(entries); - - // Place at the start of `` so it runs earlier than other head scripts - body = body.replace(/]*)?>/i, (m) => `${m}${script}`); - } - - try { - res.setHeader('Content-Length', Buffer.byteLength(body)); - } catch { - // ignore - } - - originalWrite(body); - - if (typeof encodingOrCallback === 'function') encodingOrCallback(); - if (typeof callback === 'function') callback(); - - return originalEnd(); - } as typeof res.end; - - next(); - }); -} - -// ============================================================================ -// Plugin factory (similar entry-point style to devtools.ts) -// ============================================================================ - export function statisticsPlugin(): Plugin { return { name: 'vite:qwik-component-proxy-transform', enforce: 'post', apply: 'serve', transform(code, id) { - // Avoid rewriting imports inside the perf virtual module itself (otherwise `originalComponentQrl` - // could become self-referential/undefined) if (isPerfVirtualModuleId(id)) return null; - - // By default, skip dependencies and build outputs (otherwise we'd transform node_modules / ui's `.qwik.mjs` as well) - if (!shouldTransformSource(id)) return null; + if (!shouldTransformStatisticsSource(id)) return null; let modifiedCode = code; let hasChanges = false; - // 1) Replace `componentQrl` import from `@qwik.dev/core` -> virtual module const rewritten = rewriteComponentQrlImport(modifiedCode, id); modifiedCode = rewritten.code; hasChanges = hasChanges || rewritten.changed; - // 2) Handle Qwik-generated lazy render function modules (`_component_`) const cleanId = normalizeId(id); if (cleanId.includes('_component_')) { const exports = findQwikLazyComponentExports(code); - const wrapped = wrapQwikLazyComponentExports({ code: modifiedCode, id, exports }); + const wrapped = wrapQwikLazyComponentExports({ + code: modifiedCode, + id, + exports, + }); modifiedCode = wrapped.code; hasChanges = hasChanges || wrapped.changed; } @@ -295,4 +53,4 @@ export function statisticsPlugin(): Plugin { attachSsrPerfInjectorMiddleware(server); }, }; -} \ No newline at end of file +} diff --git a/packages/plugin/src/plugin/statistics/constants.ts b/packages/plugin/src/plugin/statistics/constants.ts new file mode 100644 index 0000000..af08ed0 --- /dev/null +++ b/packages/plugin/src/plugin/statistics/constants.ts @@ -0,0 +1,6 @@ +import { debug } from 'debug'; + +export const PERF_VIRTUAL_ID = 'virtual:qwik-component-proxy'; +export const log = debug('qwik:devtools:perf'); + +export type AnyRecord = Record; diff --git a/packages/plugin/src/plugin/statistics/sourceTransforms.ts b/packages/plugin/src/plugin/statistics/sourceTransforms.ts new file mode 100644 index 0000000..95ca777 --- /dev/null +++ b/packages/plugin/src/plugin/statistics/sourceTransforms.ts @@ -0,0 +1,122 @@ +import perfLazyWrapperPreamble from '../../virtualmodules/perfLazyWrapperPreamble'; +import { isVirtualId, normalizeId } from '../../virtualmodules/virtualModules'; +import { PERF_VIRTUAL_ID, log } from './constants'; + +export function shouldTransformStatisticsSource(id: string): boolean { + const cleanId = normalizeId(id); + return !isFromNodeModules(cleanId) && !isUiLibBuildOutput(cleanId); +} + +export function isPerfVirtualModuleId(id: string): boolean { + return isVirtualId(id, PERF_VIRTUAL_ID); +} + +export function rewriteComponentQrlImport( + code: string, + id: string, +): { code: string; changed: boolean } { + if (!code.includes('@qwik.dev/core') || !code.includes('componentQrl')) { + return { code, changed: false }; + } + + const importRe = /import\s*\{([^}]*)\}\s*from\s*['"]@qwik\.dev\/core['"]/g; + let changed = false; + + const nextCode = code.replace(importRe, (match, imports) => { + const importList = String(imports) + .split(',') + .map((item) => item.trim()) + .filter(Boolean); + + const hasComponentQrl = importList.some( + (item) => item === 'componentQrl' || item.startsWith('componentQrl '), + ); + if (!hasComponentQrl) { + return match; + } + + changed = true; + log('rewrite componentQrl import %O', { + id, + isVirtual: normalizeId(id).startsWith('\0'), + }); + + const preservedImports = importList.filter( + (item) => item !== 'componentQrl' && !item.startsWith('componentQrl '), + ); + + if (preservedImports.length === 0) { + return `import { componentQrl } from '${PERF_VIRTUAL_ID}'`; + } + + return [ + `import { ${preservedImports.join(', ')} } from '@qwik.dev/core';`, + `import { componentQrl } from '${PERF_VIRTUAL_ID}'`, + ].join('\n'); + }); + + return { code: nextCode, changed }; +} + +export function findQwikLazyComponentExports(code: string): string[] { + const exportRe = /export\s+const\s+(\w+_component_\w+)\s*=/g; + const exports: string[] = []; + let match: RegExpExecArray | null; + + while ((match = exportRe.exec(code)) !== null) { + exports.push(match[1]); + } + + return exports; +} + +export function wrapQwikLazyComponentExports(params: { + code: string; + id: string; + exports: string[]; +}): { code: string; changed: boolean } { + const { code, exports, id } = params; + if (exports.length === 0) { + return { code, changed: false }; + } + + log('wrap _component_ exports %O', { id, count: exports.length }); + + let modifiedCode = perfLazyWrapperPreamble + code; + for (const exportName of exports) { + modifiedCode = replaceExportWithOriginal(modifiedCode, exportName); + modifiedCode = appendWrappedExport(modifiedCode, exportName, id); + } + + return { code: modifiedCode, changed: true }; +} + +function isFromNodeModules(cleanId: string): boolean { + return ( + cleanId.includes('/node_modules/') || cleanId.includes('\\node_modules\\') + ); +} + +function isUiLibBuildOutput(cleanId: string): boolean { + return ( + cleanId.includes('/packages/ui/lib/') || + cleanId.includes('\\packages\\ui\\lib\\') + ); +} + +function replaceExportWithOriginal(code: string, exportName: string): string { + return code.replace( + new RegExp(`export\\s+const\\s+${exportName}\\s*=`), + `const __original_${exportName}__ =`, + ); +} + +function appendWrappedExport( + code: string, + exportName: string, + id: string, +): string { + return `${code} +export const ${exportName} = __qwik_wrap__(__original_${exportName}__, '${exportName}', '${id}'); +`; +} diff --git a/packages/plugin/src/plugin/statistics/ssrPerfMiddleware.ts b/packages/plugin/src/plugin/statistics/ssrPerfMiddleware.ts new file mode 100644 index 0000000..da46ad0 --- /dev/null +++ b/packages/plugin/src/plugin/statistics/ssrPerfMiddleware.ts @@ -0,0 +1,113 @@ +import type { AnyRecord } from './constants'; +import { log } from './constants'; + +type MiddlewareNext = (err?: unknown) => void; +type MinimalMiddlewareReq = { + headers: Record; + url?: string; +}; +type MinimalMiddlewareRes = { + write: (...args: any[]) => any; + end: (...args: any[]) => any; + setHeader: (name: string, value: any) => void; +}; + +export function attachSsrPerfInjectorMiddleware(server: any) { + server.middlewares.use( + ( + req: MinimalMiddlewareReq, + res: MinimalMiddlewareRes, + next: MiddlewareNext, + ) => { + const accept = req.headers.accept || ''; + if (!accept.includes('text/html')) return next(); + + const store = getStoreForSSR() as Record; + const originalWrite = res.write.bind(res); + const originalEnd = res.end.bind(res); + let body = ''; + + res.write = function ( + chunk: unknown, + encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void), + callback?: (error?: Error | null) => void, + ): boolean { + if (chunk) { + body += + typeof chunk === 'string' + ? chunk + : Buffer.from(chunk as any).toString(); + } + + if (typeof encodingOrCallback === 'function') encodingOrCallback(); + if (typeof callback === 'function') callback(); + return true; + } as typeof res.write; + + res.end = function ( + chunk?: unknown, + encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void), + callback?: (error?: Error | null) => void, + ): typeof res { + if (chunk) { + body += + typeof chunk === 'string' + ? chunk + : Buffer.from(chunk as any).toString(); + } + + const nextBody = injectSsrPerfIntoHtml(body, store, req.url); + + try { + res.setHeader('Content-Length', Buffer.byteLength(nextBody)); + } catch { + // ignore + } + + originalWrite(nextBody); + + if (typeof encodingOrCallback === 'function') encodingOrCallback(); + if (typeof callback === 'function') callback(); + + return originalEnd(); + } as typeof res.end; + + next(); + }, + ); +} + +function getStoreForSSR(): AnyRecord { + return typeof process !== 'undefined' && process + ? (process as unknown as AnyRecord) + : (globalThis as AnyRecord); +} + +function injectSsrPerfIntoHtml( + html: string, + store: Record, + url: string | undefined, +): string { + if (!html.includes('')) { + return html; + } + + const rawEntries = store.__QWIK_SSR_PERF__; + const entries = Array.isArray(rawEntries) ? rawEntries : []; + log('inject ssr perf %O', { url, total: entries.length }); + + return html.replace( + /]*)?>/i, + (match) => `${match}${createSsrPerfInjectionScript(entries)}`, + ); +} + +function createSsrPerfInjectionScript(entries: unknown[]): string { + const serializedEntries = JSON.stringify(entries); + return ` +`; +} diff --git a/packages/plugin/src/rpc/index.ts b/packages/plugin/src/rpc/index.ts index 6c4c605..88e413e 100644 --- a/packages/plugin/src/rpc/index.ts +++ b/packages/plugin/src/rpc/index.ts @@ -1,5 +1,6 @@ import { ServerFunctions } from '@devtools/kit'; import { getAssetsFunctions } from '../assets'; +import { getBuildAnalysisFunctions } from '../build-analysis'; import { ServerContext } from '../types'; import { getRouteFunctions } from '../routes'; import { getNpmFunctions } from '../npm'; @@ -13,6 +14,7 @@ export function getServerFunctions(ctx: ServerContext): ServerFunctions { ...getComponentsFunctions(ctx), ...getRouteFunctions(ctx), ...getNpmFunctions(ctx), + ...getBuildAnalysisFunctions(ctx), ...getModulesContent(ctx), }; } diff --git a/packages/ui/src/components/DevtoolsButton/DevtoolsButton.tsx b/packages/ui/src/components/DevtoolsButton/DevtoolsButton.tsx index 7903141..116bfc7 100644 --- a/packages/ui/src/components/DevtoolsButton/DevtoolsButton.tsx +++ b/packages/ui/src/components/DevtoolsButton/DevtoolsButton.tsx @@ -1,5 +1,5 @@ import { component$, useSignal, $, useTask$ } from '@qwik.dev/core'; -import type { State } from '../../types/state'; // Assuming State type is defined elsewhere +import type { State } from '../../types/state'; interface DevtoolsButtonProps { state: State; diff --git a/packages/ui/src/components/DevtoolsPanel/DevtoolsPanel.tsx b/packages/ui/src/components/DevtoolsPanel/DevtoolsPanel.tsx index 573b0c1..18ddc1d 100644 --- a/packages/ui/src/components/DevtoolsPanel/DevtoolsPanel.tsx +++ b/packages/ui/src/components/DevtoolsPanel/DevtoolsPanel.tsx @@ -1,21 +1,315 @@ -import { component$, Slot, useTask$, isBrowser } from '@qwik.dev/core'; +import { + $, + component$, + isBrowser, + Slot, + type Signal, + useSignal, + useTask$, + useVisibleTask$, +} from '@qwik.dev/core'; import { State } from '../../types/state'; -import { IconXMark } from '../Icons/Icons'; +import { + IconArrowsPointingIn, + IconArrowsPointingOut, + IconXMark, +} from '../Icons/Icons'; interface DevtoolsPanelProps { state: State; } -export const DevtoolsPanel = component$(({ state }: DevtoolsPanelProps) => { +type ResizeDirection = + | 'n' + | 's' + | 'e' + | 'w' + | 'ne' + | 'nw' + | 'se' + | 'sw'; + +type InteractionMode = 'drag' | ResizeDirection | null; + +const WINDOW_MARGIN = 24; +const COMPACT_MARGIN = 8; +const DEFAULT_WIDTH = 1180; +const DEFAULT_HEIGHT = 760; +const MIN_WIDTH = 320; +const MIN_HEIGHT = 280; +const MIN_WIDTH_MD = 520; +const MIN_HEIGHT_MD = 360; +const FULLSCREEN_PANEL_STYLE = { + left: '0px', + top: '0px', + width: '100vw', + height: '100vh', +}; + +function clamp(value: number, min: number, max: number) { + return Math.min(Math.max(value, min), max); +} + +function getWindowMargin() { + return window.innerWidth < 640 || window.innerHeight < 640 + ? COMPACT_MARGIN + : WINDOW_MARGIN; +} + +function getMinWidth() { + const margin = getWindowMargin(); + const preferredWidth = window.innerWidth >= 768 ? MIN_WIDTH_MD : MIN_WIDTH; + return Math.min(preferredWidth, Math.max(window.innerWidth - margin * 2, 240)); +} + +function getMinHeight() { + const margin = getWindowMargin(); + const preferredHeight = window.innerHeight >= 768 ? MIN_HEIGHT_MD : MIN_HEIGHT; + return Math.min( + preferredHeight, + Math.max(window.innerHeight - margin * 2, 220), + ); +} + +function getMaxWidth() { + const margin = getWindowMargin(); + return Math.max(window.innerWidth - margin * 2, getMinWidth()); +} + +function getMaxHeight() { + const margin = getWindowMargin(); + return Math.max(window.innerHeight - margin * 2, getMinHeight()); +} + +function normalizeBounds(bounds: State['panelBounds']) { + const margin = getWindowMargin(); + const width = clamp(bounds.width, getMinWidth(), getMaxWidth()); + const height = clamp(bounds.height, getMinHeight(), getMaxHeight()); + const maxX = Math.max(margin, window.innerWidth - width - margin); + const maxY = Math.max(margin, window.innerHeight - height - margin); + + return { + x: clamp(bounds.x, margin, maxX), + y: clamp(bounds.y, margin, maxY), + width, + height, + }; +} + +function createDefaultBounds() { + const margin = getWindowMargin(); + const width = Math.min(DEFAULT_WIDTH, getMaxWidth()); + const height = Math.min(DEFAULT_HEIGHT, getMaxHeight()); + + return normalizeBounds({ + x: window.innerWidth - width - margin, + y: window.innerHeight - height - margin, + width, + height, + }); +} + +function hasValidBounds(bounds: State['panelBounds']) { + return ( + bounds.width > 0 && + bounds.height > 0 && + bounds.x >= 0 && + bounds.y >= 0 + ); +} + +function getInteractionCursor(mode: InteractionMode) { + switch (mode) { + case 'drag': + return 'grabbing'; + case 'n': + case 's': + return 'ns-resize'; + case 'e': + case 'w': + return 'ew-resize'; + case 'ne': + case 'sw': + return 'nesw-resize'; + case 'nw': + case 'se': + return 'nwse-resize'; + default: + return ''; + } +} + +function resetPointerStyles() { + document.body.style.userSelect = ''; + document.body.style.cursor = ''; +} + +function updatePanelBounds( + panelBounds: Signal, + state: State, + bounds: State['panelBounds'], +) { + const nextBounds = { ...bounds }; + panelBounds.value = nextBounds; + state.panelBounds.x = nextBounds.x; + state.panelBounds.y = nextBounds.y; + state.panelBounds.width = nextBounds.width; + state.panelBounds.height = nextBounds.height; +} + +export const DevtoolsPanel = component$((props: DevtoolsPanelProps) => { + const interactionMode = useSignal(null); + const startMousePosition = useSignal({ x: 0, y: 0 }); + const startBounds = useSignal({ + x: 0, + y: 0, + width: 0, + height: 0, + }); + const panelBounds = useSignal({ + ...props.state.panelBounds, + }); + + const stopInteraction = $(() => { + interactionMode.value = null; + resetPointerStyles(); + }); + + const handleMouseMove = $((event: MouseEvent) => { + if (!interactionMode.value || props.state.isPanelFullscreen) return; + + const deltaX = event.clientX - startMousePosition.value.x; + const deltaY = event.clientY - startMousePosition.value.y; + + if (interactionMode.value === 'drag') { + updatePanelBounds( + panelBounds, + props.state, + normalizeBounds({ + ...startBounds.value, + x: startBounds.value.x + deltaX, + y: startBounds.value.y + deltaY, + }), + ); + return; + } + + const direction = interactionMode.value; + const minWidth = getMinWidth(); + const minHeight = getMinHeight(); + const margin = getWindowMargin(); + let nextX = startBounds.value.x; + let nextY = startBounds.value.y; + let nextWidth = startBounds.value.width; + let nextHeight = startBounds.value.height; + + if (direction.includes('e')) { + nextWidth = clamp( + startBounds.value.width + deltaX, + minWidth, + window.innerWidth - startBounds.value.x - margin, + ); + } + + if (direction.includes('s')) { + nextHeight = clamp( + startBounds.value.height + deltaY, + minHeight, + window.innerHeight - startBounds.value.y - margin, + ); + } + + if (direction.includes('w')) { + const maxX = startBounds.value.x + startBounds.value.width - minWidth; + nextX = clamp(startBounds.value.x + deltaX, margin, maxX); + nextWidth = startBounds.value.width - (nextX - startBounds.value.x); + } + + if (direction.includes('n')) { + const maxY = startBounds.value.y + startBounds.value.height - minHeight; + nextY = clamp(startBounds.value.y + deltaY, margin, maxY); + nextHeight = startBounds.value.height - (nextY - startBounds.value.y); + } + + updatePanelBounds( + panelBounds, + props.state, + normalizeBounds({ + x: nextX, + y: nextY, + width: nextWidth, + height: nextHeight, + }), + ); + }); + + const handleDragStart = $((event: MouseEvent) => { + if (event.button !== 0 || props.state.isPanelFullscreen) return; + + event.preventDefault(); + event.stopPropagation(); + + const normalizedBounds = hasValidBounds(panelBounds.value) + ? normalizeBounds(panelBounds.value) + : createDefaultBounds(); + + updatePanelBounds(panelBounds, props.state, normalizedBounds); + interactionMode.value = 'drag'; + startMousePosition.value = { x: event.clientX, y: event.clientY }; + startBounds.value = { ...normalizedBounds }; + document.body.style.userSelect = 'none'; + document.body.style.cursor = getInteractionCursor('drag'); + }); + + const handleResizeStart = $((direction: ResizeDirection, event: MouseEvent) => { + if (event.button !== 0 || props.state.isPanelFullscreen) return; + + event.preventDefault(); + event.stopPropagation(); + + const normalizedBounds = hasValidBounds(panelBounds.value) + ? normalizeBounds(panelBounds.value) + : createDefaultBounds(); + + updatePanelBounds(panelBounds, props.state, normalizedBounds); + interactionMode.value = direction; + startMousePosition.value = { x: event.clientX, y: event.clientY }; + startBounds.value = { ...normalizedBounds }; + document.body.style.userSelect = 'none'; + document.body.style.cursor = getInteractionCursor(direction); + }); + + const handleToggleFullscreen = $(() => { + if (!isBrowser) return; + + if (props.state.isPanelFullscreen) { + props.state.isPanelFullscreen = false; + updatePanelBounds( + panelBounds, + props.state, + normalizeBounds(props.state.lastPanelBounds ?? createDefaultBounds()), + ); + return; + } + + const normalizedBounds = hasValidBounds(panelBounds.value) + ? normalizeBounds(panelBounds.value) + : createDefaultBounds(); + updatePanelBounds(panelBounds, props.state, normalizedBounds); + props.state.lastPanelBounds = { ...normalizedBounds }; + props.state.isPanelFullscreen = true; + interactionMode.value = null; + resetPointerStyles(); + }); + useTask$(({ cleanup }) => { const handleKeyPress = (e: KeyboardEvent) => { - if (!state) return; if (e.key === '`' && e.metaKey) { - state.isOpen = !state.isOpen; + props.state.isOpen = !props.state.isOpen; } - // Add Escape key to close - if (e.key === 'Escape' && state.isOpen) { - state.isOpen = false; + + if (e.key === 'Escape' && props.state.isOpen) { + props.state.isOpen = false; } }; @@ -27,28 +321,213 @@ export const DevtoolsPanel = component$(({ state }: DevtoolsPanelProps) => { }); }); + useTask$(({ track, cleanup }) => { + track(() => interactionMode.value); + + if (!interactionMode.value || !isBrowser) return; + + window.addEventListener('mousemove', handleMouseMove); + window.addEventListener('mouseup', stopInteraction); + window.addEventListener('blur', stopInteraction); + + cleanup(() => { + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', stopInteraction); + window.removeEventListener('blur', stopInteraction); + resetPointerStyles(); + }); + }); + + useVisibleTask$(({ track, cleanup }) => { + track(() => props.state.isOpen); + track(() => props.state.isPanelFullscreen); + + if (!props.state.isOpen) return; + + if (!props.state.isPanelFullscreen) { + updatePanelBounds( + panelBounds, + props.state, + hasValidBounds(props.state.panelBounds) + ? normalizeBounds(props.state.panelBounds) + : createDefaultBounds(), + ); + } + + const handleWindowResize = () => { + if (props.state.isPanelFullscreen) return; + updatePanelBounds( + panelBounds, + props.state, + hasValidBounds(panelBounds.value) + ? normalizeBounds(panelBounds.value) + : createDefaultBounds(), + ); + }; + + window.addEventListener('resize', handleWindowResize); + + cleanup(() => { + window.removeEventListener('resize', handleWindowResize); + resetPointerStyles(); + }); + }); + + const hasBounds = hasValidBounds(panelBounds.value); + const panelStyle = props.state.isPanelFullscreen + ? FULLSCREEN_PANEL_STYLE + : hasBounds + ? { + left: `${panelBounds.value.x}px`, + top: `${panelBounds.value.y}px`, + width: `${panelBounds.value.width}px`, + height: `${panelBounds.value.height}px`, + } + : { + right: `${WINDOW_MARGIN}px`, + bottom: `${WINDOW_MARGIN}px`, + width: `min(calc(100vw - ${WINDOW_MARGIN * 2}px), ${DEFAULT_WIDTH}px)`, + height: `min(calc(100vh - ${WINDOW_MARGIN * 2}px), ${DEFAULT_HEIGHT}px)`, + }; + return ( <>
{ - state.isOpen = false; + props.state.isOpen = false; }} /> +
{ + event.stopPropagation(); + }} > - - + {!props.state.isPanelFullscreen && ( + <> + + +
+
+ +
+ +
+ ); diff --git a/packages/ui/src/components/Icons/Icons.tsx b/packages/ui/src/components/Icons/Icons.tsx index a2a4cdd..34f7c4c 100644 --- a/packages/ui/src/components/Icons/Icons.tsx +++ b/packages/ui/src/components/Icons/Icons.tsx @@ -215,6 +215,29 @@ export const IconClockOutline = component$((props: IconProps) => { ); }); +export const IconChartBarOutline = component$((props: IconProps) => { + return ( + + {props.title ? {props.title} : null} + + + + + ); +}); + export const IconXMark = component$((props: IconProps) => { return ( @@ -228,3 +251,38 @@ export const IconXMark = component$((props: IconProps) => { ); }); +export const IconArrowsPointingOut = component$((props: IconProps) => { + return ( + + {props.title ? {props.title} : null} + + + + ); +}); + +export const IconArrowsPointingIn = component$((props: IconProps) => { + return ( + + {props.title ? {props.title} : null} + + + + ); +}); diff --git a/packages/ui/src/components/ThemeToggle/QwikThemeToggle.tsx b/packages/ui/src/components/ThemeToggle/QwikThemeToggle.tsx index bf3b528..ef21a4b 100644 --- a/packages/ui/src/components/ThemeToggle/QwikThemeToggle.tsx +++ b/packages/ui/src/components/ThemeToggle/QwikThemeToggle.tsx @@ -1,7 +1,7 @@ import { component$, event$, isServer } from '@qwik.dev/core'; import { themeStorageKey } from '../router-head/theme-script'; import { IconMoonOutline, IconSparkles, IconSunOutline } from '../Icons/Icons'; -import './themToggle.css' +import './themeToggle.css'; type ThemeName = 'dark' | 'light' | 'auto'; export const getTheme = (): ThemeName => { diff --git a/packages/ui/src/components/ThemeToggle/themToggle.css b/packages/ui/src/components/ThemeToggle/themeToggle.css similarity index 100% rename from packages/ui/src/components/ThemeToggle/themToggle.css rename to packages/ui/src/components/ThemeToggle/themeToggle.css diff --git a/packages/ui/src/devtools.tsx b/packages/ui/src/devtools.tsx index b671a25..c9008e6 100644 --- a/packages/ui/src/devtools.tsx +++ b/packages/ui/src/devtools.tsx @@ -1,260 +1 @@ -import { - component$, - useStore, - noSerialize, - useVisibleTask$, -} from '@qwik.dev/core'; -import { tryCreateHotContext } from 'vite-hot-client'; -import { - IconBoltOutline, - IconClockOutline, - IconCodeBracketSolid, - IconCubeOutline, - IconDiagram3, - IconFolderTree, - IconMegaphoneMini, - IconPhotoOutline, -} from './components/Icons/Icons'; -import { - createClientRpc, - getViteClientRpc, - setViteClientContext, - type RoutesInfo, - RouteType, -} from '@devtools/kit'; -import './global.css'; -import { Tab } from './components/Tab/Tab'; -import { TabContent } from './components/TabContent/TabContent'; -import { Overview } from './features/Overview/Overview'; -import { State } from './types/state'; -import { Assets } from './features/Assets/Assets'; -import { Routes } from './features/Routes/Routes'; -import { TabTitle } from './components/TabTitle/TabTitle'; -import { RenderTree } from './features/RenderTree/RenderTree'; -import { DevtoolsButton } from './components/DevtoolsButton/DevtoolsButton'; -import { DevtoolsContainer } from './components/DevtoolsContainer/DevtoolsContainer'; -import { DevtoolsPanel } from './components/DevtoolsPanel/DevtoolsPanel'; -import { Packages } from './features/Packages/Packages'; -import { Inspect } from './features/inspect/Inspect'; -import { QwikThemeToggle } from './components/ThemeToggle/QwikThemeToggle'; -import { ThemeScript as QwikThemeScript } from './components/ThemeToggle/theme-script'; -import { CodeBreack } from './features/CodeBreack/CodeBreack'; -import { Performance } from './features/Performance/Performance'; -import { debug } from 'debug'; - -const log = debug('qwik:devtools:devtools'); -function getClientRpcFunctions() { - return { - healthCheck: () => true, - }; -} - -function toDevtoolsRoutes(routes: any): RoutesInfo[] { - const children: RoutesInfo[] = routes?.children || []; - const directories: RoutesInfo[] = children.filter( - (child) => child.type === RouteType.DIRECTORY, - ); - - return [ - { - relativePath: '', - name: 'index', - type: RouteType.DIRECTORY, - path: '', - isSymbolicLink: false, - children: undefined, - }, - ...directories, - ]; -} - -export const QwikDevtools = component$(() => { - const state = useStore({ - isOpen: false, - activeTab: 'overview', - npmPackages: [], - assets: [], - components: [], - routes: undefined, - allDependencies: [], - isLoadingDependencies: false, - }); - - useVisibleTask$(async () => { - const hot = await tryCreateHotContext(undefined, ['/']); - if (!hot) { - throw new Error('Vite Hot Context not connected'); - } - - setViteClientContext(hot); - createClientRpc(getClientRpcFunctions()); - - const rpc = getViteClientRpc(); - - // Group 1: load most data in parallel, each failure is isolated. - const group1 = Promise.allSettled([ - rpc.getAssetsFromPublicDir(), - rpc.getComponents(), - rpc.getRoutes(), - rpc.getQwikPackages(), - ]); - - // Group 2: load dependencies separately to keep a dedicated loading state. - state.isLoadingDependencies = true; - const depsPromise = rpc - .getAllDependencies() - .then((allDeps) => { - state.allDependencies = allDeps; - }) - .catch((error) => { - log('Failed to load all dependencies:', error); - }) - .finally(() => { - state.isLoadingDependencies = false; - }); - - const [assetsRes, componentsRes, routesRes, packagesRes] = await group1; - - if (assetsRes.status === 'fulfilled') { - state.assets = assetsRes.value; - } else { - log('Failed to load assets:', assetsRes.reason); - } - - if (componentsRes.status === 'fulfilled') { - state.components = componentsRes.value; - } else { - log('Failed to load components:', componentsRes.reason); - } - - if (routesRes.status === 'fulfilled') { - state.routes = noSerialize(toDevtoolsRoutes(routesRes.value)); - } else { - log('Failed to load routes:', routesRes.reason); - } - - if (packagesRes.status === 'fulfilled') { - state.npmPackages = packagesRes.value; - } else { - log('Failed to load Qwik packages:', packagesRes.reason); - } - - await depsPromise; - }); - - return ( - <> - - - - - {state.isOpen && ( - -
- - - - - - - - - - - - - - - - - - - - - - - - -
- -
-
- -
- {state.activeTab === 'overview' && ( - -
- Qwik Logo -

Qwik DevTools

-
- -
- )} - {state.activeTab === 'assets' && ( - - -
- - Total Size:{' '} - {( - state.assets?.reduce( - (acc, asset) => acc + asset.size, - 0, - ) / 1024 - ).toFixed(2)}{' '} - KB - - Count: {state.assets?.length || 0} -
- - -
- )} - {state.activeTab === 'packages' && ( - - - - - )} - {state.activeTab === 'routes' && ( - - - - - )} - {state.activeTab === 'inspect' && ( - - - - )} - {state.activeTab === 'renderTree' && ( - - - - - )} - {state.activeTab === 'codeBreack' && ( - - - - - )} - {state.activeTab === 'performance' && ( - - - - - )} -
- - )} - - - ); -}); +export { QwikDevtools } from './devtools/QwikDevtools'; diff --git a/packages/ui/src/devtools/DevtoolsContent.tsx b/packages/ui/src/devtools/DevtoolsContent.tsx new file mode 100644 index 0000000..aba77ee --- /dev/null +++ b/packages/ui/src/devtools/DevtoolsContent.tsx @@ -0,0 +1,112 @@ +import { component$ } from '@qwik.dev/core'; +import type { AssetInfo } from '@devtools/kit'; +import { TabContent } from '../components/TabContent/TabContent'; +import { TabTitle } from '../components/TabTitle/TabTitle'; +import { Assets } from '../features/Assets/Assets'; +import { BuildAnalysis } from '../features/BuildAnalysis/BuildAnalysis'; +import { CodeBreak } from '../features/CodeBreak/CodeBreak'; +import { Inspect } from '../features/Inspect/Inspect'; +import { Overview } from '../features/Overview/Overview'; +import { Packages } from '../features/Packages/Packages'; +import { Performance } from '../features/Performance/Performance'; +import { RenderTree } from '../features/RenderTree/RenderTree'; +import { Routes } from '../features/Routes/Routes'; +import type { DevtoolsState } from './state'; + +interface DevtoolsContentProps { + state: DevtoolsState; +} + +function formatAssetSummary(assets: AssetInfo[]) { + const totalSizeInKb = + assets.reduce((totalSize, asset) => totalSize + asset.size, 0) / 1024; + + return { + count: assets.length, + totalSizeInKb: totalSizeInKb.toFixed(2), + }; +} + +export const DevtoolsContent = component$(({ state }) => { + const assetSummary = formatAssetSummary(state.assets); + + switch (state.activeTab) { + case 'overview': + return ( + +
+ Qwik Logo +

Qwik DevTools

+
+ +
+ ); + case 'assets': + return ( + + +
+ Total Size: {assetSummary.totalSizeInKb} KB + Count: {assetSummary.count} +
+ +
+ ); + case 'packages': + return ( + + + + + ); + case 'routes': + return ( + + + + + ); + case 'inspect': + return ( + + + + ); + case 'renderTree': + return ( + + + + + ); + case 'codeBreak': + return ( + + + + + ); + case 'performance': + return ( + + + + + ); + case 'buildAnalysis': + return ( + + + + + ); + default: + return null; + } +}); diff --git a/packages/ui/src/devtools/DevtoolsSidebar.tsx b/packages/ui/src/devtools/DevtoolsSidebar.tsx new file mode 100644 index 0000000..399946c --- /dev/null +++ b/packages/ui/src/devtools/DevtoolsSidebar.tsx @@ -0,0 +1,25 @@ +import { component$ } from '@qwik.dev/core'; +import { Tab } from '../components/Tab/Tab'; +import { QwikThemeToggle } from '../components/ThemeToggle/QwikThemeToggle'; +import type { DevtoolsState } from './state'; +import { devtoolsTabs } from './tabs'; + +interface DevtoolsSidebarProps { + state: DevtoolsState; +} + +export const DevtoolsSidebar = component$(({ state }) => { + return ( +
+ {devtoolsTabs.map((tab) => ( + + {tab.renderIcon()} + + ))} + +
+ +
+
+ ); +}); diff --git a/packages/ui/src/devtools/QwikDevtools.tsx b/packages/ui/src/devtools/QwikDevtools.tsx new file mode 100644 index 0000000..db75f93 --- /dev/null +++ b/packages/ui/src/devtools/QwikDevtools.tsx @@ -0,0 +1,36 @@ +import { component$, useStore, useVisibleTask$ } from '@qwik.dev/core'; +import { DevtoolsButton } from '../components/DevtoolsButton/DevtoolsButton'; +import { DevtoolsContainer } from '../components/DevtoolsContainer/DevtoolsContainer'; +import { DevtoolsPanel } from '../components/DevtoolsPanel/DevtoolsPanel'; +import { ThemeScript as QwikThemeScript } from '../components/ThemeToggle/theme-script'; +import '../global.css'; +import { DevtoolsContent } from './DevtoolsContent'; +import { loadDevtoolsData } from './rpc'; +import { createDevtoolsState, type DevtoolsState } from './state'; +import { DevtoolsSidebar } from './DevtoolsSidebar'; + +export const QwikDevtools = component$(() => { + const state = useStore(createDevtoolsState()); + + useVisibleTask$(async () => { + await loadDevtoolsData(state); + }); + + return ( + <> + + + + + {state.isOpen && ( + + +
+ +
+
+ )} +
+ + ); +}); diff --git a/packages/ui/src/devtools/rpc.ts b/packages/ui/src/devtools/rpc.ts new file mode 100644 index 0000000..a5bdd75 --- /dev/null +++ b/packages/ui/src/devtools/rpc.ts @@ -0,0 +1,103 @@ +import { + createClientRpc, + getViteClientRpc, + setViteClientContext, + type RoutesInfo, + RouteType, +} from '@devtools/kit'; +import { noSerialize } from '@qwik.dev/core'; +import debug from 'debug'; +import { tryCreateHotContext } from 'vite-hot-client'; +import type { DevtoolsState } from './state'; + +const log = debug('qwik:devtools:devtools'); + +function getClientRpcFunctions() { + return { + healthCheck: () => true, + }; +} + +function toDevtoolsRoutes(routes: any): RoutesInfo[] { + const children: RoutesInfo[] = routes?.children || []; + const directories: RoutesInfo[] = children.filter( + (child) => child.type === RouteType.DIRECTORY, + ); + + return [ + { + relativePath: '', + name: 'index', + type: RouteType.DIRECTORY, + path: '', + isSymbolicLink: false, + children: undefined, + }, + ...directories, + ]; +} + +async function connectRpc() { + const hot = await tryCreateHotContext(undefined, ['/']); + if (!hot) { + throw new Error('Vite Hot Context not connected'); + } + + setViteClientContext(hot); + createClientRpc(getClientRpcFunctions()); + + return getViteClientRpc(); +} + +export async function loadDevtoolsData(state: DevtoolsState) { + const rpc = await connectRpc(); + + const primaryDataPromise = Promise.allSettled([ + rpc.getAssetsFromPublicDir(), + rpc.getComponents(), + rpc.getRoutes(), + rpc.getQwikPackages(), + ]); + + state.isLoadingDependencies = true; + const dependenciesPromise = rpc + .getAllDependencies() + .then((allDependencies) => { + state.allDependencies = allDependencies; + }) + .catch((error) => { + log('Failed to load all dependencies:', error); + }) + .finally(() => { + state.isLoadingDependencies = false; + }); + + const [assetsResult, componentsResult, routesResult, packagesResult] = + await primaryDataPromise; + + if (assetsResult.status === 'fulfilled') { + state.assets = assetsResult.value; + } else { + log('Failed to load assets:', assetsResult.reason); + } + + if (componentsResult.status === 'fulfilled') { + state.components = componentsResult.value; + } else { + log('Failed to load components:', componentsResult.reason); + } + + if (routesResult.status === 'fulfilled') { + state.routes = noSerialize(toDevtoolsRoutes(routesResult.value)); + } else { + log('Failed to load routes:', routesResult.reason); + } + + if (packagesResult.status === 'fulfilled') { + state.npmPackages = packagesResult.value; + } else { + log('Failed to load Qwik packages:', packagesResult.reason); + } + + await dependenciesPromise; +} diff --git a/packages/ui/src/devtools/state.ts b/packages/ui/src/devtools/state.ts new file mode 100644 index 0000000..cf83e76 --- /dev/null +++ b/packages/ui/src/devtools/state.ts @@ -0,0 +1,55 @@ +import type { AssetInfo, Component, NpmInfo, RoutesInfo } from '@devtools/kit'; +import type { NoSerialize } from '@qwik.dev/core'; + +export type DevtoolsTabId = + | 'overview' + | 'packages' + | 'renderTree' + | 'routes' + | 'assets' + | 'inspect' + | 'codeBreak' + | 'performance' + | 'buildAnalysis'; + +export interface DevtoolsPanelBounds { + x: number; + y: number; + width: number; + height: number; +} + +export interface DevtoolsState { + isOpen: boolean; + isPanelFullscreen: boolean; + activeTab: DevtoolsTabId; + npmPackages: NpmInfo; + assets: AssetInfo[]; + components: Component[]; + routes: NoSerialize | undefined; + allDependencies: unknown[]; + isLoadingDependencies: boolean; + panelBounds: DevtoolsPanelBounds; + lastPanelBounds: DevtoolsPanelBounds | null; +} + +export function createDevtoolsState(): DevtoolsState { + return { + isOpen: false, + isPanelFullscreen: false, + activeTab: 'overview', + npmPackages: [], + assets: [], + components: [], + routes: undefined, + allDependencies: [], + isLoadingDependencies: false, + panelBounds: { + x: 0, + y: 0, + width: 0, + height: 0, + }, + lastPanelBounds: null, + }; +} diff --git a/packages/ui/src/devtools/tabs.tsx b/packages/ui/src/devtools/tabs.tsx new file mode 100644 index 0000000..7ff162b --- /dev/null +++ b/packages/ui/src/devtools/tabs.tsx @@ -0,0 +1,67 @@ +import type { JSXOutput } from '@qwik.dev/core'; +import { + IconBoltOutline, + IconChartBarOutline, + IconClockOutline, + IconCodeBracketSolid, + IconCubeOutline, + IconDiagram3, + IconFolderTree, + IconMegaphoneMini, + IconPhotoOutline, +} from '../components/Icons/Icons'; +import type { DevtoolsTabId } from './state'; + +export interface DevtoolsTabConfig { + id: DevtoolsTabId; + title: string; + renderIcon: () => JSXOutput; +} + +export const devtoolsTabs: DevtoolsTabConfig[] = [ + { + id: 'overview', + title: 'Overview', + renderIcon: () => , + }, + { + id: 'packages', + title: 'Packages', + renderIcon: () => , + }, + { + id: 'renderTree', + title: 'Render Tree', + renderIcon: () => , + }, + { + id: 'routes', + title: 'Routes', + renderIcon: () => , + }, + { + id: 'assets', + title: 'Assets', + renderIcon: () => , + }, + { + id: 'inspect', + title: 'Inspect', + renderIcon: () => , + }, + { + id: 'codeBreak', + title: 'Code Break', + renderIcon: () => , + }, + { + id: 'performance', + title: 'Performance', + renderIcon: () => , + }, + { + id: 'buildAnalysis', + title: 'Build Analysis', + renderIcon: () => , + }, +]; diff --git a/packages/ui/src/features/BuildAnalysis/BuildAnalysis.tsx b/packages/ui/src/features/BuildAnalysis/BuildAnalysis.tsx new file mode 100644 index 0000000..a8b37e2 --- /dev/null +++ b/packages/ui/src/features/BuildAnalysis/BuildAnalysis.tsx @@ -0,0 +1,159 @@ +import { getViteClientRpc } from '@devtools/kit'; +import { + $, + component$, + useSignal, + useVisibleTask$, +} from '@qwik.dev/core'; + +const BUILD_ANALYSIS_VIEW_PATH = '/__qwik_devtools/build-analysis/report'; + +export const BuildAnalysis = component$(() => { + const iframeVersion = useSignal(0); + const isChecking = useSignal(true); + const isBuilding = useSignal(false); + const hasReport = useSignal(false); + const reportPath = useSignal(''); + const buildCommand = useSignal(null); + const errorMessage = useSignal(''); + + const loadStatus = $(async () => { + isChecking.value = true; + errorMessage.value = ''; + + try { + const rpc = getViteClientRpc(); + const status = await rpc.getBuildAnalysisStatus(); + + hasReport.value = status.exists; + reportPath.value = status.reportPath; + buildCommand.value = status.buildCommand; + } catch (error) { + errorMessage.value = + error instanceof Error ? error.message : 'Failed to load build analysis status.'; + } finally { + isChecking.value = false; + } + }); + + const refreshFrame = $(() => { + iframeVersion.value += 1; + }); + + const buildReport = $(async () => { + if (!buildCommand.value) { + errorMessage.value = + 'No build script found. Expected "build.client" or "build" in package.json.'; + return; + } + + const confirmed = + globalThis.confirm?.( + `Build analysis needs a fresh build to generate the report.\n\nRun: ${buildCommand.value} ?`, + ) ?? true; + + if (!confirmed) { + return; + } + + isBuilding.value = true; + errorMessage.value = ''; + + try { + const rpc = getViteClientRpc(); + const result = await rpc.buildBuildAnalysisReport(); + + if (!result.success) { + errorMessage.value = result.error || 'Build failed.'; + return; + } + + await loadStatus(); + iframeVersion.value += 1; + } catch (error) { + errorMessage.value = + error instanceof Error ? error.message : 'Build failed.'; + } finally { + isBuilding.value = false; + } + }); + + useVisibleTask$(async () => { + await loadStatus(); + }); + + return ( +
+
+
+
Build Analysis
+
+ This tab embeds the HTML report generated by the visualizer plugin + after a build. +
+ {buildCommand.value ? ( + + {buildCommand.value} + + ) : null} + {errorMessage.value ? ( +
+ {errorMessage.value} +
+ ) : null} +
+
+ + {isChecking.value ? ( +
+ Checking build analysis report... +
+ ) : hasReport.value ? ( + <> +
+ +
+ +
+