From 9ac828b4877cd999716d963263475f694a08fec4 Mon Sep 17 00:00:00 2001 From: Oleksandr Fediashov Date: Tue, 24 Mar 2026 21:42:57 +0100 Subject: [PATCH 01/11] perf: optimize isGriffelCSSModule check, add processAssets timing - Replace buffer content scanning with module.identifier() path check - Remove unused CSSModule type and isCSSModule function - Add timing for processAssets hook (CSS sorting) to stats output Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/webpack-plugin/src/GriffelPlugin.mts | 28 +++++++++---------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/packages/webpack-plugin/src/GriffelPlugin.mts b/packages/webpack-plugin/src/GriffelPlugin.mts index 2066e7fbe..57c4eadd8 100644 --- a/packages/webpack-plugin/src/GriffelPlugin.mts +++ b/packages/webpack-plugin/src/GriffelPlugin.mts @@ -1,6 +1,8 @@ import { defaultCompareMediaQueries, type GriffelRenderer } from '@griffel/core'; import type { Compilation, Chunk, Compiler, Module, sources } from 'webpack'; +import * as path from 'node:path'; + import { PLUGIN_NAME, GriffelCssLoaderContextKey, type SupplementedLoaderContext } from './constants.mjs'; import { createResolverFactory, type TransformResolverFactory } from './resolver/createResolverFactory.mjs'; import { parseCSSRules } from './utils/parseCSSRules.mjs'; @@ -56,23 +58,11 @@ function getAssetSourceContents(assetSource: sources.Source): string { return source.toString(); } -// https://github.com/webpack-contrib/mini-css-extract-plugin/blob/26334462e419026086856787d672b052cd916c62/src/index.js#L90 -type CSSModule = Module & { - content: Buffer; -}; - -function isCSSModule(module: Module): module is CSSModule { - return module.type === 'css/mini-extract'; -} +const __dirname = path.dirname(new URL(import.meta.url).pathname); +const virtualLoaderPath = path.resolve(__dirname, 'virtual-loader', 'index.cjs'); function isGriffelCSSModule(module: Module): boolean { - if (isCSSModule(module)) { - if (Buffer.isBuffer(module.content)) { - return module.content.indexOf('/** @griffel:css-start') !== -1; - } - } - - return false; + return module.type === 'css/mini-extract' && module.identifier().includes(virtualLoaderPath); } function moveCSSModulesToGriffelChunk(compilation: Compilation) { @@ -121,6 +111,7 @@ export class GriffelPlugin { evaluationMode: 'ast' | 'vm'; } > = {}; + #processAssetsTime: bigint = 0n; readonly #perfIssues: Map }> = new Map(); constructor(options: GriffelCSSExtractionPluginOptions = {}) { @@ -297,6 +288,8 @@ export class GriffelPlugin { stage: Compilation.PROCESS_ASSETS_STAGE_PRE_PROCESS, }, assets => { + const start = this.#collectStats ? process.hrtime.bigint() : 0n; + let cssAssetDetails; // @ Rspack compat @@ -327,6 +320,10 @@ export class GriffelPlugin { const cssSource = sortCSSRules([cssRulesByBucket], this.#compareMediaQueries); compilation.updateAsset(cssAssetName, new compiler.webpack.sources.RawSource(remainingCSS + cssSource)); + + if (this.#collectStats) { + this.#processAssetsTime = process.hrtime.bigint() - start; + } }, ); @@ -357,6 +354,7 @@ export class GriffelPlugin { console.log('------------------------------------'); console.log('Total time spent in Griffel loader:', logTime(totalTime)); + console.log('Time spent in processAssets (sort):', logTime(this.#processAssetsTime)); console.log('Files processed:', fileCount); console.log('Average time per file:', logTime(avgTime)); console.log( From 3c791f9e999bb7a0ea695db56e3b3b7439e3afbd Mon Sep 17 00:00:00 2001 From: Oleksandr Fediashov Date: Tue, 24 Mar 2026 21:43:27 +0100 Subject: [PATCH 02/11] Change files --- ...ebpack-plugin-df2684c1-fec6-44ca-bc49-56fe55560cfa.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 change/@griffel-webpack-plugin-df2684c1-fec6-44ca-bc49-56fe55560cfa.json diff --git a/change/@griffel-webpack-plugin-df2684c1-fec6-44ca-bc49-56fe55560cfa.json b/change/@griffel-webpack-plugin-df2684c1-fec6-44ca-bc49-56fe55560cfa.json new file mode 100644 index 000000000..85e655620 --- /dev/null +++ b/change/@griffel-webpack-plugin-df2684c1-fec6-44ca-bc49-56fe55560cfa.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "perf: optimize isGriffelCSSModule check, add processAssets timing", + "packageName": "@griffel/webpack-plugin", + "email": "olfedias@microsoft.com", + "dependentChangeType": "patch" +} From 93cdf6018e7414266b388353e33e3a78dd5f41f6 Mon Sep 17 00:00:00 2001 From: Oleksandr Fediashov Date: Tue, 24 Mar 2026 21:50:12 +0100 Subject: [PATCH 03/11] feat: add detailed timing breakdowns for transform and VM evaluation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds per-phase timings to TransformResult (parsing, walking, evaluation, resolving, codeTransform) and per-operation timings in Module (transform/shaker, ESM→CJS, vm.runInContext, fs.readFileSync). Reported in stats output when collectStats is enabled. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/transform/src/evaluation/module.mts | 38 +++++++++++++ packages/transform/src/index.mts | 2 +- packages/transform/src/transformSync.mts | 56 +++++++++++++++++++ packages/webpack-plugin/src/GriffelPlugin.mts | 33 +++++++++++ packages/webpack-plugin/src/constants.mts | 3 +- packages/webpack-plugin/src/webpackLoader.mts | 3 +- 6 files changed, 132 insertions(+), 3 deletions(-) diff --git a/packages/transform/src/evaluation/module.mts b/packages/transform/src/evaluation/module.mts index 0403c98b5..847102e14 100644 --- a/packages/transform/src/evaluation/module.mts +++ b/packages/transform/src/evaluation/module.mts @@ -144,7 +144,11 @@ export class Module { if (Module.extensions.has(ext)) { // To evaluate the file, we need to read it first + const fsT0 = Module.collectTimings ? process.hrtime.bigint() : 0n; const code = fs.readFileSync(filename, 'utf-8'); + if (Module.collectTimings) { + Module.fsReadTime += process.hrtime.bigint() - fsT0; + } if (ext === '.json') { // For JSON files, parse it to a JS object similar to Node @@ -173,8 +177,27 @@ export class Module { }, ); + /** Accumulated timing for transform/shaker actions (ns). Only populated when debug is enabled. */ + static transformTime: bigint = 0n; + /** Accumulated timing for vm.Script execution (ns). Only populated when debug is enabled. */ + static vmRunTime: bigint = 0n; + /** Accumulated timing for fs.readFileSync calls (ns). Only populated when debug is enabled. */ + static fsReadTime: bigint = 0n; + /** Accumulated timing for convertESMtoCJS calls (ns). Only populated when debug is enabled. */ + static esmConvertTime: bigint = 0n; + /** Whether to collect detailed timings. */ + static collectTimings: boolean = false; + + static resetTimings(): void { + Module.transformTime = 0n; + Module.vmRunTime = 0n; + Module.fsReadTime = 0n; + Module.esmConvertTime = 0n; + } + evaluate(text: string, only: string[] | null = null, useEvalCache = true): void { const { filename } = this; + const collectTimings = Module.collectTimings; // Find last matching rule (iterate backwards, break on first match) let action: EvalRule['action'] = 'ignore'; @@ -203,14 +226,24 @@ export class Module { // For JavaScript files, we need to transpile it and to get the exports of the module this.debug('prepare-evaluation', this.filename, 'using', action.name); + let t0 = collectTimings ? process.hrtime.bigint() : 0n; const result = action(this.filename, text, only); + if (collectTimings) { + Module.transformTime += process.hrtime.bigint() - t0; + } code = result.code; this.imports = result.imports; // Convert ESM syntax to CJS so it can run inside a function wrapper in vm.Script if (result.moduleKind === 'esm') { + if (collectTimings) { + t0 = process.hrtime.bigint(); + } code = convertESMtoCJS(code, this.filename); + if (collectTimings) { + Module.esmConvertTime += process.hrtime.bigint() - t0; + } } this.debug('evaluate', `${this.filename} (only ${(only || []).join(', ')}):\n${code}`); @@ -220,6 +253,7 @@ export class Module { filename: this.filename, }); + let t0 = collectTimings ? process.hrtime.bigint() : 0n; try { script.runInContext( vm.createContext({ @@ -251,6 +285,10 @@ export class Module { } throw hostError; + } finally { + if (collectTimings) { + Module.vmRunTime += process.hrtime.bigint() - t0; + } } if (useEvalCache) { diff --git a/packages/transform/src/index.mts b/packages/transform/src/index.mts index bc23d6079..37325afcb 100644 --- a/packages/transform/src/index.mts +++ b/packages/transform/src/index.mts @@ -7,7 +7,7 @@ export type { Evaluator, EvaluatorResult, EvalRule } from './evaluation/types.mj // Our APIs -export { transformSync, type TransformOptions, type TransformResult } from './transformSync.mjs'; +export { transformSync, type TransformOptions, type TransformResult, type TransformTimings } from './transformSync.mjs'; export { DEOPT, type Deopt } from './evaluation/astEvaluator.mjs'; export type { AstEvaluatorPlugin, AstEvaluatorContext, TransformPerfIssue } from './evaluation/types.mjs'; export { fluentTokensPlugin } from './evaluation/fluentTokensPlugin.mjs'; diff --git a/packages/transform/src/transformSync.mts b/packages/transform/src/transformSync.mts index 1a1413e3e..46a928ad6 100644 --- a/packages/transform/src/transformSync.mts +++ b/packages/transform/src/transformSync.mts @@ -56,12 +56,26 @@ export type TransformOptions = { collectPerfIssues?: boolean; }; +export type TransformTimings = { + /** Time spent parsing the source code (ns) */ + parsing: bigint; + /** Time spent in AST walking to find style calls (ns) */ + walking: bigint; + /** Time spent evaluating style call arguments (ns) */ + evaluation: bigint; + /** Time spent resolving CSS rules from evaluated styles (ns) */ + resolving: bigint; + /** Time spent in code transformations via magic-string (ns) */ + codeTransform: bigint; +}; + export type TransformResult = { code: string; cssRulesByBucket?: CSSRulesByBucket; usedProcessing: boolean; usedVMForEvaluation: boolean; perfIssues?: TransformPerfIssue[]; + timings?: TransformTimings; }; type FunctionKinds = 'makeStyles' | 'makeResetStyles' | 'makeStaticStyles'; @@ -194,8 +208,23 @@ export function transformSync(sourceCode: string, options: TransformOptions): Tr throw new Error('Transform error: "filename" option is required'); } + const collectTimings = options.collectPerfIssues ?? false; + const timings: TransformTimings = { + parsing: 0n, + walking: 0n, + evaluation: 0n, + resolving: 0n, + codeTransform: 0n, + }; + + let t0 = collectTimings ? process.hrtime.bigint() : 0n; + const parseResult = parseSync(filename, sourceCode); + if (collectTimings) { + timings.parsing = process.hrtime.bigint() - t0; + } + if (parseResult.errors.length > 0) { throw new Error(`Failed to parse "${filename}": ${parseResult.errors.map(e => e.message).join(', ')}`); } @@ -230,6 +259,10 @@ export function transformSync(sourceCode: string, options: TransformOptions): Tr // ----- // Walk AST to collect style function calls using ScopeTracker for scope-aware import resolution + if (collectTimings) { + t0 = process.hrtime.bigint(); + } + const scopeTracker = new ScopeTracker(); const matchedSpecifiers = new Map(); @@ -318,6 +351,10 @@ export function transformSync(sourceCode: string, options: TransformOptions): Tr }, }); + if (collectTimings) { + timings.walking = process.hrtime.bigint() - t0; + } + // If no style calls found, return original code if (styleCalls.length === 0) { return { @@ -328,6 +365,10 @@ export function transformSync(sourceCode: string, options: TransformOptions): Tr } // Process style calls - evaluate and transform + if (collectTimings) { + t0 = process.hrtime.bigint(); + } + const { evaluationResults, usedVMForEvaluation } = batchEvaluator( sourceCode, filename, @@ -338,6 +379,11 @@ export function transformSync(sourceCode: string, options: TransformOptions): Tr astEvaluationPlugins, ); + if (collectTimings) { + timings.evaluation = process.hrtime.bigint() - t0; + t0 = process.hrtime.bigint(); + } + for (let i = styleCalls.length - 1; i >= 0; i--) { const styleCall = styleCalls[i]; const evaluationResult = evaluationResults[i]; @@ -396,6 +442,11 @@ export function transformSync(sourceCode: string, options: TransformOptions): Tr } } + if (collectTimings) { + timings.resolving = process.hrtime.bigint() - t0; + t0 = process.hrtime.bigint(); + } + // --- // Transform imports and function names @@ -414,11 +465,16 @@ export function transformSync(sourceCode: string, options: TransformOptions): Tr ); } + if (collectTimings) { + timings.codeTransform = process.hrtime.bigint() - t0; + } + return { code: magicString.toString(), cssRulesByBucket, usedProcessing: true, usedVMForEvaluation, perfIssues, + timings: collectTimings ? timings : undefined, }; } diff --git a/packages/webpack-plugin/src/GriffelPlugin.mts b/packages/webpack-plugin/src/GriffelPlugin.mts index 57c4eadd8..10c9a2fe4 100644 --- a/packages/webpack-plugin/src/GriffelPlugin.mts +++ b/packages/webpack-plugin/src/GriffelPlugin.mts @@ -1,4 +1,5 @@ import { defaultCompareMediaQueries, type GriffelRenderer } from '@griffel/core'; +import { Module as GriffelModule, type TransformTimings } from '@griffel/transform'; import type { Compilation, Chunk, Compiler, Module, sources } from 'webpack'; import * as path from 'node:path'; @@ -109,6 +110,7 @@ export class GriffelPlugin { { time: bigint; evaluationMode: 'ast' | 'vm'; + timings?: TransformTimings; } > = {}; #processAssetsTime: bigint = 0n; @@ -123,6 +125,11 @@ export class GriffelPlugin { } apply(compiler: Compiler): void { + if (this.#collectStats) { + GriffelModule.collectTimings = true; + GriffelModule.resetTimings(); + } + const IS_RSPACK = Object.prototype.hasOwnProperty.call(compiler.webpack, 'rspackVersion'); const { Compilation, NormalModule } = compiler.webpack; @@ -211,6 +218,7 @@ export class GriffelPlugin { this.#stats[meta.filename] = { time: end - start, evaluationMode: meta.evaluationMode, + timings: meta.timings, }; } @@ -352,6 +360,18 @@ export class GriffelPlugin { console.log('\nGriffel CSS extraction stats:'); + // Aggregate per-phase timings + const totals: TransformTimings = { parsing: 0n, walking: 0n, evaluation: 0n, resolving: 0n, codeTransform: 0n }; + for (const [, info] of entries) { + if (info.timings) { + totals.parsing += info.timings.parsing; + totals.walking += info.timings.walking; + totals.evaluation += info.timings.evaluation; + totals.resolving += info.timings.resolving; + totals.codeTransform += info.timings.codeTransform; + } + } + console.log('------------------------------------'); console.log('Total time spent in Griffel loader:', logTime(totalTime)); console.log('Time spent in processAssets (sort):', logTime(this.#processAssetsTime)); @@ -362,6 +382,19 @@ export class GriffelPlugin { ((entries.filter(s => s[1].evaluationMode === 'ast').length / fileCount) * 100).toFixed(2) + '%', ); console.log('------------------------------------'); + console.log('Phase breakdown (aggregate):'); + console.log(' Parsing: ', logTime(totals.parsing)); + console.log(' Walking: ', logTime(totals.walking)); + console.log(' Evaluation: ', logTime(totals.evaluation)); + console.log(' Resolving: ', logTime(totals.resolving)); + console.log(' Code transform: ', logTime(totals.codeTransform)); + console.log('------------------------------------'); + console.log('VM evaluation breakdown (Module):'); + console.log(' Transform/shaker:', logTime(GriffelModule.transformTime)); + console.log(' ESM→CJS convert: ', logTime(GriffelModule.esmConvertTime)); + console.log(' vm.runInContext: ', logTime(GriffelModule.vmRunTime)); + console.log(' fs.readFileSync: ', logTime(GriffelModule.fsReadTime)); + console.log('------------------------------------'); for (const [filename, info] of entries) { console.log(` ${logTime(info.time)} - ${filename} (evaluation mode: ${info.evaluationMode})`); diff --git a/packages/webpack-plugin/src/constants.mts b/packages/webpack-plugin/src/constants.mts index 8f5da2595..4822317c3 100644 --- a/packages/webpack-plugin/src/constants.mts +++ b/packages/webpack-plugin/src/constants.mts @@ -1,5 +1,5 @@ import type { LoaderContext } from 'webpack'; -import type { TransformResolver, TransformPerfIssue } from '@griffel/transform'; +import type { TransformResolver, TransformPerfIssue, TransformTimings } from '@griffel/transform'; export const PLUGIN_NAME = 'GriffelExtractPlugin'; export const GriffelCssLoaderContextKey = Symbol.for(`${PLUGIN_NAME}/GriffelCssLoaderContextKey`); @@ -17,6 +17,7 @@ export interface GriffelLoaderContextSupplement { step: 'transform'; evaluationMode: 'ast' | 'vm'; perfIssues?: TransformPerfIssue[]; + timings?: TransformTimings; }; }, ): T; diff --git a/packages/webpack-plugin/src/webpackLoader.mts b/packages/webpack-plugin/src/webpackLoader.mts index 5595d9eaf..d30ac1554 100644 --- a/packages/webpack-plugin/src/webpackLoader.mts +++ b/packages/webpack-plugin/src/webpackLoader.mts @@ -77,12 +77,13 @@ function webpackLoader( } if (result) { - const { code, cssRulesByBucket, usedVMForEvaluation, perfIssues } = result; + const { code, cssRulesByBucket, usedVMForEvaluation, perfIssues, timings } = result; const meta = { filename: this.resourcePath, step: 'transform' as const, evaluationMode: usedVMForEvaluation ? ('vm' as const) : ('ast' as const), perfIssues, + timings, }; if (cssRulesByBucket) { From 61056fb3bb10441e5e8c920ec79a55a24abed7d4 Mon Sep 17 00:00:00 2001 From: Oleksandr Fediashov Date: Tue, 24 Mar 2026 21:55:22 +0100 Subject: [PATCH 04/11] perf: reuse VM context across evaluate() calls Replace per-call vm.createContext() with a single shared context whose per-module bindings (module, exports, require, __filename, __dirname) are swapped before each script.runInContext(). This avoids the overhead of creating a new V8 sandbox for every module evaluation. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/transform/src/evaluation/module.mts | 47 ++++++++++++-------- 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/packages/transform/src/evaluation/module.mts b/packages/transform/src/evaluation/module.mts index 847102e14..a9513ba7e 100644 --- a/packages/transform/src/evaluation/module.mts +++ b/packages/transform/src/evaluation/module.mts @@ -35,6 +35,27 @@ let cache: Record = {}; const NOOP = () => {}; +// Reusable VM context — avoids expensive vm.createContext() per evaluate() call. +// Per-module bindings are swapped before each script.runInContext(). +const sharedSandbox = { + clearImmediate: NOOP, + clearInterval: NOOP, + clearTimeout: NOOP, + setImmediate: NOOP, + setInterval: NOOP, + setTimeout: NOOP, + fetch: NOOP, + global, + process: mockProcess, + // Per-module bindings (mutated before each run) + module: null as Module | null, + exports: {} as unknown, + require: null as ((id: string) => unknown) | null, + __filename: '', + __dirname: '', +}; +const sharedContext = vm.createContext(sharedSandbox); + /** Checks if a value is an Error-like object (works across VM contexts where `instanceof Error` fails). */ function isError(e: unknown): e is Error { return e != null && typeof e === 'object' && 'message' in e && 'stack' in e; @@ -253,26 +274,16 @@ export class Module { filename: this.filename, }); + // Swap per-module bindings on the shared context + sharedSandbox.module = this; + sharedSandbox.exports = this.exports; + sharedSandbox.require = this.require; + sharedSandbox.__filename = this.filename; + sharedSandbox.__dirname = path.dirname(this.filename); + let t0 = collectTimings ? process.hrtime.bigint() : 0n; try { - script.runInContext( - vm.createContext({ - clearImmediate: NOOP, - clearInterval: NOOP, - clearTimeout: NOOP, - setImmediate: NOOP, - setInterval: NOOP, - setTimeout: NOOP, - fetch: NOOP, - global, - process: mockProcess, - module: this, - exports: this.exports, - require: this.require, - __filename: this.filename, - __dirname: path.dirname(this.filename), - }), - ); + script.runInContext(sharedContext); } catch (vmError: unknown) { // Errors thrown inside vm.runInContext() use the VM context's Error constructor, // so they fail `instanceof Error` in the host context (e.g. webpack wraps them as NonErrorEmittedError). From 837168d249389d3b760c8614f09e4450daf5fbab Mon Sep 17 00:00:00 2001 From: Oleksandr Fediashov Date: Tue, 24 Mar 2026 22:11:46 +0100 Subject: [PATCH 05/11] feat: add shaker timing breakdown (oxc-transform, oxc-parser, graph build, dead code removal) Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/transform-shaker/src/index.ts | 16 ++++++++++++++ packages/transform-shaker/src/shaker.ts | 10 +++++++++ packages/transform-shaker/src/timings.ts | 22 +++++++++++++++++++ packages/transform/src/index.mts | 2 +- packages/webpack-plugin/src/GriffelPlugin.mts | 10 ++++++++- 5 files changed, 58 insertions(+), 2 deletions(-) create mode 100644 packages/transform-shaker/src/timings.ts diff --git a/packages/transform-shaker/src/index.ts b/packages/transform-shaker/src/index.ts index e6dfb5cf7..ffa4fdd4b 100644 --- a/packages/transform-shaker/src/index.ts +++ b/packages/transform-shaker/src/index.ts @@ -23,6 +23,9 @@ const needsTransformExtensions = new Set(['ts', 'tsx', 'jsx', 'mts', 'cts']); const CJS_EXTENSIONS = new Set(['.cjs', '.json']); +export { shakerTimings, collectTimings, enableTimings, resetTimings } from './timings.js'; +import { collectTimings, shakerTimings } from './timings.js'; + function prepareForShake(filename: string, code: string): { program: Program; code: string; hasModuleSyntax: boolean } { const ext = extname(filename).slice(1).toLowerCase(); const needsTransform = needsTransformExtensions.has(ext); @@ -32,7 +35,11 @@ function prepareForShake(filename: string, code: string): { program: Program; co // Strip TypeScript/JSX syntax if needed if (needsTransform) { + const t0 = collectTimings ? process.hrtime.bigint() : 0n; const result = transformSync(filename, code, {}); + if (collectTimings) { + shakerTimings.oxcTransform += process.hrtime.bigint() - t0; + } sourceCode = result.code; } @@ -41,7 +48,12 @@ function prepareForShake(filename: string, code: string): { program: Program; co 'evaluator:shaker:transform', `Parsed ${filename} ({${needsTransform ? 'transformed' : 'no transform needed'}})`, ); + + const t0 = collectTimings ? process.hrtime.bigint() : 0n; const parsed = parseSync(filename, sourceCode); + if (collectTimings) { + shakerTimings.oxcParse += process.hrtime.bigint() - t0; + } return { program: parsed.program, @@ -70,6 +82,10 @@ const shaker: Evaluator = (filename, text, only = null) => { }; } + if (collectTimings) { + shakerTimings.calls++; + } + const [shakenCode, imports] = shake(program, code, only); return { diff --git a/packages/transform-shaker/src/shaker.ts b/packages/transform-shaker/src/shaker.ts index 5afb17744..67db0d4aa 100644 --- a/packages/transform-shaker/src/shaker.ts +++ b/packages/transform-shaker/src/shaker.ts @@ -8,6 +8,7 @@ import MagicString from 'magic-string'; import { isNode, getVisitorKeys, debug } from './utils.js'; import build from './graphBuilder.js'; +import { collectTimings, shakerTimings } from './timings.js'; // Syntactically required children that must not be removed independently — // removing them produces invalid code (e.g. `export { X }` without `from "module"`). @@ -171,6 +172,7 @@ export default function shake( ): [string, Map] { debug('evaluator:shaker:shake', () => `source (exports: ${(exports || []).join(', ')}):\n${sourceCode}`); + const t0 = collectTimings ? process.hrtime.bigint() : 0n; const depsGraph = build(rootPath); const alive = new Set(); const reexports: string[] = []; @@ -203,9 +205,17 @@ export default function shake( deps = depsGraph.getDependencies(deps).filter(d => !alive.has(d)); } + if (collectTimings) { + shakerTimings.graphBuild += process.hrtime.bigint() - t0; + } + + const t1 = collectTimings ? process.hrtime.bigint() : 0n; const s = new MagicString(sourceCode); removeDeadCode(rootPath, alive, s, sourceCode); const shakenCode = s.toString(); + if (collectTimings) { + shakerTimings.shake += process.hrtime.bigint() - t1; + } debug('evaluator:shaker:shake', `shaken ${alive.size} alive nodes`); diff --git a/packages/transform-shaker/src/timings.ts b/packages/transform-shaker/src/timings.ts new file mode 100644 index 000000000..64e323f80 --- /dev/null +++ b/packages/transform-shaker/src/timings.ts @@ -0,0 +1,22 @@ +/** Accumulated shaker timings (ns). Only populated when `collectTimings` is true. */ +export const shakerTimings = { + oxcTransform: 0n, + oxcParse: 0n, + graphBuild: 0n, + shake: 0n, + calls: 0, +}; + +export let collectTimings = false; + +export function enableTimings(enabled: boolean): void { + collectTimings = enabled; +} + +export function resetTimings(): void { + shakerTimings.oxcTransform = 0n; + shakerTimings.oxcParse = 0n; + shakerTimings.graphBuild = 0n; + shakerTimings.shake = 0n; + shakerTimings.calls = 0; +} diff --git a/packages/transform/src/index.mts b/packages/transform/src/index.mts index 37325afcb..6b6197151 100644 --- a/packages/transform/src/index.mts +++ b/packages/transform/src/index.mts @@ -1,4 +1,4 @@ -export { default as shakerEvaluator } from '@griffel/transform-shaker'; +export { default as shakerEvaluator, shakerTimings, enableTimings as enableShakerTimings, resetTimings as resetShakerTimings } from '@griffel/transform-shaker'; export { Module, type TransformResolver } from './evaluation/module.mjs'; export * as EvalCache from './evaluation/evalCache.mjs'; export { ASSET_TAG_OPEN, ASSET_TAG_CLOSE } from './constants.mjs'; diff --git a/packages/webpack-plugin/src/GriffelPlugin.mts b/packages/webpack-plugin/src/GriffelPlugin.mts index 10c9a2fe4..f9f0e5c9f 100644 --- a/packages/webpack-plugin/src/GriffelPlugin.mts +++ b/packages/webpack-plugin/src/GriffelPlugin.mts @@ -1,5 +1,5 @@ import { defaultCompareMediaQueries, type GriffelRenderer } from '@griffel/core'; -import { Module as GriffelModule, type TransformTimings } from '@griffel/transform'; +import { Module as GriffelModule, type TransformTimings, shakerTimings, enableShakerTimings, resetShakerTimings } from '@griffel/transform'; import type { Compilation, Chunk, Compiler, Module, sources } from 'webpack'; import * as path from 'node:path'; @@ -128,6 +128,8 @@ export class GriffelPlugin { if (this.#collectStats) { GriffelModule.collectTimings = true; GriffelModule.resetTimings(); + enableShakerTimings(true); + resetShakerTimings(); } const IS_RSPACK = Object.prototype.hasOwnProperty.call(compiler.webpack, 'rspackVersion'); @@ -395,6 +397,12 @@ export class GriffelPlugin { console.log(' vm.runInContext: ', logTime(GriffelModule.vmRunTime)); console.log(' fs.readFileSync: ', logTime(GriffelModule.fsReadTime)); console.log('------------------------------------'); + console.log(`Shaker breakdown (${shakerTimings.calls} calls):`); + console.log(' oxc-transform: ', logTime(shakerTimings.oxcTransform)); + console.log(' oxc-parser: ', logTime(shakerTimings.oxcParse)); + console.log(' Graph build: ', logTime(shakerTimings.graphBuild)); + console.log(' Dead code removal:', logTime(shakerTimings.shake)); + console.log('------------------------------------'); for (const [filename, info] of entries) { console.log(` ${logTime(info.time)} - ${filename} (evaluation mode: ${info.evaluationMode})`); From 9bedf4ba1500a52da1c0c14f0d0152f6b67d9fc1 Mon Sep 17 00:00:00 2001 From: Oleksandr Fediashov Date: Tue, 24 Mar 2026 22:13:27 +0100 Subject: [PATCH 06/11] fix: pass per-module bindings as IIFE params to prevent leaking between nested requires The shared VM context sandbox gets mutated by nested require() calls, causing earlier modules to see later modules' exports. Fix by passing module, exports, require, __filename, __dirname as function parameters to the wrapper IIFE, capturing them at call time. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/transform/src/evaluation/module.mts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/transform/src/evaluation/module.mts b/packages/transform/src/evaluation/module.mts index a9513ba7e..e2cab241f 100644 --- a/packages/transform/src/evaluation/module.mts +++ b/packages/transform/src/evaluation/module.mts @@ -47,10 +47,10 @@ const sharedSandbox = { fetch: NOOP, global, process: mockProcess, - // Per-module bindings (mutated before each run) - module: null as Module | null, - exports: {} as unknown, - require: null as ((id: string) => unknown) | null, + // Per-module bindings (mutated before each run, prefixed with __ to avoid + // colliding with the function parameter names passed to the wrapper IIFE) + __module: null as Module | null, + __require: null as ((id: string) => unknown) | null, __filename: '', __dirname: '', }; @@ -270,14 +270,14 @@ export class Module { this.debug('evaluate', `${this.filename} (only ${(only || []).join(', ')}):\n${code}`); } - const script = new vm.Script(`(function (exports) { ${code}\n})(exports);`, { - filename: this.filename, - }); + const script = new vm.Script( + `(function (exports, module, require, __filename, __dirname) { ${code}\n})(__module.exports, __module, __require, __filename, __dirname);`, + { filename: this.filename }, + ); // Swap per-module bindings on the shared context - sharedSandbox.module = this; - sharedSandbox.exports = this.exports; - sharedSandbox.require = this.require; + sharedSandbox.__module = this; + sharedSandbox.__require = this.require; sharedSandbox.__filename = this.filename; sharedSandbox.__dirname = path.dirname(this.filename); From 10a5c052c55719adadc7383753c6119b1a26b01d Mon Sep 17 00:00:00 2001 From: Oleksandr Fediashov Date: Tue, 24 Mar 2026 22:20:14 +0100 Subject: [PATCH 07/11] perf: optimize graph builder hot paths - Cache getVisitors() by node type (avoids array allocations per AST node) - Replace forEach with for loops in baseVisit and callbacks - Inline addEdge resolution for non-PromisedNode (skip action queue) - Replace stack.unshift/shift with push/pop (O(1) instead of O(n)) - Replace stack.find() with reverse for loops (innermost-first) - Replace visitors.shift() with index-based iteration Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/transform-shaker/src/DepsGraph.ts | 21 ++++++------ packages/transform-shaker/src/Visitors.ts | 21 +++++++++--- packages/transform-shaker/src/graphBuilder.ts | 31 ++++++++--------- packages/transform-shaker/src/scope.ts | 33 ++++++++++++++----- 4 files changed, 66 insertions(+), 40 deletions(-) diff --git a/packages/transform-shaker/src/DepsGraph.ts b/packages/transform-shaker/src/DepsGraph.ts index 80b8dc8eb..4ad37ff20 100644 --- a/packages/transform-shaker/src/DepsGraph.ts +++ b/packages/transform-shaker/src/DepsGraph.ts @@ -6,20 +6,16 @@ import type { Node } from 'oxc-parser'; import type { IdentifierNode, StringLiteralNode } from './ast.js'; -import type { PromisedNode } from './scope.js'; +import { PromisedNode } from './scope.js'; import type ScopeManager from './scope.js'; import { resolveNode } from './scope.js'; type Action = (this: DepsGraph, a: Node, b: Node) => void; -function addEdge(this: DepsGraph, a: Node, b: Node) { - if (this.dependencies.has(a) && this.dependencies.get(a)!.has(b)) { - // edge has been already added - return; - } - - if (this.dependencies.has(a)) { - this.dependencies.get(a)!.add(b); +function addEdgeResolved(this: DepsGraph, a: Node, b: Node) { + let deps = this.dependencies.get(a); + if (deps) { + deps.add(b); } else { this.dependencies.set(a, new Set([b])); } @@ -61,7 +57,12 @@ export default class DepsGraph { constructor(protected scope: ScopeManager) {} addEdge(dependent: Node | PromisedNode, dependency: Node | PromisedNode) { - this.actionQueue.push([addEdge, dependent, dependency]); + // Fast path: if both are already resolved nodes, add edge directly + if (!PromisedNode.is(dependent) && !PromisedNode.is(dependency)) { + addEdgeResolved.call(this, dependent, dependency); + return; + } + this.actionQueue.push([addEdgeResolved, dependent, dependency]); } addExport(name: string, node: Node) { diff --git a/packages/transform-shaker/src/Visitors.ts b/packages/transform-shaker/src/Visitors.ts index 31a2a7ee6..b4c0a5b56 100644 --- a/packages/transform-shaker/src/Visitors.ts +++ b/packages/transform-shaker/src/Visitors.ts @@ -79,12 +79,23 @@ const visitors = { const isKeyOfVisitors = (type: string): type is string & keyof Visitors => type in visitors; +const visitorsCache = new Map[]>(); + export function getVisitors(node: TNode): Visitor[] { - const aliases = ALIAS_KEYS[node.type] || []; - const aliasVisitors = aliases - .map(type => (isKeyOfVisitors(type) ? visitors[type] : null)) - .filter(i => i) as Visitor[]; - return [...aliasVisitors, visitors[node.type] as Visitor].filter(v => v); + const nodeType = node.type; + let cached = visitorsCache.get(nodeType); + if (cached) return cached as Visitor[]; + + const aliases = ALIAS_KEYS[nodeType] || []; + const result: Visitor[] = []; + for (let i = 0; i < aliases.length; i++) { + const type = aliases[i]; + if (isKeyOfVisitors(type)) result.push(visitors[type]!); + } + if (visitors[nodeType]) result.push(visitors[nodeType]!); + + visitorsCache.set(nodeType, result); + return result as Visitor[]; } export default visitors; diff --git a/packages/transform-shaker/src/graphBuilder.ts b/packages/transform-shaker/src/graphBuilder.ts index 372629c4b..d7448ea78 100644 --- a/packages/transform-shaker/src/graphBuilder.ts +++ b/packages/transform-shaker/src/graphBuilder.ts @@ -124,13 +124,14 @@ class GraphBuilder { * both of them are required for evaluating the value of the expression */ baseVisit(node: TNode, ignoreDeps = false) { - const dependencies: Node[] = []; - const isExpr = isExpression(node); + const isExpr = !ignoreDeps && isExpression(node); const keys = getVisitorKeys(node); - keys.forEach(key => { + + for (let ki = 0; ki < keys.length; ki++) { + const key = keys[ki]; // Ignore all types if (key === 'typeArguments' || key === 'typeParameters') { - return; + continue; } const subNode = node[key as keyof TNode]; @@ -138,20 +139,18 @@ class GraphBuilder { if (Array.isArray(subNode)) { for (let i = 0; i < subNode.length; i++) { const child = subNode[i]; - if (child && this.visit(child, node, key, i) !== 'ignore') { - dependencies.push(child); + if (child && this.visit(child, node, key, i) !== 'ignore' && isExpr) { + this.graph.addEdge(node, child); } } - } else if (isNode(subNode) && this.visit(subNode, node, key) !== 'ignore') { - dependencies.push(subNode); + } else if (isNode(subNode) && this.visit(subNode, node, key) !== 'ignore' && isExpr) { + this.graph.addEdge(node, subNode); } - }); - - if (isExpr && !ignoreDeps) { - dependencies.forEach(dep => this.graph.addEdge(node, dep)); } - this.callbacks.forEach(callback => callback(node)); + for (let ci = 0; ci < this.callbacks.length; ci++) { + this.callbacks[ci](node); + } } visit( @@ -249,10 +248,8 @@ class GraphBuilder { const visitors = getVisitors(node); let action: VisitorAction = undefined; if (visitors.length > 0) { - let visitor: Visitor | undefined; - // eslint-disable-next-line no-cond-assign - while (!action && (visitor = visitors.shift())) { - const method: Visitor = visitor.bind(this); + for (let vi = 0; vi < visitors.length && !action; vi++) { + const method: Visitor = visitors[vi].bind(this); action = method(node, parent, parentKey, listIdx); } } else { diff --git a/packages/transform-shaker/src/scope.ts b/packages/transform-shaker/src/scope.ts index 49b0b09ca..115058edb 100644 --- a/packages/transform-shaker/src/scope.ts +++ b/packages/transform-shaker/src/scope.ts @@ -94,12 +94,12 @@ export default class ScopeManager { scopeIds.set(scope, scopeId); this.map.set(scopeId, scope); this.handlers.set(scopeId, []); - this.stack.unshift(scope); + this.stack.push(scope); return scope; } dispose(): Scope | undefined { - const disposed = this.stack.shift(); + const disposed = this.stack.pop(); if (disposed) { this.map.delete(scopeIds.get(disposed)!); } @@ -129,7 +129,15 @@ export default class ScopeManager { const identifier = identifierOrMemberExpression; const idName = identifier.name; - const scope = this.stack.slice(stack).find(s => !isHoistable || functionScopes.has(s))!; + // Search from innermost (end) towards outermost, skipping `stack` levels from the end + let scope: Scope | undefined; + for (let i = this.stack.length - 1 - stack; i >= 0; i--) { + if (!isHoistable || functionScopes.has(this.stack[i])) { + scope = this.stack[i]; + break; + } + } + scope = scope!; if (this.global.has(idName) && !globalIdentifiers.has(idName)) { // It's probably a declaration of a previous referenced identifier // Let's use naïve implementation of hoisting @@ -152,7 +160,14 @@ export default class ScopeManager { const name = isIdentifier(identifierOrMemberExpression) ? identifierOrMemberExpression.name : getExportName(identifierOrMemberExpression); - const scope = this.stack.find(s => s.has(name)) ?? this.global; + let scope: Scope | undefined; + for (let i = this.stack.length - 1; i >= 0; i--) { + if (this.stack[i].has(name)) { + scope = this.stack[i]; + break; + } + } + if (!scope) scope = this.global; const id = getId(scope, name); if (scope === this.global && !scope.has(name)) { scope.set(name, new Set()); @@ -165,9 +180,11 @@ export default class ScopeManager { whereIsDeclared(identifier: IdentifierNode): ScopeId | undefined { const { name } = identifier; - const scope = this.stack.find(s => s.has(name) && s.get(name)!.has(identifier)); - if (scope) { - return scopeIds.get(scope); + for (let i = this.stack.length - 1; i >= 0; i--) { + const s = this.stack[i]; + if (s.has(name) && s.get(name)!.has(identifier)) { + return scopeIds.get(s); + } } if (this.global.has(name)) { @@ -198,7 +215,7 @@ export default class ScopeManager { } addDeclareHandler(handler: DeclareHandler): () => void { - const scopeId = scopeIds.get(this.stack[0])!; + const scopeId = scopeIds.get(this.stack[this.stack.length - 1])!; this.handlers.get(scopeId)!.push(handler); return () => { const handlers = this.handlers.get(scopeId)!.filter(h => h !== handler); From 08431ccdcd353caac374105f73e8e3c711d17546 Mon Sep 17 00:00:00 2001 From: Oleksandr Fediashov Date: Tue, 24 Mar 2026 22:28:29 +0100 Subject: [PATCH 08/11] chore: remove detailed timing instrumentation Remove per-phase timings from transformSync, Module, and shaker. Keep only the existing processAssetsTime and per-file time/evaluationMode stats. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/transform-shaker/src/index.ts | 16 ------ packages/transform-shaker/src/shaker.ts | 10 ---- packages/transform-shaker/src/timings.ts | 22 -------- packages/transform/src/evaluation/module.mts | 38 ------------- packages/transform/src/index.mts | 4 +- packages/transform/src/transformSync.mts | 56 ------------------- packages/webpack-plugin/src/GriffelPlugin.mts | 41 -------------- packages/webpack-plugin/src/constants.mts | 3 +- packages/webpack-plugin/src/webpackLoader.mts | 3 +- 9 files changed, 4 insertions(+), 189 deletions(-) delete mode 100644 packages/transform-shaker/src/timings.ts diff --git a/packages/transform-shaker/src/index.ts b/packages/transform-shaker/src/index.ts index ffa4fdd4b..e6dfb5cf7 100644 --- a/packages/transform-shaker/src/index.ts +++ b/packages/transform-shaker/src/index.ts @@ -23,9 +23,6 @@ const needsTransformExtensions = new Set(['ts', 'tsx', 'jsx', 'mts', 'cts']); const CJS_EXTENSIONS = new Set(['.cjs', '.json']); -export { shakerTimings, collectTimings, enableTimings, resetTimings } from './timings.js'; -import { collectTimings, shakerTimings } from './timings.js'; - function prepareForShake(filename: string, code: string): { program: Program; code: string; hasModuleSyntax: boolean } { const ext = extname(filename).slice(1).toLowerCase(); const needsTransform = needsTransformExtensions.has(ext); @@ -35,11 +32,7 @@ function prepareForShake(filename: string, code: string): { program: Program; co // Strip TypeScript/JSX syntax if needed if (needsTransform) { - const t0 = collectTimings ? process.hrtime.bigint() : 0n; const result = transformSync(filename, code, {}); - if (collectTimings) { - shakerTimings.oxcTransform += process.hrtime.bigint() - t0; - } sourceCode = result.code; } @@ -48,12 +41,7 @@ function prepareForShake(filename: string, code: string): { program: Program; co 'evaluator:shaker:transform', `Parsed ${filename} ({${needsTransform ? 'transformed' : 'no transform needed'}})`, ); - - const t0 = collectTimings ? process.hrtime.bigint() : 0n; const parsed = parseSync(filename, sourceCode); - if (collectTimings) { - shakerTimings.oxcParse += process.hrtime.bigint() - t0; - } return { program: parsed.program, @@ -82,10 +70,6 @@ const shaker: Evaluator = (filename, text, only = null) => { }; } - if (collectTimings) { - shakerTimings.calls++; - } - const [shakenCode, imports] = shake(program, code, only); return { diff --git a/packages/transform-shaker/src/shaker.ts b/packages/transform-shaker/src/shaker.ts index 67db0d4aa..5afb17744 100644 --- a/packages/transform-shaker/src/shaker.ts +++ b/packages/transform-shaker/src/shaker.ts @@ -8,7 +8,6 @@ import MagicString from 'magic-string'; import { isNode, getVisitorKeys, debug } from './utils.js'; import build from './graphBuilder.js'; -import { collectTimings, shakerTimings } from './timings.js'; // Syntactically required children that must not be removed independently — // removing them produces invalid code (e.g. `export { X }` without `from "module"`). @@ -172,7 +171,6 @@ export default function shake( ): [string, Map] { debug('evaluator:shaker:shake', () => `source (exports: ${(exports || []).join(', ')}):\n${sourceCode}`); - const t0 = collectTimings ? process.hrtime.bigint() : 0n; const depsGraph = build(rootPath); const alive = new Set(); const reexports: string[] = []; @@ -205,17 +203,9 @@ export default function shake( deps = depsGraph.getDependencies(deps).filter(d => !alive.has(d)); } - if (collectTimings) { - shakerTimings.graphBuild += process.hrtime.bigint() - t0; - } - - const t1 = collectTimings ? process.hrtime.bigint() : 0n; const s = new MagicString(sourceCode); removeDeadCode(rootPath, alive, s, sourceCode); const shakenCode = s.toString(); - if (collectTimings) { - shakerTimings.shake += process.hrtime.bigint() - t1; - } debug('evaluator:shaker:shake', `shaken ${alive.size} alive nodes`); diff --git a/packages/transform-shaker/src/timings.ts b/packages/transform-shaker/src/timings.ts deleted file mode 100644 index 64e323f80..000000000 --- a/packages/transform-shaker/src/timings.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** Accumulated shaker timings (ns). Only populated when `collectTimings` is true. */ -export const shakerTimings = { - oxcTransform: 0n, - oxcParse: 0n, - graphBuild: 0n, - shake: 0n, - calls: 0, -}; - -export let collectTimings = false; - -export function enableTimings(enabled: boolean): void { - collectTimings = enabled; -} - -export function resetTimings(): void { - shakerTimings.oxcTransform = 0n; - shakerTimings.oxcParse = 0n; - shakerTimings.graphBuild = 0n; - shakerTimings.shake = 0n; - shakerTimings.calls = 0; -} diff --git a/packages/transform/src/evaluation/module.mts b/packages/transform/src/evaluation/module.mts index e2cab241f..6e946fa9b 100644 --- a/packages/transform/src/evaluation/module.mts +++ b/packages/transform/src/evaluation/module.mts @@ -165,11 +165,7 @@ export class Module { if (Module.extensions.has(ext)) { // To evaluate the file, we need to read it first - const fsT0 = Module.collectTimings ? process.hrtime.bigint() : 0n; const code = fs.readFileSync(filename, 'utf-8'); - if (Module.collectTimings) { - Module.fsReadTime += process.hrtime.bigint() - fsT0; - } if (ext === '.json') { // For JSON files, parse it to a JS object similar to Node @@ -198,27 +194,8 @@ export class Module { }, ); - /** Accumulated timing for transform/shaker actions (ns). Only populated when debug is enabled. */ - static transformTime: bigint = 0n; - /** Accumulated timing for vm.Script execution (ns). Only populated when debug is enabled. */ - static vmRunTime: bigint = 0n; - /** Accumulated timing for fs.readFileSync calls (ns). Only populated when debug is enabled. */ - static fsReadTime: bigint = 0n; - /** Accumulated timing for convertESMtoCJS calls (ns). Only populated when debug is enabled. */ - static esmConvertTime: bigint = 0n; - /** Whether to collect detailed timings. */ - static collectTimings: boolean = false; - - static resetTimings(): void { - Module.transformTime = 0n; - Module.vmRunTime = 0n; - Module.fsReadTime = 0n; - Module.esmConvertTime = 0n; - } - evaluate(text: string, only: string[] | null = null, useEvalCache = true): void { const { filename } = this; - const collectTimings = Module.collectTimings; // Find last matching rule (iterate backwards, break on first match) let action: EvalRule['action'] = 'ignore'; @@ -247,24 +224,14 @@ export class Module { // For JavaScript files, we need to transpile it and to get the exports of the module this.debug('prepare-evaluation', this.filename, 'using', action.name); - let t0 = collectTimings ? process.hrtime.bigint() : 0n; const result = action(this.filename, text, only); - if (collectTimings) { - Module.transformTime += process.hrtime.bigint() - t0; - } code = result.code; this.imports = result.imports; // Convert ESM syntax to CJS so it can run inside a function wrapper in vm.Script if (result.moduleKind === 'esm') { - if (collectTimings) { - t0 = process.hrtime.bigint(); - } code = convertESMtoCJS(code, this.filename); - if (collectTimings) { - Module.esmConvertTime += process.hrtime.bigint() - t0; - } } this.debug('evaluate', `${this.filename} (only ${(only || []).join(', ')}):\n${code}`); @@ -281,7 +248,6 @@ export class Module { sharedSandbox.__filename = this.filename; sharedSandbox.__dirname = path.dirname(this.filename); - let t0 = collectTimings ? process.hrtime.bigint() : 0n; try { script.runInContext(sharedContext); } catch (vmError: unknown) { @@ -296,10 +262,6 @@ export class Module { } throw hostError; - } finally { - if (collectTimings) { - Module.vmRunTime += process.hrtime.bigint() - t0; - } } if (useEvalCache) { diff --git a/packages/transform/src/index.mts b/packages/transform/src/index.mts index 6b6197151..bc23d6079 100644 --- a/packages/transform/src/index.mts +++ b/packages/transform/src/index.mts @@ -1,4 +1,4 @@ -export { default as shakerEvaluator, shakerTimings, enableTimings as enableShakerTimings, resetTimings as resetShakerTimings } from '@griffel/transform-shaker'; +export { default as shakerEvaluator } from '@griffel/transform-shaker'; export { Module, type TransformResolver } from './evaluation/module.mjs'; export * as EvalCache from './evaluation/evalCache.mjs'; export { ASSET_TAG_OPEN, ASSET_TAG_CLOSE } from './constants.mjs'; @@ -7,7 +7,7 @@ export type { Evaluator, EvaluatorResult, EvalRule } from './evaluation/types.mj // Our APIs -export { transformSync, type TransformOptions, type TransformResult, type TransformTimings } from './transformSync.mjs'; +export { transformSync, type TransformOptions, type TransformResult } from './transformSync.mjs'; export { DEOPT, type Deopt } from './evaluation/astEvaluator.mjs'; export type { AstEvaluatorPlugin, AstEvaluatorContext, TransformPerfIssue } from './evaluation/types.mjs'; export { fluentTokensPlugin } from './evaluation/fluentTokensPlugin.mjs'; diff --git a/packages/transform/src/transformSync.mts b/packages/transform/src/transformSync.mts index 46a928ad6..1a1413e3e 100644 --- a/packages/transform/src/transformSync.mts +++ b/packages/transform/src/transformSync.mts @@ -56,26 +56,12 @@ export type TransformOptions = { collectPerfIssues?: boolean; }; -export type TransformTimings = { - /** Time spent parsing the source code (ns) */ - parsing: bigint; - /** Time spent in AST walking to find style calls (ns) */ - walking: bigint; - /** Time spent evaluating style call arguments (ns) */ - evaluation: bigint; - /** Time spent resolving CSS rules from evaluated styles (ns) */ - resolving: bigint; - /** Time spent in code transformations via magic-string (ns) */ - codeTransform: bigint; -}; - export type TransformResult = { code: string; cssRulesByBucket?: CSSRulesByBucket; usedProcessing: boolean; usedVMForEvaluation: boolean; perfIssues?: TransformPerfIssue[]; - timings?: TransformTimings; }; type FunctionKinds = 'makeStyles' | 'makeResetStyles' | 'makeStaticStyles'; @@ -208,23 +194,8 @@ export function transformSync(sourceCode: string, options: TransformOptions): Tr throw new Error('Transform error: "filename" option is required'); } - const collectTimings = options.collectPerfIssues ?? false; - const timings: TransformTimings = { - parsing: 0n, - walking: 0n, - evaluation: 0n, - resolving: 0n, - codeTransform: 0n, - }; - - let t0 = collectTimings ? process.hrtime.bigint() : 0n; - const parseResult = parseSync(filename, sourceCode); - if (collectTimings) { - timings.parsing = process.hrtime.bigint() - t0; - } - if (parseResult.errors.length > 0) { throw new Error(`Failed to parse "${filename}": ${parseResult.errors.map(e => e.message).join(', ')}`); } @@ -259,10 +230,6 @@ export function transformSync(sourceCode: string, options: TransformOptions): Tr // ----- // Walk AST to collect style function calls using ScopeTracker for scope-aware import resolution - if (collectTimings) { - t0 = process.hrtime.bigint(); - } - const scopeTracker = new ScopeTracker(); const matchedSpecifiers = new Map(); @@ -351,10 +318,6 @@ export function transformSync(sourceCode: string, options: TransformOptions): Tr }, }); - if (collectTimings) { - timings.walking = process.hrtime.bigint() - t0; - } - // If no style calls found, return original code if (styleCalls.length === 0) { return { @@ -365,10 +328,6 @@ export function transformSync(sourceCode: string, options: TransformOptions): Tr } // Process style calls - evaluate and transform - if (collectTimings) { - t0 = process.hrtime.bigint(); - } - const { evaluationResults, usedVMForEvaluation } = batchEvaluator( sourceCode, filename, @@ -379,11 +338,6 @@ export function transformSync(sourceCode: string, options: TransformOptions): Tr astEvaluationPlugins, ); - if (collectTimings) { - timings.evaluation = process.hrtime.bigint() - t0; - t0 = process.hrtime.bigint(); - } - for (let i = styleCalls.length - 1; i >= 0; i--) { const styleCall = styleCalls[i]; const evaluationResult = evaluationResults[i]; @@ -442,11 +396,6 @@ export function transformSync(sourceCode: string, options: TransformOptions): Tr } } - if (collectTimings) { - timings.resolving = process.hrtime.bigint() - t0; - t0 = process.hrtime.bigint(); - } - // --- // Transform imports and function names @@ -465,16 +414,11 @@ export function transformSync(sourceCode: string, options: TransformOptions): Tr ); } - if (collectTimings) { - timings.codeTransform = process.hrtime.bigint() - t0; - } - return { code: magicString.toString(), cssRulesByBucket, usedProcessing: true, usedVMForEvaluation, perfIssues, - timings: collectTimings ? timings : undefined, }; } diff --git a/packages/webpack-plugin/src/GriffelPlugin.mts b/packages/webpack-plugin/src/GriffelPlugin.mts index f9f0e5c9f..57c4eadd8 100644 --- a/packages/webpack-plugin/src/GriffelPlugin.mts +++ b/packages/webpack-plugin/src/GriffelPlugin.mts @@ -1,5 +1,4 @@ import { defaultCompareMediaQueries, type GriffelRenderer } from '@griffel/core'; -import { Module as GriffelModule, type TransformTimings, shakerTimings, enableShakerTimings, resetShakerTimings } from '@griffel/transform'; import type { Compilation, Chunk, Compiler, Module, sources } from 'webpack'; import * as path from 'node:path'; @@ -110,7 +109,6 @@ export class GriffelPlugin { { time: bigint; evaluationMode: 'ast' | 'vm'; - timings?: TransformTimings; } > = {}; #processAssetsTime: bigint = 0n; @@ -125,13 +123,6 @@ export class GriffelPlugin { } apply(compiler: Compiler): void { - if (this.#collectStats) { - GriffelModule.collectTimings = true; - GriffelModule.resetTimings(); - enableShakerTimings(true); - resetShakerTimings(); - } - const IS_RSPACK = Object.prototype.hasOwnProperty.call(compiler.webpack, 'rspackVersion'); const { Compilation, NormalModule } = compiler.webpack; @@ -220,7 +211,6 @@ export class GriffelPlugin { this.#stats[meta.filename] = { time: end - start, evaluationMode: meta.evaluationMode, - timings: meta.timings, }; } @@ -362,18 +352,6 @@ export class GriffelPlugin { console.log('\nGriffel CSS extraction stats:'); - // Aggregate per-phase timings - const totals: TransformTimings = { parsing: 0n, walking: 0n, evaluation: 0n, resolving: 0n, codeTransform: 0n }; - for (const [, info] of entries) { - if (info.timings) { - totals.parsing += info.timings.parsing; - totals.walking += info.timings.walking; - totals.evaluation += info.timings.evaluation; - totals.resolving += info.timings.resolving; - totals.codeTransform += info.timings.codeTransform; - } - } - console.log('------------------------------------'); console.log('Total time spent in Griffel loader:', logTime(totalTime)); console.log('Time spent in processAssets (sort):', logTime(this.#processAssetsTime)); @@ -384,25 +362,6 @@ export class GriffelPlugin { ((entries.filter(s => s[1].evaluationMode === 'ast').length / fileCount) * 100).toFixed(2) + '%', ); console.log('------------------------------------'); - console.log('Phase breakdown (aggregate):'); - console.log(' Parsing: ', logTime(totals.parsing)); - console.log(' Walking: ', logTime(totals.walking)); - console.log(' Evaluation: ', logTime(totals.evaluation)); - console.log(' Resolving: ', logTime(totals.resolving)); - console.log(' Code transform: ', logTime(totals.codeTransform)); - console.log('------------------------------------'); - console.log('VM evaluation breakdown (Module):'); - console.log(' Transform/shaker:', logTime(GriffelModule.transformTime)); - console.log(' ESM→CJS convert: ', logTime(GriffelModule.esmConvertTime)); - console.log(' vm.runInContext: ', logTime(GriffelModule.vmRunTime)); - console.log(' fs.readFileSync: ', logTime(GriffelModule.fsReadTime)); - console.log('------------------------------------'); - console.log(`Shaker breakdown (${shakerTimings.calls} calls):`); - console.log(' oxc-transform: ', logTime(shakerTimings.oxcTransform)); - console.log(' oxc-parser: ', logTime(shakerTimings.oxcParse)); - console.log(' Graph build: ', logTime(shakerTimings.graphBuild)); - console.log(' Dead code removal:', logTime(shakerTimings.shake)); - console.log('------------------------------------'); for (const [filename, info] of entries) { console.log(` ${logTime(info.time)} - ${filename} (evaluation mode: ${info.evaluationMode})`); diff --git a/packages/webpack-plugin/src/constants.mts b/packages/webpack-plugin/src/constants.mts index 4822317c3..8f5da2595 100644 --- a/packages/webpack-plugin/src/constants.mts +++ b/packages/webpack-plugin/src/constants.mts @@ -1,5 +1,5 @@ import type { LoaderContext } from 'webpack'; -import type { TransformResolver, TransformPerfIssue, TransformTimings } from '@griffel/transform'; +import type { TransformResolver, TransformPerfIssue } from '@griffel/transform'; export const PLUGIN_NAME = 'GriffelExtractPlugin'; export const GriffelCssLoaderContextKey = Symbol.for(`${PLUGIN_NAME}/GriffelCssLoaderContextKey`); @@ -17,7 +17,6 @@ export interface GriffelLoaderContextSupplement { step: 'transform'; evaluationMode: 'ast' | 'vm'; perfIssues?: TransformPerfIssue[]; - timings?: TransformTimings; }; }, ): T; diff --git a/packages/webpack-plugin/src/webpackLoader.mts b/packages/webpack-plugin/src/webpackLoader.mts index d30ac1554..5595d9eaf 100644 --- a/packages/webpack-plugin/src/webpackLoader.mts +++ b/packages/webpack-plugin/src/webpackLoader.mts @@ -77,13 +77,12 @@ function webpackLoader( } if (result) { - const { code, cssRulesByBucket, usedVMForEvaluation, perfIssues, timings } = result; + const { code, cssRulesByBucket, usedVMForEvaluation, perfIssues } = result; const meta = { filename: this.resourcePath, step: 'transform' as const, evaluationMode: usedVMForEvaluation ? ('vm' as const) : ('ast' as const), perfIssues, - timings, }; if (cssRulesByBucket) { From 802175311ebe69750381ac83f4bcef6cc5f29ac0 Mon Sep 17 00:00:00 2001 From: Oleksandr Fediashov Date: Tue, 24 Mar 2026 22:29:01 +0100 Subject: [PATCH 09/11] chore: improve stats output format Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/webpack-plugin/src/GriffelPlugin.mts | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/packages/webpack-plugin/src/GriffelPlugin.mts b/packages/webpack-plugin/src/GriffelPlugin.mts index 57c4eadd8..e07210d5c 100644 --- a/packages/webpack-plugin/src/GriffelPlugin.mts +++ b/packages/webpack-plugin/src/GriffelPlugin.mts @@ -350,21 +350,19 @@ export class GriffelPlugin { const fileCount = entries.length; const avgTime = fileCount > 0 ? totalTime / BigInt(fileCount) : 0n; - console.log('\nGriffel CSS extraction stats:'); + const astEntries = entries.filter(s => s[1].evaluationMode === 'ast'); + const vmEntries = entries.filter(s => s[1].evaluationMode === 'vm'); + const astTime = astEntries.reduce((acc, cur) => acc + cur[1].time, 0n); + const vmTime = vmEntries.reduce((acc, cur) => acc + cur[1].time, 0n); + const astHitPct = ((astEntries.length / fileCount) * 100).toFixed(1) + '%'; - console.log('------------------------------------'); - console.log('Total time spent in Griffel loader:', logTime(totalTime)); - console.log('Time spent in processAssets (sort):', logTime(this.#processAssetsTime)); - console.log('Files processed:', fileCount); - console.log('Average time per file:', logTime(avgTime)); - console.log( - 'AST evaluation hit: ', - ((entries.filter(s => s[1].evaluationMode === 'ast').length / fileCount) * 100).toFixed(2) + '%', - ); - console.log('------------------------------------'); + console.log(`\n[Griffel] ${fileCount} files processed`); + console.log(`[Griffel] Loader: ${logTime(totalTime)} (AST ${logTime(astTime)} | VM ${logTime(vmTime)}), avg ${logTime(avgTime)}/file, AST eval hit ${astHitPct}`); + console.log(`[Griffel] Plugin: ${logTime(this.#processAssetsTime)}`); + console.log(''); for (const [filename, info] of entries) { - console.log(` ${logTime(info.time)} - ${filename} (evaluation mode: ${info.evaluationMode})`); + console.log(` ${logTime(info.time)} ${info.evaluationMode === 'vm' ? 'vm ' : 'ast'} ${filename}`); } console.log(); From f9b1d84200550380d83dc6d0b4cdd82b4392e7bf Mon Sep 17 00:00:00 2001 From: Oleksandr Fediashov Date: Tue, 24 Mar 2026 22:29:53 +0100 Subject: [PATCH 10/11] chore: align per-file stats table with padded time column Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/webpack-plugin/src/GriffelPlugin.mts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/webpack-plugin/src/GriffelPlugin.mts b/packages/webpack-plugin/src/GriffelPlugin.mts index e07210d5c..3fe64ffb3 100644 --- a/packages/webpack-plugin/src/GriffelPlugin.mts +++ b/packages/webpack-plugin/src/GriffelPlugin.mts @@ -362,7 +362,9 @@ export class GriffelPlugin { console.log(''); for (const [filename, info] of entries) { - console.log(` ${logTime(info.time)} ${info.evaluationMode === 'vm' ? 'vm ' : 'ast'} ${filename}`); + const time = logTime(info.time).padStart(6); + const mode = info.evaluationMode === 'vm' ? 'vm ' : 'ast'; + console.log(` ${time} ${mode} ${filename}`); } console.log(); From d6026d07abeae486338a9ff874bb92b718c267de Mon Sep 17 00:00:00 2001 From: Oleksandr Fediashov Date: Tue, 24 Mar 2026 22:30:50 +0100 Subject: [PATCH 11/11] chore: clean up perf issues output format Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/webpack-plugin/src/GriffelPlugin.mts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/webpack-plugin/src/GriffelPlugin.mts b/packages/webpack-plugin/src/GriffelPlugin.mts index 3fe64ffb3..29d5ae266 100644 --- a/packages/webpack-plugin/src/GriffelPlugin.mts +++ b/packages/webpack-plugin/src/GriffelPlugin.mts @@ -375,16 +375,13 @@ export class GriffelPlugin { const cjsCount = issues.filter(i => i.type === 'cjs-module').length; const barrelCount = issues.filter(i => i.type === 'barrel-export-star').length; - console.log('\nGriffel performance issues:'); - console.log('------------------------------------'); - console.log(`CJS modules (no tree-shaking): ${cjsCount}`); - console.log(`Barrel files with remaining export *: ${barrelCount}`); - console.log('------------------------------------'); + console.log(`[Griffel] Perf issues: ${cjsCount} CJS (no tree-shaking), ${barrelCount} barrel (export *)`); + console.log(''); for (const issue of issues) { - const tag = issue.type === 'cjs-module' ? 'cjs' : 'barrel'; + const tag = issue.type === 'cjs-module' ? ' cjs' : 'barrel'; const sources = Array.from(issue.sourceFilenames).join(', '); - console.log(` [${tag}] ${issue.dependencyFilename} (source: ${sources})`); + console.log(` ${tag} ${issue.dependencyFilename} (from: ${sources})`); } console.log();