diff --git a/packages/core/src/text-buffer-view.test.ts b/packages/core/src/text-buffer-view.test.ts
index 9b42b28a5..0e4de9d2d 100644
--- a/packages/core/src/text-buffer-view.test.ts
+++ b/packages/core/src/text-buffer-view.test.ts
@@ -637,5 +637,69 @@ describe("TextBufferView", () => {
expect(result!.lineCount).toBe(4)
expect(result!.maxWidth).toBe(10)
})
+
+ it("should cache measure results for same width", () => {
+ const styledText = stringToStyledText("ABCDEFGHIJKLMNOPQRST")
+ buffer.setStyledText(styledText)
+
+ view.setWrapMode("char")
+
+ // First call - cache miss
+ const result1 = view.measureForDimensions(10, 10)
+ expect(result1).not.toBeNull()
+ expect(result1!.lineCount).toBe(2)
+
+ // Second call with same width - should return cached result
+ const result2 = view.measureForDimensions(10, 10)
+ expect(result2).not.toBeNull()
+ expect(result2!.lineCount).toBe(2)
+ expect(result2!.maxWidth).toBe(result1!.maxWidth)
+ })
+
+ it("should invalidate cache when content changes", () => {
+ const styledText1 = stringToStyledText("ABCDEFGHIJ")
+ buffer.setStyledText(styledText1)
+
+ view.setWrapMode("char")
+
+ // Measure with width 5 - should be 2 lines
+ const result1 = view.measureForDimensions(5, 10)
+ expect(result1!.lineCount).toBe(2)
+
+ // Change content to be longer
+ const styledText2 = stringToStyledText("ABCDEFGHIJKLMNOPQRST")
+ buffer.setStyledText(styledText2)
+
+ // Same width should now return different result
+ const result2 = view.measureForDimensions(5, 10)
+ expect(result2!.lineCount).toBe(4)
+ })
+
+ it("should invalidate cache when wrap mode changes", () => {
+ const styledText = stringToStyledText("Hello world test string here")
+ buffer.setStyledText(styledText)
+
+ view.setWrapMode("word")
+ const resultWord = view.measureForDimensions(10, 10)
+
+ view.setWrapMode("char")
+ const resultChar = view.measureForDimensions(10, 10)
+
+ // Word and char wrap should produce different results
+ expect(resultWord!.lineCount).not.toBe(resultChar!.lineCount)
+ })
+
+ it("should handle width 0 for intrinsic measurement", () => {
+ const styledText = stringToStyledText("Hello World")
+ buffer.setStyledText(styledText)
+
+ view.setWrapMode("word")
+
+ // Width 0 means get intrinsic width (no wrapping)
+ const result = view.measureForDimensions(0, 10)
+ expect(result).not.toBeNull()
+ expect(result!.lineCount).toBe(1)
+ expect(result!.maxWidth).toBe(11) // "Hello World" = 11 chars
+ })
})
})
diff --git a/packages/core/src/zig/tests/text-buffer-view_test.zig b/packages/core/src/zig/tests/text-buffer-view_test.zig
index a8b155c42..eda874172 100644
--- a/packages/core/src/zig/tests/text-buffer-view_test.zig
+++ b/packages/core/src/zig/tests/text-buffer-view_test.zig
@@ -1,5 +1,6 @@
const std = @import("std");
const text_buffer = @import("../text-buffer.zig");
+const iter_mod = @import("../text-buffer-iterators.zig");
const text_buffer_view = @import("../text-buffer-view.zig");
const gp = @import("../grapheme.zig");
@@ -1915,6 +1916,107 @@ test "TextBufferView measureForDimensions - does not modify cache" {
try std.testing.expectEqual(@as(u32, 1), actual_count);
}
+test "TextBufferView measureForDimensions - cache invalidates after updateVirtualLines" {
+ const pool = gp.initGlobalPool(std.testing.allocator);
+ defer gp.deinitGlobalPool();
+
+ var tb = try TextBuffer.init(std.testing.allocator, pool, .wcwidth);
+ defer tb.deinit();
+
+ var view = try TextBufferView.init(std.testing.allocator, tb);
+ defer view.deinit();
+
+ try tb.setText("AAAAA");
+ view.setWrapMode(.char);
+ view.setWrapWidth(5);
+
+ const result1 = try view.measureForDimensions(5, 10);
+ try std.testing.expectEqual(@as(u32, 1), result1.line_count);
+ try std.testing.expectEqual(@as(u32, 5), result1.max_width);
+
+ try tb.setText("AAAAAAAAAA");
+
+ // This clears the dirty flag, which would cause a false cache hit
+ // if we keyed on dirty instead of epoch.
+ _ = view.getVirtualLineCount();
+
+ const result2 = try view.measureForDimensions(5, 10);
+ try std.testing.expectEqual(@as(u32, 2), result2.line_count);
+ try std.testing.expectEqual(@as(u32, 5), result2.max_width);
+}
+
+test "TextBufferView measureForDimensions - width 0 uses intrinsic line widths" {
+ const pool = gp.initGlobalPool(std.testing.allocator);
+ defer gp.deinitGlobalPool();
+
+ var tb = try TextBuffer.init(std.testing.allocator, pool, .wcwidth);
+ defer tb.deinit();
+
+ var view = try TextBufferView.init(std.testing.allocator, tb);
+ defer view.deinit();
+
+ try tb.setText("abc\ndefghij");
+ view.setWrapMode(.char);
+
+ const result = try view.measureForDimensions(0, 24);
+ try std.testing.expectEqual(tb.getLineCount(), result.line_count);
+ try std.testing.expectEqual(iter_mod.getMaxLineWidth(&tb.rope), result.max_width);
+}
+
+test "TextBufferView measureForDimensions - no wrap matches multi-segment line widths" {
+ const pool = gp.initGlobalPool(std.testing.allocator);
+ defer gp.deinitGlobalPool();
+
+ var tb = try TextBuffer.init(std.testing.allocator, pool, .wcwidth);
+ defer tb.deinit();
+
+ var view = try TextBufferView.init(std.testing.allocator, tb);
+ defer view.deinit();
+
+ try tb.setText("AAAA");
+ try tb.append("BBBB");
+ view.setWrapMode(.none);
+
+ const line_info = view.getCachedLineInfo();
+ var expected_max: u32 = 0;
+ for (line_info.widths) |w| {
+ expected_max = @max(expected_max, w);
+ }
+
+ const result = try view.measureForDimensions(80, 24);
+ try std.testing.expectEqual(expected_max, result.max_width);
+ try std.testing.expectEqual(@as(u32, @intCast(line_info.widths.len)), result.line_count);
+}
+
+test "TextBufferView measureForDimensions - cache invalidates on switchToBuffer" {
+ const pool = gp.initGlobalPool(std.testing.allocator);
+ defer gp.deinitGlobalPool();
+
+ var tb = try TextBuffer.init(std.testing.allocator, pool, .wcwidth);
+ defer tb.deinit();
+
+ var other_tb = try TextBuffer.init(std.testing.allocator, pool, .wcwidth);
+ defer other_tb.deinit();
+
+ var view = try TextBufferView.init(std.testing.allocator, tb);
+ defer view.deinit();
+
+ try tb.setText("AAAAAA");
+ view.setWrapMode(.char);
+
+ const result1 = try view.measureForDimensions(10, 10);
+ try std.testing.expectEqual(@as(u32, 6), result1.max_width);
+
+ try other_tb.setText("BBBBBBBBBB");
+ try std.testing.expectEqual(tb.getContentEpoch(), other_tb.getContentEpoch());
+
+ view.switchToBuffer(other_tb);
+
+ const result2 = try view.measureForDimensions(10, 10);
+ try std.testing.expectEqual(@as(u32, 10), result2.max_width);
+ try std.testing.expectEqual(@as(u32, 1), result2.line_count);
+}
+
test "TextBufferView measureForDimensions - char wrap" {
const pool = gp.initGlobalPool(std.testing.allocator);
defer gp.deinitGlobalPool();
diff --git a/packages/core/src/zig/tests/wrap-cache-perf_test.zig b/packages/core/src/zig/tests/wrap-cache-perf_test.zig
index 08fe8e224..0e0993f0e 100644
--- a/packages/core/src/zig/tests/wrap-cache-perf_test.zig
+++ b/packages/core/src/zig/tests/wrap-cache-perf_test.zig
@@ -49,7 +49,7 @@ test "word wrap complexity - width changes are O(n)" {
_ = view.getVirtualLineCount();
for (widths, 0..) |width, i| {
- times[i] = measureMedianViewUpdate(view, width, 3);
+ times[i] = measureMedianViewUpdate(view, width, 5);
}
var min_time: u64 = std.math.maxInt(u64);
@@ -61,8 +61,10 @@ test "word wrap complexity - width changes are O(n)" {
const ratio = @as(f64, @floatFromInt(max_time)) / @as(f64, @floatFromInt(min_time));
- // All times should be roughly similar (within 3x) since text size is constant
- try std.testing.expect(ratio < 3.0);
+ // All times should be roughly similar since text size is constant.
+ // Using 5x threshold to accommodate CI runner variability while still
+ // catching O(n^2) regressions (which would show 10-100x differences).
+ try std.testing.expect(ratio < 5.0);
}
test "word wrap - virtual line count correctness" {
diff --git a/packages/core/src/zig/text-buffer-view.zig b/packages/core/src/zig/text-buffer-view.zig
index cf8ff0b43..6f20f5bf8 100644
--- a/packages/core/src/zig/text-buffer-view.zig
+++ b/packages/core/src/zig/text-buffer-view.zig
@@ -124,9 +124,22 @@ pub const UnifiedTextBufferView = struct {
cached_line_vline_counts: std.ArrayListUnmanaged(u32),
global_allocator: Allocator,
virtual_lines_arena: *std.heap.ArenaAllocator,
+
+ /// Persistent arena for measureForDimensions. Each call resets it with
+ /// retain_capacity to avoid mmap/munmap churn during streaming.
+ measure_arena: std.heap.ArenaAllocator,
tab_indicator: ?u32,
tab_indicator_color: ?RGBA,
+ // Measurement cache for Yoga layout. Keyed by (buffer, epoch, width, wrap_mode).
+ // Using epoch instead of dirty flag prevents stale returns when unrelated
+ // code paths clear dirty (e.g., updateVirtualLines).
+ cached_measure_width: ?u32,
+ cached_measure_wrap_mode: WrapMode,
+ cached_measure_result: ?MeasureResult,
+ cached_measure_epoch: u64,
+ cached_measure_buffer: ?*UnifiedTextBuffer,
+
pub fn init(global_allocator: Allocator, text_buffer: *UnifiedTextBuffer) TextBufferViewError!*Self {
const self = global_allocator.create(Self) catch return TextBufferViewError.OutOfMemory;
errdefer global_allocator.destroy(self);
@@ -156,8 +169,14 @@ pub const UnifiedTextBufferView = struct {
.cached_line_vline_counts = .{},
.global_allocator = global_allocator,
.virtual_lines_arena = virtual_lines_internal_arena,
+ .measure_arena = std.heap.ArenaAllocator.init(global_allocator),
.tab_indicator = null,
.tab_indicator_color = null,
+ .cached_measure_width = null,
+ .cached_measure_wrap_mode = .none,
+ .cached_measure_result = null,
+ .cached_measure_epoch = 0,
+ .cached_measure_buffer = null,
};
return self;
@@ -170,6 +189,7 @@ pub const UnifiedTextBufferView = struct {
self.original_text_buffer.unregisterView(self.view_id);
self.virtual_lines_arena.deinit();
self.global_allocator.destroy(self.virtual_lines_arena);
+ self.measure_arena.deinit();
self.global_allocator.destroy(self);
}
@@ -690,14 +710,47 @@ pub const UnifiedTextBufferView = struct {
/// Measure dimensions for given width/height WITHOUT modifying virtual lines cache
/// This is useful for Yoga measure functions that need to know dimensions without committing changes
- /// Special case: width=0 means "measure intrinsic/max-content width" (no wrapping)
- pub fn measureForDimensions(self: *const Self, width: u32, height: u32) TextBufferViewError!MeasureResult {
+ /// Special case: width=0 or wrap_mode=.none means "measure intrinsic/max-content width" (no wrapping)
+ pub fn measureForDimensions(self: *Self, width: u32, height: u32) TextBufferViewError!MeasureResult {
_ = height; // Height is for future use, currently only width affects layout
- // Create temporary arena for measurement
- var measure_arena = std.heap.ArenaAllocator.init(self.global_allocator);
- defer measure_arena.deinit();
- const measure_allocator = measure_arena.allocator();
+ const epoch = self.text_buffer.getContentEpoch();
+ if (self.cached_measure_result) |result| {
+ if (self.cached_measure_epoch == epoch and self.cached_measure_buffer == self.text_buffer) {
+ if (self.cached_measure_width) |cached_width| {
+ if (cached_width == width and self.cached_measure_wrap_mode == self.wrap_mode) {
+ return result;
+ }
+ }
+ }
+ }
+
+ // No-wrap path avoids allocations by using marker-based line widths.
+ if (width == 0 or self.wrap_mode == .none) {
+ const line_count = self.text_buffer.getLineCount();
+ var max_width: u32 = 0;
+ var row: u32 = 0;
+ while (row < line_count) : (row += 1) {
+ max_width = @max(max_width, iter_mod.lineWidthAt(&self.text_buffer.rope, row));
+ }
+
+ const result = MeasureResult{
+ .line_count = line_count,
+ .max_width = max_width,
+ };
+
+ self.cached_measure_width = width;
+ self.cached_measure_wrap_mode = self.wrap_mode;
+ self.cached_measure_result = result;
+ self.cached_measure_epoch = epoch;
+ self.cached_measure_buffer = self.text_buffer;
+
+ return result;
+ }
+
+ // Reuse arena capacity to avoid allocation overhead during streaming.
+ _ = self.measure_arena.reset(.retain_capacity);
+ const measure_allocator = self.measure_arena.allocator();
// Create temporary output structures
var temp_virtual_lines = std.ArrayListUnmanaged(VirtualLine){};
@@ -719,7 +772,6 @@ pub const UnifiedTextBufferView = struct {
};
// Use width for wrap calculation
- // Special case: width=0 means get intrinsic width (no wrapping), so pass null
const wrap_width_for_measure = if (self.wrap_mode != .none and width > 0) width else null;
// Call generic calculation with temporary structures
@@ -737,10 +789,18 @@ pub const UnifiedTextBufferView = struct {
max_width = @max(max_width, w);
}
- return MeasureResult{
+ const result = MeasureResult{
.line_count = @intCast(temp_virtual_lines.items.len),
.max_width = max_width,
};
+
+ self.cached_measure_width = width;
+ self.cached_measure_wrap_mode = self.wrap_mode;
+ self.cached_measure_result = result;
+ self.cached_measure_epoch = epoch;
+ self.cached_measure_buffer = self.text_buffer;
+
+ return result;
}
/// Generic virtual line calculation that writes to provided output structures
diff --git a/packages/core/src/zig/text-buffer.zig b/packages/core/src/zig/text-buffer.zig
index 98b409dc4..7dd231a44 100644
--- a/packages/core/src/zig/text-buffer.zig
+++ b/packages/core/src/zig/text-buffer.zig
@@ -62,6 +62,10 @@ pub const UnifiedTextBuffer = struct {
next_view_id: u32,
free_view_ids: std.ArrayListUnmanaged(u32),
+ /// Monotonic counter that increments on every content change. Views use this
+ /// to detect stale caches even after clearViewDirty() runs.
+ content_epoch: u64,
+
// Per-line highlight cache (invalidated on edits)
// Maps line_idx to highlights for that line
line_highlights: std.ArrayListUnmanaged(std.ArrayListUnmanaged(Highlight)),
@@ -118,6 +122,7 @@ pub const UnifiedTextBuffer = struct {
.view_dirty_flags = view_dirty_flags,
.next_view_id = 0,
.free_view_ids = free_view_ids,
+ .content_epoch = 0,
.line_highlights = .{},
.line_spans = .{},
.highlight_batch_depth = 0,
@@ -198,7 +203,16 @@ pub const UnifiedTextBuffer = struct {
}
}
+ /// Returns the current content epoch. Use this to detect buffer changes
+ /// independent of the dirty flag (other code paths may clear dirty).
+ pub fn getContentEpoch(self: *const Self) u64 {
+ return self.content_epoch;
+ }
+
fn markAllViewsDirty(self: *Self) void {
+ // Increment epoch first so views see the new value when checking caches.
+ // Use wrapping add for safety, though u64 won't overflow in practice.
+ self.content_epoch +%= 1;
for (self.view_dirty_flags.items) |*flag| {
flag.* = true;
}
@@ -972,10 +986,15 @@ pub const UnifiedTextBuffer = struct {
return self.tab_width;
}
- /// Set tab width (will be rounded up to nearest multiple of 2)
+ /// Set tab width, rounding up to nearest multiple of 2 (minimum 2).
+ /// Marks all views dirty if the width actually changes, since tab width
+ /// affects measured line widths and virtual line calculations.
pub fn setTabWidth(self: *Self, width: u8) void {
const clamped_width = @max(2, width);
- self.tab_width = if (clamped_width % 2 == 0) clamped_width else clamped_width + 1;
+ const new_width = if (clamped_width % 2 == 0) clamped_width else clamped_width + 1;
+ if (self.tab_width == new_width) return;
+ self.tab_width = new_width;
+ self.markAllViewsDirty();
}
/// Debug log the rope structure using rope.toText
diff --git a/packages/solid/tests/__snapshots__/textarea.test.tsx.snap b/packages/solid/tests/__snapshots__/textarea.test.tsx.snap
index faf26535d..d3f4b376b 100644
--- a/packages/solid/tests/__snapshots__/textarea.test.tsx.snap
+++ b/packages/solid/tests/__snapshots__/textarea.test.tsx.snap
@@ -519,5 +519,175 @@ start
+"
+`;
+
+exports[`Textarea Layout Tests Measure Cache Edge Cases should correctly measure text after content change 1`] = `
+"┌──────────────────────────────────────┐
+│Short text │
+└──────────────────────────────────────┘
+
+
+
+
+
+
+
+
+
+
+
+
+"
+`;
+
+exports[`Textarea Layout Tests Measure Cache Edge Cases should correctly measure text after content change 2`] = `
+"┌──────────────────────────────────────┐
+│This is a much longer text that will │
+│definitely wrap to multiple lines │
+│when rendered │
+└──────────────────────────────────────┘
+
+
+
+
+
+
+
+
+
+
+"
+`;
+
+exports[`Textarea Layout Tests Measure Cache Edge Cases should handle rapid content updates correctly 1`] = `
+"┌────────────────────────────┐
+│Update 4: some text here │
+└────────────────────────────┘
+
+
+
+
+
+
+
+"
+`;
+
+exports[`Textarea Layout Tests Measure Cache Edge Cases should handle width changes with cached measures 1`] = `
+"┌────────────────────────────┐
+│Content that will wrap │
+│differently at different │
+│widths │
+└────────────────────────────┘
+
+
+
+
+
+
+
+
+
+
+"
+`;
+
+exports[`Textarea Layout Tests Measure Cache Edge Cases should handle width changes with cached measures 2`] = `
+"┌────────────────────────────────────────────────┐
+│Content that will wrap differently at different │
+│widths │
+└────────────────────────────────────────────────┘
+
+
+
+
+
+
+
+
+
+
+
+"
+`;
+
+exports[`Textarea Layout Tests Measure Cache Edge Cases should handle width changes with cached measures 3`] = `
+"┌──────────────────┐
+│Content that will │
+│wrap differently │
+│at different │
+│widths │
+└──────────────────┘
+
+
+
+
+
+
+
+
+
+"
+`;
+
+exports[`Textarea Layout Tests Measure Cache Edge Cases should handle empty to non-empty content transition 1`] = `
+"┌──────────────────────────────────────┐
+│ │
+└──────────────────────────────────────┘
+
+
+
+
+
+
+
+"
+`;
+
+exports[`Textarea Layout Tests Measure Cache Edge Cases should handle empty to non-empty content transition 2`] = `
+"┌──────────────────────────────────────┐
+│Now with content │
+└──────────────────────────────────────┘
+
+
+
+
+
+
+
+"
+`;
+
+exports[`Textarea Layout Tests Measure Cache Edge Cases should handle empty to non-empty content transition 3`] = `
+"┌──────────────────────────────────────┐
+│ │
+└──────────────────────────────────────┘
+
+
+
+
+
+
+
+"
+`;
+
+exports[`Textarea Layout Tests Measure Cache Edge Cases should correctly measure multiline content with unicode 1`] = `
+"┌────────────────────────────┐
+│Hello 世界 │
+│こんにちは │
+│🌟 Emoji 🚀 │
+└────────────────────────────┘
+
+
+
+
+
+
+
+
+
+
"
`;
diff --git a/packages/solid/tests/textarea.test.tsx b/packages/solid/tests/textarea.test.tsx
index 05e82f3af..09140ba31 100644
--- a/packages/solid/tests/textarea.test.tsx
+++ b/packages/solid/tests/textarea.test.tsx
@@ -847,4 +847,138 @@ describe("Textarea Layout Tests", () => {
expect(frame).toMatchSnapshot()
})
})
+
+ describe("Measure Cache Edge Cases", () => {
+ it("should correctly measure text after content change", async () => {
+ const [value, setValue] = createSignal("Short text")
+
+ testSetup = await testRender(
+ () => (
+
+
+ {value()}
+
+
+ ),
+ { width: 50, height: 15 },
+ )
+
+ await testSetup.renderOnce()
+ const initialFrame = testSetup.captureCharFrame()
+
+ // Change to longer content that should cause more wrapping
+ setValue("This is a much longer text that will definitely wrap to multiple lines when rendered")
+ await testSetup.renderOnce()
+ const updatedFrame = testSetup.captureCharFrame()
+
+ expect(initialFrame).toMatchSnapshot()
+ expect(updatedFrame).toMatchSnapshot()
+ expect(updatedFrame).not.toBe(initialFrame)
+ })
+
+ it("should handle rapid content updates correctly", async () => {
+ const [value, setValue] = createSignal("Initial")
+
+ testSetup = await testRender(
+ () => (
+
+
+ {value()}
+
+
+ ),
+ { width: 40, height: 10 },
+ )
+
+ // Rapid updates to simulate typing
+ for (let i = 0; i < 5; i++) {
+ setValue(`Update ${i}: some text here`)
+ await testSetup.renderOnce()
+ }
+
+ const finalFrame = testSetup.captureCharFrame()
+ expect(finalFrame).toMatchSnapshot()
+ })
+
+ it("should handle width changes with cached measures", async () => {
+ const [width, setWidth] = createSignal(30)
+
+ testSetup = await testRender(
+ () => (
+
+
+ Content that will wrap differently at different widths
+
+
+ ),
+ { width: 60, height: 15 },
+ )
+
+ await testSetup.renderOnce()
+ const frame30 = testSetup.captureCharFrame()
+
+ setWidth(50)
+ await testSetup.renderOnce()
+ const frame50 = testSetup.captureCharFrame()
+
+ setWidth(20)
+ await testSetup.renderOnce()
+ const frame20 = testSetup.captureCharFrame()
+
+ expect(frame30).toMatchSnapshot()
+ expect(frame50).toMatchSnapshot()
+ expect(frame20).toMatchSnapshot()
+ })
+
+ it("should handle empty to non-empty content transition", async () => {
+ const [value, setValue] = createSignal("")
+
+ testSetup = await testRender(
+ () => (
+
+
+ {value() || " "}
+
+
+ ),
+ { width: 50, height: 10 },
+ )
+
+ await testSetup.renderOnce()
+ const emptyFrame = testSetup.captureCharFrame()
+
+ setValue("Now with content")
+ await testSetup.renderOnce()
+ const contentFrame = testSetup.captureCharFrame()
+
+ setValue("")
+ await testSetup.renderOnce()
+ const emptyAgainFrame = testSetup.captureCharFrame()
+
+ expect(emptyFrame).toMatchSnapshot()
+ expect(contentFrame).toMatchSnapshot()
+ expect(emptyAgainFrame).toMatchSnapshot()
+ })
+
+ it("should correctly measure multiline content with unicode", async () => {
+ testSetup = await testRender(
+ () => (
+
+
+ Hello 世界
+
+ こんにちは
+
+ 🌟 Emoji 🚀
+
+
+ ),
+ { width: 40, height: 15 },
+ )
+
+ await testSetup.renderOnce()
+ const frame = testSetup.captureCharFrame()
+ expect(frame).toMatchSnapshot()
+ })
+ })
})