Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ node_modules
dist
coverage
.claude/settings.local.json
package-lock.json
package-lock.json
benchmark/memory-results.json
59 changes: 43 additions & 16 deletions src/arena.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,16 +321,49 @@ describe('CSSDataArena', () => {
})
})

describe('trim', () => {
test('should set capacity equal to count', () => {
const arena = new CSSDataArena(100)
arena.create_node(STYLESHEET, 0, 0, 1, 1)
arena.create_node(DECLARATION, 0, 0, 1, 1)
expect(arena.get_capacity()).toBe(100)
arena.trim()
expect(arena.get_capacity()).toBe(arena.get_count())
})

test('should preserve all node data after trim', () => {
const arena = new CSSDataArena(100)
const n1 = arena.create_node(STYLESHEET, 10, 500, 1, 1)
const n2 = arena.create_node(DECLARATION, 20, 30, 2, 5)
arena.set_flag(n2, FLAG_IMPORTANT)
arena.trim()

expect(arena.get_type(n1)).toBe(STYLESHEET)
expect(arena.get_start_offset(n1)).toBe(10)
expect(arena.get_type(n2)).toBe(DECLARATION)
expect(arena.get_start_offset(n2)).toBe(20)
expect(arena.has_flag(n2, FLAG_IMPORTANT)).toBe(true)
})

test('should be a no-op when already tight', () => {
const arena = new CSSDataArena(2)
arena.create_node(STYLESHEET, 0, 0, 1, 1) // count = 2, triggers grow to capacity 3
arena.create_node(STYLESHEET, 0, 0, 1, 1) // count = 3 = capacity
expect(arena.get_count()).toBe(arena.get_capacity())
arena.trim() // should not throw or corrupt
expect(arena.get_count()).toBe(arena.get_capacity())
})
})

describe('real-world CSS frameworks', () => {
test('should not grow for Bootstrap CSS', () => {
const css = readFileSync('node_modules/bootstrap/dist/css/bootstrap.css', 'utf-8')
const result = parse(css) as unknown as CSSNode
const arena = result.__get_arena()

expect(result.__get_arena().get_growth_count()).toBe(0)
const utilization =
(result.__get_arena().get_count() / result.__get_arena().get_capacity()) * 100
expect(utilization).toBeLessThan(85)
expect(utilization).toBeGreaterThan(30)
expect(arena.get_growth_count()).toBe(0)
// parse() calls trim(), so capacity must equal count
expect(arena.get_capacity()).toBe(arena.get_count())
})

test('should not grow for Bootstrap minified CSS', () => {
Expand All @@ -339,9 +372,7 @@ describe('CSSDataArena', () => {
const arena = result.__get_arena()

expect(arena.get_growth_count()).toBe(0)
const utilization = (arena.get_count() / arena.get_capacity()) * 100
expect(utilization).toBeLessThan(85)
expect(utilization).toBeGreaterThan(30)
expect(arena.get_capacity()).toBe(arena.get_count())
})

test('should not grow for Tailwind CSS', () => {
Expand All @@ -350,20 +381,16 @@ describe('CSSDataArena', () => {
const arena = result.__get_arena()

expect(arena.get_growth_count()).toBe(0)
const utilization = (arena.get_count() / arena.get_capacity()) * 100
expect(utilization).toBeLessThan(85)
expect(utilization).toBeGreaterThan(30)
expect(arena.get_capacity()).toBe(arena.get_count())
})

test('should not grow for Tailwind minified CSS', () => {
const css = readFileSync('node_modules/tailwindcss/dist/tailwind.min.css', 'utf-8')
const result = parse(css) as unknown as CSSNode
const arena = result.__get_arena()

expect(result.__get_arena().get_growth_count()).toBe(0)
const utilization =
(result.__get_arena().get_count() / result.__get_arena().get_capacity()) * 100
expect(utilization).toBeLessThan(85)
expect(utilization).toBeGreaterThan(30)
expect(arena.get_growth_count()).toBe(0)
expect(arena.get_capacity()).toBe(arena.get_count())
})
})
})
31 changes: 25 additions & 6 deletions src/arena.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,13 +116,15 @@ export class CSSDataArena {
// Growth multiplier when capacity is exceeded
private static readonly GROWTH_FACTOR = 1.3

// Estimated nodes per KB of CSS (based on real-world data)
// Increased from 270 to 325 to account for VALUE wrapper nodes
// (~20% of nodes are declarations, +1 VALUE node per declaration = +20% nodes)
private static readonly NODES_PER_KB = 325
// Estimated nodes per KB of CSS.
// Measured across real-world files (unminified and minified):
// bootstrap.css 137 | bootstrap.min 166 | tailwind.css 157 | tailwind.min 195 | small 198
// 210 keeps ~16% headroom above the observed ceiling of 198 nodes/KB.
private static readonly NODES_PER_KB = 210

// Buffer to avoid frequent growth (15%)
private static readonly CAPACITY_BUFFER = 1.2
// Safety buffer on top of NODES_PER_KB to absorb variance without a grow.
// Combined with the constant above: effective ceiling = 210 × 1.1 = 231 nodes/KB.
private static readonly CAPACITY_BUFFER = 1.1

constructor(initial_capacity: number = 1024) {
this.capacity = initial_capacity
Expand Down Expand Up @@ -350,6 +352,23 @@ export class CSSDataArena {
}
}

/**
* Shrink the buffer to exactly the number of live nodes, releasing wasted capacity.
* Call once after parsing is complete. Safe to call multiple times (no-op if already tight).
*
* @see https://doc.rust-lang.org/std/vec/struct.Vec.html#method.shrink_to_fit
* @see https://en.cppreference.com/w/cpp/container/vector/shrink_to_fit
*/
trim(): void {
if (this.count === this.capacity) return
let byte_count = this.count * BYTES_PER_NODE
let new_buffer = new ArrayBuffer(byte_count)
new Uint8Array(new_buffer).set(new Uint8Array(this.buffer, 0, byte_count))
this.buffer = new_buffer
this.view = new DataView(new_buffer)
this.capacity = this.count
}

// Check if a node has any children
has_children(node_index: number): boolean {
return this.get_first_child(node_index) !== 0
Expand Down
3 changes: 3 additions & 0 deletions src/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,9 @@ export class Parser {
// Link all rules as children
this.arena.append_children(stylesheet, rules)

// Release wasted pre-allocated capacity now that node count is final
this.arena.trim()

// Return wrapped node
return new CSSNode(this.arena, this.source, stylesheet) as StyleSheet
}
Expand Down
Loading