Skip to content
Open
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
162 changes: 162 additions & 0 deletions packages/react-grab/e2e/external-communication.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { test, expect } from "@playwright/test";

test.describe("External Communication", () => {
test("should skip external requests during initialization when disabled", async ({
page,
}) => {
const requestedUrls: string[] = [];

page.on("request", (request) => {
const requestUrl = request.url();
if (
requestUrl.startsWith("https://www.react-grab.com/api/version") ||
requestUrl.startsWith("https://fonts.googleapis.com/")
) {
requestedUrls.push(requestUrl);
}
});

await page.addInitScript(() => {
(
window as {
__REACT_GRAB_OPTIONS__?: {
allowExternalCommunication?: boolean;
};
}
).__REACT_GRAB_OPTIONS__ = {
allowExternalCommunication: false,
};
});

await page.goto("/", { waitUntil: "domcontentloaded" });
await page.waitForFunction(
() => (window as { __REACT_GRAB__?: unknown }).__REACT_GRAB__ !== undefined,
{ timeout: 5000 },
);
await page.waitForTimeout(300);

expect(requestedUrls).toEqual([]);

const hasFontLink = await page.evaluate(() => {
return document.getElementById("react-grab-fonts") !== null;
});

expect(hasFontLink).toBe(false);
});

test("should not open a remote open-file fallback from the selection label when disabled", async ({
page,
}) => {
await page.addInitScript(() => {
(
window as {
__REACT_GRAB_OPTIONS__?: {
allowExternalCommunication?: boolean;
};
}
).__REACT_GRAB_OPTIONS__ = {
allowExternalCommunication: false,
};
});

await page.goto("/", { waitUntil: "domcontentloaded" });
await page.waitForFunction(
() => (window as { __REACT_GRAB__?: unknown }).__REACT_GRAB__ !== undefined,
{ timeout: 5000 },
);

await page.evaluate(() => {
(window as { __OPEN_FILE_URLS__?: string[] }).__OPEN_FILE_URLS__ = [];

const originalFetch = window.fetch.bind(window);
window.fetch = async (input, init) => {
const requestUrl =
typeof input === "string"
? input
: input instanceof URL
? input.toString()
: input.url;
if (
requestUrl.includes("/__open-in-editor") ||
requestUrl.includes("/__nextjs_launch-editor")
) {
return new Response("", { status: 404 });
}
return originalFetch(input, init);
};

Object.defineProperty(window, "open", {
configurable: true,
value: (url?: string | URL) => {
const openUrls =
(window as { __OPEN_FILE_URLS__?: string[] }).__OPEN_FILE_URLS__ ??
[];
openUrls.push(typeof url === "string" ? url : String(url ?? ""));
(window as { __OPEN_FILE_URLS__?: string[] }).__OPEN_FILE_URLS__ =
openUrls;
return null;
},
});
});

await page.evaluate(() => {
const api = (
window as {
__REACT_GRAB__?: {
activate: () => void;
};
}
).__REACT_GRAB__;
api?.activate();
});
await page.waitForFunction(
() =>
(
window as {
__REACT_GRAB__?: {
isActive: () => boolean;
};
}
).__REACT_GRAB__?.isActive() === true,
{ timeout: 5000 },
);

const firstListItem = page.locator("li").first();
await firstListItem.hover({ force: true });

await page.waitForFunction(
() => {
const api = (
window as {
__REACT_GRAB__?: {
getState: () => {
isSelectionBoxVisible: boolean;
targetElement: unknown;
selectionFilePath: string | null;
};
};
}
).__REACT_GRAB__;
const state = api?.getState();
return Boolean(
(state?.isSelectionBoxVisible || state?.targetElement) &&
state?.selectionFilePath,
);
},
{ timeout: 5000 },
);

const selectionLabelOpenButton = page.locator(
"[data-react-grab-selection-label] .cursor-pointer",
);
await expect(selectionLabelOpenButton).toBeVisible();
await selectionLabelOpenButton.click({ force: true });
await page.waitForTimeout(200);

const openUrls = await page.evaluate(() => {
return (window as { __OPEN_FILE_URLS__?: string[] }).__OPEN_FILE_URLS__;
});

expect(openUrls ?? []).toEqual([]);
});
});
7 changes: 1 addition & 6 deletions packages/react-grab/src/components/renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
FROZEN_GLOW_EDGE_PX,
Z_INDEX_OVERLAY_CANVAS,
} from "../constants.js";
import { openFile } from "../utils/open-file.js";
import { isElementConnected } from "../utils/is-element-connected.js";
import { OverlayCanvas } from "./overlay-canvas.js";
import { SelectionLabel } from "./selection-label/index.js";
Expand Down Expand Up @@ -143,11 +142,7 @@ export const ReactGrabRenderer: Component<ReactGrabRendererProps> = (props) => {
isPendingDismiss={props.isPendingDismiss}
onConfirmDismiss={props.onConfirmDismiss}
onCancelDismiss={props.onCancelDismiss}
onOpen={() => {
if (props.selectionFilePath) {
openFile(props.selectionFilePath, props.selectionLineNumber);
}
}}
onOpen={props.onOpenSelectionFile}
isContextMenuOpen={props.contextMenuPosition !== null}
/>
</Show>
Expand Down
34 changes: 24 additions & 10 deletions packages/react-grab/src/core/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => {
activationMode: "toggle",
keyHoldDuration: DEFAULT_KEY_HOLD_DURATION_MS,
allowActivationInsideInput: true,
allowExternalCommunication: true,
maxContextLines: DEFAULT_MAX_CONTEXT_LINES,
...scriptOptions,
...rawOptions,
Expand All @@ -192,7 +193,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => {
}
hasInited = true;

logIntro();
logIntro(initialOptions.allowExternalCommunication ?? true);

// eslint-disable-next-line @typescript-eslint/no-unused-vars -- need to omit enabled from settableOptions to avoid circular dependency
const { enabled: _enabled, ...settableOptions } = initialOptions;
Expand Down Expand Up @@ -2277,31 +2278,38 @@ export const init = (rawOptions?: Options): ReactGrabAPI => {
return false;
};

const handleOpenFileShortcut = (event: KeyboardEvent): boolean => {
if (event.key?.toLowerCase() !== "o" || isPromptMode()) return false;
if (!isActivated() || !(event.metaKey || event.ctrlKey)) return false;

const openSelectionFile = (): boolean => {
const filePath = store.selectionFilePath;
const lineNumber = store.selectionLineNumber;
if (!filePath) return false;

event.preventDefault();
event.stopPropagation();

const wasHandled = pluginRegistry.hooks.onOpenFile(
filePath,
lineNumber ?? undefined,
);
if (!wasHandled) {
openFile(
void openFile(
filePath,
lineNumber ?? undefined,
pluginRegistry.hooks.transformOpenFileUrl,
pluginRegistry.store.options.allowExternalCommunication,
);
}
return true;
};

const handleOpenFileShortcut = (event: KeyboardEvent): boolean => {
if (event.key?.toLowerCase() !== "o" || isPromptMode()) return false;
if (!isActivated() || !(event.metaKey || event.ctrlKey)) return false;

const didOpenSelectionFile = openSelectionFile();
if (!didOpenSelectionFile) return false;

event.preventDefault();
event.stopPropagation();
return true;
};

const clearActionCycleIdleTimeout = () => {
if (actionCycleIdleTimeoutId !== null) {
window.clearTimeout(actionCycleIdleTimeoutId);
Expand Down Expand Up @@ -3131,7 +3139,10 @@ export const init = (rawOptions?: Options): ReactGrabAPI => {
});

const resolvedCssText = typeof cssText === "string" ? cssText : "";
const rendererRoot = mountRoot(resolvedCssText);
const rendererRoot = mountRoot(
resolvedCssText,
pluginRegistry.store.options.allowExternalCommunication,
);

const isThemeEnabled = createMemo(() => pluginRegistry.store.theme.enabled);
const isSelectionBoxThemeEnabled = createMemo(
Expand Down Expand Up @@ -3500,6 +3511,8 @@ export const init = (rawOptions?: Options): ReactGrabAPI => {
lineNumber,
componentName,
tagName,
allowExternalCommunication:
pluginRegistry.store.options.allowExternalCommunication,
enterPromptMode: customEnterPromptMode ?? defaultEnterPromptMode,
copy: copyAction,
hooks: {
Expand Down Expand Up @@ -4036,6 +4049,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => {
selectionElementsCount={frozenElementsCount()}
selectionFilePath={store.selectionFilePath ?? undefined}
selectionLineNumber={store.selectionLineNumber ?? undefined}
onOpenSelectionFile={openSelectionFile}
selectionTagName={selectionTagName()}
selectionComponentName={resolvedComponentName()}
selectionLabelVisible={selectionLabelVisible()}
Expand Down
9 changes: 7 additions & 2 deletions packages/react-grab/src/core/log-intro.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { LOGO_SVG } from "../constants.js";
import { isExtensionContext } from "../utils/is-extension-context.js";

export const logIntro = () => {
export const logIntro = (allowExternalCommunication: boolean) => {
try {
const version = process.env.VERSION;
const logoDataUri = `data:image/svg+xml;base64,${btoa(LOGO_SVG)}`;
Expand All @@ -10,7 +10,12 @@ export const logIntro = () => {
`background: #330039; color: #ffffff; border: 1px solid #d75fcb; padding: 4px 4px 4px 24px; border-radius: 4px; background-image: url("${logoDataUri}"); background-size: 16px 16px; background-repeat: no-repeat; background-position: 4px center; display: inline-block; margin-bottom: 4px;`,
"",
);
if (navigator.onLine && version && !isExtensionContext()) {
if (
allowExternalCommunication &&
navigator.onLine &&
version &&
!isExtensionContext()
) {
fetch(
`https://www.react-grab.com/api/version?source=browser&t=${Date.now()}`,
{
Expand Down
3 changes: 3 additions & 0 deletions packages/react-grab/src/core/plugin-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ interface OptionsState {
activationMode: ActivationMode;
keyHoldDuration: number;
allowActivationInsideInput: boolean;
allowExternalCommunication: boolean;
maxContextLines: number;
activationKey: ActivationKey | undefined;
getContent: ((elements: Element[]) => Promise<string> | string) | undefined;
Expand All @@ -46,6 +47,7 @@ const DEFAULT_OPTIONS: OptionsState = {
activationMode: "toggle",
keyHoldDuration: DEFAULT_KEY_HOLD_DURATION_MS,
allowActivationInsideInput: true,
allowExternalCommunication: true,
maxContextLines: DEFAULT_MAX_CONTEXT_LINES,
activationKey: undefined,
getContent: undefined,
Expand Down Expand Up @@ -128,6 +130,7 @@ const createPluginRegistry = (initialOptions: SettableOptions = {}) => {
"activationMode",
"keyHoldDuration",
"allowActivationInsideInput",
"allowExternalCommunication",
"maxContextLines",
"activationKey",
"getContent",
Expand Down
3 changes: 2 additions & 1 deletion packages/react-grab/src/core/plugins/open.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,11 @@ export const openPlugin: Plugin = {
);

if (!wasHandled) {
openFile(
void openFile(
context.filePath,
context.lineNumber,
context.hooks.transformOpenFileUrl,
context.allowExternalCommunication,
);
}

Expand Down
5 changes: 3 additions & 2 deletions packages/react-grab/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,13 @@ export type {
} from "./types.js";

import { init } from "./core/index.js";
import type { Plugin, ReactGrabAPI } from "./types.js";
import type { Options, Plugin, ReactGrabAPI } from "./types.js";

declare global {
interface Window {
__REACT_GRAB__?: ReactGrabAPI;
__REACT_GRAB_DISABLED__?: boolean;
__REACT_GRAB_OPTIONS__?: Options;
}
}

Expand Down Expand Up @@ -109,7 +110,7 @@ if (typeof window !== "undefined" && !window.__REACT_GRAB_DISABLED__) {
if (window.__REACT_GRAB__) {
globalApi = window.__REACT_GRAB__;
} else {
globalApi = init();
globalApi = init(window.__REACT_GRAB_OPTIONS__);
window.__REACT_GRAB__ = globalApi;
}
flushPendingPlugins(globalApi);
Expand Down
9 changes: 9 additions & 0 deletions packages/react-grab/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ export interface ActionContext {
lineNumber?: number;
componentName?: string;
tagName?: string;
allowExternalCommunication: boolean;
enterPromptMode?: (agent?: AgentOptions) => void;
hooks: ActionContextHooks;
performWithFeedback: (action: () => Promise<boolean>) => Promise<void>;
Expand Down Expand Up @@ -368,6 +369,13 @@ export interface Options {
activationMode?: ActivationMode;
keyHoldDuration?: number;
allowActivationInsideInput?: boolean;
/**
* Whether React Grab can make remote network requests or load remote assets.
* When disabled, React Grab skips version checks, remote font loading, and
* remote open-file fallbacks.
* @default true
*/
allowExternalCommunication?: boolean;
maxContextLines?: number;
activationKey?: ActivationKey;
getContent?: (elements: Element[]) => Promise<string> | string;
Expand Down Expand Up @@ -485,6 +493,7 @@ export interface ReactGrabRendererProps {
selectionElementsCount?: number;
selectionFilePath?: string;
selectionLineNumber?: number;
onOpenSelectionFile?: () => void;
selectionTagName?: string;
selectionComponentName?: string;
selectionLabelVisible?: boolean;
Expand Down
Loading