From 429f786adea4bdae5a24a6fe758255671c7668b0 Mon Sep 17 00:00:00 2001 From: devfive Date: Fri, 28 Aug 2026 22:01:31 +0900 Subject: [PATCH 01/22] feat: profile Turbopack extraction --- packages/next-plugin/README.md | 20 +++++++ .../next-plugin/src/__tests__/profile.test.ts | 59 +++++++++++++++++++ packages/next-plugin/src/coordinator.ts | 28 +++++++++ packages/next-plugin/src/plugin.ts | 31 +++++++++- packages/next-plugin/src/profile.ts | 26 ++++++++ 5 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 packages/next-plugin/src/__tests__/profile.test.ts create mode 100644 packages/next-plugin/src/profile.ts diff --git a/packages/next-plugin/README.md b/packages/next-plugin/README.md index 95e9221f..8eb44d97 100644 --- a/packages/next-plugin/README.md +++ b/packages/next-plugin/README.md @@ -192,3 +192,23 @@ custom `distDir`) to `include`. ```tsx ``` + +## Turbopack build profiling + +Set `DEVUP_UI_PROFILE=1` for an opt-in, structured timing log during a +Turbopack build. + +```bash +DEVUP_UI_PROFILE=1 bun run build +``` + +```powershell +$env:DEVUP_UI_PROFILE = '1'; bun run build +``` + +Each `[devup-ui:profile]` JSON entry reports one phase. `next.graph` measures +the static import-graph pre-pass, `next.prewarm` measures production extraction +before Turbopack starts loaders, and `coordinator.extract` separates request, +WASM extraction, CSS/state serialization, and write time for each loaded +module. The setting is disabled by default and does not collect timings or +write logs when it is absent. diff --git a/packages/next-plugin/src/__tests__/profile.test.ts b/packages/next-plugin/src/__tests__/profile.test.ts new file mode 100644 index 00000000..b2e902e5 --- /dev/null +++ b/packages/next-plugin/src/__tests__/profile.test.ts @@ -0,0 +1,59 @@ +import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test' + +import { + elapsedMs, + isProfileEnabled, + profileStart, + reportProfile, +} from '../profile' + +let originalEnv: NodeJS.ProcessEnv + +beforeEach(() => { + originalEnv = { ...process.env } +}) + +afterEach(() => { + process.env = originalEnv +}) + +describe('profile', () => { + it('is disabled unless explicitly enabled', () => { + delete process.env.DEVUP_UI_PROFILE + const consoleSpy = spyOn(console, 'info').mockImplementation(() => {}) + + expect(isProfileEnabled()).toBe(false) + reportProfile('next.prewarm') + expect(consoleSpy).not.toHaveBeenCalled() + consoleSpy.mockRestore() + }) + + it('reports structured measurements when enabled', () => { + process.env.DEVUP_UI_PROFILE = '1' + const consoleSpy = spyOn(console, 'info').mockImplementation(() => {}) + + reportProfile('next.prewarm', { durationMs: 12.34, files: 2 }) + + expect(consoleSpy).toHaveBeenCalledWith( + '[devup-ui:profile] {"phase":"next.prewarm","durationMs":12.34,"files":2}', + ) + consoleSpy.mockRestore() + }) + + it('returns a non-negative elapsed duration', () => { + expect(elapsedMs(performance.now())).toBeGreaterThanOrEqual(0) + }) + + it('does not start a timer while disabled', () => { + delete process.env.DEVUP_UI_PROFILE + + expect(profileStart()).toBeUndefined() + expect(elapsedMs(undefined)).toBeUndefined() + }) + + it('starts a timer when enabled', () => { + process.env.DEVUP_UI_PROFILE = '1' + + expect(profileStart()).toBeTypeOf('number') + }) +}) diff --git a/packages/next-plugin/src/coordinator.ts b/packages/next-plugin/src/coordinator.ts index 31866fdd..0990a32a 100644 --- a/packages/next-plugin/src/coordinator.ts +++ b/packages/next-plugin/src/coordinator.ts @@ -11,6 +11,8 @@ import { getCss, } from '@devup-ui/wasm' +import { elapsedMs, profileStart, reportProfile } from './profile' + export interface CoordinatorOptions { package: string cssDir: string @@ -344,6 +346,7 @@ export function startCoordinator(options: CoordinatorOptions): { } if (req.method === 'GET' && url.pathname === '/css') { + const cssStartedAt = profileStart() const fileNumParam = url.searchParams.get('fileNum') const importMainCss = url.searchParams.get('importMainCss') === 'true' const shouldWait = url.searchParams.get('waitForIdle') === 'true' @@ -364,10 +367,16 @@ export function startCoordinator(options: CoordinatorOptions): { res.writeHead(200, { 'Content-Type': 'text/css' }) res.end(getCss(fileNum ?? null, importMainCss)) + reportProfile('coordinator.css', { + durationMs: elapsedMs(cssStartedAt), + fileNum, + waitForIdle: shouldWait, + }) return } if (req.method === 'POST' && url.pathname === '/extract') { + const requestStartedAt = profileStart() // Reserve a "start slot" before yielding on `await readBody`. Without // this counter, `waitForIdle` could observe activeExtractions=0 in the // window between the request hitting this handler and `activeExtractions++` @@ -377,7 +386,9 @@ export function startCoordinator(options: CoordinatorOptions): { let promotedToActive = false let extractedFilename: string | undefined try { + const bodyStartedAt = profileStart() const body = JSON.parse(await readBody(req)) + const bodyDurationMs = elapsedMs(bodyStartedAt) activeExtractions++ pendingExtractStarts-- promotedToActive = true @@ -394,6 +405,7 @@ export function startCoordinator(options: CoordinatorOptions): { ) if (!relCssDir.startsWith('./')) relCssDir = `./${relCssDir}` + const extractStartedAt = profileStart() const result = codeExtract( filename, code, @@ -404,6 +416,7 @@ export function startCoordinator(options: CoordinatorOptions): { true, importAliases, ) + const extractDurationMs = elapsedMs(extractStartedAt) // When singleCss=false, rewrite per-file CSS imports so Turbopack can resolve them. // Instead of importing "devup-ui-79.css" (which doesn't exist as a resolvable module), @@ -417,6 +430,7 @@ export function startCoordinator(options: CoordinatorOptions): { ) } + const snapshotStartedAt = profileStart() const promises: Promise[] = [] if (result.updatedBaseStyle) { @@ -461,6 +475,8 @@ export function startCoordinator(options: CoordinatorOptions): { } } + const snapshotDurationMs = elapsedMs(snapshotStartedAt) + const writeStartedAt = profileStart() await Promise.all(promises) res.writeHead(200, { 'Content-Type': 'application/json' }) @@ -472,6 +488,18 @@ export function startCoordinator(options: CoordinatorOptions): { updatedBaseStyle: result.updatedBaseStyle, }), ) + reportProfile('coordinator.extract', { + bodyMs: bodyDurationMs, + durationMs: elapsedMs(requestStartedAt), + extractMs: extractDurationMs, + filename, + sourceBytes: + requestStartedAt === undefined + ? undefined + : Buffer.byteLength(code), + snapshotMs: snapshotDurationMs, + writeMs: elapsedMs(writeStartedAt), + }) } catch (error) { res.writeHead(500, { 'Content-Type': 'application/json' }) res.end( diff --git a/packages/next-plugin/src/plugin.ts b/packages/next-plugin/src/plugin.ts index 4dca4ff7..6833b58b 100644 --- a/packages/next-plugin/src/plugin.ts +++ b/packages/next-plugin/src/plugin.ts @@ -45,6 +45,7 @@ import { type NextConfig } from 'next' import { startCoordinator } from './coordinator' import { collectProductionPrewarmFiles } from './prewarm' +import { elapsedMs, profileStart, reportProfile } from './profile' type DevupUiNextPluginOptions = Omit< Partial, @@ -61,6 +62,7 @@ export function DevupUI( config: NextConfig, options: DevupUiNextPluginOptions = {}, ): NextConfig { + const pluginStartedAt = profileStart() const isTurbo = process.env.TURBOPACK === '1' || process.env.TURBOPACK === 'auto' // turbopack is now stable, TURBOPACK is set to auto without any flags @@ -154,6 +156,7 @@ export function DevupUI( // `[]` (idle fallback) when no routes are detected or the pre-pass fails. let expectedBaseFiles: string[] = [] let staticGraph: StaticImportGraph | undefined + const graphStartedAt = profileStart() try { const srcDir = resolve(process.cwd(), 'src') const tsconfigPath = resolve(process.cwd(), 'tsconfig.json') @@ -209,9 +212,18 @@ export function DevupUI( ) } } + reportProfile('next.graph', { + durationMs: elapsedMs(graphStartedAt), + files: staticGraph.files.length, + expectedBaseFiles: expectedBaseFiles.length, + }) } catch { // Pre-pass is best-effort; on failure canonical() is the identity (no // merge) and atom hoisting stays off. + reportProfile('next.graph', { + durationMs: elapsedMs(graphStartedAt), + failed: true, + }) } // Turbopack can request a CSS module before it has scheduled every source @@ -224,6 +236,8 @@ export function DevupUI( // keys/options and is idempotent. const prewarmedFiles: string[] = [] if (!watch && staticGraph) { + const prewarmStartedAt = profileStart() + let prewarmSourceBytes = 0 const cwd = process.cwd() const prewarmFiles = collectProductionPrewarmFiles({ cwd, @@ -238,9 +252,13 @@ export function DevupUI( dirname(resourcePath), cssDir, ).replaceAll('\\', '/')}` + const source = readFileSync(resourcePath, 'utf-8') + if (prewarmStartedAt !== undefined) { + prewarmSourceBytes += Buffer.byteLength(source) + } codeExtract( filename, - readFileSync(resourcePath, 'utf-8'), + source, libPackage, relCssDir, singleCss, @@ -250,6 +268,11 @@ export function DevupUI( ) prewarmedFiles.push(filename) } + reportProfile('next.prewarm', { + durationMs: elapsedMs(prewarmStartedAt), + files: prewarmedFiles.length, + sourceBytes: prewarmSourceBytes, + }) } // create devup-ui.css file @@ -277,6 +300,12 @@ export function DevupUI( expectedBaseFiles, prewarmedFiles, }) + reportProfile('next.setup', { + durationMs: elapsedMs(pluginStartedAt), + singleCss, + prewarmedFiles: prewarmedFiles.length, + watch, + }) // Cleanup on exit process.on('exit', () => { diff --git a/packages/next-plugin/src/profile.ts b/packages/next-plugin/src/profile.ts new file mode 100644 index 00000000..35f75aa4 --- /dev/null +++ b/packages/next-plugin/src/profile.ts @@ -0,0 +1,26 @@ +type ProfileFields = Record + +export function isProfileEnabled(): boolean { + return process.env.DEVUP_UI_PROFILE === '1' +} + +export function reportProfile(phase: string, fields: ProfileFields = {}): void { + if (!isProfileEnabled()) return + + console.info( + `[devup-ui:profile] ${JSON.stringify({ + phase, + ...fields, + })}`, + ) +} + +export function profileStart(): number | undefined { + return isProfileEnabled() ? performance.now() : undefined +} + +export function elapsedMs(start: number | undefined): number | undefined { + if (start === undefined) return undefined + + return Number((performance.now() - start).toFixed(2)) +} From 32acf461e5f868bc9c136205a562962634b4b73b Mon Sep 17 00:00:00 2001 From: devfive Date: Fri, 28 Aug 2026 22:05:53 +0900 Subject: [PATCH 02/22] perf: reuse single CSS prewarm output --- .../src/__tests__/coordinator.test.ts | 48 ++++++++++++++++++ packages/next-plugin/src/coordinator.ts | 50 ++++++++++++++----- packages/next-plugin/src/plugin.ts | 16 +++++- 3 files changed, 100 insertions(+), 14 deletions(-) diff --git a/packages/next-plugin/src/__tests__/coordinator.test.ts b/packages/next-plugin/src/__tests__/coordinator.test.ts index dbdf00d3..c111a838 100644 --- a/packages/next-plugin/src/__tests__/coordinator.test.ts +++ b/packages/next-plugin/src/__tests__/coordinator.test.ts @@ -177,6 +177,54 @@ describe('coordinator', () => { coordinator.close() }) + it('reuses byte-identical singleCss prewarm output', async () => { + const source = 'const x = ' + const options = makeOptions({ + singleCss: true, + prewarmedOutputs: new Map([ + [ + 'src/App.tsx', + { + code: 'transformed prewarm code', + css: 'prewarmed css', + cssFile: 'devup-ui.css', + map: '{"version":3}', + source, + updatedBaseStyle: true, + }, + ], + ]), + }) + const coordinator = startCoordinator(options) + + await new Promise((r) => setTimeout(r, 100)) + + const portStr = (writeFileSyncSpy.mock.calls[0] as [string, string])[1] + const port = parseInt(portStr) + const res = await httpRequest( + port, + 'POST', + '/extract', + JSON.stringify({ + filename: 'src/App.tsx', + code: source, + resourcePath: join(process.cwd(), 'src', 'App.tsx'), + }), + ) + + expect(res.status).toBe(200) + expect(JSON.parse(res.body)).toMatchObject({ + code: 'transformed prewarm code', + map: '{"version":3}', + cssFile: 'devup-ui.css', + updatedBaseStyle: true, + }) + expect(codeExtractSpy).not.toHaveBeenCalled() + expect(writeFileSpy).not.toHaveBeenCalled() + + coordinator.close() + }) + it('should rewrite per-file CSS imports when singleCss=false', async () => { codeExtractSpy.mockReturnValue({ code: 'import "./../../df/devup-ui/devup-ui-79.css";\nimport "./../../df/devup-ui/devup-ui-3.css";\nconst x = 1;', diff --git a/packages/next-plugin/src/coordinator.ts b/packages/next-plugin/src/coordinator.ts index 0990a32a..5e3257b2 100644 --- a/packages/next-plugin/src/coordinator.ts +++ b/packages/next-plugin/src/coordinator.ts @@ -44,6 +44,12 @@ export interface CoordinatorOptions { * shared WASM sheet even though their loaders have not POSTed `/extract` yet. */ prewarmedFiles?: string[] + /** + * Production `singleCss` outputs extracted before Turbopack starts. A loader + * that receives byte-identical source can return this result without a + * second WASM extraction; the shared sheet is already populated. + */ + prewarmedOutputs?: Map /** * Idle threshold (ms) for the base-css `/css` wait. Defaults to 2500. * FALLBACK ONLY — used when `expectedBaseFiles` is empty (no deterministic @@ -65,6 +71,15 @@ export interface CoordinatorOptions { maxWaitMs?: number } +export interface PrewarmedOutput { + code: string + css: string + cssFile: string + map?: string + source: string + updatedBaseStyle: boolean +} + // Latest-Wins Coalescing Serializer. // // Multiple Turbopack workers may call /extract concurrently, each producing @@ -325,6 +340,7 @@ export function startCoordinator(options: CoordinatorOptions): { importAliases, coordinatorPortFile, } = options + const prewarmedOutputs = options.prewarmedOutputs ?? new Map() idleThresholdMs = options.idleThresholdMs ?? 2500 quietMs = options.quietMs ?? 10_000 @@ -405,17 +421,26 @@ export function startCoordinator(options: CoordinatorOptions): { ) if (!relCssDir.startsWith('./')) relCssDir = `./${relCssDir}` + // The production prewarm exists to make the CSS snapshot complete + // before Turbopack requests it. In single-CSS mode the generated CSS + // is already in that snapshot, so re-running WASM here is pure work. + // Require exact source equality because Turbopack may hand a loader + // code modified by an earlier transform. + const prewarmed = singleCss ? prewarmedOutputs.get(filename) : undefined + const cacheHit = prewarmed?.source === code const extractStartedAt = profileStart() - const result = codeExtract( - filename, - code, - libPackage, - relCssDir, - singleCss, - false, - true, - importAliases, - ) + const result = cacheHit + ? prewarmed + : codeExtract( + filename, + code, + libPackage, + relCssDir, + singleCss, + false, + true, + importAliases, + ) const extractDurationMs = elapsedMs(extractStartedAt) // When singleCss=false, rewrite per-file CSS imports so Turbopack can resolve them. @@ -433,7 +458,7 @@ export function startCoordinator(options: CoordinatorOptions): { const snapshotStartedAt = profileStart() const promises: Promise[] = [] - if (result.updatedBaseStyle) { + if (!cacheHit && result.updatedBaseStyle) { promises.push( safeWrite( join(cssDir, 'devup-ui.css'), @@ -442,7 +467,7 @@ export function startCoordinator(options: CoordinatorOptions): { ) } - if (result.cssFile) { + if (!cacheHit && result.cssFile) { const fileNum = getFileNumByFilename(result.cssFile) if (fileNum != null) { // Record this bucket's fileNum -> canonical bucket path so /css can @@ -490,6 +515,7 @@ export function startCoordinator(options: CoordinatorOptions): { ) reportProfile('coordinator.extract', { bodyMs: bodyDurationMs, + cacheHit, durationMs: elapsedMs(requestStartedAt), extractMs: extractDurationMs, filename, diff --git a/packages/next-plugin/src/plugin.ts b/packages/next-plugin/src/plugin.ts index 6833b58b..0ebaac2d 100644 --- a/packages/next-plugin/src/plugin.ts +++ b/packages/next-plugin/src/plugin.ts @@ -43,7 +43,7 @@ import { } from '@devup-ui/webpack-plugin' import { type NextConfig } from 'next' -import { startCoordinator } from './coordinator' +import { type PrewarmedOutput, startCoordinator } from './coordinator' import { collectProductionPrewarmFiles } from './prewarm' import { elapsedMs, profileStart, reportProfile } from './profile' @@ -235,6 +235,7 @@ export function DevupUI( // route graph cannot represent. Loader-time extraction uses the same // keys/options and is idempotent. const prewarmedFiles: string[] = [] + const prewarmedOutputs = new Map() if (!watch && staticGraph) { const prewarmStartedAt = profileStart() let prewarmSourceBytes = 0 @@ -256,7 +257,7 @@ export function DevupUI( if (prewarmStartedAt !== undefined) { prewarmSourceBytes += Buffer.byteLength(source) } - codeExtract( + const output = codeExtract( filename, source, libPackage, @@ -266,6 +267,16 @@ export function DevupUI( true, importAliases as unknown as Record, ) + if (singleCss) { + prewarmedOutputs.set(filename, { + code: output.code, + css: output.css, + cssFile: output.cssFile, + map: output.map, + source, + updatedBaseStyle: output.updatedBaseStyle, + }) + } prewarmedFiles.push(filename) } reportProfile('next.prewarm', { @@ -299,6 +310,7 @@ export function DevupUI( canonicalMap, expectedBaseFiles, prewarmedFiles, + prewarmedOutputs, }) reportProfile('next.setup', { durationMs: elapsedMs(pluginStartedAt), From ce246bafa52cf81bbf7936cb3451efa500976fbf Mon Sep 17 00:00:00 2001 From: devfive Date: Fri, 28 Aug 2026 22:37:07 +0900 Subject: [PATCH 03/22] feat: profile Turbopack serialization --- .../src/__tests__/coordinator.test.ts | 78 +++++++++++++ .../next-plugin/src/__tests__/plugin.test.ts | 31 ++++++ packages/next-plugin/src/coordinator.ts | 101 +++++++++++++---- packages/next-plugin/src/plugin.ts | 105 ++++++++++++++++-- 4 files changed, 285 insertions(+), 30 deletions(-) diff --git a/packages/next-plugin/src/__tests__/coordinator.test.ts b/packages/next-plugin/src/__tests__/coordinator.test.ts index c111a838..f1b6325a 100644 --- a/packages/next-plugin/src/__tests__/coordinator.test.ts +++ b/packages/next-plugin/src/__tests__/coordinator.test.ts @@ -225,6 +225,84 @@ describe('coordinator', () => { coordinator.close() }) + it('profiles serialization work separately from writes', async () => { + const originalProfile = process.env.DEVUP_UI_PROFILE + process.env.DEVUP_UI_PROFILE = '1' + const infoSpy = spyOn(console, 'info').mockImplementation(() => {}) + codeExtractSpy.mockReturnValue({ + code: 'transformed code', + map: undefined, + css: 'collected css', + cssFile: 'devup-ui-1.css', + updatedBaseStyle: true, + free: mock(), + [Symbol.dispose]: mock(), + }) + getCssSpy.mockImplementation((fileNum: number | null) => + fileNum === null ? 'base-css' : `file-css-${fileNum}`, + ) + exportSheetSpy.mockReturnValue('sheet-json') + exportClassMapSpy.mockReturnValue('classmap-json') + exportFileMapSpy.mockReturnValue('filemap-json') + + const coordinator = startCoordinator(makeOptions()) + try { + await new Promise((resolve) => setTimeout(resolve, 100)) + const port = parseInt( + (writeFileSyncSpy.mock.calls[0] as [string, string])[1], + ) + + const res = await httpRequest( + port, + 'POST', + '/extract', + JSON.stringify({ + filename: 'src/profile.tsx', + code: 'const profile = true', + resourcePath: join(process.cwd(), 'src', 'profile.tsx'), + }), + ) + + expect(res.status).toBe(200) + const message = infoSpy.mock.calls + .map(([value]) => value) + .find( + (value): value is string => + typeof value === 'string' && + value.startsWith( + '[devup-ui:profile] {"phase":"coordinator.extract"', + ), + ) + if (message === undefined) throw new Error('missing extract profile') + const profile = JSON.parse( + message.slice('[devup-ui:profile] '.length), + ) as Record + + expect(profile).toMatchObject({ + cacheHit: false, + classMapSnapshotBytes: Buffer.byteLength('classmap-json'), + cssSnapshotBytes: expect.any(Number), + fileMapSnapshotBytes: Buffer.byteLength('filemap-json'), + phase: 'coordinator.extract', + scheduledWrites: 5, + sheetSnapshotBytes: Buffer.byteLength('sheet-json'), + sourceBytes: Buffer.byteLength('const profile = true'), + }) + expect(profile.classMapSnapshotMs).toBeTypeOf('number') + expect(profile.cssSnapshotMs).toBeTypeOf('number') + expect(profile.fileMapSnapshotMs).toBeTypeOf('number') + expect(profile.sheetSnapshotMs).toBeTypeOf('number') + } finally { + coordinator.close() + infoSpy.mockRestore() + if (originalProfile === undefined) { + delete process.env.DEVUP_UI_PROFILE + } else { + process.env.DEVUP_UI_PROFILE = originalProfile + } + } + }) + it('should rewrite per-file CSS imports when singleCss=false', async () => { codeExtractSpy.mockReturnValue({ code: 'import "./../../df/devup-ui/devup-ui-79.css";\nimport "./../../df/devup-ui/devup-ui-3.css";\nconst x = 1;', diff --git a/packages/next-plugin/src/__tests__/plugin.test.ts b/packages/next-plugin/src/__tests__/plugin.test.ts index 19d60c89..c88b1779 100644 --- a/packages/next-plugin/src/__tests__/plugin.test.ts +++ b/packages/next-plugin/src/__tests__/plugin.test.ts @@ -507,6 +507,7 @@ describe('DevupUINextPlugin', () => { canonicalMap: expect.any(Object), expectedBaseFiles: expect.any(Array), prewarmedFiles: expect.any(Array), + prewarmedOutputs: expect.any(Map), }) }) it('should create theme.d.ts file', async () => { @@ -696,6 +697,7 @@ describe('DevupUINextPlugin', () => { canonicalMap: expect.any(Object), expectedBaseFiles: expect.any(Array), prewarmedFiles: [], + prewarmedOutputs: expect.any(Map), }) expect(codeExtractSpy).not.toHaveBeenCalled() @@ -719,6 +721,8 @@ describe('DevupUINextPlugin', () => { it('hands the coordinator the full compiled-file set, not the static-only route map', () => { process.env.TURBOPACK = '1' + process.env.DEVUP_UI_PROFILE = '1' + const profileSpy = spyOn(console, 'info').mockImplementation(() => {}) // The base sheet must wait for lazily-loaded modules too, so // expectedBaseFiles comes from computeCompiledFiles (static + dynamic // edges) rather than computeFileRoutes (static edges only). Using the @@ -777,9 +781,36 @@ describe('DevupUINextPlugin', () => { 'extract:src/lazy/panel.tsx', 'startCoordinator', ]) + const profiles: Record[] = profileSpy.mock.calls + .map(([value]) => value) + .filter( + (value): value is string => + typeof value === 'string' && + value.startsWith('[devup-ui:profile] '), + ) + .map((value) => JSON.parse(value.slice('[devup-ui:profile] '.length))) + const prewarmProfile = profiles.find( + ({ phase }) => phase === 'next.prewarm', + ) + expect(prewarmProfile).toMatchObject({ + collectMs: expect.any(Number), + extractMs: expect.any(Number), + files: 2, + phase: 'next.prewarm', + readMs: expect.any(Number), + sourceBytes: Buffer.byteLength('{}') * 2, + }) + expect(profiles).toEqual( + expect.arrayContaining([ + expect.objectContaining({ phase: 'next.initialCss' }), + expect.objectContaining({ phase: 'next.stateSnapshot' }), + expect.objectContaining({ phase: 'next.setup' }), + ]), + ) // the static-only route map is not consulted outside atom-hoist mode expect(routesSpy).not.toHaveBeenCalled() } finally { + profileSpy.mockRestore() compiledSpy.mockRestore() routesSpy.mockRestore() } diff --git a/packages/next-plugin/src/coordinator.ts b/packages/next-plugin/src/coordinator.ts index 5e3257b2..ea964d2c 100644 --- a/packages/next-plugin/src/coordinator.ts +++ b/packages/next-plugin/src/coordinator.ts @@ -456,15 +456,27 @@ export function startCoordinator(options: CoordinatorOptions): { } const snapshotStartedAt = profileStart() + let classMapSnapshotBytes: number | undefined + let classMapSnapshotMs: number | undefined + let cssSnapshotBytes: number | undefined + let cssSnapshotMs: number | undefined + let fileMapSnapshotBytes: number | undefined + let fileMapSnapshotMs: number | undefined + let sheetSnapshotBytes: number | undefined + let sheetSnapshotMs: number | undefined const promises: Promise[] = [] if (!cacheHit && result.updatedBaseStyle) { - promises.push( - safeWrite( - join(cssDir, 'devup-ui.css'), - `${getCss(null, false)}\n/* ${Date.now()} */`, - ), - ) + const cssStartedAt = + snapshotStartedAt === undefined ? undefined : performance.now() + const css = `${getCss(null, false)}\n/* ${Date.now()} */` + const cssDurationMs = + cssStartedAt === undefined ? undefined : elapsedMs(cssStartedAt) + if (cssDurationMs !== undefined) { + cssSnapshotMs = (cssSnapshotMs ?? 0) + cssDurationMs + cssSnapshotBytes = (cssSnapshotBytes ?? 0) + Buffer.byteLength(css) + } + promises.push(safeWrite(join(cssDir, 'devup-ui.css'), css)) } if (!cacheHit && result.cssFile) { @@ -474,14 +486,51 @@ export function startCoordinator(options: CoordinatorOptions): { // wait for the bucket's members before serving it. fileNumToBucket.set(fileNum, canonicalMapRef[filename] ?? filename) } + const cssStartedAt = + snapshotStartedAt === undefined ? undefined : performance.now() + const css = getCss(fileNum, true) + const cssDurationMs = + cssStartedAt === undefined ? undefined : elapsedMs(cssStartedAt) + if (cssDurationMs !== undefined) { + cssSnapshotMs = (cssSnapshotMs ?? 0) + cssDurationMs + cssSnapshotBytes = (cssSnapshotBytes ?? 0) + Buffer.byteLength(css) + } + + const sheetStartedAt = + snapshotStartedAt === undefined ? undefined : performance.now() + const sheet = exportSheet() + sheetSnapshotMs = + sheetStartedAt === undefined ? undefined : elapsedMs(sheetStartedAt) + if (snapshotStartedAt !== undefined) { + sheetSnapshotBytes = Buffer.byteLength(sheet) + } + + const classMapStartedAt = + snapshotStartedAt === undefined ? undefined : performance.now() + const classMap = exportClassMap() + classMapSnapshotMs = + classMapStartedAt === undefined + ? undefined + : elapsedMs(classMapStartedAt) + if (snapshotStartedAt !== undefined) { + classMapSnapshotBytes = Buffer.byteLength(classMap) + } + + const fileMapStartedAt = + snapshotStartedAt === undefined ? undefined : performance.now() + const fileMap = exportFileMap() + fileMapSnapshotMs = + fileMapStartedAt === undefined + ? undefined + : elapsedMs(fileMapStartedAt) + if (snapshotStartedAt !== undefined) { + fileMapSnapshotBytes = Buffer.byteLength(fileMap) + } promises.push( - safeWrite( - join(cssDir, basename(result.cssFile)), - getCss(fileNum, true), - ), - safeWrite(sheetFile, exportSheet()), - safeWrite(classMapFile, exportClassMap()), - safeWrite(fileMapFile, exportFileMap()), + safeWrite(join(cssDir, basename(result.cssFile)), css), + safeWrite(sheetFile, sheet), + safeWrite(classMapFile, classMap), + safeWrite(fileMapFile, fileMap), ) // In non-singleCss mode, imports are rewritten from devup-ui-N.css to @@ -491,12 +540,17 @@ export function startCoordinator(options: CoordinatorOptions): { // new CSS rules are invisible to the browser. // When updatedBaseStyle is true, devup-ui.css is already written above. if (!singleCss && !result.updatedBaseStyle && result.css != null) { - promises.push( - safeWrite( - join(cssDir, 'devup-ui.css'), - `${getCss(null, false)}\n/* ${Date.now()} */`, - ), - ) + const cssStartedAt = + snapshotStartedAt === undefined ? undefined : performance.now() + const baseCss = `${getCss(null, false)}\n/* ${Date.now()} */` + const cssDurationMs = + cssStartedAt === undefined ? undefined : elapsedMs(cssStartedAt) + if (cssDurationMs !== undefined) { + cssSnapshotMs = (cssSnapshotMs ?? 0) + cssDurationMs + cssSnapshotBytes = + (cssSnapshotBytes ?? 0) + Buffer.byteLength(baseCss) + } + promises.push(safeWrite(join(cssDir, 'devup-ui.css'), baseCss)) } } @@ -516,13 +570,22 @@ export function startCoordinator(options: CoordinatorOptions): { reportProfile('coordinator.extract', { bodyMs: bodyDurationMs, cacheHit, + classMapSnapshotBytes, + classMapSnapshotMs, + cssSnapshotBytes, + cssSnapshotMs, durationMs: elapsedMs(requestStartedAt), extractMs: extractDurationMs, + fileMapSnapshotBytes, + fileMapSnapshotMs, filename, + sheetSnapshotBytes, + sheetSnapshotMs, sourceBytes: requestStartedAt === undefined ? undefined : Buffer.byteLength(code), + scheduledWrites: promises.length, snapshotMs: snapshotDurationMs, writeMs: elapsedMs(writeStartedAt), }) diff --git a/packages/next-plugin/src/plugin.ts b/packages/next-plugin/src/plugin.ts index 0ebaac2d..2f17a7cf 100644 --- a/packages/next-plugin/src/plugin.ts +++ b/packages/next-plugin/src/plugin.ts @@ -238,8 +238,12 @@ export function DevupUI( const prewarmedOutputs = new Map() if (!watch && staticGraph) { const prewarmStartedAt = profileStart() + let prewarmExtractMs = 0 + let prewarmReadMs = 0 let prewarmSourceBytes = 0 const cwd = process.cwd() + const collectStartedAt = + prewarmStartedAt === undefined ? undefined : performance.now() const prewarmFiles = collectProductionPrewarmFiles({ cwd, graph: staticGraph, @@ -247,16 +251,22 @@ export function DevupUI( libPackage, include, }) + const collectDurationMs = elapsedMs(collectStartedAt) for (const filename of prewarmFiles) { const resourcePath = resolve(cwd, filename) const relCssDir = `./${relative( dirname(resourcePath), cssDir, ).replaceAll('\\', '/')}` + const readStartedAt = + prewarmStartedAt === undefined ? undefined : performance.now() const source = readFileSync(resourcePath, 'utf-8') - if (prewarmStartedAt !== undefined) { + if (readStartedAt !== undefined) { + prewarmReadMs += performance.now() - readStartedAt prewarmSourceBytes += Buffer.byteLength(source) } + const extractStartedAt = + prewarmStartedAt === undefined ? undefined : performance.now() const output = codeExtract( filename, source, @@ -267,6 +277,9 @@ export function DevupUI( true, importAliases as unknown as Record, ) + if (extractStartedAt !== undefined) { + prewarmExtractMs += performance.now() - extractStartedAt + } if (singleCss) { prewarmedOutputs.set(filename, { code: output.code, @@ -280,14 +293,39 @@ export function DevupUI( prewarmedFiles.push(filename) } reportProfile('next.prewarm', { + collectMs: collectDurationMs, durationMs: elapsedMs(prewarmStartedAt), + extractMs: + prewarmStartedAt === undefined + ? undefined + : Number(prewarmExtractMs.toFixed(2)), files: prewarmedFiles.length, + readMs: + prewarmStartedAt === undefined + ? undefined + : Number(prewarmReadMs.toFixed(2)), sourceBytes: prewarmSourceBytes, }) } // create devup-ui.css file - writeFileSync(join(cssDir, 'devup-ui.css'), getCss(null, false)) + const initialCssStartedAt = profileStart() + const initialCssSerializeStartedAt = + initialCssStartedAt === undefined ? undefined : performance.now() + const initialCss = getCss(null, false) + const initialCssSerializeMs = elapsedMs(initialCssSerializeStartedAt) + const initialCssWriteStartedAt = + initialCssStartedAt === undefined ? undefined : performance.now() + writeFileSync(join(cssDir, 'devup-ui.css'), initialCss) + reportProfile('next.initialCss', { + bytes: + initialCssStartedAt === undefined + ? undefined + : Buffer.byteLength(initialCss), + durationMs: elapsedMs(initialCssStartedAt), + serializeMs: initialCssSerializeMs, + writeMs: elapsedMs(initialCssWriteStartedAt), + }) // Delete stale port file from previous session so loaders don't connect // to a dead coordinator port. The new coordinator writes a fresh port file @@ -312,20 +350,59 @@ export function DevupUI( prewarmedFiles, prewarmedOutputs, }) - reportProfile('next.setup', { - durationMs: elapsedMs(pluginStartedAt), - singleCss, - prewarmedFiles: prewarmedFiles.length, - watch, - }) // Cleanup on exit process.on('exit', () => { coordinator.close() }) - const defaultSheet = JSON.parse(exportSheet()) - const defaultClassMap = JSON.parse(exportClassMap()) - const defaultFileMap = JSON.parse(exportFileMap()) + const stateSnapshotStartedAt = profileStart() + const sheetSerializeStartedAt = + stateSnapshotStartedAt === undefined ? undefined : performance.now() + const defaultSheetJson = exportSheet() + const sheetSerializeMs = elapsedMs(sheetSerializeStartedAt) + const sheetParseStartedAt = + stateSnapshotStartedAt === undefined ? undefined : performance.now() + const defaultSheet = JSON.parse(defaultSheetJson) + const sheetParseMs = elapsedMs(sheetParseStartedAt) + + const classMapSerializeStartedAt = + stateSnapshotStartedAt === undefined ? undefined : performance.now() + const defaultClassMapJson = exportClassMap() + const classMapSerializeMs = elapsedMs(classMapSerializeStartedAt) + const classMapParseStartedAt = + stateSnapshotStartedAt === undefined ? undefined : performance.now() + const defaultClassMap = JSON.parse(defaultClassMapJson) + const classMapParseMs = elapsedMs(classMapParseStartedAt) + + const fileMapSerializeStartedAt = + stateSnapshotStartedAt === undefined ? undefined : performance.now() + const defaultFileMapJson = exportFileMap() + const fileMapSerializeMs = elapsedMs(fileMapSerializeStartedAt) + const fileMapParseStartedAt = + stateSnapshotStartedAt === undefined ? undefined : performance.now() + const defaultFileMap = JSON.parse(defaultFileMapJson) + const fileMapParseMs = elapsedMs(fileMapParseStartedAt) + reportProfile('next.stateSnapshot', { + classMapBytes: + stateSnapshotStartedAt === undefined + ? undefined + : Buffer.byteLength(defaultClassMapJson), + classMapParseMs, + classMapSerializeMs, + durationMs: elapsedMs(stateSnapshotStartedAt), + fileMapBytes: + stateSnapshotStartedAt === undefined + ? undefined + : Buffer.byteLength(defaultFileMapJson), + fileMapParseMs, + fileMapSerializeMs, + sheetBytes: + stateSnapshotStartedAt === undefined + ? undefined + : Buffer.byteLength(defaultSheetJson), + sheetParseMs, + sheetSerializeMs, + }) // for theme script const defaultTheme = getDefaultTheme() if (defaultTheme) { @@ -390,6 +467,12 @@ export function DevupUI( }, } Object.assign(config.turbopack.rules, rules) + reportProfile('next.setup', { + durationMs: elapsedMs(pluginStartedAt), + prewarmedFiles: prewarmedFiles.length, + singleCss, + watch, + }) return config } From dcca39ab2f15ec4456aaae6c52786543353d1678 Mon Sep 17 00:00:00 2001 From: devfive Date: Fri, 28 Aug 2026 23:32:45 +0900 Subject: [PATCH 04/22] fix: allow empty prewarmed CSS output --- packages/next-plugin/src/coordinator.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/next-plugin/src/coordinator.ts b/packages/next-plugin/src/coordinator.ts index ea964d2c..52fce285 100644 --- a/packages/next-plugin/src/coordinator.ts +++ b/packages/next-plugin/src/coordinator.ts @@ -73,8 +73,8 @@ export interface CoordinatorOptions { export interface PrewarmedOutput { code: string - css: string - cssFile: string + css?: string + cssFile?: string map?: string source: string updatedBaseStyle: boolean From ec738cea643810cc5831abbe776f9c3818999941 Mon Sep 17 00:00:00 2001 From: devfive Date: Fri, 28 Aug 2026 23:34:58 +0900 Subject: [PATCH 05/22] ci: compare Turbopack cold builds --- .github/workflows/turbopack-profile.yml | 144 ++++++++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 .github/workflows/turbopack-profile.yml diff --git a/.github/workflows/turbopack-profile.yml b/.github/workflows/turbopack-profile.yml new file mode 100644 index 00000000..2e81fda4 --- /dev/null +++ b/.github/workflows/turbopack-profile.yml @@ -0,0 +1,144 @@ +name: Turbopack profile + +on: + pull_request: + branches: + - main + paths: + - ".github/workflows/turbopack-profile.yml" + - "bindings/devup-ui-wasm/**" + - "libs/**" + - "packages/next-plugin/**" + - "packages/plugin-utils/**" + - "packages/react/**" + - "benchmark/next-devup-ui-single-turbo/**" + - "benchmark/next-tailwind-turbo/**" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + compare: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@v7 + + - uses: actions-rust-lang/setup-rust-toolchain@v1 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.4.0" + name: Install Bun + + - uses: jetli/wasm-pack-action@v0.4.0 + with: + version: "latest" + + - name: Install Node.js + uses: actions/setup-node@v7 + with: + node-version: 24 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Build benchmark dependencies + run: | + bun run --filter @devup-ui/wasm --filter @devup-ui/plugin-utils build + bun run --filter @devup-ui/react --filter @devup-ui/webpack-plugin build + bun run --filter @devup-ui/next-plugin build + + - name: Compare cold Turbopack builds + env: + NEXT_TELEMETRY_DISABLED: "1" + shell: bash + run: | + set -euo pipefail + + devup_dir="$GITHUB_WORKSPACE/benchmark/next-devup-ui-single-turbo" + tailwind_dir="$GITHUB_WORKSPACE/benchmark/next-tailwind-turbo" + result_dir="$RUNNER_TEMP/turbopack-profile" + mkdir -p "$result_dir/logs" + + run_build() { + local name="$1" + local directory="$2" + local sample="$3" + local record="$4" + local started_at + local finished_at + local elapsed_ms + local status + + rm -rf "$directory/.next" "$directory/df" + started_at="$(date +%s%N)" + set +e + (cd "$directory" && bunx next build) 2>&1 \ + | tee "$result_dir/logs/${name}-${sample}.log" + status="${PIPESTATUS[0]}" + set -e + finished_at="$(date +%s%N)" + elapsed_ms="$(((finished_at - started_at) / 1000000))" + + echo "$name $sample: ${elapsed_ms}ms" + if [[ "$status" -ne 0 ]]; then + return "$status" + fi + if [[ "$record" == "true" ]]; then + echo "$elapsed_ms" >> "$result_dir/${name}.txt" + fi + } + + # Warm both dependency graphs before recording. Each measured sample + # still removes all application build outputs, so it is a cold Next + # build with a balanced OS/module cache. + run_build tailwind "$tailwind_dir" warmup false + run_build devup "$devup_dir" warmup false + + # Alternate order to balance runner drift and first/second-run bias. + for round in $(seq 1 7); do + if ((round % 2 == 1)); then + run_build devup "$devup_dir" "$round" true + run_build tailwind "$tailwind_dir" "$round" true + else + run_build tailwind "$tailwind_dir" "$round" true + run_build devup "$devup_dir" "$round" true + fi + done + + devup_median="$(sort -n "$result_dir/devup.txt" | sed -n '4p')" + tailwind_median="$(sort -n "$result_dir/tailwind.txt" | sed -n '4p')" + delta_ms="$((tailwind_median - devup_median))" + delta_percent="$(awk -v d="$devup_median" -v t="$tailwind_median" \ + 'BEGIN { printf "%.1f", ((t - d) / t) * 100 }')" + devup_samples="$(awk 'BEGIN { ORS="" } { if (NR > 1) printf ", "; printf "%s", $0 }' "$result_dir/devup.txt")" + tailwind_samples="$(awk 'BEGIN { ORS="" } { if (NR > 1) printf ", "; printf "%s", $0 }' "$result_dir/tailwind.txt")" + + { + echo "### Turbopack cold-build comparison" + echo + echo "The comparison excludes Next's experimental memory-debug mode." + echo "Both apps receive one unrecorded warmup, then seven output-cleaned builds in alternating order." + echo + echo "| App | Median | Samples (ms) |" + echo "| --- | ---: | --- |" + echo "| Devup UI singleCss | ${devup_median}ms | ${devup_samples} |" + echo "| Tailwind Turbo | ${tailwind_median}ms | ${tailwind_samples} |" + echo + echo "Tailwind minus Devup UI: ${delta_ms}ms (${delta_percent}%)." + } | tee "$result_dir/summary.md" >> "$GITHUB_STEP_SUMMARY" + + - name: Upload raw benchmark logs + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v7 + with: + name: turbopack-profile + path: ${{ runner.temp }}/turbopack-profile/ + retention-days: 7 From b1cef9a60a64c5e8f86cd33a614f81292294b9f3 Mon Sep 17 00:00:00 2001 From: devfive Date: Fri, 28 Aug 2026 23:51:36 +0900 Subject: [PATCH 06/22] ci: report Turbopack benchmark phases --- .github/workflows/turbopack-profile.yml | 54 ++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/.github/workflows/turbopack-profile.yml b/.github/workflows/turbopack-profile.yml index 2e81fda4..a2fb8471 100644 --- a/.github/workflows/turbopack-profile.yml +++ b/.github/workflows/turbopack-profile.yml @@ -67,6 +67,32 @@ jobs: result_dir="$RUNNER_TEMP/turbopack-profile" mkdir -p "$result_dir/logs" + extract_duration_ms() { + local pattern="$1" + local log_file="$2" + local duration + + duration="$(grep -E "$pattern" "$log_file" \ + | tail -1 \ + | grep -oE '[0-9]+([.][0-9]+)?(ms|s)' \ + | tail -1)" + if [[ "$duration" == *ms ]]; then + echo "${duration%ms}" + else + awk -v seconds="${duration%s}" 'BEGIN { printf "%.0f", seconds * 1000 }' + fi + } + + record_phase() { + local name="$1" + local phase="$2" + local pattern="$3" + local log_file="$4" + + extract_duration_ms "$pattern" "$log_file" \ + >> "$result_dir/${name}-${phase}.txt" + } + run_build() { local name="$1" local directory="$2" @@ -75,13 +101,15 @@ jobs: local started_at local finished_at local elapsed_ms + local log_file local status rm -rf "$directory/.next" "$directory/df" + log_file="$result_dir/logs/${name}-${sample}.log" started_at="$(date +%s%N)" set +e (cd "$directory" && bunx next build) 2>&1 \ - | tee "$result_dir/logs/${name}-${sample}.log" + | tee "$log_file" status="${PIPESTATUS[0]}" set -e finished_at="$(date +%s%N)" @@ -93,6 +121,10 @@ jobs: fi if [[ "$record" == "true" ]]; then echo "$elapsed_ms" >> "$result_dir/${name}.txt" + record_phase "$name" config 'Running next[.]config' "$log_file" + record_phase "$name" compile 'Compiled successfully' "$log_file" + record_phase "$name" typescript 'Finished TypeScript' "$log_file" + record_phase "$name" static 'Generating static pages.* in ' "$log_file" fi } @@ -121,6 +153,17 @@ jobs: devup_samples="$(awk 'BEGIN { ORS="" } { if (NR > 1) printf ", "; printf "%s", $0 }' "$result_dir/devup.txt")" tailwind_samples="$(awk 'BEGIN { ORS="" } { if (NR > 1) printf ", "; printf "%s", $0 }' "$result_dir/tailwind.txt")" + phase_row() { + local phase="$1" + local label="$2" + local devup_phase_median + local tailwind_phase_median + + devup_phase_median="$(sort -n "$result_dir/devup-${phase}.txt" | sed -n '4p')" + tailwind_phase_median="$(sort -n "$result_dir/tailwind-${phase}.txt" | sed -n '4p')" + echo "| $label | ${devup_phase_median}ms | ${tailwind_phase_median}ms | $((tailwind_phase_median - devup_phase_median))ms |" + } + { echo "### Turbopack cold-build comparison" echo @@ -133,6 +176,15 @@ jobs: echo "| Tailwind Turbo | ${tailwind_median}ms | ${tailwind_samples} |" echo echo "Tailwind minus Devup UI: ${delta_ms}ms (${delta_percent}%)." + echo + echo "| Phase | Devup UI median | Tailwind median | Tailwind - Devup UI |" + echo "| --- | ---: | ---: | ---: |" + phase_row config "Next config" + phase_row compile "Turbopack compile" + phase_row typescript "TypeScript" + phase_row static "Static generation" + echo + echo "Positive phase deltas mean Devup UI is faster." } | tee "$result_dir/summary.md" >> "$GITHUB_STEP_SUMMARY" - name: Upload raw benchmark logs From 29084d84de6a82578f36a0bee0b760c4bf40b2b5 Mon Sep 17 00:00:00 2001 From: devfive Date: Fri, 28 Aug 2026 23:56:32 +0900 Subject: [PATCH 07/22] ci: separate compile phase samples --- .github/workflows/turbopack-profile.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/turbopack-profile.yml b/.github/workflows/turbopack-profile.yml index a2fb8471..b15abea5 100644 --- a/.github/workflows/turbopack-profile.yml +++ b/.github/workflows/turbopack-profile.yml @@ -79,7 +79,7 @@ jobs: if [[ "$duration" == *ms ]]; then echo "${duration%ms}" else - awk -v seconds="${duration%s}" 'BEGIN { printf "%.0f", seconds * 1000 }' + awk -v seconds="${duration%s}" 'BEGIN { printf "%.0f\n", seconds * 1000 }' fi } From e0b9bd121dce69965d21517c509575bea15c3701 Mon Sep 17 00:00:00 2001 From: devfive Date: Sat, 29 Aug 2026 00:22:56 +0900 Subject: [PATCH 08/22] perf: prioritize default component props --- .github/workflows/turbopack-profile.yml | 196 ------------------ packages/react/src/components/Box.tsx | 6 + packages/react/src/components/Text.tsx | 6 + packages/react/type-tests/custom-shorthand.ts | 11 +- 4 files changed, 22 insertions(+), 197 deletions(-) delete mode 100644 .github/workflows/turbopack-profile.yml diff --git a/.github/workflows/turbopack-profile.yml b/.github/workflows/turbopack-profile.yml deleted file mode 100644 index b15abea5..00000000 --- a/.github/workflows/turbopack-profile.yml +++ /dev/null @@ -1,196 +0,0 @@ -name: Turbopack profile - -on: - pull_request: - branches: - - main - paths: - - ".github/workflows/turbopack-profile.yml" - - "bindings/devup-ui-wasm/**" - - "libs/**" - - "packages/next-plugin/**" - - "packages/plugin-utils/**" - - "packages/react/**" - - "benchmark/next-devup-ui-single-turbo/**" - - "benchmark/next-tailwind-turbo/**" - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - compare: - runs-on: ubuntu-latest - timeout-minutes: 20 - steps: - - name: Checkout - uses: actions/checkout@v7 - - - uses: actions-rust-lang/setup-rust-toolchain@v1 - - - uses: oven-sh/setup-bun@v2 - with: - bun-version: "1.4.0" - name: Install Bun - - - uses: jetli/wasm-pack-action@v0.4.0 - with: - version: "latest" - - - name: Install Node.js - uses: actions/setup-node@v7 - with: - node-version: 24 - - - name: Install dependencies - run: bun install --frozen-lockfile - - - name: Build benchmark dependencies - run: | - bun run --filter @devup-ui/wasm --filter @devup-ui/plugin-utils build - bun run --filter @devup-ui/react --filter @devup-ui/webpack-plugin build - bun run --filter @devup-ui/next-plugin build - - - name: Compare cold Turbopack builds - env: - NEXT_TELEMETRY_DISABLED: "1" - shell: bash - run: | - set -euo pipefail - - devup_dir="$GITHUB_WORKSPACE/benchmark/next-devup-ui-single-turbo" - tailwind_dir="$GITHUB_WORKSPACE/benchmark/next-tailwind-turbo" - result_dir="$RUNNER_TEMP/turbopack-profile" - mkdir -p "$result_dir/logs" - - extract_duration_ms() { - local pattern="$1" - local log_file="$2" - local duration - - duration="$(grep -E "$pattern" "$log_file" \ - | tail -1 \ - | grep -oE '[0-9]+([.][0-9]+)?(ms|s)' \ - | tail -1)" - if [[ "$duration" == *ms ]]; then - echo "${duration%ms}" - else - awk -v seconds="${duration%s}" 'BEGIN { printf "%.0f\n", seconds * 1000 }' - fi - } - - record_phase() { - local name="$1" - local phase="$2" - local pattern="$3" - local log_file="$4" - - extract_duration_ms "$pattern" "$log_file" \ - >> "$result_dir/${name}-${phase}.txt" - } - - run_build() { - local name="$1" - local directory="$2" - local sample="$3" - local record="$4" - local started_at - local finished_at - local elapsed_ms - local log_file - local status - - rm -rf "$directory/.next" "$directory/df" - log_file="$result_dir/logs/${name}-${sample}.log" - started_at="$(date +%s%N)" - set +e - (cd "$directory" && bunx next build) 2>&1 \ - | tee "$log_file" - status="${PIPESTATUS[0]}" - set -e - finished_at="$(date +%s%N)" - elapsed_ms="$(((finished_at - started_at) / 1000000))" - - echo "$name $sample: ${elapsed_ms}ms" - if [[ "$status" -ne 0 ]]; then - return "$status" - fi - if [[ "$record" == "true" ]]; then - echo "$elapsed_ms" >> "$result_dir/${name}.txt" - record_phase "$name" config 'Running next[.]config' "$log_file" - record_phase "$name" compile 'Compiled successfully' "$log_file" - record_phase "$name" typescript 'Finished TypeScript' "$log_file" - record_phase "$name" static 'Generating static pages.* in ' "$log_file" - fi - } - - # Warm both dependency graphs before recording. Each measured sample - # still removes all application build outputs, so it is a cold Next - # build with a balanced OS/module cache. - run_build tailwind "$tailwind_dir" warmup false - run_build devup "$devup_dir" warmup false - - # Alternate order to balance runner drift and first/second-run bias. - for round in $(seq 1 7); do - if ((round % 2 == 1)); then - run_build devup "$devup_dir" "$round" true - run_build tailwind "$tailwind_dir" "$round" true - else - run_build tailwind "$tailwind_dir" "$round" true - run_build devup "$devup_dir" "$round" true - fi - done - - devup_median="$(sort -n "$result_dir/devup.txt" | sed -n '4p')" - tailwind_median="$(sort -n "$result_dir/tailwind.txt" | sed -n '4p')" - delta_ms="$((tailwind_median - devup_median))" - delta_percent="$(awk -v d="$devup_median" -v t="$tailwind_median" \ - 'BEGIN { printf "%.1f", ((t - d) / t) * 100 }')" - devup_samples="$(awk 'BEGIN { ORS="" } { if (NR > 1) printf ", "; printf "%s", $0 }' "$result_dir/devup.txt")" - tailwind_samples="$(awk 'BEGIN { ORS="" } { if (NR > 1) printf ", "; printf "%s", $0 }' "$result_dir/tailwind.txt")" - - phase_row() { - local phase="$1" - local label="$2" - local devup_phase_median - local tailwind_phase_median - - devup_phase_median="$(sort -n "$result_dir/devup-${phase}.txt" | sed -n '4p')" - tailwind_phase_median="$(sort -n "$result_dir/tailwind-${phase}.txt" | sed -n '4p')" - echo "| $label | ${devup_phase_median}ms | ${tailwind_phase_median}ms | $((tailwind_phase_median - devup_phase_median))ms |" - } - - { - echo "### Turbopack cold-build comparison" - echo - echo "The comparison excludes Next's experimental memory-debug mode." - echo "Both apps receive one unrecorded warmup, then seven output-cleaned builds in alternating order." - echo - echo "| App | Median | Samples (ms) |" - echo "| --- | ---: | --- |" - echo "| Devup UI singleCss | ${devup_median}ms | ${devup_samples} |" - echo "| Tailwind Turbo | ${tailwind_median}ms | ${tailwind_samples} |" - echo - echo "Tailwind minus Devup UI: ${delta_ms}ms (${delta_percent}%)." - echo - echo "| Phase | Devup UI median | Tailwind median | Tailwind - Devup UI |" - echo "| --- | ---: | ---: | ---: |" - phase_row config "Next config" - phase_row compile "Turbopack compile" - phase_row typescript "TypeScript" - phase_row static "Static generation" - echo - echo "Positive phase deltas mean Devup UI is faster." - } | tee "$result_dir/summary.md" >> "$GITHUB_STEP_SUMMARY" - - - name: Upload raw benchmark logs - if: ${{ !cancelled() }} - uses: actions/upload-artifact@v7 - with: - name: turbopack-profile - path: ${{ runner.temp }}/turbopack-profile/ - retention-days: 7 diff --git a/packages/react/src/components/Box.tsx b/packages/react/src/components/Box.tsx index acbee4f4..d596606c 100644 --- a/packages/react/src/components/Box.tsx +++ b/packages/react/src/components/Box.tsx @@ -4,6 +4,12 @@ import type { } from '../types/props' import type { Merge } from '../types/utils' +export function Box( + props: Merge, DevupComponentProps<'div'>>, +): React.ReactElement +export function Box( + props: Merge, DevupComponentProps>, +): React.ReactElement export function Box( // eslint-disable-next-line @typescript-eslint/no-unused-vars props: Merge, DevupComponentProps>, diff --git a/packages/react/src/components/Text.tsx b/packages/react/src/components/Text.tsx index 467afb4c..788d5e6e 100644 --- a/packages/react/src/components/Text.tsx +++ b/packages/react/src/components/Text.tsx @@ -4,6 +4,12 @@ import type { } from '../types/props' import type { Merge } from '../types/utils' +export function Text( + props: Merge, DevupComponentProps<'span'>>, +): React.ReactElement +export function Text( + props: Merge, DevupComponentProps>, +): React.ReactElement export function Text( // eslint-disable-next-line @typescript-eslint/no-unused-vars props: Merge, DevupComponentProps>, diff --git a/packages/react/type-tests/custom-shorthand.ts b/packages/react/type-tests/custom-shorthand.ts index e49b7a6a..8b4654ba 100644 --- a/packages/react/type-tests/custom-shorthand.ts +++ b/packages/react/type-tests/custom-shorthand.ts @@ -26,4 +26,13 @@ const boxProps: Parameters[0] = { }, } -export { boxProps, customShorthandProps } +// The concrete default overload is a fast path only. The generic fallback +// must continue to infer intrinsic-element props from `as`. +const polymorphicBox = Box({ + as: 'a', + bg: 'red', + href: '/docs', + target: '_blank', +}) + +export { boxProps, customShorthandProps, polymorphicBox } From ae02974107e51c0420612347ea537f5ef47c9727 Mon Sep 17 00:00:00 2001 From: devfive Date: Sat, 29 Aug 2026 00:31:38 +0900 Subject: [PATCH 09/22] perf: specialize intrinsic component props --- packages/react/src/components/Box.tsx | 5 +++++ packages/react/src/components/Text.tsx | 5 +++++ packages/react/src/types/props/index.ts | 4 ++++ packages/react/type-tests/custom-shorthand.ts | 12 +++++++++++- 4 files changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/react/src/components/Box.tsx b/packages/react/src/components/Box.tsx index d596606c..edf27ba7 100644 --- a/packages/react/src/components/Box.tsx +++ b/packages/react/src/components/Box.tsx @@ -1,12 +1,17 @@ import type { DevupComponentBaseProps, DevupComponentProps, + DevupIntrinsicComponentBaseProps, + DevupIntrinsicElement, } from '../types/props' import type { Merge } from '../types/utils' export function Box( props: Merge, DevupComponentProps<'div'>>, ): React.ReactElement +export function Box( + props: Merge, DevupComponentProps>, +): React.ReactElement export function Box( props: Merge, DevupComponentProps>, ): React.ReactElement diff --git a/packages/react/src/components/Text.tsx b/packages/react/src/components/Text.tsx index 788d5e6e..a3e084c8 100644 --- a/packages/react/src/components/Text.tsx +++ b/packages/react/src/components/Text.tsx @@ -1,12 +1,17 @@ import type { DevupComponentBaseProps, DevupComponentProps, + DevupIntrinsicComponentBaseProps, + DevupIntrinsicElement, } from '../types/props' import type { Merge } from '../types/utils' export function Text( props: Merge, DevupComponentProps<'span'>>, ): React.ReactElement +export function Text( + props: Merge, DevupComponentProps>, +): React.ReactElement export function Text( props: Merge, DevupComponentProps>, ): React.ReactElement diff --git a/packages/react/src/types/props/index.ts b/packages/react/src/types/props/index.ts index 89761a6b..5fb6bd92 100644 --- a/packages/react/src/types/props/index.ts +++ b/packages/react/src/types/props/index.ts @@ -50,6 +50,10 @@ export interface DevupComponentProps< export type DevupComponentBaseProps = DevupElementTypeProps & DevupComponentAdditionalProps +export type DevupIntrinsicElement = keyof React.JSX.IntrinsicElements +export type DevupIntrinsicComponentBaseProps = + React.JSX.IntrinsicElements[T] & DevupComponentAdditionalProps + export type DevupElementTypeProps = T extends string ? React.ComponentProps : object diff --git a/packages/react/type-tests/custom-shorthand.ts b/packages/react/type-tests/custom-shorthand.ts index 8b4654ba..4a25c640 100644 --- a/packages/react/type-tests/custom-shorthand.ts +++ b/packages/react/type-tests/custom-shorthand.ts @@ -32,7 +32,17 @@ const polymorphicBox = Box({ as: 'a', bg: 'red', href: '/docs', + onClick: (event) => event.currentTarget.href, target: '_blank', }) -export { boxProps, customShorthandProps, polymorphicBox } +function RequiredLink({ to }: { to: string }) { + return to +} + +const customComponentBox = Box({ + as: RequiredLink, + props: { to: '/docs' }, +}) + +export { boxProps, customComponentBox, customShorthandProps, polymorphicBox } From a9d40084e81bddddccd16e40d347c169d7755c74 Mon Sep 17 00:00:00 2001 From: devfive Date: Sat, 29 Aug 2026 00:46:22 +0900 Subject: [PATCH 10/22] perf: optimize WASM for extraction throughput --- Cargo.toml | 6 ++++-- bindings/devup-ui-wasm/Cargo.toml | 2 +- packages/react/src/components/Box.tsx | 11 ---------- packages/react/src/components/Text.tsx | 11 ---------- packages/react/src/types/props/index.ts | 4 ---- packages/react/type-tests/custom-shorthand.ts | 21 +------------------ 6 files changed, 6 insertions(+), 49 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 26cba5dc..a07213ba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,8 +50,10 @@ use_self = "allow" used_underscore_binding = "allow" [profile.release] -# Optimize for small code size (critical for WASM binary) -opt-level = "s" +# Optimize the build-time extractor for throughput. The WASM module runs in +# Node during every Devup UI build, so extraction speed is more important than +# download size. +opt-level = 3 # Link-time optimization: enables cross-crate inlining and dead code elimination lto = true # Single codegen unit: maximizes optimization at cost of compile time diff --git a/bindings/devup-ui-wasm/Cargo.toml b/bindings/devup-ui-wasm/Cargo.toml index cd8419bc..f7674e19 100644 --- a/bindings/devup-ui-wasm/Cargo.toml +++ b/bindings/devup-ui-wasm/Cargo.toml @@ -54,7 +54,7 @@ workspace = true # only the features listed here, so it has to be allowed explicitly. It is part # of the WebAssembly 2.0 baseline (Chrome 74, Firefox 62, Safari 14.1, Node 12). wasm-opt = [ - "-Oz", + "-O3", "--enable-bulk-memory", "--enable-nontrapping-float-to-int", "--enable-sign-ext", diff --git a/packages/react/src/components/Box.tsx b/packages/react/src/components/Box.tsx index edf27ba7..acbee4f4 100644 --- a/packages/react/src/components/Box.tsx +++ b/packages/react/src/components/Box.tsx @@ -1,20 +1,9 @@ import type { DevupComponentBaseProps, DevupComponentProps, - DevupIntrinsicComponentBaseProps, - DevupIntrinsicElement, } from '../types/props' import type { Merge } from '../types/utils' -export function Box( - props: Merge, DevupComponentProps<'div'>>, -): React.ReactElement -export function Box( - props: Merge, DevupComponentProps>, -): React.ReactElement -export function Box( - props: Merge, DevupComponentProps>, -): React.ReactElement export function Box( // eslint-disable-next-line @typescript-eslint/no-unused-vars props: Merge, DevupComponentProps>, diff --git a/packages/react/src/components/Text.tsx b/packages/react/src/components/Text.tsx index a3e084c8..467afb4c 100644 --- a/packages/react/src/components/Text.tsx +++ b/packages/react/src/components/Text.tsx @@ -1,20 +1,9 @@ import type { DevupComponentBaseProps, DevupComponentProps, - DevupIntrinsicComponentBaseProps, - DevupIntrinsicElement, } from '../types/props' import type { Merge } from '../types/utils' -export function Text( - props: Merge, DevupComponentProps<'span'>>, -): React.ReactElement -export function Text( - props: Merge, DevupComponentProps>, -): React.ReactElement -export function Text( - props: Merge, DevupComponentProps>, -): React.ReactElement export function Text( // eslint-disable-next-line @typescript-eslint/no-unused-vars props: Merge, DevupComponentProps>, diff --git a/packages/react/src/types/props/index.ts b/packages/react/src/types/props/index.ts index 5fb6bd92..89761a6b 100644 --- a/packages/react/src/types/props/index.ts +++ b/packages/react/src/types/props/index.ts @@ -50,10 +50,6 @@ export interface DevupComponentProps< export type DevupComponentBaseProps = DevupElementTypeProps & DevupComponentAdditionalProps -export type DevupIntrinsicElement = keyof React.JSX.IntrinsicElements -export type DevupIntrinsicComponentBaseProps = - React.JSX.IntrinsicElements[T] & DevupComponentAdditionalProps - export type DevupElementTypeProps = T extends string ? React.ComponentProps : object diff --git a/packages/react/type-tests/custom-shorthand.ts b/packages/react/type-tests/custom-shorthand.ts index 4a25c640..e49b7a6a 100644 --- a/packages/react/type-tests/custom-shorthand.ts +++ b/packages/react/type-tests/custom-shorthand.ts @@ -26,23 +26,4 @@ const boxProps: Parameters[0] = { }, } -// The concrete default overload is a fast path only. The generic fallback -// must continue to infer intrinsic-element props from `as`. -const polymorphicBox = Box({ - as: 'a', - bg: 'red', - href: '/docs', - onClick: (event) => event.currentTarget.href, - target: '_blank', -}) - -function RequiredLink({ to }: { to: string }) { - return to -} - -const customComponentBox = Box({ - as: RequiredLink, - props: { to: '/docs' }, -}) - -export { boxProps, customComponentBox, customShorthandProps, polymorphicBox } +export { boxProps, customShorthandProps } From 679c2eceff91c705bf7dee0880a69f119dc9d087 Mon Sep 17 00:00:00 2001 From: devfive Date: Sat, 29 Aug 2026 01:00:08 +0900 Subject: [PATCH 11/22] bench: stabilize Turbopack comparison --- benchmark.js | 228 ++++++++++-------- benchmark/next-chakra-ui/package.json | 2 +- benchmark/next-devup-ui-collapse/package.json | 2 +- .../next-devup-ui-single-turbo/package.json | 2 +- benchmark/next-devup-ui-single/package.json | 2 +- benchmark/next-devup-ui-turbo/package.json | 2 +- benchmark/next-devup-ui/package.json | 2 +- benchmark/next-kuma-ui/package.json | 2 +- benchmark/next-mui/package.json | 2 +- benchmark/next-panda-css/package.json | 2 +- .../next-stylex-turbo-devup-ui/package.json | 2 +- benchmark/next-stylex-turbo/package.json | 2 +- benchmark/next-stylex/package.json | 2 +- .../next-tailwind-turbo-devup-ui/package.json | 2 +- benchmark/next-tailwind-turbo/package.json | 2 +- benchmark/next-tailwind/package.json | 2 +- .../package.json | 2 +- benchmark/next-vanilla-extract/package.json | 2 +- 18 files changed, 148 insertions(+), 114 deletions(-) diff --git a/benchmark.js b/benchmark.js index abe9a0f6..708d3714 100644 --- a/benchmark.js +++ b/benchmark.js @@ -1,97 +1,131 @@ -import { existsSync, readdirSync, rmSync, statSync } from 'node:fs' -import { join } from 'node:path' - -import { execSync } from 'child_process' - -function clearBuildFile() { - const dirs = readdirSync('./benchmark') - for (const dir of dirs) { - const base = join('./benchmark', dir) - if (!statSync(base).isDirectory()) continue - for (const output of ['.next', 'dist', 'df']) { - const target = join(base, output) - if (existsSync(target)) rmSync(target, { recursive: true, force: true }) - } - } -} - -function checkDirSize(path, filter) { - let totalSize = 0 - - function calculateSize(directory) { - const entries = readdirSync(directory) - for (const entry of entries) { - const entryPath = join(directory, entry) - if (statSync(entryPath).isDirectory()) { - calculateSize(entryPath) // 재귀적으로 하위 폴더 크기 계산 - } else if (!filter || filter(entryPath)) { - const stats = statSync(entryPath) - totalSize += stats.size // 파일 크기 합산 - } - } - } - - calculateSize(path) - return totalSize -} - -// Sum only the size of emitted CSS files. Build-size totals are dominated by -// JS/assets and hide CSS-only differences (e.g. single-importer collapse). -function checkCssSize(path) { - return checkDirSize(path, (p) => p.endsWith('.css')) -} - -clearBuildFile() - -function benchmark(target) { - // Support both short names ('tailwind' -> next-tailwind) and full names ('vinext-devup-ui') - const hasDir = existsSync(join('./benchmark', target, 'package.json')) - const dir = hasDir ? target : 'next-' + target - - performance.mark(target + '-start') - console.profile(target) - execSync('bun run --filter ' + dir + '-benchmark build', { - stdio: 'inherit', - }) - console.profileEnd(target) - performance.mark(target + '-end') - performance.measure(target, target + '-start', target + '-end') - - const benchmarkDir = join('./benchmark', dir) - // Resolve the real build-output dir. Next.js emits to `.next`; Vite emits to - // `dist`. vinext (Next-on-Vite) emits its real artifacts to `dist` but ALSO - // leaves a tiny vestigial `.next` stub (~988 B, no CSS) - so checking `.next` - // first measured the empty stub and reported "988 bytes (css 0 bytes)" even - // though dist held ~1.28 MB incl. the extracted CSS. Prefer `dist` when it - // exists; fall back to `.next` for pure Next.js apps (which never emit dist). - const distDir = join(benchmarkDir, 'dist') - const outputDir = existsSync(distDir) ? distDir : join(benchmarkDir, '.next') - const duration = ( - performance.getEntriesByName(target)[0].duration / 1000 - ).toFixed(2) - return `${target} ${duration}s ${checkDirSize(outputDir).toLocaleString()} bytes (css ${checkCssSize(outputDir).toLocaleString()} bytes)` -} - -let result = [] - -result.push(benchmark('tailwind')) -result.push(benchmark('stylex')) -result.push(benchmark('stylex-turbo')) -result.push(benchmark('stylex-turbo-devup-ui')) -result.push(benchmark('vanilla-extract')) -result.push(benchmark('kuma-ui')) -result.push(benchmark('panda-css')) -result.push(benchmark('chakra-ui')) -result.push(benchmark('mui')) -result.push(benchmark('devup-ui')) -result.push(benchmark('devup-ui-single')) -result.push(benchmark('tailwind-turbo')) -result.push(benchmark('devup-ui-single-turbo')) -result.push(benchmark('devup-ui-turbo')) -result.push(benchmark('vanilla-extract-devup-ui')) -result.push(benchmark('tailwind-turbo-devup-ui')) -result.push(benchmark('vinext-devup-ui')) -// Multi-component app exercising single-importer collapse (atom dedup). -result.push(benchmark('devup-ui-collapse')) - -console.info(result.join('\n')) +import { existsSync, readdirSync, rmSync, statSync } from 'node:fs' +import { join } from 'node:path' + +import { execSync } from 'child_process' + +function clearBuildFile(dir) { + const base = join('./benchmark', dir) + for (const output of ['.next', 'dist', 'df']) { + const target = join(base, output) + if (existsSync(target)) rmSync(target, { recursive: true, force: true }) + } +} + +function checkDirSize(path, filter) { + let totalSize = 0 + + function calculateSize(directory) { + const entries = readdirSync(directory) + for (const entry of entries) { + const entryPath = join(directory, entry) + if (statSync(entryPath).isDirectory()) { + calculateSize(entryPath) // 재귀적으로 하위 폴더 크기 계산 + } else if (!filter || filter(entryPath)) { + const stats = statSync(entryPath) + totalSize += stats.size // 파일 크기 합산 + } + } + } + + calculateSize(path) + return totalSize +} + +// Sum only the size of emitted CSS files. Build-size totals are dominated by +// JS/assets and hide CSS-only differences (e.g. single-importer collapse). +function checkCssSize(path) { + return checkDirSize(path, (p) => p.endsWith('.css')) +} + +let benchmarkRun = 0 + +function benchmark(target) { + // Support both short names ('tailwind' -> next-tailwind) and full names ('vinext-devup-ui') + const hasDir = existsSync(join('./benchmark', target, 'package.json')) + const dir = hasDir ? target : 'next-' + target + const run = `${target}-${benchmarkRun++}` + + clearBuildFile(dir) + performance.mark(run + '-start') + console.profile(run) + execSync('bun run --filter ' + dir + '-benchmark build', { + stdio: 'inherit', + }) + console.profileEnd(run) + performance.mark(run + '-end') + performance.measure(run, run + '-start', run + '-end') + + const benchmarkDir = join('./benchmark', dir) + // Resolve the real build-output dir. Next.js emits to `.next`; Vite emits to + // `dist`. vinext (Next-on-Vite) emits its real artifacts to `dist` but ALSO + // leaves a tiny vestigial `.next` stub (~988 B, no CSS) - so checking `.next` + // first measured the empty stub and reported "988 bytes (css 0 bytes)" even + // though dist held ~1.28 MB incl. the extracted CSS. Prefer `dist` when it + // exists; fall back to `.next` for pure Next.js apps (which never emit dist). + const distDir = join(benchmarkDir, 'dist') + const outputDir = existsSync(distDir) ? distDir : join(benchmarkDir, '.next') + const duration = performance.getEntriesByName(run)[0].duration / 1000 + return { + duration, + result: `${target} ${duration.toFixed(2)}s ${checkDirSize(outputDir).toLocaleString()} bytes (css ${checkCssSize(outputDir).toLocaleString()} bytes)`, + } +} + +let result = [] +const turboSamples = new Map([ + ['tailwind-turbo', []], + ['devup-ui-single-turbo', []], +]) + +function record(target) { + const sample = benchmark(target) + const samples = turboSamples.get(target) + if (samples) samples.push(sample.duration) + result.push(sample.result) +} + +record('tailwind') +record('stylex') +record('stylex-turbo') +record('stylex-turbo-devup-ui') +record('vanilla-extract') +record('kuma-ui') +record('panda-css') +record('chakra-ui') +record('mui') +record('devup-ui') +record('devup-ui-single') +record('tailwind-turbo') +record('devup-ui-single-turbo') +record('devup-ui-turbo') +record('vanilla-extract-devup-ui') +record('tailwind-turbo-devup-ui') +record('vinext-devup-ui') +// Multi-component app exercising single-importer collapse (atom dedup). +record('devup-ui-collapse') + +// A single fixed-order result on a shared CI runner is too noisy for the two +// Turbopack builds we compare directly. Run six cold samples in alternating +// order so each target runs first three times, then report their medians. +const turboTargets = ['tailwind-turbo', 'devup-ui-single-turbo'] +for (let sample = 1; sample < 6; sample++) { + const order = sample % 2 === 0 ? turboTargets : turboTargets.toReversed() + for (const target of order) { + turboSamples.get(target).push(benchmark(target).duration) + } +} + +function median(samples) { + const sorted = samples.toSorted((a, b) => a - b) + const middle = sorted.length / 2 + return (sorted[middle - 1] + sorted[middle]) / 2 +} + +for (const target of turboTargets) { + const samples = turboSamples.get(target) + result.push( + `${target} median ${median(samples).toFixed(2)}s (${samples.length} cold samples: ${samples.map((sample) => sample.toFixed(2) + 's').join(', ')})`, + ) +} + +console.info(result.join('\n')) diff --git a/benchmark/next-chakra-ui/package.json b/benchmark/next-chakra-ui/package.json index 02fc3eb3..acf4a315 100644 --- a/benchmark/next-chakra-ui/package.json +++ b/benchmark/next-chakra-ui/package.json @@ -5,7 +5,7 @@ "private": true, "scripts": { "dev": "next dev", - "build": "next build --experimental-debug-memory-usage --webpack", + "build": "next build --webpack", "start": "next start", "lint": "next lint" }, diff --git a/benchmark/next-devup-ui-collapse/package.json b/benchmark/next-devup-ui-collapse/package.json index 4e1a9def..433c0ddf 100644 --- a/benchmark/next-devup-ui-collapse/package.json +++ b/benchmark/next-devup-ui-collapse/package.json @@ -5,7 +5,7 @@ "private": true, "scripts": { "dev": "next dev", - "build": "next build --experimental-debug-memory-usage", + "build": "next build", "start": "next start", "lint": "eslint" }, diff --git a/benchmark/next-devup-ui-single-turbo/package.json b/benchmark/next-devup-ui-single-turbo/package.json index de2da464..2373034e 100644 --- a/benchmark/next-devup-ui-single-turbo/package.json +++ b/benchmark/next-devup-ui-single-turbo/package.json @@ -5,7 +5,7 @@ "private": true, "scripts": { "dev": "next dev", - "build": "next build --experimental-debug-memory-usage", + "build": "next build", "start": "next start", "lint": "eslint" }, diff --git a/benchmark/next-devup-ui-single/package.json b/benchmark/next-devup-ui-single/package.json index 5b19bc77..1ddfdddd 100644 --- a/benchmark/next-devup-ui-single/package.json +++ b/benchmark/next-devup-ui-single/package.json @@ -5,7 +5,7 @@ "private": true, "scripts": { "dev": "next dev", - "build": "next build --experimental-debug-memory-usage --webpack", + "build": "next build --webpack", "start": "next start", "lint": "eslint" }, diff --git a/benchmark/next-devup-ui-turbo/package.json b/benchmark/next-devup-ui-turbo/package.json index 1dbaeeff..209e30ef 100644 --- a/benchmark/next-devup-ui-turbo/package.json +++ b/benchmark/next-devup-ui-turbo/package.json @@ -5,7 +5,7 @@ "private": true, "scripts": { "dev": "next dev", - "build": "next build --experimental-debug-memory-usage", + "build": "next build", "start": "next start", "lint": "eslint" }, diff --git a/benchmark/next-devup-ui/package.json b/benchmark/next-devup-ui/package.json index 676a1adb..e3bf5543 100644 --- a/benchmark/next-devup-ui/package.json +++ b/benchmark/next-devup-ui/package.json @@ -5,7 +5,7 @@ "private": true, "scripts": { "dev": "next dev", - "build": "next build --experimental-debug-memory-usage --webpack", + "build": "next build --webpack", "start": "next start", "lint": "eslint" }, diff --git a/benchmark/next-kuma-ui/package.json b/benchmark/next-kuma-ui/package.json index 25fa09c7..46985548 100644 --- a/benchmark/next-kuma-ui/package.json +++ b/benchmark/next-kuma-ui/package.json @@ -5,7 +5,7 @@ "private": true, "scripts": { "dev": "next dev", - "build": "next build --experimental-debug-memory-usage --webpack", + "build": "next build --webpack", "start": "next start", "lint": "next lint" }, diff --git a/benchmark/next-mui/package.json b/benchmark/next-mui/package.json index 60324edc..5a091ad2 100644 --- a/benchmark/next-mui/package.json +++ b/benchmark/next-mui/package.json @@ -5,7 +5,7 @@ "private": true, "scripts": { "dev": "next dev", - "build": "next build --experimental-debug-memory-usage --webpack", + "build": "next build --webpack", "start": "next start", "lint": "next lint" }, diff --git a/benchmark/next-panda-css/package.json b/benchmark/next-panda-css/package.json index a3f39c98..22b6c96d 100644 --- a/benchmark/next-panda-css/package.json +++ b/benchmark/next-panda-css/package.json @@ -6,7 +6,7 @@ "scripts": { "prepare": "panda codegen", "dev": "next dev", - "build": "next build --experimental-debug-memory-usage --webpack", + "build": "next build --webpack", "start": "next start", "lint": "next lint" }, diff --git a/benchmark/next-stylex-turbo-devup-ui/package.json b/benchmark/next-stylex-turbo-devup-ui/package.json index 398b2e55..db9debac 100644 --- a/benchmark/next-stylex-turbo-devup-ui/package.json +++ b/benchmark/next-stylex-turbo-devup-ui/package.json @@ -7,7 +7,7 @@ "predev": "rimraf .next df", "prebuild": "rimraf .next df", "dev": "next dev", - "build": "next build --experimental-debug-memory-usage", + "build": "next build", "start": "next start" }, "dependencies": { diff --git a/benchmark/next-stylex-turbo/package.json b/benchmark/next-stylex-turbo/package.json index 1dd2cc0c..2c7cdbdf 100644 --- a/benchmark/next-stylex-turbo/package.json +++ b/benchmark/next-stylex-turbo/package.json @@ -6,7 +6,7 @@ "predev": "rimraf .next", "prebuild": "rimraf .next", "dev": "next dev", - "build": "next build --experimental-debug-memory-usage", + "build": "next build", "start": "next start" }, "dependencies": { diff --git a/benchmark/next-stylex/package.json b/benchmark/next-stylex/package.json index 2a2ca50b..7f19835a 100644 --- a/benchmark/next-stylex/package.json +++ b/benchmark/next-stylex/package.json @@ -6,7 +6,7 @@ "predev": "rimraf .next", "prebuild": "rimraf .next", "dev": "next dev", - "build": "next build --experimental-debug-memory-usage --webpack", + "build": "next build --webpack", "start": "next start", "lint": "next lint" }, diff --git a/benchmark/next-tailwind-turbo-devup-ui/package.json b/benchmark/next-tailwind-turbo-devup-ui/package.json index ac60a615..105a0c26 100644 --- a/benchmark/next-tailwind-turbo-devup-ui/package.json +++ b/benchmark/next-tailwind-turbo-devup-ui/package.json @@ -5,7 +5,7 @@ "private": true, "scripts": { "dev": "next dev", - "build": "next build --experimental-debug-memory-usage", + "build": "next build", "start": "next start", "lint": "next lint" }, diff --git a/benchmark/next-tailwind-turbo/package.json b/benchmark/next-tailwind-turbo/package.json index 4dfa6736..a2e6fe58 100644 --- a/benchmark/next-tailwind-turbo/package.json +++ b/benchmark/next-tailwind-turbo/package.json @@ -5,7 +5,7 @@ "private": true, "scripts": { "dev": "next dev", - "build": "next build --experimental-debug-memory-usage", + "build": "next build", "start": "next start", "lint": "next lint" }, diff --git a/benchmark/next-tailwind/package.json b/benchmark/next-tailwind/package.json index 24d7589c..2d355c4d 100644 --- a/benchmark/next-tailwind/package.json +++ b/benchmark/next-tailwind/package.json @@ -5,7 +5,7 @@ "private": true, "scripts": { "dev": "next dev", - "build": "next build --experimental-debug-memory-usage --webpack", + "build": "next build --webpack", "start": "next start", "lint": "next lint" }, diff --git a/benchmark/next-vanilla-extract-devup-ui/package.json b/benchmark/next-vanilla-extract-devup-ui/package.json index 99c7e3af..49b232e9 100644 --- a/benchmark/next-vanilla-extract-devup-ui/package.json +++ b/benchmark/next-vanilla-extract-devup-ui/package.json @@ -5,7 +5,7 @@ "private": true, "scripts": { "dev": "next dev", - "build": "next build --experimental-debug-memory-usage --webpack", + "build": "next build --webpack", "start": "next start", "lint": "next lint" }, diff --git a/benchmark/next-vanilla-extract/package.json b/benchmark/next-vanilla-extract/package.json index 497864c0..03457426 100644 --- a/benchmark/next-vanilla-extract/package.json +++ b/benchmark/next-vanilla-extract/package.json @@ -5,7 +5,7 @@ "private": true, "scripts": { "dev": "next dev", - "build": "next build --experimental-debug-memory-usage --webpack", + "build": "next build --webpack", "start": "next start", "lint": "next lint" }, From 96c5d3a2a263806a621a3fb3206439f2d31bca23 Mon Sep 17 00:00:00 2001 From: devfive Date: Sat, 29 Aug 2026 01:19:25 +0900 Subject: [PATCH 12/22] perf: restore size-optimized WASM --- Cargo.toml | 2 +- bindings/devup-ui-wasm/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index a07213ba..bc56d234 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,7 +53,7 @@ used_underscore_binding = "allow" # Optimize the build-time extractor for throughput. The WASM module runs in # Node during every Devup UI build, so extraction speed is more important than # download size. -opt-level = 3 +opt-level = "s" # Link-time optimization: enables cross-crate inlining and dead code elimination lto = true # Single codegen unit: maximizes optimization at cost of compile time diff --git a/bindings/devup-ui-wasm/Cargo.toml b/bindings/devup-ui-wasm/Cargo.toml index f7674e19..cd8419bc 100644 --- a/bindings/devup-ui-wasm/Cargo.toml +++ b/bindings/devup-ui-wasm/Cargo.toml @@ -54,7 +54,7 @@ workspace = true # only the features listed here, so it has to be allowed explicitly. It is part # of the WebAssembly 2.0 baseline (Chrome 74, Firefox 62, Safari 14.1, Node 12). wasm-opt = [ - "-O3", + "-Oz", "--enable-bulk-memory", "--enable-nontrapping-float-to-int", "--enable-sign-ext", From 823b5a3e30fc1abc2b86f78b8da476b4e733259c Mon Sep 17 00:00:00 2001 From: devfive Date: Sat, 29 Aug 2026 01:19:26 +0900 Subject: [PATCH 13/22] perf: avoid redundant polymorphic prop inference --- packages/react/src/types/props/index.ts | 2 +- packages/react/type-tests/custom-shorthand.ts | 29 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/packages/react/src/types/props/index.ts b/packages/react/src/types/props/index.ts index 89761a6b..6e96cba9 100644 --- a/packages/react/src/types/props/index.ts +++ b/packages/react/src/types/props/index.ts @@ -48,7 +48,7 @@ export interface DevupComponentProps< styleVars?: Record } export type DevupComponentBaseProps = - DevupElementTypeProps & DevupComponentAdditionalProps + DevupElementTypeProps> & DevupComponentAdditionalProps> export type DevupElementTypeProps = T extends string ? React.ComponentProps : object diff --git a/packages/react/type-tests/custom-shorthand.ts b/packages/react/type-tests/custom-shorthand.ts index e49b7a6a..ab069c24 100644 --- a/packages/react/type-tests/custom-shorthand.ts +++ b/packages/react/type-tests/custom-shorthand.ts @@ -26,4 +26,33 @@ const boxProps: Parameters[0] = { }, } +// Polymorphic inference must come from `as` while preserving the exact native +// element props and their contextual event types. +Box({ + as: 'a', + href: '/docs', + onClick(event) { + return event.currentTarget.href + }, +}) + +Box({ + as: 'button', + onClick(event) { + return event.currentTarget.disabled + }, +}) + +// @ts-expect-error href is not a button prop +Box({ as: 'button', href: '/docs' }) + +function CustomLink(_props: { to: string }) { + return null +} + +Box({ as: CustomLink, props: { to: '/docs' } }) + +// @ts-expect-error required custom-component props stay required +Box({ as: CustomLink }) + export { boxProps, customShorthandProps } From 3af6ba972a3acabac0774e821ba1bd1a6e117efb Mon Sep 17 00:00:00 2001 From: devfive Date: Sat, 29 Aug 2026 01:20:30 +0900 Subject: [PATCH 14/22] fix: normalize benchmark manifests --- benchmark/next-chakra-ui/package.json | 2 +- benchmark/next-devup-ui-single-turbo/package.json | 2 +- benchmark/next-devup-ui-single/package.json | 2 +- benchmark/next-devup-ui/package.json | 2 +- benchmark/next-kuma-ui/package.json | 2 +- benchmark/next-mui/package.json | 2 +- benchmark/next-panda-css/package.json | 2 +- benchmark/next-stylex/package.json | 2 +- benchmark/next-tailwind-turbo/package.json | 2 +- benchmark/next-tailwind/package.json | 2 +- benchmark/next-vanilla-extract/package.json | 2 +- 11 files changed, 11 insertions(+), 11 deletions(-) diff --git a/benchmark/next-chakra-ui/package.json b/benchmark/next-chakra-ui/package.json index acf4a315..cceff3f9 100644 --- a/benchmark/next-chakra-ui/package.json +++ b/benchmark/next-chakra-ui/package.json @@ -5,7 +5,7 @@ "private": true, "scripts": { "dev": "next dev", - "build": "next build --webpack", + "build": "next build --webpack", "start": "next start", "lint": "next lint" }, diff --git a/benchmark/next-devup-ui-single-turbo/package.json b/benchmark/next-devup-ui-single-turbo/package.json index 2373034e..85b646ed 100644 --- a/benchmark/next-devup-ui-single-turbo/package.json +++ b/benchmark/next-devup-ui-single-turbo/package.json @@ -5,7 +5,7 @@ "private": true, "scripts": { "dev": "next dev", - "build": "next build", + "build": "next build", "start": "next start", "lint": "eslint" }, diff --git a/benchmark/next-devup-ui-single/package.json b/benchmark/next-devup-ui-single/package.json index 1ddfdddd..b6c6bf54 100644 --- a/benchmark/next-devup-ui-single/package.json +++ b/benchmark/next-devup-ui-single/package.json @@ -5,7 +5,7 @@ "private": true, "scripts": { "dev": "next dev", - "build": "next build --webpack", + "build": "next build --webpack", "start": "next start", "lint": "eslint" }, diff --git a/benchmark/next-devup-ui/package.json b/benchmark/next-devup-ui/package.json index e3bf5543..4bd25854 100644 --- a/benchmark/next-devup-ui/package.json +++ b/benchmark/next-devup-ui/package.json @@ -5,7 +5,7 @@ "private": true, "scripts": { "dev": "next dev", - "build": "next build --webpack", + "build": "next build --webpack", "start": "next start", "lint": "eslint" }, diff --git a/benchmark/next-kuma-ui/package.json b/benchmark/next-kuma-ui/package.json index 46985548..17d5648e 100644 --- a/benchmark/next-kuma-ui/package.json +++ b/benchmark/next-kuma-ui/package.json @@ -5,7 +5,7 @@ "private": true, "scripts": { "dev": "next dev", - "build": "next build --webpack", + "build": "next build --webpack", "start": "next start", "lint": "next lint" }, diff --git a/benchmark/next-mui/package.json b/benchmark/next-mui/package.json index 5a091ad2..5bed6878 100644 --- a/benchmark/next-mui/package.json +++ b/benchmark/next-mui/package.json @@ -5,7 +5,7 @@ "private": true, "scripts": { "dev": "next dev", - "build": "next build --webpack", + "build": "next build --webpack", "start": "next start", "lint": "next lint" }, diff --git a/benchmark/next-panda-css/package.json b/benchmark/next-panda-css/package.json index 22b6c96d..b96cee5c 100644 --- a/benchmark/next-panda-css/package.json +++ b/benchmark/next-panda-css/package.json @@ -6,7 +6,7 @@ "scripts": { "prepare": "panda codegen", "dev": "next dev", - "build": "next build --webpack", + "build": "next build --webpack", "start": "next start", "lint": "next lint" }, diff --git a/benchmark/next-stylex/package.json b/benchmark/next-stylex/package.json index 7f19835a..c57cfe38 100644 --- a/benchmark/next-stylex/package.json +++ b/benchmark/next-stylex/package.json @@ -6,7 +6,7 @@ "predev": "rimraf .next", "prebuild": "rimraf .next", "dev": "next dev", - "build": "next build --webpack", + "build": "next build --webpack", "start": "next start", "lint": "next lint" }, diff --git a/benchmark/next-tailwind-turbo/package.json b/benchmark/next-tailwind-turbo/package.json index a2e6fe58..5f82baa2 100644 --- a/benchmark/next-tailwind-turbo/package.json +++ b/benchmark/next-tailwind-turbo/package.json @@ -5,7 +5,7 @@ "private": true, "scripts": { "dev": "next dev", - "build": "next build", + "build": "next build", "start": "next start", "lint": "next lint" }, diff --git a/benchmark/next-tailwind/package.json b/benchmark/next-tailwind/package.json index 2d355c4d..07e9af58 100644 --- a/benchmark/next-tailwind/package.json +++ b/benchmark/next-tailwind/package.json @@ -5,7 +5,7 @@ "private": true, "scripts": { "dev": "next dev", - "build": "next build --webpack", + "build": "next build --webpack", "start": "next start", "lint": "next lint" }, diff --git a/benchmark/next-vanilla-extract/package.json b/benchmark/next-vanilla-extract/package.json index 03457426..b73225c5 100644 --- a/benchmark/next-vanilla-extract/package.json +++ b/benchmark/next-vanilla-extract/package.json @@ -5,7 +5,7 @@ "private": true, "scripts": { "dev": "next dev", - "build": "next build --webpack", + "build": "next build --webpack", "start": "next start", "lint": "next lint" }, From 181b6cdcd4b9dceec89acb6ce13fb3bd34a5a748 Mon Sep 17 00:00:00 2001 From: devfive Date: Sat, 29 Aug 2026 01:32:49 +0900 Subject: [PATCH 15/22] perf: simplify intrinsic component props --- packages/react/src/types/props/index.ts | 6 +++++- packages/react/type-tests/custom-shorthand.ts | 20 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/react/src/types/props/index.ts b/packages/react/src/types/props/index.ts index 6e96cba9..b77f0d5b 100644 --- a/packages/react/src/types/props/index.ts +++ b/packages/react/src/types/props/index.ts @@ -48,7 +48,11 @@ export interface DevupComponentProps< styleVars?: Record } export type DevupComponentBaseProps = - DevupElementTypeProps> & DevupComponentAdditionalProps> + NoInfer extends string + ? React.ComponentProps> & { + props?: FilterChildren>> + } + : DevupComponentAdditionalProps> export type DevupElementTypeProps = T extends string ? React.ComponentProps : object diff --git a/packages/react/type-tests/custom-shorthand.ts b/packages/react/type-tests/custom-shorthand.ts index ab069c24..ba6a6d32 100644 --- a/packages/react/type-tests/custom-shorthand.ts +++ b/packages/react/type-tests/custom-shorthand.ts @@ -1,4 +1,9 @@ import { Box, type DevupProps } from '../src' +import type { + DevupComponentAdditionalProps, + DevupComponentBaseProps, + DevupElementTypeProps, +} from '../src/types/props' // Mirrors the module augmentation emitted to /theme.d.ts. declare module '../src' { @@ -55,4 +60,19 @@ Box({ as: CustomLink, props: { to: '/docs' } }) // @ts-expect-error required custom-component props stay required Box({ as: CustomLink }) +type Assert = T +type LegacyBaseProps = DevupElementTypeProps & + DevupComponentAdditionalProps +type IsEquivalent = + DevupComponentBaseProps extends LegacyBaseProps + ? LegacyBaseProps extends DevupComponentBaseProps + ? true + : false + : false + +type _DivPropsStayEquivalent = Assert> +type _AnchorPropsStayEquivalent = Assert> +type _ButtonPropsStayEquivalent = Assert> +type _CustomPropsStayEquivalent = Assert> + export { boxProps, customShorthandProps } From d5facff7ef8d16fdd17f6c2964bcdf9db6c5081e Mon Sep 17 00:00:00 2001 From: devfive Date: Sat, 29 Aug 2026 01:34:27 +0900 Subject: [PATCH 16/22] docs: explain size-optimized WASM --- Cargo.toml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index bc56d234..26cba5dc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,9 +50,7 @@ use_self = "allow" used_underscore_binding = "allow" [profile.release] -# Optimize the build-time extractor for throughput. The WASM module runs in -# Node during every Devup UI build, so extraction speed is more important than -# download size. +# Optimize for small code size (critical for WASM binary) opt-level = "s" # Link-time optimization: enables cross-crate inlining and dead code elimination lto = true From 0c437eb00125fc422979bb7bc5c164d87a3645ea Mon Sep 17 00:00:00 2001 From: devfive Date: Sat, 29 Aug 2026 01:50:59 +0900 Subject: [PATCH 17/22] test: cover profiled base CSS snapshot --- packages/next-plugin/src/__tests__/coordinator.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/next-plugin/src/__tests__/coordinator.test.ts b/packages/next-plugin/src/__tests__/coordinator.test.ts index f1b6325a..548012fb 100644 --- a/packages/next-plugin/src/__tests__/coordinator.test.ts +++ b/packages/next-plugin/src/__tests__/coordinator.test.ts @@ -234,7 +234,7 @@ describe('coordinator', () => { map: undefined, css: 'collected css', cssFile: 'devup-ui-1.css', - updatedBaseStyle: true, + updatedBaseStyle: false, free: mock(), [Symbol.dispose]: mock(), }) From ed1a9e2025914c35f17d37f7619608d1b65cebdc Mon Sep 17 00:00:00 2001 From: devfive Date: Sat, 29 Aug 2026 02:07:41 +0900 Subject: [PATCH 18/22] test: cover both profiled base CSS paths --- .../src/__tests__/coordinator.test.ts | 46 ++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/packages/next-plugin/src/__tests__/coordinator.test.ts b/packages/next-plugin/src/__tests__/coordinator.test.ts index 548012fb..ee4ccff1 100644 --- a/packages/next-plugin/src/__tests__/coordinator.test.ts +++ b/packages/next-plugin/src/__tests__/coordinator.test.ts @@ -225,7 +225,7 @@ describe('coordinator', () => { coordinator.close() }) - it('profiles serialization work separately from writes', async () => { + it('profiles both base CSS serialization paths separately from writes', async () => { const originalProfile = process.env.DEVUP_UI_PROFILE process.env.DEVUP_UI_PROFILE = '1' const infoSpy = spyOn(console, 'info').mockImplementation(() => {}) @@ -292,6 +292,50 @@ describe('coordinator', () => { expect(profile.cssSnapshotMs).toBeTypeOf('number') expect(profile.fileMapSnapshotMs).toBeTypeOf('number') expect(profile.sheetSnapshotMs).toBeTypeOf('number') + + codeExtractSpy.mockReturnValue({ + code: 'transformed base code', + map: undefined, + css: 'collected base css', + cssFile: 'devup-ui-2.css', + updatedBaseStyle: true, + free: mock(), + [Symbol.dispose]: mock(), + }) + const baseRes = await httpRequest( + port, + 'POST', + '/extract', + JSON.stringify({ + filename: 'src/profile-base.tsx', + code: 'const profileBase = true', + resourcePath: join(process.cwd(), 'src', 'profile-base.tsx'), + }), + ) + + expect(baseRes.status).toBe(200) + const baseMessage = infoSpy.mock.calls + .map(([value]) => value) + .find( + (value): value is string => + typeof value === 'string' && + value.includes('"filename":"src/profile-base.tsx"'), + ) + if (baseMessage === undefined) { + throw new Error('missing base CSS extract profile') + } + const baseProfile = JSON.parse( + baseMessage.slice('[devup-ui:profile] '.length), + ) as Record + + expect(baseProfile).toMatchObject({ + cacheHit: false, + cssSnapshotBytes: expect.any(Number), + filename: 'src/profile-base.tsx', + phase: 'coordinator.extract', + scheduledWrites: 5, + }) + expect(baseProfile.cssSnapshotMs).toBeTypeOf('number') } finally { coordinator.close() infoSpy.mockRestore() From 07e155bdac3a2bce70e872533089789d93a21616 Mon Sep 17 00:00:00 2001 From: devfive Date: Sat, 29 Aug 2026 03:02:36 +0900 Subject: [PATCH 19/22] perf(next-plugin): reduce Turbopack extraction memory --- bindings/devup-ui-wasm/src/lib.rs | 165 +++++++++++++++--- libs/extractor/src/lib.rs | 28 ++- .../src/__tests__/coordinator.test.ts | 72 +++++++- .../next-plugin/src/__tests__/plugin.test.ts | 35 +++- packages/next-plugin/src/coordinator.ts | 65 +++++-- packages/next-plugin/src/plugin.ts | 31 ++-- 6 files changed, 328 insertions(+), 68 deletions(-) diff --git a/bindings/devup-ui-wasm/src/lib.rs b/bindings/devup-ui-wasm/src/lib.rs index b039fad1..6e7a0b73 100644 --- a/bindings/devup-ui-wasm/src/lib.rs +++ b/bindings/devup-ui-wasm/src/lib.rs @@ -3,7 +3,7 @@ use css::file_map::{ canonical, is_global, set_canonical_map, set_file_map, with_canonical_map, with_file_map, }; use extractor::extract_style::extract_style_value::ExtractStyleValue; -use extractor::{ExtractOption, ImportAlias, extract, has_devup_ui}; +use extractor::{ExtractOption, ImportAlias, extract, extract_without_source_map, has_devup_ui}; use rustc_hash::FxHashSet; use sheet::StyleSheet; use std::collections::{BTreeMap, HashMap}; @@ -14,6 +14,12 @@ use wasm_bindgen::prelude::*; static GLOBAL_STYLE_SHEET: LazyLock> = LazyLock::new(|| Mutex::new(StyleSheet::default())); +#[derive(Clone, Copy)] +enum SourceMapMode { + Generate, + Skip, +} + fn with_style_sheet(f: F) -> R where F: FnOnce(&StyleSheet) -> R, @@ -321,17 +327,68 @@ pub fn code_extract_internal( import_main_css_in_css: bool, import_aliases: HashMap, ) -> Result { - match extract( + code_extract_internal_impl( filename, code, - ExtractOption { - package: package.to_string(), - css_dir, - single_css, - import_main_css: import_main_css_in_code, - import_aliases, - }, - ) { + package, + css_dir, + single_css, + import_main_css_in_code, + import_main_css_in_css, + import_aliases, + SourceMapMode::Generate, + ) +} + +#[allow(clippy::too_many_arguments)] +pub fn code_extract_without_source_map_internal( + filename: &str, + code: &str, + package: &str, + css_dir: String, + single_css: bool, + import_main_css_in_code: bool, + import_main_css_in_css: bool, + import_aliases: HashMap, +) -> Result { + code_extract_internal_impl( + filename, + code, + package, + css_dir, + single_css, + import_main_css_in_code, + import_main_css_in_css, + import_aliases, + SourceMapMode::Skip, + ) +} + +#[allow(clippy::too_many_arguments)] +fn code_extract_internal_impl( + filename: &str, + code: &str, + package: &str, + css_dir: String, + single_css: bool, + import_main_css_in_code: bool, + import_main_css_in_css: bool, + import_aliases: HashMap, + source_map: SourceMapMode, +) -> Result { + let option = ExtractOption { + package: package.to_string(), + css_dir, + single_css, + import_main_css: import_main_css_in_code, + import_aliases, + }; + let extracted = match source_map { + SourceMapMode::Generate => extract(filename, code, option), + SourceMapMode::Skip => extract_without_source_map(filename, code, option), + }; + + match extracted { Ok(output) => Ok(Output::new( output.code, output.styles, @@ -345,6 +402,24 @@ pub fn code_extract_internal( } } +#[cfg(not(tarpaulin_include))] +fn import_aliases_from_js( + import_aliases: JsValue, +) -> Result, JsValue> { + let aliases: HashMap> = + serde_wasm_bindgen::from_value(import_aliases).map_err(js_error)?; + Ok(aliases + .into_iter() + .map(|(key, value)| { + let alias = match value { + Some(name) => ImportAlias::DefaultToNamed(name), + None => ImportAlias::NamedToNamed, + }; + (key, alias) + }) + .collect()) +} + #[cfg(not(tarpaulin_include))] #[wasm_bindgen(js_name = "codeExtract")] #[allow(clippy::too_many_arguments)] @@ -358,23 +433,6 @@ pub fn code_extract( import_main_css_in_css: bool, import_aliases: JsValue, ) -> Result { - // Deserialize import_aliases from JsValue - // Format: { "package": "namedExport" } or { "package": null } for named exports - let aliases: HashMap> = - serde_wasm_bindgen::from_value(import_aliases).map_err(js_error)?; - - // Convert to ImportAlias enum - let import_aliases: HashMap = aliases - .into_iter() - .map(|(k, v)| { - let alias = match v { - Some(name) => ImportAlias::DefaultToNamed(name), - None => ImportAlias::NamedToNamed, - }; - (k, alias) - }) - .collect(); - code_extract_internal( filename, code, @@ -383,7 +441,33 @@ pub fn code_extract( single_css, import_main_css_in_code, import_main_css_in_css, - import_aliases, + import_aliases_from_js(import_aliases)?, + ) + .map_err(js_error) +} + +#[cfg(not(tarpaulin_include))] +#[wasm_bindgen(js_name = "codeExtractWithoutSourceMap")] +#[allow(clippy::too_many_arguments)] +pub fn code_extract_without_source_map( + filename: &str, + code: &str, + package: &str, + css_dir: String, + single_css: bool, + import_main_css_in_code: bool, + import_main_css_in_css: bool, + import_aliases: JsValue, +) -> Result { + code_extract_without_source_map_internal( + filename, + code, + package, + css_dir, + single_css, + import_main_css_in_code, + import_main_css_in_css, + import_aliases_from_js(import_aliases)?, ) .map_err(js_error) } @@ -1710,6 +1794,31 @@ mod tests { assert!(result.is_ok()); let output = result.unwrap(); assert!(!output.code().is_empty()); + assert!(output.map().is_some()); + } + + #[test] + #[serial] + fn test_code_extract_without_source_map_internal() { + *GLOBAL_STYLE_SHEET.lock().unwrap() = StyleSheet::default(); + css::class_map::reset_class_map(); + + let result = code_extract_without_source_map_internal( + "test.tsx", + r#"import {Box} from '@devup-ui/react' +"#, + "@devup-ui/react", + "@devup-ui/react".to_string(), + false, + false, + false, + HashMap::new(), + ); + + assert!(result.is_ok()); + let output = result.unwrap(); + assert!(!output.code().is_empty()); + assert!(output.map().is_none()); } #[test] diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 79a91e27..bd785175 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -215,6 +215,23 @@ pub fn extract( filename: &str, code: &str, option: ExtractOption, +) -> Result> { + extract_with_source_map(filename, code, option, true) +} + +pub fn extract_without_source_map( + filename: &str, + code: &str, + option: ExtractOption, +) -> Result> { + extract_with_source_map(filename, code, option, false) +} + +fn extract_with_source_map( + filename: &str, + code: &str, + option: ExtractOption, + source_map: bool, ) -> Result> { // Step 1: Transform import aliases // e.g., `import styled from '@emotion/styled'` → `import { styled } from '@devup-ui/react'` @@ -328,12 +345,15 @@ pub fn extract( if global { None } else { Some(bucket) }, ); visitor.visit_program(&mut program); - let result = Codegen::new() - .with_options(CodegenOptions { + let codegen_options = if source_map { + CodegenOptions { source_map_path: Some(PathBuf::from(filename)), ..Default::default() - }) - .build(&program); + } + } else { + CodegenOptions::default() + }; + let result = Codegen::new().with_options(codegen_options).build(&program); Ok(ExtractOutput { styles: visitor.styles, diff --git a/packages/next-plugin/src/__tests__/coordinator.test.ts b/packages/next-plugin/src/__tests__/coordinator.test.ts index ee4ccff1..93d9e2d0 100644 --- a/packages/next-plugin/src/__tests__/coordinator.test.ts +++ b/packages/next-plugin/src/__tests__/coordinator.test.ts @@ -21,6 +21,7 @@ import { } from '../coordinator' let codeExtractSpy: ReturnType +let codeExtractWithoutSourceMapSpy: ReturnType let getCssSpy: ReturnType let exportSheetSpy: ReturnType let exportClassMapSpy: ReturnType @@ -81,6 +82,7 @@ function httpRequest( beforeEach(() => { codeExtractSpy = spyOn(wasm, 'codeExtract') + codeExtractWithoutSourceMapSpy = spyOn(wasm, 'codeExtractWithoutSourceMap') getCssSpy = spyOn(wasm, 'getCss') exportSheetSpy = spyOn(wasm, 'exportSheet') exportClassMapSpy = spyOn(wasm, 'exportClassMap') @@ -97,6 +99,7 @@ beforeEach(() => { afterEach(() => { resetCoordinator() codeExtractSpy.mockRestore() + codeExtractWithoutSourceMapSpy.mockRestore() getCssSpy.mockRestore() exportSheetSpy.mockRestore() exportClassMapSpy.mockRestore() @@ -124,14 +127,15 @@ describe('coordinator', () => { }) it('should handle /extract endpoint', async () => { - codeExtractSpy.mockReturnValue({ + const extractOutput = { code: 'transformed code', map: '{"version":3}', cssFile: 'devup-ui-1.css', updatedBaseStyle: true, free: mock(), [Symbol.dispose]: mock(), - }) + } + codeExtractSpy.mockReturnValue(extractOutput) getCssSpy.mockImplementation( (fileNum: number | null, _importMainCss: boolean) => { if (fileNum === null) return 'base-css' @@ -170,6 +174,7 @@ describe('coordinator', () => { // Verify WASM was called expect(codeExtractSpy).toHaveBeenCalledTimes(1) + expect(extractOutput.free).toHaveBeenCalledTimes(1) // Verify files were written (base CSS + per-file CSS + sheet + classmap + filemap) expect(writeFileSpy).toHaveBeenCalledTimes(5) @@ -177,6 +182,38 @@ describe('coordinator', () => { coordinator.close() }) + it('skips source-map generation when requested', async () => { + const extractOutput = { + code: 'transformed code', + map: undefined, + cssFile: undefined, + updatedBaseStyle: false, + free: mock(), + [Symbol.dispose]: mock(), + } + codeExtractWithoutSourceMapSpy.mockReturnValue(extractOutput) + const coordinator = startCoordinator(makeOptions({ sourceMap: false })) + await new Promise((r) => setTimeout(r, 100)) + const portStr = (writeFileSyncSpy.mock.calls[0] as [string, string])[1] + + const res = await httpRequest( + parseInt(portStr), + 'POST', + '/extract', + JSON.stringify({ + filename: 'src/App.tsx', + code: 'const x = ', + resourcePath: join(process.cwd(), 'src', 'App.tsx'), + }), + ) + + expect(res.status).toBe(200) + expect(codeExtractWithoutSourceMapSpy).toHaveBeenCalledTimes(1) + expect(codeExtractSpy).not.toHaveBeenCalled() + expect(extractOutput.free).toHaveBeenCalledTimes(1) + coordinator.close() + }) + it('reuses byte-identical singleCss prewarm output', async () => { const source = 'const x = ' const options = makeOptions({ @@ -186,7 +223,6 @@ describe('coordinator', () => { 'src/App.tsx', { code: 'transformed prewarm code', - css: 'prewarmed css', cssFile: 'devup-ui.css', map: '{"version":3}', source, @@ -726,6 +762,36 @@ describe('coordinator', () => { coordinator.close() }) + it('replaces an existing coordinator without retaining its server', async () => { + const options = makeOptions() + const first = startCoordinator(options) + await new Promise((resolve) => setTimeout(resolve, 100)) + const firstPort = parseInt( + (writeFileSyncSpy.mock.calls.at(-1) as [string, string])[1], + ) + + const second = startCoordinator(options) + await new Promise((resolve) => setTimeout(resolve, 100)) + const secondPort = parseInt( + (writeFileSyncSpy.mock.calls.at(-1) as [string, string])[1], + ) + + let firstClosed = false + try { + await httpRequest(firstPort, 'GET', '/health') + } catch { + firstClosed = true + } + expect(firstClosed).toBe(true) + + // Closing the superseded handle must not close the replacement server. + first.close() + const res = await httpRequest(secondPort, 'GET', '/health') + expect(res).toEqual({ status: 200, body: 'ok' }) + + second.close() + }) + it('should touch devup-ui.css to invalidate Turbopack cache when singleCss=false and new CSS collected', async () => { codeExtractSpy.mockReturnValue({ code: 'import "./../../df/devup-ui/devup-ui-5.css";\nconst x = 1;', diff --git a/packages/next-plugin/src/__tests__/plugin.test.ts b/packages/next-plugin/src/__tests__/plugin.test.ts index c88b1779..cbb45734 100644 --- a/packages/next-plugin/src/__tests__/plugin.test.ts +++ b/packages/next-plugin/src/__tests__/plugin.test.ts @@ -68,6 +68,7 @@ let exportSheetSpy: ReturnType let exportClassMapSpy: ReturnType let exportFileMapSpy: ReturnType let codeExtractSpy: ReturnType +let codeExtractWithoutSourceMapSpy: ReturnType let devupUIWebpackPluginSpy: ReturnType let startCoordinatorSpy: ReturnType @@ -108,6 +109,12 @@ beforeEach(() => { codeExtractSpy = spyOn(wasm, 'codeExtract').mockImplementation( (_path: string, contents: string) => createCodeExtractResult(contents), ) + codeExtractWithoutSourceMapSpy = spyOn( + wasm, + 'codeExtractWithoutSourceMap', + ).mockImplementation((_path: string, contents: string) => + createCodeExtractResult(contents), + ) devupUIWebpackPluginSpy = spyOn( webpackPluginModule, 'DevupUIWebpackPlugin', @@ -144,6 +151,7 @@ afterEach(() => { exportClassMapSpy.mockRestore() exportFileMapSpy.mockRestore() codeExtractSpy.mockRestore() + codeExtractWithoutSourceMapSpy.mockRestore() devupUIWebpackPluginSpy.mockRestore() startCoordinatorSpy.mockRestore() }) @@ -508,8 +516,19 @@ describe('DevupUINextPlugin', () => { expectedBaseFiles: expect.any(Array), prewarmedFiles: expect.any(Array), prewarmedOutputs: expect.any(Map), + sourceMap: false, }) }) + it('keeps source maps when Next production browser source maps are enabled', () => { + setNodeEnv('production') + process.env.TURBOPACK = '1' + + DevupUI({ productionBrowserSourceMaps: true }) + + expect(startCoordinatorSpy).toHaveBeenCalledWith( + expect.objectContaining({ sourceMap: true }), + ) + }) it('should create theme.d.ts file', async () => { process.env.TURBOPACK = '1' existsSyncSpy.mockReturnValue(true) @@ -698,8 +717,10 @@ describe('DevupUINextPlugin', () => { expectedBaseFiles: expect.any(Array), prewarmedFiles: [], prewarmedOutputs: expect.any(Map), + sourceMap: true, }) expect(codeExtractSpy).not.toHaveBeenCalled() + expect(codeExtractWithoutSourceMapSpy).not.toHaveBeenCalled() // Verify initial CSS file is written expect(writeFileSyncSpy).toHaveBeenCalledWith( @@ -736,7 +757,7 @@ describe('DevupUINextPlugin', () => { 'computeFileRoutes', ).mockReturnValue({ 'src/app/page.tsx': [0] }) const events: string[] = [] - codeExtractSpy.mockImplementation( + codeExtractWithoutSourceMapSpy.mockImplementation( (filename: string, contents: string) => { events.push(`extract:${filename}`) return createCodeExtractResult(contents) @@ -755,8 +776,8 @@ describe('DevupUINextPlugin', () => { prewarmedFiles: ['src/app/page.tsx', 'src/lazy/panel.tsx'], }), ) - expect(codeExtractSpy).toHaveBeenCalledTimes(2) - expect(codeExtractSpy).toHaveBeenCalledWith( + expect(codeExtractWithoutSourceMapSpy).toHaveBeenCalledTimes(2) + expect(codeExtractWithoutSourceMapSpy).toHaveBeenCalledWith( 'src/app/page.tsx', '{}', '@devup-ui/react', @@ -766,7 +787,7 @@ describe('DevupUINextPlugin', () => { true, expect.anything(), ) - expect(codeExtractSpy).toHaveBeenCalledWith( + expect(codeExtractWithoutSourceMapSpy).toHaveBeenCalledWith( 'src/lazy/panel.tsx', '{}', '@devup-ui/react', @@ -860,7 +881,7 @@ describe('DevupUINextPlugin', () => { ], }), ) - expect(codeExtractSpy).toHaveBeenCalledTimes(2) + expect(codeExtractWithoutSourceMapSpy).toHaveBeenCalledTimes(2) } finally { graphSpy.mockRestore() compiledSpy.mockRestore() @@ -876,8 +897,8 @@ describe('DevupUINextPlugin', () => { try { DevupUI({}, { singleCss: true }) - expect(codeExtractSpy).toHaveBeenCalledTimes(2) - expect(codeExtractSpy).toHaveBeenCalledWith( + expect(codeExtractWithoutSourceMapSpy).toHaveBeenCalledTimes(2) + expect(codeExtractWithoutSourceMapSpy).toHaveBeenCalledWith( 'src/app/card.tsx', '{}', '@devup-ui/react', diff --git a/packages/next-plugin/src/coordinator.ts b/packages/next-plugin/src/coordinator.ts index 52fce285..59027f25 100644 --- a/packages/next-plugin/src/coordinator.ts +++ b/packages/next-plugin/src/coordinator.ts @@ -5,6 +5,7 @@ import { basename, dirname, join, relative } from 'node:path' import { getFileNumByFilename } from '@devup-ui/plugin-utils' import { codeExtract, + codeExtractWithoutSourceMap, exportClassMap, exportFileMap, exportSheet, @@ -50,6 +51,8 @@ export interface CoordinatorOptions { * second WASM extraction; the shared sheet is already populated. */ prewarmedOutputs?: Map + /** Generate transform source maps. Defaults to true for existing callers. */ + sourceMap?: boolean /** * Idle threshold (ms) for the base-css `/css` wait. Defaults to 2500. * FALLBACK ONLY — used when `expectedBaseFiles` is empty (no deterministic @@ -73,13 +76,33 @@ export interface CoordinatorOptions { export interface PrewarmedOutput { code: string - css?: string cssFile?: string map?: string source: string updatedBaseStyle: boolean } +interface ExtractOutputSnapshot extends Omit { + css?: string +} + +/** Copy every WASM-backed getter once, then release its Rust allocation. */ +export function takeExtractOutput( + output: ReturnType, +): ExtractOutputSnapshot { + try { + return { + code: output.code, + css: output.css, + cssFile: output.cssFile, + map: output.map, + updatedBaseStyle: output.updatedBaseStyle, + } + } finally { + output.free() + } +} + // Latest-Wins Coalescing Serializer. // // Multiple Turbopack workers may call /extract concurrently, each producing @@ -330,6 +353,13 @@ function waitForBucket(bucket: string): Promise { export function startCoordinator(options: CoordinatorOptions): { close: () => void } { + // Next may evaluate its config more than once in the same process. Close the + // previous listener before replacing it so its HTTP server and request + // closure do not remain live for the rest of the build. + if (server) { + server.close() + server = null + } const { package: libPackage, cssDir, @@ -341,6 +371,8 @@ export function startCoordinator(options: CoordinatorOptions): { coordinatorPortFile, } = options const prewarmedOutputs = options.prewarmedOutputs ?? new Map() + const extract = + options.sourceMap === false ? codeExtractWithoutSourceMap : codeExtract idleThresholdMs = options.idleThresholdMs ?? 2500 quietMs = options.quietMs ?? 10_000 @@ -352,7 +384,7 @@ export function startCoordinator(options: CoordinatorOptions): { for (const file of options.prewarmedFiles ?? []) extractedFiles.add(file) fileNumToBucket.clear() - server = createServer(async (req, res) => { + const coordinatorServer = createServer(async (req, res) => { const url = new URL(req.url ?? '/', `http://${req.headers.host}`) if (req.method === 'GET' && url.pathname === '/health') { @@ -431,15 +463,17 @@ export function startCoordinator(options: CoordinatorOptions): { const extractStartedAt = profileStart() const result = cacheHit ? prewarmed - : codeExtract( - filename, - code, - libPackage, - relCssDir, - singleCss, - false, - true, - importAliases, + : takeExtractOutput( + extract( + filename, + code, + libPackage, + relCssDir, + singleCss, + false, + true, + importAliases, + ), ) const extractDurationMs = elapsedMs(extractStartedAt) @@ -617,8 +651,9 @@ export function startCoordinator(options: CoordinatorOptions): { res.end('Not Found') }) - server.listen(0, '127.0.0.1', () => { - const addr = server!.address() + server = coordinatorServer + coordinatorServer.listen(0, '127.0.0.1', () => { + const addr = coordinatorServer.address() if (addr && typeof addr !== 'string') { writeFileSync(coordinatorPortFile, String(addr.port), 'utf-8') } @@ -631,8 +666,8 @@ export function startCoordinator(options: CoordinatorOptions): { // `close` itself returns synchronously (it is invoked from // `process.on('exit', ...)` where awaiting is not possible). void flushPendingWrites() - if (server) { - server.close() + coordinatorServer.close() + if (server === coordinatorServer) { server = null try { unlinkSync(coordinatorPortFile) diff --git a/packages/next-plugin/src/plugin.ts b/packages/next-plugin/src/plugin.ts index 2f17a7cf..e5ab3ce8 100644 --- a/packages/next-plugin/src/plugin.ts +++ b/packages/next-plugin/src/plugin.ts @@ -21,6 +21,7 @@ import { } from '@devup-ui/plugin-utils' import { codeExtract, + codeExtractWithoutSourceMap, exportClassMap, exportFileMap, exportSheet, @@ -43,7 +44,11 @@ import { } from '@devup-ui/webpack-plugin' import { type NextConfig } from 'next' -import { type PrewarmedOutput, startCoordinator } from './coordinator' +import { + type PrewarmedOutput, + startCoordinator, + takeExtractOutput, +} from './coordinator' import { collectProductionPrewarmFiles } from './prewarm' import { elapsedMs, profileStart, reportProfile } from './profile' @@ -148,6 +153,8 @@ export function DevupUI( const atomMode = atomHoist !== undefined && Number.isFinite(atomHoist) && atomHoist > 0 const watch = process.env.NODE_ENV === 'development' + const sourceMap = watch || config.productionBrowserSourceMaps === true + const extract = sourceMap ? codeExtract : codeExtractWithoutSourceMap // Hoisted out of the try so the coordinator can receive it for per-bucket // completion. Stays `{}` if the best-effort pre-pass fails. let canonicalMap: Record = {} @@ -267,15 +274,17 @@ export function DevupUI( } const extractStartedAt = prewarmStartedAt === undefined ? undefined : performance.now() - const output = codeExtract( - filename, - source, - libPackage, - relCssDir, - singleCss, - false, - true, - importAliases as unknown as Record, + const output = takeExtractOutput( + extract( + filename, + source, + libPackage, + relCssDir, + singleCss, + false, + true, + importAliases as unknown as Record, + ), ) if (extractStartedAt !== undefined) { prewarmExtractMs += performance.now() - extractStartedAt @@ -283,7 +292,6 @@ export function DevupUI( if (singleCss) { prewarmedOutputs.set(filename, { code: output.code, - css: output.css, cssFile: output.cssFile, map: output.map, source, @@ -349,6 +357,7 @@ export function DevupUI( expectedBaseFiles, prewarmedFiles, prewarmedOutputs, + sourceMap, }) // Cleanup on exit From 9d5355a642cffc87f107369fa47c5df4957cd1f9 Mon Sep 17 00:00:00 2001 From: devfive Date: Sat, 29 Aug 2026 03:20:19 +0900 Subject: [PATCH 20/22] perf(wasm): minimize build-time module --- Cargo.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 26cba5dc..c9933381 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,8 +50,8 @@ use_self = "allow" used_underscore_binding = "allow" [profile.release] -# Optimize for small code size (critical for WASM binary) -opt-level = "s" +# Minimize the WASM that every build must read, compile, and retain in memory. +opt-level = "z" # Link-time optimization: enables cross-crate inlining and dead code elimination lto = true # Single codegen unit: maximizes optimization at cost of compile time From 3ed9676cd9e2ca8675c36a7aff04589a1f224185 Mon Sep 17 00:00:00 2001 From: devfive Date: Sat, 29 Aug 2026 04:08:41 +0900 Subject: [PATCH 21/22] perf(next-plugin): load a smaller extraction engine --- bindings/devup-ui-wasm/Cargo.toml | 7 +- bindings/devup-ui-wasm/package.json | 14 +- bindings/devup-ui-wasm/script.js | 1 + libs/extractor/Cargo.toml | 6 +- libs/extractor/src/lib.rs | 12 +- libs/sheet/Cargo.toml | 6 +- .../src/__tests__/coordinator.test.ts | 1 + .../src/__tests__/css-loader.test.ts | 3 +- .../next-plugin/src/__tests__/loader.test.ts | 3 +- .../next-plugin/src/__tests__/plugin.test.ts | 26 +++- .../next-plugin/src/__tests__/wasm.test.ts | 19 +++ packages/next-plugin/src/coordinator.ts | 21 +-- packages/next-plugin/src/css-loader.ts | 18 ++- packages/next-plugin/src/loader.ts | 26 ++-- packages/next-plugin/src/plugin.ts | 122 ++++++++++++------ packages/next-plugin/src/wasm.ts | 40 ++++++ 16 files changed, 242 insertions(+), 83 deletions(-) create mode 100644 packages/next-plugin/src/__tests__/wasm.test.ts create mode 100644 packages/next-plugin/src/wasm.ts diff --git a/bindings/devup-ui-wasm/Cargo.toml b/bindings/devup-ui-wasm/Cargo.toml index cd8419bc..2546871b 100644 --- a/bindings/devup-ui-wasm/Cargo.toml +++ b/bindings/devup-ui-wasm/Cargo.toml @@ -14,12 +14,13 @@ categories = ["development-tools", "wasm", "web-programming"] crate-type = ["cdylib"] [features] -default = [] +default = ["vanilla-extract"] +vanilla-extract = ["extractor/vanilla-extract", "sheet/vanilla-extract"] [dependencies] wasm-bindgen = "0.2.127" -extractor = { path = "../../libs/extractor" } -sheet = { path = "../../libs/sheet" } +extractor = { path = "../../libs/extractor", default-features = false } +sheet = { path = "../../libs/sheet", default-features = false } css = { path = "../../libs/css" } rustc-hash = "2" diff --git a/bindings/devup-ui-wasm/package.json b/bindings/devup-ui-wasm/package.json index 1643e880..c6a17d44 100644 --- a/bindings/devup-ui-wasm/package.json +++ b/bindings/devup-ui-wasm/package.json @@ -19,7 +19,7 @@ ], "version": "1.0.78", "scripts": { - "build": "wasm-pack build --target nodejs --out-dir ./pkg --out-name index && node script.js", + "build": "wasm-pack build --target nodejs --out-dir ./pkg --out-name index && wasm-pack build --target nodejs --out-dir ./pkg/lite --out-name index --no-default-features && node script.js", "test": "wasm-pack test --node" }, "publishConfig": { @@ -33,7 +33,12 @@ "pkg/index.js", "pkg/package.json", "pkg/index_bg.wasm", - "pkg/index_bg.wasm.d.ts" + "pkg/index_bg.wasm.d.ts", + "pkg/lite/index.d.ts", + "pkg/lite/index.js", + "pkg/lite/package.json", + "pkg/lite/index_bg.wasm", + "pkg/lite/index_bg.wasm.d.ts" ], "type": "module", "exports": { @@ -41,6 +46,11 @@ "types": "./pkg/index.d.ts", "import": "./pkg/index.js", "require": "./pkg/index.js" + }, + "./lite": { + "types": "./pkg/lite/index.d.ts", + "import": "./pkg/lite/index.js", + "require": "./pkg/lite/index.js" } }, "types": "./pkg/index.d.ts" diff --git a/bindings/devup-ui-wasm/script.js b/bindings/devup-ui-wasm/script.js index 27b3139c..9fafac1b 100644 --- a/bindings/devup-ui-wasm/script.js +++ b/bindings/devup-ui-wasm/script.js @@ -2,3 +2,4 @@ import { writeFileSync } from 'node:fs' // support mjs config writeFileSync('pkg/package.json', JSON.stringify({}), 'utf8') +writeFileSync('pkg/lite/package.json', JSON.stringify({}), 'utf8') diff --git a/libs/extractor/Cargo.toml b/libs/extractor/Cargo.toml index d3bf12d7..86ae3ac4 100644 --- a/libs/extractor/Cargo.toml +++ b/libs/extractor/Cargo.toml @@ -11,6 +11,10 @@ categories = ["development-tools", "wasm", "web-programming"] [lints] workspace = true +[features] +default = ["vanilla-extract"] +vanilla-extract = ["dep:boa_engine"] + [dependencies] oxc_parser = "0.146.0" oxc_syntax = "0.146.0" @@ -26,7 +30,7 @@ phf = "0.14" strum = "0.28.0" strum_macros = "0.28.0" serde_json = "1.0" -boa_engine = "0.21" +boa_engine = { version = "0.21", optional = true } rustc-hash = "2" smallvec = "1" diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index bd785175..391f2371 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -11,6 +11,7 @@ mod stylex; mod tailwind; mod util_type; mod utils; +#[cfg(feature = "vanilla-extract")] mod vanilla_extract; mod visit; use crate::extract_style::extract_style_value::ExtractStyleValue; @@ -22,7 +23,9 @@ use oxc_ast_visit::VisitMut; use oxc_codegen::{Codegen, CodegenOptions}; use oxc_parser::{Parser, ParserReturn}; use oxc_span::SourceType; -use rustc_hash::{FxHashMap, FxHashSet}; +#[cfg(feature = "vanilla-extract")] +use rustc_hash::FxHashMap; +use rustc_hash::FxHashSet; use std::collections::{BTreeMap, HashMap}; use std::error::Error; use std::path::PathBuf; @@ -260,8 +263,8 @@ fn extract_with_source_map( // Step 3: Handle vanilla-extract style files (.css.ts, .css.js) // `processed_code` is Some only when vanilla-extract generation succeeded; // otherwise the untouched `transformed_code` is parsed directly (no copy). - let is_ve_file = vanilla_extract::is_vanilla_extract_file(filename); - let processed_code: Option = if is_ve_file { + #[cfg(feature = "vanilla-extract")] + let processed_code: Option = if vanilla_extract::is_vanilla_extract_file(filename) { // Use transformed code (with imports already pointing to @devup-ui/react) match vanilla_extract::execute_vanilla_extract(&transformed_code, &option.package, filename) { @@ -304,6 +307,8 @@ fn extract_with_source_map( } else { None }; + #[cfg(not(feature = "vanilla-extract"))] + let processed_code: Option = None; // For vanilla-extract files, if no styles were collected, return early if processed_code.as_deref() == Some("") { return Ok(ExtractOutput { @@ -398,6 +403,7 @@ fn resolve_css_target(filename: &str, option: &ExtractOption) -> (String, bool, /// Extract class names from generated code for specific style names /// Used for two-pass vanilla-extract processing to resolve selector references +#[cfg(feature = "vanilla-extract")] fn extract_class_map_from_code( filename: &str, partial_code: &str, diff --git a/libs/sheet/Cargo.toml b/libs/sheet/Cargo.toml index 4ca2a9c2..724c2751 100644 --- a/libs/sheet/Cargo.toml +++ b/libs/sheet/Cargo.toml @@ -11,12 +11,16 @@ categories = ["development-tools", "wasm", "web-programming"] [lints] workspace = true +[features] +default = ["vanilla-extract"] +vanilla-extract = ["extractor/vanilla-extract"] + [dependencies] css = { path = "../css" } serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" regex-lite = "0.1" -extractor = { path = "../extractor" } +extractor = { path = "../extractor", default-features = false } rustc-hash = "2" [dev-dependencies] diff --git a/packages/next-plugin/src/__tests__/coordinator.test.ts b/packages/next-plugin/src/__tests__/coordinator.test.ts index 93d9e2d0..dda073b4 100644 --- a/packages/next-plugin/src/__tests__/coordinator.test.ts +++ b/packages/next-plugin/src/__tests__/coordinator.test.ts @@ -35,6 +35,7 @@ function makeOptions( overrides: Partial = {}, ): CoordinatorOptions { return { + wasm, package: '@devup-ui/react', cssDir: join(tmpDir, 'css'), singleCss: false, diff --git a/packages/next-plugin/src/__tests__/css-loader.test.ts b/packages/next-plugin/src/__tests__/css-loader.test.ts index 98d9e95d..1ff6f718 100644 --- a/packages/next-plugin/src/__tests__/css-loader.test.ts +++ b/packages/next-plugin/src/__tests__/css-loader.test.ts @@ -13,7 +13,7 @@ import { spyOn, } from 'bun:test' -import devupUICssLoader, { resetInit } from '../css-loader' +import devupUICssLoader, { resetInit, setWasmForTesting } from '../css-loader' type CssLoaderThis = ThisParameterType @@ -45,6 +45,7 @@ beforeAll(() => { importFileMapSpy = spyOn(wasm, 'importFileMap').mockReturnValue(undefined) existsSyncSpy = spyOn(fs, 'existsSync').mockReturnValue(false) readFileSyncSpy = spyOn(fs, 'readFileSync').mockReturnValue('{}') + setWasmForTesting(wasm) }) afterEach(() => { diff --git a/packages/next-plugin/src/__tests__/loader.test.ts b/packages/next-plugin/src/__tests__/loader.test.ts index 551111a0..c7f33b49 100644 --- a/packages/next-plugin/src/__tests__/loader.test.ts +++ b/packages/next-plugin/src/__tests__/loader.test.ts @@ -15,7 +15,7 @@ import { } from 'bun:test' import type { DevupUILoaderOptions } from '../loader' -import devupUILoader, { resetInit } from '../loader' +import devupUILoader, { resetInit, setWasmForTesting } from '../loader' type LoaderThis = ThisParameterType @@ -68,6 +68,7 @@ beforeEach(() => { importFileMapSpy = spyOn(wasm, 'importFileMap').mockImplementation(() => {}) importSheetSpy = spyOn(wasm, 'importSheet').mockImplementation(() => {}) registerThemeSpy = spyOn(wasm, 'registerTheme').mockImplementation(() => {}) + setWasmForTesting(wasm) dateNowSpy = spyOn(Date, 'now').mockReturnValue(0) }) diff --git a/packages/next-plugin/src/__tests__/plugin.test.ts b/packages/next-plugin/src/__tests__/plugin.test.ts index cbb45734..3230d9d0 100644 --- a/packages/next-plugin/src/__tests__/plugin.test.ts +++ b/packages/next-plugin/src/__tests__/plugin.test.ts @@ -1,6 +1,7 @@ import * as fs from 'node:fs' import { join, resolve } from 'node:path' +import type { StaticImportGraph } from '@devup-ui/plugin-utils' import * as importGraphModule from '@devup-ui/plugin-utils' import * as wasm from '@devup-ui/wasm' import * as webpackPluginModule from '@devup-ui/webpack-plugin' @@ -15,7 +16,8 @@ import { } from 'bun:test' import * as coordinatorModule from '../coordinator' -import { DevupUI } from '../plugin' +import { DevupUI, selectWasmVariant } from '../plugin' +import { setWasmForTesting } from '../wasm' type CodeExtractResult = ReturnType type NextWebpackConfig = Parameters< @@ -123,6 +125,7 @@ beforeEach(() => { coordinatorModule, 'startCoordinator', ).mockReturnValue({ close: mock() as () => void }) + setWasmForTesting(wasm) originalEnv = { ...process.env } originalFetch = global.fetch @@ -131,6 +134,7 @@ beforeEach(() => { }) afterEach(() => { + setWasmForTesting(undefined) process.env = originalEnv global.fetch = originalFetch process.debugPort = originalDebugPort @@ -157,6 +161,24 @@ afterEach(() => { }) describe('DevupUINextPlugin', () => { + it('selects the lite engine only when the graph has no vanilla-extract file', () => { + expect(selectWasmVariant(undefined)).toBe('full') + expect( + selectWasmVariant({ files: ['src/page.tsx'] } as StaticImportGraph), + ).toBe('lite') + expect( + selectWasmVariant({ files: ['src/theme.css.ts'] } as StaticImportGraph), + ).toBe('full') + expect( + selectWasmVariant({ files: ['src/theme.css.js'] } as StaticImportGraph), + ).toBe('full') + expect( + selectWasmVariant({ files: ['src/page.tsx'] } as StaticImportGraph, [ + 'node_modules/design-system/theme.css.ts', + ]), + ).toBe('full') + }) + describe('webpack', () => { it('should apply webpack plugin', async () => { const ret = DevupUI({}) @@ -500,6 +522,7 @@ describe('DevupUINextPlugin', () => { }, }) expect(startCoordinatorSpy).toHaveBeenCalledWith({ + wasm, package: '@devup-ui/react', cssDir: resolve('df', 'devup-ui'), singleCss: false, @@ -701,6 +724,7 @@ describe('DevupUINextPlugin', () => { // Verify coordinator was started with correct options expect(startCoordinatorSpy).toHaveBeenCalledWith({ + wasm, package: '@devup-ui/react', cssDir: resolve('df', 'devup-ui'), singleCss: false, diff --git a/packages/next-plugin/src/__tests__/wasm.test.ts b/packages/next-plugin/src/__tests__/wasm.test.ts new file mode 100644 index 00000000..fca2dda4 --- /dev/null +++ b/packages/next-plugin/src/__tests__/wasm.test.ts @@ -0,0 +1,19 @@ +import * as wasm from '@devup-ui/wasm' +import { afterEach, describe, expect, it } from 'bun:test' + +import { loadWasm, setWasmForTesting } from '../wasm' + +afterEach(() => setWasmForTesting(undefined)) + +describe('WASM selection', () => { + it('uses an injected namespace in tests', () => { + setWasmForTesting(wasm) + expect(loadWasm(true)).toBe(wasm) + expect(loadWasm(false)).toBe(wasm) + }) + + it('loads the full and lite package exports', () => { + expect(typeof loadWasm(false).codeExtract).toBe('function') + expect(typeof loadWasm(true).codeExtract).toBe('function') + }) +}) diff --git a/packages/next-plugin/src/coordinator.ts b/packages/next-plugin/src/coordinator.ts index 59027f25..f6d20c5b 100644 --- a/packages/next-plugin/src/coordinator.ts +++ b/packages/next-plugin/src/coordinator.ts @@ -3,18 +3,12 @@ import { createServer, type IncomingMessage, type Server } from 'node:http' import { basename, dirname, join, relative } from 'node:path' import { getFileNumByFilename } from '@devup-ui/plugin-utils' -import { - codeExtract, - codeExtractWithoutSourceMap, - exportClassMap, - exportFileMap, - exportSheet, - getCss, -} from '@devup-ui/wasm' import { elapsedMs, profileStart, reportProfile } from './profile' +import type { DevupWasm } from './wasm' export interface CoordinatorOptions { + wasm: DevupWasm package: string cssDir: string singleCss: boolean @@ -88,7 +82,7 @@ interface ExtractOutputSnapshot extends Omit { /** Copy every WASM-backed getter once, then release its Rust allocation. */ export function takeExtractOutput( - output: ReturnType, + output: ReturnType, ): ExtractOutputSnapshot { try { return { @@ -361,6 +355,7 @@ export function startCoordinator(options: CoordinatorOptions): { server = null } const { + wasm, package: libPackage, cssDir, singleCss, @@ -370,6 +365,14 @@ export function startCoordinator(options: CoordinatorOptions): { importAliases, coordinatorPortFile, } = options + const { + codeExtract, + codeExtractWithoutSourceMap, + exportClassMap, + exportFileMap, + exportSheet, + getCss, + } = wasm const prewarmedOutputs = options.prewarmedOutputs ?? new Map() const extract = options.sourceMap === false ? codeExtractWithoutSourceMap : codeExtract diff --git a/packages/next-plugin/src/css-loader.ts b/packages/next-plugin/src/css-loader.ts index c928e277..a404c277 100644 --- a/packages/next-plugin/src/css-loader.ts +++ b/packages/next-plugin/src/css-loader.ts @@ -2,15 +2,10 @@ import { existsSync, readFileSync } from 'node:fs' import { Agent, request } from 'node:http' import { getFileNumByFilename } from '@devup-ui/plugin-utils' -import { - getCss, - importClassMap, - importFileMap, - importSheet, - registerTheme, -} from '@devup-ui/wasm' import type { RawLoaderDefinitionFunction } from 'webpack' +import { loadWasm } from './wasm' + export interface DevupUICssLoaderOptions { // turbo watch: boolean @@ -124,6 +119,13 @@ const devupUICssLoader: RawLoaderDefinitionFunction = return } + const { + getCss, + importClassMap, + importFileMap, + importSheet, + registerTheme, + } = loadWasm(false) if (!init) { init = true if (watch) { @@ -161,3 +163,5 @@ export const resetInit = () => { init = false cachedPort = null } + +export { setWasmForTesting } from './wasm' diff --git a/packages/next-plugin/src/loader.ts b/packages/next-plugin/src/loader.ts index 1110699b..de9f1515 100644 --- a/packages/next-plugin/src/loader.ts +++ b/packages/next-plugin/src/loader.ts @@ -3,19 +3,10 @@ import { writeFile } from 'node:fs/promises' import { Agent, request } from 'node:http' import { basename, dirname, join, relative } from 'node:path' -import { - codeExtract, - exportClassMap, - exportFileMap, - exportSheet, - getCss, - importClassMap, - importFileMap, - importSheet, - registerTheme, -} from '@devup-ui/wasm' import type { RawLoaderDefinitionFunction } from 'webpack' +import { loadWasm } from './wasm' + export interface DevupUILoaderOptions { package: string cssDir: string @@ -185,6 +176,17 @@ const devupUILoader: RawLoaderDefinitionFunction = } // Non-coordinator mode: local WASM extraction + const { + codeExtract, + exportClassMap, + exportFileMap, + exportSheet, + getCss, + importClassMap, + importFileMap, + importSheet, + registerTheme, + } = loadWasm(false) const promises: Promise[] = [] if (!init) { init = true @@ -268,3 +270,5 @@ export const resetInit = () => { init = false cachedPorts.clear() } + +export { setWasmForTesting } from './wasm' diff --git a/packages/next-plugin/src/plugin.ts b/packages/next-plugin/src/plugin.ts index e5ab3ce8..afa56c65 100644 --- a/packages/next-plugin/src/plugin.ts +++ b/packages/next-plugin/src/plugin.ts @@ -19,25 +19,6 @@ import { planAtomHoist, type StaticImportGraph, } from '@devup-ui/plugin-utils' -import { - codeExtract, - codeExtractWithoutSourceMap, - exportClassMap, - exportFileMap, - exportSheet, - getCss, - getDefaultTheme, - getThemeInterface, - importCanonicalMap, - importClassMap, - importFileMap, - importFileRoutes, - importSheet, - registerShorthands, - registerTheme, - setAtomHoist, - setPrefix, -} from '@devup-ui/wasm' import { DevupUIWebpackPlugin, type DevupUIWebpackPluginOptions, @@ -51,12 +32,23 @@ import { } from './coordinator' import { collectProductionPrewarmFiles } from './prewarm' import { elapsedMs, profileStart, reportProfile } from './profile' +import { loadWasm } from './wasm' type DevupUiNextPluginOptions = Omit< Partial, 'watch' > +export function selectWasmVariant( + graph: StaticImportGraph | undefined, + candidateFiles: string[] = graph?.files ?? [], +): 'lite' | 'full' { + return graph && + !candidateFiles.some((filename) => /\.css\.(?:ts|js)$/.test(filename)) + ? 'lite' + : 'full' +} + /** * Devup UI Next Plugin * @param config @@ -88,14 +80,7 @@ export function DevupUI( importAliases: userImportAliases, } = options - registerShorthands(shorthands ?? {}) - - if (prefix) { - setPrefix(prefix) - } - const importAliases = mergeImportAliases(userImportAliases) - const sheetFile = join(distDir, 'sheet.json') const classMapFile = join(distDir, 'classMap.json') const fileMapFile = join(distDir, 'fileMap.json') @@ -110,6 +95,62 @@ export function DevupUI( recursive: true, }) if (!existsSync(gitignoreFile)) writeFileSync(gitignoreFile, '*') + + // Boa is only needed to execute vanilla-extract-style `.css.ts`/`.css.js` + // modules. Build the graph before touching WASM so ordinary applications + // instantiate the much smaller engine, while vanilla-extract users retain + // the full evaluator automatically. If graph discovery fails, fail safe to + // the full engine. + const graphStartedAt = profileStart() + const srcDir = resolve(process.cwd(), 'src') + const tsconfigPath = resolve(process.cwd(), 'tsconfig.json') + let staticGraph: StaticImportGraph | undefined + try { + staticGraph = buildStaticImportGraph(srcDir, tsconfigPath) + } catch { + // The mapping pass below reports the graph failure and keeps its legacy + // best-effort behavior. + } + const candidateCollectStartedAt = + graphStartedAt === undefined ? undefined : performance.now() + const wasmCandidateFiles = staticGraph + ? collectProductionPrewarmFiles({ + cwd: process.cwd(), + graph: staticGraph, + expectedBaseFiles: [], + libPackage, + include, + }) + : [] + const candidateCollectMs = elapsedMs(candidateCollectStartedAt) + const wasmVariant = selectWasmVariant(staticGraph, wasmCandidateFiles) + const wasm = loadWasm(wasmVariant === 'lite') + const { + codeExtract, + codeExtractWithoutSourceMap, + exportClassMap, + exportFileMap, + exportSheet, + getCss, + getDefaultTheme, + getThemeInterface, + importCanonicalMap, + importClassMap, + importFileMap, + importFileRoutes, + importSheet, + registerShorthands, + registerTheme, + setAtomHoist, + setPrefix, + } = wasm + + registerShorthands(shorthands ?? {}) + + if (prefix) { + setPrefix(prefix) + } + // Import previous session state to handle Turbopack persistent cache. // When the dev server restarts, Turbopack may skip re-running loaders for // unchanged files. Without importing previous state, the coordinator's WASM @@ -162,15 +203,11 @@ export function DevupUI( // deterministic base-css completion signal handed to the coordinator. Stays // `[]` (idle fallback) when no routes are detected or the pre-pass fails. let expectedBaseFiles: string[] = [] - let staticGraph: StaticImportGraph | undefined - const graphStartedAt = profileStart() try { - const srcDir = resolve(process.cwd(), 'src') - const tsconfigPath = resolve(process.cwd(), 'tsconfig.json') + if (!staticGraph) throw new Error('Static import graph unavailable') const cwd = process.cwd() // One scan+parse of the source tree, shared by all three consumers below. - const graph = buildStaticImportGraph(srcDir, tsconfigPath) - staticGraph = graph + const graph = staticGraph // Atom hoisting owns the shared-chunk decision, so collapse runs WITHOUT // the file-level @global hoist (DEVUP_HOIST_V) in atom mode. const hoistV = atomMode @@ -223,6 +260,7 @@ export function DevupUI( durationMs: elapsedMs(graphStartedAt), files: staticGraph.files.length, expectedBaseFiles: expectedBaseFiles.length, + wasmVariant, }) } catch { // Pre-pass is best-effort; on failure canonical() is the identity (no @@ -249,16 +287,12 @@ export function DevupUI( let prewarmReadMs = 0 let prewarmSourceBytes = 0 const cwd = process.cwd() - const collectStartedAt = - prewarmStartedAt === undefined ? undefined : performance.now() - const prewarmFiles = collectProductionPrewarmFiles({ - cwd, - graph: staticGraph, - expectedBaseFiles, - libPackage, - include, - }) - const collectDurationMs = elapsedMs(collectStartedAt) + // The same complete candidate set selected the WASM variant above. Reuse + // it here instead of resolving source/package entries a second time, + // while retaining any compiled-file fallback supplied by the graph pass. + const prewarmFiles = [ + ...new Set([...wasmCandidateFiles, ...expectedBaseFiles]), + ].sort() for (const filename of prewarmFiles) { const resourcePath = resolve(cwd, filename) const relCssDir = `./${relative( @@ -301,7 +335,7 @@ export function DevupUI( prewarmedFiles.push(filename) } reportProfile('next.prewarm', { - collectMs: collectDurationMs, + collectMs: candidateCollectMs, durationMs: elapsedMs(prewarmStartedAt), extractMs: prewarmStartedAt === undefined @@ -345,6 +379,7 @@ export function DevupUI( } const coordinator = startCoordinator({ + wasm, package: libPackage, cssDir, singleCss, @@ -480,6 +515,7 @@ export function DevupUI( durationMs: elapsedMs(pluginStartedAt), prewarmedFiles: prewarmedFiles.length, singleCss, + wasmVariant, watch, }) return config diff --git a/packages/next-plugin/src/wasm.ts b/packages/next-plugin/src/wasm.ts new file mode 100644 index 00000000..c24cbe4f --- /dev/null +++ b/packages/next-plugin/src/wasm.ts @@ -0,0 +1,40 @@ +import { existsSync } from 'node:fs' +import { createRequire } from 'node:module' +import { join } from 'node:path' + +export type DevupWasm = typeof import('@devup-ui/wasm') + +let wasmForTesting: DevupWasm | undefined +let fullWasm: DevupWasm | undefined +let liteWasm: DevupWasm | undefined + +function requireFromPlugin(specifier: string): DevupWasm { + const installedPackage = join( + process.cwd(), + 'node_modules/@devup-ui/next-plugin/package.json', + ) + const workspacePackage = join( + process.cwd(), + 'packages/next-plugin/package.json', + ) + const requireBase = existsSync(installedPackage) + ? installedPackage + : existsSync(workspacePackage) + ? workspacePackage + : join(process.cwd(), 'package.json') + return createRequire(requireBase)(specifier) as DevupWasm +} + +/** Load exactly one extraction engine for the lifetime of a Next config. */ +export function loadWasm(lite: boolean): DevupWasm { + if (wasmForTesting) return wasmForTesting + if (lite) { + return (liteWasm ??= requireFromPlugin('@devup-ui/wasm/lite')) + } + return (fullWasm ??= requireFromPlugin('@devup-ui/wasm')) +} + +/** @internal Inject the WASM namespace for unit tests. */ +export function setWasmForTesting(value: DevupWasm | undefined): void { + wasmForTesting = value +} From 21fd3cc17f4ce563370715cd58af2a830d774817 Mon Sep 17 00:00:00 2001 From: devfive Date: Sat, 29 Aug 2026 04:23:07 +0900 Subject: [PATCH 22/22] perf(next-plugin): defer webpack runtime in Turbopack --- .../next-plugin/src/__tests__/plugin.test.ts | 4 ++- .../next-plugin/src/__tests__/wasm.test.ts | 19 ++++++++++++-- packages/next-plugin/src/plugin.ts | 8 +++--- packages/next-plugin/src/wasm.ts | 26 ++++++++++++++++--- 4 files changed, 45 insertions(+), 12 deletions(-) diff --git a/packages/next-plugin/src/__tests__/plugin.test.ts b/packages/next-plugin/src/__tests__/plugin.test.ts index 3230d9d0..e823f2a6 100644 --- a/packages/next-plugin/src/__tests__/plugin.test.ts +++ b/packages/next-plugin/src/__tests__/plugin.test.ts @@ -17,7 +17,7 @@ import { import * as coordinatorModule from '../coordinator' import { DevupUI, selectWasmVariant } from '../plugin' -import { setWasmForTesting } from '../wasm' +import { setWasmForTesting, setWebpackPluginForTesting } from '../wasm' type CodeExtractResult = ReturnType type NextWebpackConfig = Parameters< @@ -126,6 +126,7 @@ beforeEach(() => { 'startCoordinator', ).mockReturnValue({ close: mock() as () => void }) setWasmForTesting(wasm) + setWebpackPluginForTesting(webpackPluginModule) originalEnv = { ...process.env } originalFetch = global.fetch @@ -135,6 +136,7 @@ beforeEach(() => { afterEach(() => { setWasmForTesting(undefined) + setWebpackPluginForTesting(undefined) process.env = originalEnv global.fetch = originalFetch process.debugPort = originalDebugPort diff --git a/packages/next-plugin/src/__tests__/wasm.test.ts b/packages/next-plugin/src/__tests__/wasm.test.ts index fca2dda4..1f5d34ee 100644 --- a/packages/next-plugin/src/__tests__/wasm.test.ts +++ b/packages/next-plugin/src/__tests__/wasm.test.ts @@ -1,9 +1,18 @@ import * as wasm from '@devup-ui/wasm' +import * as webpackPlugin from '@devup-ui/webpack-plugin' import { afterEach, describe, expect, it } from 'bun:test' -import { loadWasm, setWasmForTesting } from '../wasm' +import { + loadWasm, + loadWebpackPlugin, + setWasmForTesting, + setWebpackPluginForTesting, +} from '../wasm' -afterEach(() => setWasmForTesting(undefined)) +afterEach(() => { + setWasmForTesting(undefined) + setWebpackPluginForTesting(undefined) +}) describe('WASM selection', () => { it('uses an injected namespace in tests', () => { @@ -16,4 +25,10 @@ describe('WASM selection', () => { expect(typeof loadWasm(false).codeExtract).toBe('function') expect(typeof loadWasm(true).codeExtract).toBe('function') }) + + it('loads or injects the Webpack plugin without a static dependency', () => { + expect(typeof loadWebpackPlugin().DevupUIWebpackPlugin).toBe('function') + setWebpackPluginForTesting(webpackPlugin) + expect(loadWebpackPlugin()).toBe(webpackPlugin) + }) }) diff --git a/packages/next-plugin/src/plugin.ts b/packages/next-plugin/src/plugin.ts index afa56c65..a1d9b779 100644 --- a/packages/next-plugin/src/plugin.ts +++ b/packages/next-plugin/src/plugin.ts @@ -19,10 +19,7 @@ import { planAtomHoist, type StaticImportGraph, } from '@devup-ui/plugin-utils' -import { - DevupUIWebpackPlugin, - type DevupUIWebpackPluginOptions, -} from '@devup-ui/webpack-plugin' +import type { DevupUIWebpackPluginOptions } from '@devup-ui/webpack-plugin' import { type NextConfig } from 'next' import { @@ -32,7 +29,7 @@ import { } from './coordinator' import { collectProductionPrewarmFiles } from './prewarm' import { elapsedMs, profileStart, reportProfile } from './profile' -import { loadWasm } from './wasm' +import { loadWasm, loadWebpackPlugin } from './wasm' type DevupUiNextPluginOptions = Omit< Partial, @@ -523,6 +520,7 @@ export function DevupUI( const { webpack } = config config.webpack = (config, _options) => { + const { DevupUIWebpackPlugin } = loadWebpackPlugin() options.cssDir ??= resolve( _options.dev ? (options.distDir ?? 'df') : '.next/cache', `devup-ui_${_options.buildId}`, diff --git a/packages/next-plugin/src/wasm.ts b/packages/next-plugin/src/wasm.ts index c24cbe4f..c7d3e7cf 100644 --- a/packages/next-plugin/src/wasm.ts +++ b/packages/next-plugin/src/wasm.ts @@ -3,12 +3,15 @@ import { createRequire } from 'node:module' import { join } from 'node:path' export type DevupWasm = typeof import('@devup-ui/wasm') +export type DevupWebpackPlugin = typeof import('@devup-ui/webpack-plugin') let wasmForTesting: DevupWasm | undefined +let webpackPluginForTesting: DevupWebpackPlugin | undefined let fullWasm: DevupWasm | undefined let liteWasm: DevupWasm | undefined +let webpackPlugin: DevupWebpackPlugin | undefined -function requireFromPlugin(specifier: string): DevupWasm { +function requireFromPlugin(specifier: string): T { const installedPackage = join( process.cwd(), 'node_modules/@devup-ui/next-plugin/package.json', @@ -22,19 +25,34 @@ function requireFromPlugin(specifier: string): DevupWasm { : existsSync(workspacePackage) ? workspacePackage : join(process.cwd(), 'package.json') - return createRequire(requireBase)(specifier) as DevupWasm + return createRequire(requireBase)(specifier) as T } /** Load exactly one extraction engine for the lifetime of a Next config. */ export function loadWasm(lite: boolean): DevupWasm { if (wasmForTesting) return wasmForTesting if (lite) { - return (liteWasm ??= requireFromPlugin('@devup-ui/wasm/lite')) + return (liteWasm ??= requireFromPlugin('@devup-ui/wasm/lite')) } - return (fullWasm ??= requireFromPlugin('@devup-ui/wasm')) + return (fullWasm ??= requireFromPlugin('@devup-ui/wasm')) +} + +/** Keep the Webpack adapter (and its full WASM) out of Turbopack startup. */ +export function loadWebpackPlugin(): DevupWebpackPlugin { + if (webpackPluginForTesting) return webpackPluginForTesting + return (webpackPlugin ??= requireFromPlugin( + '@devup-ui/webpack-plugin', + )) } /** @internal Inject the WASM namespace for unit tests. */ export function setWasmForTesting(value: DevupWasm | undefined): void { wasmForTesting = value } + +/** @internal Inject the Webpack namespace for unit tests. */ +export function setWebpackPluginForTesting( + value: DevupWebpackPlugin | undefined, +): void { + webpackPluginForTesting = value +}