diff --git a/.changeset/mighty-windows-begin.md b/.changeset/mighty-windows-begin.md new file mode 100644 index 0000000..523bd12 --- /dev/null +++ b/.changeset/mighty-windows-begin.md @@ -0,0 +1,5 @@ +--- +'@qwik.dev/devtools': patch +--- + +feat: add performance tab diff --git a/package.json b/package.json index ac498e6..58224c3 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,6 @@ "license": "MIT", "description": "Qwik devtools monorepo", "scripts": { - "dev": "pnpm --filter plugin build && MODE=dev pnpm --parallel dev", "playground": "MODE=dev DEBUG=qwik:devtools:* pnpm --filter playground dev", "build": "tsx scripts/build-devtools.ts", "change": "changeset", diff --git a/packages/devtools/package.json b/packages/devtools/package.json index 30a05d9..673a48b 100644 --- a/packages/devtools/package.json +++ b/packages/devtools/package.json @@ -24,8 +24,8 @@ "README.md" ], "peerDependencies": { - "@qwik.dev/core": "2.0.0-beta.11", - "@qwik.dev/router": "2.0.0-beta.11", + "@qwik.dev/core": "2.0.0-beta.15", + "@qwik.dev/router": "2.0.0-beta.15", "vite": "7.1.3", "@tailwindcss/postcss": "^4.1.14", "@tailwindcss/vite": "^4.1.14", diff --git a/packages/kit/package.json b/packages/kit/package.json index 4c9c6b7..57fece7 100644 --- a/packages/kit/package.json +++ b/packages/kit/package.json @@ -31,7 +31,7 @@ "@typescript-eslint/parser": "7.16.1", "cpy-cli": "^5.0.0", "eslint": "8.57.0", - "eslint-plugin-qwik": "2.0.0-beta.11", + "eslint-plugin-qwik": "2.0.0-beta.15", "np": "^8.0.4", "prettier": "3.3.3", "typescript": "5.4.5", diff --git a/packages/kit/src/context.ts b/packages/kit/src/context.ts index 0b6f6ee..b259079 100644 --- a/packages/kit/src/context.ts +++ b/packages/kit/src/context.ts @@ -9,34 +9,37 @@ import { } from './globals'; import { ServerRpc, ClientRpc } from './types'; +type GlobalTarget = Record; +const t = target as unknown as GlobalTarget; + export function getViteClientContext(): ViteClientContext { - return target[CLIENT_CTX]; + return t[CLIENT_CTX] as ViteClientContext; } export function setViteClientContext(ctx: ViteClientContext) { - target[CLIENT_CTX] = ctx; + t[CLIENT_CTX] = ctx; } export function getViteServerContext() { - return target[SERVER_CTX]; + return t[SERVER_CTX] as ViteServerContext; } export function setViteServerContext(ctx: ViteServerContext) { - target[SERVER_CTX] = ctx; + t[SERVER_CTX] = ctx; } export function getViteServerRpc() { - return target[SERVER_RPC]; + return t[SERVER_RPC] as ServerRpc; } export function setViteServerRpc(rpc: ServerRpc) { - target[SERVER_RPC] = rpc; + t[SERVER_RPC] = rpc; } export function getViteClientRpc() { - return target[CLIENT_RPC]; + return t[CLIENT_RPC] as ClientRpc; } export function setViteClientRpc(rpc: ClientRpc) { - target[CLIENT_RPC] = rpc; + t[CLIENT_RPC] = rpc; } diff --git a/packages/kit/src/globals.ts b/packages/kit/src/globals.ts index f6978d6..804b897 100644 --- a/packages/kit/src/globals.ts +++ b/packages/kit/src/globals.ts @@ -1,5 +1,5 @@ import { ViteDevServer } from 'vite'; -import { ClientRpc, ServerRpc } from './types'; +import { ClientRpc, ParsedStructure, ServerRpc } from './types'; interface EventEmitter { on: (name: string, handler: (data: any) => void) => void; @@ -14,9 +14,71 @@ export const SERVER_CTX = '__qwik_server_ctx__'; export const SERVER_RPC = '__qwik_server_rpc__'; export const CLIENT_RPC = '__qwik_client_rpc__'; +// Devtools global state types +export type QwikPerfPhaseRemembered = 'ssr' | 'csr'; + +export interface QwikPerfErrorRemembered { + name: string; + message: string; +} + +export interface QwikPerfEntryRemembered { + id: number; + component: string; + phase: QwikPerfPhaseRemembered; + duration: number; + start: number; + end: number; + error?: QwikPerfErrorRemembered; + /** + * Present for wrapped `_component_` render-function modules; helps de-dupe. + */ + viteId?: string; + /** + * Present for wrapped `_component_` render-function modules. + */ + renderCount?: number; +} + +export interface QwikPerfStoreRemembered { + ssr: QwikPerfEntryRemembered[]; + csr: QwikPerfEntryRemembered[]; + +} + +export interface DevtoolsRenderStats { + /** + * In-memory performance store written by devtools instrumentation. + * (Populated at runtime; optional in types.) + */ + perf?: QwikPerfStoreRemembered; +} +export interface ComponentDevtoolsState { + hooks: ParsedStructure[]; + stats: DevtoolsRenderStats; +} + + +declare global { + interface Window { + QWIK_DEVTOOLS_GLOBAL_STATE?: Record; + /** + * Performance store (CSR + injected SSR snapshot). + * Written by `@devtools/plugin` instrumentation. + */ + __QWIK_PERF__?: QwikPerfStoreRemembered; + } +} + declare global { - var __qwik_client_ctx__: ViteClientContext; - var __qwik_server_ctx__: ViteServerContext; - var __qwik_server_rpc__: ServerRpc; - var __qwik_client_rpc__: ClientRpc; + // SSR collector lives on `process` (preferred) or `globalThis` via dynamic properties. + // We type the `process` case here to avoid `any` in plugin code. + namespace NodeJS { + interface Process { + __QWIK_SSR_PERF__?: QwikPerfEntryRemembered[]; + __QWIK_SSR_PERF_SET__?: Set; + __QWIK_SSR_PERF_ID__?: number; + __QWIK_SSR_PERF_INDEX__?: Record; + } + } } diff --git a/packages/kit/src/index.ts b/packages/kit/src/index.ts index 8446ef6..86505f7 100644 --- a/packages/kit/src/index.ts +++ b/packages/kit/src/index.ts @@ -2,4 +2,5 @@ export * from './client'; export * from './server'; export * from './context'; export * from './types'; -export * from './constants'; \ No newline at end of file +export * from './constants'; +export * from './globals'; \ No newline at end of file diff --git a/packages/playgrounds/package.json b/packages/playgrounds/package.json index fa4d337..dcb6879 100644 --- a/packages/playgrounds/package.json +++ b/packages/playgrounds/package.json @@ -17,7 +17,7 @@ "build.preview": "vite build --ssr src/entry.preview.tsx", "build.types": "tsc --incremental --noEmit", "deploy": "echo 'Run \"npm run qwik add\" to install a server adapter'", - "dev": "MODE=dev vite --mode ssr", + "dev": "MODE=dev vite --mode ssr --port 5174", "dev.debug": "node --inspect-brk ./node_modules/vite/bin/vite.js --mode ssr --force", "fmt": "prettier --write .", "fmt.check": "prettier --check .", @@ -29,14 +29,14 @@ "devDependencies": { "@devtools/plugin": "workspace:*", "@devtools/ui": "workspace:*", - "@qwik.dev/core": "2.0.0-beta.11", - "@qwik.dev/router": "2.0.0-beta.11", + "@qwik.dev/core": "2.0.0-beta.15", + "@qwik.dev/router": "2.0.0-beta.15", "@types/eslint": "8.56.10", "@types/node": "20.14.11", "@typescript-eslint/eslint-plugin": "7.16.1", "@typescript-eslint/parser": "7.16.1", "eslint": "8.57.0", - "eslint-plugin-qwik": "2.0.0-beta.11", + "eslint-plugin-qwik": "2.0.0-beta.15", "prettier": "3.3.3", "typescript": "5.4.5", "vite": "7.1.3", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 3de2118..21330bd 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -24,7 +24,7 @@ "devDependencies": { "@babel/types": "^7.26.7", "@devtools/kit": "workspace:*", - "@qwik.dev/core": "2.0.0-beta.11", + "@qwik.dev/core": "2.0.0-beta.15", "@types/eslint": "8.56.10", "@types/node": "20.14.11", "@typescript-eslint/eslint-plugin": "7.16.1", diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index b29fd1b..fe29297 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -1,114 +1 @@ -import { ResolvedConfig, type Plugin } from 'vite'; -import { getServerFunctions } from './rpc'; -import { createServerRpc, setViteServerContext, VIRTUAL_QWIK_DEVTOOLS_KEY, INNER_USE_HOOK } from '@devtools/kit'; -import VueInspector from 'vite-plugin-inspect' -import useCollectHooksSource from './utils/useCollectHooks' -import { parseQwikCode } from './parse/parse'; -import { startPreloading } from './npm/index'; -import updateConf from './utils/updateConf'; -import {debug} from 'debug' - -const log = debug('qwik:devtools:plugin'); -export function qwikDevtools(): Plugin[] { - let _config: ResolvedConfig; - const qwikData = new Map(); - let preloadStarted = false; - const qwikDevtoolsPlugin: Plugin = { - name: 'vite-plugin-qwik-devtools', - apply: 'serve', - resolveId(id) { - // Normalize to a stable, absolute-like id so Qwik can generate runtime chunks - const clean = id.split('?')[0].split('#')[0]; - if ( - clean === VIRTUAL_QWIK_DEVTOOLS_KEY || - clean === `/${VIRTUAL_QWIK_DEVTOOLS_KEY}` || - clean === `\u0000${VIRTUAL_QWIK_DEVTOOLS_KEY}` || - clean === `/@id/${VIRTUAL_QWIK_DEVTOOLS_KEY}` - ) { - return `/${VIRTUAL_QWIK_DEVTOOLS_KEY}`; - } - }, - load(id) { - if ( - id === `/${VIRTUAL_QWIK_DEVTOOLS_KEY}` || - id === VIRTUAL_QWIK_DEVTOOLS_KEY || - id === `\u0000${VIRTUAL_QWIK_DEVTOOLS_KEY}` || - id === `/@id/${VIRTUAL_QWIK_DEVTOOLS_KEY}` - ) { - return { - code: useCollectHooksSource, - map: { mappings: '' }, - }; - } - }, - configResolved(viteConfig) { - _config = viteConfig; - updateConf(_config); - // Start preloading as early as possible, right after config is resolved - if (!preloadStarted) { - preloadStarted = true; - startPreloading({ config: _config }).catch((err) => { - log('[Qwik DevTools] Failed to start preloading:', err); - }); - } - }, - transform: { - order: 'pre', - handler(code, id) { - const mode = process.env.MODE; - // Ensure virtual import is present at the very top once when a component$ is present - if (id.endsWith('.tsx') && code.includes('component$')) { - if (!code.includes(VIRTUAL_QWIK_DEVTOOLS_KEY)) { - - const importLine = `import { ${INNER_USE_HOOK} } from '${VIRTUAL_QWIK_DEVTOOLS_KEY}';\n` - code = importLine + code - }else { - log('importing virtual qwik devtools', VIRTUAL_QWIK_DEVTOOLS_KEY, code); - } - code = parseQwikCode(code, {path: id}) - } - // Only transform the root component file - if (id.endsWith('root.tsx')) { - const importPath = - mode === 'dev' ? '@devtools/ui' : '@qwik.dev/devtools/ui'; - // Check if QwikDevtools import already exists - if (!code.includes(importPath)) { - // Add import for QwikDevtools using the correct package name - code = `import { QwikDevtools } from '${importPath}';\n${code}`; - } - - // Find the closing body tag and append QwikDevtools at the end of body - const match = code.match(/]*>([\s\S]*?)<\/body>/); - if (match) { - const bodyContent = match[1]; - const newBodyContent = `${bodyContent}\n `; - code = code.replace(bodyContent, newBodyContent); - } - - return { - code, - map: null, - }; - } - - return { - code, - map: { mappings: '' }, - }; - }, - }, - configureServer(server) { - setViteServerContext(server as any); - - const rpcFunctions = getServerFunctions({ server, config: _config, qwikData }); - - createServerRpc(rpcFunctions); - - // Preloading should have already started in configResolved - }, - } - return [ - qwikDevtoolsPlugin, - VueInspector(), // Add the VueInspector plugin instance - ]; -} +export { qwikDevtools } from './plugin'; diff --git a/packages/plugin/src/parse/helpers.ts b/packages/plugin/src/parse/helpers.ts index 1554919..1126829 100644 --- a/packages/plugin/src/parse/helpers.ts +++ b/packages/plugin/src/parse/helpers.ts @@ -1,95 +1,170 @@ -import { USE_HOOK_LIST, HookType } from '@devtools/kit' +import { USE_HOOK_LIST, HookType } from '@devtools/kit'; -export const ALL_HOOK_NAMES = new Set([ - ...USE_HOOK_LIST -]) +// ============================================================================ +// Constants +// ============================================================================ -export function isAstNodeLike(value: unknown): value is { type: string } { - return Boolean(value) && typeof value === 'object' && 'type' in (value as Record) -} +export const ALL_HOOK_NAMES = new Set([...USE_HOOK_LIST]); -export function normalizeHookName(raw: string): string { - return raw.endsWith('$') ? raw.slice(0, -1) : raw +// ============================================================================ +// AST Node Utilities +// ============================================================================ + +/** + * Type guard to check if a value is an AST node with a type property + */ +export function isAstNodeLike(value: unknown): value is { type: string } { + return Boolean(value) && typeof value === 'object' && 'type' in (value as Record); } +/** + * Gets the start position of an AST node from its range or start property + */ export function getNodeStart(node: unknown): number { if (node && typeof node === 'object') { - const maybeRange = (node as any).range - if (Array.isArray(maybeRange)) return maybeRange[0] ?? 0 - const maybeStart = (node as any).start - if (typeof maybeStart === 'number') return maybeStart + const maybeRange = (node as any).range; + if (Array.isArray(maybeRange)) return maybeRange[0] ?? 0; + const maybeStart = (node as any).start; + if (typeof maybeStart === 'number') return maybeStart; } - return 0 + return 0; } +/** + * Extracts the name from an Identifier node + */ export function getVariableIdentifierName(id: unknown): string | null { - if (!isAstNodeLike(id)) return null - return id.type === 'Identifier' ? (id as any).name as string : null + if (!isAstNodeLike(id)) return null; + return id.type === 'Identifier' ? (id as any).name as string : null; } -export function isKnownHook(name: string): name is HookType { - return ALL_HOOK_NAMES.has(name) +// ============================================================================ +// Hook Name Utilities +// ============================================================================ + +/** + * Normalizes a hook name by removing the trailing $ suffix + * @example normalizeHookName('useSignal$') => 'useSignal' + */ +export function normalizeHookName(raw: string): string { + return raw.endsWith('$') ? raw.slice(0, -1) : raw; } +/** + * Normalizes a hook name by removing the trailing 'Qrl' suffix + * @example normalizeQrlHookName('useTaskQrl') => 'useTask' + */ export function normalizeQrlHookName(hookName: string): string { - return hookName.endsWith('Qrl') ? hookName.slice(0, -3) : hookName + return hookName.endsWith('Qrl') ? hookName.slice(0, -3) : hookName; +} + +/** + * Checks if a hook name is in the known hooks list + */ +export function isKnownHook(name: string): name is HookType { + return ALL_HOOK_NAMES.has(name); +} + +/** + * Checks if a hook name follows the custom hook naming convention (use[A-Z_]) + * and is not a built-in Qwik hook + */ +export function isCustomHook(hookName: string): boolean { + const isBuiltIn = USE_HOOK_LIST.some((item) => item.startsWith(hookName)); + return !isBuiltIn && /^use[A-Z_]/.test(hookName); } +// ============================================================================ +// Code Position Utilities +// ============================================================================ + +/** + * Finds the start position of the line containing the given index + */ export function findLineStart(code: string, index: number): number { - let lineStart = index for (let i = index - 1; i >= 0; i--) { - const ch = code[i] - if (ch === '\n' || ch === '\r') { lineStart = i + 1; break } - if (i === 0) lineStart = 0 + const ch = code[i]; + if (ch === '\n' || ch === '\r') { + return i + 1; + } } - return lineStart + return 0; } +/** + * Reads the indentation (spaces/tabs) starting from a given position + */ export function readIndent(code: string, indexFrom: number): string { - let indent = '' - let i = indexFrom + let indent = ''; + let i = indexFrom; while (i < code.length) { - const ch = code[i] - if (ch === ' ' || ch === '\t') { indent += ch; i++ } else { break } + const ch = code[i]; + if (ch === ' ' || ch === '\t') { + indent += ch; + i++; + } else { + break; + } } - return indent + return indent; +} + +/** + * Removes trailing semicolon and whitespace from a code segment + */ +export function trimStatementSemicolon(segment: string): string { + return segment.trim().replace(/;?\s*$/, ''); } +// ============================================================================ +// Collecthook Injection Utilities +// ============================================================================ + +export type CollecthookCategory = 'VariableDeclarator' | 'expressionStatement'; + +/** + * Builds the collecthook() call code with proper indentation + */ export function buildCollecthookPayload( indent: string, variableName: string, hookType: string, - category: 'VariableDeclarator' | 'expressionStatement', + category: CollecthookCategory, hookExpression: string | 'undefined', ): string { - const hookLine = hookExpression === 'undefined' ? 'undefined' : hookExpression - return ( -`${indent}collecthook({ + const dataValue = hookExpression === 'undefined' ? 'undefined' : hookExpression; + return `${indent}collecthook({ ${indent} variableName: '${variableName}', ${indent} hookType: '${hookType}', ${indent} category: '${category}', -${indent} data: ${hookLine} -${indent}});\n` - ) +${indent} data: ${dataValue} +${indent}});\n`; } -export function hasCollecthookAfterByVariableId(code: string, fromIndex: number, variableId: string, maxLookahead = 600): boolean { - const lookahead = code.slice(fromIndex, fromIndex + maxLookahead) - const alreadyInserted = new RegExp(`collecthook\\s*\\(\\s*\\{[\\s\\S]{0,300}?data:\\s*${variableId}\\b`).test(lookahead) - return alreadyInserted +/** + * Checks if a collecthook call already exists after the given position (by variable ID) + */ +export function hasCollecthookAfterByVariableId( + code: string, + fromIndex: number, + variableId: string, + maxLookahead = 600, +): boolean { + const lookahead = code.slice(fromIndex, fromIndex + maxLookahead); + const pattern = new RegExp(`collecthook\\s*\\(\\s*\\{[\\s\\S]{0,300}?data:\\s*${variableId}\\b`); + return pattern.test(lookahead); } -export function hasCollecthookAfterByVariableName(code: string, fromIndex: number, variableName: string, maxLookahead = 600): boolean { - const lookahead = code.slice(fromIndex, fromIndex + maxLookahead) - const alreadyInserted = new RegExp(`collecthook\\s*\\(\\s*\\{[\\s\\S]{0,200}?variableName:\\s*'${variableName}'`).test(lookahead) - return alreadyInserted -} - -export function trimStatementSemicolon(segment: string): string { - return segment.trim().replace(/;?\s*$/, '') +/** + * Checks if a collecthook call already exists after the given position (by variable name) + */ +export function hasCollecthookAfterByVariableName( + code: string, + fromIndex: number, + variableName: string, + maxLookahead = 600, +): boolean { + const lookahead = code.slice(fromIndex, fromIndex + maxLookahead); + const pattern = new RegExp(`collecthook\\s*\\(\\s*\\{[\\s\\S]{0,200}?variableName:\\s*'${variableName}'`); + return pattern.test(lookahead); } - - -export function isCustomHook(hookName: string): boolean { - return !(USE_HOOK_LIST.some(item => item.startsWith(hookName))) && /^use[A-Z_]/.test(hookName) -} \ No newline at end of file diff --git a/packages/plugin/src/parse/hookTracker.ts b/packages/plugin/src/parse/hookTracker.ts new file mode 100644 index 0000000..f6708b4 --- /dev/null +++ b/packages/plugin/src/parse/hookTracker.ts @@ -0,0 +1,243 @@ +/** + * Phase 2: Hook Tracker Injection + * Injects collecthook calls after each hook usage to track hook state + */ + +import { parseProgram, traverseProgram } from './traverse'; +import { + isAstNodeLike, + normalizeHookName, + getVariableIdentifierName, + isKnownHook, + normalizeQrlHookName, + findLineStart, + readIndent, + buildCollecthookPayload, + hasCollecthookAfterByVariableId, + hasCollecthookAfterByVariableName, + trimStatementSemicolon, + isCustomHook, +} from './helpers'; +import { INNER_USE_HOOK } from '@devtools/kit'; +import type { InjectionTask } from './types'; + +// ============================================================================ +// Main Entry +// ============================================================================ + +/** + * Injects collecthook calls after each hook usage + */ +export function injectHookTrackers(code: string): string { + const program = parseProgram(code); + const tasks: InjectionTask[] = []; + let customHookIndex = 0; + + traverseProgram(program, { + enter: (path) => { + const node: any = path.node; + if (!node) return; + + // Handle variable declarations: const x = useSignal() + if (node.type === 'VariableDeclarator') { + const task = processVariableDeclarator(code, node, path); + if (task) tasks.push(task); + } + + // Handle expression statements: useTask$(() => {}) + if (node.type === 'ExpressionStatement') { + const result = processExpressionStatement(code, node, customHookIndex); + if (result) { + tasks.push(result.task); + customHookIndex = result.newIndex; + } + } + }, + }); + + return applyTasks(code, tasks); +} + +// ============================================================================ +// Variable Declarator Processing +// ============================================================================ + +/** + * Processes: const x = useSignal(), const data = useCustomHook() + */ +function processVariableDeclarator( + code: string, + node: any, + path: { parent: any }, +): InjectionTask | null { + const hookInfo = extractHookInfo(node); + if (!hookInfo) return null; + + const { hookName, normalizedName, variableId } = hookInfo; + if (hookName === INNER_USE_HOOK) return null; + + const range = getParentRange(path.parent); + if (!range) return null; + + const { declEnd, indent } = getPositionInfo(code, range); + + // Custom hook + if (isCustomHook(normalizedName)) { + if (hasCollecthookAfterByVariableId(code, declEnd, variableId)) return null; + const payload = buildCollecthookPayload(indent, variableId, 'customhook', 'VariableDeclarator', variableId); + return { kind: 'insert', pos: declEnd, text: '\n' + payload }; + } + + // Known hook + if (!isKnownHook(normalizedName)) return null; + if (hasCollecthookAfterByVariableId(code, declEnd, variableId)) return null; + + const payload = buildCollecthookPayload(indent, variableId, normalizedName, 'VariableDeclarator', variableId); + return { kind: 'insert', pos: declEnd, text: '\n' + payload }; +} + +// ============================================================================ +// Expression Statement Processing +// ============================================================================ + +/** + * Processes: useTask$(() => {}), useCustomHook() + */ +function processExpressionStatement( + code: string, + node: any, + currentIndex: number, +): { task: InjectionTask; newIndex: number } | null { + const hookInfo = extractExpressionHookInfo(node); + if (!hookInfo) return null; + + const { hookName, normalizedName } = hookInfo; + if (hookName === INNER_USE_HOOK) return null; + + const stmtRange = node.range as number[] | undefined; + if (!stmtRange) return null; + + const [stmtStart, stmtEnd] = stmtRange; + const lineStart = findLineStart(code, stmtStart); + const indent = readIndent(code, lineStart); + + // Known hook (expression form) + if (isKnownHook(normalizedName)) { + 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, + }; + } + + // Custom hook (expression form) - convert to variable declaration + if (isCustomHook(normalizedName)) { + return convertToVariableDeclaration(code, stmtStart, stmtEnd, indent, currentIndex); + } + + return null; +} + +/** + * Converts a custom hook expression to a variable declaration with tracking + */ +function convertToVariableDeclaration( + code: string, + stmtStart: number, + stmtEnd: number, + indent: string, + currentIndex: number, +): { task: InjectionTask; newIndex: number } { + 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); + + return { + task: { kind: 'replace', start: stmtStart, end: stmtEnd, text: declLine + payload }, + newIndex: currentIndex + 1, + }; +} + +// ============================================================================ +// Hook Info Extraction +// ============================================================================ + +interface HookInfo { + hookName: string; + normalizedName: string; + variableId: string; +} + +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 }; +} + +function extractExpressionHookInfo(node: any): { hookName: string; normalizedName: string } | null { + const expr = node.expression; + if (!isAstNodeLike(expr) || expr.type !== 'CallExpression') return null; + + const callee = (expr 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 }; +} + +// ============================================================================ +// Position Helpers +// ============================================================================ + +function getParentRange(parent: any): [number, number] | null { + const range = parent?.range as number[] | undefined; + if (!range) return null; + return [range[0], range[1]]; +} + +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 new file mode 100644 index 0000000..b033c57 --- /dev/null +++ b/packages/plugin/src/parse/initInjector.ts @@ -0,0 +1,136 @@ +/** + * Phase 1: Initialization Hook Injection + * Injects collecthook setup and render stats at the beginning of each component body + */ + +import { ComponentBodyRange } from './traverse'; +import { readIndent } from './helpers'; +import { INNER_USE_HOOK } from '@devtools/kit'; +import type { InjectOptions, InitTask } from './types'; + +// ============================================================================ +// Main Entry +// ============================================================================ + +/** + * Injects collecthook initialization and render stats at the beginning of each component body + */ +export function injectInitHooks( + code: string, + bodies: ComponentBodyRange[], + options?: InjectOptions, +): string { + const tasks: InitTask[] = []; + + for (const body of bodies) { + const task = createInitTask(code, body, options); + if (task) { + tasks.push(task); + } + } + + return applyTasks(code, tasks); +} + +// ============================================================================ +// Task Creation +// ============================================================================ + +/** + * Creates an initialization task for a single component body + */ +function createInitTask( + code: string, + body: ComponentBodyRange, + options?: InjectOptions, +): InitTask | null { + const { insertPos, exportName } = body; + + // Skip if already has collecthook initialization + if (hasExistingCollecthook(code, insertPos)) { + return null; + } + + // Calculate insertion position + 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, + }; +} + +// ============================================================================ +// Position Helpers +// ============================================================================ + +function hasExistingCollecthook(code: string, insertPos: number): boolean { + const lookahead = code.slice(insertPos, insertPos + 200); + return /const\s+collecthook\s*=\s*useCollectHooks\s*\(/.test(lookahead); +} + +function calculateInsertPosition(code: string, insertPos: number): { + insertIndex: number; + prefixNewline: string; +} { + if (code[insertPos] === '\r' && code[insertPos + 1] === '\n') { + return { insertIndex: insertPos + 2, prefixNewline: '' }; + } + if (code[insertPos] === '\n') { + return { insertIndex: insertPos + 1, prefixNewline: '' }; + } + return { insertIndex: insertPos, prefixNewline: '\n' }; +} + +// ============================================================================ +// Code Builders +// ============================================================================ + +/** + * Builds the component argument string for collecthook initialization + */ +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 { + if (exportName && typeof exportName === 'string') { + return `_${exportName}`; + } + + if (baseArg.endsWith('index.tsx')) { + const parts = baseArg.split('/'); + const parent = parts.length >= 2 ? parts[parts.length - 2] : 'index'; + return `_${parent.replace(/-/g, '_')}`; + } + + const file = baseArg.split('/').pop() || ''; + 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 59cfa3b..1120e13 100644 --- a/packages/plugin/src/parse/parse.ts +++ b/packages/plugin/src/parse/parse.ts @@ -1,182 +1,33 @@ -import { parseProgram, findAllComponentBodyRangesFromProgram, traverseProgram } from './traverse' -import { - isAstNodeLike, - normalizeHookName, - getVariableIdentifierName, - isKnownHook, - normalizeQrlHookName, - findLineStart, - readIndent, - buildCollecthookPayload, - hasCollecthookAfterByVariableId, - hasCollecthookAfterByVariableName, - trimStatementSemicolon, - isCustomHook, -} from './helpers' -import { INNER_USE_HOOK } from '@devtools/kit' - -export interface InjectOptions { path?: string } -export function parseQwikCode(code: string, options?: InjectOptions): string { - - const program: any = parseProgram(code) as any - - const allBodies = findAllComponentBodyRangesFromProgram(program) - - let result = code - let index = 0 - - if (allBodies.length > 0) { - type Task = { start: number; end: number; text: string } - const tasks: Task[] = [] - for (let idx = 0; idx < allBodies.length; idx++) { - const { insertPos, exportName } = allBodies[idx] as any - // skip if this body already has init - const lookahead = result.slice(insertPos, insertPos + 200) - if (/const\s+collecthook\s*=\s*useCollectHooks\s*\(/.test(lookahead)) continue - - let i = insertPos - let insertIndex = insertPos - let prefixNewline = '' - if (result[i] === '\r' && result[i + 1] === '\n') { i += 2; insertIndex = i } - else if (result[i] === '\n') { i += 1; insertIndex = i } - else { prefixNewline = '\n' } - - const indent = readIndent(result, i) - const rawArg = String(options?.path ?? '') - const baseArg = rawArg.split('?')[0].split('#')[0] - let suffix = '' - - if (exportName && typeof exportName === 'string') { - suffix = `_${exportName}` - }else { - if (baseArg.endsWith('index.tsx')) { - const parts = baseArg.split('/') - const parent = parts.length >= 2 ? parts[parts.length - 2] : 'index' - const safeParent = parent.replace(/-/g, '_') - suffix = `_${safeParent}` - } else { - const file = baseArg.split('/').pop() || '' - const name = file.replace(/\.[^.]+$/, '') - const safeName = name.replace(/-/g, '_') - suffix = name ? `_${safeName}` : '' - } - } - const arg = JSON.stringify(`${baseArg}${suffix}`) - const initLine = `${prefixNewline}${indent}const collecthook = ${INNER_USE_HOOK}(${arg})\n` - tasks.push({ start: insertIndex, end: insertIndex, text: initLine }) - } - // apply from last to first to keep positions stable - tasks.sort((a, b) => b.start - a.start) - for (const t of tasks) { - result = result.slice(0, t.start) + t.text + result.slice(t.end) - } +/** + * Qwik Code Parser + * Transforms Qwik component code by injecting devtools hooks + */ + +import { parseProgram, findAllComponentBodyRangesFromProgram } from './traverse'; +import { injectInitHooks } from './initInjector'; +import { injectHookTrackers } from './hookTracker'; + +export type { InjectOptions } from './types'; + +/** + * Parses and transforms Qwik component code by injecting devtools hooks + * + * 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 { + const program = parseProgram(code); + const componentBodies = findAllComponentBodyRangesFromProgram(program); + + if (componentBodies.length === 0) { + return code; } - if (allBodies.length > 0) { - const programForInjections: any = parseProgram(result) as any - type InsertTask = { kind: 'insert'; pos: number; text: string } - type ReplaceTask = { kind: 'replace'; start: number; end: number; text: string } - type InjectionTask = InsertTask | ReplaceTask - const tasks: InjectionTask[] = [] - traverseProgram(programForInjections, { - enter: (path) => { - const node: any = path.node as any - if (!node) return - - if (node.type === 'VariableDeclarator') { - const init = node.init - if (!isAstNodeLike(init) || init.type !== 'CallExpression') return - const callee = (init as any).callee - if (!isAstNodeLike(callee) || callee.type !== 'Identifier') return - const hookName = normalizeHookName(((callee as any).name) as string) - const isQrlName = normalizeQrlHookName(hookName) - const variableId = getVariableIdentifierName(node.id) - if (!variableId) return + // Phase 1: Initialize hooks at component body start + let result = injectInitHooks(code, componentBodies, options); - if (hookName === INNER_USE_HOOK) return - - if (isCustomHook(isQrlName)) { - const parent: any = path.parent as any - const parentRange: number[] | undefined = parent && Array.isArray(parent.range) ? parent.range : undefined - if (!parentRange) return - const declStart = parentRange[0] as number - const declEnd = parentRange[1] as number - - const lineStart = findLineStart(result, declStart) - const indent = readIndent(result, lineStart) - const payload = buildCollecthookPayload(indent, variableId, 'customhook', 'VariableDeclarator', variableId) - if (hasCollecthookAfterByVariableId(result, declEnd, variableId)) return - tasks.push({ kind: 'insert', pos: declEnd, text: '\n' + payload }) - return - } - if (!isKnownHook(isQrlName)) return - - const parent: any = path.parent as any - const parentRange: number[] | undefined = parent && Array.isArray(parent.range) ? parent.range : undefined - if (!parentRange) return - const declStart = parentRange[0] as number - const declEnd = parentRange[1] as number - - const lineStart = findLineStart(result, declStart) - const indent = readIndent(result, lineStart) - const payload = buildCollecthookPayload(indent, variableId, isQrlName, 'VariableDeclarator', variableId) - if (hasCollecthookAfterByVariableId(result, declEnd, variableId)) return - tasks.push({ kind: 'insert', pos: declEnd, text: '\n' + payload }) - return - } - if (node.type === 'ExpressionStatement') { - const expr = node.expression - if (!isAstNodeLike(expr) || expr.type !== 'CallExpression') return - const callee = (expr as any).callee - if (!isAstNodeLike(callee) || callee.type !== 'Identifier') return - const hookName = normalizeHookName(((callee as any).name) as string) - const isQrlName = normalizeQrlHookName(hookName) - const isListed = isKnownHook(isQrlName) - if (hookName === INNER_USE_HOOK) return - const stmtRange: number[] | undefined = Array.isArray((node as any).range) ? (node as any).range : undefined - if (!stmtRange) return - const stmtStart = stmtRange[0] as number - const stmtEnd = stmtRange[1] as number - const lineStart = findLineStart(result, stmtStart) - const indent = readIndent(result, lineStart) - - if (isListed) { - const payload = buildCollecthookPayload(indent, isQrlName, isQrlName, 'expressionStatement', 'undefined') - if (hasCollecthookAfterByVariableName(result, stmtEnd, isQrlName)) return - tasks.push({ kind: 'insert', pos: stmtEnd, text: '\n' + payload }) - } else if (isCustomHook(isQrlName)) { - const stmtRange: number[] | undefined = Array.isArray((node as any).range) ? (node as any).range : undefined - if (!stmtRange) return - const stmtStart = stmtRange[0] as number - const stmtEnd = stmtRange[1] as number - const callSource = result.slice(stmtStart, stmtEnd) - const lineStart2 = findLineStart(result, stmtStart) - const indent2 = readIndent(result, lineStart2) - const variableName = `_customhook_${index}` - const declLine = `${indent2}let ${variableName} = ${trimStatementSemicolon(callSource)};\n` - const payload = buildCollecthookPayload(indent2, variableName, 'customhook', 'VariableDeclarator', variableName) - index++ - tasks.push({ kind: 'replace', start: stmtStart, end: stmtEnd, text: declLine + payload }) - } - } - } - }) - - if (tasks.length > 0) { - 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 - }) - for (const t of tasks) { - if (t.kind === 'insert') { - result = result.slice(0, t.pos) + t.text + result.slice(t.pos) - } else { - result = result.slice(0, t.start) + t.text + result.slice(t.end) - } - } - } - } + // Phase 2: Track individual hook usages + result = injectHookTrackers(result); - return result -} \ No newline at end of file + return result; +} diff --git a/packages/plugin/src/parse/traverse.ts b/packages/plugin/src/parse/traverse.ts index 618bcb0..45200d8 100644 --- a/packages/plugin/src/parse/traverse.ts +++ b/packages/plugin/src/parse/traverse.ts @@ -1,132 +1,225 @@ -import { parseSync } from 'oxc-parser' -import { isAstNodeLike } from './helpers' +import { parseSync } from 'oxc-parser'; +import { isAstNodeLike } from './helpers'; + +// ============================================================================ +// Types +// ============================================================================ export interface NodePath { - node: T - parent: any | null - key: string | number | null - index: number | null - state: any - stop: () => void - skip: () => void + node: T; + parent: any | null; + key: string | number | null; + index: number | null; + state: any; + stop: () => void; + skip: () => void; } -type VisitFn = (path: NodePath) => void -type VisitObj = { enter?: VisitFn; exit?: VisitFn } +type VisitFn = (path: NodePath) => void; +type VisitObj = { enter?: VisitFn; exit?: VisitFn }; + export type Visitor = { - enter?: VisitFn - exit?: VisitFn - [type: string]: VisitFn | VisitObj | undefined -} + enter?: VisitFn; + exit?: VisitFn; + [type: string]: VisitFn | VisitObj | undefined; +}; -function callVisitor(visitor: Visitor | undefined, type: string, hook: 'enter' | 'exit', path: NodePath) { - if (!visitor) return - const specific = visitor[type] as VisitFn | undefined - if (specific) specific(path) - const handler = visitor[path.node && (path.node as any).type] as VisitFn | VisitObj | undefined - if (!handler) return - if (typeof handler === 'function' && hook === 'enter') { - handler(path) - return - } - if (typeof handler === 'object') { - const fn = handler[hook] - if (fn) fn(path) - } +export interface ComponentBodyRange { + insertPos: number; + bodyStart: number; + bodyEnd: number; + exportName?: string; } +// ============================================================================ +// Parser +// ============================================================================ + +/** + * Parses TypeScript/JSX code into an AST using oxc-parser + */ export function parseProgram(code: string): unknown { const parsed = parseSync('file.tsx', code, { lang: 'tsx', sourceType: 'module', astType: 'ts', range: true, - }) - return parsed.program as unknown + }); + return parsed.program as unknown; +} + +// ============================================================================ +// Traverser +// ============================================================================ + +/** + * Calls the appropriate visitor function for enter/exit events + */ +function callVisitor( + visitor: Visitor | undefined, + type: string, + hook: 'enter' | 'exit', + path: NodePath, +): void { + if (!visitor) return; + + // Call generic enter/exit handler + const specific = visitor[type] as VisitFn | undefined; + if (specific) specific(path); + + // Call type-specific handler + const handler = visitor[path.node && (path.node as any).type] as VisitFn | VisitObj | undefined; + if (!handler) return; + + if (typeof handler === 'function' && hook === 'enter') { + handler(path); + return; + } + + if (typeof handler === 'object') { + const fn = handler[hook]; + if (fn) fn(path); + } } +/** + * Traverses an AST program with a visitor pattern + */ export function traverseProgram(program: unknown, visitor: Visitor, state?: any): void { - let shouldStopAll = false - function inner(node: unknown, parent: unknown, key: string | number | null, index: number | null) { - if (shouldStopAll) return - if (!isAstNodeLike(node)) return - let shouldSkipChildren = false + let shouldStopAll = false; + + function traverse( + node: unknown, + parent: unknown, + key: string | number | null, + index: number | null, + ): void { + if (shouldStopAll) return; + if (!isAstNodeLike(node)) return; + + let shouldSkipChildren = false; + const path: NodePath = { node, parent: isAstNodeLike(parent) ? parent : null, key, index, state, - stop: () => { shouldStopAll = true }, - skip: () => { shouldSkipChildren = true }, - } - callVisitor(visitor, 'enter', 'enter', path) + stop: () => { shouldStopAll = true; }, + skip: () => { shouldSkipChildren = true; }, + }; + + // Enter phase + callVisitor(visitor, 'enter', 'enter', path); + + // Traverse children if (!shouldSkipChildren) { - const record = node as Record + const record = node as Record; for (const k of Object.keys(record)) { - const value: unknown = (record as any)[k] + const value = record[k]; if (Array.isArray(value)) { - for (let i = 0; i < value.length; i++) inner(value[i], node, k, i) + for (let i = 0; i < value.length; i++) { + traverse(value[i], node, k, i); + } } else { - inner(value, node, k, null) + traverse(value, node, k, null); } } } - callVisitor(visitor, 'exit', 'exit', path) + + // Exit phase + callVisitor(visitor, 'exit', 'exit', path); } - inner(program, null, null, null) + + traverse(program, null, null, null); } +/** + * Parses code and traverses the resulting AST + */ export function traverseQwik(code: string, visitor: Visitor, state?: any): void { - const program: unknown = parseProgram(code) - traverseProgram(program, visitor, state) + const program = parseProgram(code); + traverseProgram(program, visitor, state); } -export interface ComponentBodyRange { insertPos: number; bodyStart: number; bodyEnd: number; exportName?: string } +// ============================================================================ +// Component Body Detection +// ============================================================================ +/** + * Finds all component$ function bodies in the AST and returns their positions + */ export function findAllComponentBodyRangesFromProgram(program: unknown): ComponentBodyRange[] { - const ranges: ComponentBodyRange[] = [] + const ranges: ComponentBodyRange[] = []; + traverseProgram(program, { enter: (path) => { - const node: any = path.node as any - if (!node) return - // collect any component$ call expression - if (node.type === 'CallExpression') { - const callee = node.callee - if (callee && callee.type === 'Identifier' && callee.name === 'component$') { - const firstArg = node.arguments && node.arguments[0] - if (firstArg && (firstArg.type === 'ArrowFunctionExpression' || firstArg.type === 'FunctionExpression')) { - const body = firstArg.body - if (body && body.type === 'BlockStatement' && Array.isArray(body.range)) { - const start = (body.range[0] as number) - const end = (body.range[1] as number) - // detect export form to derive a name hint - let exportName: string | undefined - const parent: any = path.parent as any - if (parent && parent.type === 'ExportDefaultDeclaration') { - exportName = '' - } else if (parent && parent.type === 'VariableDeclarator') { - const id = (parent as any).id - if (id && id.type === 'Identifier' && typeof id.name === 'string') { - exportName = id.name as string - } - } - ranges.push({ insertPos: start + 1, bodyStart: start, bodyEnd: end, exportName }) - } - } - } - } - } - }) - // de-duplicate and sort by position ascending - const seen = new Set() - const unique = ranges.filter(r => { - if (seen.has(r.insertPos)) return false - seen.add(r.insertPos) - return true - }) - unique.sort((a, b) => a.insertPos - b.insertPos) - return unique + 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 new file mode 100644 index 0000000..b47832c --- /dev/null +++ b/packages/plugin/src/parse/types.ts @@ -0,0 +1,13 @@ +/** + * Shared types for the parse module + */ + +export interface InjectOptions { + path?: string; +} + +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 }; + diff --git a/packages/plugin/src/plugin/devtools.ts b/packages/plugin/src/plugin/devtools.ts new file mode 100644 index 0000000..011ea2c --- /dev/null +++ b/packages/plugin/src/plugin/devtools.ts @@ -0,0 +1,78 @@ +import { ResolvedConfig, type Plugin } from 'vite'; +import { getServerFunctions } from '../rpc'; +import { createServerRpc, setViteServerContext } from '@devtools/kit'; +import { startPreloading } from '../npm/index'; +import updateConf from '../utils/updateConf'; +import { debug } from 'debug'; +import { findVirtualModule, transformComponentFile, transformRootFile } from '../virtualmodules/virtualModules'; + +const log = debug('qwik:devtools:plugin'); + +/** + * Core Qwik DevTools plugin + */ +export function devtoolsPlugin(): Plugin { + let resolvedConfig: ResolvedConfig; + const qwikData = new Map(); + let preloadStarted = false; + + return { + name: 'vite-plugin-qwik-devtools', + apply: 'serve', + + resolveId(id) { + const virtualModule = findVirtualModule(id); + if (virtualModule) { + return `/${virtualModule.key}`; + } + }, + + load(id) { + const virtualModule = findVirtualModule(id); + if (virtualModule) { + return { + code: virtualModule.source, + map: { mappings: '' }, + }; + } + }, + + configResolved(viteConfig) { + resolvedConfig = viteConfig; + updateConf(resolvedConfig); + + if (!preloadStarted) { + preloadStarted = true; + startPreloading({ config: resolvedConfig }).catch((err) => { + log('[Qwik DevTools] Failed to start preloading:', err); + }); + } + }, + + transform: { + order: 'pre', + handler(code, id) { + if (id.endsWith('.tsx') && code.includes('component$')) { + code = transformComponentFile(code, id); + } + + if (id.endsWith('root.tsx')) { + return { code: transformRootFile(code), map: null }; + } + + return { code, map: { mappings: '' } }; + }, + }, + + configureServer(server) { + setViteServerContext(server as any); + const rpcFunctions = getServerFunctions({ + server, + config: resolvedConfig, + qwikData, + }); + createServerRpc(rpcFunctions); + }, + }; +} + diff --git a/packages/plugin/src/plugin/index.ts b/packages/plugin/src/plugin/index.ts new file mode 100644 index 0000000..bc54e85 --- /dev/null +++ b/packages/plugin/src/plugin/index.ts @@ -0,0 +1,19 @@ +import { type Plugin } from 'vite'; +import VueInspector from 'vite-plugin-inspect'; +import { devtoolsPlugin } from './devtools'; +import qwikComponentProxy from './statistics'; + +// Re-export individual plugins +export { devtoolsPlugin } from './devtools'; + +/** + * Main entry: combines all devtools plugins + */ +export function qwikDevtools(): Plugin[] { + return [ + devtoolsPlugin(), + VueInspector(), + ...qwikComponentProxy(), + // Add more plugins here as needed + ]; +} diff --git a/packages/plugin/src/plugin/statistics.ts b/packages/plugin/src/plugin/statistics.ts new file mode 100644 index 0000000..f75e6c3 --- /dev/null +++ b/packages/plugin/src/plugin/statistics.ts @@ -0,0 +1,303 @@ +import type { Plugin } from 'vite'; +import { debug } from 'debug'; +import perfLazyWrapperPreamble from '../virtualmodules/perfLazyWrapperPreamble'; +import { isVirtualId, normalizeId } from '../virtualmodules/virtualModules'; + +/** + * 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__` + */ + +// ============================================================================ +// 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', + + 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; + + 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 }); + modifiedCode = wrapped.code; + hasChanges = hasChanges || wrapped.changed; + } + + if (!hasChanges) return null; + return { code: modifiedCode, map: null }; + }, + + configureServer(server) { + attachSsrPerfInjectorMiddleware(server); + }, + }; +} + +// Backwards compatible default export (existing callers spread a Plugin[]) +export default function qwikComponentProxy(): Plugin[] { + return [statisticsPlugin()]; +} diff --git a/packages/plugin/src/utils/useCollectHooks.ts b/packages/plugin/src/utils/useCollectHooks.ts deleted file mode 100644 index 5dd1061..0000000 --- a/packages/plugin/src/utils/useCollectHooks.ts +++ /dev/null @@ -1,22 +0,0 @@ -const useCollectHooks = `import { $, useSignal, useVisibleTask$ } from "@qwik.dev/core" -export const useCollectHooks = (src) => { - const hooksList = useSignal(new Set()) - useVisibleTask$(({ track }) => { - const newdata = track(() => hooksList.value); - if(!window.QWIK_DEVTOOLS_GLOBAL_STATE) { - window.QWIK_DEVTOOLS_GLOBAL_STATE = {} - window.QWIK_DEVTOOLS_GLOBAL_STATE[src] = [...newdata] - }else { - window.QWIK_DEVTOOLS_GLOBAL_STATE[src] = [...newdata] - } - }, { strategy: 'document-ready'}) - return $((args) => { - if(hooksList.value.has(args)) { - return - } - hooksList.value.add(args) - }) -} -` - -export default useCollectHooks \ No newline at end of file diff --git a/packages/plugin/src/virtualmodules/perfLazyWrapperPreamble.ts b/packages/plugin/src/virtualmodules/perfLazyWrapperPreamble.ts new file mode 100644 index 0000000..4134ee0 --- /dev/null +++ b/packages/plugin/src/virtualmodules/perfLazyWrapperPreamble.ts @@ -0,0 +1,51 @@ +import perfRuntime from './perfRuntime'; + +/** + * Preamble injected into Qwik-generated lazy render function modules (`_component_...`). + * + * It defines `__qwik_wrap__` that wraps each exported render function and records perf entries. + */ +const perfLazyWrapperPreamble = `${perfRuntime} + +// [qwik-component-proxy] Render function wrapper +const __qwik_wrap__ = (fn, name, viteId) => { + let renderCount = 0; + return function (...args) { + renderCount += 1; + const phase = __qwik_perf_is_server__() ? 'ssr' : 'csr'; + const start = performance.now(); + + try { + const result = fn.apply(this, args); + const duration = performance.now() - start; + __qwik_perf_commit__({ + component: name, + phase, + duration, + start, + end: start + duration, + viteId, + renderCount, + }); + return result; + } catch (err) { + const duration = performance.now() - start; + __qwik_perf_commit__({ + component: name, + phase, + duration, + start, + end: start + duration, + error: __qwik_perf_to_error__(err), + viteId, + renderCount, + }); + throw err; + } + }; +}; +`; + +export default perfLazyWrapperPreamble; + + diff --git a/packages/plugin/src/virtualmodules/perfRuntime.ts b/packages/plugin/src/virtualmodules/perfRuntime.ts new file mode 100644 index 0000000..1d33067 --- /dev/null +++ b/packages/plugin/src/virtualmodules/perfRuntime.ts @@ -0,0 +1,140 @@ +/** + * Shared runtime snippet used by perf instrumentation code. + * + * This file exports a **string** (not executable TS) that gets concatenated into: + * - the virtual module `virtual:qwik-component-proxy` + * - injected wrappers for Qwik-generated `_component_` render-function modules + * + * It is intentionally framework-agnostic and relies only on `window`/`process`. + */ +const perfRuntime = ` +// [qwik-perf-runtime] shared helpers (injected) +const __qwik_perf_is_server__ = () => typeof window === 'undefined'; + +const __qwik_perf_init_csr__ = () => { + if (typeof window === 'undefined') return; + window.__QWIK_PERF__ = window.__QWIK_PERF__ || { ssr: [], csr: [] }; + // Map: viteId -> index in perf.csr (used for upsert) + window.__QWIK_PERF__._csrByViteId = window.__QWIK_PERF__._csrByViteId || {}; + // Map: component -> index in perf.ssr (used for componentQrl upsert on CSR) + window.__QWIK_PERF__._ssrByComponent = window.__QWIK_PERF__._ssrByComponent || {}; +}; + +const __qwik_perf_get_ssr_store__ = () => + typeof process !== 'undefined' && process ? process : globalThis; + +const __qwik_perf_to_error__ = (err) => ({ + name: (err && err.name) || 'Error', + message: err && err.message ? String(err.message) : String(err), +}); + +const __qwik_perf_next_id__ = (perf) => { + perf._id = (perf._id || 0) + 1; + return perf._id; +}; + +const __qwik_perf_next_ssr_id__ = (store) => { + store.__QWIK_SSR_PERF_ID__ = (store.__QWIK_SSR_PERF_ID__ || 0) + 1; + return store.__QWIK_SSR_PERF_ID__; +}; + +const __qwik_perf_ssr_push__ = (store, entry) => { + const id = __qwik_perf_next_ssr_id__(store); + store.__QWIK_SSR_PERF__.push({ id, ...entry }); + return store.__QWIK_SSR_PERF__.length - 1; +}; + +const __qwik_perf_commit_ssr__ = (store, entry) => { + store.__QWIK_SSR_PERF__ = store.__QWIK_SSR_PERF__ || []; + + // Upsert by key (viteId preferred, otherwise component). Keep only the latest entry per key, + // but also attach an ever-increasing ssrCount so we can see how many times it rendered. + store.__QWIK_SSR_PERF_INDEX__ = store.__QWIK_SSR_PERF_INDEX__ || {}; + store.__QWIK_SSR_PERF_COUNT__ = store.__QWIK_SSR_PERF_COUNT__ || {}; + + const key = (entry && (entry.viteId || entry.component)) || 'unknown'; + const nextCount = (store.__QWIK_SSR_PERF_COUNT__[key] || 0) + 1; + store.__QWIK_SSR_PERF_COUNT__[key] = nextCount; + + const next = { ...entry, ssrCount: nextCount }; + const existingIdx = store.__QWIK_SSR_PERF_INDEX__[key]; + + if (typeof existingIdx === 'number') { + const prev = store.__QWIK_SSR_PERF__[existingIdx]; + store.__QWIK_SSR_PERF__[existingIdx] = { id: prev?.id, ...next }; + } else { + store.__QWIK_SSR_PERF_INDEX__[key] = __qwik_perf_ssr_push__(store, next); + } +}; + +const __qwik_perf_commit_csr__ = (entry) => { + __qwik_perf_init_csr__(); + const perf = window.__QWIK_PERF__; + const next = { id: __qwik_perf_next_id__(perf), ...entry }; + + // If viteId exists we treat it as an "upsert" record (render-function modules). + if (entry && entry.viteId) { + const idx = perf._csrByViteId[entry.viteId]; + if (typeof idx === 'number') perf.csr[idx] = next; + else { + perf._csrByViteId[entry.viteId] = perf.csr.length; + perf.csr.push(next); + } + return; + } + + perf.csr.push(next); +}; + +// Force componentQrl entries to be treated as SSR records. +// - On SSR: store into process/globalThis __QWIK_SSR_PERF__ (same as other SSR entries) +// - On CSR: store into window.__QWIK_PERF__.ssr (so csr only contains render-function modules) +const __qwik_perf_commit_componentqrl__ = (entry) => { + const next = { ...entry, phase: 'ssr'}; + if (__qwik_perf_is_server__()) { + const store = __qwik_perf_get_ssr_store__(); + __qwik_perf_commit_ssr__(store, next); + return; + } + + __qwik_perf_init_csr__(); + const perf = window.__QWIK_PERF__; + perf.ssr = perf.ssr || []; + // Build index lazily from any SSR-injected snapshot + if (!perf._ssrIndexBuilt) { + for (let i = 0; i < perf.ssr.length; i++) { + const e = perf.ssr[i]; + if (e && e.component && typeof perf._ssrByComponent[e.component] !== 'number') { + perf._ssrByComponent[e.component] = i; + } + } + perf._ssrIndexBuilt = true; + } + + const key = next && next.component; + const idx = key ? perf._ssrByComponent[key] : undefined; + if (typeof idx === 'number') { + const prev = perf.ssr[idx]; + const prevCount = prev && typeof prev.ssrCount === 'number' ? prev.ssrCount : 0; + const ssrCount = prevCount + 1; + perf.ssr[idx] = { id: prev && prev.id, ...next, ssrCount }; + } else { + const id = __qwik_perf_next_id__(perf); + if (key) perf._ssrByComponent[key] = perf.ssr.length; + perf.ssr.push({ id, ...next, ssrCount: 1 }); + } +}; + +const __qwik_perf_commit__ = (entry) => { + if (__qwik_perf_is_server__()) { + const store = __qwik_perf_get_ssr_store__(); + __qwik_perf_commit_ssr__(store, entry); + } else { + __qwik_perf_commit_csr__(entry); + } +}; +`; + +export default perfRuntime; + + diff --git a/packages/plugin/src/virtualmodules/qwikComponentProxy.ts b/packages/plugin/src/virtualmodules/qwikComponentProxy.ts new file mode 100644 index 0000000..ed19b82 --- /dev/null +++ b/packages/plugin/src/virtualmodules/qwikComponentProxy.ts @@ -0,0 +1,53 @@ +/** + * Virtual module source for perf tracking of Qwik's `componentQrl`. + * + * This module is loaded via the devtools plugin virtual-module registry + * (`virtualmodules/virtualModules.ts`) and is imported from: + * - `virtual:qwik-component-proxy` + */ +import perfRuntime from './perfRuntime'; + +const qwikComponentProxy = `${perfRuntime} +import { componentQrl as originalComponentQrl } from '@qwik.dev/core'; + +function componentQrl(qrl, options) { + const phase = __qwik_perf_is_server__() ? 'ssr' : 'csr'; + const start = performance.now(); + let viteId = null; + const component = qrl?.getSymbol?.() || qrl?.$symbol$ || 'unknown'; + if(qrl.dev){ + viteId = qrl.dev.file.replace(/[^/]*$/, qrl.dev.displayName); + } + try { + const result = originalComponentQrl(qrl, options); + const duration = performance.now() - start; + __qwik_perf_commit_componentqrl__({ + component, + phase, + duration, + start, + viteId, + end: start + duration, + }); + return result; + } catch (err) { + const duration = performance.now() - start; + __qwik_perf_commit_componentqrl__({ + component, + phase, + duration, + start, + viteId, + end: start + duration, + error: __qwik_perf_to_error__(err), + }); + throw err; + } +} + +export { componentQrl }; +`; + +export default qwikComponentProxy; + + diff --git a/packages/plugin/src/virtualmodules/useCollectHooks.ts b/packages/plugin/src/virtualmodules/useCollectHooks.ts new file mode 100644 index 0000000..a6ead6a --- /dev/null +++ b/packages/plugin/src/virtualmodules/useCollectHooks.ts @@ -0,0 +1,44 @@ +/** + * Virtual module source for devtools tracking. + * Collects hooks and tracks render statistics. + */ +const useCollectHooks = `import { $, useSignal, useVisibleTask$ } from "@qwik.dev/core" + +function initComponentState() { + return { + hooks: [], + stats: {} + } +} + +function getOrCreateState(src) { + if (!window.QWIK_DEVTOOLS_GLOBAL_STATE) { + window.QWIK_DEVTOOLS_GLOBAL_STATE = {} + } + if (!window.QWIK_DEVTOOLS_GLOBAL_STATE[src]) { + window.QWIK_DEVTOOLS_GLOBAL_STATE[src] = initComponentState() + } + return window.QWIK_DEVTOOLS_GLOBAL_STATE[src] +} + +/** + * Hook to collect component hooks + */ +export const useCollectHooks = (src) => { + const hooksList = useSignal(new Set()) + useVisibleTask$(({ track }) => { + const newHooks = track(() => hooksList.value) + const state = getOrCreateState(src) + state.hooks = [...newHooks] + }, { strategy: 'document-ready' }) + + return $((args) => { + if (hooksList.value.has(args)) { + return + } + hooksList.value.add(args) + }) +} +` + +export default useCollectHooks diff --git a/packages/plugin/src/virtualmodules/virtualModules.ts b/packages/plugin/src/virtualmodules/virtualModules.ts new file mode 100644 index 0000000..25587a2 --- /dev/null +++ b/packages/plugin/src/virtualmodules/virtualModules.ts @@ -0,0 +1,91 @@ +import { VIRTUAL_QWIK_DEVTOOLS_KEY, INNER_USE_HOOK } from '@devtools/kit'; +import useCollectHooksSource from './useCollectHooks'; +import qwikComponentProxySource from './qwikComponentProxy'; +import { parseQwikCode } from '../parse/parse'; +import { debug } from 'debug'; + +const log = debug('qwik:devtools:plugin'); + +// ============================================================================ +// Types & Configuration +// ============================================================================ + +export interface VirtualModuleConfig { + key: string; + source: string; + hookName: string; +} + +export const VIRTUAL_MODULES: VirtualModuleConfig[] = [ + { + key: VIRTUAL_QWIK_DEVTOOLS_KEY, + source: useCollectHooksSource, + hookName: INNER_USE_HOOK, + }, + { + // Perf tracking: used by `plugin/statistics.ts` to rewrite `componentQrl` imports + key: 'virtual:qwik-component-proxy', + source: qwikComponentProxySource, + hookName: '', + } +]; + +// ============================================================================ +// Virtual Module Helpers +// ============================================================================ + +export function normalizeId(id: string): string { + return id.split('?')[0].split('#')[0]; +} + +export function getIdVariations(key: string): string[] { + return [key, `/${key}`, `\u0000${key}`, `/@id/${key}`]; +} + +export function isVirtualId(id: string, key: string): boolean { + return getIdVariations(key).includes(normalizeId(id)); +} + +export function findVirtualModule(id: string): VirtualModuleConfig | undefined { + return VIRTUAL_MODULES.find((module) => isVirtualId(id, module.key)); +} + +// ============================================================================ +// Code Transform Helpers +// ============================================================================ + +function injectImportIfMissing(code: string, key: string, hookName: string): string { + if (!code.includes(key)) { + return `import { ${hookName} } from '${key}';\n${code}`; + } + log('importing virtual qwik devtools', key, code); + return code; +} + +export function transformComponentFile(code: string, id: string): string { + // Inject useCollectHooks import + code = injectImportIfMissing(code, VIRTUAL_QWIK_DEVTOOLS_KEY, INNER_USE_HOOK); + // Parse and transform the Qwik code + return parseQwikCode(code, { path: id }); +} + +export function transformRootFile(code: string): string { + const mode = process.env.MODE; + const importPath = mode === 'dev' ? '@devtools/ui' : '@qwik.dev/devtools/ui'; + + // Add QwikDevtools import if not present + if (!code.includes(importPath)) { + code = `import { QwikDevtools } from '${importPath}';\n${code}`; + } + + // Inject QwikDevtools component before closing body tag + const bodyMatch = code.match(/]*>([\s\S]*?)<\/body>/); + if (bodyMatch) { + const bodyContent = bodyMatch[1]; + const newBodyContent = `${bodyContent}\n `; + code = code.replace(bodyContent, newBodyContent); + } + + return code; +} + diff --git a/packages/ui/package.json b/packages/ui/package.json index 85c7a37..11c2349 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -34,7 +34,7 @@ }, "peerDependencies": { "@devtools/plugin": "workspace:*", - "@qwik.dev/core": "2.0.0-beta.11", + "@qwik.dev/core": "2.0.0-beta.15", "@tailwindcss/postcss": "^4.1.14", "@tailwindcss/vite": "^4.1.14", "tailwindcss": "^4.1.14" @@ -53,7 +53,7 @@ "cpy-cli": "^5.0.0", "dree": "^5.1.5", "eslint": "8.57.0", - "eslint-plugin-qwik": "2.0.0-alpha.9", + "eslint-plugin-qwik": "2.0.0-beta.15", "nodemon": "^3.1.9", "np": "^8.0.4", "postcss": "^8.4.39", diff --git a/packages/ui/src/components/Tree/filterVnode.ts b/packages/ui/src/components/Tree/filterVnode.ts index d81dae5..82f11db 100644 --- a/packages/ui/src/components/Tree/filterVnode.ts +++ b/packages/ui/src/components/Tree/filterVnode.ts @@ -6,12 +6,12 @@ import { _vnode_getFirstChild, _vnode_isMaterialized, _vnode_isVirtualVNode, - QRL, } from '@qwik.dev/core/internal'; import { normalizeName } from './vnode'; import { htmlContainer } from '../../utils/location'; import { TreeNode } from './Tree'; import { QRENDERFN, QTYPE } from '@devtools/kit'; +import { QRLInternal } from '../../features/RenderTree/types'; let index = 0; @@ -65,7 +65,7 @@ function buildTreeRecursive( // We skip the QTYPE prop as it's for internal use. if (key === QTYPE) return; - const value = container.getHostProp(currentVNode!, key) as QRL; + const value = container.getHostProp(currentVNode!, key) as QRLInternal; // Update the underlying VNode props array and the new object's props. currentVNode?.setProp(key, value); vnodeObject.props![key] = currentVNode?.getAttr(key); diff --git a/packages/ui/src/devtools.tsx b/packages/ui/src/devtools.tsx index 61be4a8..f887059 100644 --- a/packages/ui/src/devtools.tsx +++ b/packages/ui/src/devtools.tsx @@ -13,6 +13,7 @@ import { HiMegaphoneMini, HiCubeOutline, HiCodeBracketSolid, + HiClockOutline } from '@qwikest/icons/heroicons'; import { BsDiagram3 } from '@qwikest/icons/bootstrap'; import { LuFolderTree } from '@qwikest/icons/lucide'; @@ -40,6 +41,7 @@ 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'); @@ -182,9 +184,12 @@ export const QwikDevtools = component$(() => { - + + + +
@@ -255,6 +260,12 @@ export const QwikDevtools = component$(() => { )} + {state.activeTab === 'performance' && ( + + + + + )} )} diff --git a/packages/ui/src/features/CodeBreack/HtmlParser.tsx b/packages/ui/src/features/CodeBreack/HtmlParser.tsx index 11462f0..2aa4788 100644 --- a/packages/ui/src/features/CodeBreack/HtmlParser.tsx +++ b/packages/ui/src/features/CodeBreack/HtmlParser.tsx @@ -6,7 +6,7 @@ import { useStyles$, } from '@qwik.dev/core'; import { _getDomContainer, _vnode_toString } from '@qwik.dev/core/internal'; -import { createHighlighter } from 'shiki'; +import { getHighlighter } from '../../utils/shiki'; export const HtmlParser = component$(() => { useStyles$(` @@ -73,10 +73,7 @@ export const HtmlParser = component$(() => { return; } if (!shikiRef.value) { - shikiRef.value = await createHighlighter({ - themes: ['nord'], - langs: ['html'], - }); + shikiRef.value = await getHighlighter(); } highlightedHtml.value = shikiRef.value.codeToHtml(htmlResult.value, { lang: 'html', @@ -98,7 +95,7 @@ export const HtmlParser = component$(() => {