From ef5389dbd10e03ba0a2f39c6608060ce3941a373 Mon Sep 17 00:00:00 2001 From: scil Date: Sat, 10 Oct 2026 10:05:20 -0700 Subject: [PATCH 1/2] fix(start-plugin-core): release the start compiler when a build bundle closes --- .../start-compiler-release-after-build.md | 5 ++ .../src/vite/start-compiler-plugin/plugin.ts | 10 +++ .../tests/vite/start-compiler-release.test.ts | 71 +++++++++++++++++++ 3 files changed, 86 insertions(+) create mode 100644 .changeset/start-compiler-release-after-build.md create mode 100644 packages/start-plugin-core/tests/vite/start-compiler-release.test.ts diff --git a/.changeset/start-compiler-release-after-build.md b/.changeset/start-compiler-release-after-build.md new file mode 100644 index 00000000000..a5e59b0283c --- /dev/null +++ b/.changeset/start-compiler-release-after-build.md @@ -0,0 +1,5 @@ +--- +'@tanstack/start-plugin-core': patch +--- + +Release each environment's Start compiler when its build bundle closes. `vite build` builds the client, SSR and server environments once each in one process, so the compiler kept from an earlier environment, and the finished module graph its load/resolve closures held, stayed in memory for the rest of the build and could push large apps past the heap limit. diff --git a/packages/start-plugin-core/src/vite/start-compiler-plugin/plugin.ts b/packages/start-plugin-core/src/vite/start-compiler-plugin/plugin.ts index 3a93e286d11..ee24d75bcba 100644 --- a/packages/start-plugin-core/src/vite/start-compiler-plugin/plugin.ts +++ b/packages/start-plugin-core/src/vite/start-compiler-plugin/plugin.ts @@ -409,6 +409,16 @@ export function startCompilerPlugin( compilers.delete(this.environment.name) } }, + closeBundle() { + if (this.environment.mode === 'build') { + // `vite build` bundles each environment once, in one process, so the + // reset in buildStart never runs again for this environment. Drop the + // compiler now: its load/resolve closures hold this hook context and, + // through it, the finished module graph, which would otherwise stay + // alive while the remaining environments build. + compilers.delete(this.environment.name) + } + }, async watchChange(id) { if (bundledDev && this.environment.mode === 'dev') { if (environment.type === 'server') { diff --git a/packages/start-plugin-core/tests/vite/start-compiler-release.test.ts b/packages/start-plugin-core/tests/vite/start-compiler-release.test.ts new file mode 100644 index 00000000000..ecc65d4e3c7 --- /dev/null +++ b/packages/start-plugin-core/tests/vite/start-compiler-release.test.ts @@ -0,0 +1,71 @@ +import { setFlagsFromString } from 'node:v8' +import { runInNewContext } from 'node:vm' +import { describe, expect, test } from 'vitest' +import { VITE_ENVIRONMENT_NAMES } from '../../src/constants' +import { startCompilerPlugin } from '../../src/vite/start-compiler-plugin/plugin' + +setFlagsFromString('--expose-gc') +const collectGarbage = runInNewContext('gc') as () => void + +function createClientPlugin() { + const plugins = ( + startCompilerPlugin({ + framework: 'react', + environments: [{ name: VITE_ENVIRONMENT_NAMES.client, type: 'client' }], + providerEnvName: VITE_ENVIRONMENT_NAMES.server, + }) as Array + ).flat() + + return plugins.find( + (plugin) => + plugin.name === + `tanstack-start-core::server-fn:${VITE_ENVIRONMENT_NAMES.client}`, + ) +} + +// Runs one transform in a fresh hook context, the way a build's plugin driver +// does, and hands back only a weak reference to that context. +async function transformInFreshContext(plugin: any) { + const context = { + environment: { + name: VITE_ENVIRONMENT_NAMES.client, + mode: 'build', + config: { experimental: {} }, + }, + load: () => Promise.resolve(null), + resolve: () => Promise.resolve(null), + error: (message: string) => { + throw new Error(message) + }, + warn: () => {}, + } + + await plugin.transform.handler.call( + context, + 'export const value = 1', + '/project/src/value.ts', + ) + + return new WeakRef(context) +} + +describe('startCompilerPlugin in a build', () => { + test('releases the hook context once the environment bundle closes', async () => { + const plugin = createClientPlugin() + plugin.buildStart.call({ + environment: { name: VITE_ENVIRONMENT_NAMES.client, mode: 'build' }, + }) + + const contextRef = await transformInFreshContext(plugin) + + plugin.closeBundle?.call({ + environment: { name: VITE_ENVIRONMENT_NAMES.client, mode: 'build' }, + }) + + // A WeakRef keeps its target alive until the current job ends. + await new Promise((resolve) => setTimeout(resolve, 0)) + collectGarbage() + + expect(contextRef.deref()).toBeUndefined() + }) +}) From 994f3a182cd97160439d5ad48965464c7b95c639 Mon Sep 17 00:00:00 2001 From: scil Date: Sat, 10 Oct 2026 11:39:31 -0700 Subject: [PATCH 2/2] test(start-plugin-core): retry collection before asserting the context was released --- .../tests/vite/start-compiler-release.test.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/start-plugin-core/tests/vite/start-compiler-release.test.ts b/packages/start-plugin-core/tests/vite/start-compiler-release.test.ts index ecc65d4e3c7..0af40d658ad 100644 --- a/packages/start-plugin-core/tests/vite/start-compiler-release.test.ts +++ b/packages/start-plugin-core/tests/vite/start-compiler-release.test.ts @@ -4,8 +4,11 @@ import { describe, expect, test } from 'vitest' import { VITE_ENVIRONMENT_NAMES } from '../../src/constants' import { startCompilerPlugin } from '../../src/vite/start-compiler-plugin/plugin' +// Expose `gc()` without the process-wide `--expose-gc` flag, then turn the flag +// off again once we hold a reference to it. setFlagsFromString('--expose-gc') const collectGarbage = runInNewContext('gc') as () => void +setFlagsFromString('--no-expose-gc') function createClientPlugin() { const plugins = ( @@ -62,9 +65,12 @@ describe('startCompilerPlugin in a build', () => { environment: { name: VITE_ENVIRONMENT_NAMES.client, mode: 'build' }, }) - // A WeakRef keeps its target alive until the current job ends. - await new Promise((resolve) => setTimeout(resolve, 0)) - collectGarbage() + // One collection is not guaranteed to reclaim the target, and a WeakRef + // keeps it alive until the current job ends, so yield before each pass. + for (let attempt = 0; attempt < 10 && contextRef.deref(); attempt++) { + await new Promise((resolve) => setTimeout(resolve, 0)) + collectGarbage() + } expect(contextRef.deref()).toBeUndefined() })