diff --git a/package-lock.json b/package-lock.json
index afc1c85b0..a33716033 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -231,6 +231,10 @@
"resolved": "packages/alphatex",
"link": true
},
+ "node_modules/@coderline/alphatab-bench": {
+ "resolved": "packages/bench",
+ "link": true
+ },
"node_modules/@coderline/alphatab-csharp": {
"resolved": "packages/csharp",
"link": true
@@ -5544,6 +5548,35 @@
"node": ">=14.17"
}
},
+ "packages/bench": {
+ "name": "@coderline/alphatab-bench",
+ "version": "1.9.0",
+ "license": "MPL-2.0",
+ "dependencies": {
+ "@coderline/alphatab": "*"
+ },
+ "devDependencies": {
+ "@types/node": "^25.9.1",
+ "rimraf": "^6.1.3",
+ "tslib": "^2.8.1",
+ "typescript": "^6.0.3",
+ "vite": "^8.0.14"
+ }
+ },
+ "packages/bench/node_modules/typescript": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
+ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
"packages/csharp": {
"name": "@coderline/alphatab-csharp",
"version": "1.9.0",
diff --git a/packages/alphatab/src/platform/svg/CssFontSvgCanvas.ts b/packages/alphatab/src/platform/svg/CssFontSvgCanvas.ts
index cd254be76..add8287ca 100644
--- a/packages/alphatab/src/platform/svg/CssFontSvgCanvas.ts
+++ b/packages/alphatab/src/platform/svg/CssFontSvgCanvas.ts
@@ -1,6 +1,5 @@
-import { TextAlign } from '@coderline/alphatab/platform/ICanvas';
-import { SvgCanvas } from '@coderline/alphatab/platform/svg/SvgCanvas';
import { MusicFontSymbol } from '@coderline/alphatab/model/MusicFontSymbol';
+import { SvgCanvas } from '@coderline/alphatab/platform/svg/SvgCanvas';
/**
* This SVG canvas renders the music symbols by adding a CSS class 'at' to all elements.
@@ -43,22 +42,26 @@ export class CssFontSvgCanvas extends SvgCanvas {
symbols: string,
centerAtPosition?: boolean
): void {
- x *= this.scale;
- y *= this.scale;
-
- this.buffer += `${symbols}`;
+ this.buffer += '>' + symbols + '';
}
}
diff --git a/packages/alphatab/src/platform/svg/SvgCanvas.ts b/packages/alphatab/src/platform/svg/SvgCanvas.ts
index 7932791b9..7f28d723c 100644
--- a/packages/alphatab/src/platform/svg/SvgCanvas.ts
+++ b/packages/alphatab/src/platform/svg/SvgCanvas.ts
@@ -37,7 +37,7 @@ export abstract class SvgCanvas implements ICanvas {
}
public beginGroup(identifier: string): void {
- this.buffer += ``;
+ this.buffer += '';
}
public endGroup(): void {
@@ -51,9 +51,24 @@ export abstract class SvgCanvas implements ICanvas {
public fillRect(x: number, y: number, w: number, h: number): void {
if (w > 0) {
- this.buffer += `\n`;
+ // scale=1 fast path: skip the 4 multiplies and use `+` concat to avoid template-literal overhead.
+ const s = this.scale;
+ if (s === 1) {
+ this.buffer +=
+ '\n';
+ } else {
+ this.buffer += `\n`;
+ }
}
}
@@ -77,12 +92,22 @@ export abstract class SvgCanvas implements ICanvas {
}
public moveTo(x: number, y: number): void {
- this._currentPath += ` M${x * this.scale},${y * this.scale}`;
+ const s = this.scale;
+ if (s === 1) {
+ this._currentPath += ' M' + x + ',' + y;
+ } else {
+ this._currentPath += ` M${x * s},${y * s}`;
+ }
}
public lineTo(x: number, y: number): void {
this._currentPathIsEmpty = false;
- this._currentPath += ` L${x * this.scale},${y * this.scale}`;
+ const s = this.scale;
+ if (s === 1) {
+ this._currentPath += ' L' + x + ',' + y;
+ } else {
+ this._currentPath += ` L${x * s},${y * s}`;
+ }
}
public quadraticCurveTo(cpx: number, cpy: number, x: number, y: number): void {
@@ -92,9 +117,13 @@ export abstract class SvgCanvas implements ICanvas {
public bezierCurveTo(cp1X: number, cp1Y: number, cp2X: number, cp2Y: number, x: number, y: number): void {
this._currentPathIsEmpty = false;
- this._currentPath += ` C${cp1X * this.scale},${cp1Y * this.scale},${cp2X * this.scale},${cp2Y * this.scale},${
- x * this.scale
- },${y * this.scale}`;
+ const s = this.scale;
+ if (s === 1) {
+ this._currentPath +=
+ ' C' + cp1X + ',' + cp1Y + ',' + cp2X + ',' + cp2Y + ',' + x + ',' + y;
+ } else {
+ this._currentPath += ` C${cp1X * s},${cp1Y * s},${cp2X * s},${cp2Y * s},${x * s},${y * s}`;
+ }
}
public fillCircle(x: number, y: number, radius: number): void {
@@ -125,9 +154,9 @@ export abstract class SvgCanvas implements ICanvas {
public fill(): void {
if (!this._currentPathIsEmpty) {
- this.buffer += `';
}
@@ -137,7 +166,7 @@ export abstract class SvgCanvas implements ICanvas {
public stroke(): void {
if (!this._currentPathIsEmpty) {
- let s: string = `"']/;
+
private static _escapeText(text: string) {
+ // Short-circuit: most rendered text (bar numbers, fret numbers, etc.) has no escapable chars.
+ if (!SvgCanvas._escapeTextRegex.test(text)) {
+ return text;
+ }
return text
.replace(/&/g, '&')
.replace(/"/g, '"')
diff --git a/packages/alphatab/src/profiling/Profiler.ts b/packages/alphatab/src/profiling/Profiler.ts
new file mode 100644
index 000000000..d0113b500
--- /dev/null
+++ b/packages/alphatab/src/profiling/Profiler.ts
@@ -0,0 +1,77 @@
+/**
+ * Profiling instrumentation. Call sites are stripped from production /
+ * library / vitest / playground builds by `stripProfilingPlugin` and only
+ * retained in the bench harness.
+ */
+
+export interface StageStats {
+ count: number;
+ totalNs: number;
+ maxNs: number;
+}
+
+export interface ProfilerSnapshot {
+ stages: Record;
+ counters: Record;
+}
+
+const STACK_LIMIT = 64;
+
+export class Profiler {
+ private static readonly _stages = new Map();
+ private static readonly _counters = new Map();
+ private static readonly _stack: { name: string; startNs: number }[] = [];
+
+ static begin(name: string): void {
+ if (Profiler._stack.length >= STACK_LIMIT) {
+ throw new Error(`Profiler stack overflow on '${name}'`);
+ }
+ Profiler._stack.push({ name, startNs: nowNs() });
+ }
+
+ static end(name: string): void {
+ const top = Profiler._stack.pop();
+ if (!top || top.name !== name) {
+ throw new Error(
+ `Profiler.end('${name}') mismatched; expected '${top?.name ?? ''}'`
+ );
+ }
+ const elapsed = nowNs() - top.startNs;
+ const stats = Profiler._stages.get(name);
+ if (stats === undefined) {
+ Profiler._stages.set(name, { count: 1, totalNs: elapsed, maxNs: elapsed });
+ } else {
+ stats.count++;
+ stats.totalNs += elapsed;
+ if (elapsed > stats.maxNs) {
+ stats.maxNs = elapsed;
+ }
+ }
+ }
+
+ static bump(name: string, delta: number = 1): void {
+ Profiler._counters.set(name, (Profiler._counters.get(name) ?? 0) + delta);
+ }
+
+ static snapshot(): ProfilerSnapshot {
+ const stages: Record = {};
+ for (const [name, stats] of Profiler._stages) {
+ stages[name] = { count: stats.count, totalNs: stats.totalNs, maxNs: stats.maxNs };
+ }
+ const counters: Record = {};
+ for (const [name, value] of Profiler._counters) {
+ counters[name] = value;
+ }
+ return { stages, counters };
+ }
+
+ static reset(): void {
+ Profiler._stages.clear();
+ Profiler._counters.clear();
+ Profiler._stack.length = 0;
+ }
+}
+
+function nowNs(): number {
+ return Math.round(performance.now() * 1_000_000);
+}
diff --git a/packages/alphatab/src/rendering/BarRendererBase.ts b/packages/alphatab/src/rendering/BarRendererBase.ts
index 3dcb7987d..3d3a240ef 100644
--- a/packages/alphatab/src/rendering/BarRendererBase.ts
+++ b/packages/alphatab/src/rendering/BarRendererBase.ts
@@ -439,17 +439,28 @@ export class BarRendererBase {
return !this.bar || this.bar.index === this.scoreRenderer.layout!.lastBarIndex;
}
+ /**
+ * Gates the voice-container walk in {@link _registerLayoutingInfo}.
+ * Broker outputs from the walk are bar-local invariant; only the
+ * pre/post-beat `max` writes need to run each resize cycle (the broker
+ * zeroes `preBeatSize` at the head of every resize).
+ */
+ private _voiceWalkDone: boolean = false;
+
public _registerLayoutingInfo(): void {
const info: BarLayoutingInfo = this.layoutingInfo;
const preSize: number = this._preBeatGlyphs.width;
if (info.preBeatSize < preSize) {
info.preBeatSize = preSize;
}
- const container = this.voiceContainer;
- container.registerLayoutingInfo(info);
+ if (!this._voiceWalkDone) {
+ const container = this.voiceContainer;
+ container.registerLayoutingInfo(info);
- this.topEffects.registerLayoutingInfo(info);
- this.bottomEffects.registerLayoutingInfo(info);
+ this.topEffects.registerLayoutingInfo(info);
+ this.bottomEffects.registerLayoutingInfo(info);
+ this._voiceWalkDone = true;
+ }
const postSize: number = this._postBeatGlyphs.width;
if (info.postBeatSize < postSize) {
@@ -461,6 +472,9 @@ export class BarRendererBase {
this.staff = undefined;
this.registerMultiSystemSlurs(undefined);
this.isFinalized = false;
+ // `_layoutInvariantCached` and `_voiceWalkDone` deliberately survive:
+ // they cache bar-local invariant state and `afterReverted` fires every
+ // resize cycle — invalidating here would defeat the optimisation.
}
public afterStaffBarReverted() {
@@ -498,6 +512,18 @@ export class BarRendererBase {
public isFinalized: boolean = false;
+ /**
+ * Set once {@link doLayout} has populated the bar-local invariant state
+ * (`_preBeatGlyphs.width`, `_postBeatGlyphs.width`, broker per-beat sizes,
+ * local pre/post-beat skylines). Lets {@link reLayout} skip the bar-local
+ * re-walk on width-only changes.
+ */
+ private _layoutInvariantCached: boolean = false;
+
+ public invalidateLayoutCache(): void {
+ this._layoutInvariantCached = false;
+ }
+
public registerMultiSystemSlurs(startedTies: Generator | undefined) {
if (!startedTies) {
this._multiSystemSlurs = undefined;
@@ -638,6 +664,8 @@ export class BarRendererBase {
this.computedWidth = this.width;
this.calculateOverflows(0, this.height);
+
+ this._layoutInvariantCached = true;
}
protected calculateOverflows(_rendererTop: number, rendererBottom: number) {
@@ -882,11 +910,17 @@ export class BarRendererBase {
this.recreatePreBeatGlyphs();
}
+ // Must always re-register: the broker zeroes `preBeatSize` at the head of every resize cycle.
this._registerLayoutingInfo();
- this.calculateOverflows(0, this.height);
+ if (!this._layoutInvariantCached) {
+ this.calculateOverflows(0, this.height);
+ this._layoutInvariantCached = true;
+ }
}
protected recreatePreBeatGlyphs() {
+ // Pre-beat composition is changing — invalidate cached bar-local state.
+ this._layoutInvariantCached = false;
this._preBeatGlyphs = new LeftToRightLayoutingGlyphGroup();
this._preBeatGlyphs.renderer = this;
this.createPreBeatGlyphs();
diff --git a/packages/alphatab/src/rendering/ScoreRenderer.ts b/packages/alphatab/src/rendering/ScoreRenderer.ts
index 5c33e3e11..edd57b69d 100644
--- a/packages/alphatab/src/rendering/ScoreRenderer.ts
+++ b/packages/alphatab/src/rendering/ScoreRenderer.ts
@@ -1,20 +1,21 @@
-import { LayoutMode } from '@coderline/alphatab/LayoutMode';
import { Environment } from '@coderline/alphatab/Environment';
import {
EventEmitter,
+ EventEmitterOfT,
type IEventEmitter,
- type IEventEmitterOfT,
- EventEmitterOfT
+ type IEventEmitterOfT
} from '@coderline/alphatab/EventEmitter';
+import { LayoutMode } from '@coderline/alphatab/LayoutMode';
+import { Logger } from '@coderline/alphatab/Logger';
import type { Score } from '@coderline/alphatab/model/Score';
import type { Track } from '@coderline/alphatab/model/Track';
import type { ICanvas } from '@coderline/alphatab/platform/ICanvas';
+import { Profiler } from '@coderline/alphatab/profiling/Profiler';
import type { IScoreRenderer, RenderHints } from '@coderline/alphatab/rendering/IScoreRenderer';
import type { ScoreLayout } from '@coderline/alphatab/rendering/layout/ScoreLayout';
import { RenderFinishedEventArgs } from '@coderline/alphatab/rendering/RenderFinishedEventArgs';
import { BoundsLookup } from '@coderline/alphatab/rendering/utils/BoundsLookup';
import type { Settings } from '@coderline/alphatab/Settings';
-import { Logger } from '@coderline/alphatab/Logger';
/**
* This is the main wrapper of the rendering engine which
@@ -136,8 +137,10 @@ export class ScoreRenderer implements IScoreRenderer {
}
public render(renderHints?: RenderHints): void {
+ Profiler.begin('render.total');
if (this.width === 0) {
Logger.warning('Rendering', 'AlphaTab skipped rendering because of width=0 (element invisible)', null);
+ Profiler.end('render.total');
return;
}
// For partial renders we preserve the existing lookup so bars outside the re-layouted
@@ -170,9 +173,11 @@ export class ScoreRenderer implements IScoreRenderer {
this._layoutAndRender(renderHints);
Logger.debug('Rendering', 'Rendering finished');
}
+ Profiler.end('render.total');
}
public resizeRender(): void {
+ Profiler.begin('resize.total');
if (this._recreateLayout() || this._recreateCanvas() || this._renderedTracks !== this.tracks || !this.tracks) {
Logger.debug('Rendering', 'Starting full rerendering due to layout or canvas change', null);
this.render();
@@ -181,13 +186,16 @@ export class ScoreRenderer implements IScoreRenderer {
this.boundsLookup = new BoundsLookup();
(this.preRender as EventEmitterOfT).trigger(true);
this.canvas!.settings = this.settings;
+ Profiler.begin('resize.layoutResize');
this.layout!.resize();
+ Profiler.end('resize.layoutResize');
this._onRenderFinished();
(this.postRenderFinished as EventEmitter).trigger();
} else {
Logger.debug('Rendering', 'Current layout does not support dynamic resizing, nothing was done', null);
}
Logger.debug('Rendering', 'Resize finished');
+ Profiler.end('resize.total');
}
private _layoutAndRender(renderHints?: RenderHints): void {
@@ -196,7 +204,9 @@ export class ScoreRenderer implements IScoreRenderer {
`Rendering at scale ${this.settings.display.scale} with layout ${this.layout!.name}`,
null
);
+ Profiler.begin('render.layoutAndRender');
this.layout!.layoutAndRender(renderHints);
+ Profiler.end('render.layoutAndRender');
this._renderedTracks = this.tracks;
this._onRenderFinished();
(this.postRenderFinished as EventEmitter).trigger();
diff --git a/packages/alphatab/src/rendering/TabBarRenderer.ts b/packages/alphatab/src/rendering/TabBarRenderer.ts
index a08f30a37..e420ed510 100644
--- a/packages/alphatab/src/rendering/TabBarRenderer.ts
+++ b/packages/alphatab/src/rendering/TabBarRenderer.ts
@@ -39,6 +39,18 @@ export class TabBarRenderer extends LineBarRenderer {
private _showMultiBarRest: boolean = false;
+ /**
+ * Layout-time staff-line gap cache, bucketed by string-line index.
+ * `_gapBucketEnd[i]` is the exclusive end-offset into the parallel
+ * `_gapRelXAndWidth` / `_gapBeatRefs` arrays for line `i`. Stores the
+ * width-invariant payload; absolute x is projected at paint time via
+ * `beatGlyphsStart + bg.x + relativeX`.
+ */
+ private _gapBucketEnd: Uint32Array | null = null;
+ private _gapRelXAndWidth: Float32Array | null = null;
+ private _gapBeatRefs: BeatContainerGlyphBase[] | null = null;
+ private _gapCount: number = 0;
+
public override get showMultiBarRest(): boolean {
return this._showMultiBarRest;
}
@@ -86,13 +98,147 @@ export class TabBarRenderer extends LineBarRenderer {
public minString = Number.NaN;
public maxString = Number.NaN;
+ /**
+ * Legacy adapter projecting the gap cache into the base class
+ * `spaces[][]` shape. The real consumer is {@link paintStaffLines}
+ * which reads the cache directly.
+ */
protected override collectSpaces(spaces: Float32Array[][]): void {
if (this.additionalMultiRestBars) {
return;
}
+ if (this._gapBucketEnd === null) {
+ this._buildGapCache();
+ }
+ const count = this._gapCount;
+ if (count === 0) {
+ return;
+ }
+ const bucketEnd = this._gapBucketEnd!;
+ const relXAndWidth = this._gapRelXAndWidth!;
+ const beatRefs = this._gapBeatRefs!;
+ const base = this.beatGlyphsStart;
+ let line = 0;
+ for (let i = 0; i < count; i++) {
+ while (i >= bucketEnd[line]) {
+ line++;
+ }
+ const absX = base + beatRefs[i].x + relXAndWidth[i * 2];
+ spaces[line].push(new Float32Array([absX, relXAndWidth[i * 2 + 1]]));
+ }
+ }
- const padding: number = this.smuflMetrics.staffLineThickness;
+ protected override paintStaffLines(cx: number, cy: number, canvas: ICanvas): void {
+ using _ = ElementStyleHelper.bar(canvas, this.staffLineBarSubElement, this.bar, true);
+
+ const lineWidth = this.width;
+ const lineYOffset = this.smuflMetrics.staffLineThickness / 2;
+ const thickness = this.smuflMetrics.staffLineThickness;
+ const drawnLineCount = this.drawnLineCount;
+ const cxLocal = cx + this.x;
+ const cyLocal = cy + this.y;
+
+ if (this.additionalMultiRestBars) {
+ for (let line = 0; line < drawnLineCount; line++) {
+ const lineY = this.getLineY(line) - lineYOffset;
+ canvas.fillRect(cxLocal, cyLocal + lineY, lineWidth, thickness);
+ }
+ return;
+ }
+
+ if (this._gapBucketEnd === null) {
+ this._buildGapCache();
+ }
+
+ const count = this._gapCount;
+ if (count === 0) {
+ for (let line = 0; line < drawnLineCount; line++) {
+ const lineY = this.getLineY(line) - lineYOffset;
+ canvas.fillRect(cxLocal, cyLocal + lineY, lineWidth, thickness);
+ }
+ return;
+ }
+
+ const bucketEnd = this._gapBucketEnd!;
+ const relXAndWidth = this._gapRelXAndWidth!;
+ const beatRefs = this._gapBeatRefs!;
+ const base = this.beatGlyphsStart;
+
+ let cursor = 0;
+ for (let line = 0; line < drawnLineCount; line++) {
+ const lineY = this.getLineY(line) - lineYOffset;
+ const cyLine = cyLocal + lineY;
+ const end = bucketEnd[line];
+
+ // Gaps were built in beat-iteration order, which is left-to-right
+ // except for grace-note edge cases — sort only on inversion.
+ let lineX = 0;
+ if (end > cursor + 1) {
+ let inverted = false;
+ let prevX = base + beatRefs[cursor].x + relXAndWidth[cursor * 2];
+ for (let k = cursor + 1; k < end; k++) {
+ const xk = base + beatRefs[k].x + relXAndWidth[k * 2];
+ if (xk < prevX) {
+ inverted = true;
+ break;
+ }
+ prevX = xk;
+ }
+ if (inverted) {
+ const segCount = end - cursor;
+ const xs = new Float32Array(segCount);
+ const ws = new Float32Array(segCount);
+ for (let k = 0; k < segCount; k++) {
+ const ki = cursor + k;
+ xs[k] = base + beatRefs[ki].x + relXAndWidth[ki * 2];
+ ws[k] = relXAndWidth[ki * 2 + 1];
+ }
+ const order = new Uint32Array(segCount);
+ for (let k = 0; k < segCount; k++) {
+ order[k] = k;
+ }
+ for (let k = 1; k < segCount; k++) {
+ const cur = order[k];
+ const curX = xs[cur];
+ let m = k - 1;
+ while (m >= 0 && xs[order[m]] > curX) {
+ order[m + 1] = order[m];
+ m--;
+ }
+ order[m + 1] = cur;
+ }
+ for (let k = 0; k < segCount; k++) {
+ const oi = order[k];
+ const gx = xs[oi];
+ const gw = ws[oi];
+ canvas.fillRect(cxLocal + lineX, cyLine, gx - lineX, thickness);
+ lineX = gx + gw;
+ }
+ canvas.fillRect(cxLocal + lineX, cyLine, lineWidth - lineX, thickness);
+ cursor = end;
+ continue;
+ }
+ }
+
+ for (let k = cursor; k < end; k++) {
+ const gx = base + beatRefs[k].x + relXAndWidth[k * 2];
+ const gw = relXAndWidth[k * 2 + 1];
+ canvas.fillRect(cxLocal + lineX, cyLine, gx - lineX, thickness);
+ lineX = gx + gw;
+ }
+ canvas.fillRect(cxLocal + lineX, cyLine, lineWidth - lineX, thickness);
+ cursor = end;
+ }
+ }
+
+ private _buildGapCache(): void {
+ const drawnLineCount = this.drawnLineCount;
const tuning = this.bar.staff.tuning;
+ const tuningLen = tuning.length;
+
+ // First pass: count gaps per string-line for exact typed-array sizing.
+ const bucketCount = new Uint32Array(drawnLineCount);
+ let total = 0;
for (const voice of this.voiceContainer.beatGlyphs.values()) {
for (const bg of voice) {
const notes: TabBeatGlyph = (bg as TabBeatContainerGlyph).onNotes as TabBeatGlyph;
@@ -100,17 +246,81 @@ export class TabBarRenderer extends LineBarRenderer {
if (noteNumbers) {
for (const [str, noteNumber] of noteNumbers.notesPerString) {
if (!noteNumber.isEmpty) {
- spaces[tuning.length - str].push(
- new Float32Array([
- this.beatGlyphsStart + bg.x + notes.x + noteNumbers!.x - padding,
- noteNumbers!.width + padding * 2
- ])
- );
+ const lineIdx = tuningLen - str;
+ if (lineIdx >= 0 && lineIdx < drawnLineCount) {
+ bucketCount[lineIdx]++;
+ total++;
+ }
}
}
}
}
}
+
+ this._gapCount = total;
+ if (total === 0) {
+ this._gapBucketEnd = new Uint32Array(drawnLineCount);
+ this._gapRelXAndWidth = new Float32Array(0);
+ this._gapBeatRefs = [];
+ return;
+ }
+
+ // CSR-style prefix sum to exclusive end-offsets, with parallel write cursors.
+ const bucketEnd = new Uint32Array(drawnLineCount);
+ const fwdCursor = new Uint32Array(drawnLineCount);
+ let running = 0;
+ for (let line = 0; line < drawnLineCount; line++) {
+ fwdCursor[line] = running;
+ running += bucketCount[line];
+ bucketEnd[line] = running;
+ }
+
+ const relXAndWidth = new Float32Array(total * 2);
+ const beatRefs: BeatContainerGlyphBase[] = new Array(total);
+ const padding: number = this.smuflMetrics.staffLineThickness;
+
+ for (const voice of this.voiceContainer.beatGlyphs.values()) {
+ for (const bg of voice) {
+ const notes: TabBeatGlyph = (bg as TabBeatContainerGlyph).onNotes as TabBeatGlyph;
+ const noteNumbers: TabNoteChordGlyph | null = notes.noteNumbers;
+ if (noteNumbers) {
+ const relativeXBase = notes.x + noteNumbers.x - padding;
+ const fullWidth = noteNumbers.width + padding * 2;
+ for (const [str, noteNumber] of noteNumbers.notesPerString) {
+ if (!noteNumber.isEmpty) {
+ const lineIdx = tuningLen - str;
+ if (lineIdx >= 0 && lineIdx < drawnLineCount) {
+ const idx = fwdCursor[lineIdx]++;
+ relXAndWidth[idx * 2] = relativeXBase;
+ relXAndWidth[idx * 2 + 1] = fullWidth;
+ beatRefs[idx] = bg;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ this._gapBucketEnd = bucketEnd;
+ this._gapRelXAndWidth = relXAndWidth;
+ this._gapBeatRefs = beatRefs;
+ }
+
+ private _invalidateGapCache(): void {
+ this._gapBucketEnd = null;
+ this._gapRelXAndWidth = null;
+ this._gapBeatRefs = null;
+ this._gapCount = 0;
+ }
+
+ public override invalidateLayoutCache(): void {
+ super.invalidateLayoutCache();
+ this._invalidateGapCache();
+ }
+
+ protected override recreatePreBeatGlyphs(): void {
+ super.recreatePreBeatGlyphs();
+ this._invalidateGapCache();
}
public override doLayout(): void {
diff --git a/packages/alphatab/src/rendering/layout/ScoreLayout.ts b/packages/alphatab/src/rendering/layout/ScoreLayout.ts
index 3828d17f8..352b8eb29 100644
--- a/packages/alphatab/src/rendering/layout/ScoreLayout.ts
+++ b/packages/alphatab/src/rendering/layout/ScoreLayout.ts
@@ -9,6 +9,7 @@ import type { Staff } from '@coderline/alphatab/model/Staff';
import { type Track, TrackSubElement } from '@coderline/alphatab/model/Track';
import { NotationElement } from '@coderline/alphatab/NotationSettings';
import { type ICanvas, TextAlign, TextBaseline } from '@coderline/alphatab/platform/ICanvas';
+import { Profiler } from '@coderline/alphatab/profiling/Profiler';
import type { RenderingResources } from '@coderline/alphatab/RenderingResources';
import { BarRendererBase } from '@coderline/alphatab/rendering/BarRendererBase';
import { type EffectBandInfo, EffectBandMode } from '@coderline/alphatab/rendering/BarRendererFactory';
@@ -80,7 +81,9 @@ export abstract class ScoreLayout {
public resize(): void {
this._lazyPartials.clear();
this.slurRegistry.clear();
+ Profiler.begin('layout.doResize');
this.doResize();
+ Profiler.end('layout.doResize');
}
public abstract doResize(): void;
@@ -123,7 +126,9 @@ export abstract class ScoreLayout {
}
this._createScoreInfoGlyphs();
+ Profiler.begin('layout.doLayoutAndRender');
this.doLayoutAndRender(renderHints);
+ Profiler.end('layout.doLayoutAndRender');
}
private _lazyPartials: Map = new Map();
diff --git a/packages/alphatab/src/rendering/skyline/Skyline.ts b/packages/alphatab/src/rendering/skyline/Skyline.ts
index 8194de9b1..9739e3d16 100644
--- a/packages/alphatab/src/rendering/skyline/Skyline.ts
+++ b/packages/alphatab/src/rendering/skyline/Skyline.ts
@@ -205,6 +205,218 @@ export class Skyline {
}
}
+ /**
+ * Three-input variant of {@link unionShifted} fused into a single 4-way
+ * pair-merge pass: one result list and one segment rebuild instead of
+ * three.
+ */
+ public unionShifted3(o1: Skyline, dx1: number, o2: Skyline, dx2: number, o3: Skyline, dx3: number): void {
+ const a: SkylineSegment[] = o1._segments;
+ const b: SkylineSegment[] = o2._segments;
+ const c: SkylineSegment[] = o3._segments;
+ const aLast: number = a.length - 1;
+ const bLast: number = b.length - 1;
+ const cLast: number = c.length - 1;
+ const xMin: number = this.xMin;
+ const xMax: number = this.xMax;
+
+ // Treat streams that are out-of-range or have no positive height as absent.
+ let aActive: boolean = !(a[aLast].xStart + dx1 <= xMin || a[0].xStart + dx1 >= xMax);
+ if (aActive) {
+ let any: boolean = false;
+ for (let k: number = 0; k < aLast; k = k + 1) {
+ if (a[k].height > 0) {
+ any = true;
+ break;
+ }
+ }
+ aActive = any;
+ }
+ let bActive: boolean = !(b[bLast].xStart + dx2 <= xMin || b[0].xStart + dx2 >= xMax);
+ if (bActive) {
+ let any: boolean = false;
+ for (let k: number = 0; k < bLast; k = k + 1) {
+ if (b[k].height > 0) {
+ any = true;
+ break;
+ }
+ }
+ bActive = any;
+ }
+ let cActive: boolean = !(c[cLast].xStart + dx3 <= xMin || c[0].xStart + dx3 >= xMax);
+ if (cActive) {
+ let any: boolean = false;
+ for (let k: number = 0; k < cLast; k = k + 1) {
+ if (c[k].height > 0) {
+ any = true;
+ break;
+ }
+ }
+ cActive = any;
+ }
+ if (!aActive && !bActive && !cActive) {
+ return;
+ }
+
+ const t: SkylineSegment[] = this._segments;
+ const tLast: number = t.length - 1;
+
+ const newSegs: SkylineSegment[] = [];
+
+ let i: number = 0;
+ let ja: number = 0;
+ let jb: number = 0;
+ let jc: number = 0;
+
+ // Skip leading segments that end at or before xMin.
+ if (aActive) {
+ while (ja < aLast && a[ja + 1].xStart + dx1 <= xMin) {
+ ja = ja + 1;
+ }
+ }
+ if (bActive) {
+ while (jb < bLast && b[jb + 1].xStart + dx2 <= xMin) {
+ jb = jb + 1;
+ }
+ }
+ if (cActive) {
+ while (jc < cLast && c[jc + 1].xStart + dx3 <= xMin) {
+ jc = jc + 1;
+ }
+ }
+
+ let x: number = xMin;
+ while (x < xMax) {
+ const thisH: number = i < tLast ? t[i].height : 0;
+ let h: number = thisH;
+
+ if (aActive && ja < aLast) {
+ const oStart: number = a[ja].xStart + dx1;
+ if (x >= oStart) {
+ const ah: number = a[ja].height;
+ if (ah > h) {
+ h = ah;
+ }
+ }
+ }
+ if (bActive && jb < bLast) {
+ const oStart: number = b[jb].xStart + dx2;
+ if (x >= oStart) {
+ const bh: number = b[jb].height;
+ if (bh > h) {
+ h = bh;
+ }
+ }
+ }
+ if (cActive && jc < cLast) {
+ const oStart: number = c[jc].xStart + dx3;
+ if (x >= oStart) {
+ const ch: number = c[jc].height;
+ if (ch > h) {
+ h = ch;
+ }
+ }
+ }
+
+ // Find next breakpoint across all 4 streams.
+ let nextX: number = xMax;
+ if (i < tLast) {
+ const tNext: number = t[i + 1].xStart;
+ if (tNext < nextX) {
+ nextX = tNext;
+ }
+ }
+ if (aActive && ja < aLast) {
+ const oStart: number = a[ja].xStart + dx1;
+ if (x < oStart) {
+ if (oStart < nextX) {
+ nextX = oStart;
+ }
+ } else {
+ const oExit: number = a[ja + 1].xStart + dx1;
+ if (oExit < nextX) {
+ nextX = oExit;
+ }
+ }
+ }
+ if (bActive && jb < bLast) {
+ const oStart: number = b[jb].xStart + dx2;
+ if (x < oStart) {
+ if (oStart < nextX) {
+ nextX = oStart;
+ }
+ } else {
+ const oExit: number = b[jb + 1].xStart + dx2;
+ if (oExit < nextX) {
+ nextX = oExit;
+ }
+ }
+ }
+ if (cActive && jc < cLast) {
+ const oStart: number = c[jc].xStart + dx3;
+ if (x < oStart) {
+ if (oStart < nextX) {
+ nextX = oStart;
+ }
+ } else {
+ const oExit: number = c[jc + 1].xStart + dx3;
+ if (oExit < nextX) {
+ nextX = oExit;
+ }
+ }
+ }
+ if (nextX > xMax) {
+ nextX = xMax;
+ }
+ if (nextX <= x) {
+ nextX = xMax;
+ }
+
+ if (newSegs.length > 0 && newSegs[newSegs.length - 1].height === h) {
+ // coalesce equal-height neighbours
+ } else {
+ const ns: SkylineSegment = this._pool.acquire();
+ ns.xStart = x;
+ ns.height = h;
+ newSegs.push(ns);
+ }
+
+ x = nextX;
+
+ while (i < tLast && t[i + 1].xStart <= x) {
+ i = i + 1;
+ }
+ if (aActive) {
+ while (ja < aLast && a[ja + 1].xStart + dx1 <= x) {
+ ja = ja + 1;
+ }
+ }
+ if (bActive) {
+ while (jb < bLast && b[jb + 1].xStart + dx2 <= x) {
+ jb = jb + 1;
+ }
+ }
+ if (cActive) {
+ while (jc < cLast && c[jc + 1].xStart + dx3 <= x) {
+ jc = jc + 1;
+ }
+ }
+ }
+
+ const sentinel: SkylineSegment = this._pool.acquire();
+ sentinel.xStart = xMax;
+ sentinel.height = 0;
+ newSegs.push(sentinel);
+
+ while (this._segments.length > 0) {
+ const s: SkylineSegment = this._segments.pop()!;
+ this._pool.release(s);
+ }
+ for (let k: number = 0; k < newSegs.length; k = k + 1) {
+ this._segments.push(newSegs[k]);
+ }
+ }
+
public maxHeightInRange(xStart: number, xEnd: number): number {
return this._maxHeightInRange(xStart, xEnd);
}
diff --git a/packages/alphatab/src/rendering/skyline/SkylineSegmentPool.ts b/packages/alphatab/src/rendering/skyline/SkylineSegmentPool.ts
index db200b585..847ea05d3 100644
--- a/packages/alphatab/src/rendering/skyline/SkylineSegmentPool.ts
+++ b/packages/alphatab/src/rendering/skyline/SkylineSegmentPool.ts
@@ -1,9 +1,11 @@
+import { type IPoolable, ObjectPool } from '@coderline/alphatab/rendering/utils/ObjectPool';
+
/**
* One segment of a piecewise-constant skyline. segment[i] covers
* `[xStart, segments[i+1].xStart)`; final entry is a sentinel.
* @internal
*/
-export class SkylineSegment {
+export class SkylineSegment implements IPoolable {
public xStart: number = 0;
public height: number = 0;
@@ -14,27 +16,8 @@ export class SkylineSegment {
}
/** @internal */
-export class SkylineSegmentPool {
- private readonly _free: SkylineSegment[] = [];
- private _grown: number = 0;
-
- public acquire(): SkylineSegment {
- let s: SkylineSegment;
- if (this._free.length > 0) {
- s = this._free.pop()!;
- } else {
- s = new SkylineSegment();
- this._grown = this._grown + 1;
- }
- s.reset();
- return s;
- }
-
- public release(s: SkylineSegment): void {
- this._free.push(s);
- }
-
- public get grownCount(): number {
- return this._grown;
+export class SkylineSegmentPool extends ObjectPool {
+ public constructor() {
+ super(() => new SkylineSegment());
}
}
diff --git a/packages/alphatab/src/rendering/staves/RenderStaff.ts b/packages/alphatab/src/rendering/staves/RenderStaff.ts
index e4cb9ad11..9d4ba737d 100644
--- a/packages/alphatab/src/rendering/staves/RenderStaff.ts
+++ b/packages/alphatab/src/rendering/staves/RenderStaff.ts
@@ -1,6 +1,7 @@
import type { Bar } from '@coderline/alphatab/model/Bar';
import type { Staff } from '@coderline/alphatab/model/Staff';
import type { ICanvas } from '@coderline/alphatab/platform/ICanvas';
+import { Profiler } from '@coderline/alphatab/profiling/Profiler';
import type { BarRendererBase } from '@coderline/alphatab/rendering/BarRendererBase';
import {
type BarRendererFactory,
@@ -242,12 +243,8 @@ export class RenderStaff {
// group's final x (settled by scaleToWidth) before unioning.
const postBaseX = baseX + renderer.postBeatGroupOffset;
- sky.upSky.unionShifted(bar.upSky, baseX);
- sky.downSky.unionShifted(bar.downSky, baseX);
- sky.upSky.unionShifted(pre.upSky, baseX);
- sky.downSky.unionShifted(pre.downSky, baseX);
- sky.upSky.unionShifted(post.upSky, postBaseX);
- sky.downSky.unionShifted(post.downSky, postBaseX);
+ sky.upSky.unionShifted3(bar.upSky, baseX, pre.upSky, baseX, post.upSky, postBaseX);
+ sky.downSky.unionShifted3(bar.downSky, baseX, pre.downSky, baseX, post.downSky, postBaseX);
}
/**
@@ -274,6 +271,7 @@ export class RenderStaff {
}
public finalizeStaff(): void {
+ Profiler.begin('layout.finalizeStaff');
this._applyStaffPaddings();
this.height = 0;
@@ -311,6 +309,7 @@ export class RenderStaff {
this.height = Math.ceil(this.height);
this._updateVisibility();
+ Profiler.end('layout.finalizeStaff');
}
public paint(cx: number, cy: number, canvas: ICanvas, startIndex: number, count: number): void {
diff --git a/packages/alphatab/src/rendering/staves/StaffSystem.ts b/packages/alphatab/src/rendering/staves/StaffSystem.ts
index 27b77f1a2..02e7915a9 100644
--- a/packages/alphatab/src/rendering/staves/StaffSystem.ts
+++ b/packages/alphatab/src/rendering/staves/StaffSystem.ts
@@ -12,6 +12,7 @@ import { SimileMark } from '@coderline/alphatab/model/SimileMark';
import { type Track, TrackSubElement } from '@coderline/alphatab/model/Track';
import { NotationElement } from '@coderline/alphatab/NotationSettings';
import { CanvasHelper, type ICanvas, TextAlign, TextBaseline } from '@coderline/alphatab/platform/ICanvas';
+import { Profiler } from '@coderline/alphatab/profiling/Profiler';
import type { RenderingResources } from '@coderline/alphatab/RenderingResources';
import type { BarRendererBase } from '@coderline/alphatab/rendering/BarRendererBase';
import type { LineBarRenderer } from '@coderline/alphatab/rendering/LineBarRenderer';
@@ -1077,6 +1078,7 @@ export class StaffSystem {
}
public finalizeSystem(): void {
+ Profiler.begin('layout.finalizeSystem');
const settings = this.layout.renderer.settings;
if (this.index === 0) {
this.topPadding = settings.display.firstSystemPaddingTop;
@@ -1110,6 +1112,7 @@ export class StaffSystem {
for (const b of this._brackets!) {
b.finalizeBracket(settings.display.resources.engravingSettings);
}
+ Profiler.end('layout.finalizeSystem');
}
private _finalizeTrackGroups(onlyFirstGroup: boolean = false) {
diff --git a/packages/alphatab/src/rendering/utils/ObjectPool.ts b/packages/alphatab/src/rendering/utils/ObjectPool.ts
new file mode 100644
index 000000000..826091de5
--- /dev/null
+++ b/packages/alphatab/src/rendering/utils/ObjectPool.ts
@@ -0,0 +1,57 @@
+/**
+ * `reset()` is invoked at acquire-time (not release-time) so callers can skip
+ * zeroing slots that get immediately overwritten.
+ * @internal
+ */
+export interface IPoolable {
+ reset(): void;
+}
+
+/**
+ * Bump-allocator / arena-style object pool. Callers may either pair each
+ * {@link acquire} with a {@link release} or batch-acquire and call
+ * {@link releaseAll} at a lifecycle boundary.
+ * @internal
+ */
+export class ObjectPool {
+ private readonly _items: T[] = [];
+ private readonly _recycled: T[] = [];
+ private _cursor: number = 0;
+ private _grown: number = 0;
+ private readonly _factory: () => T;
+
+ public constructor(factory: () => T) {
+ this._factory = factory;
+ }
+
+ public acquire(): T {
+ let obj: T;
+ if (this._recycled.length > 0) {
+ obj = this._recycled.pop()!;
+ } else if (this._cursor < this._items.length) {
+ obj = this._items[this._cursor];
+ this._cursor++;
+ } else {
+ obj = this._factory();
+ this._items.push(obj);
+ this._cursor++;
+ this._grown++;
+ }
+ obj.reset();
+ return obj;
+ }
+
+ public release(obj: T): void {
+ this._recycled.push(obj);
+ }
+
+ public releaseAll(): void {
+ this._cursor = 0;
+ // splice(0) instead of `.length = 0` for transpiler compatibility.
+ this._recycled.splice(0);
+ }
+
+ public get grownCount(): number {
+ return this._grown;
+ }
+}
diff --git a/packages/alphatab/test-data/musicxml-samples/MozartPianoSonata.png b/packages/alphatab/test-data/musicxml-samples/MozartPianoSonata.png
index 857c82649..771749a71 100644
Binary files a/packages/alphatab/test-data/musicxml-samples/MozartPianoSonata.png and b/packages/alphatab/test-data/musicxml-samples/MozartPianoSonata.png differ
diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/full-default-large.png b/packages/alphatab/test-data/visual-tests/notation-legend/full-default-large.png
index 3b3fb3292..cfa25373f 100644
Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/full-default-large.png and b/packages/alphatab/test-data/visual-tests/notation-legend/full-default-large.png differ
diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/full-default-small.png b/packages/alphatab/test-data/visual-tests/notation-legend/full-default-small.png
index 3b170aaac..c362760f9 100644
Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/full-default-small.png and b/packages/alphatab/test-data/visual-tests/notation-legend/full-default-small.png differ
diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/resize-sequence-1300.png b/packages/alphatab/test-data/visual-tests/notation-legend/resize-sequence-1300.png
index 582f6f827..748da29c3 100644
Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/resize-sequence-1300.png and b/packages/alphatab/test-data/visual-tests/notation-legend/resize-sequence-1300.png differ
diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/resize-sequence-1500.png b/packages/alphatab/test-data/visual-tests/notation-legend/resize-sequence-1500.png
index dd4f3ba24..6f51a5ed1 100644
Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/resize-sequence-1500.png and b/packages/alphatab/test-data/visual-tests/notation-legend/resize-sequence-1500.png differ
diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/resize-sequence-500.png b/packages/alphatab/test-data/visual-tests/notation-legend/resize-sequence-500.png
index 2b4ad82d6..017debc56 100644
Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/resize-sequence-500.png and b/packages/alphatab/test-data/visual-tests/notation-legend/resize-sequence-500.png differ
diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/resize-sequence-800.png b/packages/alphatab/test-data/visual-tests/notation-legend/resize-sequence-800.png
index c79490957..afe3deaf6 100644
Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/resize-sequence-800.png and b/packages/alphatab/test-data/visual-tests/notation-legend/resize-sequence-800.png differ
diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/smufl-petaluma-1300.png b/packages/alphatab/test-data/visual-tests/notation-legend/smufl-petaluma-1300.png
index bf6fba6ee..9e7dc04e8 100644
Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/smufl-petaluma-1300.png and b/packages/alphatab/test-data/visual-tests/notation-legend/smufl-petaluma-1300.png differ
diff --git a/packages/alphatab/test-data/visual-tests/special-notes/grace-notes-alignment.png b/packages/alphatab/test-data/visual-tests/special-notes/grace-notes-alignment.png
index 2f0025676..07b371092 100644
Binary files a/packages/alphatab/test-data/visual-tests/special-notes/grace-notes-alignment.png and b/packages/alphatab/test-data/visual-tests/special-notes/grace-notes-alignment.png differ
diff --git a/packages/playground/vite.config.ts b/packages/playground/vite.config.ts
index 11a0ecd5a..aa3281017 100644
--- a/packages/playground/vite.config.ts
+++ b/packages/playground/vite.config.ts
@@ -1,11 +1,12 @@
import { defineConfig, type UserConfig } from 'vite';
import { buildTsconfigAliases } from '../tooling/src/vite';
+import { stripProfilingPlugin } from '../tooling/src/vite.plugin.strip-profiling';
import { elementStyleUsingPlugin } from '../tooling/src/vite.plugin.transform';
import server from './vite.plugin.server';
export default defineConfig(_ => {
const config: UserConfig = {
- plugins: [server(), elementStyleUsingPlugin()],
+ plugins: [server(), elementStyleUsingPlugin(), stripProfilingPlugin({ enabled: false })],
resolve: {
tsconfigPaths: true,
alias: buildTsconfigAliases(process.cwd())
diff --git a/packages/tooling/src/vite.plugin.strip-profiling.ts b/packages/tooling/src/vite.plugin.strip-profiling.ts
new file mode 100644
index 000000000..5c64e8ea2
--- /dev/null
+++ b/packages/tooling/src/vite.plugin.strip-profiling.ts
@@ -0,0 +1,57 @@
+import MagicString from 'magic-string';
+import type { Plugin } from 'vite';
+
+/**
+ * Strips `Profiler.(...)` statements and the `Profiler` import from
+ * alphatab source at transform time when `enabled: false`. With the call
+ * sites gone, tree-shaking drops the {@link Profiler} module from the
+ * bundle. The Profiler module itself is exempt. With `enabled: true` the
+ * plugin is a passthrough.
+ */
+export function stripProfilingPlugin(options: { enabled: boolean }): Plugin {
+ const importLine = /^[\t ]*import\s*\{\s*Profiler\s*\}\s*from\s*['"][^'"]*profiling\/Profiler['"]\s*;?[\t ]*\r?\n/m;
+ const callStatement = /^[\t ]*Profiler\.[A-Za-z_$][\w$]*\s*\([^()\n]*\)\s*;?[\t ]*\r?\n/gm;
+
+ return {
+ name: 'alphatab:strip-profiling',
+ enforce: 'pre',
+ transform(code, id) {
+ if (options.enabled) {
+ return null;
+ }
+ if (!code.includes('Profiler')) {
+ return null;
+ }
+ // Skip the Profiler module itself.
+ if (/[\\/]profiling[\\/]Profiler\.ts$/.test(id)) {
+ return null;
+ }
+
+ const ms = new MagicString(code);
+ let changed = false;
+
+ const importMatch = importLine.exec(code);
+ if (importMatch && importMatch.index !== undefined) {
+ ms.remove(importMatch.index, importMatch.index + importMatch[0].length);
+ changed = true;
+ }
+
+ for (const m of code.matchAll(callStatement)) {
+ if (m.index === undefined) {
+ continue;
+ }
+ ms.remove(m.index, m.index + m[0].length);
+ changed = true;
+ }
+
+ if (!changed) {
+ return null;
+ }
+
+ return {
+ code: ms.toString(),
+ map: ms.generateMap({ hires: 'boundary' })
+ };
+ }
+ };
+}
diff --git a/packages/tooling/src/vite.plugin.transform.ts b/packages/tooling/src/vite.plugin.transform.ts
index a7fcb3a83..99fa8d11c 100644
--- a/packages/tooling/src/vite.plugin.transform.ts
+++ b/packages/tooling/src/vite.plugin.transform.ts
@@ -101,8 +101,45 @@ function isElementStyleHelperUsing(
if (stmt.kind !== 'using' && stmt.kind !== 'await using') { return false; }
if (stmt.declarations.length !== 1) { return false; }
const init = stmt.declarations[0].init;
- if (!init || init.type !== 'CallExpression') { return false; }
- const callee = (init as { callee: unknown }).callee as MemberExpression | { type: string };
+ if (!init) { return false; }
+ // Accept a direct `ElementStyleHelper.X(...)` call or a ternary
+ // whose leaves are each a call or undefined/void 0.
+ return isElementStyleHelperInit(init as unknown as { type: string }) && hasElementStyleHelperCall(init as unknown as { type: string });
+}
+
+function isElementStyleHelperInit(expr: { type: string } | null | undefined): boolean {
+ if (!expr) { return false; }
+ if (expr.type === 'CallExpression') {
+ return isElementStyleHelperCall(expr);
+ }
+ if (expr.type === 'Identifier') {
+ return (expr as IdentifierName).name === 'undefined';
+ }
+ if (expr.type === 'UnaryExpression') {
+ // `void 0`, `void undefined`, etc.
+ return (expr as unknown as { operator: string }).operator === 'void';
+ }
+ if (expr.type === 'ConditionalExpression') {
+ const c = expr as unknown as { consequent: { type: string }; alternate: { type: string } };
+ return isElementStyleHelperInit(c.consequent) && isElementStyleHelperInit(c.alternate);
+ }
+ return false;
+}
+
+function hasElementStyleHelperCall(expr: { type: string } | null | undefined): boolean {
+ if (!expr) { return false; }
+ if (expr.type === 'CallExpression') {
+ return isElementStyleHelperCall(expr);
+ }
+ if (expr.type === 'ConditionalExpression') {
+ const c = expr as unknown as { consequent: { type: string }; alternate: { type: string } };
+ return hasElementStyleHelperCall(c.consequent) || hasElementStyleHelperCall(c.alternate);
+ }
+ return false;
+}
+
+function isElementStyleHelperCall(expr: { type: string }): boolean {
+ const callee = (expr as unknown as { callee: unknown }).callee as MemberExpression | { type: string } | undefined;
if (!callee || callee.type !== 'MemberExpression') { return false; }
const object = (callee as MemberExpression).object;
return object.type === 'Identifier' && (object as IdentifierName).name === 'ElementStyleHelper';
diff --git a/packages/tooling/src/vite.ts b/packages/tooling/src/vite.ts
index 78d1f51fb..b08537016 100644
--- a/packages/tooling/src/vite.ts
+++ b/packages/tooling/src/vite.ts
@@ -12,6 +12,7 @@ import { createApiDtsFiles } from './typescript';
import generateDts from './vite.plugin.dts';
import { emitDtsPlugin } from './vite.plugin.emit-dts';
import min from './vite.plugin.min';
+import { stripProfilingPlugin } from './vite.plugin.strip-profiling';
import { elementStyleUsingPlugin } from './vite.plugin.transform';
const terserOptions: MinifyOptions = {
@@ -122,7 +123,7 @@ export function buildTsconfigAliases(projectDir: string): Array<{ find: RegExp;
export function defaultBuildUserConfig(projectDir: string = process.cwd()): UserConfig {
return {
- plugins: [licenseHeaderPlugin(), elementStyleUsingPlugin()],
+ plugins: [licenseHeaderPlugin(), elementStyleUsingPlugin(), stripProfilingPlugin({ enabled: false })],
resolve: {
tsconfigPaths: true,
alias: buildTsconfigAliases(projectDir)
diff --git a/packages/tooling/src/vitest.ts b/packages/tooling/src/vitest.ts
index 43b438490..9a9886081 100644
--- a/packages/tooling/src/vitest.ts
+++ b/packages/tooling/src/vitest.ts
@@ -2,6 +2,7 @@ import { appendFileSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { defineConfig } from 'vitest/config';
import { buildTsconfigAliases } from './vite';
+import { stripProfilingPlugin } from './vite.plugin.strip-profiling';
class SummaryLabelReporter {
constructor(private readonly label: string) {}
@@ -25,6 +26,7 @@ export function defineVitestConfig(options: VitestPackageOptions = {}) {
? ['default', 'github-actions', new SummaryLabelReporter(pkg.name)]
: ['default'];
return defineConfig({
+ plugins: [stripProfilingPlugin({ enabled: false })],
resolve: {
tsconfigPaths: true,
alias: buildTsconfigAliases(process.cwd())