From 24ef9625a339ab2080300fb75fc902b567c4e815 Mon Sep 17 00:00:00 2001 From: Marcis Bergmanis Date: Wed, 28 Oct 2026 00:35:27 +0200 Subject: [PATCH 1/7] Fix Messagepipe formatting correctness and reuse Intl formatters --- .github/workflows/main.yml | 2 + .github/workflows/publish.yml | 3 +- README.md | 39 ++++++++++++++-- package.json | 10 +++-- scripts/test-package.cjs | 16 +++++++ src/messagepipe.ts | 10 ++--- src/parser.ts | 31 +++++++++---- src/regressions.test.ts | 83 +++++++++++++++++++++++++++++++++++ src/transformers.ts | 76 +++++++++++++++++++++----------- 9 files changed, 222 insertions(+), 48 deletions(-) create mode 100644 scripts/test-package.cjs create mode 100644 src/regressions.test.ts diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 9ea6ec2..f59957a 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -31,3 +31,5 @@ jobs: run: npm run declarations env: CI: true + - name: Package imports + run: npm run test:package diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2be615b..f1b2321 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -14,7 +14,7 @@ jobs: node-version: 16 - run: npm ci - run: npm test - - run: npm lint + - run: npm run lint publish-npm: needs: test @@ -28,6 +28,7 @@ jobs: - run: npm ci - run: npm run build - run: npm run declarations + - run: npm run test:package - run: npm publish env: NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} diff --git a/README.md b/README.md index e90fbc0..defb579 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,7 @@ Formats message strings with number, date, plural, and select placeholders to create localized messages. -* **Small.** Between 700 bytes and 1.3 kilobytes (minified and gzipped). - Zero dependencies. +* **Small.** Zero runtime dependencies. * **Fast.** Does absolute minimum amount of computations necessary. View [benchmarks](#benchmarks). * **Tree Shakable.** Includes separate global transformers config that can be omitted. * **Pipe syntax.** Transformer functions can customized and chained. @@ -111,6 +110,12 @@ const msgPipe = MessagePipe({ This would be valid use case for it: `"Greetings {name | capitalize}!"`. ### Argument + +Single- and double-quoted argument values may contain delimiters such as commas, +colons, pipes, and braces. JavaScript string escapes are preserved inside quotes. +Outside placeholders, a backslash escapes the following character; use two +backslashes for a literal backslash. + To allow more functionality, we can use arguments, that are passed to transformer function. ```ts @@ -197,13 +202,33 @@ const msg = compile('Currently it is {now | time}') msg({ now: new Date('1983-05-25 16:42') }) // "Currently it is 16:42:00" ``` +#### relativeTime + +Formats a duration in **milliseconds**, using hours by default. Set `type` to an +Intl relative-time unit (singular or plural); other options, such as `numeric`, +are forwarded to `Intl.RelativeTimeFormat`. + +```ts +const msg = compile('{duration | relativeTime, type:"day", numeric:"auto"}') +msg({ duration: 86400000 }) // "tomorrow" (English locale) +``` + +Months, quarters, and years use fixed durations of 30, 90, and 365 days. For +calendar-aware differences, compute the duration in your application first. + +Intl formatters are reused per `intlTransformers(locale)` instance, with at most +100 option combinations retained per formatter kind. Dynamic option values +continue to work. Date and time component defaults are omitted when `dateStyle` +or `timeStyle` is supplied. Plural selection falls back to `other` and replaces +every `#` in the selected text; an explicitly empty selection stays empty. + ## API ### `MessagePipe` This is the main function that takes in all the transformers that will be available to all the messages. ```ts -function MessagePipe(transformers?: MessagePipeTransformers): { +function MessagePipe(transformers?: MessagePipeTransformers, options?: { disableCache?: boolean }): { compileRaw(message: string): (props?: Record) => string[] compile(message: string): (props?: Record) => string } @@ -217,6 +242,14 @@ const messagePipe = MessagePipe({ }) ``` +Compiled messages are cached within each MessagePipe instance, so transformer +sets and locales stay independent. Set `disableCache: true` to bypass message +caching. Omitted props are treated as an empty object. + +Message templates are trusted application code: compilation uses `Function` +and evaluates JavaScript selector and argument expressions. Do not compile +untrusted templates. This also requires a CSP that allows dynamic compilation. + Now all the messages that get compiled from `messagePipe` can use this transformer like so `"{name | hello}"`. #### `compile` diff --git a/package.json b/package.json index 8e2dbec..6be8897 100644 --- a/package.json +++ b/package.json @@ -3,16 +3,17 @@ "version": "0.2.3", "description": "Formats message strings with number, date, plural, and select placeholders to create localized messages", "main": "dist/index.js", - "module": "dist/index.esm.js", + "module": "dist/index.mjs", "types": "dist/index.d.ts", "scripts": { "dev": "node -r tsm dev.ts", "build": "npm run build:esm && npm run build:cjs", - "build:esm": "esbuild src/index.ts --minify --outfile=dist/index.esm.js --bundle --format=esm --sourcemap --define:process.env.NODE_ENV=\\\"production\\\"", + "build:esm": "esbuild src/index.ts --minify --outfile=dist/index.mjs --bundle --format=esm --sourcemap --define:process.env.NODE_ENV=\\\"production\\\"", "build:cjs": "esbuild src/index.ts --minify --outfile=dist/index.js --bundle --format=cjs --sourcemap --define:process.env.NODE_ENV=\\\"production\\\"", "declarations": "tsc --declarationDir dist --emitDeclarationOnly --declaration", "test": "uvu -r tsm", - "lint": "rome check ./src/" + "lint": "rome check ./src/", + "test:package": "node scripts/test-package.cjs" }, "repository": { "type": "git", @@ -36,8 +37,9 @@ ], "exports": { ".": { + "types": "./dist/index.d.ts", "require": "./dist/index.js", - "import": "./dist/index.esm.js" + "import": "./dist/index.mjs" } } } diff --git a/scripts/test-package.cjs b/scripts/test-package.cjs new file mode 100644 index 0000000..37b918d --- /dev/null +++ b/scripts/test-package.cjs @@ -0,0 +1,16 @@ +const assert = require('node:assert/strict'); +const { spawnSync } = require('node:child_process'); + +// Exercise the package export map, rather than importing a source file. +for (const [mode, load] of [ + ['commonjs', "const { MessagePipe } = require('messagepipe');"], + ['module', "import { MessagePipe } from 'messagepipe';"], +]) { + const result = spawnSync(process.execPath, [ + `--input-type=${mode}`, '-e', + `${load} console.log(MessagePipe().compile('Hello {name}')({ name: 'Ada' }));`, + ], { encoding: 'utf8' }); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout.trim(), 'Hello Ada'); +} +console.log('CommonJS and ESM package imports passed'); diff --git a/src/messagepipe.ts b/src/messagepipe.ts index e10f56d..c0a47b9 100644 --- a/src/messagepipe.ts +++ b/src/messagepipe.ts @@ -12,17 +12,17 @@ export interface MessagePipeOptions { disableCache?: boolean; } -const rcache: Record = {}; -const scache: Record = {}; - export function MessagePipe(transformers: MessagePipeTransformers = {}, { disableCache = false }: MessagePipeOptions = {}) { + const rcache: Record = Object.create(null); + const scache: Record = Object.create(null); + function compileRaw(message: string): (props?: Record) => Output { if (!disableCache && rcache[message]) { return rcache[message]; } - const parsedMessage = Function('b', 'return (a)=>[' + parser(message).join(',') + ']'); + const parsedMessage = Function('b', 'return (a={})=>[' + parser(message).join(',') + ']'); return disableCache ? parsedMessage(transformers) : rcache[message] = parsedMessage(transformers); } @@ -32,7 +32,7 @@ export function MessagePipe(transformers: MessagePipeTransformers = {}, { disabl return scache[message]; } - const parsedMessage = Function('b', 'return (a)=>' + parser(message).join('+')); + const parsedMessage = Function('b', 'return (a={})=>' + parser(message).join('+')); return disableCache ? parsedMessage(transformers) : scache[message] = parsedMessage(transformers); } diff --git a/src/parser.ts b/src/parser.ts index b7ff578..8419784 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -30,7 +30,6 @@ function reducePipe(acc: string, [pipeName, ...pipeProps]: [string, ...[string, export function parser(text: string): string[] { const output: string[] = []; let chunk = ''; - let previousToken = ''; // This is where message starts. // "Hello {name}!" @@ -40,6 +39,8 @@ export function parser(text: string): string[] { let localSelector = ''; let localPipes: [string, ...[string, string][]][] = []; let localPipeIndex = -1; + let quote = ''; + let escaped = false; function end(index: number) { const selectorTrim = localSelector.trim(); @@ -58,10 +59,24 @@ export function parser(text: string): string[] { for (let i = startIndex; i < text.length; i++) { const char = text[i]; - const escaped = previousToken === tokenEscape; - previousToken = char; - - if (char === tokenEscape) { + // Quoted JavaScript strings are opaque to message delimiters. + if (quote || char === '"' || char === "'") { + if (localPipeIndex === -1) { + localSelector += char; + } else { + const pipe = localPipes[localPipeIndex]; + const args = pipe[pipe.length - 1]; + if (!Array.isArray(args)) { + fail(char, i); + } + args[args.length - 1] += char; + } + if (!quote) { + quote = char; + } else if (!escaped && char === quote) { + quote = ''; + } + escaped = !escaped && char === tokenEscape; continue; } @@ -137,14 +152,12 @@ export function parser(text: string): string[] { for (let i = 0; i < text.length; i++) { const char = text[i]; - const escaped = previousToken === tokenEscape; - previousToken = char; - if (char === tokenEscape) { + chunk += text[++i] ?? tokenEscape; continue; } - if (tokenStart === char && !escaped) { + if (tokenStart === char) { output.push(JSON.stringify(chunk)); chunk = ''; const messageData = messageStart(i + 1); diff --git a/src/regressions.test.ts b/src/regressions.test.ts new file mode 100644 index 0000000..6e102fc --- /dev/null +++ b/src/regressions.test.ts @@ -0,0 +1,83 @@ +import { test } from 'uvu'; +import assert from 'uvu/assert'; +import { MessagePipe, defaultTransformers, intlTransformers } from './index'; + +for (const method of ['compile', 'compileRaw'] as const) { + test(`${method} isolates transformers and accepts prototype-named messages`, () => { + const first = MessagePipe({ label: () => 'first' }); + const second = MessagePipe({ label: () => 'second' }); + const output = (value: string) => method === 'compile' ? value : ['', value, '']; + assert.equal(first[method]('{x|label}')({}), output('first')); + assert.equal(second[method]('{x|label}')({}), output('second')); + for (const text of ['constructor', 'toString', '__proto__']) { + assert.equal(first[method](text)(), method === 'compile' ? text : [text]); + } + }); +} + +test('quoted arguments and selectors preserve delimiters and escapes', () => { + const pipe = MessagePipe(defaultTransformers()); + assert.equal(pipe.compile('{x|select,other:"a,b:c|d{e}"}')({}), 'a,b:c|d{e}'); + assert.equal(pipe.compile(String.raw`{x|select,other:"say \"hi\""}`)({}), 'say "hi"'); + assert.equal(pipe.compile('{["a|b,c:d}"]}')({ 'a|b,c:d}': 'found' }), 'found'); + assert.equal(pipe.compile(String.raw`\\{name}`)({ name: 'Ada' }), '\\Ada'); +}); + +test('select preserves empty choices and ignores inherited keys', () => { + const pipe = MessagePipe(defaultTransformers()); + const message = pipe.compile('{x|select,empty:"",other:"fallback"}'); + assert.equal(message({ x: 'empty' }), ''); + assert.equal(message({ x: 'toString' }), 'fallback'); +}); + +test('Intl options reach formatters and plural falls back and replaces every marker', () => { + const pipe = MessagePipe(intlTransformers('en-GB')); + assert.equal(pipe.compile('{x|number,minimumFractionDigits:2}')({ x: 1 }), '1.00'); + assert.equal(pipe.compile('{x|date,year:"2-digit",month:"long",day:"2-digit",timeZone:"UTC"}')({ x: 0 }), '01 January 70'); + assert.equal(pipe.compile('{x|time,hour12:false,timeZone:"UTC"}')({ x: 0 }), '00:00:00'); + assert.equal(pipe.compile('{x|plural,other:"# of #"}')({ x: 1 }), '1 of 1'); + assert.equal(pipe.compile('{x|plural,one:"one",other:"many",minimumFractionDigits:2}')({ x: 1 }), 'many'); +}); + +test('formatter reuse respects changing options and locale isolation', () => { + const gb = MessagePipe(intlTransformers('en-GB')); + const de = MessagePipe(intlTransformers('de-DE')); + const text = '{x|number,minimumFractionDigits:{digits}}'; + const render = gb.compile(text); + assert.equal(render({ x: 1.5, digits: 2 }), '1.50'); + assert.equal(render({ x: 1.5, digits: 3 }), '1.500'); + assert.equal(render({ x: 1.5, digits: 2 }), '1.50'); + assert.equal(de.compile(text)({ x: 1.5, digits: 2 }), '1,50'); +}); + +test('omitted props behave like an empty props object', () => { + const pipe = MessagePipe(); + assert.equal(pipe.compile('Hello {name}')(), 'Hello undefined'); + assert.equal(pipe.compileRaw('{name}')(), ['', undefined, '']); +}); + +for (const [type, milliseconds, expected] of [ + ['second', 1000, 'in 1 second'], + ['minutes', -60000, '1 minute ago'], + ['hour', 3600000, 'in 1 hour'], + ['day', 86400000, 'in 1 day'], + ['week', 604800000, 'in 1 week'], + ['month', 2592000000, 'in 1 month'], + ['quarter', 7776000000, 'in 1 quarter'], + ['year', 31536000000, 'in 1 year'], +] as const) { + test(`relativeTime converts milliseconds to ${type}`, () => { + const render = MessagePipe(intlTransformers('en-GB')).compile('{x|relativeTime,type:{unit}}'); + assert.equal(render({ x: milliseconds, unit: type }), expected); + }); +} + +test('raw compilation preserves values and bypassed caches stay independent', () => { + const object = { id: 1 }; + const pipe = MessagePipe({ identity: value => value }, { disableCache: true }); + assert.equal(pipe.compileRaw('{x|identity}')({ x: object }), ['', object, '']); + assert.is.not(pipe.compile('x'), pipe.compile('x')); + assert.is.not(pipe.compileRaw('x'), pipe.compileRaw('x')); +}); + +test.run(); diff --git a/src/transformers.ts b/src/transformers.ts index 3f970c6..e1dd828 100644 --- a/src/transformers.ts +++ b/src/transformers.ts @@ -1,35 +1,59 @@ +// Bound retention when dynamic messages produce many distinct Intl options. +function formatterCache(create: (options: Record) => T) { + const cache = new Map(); + return (options: Record): T => { + const entries = Object.entries(options).filter(([, value]) => value !== undefined); + // Preserve Intl coercion semantics for non-primitive values and non-finite numbers. + if (entries.some(([, value]) => !['string', 'number', 'boolean'].includes(typeof value) + || (typeof value === 'number' && !Number.isFinite(value)))) { + return create(options); + } + const key = JSON.stringify(entries.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)); + let formatter = cache.get(key); + if (!formatter) { + formatter = create(options); + if (cache.size >= 100) { + cache.delete(cache.keys().next().value!); + } + cache.set(key, formatter); + } + return formatter; + }; +} + export function intlTransformers(locale: string = 'en-GB') { + const number = formatterCache(options => new Intl.NumberFormat(locale, options)); + const date = formatterCache(options => new Intl.DateTimeFormat(locale, options)); + const plural = formatterCache(options => new Intl.PluralRules(locale, options)); + const relativeTime = formatterCache(options => new Intl.RelativeTimeFormat(locale, options)); const globalTransformers: Record) => string> = { relativeTime(value, { type = 'hour', ...options } = {}) { - return new Intl.RelativeTimeFormat(locale, { - ...options, - }).format(Number(value) / 1000 / 60 / 60, type); + // Calendar units use fixed approximations because the input is a duration. + const seconds: Record = { + second: 1, minute: 60, hour: 3600, day: 86400, week: 604800, + month: 2592000, quarter: 7776000, year: 31536000, + }; + const unit = type.replace(/s$/, ''); + return relativeTime(options).format(Number(value) / 1000 / (seconds[unit] || 1), type); }, - date(value) { - return new Intl.DateTimeFormat(locale, { - year: 'numeric', - month: 'numeric', - day: 'numeric', - }).format(value as any); + date(value, options = {}) { + const defaults = options.dateStyle || options.timeStyle ? {} : { + year: 'numeric', month: 'numeric', day: 'numeric', + }; + return date({ ...defaults, ...options }).format(value as any); }, - time(value) { - return new Intl.DateTimeFormat(locale, { - hour: 'numeric', - minute: 'numeric', - second: 'numeric', - }).format(value as any); + time(value, options = {}) { + const defaults = options.dateStyle || options.timeStyle ? {} : { + hour: 'numeric', minute: 'numeric', second: 'numeric', + }; + return date({ ...defaults, ...options }).format(value as any); }, - plural(value, { type = 'cardinal', ...options } = {}) { - return options[new Intl.PluralRules(locale, { type }).select(value as any)]?.replace('#', value); + plural(value, { zero, one, two, few, many, other, ...options } = {}) { + const choices: Record = { zero, one, two, few, many, other }; + return (choices[plural(options).select(value as any)] ?? other)?.split('#').join(value); }, - number(value, { style, currency, unit, unitDisplay, maximumSignificantDigits } = {}) { - return new Intl.NumberFormat(locale, { - style, - currency, - unit, - unitDisplay, - maximumSignificantDigits, - }).format(value as any); + number(value, options = {}) { + return number(options).format(value as any); }, }; @@ -42,7 +66,7 @@ export function defaultTransformers() { return JSON.stringify(value); }, select(value, options = {}) { - return options[value] || options.other; + return Object.prototype.hasOwnProperty.call(options, value) ? options[value] : options.other; }, }; From 0f11eef39e3a9f956fc0c326d398f697245e06d7 Mon Sep 17 00:00:00 2001 From: Marcis Bergmanis Date: Wed, 28 Oct 2026 14:22:44 +0200 Subject: [PATCH 2/7] Fix Messagepipe formatting correctness and reuse Intl formatters --- README.md | 44 +++++++++++++++++++++++++++++++++++------ package.json | 3 ++- scripts/benchmark.cjs | 40 +++++++++++++++++++++++++++++++++++++ src/regressions.test.ts | 18 +++++++++++++++++ src/transformers.ts | 13 +++++++++++- 5 files changed, 110 insertions(+), 8 deletions(-) create mode 100644 scripts/benchmark.cjs diff --git a/README.md b/README.md index defb579..de410e3 100644 --- a/README.md +++ b/README.md @@ -263,13 +263,45 @@ This is practically the same as [compile](#compile) but instead of it returning So from the example that was before, the output of that message would be `(a) => ["Hello ", capitalize(a.name), "!"]`. ## Benchmarks -It is necessary for me that this library is as small and as fast as possible. Since this library compares directly with MessageFormat, I treated both as equal in benchmarks. -Message | MessageFormat | MessagePipe | Improvement -|-|-|-|-| -["Wow"](https://fastbench.dev/Format-%22Wow%22-JtYOF/0) | 926,368 ops/s | __1,847,253 ops/s__ | 2x -["Hello {planet}"](https://fastbench.dev/Format-%22Hello-planet%22-tKWLnH/0) | 560,131 ops/s | __1,024,051 ops/s__ | 1.8x -[select transformer](https://fastbench.dev/Format-select-MovWE/0) | 209,513 ops/s | __337,226 ops/s__ | 1.6x +Repeated formatting before and after the Intl formatter-cache changes, measured +on 2026-10-28. The baseline is commit `ef20fcd`; both revisions use minified +CommonJS builds from the same esbuild version. Lower elapsed time is better. + +| Workload | Before | Current | Speedup | +| --- | ---: | ---: | ---: | +| Number: EUR currency, `1234.5` | 125.10 ms | 10.93 ms | 11.4× | +| Date: default format, epoch `0` | 262.21 ms | 16.04 ms | 16.3× | +| Plural: `5` items | 75.88 ms | 5.47 ms | 13.9× | + +Each cell is the median of five samples of 10,000 calls after 1,000 warmup calls. +Messages are compiled once before timing, with fixed props and options. The +runner checks the formatted output and prints every sample plus runtime metadata. +The measured host used Linux x64 (WSL), an AMD Ryzen 9 9950X, Node 22.23.2, +ICU 78.2, locale `en-GB`, and timezone UTC. + +Run the current build: + +```sh +npm ci +npm run build +npm run benchmark +``` + +To compare the baseline with the same runner: + +```sh +git worktree add --detach ../messagepipe-baseline ef20fcd +(cd ../messagepipe-baseline && npm ci && npm run build) +npm run benchmark -- ../messagepipe-baseline/dist/index.js +``` + +These measurements cover repeated formatting with reusable Intl options. +Compilation, cold formatter construction, and changing option combinations have +different costs. Timings vary with runtime, hardware, and garbage collection; +they are not application-wide throughput guarantees. The formatter caches retain +at most 100 combinations per kind. Inherited or accessor-backed option objects +bypass caching to preserve Intl behavior. ## Framework integration Works with React and Preact out of the box. Just swap out [`compile`](#compile) with [`compileRaw`](#compile) and good to go. This works because it returns raw array of values that was the output of selectors and transformers. diff --git a/package.json b/package.json index 6be8897..69041d5 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "declarations": "tsc --declarationDir dist --emitDeclarationOnly --declaration", "test": "uvu -r tsm", "lint": "rome check ./src/", - "test:package": "node scripts/test-package.cjs" + "test:package": "node scripts/test-package.cjs", + "benchmark": "node scripts/benchmark.cjs" }, "repository": { "type": "git", diff --git a/scripts/benchmark.cjs b/scripts/benchmark.cjs new file mode 100644 index 0000000..2d5482f --- /dev/null +++ b/scripts/benchmark.cjs @@ -0,0 +1,40 @@ +// Fix the date workload's timezone across hosts. +process.env.TZ = 'UTC'; + +const assert = require('node:assert/strict'); +const { cpus } = require('node:os'); +const { resolve } = require('node:path'); +const { performance } = require('node:perf_hooks'); +const bundle = resolve(process.argv[2] || 'dist/index.js'); +const { MessagePipe, intlTransformers } = require(bundle); + +const warmup = 1000; +const iterations = 10000; +const repeats = 5; +const results = []; +for (const [name, template, value, expected] of [ + ['number', '{x|number,style:"currency",currency:"EUR"}', 1234.5, '€1,234.50'], + ['date', '{x|date}', 0, '01/01/1970'], + ['plural', '{x|plural,one:"one",other:"# items"}', 5, '5 items'], +]) { + const render = MessagePipe(intlTransformers('en-GB')).compile(template); + const props = { x: value }; + assert.equal(render(props), expected); + for (let i = 0; i < warmup; i++) render(props); + const samples = []; + let output; + for (let sample = 0; sample < repeats; sample++) { + const start = performance.now(); + for (let i = 0; i < iterations; i++) output = render(props); + samples.push(performance.now() - start); + assert.equal(output, expected); + } + const median = [...samples].sort((a, b) => a - b)[Math.floor(repeats / 2)]; + results.push({ name, template, value, samples_ms: samples, median_ms: median }); +} +console.log(JSON.stringify({ + bundle, node: process.version, icu: process.versions.icu, + platform: process.platform, arch: process.arch, cpu: cpus()[0].model, + locale: 'en-GB', timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, + warmup, iterations, repeats, results, +}, null, 2)); diff --git a/src/regressions.test.ts b/src/regressions.test.ts index 6e102fc..9a16abb 100644 --- a/src/regressions.test.ts +++ b/src/regressions.test.ts @@ -80,4 +80,22 @@ test('raw compilation preserves values and bypassed caches stay independent', () assert.is.not(pipe.compileRaw('x'), pipe.compileRaw('x')); }); +test('number options preserve inherited and non-enumerable properties', () => { + const { number } = intlTransformers('en-GB'); + assert.equal(number('0.5'), '0.5'); + assert.equal(number('0.5', Object.create({ style: 'percent' })), '50%'); + assert.equal(number('0.5', Object.defineProperty({}, 'style', { value: 'percent' })), '50%'); + assert.equal(number('0.5'), '0.5'); +}); + +test('number options evaluate getters once per call', () => { + const { number } = intlTransformers('en-GB'); + let reads = 0; + const options = { get style() { return reads++ === 0 ? 'percent' : 'decimal'; } }; + assert.equal(number('0.5', options), '50%'); + assert.equal(reads, 1); + assert.equal(number('0.5', options), '0.5'); + assert.equal(reads, 2); +}); + test.run(); diff --git a/src/transformers.ts b/src/transformers.ts index e1dd828..53626db 100644 --- a/src/transformers.ts +++ b/src/transformers.ts @@ -2,7 +2,18 @@ function formatterCache(create: (options: Record) => T) { const cache = new Map(); return (options: Record): T => { - const entries = Object.entries(options).filter(([, value]) => value !== undefined); + const prototype = Object.getPrototypeOf(options); + if (prototype !== Object.prototype && prototype !== null) { + return create(options); + } + const descriptors = Object.entries(Object.getOwnPropertyDescriptors(options)); + // Let Intl read accessors itself, exactly once and in its specified order. + if (descriptors.some(([, descriptor]) => !('value' in descriptor))) { + return create(options); + } + const entries = descriptors + .map(([key, descriptor]) => [key, descriptor.value] as [string, any]) + .filter(([, value]) => value !== undefined); // Preserve Intl coercion semantics for non-primitive values and non-finite numbers. if (entries.some(([, value]) => !['string', 'number', 'boolean'].includes(typeof value) || (typeof value === 'number' && !Number.isFinite(value)))) { From 1463c43b804a776d6a5a64bd5e3fa7f6c63c4d48 Mon Sep 17 00:00:00 2001 From: Marcis Bergmanis Date: Wed, 28 Oct 2026 14:34:02 +0200 Subject: [PATCH 3/7] Fix Messagepipe formatting correctness and reuse Intl formatters --- README.md | 66 +++++++++++++-------- package-lock.json | 133 ++++++++++++++++++++++++++++++++++++++++++ package.json | 1 + scripts/benchmark.cjs | 87 ++++++++++++++++++++------- 4 files changed, 240 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index de410e3..8a0a7bf 100644 --- a/README.md +++ b/README.md @@ -264,23 +264,41 @@ So from the example that was before, the output of that message would be `(a) => ## Benchmarks -Repeated formatting before and after the Intl formatter-cache changes, measured -on 2026-10-28. The baseline is commit `ef20fcd`; both revisions use minified -CommonJS builds from the same esbuild version. Lower elapsed time is better. +### MessagePipe vs. MessageFormat -| Workload | Before | Current | Speedup | -| --- | ---: | ---: | ---: | -| Number: EUR currency, `1234.5` | 125.10 ms | 10.93 ms | 11.4× | -| Date: default format, epoch `0` | 262.21 ms | 16.04 ms | 16.3× | -| Plural: `5` items | 75.88 ms | 5.47 ms | 13.9× | - -Each cell is the median of five samples of 10,000 calls after 1,000 warmup calls. -Messages are compiled once before timing, with fixed props and options. The -runner checks the formatted output and prints every sample plus runtime metadata. -The measured host used Linux x64 (WSL), an AMD Ryzen 9 9950X, Node 22.23.2, -ICU 78.2, locale `en-GB`, and timezone UTC. +Measured on 2026-10-28 against **`@messageformat/core@3.4.1`**, the +[ICU MessageFormat compiler](https://messageformat.github.io/messageformat/api/core.messageformat/). +This is the MessageFormat v3 API, rather than the newer MessageFormat 2 syntax +implemented by the `messageformat` v4 package. The comparison dependency is +pinned for reproducibility and is development-only. -Run the current build: +| Workload | MessageFormat (ops/s) | MessagePipe (ops/s) | MessagePipe / MessageFormat | +| --- | ---: | ---: | ---: | +| Plain text: `Wow` | 257,729,700 | 271,184,455 | 1.05× | +| Interpolation: `Hello {planet}` | 129,552,306 | 129,540,407 | 1.00× | +| Select: gender pronoun | 79,604,246 | 79,768,518 | 1.00× | +| Number: EUR currency | 4,672,308 | 957,623 | 0.20× | +| Date: day/month/year | 2,339,038 | 639,205 | 0.27× | +| Plural: item count | 4,081,153 | 1,914,604 | 0.47× | + +Higher throughput is better; ratios below 1 mean MessagePipe is slower. +Plain text, interpolation, and select are close enough that small differences +should not be treated as a reliable advantage. MessageFormat is faster on the +number, date, and plural workloads in this run. + +Both libraries compile once before timing and receive equivalent templates, +locales, and inputs. The original plain-text, interpolation, and select cases +are included alongside number, date, and plural formatting. Each non-constant +workload rotates through three inputs; both libraries must match the same +expected strings before measurement. Timed calls contribute to a checked output +checksum. Compiler initialization and compilation are excluded. + +Each result uses the median of seven samples of **1,000,000 calls**, following +10,000 warmup calls per library and workload. Measurement order alternates +between libraries. The runner prints both templates, inputs, all timing samples, +and runtime metadata. Host: Linux x64 (WSL), AMD Ryzen 9 9950X, Node 22.23.2, +ICU 78.2, `en-GB`, UTC. MessagePipe uses its minified CommonJS build; +MessageFormat uses the functions returned by its installed compiler. ```sh npm ci @@ -288,20 +306,18 @@ npm run build npm run benchmark ``` -To compare the baseline with the same runner: +An optional CommonJS bundle path runs the same comparison against another +MessagePipe build: ```sh -git worktree add --detach ../messagepipe-baseline ef20fcd -(cd ../messagepipe-baseline && npm ci && npm run build) -npm run benchmark -- ../messagepipe-baseline/dist/index.js +npm run benchmark -- /path/to/messagepipe/dist/index.js ``` -These measurements cover repeated formatting with reusable Intl options. -Compilation, cold formatter construction, and changing option combinations have -different costs. Timings vary with runtime, hardware, and garbage collection; -they are not application-wide throughput guarantees. The formatter caches retain -at most 100 combinations per kind. Inherited or accessor-backed option objects -bypass caching to preserve Intl behavior. +These are warm, repeated-format microbenchmarks, not application-wide throughput +guarantees. Cold construction, compilation, changing options, and other runtimes +can give different results. Timing includes the shared input-rotation and +checksum loop, which matters most for the fastest cases. Re-run on your target +runtime and representative messages before drawing broader conclusions. ## Framework integration Works with React and Preact out of the box. Just swap out [`compile`](#compile) with [`compileRaw`](#compile) and good to go. This works because it returns raw array of values that was the output of selectors and transformers. diff --git a/package-lock.json b/package-lock.json index 9f954d2..573473a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.2.3", "license": "MIT", "devDependencies": { + "@messageformat/core": "3.4.1", "esbuild": "^0.15.5", "rome": "^0.9.0-next", "tsm": "^2.2.2", @@ -32,6 +33,55 @@ "node": ">=12" } }, + "node_modules/@messageformat/core": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@messageformat/core/-/core-3.4.1.tgz", + "integrity": "sha512-98uXHLDjPe2WZ8Xr05MhFQGfpMRbUcJDEYgjLV5ZBTgnntIkKS0VKGE+g8ug/hbgggyPEAeZBRJryIsBUp1vvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@messageformat/date-skeleton": "2.0.0-0", + "@messageformat/number-skeleton": "2.0.0-0", + "@messageformat/parser": "^5.2.0", + "@messageformat/runtime": "^3.0.3", + "make-plural": "^7.0.0", + "safe-identifier": "^0.4.1" + } + }, + "node_modules/@messageformat/date-skeleton": { + "version": "2.0.0-0", + "resolved": "https://registry.npmjs.org/@messageformat/date-skeleton/-/date-skeleton-2.0.0-0.tgz", + "integrity": "sha512-gkCatAK4MYlhjKYWxkfRM6Ql0M1VmA1DK6A9rszUBprZpQox/XqX987AI2w7YoEz0p5DgkPOpI6PK1mW08yNqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@messageformat/number-skeleton": { + "version": "2.0.0-0", + "resolved": "https://registry.npmjs.org/@messageformat/number-skeleton/-/number-skeleton-2.0.0-0.tgz", + "integrity": "sha512-bDrIrVSKwn0sNrbU+N6tjwljV+Zl9pRCR/D9Xim6Q7lLzZ20nFYtuuKOZ3QKisN+vPxa9sTqu3ufJ+uwKNpHbQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@messageformat/parser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@messageformat/parser/-/parser-5.2.0.tgz", + "integrity": "sha512-sc3hNZ0JtC32GhL5nUgPbh2ilcwji4G+Vmj/vhMd8hZaH//NDOvkfwKK1mFnRWG5h2DpksN8xr/HB5/+vMg3iw==", + "dev": true, + "license": "MIT", + "dependencies": { + "moo": "^0.5.1" + } + }, + "node_modules/@messageformat/runtime": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@messageformat/runtime/-/runtime-3.0.3.tgz", + "integrity": "sha512-FE0hP0m15+9p/ia5HWQ/JsUSNLcxZkl1jl1HlRrWCiMAsgiulg7AuOFLaBR6dpPqtPWWuApY7VLo3rIAcrgrkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "make-plural": "^7.0.0" + } + }, "node_modules/@rometools/cli-darwin-arm64": { "version": "0.9.0-next", "resolved": "https://registry.npmjs.org/@rometools/cli-darwin-arm64/-/cli-darwin-arm64-0.9.0-next.tgz", @@ -514,6 +564,20 @@ "node": ">=6" } }, + "node_modules/make-plural": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/make-plural/-/make-plural-7.5.0.tgz", + "integrity": "sha512-0booA+aVYyVFoR67JBHdfVk0U08HmrBH2FrtmBqBa+NldlqXv/G2Z9VQuQq6Wgp2jDWdybEWGfBkk1cq5264WA==", + "dev": true, + "license": "Unicode-DFS-2016" + }, + "node_modules/moo": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/moo/-/moo-0.5.3.tgz", + "integrity": "sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -559,6 +623,13 @@ "node": ">=6" } }, + "node_modules/safe-identifier": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/safe-identifier/-/safe-identifier-0.4.2.tgz", + "integrity": "sha512-6pNbSMW6OhAi9j+N8V+U715yBQsaWJ7eyEUaOrawX+isg5ZxhUlV1NipNtgaKHmFGiABwt+ZF04Ii+3Xjkg+8w==", + "dev": true, + "license": "ISC" + }, "node_modules/tsm": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/tsm/-/tsm-2.2.2.tgz", @@ -986,6 +1057,50 @@ "dev": true, "optional": true }, + "@messageformat/core": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@messageformat/core/-/core-3.4.1.tgz", + "integrity": "sha512-98uXHLDjPe2WZ8Xr05MhFQGfpMRbUcJDEYgjLV5ZBTgnntIkKS0VKGE+g8ug/hbgggyPEAeZBRJryIsBUp1vvw==", + "dev": true, + "requires": { + "@messageformat/date-skeleton": "2.0.0-0", + "@messageformat/number-skeleton": "2.0.0-0", + "@messageformat/parser": "^5.2.0", + "@messageformat/runtime": "^3.0.3", + "make-plural": "^7.0.0", + "safe-identifier": "^0.4.1" + } + }, + "@messageformat/date-skeleton": { + "version": "2.0.0-0", + "resolved": "https://registry.npmjs.org/@messageformat/date-skeleton/-/date-skeleton-2.0.0-0.tgz", + "integrity": "sha512-gkCatAK4MYlhjKYWxkfRM6Ql0M1VmA1DK6A9rszUBprZpQox/XqX987AI2w7YoEz0p5DgkPOpI6PK1mW08yNqQ==", + "dev": true + }, + "@messageformat/number-skeleton": { + "version": "2.0.0-0", + "resolved": "https://registry.npmjs.org/@messageformat/number-skeleton/-/number-skeleton-2.0.0-0.tgz", + "integrity": "sha512-bDrIrVSKwn0sNrbU+N6tjwljV+Zl9pRCR/D9Xim6Q7lLzZ20nFYtuuKOZ3QKisN+vPxa9sTqu3ufJ+uwKNpHbQ==", + "dev": true + }, + "@messageformat/parser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@messageformat/parser/-/parser-5.2.0.tgz", + "integrity": "sha512-sc3hNZ0JtC32GhL5nUgPbh2ilcwji4G+Vmj/vhMd8hZaH//NDOvkfwKK1mFnRWG5h2DpksN8xr/HB5/+vMg3iw==", + "dev": true, + "requires": { + "moo": "^0.5.1" + } + }, + "@messageformat/runtime": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@messageformat/runtime/-/runtime-3.0.3.tgz", + "integrity": "sha512-FE0hP0m15+9p/ia5HWQ/JsUSNLcxZkl1jl1HlRrWCiMAsgiulg7AuOFLaBR6dpPqtPWWuApY7VLo3rIAcrgrkA==", + "dev": true, + "requires": { + "make-plural": "^7.0.0" + } + }, "@rometools/cli-darwin-arm64": { "version": "0.9.0-next", "resolved": "https://registry.npmjs.org/@rometools/cli-darwin-arm64/-/cli-darwin-arm64-0.9.0-next.tgz", @@ -1236,6 +1351,18 @@ "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", "dev": true }, + "make-plural": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/make-plural/-/make-plural-7.5.0.tgz", + "integrity": "sha512-0booA+aVYyVFoR67JBHdfVk0U08HmrBH2FrtmBqBa+NldlqXv/G2Z9VQuQq6Wgp2jDWdybEWGfBkk1cq5264WA==", + "dev": true + }, + "moo": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/moo/-/moo-0.5.3.tgz", + "integrity": "sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==", + "dev": true + }, "mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -1268,6 +1395,12 @@ "mri": "^1.1.0" } }, + "safe-identifier": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/safe-identifier/-/safe-identifier-0.4.2.tgz", + "integrity": "sha512-6pNbSMW6OhAi9j+N8V+U715yBQsaWJ7eyEUaOrawX+isg5ZxhUlV1NipNtgaKHmFGiABwt+ZF04Ii+3Xjkg+8w==", + "dev": true + }, "tsm": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/tsm/-/tsm-2.2.2.tgz", diff --git a/package.json b/package.json index 69041d5..1256a8b 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ }, "homepage": "https://github.com/Marcisbee/messagepipe#readme", "devDependencies": { + "@messageformat/core": "3.4.1", "esbuild": "^0.15.5", "rome": "^0.9.0-next", "tsm": "^2.2.2", diff --git a/scripts/benchmark.cjs b/scripts/benchmark.cjs index 2d5482f..4445833 100644 --- a/scripts/benchmark.cjs +++ b/scripts/benchmark.cjs @@ -5,35 +5,78 @@ const assert = require('node:assert/strict'); const { cpus } = require('node:os'); const { resolve } = require('node:path'); const { performance } = require('node:perf_hooks'); +const MessageFormat = require('@messageformat/core'); const bundle = resolve(process.argv[2] || 'dist/index.js'); -const { MessagePipe, intlTransformers } = require(bundle); +const { MessagePipe, defaultTransformers, intlTransformers } = require(bundle); +const pipe = MessagePipe({ ...defaultTransformers(), ...intlTransformers('en-GB') }); +const messageformat = new MessageFormat('en-GB'); + +const warmup = 10000; +const iterations = 1000000; +const repeats = 7; +const workloads = [ + ['plain', 'Wow', 'Wow', [[{}, 'Wow']]], + ['interpolation', 'Hello {planet}', 'Hello {planet}', [ + [{ planet: 'Mars' }, 'Hello Mars'], [{ planet: 'Venus' }, 'Hello Venus'], + [{ planet: 'Earth' }, 'Hello Earth'], + ]], + ['select', '{gender|select,male:"He",female:"She",other:"They"} liked this.', + '{gender, select, male{He} female{She} other{They}} liked this.', [ + [{ gender: 'male' }, 'He liked this.'], [{ gender: 'female' }, 'She liked this.'], + [{ gender: 'other' }, 'They liked this.'], + ]], + ['number', '{x|number,style:"currency",currency:"EUR"}', '{x, number, ::currency/EUR}', [ + [{ x: 1234.5 }, '€1,234.50'], [{ x: 0 }, '€0.00'], [{ x: -42 }, '-€42.00'], + ]], + ['date', '{x|date}', '{x, date, ::ddMMyyyy}', [ + [{ x: 0 }, '01/01/1970'], [{ x: 86400000 }, '02/01/1970'], + [{ x: 172800000 }, '03/01/1970'], + ]], + ['plural', '{x|plural,one:"one",other:"# items"}', '{x, plural, one{one} other{# items}}', [ + [{ x: 0 }, '0 items'], [{ x: 1 }, 'one'], [{ x: 5 }, '5 items'], + ]], +]; + +function run(render, cases, count) { + let checksum = 0; + for (let i = 0; i < count; i++) checksum += render(cases[i % cases.length][0]).length; + return checksum; +} -const warmup = 1000; -const iterations = 10000; -const repeats = 5; const results = []; -for (const [name, template, value, expected] of [ - ['number', '{x|number,style:"currency",currency:"EUR"}', 1234.5, '€1,234.50'], - ['date', '{x|date}', 0, '01/01/1970'], - ['plural', '{x|plural,one:"one",other:"# items"}', 5, '5 items'], -]) { - const render = MessagePipe(intlTransformers('en-GB')).compile(template); - const props = { x: value }; - assert.equal(render(props), expected); - for (let i = 0; i < warmup; i++) render(props); - const samples = []; - let output; +for (const [name, pipeTemplate, mfTemplate, cases] of workloads) { + const renderers = { + messagepipe: pipe.compile(pipeTemplate), + messageformat: messageformat.compile(mfTemplate), + }; + for (const render of Object.values(renderers)) { + for (const [props, expected] of cases) assert.equal(render(props), expected); + run(render, cases, warmup); + } + const cycleLength = cases.reduce((sum, [, expected]) => sum + expected.length, 0); + const expectedChecksum = Math.floor(iterations / cases.length) * cycleLength + + cases.slice(0, iterations % cases.length).reduce((sum, [, expected]) => sum + expected.length, 0); + const samples = { messagepipe: [], messageformat: [] }; for (let sample = 0; sample < repeats; sample++) { - const start = performance.now(); - for (let i = 0; i < iterations; i++) output = render(props); - samples.push(performance.now() - start); - assert.equal(output, expected); + // Alternate measurement order to reduce systematic warmup/order bias. + const order = sample % 2 ? ['messageformat', 'messagepipe'] : ['messagepipe', 'messageformat']; + for (const library of order) { + const start = performance.now(); + const checksum = run(renderers[library], cases, iterations); + samples[library].push(performance.now() - start); + assert.equal(checksum, expectedChecksum); + } + } + const timings = {}; + for (const [library, values] of Object.entries(samples)) { + const median = [...values].sort((a, b) => a - b)[Math.floor(repeats / 2)]; + timings[library] = { samples_ms: values, median_ms: median, ops_per_second: iterations * 1000 / median }; } - const median = [...samples].sort((a, b) => a - b)[Math.floor(repeats / 2)]; - results.push({ name, template, value, samples_ms: samples, median_ms: median }); + results.push({ name, messagepipe_template: pipeTemplate, messageformat_template: mfTemplate, cases, timings }); } console.log(JSON.stringify({ - bundle, node: process.version, icu: process.versions.icu, + bundle, messageformat: `@messageformat/core@${require('@messageformat/core/package.json').version}`, + node: process.version, icu: process.versions.icu, platform: process.platform, arch: process.arch, cpu: cpus()[0].model, locale: 'en-GB', timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, warmup, iterations, repeats, results, From e91242716ee2f39fb3fcda972d707076b75b008a Mon Sep 17 00:00:00 2001 From: Marcis Bergmanis Date: Wed, 28 Oct 2026 14:51:25 +0200 Subject: [PATCH 4/7] Fix Messagepipe correctness and prepare built-in formatting paths --- README.md | 78 +++++++++++++++++++++++++---------------- scripts/benchmark.cjs | 19 ++++++++-- src/messagepipe.ts | 48 ++++++++++++++++++++----- src/parser.ts | 9 +++-- src/prepared.ts | 34 ++++++++++++++++++ src/regressions.test.ts | 69 ++++++++++++++++++++++++++++++++++++ src/transformers.ts | 43 +++++++++++++++++++++++ 7 files changed, 258 insertions(+), 42 deletions(-) create mode 100644 src/prepared.ts diff --git a/README.md b/README.md index 8a0a7bf..1e6f2c9 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Formats message strings with number, date, plural, and select placeholders to create localized messages. * **Small.** Zero runtime dependencies. -* **Fast.** Does absolute minimum amount of computations necessary. View [benchmarks](#benchmarks). +* **Fast.** Precompiles messages and reuses formatters. View [benchmarks](#benchmarks). * **Tree Shakable.** Includes separate global transformers config that can be omitted. * **Pipe syntax.** Transformer functions can customized and chained. * **View framework support.** Use React/Preact etc. components as transformers. @@ -242,6 +242,14 @@ const messagePipe = MessagePipe({ }) ``` +Built-in number, date, time, plural, and select pipes with scalar literal options +prepare their options on first render. Number/date/time/plural pipes retain at +most 100 formatted scalar values per compiled pipe. After 100 consecutive cache +misses, that result cache is cleared and bypassed for the lifetime of the pipe. +Mutable objects, omitted values, and negative zero bypass result caching. +Dynamic options and custom transformers retain their normal per-call behavior. +Replacing a transformer is still observed by already-compiled messages. + Compiled messages are cached within each MessagePipe instance, so transformer sets and locales stay independent. Set `disableCache: true` to bypass message caching. Omitted props are treated as an empty object. @@ -255,7 +263,7 @@ Now all the messages that get compiled from `messagePipe` can use this transform #### `compile` This is where given [message](#message) gets parsed and prepared for usage. It is very efficient compiler that does only 1 pass and prepares very tiny and performant function from it. -Given this message `"Hello {name | capitalize}!"`, compiler will output this function `(a) => "Hello " + capitalize(a.name) + "!"` and that is the only thing that runs when executing it. No hidden performance penalties. +Given this message `"Hello {name | capitalize}!"`, compiler will output this function `(a) => "Hello " + capitalize(a.name) + "!"` for the custom transformer call. #### `compileRaw` This is practically the same as [compile](#compile) but instead of it returning one string, it returns array of all of the things as a separate chunks so that this compiler can be used as part of React component for example. @@ -274,50 +282,60 @@ pinned for reproducibility and is development-only. | Workload | MessageFormat (ops/s) | MessagePipe (ops/s) | MessagePipe / MessageFormat | | --- | ---: | ---: | ---: | -| Plain text: `Wow` | 257,729,700 | 271,184,455 | 1.05× | -| Interpolation: `Hello {planet}` | 129,552,306 | 129,540,407 | 1.00× | -| Select: gender pronoun | 79,604,246 | 79,768,518 | 1.00× | -| Number: EUR currency | 4,672,308 | 957,623 | 0.20× | -| Date: day/month/year | 2,339,038 | 639,205 | 0.27× | -| Plural: item count | 4,081,153 | 1,914,604 | 0.47× | - -Higher throughput is better; ratios below 1 mean MessagePipe is slower. -Plain text, interpolation, and select are close enough that small differences -should not be treated as a reliable advantage. MessageFormat is faster on the -number, date, and plural workloads in this run. +| Plain text: `Wow` | 255,376,701 | 270,826,783 | 1.06× | +| Interpolation: `Hello {planet}` | 129,480,176 | 129,084,392 | 1.00× | +| Select: gender pronoun | 73,510,434 | 107,647,250 | 1.46× | +| Number: EUR currency | 4,729,385 | 74,172,405 | 15.68× | +| Date: day/month/year | 2,363,663 | 72,330,073 | 30.60× | +| Plural: item count | 4,274,800 | 64,555,366 | 15.10× | + +Higher throughput is better. Select and the Intl workloads are faster in these +measurements; interpolation is effectively tied. The small plain-text difference +is sensitive to loop overhead and does not establish a universal speed advantage. + +The number, date, and plural rows above rotate three values and benefit from +bounded formatted-value reuse. To expose cache-miss costs, a second run rotates +**1,000 distinct inputs** for each Intl workload: + +| Workload | MessageFormat (ops/s) | MessagePipe (ops/s) | MessagePipe / MessageFormat | +| --- | ---: | ---: | ---: | +| Number | 4,617,836 | 6,171,794 | 1.34× | +| Date | 2,342,674 | 2,648,567 | 1.13× | +| Plural | 3,332,201 | 3,767,459 | 1.13× | Both libraries compile once before timing and receive equivalent templates, -locales, and inputs. The original plain-text, interpolation, and select cases -are included alongside number, date, and plural formatting. Each non-constant -workload rotates through three inputs; both libraries must match the same -expected strings before measurement. Timed calls contribute to a checked output +locales, and inputs. Every input must produce the same expected string in both +libraries before measurement; timed calls contribute to a checked output checksum. Compiler initialization and compilation are excluded. -Each result uses the median of seven samples of **1,000,000 calls**, following -10,000 warmup calls per library and workload. Measurement order alternates -between libraries. The runner prints both templates, inputs, all timing samples, -and runtime metadata. Host: Linux x64 (WSL), AMD Ryzen 9 9950X, Node 22.23.2, -ICU 78.2, `en-GB`, UTC. MessagePipe uses its minified CommonJS build; -MessageFormat uses the functions returned by its installed compiler. +Tables use the median of **21 samples** per library and workload, collected in +three independent processes with seven samples each. Each sample performs +1,000,000 calls after 10,000 warmup calls. Measurement order alternates between +libraries. The runner prints templates, inputs, samples, and runtime metadata. +Host: Linux x64 (WSL), AMD Ryzen 9 9950X, Node 22.23.2, ICU 78.2, `en-GB`, UTC. +MessagePipe uses its minified CommonJS build; MessageFormat uses the functions +returned by its installed compiler. ```sh npm ci npm run build npm run benchmark +npm run benchmark -- --varied ``` -An optional CommonJS bundle path runs the same comparison against another -MessagePipe build: +Run each benchmark command three times to reproduce the table's sampling method. +An optional CommonJS bundle path compares another MessagePipe build: ```sh npm run benchmark -- /path/to/messagepipe/dist/index.js ``` -These are warm, repeated-format microbenchmarks, not application-wide throughput -guarantees. Cold construction, compilation, changing options, and other runtimes -can give different results. Timing includes the shared input-rotation and -checksum loop, which matters most for the fastest cases. Re-run on your target -runtime and representative messages before drawing broader conclusions. +These are warm formatting microbenchmarks. Literal options are prepared once, +and repeated immutable values may reuse a formatted result. The varied-input +mode exceeds the result cache capacity and exercises its automatic bypass. +Compilation, dynamic options, mutable objects, other runtimes, and other messages +can give different results. The shared input-rotation/checksum loop matters most +for the fastest cases. These results do not prove superiority on every workload. ## Framework integration Works with React and Preact out of the box. Just swap out [`compile`](#compile) with [`compileRaw`](#compile) and good to go. This works because it returns raw array of values that was the output of selectors and transformers. diff --git a/scripts/benchmark.cjs b/scripts/benchmark.cjs index 4445833..e6d4c12 100644 --- a/scripts/benchmark.cjs +++ b/scripts/benchmark.cjs @@ -6,7 +6,8 @@ const { cpus } = require('node:os'); const { resolve } = require('node:path'); const { performance } = require('node:perf_hooks'); const MessageFormat = require('@messageformat/core'); -const bundle = resolve(process.argv[2] || 'dist/index.js'); +const varied = process.argv.includes('--varied'); +const bundle = resolve(process.argv.slice(2).find(arg => arg !== '--varied') || 'dist/index.js'); const { MessagePipe, defaultTransformers, intlTransformers } = require(bundle); const pipe = MessagePipe({ ...defaultTransformers(), ...intlTransformers('en-GB') }); const messageformat = new MessageFormat('en-GB'); @@ -37,6 +38,20 @@ const workloads = [ ]], ]; +if (varied) { + const number = new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'EUR' }); + const date = new Intl.DateTimeFormat('en-GB', { year: 'numeric', month: 'numeric', day: 'numeric' }); + for (const workload of workloads) { + const [name] = workload; + if (name === 'number' || name === 'date' || name === 'plural') { + workload[3] = Array.from({ length: 1000 }, (_, i) => { + const x = name === 'date' ? i * 86400000 : name === 'plural' ? i : i + 1000; + return [{ x }, name === 'number' ? number.format(x) : name === 'date' ? date.format(x) : x === 1 ? 'one' : x + ' items']; + }); + } + } +} + function run(render, cases, count) { let checksum = 0; for (let i = 0; i < count; i++) checksum += render(cases[i % cases.length][0]).length; @@ -79,5 +94,5 @@ console.log(JSON.stringify({ node: process.version, icu: process.versions.icu, platform: process.platform, arch: process.arch, cpu: cpus()[0].model, locale: 'en-GB', timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, - warmup, iterations, repeats, results, + varied, warmup, iterations, repeats, results, }, null, 2)); diff --git a/src/messagepipe.ts b/src/messagepipe.ts index c0a47b9..d386710 100644 --- a/src/messagepipe.ts +++ b/src/messagepipe.ts @@ -1,4 +1,5 @@ import { parser } from './parser'; +import { preparations, type Transform } from './prepared'; export interface MessagePipeTransformer { (value: Input, options?: Record): Output; @@ -17,24 +18,55 @@ export function MessagePipe(transformers: MessagePipeTransformers = {}, { disabl const rcache: Record = Object.create(null); const scache: Record = Object.create(null); + function build(message: string, raw: boolean) { + const prepared: Transform[] = []; + const chunks = parser(message, (name, input, props) => { + const original = Object.getOwnPropertyDescriptor(transformers, name)?.value; + const prepare = preparations.get(original); + // Only scalar literals are safe to move out of the render path. Dynamic + // selectors, expressions and custom transformers keep their original behavior. + const literal = /^(?:"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|-?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?|true|false|null)$/; + if (!prepare || props.some(([key, value]) => + !/^[A-Za-z_$][\w$]*$/.test(key.trim()) || (value !== undefined && !literal.test(value.trim())))) { + return undefined; + } + const source = '{' + props.map(([key, value = 'true']) => key + ':' + value).join(',') + '}'; + const createOptions = Function('return (' + source + ')'); + const options = createOptions(); + // Preparation stays lazy so invalid Intl options still throw on rendering. + let render: Transform | undefined; + const index = prepared.push(value => { + const current = transformers[name]; + if (current !== original) { + if (props.length) { + return current.call(transformers, value, createOptions()); + } + return input.trim() ? current.call(transformers, value) : Reflect.apply(current, transformers, []); + } + render ??= prepare(options); + return render(value); + }) - 1; + return 'c[' + index + '](' + input + ')'; + }); + const expression = raw ? '[' + chunks.join(',') + ']' + : chunks.filter((chunk, index) => index === 0 || chunk !== '""').join('+'); + return Function('b', 'c', 'return (a={})=>' + expression)(transformers, prepared); + } + function compileRaw(message: string): (props?: Record) => Output { if (!disableCache && rcache[message]) { return rcache[message]; } - - const parsedMessage = Function('b', 'return (a={})=>[' + parser(message).join(',') + ']'); - - return disableCache ? parsedMessage(transformers) : rcache[message] = parsedMessage(transformers); + const render = build(message, true); + return disableCache ? render : rcache[message] = render; } function compile(message: string): (props?: Record) => Output { if (!disableCache && scache[message]) { return scache[message]; } - - const parsedMessage = Function('b', 'return (a={})=>' + parser(message).join('+')); - - return disableCache ? parsedMessage(transformers) : scache[message] = parsedMessage(transformers); + const render = build(message, false); + return disableCache ? render : scache[message] = render; } return { diff --git a/src/parser.ts b/src/parser.ts index 8419784..d6a88cc 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -27,7 +27,9 @@ function reducePipe(acc: string, [pipeName, ...pipeProps]: [string, ...[string, + ')'; } -export function parser(text: string): string[] { +export function parser(text: string, prepare?: ( + name: string, input: string, props: [string, string][] +) => string | undefined): string[] { const output: string[] = []; let chunk = ''; @@ -49,7 +51,10 @@ export function parser(text: string): string[] { ? 'a' + selectorTrim : 'a.' + selectorTrim : ''; - const output = localPipes.reduce(reducePipe, selector); + const output = localPipes.reduce((input, pipe) => { + const [name, ...props] = pipe; + return prepare?.(name.trim(), input, props) ?? reducePipe(input, pipe); + }, selector); return { index, diff --git a/src/prepared.ts b/src/prepared.ts new file mode 100644 index 0000000..f021efe --- /dev/null +++ b/src/prepared.ts @@ -0,0 +1,34 @@ +// Internal compiler hooks: only built-ins opt in to constant-option preparation. +export type Transform = (value: any, options?: Record) => any; +export const preparations = new WeakMap) => Transform>(); + +// Cache only immutable scalar inputs. Objects may have mutable coercion behavior, +// and undefined can mean the current time. Keep signed zero distinct by bypassing it. +export function memoizeFormat(format: Transform): Transform { + const cache = new Map(); + let misses = 0; + return value => { + if (misses >= 100) { + return format(value); + } + const type = typeof value; + if ((type !== 'string' && type !== 'number' && type !== 'bigint') || Object.is(value, -0)) { + return format(value); + } + const cached = cache.get(value); + if (cached !== undefined) { + misses = 0; + return cached; + } + const output = format(value); + if (++misses >= 100) { + cache.clear(); + return output; + } + if (cache.size >= 100) { + cache.delete(cache.keys().next().value); + } + cache.set(value, output); + return output; + }; +} diff --git a/src/regressions.test.ts b/src/regressions.test.ts index 9a16abb..93330eb 100644 --- a/src/regressions.test.ts +++ b/src/regressions.test.ts @@ -98,4 +98,73 @@ test('number options evaluate getters once per call', () => { assert.equal(reads, 2); }); +test('prepared formatting preserves signed zero, mutable inputs and cache eviction', () => { + const pipe = MessagePipe(intlTransformers('en-GB')); + const number = pipe.compile('{x|number,minimumFractionDigits:2}'); + assert.equal(number({ x: 0 }), '0.00'); + assert.equal(number({ x: -0 }), '-0.00'); + assert.equal(number({ x: 0 }), '0.00'); + let amount = 1; + const mutable = { valueOf: () => amount++ }; + assert.equal(number({ x: mutable }), '1.00'); + assert.equal(number({ x: mutable }), '2.00'); + for (let i = 1; i <= 110; i++) { + assert.equal(number({ x: i }), i + '.00'); + } + assert.equal(number({ x: 0 }), '0.00'); + const date = pipe.compile('{x|date,timeZone:"UTC"}'); + const instant = new Date(0); + assert.equal(date({ x: instant }), '01/01/1970'); + instant.setUTCDate(2); + assert.equal(date({ x: instant }), '02/01/1970'); +}); + +test('compiled built-ins remain replaceable and custom options stay fresh', () => { + const transformers = intlTransformers('en-GB'); + const original = transformers.number; + const pipe = MessagePipe(transformers); + const render = pipe.compile('{x|number,minimumFractionDigits:2}'); + assert.equal(render({ x: 1 }), '1.00'); + transformers.number = function(value, options) { + assert.is(this, transformers); + const digits = options.minimumFractionDigits; + options.minimumFractionDigits = 9; + return value + ':' + digits; + }; + assert.equal(render({ x: 1 }), '1:2'); + assert.equal(render({ x: 2 }), '2:2'); + transformers.number = original; + assert.equal(render({ x: 1 }), '1.00'); +}); + +test('constant preparation stays lazy and dynamic arguments remain live', () => { + const pipe = MessagePipe(intlTransformers('en-GB')); + const invalid = pipe.compile('{x|number,style:"invalid"}'); + assert.throws(() => invalid({ x: 1 }), RangeError); + const render = pipe.compile('{x|number,minimumFractionDigits:{digits}}'); + assert.equal(render({ x: 1, digits: 1 }), '1.0'); + assert.equal(render({ x: 1, digits: 3 }), '1.000'); + assert.equal(pipe.compileRaw('{x|number,minimumFractionDigits:2}')({ x: 1 }), ['', '1.00', '']); +}); + +test('adjacent placeholders preserve string coercion and raw values', () => { + const pipe = MessagePipe(); + assert.equal(pipe.compile('{a}{b}')({ a: 1, b: 2 }), '12'); + assert.equal(pipe.compileRaw('{a}{b}')({ a: 1, b: 2 }), ['', 1, '', 2, '']); +}); + +for (const locale of ['en-GB', 'de-DE', 'ar', 'lv']) { + test(`prepared formats preserve native Intl behavior in ${locale}`, () => { + const pipe = MessagePipe(intlTransformers(locale)); + const number = pipe.compile('{x|number,style:"currency",currency:"EUR"}'); + const expectedNumber = new Intl.NumberFormat(locale, { style: 'currency', currency: 'EUR' }); + const plural = pipe.compile('{x|plural,zero:"zero # #",one:"one # #",two:"two # #",few:"few # #",many:"many # #",other:"other # #"}'); + const rules = new Intl.PluralRules(locale); + for (const x of [0, -0, 1, 2, 5, 21, 1.5, NaN]) { + assert.equal(number({ x }), expectedNumber.format(x)); + assert.equal(plural({ x }), rules.select(x) + ' ' + x + ' ' + x); + } + }); +} + test.run(); diff --git a/src/transformers.ts b/src/transformers.ts index 53626db..5cf27bf 100644 --- a/src/transformers.ts +++ b/src/transformers.ts @@ -1,3 +1,5 @@ +import { preparations, memoizeFormat } from './prepared'; + // Bound retention when dynamic messages produce many distinct Intl options. function formatterCache(create: (options: Record) => T) { const cache = new Map(); @@ -68,6 +70,35 @@ export function intlTransformers(locale: string = 'en-GB') { }, }; + preparations.set(globalTransformers.number, options => { + const format = number(options).format; + return memoizeFormat(value => format(value)); + }); + for (const kind of ['date', 'time'] as const) { + preparations.set(globalTransformers[kind], options => { + const defaults = options.dateStyle || options.timeStyle ? {} : kind === 'date' + ? { year: 'numeric', month: 'numeric', day: 'numeric' } + : { hour: 'numeric', minute: 'numeric', second: 'numeric' }; + const format = date({ ...defaults, ...options }).format; + return memoizeFormat(value => format(value)); + }); + } + preparations.set(globalTransformers.plural, ({ zero, one, two, few, many, other, ...options }) => { + const rules = plural(options); + const choices: Record = { zero, one, two, few, many, other }; + const parts: Record = Object.create(null); + for (const [category, text] of Object.entries(choices)) { + if (typeof text === 'string') { + parts[category] = text.split('#'); + } + } + return memoizeFormat(value => { + const category = rules.select(value); + const chunks = parts[category] ?? (choices[category] == null ? parts.other : undefined); + return chunks ? chunks.join(value) : (choices[category] ?? other)?.split('#').join(value); + }); + }); + return globalTransformers; } @@ -81,5 +112,17 @@ export function defaultTransformers() { }, }; + const select = globalTransformers.select; + preparations.set(select, options => { + const choices = Object.assign(Object.create(null), options); + return value => { + if (typeof value !== 'string') { + return select(value, options); + } + const choice = choices[value]; + return choice === undefined ? options.other : choice; + }; + }); + return globalTransformers; } From d5b24d6a31cf4705bd81250404a71476666219e1 Mon Sep 17 00:00:00 2001 From: Marcis Bergmanis Date: Wed, 28 Oct 2026 15:13:40 +0200 Subject: [PATCH 5/7] Fix Messagepipe correctness and prepare built-in formatting paths --- README.md | 43 ++++++++++++++++++++++++----------------- scripts/benchmark.cjs | 13 ++++++++++++- src/messagepipe.ts | 27 +++++++++++++++++++------- src/prepared.ts | 37 +++++++++++++++++++++++++++++++++++ src/regressions.test.ts | 34 ++++++++++++++++++++++++++++++++ src/transformers.ts | 12 ++++++++++-- 6 files changed, 138 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 1e6f2c9..7fcb1fd 100644 --- a/README.md +++ b/README.md @@ -242,13 +242,16 @@ const messagePipe = MessagePipe({ }) ``` -Built-in number, date, time, plural, and select pipes with scalar literal options -prepare their options on first render. Number/date/time/plural pipes retain at -most 100 formatted scalar values per compiled pipe. After 100 consecutive cache -misses, that result cache is cleared and bypassed for the lifetime of the pipe. -Mutable objects, omitted values, and negative zero bypass result caching. -Dynamic options and custom transformers retain their normal per-call behavior. -Replacing a transformer is still observed by already-compiled messages. +Built-in number, date, time, plural, and select pipes prepare scalar literal +options on first render. Dynamic arguments are still evaluated on every render, +with preparation reused for at most 16 primitive option combinations per pipe. +Object-valued options use the generic path to preserve mutable coercion behavior. +Each prepared number/date/time/plural variant retains at most 100 scalar results +(up to 1,600 for a dynamic pipe). After 100 consecutive misses, that variant's +result cache is cleared and bypassed for its lifetime. Mutable inputs, omitted +values, and negative zero bypass result caching. Custom transformers keep their +normal per-call behavior. Already-compiled messages observe replacement functions +and resolve the transformer before evaluating its arguments. Compiled messages are cached within each MessagePipe instance, so transformer sets and locales stay independent. Set `disableCache: true` to bypass message @@ -282,31 +285,35 @@ pinned for reproducibility and is development-only. | Workload | MessageFormat (ops/s) | MessagePipe (ops/s) | MessagePipe / MessageFormat | | --- | ---: | ---: | ---: | -| Plain text: `Wow` | 255,376,701 | 270,826,783 | 1.06× | -| Interpolation: `Hello {planet}` | 129,480,176 | 129,084,392 | 1.00× | -| Select: gender pronoun | 73,510,434 | 107,647,250 | 1.46× | -| Number: EUR currency | 4,729,385 | 74,172,405 | 15.68× | -| Date: day/month/year | 2,363,663 | 72,330,073 | 30.60× | -| Plural: item count | 4,274,800 | 64,555,366 | 15.10× | +| Plain text: `Wow` | 256,691,037 | 271,400,102 | 1.06× | +| Interpolation: `Hello {planet}` | 128,767,263 | 128,096,099 | 0.99× | +| Select: gender pronoun | 74,325,542 | 107,985,658 | 1.45× | +| Number: EUR currency | 4,580,702 | 88,778,039 | 19.38× | +| Number: dynamic fraction digits | 2,230,485 | 8,701,840 | 3.90× | +| Date: day/month/year | 2,386,225 | 86,707,280 | 36.34× | +| Plural: item count | 4,163,758 | 86,921,254 | 20.88× | Higher throughput is better. Select and the Intl workloads are faster in these measurements; interpolation is effectively tied. The small plain-text difference is sensitive to loop overhead and does not establish a universal speed advantage. -The number, date, and plural rows above rotate three values and benefit from +The number, dynamic-number, date, and plural rows above rotate three values and benefit from bounded formatted-value reuse. To expose cache-miss costs, a second run rotates **1,000 distinct inputs** for each Intl workload: | Workload | MessageFormat (ops/s) | MessagePipe (ops/s) | MessagePipe / MessageFormat | | --- | ---: | ---: | ---: | -| Number | 4,617,836 | 6,171,794 | 1.34× | -| Date | 2,342,674 | 2,648,567 | 1.13× | -| Plural | 3,332,201 | 3,767,459 | 1.13× | +| Number: EUR currency | 4,694,507 | 6,450,686 | 1.37× | +| Number: dynamic fraction digits | 2,021,094 | 2,734,714 | 1.35× | +| Date: day/month/year | 2,352,332 | 2,740,688 | 1.17× | +| Plural: item count | 3,378,905 | 5,726,339 | 1.69× | Both libraries compile once before timing and receive equivalent templates, locales, and inputs. Every input must produce the same expected string in both libraries before measurement; timed calls contribute to a checked output -checksum. Compiler initialization and compilation are excluded. +checksum. The dynamic-number workload alternates two and three fraction digits; +MessagePipe uses dynamic pipe arguments and MessageFormat selects between two +compiled number skeletons. Compiler initialization and compilation are excluded. Tables use the median of **21 samples** per library and workload, collected in three independent processes with seven samples each. Each sample performs diff --git a/scripts/benchmark.cjs b/scripts/benchmark.cjs index e6d4c12..077111e 100644 --- a/scripts/benchmark.cjs +++ b/scripts/benchmark.cjs @@ -29,6 +29,11 @@ const workloads = [ ['number', '{x|number,style:"currency",currency:"EUR"}', '{x, number, ::currency/EUR}', [ [{ x: 1234.5 }, '€1,234.50'], [{ x: 0 }, '€0.00'], [{ x: -42 }, '-€42.00'], ]], + ['dynamic number', '{x|number,minimumFractionDigits:{digits},maximumFractionDigits:{digits}}', + '{digits, select, 2{{x, number, ::.00}} other{{x, number, ::.000}}}', [ + [{ x: 1.25, digits: 2 }, '1.25'], [{ x: 42, digits: 3 }, '42.000'], + [{ x: -8, digits: 2 }, '-8.00'], + ]], ['date', '{x|date}', '{x, date, ::ddMMyyyy}', [ [{ x: 0 }, '01/01/1970'], [{ x: 86400000 }, '02/01/1970'], [{ x: 172800000 }, '03/01/1970'], @@ -43,7 +48,13 @@ if (varied) { const date = new Intl.DateTimeFormat('en-GB', { year: 'numeric', month: 'numeric', day: 'numeric' }); for (const workload of workloads) { const [name] = workload; - if (name === 'number' || name === 'date' || name === 'plural') { + if (name === 'dynamic number') { + workload[3] = Array.from({ length: 1000 }, (_, i) => { + const digits = i % 2 ? 3 : 2; + const x = i / 4; + return [{ x, digits }, new Intl.NumberFormat('en-GB', { minimumFractionDigits: digits, maximumFractionDigits: digits }).format(x)]; + }); + } else if (name === 'number' || name === 'date' || name === 'plural') { workload[3] = Array.from({ length: 1000 }, (_, i) => { const x = name === 'date' ? i * 86400000 : name === 'plural' ? i : i + 1000; return [{ x }, name === 'number' ? number.format(x) : name === 'date' ? date.format(x) : x === 1 ? 'one' : x + ' items']; diff --git a/src/messagepipe.ts b/src/messagepipe.ts index d386710..fd64daf 100644 --- a/src/messagepipe.ts +++ b/src/messagepipe.ts @@ -1,5 +1,5 @@ import { parser } from './parser'; -import { preparations, type Transform } from './prepared'; +import { preparations, prepareDynamic, type Transform } from './prepared'; export interface MessagePipeTransformer { (value: Input, options?: Record): Output; @@ -19,24 +19,37 @@ export function MessagePipe(transformers: MessagePipeTransformers = {}, { disabl const scache: Record = Object.create(null); function build(message: string, raw: boolean) { - const prepared: Transform[] = []; + const prepared: Function[] = []; const chunks = parser(message, (name, input, props) => { const original = Object.getOwnPropertyDescriptor(transformers, name)?.value; const prepare = preparations.get(original); // Only scalar literals are safe to move out of the render path. Dynamic // selectors, expressions and custom transformers keep their original behavior. const literal = /^(?:"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|-?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?|true|false|null)$/; - if (!prepare || props.some(([key, value]) => - !/^[A-Za-z_$][\w$]*$/.test(key.trim()) || (value !== undefined && !literal.test(value.trim())))) { + if (!prepare || props.some(([key]) => !/^[A-Za-z_$][\w$]*$/.test(key.trim()) || key.trim() === '__proto__')) { return undefined; } + const constant = props.every(([, value]) => value === undefined || literal.test(value.trim())); const source = '{' + props.map(([key, value = 'true']) => key + ':' + value).join(',') + '}'; + const target = 'b[' + JSON.stringify(name) + ']'; + if (!constant) { + const getRender = prepareDynamic(prepare, props.map(([key]) => key.trim())); + const index = prepared.push((current: Transform, value: any, options: Record) => { + if (current === original) { + const render = getRender(options); + if (render) { + return render(value); + } + } + return current.call(transformers, value, options); + }) - 1; + return 'c[' + index + '](' + target + ',' + (input.trim() ? input : 'void 0') + ',' + source + ')'; + } const createOptions = Function('return (' + source + ')'); const options = createOptions(); // Preparation stays lazy so invalid Intl options still throw on rendering. let render: Transform | undefined; - const index = prepared.push(value => { - const current = transformers[name]; + const index = prepared.push((current: Transform, value: any) => { if (current !== original) { if (props.length) { return current.call(transformers, value, createOptions()); @@ -46,7 +59,7 @@ export function MessagePipe(transformers: MessagePipeTransformers = {}, { disabl render ??= prepare(options); return render(value); }) - 1; - return 'c[' + index + '](' + input + ')'; + return 'c[' + index + '](' + target + (input.trim() ? ',' + input : '') + ')'; }); const expression = raw ? '[' + chunks.join(',') + ']' : chunks.filter((chunk, index) => index === 0 || chunk !== '""').join('+'); diff --git a/src/prepared.ts b/src/prepared.ts index f021efe..29e39c2 100644 --- a/src/prepared.ts +++ b/src/prepared.ts @@ -32,3 +32,40 @@ export function memoizeFormat(format: Transform): Transform { return output; }; } + +// Dynamic arguments are evaluated by the generated function before this lookup. +// Only immutable option values can safely reuse a prepared closure. +export function prepareDynamic(prepare: (options: Record) => Transform, keys: string[]) { + const cache = new Map(); + let previous: Record | undefined; + let previousRender: Transform | undefined; + return (options: Record): Transform | undefined => { + let same = previous !== undefined; + let key = ''; + for (const name of keys) { + const value = options[name]; + const type = typeof value; + if ((value !== null && type !== 'string' && type !== 'number' && type !== 'boolean' && type !== 'undefined') + || (type === 'number' && !Number.isFinite(value))) { + return undefined; + } + same = same && Object.is(value, previous![name]); + const text = Object.is(value, -0) ? '-0' : String(value); + key += type + ':' + text.length + ':' + text; + } + if (same) { + return previousRender; + } + let render = cache.get(key); + if (!render) { + render = prepare(options); + if (cache.size >= 16) { + cache.delete(cache.keys().next().value!); + } + cache.set(key, render); + } + previous = options; + previousRender = render; + return render; + }; +} diff --git a/src/regressions.test.ts b/src/regressions.test.ts index 93330eb..83f2b66 100644 --- a/src/regressions.test.ts +++ b/src/regressions.test.ts @@ -167,4 +167,38 @@ for (const locale of ['en-GB', 'de-DE', 'ar', 'lv']) { }); } +for (const template of ['{x|number,minimumFractionDigits:2}', '{x|number,minimumFractionDigits:{digits}}']) { + test(`transformer lookup precedes argument evaluation: ${template}`, () => { + const transformers = intlTransformers('en-GB'); + const original = transformers.number; + const render = MessagePipe(transformers).compile(template); + const props = { get x() { transformers.number = () => 'replacement'; return 1; }, digits: 2 }; + assert.equal(render(props), '1.00'); + assert.equal(render({ x: 1, digits: 2 }), 'replacement'); + transformers.number = original; + assert.equal(render({ x: 1, get digits() { transformers.number = () => 'replacement'; return 2; } }), '1.00'); + }); +} + +test('dynamic preparation preserves option changes, objects and undefined choices', () => { + const pipe = MessagePipe({ ...intlTransformers('en-GB'), ...defaultTransformers() }); + const number = pipe.compile('{x|number,minimumFractionDigits:{digits},maximumFractionDigits:{digits}}'); + for (let digits = 0; digits < 20; digits++) { + assert.equal(number({ x: 1, digits }), digits ? '1.' + '0'.repeat(digits) : '1'); + } + assert.equal(number({ x: 1, digits: 2 }), '1.00'); + let digits = 3; + const mutable = { valueOf: () => digits }; + assert.equal(number({ x: 1, digits: mutable }), '1.000'); + digits = 2; + assert.equal(number({ x: 1, digits: mutable }), '1.00'); + assert.throws(() => number({ x: 1, digits: -1 }), RangeError); + const select = pipe.compileRaw('{key|select,match:{choice},other:"fallback"}'); + assert.equal(select({ key: 'match', choice: undefined }), ['', undefined, '']); + assert.equal(select({ key: 'match', choice: null }), ['', null, '']); + assert.equal(select({ key: 'match', choice: '' }), ['', '', '']); + assert.equal(select({ key: 'missing', choice: undefined }), ['', 'fallback', '']); +}); + + test.run(); diff --git a/src/transformers.ts b/src/transformers.ts index 5cf27bf..c2dd913 100644 --- a/src/transformers.ts +++ b/src/transformers.ts @@ -95,7 +95,15 @@ export function intlTransformers(locale: string = 'en-GB') { return memoizeFormat(value => { const category = rules.select(value); const chunks = parts[category] ?? (choices[category] == null ? parts.other : undefined); - return chunks ? chunks.join(value) : (choices[category] ?? other)?.split('#').join(value); + if (chunks) { + // The common single-marker case needs no temporary join operation. + // Preserve join's coercion behavior for objects and other input types. + if (chunks.length === 2 && (typeof value === 'number' || typeof value === 'string')) { + return chunks[0] + value + chunks[1]; + } + return chunks.join(value); + } + return (choices[category] ?? other)?.split('#').join(value); }); }); @@ -120,7 +128,7 @@ export function defaultTransformers() { return select(value, options); } const choice = choices[value]; - return choice === undefined ? options.other : choice; + return choice !== undefined || Object.prototype.hasOwnProperty.call(choices, value) ? choice : options.other; }; }); From 7a1a89716e1deddc89599b07c30428eb1c2ee10c Mon Sep 17 00:00:00 2001 From: Marcis Bergmanis Date: Wed, 28 Oct 2026 15:43:48 +0200 Subject: [PATCH 6/7] Fix Messagepipe correctness and prepare built-in formatting paths --- README.md | 31 +++++++++++++++++++------------ scripts/benchmark.cjs | 4 +++- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 7fcb1fd..2070a83 100644 --- a/README.md +++ b/README.md @@ -285,13 +285,13 @@ pinned for reproducibility and is development-only. | Workload | MessageFormat (ops/s) | MessagePipe (ops/s) | MessagePipe / MessageFormat | | --- | ---: | ---: | ---: | -| Plain text: `Wow` | 256,691,037 | 271,400,102 | 1.06× | -| Interpolation: `Hello {planet}` | 128,767,263 | 128,096,099 | 0.99× | -| Select: gender pronoun | 74,325,542 | 107,985,658 | 1.45× | -| Number: EUR currency | 4,580,702 | 88,778,039 | 19.38× | -| Number: dynamic fraction digits | 2,230,485 | 8,701,840 | 3.90× | -| Date: day/month/year | 2,386,225 | 86,707,280 | 36.34× | -| Plural: item count | 4,163,758 | 86,921,254 | 20.88× | +| Plain text: `Wow` | 256,043,589 | 269,588,503 | 1.05× | +| Interpolation: `Hello {planet}` | 130,454,294 | 129,213,173 | 0.99× | +| Select: gender pronoun | 74,260,093 | 107,320,883 | 1.45× | +| Number: EUR currency | 4,548,900 | 89,069,331 | 19.58× | +| Number: dynamic fraction digits | 2,184,894 | 8,686,990 | 3.98× | +| Date: day/month/year | 2,355,474 | 86,348,441 | 36.66× | +| Plural: item count | 4,297,437 | 86,933,654 | 20.23× | Higher throughput is better. Select and the Intl workloads are faster in these measurements; interpolation is effectively tied. The small plain-text difference @@ -299,14 +299,21 @@ is sensitive to loop overhead and does not establish a universal speed advantage The number, dynamic-number, date, and plural rows above rotate three values and benefit from bounded formatted-value reuse. To expose cache-miss costs, a second run rotates -**1,000 distinct inputs** for each Intl workload: +**1,000 distinct inputs** for interpolation and each Intl workload: | Workload | MessageFormat (ops/s) | MessagePipe (ops/s) | MessagePipe / MessageFormat | | --- | ---: | ---: | ---: | -| Number: EUR currency | 4,694,507 | 6,450,686 | 1.37× | -| Number: dynamic fraction digits | 2,021,094 | 2,734,714 | 1.35× | -| Date: day/month/year | 2,352,332 | 2,740,688 | 1.17× | -| Plural: item count | 3,378,905 | 5,726,339 | 1.69× | +| Interpolation: `Hello {planet}` | 167,120,929 | 168,019,475 | 1.01× | +| Number: EUR currency | 4,586,144 | 6,132,395 | 1.34× | +| Number: dynamic fraction digits | 1,991,615 | 2,734,228 | 1.37× | +| Date: day/month/year | 2,305,404 | 2,684,219 | 1.16× | +| Plural: item count | 3,337,506 | 5,730,987 | 1.72× | + +Plain text compiles to a constant return. Simple interpolation compiles to a +property read and string concatenation. Neither uses a rendered-string cache: +lookup overhead can exceed the concatenation cost when inputs vary. These +paths have little remaining work to eliminate, so small throughput differences +should not be treated as reliable improvements. Both libraries compile once before timing and receive equivalent templates, locales, and inputs. Every input must produce the same expected string in both diff --git a/scripts/benchmark.cjs b/scripts/benchmark.cjs index 077111e..67b2cfc 100644 --- a/scripts/benchmark.cjs +++ b/scripts/benchmark.cjs @@ -48,7 +48,9 @@ if (varied) { const date = new Intl.DateTimeFormat('en-GB', { year: 'numeric', month: 'numeric', day: 'numeric' }); for (const workload of workloads) { const [name] = workload; - if (name === 'dynamic number') { + if (name === 'interpolation') { + workload[3] = Array.from({ length: 1000 }, (_, i) => [{ planet: 'Planet ' + i }, 'Hello Planet ' + i]); + } else if (name === 'dynamic number') { workload[3] = Array.from({ length: 1000 }, (_, i) => { const digits = i % 2 ? 3 : 2; const x = i / 4; From b3de6b0af84fbde2fc0fb794d7dd797d5056cb4a Mon Sep 17 00:00:00 2001 From: Marcis Bergmanis Date: Wed, 28 Oct 2026 18:08:55 +0200 Subject: [PATCH 7/7] Fix Messagepipe correctness and prepare built-in formatting paths --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 573473a..806a002 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "messagepipe", - "version": "0.2.3", + "version": "0.2.4", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "messagepipe", - "version": "0.2.3", + "version": "0.2.4", "license": "MIT", "devDependencies": { "@messageformat/core": "3.4.1", diff --git a/package.json b/package.json index 1256a8b..65ab02a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "messagepipe", - "version": "0.2.3", + "version": "0.2.4", "description": "Formats message strings with number, date, plural, and select placeholders to create localized messages", "main": "dist/index.js", "module": "dist/index.mjs",