diff --git a/src/App.tsx b/src/App.tsx index 291095ae..6c75105e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,6 +14,21 @@ const LearnNow = lazy(() => import("./pages/LearnNow")); const MainContainer = lazy(() => import("./pages/MainContainer")); const { Content } = Layout; +const APP_INIT_TIMEOUT_MS = 12000; + +const withTimeout = async (promise: Promise, ms: number, message: string): Promise => { + let timeoutHandle: ReturnType | undefined; + const timeoutPromise = new Promise((_, reject) => { + timeoutHandle = setTimeout(() => reject(new Error(message)), ms); + }); + try { + await Promise.race([promise, timeoutPromise]); + } finally { + if (timeoutHandle) { + clearTimeout(timeoutHandle); + } + } +}; const App = () => { const navigate = useNavigate(); @@ -42,12 +57,20 @@ const App = () => { compressedData = searchParams.get("data"); } if (compressedData) { - await loadFromLink(compressedData); + await withTimeout( + loadFromLink(compressedData), + APP_INIT_TIMEOUT_MS, + `Initialization timed out after ${APP_INIT_TIMEOUT_MS}ms while loading shared data.` + ); if (window.location.pathname !== "/") { navigate("/", { replace: true }); } } else { - await init(); + await withTimeout( + init(), + APP_INIT_TIMEOUT_MS, + `Initialization timed out after ${APP_INIT_TIMEOUT_MS}ms.` + ); } } catch (error) { console.error("Initialization error:", error); diff --git a/src/store/store.ts b/src/store/store.ts index d3f9d0de..3bb56cbe 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -79,6 +79,27 @@ export interface DecompressedData { const rebuildDeBounce = debounce(rebuild, 500); +const EXTERNAL_MODEL_RESOLUTION_TIMEOUT_MS = 10000; + +function withTimeout(promise: Promise, ms: number, message: string): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(message)), ms); + promise + .then((value) => { + clearTimeout(timer); + resolve(value); + }) + .catch((error: unknown) => { + clearTimeout(timer); + reject(error); + }); + }); +} + +function hasExternalImports(model: string): boolean { + return /^\s*import\s+/m.test(model); +} + async function rebuild(template: string, model: string, dataString: string): Promise { // Validate inputs before expensive operations // This fails fast on invalid JSON or CTO syntax without running network calls @@ -86,8 +107,15 @@ async function rebuild(template: string, model: string, dataString: string): Pro const modelManager = new ModelManager({ strict: true }); modelManager.addCTOModel(model, undefined, true); - await modelManager.updateExternalModels(); - const engine = new TemplateMarkInterpreter(modelManager, {}); + if (hasExternalImports(model)) { + await withTimeout( + modelManager.updateExternalModels(), + EXTERNAL_MODEL_RESOLUTION_TIMEOUT_MS, + `External model resolution timed out after ${EXTERNAL_MODEL_RESOLUTION_TIMEOUT_MS}ms. Check connectivity or remove remote imports.` + ); + } + // template-engine currently resolves concerto-core v3 types; cast to bridge v4 app types at this boundary. + const engine = new TemplateMarkInterpreter(modelManager as unknown as never, {}); // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call const templateMarkTransformer = new TemplateMarkTransformer(); // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call