Unverified 提交 e3baf852 authored 作者: Will Chen's avatar Will Chen 提交者: GitHub

Fix preview iframe URL error & create debugging skill (#2887)

<!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/dyad-sh/dyad/pull/2887" target="_blank"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin"> </picture> </a> <!-- devin-review-badge-end -->
上级 bfeaebfe
---
name: dyad:debug-minified-error
description: Map a minified error stack trace from a production Dyad build back to original source locations using source maps.
---
# Debug Minified Error
Given a minified error stack trace from a production Dyad build (referencing `app.asar/.vite/renderer/main_window/assets/index-*.js`), map each frame back to the original TypeScript source file, line, and column.
## Arguments
- `$ARGUMENTS`: The full error message and stack trace from the minified production build. Should contain lines like:
```
TypeError: Invalid URL
at FOt (file:///usr/lib/dyad/resources/app.asar/.vite/renderer/main_window/assets/index-XXXX.js:1432:7223)
```
## Instructions
### 1. Determine the Dyad release version
You **must** know which Dyad release version this error occurred in. Check if the user provided it in `$ARGUMENTS` or in conversation context.
**If the version is not known, ASK THE USER.** Do not assume or guess the version.
### 2. Check out the matching release commit
Look up the GitHub release for that version to find the exact commit hash:
```bash
gh release view v<VERSION> --repo dyad-sh/dyad --json tagCommitish,targetCommitish
```
If the release tag doesn't resolve directly, find the commit from the tag:
```bash
git ls-remote --tags origin "v<VERSION>"
```
Then check out that commit:
```bash
git checkout <commit-hash>
```
### 3. Install dependencies and build
```bash
npm install
npm run package
```
This ensures the local build matches the exact code that produced the error's minified bundle.
### 4. Extract the app.asar
Find the built `app.asar` in the `out/` directory and extract it to a temp directory:
```bash
find out/ -name "app.asar" -print -quit
```
```bash
npx @electron/asar extract <path-to-app.asar> /tmp/dyad-asar-extracted
```
If `out/` doesn't exist or has no `app.asar`, the build may have failed — check the build output for errors.
### 5. Check for existing source maps
Look for `.js.map` files alongside the renderer bundle:
```bash
find /tmp/dyad-asar-extracted/.vite/renderer/main_window/assets -name "*.map" 2>/dev/null
```
### 6. Build with source maps if needed
If no source maps exist in the extracted asar (which is typical for production builds), do a renderer-only build with source maps:
```bash
npx vite build --config vite.renderer.config.mts --outDir /tmp/dyad-sourcemap-build --sourcemap
```
This produces an `index-*.js` and `index-*.js.map` in `/tmp/dyad-sourcemap-build/assets/`.
**Important:** The build hash will differ from the error stack trace's hash. That's fine — we match by **minified function names**, not by line/column from the error directly.
### 7. Find minified function names in the new build
For each function name in the error stack trace (e.g., `FOt`, `xO`, `PR`), find its position in the newly built bundle:
```js
// Search for each function name and record line:column positions
node -e "
const fs = require('fs');
const content = fs.readFileSync('/tmp/dyad-sourcemap-build/assets/<index-file>.js', 'utf8');
const lines = content.split('\n');
const names = ['FOt', 'xO', ...]; // from stack trace
for (const name of names) {
for (let i = 0; i < lines.length; i++) {
let col = lines[i].indexOf(name);
while (col !== -1) {
console.log(name + ' at Line ' + (i+1) + ', Col ' + col);
col = lines[i].indexOf(name, col + 1);
}
}
}
"
```
**Disambiguation:** If a function name appears multiple times:
- The **definition** (e.g., `const FOt=` or `function FOt(`) is usually the one referenced in the stack trace.
- Cross-reference with the column offset from the error to pick the right occurrence.
### 8. Map positions to original source using source maps
Use the `source-map` package (available in node_modules) to resolve each position:
```js
node -e "
const fs = require('fs');
const { SourceMapConsumer } = require(require.resolve('source-map', {paths: [process.cwd()]}));
async function main() {
const rawMap = JSON.parse(fs.readFileSync('/tmp/dyad-sourcemap-build/assets/<index-file>.js.map', 'utf8'));
const consumer = await new SourceMapConsumer(rawMap);
const positions = [
{name: 'FOt', line: <line>, col: <col>},
// ... one entry per stack frame
];
for (const pos of positions) {
const orig = consumer.originalPositionFor({line: pos.line, column: pos.col});
console.log(pos.name + ':');
console.log(' -> ' + orig.source + ':' + orig.line + ':' + orig.column + ' (name: ' + orig.name + ')');
}
}
main().catch(console.error);
"
```
### 9. For the root cause frame, find all relevant expressions
The topmost non-React frame is usually the root cause. For that frame's line in the minified bundle, search for the specific expression that throws (e.g., all `new URL(` calls) and map each to the original source:
```js
// Find all occurrences of the throwing expression on the relevant minified line
// and map each to original source
```
This narrows down the exact expression within a large component.
### 10. Report the de-minified stack trace
Present the mapped stack trace in a clear format:
```
Original stack trace:
1. ErrorBanner (src/components/preview_panel/PreviewIframe.tsx:1148:22)
2. React internals (renderWithHooks, reconcileChildren, etc.)
...
```
**Distinguish between:**
- **Application frames** — these are actionable, show full source path and line
- **React/library internals** — label these as such, no need to map in detail
- **The root cause** — highlight which frame and expression actually threw the error
### 11. Show the offending source code
Read the original source file at the identified line and show the surrounding context (5-10 lines). Explain why the expression throws and suggest a fix if obvious.
## Tips
- React stack frames (reconciler functions like `renderWithHooks`, `beginWork`, `completeWork`, etc.) can be identified by their patterns — they bubble up from the actual throw site. Focus on the topmost non-React frame.
- If the error is `TypeError: Invalid URL`, look for unguarded `new URL()` calls in render paths.
- If the error is during React rendering, the topmost frame is the component whose render threw.
- The `source-map` package version 0.6.x uses `new SourceMapConsumer(rawMap)` which returns a Promise. Version 0.5.x is synchronous.
- Source paths in the map often have relative prefixes like `../../../` — strip these mentally or programmatically to get the repo-relative path.
## Cleanup
After reporting, restore the repo and clean up temp files:
```bash
git checkout -
npm install
rm -rf /tmp/dyad-asar-extracted /tmp/dyad-sourcemap-build
```
...@@ -620,22 +620,33 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => { ...@@ -620,22 +620,33 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
// Also update UI state // Also update UI state
setConsoleEntries((prev) => [...prev, logEntry]); setConsoleEntries((prev) => [...prev, logEntry]);
} else if (type === "pushState" || type === "replaceState") { } else if (type === "pushState" || type === "replaceState") {
// Resolve relative URLs against the app's base URL so that all
// entries in navigationHistory are always absolute URLs.
let resolvedUrl = payload?.newUrl;
if (resolvedUrl) {
try {
resolvedUrl = new URL(resolvedUrl, appUrl ?? undefined).href;
} catch {
// If it can't be resolved at all, keep the raw value
}
}
// Update navigation history based on the type of state change // Update navigation history based on the type of state change
if (type === "pushState" && payload?.newUrl) { if (type === "pushState" && resolvedUrl) {
// For pushState, we trim any forward history and add the new URL // For pushState, we trim any forward history and add the new URL
const newHistory = [ const newHistory = [
...navigationHistory.slice(0, currentHistoryPosition + 1), ...navigationHistory.slice(0, currentHistoryPosition + 1),
payload.newUrl, resolvedUrl,
]; ];
setNavigationHistory(newHistory); setNavigationHistory(newHistory);
setCurrentHistoryPosition(newHistory.length - 1); setCurrentHistoryPosition(newHistory.length - 1);
// Update the current iframe URL ref to match the navigation // Update the current iframe URL ref to match the navigation
currentIframeUrlRef.current = payload.newUrl; currentIframeUrlRef.current = resolvedUrl;
// Preserve URL for HMR remounts - only if it's a different route from root // Preserve URL for HMR remounts - only if it's a different route from root
// Compare origins and check if there's a meaningful path // Compare origins and check if there's a meaningful path
if (selectedAppId && appUrl) { if (selectedAppId && appUrl) {
try { try {
const newUrlObj = new URL(payload.newUrl); const newUrlObj = new URL(resolvedUrl);
const appUrlObj = new URL(appUrl); const appUrlObj = new URL(appUrl);
// Only preserve if there's a non-root path // Only preserve if there's a non-root path
if ( if (
...@@ -643,10 +654,9 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => { ...@@ -643,10 +654,9 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
newUrlObj.pathname !== "/" && newUrlObj.pathname !== "/" &&
newUrlObj.pathname !== "" newUrlObj.pathname !== ""
) { ) {
const urlToPreserve = payload.newUrl;
setPreservedUrls((prev) => ({ setPreservedUrls((prev) => ({
...prev, ...prev,
[selectedAppId]: urlToPreserve, [selectedAppId]: resolvedUrl,
})); }));
} else if (newUrlObj.origin === appUrlObj.origin) { } else if (newUrlObj.origin === appUrlObj.origin) {
// Clear preserved URL when navigating back to root // Clear preserved URL when navigating back to root
...@@ -660,17 +670,17 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => { ...@@ -660,17 +670,17 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
// Invalid URL, don't preserve // Invalid URL, don't preserve
} }
} }
} else if (type === "replaceState" && payload?.newUrl) { } else if (type === "replaceState" && resolvedUrl) {
// For replaceState, we replace the current URL // For replaceState, we replace the current URL
const newHistory = [...navigationHistory]; const newHistory = [...navigationHistory];
newHistory[currentHistoryPosition] = payload.newUrl; newHistory[currentHistoryPosition] = resolvedUrl;
setNavigationHistory(newHistory); setNavigationHistory(newHistory);
// Update the current iframe URL ref to match the navigation // Update the current iframe URL ref to match the navigation
currentIframeUrlRef.current = payload.newUrl; currentIframeUrlRef.current = resolvedUrl;
// Preserve URL for HMR remounts - only if it's a different route from root // Preserve URL for HMR remounts - only if it's a different route from root
if (selectedAppId && appUrl) { if (selectedAppId && appUrl) {
try { try {
const newUrlObj = new URL(payload.newUrl); const newUrlObj = new URL(resolvedUrl);
const appUrlObj = new URL(appUrl); const appUrlObj = new URL(appUrl);
// Only preserve if there's a non-root path // Only preserve if there's a non-root path
if ( if (
...@@ -678,10 +688,9 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => { ...@@ -678,10 +688,9 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
newUrlObj.pathname !== "/" && newUrlObj.pathname !== "/" &&
newUrlObj.pathname !== "" newUrlObj.pathname !== ""
) { ) {
const urlToPreserve = payload.newUrl;
setPreservedUrls((prev) => ({ setPreservedUrls((prev) => ({
...prev, ...prev,
[selectedAppId]: urlToPreserve, [selectedAppId]: resolvedUrl,
})); }));
} else if (newUrlObj.origin === appUrlObj.origin) { } else if (newUrlObj.origin === appUrlObj.origin) {
// Clear preserved URL when navigating back to root // Clear preserved URL when navigating back to root
...@@ -1144,10 +1153,14 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => { ...@@ -1144,10 +1153,14 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
className="truncate flex-1 mr-2 min-w-0" className="truncate flex-1 mr-2 min-w-0"
data-testid="preview-address-bar-path" data-testid="preview-address-bar-path"
> >
{navigationHistory[currentHistoryPosition] {(() => {
? new URL(navigationHistory[currentHistoryPosition]) try {
.pathname return new URL(navigationHistory[currentHistoryPosition])
: "/"} .pathname;
} catch {
return "/";
}
})()}
</span> </span>
<ChevronDown size={14} className="flex-shrink-0" /> <ChevronDown size={14} className="flex-shrink-0" />
</DropdownMenuTrigger> </DropdownMenuTrigger>
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论