Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"@changesets/cli": "^2.27.10",
"oxfmt": "^0.27.0",
"turbo": "^2.6.3",
"typescript": "^5.9.3",
"untun": "^0.1.3"
},
"engines": {
Expand Down
12 changes: 11 additions & 1 deletion packages/react-grab/src/components/toolbar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ import {
} from "../../constants.js";
import { formatShortcut } from "../../utils/format-shortcut.js";
import { freezeUpdates } from "../../utils/freeze-updates.js";
import {
freezeGlobalAnimations,
unfreezeGlobalAnimations,
freezePseudoStates,
unfreezePseudoStates,
} from "../../utils/freeze-animations.js";
import { Tooltip } from "../tooltip.jsx";

interface ToolbarProps {
Expand Down Expand Up @@ -974,7 +980,7 @@ export const Toolbar: Component<ToolbarProps> = (props) => {
class={cn(
"grid transition-all duration-150 ease-out",
isCollapsed()
? "grid-cols-[0fr] opacity-0"
? "grid-cols-[0fr] opacity-0 pointer-events-none"
: "grid-cols-[1fr] opacity-100",
)}
>
Expand Down Expand Up @@ -1010,6 +1016,8 @@ export const Toolbar: Component<ToolbarProps> = (props) => {
props.onSelectHoverChange?.(true);
if (!unfreezeUpdatesCallback) {
unfreezeUpdatesCallback = freezeUpdates();
freezeGlobalAnimations();
freezePseudoStates();
}
}}
onMouseLeave={() => {
Expand All @@ -1018,6 +1026,8 @@ export const Toolbar: Component<ToolbarProps> = (props) => {
if (!props.isActive && !props.isContextMenuOpen) {
unfreezeUpdatesCallback?.();
unfreezeUpdatesCallback = null;
unfreezeGlobalAnimations();
unfreezePseudoStates();
}
}}
>
Expand Down
2 changes: 2 additions & 0 deletions packages/react-grab/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ export const ARROW_HEIGHT_PX = 8;
export const ARROW_CENTER_PERCENT = 50;
export const LABEL_GAP_PX = 4;
export const MAX_HTML_FALLBACK_LENGTH = 500;
export const DEFAULT_STACK_CONTEXT_LINES = 3;
export const SOURCE_CONTEXT_LINES = 3;

export const PREVIEW_ATTR_VALUE_MAX_LENGTH = 15;
export const PREVIEW_MAX_ATTRS = 3;
Expand Down
217 changes: 201 additions & 16 deletions packages/react-grab/src/core/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ import {
isSourceFile,
normalizeFileName,
getOwnerStack,
sourceMapCache,
getSourceMap,
StackFrame,
} from "bippy/source";
import type { SourceMap } from "bippy/source";
import { isCapitalized } from "../utils/is-capitalized.js";
import {
getFiberFromHostInstance,
Expand All @@ -13,10 +16,12 @@ import {
traverseFiber,
} from "bippy";
import {
DEFAULT_STACK_CONTEXT_LINES,
MAX_HTML_FALLBACK_LENGTH,
PREVIEW_ATTR_VALUE_MAX_LENGTH,
PREVIEW_MAX_ATTRS,
PREVIEW_PRIORITY_ATTRS,
SOURCE_CONTEXT_LINES,
} from "../constants.js";

const NEXT_INTERNAL_COMPONENT_NAMES = new Set([
Expand Down Expand Up @@ -55,6 +60,163 @@ const REACT_INTERNAL_COMPONENT_NAMES = new Set([
"SuspenseList",
]);

interface SourceMapData {
[filename: string]: string;
}

const isWeakRef = (
value: SourceMap | WeakRef<SourceMap>,
): value is WeakRef<SourceMap> =>
typeof WeakRef !== "undefined" && value instanceof WeakRef;

const resolveSourceMapFromCache = (
cacheEntry: SourceMap | WeakRef<SourceMap> | null,
): SourceMap | null => {
if (!cacheEntry) return null;
if (isWeakRef(cacheEntry)) return cacheEntry.deref() ?? null;
return cacheEntry;
};

const extractSourceMapData = (sourceMap: SourceMap): SourceMapData => {
const result: SourceMapData = {};

const processSources = (
sources: string[],
contents: (string | null)[] | undefined,
) => {
if (!contents) return;

for (let i = 0; i < sources.length; i++) {
const filename = normalizeFileName(sources[i]);
const content = contents[i];
if (filename && content && !result[filename]) {
result[filename] = content;
}
}
};

if (sourceMap.sections) {
for (const section of sourceMap.sections) {
processSources(section.map.sources, section.map.sourcesContent);
}
}

processSources(sourceMap.sources, sourceMap.sourcesContent);
return result;
};

const hasJavaScriptType = (script: Element): boolean => {
const type = script.getAttribute("type")?.toLowerCase().trim();
return (
!type ||
type === "text/javascript" ||
type === "application/javascript" ||
type === "module"
);
};

const getScriptUrls = (): string[] => {
if (typeof document === "undefined") return [];

const urls = new Set<string>();

for (const script of Array.from(document.querySelectorAll("script[src]"))) {
const src = script.getAttribute("src");
if (!src || !hasJavaScriptType(script)) continue;

try {
const url = new URL(src, window.location.href).href;
if (url.split("?")[0].endsWith(".js")) {
urls.add(url);
}
} catch {
continue;
}
}

return Array.from(urls);
};

const getSourceMapDataFromCache = (): SourceMapData => {
const result: SourceMapData = {};

for (const cacheEntry of sourceMapCache.values()) {
const sourceMap = resolveSourceMapFromCache(cacheEntry);
if (sourceMap) {
Object.assign(result, extractSourceMapData(sourceMap));
}
}

return result;
};

const getSourceMapDataForUrl = async (url: string): Promise<SourceMapData> => {
try {
const sourceMap = await getSourceMap(url, true);
return sourceMap ? extractSourceMapData(sourceMap) : {};
} catch {
return {};
}
};

const getSourceMapDataFromScripts = async (): Promise<SourceMapData> => {
const cachedUrls = new Set(sourceMapCache.keys());
const uncachedUrls = getScriptUrls().filter((url) => !cachedUrls.has(url));

const results = await Promise.all(
uncachedUrls.map((url) => getSourceMapDataForUrl(url)),
);

const combined: SourceMapData = {};
for (const data of results) {
Object.assign(combined, data);
}
return combined;
};

let sourceMapDataCache: SourceMapData | null = null;
let sourceMapDataPromise: Promise<SourceMapData> | null = null;

const getSourceMapData = async (): Promise<SourceMapData> => {
if (sourceMapDataCache) return sourceMapDataCache;

if (!sourceMapDataPromise) {
sourceMapDataPromise = (async () => {
const cached = getSourceMapDataFromCache();
const fromScripts = await getSourceMapDataFromScripts();
sourceMapDataCache = { ...cached, ...fromScripts };
return sourceMapDataCache;
})();
}

return sourceMapDataPromise;
};

const getFileExtension = (filename: string): string => {
const match = filename.match(/\.([^.]+)$/);
return match ? match[1] : "js";
};

const getSourceContext = (
fileContent: string,
lineNumber: number,
contextLines: number,
): string => {
const lines = fileContent.split("\n");
const startLine = Math.max(0, lineNumber - contextLines - 1);
const endLine = Math.min(lines.length, lineNumber + contextLines);

const contextSnippet: string[] = [];
const maxLineNumWidth = String(endLine).length;

for (let i = startLine; i < endLine; i++) {
const lineNum = String(i + 1).padStart(maxLineNumWidth, " ");
contextSnippet.push(`${lineNum} | ${lines[i]}`);
}

return contextSnippet.join("\n");
};

export const checkIsNextProject = (): boolean => {
if (typeof document === "undefined") return false;
return Boolean(
Expand Down Expand Up @@ -197,15 +359,17 @@ export const getElementContext = async (
element: Element,
options: GetElementContextOptions = {},
): Promise<string> => {
const { maxLines = 3 } = options;
const { maxLines = DEFAULT_STACK_CONTEXT_LINES } = options;
const stack = await getStack(element);
const html = getHTMLPreview(element);

if (hasSourceFiles(stack)) {
const isNextProject = checkIsNextProject();
const sourceMapData = isNextProject ? await getSourceMapData() : {};
const stackContext: string[] = [];

if (stack) {
let didAttachSourceSnippet = false;
for (const frame of stack) {
if (stackContext.length >= maxLines) break;

Expand All @@ -219,26 +383,47 @@ export const getElementContext = async (
);
continue;
}
if (frame.fileName && isSourceFile(frame.fileName)) {
let line = "\n in ";
const hasComponentName =
frame.functionName &&
checkIsSourceComponentName(frame.functionName);

if (hasComponentName) {
line += `${frame.functionName} (at `;
if (frame.fileName && isSourceFile(frame.fileName)) {
const filename = normalizeFileName(frame.fileName);
const fileContent = sourceMapData[filename];

if (
!didAttachSourceSnippet &&
isNextProject &&
fileContent &&
frame.lineNumber
) {
didAttachSourceSnippet = true;
const extension = getFileExtension(filename);
const sourceContext = getSourceContext(
fileContent,
frame.lineNumber,
SOURCE_CONTEXT_LINES,
);
const locationInfo = frame.columnNumber
? `${filename}:${frame.lineNumber}:${frame.columnNumber}`
: `${filename}:${frame.lineNumber}`;

stackContext.push(
`\n\n\`\`\`${extension}\n${sourceContext}\n\`\`\`\nat ${locationInfo}`,
);
continue;
}

line += normalizeFileName(frame.fileName);
const isValidSourceComponent =
frame.functionName &&
checkIsSourceComponentName(frame.functionName);

// HACK: bundlers like vite mess up the line number and column number
if (isNextProject && frame.lineNumber && frame.columnNumber) {
line += `:${frame.lineNumber}:${frame.columnNumber}`;
}

if (hasComponentName) {
line += `)`;
}
const locationSuffix =
isNextProject && frame.lineNumber && frame.columnNumber
? `:${frame.lineNumber}:${frame.columnNumber}`
: "";

const line = isValidSourceComponent
? `\n in ${frame.functionName} (at ${filename}${locationSuffix})`
: `\n in ${filename}${locationSuffix}`;

stackContext.push(line);
}
Expand Down
9 changes: 6 additions & 3 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading