feat(devtools): vite devtools integration#731
Conversation
Add devtools and devtools-app packages with Vue composables and plugin updates.
Add devtools and devtools-app packages with Vue composables and plugin updates.
|
Caution Review failedPull request was closed or merged during review Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds a Unhead DevTools system: Vite transform injects source locations, SSR emits a devtools JSON payload, a client bridge serializes/syncs head state via Vite DevTools RPC, a Vite server plugin serves the bridge/UI and registers RPCs, bundler support (plugin/types/RPC/bridge) is added, and a Nuxt-based DevTools UI app is introduced. Changes
Sequence Diagram(s)sequenceDiagram
participant Source as Source Code
participant Vite as Vite Dev Plugin
participant AST as AST Transform
participant Browser as Browser Runtime
participant Bridge as Devtools Bridge
participant RPC as DevTools RPC
participant UI as DevTools UI
Source->>Vite: request module (.vue/.ts/.js/.jsx/.svelte)
Vite->>AST: parse & inject `_source` into useHead/useSeoMeta/useScript
AST-->>Vite: transformed module
Vite->>Browser: deliver transformed module + bridge import
Browser->>Bridge: init, locate head state (window or SSR payload)
Bridge->>Bridge: serialize entries/tags/scripts/seo/validation
Bridge->>RPC: connect via devtools-kit client and publish `unhead:state`
RPC-->>UI: UI subscribes to `unhead:state` updates
Browser->>Bridge: on 'dom:rendered' -> trigger update
Bridge->>RPC: update shared state
RPC->>UI: notify -> UI re-renders
sequenceDiagram
participant App as Vue SSR App
participant Composable as useHead/useSeoMeta
participant DevPlugin as devtoolsPlugin (SSR)
participant HTML as Rendered HTML
participant Bridge as Devtools Bridge
App->>Composable: call useHead(meta) (with injected _source)
Composable->>DevPlugin: entries/tags available during SSR
DevPlugin->>DevPlugin: serialize entries/tags -> payload
DevPlugin->>HTML: append `<script id="unhead:devtools">` JSON payload
HTML->>Browser: client receives SSR payload
Browser->>Bridge: bridge merges server payload with client-resolved state
Bridge->>RPC: publish merged state for UI consumption
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120+ minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
- Allow `preload` + `fetchpriority="low"` for `as="script"` (the warmup pattern used by `useScript` to start fetching at low priority). - Skip `preload-async-defer-conflict` when the preload uses `fetchpriority="low"` for the same reason. - Run `charset-not-early` only on SSR (DOM order is already set after hydration), and sort by capo weight while filtering virtual tags (`templateParams`, `titleTemplate`) so they don't inflate the position count. - Pass tag references to several `report()` calls so consumers can surface the offending tag in error messages.
# Conflicts: # examples/vite-ssr-vue/package.json # pnpm-lock.yaml # pnpm-workspace.yaml
…e validate
Foundational changes that landed alongside the in-progress devtools work.
Splits the bundler refactor + DX improvements out from the devtools branch
so they can be reviewed and merged independently.
## Bundler
- BREAKING: `default` export → named `Unhead` export on `@unhead/bundler/{vite,webpack}`
and on every framework wrapper (`@unhead/{react,solid-js,svelte,vue}/vite`).
Existing imports must change from `import unhead from ...` to
`import { Unhead } from ...`.
- New `VitePluginOptions` interface (extends `UnpluginOptions`) with a
`validate` flag that injects `ValidatePlugin` in dev so head-tag warnings
surface in the console without manual setup. Internal `_framework` field
lets framework wrappers identify themselves to the runtime.
- New `CreateHeadTransform` + `createHeadTransformContext` foundation: a
single transform handles `createHead()` wrapping and lets other plugins
register runtime plugins via shared context.
- New `SSRStaticReplace` transform: replaces `head.ssr` with a static
boolean per environment so the dead branch tree-shakes cleanly.
- `UseSeoMetaTransform` now preserves the second argument (e.g.
`useSeoMeta(meta, { head })`) when rewriting to `useHead`.
- Tests: `createHeadTransform.test.ts` and updated `useSeoMetaTransform.test.ts`.
## Framework wrappers
- Vue / React / Solid / Svelte vite plugins updated for the named export
and forward `_framework` so the bundler can resolve framework-scoped
runtime plugins (`@unhead/vue/plugins`, etc.).
- `packages-aliased/addons` deprecation shim re-exports the new named export.
## Vue
- Re-exports `useServerHead`, `useServerHeadSafe`, `useServerSeoMeta` as
deprecated aliases for the non-`Server` variants (v2 compat).
- Re-exports `resolveUnrefHeadInput` from `utils` (v2 compat).
## Schema.org
- Exports `schemaAutoImports` from the package root so consumers can wire
it into `unplugin-auto-import` without reaching into subpaths.
## Docs
- Reorganises `1.guides/2.advanced/{vite-plugin,client-only-tags,extending-unhead}`
into a new `1.guides/build-plugins/` section: overview, tree-shaking,
seo-meta-transform, minify-transform.
- Migration guide moves out of `content/` to top-level `6.migration-guide/`.
- Adds `7.api/plugins.md` and updates `use-head` / `use-seo-meta` API pages.
## Examples
- `vite-ssr-vue-prerender`: switches to the named `Unhead` import.
Two [email protected] instances were resolved due to a peer-dep variation on the bundler package's optional `esbuild` peer, causing TS to see two incompatible Plugin types in the new bundler transforms. `pnpm update && pnpm dedupe` collapses them onto a single resolution and bumps vitest to 4.1.4.
Addresses CodeRabbit review feedback on #733. - SSRStaticReplace: vite.apply now returns true so the plugin actually runs during SSR builds (returning false disabled it entirely, so the head.ssr static replacement never happened) - CreateHeadTransform: scope createHead callsite matching to symbols imported from unhead/@unhead modules; supports named, aliased, and namespace imports; ignores shadowed locals and unrelated packages - Tests: cover false-positive cases (non-Unhead packages, shadowed locals) plus aliased and namespace imports - Docs: rename unhead({...}) -> Unhead({...}) in build-plugins examples, drop undocumented devtools option from overview, fix head.resolveTags() to resolveTags(head) in plugins API page - Migration guide: restore v3 content (was emptied in 66c387c) and add @unhead/addons -> @unhead/bundler rename + named Unhead export sections
- v3.md: add blank line before ::tip closing marker (markdownlint MD031) - plugins.md: hyphenate "hooks-based" compound adjective
…ools-next Brings the named Unhead export, ctx-based transforms, dev-mode validate flag, and CodeRabbit fixes from #733 into the devtools branch. Conflict resolution: - types.ts / vite.ts: keep both devtools and validate options side by side - CreateHeadTransform / SSRStaticReplace / tests / build-plugins docs: take the bundler branch versions (scoped createHead matching, vite.apply returning true, Unhead({...}) casing, plugins API resolveTags fix) - v3 migration guide: take bundler branch (full v3 content + addons->bundler rename + named export sections) - 0.overview.md: merge devtools and validate option rows + bullets - pnpm-lock.yaml: regenerated via pnpm install + pnpm dedupe to match the bundler dedupe fix from 73fe714 - SSRStaticReplace.ts: annotate vite.apply return type as boolean to avoid literal-narrowing TS error against vite's UnpluginOptions['vite'] Tests: 74/74 bundler tests pass; remaining tsc errors are pre-existing "Cannot find module" issues from packages requiring build first.
There was a problem hiding this comment.
Actionable comments posted: 19
🧹 Nitpick comments (18)
packages/devtools-app/app/components/Logo.vue (1)
41-67: Consider a reduced-motion fallback for logo animations.This animation runs unconditionally; adding a
prefers-reduced-motionoverride improves accessibility without changing default visuals.♿ Suggested CSS fallback
+@media (prefers-reduced-motion: reduce) { + .logo-path, + .logo-letter { + animation: none !important; + opacity: 1; + transform: none; + } +}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/devtools-app/app/components/Logo.vue` around lines 41 - 67, Add a prefers-reduced-motion CSS override so the logo animations don't run for users who request reduced motion: target the .logo-path and .logo-letter selectors (and the `@keyframes` draw/fadeIn outcomes) inside a `@media` (prefers-reduced-motion: reduce) block and disable animations by setting animation: none and applying the final visual states (stroke-dashoffset: 0 for .logo-path and opacity: 1; transform: translateX(0) for .logo-letter) to preserve the intended static appearance without motion.packages/devtools-app/app/composables/tools.ts (1)
119-124: Avoid magic number intitleColorand align withSEO_LIMITS.
titleColorcurrently hardcodes30, whileSEO_LIMITSalready defines title thresholds. Centralizing this avoids drift and keeps the UI rules explicit.♻️ Suggested refactor
export const SEO_LIMITS = { TITLE_MAX_CHARS: 60, TITLE_WARN_CHARS: 50, + TITLE_MIN_CHARS: 30, TITLE_MAX_PIXELS: 580, DESC_MAX_CHARS: 160, DESC_WARN_CHARS: 150, DESC_MAX_PIXELS: 920, } as constexport function titleColor(length: number) { if (length > SEO_LIMITS.TITLE_MAX_CHARS) return 'error' - if (length < 30) + if (length < SEO_LIMITS.TITLE_MIN_CHARS) return 'warning' return 'success' }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/devtools-app/app/composables/tools.ts` around lines 119 - 124, The function titleColor uses a magic number 30; replace that hardcoded threshold with the corresponding constant from SEO_LIMITS (e.g., SEO_LIMITS.TITLE_MIN_CHARS) so the logic reads: if length > SEO_LIMITS.TITLE_MAX_CHARS return 'error', if length < SEO_LIMITS.TITLE_MIN_CHARS return 'warning', else 'success'; if SEO_LIMITS does not currently expose a min/title-warning constant, add SEO_LIMITS.TITLE_MIN_CHARS (or a clearly named threshold) and use it in titleColor to centralize thresholds.examples/vite-ssr-vue-streaming/vite.config.ts (1)
12-15: Clarify the purpose of the top-leveldevtoolsconfig block.This
devtoolsconfiguration at the Vite root level (withenabled,clientAuth,clientAuthTokens) appears to be for@vitejs/devtools, not for Unhead's devtools. This is fine, but ensure the example documentation clarifies this distinction to avoid confusion with theUnhead({ devtools: ... })option.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@examples/vite-ssr-vue-streaming/vite.config.ts` around lines 12 - 15, Clarify in the example that the top-level devtools block in vite.config.ts (the object with properties enabled, clientAuth, clientAuthTokens) is the Vite root-level configuration for `@vitejs/devtools` and not the Unhead({ devtools: ... }) option; update the surrounding comment or documentation text to explicitly state this distinction and, if helpful, mention the Unhead devtools option by name (Unhead({ devtools: ... })) so readers don't confuse the two.packages/bundler/src/unplugin/types.ts (1)
27-28: Consider adding placeholder documentation for future options.The empty
UnheadDevtoolsOptionsinterface is fine as a starting point, but consider adding a JSDoc comment indicating it's extensible for future configuration options.📝 Suggested documentation
+/** Configuration options for Unhead DevTools integration. */ export interface UnheadDevtoolsOptions {}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/bundler/src/unplugin/types.ts` around lines 27 - 28, Add a JSDoc comment above the empty UnheadDevtoolsOptions interface to document that this interface is intentionally empty/extendable for future configuration options; mention it's a placeholder for optional fields, how consumers can extend it, and any intended usage or stability guarantees so future contributors know it's safe to add properties and where to document them (reference the UnheadDevtoolsOptions interface).packages/devtools-app/app/components/OCodeBlock.vue (1)
9-9: Minor: Simplify computed wrapper.The
computed(() => code)wrapping is unnecessary whencodeis already a reactive prop. You can passcodedirectly or usetoRefif the composable specifically requires a ref.♻️ Suggested simplification
-const rendered = useRenderCodeHighlight(computed(() => code), lang) +const rendered = useRenderCodeHighlight(toRef(() => code), lang)Or if
useRenderCodeHighlightaccepts plain values with reactive tracking:-const rendered = useRenderCodeHighlight(computed(() => code), lang) +const rendered = useRenderCodeHighlight(() => code, lang)🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/devtools-app/app/components/OCodeBlock.vue` at line 9, The call to useRenderCodeHighlight currently wraps the reactive prop in computed(() => code), which is unnecessary; change the argument to pass the prop directly (code) or convert it to a ref with toRef(props, 'code') if the composable requires a Ref, i.e., replace computed(() => code) with either code or toRef(...) when invoking useRenderCodeHighlight so the composable receives the original reactive value.examples/vite-ssr-vue/vite.config.ts (1)
15-19: Consider consolidatinguseScriptintounheadVueComposablesImports.The
useScriptcomposable is imported separately on line 18, but based on the context snippet frompackages/vue/src/index.ts,useScriptis exported from@unhead/vue. Consider adding it tounheadVueComposablesImportsin the source package rather than requiring separate auto-import configuration in each project.For this example, the current approach works fine.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@examples/vite-ssr-vue/vite.config.ts` around lines 15 - 19, The config separately imports useScript from '@unhead/vue' even though it should be part of the shared unheadVueComposablesImports list; update the exports in the source package (packages/vue/src/index.ts) to include 'useScript' in the unheadVueComposablesImports array (so the composable is auto-exported centrally), then remove the ad-hoc { '@unhead/vue': ['useScript'] } entry from vite.config.ts; target symbols: unheadVueComposablesImports, useScript, and the export surface in packages/vue/src/index.ts.examples/vite-ssr-vue/src/router.ts (1)
7-24: Consider adding type annotation to routes array.Adding a type annotation improves IDE support and catches route configuration errors early.
📝 Suggested type annotation
+import type { RouteRecordRaw } from 'vue-router' import { createRouter as _createRouter, createMemoryHistory, createWebHistory, } from 'vue-router' -const routes = [ +const routes: RouteRecordRaw[] = [ { path: '/', component: () => import('./pages/HomePage.vue'), },🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@examples/vite-ssr-vue/src/router.ts` around lines 7 - 24, The routes array lacks a type annotation which hurts IDE support; import the RouteRecordRaw type from 'vue-router' and annotate the routes constant (e.g., const routes: RouteRecordRaw[] = [...]) so each entry is type-checked; update any async component entries as needed to satisfy the RouteRecordRaw shape and fix any type errors reported by the compiler in the routes definition.examples/vite-ssr-vue/src/App.vue (1)
27-32: Consider adding an accessible label to the navigation landmark.If another
<nav>is introduced later, naming this one improves landmark clarity for assistive tech.♿ Suggested tweak
- <nav class="site-nav"> + <nav class="site-nav" aria-label="Primary">🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@examples/vite-ssr-vue/src/App.vue` around lines 27 - 32, The <nav class="site-nav"> landmark in App.vue lacks an accessible name; update the element (in the App.vue template where <nav class="site-nav"> is defined) to include an accessible label such as aria-label="Main navigation" or aria-labelledby referencing a visible heading, so screen readers can distinguish this navigation if additional <nav> elements are added.packages/devtools-app/app/components/DevtoolsToolbar.vue (1)
2-11: Optional: expose an accessible toolbar label prop.Adding
aria-labelsupport makes this reusable in views that may contain multiple toolbars.♿ Suggested enhancement
const { variant = 'default', + label = 'Devtools toolbar', } = defineProps<{ variant?: 'default' | 'minimal' + label?: string }>()- <div class="devtools-toolbar" :class="`devtools-toolbar--${variant}`" role="toolbar"> + <div class="devtools-toolbar" :class="`devtools-toolbar--${variant}`" role="toolbar" :aria-label="label">🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/devtools-app/app/components/DevtoolsToolbar.vue` around lines 2 - 11, The toolbar component currently accepts only the variant prop; add an optional accessible label prop (e.g., label?: string) via defineProps in DevtoolsToolbar.vue and bind it to the root element as an aria-label (and/or aria-labelledby when applicable) while preserving the existing role="toolbar" and class binding (`devtools-toolbar--${variant}`); ensure the prop has a sensible default or is optional so existing usages are unaffected.packages/devtools-app/app/app.vue (1)
9-10: Async functions called withoutawaitor error handling.Both
useDevtoolsConnection()andloadShiki()are async functions called in a fire-and-forget manner. If either fails, the error will be an unhandled promise rejection. Consider wrapping in try/catch or using.catch()to handle failures gracefully.♻️ Optional: Add error handling for async initialization
-useDevtoolsConnection() -loadShiki() +useDevtoolsConnection().catch(err => console.warn('[devtools] RPC connection failed:', err)) +loadShiki().catch(err => console.warn('[devtools] Shiki load failed:', err))Note:
loadShiki()already has internal error handling, butuseDevtoolsConnection()does not (see earlier comment onrpc.ts).🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/devtools-app/app/app.vue` around lines 9 - 10, Both async calls are fire-and-forget and can produce unhandled rejections: update the component setup to await useDevtoolsConnection() and loadShiki() inside an async init function (or attach .catch handlers) and wrap useDevtoolsConnection() in try/catch (or ensure rpc.ts exposes internal error handling) so failures are logged/handled instead of propagating; specifically locate the calls to useDevtoolsConnection() and loadShiki(), call them from an async init/created hook, await them or add .catch(...) and ensure errors from useDevtoolsConnection() are caught and sent to your logger/console.examples/vite-ssr-vue/src/pages/ScriptsPage.vue (1)
10-25: Type safety issue withwindow.confettiaccess.The
useScriptgeneric type defines{ confetti: (opts: any) => void }butwindow.confettiis accessed directly without type assertion. This may cause TypeScript errors in strict mode sincewindowdoesn't have aconfettiproperty by default.♻️ Suggested fix for type safety
-const { $script: confetti } = useScript<{ confetti: (opts: any) => void }>({ +const { $script: confetti } = useScript({ src: 'https://cdn.jsdelivr.net/npm/[email protected]/dist/confetti.browser.min.js', }, { trigger: 'manual', - use: () => ({ confetti: window.confetti }), + use: () => ({ confetti: (window as any).confetti as (opts: any) => void }), }) function fireConfetti() { confetti.load().then(() => { - window.confetti({ + ;(window as any).confetti({ particleCount: 150, spread: 80, origin: { y: 0.6 }, }) }) }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@examples/vite-ssr-vue/src/pages/ScriptsPage.vue` around lines 10 - 25, The code accesses window.confetti without a type assertion which breaks strict TS; update the useScript call and fireConfetti to be type-safe: either add a global declaration for confetti (declare global interface Window { confetti?: (opts: any) => void }) or cast when reading from window, e.g., change the use callback to () => ({ confetti: (window as any).confetti }) and in fireConfetti guard with confetti?.load()?.then(...) and call confetti?.({ ... }) (or cast confetti as the typed function before invoking) so all uses of window.confetti and the confetti variable match the { confetti: (opts: any) => void } generic.packages/devtools-app/app/pages/schema.vue (2)
23-31: Silent continuation on JSON parse failure may hide issues.The loop silently continues when JSON parsing fails. While this gracefully handles malformed JSON-LD, it might be helpful to log a warning in development so authors know their JSON-LD is invalid.
♻️ Optional: Add development warning for parse failures
for (const tag of jsonLdTags) { try { return JSON.parse(tag.innerHTML || '{}') } catch { + console.warn('[devtools] Failed to parse JSON-LD:', tag.innerHTML?.slice(0, 100)) continue } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/devtools-app/app/pages/schema.vue` around lines 23 - 31, The loop over jsonLdTags currently swallows JSON.parse errors silently; update the catch block inside the for (const tag of jsonLdTags) loop to emit a development-only warning (e.g., when process.env.NODE_ENV !== 'production') that includes context such as the failing tag content or its outerHTML and the parse error message; keep the existing continue behavior so runtime isn't disrupted but authors get a clear console.warn in dev when JSON-LD is malformed.
104-170: RepeatedgetNodeType(node)andgoogleRichResultsRequirementslookups.Within the template,
getNodeType(node)andgoogleRichResultsRequirements[getNodeType(node)]are called multiple times per node iteration. For better performance and readability, consider pre-computing these values.♻️ Optional: Pre-compute node analysis
+const processedNodes = computed(() => { + return graphNodes.value.map((node: any) => { + const type = getNodeType(node) + const requirements = googleRichResultsRequirements[type] + const analysis = requirements ? analyzeNodeProperties(node) : null + return { node, type, requirements, analysis } + }) +})Then use
processedNodesin the template instead of calling these functions repeatedly.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/devtools-app/app/pages/schema.vue` around lines 104 - 170, Precompute the node type and its Google requirements once per node and use those local values in the template instead of calling getNodeType(node) and indexing googleRichResultsRequirements repeatedly; e.g., create a processedNodes array or computed that maps each node to { node, nodeType: getNodeType(node), req: googleRichResultsRequirements[getNodeType(node)], analysis: analyzeNodeProperties(node) } and then update the template to use nodeType, req, and analysis (replace getNodeType(node), googleRichResultsRequirements[getNodeType(node)], and analyzeNodeProperties(node) calls), and keep other helpers like formatPropertyValue and nodeToSchemaOrgLink unchanged but reference them with the precomputed nodeType/req.packages/devtools-app/app/composables/state.ts (1)
3-72: Keep the devtools state contract in one shared module.These interfaces are duplicated from
packages/bundler/src/devtools/rpc/types.ts. Since that file defines the payload produced by the bridge, copying the shapes here makes it easy for the client and server to drift out of sync on the next field change.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/devtools-app/app/composables/state.ts` around lines 3 - 72, The declared interfaces (e.g., UnheadDevtoolsState, SerializedEntry, SerializedTag, SerializedScript, SerializedValidationRule, SeoOverview) duplicate types from the bridge and should be centralized: remove these duplicated type declarations and import the canonical types from the shared RPC/types module used by the bridge; update any references in this file to use the imported types (e.g., replace local UnheadDevtoolsState with the shared UnheadDevtoolsState) and ensure TypeScript exports/usage remain consistent so client and server share a single source of truth.packages/bundler/src/devtools/bridge.ts (2)
200-200: JSON object key order may cause inconsistent fingerprints.
JSON.stringify(t.props)doesn't guarantee consistent key ordering across different execution contexts. If SSR and client construct the props object with different key insertion orders, identical tags may not match, resulting in duplicate entries.Consider sorting keys before stringifying:
♻️ Suggested fix
- const tagFingerprint = (t: any) => t.dedupeKey || `${t.tag}:${JSON.stringify(t.props || {})}` + const tagFingerprint = (t: any) => { + if (t.dedupeKey) return t.dedupeKey + const sortedProps = Object.keys(t.props || {}).sort().reduce((acc, k) => { acc[k] = t.props[k]; return acc }, {} as Record<string, any>) + return `${t.tag}:${JSON.stringify(sortedProps)}` + }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/bundler/src/devtools/bridge.ts` at line 200, The tagFingerprint function uses JSON.stringify(t.props) which can produce inconsistent key orders; update tagFingerprint (the const tagFingerprint = (t: any) => ...) to produce a deterministic fingerprint by canonicalizing/sorting the keys of t.props before stringifying (e.g., perform a stable sort of object keys or use a stable stringify helper) and still prefer t.dedupeKey when present so identical tags from SSR and client yield the same fingerprint.
365-378: Consider logging a more informative message on polling failure.When head polling fails after 50 attempts, users may not understand why devtools aren't working. Consider adding guidance or checking common misconfigurations.
♻️ Suggested improvement
if (++attempts > 50) { globalThis.clearInterval(handle) - console.warn('[unhead bridge] gave up polling for head after 50 attempts') + console.warn('[unhead bridge] gave up polling for head after 50 attempts. Ensure `window.__unhead__._head` or `window.__unhead_devtools__` is set by your app.') }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/bundler/src/devtools/bridge.ts` around lines 365 - 378, The pollForHead function's warning after 50 attempts is too vague; update the console.warn in pollForHead (which calls findHead and connectBridge) to provide actionable guidance and context — include the attempted count, the fact that no <head> was found, possible common causes (e.g., script loaded before DOM/head available, CSP blocking, or unmounting), and a suggestion to check that the bundler/devtools script is included after the HTML head or to enable a specific flag; keep the message clear and concise so users know what to inspect when polling fails.packages/bundler/src/devtools/vite.ts (2)
59-68: Missing case: 2+ args where second arg is not an ObjectExpression.When there are 2+ arguments but the second argument is not an
ObjectExpression(e.g., a variable reference or function call), the source location won't be injected. This is likely fine for devtools (partial coverage is acceptable), but worth noting for completeness.If coverage is desired for all cases, consider handling non-object second arguments by replacing or wrapping them, though this adds complexity.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/bundler/src/devtools/vite.ts` around lines 59 - 68, The current transform in packages/bundler/src/devtools/vite.ts only injects _source when args.length === 1 or when args[1].type === 'ObjectExpression', so calls where args.length >= 2 but args[1] is not an ObjectExpression are skipped; update the logic around args, args[1], s.appendRight and sourceValue to handle the "second arg is not an ObjectExpression" case by wrapping or replacing the second argument with an object that spreads the original value (e.g., { ...(originalSecondArg), _source: ... }) or by inserting a new third argument that contains _source, and ensure transformed is set to true; implement this in the same transform block that currently sets transformed and uses s.appendRight so the injection consistently covers non-object second-argument cases.
111-117: Node modules resolution may fail in monorepos or with alternative package managers.The path
resolve(pkgDir, 'node_modules/unhead/package.json')assumes a specific node_modules structure. In monorepos, pnpm workspaces, or when hoisting is disabled,unheadmay be located elsewhere.Consider using Node's module resolution APIs for more robust lookup:
♻️ Suggested improvement
+import { createRequire } from 'node:module' + +const require = createRequire(import.meta.url) + // In configResolved: - try { - const unheadPkg = resolve(pkgDir, 'node_modules/unhead/package.json') - if (existsSync(unheadPkg)) - unheadVersion = JSON.parse(readFileSync(unheadPkg, 'utf-8')).version || '' - } - catch {} + try { + const unheadPkgPath = require.resolve('unhead/package.json') + unheadVersion = JSON.parse(readFileSync(unheadPkgPath, 'utf-8')).version || '' + } + catch {}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/bundler/src/devtools/vite.ts` around lines 111 - 117, The current lookup of unhead's package.json via resolve(pkgDir, 'node_modules/unhead/package.json') is brittle in monorepos/alternative package managers; replace it with Node's module resolution (e.g., require.resolve or import.meta.resolve with a paths option) to find 'unhead/package.json' relative to pkgDir, then read and parse that resolved path into unheadVersion (symbols: pkgDir, unheadPkg, unheadVersion). Keep the existing try/catch behavior and fallback to an empty string if resolution or read fails.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@docs/head/1.guides/build-plugins/4.devtools.md`:
- Line 7: Update the Vite DevTools link in the "**Quick Answer:**" line that
currently points to https://github.com/nicolo-ribaudo/vite-devtools so it uses
the official repository URL https://github.com/vitejs/devtools; locate the line
text containing "Vite DevTools" in the content and replace the old URL with the
new one while keeping the surrounding sentence intact.
In `@examples/vite-ssr-vue/src/pages/BlogPage.vue`:
- Line 67: The <dd> element content currently reads "Yes, Unhead has first class
SSR support with streaming capabilities."; update that string to use the
hyphenated form "first-class" to match the structured-data copy and maintain
consistency (locate and edit the <dd> node in BlogPage.vue where the phrase
appears).
In `@packages/bundler/package.json`:
- Line 108: The package "@vitejs/devtools-kit" is currently in devDependencies
but is required at runtime by the published entry point
packages/bundler/src/devtools/bridge.ts (registered in build.config.ts) which
dynamically imports "@vitejs/devtools-kit/client" (see the .catch() handling
around that import); move "@vitejs/devtools-kit" from devDependencies into
dependencies in package.json (or alternatively declare it as a peerDependency
and update README/install docs) so runtime installs include it and the dynamic
import in bridge.ts won’t fail in production.
In `@packages/bundler/src/devtools/plugin.ts`:
- Around line 66-69: The inline JSON payload added to ctx.tags (the script tag
with id 'unhead:devtools') is vulnerable because JSON.stringify leaves '<'
intact; update the code that pushes the script (where ctx.tags.push creates tag:
'script', innerHTML: JSON.stringify({ entries, tags })) to escape problematic
characters before inlining—e.g., post-process the JSON string to replace '<'
(and specifically the sequence '</script>') with their safe escapes (such as
'\u003c') or introduce a small utility (safeSerialize/safeJson) and use it
instead of plain JSON.stringify to produce innerHTML; ensure the replacement is
applied to the output used for innerHTML of the 'unhead:devtools' script tag.
In `@packages/devtools-app/app/assets/css/global.css`:
- Around line 102-108: The universal selector block (*) contains a duplicate
custom property (--scrollbar-thumb) which triggers the linter; remove the
redundant declaration so --scrollbar-thumb is defined only once in the * block
(keep the desired value, e.g., --scrollbar-thumb: `#acbad2`) and leave the other
scrollbar-related properties (scrollbar-color, scrollbar-width,
--scrollbar-track) unchanged to resolve
declaration-block-no-duplicate-custom-properties.
In `@packages/devtools-app/app/components/DevtoolsEmptyState.vue`:
- Around line 19-22: The template currently wraps the named slot "description"
inside a <p>, which can produce invalid HTML; update the template in
DevtoolsEmptyState.vue to treat the slot and the plain-string prop separately:
render the description prop (prop name description) inside the <p class="text-xs
text-muted max-w-sm mx-auto">, but render the slot (slot name="description" /
$slots.description) inside a neutral container (e.g., a <div> or fragment) with
the same classes so arbitrary block markup from the slot isn’t nested inside a
<p>.
In `@packages/devtools-app/app/components/DevtoolsHeadEntry.vue`:
- Around line 23-26: The badge text can be empty when entry.mode is missing;
update the rendering in DevtoolsHeadEntry.vue to explicitly show a fallback
label (e.g., "Unknown" or modeConfig[entry.mode]?.label || "Unknown") instead of
rendering a blank string—keep the existing color/icon fallbacks
(modeConfig[entry.mode]?.color/icon) and replace {{ entry.mode }} with the
chosen fallback expression so UBadge always displays a non-empty label.
In `@packages/devtools-app/app/components/DevtoolsLayout.vue`:
- Around line 20-25: The activeTab computed currently marks a tab active if
route.path.startsWith(item.to), which incorrectly matches sibling routes; update
the matching logic inside the activeTab computed (iterating navItems) to return
item.value only when route.path === item.to or route.path.startsWith(item.to +
'/'), keeping the existing root check for item.to === '/' and route.path ===
'/'; this tightens non-root matches so a tab for '/schema' won’t match
'/schema-preview' while preserving matching for nested paths like
'/schema/fields'.
In `@packages/devtools-app/app/components/DevtoolsSection.vue`:
- Around line 8-18: The duplicate 'open' prop in defineProps causes a
compilation error because defineModel('open', { default: true }) already
declares the prop; remove the open?: boolean entry from the defineProps type
declaration so only defineModel manages the 'open' prop, leaving the existing
const open = defineModel('open', { default: true }) intact (search for
defineProps and defineModel('open') in DevtoolsSection.vue to update the props
list).
In `@packages/devtools-app/app/components/DevtoolsTagTable.vue`:
- Around line 205-212: expandedRows should track tag identity strings instead of
filtered index numbers; change its type from Set<number> to Set<string>, and
update toggleRow and all checks to compute the identity key via
tagMatchKey(filteredTags.value[index]) (or pass the tag directly) before
toggling or checking membership. Specifically, replace uses of
expandedRows.value.has(index)/delete(index)/add(index) in toggleRow and in the
render/row-expansion logic (the areas around toggleRow and the block at 278-335)
to use the computed key string, ensuring you call tagMatchKey(tag) consistently
so expanded rows remain stable across filtering/reordering.
In `@packages/devtools-app/app/composables/link-checker.ts`:
- Around line 87-106: The current validateLinks implementation permanently
prevents rechecking URLs by adding them to checkedUrls and also never removes
brokenLinks entries on a later successful check; update validateLinks so that
after checkUrl resolves a successful status (not 'error', not 0, and not in
400–599) you (1) remove any existing entry for that url from brokenLinks.value
(and call triggerBrokenLinks()), and (2) remove the url from checkedUrls so
future calls to validateLinks can re-evaluate it; keep the existing behavior for
pendingUrls.value and triggerPending, and continue marking and keeping urls in
checkedUrls only for the duration of the current validation unless the check
failed.
- Around line 66-75: The image probing branch in checkUrl (which uses new
Image()) must be guarded so it only runs in the browser; new Image() will throw
during SSR/static generation. Modify checkUrl to first detect client-side
execution (e.g., typeof window !== 'undefined' or the framework's client flag)
before using new Image(), and if running server-side return a safe fallback
(e.g., 'error' or 0) for cases matched by IMAGE_URL_META, ICON_RELS, or
IMAGE_EXT_RE; alternatively gate validateLinks() so it only runs client-side.
Ensure the guard references the checkUrl function and the IMAGE_URL_META /
ICON_RELS / IMAGE_EXT_RE conditions so the image-probing code never executes
during SSR.
In `@packages/devtools-app/app/composables/rpc.ts`:
- Around line 11-25: Wrap the RPC initialization in a try/catch around
getDevToolsRpcClient() and the sharedState access so any failure is caught and
logged (use processLogger or console.error) and avoid throwing; check that
(client.sharedState as any).get('unhead:state') returns a valid object before
calling .value() and .on(). Register the 'updated' listener with a named handler
and ensure it is removed when the composable is torn down (e.g., return a
cleanup function or call sharedState.off('updated', handler) from
useDevtoolsConnection's teardown/unmounted logic) to prevent listener leaks;
reference getDevToolsRpcClient, sharedState, sharedState.on('updated') and the
syncState/isConnected updates when implementing these guards and cleanup.
- Around line 12-13: Remove the unnecessary "as any" cast on client.sharedState:
update the call that obtains the shared state
(client.sharedState.get('unhead:state')) to use the proper typed API and then
call sharedState.value() as UnheadDevtoolsState | null; specifically modify the
code around client.sharedState, get('unhead:state') and sharedState.value() to
drop the cast and, if TypeScript complains, add a short comment explaining the
exact typing issue rather than using as any.
In `@packages/devtools-app/app/composables/shiki.ts`:
- Around line 14-18: The Shiki config's langs array is missing CSS, causing
highlights to fail when DevtoolsSnippet.vue passes lang='css'; update the langs
array in packages/devtools-app/app/composables/shiki.ts (the langs: [...]
declaration) to include the CSS language by adding import('@shikijs/langs/css')
alongside the existing json, js, and xml imports so the CSS grammar is loaded.
In `@packages/devtools-app/app/composables/update-check.ts`:
- Around line 6-19: checkForUpdate currently runs on the server during prerender
and flips the module-level checked flag before the network resolves, disabling
future checks on transient failures; change it so it only runs in the client and
only marks the check as completed after the request finishes: in checkForUpdate
add a client-only guard (e.g. if (typeof window === 'undefined') return) before
doing anything, remove or delay setting checked = true until inside the
fetch.then/.catch (or use a separate inFlight flag) and ensure both success and
error paths set/clear the flag so latestVersion.value and hasUpdate.value are
updated only from the browser and transient network errors do not permanently
disable further checks (update references to checked, latestVersion, hasUpdate,
and checkForUpdate accordingly).
In `@packages/devtools-app/app/pages/identity.vue`:
- Around line 166-171: The v-for key using only icon.href is not unique and can
cause DOM reuse bugs; update the key on the loop over groupIcons to a composite
key that uniquely identifies each icon (for example combine icon.href with
icon.rel and any size/variant field or the index) so Vue can distinguish
distinct entries; locate the v-for in the template that renders the id-icon-card
(loop variable groupIcons) and change the :key to include icon.rel (and
icon.size or a variant field if present) or fallback to index to ensure keys are
unique alongside the isBrokenUrl(icon.href) logic.
In `@packages/devtools-app/app/pages/serp.vue`:
- Around line 157-171: The jsonLdData computed currently returns after parsing
the first valid script, so update the jsonLdData logic in the computed to
iterate all state.value.tags entries where tag === 'script' and props.type ===
'application/ld+json', parse each (skipping invalid JSON) and accumulate results
into a single merged value (e.g., flatten arrays and merge/collect objects into
an array or combined object) instead of returning on the first success; adjust
any downstream usage that expects a single object (such as richResultNodes) to
handle the combined array/merged structure. Ensure you modify the jsonLdData
computed and the equivalent block around the other instance (the similar code
referenced at the later occurrence) so all JSON-LD scripts are parsed and
included.
- Around line 401-414: The two icon-only toggle buttons for preview mode (the
buttons that set previewMode = 'desktop' and previewMode = 'mobile' and render
<UIcon name="i-carbon-laptop"> / "i-carbon-mobile") lack accessible names and
button semantics; update each button to include type="button", an appropriate
aria-label (e.g., "Desktop preview" and "Mobile preview"), and an aria-pressed
attribute bound to the active state (aria-pressed="previewMode === 'desktop'"
for the desktop button and aria-pressed="previewMode === 'mobile'" for the
mobile button) so assistive tech can identify and read their state.
---
Nitpick comments:
In `@examples/vite-ssr-vue-streaming/vite.config.ts`:
- Around line 12-15: Clarify in the example that the top-level devtools block in
vite.config.ts (the object with properties enabled, clientAuth,
clientAuthTokens) is the Vite root-level configuration for `@vitejs/devtools` and
not the Unhead({ devtools: ... }) option; update the surrounding comment or
documentation text to explicitly state this distinction and, if helpful, mention
the Unhead devtools option by name (Unhead({ devtools: ... })) so readers don't
confuse the two.
In `@examples/vite-ssr-vue/src/App.vue`:
- Around line 27-32: The <nav class="site-nav"> landmark in App.vue lacks an
accessible name; update the element (in the App.vue template where <nav
class="site-nav"> is defined) to include an accessible label such as
aria-label="Main navigation" or aria-labelledby referencing a visible heading,
so screen readers can distinguish this navigation if additional <nav> elements
are added.
In `@examples/vite-ssr-vue/src/pages/ScriptsPage.vue`:
- Around line 10-25: The code accesses window.confetti without a type assertion
which breaks strict TS; update the useScript call and fireConfetti to be
type-safe: either add a global declaration for confetti (declare global
interface Window { confetti?: (opts: any) => void }) or cast when reading from
window, e.g., change the use callback to () => ({ confetti: (window as
any).confetti }) and in fireConfetti guard with confetti?.load()?.then(...) and
call confetti?.({ ... }) (or cast confetti as the typed function before
invoking) so all uses of window.confetti and the confetti variable match the {
confetti: (opts: any) => void } generic.
In `@examples/vite-ssr-vue/src/router.ts`:
- Around line 7-24: The routes array lacks a type annotation which hurts IDE
support; import the RouteRecordRaw type from 'vue-router' and annotate the
routes constant (e.g., const routes: RouteRecordRaw[] = [...]) so each entry is
type-checked; update any async component entries as needed to satisfy the
RouteRecordRaw shape and fix any type errors reported by the compiler in the
routes definition.
In `@examples/vite-ssr-vue/vite.config.ts`:
- Around line 15-19: The config separately imports useScript from '@unhead/vue'
even though it should be part of the shared unheadVueComposablesImports list;
update the exports in the source package (packages/vue/src/index.ts) to include
'useScript' in the unheadVueComposablesImports array (so the composable is
auto-exported centrally), then remove the ad-hoc { '@unhead/vue': ['useScript']
} entry from vite.config.ts; target symbols: unheadVueComposablesImports,
useScript, and the export surface in packages/vue/src/index.ts.
In `@packages/bundler/src/devtools/bridge.ts`:
- Line 200: The tagFingerprint function uses JSON.stringify(t.props) which can
produce inconsistent key orders; update tagFingerprint (the const tagFingerprint
= (t: any) => ...) to produce a deterministic fingerprint by
canonicalizing/sorting the keys of t.props before stringifying (e.g., perform a
stable sort of object keys or use a stable stringify helper) and still prefer
t.dedupeKey when present so identical tags from SSR and client yield the same
fingerprint.
- Around line 365-378: The pollForHead function's warning after 50 attempts is
too vague; update the console.warn in pollForHead (which calls findHead and
connectBridge) to provide actionable guidance and context — include the
attempted count, the fact that no <head> was found, possible common causes
(e.g., script loaded before DOM/head available, CSP blocking, or unmounting),
and a suggestion to check that the bundler/devtools script is included after the
HTML head or to enable a specific flag; keep the message clear and concise so
users know what to inspect when polling fails.
In `@packages/bundler/src/devtools/vite.ts`:
- Around line 59-68: The current transform in
packages/bundler/src/devtools/vite.ts only injects _source when args.length ===
1 or when args[1].type === 'ObjectExpression', so calls where args.length >= 2
but args[1] is not an ObjectExpression are skipped; update the logic around
args, args[1], s.appendRight and sourceValue to handle the "second arg is not an
ObjectExpression" case by wrapping or replacing the second argument with an
object that spreads the original value (e.g., { ...(originalSecondArg), _source:
... }) or by inserting a new third argument that contains _source, and ensure
transformed is set to true; implement this in the same transform block that
currently sets transformed and uses s.appendRight so the injection consistently
covers non-object second-argument cases.
- Around line 111-117: The current lookup of unhead's package.json via
resolve(pkgDir, 'node_modules/unhead/package.json') is brittle in
monorepos/alternative package managers; replace it with Node's module resolution
(e.g., require.resolve or import.meta.resolve with a paths option) to find
'unhead/package.json' relative to pkgDir, then read and parse that resolved path
into unheadVersion (symbols: pkgDir, unheadPkg, unheadVersion). Keep the
existing try/catch behavior and fallback to an empty string if resolution or
read fails.
In `@packages/bundler/src/unplugin/types.ts`:
- Around line 27-28: Add a JSDoc comment above the empty UnheadDevtoolsOptions
interface to document that this interface is intentionally empty/extendable for
future configuration options; mention it's a placeholder for optional fields,
how consumers can extend it, and any intended usage or stability guarantees so
future contributors know it's safe to add properties and where to document them
(reference the UnheadDevtoolsOptions interface).
In `@packages/devtools-app/app/app.vue`:
- Around line 9-10: Both async calls are fire-and-forget and can produce
unhandled rejections: update the component setup to await
useDevtoolsConnection() and loadShiki() inside an async init function (or attach
.catch handlers) and wrap useDevtoolsConnection() in try/catch (or ensure rpc.ts
exposes internal error handling) so failures are logged/handled instead of
propagating; specifically locate the calls to useDevtoolsConnection() and
loadShiki(), call them from an async init/created hook, await them or add
.catch(...) and ensure errors from useDevtoolsConnection() are caught and sent
to your logger/console.
In `@packages/devtools-app/app/components/DevtoolsToolbar.vue`:
- Around line 2-11: The toolbar component currently accepts only the variant
prop; add an optional accessible label prop (e.g., label?: string) via
defineProps in DevtoolsToolbar.vue and bind it to the root element as an
aria-label (and/or aria-labelledby when applicable) while preserving the
existing role="toolbar" and class binding (`devtools-toolbar--${variant}`);
ensure the prop has a sensible default or is optional so existing usages are
unaffected.
In `@packages/devtools-app/app/components/Logo.vue`:
- Around line 41-67: Add a prefers-reduced-motion CSS override so the logo
animations don't run for users who request reduced motion: target the .logo-path
and .logo-letter selectors (and the `@keyframes` draw/fadeIn outcomes) inside a
`@media` (prefers-reduced-motion: reduce) block and disable animations by setting
animation: none and applying the final visual states (stroke-dashoffset: 0 for
.logo-path and opacity: 1; transform: translateX(0) for .logo-letter) to
preserve the intended static appearance without motion.
In `@packages/devtools-app/app/components/OCodeBlock.vue`:
- Line 9: The call to useRenderCodeHighlight currently wraps the reactive prop
in computed(() => code), which is unnecessary; change the argument to pass the
prop directly (code) or convert it to a ref with toRef(props, 'code') if the
composable requires a Ref, i.e., replace computed(() => code) with either code
or toRef(...) when invoking useRenderCodeHighlight so the composable receives
the original reactive value.
In `@packages/devtools-app/app/composables/state.ts`:
- Around line 3-72: The declared interfaces (e.g., UnheadDevtoolsState,
SerializedEntry, SerializedTag, SerializedScript, SerializedValidationRule,
SeoOverview) duplicate types from the bridge and should be centralized: remove
these duplicated type declarations and import the canonical types from the
shared RPC/types module used by the bridge; update any references in this file
to use the imported types (e.g., replace local UnheadDevtoolsState with the
shared UnheadDevtoolsState) and ensure TypeScript exports/usage remain
consistent so client and server share a single source of truth.
In `@packages/devtools-app/app/composables/tools.ts`:
- Around line 119-124: The function titleColor uses a magic number 30; replace
that hardcoded threshold with the corresponding constant from SEO_LIMITS (e.g.,
SEO_LIMITS.TITLE_MIN_CHARS) so the logic reads: if length >
SEO_LIMITS.TITLE_MAX_CHARS return 'error', if length <
SEO_LIMITS.TITLE_MIN_CHARS return 'warning', else 'success'; if SEO_LIMITS does
not currently expose a min/title-warning constant, add
SEO_LIMITS.TITLE_MIN_CHARS (or a clearly named threshold) and use it in
titleColor to centralize thresholds.
In `@packages/devtools-app/app/pages/schema.vue`:
- Around line 23-31: The loop over jsonLdTags currently swallows JSON.parse
errors silently; update the catch block inside the for (const tag of jsonLdTags)
loop to emit a development-only warning (e.g., when process.env.NODE_ENV !==
'production') that includes context such as the failing tag content or its
outerHTML and the parse error message; keep the existing continue behavior so
runtime isn't disrupted but authors get a clear console.warn in dev when JSON-LD
is malformed.
- Around line 104-170: Precompute the node type and its Google requirements once
per node and use those local values in the template instead of calling
getNodeType(node) and indexing googleRichResultsRequirements repeatedly; e.g.,
create a processedNodes array or computed that maps each node to { node,
nodeType: getNodeType(node), req:
googleRichResultsRequirements[getNodeType(node)], analysis:
analyzeNodeProperties(node) } and then update the template to use nodeType, req,
and analysis (replace getNodeType(node),
googleRichResultsRequirements[getNodeType(node)], and
analyzeNodeProperties(node) calls), and keep other helpers like
formatPropertyValue and nodeToSchemaOrgLink unchanged but reference them with
the precomputed nodeType/req.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
Four CI fixes needed after merging refactor/bundler-vite-improvements: - package.json: exclude @unhead/devtools-app from the main build script (needs nuxi prepare and was racing pnpm topology against @unhead/vue, blowing up the Build step). Add a separate build:devtools-app script for use during release. - bundler/src/devtools/rpc/functions/get-config.ts: annotate getConfigRpc as `: any` to break a TS2883 portable-inference chain caused by duplicate @vitejs/devtools-rpc resolutions across two TS peer-dep variants in the lockfile. - unhead/src/scripts/useScript.ts: revert the SSR proxy skip so script.proxy is still a noop recording proxy in SSR. The skip broke ssr.test.ts/use.test.ts which intentionally exercise proxy access on a server head. - test/exports/bundler.yaml: regenerate snapshot to include the new devtoolsPlugin export from @unhead/bundler.
Bundle Size Analysis
|
158 .nuxt files were committed accidentally. .gitignore already covers .nuxt at the root, so just removing them from the index is enough.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@package.json`:
- Around line 18-19: The root package.json's "build" script currently excludes
"@unhead/devtools-app" and the repo only provides a separate
"build:devtools-app" script, so CI/release flows invoking "pnpm build" skip
devtools artifacts; fix by wiring the devtools app into the default build:
either remove the exclusion '!@unhead/devtools-app' from the "build" script so
the recursive filter includes the package, or update "build" to explicitly run
the dedicated task (e.g., invoke "build:devtools-app" as part of the "build"
script) so that "@unhead/devtools-app" is built when "pnpm build" runs.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: a7e541ff-3a5c-421e-8272-72de6fdde116
📒 Files selected for processing (4)
package.jsonpackages/bundler/src/devtools/rpc/functions/get-config.tspackages/unhead/src/scripts/useScript.tstest/exports/bundler.yaml
🚧 Files skipped from review as they are similar to previous changes (1)
- packages/bundler/src/devtools/rpc/functions/get-config.ts
Critical / major fixes:
- bundler/devtools/plugin.ts: escape `<` in inlined JSON payload so a
serialized `</script>` can't close the application/json block early
- DevtoolsSection.vue: remove duplicate `open` prop (already declared
by `defineModel('open')`, was a Vue compile error)
- composables/link-checker.ts: guard `new Image()` and fetch behind
`typeof window === 'undefined'` so SSR/prerender doesn't crash; also
drop the permanent `checkedUrls` set so fixed assets self-heal and
pendingUrls becomes the de-duplication gate
- composables/update-check.ts: skip the npm registry fetch in SSR; only
flip `checked` after a successful response (was permanently disabling
on transient failures)
- bundler/package.json: move `@vitejs/devtools-kit` from devDeps to deps
since `bundler/src/devtools/bridge.ts` is published and imports it at
runtime via `@vitejs/devtools-kit/client`
- DevtoolsTagTable.vue: track `expandedRows` by `tagMatchKey(tag)`
instead of filteredTags index so filtering doesn't reopen the wrong
detail row
- pages/serp.vue: parse every JSON-LD `<script>` tag (collapse multi-tag
pages into a synthetic `@graph`) instead of stopping at the first
- pages/serp.vue: add `type="button"`, `aria-label`, and `aria-pressed`
to the desktop/mobile preview toggles
Minor fixes:
- docs/build-plugins/4.devtools.md: vite-devtools repo URL → vitejs/devtools
- examples vite-ssr-vue/BlogPage.vue: "first class" → "first-class"
- assets/css/global.css: drop duplicate `--scrollbar-thumb` declaration
- DevtoolsEmptyState.vue: render description slot in a `<div>` instead
of nesting block content inside `<p>`
- DevtoolsHeadEntry.vue: fall back to "unknown" when `entry.mode` is empty
- DevtoolsLayout.vue: tighten non-root tab match so `/schema-preview`
doesn't activate the `/schema` tab
- composables/rpc.ts: drop the `as any` cast on sharedState, add
try/catch around the connection setup with a console warning
- composables/shiki.ts: load CSS grammar (DevtoolsSnippet accepts `css`)
- pages/identity.vue: composite icon-card key including rel/sizes/type
so duplicate hrefs across variants don't collide
Tests: 1214 passed; build, vue-tsc, eslint, all green.
There was a problem hiding this comment.
Actionable comments posted: 8
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@docs/head/1.guides/build-plugins/4.devtools.md`:
- Line 7: Replace the absolute claim "zero config required" in the sentence that
currently reads "**Quick Answer:** The Unhead devtools integration adds a
panel..." with a softened phrasing like "enabled by default in dev mode" and add
a short note that the panel UI requires the presence of `@vitejs/devtools-kit` and
`@unhead/devtools-app`; specifically edit the phrase "zero config required" to
avoid contradiction with the later mention of `@vitejs/devtools-kit` and
`@unhead/devtools-app` by stating the integration is enabled by default in dev
mode but the interactive panel UI depends on those packages being installed.
- Around line 32-38: The Vite examples call defineConfig but never import it;
update both snippets that use defineConfig (the Vue example using Unhead and the
React example using Unhead) to add an import for defineConfig from 'vite' at the
top of each file (i.e., ensure an import { defineConfig } from 'vite' precedes
the existing imports so the call to defineConfig is resolved).
In `@packages/devtools-app/app/assets/css/global.css`:
- Around line 102-106: The scrollbar block under the universal selector (*)
violates stylelint's declaration-empty-line-before rule; open the selector block
containing --scrollbar-track, --scrollbar-thumb, scrollbar-color, and
scrollbar-width and add the required blank line immediately before the
scrollbar-color declaration (i.e., ensure an empty line separates the custom
properties from the scrollbar-color declaration) so stylelint no longer flags
the rule in global.css.
In `@packages/devtools-app/app/components/DevtoolsTagTable.vue`:
- Around line 46-52: The filter callback in DevtoolsTagTable.vue lowercases
tagFilter but compares t.tag as-is, so camelCase tags are missed; update the
callback to normalize the tag before comparing (e.g., compute const tag = (t.tag
|| '').toLowerCase() and use tag.includes(filter) instead of
t.tag.includes(filter)) while keeping the existing lowercasing for name and
source; adjust references in the same filter block that use t.tag to use the
normalized tag variable.
- Around line 280-291: The row expansion control is not keyboard-focusable or
announced; make the expand toggle a real button inside the first <td> instead of
relying on the <tr> click: move the `@click`="toggleRow(tag)" from the <tr> to a
focusable <button> wrapping the UIcon (keep the visual styling), bind
:aria-expanded="expandedRows.has(tagMatchKey(tag))" on that button, add
:aria-controls pointing to a unique details id (e.g.
"details-"+tagMatchKey(tag)) and ensure the details panel element uses that id,
and keep toggleRow(tag) as the click/keydown handler so Enter/Space toggles
expansion; leave non-interactive row click behavior unchanged.
In `@packages/devtools-app/app/composables/link-checker.ts`:
- Around line 89-113: validateLinks currently only updates URLs returned from
extractCheckableUrls(tags) but never removes stale entries from brokenLinks;
update validateLinks to compute the set of current URLs from
extractCheckableUrls(tags) at the start, then iterate brokenLinks.value and
delete any keys not present in that current URL set (and call
triggerBrokenLinks() if any deletions occur) before proceeding to add/check new
URLs with pendingUrls and checkUrl so removed tags no longer leave stale
brokenLinks entries.
In `@packages/devtools-app/app/composables/update-check.ts`:
- Around line 17-20: The version comparison currently does a raw string compare
(setting latestVersion.value and hasUpdate.value using data.version !==
currentVersion) which can false-positive for equivalent formats; normalize both
versions before comparing by stripping any leading "v" (or use a semver utility
like semver.clean/semver.coerce) and then compare normalized values (or use
semver.gt/semver.eq) when assigning hasUpdate.value; update the block that sets
latestVersion.value, hasUpdate.value and checked to normalize data.version and
currentVersion first (or call a small helper normalizeVersion) so equivalent
versions (e.g., "v1.2.3" vs "1.2.3") do not produce a bogus update.
In `@packages/devtools-app/app/pages/serp.vue`:
- Around line 528-530: The anchor that renders only an icon (uses
preview.documentationUrl and UIcon) needs an accessible name; add an aria-label
(or :aria-label) on the <a> that describes the action, e.g. "Open documentation"
or a dynamic label like `Open documentation for ${preview.title || 'result'}` so
screen readers can announce the link; update the anchor where
preview.documentationUrl is used to include this aria-label attribute.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 0f793543-0f44-4f78-8132-c1311d626a3c
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (16)
docs/head/1.guides/build-plugins/4.devtools.mdexamples/vite-ssr-vue/src/pages/BlogPage.vuepackages/bundler/package.jsonpackages/bundler/src/devtools/plugin.tspackages/devtools-app/app/assets/css/global.csspackages/devtools-app/app/components/DevtoolsEmptyState.vuepackages/devtools-app/app/components/DevtoolsHeadEntry.vuepackages/devtools-app/app/components/DevtoolsLayout.vuepackages/devtools-app/app/components/DevtoolsSection.vuepackages/devtools-app/app/components/DevtoolsTagTable.vuepackages/devtools-app/app/composables/link-checker.tspackages/devtools-app/app/composables/rpc.tspackages/devtools-app/app/composables/shiki.tspackages/devtools-app/app/composables/update-check.tspackages/devtools-app/app/pages/identity.vuepackages/devtools-app/app/pages/serp.vue
✅ Files skipped from review due to trivial changes (4)
- packages/bundler/package.json
- examples/vite-ssr-vue/src/pages/BlogPage.vue
- packages/devtools-app/app/components/DevtoolsEmptyState.vue
- packages/devtools-app/app/pages/identity.vue
🚧 Files skipped from review as they are similar to previous changes (6)
- packages/devtools-app/app/components/DevtoolsHeadEntry.vue
- packages/devtools-app/app/composables/shiki.ts
- packages/devtools-app/app/composables/rpc.ts
- packages/bundler/src/devtools/plugin.ts
- packages/devtools-app/app/components/DevtoolsLayout.vue
- packages/devtools-app/app/components/DevtoolsSection.vue
There was a problem hiding this comment.
Actionable comments posted: 3
♻️ Duplicate comments (1)
packages/devtools-app/app/components/DevtoolsTagTable.vue (1)
280-291:⚠️ Potential issue | 🟡 MinorRow expansion is not keyboard accessible.
The expandable rows rely on
@clickon the<tr>element, which is not keyboard-focusable. Users navigating with keyboard cannot expand tag details. Consider wrapping the toggle trigger in a focusable<button>witharia-expanded.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/devtools-app/app/components/DevtoolsTagTable.vue` around lines 280 - 291, The row toggle is only on the non-focusable <tr> via `@click` so keyboard users can't expand rows; move the interactive handler into a focusable <button> inside the row (e.g., wrap the UIcon and/or the cell content with a button), replace the tr-level `@click`="toggleRow(tag)" with the button having `@click`="toggleRow(tag)", add :aria-expanded="expandedRows.has(tagMatchKey(tag))" on that button, ensure the button has an accessible label (aria-label or visible text) and preserves the existing visual classes/transition so styling/rotation (UIcon + 'rotate-90' binding) continue to work.
🧹 Nitpick comments (3)
packages/unhead/test/streaming/streaming.test.ts (1)
612-613: Update stale expectation comment forendsegment semantics.The note says
endstarts at</body>, but with preserved body interior it can start with injected/static body content. Consider rewording to avoid confusion.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/unhead/test/streaming/streaming.test.ts` around lines 612 - 613, Update the stale inline comment about the `end` segment semantics near the `expect(shell).toContain('<title>Test</title>')` assertion: change the wording to say that while the shell ends with the opening `<body>`, the `end` segment may begin with injected or static body content (not necessarily at `</body>`) when body interior is preserved; update the comment that references `shell` and `end` to reflect this clearer semantics.packages/bundler/src/devtools/vite.ts (1)
111-117: Version resolution may fail in hoisted or monorepo setups.The path
node_modules/unhead/package.jsonrelative topkgDirassumesunheadis a direct dependency. In pnpm workspaces or hoisted installations, the package might be located elsewhere.💡 Consider using require.resolve for more robust resolution
// Resolve unhead version for the devtools UI try { - const unheadPkg = resolve(pkgDir, 'node_modules/unhead/package.json') - if (existsSync(unheadPkg)) - unheadVersion = JSON.parse(readFileSync(unheadPkg, 'utf-8')).version || '' + const unheadPkgPath = require.resolve('unhead/package.json', { paths: [root, pkgDir] }) + unheadVersion = JSON.parse(readFileSync(unheadPkgPath, 'utf-8')).version || '' } catch {}Note: This requires access to
require.resolve. Alternatively, useimport.meta.resolveif targeting ESM-only environments.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/bundler/src/devtools/vite.ts` around lines 111 - 117, The current resolution of unhead via resolve(pkgDir, 'node_modules/unhead/package.json') can miss hoisted/monorepo installs; update the try block that sets unheadVersion to use Node's module resolution (e.g. require.resolve('unhead/package.json', { paths: [pkgDir] })) to find the real package.json path, falling back to import.meta.resolve or require.resolve from process.cwd() for ESM environments, then read and parse the resolved path into unheadVersion; keep the existing try/catch and the variables pkgDir and unheadVersion so behavior is unchanged when resolution fails.packages/bundler/src/devtools/bridge.ts (1)
271-277: Silent catch may hide serialization errors.The empty catch block when cloning
_templateParamssuppresses all errors. Consider logging in development to aid debugging if template params contain non-serializable values.💡 Optional: Add debug logging
if (head._templateParams) { try { templateParams = JSON.parse(JSON.stringify(head._templateParams)) } - catch {} + catch (e) { + if (import.meta.env?.DEV) + console.debug('[unhead devtools] failed to serialize templateParams:', e) + } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/bundler/src/devtools/bridge.ts` around lines 271 - 277, The empty catch when deep-cloning head._templateParams (code around templateParams and head._templateParams in bridge.ts) silently hides serialization errors; change the catch to log the error details in development (e.g., check process.env.NODE_ENV !== 'production' or an existing dev logger) and then keep templateParams as null as the safe fallback so non-serializable values are visible during debugging without breaking production.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@packages/devtools-app/app/components/DevtoolsTagTable.vue`:
- Around line 46-52: The tag filter lowercases tagFilter into the local variable
filter but compares it against t.tag without normalizing case, causing missed
matches; update the filtering in the function that uses tagFilter and result
(the block referencing tagFilter.value, filter, and result.filter) to compare
filter against a lowercased tag by using t.tag.toLowerCase() (or an equivalent
normalized value), and also ensure any other compared fields (e.g., name and
t.source) are consistently lowercased as already done so comparisons are
case-insensitive.
In `@packages/devtools-app/app/composables/update-check.ts`:
- Around line 14-16: The regex literals used inside parseVersion (and the other
regexes recreated during iteration) should be hoisted to module scope to satisfy
the e18e/prefer-static-regex lint rule and avoid recreating them on each call;
create named consts (e.g., VERSION_RE = /^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/ and
any other pattern used around the iteration) at top-level and replace the inline
regex usages in parseVersion and the loop/other function(s) with these constants
so the same RegExp objects are reused.
- Around line 89-99: The loop that chooses `best` should skip prerelease
versions when the user's `currentVersion` is a stable release: add a prerelease
check (e.g., `isPrerelease(version)` that returns true if the semver has a
prerelease tag) and in the loop before considering `v` do: if `isPrerelease(v)`
and NOT `isPrerelease(currentVersion)` then continue; keep the existing
`compareSemver` logic otherwise and update
`latestVersion.value`/`hasUpdate.value` as before so stable users are not
offered prereleases.
---
Duplicate comments:
In `@packages/devtools-app/app/components/DevtoolsTagTable.vue`:
- Around line 280-291: The row toggle is only on the non-focusable <tr> via
`@click` so keyboard users can't expand rows; move the interactive handler into a
focusable <button> inside the row (e.g., wrap the UIcon and/or the cell content
with a button), replace the tr-level `@click`="toggleRow(tag)" with the button
having `@click`="toggleRow(tag)", add
:aria-expanded="expandedRows.has(tagMatchKey(tag))" on that button, ensure the
button has an accessible label (aria-label or visible text) and preserves the
existing visual classes/transition so styling/rotation (UIcon + 'rotate-90'
binding) continue to work.
---
Nitpick comments:
In `@packages/bundler/src/devtools/bridge.ts`:
- Around line 271-277: The empty catch when deep-cloning head._templateParams
(code around templateParams and head._templateParams in bridge.ts) silently
hides serialization errors; change the catch to log the error details in
development (e.g., check process.env.NODE_ENV !== 'production' or an existing
dev logger) and then keep templateParams as null as the safe fallback so
non-serializable values are visible during debugging without breaking
production.
In `@packages/bundler/src/devtools/vite.ts`:
- Around line 111-117: The current resolution of unhead via resolve(pkgDir,
'node_modules/unhead/package.json') can miss hoisted/monorepo installs; update
the try block that sets unheadVersion to use Node's module resolution (e.g.
require.resolve('unhead/package.json', { paths: [pkgDir] })) to find the real
package.json path, falling back to import.meta.resolve or require.resolve from
process.cwd() for ESM environments, then read and parse the resolved path into
unheadVersion; keep the existing try/catch and the variables pkgDir and
unheadVersion so behavior is unchanged when resolution fails.
In `@packages/unhead/test/streaming/streaming.test.ts`:
- Around line 612-613: Update the stale inline comment about the `end` segment
semantics near the `expect(shell).toContain('<title>Test</title>')` assertion:
change the wording to say that while the shell ends with the opening `<body>`,
the `end` segment may begin with injected or static body content (not
necessarily at `</body>`) when body interior is preserved; update the comment
that references `shell` and `end` to reflect this clearer semantics.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: de0290fa-2d0f-4b0c-bc99-e4c270014e12
📒 Files selected for processing (10)
packages/bundler/src/devtools/bridge.tspackages/bundler/src/devtools/rpc/types.tspackages/bundler/src/devtools/vite.tspackages/devtools-app/app/components/DevtoolsHeadEntry.vuepackages/devtools-app/app/components/DevtoolsTagTable.vuepackages/devtools-app/app/composables/state.tspackages/devtools-app/app/composables/update-check.tspackages/unhead/src/stream/iife.tspackages/unhead/src/stream/server.tspackages/unhead/test/streaming/streaming.test.ts
✅ Files skipped from review due to trivial changes (2)
- packages/devtools-app/app/components/DevtoolsHeadEntry.vue
- packages/devtools-app/app/composables/state.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- packages/bundler/src/devtools/rpc/types.ts
|
i am trying to install |
|
It's bundled under the vite plugin Just install the Vite plugin and it should work |
|
Hi Harlan, I did that with Also here it says:
|
|
Are you able to make a new issue for this and I'll investigate further for you 🙏 |
🔗 Linked issue
Related to #711
❓ Type of change
📚 Description
WIP — vite devtools integration for unhead. Adds a devtools panel UI (
packages/devtools-app) plus a bridge/plugin/RPC layer in@unhead/bundler(src/devtools/) so head state, scripts, schema, SERP, social and identity can be inspected live during dev.Also lands a few supporting pieces picked up along the way:
CreateHeadTransform+SSRStaticReplaceunplugin transforms (with tests)build-plugins/section (overview, tree-shaking, seo-meta-transform, minify-transform, devtools)validateplugin updatesDrafted off
mainafter the originalfeat/vite-devtoolswork was accidentally landed there; this branch preserves the full history.Summary by CodeRabbit
New Features
Documentation
Examples
Chores
Bug Fixes