From 3a0d80d1f4526af0fbca2bb2e0a9c51669d2fd3e Mon Sep 17 00:00:00 2001 From: Max Medve <23156422+amedve@users.noreply.github.com> Date: Mon, 22 Jun 2026 08:11:06 -0400 Subject: [PATCH] perf(parser): memoize createTrie to avoid rebuilding tries per Tokenizer (#911) The Tokenizer constructor calls createTrie(operators) and createTrie(literalValues) on every instantiation, and liquidjs builds a fresh Tokenizer per output/tag while parsing. On typical templates this rebuilt the same prefix-tries dozens of times and showed up as a large share of parse CPU in profiling. Memoize createTrie with a module-level WeakMap keyed on the input object. The inputs (operators, literalValues) are stable references and the trie is only ever read afterward (via matchTrie), never mutated, so caching by reference is behavior-preserving. WeakMap (not Map) lets short-lived, per-instance operator objects and their tries be garbage collected. --- src/parser/tokenizer.spec.ts | 8 ++++++++ src/util/operator-trie.ts | 10 ++++++++++ 2 files changed, 18 insertions(+) diff --git a/src/parser/tokenizer.spec.ts b/src/parser/tokenizer.spec.ts index c8d4f6311..08cf084b7 100644 --- a/src/parser/tokenizer.spec.ts +++ b/src/parser/tokenizer.spec.ts @@ -522,6 +522,14 @@ describe('Tokenizer', function () { expect(new Tokenizer('contains b').matchTrie(opTrie)).toBe(8) }) }) + describe('#createTrie()', function () { + it('should return the same trie for the same input', () => { + expect(createTrie(defaultOperators)).toBe(createTrie(defaultOperators)) + }) + it('should return distinct tries for distinct inputs', () => { + expect(createTrie({ foo: 1 })).not.toBe(createTrie({ foo: 1 })) + }) + }) describe('#readLiquidTagTokens', () => { it('should read newline terminated tokens', () => { const tokenizer = new Tokenizer('echo \'hello\'') diff --git a/src/util/operator-trie.ts b/src/util/operator-trie.ts index f94d7138a..0ecbaa21d 100644 --- a/src/util/operator-trie.ts +++ b/src/util/operator-trie.ts @@ -10,7 +10,16 @@ export type Trie = { needBoundary?: true } & Record +// Tries are built once per input object and reused: the Tokenizer rebuilds them +// on every instantiation, but `input` (operators/literalValues) is a stable +// reference. WeakMap-keying by `input` lets short-lived operator objects (and +// their tries) be garbage collected. The returned trie is treated as read-only +// by callers (matchTrie only reads it); do not mutate it. +const trieCache = new WeakMap, Trie>() + export function createTrie (input: TrieInput): Trie { + const cached = trieCache.get(input) + if (cached) return cached const trie: Trie = {} for (const [name, data] of Object.entries(input)) { let node = trie @@ -29,5 +38,6 @@ export function createTrie (input: TrieInput): Trie { node.data = data node.end = true } + trieCache.set(input, trie) return trie }