diff --git a/.changeset/shared-vnode-bridge.md b/.changeset/shared-vnode-bridge.md new file mode 100644 index 00000000000..355a40fbf83 --- /dev/null +++ b/.changeset/shared-vnode-bridge.md @@ -0,0 +1,12 @@ +--- +'@qwik.dev/devtools': patch +--- + +refactor(devtools): generate the extension VNode bridge from one shared source + +The browser extension's `public/vnode-bridge.js` duplicated the VNode bridge logic +(tree building, prop serialization, name normalization, DOM resolution, highlighting, +component tree update posting) that the Vite plugin already owns via +`__qwik_install_vnode_runtime__` / `createVNodeRuntime()`. It is now generated from +that single canonical source by the extension build (alongside `devtools-hook.js`) +and is no longer committed. diff --git a/packages/browser-extension/.gitignore b/packages/browser-extension/.gitignore index 0cb4ce8fdf6..7b453c2ecec 100644 --- a/packages/browser-extension/.gitignore +++ b/packages/browser-extension/.gitignore @@ -2,5 +2,6 @@ node_modules/ # Generated from packages/devtools/plugin/src/runtime/installers.ts by -# scripts/gen-devtools-hook.mjs (runs as part of build/dev). Not committed. +# scripts/gen-injected-scripts.mjs (runs as part of build/dev). Not committed. public/devtools-hook.js +public/vnode-bridge.js diff --git a/packages/browser-extension/package.json b/packages/browser-extension/package.json index 17ee3fb9c73..b1643a72c30 100644 --- a/packages/browser-extension/package.json +++ b/packages/browser-extension/package.json @@ -14,10 +14,10 @@ }, "private": true, "scripts": { - "build": "node scripts/gen-devtools-hook.mjs && wxt build", - "build:firefox": "node scripts/gen-devtools-hook.mjs && wxt build --browser firefox", - "dev": "node scripts/gen-devtools-hook.mjs && wxt", - "dev:firefox": "node scripts/gen-devtools-hook.mjs && wxt --browser firefox", + "build": "node scripts/gen-injected-scripts.mjs && wxt build", + "build:firefox": "node scripts/gen-injected-scripts.mjs && wxt build --browser firefox", + "dev": "node scripts/gen-injected-scripts.mjs && wxt", + "dev:firefox": "node scripts/gen-injected-scripts.mjs && wxt --browser firefox", "lint": "eslint src/", "test": "vitest run", "test:watch": "vitest", diff --git a/packages/browser-extension/public/vnode-bridge.js b/packages/browser-extension/public/vnode-bridge.js deleted file mode 100644 index de37e637573..00000000000 --- a/packages/browser-extension/public/vnode-bridge.js +++ /dev/null @@ -1,308 +0,0 @@ -/** - * 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 = ''; - var qrlPath = ''; - try { - var chunk = renderFn.$chunk$ || ''; - var splitPoint = '_component'; - var idx = chunk.indexOf(splitPoint); - qrlChunk = idx > 0 ? chunk.substring(0, idx) : chunk; - qrlPath = renderFn.dev && renderFn.dev.file ? renderFn.dev.file : qrlChunk; - } 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; - if (qrlPath) nodeProps.__qrlPath = qrlPath; - 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__ && 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/scripts/gen-devtools-hook.mjs b/packages/browser-extension/scripts/gen-devtools-hook.mjs deleted file mode 100644 index 464a7d941fb..00000000000 --- a/packages/browser-extension/scripts/gen-devtools-hook.mjs +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Generates `public/devtools-hook.js` from the canonical devtools hook runtime. - * - * The runtime logic lives once in `packages/devtools/plugin/src/runtime/installers.ts` - * (**qwik_install_hook_runtime**) and is shared by the Vite plugin (SSR middleware) and this - * extension (content script). - * - * Runs automatically as the first step of the `build` and `dev` scripts. - */ -import { existsSync, writeFileSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const here = dirname(fileURLToPath(import.meta.url)); - -// Internal build artifact of @qwik.dev/devtools (not a published subpath), imported -// by relative path. Requires the devtools package to be built first. -const codegenPath = resolve(here, '..', '..', 'devtools', 'dist', 'plugin', 'codegen.mjs'); -if (!existsSync(codegenPath)) { - console.error( - `[gen-devtools-hook] missing ${codegenPath}\n` + - 'Build the devtools package first (pnpm --filter @qwik.dev/devtools build).' - ); - process.exit(1); -} - -const { createExtensionHookRuntime } = await import(codegenPath); -const outPath = resolve(here, '..', 'public', 'devtools-hook.js'); -writeFileSync(outPath, createExtensionHookRuntime()); -console.log(`[gen-devtools-hook] wrote ${outPath}`); diff --git a/packages/browser-extension/scripts/gen-injected-scripts.mjs b/packages/browser-extension/scripts/gen-injected-scripts.mjs new file mode 100644 index 00000000000..80d7d30102b --- /dev/null +++ b/packages/browser-extension/scripts/gen-injected-scripts.mjs @@ -0,0 +1,46 @@ +/** + * Generates the browser extension's injected page scripts from their canonical implementations: + * + * - `public/devtools-hook.js` from `__qwik_install_hook_runtime__` + * - `public/vnode-bridge.js` from `__qwik_install_vnode_runtime__` + * + * Both live once in `packages/devtools/plugin/src/runtime/` and are shared with the Vite plugin + * (SSR middleware / virtual module). This script keeps the extension copies in sync. + * + * Runs automatically as the first step of the `build` and `dev` scripts. + */ +import { existsSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); + +// Internal build artifact of @qwik.dev/devtools (not a published subpath), imported +// by relative path. Requires the devtools package to be built first. +const codegenPath = resolve(here, '..', '..', 'devtools', 'dist', 'plugin', 'codegen.mjs'); +if (!existsSync(codegenPath)) { + console.error( + `[gen-injected-scripts] missing ${codegenPath}\n` + + 'Build the devtools package first (pnpm --filter @qwik.dev/devtools build).' + ); + process.exit(1); +} + +const publicDir = resolve(here, '..', 'public'); +const { createExtensionHookRuntime, createVNodeRuntime } = await import(codegenPath); + +const VNODE_BANNER = + '// GENERATED by scripts/gen-injected-scripts.mjs from\n' + + '// packages/devtools/plugin/src/runtime/installers.ts (__qwik_install_vnode_runtime__).\n' + + '// Do not edit by hand.\n'; + +const outputs = [ + { file: 'devtools-hook.js', source: createExtensionHookRuntime() }, + { file: 'vnode-bridge.js', source: VNODE_BANNER + createVNodeRuntime() }, +]; + +for (const { file, source } of outputs) { + const outPath = resolve(publicDir, file); + writeFileSync(outPath, source); + console.log(`[gen-injected-scripts] wrote ${outPath}`); +} diff --git a/packages/devtools/plugin/src/codegen.ts b/packages/devtools/plugin/src/codegen.ts index 438c35d99b1..3ed1a7d9a9d 100644 --- a/packages/devtools/plugin/src/codegen.ts +++ b/packages/devtools/plugin/src/codegen.ts @@ -1,5 +1,5 @@ /** - * Build-time codegen entry for the devtools hook runtimes. + * Build-time codegen entry for the devtools injected runtimes. * * Exposes the runtime-string builders so in-repo build steps (e.g. the browser extension) can * generate their injected scripts from the single canonical implementation in @@ -10,3 +10,4 @@ * subpath. It is also not part of the browser bundle. */ export { createHookRuntime, createExtensionHookRuntime } from './runtime/create-hook-runtime'; +export { createVNodeRuntime } from './runtime/create-vnode-runtime';