diff --git a/playground/e2e/jsxPlaygroundRuntime.test.ts b/playground/e2e/jsxPlaygroundRuntime.test.ts
index c3ec4e16..fcc6a5ff 100644
--- a/playground/e2e/jsxPlaygroundRuntime.test.ts
+++ b/playground/e2e/jsxPlaygroundRuntime.test.ts
@@ -21,6 +21,20 @@ describe('JSX playground runtime', () => {
expect(() => compileJsxFunctionBody('return window.localStorage;')).toThrow(
'does not allow window',
);
+ expect(() =>
+ compileJsxFunctionBody('return importScripts("https://example.com/a.js");'),
+ ).toThrow('does not allow importScripts');
+ expect(() =>
+ compileJsxFunctionBody('return ({fetch("/")});'),
+ ).toThrow('does not allow fetch');
+ });
+
+ it('allows restricted words in rendered text content', () => {
+ expect(() =>
+ compileJsxFunctionBody(
+ 'return (window document importScripts);',
+ ),
+ ).not.toThrow();
});
it('renders JSX into a normal pdfme template result', async () => {
diff --git a/playground/src/routes/JsxPlayground.tsx b/playground/src/routes/JsxPlayground.tsx
index 06e15236..6bfdd658 100644
--- a/playground/src/routes/JsxPlayground.tsx
+++ b/playground/src/routes/JsxPlayground.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useRef, useState } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
import type { Template } from '@pdfme/common';
import type { RenderResult } from '@pdfme/jsx';
import { Viewer } from '@pdfme/ui';
@@ -16,43 +16,22 @@ const RENDER_TIMEOUT_MS = 15_000;
type WorkerResponse =
| {
+ id: number;
ok: true;
result: RenderResult;
}
| {
error: string;
+ id: number;
ok: false;
};
-const renderJsxSourceInWorker = (source: string) =>
- new Promise((resolve, reject) => {
- const worker = new JsxPlaygroundWorker();
- const timeoutId = window.setTimeout(() => {
- worker.terminate();
- reject(new Error('JSX render timed out.'));
- }, RENDER_TIMEOUT_MS);
-
- const cleanup = () => {
- window.clearTimeout(timeoutId);
- worker.terminate();
- };
-
- worker.onmessage = (event: MessageEvent) => {
- cleanup();
- if (event.data.ok) {
- resolve(event.data.result);
- } else {
- reject(new Error(event.data.error));
- }
- };
-
- worker.onerror = (event) => {
- cleanup();
- reject(new Error(event.message || 'JSX render worker failed.'));
- };
-
- worker.postMessage({ font: getFontsData(), source });
- });
+type PendingRender = {
+ id: number;
+ reject: (error: Error) => void;
+ resolve: (result: RenderResult) => void;
+ timeoutId: number;
+};
const configureJsxEditor: Parameters[0]['beforeMount'] = (monaco) => {
const typeScriptLanguage = monaco.languages.typescript;
@@ -96,6 +75,9 @@ declare function PageBreak(props?: Record): unknown;
export default function JsxPlayground() {
const viewerRootRef = useRef(null);
const viewerRef = useRef(null);
+ const renderWorkerRef = useRef(null);
+ const pendingRenderRef = useRef(null);
+ const nextRenderRequestIdRef = useRef(0);
const [source, setSource] = useState(initialJsx);
const [template, setTemplate] = useState(null);
const [inputs, setInputs] = useState[]>([{}]);
@@ -104,6 +86,69 @@ export default function JsxPlayground() {
const [pdfDuration, setPdfDuration] = useState(null);
const [isGeneratingPdf, setIsGeneratingPdf] = useState(false);
+ const terminateRenderWorker = useCallback(() => {
+ renderWorkerRef.current?.terminate();
+ renderWorkerRef.current = null;
+ }, []);
+
+ const clearPendingRender = useCallback((error?: Error) => {
+ const pendingRender = pendingRenderRef.current;
+ if (!pendingRender) return;
+
+ window.clearTimeout(pendingRender.timeoutId);
+ pendingRenderRef.current = null;
+ if (error) pendingRender.reject(error);
+ }, []);
+
+ const getRenderWorker = useCallback(() => {
+ if (renderWorkerRef.current) return renderWorkerRef.current;
+
+ const worker = new JsxPlaygroundWorker();
+ worker.onmessage = (event: MessageEvent) => {
+ const pendingRender = pendingRenderRef.current;
+ if (!pendingRender || event.data.id !== pendingRender.id) return;
+
+ clearPendingRender();
+ if (event.data.ok) {
+ pendingRender.resolve(event.data.result);
+ } else {
+ pendingRender.reject(new Error(event.data.error));
+ }
+ };
+ worker.onerror = (event) => {
+ const pendingRender = pendingRenderRef.current;
+ clearPendingRender();
+ terminateRenderWorker();
+ pendingRender?.reject(new Error(event.message || 'JSX render worker failed.'));
+ };
+ renderWorkerRef.current = worker;
+ return worker;
+ }, [clearPendingRender, terminateRenderWorker]);
+
+ const renderJsxSourceInWorker = useCallback(
+ (nextSource: string) =>
+ new Promise((resolve, reject) => {
+ if (pendingRenderRef.current) {
+ clearPendingRender(new Error('JSX render cancelled.'));
+ terminateRenderWorker();
+ }
+
+ const worker = getRenderWorker();
+ const id = (nextRenderRequestIdRef.current += 1);
+ const timeoutId = window.setTimeout(() => {
+ const pendingRender = pendingRenderRef.current;
+ if (!pendingRender || pendingRender.id !== id) return;
+
+ clearPendingRender(new Error('JSX render timed out.'));
+ terminateRenderWorker();
+ }, RENDER_TIMEOUT_MS);
+
+ pendingRenderRef.current = { id, reject, resolve, timeoutId };
+ worker.postMessage({ font: getFontsData(), id, source: nextSource });
+ }),
+ [clearPendingRender, getRenderWorker, terminateRenderWorker],
+ );
+
useEffect(() => {
let cancelled = false;
const timer = window.setTimeout(async () => {
@@ -126,7 +171,7 @@ export default function JsxPlayground() {
cancelled = true;
window.clearTimeout(timer);
};
- }, [source]);
+ }, [renderJsxSourceInWorker, source]);
useEffect(() => {
if (!viewerRootRef.current || !template) return;
@@ -155,10 +200,12 @@ export default function JsxPlayground() {
useEffect(() => {
return () => {
+ clearPendingRender(new Error('JSX render cancelled.'));
+ terminateRenderWorker();
viewerRef.current?.destroy();
viewerRef.current = null;
};
- }, []);
+ }, [clearPendingRender, terminateRenderWorker]);
const onGeneratePdf = async () => {
if (isGeneratingPdf) return;
diff --git a/playground/src/routes/jsxPlaygroundRuntime.ts b/playground/src/routes/jsxPlaygroundRuntime.ts
index 7586d856..c8e4ff41 100644
--- a/playground/src/routes/jsxPlaygroundRuntime.ts
+++ b/playground/src/routes/jsxPlaygroundRuntime.ts
@@ -33,6 +33,7 @@ const RESTRICTED_GLOBALS = [
'fetch',
'Function',
'globalThis',
+ 'importScripts',
'indexedDB',
'localStorage',
'location',
@@ -50,6 +51,8 @@ const RESTRICTED_GLOBALS = [
const IMPORT_EXPORT_PATTERN = /^\s*(import|export)\b/m;
const RESTRICTED_GLOBAL_PATTERN = new RegExp(`\\b(${RESTRICTED_GLOBALS.join('|')})\\b`);
+const JS_COMMENT_OR_STRING_PATTERN =
+ /\/\*[\s\S]*?\*\/|\/\/[^\r\n]*|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/g;
const jsxScope = {
Absolute,
@@ -85,14 +88,20 @@ const createElement = (
return jsx(type, nextProps);
};
-const assertAllowedJsxSource = (source: string) => {
+const stripCommentsAndQuotedStrings = (source: string) =>
+ source.replace(JS_COMMENT_OR_STRING_PATTERN, '');
+
+const assertNoImportExport = (source: string) => {
if (IMPORT_EXPORT_PATTERN.test(source)) {
throw new Error(
'The JSX playground beta does not support import/export. Use a function body that returns nodes.',
);
}
+};
- const restrictedGlobal = source.match(RESTRICTED_GLOBAL_PATTERN)?.[1];
+const assertNoRestrictedGlobals = (source: string) => {
+ const restrictedGlobal =
+ stripCommentsAndQuotedStrings(source).match(RESTRICTED_GLOBAL_PATTERN)?.[1];
if (restrictedGlobal) {
throw new Error(
`The JSX playground beta does not allow ${restrictedGlobal}. Only pdfme JSX components are available.`,
@@ -101,16 +110,18 @@ const assertAllowedJsxSource = (source: string) => {
};
export const compileJsxFunctionBody = (source: string) => {
- assertAllowedJsxSource(source);
+ assertNoImportExport(source);
try {
- return transform(source, {
+ const compiled = transform(source, {
filePath: 'playground.tsx',
jsxFragmentPragma: 'Fragment',
jsxPragma: 'createElement',
production: true,
transforms: ['typescript', 'jsx'],
}).code;
+ assertNoRestrictedGlobals(compiled);
+ return compiled;
} catch (error) {
throw new Error(error instanceof Error ? error.message : String(error));
}
diff --git a/playground/src/routes/jsxPlaygroundWorker.ts b/playground/src/routes/jsxPlaygroundWorker.ts
index d771ab48..fa9f2c29 100644
--- a/playground/src/routes/jsxPlaygroundWorker.ts
+++ b/playground/src/routes/jsxPlaygroundWorker.ts
@@ -4,16 +4,19 @@ import { renderJsxSource } from './jsxPlaygroundRuntime';
type RenderRequest = {
font: Font;
+ id: number;
source: string;
};
type RenderResponse =
| {
+ id: number;
ok: true;
result: RenderResult;
}
| {
error: string;
+ id: number;
ok: false;
};
@@ -23,12 +26,14 @@ const workerScope = self as unknown as {
};
workerScope.onmessage = async (event: MessageEvent) => {
+ const { font, id, source } = event.data;
try {
- const result = await renderJsxSource(event.data.source, event.data.font);
- workerScope.postMessage({ ok: true, result } satisfies RenderResponse);
+ const result = await renderJsxSource(source, font);
+ workerScope.postMessage({ id, ok: true, result } satisfies RenderResponse);
} catch (error) {
workerScope.postMessage({
error: error instanceof Error ? error.message : String(error),
+ id,
ok: false,
} satisfies RenderResponse);
}