From 47a4e37a6de57d0a31327957a7df3c6996d0ad99 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Tue, 5 May 2026 21:26:45 +0200 Subject: [PATCH 01/29] feat: characterize petrinaut build output --- libs/@hashintel/petrinaut/package.json | 1 + .../scripts/characterize-build-output.mjs | 849 ++++++++++++++++++ .../characterize-build-output.test.mjs | 84 ++ 3 files changed, 934 insertions(+) create mode 100644 libs/@hashintel/petrinaut/scripts/characterize-build-output.mjs create mode 100644 libs/@hashintel/petrinaut/scripts/characterize-build-output.test.mjs diff --git a/libs/@hashintel/petrinaut/package.json b/libs/@hashintel/petrinaut/package.json index dee58f2e645..5269c4f0ddc 100644 --- a/libs/@hashintel/petrinaut/package.json +++ b/libs/@hashintel/petrinaut/package.json @@ -31,6 +31,7 @@ "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", + "profile:build": "node scripts/characterize-build-output.mjs", "test:unit": "vitest" }, "dependencies": { 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..4a07bc858b6 --- /dev/null +++ b/libs/@hashintel/petrinaut/scripts/characterize-build-output.mjs @@ -0,0 +1,849 @@ +#!/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", + }, +]; + +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)); +} + +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 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), + 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, + 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 "--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. + --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); + + 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 (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..53a50bbe033 --- /dev/null +++ b/libs/@hashintel/petrinaut/scripts/characterize-build-output.test.mjs @@ -0,0 +1,84 @@ +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 { + 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("formats byte counts consistently", () => { + expect(formatBytes(0)).toBe("0 B"); + expect(formatBytes(1_536)).toBe("1.5 KiB"); + }); +}); From c2533255723015bec0a6f75b831dadf3dfab2196 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Wed, 6 May 2026 09:25:13 +0200 Subject: [PATCH 02/29] test: characterize petrinaut provider lifecycles --- .../petrinaut/src/provider-lifecycle.test.tsx | 467 ++++++++++++++++++ 1 file changed, 467 insertions(+) create mode 100644 libs/@hashintel/petrinaut/src/provider-lifecycle.test.tsx 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..99d9acda3ec --- /dev/null +++ b/libs/@hashintel/petrinaut/src/provider-lifecycle.test.tsx @@ -0,0 +1,467 @@ +/** + * @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 MonacoContextValue } 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: Promise) => 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 | 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 a shared Monaco initialization promise when mounted", async () => { + const providedPromises: Promise[] = []; + + render( + + { + providedPromises.push(value); + }} + /> + , + ); + + const providedPromise = providedPromises[0]; + + expect(providedPromise).toBeInstanceOf(Promise); + if (!providedPromise) { + throw new Error("MonacoProvider did not provide a promise"); + } + + const monacoContextValue = await providedPromise; + + expect(monacoContextValue.monaco).toBeDefined(); + expect(typeof monacoContextValue.Editor).toBe("function"); + expect(mocks.monacoLoaderConfig).toHaveBeenCalledOnce(); + }); + }); +}); From 2b916960585a604b23500401b5efb789fcc35141 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Wed, 6 May 2026 09:26:54 +0200 Subject: [PATCH 03/29] fix: encode petrinaut package boundary --- libs/@hashintel/petrinaut/package.json | 8 +++ libs/@hashintel/petrinaut/vite.config.test.ts | 55 +++++++++++++++++++ libs/@hashintel/petrinaut/vite.config.ts | 32 ++++++----- 3 files changed, 81 insertions(+), 14 deletions(-) create mode 100644 libs/@hashintel/petrinaut/vite.config.test.ts diff --git a/libs/@hashintel/petrinaut/package.json b/libs/@hashintel/petrinaut/package.json index 5269c4f0ddc..0fb5c9a9154 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", diff --git a/libs/@hashintel/petrinaut/vite.config.test.ts b/libs/@hashintel/petrinaut/vite.config.test.ts new file mode 100644 index 00000000000..4bfc60c8052 --- /dev/null +++ b/libs/@hashintel/petrinaut/vite.config.test.ts @@ -0,0 +1,55 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { isLibraryExternal } 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"); + }); +}); diff --git a/libs/@hashintel/petrinaut/vite.config.ts b/libs/@hashintel/petrinaut/vite.config.ts index e79a660f250..56ac7d0c8d9 100644 --- a/libs/@hashintel/petrinaut/vite.config.ts +++ b/libs/@hashintel/petrinaut/vite.config.ts @@ -4,6 +4,20 @@ 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)); +} + /** * Library build config */ @@ -15,20 +29,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", From 28950117a3553566f327b1705edfdc5c357cfc1d Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Wed, 6 May 2026 09:28:03 +0200 Subject: [PATCH 04/29] fix: emit petrinaut workers as assets --- .../petrinaut/src/lsp/worker/use-language-client.ts | 6 ++---- .../src/simulation/worker/create-simulation-worker.ts | 4 ++-- .../src/simulation/worker/use-simulation-worker.test.ts | 4 ++-- 3 files changed, 6 insertions(+), 8 deletions(-) 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..6f6d9bf470a 100644 --- a/libs/@hashintel/petrinaut/src/lsp/worker/use-language-client.ts +++ b/libs/@hashintel/petrinaut/src/lsp/worker/use-language-client.ts @@ -14,11 +14,9 @@ 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" - ); + const LanguageServerWorker = await import("./language-server.worker.ts?worker"); // eslint-disable-next-line new-cap return new LanguageServerWorker.default(); } 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..bbd2999c191 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: () => { From 7542b42d8948ebf2dd0965b36644c824a2914f92 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Wed, 6 May 2026 09:31:32 +0200 Subject: [PATCH 05/29] fix: narrow petrinaut react compiler transform --- libs/@hashintel/petrinaut/vite.config.test.ts | 35 ++++++++++++- libs/@hashintel/petrinaut/vite.config.ts | 51 +++++++++++++++---- 2 files changed, 76 insertions(+), 10 deletions(-) diff --git a/libs/@hashintel/petrinaut/vite.config.test.ts b/libs/@hashintel/petrinaut/vite.config.test.ts index 4bfc60c8052..dcf6694231f 100644 --- a/libs/@hashintel/petrinaut/vite.config.test.ts +++ b/libs/@hashintel/petrinaut/vite.config.test.ts @@ -3,7 +3,7 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; -import { isLibraryExternal } from "./vite.config"; +import { isLibraryExternal, shouldApplyReactCompiler } from "./vite.config"; describe("petrinaut package boundary", () => { it("externalizes peer dependency subpaths as part of the library boundary", () => { @@ -52,4 +52,37 @@ describe("petrinaut package boundary", () => { 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 56ac7d0c8d9..f86ea0a7116 100644 --- a/libs/@hashintel/petrinaut/vite.config.ts +++ b/libs/@hashintel/petrinaut/vite.config.ts @@ -1,4 +1,4 @@ -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"; @@ -18,6 +18,46 @@ 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 */ @@ -84,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" && From 4215c86eaf494cc1af0096191f56cb7e3fa9d587 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Wed, 6 May 2026 09:33:58 +0200 Subject: [PATCH 06/29] fix: lazy load petrinaut graph layout --- .../src/state/mutation-provider.test.tsx | 83 +++++++++++++++++++ .../petrinaut/src/state/mutation-provider.tsx | 4 +- .../src/views/Editor/editor-view.tsx | 4 +- 3 files changed, 89 insertions(+), 2 deletions(-) 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..5d939f5b543 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx @@ -11,7 +11,6 @@ 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"; @@ -176,6 +175,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) { From b550252c9780f7b40e36440f927418b7572615f1 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Wed, 6 May 2026 09:35:23 +0200 Subject: [PATCH 07/29] fix: lazy load petrinaut visualizer compiler --- .../subviews/place-visualizer/subview.tsx | 54 +++++++++++++++---- 1 file changed, 43 insertions(+), 11 deletions(-) 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..20e8220d72f 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,12 +20,15 @@ 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 { EditorContext } from "../../../../../../../state/editor-context"; import { usePlacePropertiesContext } from "../../context"; import { VisualizerErrorBoundary } from "./visualizer-error-boundary"; type ViewMode = "code" | "preview" | "split"; +type VisualizerComponent = React.FC<{ + tokens: Record[]; + parameters: Record; +}>; const contentStyle = css({ display: "flex", @@ -86,18 +89,47 @@ 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) { From 40204300fa02617a8e0660c65454c02aff251e8e Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Wed, 6 May 2026 09:38:14 +0200 Subject: [PATCH 08/29] fix: lazy load petrinaut timeline chart --- .../petrinaut/src/constants/ui-subviews.ts | 40 ++++++++++++++++++- .../subviews/simulation-timeline.tsx | 4 +- 2 files changed, 41 insertions(+), 3 deletions(-) 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/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); From ea1faf95d5dfabfb066c367564191b2d0a547c06 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Wed, 6 May 2026 09:47:41 +0200 Subject: [PATCH 09/29] fix: lazy initialize petrinaut simulation worker --- .../worker/use-simulation-worker.test.ts | 129 ++++++++--- .../worker/use-simulation-worker.ts | 213 ++++++++++-------- memory/REFACTOR.md | 96 ++++++++ 3 files changed, 307 insertions(+), 131 deletions(-) create mode 100644 memory/REFACTOR.md 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 bbd2999c191..30514b6d9b4 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 @@ -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"); @@ -184,15 +205,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 +227,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 +246,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 +267,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 +282,7 @@ describe("useSimulationWorker", () => { it("sends pause message", async () => { const { result } = renderHook(() => useSimulationWorker()); await flushMicrotasks(); + await initializeWorker(result); act(() => { result.current.actions.pause(); @@ -266,6 +296,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 +316,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 +330,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 +343,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 +390,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 +418,7 @@ describe("useSimulationWorker", () => { it("handles frame message", async () => { const { result } = renderHook(() => useSimulationWorker()); await flushMicrotasks(); + await initializeWorker(result); const frame = { time: 1.5, @@ -389,6 +440,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 +460,7 @@ describe("useSimulationWorker", () => { it("handles complete message", async () => { const { result } = renderHook(() => useSimulationWorker()); await flushMicrotasks(); + await initializeWorker(result); act(() => { result.current.actions.start(); @@ -427,6 +480,7 @@ describe("useSimulationWorker", () => { it("handles paused message", async () => { const { result } = renderHook(() => useSimulationWorker()); await flushMicrotasks(); + await initializeWorker(result); act(() => { result.current.actions.start(); @@ -445,6 +499,7 @@ describe("useSimulationWorker", () => { it("handles error message", async () => { const { result } = renderHook(() => useSimulationWorker()); await flushMicrotasks(); + await initializeWorker(result); act(() => { mockWorkerInstance!.simulateMessage({ @@ -462,6 +517,7 @@ describe("useSimulationWorker", () => { it("handles worker onerror", async () => { const { result } = renderHook(() => useSimulationWorker()); await flushMicrotasks(); + await initializeWorker(result); act(() => { mockWorkerInstance!.simulateError("Worker crashed"); @@ -476,6 +532,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 +548,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 +570,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..393ce4ea094 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,9 @@ 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; - }); - return () => { - terminated = true; + isMountedRef.current = false; workerRef.current?.terminate(); // Reject any pending initialization promise on teardown if (pendingInitRef.current) { @@ -222,6 +140,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 +267,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/memory/REFACTOR.md b/memory/REFACTOR.md new file mode 100644 index 00000000000..5ffe198ed4b --- /dev/null +++ b/memory/REFACTOR.md @@ -0,0 +1,96 @@ +## Problem Statement + +Petrinaut currently gives development tooling too little isolation between the editor shell and optional heavy subsystems. Mounting the editor reaches the language worker, Monaco setup, simulation worker, full panel registry, layout engine, charting code, examples, font CSS, and package CSS in one broad graph. CSS and small UI changes can therefore cause bundlers and transforms to revisit code that is unrelated to the edited feature. + +The attached analysis is broadly correct. The highest-confidence findings are the inline worker imports, broad React Compiler/Babel transform, static layout and visualizer compiler imports, eager provider initialization, static timeline chart registration, incomplete peer subpath externalization, missing explicit package exports, HASH frontend transpilation of Petrinaut, and broad Panda scanning. The main adjustment is sequencing: keep the current Vite/Rolldown build initially, fix graph topology first, and treat a `tsdown` migration as a later spike with separate acceptance criteria. + +## Solution + +Make Petrinaut cheaper to import and cheaper to rebuild by adding measurement first, then carving stable boundaries around workers, package metadata, heavy feature code, runtime initialization, and CSS generation. + +The target state is: + +- Worker internals are emitted as separate assets or chunks, not as huge inline worker string modules. +- React Compiler/Babel does not process worker modules or other non-React code unnecessarily. +- Heavy optional dependencies enter the graph only when their feature is used. +- Mounting Petrinaut does not immediately initialize Monaco, the language server worker, or the simulation worker. +- Package metadata and externalization express an intentional reusable-library boundary. +- HASH frontend can be tested against precompiled Petrinaut instead of transpiling it as app source. +- CSS and Panda scanning are narrowed enough that style edits do not wake unrelated editor internals. + +## Commits + +Status legend: `[x]` landed, `[ ]` pending. + +1. [x] Add a bundle graph characterization report that records emitted imports, worker artifact shape, main asset sizes, CSS size, and the presence of known heavy packages without introducing failing thresholds. Landed in `47a4e37a6d`. +2. [x] Add provider lifecycle characterization coverage for the public behavior of language services, Monaco-backed editors, and simulation controls so later lazy-initialization changes can preserve user-visible behavior. Landed in `c253325572`. +3. [x] Encode an explicit package-boundary policy with exports for the main module and stylesheet, complete dependency declarations for emitted bare imports, and subpath-aware externalization for peer packages. Landed in `2b91696058`. +4. [x] Stop inlining Petrinaut's application workers while preserving their existing message protocols and consumer URL compatibility. Landed in `28950117a3`. +5. [x] Narrow the React Compiler/Babel transform so worker modules and obvious non-React modules are outside the transform surface. Landed in `7542b42d89`. +6. [x] Lazy-load graph layout at the import and manual-layout call sites while keeping the same layout result and read-only behavior. Landed in `4215c86eaf`. +7. [x] Lazy-load visualizer compilation so the Babel standalone dependency is pulled only when previewing visualizer code. Landed in `b550252c97`. +8. [x] Refactor the bottom-panel registry so the timeline chart is loaded only when the simulation timeline tab is active. Landed in `40204300fa`. +9. [x] Defer simulation worker creation until the first simulation initialization while keeping reset, teardown, error, and backpressure behavior intact. Landed in `9a9ba2bef6`. +10. Defer language worker creation until diagnostics or editor language features are actually requested while preserving queued document updates and diagnostics publication. +11. Defer Monaco initialization until the first code editor renders, with the sync helpers subscribing only after Monaco and language services are available. +12. Move examples behind a lazy menu boundary so the editor shell does not statically import every example net. +13. Reduce library CSS coupling by removing full font package imports from the component entry or moving them behind an explicit opt-in style contract. +14. Split library and Storybook Panda scanning, and remove source-runtime constants from Panda config evaluation. +15. Test HASH frontend without transpiling Petrinaut and either remove Petrinaut from transpilation or document the remaining blockers with the bundle report. +16. Turn the bundle graph characterization report into a regression guard with agreed thresholds once the new topology has landed. + +## Progress Notes + +Current branch: `ln/petrinaut-imports`. + +Latest verified slice: simulation worker lazy initialization (`9a9ba2bef6`). + +Verification used for landed implementation slices: + +- `yarn workspace @hashintel/petrinaut lint:eslint` +- `yarn workspace @hashintel/petrinaut lint:tsc` +- `yarn workspace @hashintel/petrinaut test:unit run` +- `yarn workspace @hashintel/petrinaut build` + +Latest full Petrinaut verification passed with 34 Vitest files and 480 tests. + +Current build signals after item 9: + +- `main.js`: approximately `637.1 KiB`, `164.7 KiB gzip`. +- CSS: approximately `1.47 MiB`, `685 KiB gzip`; font/CSS work remains pending. +- Worker internals emit as separate `dist/assets/*worker*.js` files with tiny URL wrapper modules. +- `calculate-graph-layout`, `compile-visualizer`, and `simulation-timeline` now emit as lazy chunks. +- The simulation worker is not created when the worker hook mounts; it is created on first simulation initialization. +- Babel deoptimization warnings for inline worker modules are gone. + +Observed improvement from the original characterization baseline: + +- Baseline `main.js`: approximately `1.4 MiB`, `313 KiB gzip`. +- Current `main.js`: approximately `637.1 KiB`, `164.7 KiB gzip`. +- Baseline build time: `6.46s`; latest observed build: `5.11s`. + +Next slice: item 10, defer language worker creation until diagnostics or editor language features are actually requested. + +## Decision Document + +- Modules built or modified: Petrinaut package build configuration, package metadata, worker factories, language service provider, Monaco provider, simulation worker hook, editor shell, mutation provider, bottom-panel subview registry, visualizer preview, example loading, CSS/font entrypoints, Panda configuration, HASH frontend package consumption. +- Interface changes: add explicit package exports for the main entry and stylesheet; keep the current React component API stable; keep worker message protocols stable; keep editor mutation, simulation, playback, diagnostics, and code-editor context APIs source-compatible unless a later scoped commit proves a smaller API is needed. +- Architectural decisions: prioritize topology fixes over a bundler replacement; treat externalization and lazy-loading as separate concerns; bundle worker internals if necessary but keep them outside the main-thread module graph; prefer feature-level lazy boundaries over a broad editor rewrite; use measurement before thresholds. +- Schema changes, API contracts: no SDCPN schema changes; no persisted user setting changes planned; no public Petrinaut prop changes planned; package import contract gains an explicit stylesheet export. + +## Testing Decisions + +- Good tests here should verify behavior and build artifacts, not implementation details. For example, user-facing simulation controls still initialize, run, pause, reset, and report errors; code editors still provide diagnostics/completion/hover/signature help; graph layout still produces positions for imports and manual layout; visualizer previews still compile valid code and show errors for invalid code. +- Existing coverage is useful for simulation internals, playback behavior, mutation behavior, validation, import/export, LSP helpers, and the simulation worker hook. Some current worker-hook tests assert eager worker creation and should be rewritten around observable behavior when the lazy boundary lands. +- Coverage gaps to close before risky changes: language client provider lifecycle, Monaco provider lazy initialization, emitted bundle import auditing, worker asset shape, package export consumption, and HASH frontend consumption of prebuilt Petrinaut. +- Verification stack for each implementation commit: Petrinaut eslint, Petrinaut type check, Petrinaut unit tests, Petrinaut build, then HASH frontend build or targeted Next compilation checks when package exports, worker URLs, CSS imports, or transpilation settings change. +- Bundle verification should inspect emitted assets for inline worker wrappers, top-level imports of known heavy dependencies, undeclared bare imports, CSS size, and worker asset sizes. + +## Out of Scope + +- Replacing Vite/Rolldown with `tsdown` in this refactor. +- Rewriting the editor shell, state model, or SDCPN schema. +- Changing user-visible editor workflows beyond loading delays for optional systems. +- Replacing React Flow, Monaco, Babel standalone, TypeScript LSP, or uPlot with different libraries. +- A broad icon-system migration. +- Full performance-budget enforcement before the graph has been reshaped and measured. From f5b3c652fad9ffe06f74cca43873165b0eb4626e Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Wed, 6 May 2026 09:48:13 +0200 Subject: [PATCH 10/29] docs: record petrinaut simulation worker slice --- memory/REFACTOR.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/memory/REFACTOR.md b/memory/REFACTOR.md index 5ffe198ed4b..8413a73ca07 100644 --- a/memory/REFACTOR.md +++ b/memory/REFACTOR.md @@ -30,7 +30,7 @@ Status legend: `[x]` landed, `[ ]` pending. 6. [x] Lazy-load graph layout at the import and manual-layout call sites while keeping the same layout result and read-only behavior. Landed in `4215c86eaf`. 7. [x] Lazy-load visualizer compilation so the Babel standalone dependency is pulled only when previewing visualizer code. Landed in `b550252c97`. 8. [x] Refactor the bottom-panel registry so the timeline chart is loaded only when the simulation timeline tab is active. Landed in `40204300fa`. -9. [x] Defer simulation worker creation until the first simulation initialization while keeping reset, teardown, error, and backpressure behavior intact. Landed in `9a9ba2bef6`. +9. [x] Defer simulation worker creation until the first simulation initialization while keeping reset, teardown, error, and backpressure behavior intact. Landed in `ea1faf95d5`. 10. Defer language worker creation until diagnostics or editor language features are actually requested while preserving queued document updates and diagnostics publication. 11. Defer Monaco initialization until the first code editor renders, with the sync helpers subscribing only after Monaco and language services are available. 12. Move examples behind a lazy menu boundary so the editor shell does not statically import every example net. @@ -43,7 +43,7 @@ Status legend: `[x]` landed, `[ ]` pending. Current branch: `ln/petrinaut-imports`. -Latest verified slice: simulation worker lazy initialization (`9a9ba2bef6`). +Latest verified slice: simulation worker lazy initialization (`ea1faf95d5`). Verification used for landed implementation slices: From 4d13fdec9f8d87154a32c3893adaab9501be6b6b Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Wed, 6 May 2026 09:50:28 +0200 Subject: [PATCH 11/29] fix: lazy initialize petrinaut language worker --- .../lsp/worker/use-language-client.test.ts | 149 +++++++++++ .../src/lsp/worker/use-language-client.ts | 236 +++++++++++------- 2 files changed, 299 insertions(+), 86 deletions(-) create mode 100644 libs/@hashintel/petrinaut/src/lsp/worker/use-language-client.test.ts 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..d4e65cf4bec --- /dev/null +++ b/libs/@hashintel/petrinaut/src/lsp/worker/use-language-client.test.ts @@ -0,0 +1,149 @@ +/** + * @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: [], + }); + }); +}); 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 6f6d9bf470a..ff7a0099888 100644 --- a/libs/@hashintel/petrinaut/src/lsp/worker/use-language-client.ts +++ b/libs/@hashintel/petrinaut/src/lsp/worker/use-language-client.ts @@ -16,7 +16,9 @@ import type { /** Dynamically import and instantiate the language server worker as an emitted asset. */ async function createLanguageServerWorker() { - const LanguageServerWorker = await import("./language-server.worker.ts?worker"); + const LanguageServerWorker = await import( + "./language-server.worker.ts?worker" + ); // eslint-disable-next-line new-cap return new LanguageServerWorker.default(); } @@ -65,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([]); @@ -78,39 +82,62 @@ export function useLanguageClient(): LanguageClientApi { >(null); useEffect(() => { - let terminated = false; + 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 @@ -118,31 +145,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) => { @@ -168,33 +201,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], ); @@ -212,22 +254,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], ); @@ -245,20 +293,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 => { From 6c6923dabf3137e3bbc9ebcbbfc46e37c2143531 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Wed, 6 May 2026 09:50:44 +0200 Subject: [PATCH 12/29] docs: record petrinaut language worker slice --- memory/REFACTOR.md | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/memory/REFACTOR.md b/memory/REFACTOR.md index 8413a73ca07..2028458a26a 100644 --- a/memory/REFACTOR.md +++ b/memory/REFACTOR.md @@ -31,7 +31,7 @@ Status legend: `[x]` landed, `[ ]` pending. 7. [x] Lazy-load visualizer compilation so the Babel standalone dependency is pulled only when previewing visualizer code. Landed in `b550252c97`. 8. [x] Refactor the bottom-panel registry so the timeline chart is loaded only when the simulation timeline tab is active. Landed in `40204300fa`. 9. [x] Defer simulation worker creation until the first simulation initialization while keeping reset, teardown, error, and backpressure behavior intact. Landed in `ea1faf95d5`. -10. Defer language worker creation until diagnostics or editor language features are actually requested while preserving queued document updates and diagnostics publication. +10. [x] Defer language worker creation until diagnostics or editor language features are actually requested while preserving queued document updates and diagnostics publication. Landed in `4d13fdec9f`. 11. Defer Monaco initialization until the first code editor renders, with the sync helpers subscribing only after Monaco and language services are available. 12. Move examples behind a lazy menu boundary so the editor shell does not statically import every example net. 13. Reduce library CSS coupling by removing full font package imports from the component entry or moving them behind an explicit opt-in style contract. @@ -43,7 +43,7 @@ Status legend: `[x]` landed, `[ ]` pending. Current branch: `ln/petrinaut-imports`. -Latest verified slice: simulation worker lazy initialization (`ea1faf95d5`). +Latest verified slice: language worker lazy initialization (`4d13fdec9f`). Verification used for landed implementation slices: @@ -52,24 +52,25 @@ Verification used for landed implementation slices: - `yarn workspace @hashintel/petrinaut test:unit run` - `yarn workspace @hashintel/petrinaut build` -Latest full Petrinaut verification passed with 34 Vitest files and 480 tests. +Latest full Petrinaut verification passed with 35 Vitest files and 483 tests. -Current build signals after item 9: +Current build signals after item 10: -- `main.js`: approximately `637.1 KiB`, `164.7 KiB gzip`. +- `main.js`: approximately `637.7 KiB`, `164.9 KiB gzip`. - CSS: approximately `1.47 MiB`, `685 KiB gzip`; font/CSS work remains pending. - Worker internals emit as separate `dist/assets/*worker*.js` files with tiny URL wrapper modules. - `calculate-graph-layout`, `compile-visualizer`, and `simulation-timeline` now emit as lazy chunks. - The simulation worker is not created when the worker hook mounts; it is created on first simulation initialization. +- The language worker is not created by provider mount or structural initialization; diagnostics/document sync and language feature requests activate it and drain queued messages. - Babel deoptimization warnings for inline worker modules are gone. Observed improvement from the original characterization baseline: - Baseline `main.js`: approximately `1.4 MiB`, `313 KiB gzip`. -- Current `main.js`: approximately `637.1 KiB`, `164.7 KiB gzip`. -- Baseline build time: `6.46s`; latest observed build: `5.11s`. +- Current `main.js`: approximately `637.7 KiB`, `164.9 KiB gzip`. +- Baseline build time: `6.46s`; latest observed build: `5.18s`. -Next slice: item 10, defer language worker creation until diagnostics or editor language features are actually requested. +Next slice: item 11, defer Monaco initialization until the first code editor renders. ## Decision Document From 5ed3f914e2f9d8c29f7de4cb943ccb0a270040df Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Wed, 6 May 2026 09:54:03 +0200 Subject: [PATCH 13/29] fix: lazy initialize petrinaut monaco --- .../petrinaut/src/monaco/code-editor.tsx | 2 +- .../petrinaut/src/monaco/completion-sync.tsx | 23 ++++++--- .../petrinaut/src/monaco/context.ts | 9 ++-- .../petrinaut/src/monaco/diagnostics-sync.tsx | 23 ++++++--- .../petrinaut/src/monaco/hover-sync.tsx | 23 ++++++--- .../petrinaut/src/monaco/provider.tsx | 22 ++++++-- .../src/monaco/signature-help-sync.tsx | 23 ++++++--- .../petrinaut/src/provider-lifecycle.test.tsx | 50 ++++++++++++------- 8 files changed, 125 insertions(+), 50 deletions(-) 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/provider-lifecycle.test.tsx b/libs/@hashintel/petrinaut/src/provider-lifecycle.test.tsx index 99d9acda3ec..4cd91a41b11 100644 --- a/libs/@hashintel/petrinaut/src/provider-lifecycle.test.tsx +++ b/libs/@hashintel/petrinaut/src/provider-lifecycle.test.tsx @@ -13,7 +13,7 @@ import type { PublishDiagnosticsParams, } from "./lsp/worker/protocol"; import type { LanguageClientApi } from "./lsp/worker/use-language-client"; -import { MonacoContext, type MonacoContextValue } from "./monaco/context"; +import { MonacoContext, type MonacoContextHandle } from "./monaco/context"; import { MonacoProvider } from "./monaco/provider"; import { NotificationsContext } from "./notifications/notifications-context"; import { SimulationContext } from "./simulation/context"; @@ -81,9 +81,12 @@ vi.mock( () => ({}), ); -vi.mock("monaco-editor/esm/vs/editor/contrib/folding/browser/folding.js", () => ({ - default: {}, -})); +vi.mock( + "monaco-editor/esm/vs/editor/contrib/folding/browser/folding.js", + () => ({ + default: {}, + }), +); vi.mock("./monaco/diagnostics-sync", () => ({ DiagnosticsSync: () => null, @@ -138,7 +141,10 @@ function createLanguageClientMock(): LanguageClientApi & { notifySDCPNChanged: vi.fn(), notifyDocumentChanged: vi.fn(), requestCompletion: vi.fn(() => - Promise.resolve({ isIncomplete: false, items: [] } satisfies CompletionList), + Promise.resolve({ + isIncomplete: false, + items: [], + } satisfies CompletionList), ), requestHover: vi.fn(() => Promise.resolve(null)), requestSignatureHelp: vi.fn(() => Promise.resolve(null)), @@ -150,7 +156,7 @@ function createLanguageClientMock(): LanguageClientApi & { killMetricSession: vi.fn(), onDiagnostics: vi.fn( (callback: (params: PublishDiagnosticsParams[]) => void) => { - diagnosticsCallback = callback; + diagnosticsCallback = callback; }, ), emitDiagnostics(params) { @@ -194,7 +200,7 @@ function useMonacoContext() { } const MonacoContextProbe: React.FC<{ - onValue: (value: Promise) => void; + onValue: (value: MonacoContextHandle) => void; }> = ({ onValue }) => { onValue(useMonacoContext()); return null; @@ -308,9 +314,12 @@ describe("provider lifecycle characterization", () => { }); 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); + 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", { @@ -401,7 +410,9 @@ describe("provider lifecycle characterization", () => { const firstContext = createSdcpnContext(EMPTY_SDCPN, "net-1"); const secondContext = createSdcpnContext(EMPTY_SDCPN, "net-2"); - let simulationContext = null as ReturnType | null; + let simulationContext = null as ReturnType< + typeof useSimulationContext + > | null; const { rerender } = render( { }); describe("MonacoProvider", () => { - it("provides a shared Monaco initialization promise when mounted", async () => { - const providedPromises: Promise[] = []; + it("provides Monaco initialization without starting it on mount", async () => { + const providedHandles: MonacoContextHandle[] = []; render( { - providedPromises.push(value); + providedHandles.push(value); }} /> , ); - const providedPromise = providedPromises[0]; + const providedHandle = providedHandles[0]; - expect(providedPromise).toBeInstanceOf(Promise); - if (!providedPromise) { - throw new Error("MonacoProvider did not provide a promise"); + expect(providedHandle?.monacoPromise).toBeNull(); + expect(mocks.monacoLoaderConfig).not.toHaveBeenCalled(); + if (!providedHandle) { + throw new Error("MonacoProvider did not provide a handle"); } - const monacoContextValue = await providedPromise; + const monacoContextValue = await providedHandle.getMonaco(); expect(monacoContextValue.monaco).toBeDefined(); expect(typeof monacoContextValue.Editor).toBe("function"); From 335a05858bd49c8b5e3eef060a05135b767afb16 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Wed, 6 May 2026 09:54:29 +0200 Subject: [PATCH 14/29] docs: record petrinaut monaco slice --- memory/REFACTOR.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/memory/REFACTOR.md b/memory/REFACTOR.md index 2028458a26a..5095273d7b4 100644 --- a/memory/REFACTOR.md +++ b/memory/REFACTOR.md @@ -32,7 +32,7 @@ Status legend: `[x]` landed, `[ ]` pending. 8. [x] Refactor the bottom-panel registry so the timeline chart is loaded only when the simulation timeline tab is active. Landed in `40204300fa`. 9. [x] Defer simulation worker creation until the first simulation initialization while keeping reset, teardown, error, and backpressure behavior intact. Landed in `ea1faf95d5`. 10. [x] Defer language worker creation until diagnostics or editor language features are actually requested while preserving queued document updates and diagnostics publication. Landed in `4d13fdec9f`. -11. Defer Monaco initialization until the first code editor renders, with the sync helpers subscribing only after Monaco and language services are available. +11. [x] Defer Monaco initialization until the first code editor renders, with the sync helpers subscribing only after Monaco and language services are available. Landed in `5ed3f914e2`. 12. Move examples behind a lazy menu boundary so the editor shell does not statically import every example net. 13. Reduce library CSS coupling by removing full font package imports from the component entry or moving them behind an explicit opt-in style contract. 14. Split library and Storybook Panda scanning, and remove source-runtime constants from Panda config evaluation. @@ -43,7 +43,7 @@ Status legend: `[x]` landed, `[ ]` pending. Current branch: `ln/petrinaut-imports`. -Latest verified slice: language worker lazy initialization (`4d13fdec9f`). +Latest verified slice: Monaco lazy initialization (`5ed3f914e2`). Verification used for landed implementation slices: @@ -54,23 +54,24 @@ Verification used for landed implementation slices: Latest full Petrinaut verification passed with 35 Vitest files and 483 tests. -Current build signals after item 10: +Current build signals after item 11: -- `main.js`: approximately `637.7 KiB`, `164.9 KiB gzip`. +- `main.js`: approximately `638.8 KiB`, `165.1 KiB gzip`. - CSS: approximately `1.47 MiB`, `685 KiB gzip`; font/CSS work remains pending. - Worker internals emit as separate `dist/assets/*worker*.js` files with tiny URL wrapper modules. - `calculate-graph-layout`, `compile-visualizer`, and `simulation-timeline` now emit as lazy chunks. - The simulation worker is not created when the worker hook mounts; it is created on first simulation initialization. - The language worker is not created by provider mount or structural initialization; diagnostics/document sync and language feature requests activate it and drain queued messages. +- Monaco is not initialized when `MonacoProvider` mounts; the first rendered `CodeEditor` asks for Monaco, and sync helpers subscribe after that promise exists. - Babel deoptimization warnings for inline worker modules are gone. Observed improvement from the original characterization baseline: - Baseline `main.js`: approximately `1.4 MiB`, `313 KiB gzip`. -- Current `main.js`: approximately `637.7 KiB`, `164.9 KiB gzip`. -- Baseline build time: `6.46s`; latest observed build: `5.18s`. +- Current `main.js`: approximately `638.8 KiB`, `165.1 KiB gzip`. +- Baseline build time: `6.46s`; latest observed build: `5.02s`. -Next slice: item 11, defer Monaco initialization until the first code editor renders. +Next slice: item 12, move examples behind a lazy menu boundary. ## Decision Document From 781d48a873db1da035ec36fb8b1bc5946d71240a Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Wed, 6 May 2026 09:55:51 +0200 Subject: [PATCH 15/29] fix: lazy load petrinaut examples --- .../src/views/Editor/editor-view.tsx | 75 +++++++++++-------- 1 file changed, 45 insertions(+), 30 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx b/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx index 5d939f5b543..c927d0fbb12 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx @@ -3,12 +3,6 @@ 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 { EditorContext } from "../../state/editor-context"; @@ -157,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) { @@ -277,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, + ), }, ], }, From 57810eb7975014f9f86a0cbac8f59320d9a9d339 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Wed, 6 May 2026 09:56:38 +0200 Subject: [PATCH 16/29] docs: record petrinaut examples slice --- memory/REFACTOR.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/memory/REFACTOR.md b/memory/REFACTOR.md index 5095273d7b4..f1f7865a913 100644 --- a/memory/REFACTOR.md +++ b/memory/REFACTOR.md @@ -33,7 +33,7 @@ Status legend: `[x]` landed, `[ ]` pending. 9. [x] Defer simulation worker creation until the first simulation initialization while keeping reset, teardown, error, and backpressure behavior intact. Landed in `ea1faf95d5`. 10. [x] Defer language worker creation until diagnostics or editor language features are actually requested while preserving queued document updates and diagnostics publication. Landed in `4d13fdec9f`. 11. [x] Defer Monaco initialization until the first code editor renders, with the sync helpers subscribing only after Monaco and language services are available. Landed in `5ed3f914e2`. -12. Move examples behind a lazy menu boundary so the editor shell does not statically import every example net. +12. [x] Move examples behind a lazy menu boundary so the editor shell does not statically import every example net. Landed in `781d48a873`. 13. Reduce library CSS coupling by removing full font package imports from the component entry or moving them behind an explicit opt-in style contract. 14. Split library and Storybook Panda scanning, and remove source-runtime constants from Panda config evaluation. 15. Test HASH frontend without transpiling Petrinaut and either remove Petrinaut from transpilation or document the remaining blockers with the bundle report. @@ -43,7 +43,7 @@ Status legend: `[x]` landed, `[ ]` pending. Current branch: `ln/petrinaut-imports`. -Latest verified slice: Monaco lazy initialization (`5ed3f914e2`). +Latest verified slice: example menu lazy loading (`781d48a873`). Verification used for landed implementation slices: @@ -54,12 +54,13 @@ Verification used for landed implementation slices: Latest full Petrinaut verification passed with 35 Vitest files and 483 tests. -Current build signals after item 11: +Current build signals after item 12: -- `main.js`: approximately `638.8 KiB`, `165.1 KiB gzip`. +- `main.js`: approximately `589.9 KiB`, `157.0 KiB gzip`. - CSS: approximately `1.47 MiB`, `685 KiB gzip`; font/CSS work remains pending. - Worker internals emit as separate `dist/assets/*worker*.js` files with tiny URL wrapper modules. - `calculate-graph-layout`, `compile-visualizer`, and `simulation-timeline` now emit as lazy chunks. +- Example nets now emit as individual lazy chunks loaded only from the Load example submenu actions. - The simulation worker is not created when the worker hook mounts; it is created on first simulation initialization. - The language worker is not created by provider mount or structural initialization; diagnostics/document sync and language feature requests activate it and drain queued messages. - Monaco is not initialized when `MonacoProvider` mounts; the first rendered `CodeEditor` asks for Monaco, and sync helpers subscribe after that promise exists. @@ -68,10 +69,10 @@ Current build signals after item 11: Observed improvement from the original characterization baseline: - Baseline `main.js`: approximately `1.4 MiB`, `313 KiB gzip`. -- Current `main.js`: approximately `638.8 KiB`, `165.1 KiB gzip`. -- Baseline build time: `6.46s`; latest observed build: `5.02s`. +- Current `main.js`: approximately `589.9 KiB`, `157.0 KiB gzip`. +- Baseline build time: `6.46s`; latest observed build: `5.11s`. -Next slice: item 12, move examples behind a lazy menu boundary. +Next slice: item 13, reduce library CSS coupling by removing full font package imports from the component entry or moving them behind an explicit opt-in style contract. ## Decision Document From a3240c4c269612195d1a544d6bf856124ba0b008 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Wed, 6 May 2026 09:58:28 +0200 Subject: [PATCH 17/29] fix: decouple petrinaut bundled fonts --- libs/@hashintel/petrinaut/package.json | 3 --- libs/@hashintel/petrinaut/src/fontsource.d.ts | 5 ----- libs/@hashintel/petrinaut/src/petrinaut.tsx | 3 --- yarn.lock | 10 ---------- 4 files changed, 21 deletions(-) delete mode 100644 libs/@hashintel/petrinaut/src/fontsource.d.ts diff --git a/libs/@hashintel/petrinaut/package.json b/libs/@hashintel/petrinaut/package.json index 0fb5c9a9154..13a2a17a3f0 100644 --- a/libs/@hashintel/petrinaut/package.json +++ b/libs/@hashintel/petrinaut/package.json @@ -45,9 +45,6 @@ "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/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/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/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:^" From a6bce72a710a01a5cd332ef62bf2377835f37b4a Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Wed, 6 May 2026 10:00:13 +0200 Subject: [PATCH 18/29] docs: record petrinaut font slice --- memory/REFACTOR.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/memory/REFACTOR.md b/memory/REFACTOR.md index f1f7865a913..1981c994786 100644 --- a/memory/REFACTOR.md +++ b/memory/REFACTOR.md @@ -34,7 +34,7 @@ Status legend: `[x]` landed, `[ ]` pending. 10. [x] Defer language worker creation until diagnostics or editor language features are actually requested while preserving queued document updates and diagnostics publication. Landed in `4d13fdec9f`. 11. [x] Defer Monaco initialization until the first code editor renders, with the sync helpers subscribing only after Monaco and language services are available. Landed in `5ed3f914e2`. 12. [x] Move examples behind a lazy menu boundary so the editor shell does not statically import every example net. Landed in `781d48a873`. -13. Reduce library CSS coupling by removing full font package imports from the component entry or moving them behind an explicit opt-in style contract. +13. [x] Reduce library CSS coupling by removing full font package imports from the component entry or moving them behind an explicit opt-in style contract. Landed in `a3240c4c26`. 14. Split library and Storybook Panda scanning, and remove source-runtime constants from Panda config evaluation. 15. Test HASH frontend without transpiling Petrinaut and either remove Petrinaut from transpilation or document the remaining blockers with the bundle report. 16. Turn the bundle graph characterization report into a regression guard with agreed thresholds once the new topology has landed. @@ -43,7 +43,7 @@ Status legend: `[x]` landed, `[ ]` pending. Current branch: `ln/petrinaut-imports`. -Latest verified slice: example menu lazy loading (`781d48a873`). +Latest verified slice: bundled font decoupling (`a3240c4c26`). Verification used for landed implementation slices: @@ -54,10 +54,10 @@ Verification used for landed implementation slices: Latest full Petrinaut verification passed with 35 Vitest files and 483 tests. -Current build signals after item 12: +Current build signals after item 13: - `main.js`: approximately `589.9 KiB`, `157.0 KiB gzip`. -- CSS: approximately `1.47 MiB`, `685 KiB gzip`; font/CSS work remains pending. +- CSS: approximately `763.4 KiB`, `152.6 KiB gzip`; full Fontsource packages are no longer imported by the component entry. - Worker internals emit as separate `dist/assets/*worker*.js` files with tiny URL wrapper modules. - `calculate-graph-layout`, `compile-visualizer`, and `simulation-timeline` now emit as lazy chunks. - Example nets now emit as individual lazy chunks loaded only from the Load example submenu actions. @@ -70,9 +70,9 @@ Observed improvement from the original characterization baseline: - Baseline `main.js`: approximately `1.4 MiB`, `313 KiB gzip`. - Current `main.js`: approximately `589.9 KiB`, `157.0 KiB gzip`. -- Baseline build time: `6.46s`; latest observed build: `5.11s`. +- Baseline build time: `6.46s`; latest observed build: `5.12s`. -Next slice: item 13, reduce library CSS coupling by removing full font package imports from the component entry or moving them behind an explicit opt-in style contract. +Next slice: item 14, split library and Storybook Panda scanning, and remove source-runtime constants from Panda config evaluation. ## Decision Document From 8306f1f9b262256c7762916696ca27d18602aa1c Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Wed, 6 May 2026 10:02:24 +0200 Subject: [PATCH 19/29] fix: split petrinaut panda scanning --- .../petrinaut/panda.config.shared.test.ts | 13 ++++++++++++- .../@hashintel/petrinaut/panda.config.shared.ts | 17 +++++++++++++---- .../petrinaut/panda.storybook.config.ts | 13 +++++++++++++ libs/@hashintel/petrinaut/postcss.config.cjs | 6 +++++- 4 files changed, 43 insertions(+), 6 deletions(-) create mode 100644 libs/@hashintel/petrinaut/panda.storybook.config.ts 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", + }, }, }; From 781c66d7257783557109d7b34202af6fa437598c Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Wed, 6 May 2026 10:02:46 +0200 Subject: [PATCH 20/29] docs: record petrinaut panda slice --- memory/REFACTOR.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/memory/REFACTOR.md b/memory/REFACTOR.md index 1981c994786..574a94a1bbd 100644 --- a/memory/REFACTOR.md +++ b/memory/REFACTOR.md @@ -35,7 +35,7 @@ Status legend: `[x]` landed, `[ ]` pending. 11. [x] Defer Monaco initialization until the first code editor renders, with the sync helpers subscribing only after Monaco and language services are available. Landed in `5ed3f914e2`. 12. [x] Move examples behind a lazy menu boundary so the editor shell does not statically import every example net. Landed in `781d48a873`. 13. [x] Reduce library CSS coupling by removing full font package imports from the component entry or moving them behind an explicit opt-in style contract. Landed in `a3240c4c26`. -14. Split library and Storybook Panda scanning, and remove source-runtime constants from Panda config evaluation. +14. [x] Split library and Storybook Panda scanning, and remove source-runtime constants from Panda config evaluation. Landed in `8306f1f9b2`. 15. Test HASH frontend without transpiling Petrinaut and either remove Petrinaut from transpilation or document the remaining blockers with the bundle report. 16. Turn the bundle graph characterization report into a regression guard with agreed thresholds once the new topology has landed. @@ -43,7 +43,7 @@ Status legend: `[x]` landed, `[ ]` pending. Current branch: `ln/petrinaut-imports`. -Latest verified slice: bundled font decoupling (`a3240c4c26`). +Latest verified slice: Panda scanning split (`8306f1f9b2`). Verification used for landed implementation slices: @@ -54,7 +54,7 @@ Verification used for landed implementation slices: Latest full Petrinaut verification passed with 35 Vitest files and 483 tests. -Current build signals after item 13: +Current build signals after item 14: - `main.js`: approximately `589.9 KiB`, `157.0 KiB gzip`. - CSS: approximately `763.4 KiB`, `152.6 KiB gzip`; full Fontsource packages are no longer imported by the component entry. @@ -65,14 +65,15 @@ Current build signals after item 13: - The language worker is not created by provider mount or structural initialization; diagnostics/document sync and language feature requests activate it and drain queued messages. - Monaco is not initialized when `MonacoProvider` mounts; the first rendered `CodeEditor` asks for Monaco, and sync helpers subscribe after that promise exists. - Babel deoptimization warnings for inline worker modules are gone. +- Library Panda scanning excludes Storybook files by default; Storybook has an explicit opt-in Panda config, and Panda config no longer imports runtime constants from `src`. Observed improvement from the original characterization baseline: - Baseline `main.js`: approximately `1.4 MiB`, `313 KiB gzip`. - Current `main.js`: approximately `589.9 KiB`, `157.0 KiB gzip`. -- Baseline build time: `6.46s`; latest observed build: `5.12s`. +- Baseline build time: `6.46s`; latest observed build: `5.35s`. -Next slice: item 14, split library and Storybook Panda scanning, and remove source-runtime constants from Panda config evaluation. +Next slice: item 15, test HASH frontend without transpiling Petrinaut and either remove Petrinaut from transpilation or document the remaining blockers with the bundle report. ## Decision Document From 4dd52cb214a454c9e60c19922b50767e44f0bc2b Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Wed, 6 May 2026 10:04:31 +0200 Subject: [PATCH 21/29] fix: consume prebuilt petrinaut in frontend --- apps/hash-frontend/next.config.js | 1 - .../src/pages/process.page/process-editor-wrapper.tsx | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) 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"; From c66501ee67da5f5471f76bc38a5edc310bd4137f Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Wed, 6 May 2026 10:04:56 +0200 Subject: [PATCH 22/29] docs: record petrinaut frontend consumption slice --- memory/REFACTOR.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/memory/REFACTOR.md b/memory/REFACTOR.md index 574a94a1bbd..5bee7291acb 100644 --- a/memory/REFACTOR.md +++ b/memory/REFACTOR.md @@ -36,14 +36,14 @@ Status legend: `[x]` landed, `[ ]` pending. 12. [x] Move examples behind a lazy menu boundary so the editor shell does not statically import every example net. Landed in `781d48a873`. 13. [x] Reduce library CSS coupling by removing full font package imports from the component entry or moving them behind an explicit opt-in style contract. Landed in `a3240c4c26`. 14. [x] Split library and Storybook Panda scanning, and remove source-runtime constants from Panda config evaluation. Landed in `8306f1f9b2`. -15. Test HASH frontend without transpiling Petrinaut and either remove Petrinaut from transpilation or document the remaining blockers with the bundle report. +15. [x] Test HASH frontend without transpiling Petrinaut and either remove Petrinaut from transpilation or document the remaining blockers with the bundle report. Landed in `4dd52cb214`. 16. Turn the bundle graph characterization report into a regression guard with agreed thresholds once the new topology has landed. ## Progress Notes Current branch: `ln/petrinaut-imports`. -Latest verified slice: Panda scanning split (`8306f1f9b2`). +Latest verified slice: HASH frontend prebuilt Petrinaut consumption (`4dd52cb214`). Verification used for landed implementation slices: @@ -66,6 +66,7 @@ Current build signals after item 14: - Monaco is not initialized when `MonacoProvider` mounts; the first rendered `CodeEditor` asks for Monaco, and sync helpers subscribe after that promise exists. - Babel deoptimization warnings for inline worker modules are gone. - Library Panda scanning excludes Storybook files by default; Storybook has an explicit opt-in Panda config, and Panda config no longer imports runtime constants from `src`. +- HASH frontend imports `@hashintel/petrinaut/styles.css` and no longer lists `@hashintel/petrinaut` in `transpilePackages`. Observed improvement from the original characterization baseline: @@ -73,7 +74,13 @@ Observed improvement from the original characterization baseline: - Current `main.js`: approximately `589.9 KiB`, `157.0 KiB gzip`. - Baseline build time: `6.46s`; latest observed build: `5.35s`. -Next slice: item 15, test HASH frontend without transpiling Petrinaut and either remove Petrinaut from transpilation or document the remaining blockers with the bundle report. +Frontend verification for item 15: + +- `yarn workspace @apps/hash-frontend lint:tsc` currently fails before the Petrinaut boundary because generated GraphQL/API artifacts are missing and existing Block Protocol type imports do not match the installed package surface. +- `yarn workspace @apps/hash-frontend build` currently fails before the Petrinaut boundary because generated GraphQL artifacts such as `src/graphql/api-types.gen` and `libs/@local/hash-isomorphic-utils/src/graphql/fragment-types.gen.json` are missing. +- No Petrinaut package-resolution, stylesheet-export, or worker-asset error was reached in those frontend checks. + +Next slice: item 16, turn the bundle graph characterization report into a regression guard with agreed thresholds. ## Decision Document From 4eafbe7ecda5a761c8a58de6e93f88443904c33b Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Wed, 6 May 2026 10:07:39 +0200 Subject: [PATCH 23/29] test: guard petrinaut bundle graph --- libs/@hashintel/petrinaut/package.json | 1 + .../scripts/characterize-build-output.mjs | 138 +++++++++++++++--- .../characterize-build-output.test.mjs | 45 ++++++ 3 files changed, 160 insertions(+), 24 deletions(-) diff --git a/libs/@hashintel/petrinaut/package.json b/libs/@hashintel/petrinaut/package.json index 13a2a17a3f0..b6501542448 100644 --- a/libs/@hashintel/petrinaut/package.json +++ b/libs/@hashintel/petrinaut/package.json @@ -39,6 +39,7 @@ "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" }, diff --git a/libs/@hashintel/petrinaut/scripts/characterize-build-output.mjs b/libs/@hashintel/petrinaut/scripts/characterize-build-output.mjs index 4a07bc858b6..06b380496e3 100644 --- a/libs/@hashintel/petrinaut/scripts/characterize-build-output.mjs +++ b/libs/@hashintel/petrinaut/scripts/characterize-build-output.mjs @@ -4,13 +4,7 @@ import { spawn } from "node:child_process"; import { createHash } from "node:crypto"; -import { - cpus, - freemem, - platform, - release, - totalmem, -} from "node:os"; +import { cpus, freemem, platform, release, totalmem } from "node:os"; import { mkdir, readFile, @@ -66,6 +60,22 @@ const DEFAULT_WATCH_SAMPLES = [ }, ]; +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", @@ -77,7 +87,8 @@ const HEAVY_SOURCE_PATTERNS = [ }, { key: "babelStandaloneImports", - pattern: /from\s+["']@babel\/standalone["']|import\s+\*\s+as\s+\w+\s+from\s+["']@babel\/standalone["']/g, + pattern: + /from\s+["']@babel\/standalone["']|import\s+\*\s+as\s+\w+\s+from\s+["']@babel\/standalone["']/g, }, { key: "elkImports", @@ -85,7 +96,8 @@ const HEAVY_SOURCE_PATTERNS = [ }, { key: "uplotImports", - pattern: /from\s+["']uplot["']|import\s+["']uplot\/dist\/uPlot\.min\.css["']/g, + pattern: + /from\s+["']uplot["']|import\s+["']uplot\/dist\/uPlot\.min\.css["']/g, }, { key: "fontsourceImports", @@ -132,8 +144,7 @@ export function parseBareImports(source) { /(?:^|[;\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; + const sideEffectImportPattern = /(?:^|[;\n])\s*import\s*["']([^"']+)["']/g; for (const pattern of [ importFromPattern, @@ -151,6 +162,52 @@ export function parseBareImports(source) { return [...imports].sort((left, right) => left.localeCompare(right)); } +export function evaluateBundleGuard( + report, + thresholds = BUNDLE_GUARD_THRESHOLDS, +) { + const failures = []; + + 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 []; @@ -256,7 +313,9 @@ export async function summarizeDistDirectory(distDir = DIST_DIR) { ]), ); - const sourceMapSources = Array.isArray(mainMap?.sources) ? mainMap.sources : []; + const sourceMapSources = Array.isArray(mainMap?.sources) + ? mainMap.sources + : []; return { assetGroups, @@ -293,9 +352,9 @@ export async function summarizeDistDirectory(distDir = DIST_DIR) { } async function summarizeSourceHotspots() { - const sourceFiles = ( - await listFiles(path.join(PACKAGE_ROOT, "src")) - ).filter((filePath) => /\.(?:css|ts|tsx)$/.test(filePath)); + 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"), @@ -403,6 +462,7 @@ function parseArgs(argv) { build: true, watch: true, checks: false, + guard: false, outputDir: null, }; @@ -418,6 +478,9 @@ function parseArgs(argv) { case "--include-checks": args.checks = true; break; + case "--guard": + args.guard = true; + break; case "--output-dir": args.outputDir = argv[index + 1] ?? null; index += 1; @@ -441,6 +504,7 @@ 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. `); } @@ -489,7 +553,9 @@ async function getGitValue(args) { } async function collectEnvironment() { - const packageJson = await readJsonIfExists(path.join(PACKAGE_ROOT, "package.json")); + const packageJson = await readJsonIfExists( + path.join(PACKAGE_ROOT, "package.json"), + ); return { timestamp: new Date().toISOString(), @@ -629,7 +695,10 @@ function renderReportMarkdown(report) { `${report.environment.cpuCount} x ${report.environment.cpuModel}`, ], ["Total memory", formatBytes(report.environment.totalMemoryBytes)], - ["Free memory at start", formatBytes(report.environment.freeMemoryBytes)], + [ + "Free memory at start", + formatBytes(report.environment.freeMemoryBytes), + ], ], ), "", @@ -650,7 +719,11 @@ function renderReportMarkdown(report) { ? markdownTable( ["Sample", "Touched file", "Duration"], [ - ["initial library watch build", "", formatMs(report.watch.initialBuild.durationMs)], + [ + "initial library watch build", + "", + formatMs(report.watch.initialBuild.durationMs), + ], ...report.watch.rebuilds.map((rebuild) => [ rebuild.label, rebuild.path, @@ -676,11 +749,13 @@ function renderReportMarkdown(report) { "", markdownTable( ["Asset", "Size", "Gzip"], - report.dist.largestAssets.slice(0, 12).map((asset) => [ - asset.path, - formatBytes(asset.bytes), - formatBytes(asset.gzipBytes), - ]), + report.dist.largestAssets + .slice(0, 12) + .map((asset) => [ + asset.path, + formatBytes(asset.bytes), + formatBytes(asset.gzipBytes), + ]), ), "", "## Worker Assets", @@ -776,7 +851,12 @@ async function writeReports(report, outputDir) { await writeFile(timestampedJsonPath, json); await writeFile(timestampedMarkdownPath, markdown); - return { jsonPath, markdownPath, timestampedJsonPath, timestampedMarkdownPath }; + return { + jsonPath, + markdownPath, + timestampedJsonPath, + timestampedMarkdownPath, + }; } async function main() { @@ -824,6 +904,7 @@ async function main() { }; const reportPaths = await writeReports(report, outputDir); + const guardFailures = args.guard ? evaluateBundleGuard(report) : []; console.log(""); console.log(`Wrote ${path.relative(repoRoot, reportPaths.markdownPath)}`); @@ -839,6 +920,15 @@ async function main() { 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) { diff --git a/libs/@hashintel/petrinaut/scripts/characterize-build-output.test.mjs b/libs/@hashintel/petrinaut/scripts/characterize-build-output.test.mjs index 53a50bbe033..14c113fdb42 100644 --- a/libs/@hashintel/petrinaut/scripts/characterize-build-output.test.mjs +++ b/libs/@hashintel/petrinaut/scripts/characterize-build-output.test.mjs @@ -5,6 +5,7 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import { + evaluateBundleGuard, formatBytes, parseBareImports, summarizeDistDirectory, @@ -81,4 +82,48 @@ describe("characterize-build-output", () => { 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", + ]); + }); }); From 5899672478fddf4dbb3b914c380bcd25e6efbf46 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Wed, 6 May 2026 10:08:18 +0200 Subject: [PATCH 24/29] docs: record petrinaut bundle guard slice --- memory/REFACTOR.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/memory/REFACTOR.md b/memory/REFACTOR.md index 5bee7291acb..3419d5929c0 100644 --- a/memory/REFACTOR.md +++ b/memory/REFACTOR.md @@ -37,13 +37,13 @@ Status legend: `[x]` landed, `[ ]` pending. 13. [x] Reduce library CSS coupling by removing full font package imports from the component entry or moving them behind an explicit opt-in style contract. Landed in `a3240c4c26`. 14. [x] Split library and Storybook Panda scanning, and remove source-runtime constants from Panda config evaluation. Landed in `8306f1f9b2`. 15. [x] Test HASH frontend without transpiling Petrinaut and either remove Petrinaut from transpilation or document the remaining blockers with the bundle report. Landed in `4dd52cb214`. -16. Turn the bundle graph characterization report into a regression guard with agreed thresholds once the new topology has landed. +16. [x] Turn the bundle graph characterization report into a regression guard with agreed thresholds once the new topology has landed. Landed in `4eafbe7ecd`. ## Progress Notes Current branch: `ln/petrinaut-imports`. -Latest verified slice: HASH frontend prebuilt Petrinaut consumption (`4dd52cb214`). +Latest verified slice: bundle graph regression guard (`4eafbe7ecd`). Verification used for landed implementation slices: @@ -67,6 +67,7 @@ Current build signals after item 14: - Babel deoptimization warnings for inline worker modules are gone. - Library Panda scanning excludes Storybook files by default; Storybook has an explicit opt-in Panda config, and Panda config no longer imports runtime constants from `src`. - HASH frontend imports `@hashintel/petrinaut/styles.css` and no longer lists `@hashintel/petrinaut` in `transpilePackages`. +- `yarn workspace @hashintel/petrinaut check:bundle` now fails when emitted main JS, CSS, font-face count, inline worker imports, or known heavy dependency signals regress past the current thresholds. Observed improvement from the original characterization baseline: @@ -80,7 +81,7 @@ Frontend verification for item 15: - `yarn workspace @apps/hash-frontend build` currently fails before the Petrinaut boundary because generated GraphQL artifacts such as `src/graphql/api-types.gen` and `libs/@local/hash-isomorphic-utils/src/graphql/fragment-types.gen.json` are missing. - No Petrinaut package-resolution, stylesheet-export, or worker-asset error was reached in those frontend checks. -Next slice: item 16, turn the bundle graph characterization report into a regression guard with agreed thresholds. +Refactor plan items 1-16 are now landed. ## Decision Document From 04ac9b595740d4b9db0af51a1c2e8645f6730e68 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Wed, 6 May 2026 10:08:41 +0200 Subject: [PATCH 25/29] docs: refresh petrinaut final refactor status --- memory/REFACTOR.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/memory/REFACTOR.md b/memory/REFACTOR.md index 3419d5929c0..9230fdc5b9f 100644 --- a/memory/REFACTOR.md +++ b/memory/REFACTOR.md @@ -52,9 +52,9 @@ Verification used for landed implementation slices: - `yarn workspace @hashintel/petrinaut test:unit run` - `yarn workspace @hashintel/petrinaut build` -Latest full Petrinaut verification passed with 35 Vitest files and 483 tests. +Latest full Petrinaut verification passed with 35 Vitest files and 485 tests. -Current build signals after item 14: +Current build signals after item 16: - `main.js`: approximately `589.9 KiB`, `157.0 KiB gzip`. - CSS: approximately `763.4 KiB`, `152.6 KiB gzip`; full Fontsource packages are no longer imported by the component entry. @@ -73,7 +73,7 @@ Observed improvement from the original characterization baseline: - Baseline `main.js`: approximately `1.4 MiB`, `313 KiB gzip`. - Current `main.js`: approximately `589.9 KiB`, `157.0 KiB gzip`. -- Baseline build time: `6.46s`; latest observed build: `5.35s`. +- Baseline build time: `6.46s`; latest observed build: `5.31s`. Frontend verification for item 15: From d0122216ade84a19002b1caa7de70a55602f24af Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Wed, 6 May 2026 11:34:18 +0200 Subject: [PATCH 26/29] fix: support strict mode lazy workers --- .../lsp/worker/use-language-client.test.ts | 33 +++++++++++++++++++ .../src/lsp/worker/use-language-client.ts | 1 + .../worker/use-simulation-worker.test.ts | 29 ++++++++++++++++ .../worker/use-simulation-worker.ts | 2 ++ 4 files changed, 65 insertions(+) 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 index d4e65cf4bec..fabe6105f23 100644 --- a/libs/@hashintel/petrinaut/src/lsp/worker/use-language-client.test.ts +++ b/libs/@hashintel/petrinaut/src/lsp/worker/use-language-client.test.ts @@ -146,4 +146,37 @@ describe("useLanguageClient", () => { 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 ff7a0099888..079eb735899 100644 --- a/libs/@hashintel/petrinaut/src/lsp/worker/use-language-client.ts +++ b/libs/@hashintel/petrinaut/src/lsp/worker/use-language-client.ts @@ -82,6 +82,7 @@ export function useLanguageClient(): LanguageClientApi { >(null); useEffect(() => { + isMountedRef.current = true; const pending = pendingRef.current; return () => { 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 30514b6d9b4..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 @@ -195,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(); 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 393ce4ea094..cd91d0baa61 100644 --- a/libs/@hashintel/petrinaut/src/simulation/worker/use-simulation-worker.ts +++ b/libs/@hashintel/petrinaut/src/simulation/worker/use-simulation-worker.ts @@ -129,6 +129,8 @@ export function useSimulationWorker(): { } | null>(null); useEffect(() => { + isMountedRef.current = true; + return () => { isMountedRef.current = false; workerRef.current?.terminate(); From dfbc03e47387d59d4b40a45314ea62ecc6f7e65a Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Wed, 6 May 2026 11:37:54 +0200 Subject: [PATCH 27/29] fix: fail petrinaut bundle guard on missing entries --- .../scripts/characterize-build-output.mjs | 12 ++++++++++++ .../scripts/characterize-build-output.test.mjs | 18 ++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/libs/@hashintel/petrinaut/scripts/characterize-build-output.mjs b/libs/@hashintel/petrinaut/scripts/characterize-build-output.mjs index 06b380496e3..7fdb42f4e44 100644 --- a/libs/@hashintel/petrinaut/scripts/characterize-build-output.mjs +++ b/libs/@hashintel/petrinaut/scripts/characterize-build-output.mjs @@ -168,6 +168,10 @@ export function evaluateBundleGuard( ) { 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( @@ -300,6 +304,13 @@ export async function summarizeDistDirectory(distDir = DIST_DIR) { 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")); @@ -320,6 +331,7 @@ export async function summarizeDistDirectory(distDir = DIST_DIR) { return { assetGroups, exists: await pathExists(distDir), + missingEntryAssets, assets, largestAssets: assets.slice(0, 20), workerAssets: assets.filter((asset) => diff --git a/libs/@hashintel/petrinaut/scripts/characterize-build-output.test.mjs b/libs/@hashintel/petrinaut/scripts/characterize-build-output.test.mjs index 14c113fdb42..5b78ee321f6 100644 --- a/libs/@hashintel/petrinaut/scripts/characterize-build-output.test.mjs +++ b/libs/@hashintel/petrinaut/scripts/characterize-build-output.test.mjs @@ -78,6 +78,24 @@ describe("characterize-build-output", () => { } }); + 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"); From 040a649e5fc8da1112257547886b4dac62e9ee03 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Wed, 6 May 2026 11:39:39 +0200 Subject: [PATCH 28/29] fix: share visualizer component type --- .../src/simulation/simulator/compile-visualizer.ts | 4 ++-- .../subviews/place-visualizer/subview.tsx | 9 ++++----- 2 files changed, 6 insertions(+), 7 deletions(-) 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/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 20e8220d72f..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 @@ -20,15 +20,12 @@ import { import { CodeEditor } from "../../../../../../../monaco/code-editor"; import { PlaybackContext } from "../../../../../../../playback/context"; import { SimulationContext } from "../../../../../../../simulation/context"; +import type { VisualizerComponent } from "../../../../../../../simulation/simulator/compile-visualizer"; import { EditorContext } from "../../../../../../../state/editor-context"; import { usePlacePropertiesContext } from "../../context"; import { VisualizerErrorBoundary } from "./visualizer-error-boundary"; type ViewMode = "code" | "preview" | "split"; -type VisualizerComponent = React.FC<{ - tokens: Record[]; - parameters: Record; -}>; const contentStyle = css({ display: "flex", @@ -109,7 +106,9 @@ const VisualizerPreview: React.FC = () => { } try { - setVisualizerComponent(() => compileVisualizer(place.visualizerCode!)); + setVisualizerComponent(() => + compileVisualizer(place.visualizerCode!), + ); } catch (error) { // eslint-disable-next-line no-console console.error("Failed to compile visualizer code:", error); From 3148923cdb4f9c4d4440541d268983d87bc170c5 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Wed, 6 May 2026 17:07:23 +0200 Subject: [PATCH 29/29] docs: remove petrinaut refactor planning artifact --- memory/REFACTOR.md | 108 --------------------------------------------- 1 file changed, 108 deletions(-) delete mode 100644 memory/REFACTOR.md diff --git a/memory/REFACTOR.md b/memory/REFACTOR.md deleted file mode 100644 index 9230fdc5b9f..00000000000 --- a/memory/REFACTOR.md +++ /dev/null @@ -1,108 +0,0 @@ -## Problem Statement - -Petrinaut currently gives development tooling too little isolation between the editor shell and optional heavy subsystems. Mounting the editor reaches the language worker, Monaco setup, simulation worker, full panel registry, layout engine, charting code, examples, font CSS, and package CSS in one broad graph. CSS and small UI changes can therefore cause bundlers and transforms to revisit code that is unrelated to the edited feature. - -The attached analysis is broadly correct. The highest-confidence findings are the inline worker imports, broad React Compiler/Babel transform, static layout and visualizer compiler imports, eager provider initialization, static timeline chart registration, incomplete peer subpath externalization, missing explicit package exports, HASH frontend transpilation of Petrinaut, and broad Panda scanning. The main adjustment is sequencing: keep the current Vite/Rolldown build initially, fix graph topology first, and treat a `tsdown` migration as a later spike with separate acceptance criteria. - -## Solution - -Make Petrinaut cheaper to import and cheaper to rebuild by adding measurement first, then carving stable boundaries around workers, package metadata, heavy feature code, runtime initialization, and CSS generation. - -The target state is: - -- Worker internals are emitted as separate assets or chunks, not as huge inline worker string modules. -- React Compiler/Babel does not process worker modules or other non-React code unnecessarily. -- Heavy optional dependencies enter the graph only when their feature is used. -- Mounting Petrinaut does not immediately initialize Monaco, the language server worker, or the simulation worker. -- Package metadata and externalization express an intentional reusable-library boundary. -- HASH frontend can be tested against precompiled Petrinaut instead of transpiling it as app source. -- CSS and Panda scanning are narrowed enough that style edits do not wake unrelated editor internals. - -## Commits - -Status legend: `[x]` landed, `[ ]` pending. - -1. [x] Add a bundle graph characterization report that records emitted imports, worker artifact shape, main asset sizes, CSS size, and the presence of known heavy packages without introducing failing thresholds. Landed in `47a4e37a6d`. -2. [x] Add provider lifecycle characterization coverage for the public behavior of language services, Monaco-backed editors, and simulation controls so later lazy-initialization changes can preserve user-visible behavior. Landed in `c253325572`. -3. [x] Encode an explicit package-boundary policy with exports for the main module and stylesheet, complete dependency declarations for emitted bare imports, and subpath-aware externalization for peer packages. Landed in `2b91696058`. -4. [x] Stop inlining Petrinaut's application workers while preserving their existing message protocols and consumer URL compatibility. Landed in `28950117a3`. -5. [x] Narrow the React Compiler/Babel transform so worker modules and obvious non-React modules are outside the transform surface. Landed in `7542b42d89`. -6. [x] Lazy-load graph layout at the import and manual-layout call sites while keeping the same layout result and read-only behavior. Landed in `4215c86eaf`. -7. [x] Lazy-load visualizer compilation so the Babel standalone dependency is pulled only when previewing visualizer code. Landed in `b550252c97`. -8. [x] Refactor the bottom-panel registry so the timeline chart is loaded only when the simulation timeline tab is active. Landed in `40204300fa`. -9. [x] Defer simulation worker creation until the first simulation initialization while keeping reset, teardown, error, and backpressure behavior intact. Landed in `ea1faf95d5`. -10. [x] Defer language worker creation until diagnostics or editor language features are actually requested while preserving queued document updates and diagnostics publication. Landed in `4d13fdec9f`. -11. [x] Defer Monaco initialization until the first code editor renders, with the sync helpers subscribing only after Monaco and language services are available. Landed in `5ed3f914e2`. -12. [x] Move examples behind a lazy menu boundary so the editor shell does not statically import every example net. Landed in `781d48a873`. -13. [x] Reduce library CSS coupling by removing full font package imports from the component entry or moving them behind an explicit opt-in style contract. Landed in `a3240c4c26`. -14. [x] Split library and Storybook Panda scanning, and remove source-runtime constants from Panda config evaluation. Landed in `8306f1f9b2`. -15. [x] Test HASH frontend without transpiling Petrinaut and either remove Petrinaut from transpilation or document the remaining blockers with the bundle report. Landed in `4dd52cb214`. -16. [x] Turn the bundle graph characterization report into a regression guard with agreed thresholds once the new topology has landed. Landed in `4eafbe7ecd`. - -## Progress Notes - -Current branch: `ln/petrinaut-imports`. - -Latest verified slice: bundle graph regression guard (`4eafbe7ecd`). - -Verification used for landed implementation slices: - -- `yarn workspace @hashintel/petrinaut lint:eslint` -- `yarn workspace @hashintel/petrinaut lint:tsc` -- `yarn workspace @hashintel/petrinaut test:unit run` -- `yarn workspace @hashintel/petrinaut build` - -Latest full Petrinaut verification passed with 35 Vitest files and 485 tests. - -Current build signals after item 16: - -- `main.js`: approximately `589.9 KiB`, `157.0 KiB gzip`. -- CSS: approximately `763.4 KiB`, `152.6 KiB gzip`; full Fontsource packages are no longer imported by the component entry. -- Worker internals emit as separate `dist/assets/*worker*.js` files with tiny URL wrapper modules. -- `calculate-graph-layout`, `compile-visualizer`, and `simulation-timeline` now emit as lazy chunks. -- Example nets now emit as individual lazy chunks loaded only from the Load example submenu actions. -- The simulation worker is not created when the worker hook mounts; it is created on first simulation initialization. -- The language worker is not created by provider mount or structural initialization; diagnostics/document sync and language feature requests activate it and drain queued messages. -- Monaco is not initialized when `MonacoProvider` mounts; the first rendered `CodeEditor` asks for Monaco, and sync helpers subscribe after that promise exists. -- Babel deoptimization warnings for inline worker modules are gone. -- Library Panda scanning excludes Storybook files by default; Storybook has an explicit opt-in Panda config, and Panda config no longer imports runtime constants from `src`. -- HASH frontend imports `@hashintel/petrinaut/styles.css` and no longer lists `@hashintel/petrinaut` in `transpilePackages`. -- `yarn workspace @hashintel/petrinaut check:bundle` now fails when emitted main JS, CSS, font-face count, inline worker imports, or known heavy dependency signals regress past the current thresholds. - -Observed improvement from the original characterization baseline: - -- Baseline `main.js`: approximately `1.4 MiB`, `313 KiB gzip`. -- Current `main.js`: approximately `589.9 KiB`, `157.0 KiB gzip`. -- Baseline build time: `6.46s`; latest observed build: `5.31s`. - -Frontend verification for item 15: - -- `yarn workspace @apps/hash-frontend lint:tsc` currently fails before the Petrinaut boundary because generated GraphQL/API artifacts are missing and existing Block Protocol type imports do not match the installed package surface. -- `yarn workspace @apps/hash-frontend build` currently fails before the Petrinaut boundary because generated GraphQL artifacts such as `src/graphql/api-types.gen` and `libs/@local/hash-isomorphic-utils/src/graphql/fragment-types.gen.json` are missing. -- No Petrinaut package-resolution, stylesheet-export, or worker-asset error was reached in those frontend checks. - -Refactor plan items 1-16 are now landed. - -## Decision Document - -- Modules built or modified: Petrinaut package build configuration, package metadata, worker factories, language service provider, Monaco provider, simulation worker hook, editor shell, mutation provider, bottom-panel subview registry, visualizer preview, example loading, CSS/font entrypoints, Panda configuration, HASH frontend package consumption. -- Interface changes: add explicit package exports for the main entry and stylesheet; keep the current React component API stable; keep worker message protocols stable; keep editor mutation, simulation, playback, diagnostics, and code-editor context APIs source-compatible unless a later scoped commit proves a smaller API is needed. -- Architectural decisions: prioritize topology fixes over a bundler replacement; treat externalization and lazy-loading as separate concerns; bundle worker internals if necessary but keep them outside the main-thread module graph; prefer feature-level lazy boundaries over a broad editor rewrite; use measurement before thresholds. -- Schema changes, API contracts: no SDCPN schema changes; no persisted user setting changes planned; no public Petrinaut prop changes planned; package import contract gains an explicit stylesheet export. - -## Testing Decisions - -- Good tests here should verify behavior and build artifacts, not implementation details. For example, user-facing simulation controls still initialize, run, pause, reset, and report errors; code editors still provide diagnostics/completion/hover/signature help; graph layout still produces positions for imports and manual layout; visualizer previews still compile valid code and show errors for invalid code. -- Existing coverage is useful for simulation internals, playback behavior, mutation behavior, validation, import/export, LSP helpers, and the simulation worker hook. Some current worker-hook tests assert eager worker creation and should be rewritten around observable behavior when the lazy boundary lands. -- Coverage gaps to close before risky changes: language client provider lifecycle, Monaco provider lazy initialization, emitted bundle import auditing, worker asset shape, package export consumption, and HASH frontend consumption of prebuilt Petrinaut. -- Verification stack for each implementation commit: Petrinaut eslint, Petrinaut type check, Petrinaut unit tests, Petrinaut build, then HASH frontend build or targeted Next compilation checks when package exports, worker URLs, CSS imports, or transpilation settings change. -- Bundle verification should inspect emitted assets for inline worker wrappers, top-level imports of known heavy dependencies, undeclared bare imports, CSS size, and worker asset sizes. - -## Out of Scope - -- Replacing Vite/Rolldown with `tsdown` in this refactor. -- Rewriting the editor shell, state model, or SDCPN schema. -- Changing user-visible editor workflows beyond loading delays for optional systems. -- Replacing React Flow, Monaco, Babel standalone, TypeScript LSP, or uPlot with different libraries. -- A broad icon-system migration. -- Full performance-budget enforcement before the graph has been reshaped and measured.