diff --git a/apps/hash-frontend/next.config.js b/apps/hash-frontend/next.config.js index 19ce65f5d5c..31dfeefc150 100644 --- a/apps/hash-frontend/next.config.js +++ b/apps/hash-frontend/next.config.js @@ -164,7 +164,6 @@ export default withSentryConfig( "@emotion/server", "@hashintel/block-design-system", "@hashintel/design-system", - "@hashintel/petrinaut", "@hashintel/ds-components", "@hashintel/ds-helpers", "@hashintel/type-editor", diff --git a/apps/hash-frontend/src/pages/process.page/process-editor-wrapper.tsx b/apps/hash-frontend/src/pages/process.page/process-editor-wrapper.tsx index d0ca7b7113b..bb340c8d85e 100644 --- a/apps/hash-frontend/src/pages/process.page/process-editor-wrapper.tsx +++ b/apps/hash-frontend/src/pages/process.page/process-editor-wrapper.tsx @@ -1,4 +1,4 @@ -import "@hashintel/petrinaut/dist/main.css"; +import "@hashintel/petrinaut/styles.css"; import type { EntityId } from "@blockprotocol/type-system"; import { AlertModal } from "@hashintel/design-system"; diff --git a/libs/@hashintel/petrinaut/package.json b/libs/@hashintel/petrinaut/package.json index dee58f2e645..b6501542448 100644 --- a/libs/@hashintel/petrinaut/package.json +++ b/libs/@hashintel/petrinaut/package.json @@ -24,6 +24,14 @@ ], "main": "dist/main.js", "types": "dist/main.d.ts", + "exports": { + ".": { + "types": "./dist/main.d.ts", + "import": "./dist/main.js" + }, + "./styles.css": "./dist/main.css", + "./package.json": "./package.json" + }, "scripts": { "build": "vite build", "dev": "storybook dev", @@ -31,14 +39,13 @@ "lint:eslint": "oxlint --type-aware --report-unused-disable-directives-severity=error .", "fix:eslint": "oxlint --fix --type-aware --report-unused-disable-directives-severity=error .", "prepublishOnly": "turbo run build", + "check:bundle": "node scripts/characterize-build-output.mjs --skip-build --skip-watch --guard", + "profile:build": "node scripts/characterize-build-output.mjs", "test:unit": "vitest" }, "dependencies": { "@ark-ui/react": "5.26.2", "@babel/standalone": "7.28.5", - "@fontsource-variable/inter": "5.2.8", - "@fontsource-variable/inter-tight": "5.2.7", - "@fontsource-variable/jetbrains-mono": "5.2.8", "@hashintel/ds-components": "workspace:^", "@hashintel/ds-helpers": "workspace:^", "@hashintel/refractive": "workspace:^", diff --git a/libs/@hashintel/petrinaut/panda.config.shared.test.ts b/libs/@hashintel/petrinaut/panda.config.shared.test.ts index 5c109150a3a..16bbd6e02b1 100644 --- a/libs/@hashintel/petrinaut/panda.config.shared.test.ts +++ b/libs/@hashintel/petrinaut/panda.config.shared.test.ts @@ -37,14 +37,25 @@ describe("resolveDsComponentsBuildInfoPath", () => { }); describe("createPetrinautPandaConfig", () => { - it("includes the shipped build-info file instead of ds-components source globs", () => { + it("includes library source and shipped build-info without Storybook globs", () => { const config = createPetrinautPandaConfig( "/virtual/ds-components/panda.buildinfo.json", ); + expect(config.include).toContain("./src/**/*.{js,jsx,ts,tsx}"); expect(config.include).toContain( "/virtual/ds-components/panda.buildinfo.json", ); + expect(config.include).not.toContain("./.storybook/**/*.{js,jsx,ts,tsx}"); expect(config.include).not.toContain("../ds-components/src/**/*.{ts,tsx}"); }); + + it("includes Storybook globs only for the Storybook config mode", () => { + const config = createPetrinautPandaConfig( + "/virtual/ds-components/panda.buildinfo.json", + { includeStorybook: true }, + ); + + expect(config.include).toContain("./.storybook/**/*.{js,jsx,ts,tsx}"); + }); }); diff --git a/libs/@hashintel/petrinaut/panda.config.shared.ts b/libs/@hashintel/petrinaut/panda.config.shared.ts index 2823310f4fd..ae42ce9df4c 100644 --- a/libs/@hashintel/petrinaut/panda.config.shared.ts +++ b/libs/@hashintel/petrinaut/panda.config.shared.ts @@ -3,11 +3,15 @@ import { createRequire } from "node:module"; import { defineConfig } from "@pandacss/dev"; import { scopedThemeConfig } from "@hashintel/ds-components/preset"; -import { CODE_FONT_FAMILY } from "./src/constants/ui"; - export const DS_COMPONENTS_BUILD_INFO_SUBPATH = "@hashintel/ds-components/panda.buildinfo.json"; +const CODE_FONT_FAMILY = "'JetBrains Mono Variable', monospace"; + +type PetrinautPandaConfigOptions = { + includeStorybook?: boolean; +}; + export const createNodeSpecifierResolver = (moduleLocation: string | URL) => { const require = createRequire(moduleLocation); @@ -18,14 +22,19 @@ export const resolveDsComponentsBuildInfoPath = ( resolve: (specifier: string) => string, ) => resolve(DS_COMPONENTS_BUILD_INFO_SUBPATH); -export const createPetrinautPandaConfig = (dsComponentsBuildInfoPath: string) => +export const createPetrinautPandaConfig = ( + dsComponentsBuildInfoPath: string, + options: PetrinautPandaConfigOptions = {}, +) => defineConfig({ ...scopedThemeConfig(".petrinaut-root"), include: [ "./src/**/*.{js,jsx,ts,tsx}", dsComponentsBuildInfoPath, - "./.storybook/**/*.{js,jsx,ts,tsx}", + ...(options.includeStorybook + ? ["./.storybook/**/*.{js,jsx,ts,tsx}"] + : []), ], exclude: [], diff --git a/libs/@hashintel/petrinaut/panda.storybook.config.ts b/libs/@hashintel/petrinaut/panda.storybook.config.ts new file mode 100644 index 00000000000..7b91a59910d --- /dev/null +++ b/libs/@hashintel/petrinaut/panda.storybook.config.ts @@ -0,0 +1,13 @@ +import { + createNodeSpecifierResolver, + createPetrinautPandaConfig, + resolveDsComponentsBuildInfoPath, +} from "./panda.config.shared"; + +export default createPetrinautPandaConfig( + resolveDsComponentsBuildInfoPath( + /** Panda evaluates this config through CJS, so `__filename` is available here. */ + createNodeSpecifierResolver(__filename), + ), + { includeStorybook: true }, +); diff --git a/libs/@hashintel/petrinaut/postcss.config.cjs b/libs/@hashintel/petrinaut/postcss.config.cjs index 1bfc8f1deb5..e3c112732e6 100644 --- a/libs/@hashintel/petrinaut/postcss.config.cjs +++ b/libs/@hashintel/petrinaut/postcss.config.cjs @@ -1,5 +1,9 @@ +const isStorybook = process.env.npm_lifecycle_event === "dev"; + module.exports = { plugins: { - "@pandacss/dev/postcss": {}, + "@pandacss/dev/postcss": { + configPath: isStorybook ? "panda.storybook.config.ts" : "panda.config.ts", + }, }, }; diff --git a/libs/@hashintel/petrinaut/scripts/characterize-build-output.mjs b/libs/@hashintel/petrinaut/scripts/characterize-build-output.mjs new file mode 100644 index 00000000000..7fdb42f4e44 --- /dev/null +++ b/libs/@hashintel/petrinaut/scripts/characterize-build-output.mjs @@ -0,0 +1,951 @@ +#!/usr/bin/env node + +/* eslint-disable no-console, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unnecessary-condition */ + +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { cpus, freemem, platform, release, totalmem } from "node:os"; +import { + mkdir, + readFile, + readdir, + stat, + utimes, + writeFile, +} from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { gzipSync } from "node:zlib"; +import { performance } from "node:perf_hooks"; + +const SCRIPT_PATH = fileURLToPath(import.meta.url); +const SCRIPT_DIR = path.dirname(SCRIPT_PATH); +const PACKAGE_ROOT = path.resolve(SCRIPT_DIR, ".."); +const DIST_DIR = path.join(PACKAGE_ROOT, "dist"); + +const KNOWN_HEAVY_DEPENDENCIES = [ + "@babel/standalone", + "elkjs", + "monaco-editor", + "@monaco-editor/react", + "typescript", + "uplot", + "@xyflow/react", +]; + +const DEFAULT_WATCH_SAMPLES = [ + { + label: "css entry", + path: "src/index.css", + }, + { + label: "small component", + path: "src/components/icon-button.tsx", + }, + { + label: "editor shell", + path: "src/views/Editor/editor-view.tsx", + }, + { + label: "simulation worker", + path: "src/simulation/worker/simulation.worker.ts", + }, + { + label: "compiler utility", + path: "src/simulation/simulator/compile-user-code.ts", + }, + { + label: "panda shared config", + path: "panda.config.shared.ts", + }, +]; + +export const BUNDLE_GUARD_THRESHOLDS = { + mainJsBytes: 650 * 1024, + mainJsGzipBytes: 180 * 1024, + cssBytes: 850 * 1024, + cssGzipBytes: 180 * 1024, + fontFaceRules: 1, + inlineWorkerImports: 0, + heavyDependenciesAbsentFromMain: [ + "@babel/standalone", + "elkjs", + "monaco-editor", + "@monaco-editor/react", + "uplot", + ], +}; + +const HEAVY_SOURCE_PATTERNS = [ + { + key: "inlineWorkerImports", + pattern: /\?worker&inline/g, + }, + { + key: "workerImports", + pattern: /\?worker/g, + }, + { + key: "babelStandaloneImports", + pattern: + /from\s+["']@babel\/standalone["']|import\s+\*\s+as\s+\w+\s+from\s+["']@babel\/standalone["']/g, + }, + { + key: "elkImports", + pattern: /from\s+["']elkjs["']/g, + }, + { + key: "uplotImports", + pattern: + /from\s+["']uplot["']|import\s+["']uplot\/dist\/uPlot\.min\.css["']/g, + }, + { + key: "fontsourceImports", + pattern: /@fontsource-variable\//g, + }, + { + key: "dsHelpersCssImports", + pattern: /@hashintel\/ds-helpers\/css/g, + }, + { + key: "reactIconsImports", + pattern: /from\s+["']react-icons\//g, + }, +]; + +export function formatBytes(bytes) { + if (bytes === 0) { + return "0 B"; + } + + const units = ["B", "KiB", "MiB", "GiB"]; + const exponent = Math.min( + Math.floor(Math.log(bytes) / Math.log(1024)), + units.length - 1, + ); + const value = bytes / 1024 ** exponent; + const formatted = + exponent === 0 ? String(value) : value.toFixed(value >= 10 ? 1 : 1); + + return `${formatted.replace(/\.0$/, "")} ${units[exponent]}`; +} + +function gzipSize(buffer) { + return gzipSync(buffer, { level: 9 }).byteLength; +} + +function isBareSpecifier(specifier) { + return !specifier.startsWith(".") && !specifier.startsWith("/"); +} + +export function parseBareImports(source) { + const imports = new Set(); + const importFromPattern = + /(?:^|[;\n])\s*import\s+(?:[^'"()]+?\s+from\s+)?["']([^"']+)["']/g; + const exportFromPattern = + /(?:^|[;\n])\s*export\s+(?:[^'"]+?\s+from\s+)["']([^"']+)["']/g; + const sideEffectImportPattern = /(?:^|[;\n])\s*import\s*["']([^"']+)["']/g; + + for (const pattern of [ + importFromPattern, + exportFromPattern, + sideEffectImportPattern, + ]) { + for (const match of source.matchAll(pattern)) { + const specifier = match[1]; + if (specifier && isBareSpecifier(specifier)) { + imports.add(specifier); + } + } + } + + return [...imports].sort((left, right) => left.localeCompare(right)); +} + +export function evaluateBundleGuard( + report, + thresholds = BUNDLE_GUARD_THRESHOLDS, +) { + const failures = []; + + for (const assetPath of report.dist.missingEntryAssets ?? []) { + failures.push(`dist/${assetPath} is missing`); + } + + const checkMax = (label, actual, expected) => { + if (actual > expected) { + failures.push( + `${label} is ${formatBytes(actual)}, expected <= ${formatBytes(expected)}`, + ); + } + }; + + checkMax("main.js", report.dist.mainJs.bytes, thresholds.mainJsBytes); + checkMax( + "main.js gzip", + report.dist.mainJs.gzipBytes, + thresholds.mainJsGzipBytes, + ); + checkMax("main.css", report.dist.css.bytes, thresholds.cssBytes); + checkMax("main.css gzip", report.dist.css.gzipBytes, thresholds.cssGzipBytes); + + if (report.dist.css.fontFaceRules > thresholds.fontFaceRules) { + failures.push( + `main.css has ${report.dist.css.fontFaceRules} @font-face rules, expected <= ${thresholds.fontFaceRules}`, + ); + } + + const inlineWorkerImports = + report.sourceHotspots.totals.inlineWorkerImports ?? 0; + if (inlineWorkerImports !== thresholds.inlineWorkerImports) { + failures.push( + `source has ${inlineWorkerImports} inline worker imports, expected ${thresholds.inlineWorkerImports}`, + ); + } + + for (const dependency of thresholds.heavyDependenciesAbsentFromMain) { + if (report.dist.mainJs.heavyDependencySignals[dependency]) { + failures.push(`${dependency} is present in main.js`); + } + } + + return failures; +} + +async function listFiles(directory) { + if (!(await pathExists(directory))) { + return []; + } + + const entries = await readdir(directory, { withFileTypes: true }); + const files = await Promise.all( + entries.map(async (entry) => { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + return listFiles(entryPath); + } + if (entry.isFile()) { + return [entryPath]; + } + return []; + }), + ); + + return files.flat().sort(); +} + +async function pathExists(filePath) { + try { + await stat(filePath); + return true; + } catch { + return false; + } +} + +function createEmptyAssetGroup() { + return { + count: 0, + bytes: 0, + gzipBytes: 0, + }; +} + +function assetGroupFor(relativePath) { + if (relativePath.endsWith(".js")) { + return "js"; + } + if (relativePath.endsWith(".css")) { + return "css"; + } + if (relativePath.endsWith(".map")) { + return "maps"; + } + return "other"; +} + +async function readJsonIfExists(filePath) { + try { + return JSON.parse(await readFile(filePath, "utf8")); + } catch { + return null; + } +} + +export async function summarizeDistDirectory(distDir = DIST_DIR) { + const files = await listFiles(distDir); + const assetGroups = { + js: createEmptyAssetGroup(), + css: createEmptyAssetGroup(), + maps: createEmptyAssetGroup(), + other: createEmptyAssetGroup(), + }; + const assets = []; + + for (const filePath of files) { + const bytes = await readFile(filePath); + const relativePath = path.relative(distDir, filePath); + const gzipBytes = gzipSize(bytes); + const group = assetGroupFor(relativePath); + + assetGroups[group].count += 1; + assetGroups[group].bytes += bytes.byteLength; + assetGroups[group].gzipBytes += gzipBytes; + + assets.push({ + path: relativePath, + bytes: bytes.byteLength, + gzipBytes, + group, + }); + } + + assets.sort((left, right) => right.bytes - left.bytes); + + const mainJsPath = path.join(distDir, "main.js"); + const mainCssPath = path.join(distDir, "main.css"); + const missingEntryAssets = []; + if (!(await pathExists(mainJsPath))) { + missingEntryAssets.push("main.js"); + } + if (!(await pathExists(mainCssPath))) { + missingEntryAssets.push("main.css"); + } + const mainJs = await readFile(mainJsPath, "utf8").catch(() => ""); + const mainCss = await readFile(mainCssPath, "utf8").catch(() => ""); + const mainMap = await readJsonIfExists(path.join(distDir, "main.js.map")); + + const heavyDependencySignals = Object.fromEntries( + KNOWN_HEAVY_DEPENDENCIES.map((dependency) => [ + dependency, + mainJs.includes(`"${dependency}"`) || + mainJs.includes(`'${dependency}'`) || + mainJs.includes(`${dependency}/`), + ]), + ); + + const sourceMapSources = Array.isArray(mainMap?.sources) + ? mainMap.sources + : []; + + return { + assetGroups, + exists: await pathExists(distDir), + missingEntryAssets, + assets, + largestAssets: assets.slice(0, 20), + workerAssets: assets.filter((asset) => + /(?:^|[./-])worker(?:[.-]|$)/.test(asset.path), + ), + mainJs: { + bytes: Buffer.byteLength(mainJs), + gzipBytes: mainJs ? gzipSize(Buffer.from(mainJs)) : 0, + bareImports: parseBareImports(mainJs), + heavyDependencySignals, + }, + css: { + bytes: Buffer.byteLength(mainCss), + gzipBytes: mainCss ? gzipSize(Buffer.from(mainCss)) : 0, + fontFaceRules: (mainCss.match(/@font-face/g) ?? []).length, + }, + sourceMapSignals: { + sources: sourceMapSources.length, + dsHelpersSources: sourceMapSources.filter((source) => + source.includes("ds-helpers"), + ).length, + babelStandaloneSources: sourceMapSources.filter((source) => + source.includes("@babel/standalone"), + ).length, + workerSources: sourceMapSources.filter((source) => + source.includes(".worker"), + ).length, + }, + }; +} + +async function summarizeSourceHotspots() { + const sourceFiles = (await listFiles(path.join(PACKAGE_ROOT, "src"))).filter( + (filePath) => /\.(?:css|ts|tsx)$/.test(filePath), + ); + const extraFiles = [ + path.join(PACKAGE_ROOT, "panda.config.ts"), + path.join(PACKAGE_ROOT, "panda.config.shared.ts"), + path.join(PACKAGE_ROOT, "vite.config.ts"), + ]; + const files = [...sourceFiles, ...extraFiles]; + const totals = Object.fromEntries( + HEAVY_SOURCE_PATTERNS.map(({ key }) => [key, 0]), + ); + const samples = []; + + for (const filePath of files) { + const source = await readFile(filePath, "utf8"); + const fileCounts = {}; + + for (const { key, pattern } of HEAVY_SOURCE_PATTERNS) { + const count = (source.match(pattern) ?? []).length; + if (count > 0) { + fileCounts[key] = count; + totals[key] += count; + } + } + + if (Object.keys(fileCounts).length > 0) { + samples.push({ + path: path.relative(PACKAGE_ROOT, filePath), + counts: fileCounts, + }); + } + } + + return { + totals, + samples: samples.slice(0, 100), + }; +} + +async function commandExists(command, args = ["--version"]) { + return new Promise((resolve) => { + const child = spawn(command, args, { + cwd: PACKAGE_ROOT, + stdio: "ignore", + }); + child.on("error", () => resolve(false)); + child.on("exit", (code) => resolve(code === 0)); + }); +} + +async function runTimedCommand(label, command, args) { + const startedAt = performance.now(); + let stdout = ""; + let stderr = ""; + + const child = spawn(command, args, { + cwd: PACKAGE_ROOT, + env: { + ...process.env, + CI: "1", + FORCE_COLOR: process.env.FORCE_COLOR ?? "0", + }, + stdio: ["ignore", "pipe", "pipe"], + }); + + child.stdout.on("data", (chunk) => { + const text = chunk.toString(); + stdout += text; + process.stdout.write(text); + }); + + child.stderr.on("data", (chunk) => { + const text = chunk.toString(); + stderr += text; + process.stderr.write(text); + }); + + const exitCode = await new Promise((resolve, reject) => { + child.on("error", reject); + child.on("exit", (code) => resolve(code ?? 1)); + }); + + const durationMs = performance.now() - startedAt; + const combinedOutput = `${stdout}\n${stderr}`; + + return { + label, + command: [command, ...args].join(" "), + exitCode, + durationMs, + warnings: extractWarnings(combinedOutput), + outputHash: createHash("sha256").update(combinedOutput).digest("hex"), + }; +} + +function extractWarnings(output) { + return output + .split(/\r?\n/) + .filter((line) => + /warning|deoptim|exceed|large|chunk|worker|error/i.test(line), + ) + .slice(-80); +} + +function parseArgs(argv) { + const args = { + build: true, + watch: true, + checks: false, + guard: false, + outputDir: null, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + switch (arg) { + case "--skip-build": + args.build = false; + break; + case "--skip-watch": + args.watch = false; + break; + case "--include-checks": + args.checks = true; + break; + case "--guard": + args.guard = true; + break; + case "--output-dir": + args.outputDir = argv[index + 1] ?? null; + index += 1; + break; + case "--help": + printHelp(); + process.exit(0); + break; + default: + throw new Error(`Unknown argument: ${arg}`); + } + } + + return args; +} + +function printHelp() { + console.log(`Usage: node scripts/characterize-build-output.mjs [options] + +Options: + --skip-build Analyze the existing dist directory without running yarn build. + --skip-watch Skip Vite library watch rebuild profiling. + --include-checks Also time lint, typecheck, and unit tests. + --guard Fail if the emitted bundle exceeds regression thresholds. + --output-dir DIR Write markdown and JSON reports to DIR. +`); +} + +async function findRepoRoot() { + let current = PACKAGE_ROOT; + while (current !== path.dirname(current)) { + const packageJsonPath = path.join(current, "package.json"); + const packageJson = await readJsonIfExists(packageJsonPath); + if (packageJson?.name === "hash") { + return current; + } + current = path.dirname(current); + } + return PACKAGE_ROOT; +} + +async function getCommandVersion(command, args = ["--version"]) { + return new Promise((resolve) => { + const child = spawn(command, args, { + cwd: PACKAGE_ROOT, + stdio: ["ignore", "pipe", "ignore"], + }); + let output = ""; + child.stdout.on("data", (chunk) => { + output += chunk.toString(); + }); + child.on("error", () => resolve(null)); + child.on("exit", (code) => resolve(code === 0 ? output.trim() : null)); + }); +} + +async function getGitValue(args) { + return new Promise((resolve) => { + const child = spawn("git", args, { + cwd: PACKAGE_ROOT, + stdio: ["ignore", "pipe", "ignore"], + }); + let output = ""; + child.stdout.on("data", (chunk) => { + output += chunk.toString(); + }); + child.on("error", () => resolve(null)); + child.on("exit", (code) => resolve(code === 0 ? output.trim() : null)); + }); +} + +async function collectEnvironment() { + const packageJson = await readJsonIfExists( + path.join(PACKAGE_ROOT, "package.json"), + ); + + return { + timestamp: new Date().toISOString(), + packageName: packageJson?.name ?? null, + packageVersion: packageJson?.version ?? null, + gitCommit: await getGitValue(["rev-parse", "HEAD"]), + gitBranch: await getGitValue(["branch", "--show-current"]), + node: process.version, + yarn: await getCommandVersion("yarn"), + platform: `${platform()} ${release()}`, + cpuCount: cpus().length, + cpuModel: cpus()[0]?.model ?? null, + totalMemoryBytes: totalmem(), + freeMemoryBytes: freemem(), + }; +} + +async function touchFile(filePath) { + const now = new Date(); + await utimes(filePath, now, now); +} + +async function profileWatchRebuilds() { + const vite = await import("vite"); + const builds = []; + let activeBuild = null; + let buildEndResolver = null; + + const waitForNextBuild = () => + new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error("Timed out waiting for Vite watch rebuild")); + }, 180_000); + + buildEndResolver = (build) => { + clearTimeout(timeout); + resolve(build); + }; + }); + + const timingPlugin = { + name: "petrinaut-characterize-watch-timing", + buildStart() { + activeBuild = { + startedAt: performance.now(), + }; + }, + closeBundle() { + if (!activeBuild) { + return; + } + + const build = { + index: builds.length, + durationMs: performance.now() - activeBuild.startedAt, + }; + builds.push(build); + activeBuild = null; + buildEndResolver?.(build); + buildEndResolver = null; + }, + }; + + const watcher = await vite.build({ + configFile: path.join(PACKAGE_ROOT, "vite.config.ts"), + root: PACKAGE_ROOT, + logLevel: "warn", + plugins: [timingPlugin], + build: { + watch: {}, + }, + }); + + try { + const initialBuild = await waitForNextBuild(); + const rebuilds = []; + + for (const sample of DEFAULT_WATCH_SAMPLES) { + const absolutePath = path.join(PACKAGE_ROOT, sample.path); + const expectedBuildIndex = builds.length; + const nextBuild = waitForNextBuild(); + await touchFile(absolutePath); + const build = await nextBuild; + + rebuilds.push({ + ...sample, + buildIndex: expectedBuildIndex, + durationMs: build.durationMs, + }); + } + + return { + initialBuild, + rebuilds, + }; + } finally { + await watcher.close(); + } +} + +function formatMs(durationMs) { + return `${(durationMs / 1_000).toFixed(2)}s`; +} + +function markdownTable(headers, rows) { + const escapeCell = (value) => + String(value ?? "") + .replace(/\|/g, "\\|") + .replace(/\n/g, "
"); + + return [ + `| ${headers.map(escapeCell).join(" | ")} |`, + `| ${headers.map(() => "---").join(" | ")} |`, + ...rows.map((row) => `| ${row.map(escapeCell).join(" | ")} |`), + ].join("\n"); +} + +function renderReportMarkdown(report) { + const sections = [ + "# Petrinaut Build Characterization", + "", + `Generated: ${report.environment.timestamp}`, + "", + "## Environment", + "", + markdownTable( + ["Metric", "Value"], + [ + ["Package", report.environment.packageName], + ["Git branch", report.environment.gitBranch], + ["Git commit", report.environment.gitCommit], + ["Node", report.environment.node], + ["Yarn", report.environment.yarn], + ["Platform", report.environment.platform], + [ + "CPU", + `${report.environment.cpuCount} x ${report.environment.cpuModel}`, + ], + ["Total memory", formatBytes(report.environment.totalMemoryBytes)], + [ + "Free memory at start", + formatBytes(report.environment.freeMemoryBytes), + ], + ], + ), + "", + "## Timed Commands", + "", + markdownTable( + ["Command", "Duration", "Exit"], + report.commands.map((command) => [ + command.command, + formatMs(command.durationMs), + command.exitCode, + ]), + ), + "", + "## Watch Rebuilds", + "", + report.watch + ? markdownTable( + ["Sample", "Touched file", "Duration"], + [ + [ + "initial library watch build", + "", + formatMs(report.watch.initialBuild.durationMs), + ], + ...report.watch.rebuilds.map((rebuild) => [ + rebuild.label, + rebuild.path, + formatMs(rebuild.durationMs), + ]), + ], + ) + : "Watch rebuild profiling was skipped or failed.", + "", + "## Asset Groups", + "", + markdownTable( + ["Group", "Count", "Size", "Gzip"], + Object.entries(report.dist.assetGroups).map(([group, summary]) => [ + group, + summary.count, + formatBytes(summary.bytes), + formatBytes(summary.gzipBytes), + ]), + ), + "", + "## Largest Assets", + "", + markdownTable( + ["Asset", "Size", "Gzip"], + report.dist.largestAssets + .slice(0, 12) + .map((asset) => [ + asset.path, + formatBytes(asset.bytes), + formatBytes(asset.gzipBytes), + ]), + ), + "", + "## Worker Assets", + "", + report.dist.workerAssets.length > 0 + ? markdownTable( + ["Asset", "Size", "Gzip"], + report.dist.workerAssets.map((asset) => [ + asset.path, + formatBytes(asset.bytes), + formatBytes(asset.gzipBytes), + ]), + ) + : "No emitted worker assets were detected.", + "", + "## Main JS Imports", + "", + markdownTable( + ["Bare import"], + report.dist.mainJs.bareImports.map((specifier) => [specifier]), + ), + "", + "## Heavy Dependency Signals In Main JS", + "", + markdownTable( + ["Dependency", "Present"], + Object.entries(report.dist.mainJs.heavyDependencySignals).map( + ([dependency, present]) => [dependency, present ? "yes" : "no"], + ), + ), + "", + "## CSS", + "", + markdownTable( + ["Metric", "Value"], + [ + ["main.css size", formatBytes(report.dist.css.bytes)], + ["main.css gzip", formatBytes(report.dist.css.gzipBytes)], + ["@font-face rules", report.dist.css.fontFaceRules], + ], + ), + "", + "## Sourcemap Signals", + "", + markdownTable( + ["Metric", "Value"], + Object.entries(report.dist.sourceMapSignals).map(([key, value]) => [ + key, + value, + ]), + ), + "", + "## Source Hotspots", + "", + markdownTable( + ["Signal", "Count"], + Object.entries(report.sourceHotspots.totals).map(([key, value]) => [ + key, + value, + ]), + ), + "", + "## Warning Lines", + "", + report.commands.flatMap((command) => command.warnings).length > 0 + ? [ + "```txt", + ...report.commands.flatMap((command) => + command.warnings.map((warning) => `[${command.label}] ${warning}`), + ), + "```", + ].join("\n") + : "No warning-like lines were captured from timed commands.", + "", + ]; + + return sections.join("\n"); +} + +async function writeReports(report, outputDir) { + await mkdir(outputDir, { recursive: true }); + + const timestamp = report.environment.timestamp.replace(/[:.]/g, "-"); + const jsonPath = path.join(outputDir, "latest.json"); + const markdownPath = path.join(outputDir, "latest.md"); + const timestampedJsonPath = path.join(outputDir, `${timestamp}.json`); + const timestampedMarkdownPath = path.join(outputDir, `${timestamp}.md`); + const json = `${JSON.stringify(report, null, 2)}\n`; + const markdown = renderReportMarkdown(report); + + await writeFile(jsonPath, json); + await writeFile(markdownPath, markdown); + await writeFile(timestampedJsonPath, json); + await writeFile(timestampedMarkdownPath, markdown); + + return { + jsonPath, + markdownPath, + timestampedJsonPath, + timestampedMarkdownPath, + }; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const repoRoot = await findRepoRoot(); + const outputDir = + args.outputDir ?? + path.join(repoRoot, ".context", "petrinaut-characterization"); + const commands = []; + + if (args.build) { + commands.push(await runTimedCommand("build", "yarn", ["build"])); + } + + if (args.checks) { + commands.push( + await runTimedCommand("lint:eslint", "yarn", ["lint:eslint"]), + await runTimedCommand("lint:tsc", "yarn", ["lint:tsc"]), + await runTimedCommand("test:unit", "yarn", ["vitest", "run"]), + ); + } + + let watch = null; + let watchError = null; + const buildFailed = commands.some( + (command) => command.label === "build" && command.exitCode !== 0, + ); + if (args.watch && !buildFailed) { + try { + watch = await profileWatchRebuilds(); + } catch (error) { + watchError = error instanceof Error ? error.message : String(error); + } + } else if (args.watch && buildFailed) { + watchError = "Skipped because the production build command failed."; + } + + const report = { + environment: await collectEnvironment(), + commands, + watch, + watchError, + dist: await summarizeDistDirectory(DIST_DIR), + sourceHotspots: await summarizeSourceHotspots(), + }; + + const reportPaths = await writeReports(report, outputDir); + const guardFailures = args.guard ? evaluateBundleGuard(report) : []; + + console.log(""); + console.log(`Wrote ${path.relative(repoRoot, reportPaths.markdownPath)}`); + console.log(`Wrote ${path.relative(repoRoot, reportPaths.jsonPath)}`); + console.log( + `Wrote ${path.relative(repoRoot, reportPaths.timestampedMarkdownPath)}`, + ); + console.log( + `Wrote ${path.relative(repoRoot, reportPaths.timestampedJsonPath)}`, + ); + + const failedCommand = commands.find((command) => command.exitCode !== 0); + if (failedCommand) { + process.exitCode = failedCommand.exitCode; + } + + if (guardFailures.length > 0) { + console.error(""); + console.error("Bundle guard failed:"); + for (const failure of guardFailures) { + console.error(`- ${failure}`); + } + process.exitCode = 1; + } +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + if (!(await commandExists("yarn"))) { + throw new Error("Expected yarn to be available on PATH."); + } + await main(); +} diff --git a/libs/@hashintel/petrinaut/scripts/characterize-build-output.test.mjs b/libs/@hashintel/petrinaut/scripts/characterize-build-output.test.mjs new file mode 100644 index 00000000000..5b78ee321f6 --- /dev/null +++ b/libs/@hashintel/petrinaut/scripts/characterize-build-output.test.mjs @@ -0,0 +1,147 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { + evaluateBundleGuard, + formatBytes, + parseBareImports, + summarizeDistDirectory, +} from "./characterize-build-output.mjs"; + +describe("characterize-build-output", () => { + it("parses static bare imports from ESM output", () => { + const imports = parseBareImports(` + import React from "react"; + import "@hashintel/petrinaut/styles.css"; + import { css } from "@hashintel/ds-helpers/css"; + import("./lazy-local.js"); + import("./worker.js"); + export * from "use-sync-external-store/shim/with-selector"; + `); + + expect(imports).toEqual([ + "@hashintel/ds-helpers/css", + "@hashintel/petrinaut/styles.css", + "react", + "use-sync-external-store/shim/with-selector", + ]); + }); + + it("summarizes dist assets by type and known heavy dependency signals", async () => { + const tempDir = await mkdtemp(path.join(tmpdir(), "petrinaut-dist-")); + try { + await writeFile( + path.join(tempDir, "main.js"), + [ + 'import * as Babel from "@babel/standalone";', + 'import ELK from "elkjs";', + 'import { jsx } from "react/jsx-runtime";', + ].join("\n"), + ); + await writeFile( + path.join(tempDir, "main.css"), + "@font-face{font-family:Inter}.root{display:flex}", + ); + await writeFile( + path.join(tempDir, "simulation.worker-abc123.js"), + "self.postMessage('ready');", + ); + await writeFile( + path.join(tempDir, "main.js.map"), + JSON.stringify({ + version: 3, + sources: ["../../ds-helpers/styled-system/css/css.mjs"], + }), + ); + + const summary = await summarizeDistDirectory(tempDir); + + expect(summary.assetGroups.js.count).toBe(2); + expect(summary.assetGroups.css.count).toBe(1); + expect(summary.workerAssets).toHaveLength(1); + expect(summary.css.fontFaceRules).toBe(1); + expect(summary.mainJs.bareImports).toEqual([ + "@babel/standalone", + "elkjs", + "react/jsx-runtime", + ]); + expect(summary.mainJs.heavyDependencySignals).toMatchObject({ + "@babel/standalone": true, + elkjs: true, + }); + expect(summary.sourceMapSignals.dsHelpersSources).toBe(1); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it("reports missing dist entry assets as bundle guard failures", async () => { + const tempDir = await mkdtemp(path.join(tmpdir(), "petrinaut-dist-")); + try { + const summary = await summarizeDistDirectory(tempDir); + const failures = evaluateBundleGuard({ + dist: summary, + sourceHotspots: { totals: { inlineWorkerImports: 0 } }, + }); + + expect(failures).toEqual([ + "dist/main.js is missing", + "dist/main.css is missing", + ]); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it("formats byte counts consistently", () => { + expect(formatBytes(0)).toBe("0 B"); + expect(formatBytes(1_536)).toBe("1.5 KiB"); + }); + + it("reports bundle guard threshold failures", () => { + const failures = evaluateBundleGuard( + { + dist: { + mainJs: { + bytes: 12, + gzipBytes: 6, + heavyDependencySignals: { + "@babel/standalone": true, + elkjs: false, + }, + }, + css: { + bytes: 20, + gzipBytes: 10, + fontFaceRules: 1, + }, + }, + sourceHotspots: { + totals: { + inlineWorkerImports: 1, + }, + }, + }, + { + mainJsBytes: 10, + mainJsGzipBytes: 10, + cssBytes: 25, + cssGzipBytes: 8, + fontFaceRules: 0, + inlineWorkerImports: 0, + heavyDependenciesAbsentFromMain: ["@babel/standalone", "elkjs"], + }, + ); + + expect(failures).toEqual([ + "main.js is 12 B, expected <= 10 B", + "main.css gzip is 10 B, expected <= 8 B", + "main.css has 1 @font-face rules, expected <= 0", + "source has 1 inline worker imports, expected 0", + "@babel/standalone is present in main.js", + ]); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/constants/ui-subviews.ts b/libs/@hashintel/petrinaut/src/constants/ui-subviews.ts index 75a801e9eb1..ec9eda36c02 100644 --- a/libs/@hashintel/petrinaut/src/constants/ui-subviews.ts +++ b/libs/@hashintel/petrinaut/src/constants/ui-subviews.ts @@ -6,15 +6,53 @@ */ import type { SubView } from "../components/sub-view/types"; +import { createElement, lazy, Suspense } from "react"; import { diagnosticsSubView } from "../views/Editor/panels/BottomPanel/subviews/diagnostics"; import { simulationSettingsSubView } from "../views/Editor/panels/BottomPanel/subviews/simulation-settings"; -import { simulationTimelineSubView } from "../views/Editor/panels/BottomPanel/subviews/simulation-timeline"; import { differentialEquationsListSubView } from "../views/Editor/panels/LeftSideBar/subviews/differential-equations-list"; import { entitiesTreeSubView } from "../views/Editor/panels/LeftSideBar/subviews/entities-tree"; import { nodesListSubView } from "../views/Editor/panels/LeftSideBar/subviews/nodes-list"; import { parametersListSubView } from "../views/Editor/panels/LeftSideBar/subviews/parameters-list"; import { typesListSubView } from "../views/Editor/panels/LeftSideBar/subviews/types-list"; +const LazySimulationTimelineContent = lazy(() => + import("../views/Editor/panels/BottomPanel/subviews/simulation-timeline").then( + (module) => ({ default: module.SimulationTimelineContent }), + ), +); + +const LazyTimelineHeaderActions = lazy(() => + import("../views/Editor/panels/BottomPanel/subviews/simulation-timeline").then( + (module) => ({ default: module.TimelineHeaderActions }), + ), +); + +const SimulationTimelineContent: React.FC = () => ( + createElement( + Suspense, + { fallback: null }, + createElement(LazySimulationTimelineContent), + ) +); + +const TimelineHeaderActions: React.FC = () => ( + createElement( + Suspense, + { fallback: null }, + createElement(LazyTimelineHeaderActions), + ) +); + +export const simulationTimelineSubView: SubView = { + id: "simulation-timeline", + title: "Timeline", + tooltip: + "View the simulation timeline with compartment time-series. Click/drag to scrub through frames.", + component: SimulationTimelineContent, + renderHeaderAction: () => createElement(TimelineHeaderActions), + noPadding: true, +}; + export const LEFT_SIDEBAR_SUBVIEWS: SubView[] = [ nodesListSubView, typesListSubView, diff --git a/libs/@hashintel/petrinaut/src/fontsource.d.ts b/libs/@hashintel/petrinaut/src/fontsource.d.ts deleted file mode 100644 index 9bb324f5a1b..00000000000 --- a/libs/@hashintel/petrinaut/src/fontsource.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -// tsgo (unlike tsc) requires type declarations for side-effect-only imports. -// Fontsource packages ship CSS only, so we declare the module manually. -declare module "@fontsource-variable/jetbrains-mono"; -declare module "@fontsource-variable/inter"; -declare module "@fontsource-variable/inter-tight"; diff --git a/libs/@hashintel/petrinaut/src/lsp/worker/use-language-client.test.ts b/libs/@hashintel/petrinaut/src/lsp/worker/use-language-client.test.ts new file mode 100644 index 00000000000..fabe6105f23 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/lsp/worker/use-language-client.test.ts @@ -0,0 +1,182 @@ +/** + * @vitest-environment jsdom + */ +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { SDCPN } from "../../core/types/sdcpn"; +import type { ClientMessage, ServerMessage } from "./protocol"; +import { useLanguageClient } from "./use-language-client"; + +class MockWorker { + private messageListeners: ((event: MessageEvent) => void)[] = + []; + + postedMessages: ClientMessage[] = []; + + addEventListener(type: string, listener: (event: never) => void): void { + if (type === "message") { + this.messageListeners.push( + listener as (event: MessageEvent) => void, + ); + } + } + + postMessage(message: ClientMessage): void { + this.postedMessages.push(message); + } + + terminate(): void { + // No-op + } + + simulateMessage(message: ServerMessage): void { + const event = { data: message } as MessageEvent; + for (const listener of this.messageListeners) { + listener(event); + } + } + + getMessages(method: ClientMessage["method"]): ClientMessage[] { + return this.postedMessages.filter((message) => message.method === method); + } +} + +const mocks = vi.hoisted(() => ({ + worker: null as MockWorker | null, +})); + +vi.mock("./language-server.worker.ts?worker", () => ({ + default: class LanguageServerWorker extends MockWorker { + constructor() { + super(); + mocks.worker = this; + } + }, +})); + +async function flushMicrotasks() { + await act(async () => {}); +} + +const EMPTY_SDCPN: SDCPN = { + places: [], + transitions: [], + types: [], + differentialEquations: [], + parameters: [], +}; + +describe("useLanguageClient", () => { + beforeEach(() => { + mocks.worker = null; + }); + + it("does not create the language worker on mount or structural initialization", async () => { + const { result } = renderHook(() => useLanguageClient()); + await flushMicrotasks(); + + act(() => { + result.current.initialize(EMPTY_SDCPN); + }); + await flushMicrotasks(); + + expect(mocks.worker).toBeNull(); + }); + + it("creates the worker for document diagnostics and drains queued structural messages first", async () => { + const diagnostics = vi.fn(); + const { result } = renderHook(() => useLanguageClient()); + + act(() => { + result.current.onDiagnostics(diagnostics); + result.current.initialize(EMPTY_SDCPN); + result.current.notifyDocumentChanged( + "file:///predicate.ts", + "return true;", + ); + }); + await flushMicrotasks(); + + expect(mocks.worker).not.toBeNull(); + expect( + mocks.worker?.postedMessages.map((message) => message.method), + ).toEqual(["initialize", "textDocument/didChange"]); + + act(() => { + mocks.worker?.simulateMessage({ + jsonrpc: "2.0", + method: "textDocument/publishDiagnostics", + params: [{ uri: "file:///predicate.ts", diagnostics: [] }], + }); + }); + + expect(diagnostics).toHaveBeenCalledWith([ + { uri: "file:///predicate.ts", diagnostics: [] }, + ]); + }); + + it("creates the worker for language feature requests and resolves responses", async () => { + const { result } = renderHook(() => useLanguageClient()); + + act(() => { + result.current.initialize(EMPTY_SDCPN); + }); + + const completionPromise = result.current.requestCompletion( + "file:///predicate.ts", + { line: 0, character: 1 }, + ); + await flushMicrotasks(); + + expect( + mocks.worker?.postedMessages.map((message) => message.method), + ).toEqual(["initialize", "textDocument/completion"]); + + act(() => { + mocks.worker?.simulateMessage({ + jsonrpc: "2.0", + id: 0, + result: { isIncomplete: false, items: [] }, + }); + }); + + await expect(completionPromise).resolves.toEqual({ + isIncomplete: false, + items: [], + }); + }); + + it("creates the worker for language feature requests after a StrictMode remount", async () => { + const { result } = renderHook(() => useLanguageClient(), { + reactStrictMode: true, + }); + + act(() => { + result.current.initialize(EMPTY_SDCPN); + }); + + const completionPromise = result.current.requestCompletion( + "file:///predicate.ts", + { line: 0, character: 1 }, + ); + await flushMicrotasks(); + + expect( + mocks.worker?.postedMessages.map((message) => message.method), + ).toEqual(["initialize", "textDocument/completion"]); + + act(() => { + mocks.worker?.simulateMessage({ + jsonrpc: "2.0", + id: 0, + result: { isIncomplete: false, items: [] }, + }); + }); + + await expect(completionPromise).resolves.toEqual({ + isIncomplete: false, + items: [], + }); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/lsp/worker/use-language-client.ts b/libs/@hashintel/petrinaut/src/lsp/worker/use-language-client.ts index a25dccbb8a8..079eb735899 100644 --- a/libs/@hashintel/petrinaut/src/lsp/worker/use-language-client.ts +++ b/libs/@hashintel/petrinaut/src/lsp/worker/use-language-client.ts @@ -14,10 +14,10 @@ import type { SignatureHelp, } from "./protocol"; -/** Dynamically import and instantiate the language server worker (inlined as blob URL). */ +/** Dynamically import and instantiate the language server worker as an emitted asset. */ async function createLanguageServerWorker() { const LanguageServerWorker = await import( - "./language-server.worker.ts?worker&inline" + "./language-server.worker.ts?worker" ); // eslint-disable-next-line new-cap return new LanguageServerWorker.default(); @@ -67,11 +67,13 @@ export type LanguageClientApi = { }; /** - * Spawn the language server WebWorker and return an LSP-inspired API to interact with it. - * The worker is created on mount and terminated on unmount. + * Return an LSP-inspired API to interact with the language server WebWorker. + * The worker is created lazily when diagnostics or language features are requested. */ export function useLanguageClient(): LanguageClientApi { + const isMountedRef = useRef(true); const workerRef = useRef(null); + const workerPromiseRef = useRef | null>(null); const pendingRef = useRef(new Map()); const nextId = useRef(0); const queueRef = useRef([]); @@ -80,39 +82,63 @@ export function useLanguageClient(): LanguageClientApi { >(null); useEffect(() => { - let terminated = false; + isMountedRef.current = true; + const pending = pendingRef.current; - void createLanguageServerWorker().then((worker) => { - if (terminated) { - worker.terminate(); - return; + return () => { + isMountedRef.current = false; + workerRef.current?.terminate(); + workerRef.current = null; + for (const entry of pending.values()) { + entry.reject(new Error("Worker terminated")); } + pending.clear(); + }; + }, []); - worker.addEventListener( - "message", - (event: MessageEvent) => { - const msg = event.data; + const attachWorkerListeners = (worker: Worker) => { + worker.addEventListener("message", (event: MessageEvent) => { + const msg = event.data; + + if ("id" in msg) { + // Response to a request + const pending = pendingRef.current.get(msg.id); + if (!pending) { + return; + } + pendingRef.current.delete(msg.id); + + if ("error" in msg) { + pending.reject(new Error(msg.error.message)); + } else { + pending.resolve(msg.result as never); + } + } else if ("method" in msg) { + // Server-pushed notification + diagnosticsCallbackRef.current?.(msg.params); + } + }); + }; - if ("id" in msg) { - // Response to a request - const pending = pendingRef.current.get(msg.id); - if (!pending) { - return; - } - pendingRef.current.delete(msg.id); + const rejectPendingRequests = (error: Error) => { + for (const entry of pendingRef.current.values()) { + entry.reject(error); + } + pendingRef.current.clear(); + }; - if ("error" in msg) { - pending.reject(new Error(msg.error.message)); - } else { - pending.resolve(msg.result as never); - } - } else if ("method" in msg) { - // Server-pushed notification - diagnosticsCallbackRef.current?.(msg.params); - } - }, - ); + const ensureWorker = useCallback(async () => { + if (workerRef.current) { + return workerRef.current; + } + workerPromiseRef.current ??= createLanguageServerWorker().then((worker) => { + if (!isMountedRef.current) { + worker.terminate(); + throw new Error("Worker terminated"); + } + + attachWorkerListeners(worker); workerRef.current = worker; // Drain any messages queued before the worker was ready @@ -120,31 +146,37 @@ export function useLanguageClient(): LanguageClientApi { worker.postMessage(message); } queueRef.current = []; - }); - const pending = pendingRef.current; + return worker; + }); - return () => { - terminated = true; - workerRef.current?.terminate(); - workerRef.current = null; - for (const entry of pending.values()) { - entry.reject(new Error("Worker terminated")); - } - pending.clear(); - }; + return workerPromiseRef.current; }, []); // --- Notifications (fire-and-forget) --- - const sendNotification = useCallback((message: Omit) => { - const worker = workerRef.current; - if (worker) { - worker.postMessage(message); - } else { + const sendNotification = useCallback( + (message: Omit, options?: { activate: boolean }) => { + const worker = workerRef.current; + if (worker) { + worker.postMessage(message); + return; + } + queueRef.current.push(message); - } - }, []); + + if (options?.activate) { + void ensureWorker().catch((error: unknown) => { + rejectPendingRequests( + error instanceof Error + ? error + : new Error("Failed to create language worker"), + ); + }); + } + }, + [ensureWorker], + ); const initialize = useCallback( (sdcpn: SDCPN) => { @@ -170,33 +202,42 @@ export function useLanguageClient(): LanguageClientApi { const notifyDocumentChanged = useCallback( (uri: DocumentUri, text: string) => { - sendNotification({ - jsonrpc: "2.0", - method: "textDocument/didChange", - params: { textDocument: { uri }, text }, - }); + sendNotification( + { + jsonrpc: "2.0", + method: "textDocument/didChange", + params: { textDocument: { uri }, text }, + }, + { activate: true }, + ); }, [sendNotification], ); const initializeScenarioSession = useCallback( (params: ScenarioSessionParams) => { - sendNotification({ - jsonrpc: "2.0", - method: "temp/scenario/initialize", - params, - }); + sendNotification( + { + jsonrpc: "2.0", + method: "temp/scenario/initialize", + params, + }, + { activate: true }, + ); }, [sendNotification], ); const updateScenarioSession = useCallback( (params: ScenarioSessionParams) => { - sendNotification({ - jsonrpc: "2.0", - method: "temp/scenario/didChange", - params, - }); + sendNotification( + { + jsonrpc: "2.0", + method: "temp/scenario/didChange", + params, + }, + { activate: true }, + ); }, [sendNotification], ); @@ -214,22 +255,28 @@ export function useLanguageClient(): LanguageClientApi { const initializeMetricSession = useCallback( (params: MetricSessionParams) => { - sendNotification({ - jsonrpc: "2.0", - method: "temp/metric/initialize", - params, - }); + sendNotification( + { + jsonrpc: "2.0", + method: "temp/metric/initialize", + params, + }, + { activate: true }, + ); }, [sendNotification], ); const updateMetricSession = useCallback( (params: MetricSessionParams) => { - sendNotification({ - jsonrpc: "2.0", - method: "temp/metric/didChange", - params, - }); + sendNotification( + { + jsonrpc: "2.0", + method: "temp/metric/didChange", + params, + }, + { activate: true }, + ); }, [sendNotification], ); @@ -247,20 +294,36 @@ export function useLanguageClient(): LanguageClientApi { // --- Requests (return Promise) --- - const sendRequest = useCallback((message: ClientMessage): Promise => { - return new Promise((resolve, reject) => { - pendingRef.current.set((message as { id: number }).id, { - resolve: resolve as (result: never) => void, - reject, + const sendRequest = useCallback( + (message: ClientMessage): Promise => { + return new Promise((resolve, reject) => { + pendingRef.current.set((message as { id: number }).id, { + resolve: resolve as (result: never) => void, + reject, + }); + const worker = workerRef.current; + if (worker) { + worker.postMessage(message); + } else { + queueRef.current.push(message); + void ensureWorker().catch((error: unknown) => { + const pending = pendingRef.current.get( + (message as { id: number }).id, + ); + if (pending) { + pending.reject( + error instanceof Error + ? error + : new Error("Failed to create language worker"), + ); + pendingRef.current.delete((message as { id: number }).id); + } + }); + } }); - const worker = workerRef.current; - if (worker) { - worker.postMessage(message); - } else { - queueRef.current.push(message); - } - }); - }, []); + }, + [ensureWorker], + ); const requestCompletion = useCallback( (uri: DocumentUri, position: Position): Promise => { diff --git a/libs/@hashintel/petrinaut/src/monaco/code-editor.tsx b/libs/@hashintel/petrinaut/src/monaco/code-editor.tsx index 8ea235542cd..79b9470a90c 100644 --- a/libs/@hashintel/petrinaut/src/monaco/code-editor.tsx +++ b/libs/@hashintel/petrinaut/src/monaco/code-editor.tsx @@ -138,7 +138,7 @@ const CodeEditorInner: React.FC = ({ onChange, ...props }) => { - const { Editor } = use(use(MonacoContext)); + const { Editor } = use(use(MonacoContext).getMonaco()); const editorRef = useRef(null); const handleMount = ( diff --git a/libs/@hashintel/petrinaut/src/monaco/completion-sync.tsx b/libs/@hashintel/petrinaut/src/monaco/completion-sync.tsx index a520751db3a..928da594bd9 100644 --- a/libs/@hashintel/petrinaut/src/monaco/completion-sync.tsx +++ b/libs/@hashintel/petrinaut/src/monaco/completion-sync.tsx @@ -60,7 +60,11 @@ function toMonacoCompletion( } const CompletionSyncInner = () => { - const { monaco } = use(use(MonacoContext)); + const { monacoPromise } = use(MonacoContext); + if (!monacoPromise) { + throw new Error("Monaco is not initialized"); + } + const { monaco } = use(monacoPromise); const { notifyDocumentChanged, requestCompletion } = use( LanguageClientContext, ); @@ -111,8 +115,15 @@ const CompletionSyncInner = () => { }; /** Renders nothing visible — registers a Monaco CompletionItemProvider backed by the language server. */ -export const CompletionSync: React.FC = () => ( - - - -); +export const CompletionSync: React.FC = () => { + const { monacoPromise } = use(MonacoContext); + if (!monacoPromise) { + return null; + } + + return ( + + + + ); +}; diff --git a/libs/@hashintel/petrinaut/src/monaco/context.ts b/libs/@hashintel/petrinaut/src/monaco/context.ts index ab48ffe022c..ac56f71871a 100644 --- a/libs/@hashintel/petrinaut/src/monaco/context.ts +++ b/libs/@hashintel/petrinaut/src/monaco/context.ts @@ -7,6 +7,9 @@ export type MonacoContextValue = { Editor: React.FC; }; -export const MonacoContext = createContext>( - null as never, -); +export type MonacoContextHandle = { + monacoPromise: Promise | null; + getMonaco: () => Promise; +}; + +export const MonacoContext = createContext(null as never); diff --git a/libs/@hashintel/petrinaut/src/monaco/diagnostics-sync.tsx b/libs/@hashintel/petrinaut/src/monaco/diagnostics-sync.tsx index c1dddfbbd22..6c000bdacb9 100644 --- a/libs/@hashintel/petrinaut/src/monaco/diagnostics-sync.tsx +++ b/libs/@hashintel/petrinaut/src/monaco/diagnostics-sync.tsx @@ -47,7 +47,11 @@ function diagnosticsToMarkers( } const DiagnosticsSyncInner = () => { - const { monaco } = use(use(MonacoContext)); + const { monacoPromise } = use(MonacoContext); + if (!monacoPromise) { + throw new Error("Monaco is not initialized"); + } + const { monaco } = use(monacoPromise); const { diagnosticsByUri } = use(LanguageClientContext); const prevUrisRef = useRef>(new Set()); @@ -94,8 +98,15 @@ const DiagnosticsSyncInner = () => { }; /** Renders nothing visible — syncs diagnostics from LanguageClientContext to Monaco model markers. */ -export const DiagnosticsSync: React.FC = () => ( - - - -); +export const DiagnosticsSync: React.FC = () => { + const { monacoPromise } = use(MonacoContext); + if (!monacoPromise) { + return null; + } + + return ( + + + + ); +}; diff --git a/libs/@hashintel/petrinaut/src/monaco/hover-sync.tsx b/libs/@hashintel/petrinaut/src/monaco/hover-sync.tsx index aff2e3077bc..ae49c8e213e 100644 --- a/libs/@hashintel/petrinaut/src/monaco/hover-sync.tsx +++ b/libs/@hashintel/petrinaut/src/monaco/hover-sync.tsx @@ -48,7 +48,11 @@ function hoverContentsToMarkdown(hover: Hover): Monaco.IMarkdownString[] { } const HoverSyncInner = () => { - const { monaco } = use(use(MonacoContext)); + const { monacoPromise } = use(MonacoContext); + if (!monacoPromise) { + throw new Error("Monaco is not initialized"); + } + const { monaco } = use(monacoPromise); const { notifyDocumentChanged, requestHover } = use(LanguageClientContext); useEffect(() => { @@ -91,8 +95,15 @@ const HoverSyncInner = () => { }; /** Renders nothing visible — registers a Monaco HoverProvider backed by the language server. */ -export const HoverSync: React.FC = () => ( - - - -); +export const HoverSync: React.FC = () => { + const { monacoPromise } = use(MonacoContext); + if (!monacoPromise) { + return null; + } + + return ( + + + + ); +}; diff --git a/libs/@hashintel/petrinaut/src/monaco/provider.tsx b/libs/@hashintel/petrinaut/src/monaco/provider.tsx index 02fd50f6480..cfcdf199e8a 100644 --- a/libs/@hashintel/petrinaut/src/monaco/provider.tsx +++ b/libs/@hashintel/petrinaut/src/monaco/provider.tsx @@ -1,5 +1,7 @@ +import { useCallback, useMemo, useState } from "react"; + import { CompletionSync } from "./completion-sync"; -import type { MonacoContextValue } from "./context"; +import type { MonacoContextHandle, MonacoContextValue } from "./context"; import { MonacoContext } from "./context"; import { DiagnosticsSync } from "./diagnostics-sync"; import { HoverSync } from "./hover-sync"; @@ -61,10 +63,24 @@ function getMonacoPromise(): Promise { export const MonacoProvider: React.FC<{ children: React.ReactNode }> = ({ children, }) => { - const promise = getMonacoPromise(); + const [activeMonacoPromise, setActiveMonacoPromise] = + useState | null>(null); + + const getMonaco = useCallback(() => { + const promise = getMonacoPromise(); + queueMicrotask(() => { + setActiveMonacoPromise(promise); + }); + return promise; + }, []); + + const contextValue: MonacoContextHandle = useMemo( + () => ({ monacoPromise: activeMonacoPromise, getMonaco }), + [activeMonacoPromise, getMonaco], + ); return ( - + diff --git a/libs/@hashintel/petrinaut/src/monaco/signature-help-sync.tsx b/libs/@hashintel/petrinaut/src/monaco/signature-help-sync.tsx index f7e1e292593..54aa117cdc7 100644 --- a/libs/@hashintel/petrinaut/src/monaco/signature-help-sync.tsx +++ b/libs/@hashintel/petrinaut/src/monaco/signature-help-sync.tsx @@ -43,7 +43,11 @@ function toMonacoSignatureHelp( } const SignatureHelpSyncInner = () => { - const { monaco } = use(use(MonacoContext)); + const { monacoPromise } = use(MonacoContext); + if (!monacoPromise) { + throw new Error("Monaco is not initialized"); + } + const { monaco } = use(monacoPromise); const { notifyDocumentChanged, requestSignatureHelp } = use( LanguageClientContext, ); @@ -85,8 +89,15 @@ const SignatureHelpSyncInner = () => { }; /** Renders nothing visible — registers a Monaco SignatureHelpProvider backed by the language server. */ -export const SignatureHelpSync: React.FC = () => ( - - - -); +export const SignatureHelpSync: React.FC = () => { + const { monacoPromise } = use(MonacoContext); + if (!monacoPromise) { + return null; + } + + return ( + + + + ); +}; diff --git a/libs/@hashintel/petrinaut/src/petrinaut.tsx b/libs/@hashintel/petrinaut/src/petrinaut.tsx index f5343cece23..0c326c8e41d 100644 --- a/libs/@hashintel/petrinaut/src/petrinaut.tsx +++ b/libs/@hashintel/petrinaut/src/petrinaut.tsx @@ -1,6 +1,3 @@ -import "@fontsource-variable/inter"; -import "@fontsource-variable/inter-tight"; -import "@fontsource-variable/jetbrains-mono"; import "@xyflow/react/dist/style.css"; import "./index.css"; diff --git a/libs/@hashintel/petrinaut/src/provider-lifecycle.test.tsx b/libs/@hashintel/petrinaut/src/provider-lifecycle.test.tsx new file mode 100644 index 00000000000..4cd91a41b11 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/provider-lifecycle.test.tsx @@ -0,0 +1,479 @@ +/** + * @vitest-environment jsdom + */ +import { act, render, renderHook } from "@testing-library/react"; +import { use } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { SDCPN } from "./core/types/sdcpn"; +import { LanguageClientContext } from "./lsp/context"; +import { LanguageClientProvider } from "./lsp/provider"; +import type { + CompletionList, + PublishDiagnosticsParams, +} from "./lsp/worker/protocol"; +import type { LanguageClientApi } from "./lsp/worker/use-language-client"; +import { MonacoContext, type MonacoContextHandle } from "./monaco/context"; +import { MonacoProvider } from "./monaco/provider"; +import { NotificationsContext } from "./notifications/notifications-context"; +import { SimulationContext } from "./simulation/context"; +import { SimulationProvider } from "./simulation/provider"; +import type { + WorkerActions, + WorkerState, +} from "./simulation/worker/use-simulation-worker"; +import { SDCPNContext, type SDCPNContextValue } from "./state/sdcpn-context"; + +const mocks = vi.hoisted(() => ({ + languageClient: null as LanguageClientApi | null, + monacoLoaderConfig: vi.fn(), + notify: vi.fn(), + simulationActions: null as WorkerActions | null, + simulationState: null as WorkerState | null, +})); + +vi.mock("./lsp/worker/use-language-client", () => ({ + useLanguageClient: () => { + if (!mocks.languageClient) { + throw new Error("language client mock was not initialized"); + } + return mocks.languageClient; + }, +})); + +vi.mock("./simulation/worker/use-simulation-worker", () => ({ + useSimulationWorker: () => { + if (!mocks.simulationActions || !mocks.simulationState) { + throw new Error("simulation worker mock was not initialized"); + } + return { state: mocks.simulationState, actions: mocks.simulationActions }; + }, +})); + +vi.mock("@monaco-editor/react", () => ({ + default: vi.fn(() => null), + loader: { + config: mocks.monacoLoaderConfig, + }, +})); + +vi.mock("monaco-editor/esm/vs/editor/editor.api.js", () => ({ + editor: {}, +})); + +vi.mock( + "monaco-editor/esm/vs/basic-languages/typescript/typescript.contribution.js", + () => ({}), +); + +vi.mock( + "monaco-editor/esm/vs/editor/contrib/hover/browser/hoverContribution.js", + () => ({}), +); + +vi.mock( + "monaco-editor/esm/vs/editor/contrib/suggest/browser/suggestController.js", + () => ({}), +); + +vi.mock( + "monaco-editor/esm/vs/editor/contrib/parameterHints/browser/parameterHints.js", + () => ({}), +); + +vi.mock( + "monaco-editor/esm/vs/editor/contrib/folding/browser/folding.js", + () => ({ + default: {}, + }), +); + +vi.mock("./monaco/diagnostics-sync", () => ({ + DiagnosticsSync: () => null, +})); + +vi.mock("./monaco/completion-sync", () => ({ + CompletionSync: () => null, +})); + +vi.mock("./monaco/hover-sync", () => ({ + HoverSync: () => null, +})); + +vi.mock("./monaco/signature-help-sync", () => ({ + SignatureHelpSync: () => null, +})); + +const EMPTY_SDCPN: SDCPN = { + places: [], + transitions: [], + types: [], + differentialEquations: [], + parameters: [], +}; + +function createSdcpnContext( + petriNetDefinition: SDCPN = EMPTY_SDCPN, + petriNetId = "test-net", +): SDCPNContextValue { + return { + createNewNet: () => {}, + existingNets: [], + loadPetriNet: () => {}, + petriNetId, + petriNetDefinition, + readonly: false, + setTitle: () => {}, + title: "Test net", + getItemType: () => null, + }; +} + +function createLanguageClientMock(): LanguageClientApi & { + emitDiagnostics: (params: PublishDiagnosticsParams[]) => void; +} { + let diagnosticsCallback: + | ((params: PublishDiagnosticsParams[]) => void) + | null = null; + + return { + initialize: vi.fn(), + notifySDCPNChanged: vi.fn(), + notifyDocumentChanged: vi.fn(), + requestCompletion: vi.fn(() => + Promise.resolve({ + isIncomplete: false, + items: [], + } satisfies CompletionList), + ), + requestHover: vi.fn(() => Promise.resolve(null)), + requestSignatureHelp: vi.fn(() => Promise.resolve(null)), + initializeScenarioSession: vi.fn(), + updateScenarioSession: vi.fn(), + killScenarioSession: vi.fn(), + initializeMetricSession: vi.fn(), + updateMetricSession: vi.fn(), + killMetricSession: vi.fn(), + onDiagnostics: vi.fn( + (callback: (params: PublishDiagnosticsParams[]) => void) => { + diagnosticsCallback = callback; + }, + ), + emitDiagnostics(params) { + diagnosticsCallback?.(params); + }, + }; +} + +function createSimulationActionsMock(): WorkerActions { + return { + initialize: vi.fn(() => Promise.resolve()), + start: vi.fn(), + pause: vi.fn(), + stop: vi.fn(), + setBackpressure: vi.fn(), + ack: vi.fn(), + reset: vi.fn(), + }; +} + +function setSimulationState(partial: Partial = {}) { + mocks.simulationState = { + status: "idle", + frames: [], + error: null, + errorItemId: null, + ...partial, + }; +} + +function useLanguageClientContext() { + return use(LanguageClientContext); +} + +function useSimulationContext() { + return use(SimulationContext); +} + +function useMonacoContext() { + return use(MonacoContext); +} + +const MonacoContextProbe: React.FC<{ + onValue: (value: MonacoContextHandle) => void; +}> = ({ onValue }) => { + onValue(useMonacoContext()); + return null; +}; + +const LanguageClientContextProbe: React.FC = () => { + useLanguageClientContext(); + return null; +}; + +const LanguageProviderHarness: React.FC<{ + petriNetDefinition: SDCPN; +}> = ({ petriNetDefinition }) => ( + + + + + +); + +const SimulationContextProbe: React.FC<{ + onValue: (value: ReturnType) => void; +}> = ({ onValue }) => { + onValue(useSimulationContext()); + return null; +}; + +const SimulationProviderHarness: React.FC<{ + sdcpnContext: SDCPNContextValue; + onValue: (value: ReturnType) => void; +}> = ({ sdcpnContext, onValue }) => ( + + + + + + + +); + +beforeEach(() => { + mocks.languageClient = createLanguageClientMock(); + mocks.simulationActions = createSimulationActionsMock(); + setSimulationState(); + mocks.monacoLoaderConfig.mockClear(); + mocks.notify.mockClear(); +}); + +describe("provider lifecycle characterization", () => { + describe("LanguageClientProvider", () => { + it("initializes once on mount and sends structural updates after SDCPN changes", () => { + const firstSdcpn = EMPTY_SDCPN; + const secondSdcpn: SDCPN = { + ...EMPTY_SDCPN, + parameters: [ + { + id: "parameter-1", + name: "Rate", + variableName: "rate", + type: "real", + defaultValue: "1", + }, + ], + }; + + const { rerender } = render( + , + ); + + expect(mocks.languageClient?.initialize).toHaveBeenCalledExactlyOnceWith( + firstSdcpn, + ); + expect(mocks.languageClient?.notifySDCPNChanged).not.toHaveBeenCalled(); + + rerender(); + + expect(mocks.languageClient?.initialize).toHaveBeenCalledOnce(); + expect(mocks.languageClient?.notifySDCPNChanged).toHaveBeenCalledWith( + secondSdcpn, + ); + }); + + it("publishes diagnostics and delegates document/language feature actions", async () => { + const { result } = renderHook(useLanguageClientContext, { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + act(() => { + ( + mocks.languageClient as ReturnType + ).emitDiagnostics([ + { + uri: "file:///has-diagnostic.ts", + diagnostics: [ + { + message: "Bad predicate", + severity: 1, + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 3 }, + }, + }, + ], + }, + { uri: "file:///empty.ts", diagnostics: [] }, + ]); + }); + + expect(result.current.totalDiagnosticsCount).toBe(1); + expect(result.current.diagnosticsByUri.has("file:///empty.ts")).toBe( + false, + ); + expect( + result.current.diagnosticsByUri.get("file:///has-diagnostic.ts"), + ).toHaveLength(1); + + result.current.notifyDocumentChanged("file:///lambda.ts", "return true;"); + await result.current.requestCompletion("file:///lambda.ts", { + line: 0, + character: 6, + }); + result.current.initializeScenarioSession({ + sessionId: "scenario-1", + scenarioParameters: [], + parameterOverrides: {}, + initialState: {}, + initialStateAsCode: false, + }); + + expect(mocks.languageClient?.notifyDocumentChanged).toHaveBeenCalledWith( + "file:///lambda.ts", + "return true;", + ); + expect(mocks.languageClient?.requestCompletion).toHaveBeenCalledWith( + "file:///lambda.ts", + { line: 0, character: 6 }, + ); + expect( + mocks.languageClient?.initializeScenarioSession, + ).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: "scenario-1" }), + ); + }); + }); + + describe("SimulationProvider", () => { + it("maps worker lifecycle state and delegates controls through context", async () => { + const frame = { + time: 0, + places: {}, + transitions: {}, + buffer: new Float64Array(), + }; + setSimulationState({ status: "ready", frames: [frame] }); + + const { result } = renderHook(useSimulationContext, { + wrapper: ({ children }) => ( + + + {children} + + + ), + }); + + expect(result.current.state).toBe("Paused"); + expect(result.current.totalFrames).toBe(1); + await expect(result.current.getFrame(0)).resolves.toBe(frame); + + await act(async () => { + await result.current.initialize({ + seed: 7, + dt: 0.05, + maxFramesAhead: 12, + batchSize: 3, + }); + }); + result.current.run(); + result.current.pause(); + result.current.setBackpressure({ maxFramesAhead: 4, batchSize: 2 }); + result.current.ack(5); + + expect(mocks.simulationActions?.initialize).toHaveBeenCalledWith( + expect.objectContaining({ + sdcpn: EMPTY_SDCPN, + seed: 7, + dt: 0.05, + maxTime: null, + maxFramesAhead: 12, + batchSize: 3, + }), + ); + expect(mocks.simulationActions?.start).toHaveBeenCalledOnce(); + expect(mocks.simulationActions?.pause).toHaveBeenCalledOnce(); + expect(mocks.simulationActions?.setBackpressure).toHaveBeenCalledWith({ + maxFramesAhead: 4, + batchSize: 2, + }); + expect(mocks.simulationActions?.ack).toHaveBeenCalledWith(5); + }); + + it("resets worker and editable configuration when the loaded net changes", () => { + const firstContext = createSdcpnContext(EMPTY_SDCPN, "net-1"); + const secondContext = createSdcpnContext(EMPTY_SDCPN, "net-2"); + + let simulationContext = null as ReturnType< + typeof useSimulationContext + > | null; + const { rerender } = render( + { + simulationContext = value; + }} + />, + ); + + act(() => { + simulationContext?.setParameterValue("parameter-1", "42"); + simulationContext?.setDt(0.2); + }); + + expect(simulationContext?.parameterValues).toEqual({ + "parameter-1": "42", + }); + expect(simulationContext?.dt).toBe(0.2); + + rerender( + { + simulationContext = value; + }} + />, + ); + + expect(mocks.simulationActions?.reset).toHaveBeenCalledTimes(2); + expect(simulationContext?.parameterValues).toEqual({}); + expect(simulationContext?.dt).toBe(0.01); + }); + }); + + describe("MonacoProvider", () => { + it("provides Monaco initialization without starting it on mount", async () => { + const providedHandles: MonacoContextHandle[] = []; + + render( + + { + providedHandles.push(value); + }} + /> + , + ); + + const providedHandle = providedHandles[0]; + + expect(providedHandle?.monacoPromise).toBeNull(); + expect(mocks.monacoLoaderConfig).not.toHaveBeenCalled(); + if (!providedHandle) { + throw new Error("MonacoProvider did not provide a handle"); + } + + const monacoContextValue = await providedHandle.getMonaco(); + + expect(monacoContextValue.monaco).toBeDefined(); + expect(typeof monacoContextValue.Editor).toBe("function"); + expect(mocks.monacoLoaderConfig).toHaveBeenCalledOnce(); + }); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/simulation/simulator/compile-visualizer.ts b/libs/@hashintel/petrinaut/src/simulation/simulator/compile-visualizer.ts index d761d6b83cb..73a1e58446a 100644 --- a/libs/@hashintel/petrinaut/src/simulation/simulator/compile-visualizer.ts +++ b/libs/@hashintel/petrinaut/src/simulation/simulator/compile-visualizer.ts @@ -1,12 +1,12 @@ import * as Babel from "@babel/standalone"; import { createElement, type ReactElement } from "react"; -type VisualizerProps = { +export type VisualizerProps = { tokens: Record[]; parameters: Record; }; -type VisualizerComponent = (props: VisualizerProps) => ReactElement; +export type VisualizerComponent = (props: VisualizerProps) => ReactElement; /** * Compiles TypeScript/JSX visualizer code into a React component. diff --git a/libs/@hashintel/petrinaut/src/simulation/worker/create-simulation-worker.ts b/libs/@hashintel/petrinaut/src/simulation/worker/create-simulation-worker.ts index df03ce84a0b..1745538af77 100644 --- a/libs/@hashintel/petrinaut/src/simulation/worker/create-simulation-worker.ts +++ b/libs/@hashintel/petrinaut/src/simulation/worker/create-simulation-worker.ts @@ -1,6 +1,6 @@ -/** Dynamically import and instantiate the simulation worker (inlined as blob URL). */ +/** Dynamically import and instantiate the simulation worker as an emitted asset. */ export async function createSimulationWorker(): Promise { - const SimulationWorker = await import("./simulation.worker.ts?worker&inline"); + const SimulationWorker = await import("./simulation.worker.ts?worker"); // eslint-disable-next-line new-cap return new SimulationWorker.default(); } diff --git a/libs/@hashintel/petrinaut/src/simulation/worker/use-simulation-worker.test.ts b/libs/@hashintel/petrinaut/src/simulation/worker/use-simulation-worker.test.ts index 4eeec567166..7e25b62f90f 100644 --- a/libs/@hashintel/petrinaut/src/simulation/worker/use-simulation-worker.test.ts +++ b/libs/@hashintel/petrinaut/src/simulation/worker/use-simulation-worker.test.ts @@ -74,8 +74,8 @@ class MockWorker { // Store the mock worker instance for access in tests let mockWorkerInstance: MockWorker | null = null; -// Mock the extracted createSimulationWorker module so the dynamic import -// (with ?worker&inline) never runs. Returns a MockWorker synchronously +// Mock the extracted createSimulationWorker module so the dynamic worker import +// never runs. Returns a MockWorker synchronously // via a resolved promise, eliminating all async timing issues. vi.mock("./create-simulation-worker", () => ({ createSimulationWorker: () => { @@ -120,6 +120,24 @@ function createMinimalSDCPN(): SDCPN { }; } +async function initializeWorker(result: { + current: ReturnType; +}) { + act(() => { + void result.current.actions + .initialize({ + sdcpn: createMinimalSDCPN(), + initialMarking: new Map(), + parameterValues: {}, + seed: 42, + dt: 0.1, + maxTime: null, + }) + .catch(() => {}); + }); + await flushMicrotasks(); +} + describe("useSimulationWorker", () => { beforeEach(() => { mockWorkerInstance = null; @@ -135,11 +153,11 @@ describe("useSimulationWorker", () => { expect(result.current.state.error).toBeNull(); }); - it("creates worker on mount", async () => { + it("does not create worker on mount", async () => { renderHook(() => useSimulationWorker()); await flushMicrotasks(); - expect(mockWorkerInstance).not.toBeNull(); + expect(mockWorkerInstance).toBeNull(); }); }); @@ -154,15 +172,18 @@ describe("useSimulationWorker", () => { ]); act(() => { - void result.current.actions.initialize({ - sdcpn, - initialMarking, - parameterValues: { param1: "1.0" }, - seed: 42, - dt: 0.1, - maxTime: 100, - }); + void result.current.actions + .initialize({ + sdcpn, + initialMarking, + parameterValues: { param1: "1.0" }, + seed: 42, + dt: 0.1, + maxTime: 100, + }) + .catch(() => {}); }); + await flushMicrotasks(); expect(result.current.state.status).toBe("initializing"); @@ -174,6 +195,35 @@ describe("useSimulationWorker", () => { expect(initMessages[0]?.maxTime).toBe(100); }); + it("sends init message to worker after a StrictMode remount", async () => { + const { result } = renderHook(() => useSimulationWorker(), { + reactStrictMode: true, + }); + await flushMicrotasks(); + + const initializePromise = result.current.actions.initialize({ + sdcpn: createMinimalSDCPN(), + initialMarking: new Map(), + parameterValues: {}, + seed: 42, + dt: 0.1, + maxTime: null, + }); + await flushMicrotasks(); + + const initMessages = mockWorkerInstance!.getMessages("init"); + expect(initMessages).toHaveLength(1); + + act(() => { + mockWorkerInstance!.simulateMessage({ + type: "ready", + initialFrameCount: 1, + }); + }); + + await expect(initializePromise).resolves.toBeUndefined(); + }); + it("serializes initialMarking Map to array", async () => { const { result } = renderHook(() => useSimulationWorker()); await flushMicrotasks(); @@ -184,15 +234,18 @@ describe("useSimulationWorker", () => { ]); act(() => { - void result.current.actions.initialize({ - sdcpn, - initialMarking, - parameterValues: {}, - seed: 42, - dt: 0.1, - maxTime: null, - }); + void result.current.actions + .initialize({ + sdcpn, + initialMarking, + parameterValues: {}, + seed: 42, + dt: 0.1, + maxTime: null, + }) + .catch(() => {}); }); + await flushMicrotasks(); const initMessages = mockWorkerInstance!.getMessages("init"); expect(initMessages[0]?.initialMarking).toBeInstanceOf(Array); @@ -203,6 +256,7 @@ describe("useSimulationWorker", () => { it("clears frames on initialize", async () => { const { result } = renderHook(() => useSimulationWorker()); await flushMicrotasks(); + await initializeWorker(result); // Simulate having some frames act(() => { @@ -221,15 +275,18 @@ describe("useSimulationWorker", () => { // Initialize again act(() => { - void result.current.actions.initialize({ - sdcpn: createMinimalSDCPN(), - initialMarking: new Map(), - parameterValues: {}, - seed: 42, - dt: 0.1, - maxTime: null, - }); + void result.current.actions + .initialize({ + sdcpn: createMinimalSDCPN(), + initialMarking: new Map(), + parameterValues: {}, + seed: 42, + dt: 0.1, + maxTime: null, + }) + .catch(() => {}); }); + await flushMicrotasks(); expect(result.current.state.frames).toHaveLength(0); }); @@ -239,6 +296,7 @@ describe("useSimulationWorker", () => { it("sends start message and updates status", async () => { const { result } = renderHook(() => useSimulationWorker()); await flushMicrotasks(); + await initializeWorker(result); act(() => { result.current.actions.start(); @@ -253,6 +311,7 @@ describe("useSimulationWorker", () => { it("sends pause message", async () => { const { result } = renderHook(() => useSimulationWorker()); await flushMicrotasks(); + await initializeWorker(result); act(() => { result.current.actions.pause(); @@ -266,6 +325,7 @@ describe("useSimulationWorker", () => { it("sends stop message and resets state", async () => { const { result } = renderHook(() => useSimulationWorker()); await flushMicrotasks(); + await initializeWorker(result); act(() => { result.current.actions.start(); @@ -285,6 +345,7 @@ describe("useSimulationWorker", () => { it("sends setBackpressure message with maxFramesAhead", async () => { const { result } = renderHook(() => useSimulationWorker()); await flushMicrotasks(); + await initializeWorker(result); act(() => { result.current.actions.setBackpressure({ maxFramesAhead: 50000 }); @@ -298,6 +359,7 @@ describe("useSimulationWorker", () => { it("sends setBackpressure message with batchSize", async () => { const { result } = renderHook(() => useSimulationWorker()); await flushMicrotasks(); + await initializeWorker(result); act(() => { result.current.actions.setBackpressure({ batchSize: 500 }); @@ -310,9 +372,23 @@ describe("useSimulationWorker", () => { }); describe("reset action", () => { + it("resets state without creating a worker before initialization", async () => { + const { result } = renderHook(() => useSimulationWorker()); + await flushMicrotasks(); + + act(() => { + result.current.actions.reset(); + }); + + expect(mockWorkerInstance).toBeNull(); + expect(result.current.state.status).toBe("idle"); + expect(result.current.state.frames).toEqual([]); + }); + it("sends stop message and resets state", async () => { const { result } = renderHook(() => useSimulationWorker()); await flushMicrotasks(); + await initializeWorker(result); act(() => { result.current.actions.start(); @@ -343,15 +419,18 @@ describe("useSimulationWorker", () => { await flushMicrotasks(); act(() => { - void result.current.actions.initialize({ - sdcpn: createMinimalSDCPN(), - initialMarking: new Map(), - parameterValues: {}, - seed: 42, - dt: 0.1, - maxTime: null, - }); + void result.current.actions + .initialize({ + sdcpn: createMinimalSDCPN(), + initialMarking: new Map(), + parameterValues: {}, + seed: 42, + dt: 0.1, + maxTime: null, + }) + .catch(() => {}); }); + await flushMicrotasks(); expect(result.current.state.status).toBe("initializing"); @@ -368,6 +447,7 @@ describe("useSimulationWorker", () => { it("handles frame message", async () => { const { result } = renderHook(() => useSimulationWorker()); await flushMicrotasks(); + await initializeWorker(result); const frame = { time: 1.5, @@ -389,6 +469,7 @@ describe("useSimulationWorker", () => { it("handles frames (batch) message", async () => { const { result } = renderHook(() => useSimulationWorker()); await flushMicrotasks(); + await initializeWorker(result); const frames = [ { time: 1, places: {}, transitions: {}, buffer: new Float64Array() }, @@ -408,6 +489,7 @@ describe("useSimulationWorker", () => { it("handles complete message", async () => { const { result } = renderHook(() => useSimulationWorker()); await flushMicrotasks(); + await initializeWorker(result); act(() => { result.current.actions.start(); @@ -427,6 +509,7 @@ describe("useSimulationWorker", () => { it("handles paused message", async () => { const { result } = renderHook(() => useSimulationWorker()); await flushMicrotasks(); + await initializeWorker(result); act(() => { result.current.actions.start(); @@ -445,6 +528,7 @@ describe("useSimulationWorker", () => { it("handles error message", async () => { const { result } = renderHook(() => useSimulationWorker()); await flushMicrotasks(); + await initializeWorker(result); act(() => { mockWorkerInstance!.simulateMessage({ @@ -462,6 +546,7 @@ describe("useSimulationWorker", () => { it("handles worker onerror", async () => { const { result } = renderHook(() => useSimulationWorker()); await flushMicrotasks(); + await initializeWorker(result); act(() => { mockWorkerInstance!.simulateError("Worker crashed"); @@ -476,6 +561,7 @@ describe("useSimulationWorker", () => { it("sends ack message when ack action is called", async () => { const { result } = renderHook(() => useSimulationWorker()); await flushMicrotasks(); + await initializeWorker(result); mockWorkerInstance!.clearMessages(); @@ -491,6 +577,7 @@ describe("useSimulationWorker", () => { it("sends multiple ack messages with different frame numbers", async () => { const { result } = renderHook(() => useSimulationWorker()); await flushMicrotasks(); + await initializeWorker(result); mockWorkerInstance!.clearMessages(); @@ -512,8 +599,9 @@ describe("useSimulationWorker", () => { it("terminates worker on unmount", async () => { const terminateSpy = vi.fn(); - const { unmount } = renderHook(() => useSimulationWorker()); + const { result, unmount } = renderHook(() => useSimulationWorker()); await flushMicrotasks(); + await initializeWorker(result); // Replace terminate with spy mockWorkerInstance!.terminate = terminateSpy; diff --git a/libs/@hashintel/petrinaut/src/simulation/worker/use-simulation-worker.ts b/libs/@hashintel/petrinaut/src/simulation/worker/use-simulation-worker.ts index 85c83e6303d..cd91d0baa61 100644 --- a/libs/@hashintel/petrinaut/src/simulation/worker/use-simulation-worker.ts +++ b/libs/@hashintel/petrinaut/src/simulation/worker/use-simulation-worker.ts @@ -118,7 +118,9 @@ export function useSimulationWorker(): { actions: WorkerActions; } { const [state, setState] = useState(initialState); + const isMountedRef = useRef(true); const workerRef = useRef(null); + const workerPromiseRef = useRef | null>(null); // Pending initialization promise resolver const pendingInitRef = useRef<{ @@ -126,93 +128,11 @@ export function useSimulationWorker(): { reject: (error: Error) => void; } | null>(null); - // Initialize worker on mount useEffect(() => { - let terminated = false; - - void createSimulationWorker().then((worker) => { - if (terminated) { - worker.terminate(); - return; - } - - worker.addEventListener( - "message", - (event: MessageEvent) => { - const message = event.data; - - switch (message.type) { - case "ready": - setState((prev) => ({ - ...prev, - status: prev.status === "initializing" ? "ready" : prev.status, - })); - // Resolve pending initialization promise - if (pendingInitRef.current) { - pendingInitRef.current.resolve(); - pendingInitRef.current = null; - } - break; - - case "frame": - setState((prev) => ({ - ...prev, - frames: [...prev.frames, message.frame], - })); - break; - - case "frames": - setState((prev) => ({ - ...prev, - frames: [...prev.frames, ...message.frames], - })); - break; - - case "complete": - setState((prev) => ({ - ...prev, - status: "complete", - })); - break; - - case "paused": - setState((prev) => ({ - ...prev, - status: "paused", - })); - break; - - case "error": - setState((prev) => ({ - ...prev, - status: "error", - error: message.message, - errorItemId: message.itemId, - })); - // Reject pending initialization promise if this error occurred during init - if (pendingInitRef.current) { - pendingInitRef.current.reject(new Error(message.message)); - pendingInitRef.current = null; - } - break; - } - }, - ); - - worker.addEventListener("error", (error) => { - setState((prev) => ({ - ...prev, - status: "error", - error: error.message || "Worker error", - errorItemId: null, - })); - }); - - workerRef.current = worker; - }); + isMountedRef.current = true; return () => { - terminated = true; + isMountedRef.current = false; workerRef.current?.terminate(); // Reject any pending initialization promise on teardown if (pendingInitRef.current) { @@ -222,6 +142,96 @@ export function useSimulationWorker(): { }; }, []); + const attachWorkerListeners = (worker: Worker) => { + worker.addEventListener("message", (event: MessageEvent) => { + const message = event.data; + + switch (message.type) { + case "ready": + setState((prev) => ({ + ...prev, + status: prev.status === "initializing" ? "ready" : prev.status, + })); + // Resolve pending initialization promise + if (pendingInitRef.current) { + pendingInitRef.current.resolve(); + pendingInitRef.current = null; + } + break; + + case "frame": + setState((prev) => ({ + ...prev, + frames: [...prev.frames, message.frame], + })); + break; + + case "frames": + setState((prev) => ({ + ...prev, + frames: [...prev.frames, ...message.frames], + })); + break; + + case "complete": + setState((prev) => ({ + ...prev, + status: "complete", + })); + break; + + case "paused": + setState((prev) => ({ + ...prev, + status: "paused", + })); + break; + + case "error": + setState((prev) => ({ + ...prev, + status: "error", + error: message.message, + errorItemId: message.itemId, + })); + // Reject pending initialization promise if this error occurred during init + if (pendingInitRef.current) { + pendingInitRef.current.reject(new Error(message.message)); + pendingInitRef.current = null; + } + break; + } + }); + + worker.addEventListener("error", (error) => { + setState((prev) => ({ + ...prev, + status: "error", + error: error.message || "Worker error", + errorItemId: null, + })); + }); + }; + + const ensureWorker = async () => { + if (workerRef.current) { + return workerRef.current; + } + + workerPromiseRef.current ??= createSimulationWorker().then((worker) => { + if (!isMountedRef.current) { + worker.terminate(); + throw new Error("Worker terminated"); + } + + attachWorkerListeners(worker); + workerRef.current = worker; + return worker; + }); + + return workerPromiseRef.current; + }; + // Helper to post messages const postMessage = (message: ToWorkerMessage) => { workerRef.current?.postMessage(message); @@ -259,17 +269,30 @@ export function useSimulationWorker(): { pendingInitRef.current = { resolve, reject }; }); - postMessage({ - type: "init", - sdcpn, - initialMarking: serializedMarking, - parameterValues, - seed, - dt, - maxTime, - maxFramesAhead, - batchSize, - }); + void ensureWorker() + .then((worker) => { + worker.postMessage({ + type: "init", + sdcpn, + initialMarking: serializedMarking, + parameterValues, + seed, + dt, + maxTime, + maxFramesAhead, + batchSize, + }); + }) + .catch((error: unknown) => { + if (pendingInitRef.current) { + pendingInitRef.current.reject( + error instanceof Error + ? error + : new Error("Failed to create simulation worker"), + ); + pendingInitRef.current = null; + } + }); return promise; }; diff --git a/libs/@hashintel/petrinaut/src/state/mutation-provider.test.tsx b/libs/@hashintel/petrinaut/src/state/mutation-provider.test.tsx index 46eb0c2a62f..27a27f32f87 100644 --- a/libs/@hashintel/petrinaut/src/state/mutation-provider.test.tsx +++ b/libs/@hashintel/petrinaut/src/state/mutation-provider.test.tsx @@ -24,6 +24,12 @@ import { type UserSettingsContextValue, } from "./user-settings-context"; +const calculateGraphLayoutMock = vi.hoisted(() => vi.fn()); + +vi.mock("../lib/calculate-graph-layout", () => ({ + calculateGraphLayout: calculateGraphLayoutMock, +})); + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -289,6 +295,57 @@ describe("MutationProvider", () => { expect(getSdcpn().transitions[0]!.x).toBe(300); expect(getSdcpn().transitions[0]!.y).toBe(400); }); + + test("layoutGraph loads layout only when invoked and applies positions", async () => { + const sdcpn = makeSDCPN({ + places: [ + { + id: "p1", + name: "P1", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }, + ], + transitions: [ + { + id: "t1", + name: "T1", + inputArcs: [], + outputArcs: [], + lambdaType: "predicate", + lambdaCode: "", + transitionKernelCode: "", + x: 0, + y: 0, + }, + ], + }); + calculateGraphLayoutMock.mockResolvedValueOnce({ + p1: { x: 10, y: 20 }, + t1: { x: 30, y: 40 }, + }); + const { Wrapper, getSdcpn } = createWrapper({ sdcpn }); + const { result } = renderHook(useMutations, { wrapper: Wrapper }); + + expect(calculateGraphLayoutMock).not.toHaveBeenCalled(); + + await act(async () => { + await result.current.layoutGraph(); + }); + + expect(calculateGraphLayoutMock).toHaveBeenCalledWith( + sdcpn, + { + place: { width: 130, height: 130 }, + transition: { width: 160, height: 80 }, + }, + ); + expect(getSdcpn().places[0]).toMatchObject({ x: 10, y: 20 }); + expect(getSdcpn().transitions[0]).toMatchObject({ x: 30, y: 40 }); + }); }); describe("readonly enforcement", () => { @@ -421,6 +478,32 @@ describe("MutationProvider", () => { expect(mutateFn).not.toHaveBeenCalled(); }); + + test("layoutGraph no-ops when readonly", async () => { + calculateGraphLayoutMock.mockClear(); + const sdcpn = makeSDCPN({ + places: [ + { + id: "p1", + name: "P1", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }, + ], + }); + const { Wrapper, mutateFn } = createWrapper({ sdcpn, readonly: true }); + const { result } = renderHook(useMutations, { wrapper: Wrapper }); + + await act(async () => { + await result.current.layoutGraph(); + }); + + expect(calculateGraphLayoutMock).not.toHaveBeenCalled(); + expect(mutateFn).not.toHaveBeenCalled(); + }); }); describe("cascading deletes", () => { diff --git a/libs/@hashintel/petrinaut/src/state/mutation-provider.tsx b/libs/@hashintel/petrinaut/src/state/mutation-provider.tsx index 12ee53e7bbe..4eb043bad30 100644 --- a/libs/@hashintel/petrinaut/src/state/mutation-provider.tsx +++ b/libs/@hashintel/petrinaut/src/state/mutation-provider.tsx @@ -2,7 +2,6 @@ import { use } from "react"; import { pasteFromClipboard } from "../clipboard/clipboard"; import type { MutateSDCPN, SDCPN } from "../core/types/sdcpn"; -import { calculateGraphLayout } from "../lib/calculate-graph-layout"; import { classicNodeDimensions, compactNodeDimensions, @@ -501,6 +500,9 @@ export const MutationProvider: React.FC = ({ return; } + const { calculateGraphLayout } = await import( + "../lib/calculate-graph-layout" + ); const positions = await calculateGraphLayout(sdcpn, dimensions); guardedMutate((sdcpnToMutate) => { diff --git a/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx b/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx index e862a215fd4..c927d0fbb12 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx @@ -3,15 +3,8 @@ import { use, useRef, useState } from "react"; import { Box } from "../../components/box"; import { Stack } from "../../components/stack"; -import { productionMachines } from "../../examples/broken-machines"; -import { deploymentPipelineSDCPN } from "../../examples/deployment-pipeline"; -import { satellitesSDCPN } from "../../examples/satellites"; -import { probabilisticSatellitesSDCPN } from "../../examples/satellites-launcher"; -import { sirModel } from "../../examples/sir-model"; -import { supplyChainStochasticSDCPN } from "../../examples/supply-chain-stochastic"; import { exportSDCPN } from "../../file-format/export-sdcpn"; import { importSDCPN } from "../../file-format/import-sdcpn"; -import { calculateGraphLayout } from "../../lib/calculate-graph-layout"; import { EditorContext } from "../../state/editor-context"; import { MutationContext } from "../../state/mutation-context"; import { PortalContainerContext } from "../../state/portal-container-context"; @@ -158,6 +151,16 @@ export const EditorView = ({ exportTikZ({ petriNetDefinition, title }); } + async function handleLoadExample( + loadExample: () => Promise<{ + title: string; + petriNetDefinition: typeof petriNetDefinition; + }>, + ) { + createNewNet(await loadExample()); + clearSelection(); + } + async function handleImport() { const result = await importSDCPN(); if (!result) { @@ -176,6 +179,9 @@ export const EditorView = ({ // We must do this before createNewNet because after createNewNet triggers a // re-render, the mutatePetriNetDefinition closure would be stale. if (hadMissingPositions) { + const { calculateGraphLayout } = await import( + "../../lib/calculate-graph-layout" + ); const positions = await calculateGraphLayout(sdcpnToLoad, dims); if (Object.keys(positions).length > 0) { @@ -275,50 +281,61 @@ export const EditorView = ({ { id: "load-example-supply-chain-stochastic", label: "Probabilistic Supply Chain", - onClick: () => { - createNewNet(supplyChainStochasticSDCPN); - clearSelection(); - }, + onClick: () => + handleLoadExample( + async () => + (await import("../../examples/supply-chain-stochastic")) + .supplyChainStochasticSDCPN, + ), }, { id: "load-example-satellites", label: "Satellites", - onClick: () => { - createNewNet(satellitesSDCPN); - clearSelection(); - }, + onClick: () => + handleLoadExample( + async () => + (await import("../../examples/satellites")) + .satellitesSDCPN, + ), }, { id: "load-example-probabilistic-satellites", label: "Probabilistic Satellites Launcher", - onClick: () => { - createNewNet(probabilisticSatellitesSDCPN); - clearSelection(); - }, + onClick: () => + handleLoadExample( + async () => + (await import("../../examples/satellites-launcher")) + .probabilisticSatellitesSDCPN, + ), }, { id: "load-example-production-machines", label: "Production Machines", - onClick: () => { - createNewNet(productionMachines); - clearSelection(); - }, + onClick: () => + handleLoadExample( + async () => + (await import("../../examples/broken-machines")) + .productionMachines, + ), }, { id: "load-example-sir-model", label: "SIR Model", - onClick: () => { - createNewNet(sirModel); - clearSelection(); - }, + onClick: () => + handleLoadExample( + async () => + (await import("../../examples/sir-model")).sirModel, + ), }, { id: "load-example-deployment-pipeline", label: "Deployment Pipeline", - onClick: () => { - createNewNet(deploymentPipelineSDCPN); - clearSelection(); - }, + onClick: () => + handleLoadExample( + async () => + (await import("../../examples/deployment-pipeline")) + .deploymentPipelineSDCPN, + ), }, ], }, diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/subviews/simulation-timeline.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/subviews/simulation-timeline.tsx index bce67d0ee91..7d5c5c5fe63 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/subviews/simulation-timeline.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/subviews/simulation-timeline.tsx @@ -282,7 +282,7 @@ const TimelineViewPicker: React.FC = () => { ); }; -const TimelineHeaderActions: React.FC = () => ( +export const TimelineHeaderActions: React.FC = () => (
@@ -1342,7 +1342,7 @@ const TimelineLegend: React.FC<{ // -- Main component ----------------------------------------------------------- -const SimulationTimelineContent: React.FC = () => { +export const SimulationTimelineContent: React.FC = () => { const { timelineChartType: chartType } = use(EditorContext); const { totalFrames } = use(SimulationContext); const { currentFrameIndex } = use(PlaybackContext); diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/place-properties/subviews/place-visualizer/subview.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/place-properties/subviews/place-visualizer/subview.tsx index a39409806db..640fbc38db3 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/place-properties/subviews/place-visualizer/subview.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/place-properties/subviews/place-visualizer/subview.tsx @@ -1,5 +1,5 @@ import { css } from "@hashintel/ds-helpers/css"; -import { use, useEffect, useMemo, useState } from "react"; +import { use, useEffect, useState } from "react"; import { TbDotsVertical, TbSparkles } from "react-icons/tb"; import { IconButton } from "../../../../../../../components/icon-button"; @@ -20,7 +20,7 @@ import { import { CodeEditor } from "../../../../../../../monaco/code-editor"; import { PlaybackContext } from "../../../../../../../playback/context"; import { SimulationContext } from "../../../../../../../simulation/context"; -import { compileVisualizer } from "../../../../../../../simulation/simulator/compile-visualizer"; +import type { VisualizerComponent } from "../../../../../../../simulation/simulator/compile-visualizer"; import { EditorContext } from "../../../../../../../state/editor-context"; import { usePlacePropertiesContext } from "../../context"; import { VisualizerErrorBoundary } from "./visualizer-error-boundary"; @@ -86,18 +86,49 @@ const VisualizerPreview: React.FC = () => { const { currentFrame, totalFrames } = use(PlaybackContext); const defaultParameterValues = useDefaultParameterValues(); + const [VisualizerComponent, setVisualizerComponent] = + useState(null); + + useEffect(() => { + let cancelled = false; - const VisualizerComponent = useMemo(() => { if (!place.visualizerCode) { - return null; - } - try { - return compileVisualizer(place.visualizerCode); - } catch (error) { - // eslint-disable-next-line no-console - console.error("Failed to compile visualizer code:", error); - return null; + setVisualizerComponent(null); + return; } + + void import( + "../../../../../../../simulation/simulator/compile-visualizer" + ).then( + ({ compileVisualizer }) => { + if (cancelled) { + return; + } + + try { + setVisualizerComponent(() => + compileVisualizer(place.visualizerCode!), + ); + } catch (error) { + // eslint-disable-next-line no-console + console.error("Failed to compile visualizer code:", error); + setVisualizerComponent(null); + } + }, + (error: unknown) => { + if (cancelled) { + return; + } + + // eslint-disable-next-line no-console + console.error("Failed to load visualizer compiler:", error); + setVisualizerComponent(null); + }, + ); + + return () => { + cancelled = true; + }; }, [place.visualizerCode]); if (!place.visualizerCode) { diff --git a/libs/@hashintel/petrinaut/vite.config.test.ts b/libs/@hashintel/petrinaut/vite.config.test.ts new file mode 100644 index 00000000000..dcf6694231f --- /dev/null +++ b/libs/@hashintel/petrinaut/vite.config.test.ts @@ -0,0 +1,88 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { isLibraryExternal, shouldApplyReactCompiler } from "./vite.config"; + +describe("petrinaut package boundary", () => { + it("externalizes peer dependency subpaths as part of the library boundary", () => { + expect(isLibraryExternal("@hashintel/ds-components")).toBe(true); + expect(isLibraryExternal("@hashintel/ds-components/preset")).toBe(true); + expect(isLibraryExternal("@hashintel/ds-helpers")).toBe(true); + expect(isLibraryExternal("@hashintel/ds-helpers/css")).toBe(true); + expect(isLibraryExternal("@xyflow/react")).toBe(true); + expect(isLibraryExternal("@xyflow/react/dist/style.css")).toBe(true); + expect(isLibraryExternal("react")).toBe(true); + expect(isLibraryExternal("react/jsx-runtime")).toBe(true); + expect(isLibraryExternal("react-dom")).toBe(true); + expect(isLibraryExternal("react-dom/client")).toBe(true); + expect(isLibraryExternal("use-sync-external-store/shim/with-selector")).toBe( + true, + ); + }); + + it("keeps local and ordinary dependency imports inside the library build", () => { + expect(isLibraryExternal("./src/main")).toBe(false); + expect(isLibraryExternal("fuzzysort")).toBe(false); + expect(isLibraryExternal("@ark-ui/react/select")).toBe(false); + }); + + it("declares explicit public exports for the entrypoint and stylesheet", async () => { + const packageJson = JSON.parse( + await readFile(path.join(import.meta.dirname, "package.json"), "utf8"), + ) as { + exports?: { + "."?: { types?: string; import?: string }; + "./styles.css"?: string; + "./package.json"?: string; + }; + main?: string; + style?: string; + types?: string; + }; + + expect(packageJson.main).toBe("dist/main.js"); + expect(packageJson.types).toBe("dist/main.d.ts"); + expect(packageJson.style).toBe("dist/main.css"); + expect(packageJson.exports?.["."]).toEqual({ + types: "./dist/main.d.ts", + import: "./dist/main.js", + }); + expect(packageJson.exports?.["./styles.css"]).toBe("./dist/main.css"); + expect(packageJson.exports?.["./package.json"]).toBe("./package.json"); + }); + + it("limits React Compiler to Petrinaut source modules that import React", () => { + expect( + shouldApplyReactCompiler( + "/repo/libs/@hashintel/petrinaut/src/components/input.tsx", + 'import { useId } from "react";', + ), + ).toBe(true); + expect( + shouldApplyReactCompiler( + "/repo/libs/@hashintel/petrinaut/src/hooks/use-latest.ts", + 'import { useRef } from "react";', + ), + ).toBe(true); + expect( + shouldApplyReactCompiler( + "/repo/libs/@hashintel/petrinaut/src/simulation/worker/simulation.worker.ts", + 'import { compileSimulation } from "../simulator";', + ), + ).toBe(false); + expect( + shouldApplyReactCompiler( + "/repo/libs/@hashintel/petrinaut/src/simulation/simulator/build-simulation.ts", + 'import { compileUserCode } from "./compile-user-code";', + ), + ).toBe(false); + expect( + shouldApplyReactCompiler( + "/repo/libs/@hashintel/petrinaut/src/components/input.test.tsx", + 'import { render } from "@testing-library/react";', + ), + ).toBe(false); + }); +}); diff --git a/libs/@hashintel/petrinaut/vite.config.ts b/libs/@hashintel/petrinaut/vite.config.ts index e79a660f250..f86ea0a7116 100644 --- a/libs/@hashintel/petrinaut/vite.config.ts +++ b/libs/@hashintel/petrinaut/vite.config.ts @@ -1,9 +1,63 @@ -import babel from "@rolldown/plugin-babel"; +import babel, { defineRolldownBabelPreset } from "@rolldown/plugin-babel"; import react, { reactCompilerPreset } from "@vitejs/plugin-react"; import { replacePlugin } from "rolldown/plugins"; import { dts } from "rolldown-plugin-dts"; import { defineConfig, esmExternalRequirePlugin } from "vite"; +export const libraryExternalPatterns = [ + /^@babel\/standalone$/, + /^@hashintel\/ds-components(\/.*)?$/, + /^@hashintel\/ds-helpers(\/.*)?$/, + /^@xyflow\/react(\/.*)?$/, + /^react(\/.*)?$/, + /^react-dom(\/.*)?$/, + /^use-sync-external-store(\/.*)?$/, +]; + +export function isLibraryExternal(id: string) { + return libraryExternalPatterns.some((pattern) => pattern.test(id)); +} + +const reactCompilerIdInclude = /[\\/]src[\\/].+\.[jt]sx?$/; +const reactCompilerIdExclude = [ + /[\\/]src[\\/].+\.stories\.[jt]sx?$/, + /[\\/]src[\\/].+\.test\.[jt]sx?$/, + /[\\/]src[\\/].+\.worker\.[jt]s$/, + /[\\/]src[\\/]simulation[\\/]worker[\\/]/, + /[\\/]src[\\/]lsp[\\/]worker[\\/]/, +]; +const reactCompilerCodeInclude = + /(?=[\s\S]*(?:from\s+["']react(?:\/[^"']*)?["']|import\s+["']react(?:\/[^"']*)?["']))(?=[\s\S]*(?:\b[A-Z]|\buse))/; + +export function shouldApplyReactCompiler(id: string, code: string) { + return ( + reactCompilerIdInclude.test(id) && + !reactCompilerIdExclude.some((pattern) => pattern.test(id)) && + reactCompilerCodeInclude.test(code) + ); +} + +const baseReactCompilerBabelPreset = reactCompilerPreset({ + target: "19", + compilationMode: "infer", + // @ts-expect-error - panicThreshold is accepted at runtime + panicThreshold: "critical_errors", +}); + +const reactCompilerBabelPreset = defineRolldownBabelPreset({ + ...baseReactCompilerBabelPreset, + rolldown: { + ...baseReactCompilerBabelPreset.rolldown, + filter: { + id: { + include: reactCompilerIdInclude, + exclude: reactCompilerIdExclude, + }, + code: reactCompilerCodeInclude, + }, + }, +}); + /** * Library build config */ @@ -15,20 +69,10 @@ export default defineConfig(({ command }) => ({ formats: ["es"], }, rolldownOptions: { - external: [ - "@hashintel/ds-components", - "@hashintel/ds-helpers", - "react", - "react-dom", - "@xyflow/react", - "@babel/standalone", - // Pure-CJS dep pulled in transitively by @tanstack/react-form → - // @tanstack/react-store. Rolldown can't safely transform its - // `require("react")` when react is external, so it falls back to a - // runtime require helper that throws in the browser. Externalising it - // pushes CJS→ESM interop to the consumer's bundler. - /^use-sync-external-store(\/.*)?$/, - ], + // Keep peer packages external by subpath too. Source imports helper + // subpaths such as `@hashintel/ds-helpers/css`; externalizing only the + // package root lets those internals leak into Petrinaut's emitted graph. + external: isLibraryExternal, output: { globals: { react: "React", @@ -80,14 +124,7 @@ export default defineConfig(({ command }) => ({ react(), babel({ - presets: [ - reactCompilerPreset({ - target: "19", - compilationMode: "infer", - // @ts-expect-error - panicThreshold is accepted at runtime - panicThreshold: "critical_errors", - }), - ], + presets: [reactCompilerBabelPreset], }), command === "build" && diff --git a/yarn.lock b/yarn.lock index 0e0a96f1b80..75dc63dc994 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6642,13 +6642,6 @@ __metadata: languageName: node linkType: hard -"@fontsource-variable/jetbrains-mono@npm:5.2.8": - version: 5.2.8 - resolution: "@fontsource-variable/jetbrains-mono@npm:5.2.8" - checksum: 10c0/574e5463b802cfdd6ec8dd16724d2fd5ee38204815729c9dca0f457a417f0a4d32e6ec4ed2dfa0e5a5de5a9b0deaeb9f3c0b49b332763ed40172de43d6b1502f - languageName: node - linkType: hard - "@fortawesome/fontawesome-common-types@npm:6.7.2": version: 6.7.2 resolution: "@fortawesome/fontawesome-common-types@npm:6.7.2" @@ -7677,9 +7670,6 @@ __metadata: dependencies: "@ark-ui/react": "npm:5.26.2" "@babel/standalone": "npm:7.28.5" - "@fontsource-variable/inter": "npm:5.2.8" - "@fontsource-variable/inter-tight": "npm:5.2.7" - "@fontsource-variable/jetbrains-mono": "npm:5.2.8" "@hashintel/ds-components": "workspace:^" "@hashintel/ds-helpers": "workspace:*" "@hashintel/refractive": "workspace:^"