diff --git a/scripts/dev/tauri-dev-processes.cjs b/scripts/dev/tauri-dev-processes.cjs index 066f4cacf..d473a196d 100644 --- a/scripts/dev/tauri-dev-processes.cjs +++ b/scripts/dev/tauri-dev-processes.cjs @@ -1,6 +1,26 @@ const http = require("node:http"); const https = require("node:https"); +const LIGHT_DEV_WEBPACK_MAX_OLD_SPACE_MIB = 1792; + +function applyLightDevWebpackMemoryLimit(env, { lightDev = false } = {}) { + const nextEnv = { ...env }; + if (!lightDev) return nextEnv; + + const nodeOptions = nextEnv.NODE_OPTIONS?.trim() || ""; + const hasExplicitLimit = + /(?:^|\s)--max[-_]old[-_]space[-_]size(?:=|\s)/u.test(nodeOptions); + if (hasExplicitLimit) return nextEnv; + + nextEnv.NODE_OPTIONS = [ + nodeOptions, + `--max-old-space-size=${LIGHT_DEV_WEBPACK_MAX_OLD_SPACE_MIB}`, + ] + .filter(Boolean) + .join(" "); + return nextEnv; +} + function createTauriArgs({ features = [], devUrl } = {}) { const args = ["dev"]; if (features.length > 0) { @@ -138,6 +158,7 @@ async function waitForDevServerAsset( } module.exports = { + applyLightDevWebpackMemoryLimit, createDevUrl, createFrontendScriptName, createTauriArgs, diff --git a/scripts/dev/tauri-dev-processes.test.cjs b/scripts/dev/tauri-dev-processes.test.cjs index d78df5914..a482ebcd3 100644 --- a/scripts/dev/tauri-dev-processes.test.cjs +++ b/scripts/dev/tauri-dev-processes.test.cjs @@ -3,6 +3,7 @@ const fs = require("node:fs"); const test = require("node:test"); const { + applyLightDevWebpackMemoryLimit, createDevUrl, createTauriArgs, formatElapsedMs, @@ -12,6 +13,33 @@ const { waitForDevServerAsset, } = require("./tauri-dev-processes.cjs"); +test("light webpack gets a bounded default heap without overriding user options", () => { + assert.deepEqual( + applyLightDevWebpackMemoryLimit( + { NODE_OPTIONS: "--trace-warnings", KEEP: "yes" }, + { lightDev: true } + ), + { + NODE_OPTIONS: "--trace-warnings --max-old-space-size=1792", + KEEP: "yes", + } + ); + assert.deepEqual( + applyLightDevWebpackMemoryLimit( + { NODE_OPTIONS: "--max_old_space_size=3072" }, + { lightDev: true } + ), + { NODE_OPTIONS: "--max_old_space_size=3072" } + ); + assert.deepEqual( + applyLightDevWebpackMemoryLimit( + { NODE_OPTIONS: "--trace-warnings" }, + { lightDev: false } + ), + { NODE_OPTIONS: "--trace-warnings" } + ); +}); + const tauriDevSource = fs.readFileSync("scripts/dev/tauri.js", "utf8"); const tauriLauncherSource = fs.readFileSync( "scripts/dev/tauri-launcher.cjs", @@ -81,8 +109,14 @@ test("tauri dev npm scripts use a cross-platform launcher", () => { }); test("tauri dev launcher detaches Unix stdin without requiring setsid", () => { - assert.match(tauriLauncherSource, /detached:\s*process\.platform !== "win32"/); - assert.match(tauriLauncherSource, /stdio:\s*\["ignore",\s*"inherit",\s*"inherit"\]/); + assert.match( + tauriLauncherSource, + /detached:\s*process\.platform !== "win32"/ + ); + assert.match( + tauriLauncherSource, + /stdio:\s*\["ignore",\s*"inherit",\s*"inherit"\]/ + ); assert.match(tauriLauncherSource, /env\.ORGII_LIGHT_DEV = "true"/); assert.match(tauriLauncherSource, /SIGINT:\s*130/); assert.match(tauriLauncherSource, /SIGTERM:\s*143/); diff --git a/scripts/dev/tauri.js b/scripts/dev/tauri.js index 6b58c9bdf..111220e28 100755 --- a/scripts/dev/tauri.js +++ b/scripts/dev/tauri.js @@ -18,6 +18,7 @@ const { applyDefaultDiagnosticsEndpoint, } = require("../tauri/diagnostics-endpoint.cjs"); const { + applyLightDevWebpackMemoryLimit, createDevUrl, createFrontendScriptName, createTauriArgs, @@ -494,9 +495,8 @@ function pipeProcessLines(childProcess, onLine) { } function startFrontendDev() { - const scriptName = createFrontendScriptName({ - lightDev: process.env.ORGII_LIGHT_DEV === "true", - }); + const lightDev = process.env.ORGII_LIGHT_DEV === "true"; + const scriptName = createFrontendScriptName({ lightDev }); const pnpmCli = createPnpmCliCommand(); setWebpackStatus("starting dev server..."); @@ -508,7 +508,7 @@ function startFrontendDev() { { stdio: ["ignore", "pipe", "pipe"], cwd: rootDir, - env: cleanChildEnv(), + env: applyLightDevWebpackMemoryLimit(cleanChildEnv(), { lightDev }), } ); diff --git a/scripts/dev/webpack-config-light.test.cjs b/scripts/dev/webpack-config-light.test.cjs index 38301c192..eb8089e7e 100644 --- a/scripts/dev/webpack-config-light.test.cjs +++ b/scripts/dev/webpack-config-light.test.cjs @@ -49,6 +49,50 @@ test("light dev disables webpack dev-server browser client", () => { assert.equal(htmlPlugin?.userOptions?.retryMainScriptLoad, false); }); +test("light development cache stays memory-only and bounded", () => { + const config = withEnv( + { + ORGII_LIGHT_DEV: "true", + FAST_DEV: "true", + DEV_SOURCEMAPS: "false", + }, + () => createWebpackConfig({}, { mode: "development" }) + ); + + assert.deepEqual(config.cache, { + type: "memory", + maxGenerations: 1, + }); +}); + +test("standard development keeps its filesystem cache", () => { + const config = withEnv( + { + ORGII_LIGHT_DEV: null, + FAST_DEV: null, + DEV_SOURCEMAPS: null, + }, + () => createWebpackConfig({}, { mode: "development" }) + ); + + assert.equal(config.cache.type, "filesystem"); + assert.equal(config.cache.version, "dev-11"); +}); + +test("production keeps its isolated filesystem cache", () => { + for (const fastProd of [false, true]) { + const config = withEnv( + { + FAST_PROD: fastProd ? "true" : null, + }, + () => createWebpackConfig({}, { mode: "production" }) + ); + + assert.equal(config.cache.type, "filesystem"); + assert.equal(config.cache.version, `${fastProd ? "prod-fast" : "prod"}-11`); + } +}); + test("retrying main script loader disables static HTML injection in dev", () => { const config = withEnv( { diff --git a/src/modules/ProjectManager/WorkItems/components/GitHubIssueThreadSurface.test.ts b/src/modules/ProjectManager/WorkItems/components/GitHubIssueThreadSurface.test.ts index 3e2b58099..36ce28e4a 100644 --- a/src/modules/ProjectManager/WorkItems/components/GitHubIssueThreadSurface.test.ts +++ b/src/modules/ProjectManager/WorkItems/components/GitHubIssueThreadSurface.test.ts @@ -10,12 +10,26 @@ import GitHubIssueThreadSurface, { import type { GitHubIssueInteractionConfig } from "./WorkItemContent/types"; import { toggleExternalAssigneeIds } from "./WorkItemProperties/AssigneePropertyField"; +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + vi.mock("@src/components/IntegrationIcon", () => ({ default: ({ type }: { type: string }) => React.createElement("span", { "data-integration-icon": type }), })); +// The product renderer is lazy-loaded behind Suspense. These server-rendered +// structure tests need a synchronous leaf so React 19 does not abort static +// markup generation while the dynamic Markdown chunk is loading. +vi.mock("@src/components/MarkDown", () => ({ + default: ({ textContent }: { textContent: string }) => + React.createElement("div", { "data-testid": "markdown" }, textContent), +})); + vi.mock("@src/modules/shared/components/RichMarkdownEditor", () => ({ + RICH_MARKDOWN_COMPOSER_TOOLBAR_CLASS: + "!min-h-0 !border-b-0 !pb-0.5 [&_svg]:size-3.5", default: ({ dataTestId }: { dataTestId?: string }) => React.createElement("div", { "data-testid": dataTestId }), })); diff --git a/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/content/IssuesContent/__tests__/IssueDetailExternalLinkButton.test.ts b/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/content/IssuesContent/__tests__/IssueDetailExternalLinkButton.test.ts index cbdb12524..b4246f1b4 100644 --- a/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/content/IssuesContent/__tests__/IssueDetailExternalLinkButton.test.ts +++ b/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/content/IssuesContent/__tests__/IssueDetailExternalLinkButton.test.ts @@ -33,7 +33,17 @@ vi.mock("@src/components/IntegrationIcon", () => ({ createElement("span", { "data-integration-icon": type }), })); +// The product renderer is lazy-loaded behind Suspense. These server-rendered +// structure tests need a synchronous leaf so React 19 does not abort static +// markup generation while the dynamic Markdown chunk is loading. +vi.mock("@src/components/MarkDown", () => ({ + default: ({ textContent }: { textContent: string }) => + createElement("div", { "data-testid": "markdown" }, textContent), +})); + vi.mock("@src/modules/shared/components/RichMarkdownEditor", () => ({ + RICH_MARKDOWN_COMPOSER_TOOLBAR_CLASS: + "!min-h-0 !border-b-0 !pb-0.5 [&_svg]:size-3.5", default: forwardRef(function MockRichMarkdownEditor( { appearance, diff --git a/webpack.config.js b/webpack.config.js index 0ad0e91df..1cd44d3fb 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -62,24 +62,33 @@ module.exports = (env, argv) => { chunkFilename: isProduction ? "[name].[contenthash].js" : "[name].js", clean: true, }, - cache: { - type: "filesystem", - // FAST_PROD swaps both the transpiler and minimizer. Keep it in a - // separate filesystem-cache namespace so webpack cannot reuse cached - // runtime-condition code generated by the regular production pipeline. - // Mixing those caches can leave async chunks guarded by a stale runtime - // id (for example `__webpack_require__.j == 9121` while the emitted - // runtime id is `49121`), which turns otherwise valid imports into - // `undefined` only in the packaged app. - version: `${ - isProduction ? (useFastProd ? "prod-fast" : "prod") : "dev" - }-11`, - buildDependencies: { - config: [__filename], - }, - // Don't compress - avoids sass serialization issues - compression: false, - }, + cache: isLightDev + ? { + // Webpack's filesystem cache can serialize gigabyte-scale pack files + // for this app and raises the light-dev rebuild RSS ceiling. Retain + // only one unused generation and never persist the light-mode + // compilation graph across restarts. + type: "memory", + maxGenerations: 1, + } + : { + type: "filesystem", + // FAST_PROD swaps both the transpiler and minimizer. Keep it in a + // separate filesystem-cache namespace so webpack cannot reuse cached + // runtime-condition code generated by the regular production pipeline. + // Mixing those caches can leave async chunks guarded by a stale runtime + // id (for example `__webpack_require__.j == 9121` while the emitted + // runtime id is `49121`), which turns otherwise valid imports into + // `undefined` only in the packaged app. + version: isProduction + ? `${useFastProd ? "prod-fast" : "prod"}-11` + : "dev-11", + buildDependencies: { + config: [__filename], + }, + // Don't compress - avoids sass serialization issues + compression: false, + }, // Snapshot: use timestamps for node_modules instead of content hashing. // node_modules rarely change during a dev session; timestamp checks are much faster. snapshot: {