diff --git a/viz/.eslintrc.json b/viz/.eslintrc.json
index 3faa07b9a705..de1e7e4bb822 100644
--- a/viz/.eslintrc.json
+++ b/viz/.eslintrc.json
@@ -6,7 +6,8 @@
],
"rules": {
"eqeqeq": "error",
- "no-unused-vars": "error",
+ "no-unused-vars": "off",
+ "@typescript-eslint/no-unused-vars": "error",
"@typescript-eslint/no-explicit-any": "error"
}
}
diff --git a/viz/app/components/ErrorBoundary.tsx b/viz/app/components/ErrorBoundary.tsx
new file mode 100644
index 000000000000..5bd8392f853b
--- /dev/null
+++ b/viz/app/components/ErrorBoundary.tsx
@@ -0,0 +1,92 @@
+import { Button, ErrorMessage } from "@viz/app/components/Components";
+import React, { useState } from "react";
+
+interface ErrorBoundaryState {
+ error: unknown;
+ hasError: boolean;
+}
+
+interface ErrorBoundaryProps {
+ children: React.ReactNode;
+ errorMessage: string;
+ onRetryClick: (errorMessage: string) => void;
+}
+
+export class ErrorBoundary extends React.Component<
+ ErrorBoundaryProps,
+ ErrorBoundaryState
+> {
+ constructor(props: ErrorBoundaryProps) {
+ super(props);
+ this.state = { hasError: false, error: null };
+ }
+
+ static getDerivedStateFromError() {
+ // Update state so the next render will show the fallback UI.
+ return { hasError: true };
+ }
+
+ componentDidCatch(error: unknown) {
+ this.setState({ hasError: true, error });
+ }
+
+ render() {
+ if (this.state.hasError) {
+ let error: Error;
+ if (this.state.error instanceof Error) {
+ error = this.state.error;
+ } else {
+ error = new Error("Unknown error.");
+ }
+
+ return (
+
+ );
+ }
+
+ return <>{this.props.children}>;
+ }
+}
+
+// This is the component to render when an error occurs.
+export function RenderError({
+ error,
+ message,
+ onRetryClick,
+}: {
+ error: Error;
+ message: string;
+ onRetryClick: (errorMessage: string) => void;
+}) {
+ const [showDetails, setShowDetails] = useState(false);
+
+ return (
+
+
+ <>
+ {message}
+
+
+ {showDetails && (
+
+ Error message: {error.message}
+
+ )}
+
+ >
+
+
+
+
+ );
+}
diff --git a/viz/app/components/VisualizationWrapper.tsx b/viz/app/components/VisualizationWrapper.tsx
index 673a6a671e3d..74b653f9d274 100644
--- a/viz/app/components/VisualizationWrapper.tsx
+++ b/viz/app/components/VisualizationWrapper.tsx
@@ -8,22 +8,21 @@ import type {
import { Button, ErrorMessage, Spinner } from "@viz/app/components/Components";
import * as papaparseAll from "papaparse";
import * as reactAll from "react";
-import React, { useCallback } from "react";
+import React, { useCallback, useMemo } from "react";
import { useEffect, useState } from "react";
import { importCode, Runner } from "react-runner";
import * as rechartsAll from "recharts";
import { useResizeDetector } from "react-resize-detector";
+import { ErrorBoundary, RenderError } from "@viz/app/components/ErrorBoundary";
-export function useVisualizationAPI(actionId: number) {
+export function useVisualizationAPI(
+ sendCrossDocumentMessage: ReturnType
+) {
const [error, setError] = useState(null);
const fetchCode = useCallback(async (): Promise => {
- const getCode = makeIframeMessagePassingFunction(
- "getCodeToExecute",
- actionId
- );
try {
- const result = await getCode(null);
+ const result = await sendCrossDocumentMessage("getCodeToExecute", null);
const { code } = result;
if (!code) {
@@ -42,12 +41,11 @@ export function useVisualizationAPI(actionId: number) {
return null;
}
- }, [actionId]);
+ }, [sendCrossDocumentMessage]);
const fetchFile = useCallback(
async (fileId: string): Promise => {
- const getFile = makeIframeMessagePassingFunction("getFile", actionId);
- const res = await getFile({ fileId });
+ const res = await sendCrossDocumentMessage("getFile", { fileId });
const { fileBlob: blob } = res;
@@ -60,59 +58,40 @@ export function useVisualizationAPI(actionId: number) {
return file;
},
- [actionId]
+ [sendCrossDocumentMessage]
);
// This retry function sends a command to the host window requesting a retry of a previous
// operation, typically if the generated code fails.
const retry = useCallback(
async (errorMessage: string): Promise => {
- const sendRetry = makeIframeMessagePassingFunction("retry", actionId);
- await sendRetry({ errorMessage });
+ await sendCrossDocumentMessage("retry", { errorMessage });
},
- [actionId]
+ [sendCrossDocumentMessage]
);
- return { fetchCode, fetchFile, error, retry };
-}
+ const sendHeightToParent = useCallback(
+ async ({ height }: { height: number | null }) => {
+ if (height === null) {
+ return;
+ }
-// This function creates a function that sends a command to the host window with templated Input and Output types.
-function makeIframeMessagePassingFunction(
- methodName: T,
- actionId: number
-) {
- return (params: VisualizationRPCRequestMap[T]) => {
- return new Promise((resolve, reject) => {
- const messageUniqueId = Math.random().toString();
- const listener = (event: MessageEvent) => {
- if (event.data.messageUniqueId === messageUniqueId) {
- if (event.data.error) {
- reject(event.data.error);
- } else {
- resolve(event.data.result);
- }
- window.removeEventListener("message", listener);
- }
- };
- window.addEventListener("message", listener);
- window.top?.postMessage(
- {
- command: methodName,
- messageUniqueId,
- actionId,
- params,
- },
- "*"
- );
- });
- };
+ await sendCrossDocumentMessage("setContentHeight", {
+ height,
+ });
+ },
+ [sendCrossDocumentMessage]
+ );
+
+ return { fetchCode, fetchFile, error, retry, sendHeightToParent };
}
-const useFile = (actionId: number, fileId: string) => {
+const useFile = (
+ fileId: string,
+ fetchFile: (fileId: string) => Promise
+) => {
const [file, setFile] = useState(null);
- const { fetchFile } = useVisualizationAPI(actionId); // Adjust the import based on your project structure
-
useEffect(() => {
const fetch = async () => {
try {
@@ -131,20 +110,34 @@ const useFile = (actionId: number, fileId: string) => {
return file;
};
+interface RunnerParams {
+ code: string;
+ scope: Record;
+}
+
// This component renders the generated code.
// It gets the generated code via message passing to the host window.
-export function VisualizationWrapper({ actionId }: { actionId: string }) {
- type RunnerParams = {
- code: string;
- scope: Record;
- };
-
+export function VisualizationWrapper({
+ actionId,
+ allowedVisualizationOrigin,
+}: {
+ actionId: number;
+ allowedVisualizationOrigin: string | undefined;
+}) {
const [runnerParams, setRunnerParams] = useState(null);
const [errored, setErrored] = useState(null);
- const actionIdParsed = parseInt(actionId, 10);
+ const sendCrossDocumentMessage = useMemo(
+ () =>
+ makeSendCrossDocumentMessage({
+ actionId,
+ allowedVisualizationOrigin,
+ }),
+ [actionId, allowedVisualizationOrigin]
+ );
- const { fetchCode, error, retry } = useVisualizationAPI(actionIdParsed);
+ const { fetchCode, fetchFile, error, retry, sendHeightToParent } =
+ useVisualizationAPI(sendCrossDocumentMessage);
useEffect(() => {
const loadCode = async () => {
@@ -165,8 +158,7 @@ export function VisualizationWrapper({ actionId }: { actionId: string }) {
react: reactAll,
papaparse: papaparseAll,
"@dust/react-hooks": {
- useFile: (fileId: string) =>
- useFile(actionIdParsed, fileId),
+ useFile: (fileId: string) => useFile(fileId, fetchFile),
},
},
}),
@@ -180,21 +172,7 @@ export function VisualizationWrapper({ actionId }: { actionId: string }) {
};
loadCode();
- }, [fetchCode, actionIdParsed]);
-
- const sendHeightToParent = useCallback(
- ({ height }: { height: number | null }) => {
- if (height === null) {
- return;
- }
- const sendHeight = makeIframeMessagePassingFunction<"setContentHeight">(
- "setContentHeight",
- actionIdParsed
- );
- sendHeight({ height });
- },
- [actionIdParsed]
- );
+ }, [fetchCode, fetchFile]);
const { ref } = useResizeDetector({
handleHeight: true,
@@ -210,12 +188,8 @@ export function VisualizationWrapper({ actionId }: { actionId: string }) {
}, [error]);
if (errored) {
- return (
- retry(errored.message)}
- />
- );
+ // Throw the error to the ErrorBoundary.
+ throw errored;
}
if (!runnerParams) {
@@ -223,103 +197,67 @@ export function VisualizationWrapper({ actionId }: { actionId: string }) {
}
return (
-
- {
- if (error) {
- setErrored(error);
- }
- }}
- />
-
+
+
+ {
+ if (error) {
+ setErrored(error);
+ }
+ }}
+ />
+
+
);
}
-// This is the component to render when an error occurs.
-function VisualizationError({
- error,
- retry,
+export function makeSendCrossDocumentMessage({
+ actionId,
+ allowedVisualizationOrigin,
}: {
- error: Error;
- retry: () => void;
+ actionId: number;
+ allowedVisualizationOrigin: string | undefined;
}) {
- const [showDetails, setShowDetails] = useState(false);
-
- return (
-
-
- <>
- We encountered an error while running the code generated above. You
- can try again by clicking the button below.
-
-
- {showDetails && (
-
- Error message: {error.message}
-
- )}
-
- >
-
-
-
-
-
- );
-}
-
-type ErrorBoundaryProps = {
- actionId: string;
-};
-
-type ErrorBoundaryState = {
- hasError: boolean;
- error: unknown;
-};
-
-// This is the error boundary component that wraps the VisualizationWrapper component.
-// It needs to be a class component for error handling to work.
-export class VisualizationWrapperWithErrorHandling extends React.Component<
- ErrorBoundaryProps,
- ErrorBoundaryState
-> {
- constructor(props: ErrorBoundaryProps) {
- super(props);
- this.state = { hasError: false, error: null };
- }
-
- static getDerivedStateFromError() {
- // Update state so the next render will show the fallback UI.
- return { hasError: true };
- }
-
- componentDidCatch(error: unknown) {
- this.setState({ hasError: true, error });
- }
+ return (
+ command: T,
+ params: VisualizationRPCRequestMap[T]
+ ) => {
+ return new Promise((resolve, reject) => {
+ const messageUniqueId = Math.random().toString();
+ const listener = (event: MessageEvent) => {
+ if (event.origin !== allowedVisualizationOrigin) {
+ console.log(
+ `Ignored message from unauthorized origin: ${event.origin}`
+ );
- render() {
- if (this.state.hasError) {
- let error: Error;
- if (this.state.error instanceof Error) {
- error = this.state.error;
- } else {
- error = new Error("Unknown error.");
- }
+ // Simply ignore messages from unauthorized origins.
+ return;
+ }
- const retry = makeIframeMessagePassingFunction(
- "retry",
- parseInt(this.props.actionId, 10)
+ if (event.data.messageUniqueId === messageUniqueId) {
+ if (event.data.error) {
+ reject(event.data.error);
+ } else {
+ resolve(event.data.result);
+ }
+ window.removeEventListener("message", listener);
+ }
+ };
+ window.addEventListener("message", listener);
+ window.top?.postMessage(
+ {
+ command,
+ messageUniqueId,
+ actionId,
+ params,
+ },
+ "*"
);
- return retry} />;
- }
-
- return ;
- }
+ });
+ };
}
diff --git a/viz/app/content/page.tsx b/viz/app/content/page.tsx
index e01902cc6aa9..f2a974c81dd8 100644
--- a/viz/app/content/page.tsx
+++ b/viz/app/content/page.tsx
@@ -1,14 +1,20 @@
-import { VisualizationWrapperWithErrorHandling } from "@viz/app/components/VisualizationWrapper";
+import { VisualizationWrapper } from "@viz/app/components/VisualizationWrapper";
-type IframeProps = {
- wId: string;
+type RenderVisualizationSearchParams = {
aId: string;
};
-export default function Iframe({
+const { ALLOWED_VISUALIZATION_ORIGIN } = process.env;
+
+export default function RenderVisualization({
searchParams,
}: {
- searchParams: IframeProps;
+ searchParams: RenderVisualizationSearchParams;
}) {
- return ;
+ return (
+
+ );
}