diff --git a/.changeset/browser-extension.md b/.changeset/browser-extension.md new file mode 100644 index 0000000..7849e6f --- /dev/null +++ b/.changeset/browser-extension.md @@ -0,0 +1,7 @@ +--- +'@qwik.dev/devtools': minor +--- + +feat: add browser extension for Chrome and Firefox + +New browser extension package that brings Qwik DevTools to the browser's DevTools panel. Features real-time component tree, state inspection, element picker, hover highlight, live render events, and SPA navigation support. Works standalone or alongside the Vite plugin overlay. diff --git a/package.json b/package.json index 587050e..8d497b7 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,10 @@ "description": "Qwik devtools monorepo", "scripts": { "playground": "MODE=dev DEBUG=qwik:devtools:* pnpm --filter playground dev", + "ext:dev": "pnpm --filter @devtools/browser-extension dev", + "ext:dev:firefox": "pnpm --filter @devtools/browser-extension dev:firefox", + "ext:build": "pnpm --filter @devtools/browser-extension build", + "ext:build:firefox": "pnpm --filter @devtools/browser-extension build:firefox", "build": "tsx scripts/build-devtools.ts", "change": "changeset", "release": "changeset publish", diff --git a/packages/browser-extension/.gitignore b/packages/browser-extension/.gitignore new file mode 100644 index 0000000..2f4b010 --- /dev/null +++ b/packages/browser-extension/.gitignore @@ -0,0 +1,2 @@ +.output/ +node_modules/ diff --git a/packages/browser-extension/.wxt/tsconfig.json b/packages/browser-extension/.wxt/tsconfig.json new file mode 100644 index 0000000..6f2e680 --- /dev/null +++ b/packages/browser-extension/.wxt/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmit": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "strict": true, + "skipLibCheck": true, + "paths": { + "@": ["../src"], + "@/*": ["../src/*"], + "~": ["../src"], + "~/*": ["../src/*"], + "@@": [".."], + "@@/*": ["../*"], + "~~": [".."], + "~~/*": ["../*"] + } + }, + "include": [ + "../**/*", + "./wxt.d.ts" + ], + "exclude": ["../.output"] +} \ No newline at end of file diff --git a/packages/browser-extension/.wxt/types/globals.d.ts b/packages/browser-extension/.wxt/types/globals.d.ts new file mode 100644 index 0000000..b8aa6c7 --- /dev/null +++ b/packages/browser-extension/.wxt/types/globals.d.ts @@ -0,0 +1,15 @@ +// Generated by wxt +interface ImportMetaEnv { + readonly MANIFEST_VERSION: 2 | 3; + readonly BROWSER: string; + readonly CHROME: boolean; + readonly FIREFOX: boolean; + readonly SAFARI: boolean; + readonly EDGE: boolean; + readonly OPERA: boolean; + readonly COMMAND: "build" | "serve"; + readonly ENTRYPOINT: string; +} +interface ImportMeta { + readonly env: ImportMetaEnv +} diff --git a/packages/browser-extension/.wxt/types/i18n.d.ts b/packages/browser-extension/.wxt/types/i18n.d.ts new file mode 100644 index 0000000..0a0577a --- /dev/null +++ b/packages/browser-extension/.wxt/types/i18n.d.ts @@ -0,0 +1,81 @@ +// Generated by wxt +import "wxt/browser"; + +declare module "wxt/browser" { + /** + * See https://developer.chrome.com/docs/extensions/reference/i18n/#method-getMessage + */ + interface GetMessageOptions { + /** + * See https://developer.chrome.com/docs/extensions/reference/i18n/#method-getMessage + */ + escapeLt?: boolean + } + + export interface WxtI18n extends I18n.Static { + /** + * The extension or app ID; you might use this string to construct URLs for resources inside the extension. Even unlocalized extensions can use this message. + * Note: You can't use this message in a manifest file. + * + * "" + */ + getMessage( + messageName: "@@extension_id", + substitutions?: string | string[], + options?: GetMessageOptions, + ): string; + /** + * "" + */ + getMessage( + messageName: "@@ui_locale", + substitutions?: string | string[], + options?: GetMessageOptions, + ): string; + /** + * The text direction for the current locale, either "ltr" for left-to-right languages such as English or "rtl" for right-to-left languages such as Japanese. + * + * "" + */ + getMessage( + messageName: "@@bidi_dir", + substitutions?: string | string[], + options?: GetMessageOptions, + ): string; + /** + * If the @@bidi_dir is "ltr", then this is "rtl"; otherwise, it's "ltr". + * + * "" + */ + getMessage( + messageName: "@@bidi_reversed_dir", + substitutions?: string | string[], + options?: GetMessageOptions, + ): string; + /** + * If the @@bidi_dir is "ltr", then this is "left"; otherwise, it's "right". + * + * "" + */ + getMessage( + messageName: "@@bidi_start_edge", + substitutions?: string | string[], + options?: GetMessageOptions, + ): string; + /** + * If the @@bidi_dir is "ltr", then this is "right"; otherwise, it's "left". + * + * "" + */ + getMessage( + messageName: "@@bidi_end_edge", + substitutions?: string | string[], + options?: GetMessageOptions, + ): string; + getMessage( + messageName: "@@extension_id" | "@@ui_locale" | "@@bidi_dir" | "@@bidi_reversed_dir" | "@@bidi_start_edge" | "@@bidi_end_edge", + substitutions?: string | string[], + options?: GetMessageOptions, + ): string; + } +} diff --git a/packages/browser-extension/.wxt/types/imports-module.d.ts b/packages/browser-extension/.wxt/types/imports-module.d.ts new file mode 100644 index 0000000..ac0c88e --- /dev/null +++ b/packages/browser-extension/.wxt/types/imports-module.d.ts @@ -0,0 +1,20 @@ +// Generated by wxt +// Types for the #import virtual module +declare module '#imports' { + export { browser, Browser } from 'wxt/browser'; + export { storage, StorageArea, WxtStorage, WxtStorageItem, StorageItemKey, StorageAreaChanges, MigrationError } from 'wxt/utils/storage'; + export { getAppConfig, useAppConfig } from 'wxt/utils/app-config'; + export { ContentScriptContext, WxtWindowEventMap } from 'wxt/utils/content-script-context'; + export { createIframeUi, IframeContentScriptUi, IframeContentScriptUiOptions } from 'wxt/utils/content-script-ui/iframe'; + export { createIntegratedUi, IntegratedContentScriptUi, IntegratedContentScriptUiOptions } from 'wxt/utils/content-script-ui/integrated'; + export { createShadowRootUi, ShadowRootContentScriptUi, ShadowRootContentScriptUiOptions } from 'wxt/utils/content-script-ui/shadow-root'; + export { ContentScriptUi, ContentScriptUiOptions, ContentScriptOverlayAlignment, ContentScriptAppendMode, ContentScriptInlinePositioningOptions, ContentScriptOverlayPositioningOptions, ContentScriptModalPositioningOptions, ContentScriptPositioningOptions, ContentScriptAnchoredOptions, AutoMountOptions, StopAutoMount, AutoMount } from 'wxt/utils/content-script-ui/types'; + export { defineAppConfig, WxtAppConfig } from 'wxt/utils/define-app-config'; + export { defineBackground } from 'wxt/utils/define-background'; + export { defineContentScript } from 'wxt/utils/define-content-script'; + export { defineUnlistedScript } from 'wxt/utils/define-unlisted-script'; + export { defineWxtPlugin } from 'wxt/utils/define-wxt-plugin'; + export { injectScript, ScriptPublicPath, InjectScriptOptions } from 'wxt/utils/inject-script'; + export { InvalidMatchPattern, MatchPattern } from 'wxt/utils/match-patterns'; + export { fakeBrowser } from 'wxt/testing'; +} diff --git a/packages/browser-extension/.wxt/types/paths.d.ts b/packages/browser-extension/.wxt/types/paths.d.ts new file mode 100644 index 0000000..4596a83 --- /dev/null +++ b/packages/browser-extension/.wxt/types/paths.d.ts @@ -0,0 +1,27 @@ +// Generated by wxt +import "wxt/browser"; + +declare module "wxt/browser" { + export type PublicPath = + | "" + | "/" + | "/background.js" + | "/content-scripts/content.js" + | "/devtools-hook.js" + | "/devtools.html" + | "/icon-128.png" + | "/icon-16.png" + | "/icon-32.png" + | "/icon-48.png" + | "/inspect-hook.js" + | "/nav-hook.js" + | "/panel.html" + | "/qwikloader.js" + | "/theme-init.js" + | "/vnode-bridge.js" + type HtmlPublicPath = Extract + export interface WxtRuntime { + getURL(path: PublicPath): string; + getURL(path: `${HtmlPublicPath}${string}`): string; + } +} diff --git a/packages/browser-extension/.wxt/wxt.d.ts b/packages/browser-extension/.wxt/wxt.d.ts new file mode 100644 index 0000000..3ba1725 --- /dev/null +++ b/packages/browser-extension/.wxt/wxt.d.ts @@ -0,0 +1,6 @@ +// Generated by wxt +/// +/// +/// +/// +/// diff --git a/packages/browser-extension/package.json b/packages/browser-extension/package.json new file mode 100644 index 0000000..83bff6a --- /dev/null +++ b/packages/browser-extension/package.json @@ -0,0 +1,28 @@ +{ + "name": "@devtools/browser-extension", + "description": "Qwik DevTools browser extension for Chrome and Firefox", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "wxt", + "dev:firefox": "wxt --browser firefox", + "build": "wxt build", + "build:firefox": "wxt build --browser firefox", + "zip": "wxt zip", + "zip:firefox": "wxt zip --browser firefox", + "test": "vitest run", + "test:watch": "vitest", + "lint": "eslint src/" + }, + "dependencies": { + "lit": "^3.3.2", + "wxt": "^0.20.20" + }, + "devDependencies": { + "@devtools/kit": "workspace:*", + "@types/chrome": "^0.1.38", + "typescript": "5.9.3", + "vitest": "^4.1.0" + } +} diff --git a/packages/browser-extension/public/devtools-hook.js b/packages/browser-extension/public/devtools-hook.js new file mode 100644 index 0000000..82eff37 --- /dev/null +++ b/packages/browser-extension/public/devtools-hook.js @@ -0,0 +1,194 @@ +/** + * Devtools hook runtime - injected by the browser extension into the main world. + * Sets up window.__QWIK_DEVTOOLS_HOOK__ with signal tracking, component snapshots, + * and state editing. Skips if the Vite plugin already installed the hook. + * + * NOTE: This duplicates logic from plugin/virtualmodules/hookRuntime.ts. + * Both must stay in sync. The duplication is intentional: the plugin injects + * via SSR middleware, while the extension injects via content script. + * + * This is a plain script (no ES module imports needed). + */ +(function () { + 'use strict'; + if (typeof window === 'undefined' || window.__QWIK_DEVTOOLS_HOOK__) return; + + var renderListeners = []; + + var signalTypes = { + useSignal: true, + useStore: true, + useComputed: true, + useAsyncComputed: true, + useContext: true, + }; + + function safeSerialize(val) { + if (val === null || val === undefined) return val; + var t = typeof val; + if (t === 'string' || t === 'number' || t === 'boolean') return val; + if (t === 'function') return '[Function]'; + try { return JSON.parse(JSON.stringify(val)); } catch (_) { return '[' + t + ']'; } + } + + function deepSerialize(val, depth) { + if (depth > 6) return '[depth limit]'; + if (val === null) return null; + if (val === undefined) return undefined; + var t = typeof val; + if (t === 'string' || t === 'number' || t === 'boolean') return val; + if (t === 'function') return { __type: 'function', __name: val.name || 'anonymous' }; + try { + if (val && t === 'object' && '$untrackedValue$' in val) { + return deepSerialize(val.$untrackedValue$, depth + 1); + } + if (Array.isArray(val)) { + return val.map(function (item) { return deepSerialize(item, depth + 1); }); + } + if (t === 'object') { + var className = val.constructor ? val.constructor.name : 'Object'; + var result = {}; + if (className !== 'Object') { + result.__className = className; + try { + if (typeof val.toString === 'function' && val.toString !== Object.prototype.toString) { + result.__display = val.toString(); + } + } catch (_) {} + } + var keys = Object.keys(val); + for (var i = 0; i < keys.length; i++) { + var key = keys[i]; + if (key.charAt(0) === '$' && key.charAt(key.length - 1) === '$') continue; + try { result[key] = deepSerialize(val[key], depth + 1); } catch (_) { result[key] = '[unreadable]'; } + } + return result; + } + } catch (_) {} + return String(val); + } + + function readValue(ref) { + try { + if (ref && typeof ref === 'object' && 'value' in ref) return safeSerialize(ref.value); + if (ref && typeof ref === 'object') return safeSerialize(ref); + return undefined; + } catch (_) { return '[error]'; } + } + + function findComponentKey(componentName, qrlChunk) { + var state = window.QWIK_DEVTOOLS_GLOBAL_STATE; + if (!state) return null; + var keys = Object.keys(state); + if (qrlChunk) { + for (var i = 0; i < keys.length; i++) { + if (keys[i].endsWith(qrlChunk)) return keys[i]; + } + } + var lowerName = componentName.toLowerCase(); + for (var j = 0; j < keys.length; j++) { + var lastSeg = keys[j].split('/').pop() || keys[j]; + var underIdx = lastSeg.lastIndexOf('_'); + var name = underIdx > 0 ? lastSeg.substring(underIdx + 1) : lastSeg; + if (name.toLowerCase() === lowerName) return keys[j]; + } + return null; + } + + window.__QWIK_DEVTOOLS_HOOK__ = { + version: 1, + + _emitRender: function (info) { + for (var i = 0; i < renderListeners.length; i++) { + try { renderListeners[i](info); } catch (_) {} + } + }, + + getSignalValue: function (signal) { + if (signal && typeof signal === 'object' && 'value' in signal) return signal.value; + return undefined; + }, + + getSignalsSnapshot: function () { + var state = window.QWIK_DEVTOOLS_GLOBAL_STATE; + if (!state) return {}; + var snapshot = {}; + var paths = Object.keys(state); + for (var p = 0; p < paths.length; p++) { + var hooks = state[paths[p]].hooks || []; + var signals = []; + for (var h = 0; h < hooks.length; h++) { + if (signalTypes[hooks[h].hookType] && hooks[h].data != null) { + signals.push({ name: hooks[h].variableName || '', hookType: hooks[h].hookType, value: readValue(hooks[h].data) }); + } + } + if (signals.length > 0) snapshot[paths[p]] = signals; + } + return snapshot; + }, + + getComponentTreeSnapshot: function () { + var state = window.QWIK_DEVTOOLS_GLOBAL_STATE; + if (!state) return []; + return Object.keys(state).map(function (path) { + var comp = state[path]; + var hooks = comp.hooks || []; + var lastSeg = path.split('/').pop() || path; + var underIdx = lastSeg.lastIndexOf('_'); + var name = underIdx > 0 ? lastSeg.substring(underIdx + 1) : lastSeg; + var signals = []; + var hookEntries = []; + for (var i = 0; i < hooks.length; i++) { + hookEntries.push({ variableName: hooks[i].variableName || '', hookType: hooks[i].hookType || '', category: hooks[i].category || '' }); + if (signalTypes[hooks[i].hookType] && hooks[i].data != null) { + signals.push({ name: hooks[i].variableName || '', hookType: hooks[i].hookType, value: readValue(hooks[i].data) }); + } + } + return { path: path, name: name, signals: signals, hooks: hookEntries }; + }); + }, + + onRender: function (callback) { + renderListeners.push(callback); + return function () { + var idx = renderListeners.indexOf(callback); + if (idx >= 0) renderListeners.splice(idx, 1); + }; + }, + + getComponentDetail: function (componentName, qrlChunk) { + var key = findComponentKey(componentName, qrlChunk); + if (!key) return null; + var state = window.QWIK_DEVTOOLS_GLOBAL_STATE; + var comp = state[key]; + if (!comp || !comp.hooks) return null; + var result = []; + for (var i = 0; i < comp.hooks.length; i++) { + var h = comp.hooks[i]; + if (h.data != null) { + result.push({ hookType: h.hookType || 'unknown', variableName: h.variableName || h.hookType || 'unknown', data: deepSerialize(h.data, 0) }); + } + } + return result; + }, + + setSignalValue: function (componentName, qrlChunk, variableName, newValue) { + var key = findComponentKey(componentName, qrlChunk); + if (!key) return false; + var state = window.QWIK_DEVTOOLS_GLOBAL_STATE; + var comp = state[key]; + if (!comp || !comp.hooks) return false; + for (var i = 0; i < comp.hooks.length; i++) { + var h = comp.hooks[i]; + if (h.variableName === variableName && h.data != null) { + try { + if (typeof h.data === 'object' && 'value' in h.data) { h.data.value = newValue; return true; } + } catch (_) {} + } + } + return false; + }, + + onSignalUpdate: function () { return function () {}; }, + }; +})(); diff --git a/packages/browser-extension/public/icon-128.png b/packages/browser-extension/public/icon-128.png new file mode 100644 index 0000000..92f7d39 Binary files /dev/null and b/packages/browser-extension/public/icon-128.png differ diff --git a/packages/browser-extension/public/icon-16.png b/packages/browser-extension/public/icon-16.png new file mode 100644 index 0000000..196b856 Binary files /dev/null and b/packages/browser-extension/public/icon-16.png differ diff --git a/packages/browser-extension/public/icon-32.png b/packages/browser-extension/public/icon-32.png new file mode 100644 index 0000000..f9b87b9 Binary files /dev/null and b/packages/browser-extension/public/icon-32.png differ diff --git a/packages/browser-extension/public/icon-48.png b/packages/browser-extension/public/icon-48.png new file mode 100644 index 0000000..8c1544f Binary files /dev/null and b/packages/browser-extension/public/icon-48.png differ diff --git a/packages/browser-extension/public/inspect-hook.js b/packages/browser-extension/public/inspect-hook.js new file mode 100644 index 0000000..c264d35 --- /dev/null +++ b/packages/browser-extension/public/inspect-hook.js @@ -0,0 +1,106 @@ +/** + * Main-world element picker for Qwik DevTools. + * + * Injected into the inspected page's main world by the content script. + * Must run in the SAME world as qwikloader so that + * `stopImmediatePropagation` actually blocks Qwik's event handlers. + * + * Lifecycle: + * 1. Content script posts `__QWIK_DT_INSPECT_START` to activate. + * 2. User clicks an element on the page. + * 3. This script intercepts the click (window capture, before qwikloader), + * resolves the nearest Qwik component, and posts + * `__QWIK_DT_ELEMENT_PICKED` back to the content script. + * 4. Content script forwards the result to the devtools panel. + * + * Why window capture? + * Qwik's qwikloader registers its click handler on `document` in capture. + * Capture order is: window > document > html > body > ... > target. + * By listening on `window` we fire BEFORE qwikloader. + */ +(function () { + /** @type {boolean} Whether the picker is currently active. */ + let active = false; + + /** + * Listen for activation/deactivation messages from the content script. + * @param {MessageEvent} e + */ + window.addEventListener('message', function (e) { + if (e.source !== window) return; + if (e.data && e.data.type === '__QWIK_DT_INSPECT_START') active = true; + if (e.data && e.data.type === '__QWIK_DT_INSPECT_STOP') active = false; + }); + + /** + * Walk up the DOM to find the nearest element with a Qwik binding attribute. + * @param {Element} el - Starting element. + * @returns {Element|null} Nearest Qwik-managed ancestor, or null. + */ + function findQwikAncestor(el) { + let current = el; + while (current) { + if (current.getAttribute && (current.hasAttribute('q:id') || current.hasAttribute(':'))) { + return current; + } + current = current.parentElement; + } + return null; + } + + /** + * Try to resolve a DOM element to its owning Qwik component's tree node ID + * using the devtools hook installed by the Vite plugin. + * @param {Element} target - The clicked DOM element. + * @returns {string|null} Tree node ID (e.g. "vnode-5"), or null. + */ + function resolveComponentId(target) { + try { + const hook = window.__QWIK_DEVTOOLS_HOOK__; + if (hook && typeof hook.resolveElementToComponent === 'function') { + return hook.resolveElementToComponent(target); + } + } catch (_) {} + return null; + } + + // Click interceptor (window capture - fires before qwikloader) + window.addEventListener( + 'click', + function (e) { + if (!active) return; + e.preventDefault(); + e.stopImmediatePropagation(); + e.stopPropagation(); + active = false; + + const qwikEl = findQwikAncestor(e.target); + + /** @type {{ type: string, qId: string|null, colonId: string|null, treeNodeId: string|null }} */ + const msg = { + type: '__QWIK_DT_ELEMENT_PICKED', + qId: qwikEl ? qwikEl.getAttribute('q:id') : null, + colonId: qwikEl ? qwikEl.getAttribute(':') : null, + treeNodeId: resolveComponentId(e.target), + }; + window.postMessage(msg, '*'); + return false; + }, + true + ); + + // Block mousedown/mouseup to prevent focus, text selection, and button activation. + ['mousedown', 'mouseup'].forEach(function (eventName) { + window.addEventListener( + eventName, + function (e) { + if (!active) return; + e.preventDefault(); + e.stopImmediatePropagation(); + e.stopPropagation(); + return false; + }, + true + ); + }); +})(); diff --git a/packages/browser-extension/public/nav-hook.js b/packages/browser-extension/public/nav-hook.js new file mode 100644 index 0000000..8a91896 --- /dev/null +++ b/packages/browser-extension/public/nav-hook.js @@ -0,0 +1,27 @@ +/** + * Main-world SPA navigation hook for Qwik DevTools. + * + * Injected into the inspected page's main world by the content script. + * Monkey-patches `history.pushState` and `history.replaceState` to post + * a `__QWIK_DT_NAV` message that the content script listens for. + * + * This is necessary because the `popstate` event only fires on + * back/forward navigation, not on programmatic pushState/replaceState + * calls (which Qwik Router uses for SPA transitions). + */ +(function () { + /** @type {typeof history.pushState} */ + const originalPush = history.pushState.bind(history); + /** @type {typeof history.replaceState} */ + const originalReplace = history.replaceState.bind(history); + + history.pushState = function () { + originalPush.apply(this, arguments); + window.postMessage({ type: '__QWIK_DT_NAV' }, '*'); + }; + + history.replaceState = function () { + originalReplace.apply(this, arguments); + window.postMessage({ type: '__QWIK_DT_NAV' }, '*'); + }; +})(); diff --git a/packages/browser-extension/public/qwikloader.js b/packages/browser-extension/public/qwikloader.js new file mode 100644 index 0000000..5b808fd --- /dev/null +++ b/packages/browser-extension/public/qwikloader.js @@ -0,0 +1 @@ +const e=document,t=window,n="w",o="d",r=new Set,s=new Set([e]),i=new Map;let a,c;const l=(e,t)=>Array.from(e.querySelectorAll(t)),q=e=>{const t=[];return s.forEach(n=>t.push(...l(n,e))),t},d=(e,t,n,o=!1)=>e.addEventListener(t,n,{capture:o,passive:!1}),b=e=>{_(e);const t=l(e,"[q\\:shadowroot]");for(let e=0;ee&&"function"==typeof e.then,p=t=>{if(void 0===t._qwikjson_){let n=(t===e.documentElement?e.body:t).lastElementChild;for(;n;){if("SCRIPT"===n.tagName&&"qwik/json"===n.getAttribute("type")){t._qwikjson_=JSON.parse(n.textContent.replace(/\\x3C(\/?script)/gi,"<$1"));break}n=n.previousElementSibling}}},u=(e,t)=>new CustomEvent(e,{detail:t}),h=(t,n)=>{e.dispatchEvent(u(t,n))},g=e=>e.replace(/([A-Z-])/g,e=>"-"+e.toLowerCase()),m=e=>e.replace(/-./g,e=>e[1].toUpperCase()),v=e=>({scope:e.charAt(0),eventName:m(e.slice(2))}),w=async(t,n,o,r)=>{r&&(t.hasAttribute("preventdefault:"+r)&&n.preventDefault(),t.hasAttribute("stoppropagation:"+r)&&n.stopPropagation());const s=t._qDispatch?.[o];if(s){if("function"==typeof s){const e=s(n,t);f(e)&&await e}else if(s.length)for(let e=0;e{const t=g(e.type),n="e:"+t;let o=e.target;for(;o&&o.getAttribute;){const r=w(o,e,n,t),s=e.bubbles&&!e.cancelBubble;f(r)&&await r,o=s&&e.bubbles&&!e.cancelBubble?o.parentElement:null}},A=(e,t)=>{const n=g(t.type),o=e+":"+n,r=q("[q-"+e+"\\:"+n+"]");for(let e=0;e{A(o,e)},C=e=>{A(n,e)},k=()=>{const n=e.readyState;if("interactive"==n||"complete"==n){if(c=1,s.forEach(b),r.has("d:qinit")){r.delete("d:qinit");const e=u("qinit"),t=q("[q-d\\:qinit]");for(let n=0;n{const e=u("qidle"),t=q("[q-d\\:qidle]");for(let n=0;n{for(let t=0;t{for(let i=0;id(t,i,e===o?E:y,!0)),1!==c||"e:qvisible"!==a&&"d:qinit"!==a&&"d:qidle"!==a||k()}}else s.has(a)||(r.forEach(e=>{const{scope:t,eventName:r}=v(e);t!==n&&d(a,r,t===o?E:y,!0)}),s.add(a))}},S=t._qwikEv;S?.roots||(Array.isArray(S)?_(...S):_("e:click","e:input"),t._qwikEv={events:r,roots:s,push:_},d(e,"readystatechange",k),k()); \ No newline at end of file diff --git a/packages/browser-extension/public/theme-init.js b/packages/browser-extension/public/theme-init.js new file mode 100644 index 0000000..820095d --- /dev/null +++ b/packages/browser-extension/public/theme-init.js @@ -0,0 +1,6 @@ +(function() { + var theme = localStorage.getItem('vueuse-color-scheme') || 'dark'; + if (theme !== 'auto') { + document.documentElement.setAttribute('data-theme', theme); + } +})(); diff --git a/packages/browser-extension/public/vnode-bridge.js b/packages/browser-extension/public/vnode-bridge.js new file mode 100644 index 0000000..42eb6dd --- /dev/null +++ b/packages/browser-extension/public/vnode-bridge.js @@ -0,0 +1,274 @@ +/** + * VNode bridge - injected by the browser extension as an ES module. + * Bridges Qwik VNode internals to the devtools hook. + * + * NOTE: This duplicates logic from plugin/virtualmodules/vnodeBridge.ts + * and the EVAL_INSTALL_BRIDGE in extension-data-provider.ts. All three + * must stay in sync. The duplication exists because each runs in a + * different context (Vite SSR, ES module, inspectedWindow.eval). + * + * Requires @qwik.dev/core/internal to be resolvable (works in dev mode + * where Vite serves bare module imports). + * + * Skips silently if the Vite plugin already set up the bridge + * (checks hook.getVNodeTree existence). + */ +import { + _getDomContainer, + _vnode_getFirstChild, + _vnode_isVirtualVNode, + _vnode_isMaterialized, + _vnode_getAttrKeys, +} from '@qwik.dev/core/internal'; + +var QRENDERFN = 'q:renderFn'; +var QPROPS = 'q:props'; +var QTYPE = 'q:type'; +var _idx = 0; +var _vnodeMap = {}; + +function serializeProps(val, depth) { + if (depth > 4) return '[depth]'; + if (val === null || val === undefined) return val; + var t = typeof val; + if (t === 'string' || t === 'number' || t === 'boolean') return val; + if (t === 'function') return '[Function]'; + try { + if (Array.isArray(val)) { + return val.map(function(item) { return serializeProps(item, depth + 1); }); + } + if (t === 'object') { + if ('$chunk$' in val || '$symbol$' in val) return '[QRL]'; + if ('$untrackedValue$' in val) return serializeProps(val.$untrackedValue$, depth + 1); + var result = {}; + var keys = Object.keys(val); + for (var i = 0; i < keys.length; i++) { + var key = keys[i]; + if (key.startsWith('$') && key.endsWith('$')) continue; + try { result[key] = serializeProps(val[key], depth + 1); } catch(_) { result[key] = '[error]'; } + } + return result; + } + } catch(_) {} + return String(val); +} + +function normalizeName(str) { + var parts = str.split('_'); + var name = parts[0] || ''; + return name.charAt(0).toUpperCase() + name.slice(1).toLowerCase(); +} + +function buildTree(container, vnode) { + if (!vnode) return []; + var result = []; + var current = vnode; + while (current) { + var isVirtual = _vnode_isVirtualVNode(current); + var renderFn = isVirtual ? container.getHostProp(current, QRENDERFN) : null; + var isComponent = isVirtual && typeof renderFn === 'function'; + if (isComponent) { + var name = 'Component'; + var qId = ''; + var colonId = ''; + try { + var keys = _vnode_getAttrKeys(container, current); + for (var i = 0; i < keys.length; i++) { + if (keys[i] === QTYPE) continue; + if (keys[i] === 'q:id') qId = String(container.getHostProp(current, 'q:id') || ''); + if (keys[i] === ':') colonId = String(container.getHostProp(current, ':') || ''); + } + if (renderFn.getSymbol) name = normalizeName(renderFn.getSymbol()); + else if (renderFn.$symbol$) name = normalizeName(renderFn.$symbol$); + } catch (_) {} + var qrlChunk = ''; + try { + var chunk = renderFn.$chunk$ || ''; + var splitPoint = '_component'; + var idx = chunk.indexOf(splitPoint); + qrlChunk = idx > 0 ? chunk.substring(0, idx) : chunk; + } catch (_) {} + var children = []; + var firstChild = _vnode_getFirstChild(current); + if (firstChild) children = buildTree(container, firstChild); + var nodeProps = qId ? { 'q:id': qId } : {}; + if (colonId) nodeProps.__colonId = colonId; + if (qrlChunk) nodeProps.__qrlChunk = qrlChunk; + var nodeId = qId ? ('q-' + qId) : ('vnode-' + (_idx++)); + _vnodeMap[nodeId] = { vnode: current, container: container }; + result.push({ name: name, id: nodeId, label: name, props: nodeProps, children: children.length > 0 ? children : undefined }); + } else if (_vnode_isMaterialized(current) || (isVirtual && !isComponent)) { + var fc = _vnode_getFirstChild(current); + if (fc) { + var nested = buildTree(container, fc); + for (var j = 0; j < nested.length; j++) result.push(nested[j]); + } + } + current = current.nextSibling || null; + } + return result; +} + +function getTree() { + try { + _idx = 0; + _vnodeMap = {}; + var container = _getDomContainer(document.documentElement); + if (!container || !container.rootVNode) return null; + var tree = buildTree(container, container.rootVNode); + return filterDevtools(tree); + } catch (e) { + return null; + } +} + +function filterDevtools(nodes) { + var result = []; + for (var i = 0; i < nodes.length; i++) { + var n = nodes[i]; + if (n.name === 'Qwikdevtools' || n.name === 'Devtoolscontainer') continue; + if (n.children) { + n = { name: n.name, id: n.id, label: n.label, props: n.props, children: filterDevtools(n.children) }; + if (n.children.length === 0) delete n.children; + } + result.push(n); + } + return result; +} + +function setupBridge() { + if (typeof window === 'undefined') return; + var hook = window.__QWIK_DEVTOOLS_HOOK__; + if (!hook) return; + // Skip if Vite plugin already set up the bridge + if (typeof hook.getVNodeTree === 'function') return; + + hook.getVNodeTree = getTree; + + hook.resolveElementToComponent = function(el) { + if (!el) return null; + var cur = el; + while (cur) { + var inspector = cur.getAttribute ? cur.getAttribute('data-qwik-inspector') : null; + if (inspector) { + var parts = inspector.split('/'); + var fileName = (parts[parts.length - 1] || '').split(':')[0]; + var compName = fileName.replace(/\.(tsx|ts|jsx|js)$/, ''); + if (compName) { + for (var id in _vnodeMap) { + var entry = _vnodeMap[id]; + try { + var renderFn = entry.container.getHostProp(entry.vnode, QRENDERFN); + if (typeof renderFn === 'function') { + var sym = renderFn.getSymbol ? renderFn.getSymbol() : (renderFn.$symbol$ || ''); + var nodeName = normalizeName(sym); + if (nodeName.toLowerCase() === compName.toLowerCase()) return id; + } + } catch(_) {} + } + } + } + cur = cur.parentElement; + } + return null; + }; + + function findDomElement(vnode) { + if (!vnode) return null; + if (!_vnode_isVirtualVNode(vnode) || vnode.node) return vnode.node || null; + var child = _vnode_getFirstChild(vnode); + while (child) { + var el = findDomElement(child); + if (el) return el; + child = child.nextSibling || null; + } + return null; + } + + hook.getElementRect = function(nodeId) { + var entry = _vnodeMap[nodeId]; + if (!entry) return null; + try { + var el = findDomElement(entry.vnode); + if (!el) return null; + var r = el.getBoundingClientRect(); + return { top: r.top, left: r.left, width: r.width, height: r.height }; + } catch(_) { return null; } + }; + + hook.highlightNode = function(nodeId, name) { + var entry = _vnodeMap[nodeId]; + if (!entry) return false; + try { + var el = findDomElement(entry.vnode); + if (!el) return false; + var ov = document.getElementById('__qwik_dt_hover_ov'); + if (!ov) { + ov = document.createElement('div'); + ov.id = '__qwik_dt_hover_ov'; + ov.style.cssText = 'position:fixed;pointer-events:none;border:2px solid #8b5cf6;background:rgba(139,92,246,0.08);z-index:2147483646;border-radius:4px;transition:all 0.15s ease'; + var lbl = document.createElement('div'); + lbl.id = '__qwik_dt_hover_lbl'; + lbl.style.cssText = 'position:absolute;top:-20px;left:-2px;background:#8b5cf6;color:#fff;font-size:10px;padding:1px 6px;border-radius:3px 3px 0 0;white-space:nowrap;font-family:system-ui,sans-serif'; + ov.appendChild(lbl); + document.body.appendChild(ov); + } + var r = el.getBoundingClientRect(); + ov.style.display = 'block'; + ov.style.top = r.top + 'px'; + ov.style.left = r.left + 'px'; + ov.style.width = r.width + 'px'; + ov.style.height = r.height + 'px'; + var lbl2 = document.getElementById('__qwik_dt_hover_lbl'); + if (lbl2) lbl2.textContent = '<' + (name || 'Component') + ' />'; + return true; + } catch(_) { return false; } + }; + + hook.unhighlightNode = function() { + var ov = document.getElementById('__qwik_dt_hover_ov'); + if (ov) ov.style.display = 'none'; + }; + + hook.getNodeProps = function(nodeId) { + var entry = _vnodeMap[nodeId]; + if (!entry) return null; + try { + var props = entry.container.getHostProp(entry.vnode, QPROPS); + if (!props) return null; + var result = {}; + var keys = Object.keys(props); + for (var i = 0; i < keys.length; i++) { + var key = keys[i]; + if (key.startsWith('on:') || key.startsWith('on$:')) continue; + try { result[key] = serializeProps(props[key], 0); } catch(_) { result[key] = '[error]'; } + } + return result; + } catch(_) { return null; } + }; + + // Real-time tree push via MutationObserver + var debounceTimer = null; + function pushTree() { + var tree = getTree(); + if (!tree) return; + window.postMessage({ source: 'qwik-devtools', type: 'COMPONENT_TREE_UPDATE', tree: tree }, '*'); + } + var observer = new MutationObserver(function() { + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(pushTree, 100); + }); + observer.observe(document.documentElement, { + childList: true, subtree: true, characterData: true, + attributes: true, attributeFilter: ['q:id', 'q:key', ':='] + }); + pushTree(); +} + +if (typeof window !== 'undefined') { + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', setupBridge); + } else { + setupBridge(); + } +} diff --git a/packages/browser-extension/src/entrypoints/background.ts b/packages/browser-extension/src/entrypoints/background.ts new file mode 100644 index 0000000..0ea3606 --- /dev/null +++ b/packages/browser-extension/src/entrypoints/background.ts @@ -0,0 +1,71 @@ +import { browser } from 'wxt/browser'; +import { isExtensionMessage } from '../lib/types.js'; + +export default defineBackground(() => { + const devtoolsPorts = new Map(); + + browser.runtime.onConnect.addListener((port) => { + if (!port.name.startsWith('devtools-')) return; + + const segments = port.name.split('-'); + if (segments.length < 2) return; + const tabId = parseInt(segments[1], 10); + if (Number.isNaN(tabId)) return; + + devtoolsPorts.set(tabId, port); + + port.onDisconnect.addListener(() => { + devtoolsPorts.delete(tabId); + }); + + port.onMessage.addListener((msg: unknown) => { + if (!isExtensionMessage(msg)) return; + + browser.tabs + .sendMessage(tabId, msg) + .then((response: unknown) => { + try { + port.postMessage(response); + } catch (err) { + console.debug('[Qwik DevTools] background relay error:', err); + } + }) + .catch((err: unknown) => { + try { + port.postMessage({ + type: `${msg.type}_ERROR`, + payload: { error: String(err) }, + }); + } catch { + // port disconnected, nothing to do + } + }); + }); + }); + + // Forward content-script-initiated messages to the corresponding panel + browser.runtime.onMessage.addListener((msg: unknown, sender) => { + if (!isExtensionMessage(msg)) return; + const tabId = sender.tab?.id; + if (tabId !== undefined && devtoolsPorts.has(tabId)) { + try { + devtoolsPorts.get(tabId)?.postMessage(msg); + } catch { + // port disconnected + } + } + }); + + // Notify panel on full-page navigations (non-SPA) + browser.tabs.onUpdated.addListener( + (tabId: number, changeInfo: { status?: string }) => { + if (changeInfo.status === 'complete' && devtoolsPorts.has(tabId)) { + try { + devtoolsPorts.get(tabId)?.postMessage({ type: 'PAGE_CHANGED' }); + } catch { + // port disconnected + } + } + }, + ); +}); diff --git a/packages/browser-extension/src/entrypoints/content.ts b/packages/browser-extension/src/entrypoints/content.ts new file mode 100644 index 0000000..75bed7b --- /dev/null +++ b/packages/browser-extension/src/entrypoints/content.ts @@ -0,0 +1,338 @@ +import { browser } from 'wxt/browser'; +import { QWIK_ATTR, QWIK_CONTAINER_SELECTOR, V2_BINDING_ATTR } from '../lib/constants.js'; +import type { ExtensionMessage, QwikComponentNode, QwikContainerInfo } from '../lib/types.js'; +import { isExtensionMessage } from '../lib/types.js'; + +export default defineContentScript({ + matches: [''], + runAt: 'document_idle', + main() { + let inspectOverlay: HTMLDivElement | null = null; + let inspectActive = false; + + /** Qwik detection */ + function detectQwik(): QwikContainerInfo { + const containers = document.querySelectorAll(QWIK_CONTAINER_SELECTOR); + if (containers.length === 0) { + return { + detected: false, + version: null, + renderMode: null, + containerState: null, + base: null, + manifestHash: null, + containerCount: 0, + runtime: null, + }; + } + + const el = containers[0]; + return { + detected: true, + version: el.getAttribute(QWIK_ATTR.VERSION), + renderMode: el.getAttribute(QWIK_ATTR.RENDER), + containerState: el.getAttribute(QWIK_ATTR.CONTAINER), + base: el.getAttribute(QWIK_ATTR.BASE), + manifestHash: el.getAttribute(QWIK_ATTR.MANIFEST_HASH), + containerCount: containers.length, + runtime: el.getAttribute(QWIK_ATTR.RUNTIME), + }; + } + + /** Element picker */ + function createOverlay(): HTMLDivElement { + const overlay = document.createElement('div'); + overlay.id = '__qwik_devtools_overlay'; + overlay.style.cssText = ` + position: fixed; + pointer-events: none; + border: 2px solid #8b5cf6; + background: rgba(139, 92, 246, 0.1); + z-index: 2147483647; + transition: all 0.1s ease; + display: none; + border-radius: 3px; + `; + + const label = document.createElement('div'); + label.style.cssText = ` + position: absolute; + top: -22px; + left: -2px; + background: #8b5cf6; + color: white; + font-size: 11px; + font-family: -apple-system, sans-serif; + padding: 2px 6px; + border-radius: 3px 3px 0 0; + white-space: nowrap; + pointer-events: none; + `; + label.id = '__qwik_devtools_label'; + overlay.appendChild(label); + + document.body.appendChild(overlay); + return overlay; + } + + /** + * Walk up the DOM to find the nearest Qwik-managed ancestor. + * Handles both v1 (`q:id`) and v2 (`:=` binding attribute). + */ + function findQwikAncestor(el: Element): Element | null { + let current: Element | null = el; + while (current) { + if (current.hasAttribute(QWIK_ATTR.ID)) return current; + if (current.hasAttribute(V2_BINDING_ATTR)) return current; + current = current.parentElement; + } + return null; + } + + function getElementIdentifier(el: Element): string | null { + return el.getAttribute(QWIK_ATTR.ID) ?? el.getAttribute(V2_BINDING_ATTR); + } + + /** + * Extract the Qwik component name from `data-qwik-inspector` attribute. + * Format: "/src/components/Button/Button.tsx:49:10" + */ + function getComponentName(el: Element): string | null { + const inspector = el.getAttribute('data-qwik-inspector') + ?? el.closest('[data-qwik-inspector]')?.getAttribute('data-qwik-inspector'); + if (!inspector) return null; + const parts = inspector.split('/'); + const fileName = (parts[parts.length - 1] || '').split(':')[0]; + return fileName.replace(/\.(tsx|ts|jsx|js)$/, '') || null; + } + + function handleInspectMove(e: MouseEvent) { + if (!inspectActive || !inspectOverlay) return; + const target = e.target as Element; + const qwikEl = findQwikAncestor(target); + + if (qwikEl) { + const rect = qwikEl.getBoundingClientRect(); + inspectOverlay.style.display = 'block'; + inspectOverlay.style.top = `${rect.top}px`; + inspectOverlay.style.left = `${rect.left}px`; + inspectOverlay.style.width = `${rect.width}px`; + inspectOverlay.style.height = `${rect.height}px`; + + const label = inspectOverlay.querySelector('#__qwik_devtools_label') as HTMLDivElement | null; + if (label) { + const compName = getComponentName(qwikEl) || getComponentName(target); + if (compName) { + label.textContent = `<${compName} />`; + } else { + const tag = qwikEl.tagName.toLowerCase(); + const id = getElementIdentifier(qwikEl); + label.textContent = `<${tag}> #${id ?? '?'}`; + } + } + } else { + inspectOverlay.style.display = 'none'; + } + } + + // Inject main-world inspect hook (blocks clicks in the same world as qwikloader) + const inspectScript = document.createElement('script'); + inspectScript.src = chrome.runtime.getURL('/inspect-hook.js'); + (document.documentElement || document.head).appendChild(inspectScript); + inspectScript.addEventListener('load', () => inspectScript.remove()); + + // Inject devtools hook if Qwik is detected (plain script, no ES imports needed) + if (detectQwik().detected) { + const hookScript = document.createElement('script'); + hookScript.src = chrome.runtime.getURL('/devtools-hook.js'); + (document.documentElement || document.head).appendChild(hookScript); + hookScript.addEventListener('load', () => hookScript.remove()); + } + // VNode bridge is injected by the panel via evalInPage (needs dynamic import) + + function startInspect() { + inspectActive = true; + if (!inspectOverlay) inspectOverlay = createOverlay(); + document.removeEventListener('mousemove', handleInspectMove, true); + document.addEventListener('mousemove', handleInspectMove, true); + document.body.style.cursor = 'crosshair'; + // Tell main-world hook to start intercepting clicks + window.postMessage({ type: '__QWIK_DT_INSPECT_START' }, '*'); + } + + function stopInspect() { + inspectActive = false; + if (inspectOverlay) { + inspectOverlay.style.display = 'none'; + } + document.removeEventListener('mousemove', handleInspectMove, true); + document.body.style.cursor = ''; + window.postMessage({ type: '__QWIK_DT_INSPECT_STOP' }, '*'); + } + + /** SPA navigation detection */ + let lastUrl = location.href; + let spaNavTimeout: ReturnType | null = null; + let spaObserver: MutationObserver | null = null; + + const notifyPageChanged = () => { + try { + browser.runtime.sendMessage({ type: 'PAGE_CHANGED' }); + } catch { + // extension context invalidated + } + }; + + const cleanupSpaDetection = () => { + if (spaNavTimeout) { + clearTimeout(spaNavTimeout); + spaNavTimeout = null; + } + if (spaObserver) { + spaObserver.disconnect(); + spaObserver = null; + } + }; + + const checkUrlChange = () => { + if (location.href !== lastUrl) { + lastUrl = location.href; + cleanupSpaDetection(); + + const container = + document.querySelector(QWIK_CONTAINER_SELECTOR) ?? document.body; + + spaObserver = new MutationObserver(() => { + if (spaNavTimeout) clearTimeout(spaNavTimeout); + spaNavTimeout = setTimeout(() => { + cleanupSpaDetection(); + notifyPageChanged(); + }, 200); + }); + + spaObserver.observe(container, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: [QWIK_ATTR.ID, QWIK_ATTR.KEY, QWIK_ATTR.CONTAINER, V2_BINDING_ATTR], + }); + + // Fallback: notify after 1s even if no mutations + spaNavTimeout = setTimeout(() => { + cleanupSpaDetection(); + notifyPageChanged(); + }, 1000); + } + }; + + // Inject main-world script to intercept pushState/replaceState + const navScript = document.createElement('script'); + navScript.src = chrome.runtime.getURL('/nav-hook.js'); + (document.documentElement || document.head).appendChild(navScript); + navScript.addEventListener('load', () => navScript.remove()); + + const handleNavMessage = (e: MessageEvent) => { + if (e.data?.type === '__QWIK_DT_NAV') checkUrlChange(); + }; + + // Forward devtools messages from page main world to background/panel + const handleDevtoolsMessage = (e: MessageEvent) => { + if (e.source !== window || !e.data) return; + + // Forward component tree updates and render events + if ( + e.data.source === 'qwik-devtools' && + (e.data.type === 'COMPONENT_TREE_UPDATE' || e.data.type === 'RENDER_EVENT') + ) { + try { + browser.runtime.sendMessage({ + type: e.data.type, + payload: e.data.tree || e.data.event, + }); + } catch { + // extension context invalidated + } + } + + // Forward element pick from main-world inspect hook + if (e.data.type === '__QWIK_DT_ELEMENT_PICKED') { + + stopInspect(); + try { + browser.runtime.sendMessage({ + type: 'ELEMENT_PICKED', + payload: { + qId: e.data.qId, + colonId: e.data.colonId, + treeNodeId: e.data.treeNodeId, + }, + }); + + } catch { + // extension context invalidated + } + } + }; + + // Abort previous listeners if the content script reinitializes + ( + (window as Record).__qwik_dt_abort as + | AbortController + | undefined + )?.abort(); + const navAbort = new AbortController(); + (window as Record).__qwik_dt_abort = navAbort; + + window.addEventListener('message', handleNavMessage, { + signal: navAbort.signal, + }); + window.addEventListener('message', handleDevtoolsMessage, { + signal: navAbort.signal, + }); + window.addEventListener('popstate', checkUrlChange, { + signal: navAbort.signal, + }); + + browser.runtime.onMessage.addListener( + (msg: unknown, _sender, sendResponse) => { + if (!isExtensionMessage(msg)) return; + let response: ExtensionMessage; + + switch (msg.type) { + case 'DETECT_QWIK': + response = { type: 'QWIK_DETECTION_RESULT', payload: detectQwik() }; + break; + case 'GET_COMPONENT_TREE': { + // Placeholder: full tree builder will be added in Phase 3 + const tree: QwikComponentNode[] = []; + response = { type: 'COMPONENT_TREE_RESULT', payload: tree }; + break; + } + case 'GET_ROUTES': + // Placeholder: route explorer will be added in Phase 3 + response = { type: 'ROUTES_RESULT', payload: { activeRoute: null, preloadedModules: [], detectedRoutes: [] } }; + break; + case 'START_INSPECT': + + startInspect(); + response = { type: 'OK' }; + break; + case 'STOP_INSPECT': + stopInspect(); + response = { type: 'OK' }; + break; + default: + response = { type: 'OK' }; + } + + try { + sendResponse(response); + } catch (err) { + console.debug('[Qwik DevTools]', err); + } + // Keep channel open for Chrome's sendResponse callback + return true; + }, + ); + }, +}); diff --git a/packages/browser-extension/src/entrypoints/devtools.html b/packages/browser-extension/src/entrypoints/devtools.html new file mode 100644 index 0000000..3e52132 --- /dev/null +++ b/packages/browser-extension/src/entrypoints/devtools.html @@ -0,0 +1,34 @@ + + + + + + + + + diff --git a/packages/browser-extension/src/entrypoints/panel.html b/packages/browser-extension/src/entrypoints/panel.html new file mode 100644 index 0000000..17457c0 --- /dev/null +++ b/packages/browser-extension/src/entrypoints/panel.html @@ -0,0 +1,94 @@ + + + + + + + + + + +
Loading Qwik DevTools...
+ + + diff --git a/packages/browser-extension/src/lib/constants.ts b/packages/browser-extension/src/lib/constants.ts new file mode 100644 index 0000000..eef4811 --- /dev/null +++ b/packages/browser-extension/src/lib/constants.ts @@ -0,0 +1,138 @@ +/** Qwik DOM attribute names used for detection and tree building. */ +export const QWIK_ATTR = { + CONTAINER: 'q:container', + VERSION: 'q:version', + RENDER: 'q:render', + BASE: 'q:base', + ROUTE: 'q:route', + MANIFEST_HASH: 'q:manifest-hash', + /** v1 element identifier */ + ID: 'q:id', + /** v1 element key */ + KEY: 'q:key', + /** v2 runtime marker (value `"2"`) */ + RUNTIME: 'q:runtime', +} as const; + +export const QWIK_CONTAINER_SELECTOR = '[q\\:container]'; + +/** v1 virtual node comment tag */ +export const VIRTUAL_NODE_TAG = ''; + +/** Event attribute prefixes used in v1 */ +export const V1_EVENT_PREFIX = { + COLON: 'on:', + DASH: 'on-', +} as const; + +/** Event attribute prefix used in v2 */ +export const V2_EVENT_PREFIX = 'q-e:'; + +/** + * v2 uses `:=` as the element binding attribute instead of `q:id`. + * The colon-equals attribute carries the component binding identifier. + */ +export const V2_BINDING_ATTR = ':'; + +/** v1 serialization script type */ +export const QWIK_JSON_SCRIPT_TYPE = 'qwik/json'; + +/** v2 serialization script types */ +export const QWIK_STATE_SCRIPT_TYPE = 'qwik/state'; +export const QWIK_VNODE_SCRIPT_TYPE = 'qwik/vnode'; + +export const HIGHLIGHT_OVERLAY_ID = '__qwik_dt_highlight'; + +/** Polling interval (ms) for live DOM state watching */ +export const LIVE_POLL_INTERVAL_MS = 800; + +/** Timeout (ms) before giving up on initial Qwik detection */ +export const DETECTION_TIMEOUT_MS = 3000; + +/** Retry delays (ms) for Qwik detection backoff. Tried in order before final timeout. */ +export const DETECTION_RETRY_DELAYS = [500, 1000, 2000] as const; + +/** + * Build a safe CSS attribute selector for `q:id` (v1) or `:` (v2). + * Validates the format and rejects anything with suspicious characters. + */ +export function safeQIdSelector(qId: string): string { + if (!/^[\w.-]+$/.test(qId)) { + console.debug('[Qwik DevTools] Unexpected q:id format:', qId); + return '[q\\\\:id="invalid"]'; + } + return `[q\\\\:id="${qId}"]`; +} + +/** + * Type guard for unknown values that should be a plain object. + * Avoids unsafe `as Record` casts. + */ +export function isRecord(v: unknown): v is Record { + return typeof v === 'object' && v !== null && !Array.isArray(v); +} + +/** + * Normalize a route path for comparison: always ends with '/'. + */ +export function normalizePath(path: string): string { + return path.endsWith('/') ? path : `${path}/`; +} + +/** Matches CSS hash class names generated by build tools (e.g. Cxmqbja) */ +export const RE_CSS_HASH = /^[A-Z][a-z0-9]{2,9}$/; + +/** Common component name prefixes that should NOT be treated as hashes */ +export const RE_KNOWN_PREFIX = + /^(Doc|Nav|App|List|Form|Menu|Card|Hero|Main|Page|View|Item|Tabs|Grid|Flex|Wrap|Icon|Logo|Text|Link|Body|Head)/; + +/** Matches vowels (case-insensitive) */ +export const RE_VOWELS = /[aeiou]/gi; + +/** PascalCase class name - likely a component */ +export const RE_PASCAL_CLASS = /^[A-Z][a-zA-Z0-9]+$/; + +/** camelCase class name */ +export const RE_CAMEL_CLASS = /^[a-z]+[A-Z][a-zA-Z0-9]*$/; + +/** BEM-style block name (e.g. Header-modifier) */ +export const RE_BEM_BLOCK = /^[A-Z][a-zA-Z]+-[A-Z]/; + +/** Utility-first CSS class prefix (Tailwind, etc.) */ +export const RE_UTILITY_PREFIX = + /^(px|py|mx|my|mt|mb|ml|mr|pt|pb|pl|pr|flex|grid|block|inline|hidden|relative|absolute|fixed|sticky|text|bg|border|rounded|shadow|overflow|cursor|transition|transform|opacity|z|w|h|min|max|gap|space|font|leading|tracking|align|justify|items|self|place|col|row|sm|md|lg|xl|2xl|hover|focus|active|group|dark)[-:]/; + +/** Short BEM-like class names with hyphen (e.g. my-component) */ +export const RE_SHORT_HYPHEN = /^[a-z][\w]*-[\w-]+$/; + +/** Short utility prefix (1-3 lowercase + colon/hyphen) */ +export const RE_SHORT_UTILITY = /^[a-z]{1,3}[-:]/; + +/** Dev-mode q:key (e.g. Header_component_0) */ +export const RE_DEV_KEY = /^([A-Z][a-zA-Z0-9]+)_/; + +/** Prod-mode hash q:key (e.g. w5MY:0G_0) */ +export const RE_HASH_KEY_COLON = /^[a-zA-Z0-9]{2,6}:[a-zA-Z0-9]{2,6}/; + +/** Prod-mode short hash key (e.g. ab_0) */ +export const RE_HASH_KEY_SHORT = /^[a-zA-Z0-9]{2,4}_\d+$/; + +/** Alphabetic-only string (for cleaned key names) */ +export const RE_ALPHA_ONLY = /^[a-zA-Z]+$/; + +/** Prod-mode QRL symbol prefix */ +export const RE_PROD_SYMBOL = /^s_/; + +export const MAX_TREE_DEPTH = 100; +export const MAX_ROUTE_DEPTH = 50; +export const DETAIL_WIDTH_MIN = 200; +export const DETAIL_WIDTH_MAX = 600; +export const DETAIL_WIDTH_DEFAULT = 340; +export const TREE_INDENT_PX = 14; +export const ROUTE_INDENT_PX = 18; +export const TOAST_DISMISS_MS = 4000; +export const MAX_LIVE_INPUTS = 5; +export const MAX_NEARBY_ELEMENTS = 8; +export const VIRTUAL_SCROLL_THRESHOLD = 500; +export const TREE_ROW_HEIGHT = 26; +export const VIRTUAL_SCROLL_OVERSCAN = 20; diff --git a/packages/browser-extension/src/lib/extension-data-provider.ts b/packages/browser-extension/src/lib/extension-data-provider.ts new file mode 100644 index 0000000..de924e1 --- /dev/null +++ b/packages/browser-extension/src/lib/extension-data-provider.ts @@ -0,0 +1,465 @@ +import type { DataProvider } from '../../../ui/src/devtools/data-provider'; +import type { PageDataSource } from '../../../ui/src/devtools/page-data-source'; +import type { DevtoolsState } from '../../../ui/src/devtools/state'; +import type { + QwikPerfStoreRemembered, + QwikPreloadStoreRemembered, +} from '@devtools/kit'; + +// -- Eval helpers -------------------------------------------------------- + +/** Evaluate a script in the inspected page and return the result. */ +function evalInPage(script: string, timeoutMs = 5000): Promise { + return new Promise((resolve) => { + const timer = setTimeout(() => resolve(null), timeoutMs); + chrome.devtools.inspectedWindow.eval(script, (result, exceptionInfo) => { + clearTimeout(timer); + if (exceptionInfo) { + console.debug('[Qwik DevTools] eval error:', exceptionInfo); + resolve(null); + } else { + resolve(result as T); + } + }); + }); +} + +/** + * Ensure the VNode bridge is installed on the page. + * Uses dynamic import() in the page context where Vite resolves bare specifiers. + * Since inspectedWindow.eval can't await, we fire-and-forget then poll. + */ +function ensureVNodeBridge(): Promise { + return evalInPage( + `!!(window.__QWIK_DEVTOOLS_HOOK__ && typeof window.__QWIK_DEVTOOLS_HOOK__.getVNodeTree === 'function')`, + ).then((ready) => { + if (ready) return true; + + // Fire-and-forget: kick off the async bridge setup via .then() + evalInPage(EVAL_INSTALL_BRIDGE); + + // Poll until bridge is ready (import() is async) + return new Promise((resolve) => { + let attempts = 0; + const check = () => { + evalInPage( + `!!(window.__QWIK_DEVTOOLS_HOOK__ && typeof window.__QWIK_DEVTOOLS_HOOK__.getVNodeTree === 'function')`, + ).then((ok) => { + if (ok) { resolve(true); return; } + if (++attempts >= 20) { resolve(false); return; } + setTimeout(check, 250); + }); + }; + setTimeout(check, 300); + }); + }); +} + +/** Eval script that installs the VNode bridge via dynamic import(). */ +const EVAL_INSTALL_BRIDGE = `(function() { + var hook = window.__QWIK_DEVTOOLS_HOOK__; + if (!hook) return 'no-hook'; + if (typeof hook.getVNodeTree === 'function') return 'already'; + // Find the exact URL Vite used to load Qwik core (reuses cached module, no duplicate) + var qwikUrl = null; + var entries = performance.getEntriesByType('resource'); + for (var i = 0; i < entries.length; i++) { + var n = entries[i].name; + if (n.indexOf('core.mjs') > -1 && n.indexOf('qwik') > -1 && n.indexOf('.dev/core') > -1) { + qwikUrl = n; break; + } + } + // Fallback to direct Vite node_modules path + var importUrl = qwikUrl || '/node_modules/@qwik.dev/core/dist/core.mjs'; + import(importUrl).then(function(m) { + var _getDomContainer = m._getDomContainer; + var _vnode_getFirstChild = m._vnode_getFirstChild; + var _vnode_isVirtualVNode = m._vnode_isVirtualVNode; + var _vnode_isMaterialized = m._vnode_isMaterialized; + var _vnode_getAttrKeys = m._vnode_getAttrKeys; + var QRENDERFN = 'q:renderFn', QPROPS = 'q:props', QTYPE = 'q:type'; + var _idx = 0, _vnodeMap = {}; + function serializeProps(v,d){if(d>4)return'[depth]';if(v===null||v===undefined)return v;var t=typeof v;if(t==='string'||t==='number'||t==='boolean')return v;if(t==='function')return'[Function]';try{if(Array.isArray(v))return v.map(function(i){return serializeProps(i,d+1)});if(t==='object'){if('$chunk$'in v||'$symbol$'in v)return'[QRL]';if('$untrackedValue$'in v)return serializeProps(v.$untrackedValue$,d+1);var r={},ks=Object.keys(v);for(var i=0;i0?ch.substring(0,ix):ch}catch(_){}var ch2=[];var fc=_vnode_getFirstChild(cur);if(fc)ch2=buildTree(c,fc);var np=qId?{'q:id':qId}:{};if(colId)np.__colonId=colId;if(qC)np.__qrlChunk=qC;var nId=qId?('q-'+qId):('vnode-'+(_idx++));_vnodeMap[nId]={vnode:cur,container:c};res.push({name:nm,id:nId,label:nm,props:np,children:ch2.length>0?ch2:undefined})}else if(_vnode_isMaterialized(cur)||(isV&&!isCmp)){var f=_vnode_getFirstChild(cur);if(f){var ns=buildTree(c,f);for(var j=0;j';return true}catch(_){return false}}; + hook.unhighlightNode=function(){var ov=document.getElementById('__qwik_dt_hover_ov');if(ov)ov.style.display='none'}; + hook.getNodeProps=function(nId){var en=_vnodeMap[nId];if(!en)return null;try{var p=en.container.getHostProp(en.vnode,QPROPS);if(!p)return null;var r={},ks=Object.keys(p);for(var i=0;i { + return evalInPage( + `typeof window.QWIK_DEVTOOLS_GLOBAL_STATE !== 'undefined' || + typeof window.__QWIK_PERF__ !== 'undefined' || + typeof window.__QWIK_DEVTOOLS_HOOK__ !== 'undefined' || + !!document.querySelector('[q\\\\:container]')`, + ).then((r) => r === true); +} + +/** Check whether the devtools hook is installed. */ +function isHookAvailable(): Promise { + return evalInPage( + `!!(window.__QWIK_DEVTOOLS_HOOK__ && window.__QWIK_DEVTOOLS_HOOK__.version === 1)`, + ).then((r) => r === true); +} + +// -- Eval scripts -------------------------------------------------------- +// Executed in the inspected page context via inspectedWindow.eval(). +// Must use ES5 syntax (no arrow functions, no const/let). + +/** Read component tree from the hook, with fallback to raw global. */ +const EVAL_READ_COMPONENTS = `(function() { + var hook = window.__QWIK_DEVTOOLS_HOOK__; + if (hook && hook.version === 1 && typeof hook.getComponentTreeSnapshot === 'function') { + return hook.getComponentTreeSnapshot(); + } + var state = window.QWIK_DEVTOOLS_GLOBAL_STATE; + if (!state) return null; + return Object.keys(state).map(function(key) { + var lastSeg = key.split('/').pop() || key; + var underIdx = lastSeg.lastIndexOf('_'); + var name = underIdx > 0 ? lastSeg.substring(underIdx + 1) : lastSeg; + return { path: key, name: name, file: key, signals: [], hooks: [] }; + }); +})()`; + +/** Read signal values snapshot from the hook. */ +const EVAL_READ_SIGNALS = `(function() { + var hook = window.__QWIK_DEVTOOLS_HOOK__; + if (hook && hook.version === 1 && typeof hook.getSignalsSnapshot === 'function') { + return hook.getSignalsSnapshot(); + } + return null; +})()`; + +/** Read VNode component tree from the hook bridge. */ +const EVAL_READ_VNODE_TREE = `(function() { + var hook = window.__QWIK_DEVTOOLS_HOOK__; + if (hook && typeof hook.getVNodeTree === 'function') { + return hook.getVNodeTree(); + } + return null; +})()`; + +/** Read detailed hook data for a specific component. */ +function evalComponentDetail(name: string, qrlChunk?: string): Promise | null> { + const escapedName = name.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); + const escapedChunk = (qrlChunk || '').replace(/\\/g, '\\\\').replace(/'/g, "\\'"); + return evalInPage(`(function() { + var hook = window.__QWIK_DEVTOOLS_HOOK__; + if (hook && typeof hook.getComponentDetail === 'function') { + return hook.getComponentDetail('${escapedName}', '${escapedChunk}'); + } + return null; +})()`); +} + +/** Detect Qwik packages from container attributes and runtime globals. */ +const EVAL_READ_PACKAGES = `(function() { + var el = document.querySelector('[q\\\\:container]'); + if (!el) return []; + var pkgs = []; + var version = el.getAttribute('q:version') || ''; + if (version) { + pkgs.push(['@qwik.dev/core', version]); + // Detect router: check for Qwik router provider in the component tree + var hasRouter = !!document.querySelector('[data-qwik-inspector*="router"]') + || !!(window.__QWIK_DEVTOOLS_HOOK__ && window.__QWIK_DEVTOOLS_HOOK__.getVNodeTree + && JSON.stringify(window.__QWIK_DEVTOOLS_HOOK__.getVNodeTree() || []).indexOf('routerprovider') > -1); + if (hasRouter) pkgs.push(['@qwik.dev/router', version]); + } + return pkgs; +})()`; + +const EVAL_READ_PERF = `(function() { + var p = window.__QWIK_PERF__; + if (!p) return null; + return { ssr: p.ssr || [], csr: p.csr || [] }; +})()`; + +const EVAL_READ_PRELOADS = `(function() { + var s = window.__QWIK_PRELOADS__; + if (!s) return null; + return { + entries: s.entries || [], + qrlRequests: s.qrlRequests || [], + startedAt: s.startedAt || 0, + _id: s._id || 0, + _initialized: !!s._initialized, + _byHref: s._byHref || {}, + _byId: s._byId || {} + }; +})()`; + +const EVAL_CLEAR_PRELOADS = `(function() { + var s = window.__QWIK_PRELOADS__; + if (s && typeof s.clear === 'function') s.clear(); + return true; +})()`; + +// -- RemotePageDataSource ------------------------------------------------ + +const POLL_INTERVAL_MS = 2000; + +/** + * {@link PageDataSource} for the browser extension panel. + * + * Reads page globals via `chrome.devtools.inspectedWindow.eval()`. + * Preload updates use polling since cross-document event listeners + * are not possible. + */ +class RemotePageDataSource implements PageDataSource { + readPerfData(): Promise { + return evalInPage(EVAL_READ_PERF); + } + + readPreloadStore(): Promise { + return evalInPage(EVAL_READ_PRELOADS); + } + + async clearPreloadStore(): Promise { + await evalInPage(EVAL_CLEAR_PRELOADS); + } + + subscribePreloadUpdates(cb: () => void): (() => void) | null { + const id = setInterval(cb, POLL_INTERVAL_MS); + return () => clearInterval(id); + } + + readComponentTree(): Promise; + hooks: Array<{ variableName: string; hookType: string; category: string }>; + }> | null> { + return evalInPage(EVAL_READ_COMPONENTS); + } + + readSignals(): Promise + > | null> { + return evalInPage(EVAL_READ_SIGNALS); + } + + readVNodeTree(): Promise; + children?: any[]; + }> | null> { + return evalInPage(EVAL_READ_VNODE_TREE); + } + + async readNodeProps(nodeId: string): Promise | null> { + const escaped = nodeId.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); + return evalInPage>(`(function() { + var hook = window.__QWIK_DEVTOOLS_HOOK__; + if (hook && typeof hook.getNodeProps === 'function') { + return hook.getNodeProps('${escaped}'); + } + return null; +})()`); + } + + async setSignalValue(componentName: string, qrlChunk: string | undefined, variableName: string, newValue: unknown): Promise { + const escapedName = componentName.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); + const escapedChunk = (qrlChunk || '').replace(/\\/g, '\\\\').replace(/'/g, "\\'"); + const escapedVar = variableName.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); + const serializedValue = JSON.stringify(newValue); + return (await evalInPage(`(function() { + var hook = window.__QWIK_DEVTOOLS_HOOK__; + if (hook && typeof hook.setSignalValue === 'function') { + return hook.setSignalValue('${escapedName}', '${escapedChunk}', '${escapedVar}', ${serializedValue}); + } + return false; +})()`)) ?? false; + } + + async highlightElement(nodeId: string, componentName: string): Promise { + const eNodeId = nodeId.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); + const eName = componentName.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); + await evalInPage(`(function() { + var hook = window.__QWIK_DEVTOOLS_HOOK__; + if (hook && typeof hook.highlightNode === 'function') { + return hook.highlightNode('${eNodeId}', '${eName}'); + } + return false; +})()`); + } + + async unhighlightElement(): Promise { + await evalInPage(`(function() { + var hook = window.__QWIK_DEVTOOLS_HOOK__; + if (hook && typeof hook.unhighlightNode === 'function') { + hook.unhighlightNode(); + } +})()`); + } + + readComponentDetail(componentName: string, qrlChunk?: string): Promise | null> { + return evalComponentDetail(componentName, qrlChunk); + } + + subscribeTreeUpdates(cb: (tree: Array<{ + name?: string; + id: string; + label?: string; + props?: Record; + children?: any[]; + }>) => void): (() => void) | null { + const port = (window as any).__devtools_port as chrome.runtime.Port | undefined; + if (!port) return null; + const handler = (msg: any) => { + if (msg?.type === 'COMPONENT_TREE_UPDATE' && Array.isArray(msg.payload)) { + cb(msg.payload); + } + }; + port.onMessage.addListener(handler); + return () => port.onMessage.removeListener(handler); + } + + subscribeRenderEvents(cb: (event: { + component: string; + phase: string; + duration: number; + timestamp: number; + }) => void): (() => void) | null { + const port = (window as any).__devtools_port as chrome.runtime.Port | undefined; + if (!port) return null; + const handler = (msg: any) => { + if (msg?.type === 'RENDER_EVENT' && msg.payload) { + cb(msg.payload); + } + }; + port.onMessage.addListener(handler); + return () => port.onMessage.removeListener(handler); + } +} + +// -- Component snapshot type (matches hook's getComponentTreeSnapshot) ---- + +interface ComponentSnapshot { + path: string; + name: string; + signals: Array<{ name: string; hookType: string; value: unknown }>; + hooks: Array<{ + variableName: string; + hookType: string; + category: string; + }>; +} + +// -- DataProvider -------------------------------------------------------- + +/** + * {@link DataProvider} for the browser extension. + * + * When `__QWIK_DEVTOOLS_HOOK__` is available, reads component tree and + * signal data from it. Falls back to `QWIK_DEVTOOLS_GLOBAL_STATE` for + * basic component listing. + * + * Performance and preload data is handled by {@link RemotePageDataSource}. + * + * Features requiring Vite server access (packages, dependencies, routes, + * assets, build analysis, code inspection) are not available. + */ +export function createExtensionDataProvider(): DataProvider { + return { + async loadData(state: DevtoolsState) { + if (!(await isDevModeActive())) return; + + const hookAvailable = await isHookAvailable(); + + // Ensure VNode bridge is installed (injects via dynamic import if needed) + const bridgeReady = await ensureVNodeBridge(); + + // Detect Vite plugin overlay + const hasVitePlugin = await evalInPage( + `typeof window.QWIK_DEVTOOLS_GLOBAL_STATE === 'object' && window.QWIK_DEVTOOLS_GLOBAL_STATE !== null`, + ); + state.vitePluginDetected = hasVitePlugin === true; + + // Load packages from container attributes + const pkgs = await evalInPage>(EVAL_READ_PACKAGES); + if (pkgs && pkgs.length > 0) { + state.npmPackages = pkgs; + } + + if (hookAvailable) { + const snapshots = await evalInPage( + EVAL_READ_COMPONENTS, + ); + + if (snapshots && snapshots.length > 0) { + state.components = snapshots.map((c) => ({ + name: c.name, + fileName: c.name, + file: c.path, + })); + } + } + + // Fallback: count components from VNode tree if snapshot was empty + if (state.components.length === 0 && bridgeReady) { + const tree = await evalInPage>(EVAL_READ_VNODE_TREE); + if (tree) { + const names: string[] = []; + const walk = (nodes: any[]) => { + for (const n of nodes) { + if (n.name) names.push(n.name); + if (n.children) walk(n.children); + } + }; + walk(tree); + state.components = names.map((name) => ({ + name, + fileName: name, + file: name, + })); + } + } + }, + }; +} + +/** Read signal values from the hook. Returns null if hook unavailable. */ +export async function readSignalsSnapshot(): Promise +> | null> { + return evalInPage(EVAL_READ_SIGNALS); +} + +/** Create a {@link PageDataSource} backed by `inspectedWindow.eval()`. */ +export function createRemotePageDataSource(): PageDataSource { + return new RemotePageDataSource(); +} diff --git a/packages/browser-extension/src/lib/types.test.ts b/packages/browser-extension/src/lib/types.test.ts new file mode 100644 index 0000000..48e8fae --- /dev/null +++ b/packages/browser-extension/src/lib/types.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { isExtensionMessage } from './types'; + +describe('isExtensionMessage', () => { + it('accepts valid message with type string', () => { + expect(isExtensionMessage({ type: 'PAGE_CHANGED' })).toBe(true); + }); + + it('accepts message with payload', () => { + expect(isExtensionMessage({ type: 'RENDER_EVENT', payload: { component: 'Counter' } })).toBe(true); + }); + + it('rejects null', () => { + expect(isExtensionMessage(null)).toBe(false); + }); + + it('rejects undefined', () => { + expect(isExtensionMessage(undefined)).toBe(false); + }); + + it('rejects array', () => { + expect(isExtensionMessage([1, 2, 3])).toBe(false); + }); + + it('rejects string', () => { + expect(isExtensionMessage('hello')).toBe(false); + }); + + it('rejects object without type', () => { + expect(isExtensionMessage({ payload: 'data' })).toBe(false); + }); + + it('rejects object with non-string type', () => { + expect(isExtensionMessage({ type: 42 })).toBe(false); + }); +}); diff --git a/packages/browser-extension/src/lib/types.ts b/packages/browser-extension/src/lib/types.ts new file mode 100644 index 0000000..ba6a935 --- /dev/null +++ b/packages/browser-extension/src/lib/types.ts @@ -0,0 +1,208 @@ +/** + * Extension message types for communication between content script, + * background service worker, and DevTools panel. + */ + +export type MessageType = + | 'DETECT_QWIK' + | 'QWIK_DETECTION_RESULT' + | 'GET_COMPONENT_TREE' + | 'COMPONENT_TREE_RESULT' + | 'GET_ROUTES' + | 'ROUTES_RESULT' + | 'PAGE_CHANGED' + | 'START_INSPECT' + | 'STOP_INSPECT' + | 'ELEMENT_PICKED' + | 'COMPONENT_TREE_UPDATE' + | 'RENDER_EVENT' + | 'OK' + | `${string}_ERROR`; + +export interface ExtensionMessage { + type: MessageType; + payload?: QwikContainerInfo | QwikComponentNode[] | QwikRouteInfo | ErrorPayload | ElementPickedPayload; +} + +export interface ErrorPayload { + error: string; +} + +export interface ElementPickedPayload { + qId: string | null; + /** v2 uses `:=` instead of `q:id` */ + colonId: string | null; + /** Tree node ID resolved by the page-side hook. */ + treeNodeId: string | null; +} + +function isRecord(v: unknown): v is Record { + return typeof v === 'object' && v !== null && !Array.isArray(v); +} + +/** + * Runtime type guard for messages received from the extension messaging + * channel. Validates shape before casting to avoid acting on malformed data. + */ +export function isExtensionMessage(msg: unknown): msg is ExtensionMessage { + if (!isRecord(msg)) return false; + return typeof msg['type'] === 'string'; +} + +export interface QwikContainerInfo { + detected: boolean; + version: string | null; + renderMode: string | null; + containerState: string | null; + base: string | null; + manifestHash: string | null; + containerCount: number; + /** `q:runtime` attribute, present in v2 (value `"2"`) */ + runtime: string | null; +} + +export type QwikObjectType = + | 'signal' + | 'computed' + | 'qrl' + | 'element-ref' + | 'text-ref' + | 'string' + | 'number' + | 'boolean' + | 'object' + | 'array' + | 'null' + | 'undefined'; + +export interface ComponentStateEntry { + index: number; + type: QwikObjectType; + rawValue: string | number | boolean | null | Record | unknown[]; + decodedValue: string | number | boolean | null | Record | unknown[]; +} + +export interface ComponentContext { + componentQrl?: string; + componentName?: string; + props?: Record; +} + +export interface QwikComponentNode { + id: string; + key: string | null; + tagName: string; + componentName: string; + children: QwikComponentNode[]; + depth: number; + hasContext: boolean; + attributes: Record; + state: ComponentStateEntry[]; + context: ComponentContext | null; +} + +export interface PreloadedModule { + href: string; + as: string | null; + size: number | null; +} + +export interface QwikRouteInfo { + activeRoute: string | null; + preloadedModules: PreloadedModule[]; + detectedRoutes: string[]; +} + +export interface AssetEntry { + url: string; + size: number; + type: string; +} + +export interface ImageEntry { + src: string; + width: number | null; + height: number | null; + naturalWidth: number; + naturalHeight: number; + renderedWidth: number; + renderedHeight: number; + hasWidthAttr: boolean; + hasHeightAttr: boolean; + hasAlt: boolean; + alt: string; + loading: string | null; + format: string; + transferSize: number; +} + +export interface AssetData { + scripts: AssetEntry[]; + styles: AssetEntry[]; + images: ImageEntry[]; + preloads: AssetEntry[]; +} + +export interface ListenerInfo { + event: string; + elementId: string; + qrl: string; + loaded: boolean; +} + +export interface SerializationBreakdown { + totalObjects: number; + signal: number; + computed: number; + qrl: number; + string: number; + number: number; + object: number; + array: number; + other: number; + rawSize: number; + topObjects: { index: number; size: number; type: string; preview: string }[]; +} + +export interface PrefetchInfo { + totalModules: number; + loadedModules: number; + pendingModules: number; + totalSize: number; + loadedSize: number; +} + +export interface ResumabilityData { + containerState: string; + totalListeners: number; + pendingListeners: number; + resumedListeners: number; + resumabilityScore: number; + listenerBreakdown: ListenerInfo[]; + serializationSize: number; + serializationBreakdown: SerializationBreakdown; + prefetchStatus: PrefetchInfo; +} + +export interface QwikContext { + componentQrl?: string; + props?: Record; + tasks?: string[]; +} + +export interface QwikSerializedObject { + index: number; + rawValue: string | number | boolean | null | Record | unknown[]; + type: QwikObjectType; + decodedValue: string | number | boolean | null | Record | unknown[]; +} + +export interface QwikSerializedState { + raw: string; + refs: Record; + ctx: Record; + objs: QwikSerializedObject[]; + subs: string[][]; +} + +export type StoreListener = () => void; diff --git a/packages/browser-extension/src/styles/panel.css b/packages/browser-extension/src/styles/panel.css new file mode 100644 index 0000000..4857e56 --- /dev/null +++ b/packages/browser-extension/src/styles/panel.css @@ -0,0 +1,41 @@ +@import '@devtools/ui/styles.css'; + +/* + * Override glass/transparent colors with opaque equivalents. + * + * The overlay blends semi-transparent backgrounds over the page via + * backdrop-blur. The extension has nothing behind it. These values + * approximate the composite color of the overlay glass over a typical + * light page background. + */ + +:root[data-theme='dark'], +:root:not([data-theme]) { + --color-background: #323234 !important; + --color-glass-bg: #3a3a3d !important; + --color-glass-panel-bg: #3a3a3d !important; + --color-glass-border: rgba(255, 255, 255, 0.12) !important; + --color-glass-shadow: rgba(0, 0, 0, 0.2) !important; + --color-card: #424245 !important; + --color-card-item-bg: rgba(255, 255, 255, 0.05) !important; + --color-card-item-hover-bg: rgba(255, 255, 255, 0.09) !important; +} + +:root[data-theme='light'] { + --color-background: #dbdbdb !important; + --color-glass-bg: #d2d2d2 !important; + --color-glass-panel-bg: #d2d2d2 !important; + --color-card: #e8e8e8 !important; + --color-card-item-bg: rgba(0, 0, 0, 0.03) !important; + --color-card-item-hover-bg: rgba(0, 0, 0, 0.06) !important; + --color-glass-border: #f1f1f1 !important; + --color-border: #f1f1f1 !important; +} + +/* Disable backdrop-filter (nothing to blur in extension panel) */ +.glass-panel, +.glass-button, +.glass-bg { + -webkit-backdrop-filter: none !important; + backdrop-filter: none !important; +} diff --git a/packages/browser-extension/tsconfig.json b/packages/browser-extension/tsconfig.json new file mode 100644 index 0000000..d4a0811 --- /dev/null +++ b/packages/browser-extension/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "declaration": true, + "jsx": "react-jsx", + "jsxImportSource": "lit", + "lib": ["esnext", "dom", "dom.iterable"], + "types": ["chrome"] + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/browser-extension/wxt.config.ts b/packages/browser-extension/wxt.config.ts new file mode 100644 index 0000000..55879dc --- /dev/null +++ b/packages/browser-extension/wxt.config.ts @@ -0,0 +1,32 @@ +import { defineConfig } from 'wxt'; +import { resolve } from 'node:path'; + +export default defineConfig({ + srcDir: 'src', + imports: false, + vite: () => ({ + resolve: { + alias: { + '@devtools/ui': resolve(__dirname, '../ui/lib'), + }, + conditions: ['production'], + }, + define: { + 'globalThis.qDev': 'false', + 'globalThis.qSerialize': 'false', + }, + }), + manifest: { + name: 'Qwik DevTools', + description: 'Developer tools for Qwik framework applications', + version: '0.1.0', + permissions: ['tabs'], + devtools_page: 'devtools.html', + web_accessible_resources: [ + { + resources: ['nav-hook.js', 'inspect-hook.js', 'devtools-hook.js', 'vnode-bridge.js'], + matches: [''], + }, + ], + }, +}); diff --git a/packages/devtools/package.json b/packages/devtools/package.json index 72cf97f..022af3c 100644 --- a/packages/devtools/package.json +++ b/packages/devtools/package.json @@ -28,8 +28,8 @@ "README.md" ], "peerDependencies": { - "@qwik.dev/core": "^2.0.0-beta.29", - "@qwik.dev/router": "^2.0.0-beta.29", + "@qwik.dev/core": "^2.0.0-beta.31", + "@qwik.dev/router": "^2.0.0-beta.31", "@tailwindcss/postcss": "^4.2.1", "@tailwindcss/vite": "^4.2.1", "tailwindcss": "^4.2.1", diff --git a/packages/kit/package.json b/packages/kit/package.json index f19f32b..6dabdde 100644 --- a/packages/kit/package.json +++ b/packages/kit/package.json @@ -31,7 +31,7 @@ "@typescript-eslint/parser": "8.57.1", "cpy-cli": "^7.0.0", "eslint": "10.0.3", - "eslint-plugin-qwik": "2.0.0-beta.29", + "eslint-plugin-qwik": "2.0.0-beta.31", "np": "^11.0.2", "prettier": "3.6.2", "typescript": "5.9.3", diff --git a/packages/kit/src/globals.ts b/packages/kit/src/globals.ts index 6687a4c..04f328f 100644 --- a/packages/kit/src/globals.ts +++ b/packages/kit/src/globals.ts @@ -1,5 +1,6 @@ import { ViteDevServer } from 'vite'; import { ClientRpc, ParsedStructure, ServerRpc } from './types'; +import type { QwikDevtoolsHook } from './hook-types'; interface EventEmitter { on: (name: string, handler: (data: any) => void) => void; @@ -145,6 +146,12 @@ declare global { __QWIK_PERF__?: QwikPerfStoreRemembered; __QWIK_PRELOADS__?: QwikPreloadStoreRemembered; __QWIK_SSR_PRELOADS__?: QwikSsrPreloadSnapshotRemembered[]; + /** + * Runtime devtools hook installed by `@devtools/plugin` in dev mode. + * Provides structured signal/component/render inspection for the + * browser extension and in-app overlay. + */ + __QWIK_DEVTOOLS_HOOK__?: QwikDevtoolsHook; } } diff --git a/packages/kit/src/hook-types.ts b/packages/kit/src/hook-types.ts new file mode 100644 index 0000000..9eec2d7 --- /dev/null +++ b/packages/kit/src/hook-types.ts @@ -0,0 +1,98 @@ +/** + * Runtime devtools hook exposed on `window.__QWIK_DEVTOOLS_HOOK__` + * by the `@devtools/plugin` in dev mode. + * + * Provides structured access to component state, signals, and render + * events for both the in-app overlay and the browser extension. + */ +export interface QwikDevtoolsHook { + /** Hook API version. Consumers must check this before using the API. */ + version: 1; + + // -- Signal inspection ------------------------------------------------ + + /** + * Read the current value of a signal reference. + * Only works in-page (not serializable for extension eval). + */ + getSignalValue(signal: unknown): unknown; + + /** + * Return a serializable snapshot of all tracked signals grouped by + * component source path. + */ + getSignalsSnapshot(): QwikDevtoolsSignalsSnapshot; + + // -- Component inspection --------------------------------------------- + + /** + * Return a serializable snapshot of all tracked components with + * their hooks and current signal values. + */ + getComponentTreeSnapshot(): QwikDevtoolsComponentSnapshot[]; + + // -- Event subscriptions ---------------------------------------------- + + /** + * Subscribe to CSR render events. + * Returns an unsubscribe function. + */ + onRender(callback: (info: QwikDevtoolsRenderEvent) => void): () => void; + + /** + * Subscribe to signal value changes. + * Returns an unsubscribe function. + * + * Note: v1 does not implement real-time signal tracking. + * Consumers should poll {@link getSignalsSnapshot} and diff. + */ + onSignalUpdate( + callback: (info: QwikDevtoolsSignalEvent) => void, + ): () => void; +} + +/** Serializable signal snapshot grouped by component path. */ +export type QwikDevtoolsSignalsSnapshot = Record< + string, + QwikDevtoolsSignalEntry[] +>; + +/** A single tracked signal entry. */ +export interface QwikDevtoolsSignalEntry { + name: string; + hookType: string; + value: unknown; +} + +/** Serializable component snapshot. */ +export interface QwikDevtoolsComponentSnapshot { + /** Source path (e.g. `src/routes/index.tsx_Counter`) */ + path: string; + /** Short display name */ + name: string; + /** Tracked signals with current values */ + signals: QwikDevtoolsSignalEntry[]; + /** All hook metadata */ + hooks: Array<{ + variableName: string; + hookType: string; + category: string; + }>; +} + +/** Emitted when a component renders (CSR only). */ +export interface QwikDevtoolsRenderEvent { + component: string; + phase: 'ssr' | 'csr'; + duration: number; + timestamp: number; +} + +/** Emitted when a signal value changes. */ +export interface QwikDevtoolsSignalEvent { + componentPath: string; + signalName: string; + oldValue: unknown; + newValue: unknown; + timestamp: number; +} diff --git a/packages/kit/src/index.ts b/packages/kit/src/index.ts index 86505f7..2e656c1 100644 --- a/packages/kit/src/index.ts +++ b/packages/kit/src/index.ts @@ -3,4 +3,5 @@ export * from './server'; export * from './context'; export * from './types'; export * from './constants'; -export * from './globals'; \ No newline at end of file +export * from './globals'; +export * from './hook-types'; \ No newline at end of file diff --git a/packages/playgrounds/package.json b/packages/playgrounds/package.json index 3f2f276..52e73c9 100644 --- a/packages/playgrounds/package.json +++ b/packages/playgrounds/package.json @@ -29,14 +29,14 @@ "devDependencies": { "@devtools/plugin": "workspace:*", "@devtools/ui": "workspace:*", - "@qwik.dev/core": "2.0.0-beta.29", - "@qwik.dev/router": "2.0.0-beta.29", + "@qwik.dev/core": "2.0.0-beta.31", + "@qwik.dev/router": "2.0.0-beta.31", "@types/eslint": "9.6.1", "@types/node": "25.5.0", "@typescript-eslint/eslint-plugin": "8.57.1", "@typescript-eslint/parser": "8.57.1", "eslint": "10.0.3", - "eslint-plugin-qwik": "2.0.0-beta.29", + "eslint-plugin-qwik": "2.0.0-beta.31", "prettier": "3.8.1", "typescript": "5.9.3", "vite": "8.0.0", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 3da5a79..e325585 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -27,7 +27,7 @@ "devDependencies": { "@babel/types": "^7.29.0", "@devtools/kit": "workspace:*", - "@qwik.dev/core": "2.0.0-beta.29", + "@qwik.dev/core": "2.0.0-beta.31", "@types/eslint": "9.6.1", "@types/node": "25.5.0", "@typescript-eslint/eslint-plugin": "8.57.1", diff --git a/packages/plugin/src/plugin/statistics/ssrPerfMiddleware.ts b/packages/plugin/src/plugin/statistics/ssrPerfMiddleware.ts index ecbdc67..e1774fd 100644 --- a/packages/plugin/src/plugin/statistics/ssrPerfMiddleware.ts +++ b/packages/plugin/src/plugin/statistics/ssrPerfMiddleware.ts @@ -1,5 +1,7 @@ import type { AnyRecord } from './constants'; import { log } from './constants'; +import hookRuntime from '../../virtualmodules/hookRuntime'; +import { VNODE_BRIDGE_KEY } from '../../virtualmodules/vnodeBridge'; type MiddlewareNext = (err?: unknown) => void; type MinimalMiddlewareReq = { @@ -192,10 +194,10 @@ export function injectSsrDevtoolsIntoHtml( }); const scripts = [ + createHookInjectionScript(), perfEntries.length > 0 ? createSsrPerfInjectionScript(perfEntries) : '', preloadEntries.length > 0 ? createSsrPreloadInjectionScript(preloadEntries) : '', ].join(''); - if (!scripts) return html; return html.replace( /]*)?>/i, @@ -203,6 +205,11 @@ export function injectSsrDevtoolsIntoHtml( ); } +function createHookInjectionScript(): string { + return `\n` + + `\n`; +} + function createSsrPerfInjectionScript(entries: unknown[]): string { const serializedEntries = JSON.stringify(entries); return ` diff --git a/packages/plugin/src/virtualmodules/hookRuntime.ts b/packages/plugin/src/virtualmodules/hookRuntime.ts new file mode 100644 index 0000000..659f06e --- /dev/null +++ b/packages/plugin/src/virtualmodules/hookRuntime.ts @@ -0,0 +1,271 @@ +/** + * Runtime snippet that installs `window.__QWIK_DEVTOOLS_HOOK__`. + * + * This file exports a **string** (not executable TS) that gets appended + * to {@link perfRuntime}. Since perfRuntime is concatenated into plain JS + * modules (`virtual:qwik-component-proxy`, lazy render wrappers), the + * hook initialises before any Qwik component renders. + * + * Signal values are read directly from `QWIK_DEVTOOLS_GLOBAL_STATE`: + * each hook entry has `data` set to the actual signal/store reference + * by the `collecthook()` instrumentation. The hook reads `.value` + * from those references at snapshot time. + */ +const hookRuntime = ` +// [qwik-devtools-hook] runtime (injected by @devtools/plugin) +const __qwik_hook_render_listeners__ = []; + +const __qwik_hook_signal_types__ = { + useSignal: true, + useStore: true, + useComputed: true, + useAsyncComputed: true, + useContext: true, +}; + +const __qwik_hook_safe_serialize__ = (val) => { + if (val === null || val === undefined) return val; + const t = typeof val; + if (t === 'string' || t === 'number' || t === 'boolean') return val; + if (t === 'function') return '[Function]'; + try { return JSON.parse(JSON.stringify(val)); } catch (_) { return '[' + t + ']'; } +}; + +const __qwik_hook_serialize_deep__ = (val, depth) => { + if (depth > 6) return '[depth limit]'; + if (val === null) return null; + if (val === undefined) return undefined; + const t = typeof val; + if (t === 'string' || t === 'number' || t === 'boolean') return val; + if (t === 'function') { + const n = val.name || 'anonymous'; + return { __type: 'function', __name: n }; + } + + try { + // Signal: read untracked value to avoid tracking + if (val && t === 'object' && '$untrackedValue$' in val) { + return __qwik_hook_serialize_deep__(val.$untrackedValue$, depth + 1); + } + + if (Array.isArray(val)) { + return val.map((item) => __qwik_hook_serialize_deep__(item, depth + 1)); + } + + if (t === 'object') { + const className = val.constructor ? val.constructor.name : 'Object'; + const result = {}; + + if (className !== 'Object') { + result.__className = className; + // Provide string representation for known types + try { + if (typeof val.toString === 'function' && val.toString !== Object.prototype.toString) { + result.__display = val.toString(); + } + } catch (_) {} + } + + const keys = Object.keys(val); + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + if (key.startsWith('$') && key.endsWith('$')) continue; // skip Qwik internals + try { + result[key] = __qwik_hook_serialize_deep__(val[key], depth + 1); + } catch (_) { + result[key] = '[unreadable]'; + } + } + + return result; + } + } catch (_) {} + + return String(val); +}; + +const __qwik_hook_read_value__ = (ref) => { + try { + if (ref && typeof ref === 'object' && 'value' in ref) { + return __qwik_hook_safe_serialize__(ref.value); + } + // useStore returns a proxy directly (no .value wrapper) + if (ref && typeof ref === 'object') { + return __qwik_hook_safe_serialize__(ref); + } + return undefined; + } catch (_) { + return '[error]'; + } +}; + +const __qwik_hook_init__ = () => { + if (typeof window === 'undefined' || window.__QWIK_DEVTOOLS_HOOK__) return; + + window.__QWIK_DEVTOOLS_HOOK__ = { + version: 1, + + _emitRender(info) { + for (let i = 0; i < __qwik_hook_render_listeners__.length; i++) { + try { __qwik_hook_render_listeners__[i](info); } catch (_) { /* skip */ } + } + }, + + getSignalValue(signal) { + if (signal && typeof signal === 'object' && 'value' in signal) { + return signal.value; + } + return undefined; + }, + + getSignalsSnapshot() { + const state = window.QWIK_DEVTOOLS_GLOBAL_STATE; + if (!state) return {}; + const snapshot = {}; + for (const path of Object.keys(state)) { + const hooks = state[path].hooks || []; + const signals = []; + for (const h of hooks) { + if (__qwik_hook_signal_types__[h.hookType] && h.data != null) { + signals.push({ + name: h.variableName || '', + hookType: h.hookType, + value: __qwik_hook_read_value__(h.data), + }); + } + } + if (signals.length > 0) { + snapshot[path] = signals; + } + } + return snapshot; + }, + + getComponentTreeSnapshot() { + const state = window.QWIK_DEVTOOLS_GLOBAL_STATE; + if (!state) return []; + + return Object.keys(state).map((path) => { + const comp = state[path]; + const hooks = comp.hooks || []; + const lastSeg = path.split('/').pop() || path; + const underIdx = lastSeg.lastIndexOf('_'); + const name = underIdx > 0 ? lastSeg.substring(underIdx + 1) : lastSeg; + + const signals = []; + const hookEntries = []; + for (const h of hooks) { + hookEntries.push({ + variableName: h.variableName || '', + hookType: h.hookType || '', + category: h.category || '', + }); + if (__qwik_hook_signal_types__[h.hookType] && h.data != null) { + signals.push({ + name: h.variableName || '', + hookType: h.hookType, + value: __qwik_hook_read_value__(h.data), + }); + } + } + + return { path, name, signals, hooks: hookEntries }; + }); + }, + + onRender(callback) { + __qwik_hook_render_listeners__.push(callback); + return () => { + const idx = __qwik_hook_render_listeners__.indexOf(callback); + if (idx >= 0) __qwik_hook_render_listeners__.splice(idx, 1); + }; + }, + + getComponentDetail(componentName, qrlChunk) { + const state = window.QWIK_DEVTOOLS_GLOBAL_STATE; + if (!state) return null; + + let matchingKey = null; + const keys = Object.keys(state); + + // Strategy 1: match by QRL chunk path (same as overlay's getQwikState) + if (qrlChunk) { + matchingKey = keys.find((key) => key.endsWith(qrlChunk)) || null; + } + + // Strategy 2: match by component name + if (!matchingKey) { + const lowerName = componentName.toLowerCase(); + for (const key of keys) { + const lastSeg = key.split('/').pop() || key; + const underIdx = lastSeg.lastIndexOf('_'); + const name = underIdx > 0 ? lastSeg.substring(underIdx + 1) : lastSeg; + if (name.toLowerCase() === lowerName) { + matchingKey = key; + break; + } + } + } + + if (!matchingKey) return null; + const comp = state[matchingKey]; + if (!comp || !comp.hooks) return null; + + return comp.hooks + .filter((h) => h.data != null) + .map((h) => ({ + hookType: h.hookType || 'unknown', + variableName: h.variableName || h.hookType || 'unknown', + data: __qwik_hook_serialize_deep__(h.data, 0), + })); + }, + + setSignalValue(componentName, qrlChunk, variableName, newValue) { + const state = window.QWIK_DEVTOOLS_GLOBAL_STATE; + if (!state) return false; + + let matchingKey = null; + const keys = Object.keys(state); + + if (qrlChunk) { + matchingKey = keys.find((key) => key.endsWith(qrlChunk)) || null; + } + if (!matchingKey) { + const lowerName = componentName.toLowerCase(); + for (const key of keys) { + const lastSeg = key.split('/').pop() || key; + const underIdx = lastSeg.lastIndexOf('_'); + const name = underIdx > 0 ? lastSeg.substring(underIdx + 1) : lastSeg; + if (name.toLowerCase() === lowerName) { matchingKey = key; break; } + } + } + if (!matchingKey) return false; + + const comp = state[matchingKey]; + if (!comp || !comp.hooks) return false; + + for (const h of comp.hooks) { + if (h.variableName === variableName && h.data != null) { + try { + if (typeof h.data === 'object' && 'value' in h.data) { + h.data.value = newValue; + return true; + } + } catch (_) {} + } + } + return false; + }, + + onSignalUpdate(callback) { + // v1: no real-time signal tracking. + // Consumers should poll getSignalsSnapshot() and diff. + return () => {}; + }, + }; +}; + +__qwik_hook_init__(); +`; + +export default hookRuntime; diff --git a/packages/plugin/src/virtualmodules/perfRuntime.ts b/packages/plugin/src/virtualmodules/perfRuntime.ts index fee189e..94b2693 100644 --- a/packages/plugin/src/virtualmodules/perfRuntime.ts +++ b/packages/plugin/src/virtualmodules/perfRuntime.ts @@ -75,10 +75,21 @@ const __qwik_perf_commit_csr__ = (entry) => { perf._csrByViteId[entry.viteId] = perf.csr.length; perf.csr.push(next); } - return; + } else { + perf.csr.push(next); } - perf.csr.push(next); + // Notify devtools hook + extension + var renderEvent = { + component: (entry && entry.component) || 'unknown', + phase: 'csr', + duration: (entry && entry.duration) || 0, + timestamp: Date.now(), + }; + if (window.__QWIK_DEVTOOLS_HOOK__ && window.__QWIK_DEVTOOLS_HOOK__._emitRender) { + window.__QWIK_DEVTOOLS_HOOK__._emitRender(renderEvent); + } + window.postMessage({ source: 'qwik-devtools', type: 'RENDER_EVENT', event: renderEvent }, '*'); }; // Force componentQrl entries to be treated as SSR records. @@ -118,6 +129,18 @@ const __qwik_perf_commit_componentqrl__ = (entry) => { if (key) perf._ssrByComponent[key] = perf.ssr.length; perf.ssr.push({ id, ...next, ssrCount: 1 }); } + + // Notify devtools hook + extension (componentQrl runs on CSR too) + var renderEvent2 = { + component: (entry && entry.component) || 'unknown', + phase: 'csr', + duration: (entry && entry.duration) || 0, + timestamp: Date.now(), + }; + if (window.__QWIK_DEVTOOLS_HOOK__ && window.__QWIK_DEVTOOLS_HOOK__._emitRender) { + window.__QWIK_DEVTOOLS_HOOK__._emitRender(renderEvent2); + } + window.postMessage({ source: 'qwik-devtools', type: 'RENDER_EVENT', event: renderEvent2 }, '*'); }; const __qwik_perf_commit__ = (entry) => { diff --git a/packages/plugin/src/virtualmodules/useCollectHooks.ts b/packages/plugin/src/virtualmodules/useCollectHooks.ts index a6ead6a..cf05665 100644 --- a/packages/plugin/src/virtualmodules/useCollectHooks.ts +++ b/packages/plugin/src/virtualmodules/useCollectHooks.ts @@ -31,7 +31,7 @@ export const useCollectHooks = (src) => { const state = getOrCreateState(src) state.hooks = [...newHooks] }, { strategy: 'document-ready' }) - + return $((args) => { if (hooksList.value.has(args)) { return @@ -39,6 +39,6 @@ export const useCollectHooks = (src) => { hooksList.value.add(args) }) } -` +`; -export default useCollectHooks +export default useCollectHooks; diff --git a/packages/plugin/src/virtualmodules/virtualModules.ts b/packages/plugin/src/virtualmodules/virtualModules.ts index 8e53ce5..9d9a2ca 100644 --- a/packages/plugin/src/virtualmodules/virtualModules.ts +++ b/packages/plugin/src/virtualmodules/virtualModules.ts @@ -1,6 +1,7 @@ import { VIRTUAL_QWIK_DEVTOOLS_KEY, INNER_USE_HOOK } from '@devtools/kit'; import useCollectHooksSource from './useCollectHooks'; import qwikComponentProxySource from './qwikComponentProxy'; +import vnodeBridgeSource, { VNODE_BRIDGE_KEY } from './vnodeBridge'; import { parseQwikCode } from '../parse/parse'; import { debug } from 'debug'; @@ -28,6 +29,12 @@ export const VIRTUAL_MODULES: VirtualModuleConfig[] = [ source: qwikComponentProxySource, hookName: '', }, + { + // VNode bridge: exposes getVNodeTree() on the devtools hook + key: VNODE_BRIDGE_KEY, + source: vnodeBridgeSource, + hookName: '', + }, ]; // ============================================================================ @@ -76,6 +83,7 @@ export function transformRootFile(code: string): string { mode === 'dev' ? '@devtools/ui/styles.css' : '@qwik.dev/devtools/ui/styles.css'; const devtoolsImport = `import { QwikDevtools } from '${importPath}';`; const stylesImport = `import '${styleImportPath}';`; + const bridgeImport = `import '${VNODE_BRIDGE_KEY}';`; // Add QwikDevtools import if not present if (!code.includes(devtoolsImport)) { @@ -87,6 +95,9 @@ export function transformRootFile(code: string): string { code = `${stylesImport}\n${code}`; } + // VNode bridge is loaded via SSR middleware