diff --git a/.gitignore b/.gitignore index bfc136b..977259c 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,8 @@ dist/ .astro .yarn docs-dist + +# Perf profiling artifacts +*.cpuprofile +.perf/ +benchmarks/minidom/profiles/ diff --git a/benchmarks/README.md b/benchmarks/README.md new file mode 100644 index 0000000..60f2d61 --- /dev/null +++ b/benchmarks/README.md @@ -0,0 +1,44 @@ +# DOM Benchmarks + +This folder contains a browser DOM benchmark that compares: + +- `Regor` (local source from this repository) +- `Vue@latest` (loaded from CDN at runtime) + +## Workload + +The benchmark mounts a large bound list and then runs repeated manipulations: + +- `for`-bound list rendering with keyed rows +- row property updates (`label`, `value`, `active`) +- array operations (`splice`, `push`, `shift`) + +Each framework runs the same operation pattern; timings are measured with +`performance.now()`. + +## Run + +1. Build benchmark bundle: + +```bash +yarn bench:build +``` + +2. Start static server: + +```bash +yarn bench:serve +``` + +3. Open: + +`http://localhost:4177/` + +4. Click **Run Benchmark**. + +## Notes + +- `Vue@latest` requires internet access. +- Run multiple times and compare medians. +- Keep browser/devtools conditions consistent for fair comparisons. + diff --git a/benchmarks/build.ts b/benchmarks/build.ts new file mode 100644 index 0000000..a53e2b8 --- /dev/null +++ b/benchmarks/build.ts @@ -0,0 +1,14 @@ +import { build } from 'esbuild' + +await build({ + entryPoints: ['benchmarks/src/index.ts', 'benchmarks/src/initial-load.ts'], + outdir: 'benchmarks/dist', + bundle: true, + format: 'esm', + platform: 'browser', + target: ['es2022'], + sourcemap: true, + legalComments: 'none', +}) + +console.log('Built benchmarks/dist/*.js') diff --git a/benchmarks/index.html b/benchmarks/index.html new file mode 100644 index 0000000..47752bd --- /dev/null +++ b/benchmarks/index.html @@ -0,0 +1,129 @@ + + + + + + Regor vs Vue DOM Benchmark + + + +
+
+

DOM Benchmark: Regor vs Vue (latest)

+

+ Workload: mount a bound list, then run repeated list/data manipulations + (`for` binding updates, splices, push/shift, property updates). +

+ + + + + + + + + + + + + + + + + + +
FrameworkMount MedianMount P90Mutate MedianMutate P90Total MedianTotal P90Rows FinalSamples
+
+
+
+ Regor Root +
+
+
+ Vue Root +
+
+
+
+ Tip: run a few times and compare medians in devtools Performance panel. +
+
+
+ + + diff --git a/benchmarks/initial-load.html b/benchmarks/initial-load.html new file mode 100644 index 0000000..ad2e7d4 --- /dev/null +++ b/benchmarks/initial-load.html @@ -0,0 +1,119 @@ + + + + + + Regor vs Vue Initial Load Benchmark + + + +
+
+

Initial Load Benchmark: Regor vs Vue

+

Measures mount time only.

+ + + + + + + + + + + + + + +
FrameworkMount MedianMount P90Rows FinalSamples
+
+
+
+ Regor Root +
+
+
+ Vue Root +
+
+
+
+
+ + + diff --git a/benchmarks/minidom/README.md b/benchmarks/minidom/README.md new file mode 100644 index 0000000..ec83761 --- /dev/null +++ b/benchmarks/minidom/README.md @@ -0,0 +1,112 @@ +# Minidom Perf Suite + +Regor performance suite running on `tests/minidom`, with baseline comparison and optional regression gate. + +## Quick Start + +Run with default row count (`500`): + +```bash +yarn perf +``` + +Run with custom rows: + +```bash +yarn perf 1000 +``` + +Record baseline for a row count: + +```bash +yarn perf:record 1000 +``` + +Gate against baseline: + +```bash +yarn perf:check 1000 +``` + +## Modes + +- `perf` (`run` mode) + - Executes suite, prints table, compares against baseline if present. + - Interactive in TTY: `Enter` reruns, `q` quits. +- `perf:record` (`record` mode) + - Executes suite, compares against current baseline, then overwrites baseline with new results. +- `perf:check` (`check` mode) + - Executes suite, compares against baseline, fails process on regression over threshold (configurable). + +## Profiling + +Generate CPU profile + hotspot report in one command: + +```bash +yarn perf:profile 2000 +``` + +Optional second argument controls how many hotspot lines are printed: + +```bash +yarn perf:profile 2000 40 +``` + +What this does: + +- Bundles `benchmarks/minidom/perf.ts` to plain Node JS (removes tsx runtime noise). +- Runs the benchmark under Node CPU profiler. +- Prints top functions and top frames (including Regor/minidom-focused frames). +- Writes artifacts to `benchmarks/minidom/profiles/`: + - `perf--.cpuprofile` + - `perf--.summary.txt` + +Tip: +- Use `PERF_SAMPLES` and `PERF_WARMUPS` to tune profile stability. +- Example: + - `PERF_SAMPLES=30 PERF_WARMUPS=8 yarn perf:profile 2000` + +## Output + +Table columns: + +- `Median`, `P90`: primary runtime metrics per scenario. +- `Mean`, `Min`, `Max`: secondary distribution metrics. +- `UnmMed`, `UnmP90`: unmount cleanup timing. + +Comparison section: + +- `IMPROVEMENT ... (x.xx% faster)` +- `OK ... (x.xx% slower)` (within threshold) +- `REGRESSION ... (x.xx% slower)` (beyond threshold in check mode fails by default) + +## Baselines + +Baselines are row-count specific: + +- `benchmarks/minidom/perf-baseline..json` +- Example: `benchmarks/minidom/perf-baseline.500.json` + +If a baseline does not exist, create it first: + +```bash +yarn perf:record 500 +``` + +## Environment Variables + +- `PERF_SAMPLES` (default `20`) + - Number of measured runs per scenario. +- `PERF_WARMUPS` (default `5`) + - Number of warmup runs per scenario (not measured). +- `PERF_MAX_REGRESSION_PCT` (default `5`) + - Maximum allowed median slowdown per scenario for gate failure. +- `PERF_FAIL_ON_REGRESSION` (default `1`) + - `1`: fail on regression over threshold. + - `0`: warn only. + +Example: + +```bash +PERF_SAMPLES=30 PERF_WARMUPS=8 PERF_MAX_REGRESSION_PCT=3 yarn perf:check 1000 +``` diff --git a/benchmarks/minidom/perf-baseline.1000.json b/benchmarks/minidom/perf-baseline.1000.json new file mode 100644 index 0000000..a1abde1 --- /dev/null +++ b/benchmarks/minidom/perf-baseline.1000.json @@ -0,0 +1,62 @@ +{ + "meta": { + "version": 1, + "createdAt": "2026-02-24T07:24:04.513Z", + "platform": "win32", + "arch": "x64", + "node": "v22.16.0", + "rowCount": 1000, + "samples": 20, + "warmups": 5 + }, + "results": { + "mount_rfor_rtext": { + "median": 73.023, + "p90": 80.194, + "mean": 74.312, + "min": 68.114, + "max": 83.47, + "samples": 20, + "unmount": { + "median": 7.723, + "p90": 8.965, + "mean": 8.021, + "min": 7.033, + "max": 11.321, + "samples": 20 + } + }, + "mutate_rows_fields": { + "median": 87.079, + "p90": 92.333, + "mean": 87.982, + "min": 84.215, + "max": 101.536, + "samples": 20, + "unmount": { + "median": 8.655, + "p90": 9.838, + "mean": 8.678, + "min": 7.588, + "max": 10.206, + "samples": 20 + } + }, + "toggle_if_loop": { + "median": 11.49, + "p90": 11.779, + "mean": 11.277, + "min": 10.591, + "max": 12.1, + "samples": 20, + "unmount": { + "median": 0.309, + "p90": 0.337, + "mean": 0.31, + "min": 0.259, + "max": 0.411, + "samples": 20 + } + } + } +} diff --git a/benchmarks/minidom/perf-baseline.2000.json b/benchmarks/minidom/perf-baseline.2000.json new file mode 100644 index 0000000..8c1578f --- /dev/null +++ b/benchmarks/minidom/perf-baseline.2000.json @@ -0,0 +1,62 @@ +{ + "meta": { + "version": 1, + "createdAt": "2026-02-24T07:24:39.020Z", + "platform": "win32", + "arch": "x64", + "node": "v22.16.0", + "rowCount": 2000, + "samples": 20, + "warmups": 5 + }, + "results": { + "mount_rfor_rtext": { + "median": 142.506, + "p90": 154.709, + "mean": 145.585, + "min": 139.945, + "max": 161.472, + "samples": 20, + "unmount": { + "median": 16.02, + "p90": 18.554, + "mean": 16.629, + "min": 15.139, + "max": 20.52, + "samples": 20 + } + }, + "mutate_rows_fields": { + "median": 175.106, + "p90": 182.876, + "mean": 175.632, + "min": 169.708, + "max": 183.928, + "samples": 20, + "unmount": { + "median": 17.867, + "p90": 19.179, + "mean": 18.065, + "min": 17.053, + "max": 19.766, + "samples": 20 + } + }, + "toggle_if_loop": { + "median": 21.255, + "p90": 21.506, + "mean": 21.269, + "min": 20.292, + "max": 22.939, + "samples": 20, + "unmount": { + "median": 0.549, + "p90": 0.663, + "mean": 0.573, + "min": 0.497, + "max": 0.706, + "samples": 20 + } + } + } +} diff --git a/benchmarks/minidom/perf-baseline.500.json b/benchmarks/minidom/perf-baseline.500.json new file mode 100644 index 0000000..00f0c98 --- /dev/null +++ b/benchmarks/minidom/perf-baseline.500.json @@ -0,0 +1,62 @@ +{ + "meta": { + "version": 1, + "createdAt": "2026-02-24T07:23:35.961Z", + "platform": "win32", + "arch": "x64", + "node": "v22.16.0", + "rowCount": 500, + "samples": 20, + "warmups": 5 + }, + "results": { + "mount_rfor_rtext": { + "median": 36.695, + "p90": 42.143, + "mean": 37.997, + "min": 34.115, + "max": 47.745, + "samples": 20, + "unmount": { + "median": 3.852, + "p90": 4.428, + "mean": 3.879, + "min": 3.119, + "max": 6.8, + "samples": 20 + } + }, + "mutate_rows_fields": { + "median": 42.208, + "p90": 47.186, + "mean": 42.969, + "min": 41.301, + "max": 47.397, + "samples": 20, + "unmount": { + "median": 3.249, + "p90": 4.4, + "mean": 3.407, + "min": 2.811, + "max": 4.437, + "samples": 20 + } + }, + "toggle_if_loop": { + "median": 5.5, + "p90": 6.532, + "mean": 5.895, + "min": 5.151, + "max": 8.589, + "samples": 20, + "unmount": { + "median": 0.172, + "p90": 0.248, + "mean": 0.187, + "min": 0.144, + "max": 0.308, + "samples": 20 + } + } + } +} diff --git a/benchmarks/minidom/perf.ts b/benchmarks/minidom/perf.ts new file mode 100644 index 0000000..72a3166 --- /dev/null +++ b/benchmarks/minidom/perf.ts @@ -0,0 +1,417 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { performance } from 'node:perf_hooks' +import process from 'node:process' +import readline from 'node:readline' + +import { createApp, drainUnbind, html, ref, sref } from '../../src' +import { App } from '../../src/api/types' +import { createDom } from '../../tests/minidom/createDom' + +type Stats = { + median: number + p90: number + mean: number + min: number + max: number + samples: number +} + +type ScenarioResult = Stats & { + unmount: { + median: number + p90: number + mean: number + min: number + max: number + samples: number + } +} + +type PerfBaseline = { + meta: { + version: number + createdAt: string + platform: string + arch: string + node: string + rowCount: number + samples: number + warmups: number + } + results: Record +} + +type Scenario = { + name: string + run: () => Promise<{ runMs: number; unmountMs: number }> +} + +const supportsColor = process.stdout.isTTY +const color = (code: number, text: string) => + supportsColor ? `\x1b[${code}m${text}\x1b[0m` : text +const bold = (text: string) => color(1, text) +const cyan = (text: string) => color(36, text) +const green = (text: string) => color(32, text) +const dim = (text: string) => color(2, text) +const yellow = (text: string) => color(33, text) +const red = (text: string) => color(31, text) + +const parseModeAndRows = (): { + mode: 'run' | 'record' | 'check' + rows: number +} => { + const arg2 = (process.argv[2] ?? '').toLowerCase() + const arg3 = process.argv[3] + const mode = + arg2 === 'record' || arg2 === 'check' || arg2 === 'run' ? arg2 : 'run' + const rowArg = mode === 'run' && arg2 !== 'run' ? process.argv[2] : arg3 + const rows = Math.max(1, Number(rowArg ?? 500) || 500) + return { mode, rows } +} + +const { mode, rows: rowCount } = parseModeAndRows() +const baselinePath = path.resolve( + process.cwd(), + `benchmarks/minidom/perf-baseline.${rowCount}.json`, +) + +const toFixed = (n: number): number => Number(n.toFixed(3)) + +const stats = (data: number[]): Stats => { + const sorted = [...data].sort((a, b) => a - b) + const len = sorted.length + const median = + len % 2 === 0 + ? (sorted[len / 2 - 1] + sorted[len / 2]) / 2 + : sorted[(len - 1) / 2] + const p90 = sorted[Math.min(len - 1, Math.ceil(len * 0.9) - 1)] + const sum = sorted.reduce((p, c) => p + c, 0) + return { + median: toFixed(median), + p90: toFixed(p90), + mean: toFixed(sum / len), + min: toFixed(sorted[0]), + max: toFixed(sorted[len - 1]), + samples: len, + } +} + +const withApp = async ( + template: string, + contextFactory: () => Record, + run: (app: App>) => void, +): Promise<{ runMs: number; unmountMs: number }> => { + const cleanup = createDom('
') + try { + const context = contextFactory() + const t0 = performance.now() + const app = createApp(context, { + selector: '#app', + template: html`${template}`, + }) + run(app) + const t1 = performance.now() + app.unmount() + await drainUnbind() + const t2 = performance.now() + return { + runMs: t1 - t0, + unmountMs: t2 - t1, + } + } finally { + await drainUnbind() + cleanup() + } +} + +const createRows = (count: number) => + Array.from({ length: count }, (_, i) => + ref({ + id: ref(i), + label: ref(`row-${i}`), + value: ref(i * 2), + }), + ) + +const scenarios: Scenario[] = [ + { + name: 'mount_rfor_rtext', + run: () => + withApp( + `
+

+ + + +

+
`, + () => ({ rows: sref(createRows(rowCount)) }), + () => {}, + ), + }, + { + name: 'mutate_rows_fields', + run: () => + withApp( + `
+

+ + + +

+
`, + () => ({ rows: sref(createRows(rowCount)) }), + (app) => { + const rows = app.context.rows as ReturnType> + const list = rows() + for (let i = 0; i < list.length; ++i) { + const row = list[i]() + row.label(`next-${i}`) + row.value(i + 1000) + } + }, + ), + }, + { + name: 'toggle_if_loop', + run: () => + withApp( + `
+

a

+

b

+
`, + () => ({ visible: sref(true) }), + (app) => { + const visible = app.context.visible as ReturnType< + typeof sref + > + for (let i = 0; i < rowCount; ++i) visible(i % 2 === 0) + }, + ), + }, +] + +const samples = Number(process.env.PERF_SAMPLES ?? 20) +const warmups = Number(process.env.PERF_WARMUPS ?? 5) +const maxRegressionPct = Number(process.env.PERF_MAX_REGRESSION_PCT ?? 5) +const failOnRegression = process.env.PERF_FAIL_ON_REGRESSION !== '0' + +const runSuite = async (): Promise> => { + const out: Record = {} + for (const scenario of scenarios) { + for (let i = 0; i < warmups; ++i) await scenario.run() + const runs: number[] = [] + const unmounts: number[] = [] + for (let i = 0; i < samples; ++i) { + const r = await scenario.run() + runs.push(r.runMs) + unmounts.push(r.unmountMs) + } + out[scenario.name] = { + ...stats(runs), + unmount: stats(unmounts), + } + } + return out +} + +const printResults = (results: Record): void => { + const nameW = 30 + const numW = 8 + const formatCell = ( + value: string, + width: number, + align: 'left' | 'right' = 'right', + ): string => + align === 'left' ? value.padEnd(width, ' ') : value.padStart(width, ' ') + const fmtNum = (n: number): string => formatCell(n.toFixed(3), numW, 'right') + + console.log(`\n${bold('Minidom Performance Results')}`) + const header = + `${formatCell('Scenario', nameW, 'left')} ` + + `${formatCell('Median', numW)} ` + + `${formatCell('P90', numW)} ` + + `${formatCell('Mean', numW)} ` + + `${formatCell('Min', numW)} ` + + `${formatCell('Max', numW)} ` + + `${formatCell('UnmMed', numW)} ` + + `${formatCell('UnmP90', numW)}` + console.log(header) + for (const [name, r] of Object.entries(results)) { + const n = formatCell(name, nameW, 'left') + const median = green(fmtNum(r.median)) + const p90 = green(fmtNum(r.p90)) + const mean = dim(green(fmtNum(r.mean))) + const min = dim(green(fmtNum(r.min))) + const max = dim(green(fmtNum(r.max))) + const unmountMedian = green(fmtNum(r.unmount.median)) + const unmountP90 = dim(green(fmtNum(r.unmount.p90))) + console.log( + `${cyan(n)} ${median} ${p90} ${mean} ${min} ${max} ${unmountMedian} ${unmountP90}`, + ) + } +} + +const recordBaseline = (results: Record): void => { + const baseline: PerfBaseline = { + meta: { + version: 1, + createdAt: new Date().toISOString(), + platform: process.platform, + arch: os.arch(), + node: process.version, + rowCount, + samples, + warmups, + }, + results, + } + fs.mkdirSync(path.dirname(baselinePath), { recursive: true }) + fs.writeFileSync(baselinePath, JSON.stringify(baseline, null, 2) + '\n') + console.log(`\n${green('Recorded baseline:')} ${baselinePath}`) +} + +const loadBaseline = (): PerfBaseline | null => { + if (!fs.existsSync(baselinePath)) return null + return JSON.parse(fs.readFileSync(baselinePath, 'utf-8')) as PerfBaseline +} + +const compareToBaseline = ( + results: Record, + enforceGate: boolean, +): number => { + const baseline = loadBaseline() + if (!baseline) { + console.warn( + `\n${yellow('No baseline found')} at ${baselinePath}. Run: yarn perf:record ${rowCount}`, + ) + return enforceGate ? 2 : 0 + } + + let regressions = 0 + console.log( + `\n${bold('Comparing to baseline')} (max allowed regression: ${maxRegressionPct.toFixed(2)}%)`, + ) + for (const [name, current] of Object.entries(results)) { + const base = baseline.results[name] + if (!base) { + console.warn(`- ${name}: missing in baseline (skipped)`) + continue + } + const pct = ((current.median - base.median) / base.median) * 100 + if (pct > maxRegressionPct) { + ++regressions + console.warn( + red( + `- REGRESSION ${name}: median ${base.median.toFixed(3)} -> ${current.median.toFixed(3)} (${pct.toFixed(2)}% slower)`, + ), + ) + } else if (pct < 0) { + const fasterPct = Math.abs(pct) + console.log( + green( + `- IMPROVEMENT ${name}: median ${base.median.toFixed(3)} -> ${current.median.toFixed(3)} (${fasterPct.toFixed(2)}% faster)`, + ), + ) + } else { + console.log( + yellow( + `- OK ${name}: median ${base.median.toFixed(3)} -> ${current.median.toFixed(3)} (${pct.toFixed(2)}% slower)`, + ), + ) + } + } + + if (regressions > 0 && enforceGate && failOnRegression) { + console.error( + `\n${red(`Performance gate failed: ${regressions} regression(s).`)}`, + ) + return 1 + } + if (regressions > 0) { + console.warn( + `\n${yellow( + enforceGate + ? `Performance gate warning: ${regressions} regression(s).` + : `Comparison warning: ${regressions} regression(s).`, + )}`, + ) + return 0 + } + console.log( + `\n${green(enforceGate ? 'Performance gate passed.' : 'Comparison passed.')}`, + ) + return 0 +} + +const runOnce = async (): Promise => { + if (process.stdout.isTTY) console.clear() + console.log( + `\n${bold('Running minidom perf suite...')} mode=${mode}, rows=${rowCount}, samples=${samples}, warmups=${warmups}`, + ) + console.log(cyan(`Scenarios: ${scenarios.map((x) => x.name).join(', ')}`)) + const results = await runSuite() + printResults(results) + if (mode === 'record') { + compareToBaseline(results, false) + recordBaseline(results) + return 0 + } + if (mode === 'check') return compareToBaseline(results, true) + compareToBaseline(results, false) + return 0 +} + +const runInteractive = async (): Promise => { + let code = await runOnce() + if (mode !== 'run' || !process.stdin.isTTY || !process.stdout.isTTY) { + return code + } + + console.log('\nPress Enter to rerun, press q to quit.') + readline.emitKeypressEvents(process.stdin) + process.stdin.setRawMode(true) + process.stdin.resume() + + return await new Promise((resolve) => { + let running = false + const onKeypress = (str: string, key: readline.Key) => { + if (key?.ctrl && key.name === 'c') { + cleanup() + resolve(code) + return + } + if (str === 'q' || str === 'Q') { + cleanup() + resolve(code) + return + } + if (key?.name === 'return' || key?.name === 'enter') { + if (running) return + running = true + void runOnce() + .then((nextCode) => { + code = nextCode + console.log('\nPress Enter to rerun, press q to quit.') + }) + .finally(() => { + running = false + }) + } + } + + const cleanup = () => { + process.stdin.off('keypress', onKeypress) + if (process.stdin.isTTY) process.stdin.setRawMode(false) + process.stdin.pause() + } + + process.stdin.on('keypress', onKeypress) + }) +} + +void runInteractive().then((code) => { + process.exit(code) +}) diff --git a/benchmarks/minidom/profile.ts b/benchmarks/minidom/profile.ts new file mode 100644 index 0000000..3345f61 --- /dev/null +++ b/benchmarks/minidom/profile.ts @@ -0,0 +1,218 @@ +import { spawnSync } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' +import process from 'node:process' + +import { build } from 'esbuild' + +type CpuProfile = { + nodes: Array<{ + id: number + callFrame: { + functionName: string + url: string + lineNumber: number + columnNumber: number + } + parent?: number + }> + samples?: number[] + timeDeltas?: number[] +} + +type Frame = { + fn: string + url: string + line: number + col: number +} + +const supportsColor = process.stdout.isTTY +const color = (code: number, text: string) => + supportsColor ? `\x1b[${code}m${text}\x1b[0m` : text +const bold = (text: string) => color(1, text) +const cyan = (text: string) => color(36, text) +const green = (text: string) => color(32, text) +const yellow = (text: string) => color(33, text) + +const rows = Math.max(1, Number(process.argv[2] ?? 2000) || 2000) +const topN = Math.max(5, Number(process.argv[3] ?? 30) || 30) +const outDir = path.resolve(process.cwd(), 'benchmarks/minidom/profiles') +const bundlePath = path.join(outDir, 'perf.profile.bundle.mjs') +const stamp = new Date().toISOString().replace(/[:.]/g, '-') +const profileName = `perf-${rows}-${stamp}.cpuprofile` +const profilePath = path.join(outDir, profileName) +const summaryPath = path.join(outDir, `perf-${rows}-${stamp}.summary.txt`) + +const isIdleFrame = (fn: string) => + fn === '(idle)' || fn === '(program)' || fn === '(garbage collector)' + +const shortUrl = (url: string): string => { + if (!url) return '' + const cwd = process.cwd().replace(/\\/g, '/') + return url.replace(cwd + '/', '').replace('file:///', '') +} + +const isRegorFrame = (url: string): boolean => { + const normalized = shortUrl(url).toLowerCase() + if ( + normalized.includes('benchmarks/minidom/profiles/perf.profile.bundle.mjs') + ) + return true + return ( + normalized.includes('/src/') || + normalized.includes('\\src\\') || + normalized.includes('/tests/minidom/') || + normalized.includes('\\tests\\minidom\\') + ) +} + +const renderTopFrames = ( + entries: Array<{ frame: Frame; timeUs: number }>, + totalUs: number, +): string[] => + entries.slice(0, topN).map((x) => { + const pct = ((x.timeUs * 100) / totalUs).toFixed(2).padStart(6, ' ') + const ms = (x.timeUs / 1000).toFixed(2).padStart(8, ' ') + return `${pct}% ${ms}ms | ${x.frame.fn} | ${shortUrl(x.frame.url)}:${x.frame.line}:${x.frame.col}` + }) + +const aggregateProfile = (profile: CpuProfile) => { + const nodesById = new Map(profile.nodes.map((n) => [n.id, n])) + const samples = profile.samples ?? [] + const deltas = profile.timeDeltas ?? [] + const nonIdleByFrame = new Map() + const regorByFrame = new Map() + const fnTotals = new Map() + let nonIdleTotalUs = 0 + let regorTotalUs = 0 + + for (let i = 0; i < samples.length; ++i) { + const node = nodesById.get(samples[i]) + if (!node) continue + const dt = deltas[i] ?? 0 + const frame: Frame = { + fn: node.callFrame.functionName || '(anonymous)', + url: node.callFrame.url ?? '', + line: (node.callFrame.lineNumber ?? 0) + 1, + col: (node.callFrame.columnNumber ?? 0) + 1, + } + if (isIdleFrame(frame.fn)) continue + nonIdleTotalUs += dt + fnTotals.set(frame.fn, (fnTotals.get(frame.fn) ?? 0) + dt) + + const key = `${frame.fn}|${frame.url}|${frame.line}|${frame.col}` + const cur = nonIdleByFrame.get(key) + if (cur) cur.timeUs += dt + else nonIdleByFrame.set(key, { frame, timeUs: dt }) + + if (isRegorFrame(frame.url)) { + regorTotalUs += dt + const regorCur = regorByFrame.get(key) + if (regorCur) regorCur.timeUs += dt + else regorByFrame.set(key, { frame, timeUs: dt }) + } + } + + const topNonIdle = [...nonIdleByFrame.values()].sort( + (a, b) => b.timeUs - a.timeUs, + ) + const topRegor = [...regorByFrame.values()].sort( + (a, b) => b.timeUs - a.timeUs, + ) + const topFns = [...fnTotals.entries()] + .map(([fn, timeUs]) => ({ fn, timeUs })) + .sort((a, b) => b.timeUs - a.timeUs) + .slice(0, topN) + + return { + nonIdleTotalUs, + regorTotalUs, + topNonIdle, + topRegor, + topFns, + } +} + +const main = async () => { + fs.mkdirSync(outDir, { recursive: true }) + console.log( + `${bold('Profiling minidom perf')} rows=${rows}, top=${topN}, samples=${process.env.PERF_SAMPLES ?? '20'}, warmups=${process.env.PERF_WARMUPS ?? '5'}`, + ) + console.log(cyan(`Building bundle -> ${bundlePath}`)) + + await build({ + entryPoints: [path.resolve(process.cwd(), 'benchmarks/minidom/perf.ts')], + outfile: bundlePath, + bundle: true, + platform: 'node', + format: 'esm', + target: 'node20', + sourcemap: false, + keepNames: false, + logLevel: 'silent', + }) + + console.log(cyan(`Running perf + CPU profile -> ${profilePath}`)) + const run = spawnSync( + process.execPath, + [ + `--cpu-prof`, + `--cpu-prof-name=${profileName}`, + `--cpu-prof-dir=${outDir}`, + bundlePath, + String(rows), + ], + { + stdio: ['ignore', 'pipe', 'pipe'], + encoding: 'utf8', + env: process.env, + }, + ) + if (run.stdout) process.stdout.write(run.stdout) + if (run.stderr) process.stderr.write(run.stderr) + if (run.status !== 0) process.exit(run.status ?? 1) + if (!fs.existsSync(profilePath)) { + console.error(`Profile file missing: ${profilePath}`) + process.exit(1) + } + + const profile = JSON.parse(fs.readFileSync(profilePath, 'utf8')) as CpuProfile + const analysis = aggregateProfile(profile) + const lines: string[] = [] + lines.push(`Profile: ${profilePath}`) + lines.push( + `Non-idle total: ${(analysis.nonIdleTotalUs / 1000).toFixed(2)} ms`, + ) + lines.push( + `Regor/minidom non-idle: ${(analysis.regorTotalUs / 1000).toFixed(2)} ms (${((analysis.regorTotalUs * 100) / Math.max(1, analysis.nonIdleTotalUs)).toFixed(2)}%)`, + ) + lines.push('') + lines.push('Top Functions (non-idle self):') + for (const x of analysis.topFns) { + lines.push( + `${((x.timeUs * 100) / Math.max(1, analysis.nonIdleTotalUs)).toFixed(2).padStart(6, ' ')}% ${(x.timeUs / 1000).toFixed(2).padStart(8, ' ')}ms | ${x.fn}`, + ) + } + lines.push('') + lines.push('Top Frames (non-idle self):') + lines.push( + ...renderTopFrames( + analysis.topNonIdle, + Math.max(1, analysis.nonIdleTotalUs), + ), + ) + lines.push('') + lines.push('Top Regor/Minidom Frames (non-idle self):') + lines.push( + ...renderTopFrames(analysis.topRegor, Math.max(1, analysis.nonIdleTotalUs)), + ) + + fs.writeFileSync(summaryPath, lines.join('\n') + '\n') + + console.log(`\n${green('Profile summary')}`) + console.log(lines.join('\n')) + console.log(`\n${yellow('Saved summary:')} ${summaryPath}`) +} + +void main() diff --git a/benchmarks/server.ts b/benchmarks/server.ts new file mode 100644 index 0000000..3eb6c74 --- /dev/null +++ b/benchmarks/server.ts @@ -0,0 +1,55 @@ +import { createReadStream, existsSync } from 'node:fs' +import { stat } from 'node:fs/promises' +import { createServer } from 'node:http' +import { extname, join, normalize } from 'node:path' + +const PORT = 4177 +const ROOT = process.cwd() + +const mime: Record = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.mjs': 'text/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.map': 'application/json; charset=utf-8', +} + +const safePath = (urlPath: string): string => { + const base = + urlPath === '/' + ? '/benchmarks/index.html' + : urlPath.startsWith('/dist/') + ? `/benchmarks${urlPath}` + : urlPath + const normalized = normalize(base).replace(/^(\.\.[/\\])+/, '') + return join(ROOT, normalized) +} + +createServer(async (req, res) => { + try { + const requestPath = req.url?.split('?')[0] ?? '/' + const filePath = safePath(requestPath) + if (!existsSync(filePath)) { + res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }) + res.end('Not found') + return + } + + const s = await stat(filePath) + if (!s.isFile()) { + res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }) + res.end('Not found') + return + } + + const type = mime[extname(filePath)] ?? 'application/octet-stream' + res.writeHead(200, { 'content-type': type, 'cache-control': 'no-store' }) + createReadStream(filePath).pipe(res) + } catch (e) { + res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' }) + res.end(`Server error: ${(e as Error).message}`) + } +}).listen(PORT, () => { + console.log(`Benchmark server: http://localhost:${PORT}/`) +}) diff --git a/benchmarks/src/index.ts b/benchmarks/src/index.ts new file mode 100644 index 0000000..41ed36e --- /dev/null +++ b/benchmarks/src/index.ts @@ -0,0 +1,420 @@ +import { createApp as createRegorApp, html, ref } from '../../src' + +type Row = { + id: number + label: string + value: number + active: boolean +} + +type BenchResult = { + framework: string + mountMs: number + mutateMs: number + totalMs: number + rowsFinal: number +} + +type BenchSummary = { + framework: string + mountMedian: number + mountP90: number + mutateMedian: number + mutateP90: number + totalMedian: number + totalP90: number + rowsFinal: number + samples: number +} + +const ROW_COUNT = 500 +const ROUNDS = 20 +const STRIDE = 17 +const SPLICE_SIZE = 24 +const SAMPLE_RUNS = 20 +const WARMUP_RUNS = 6 +const VUE_ESM_URL = + 'https://cdn.jsdelivr.net/npm/vue@latest/dist/vue.esm-browser.prod.js' +let vueModulePromise: Promise | null = null +let lastRegorApp: { unbind: () => void } | null = null +let lastVueApp: { unmount: () => void } | null = null + +const nextFrame = async (): Promise => { + await new Promise((resolve) => requestAnimationFrame(() => resolve())) +} + +const makeRows = (count: number, startId = 0): Row[] => { + const out: Row[] = [] + for (let i = 0; i < count; i++) { + const id = startId + i + out.push({ + id, + label: `row-${id}`, + value: id % 97, + active: (id & 1) === 0, + }) + } + return out +} + +const clearRoot = (root: HTMLElement): void => { + root.innerHTML = '' +} + +const loadVueModule = async (): Promise => { + if (!vueModulePromise) { + vueModulePromise = import(/* @vite-ignore */ VUE_ESM_URL) + } + return vueModulePromise +} + +const runRegor = async (root: HTMLElement): Promise => { + if (lastRegorApp) { + lastRegorApp.unbind() + lastRegorApp = null + } + clearRoot(root) + let nextId = ROW_COUNT + const initialRows = makeRows(ROW_COUNT) + + await nextFrame() + const mountT0 = performance.now() + const app = createRegorApp( + { + rows: ref(initialRows), + }, + { + element: root, + template: html`
+
+ {{ row.id }}{{ row.label }}{{ row.value }} +
+
`, + }, + ) + lastRegorApp = app + await nextFrame() + const mountMs = performance.now() - mountT0 + + const mutateT0 = performance.now() + for (let round = 0; round < ROUNDS; round++) { + const rows = app.context.rows() + for (let i = 0; i < rows.length; i += STRIDE) { + const row = rows[i]() + row.value.value += round + 1 + row.active.value = !row.active.value + row.label.value = `row-${row.id.value}-${round}` + } + const mid = (rows.length / 2) | 0 + const inserts = makeRows(SPLICE_SIZE, nextId).map((x) => ref(x)) + nextId += SPLICE_SIZE + rows.splice(mid, SPLICE_SIZE, ...inserts) + rows.push(ref(makeRows(1, nextId++)[0])) + rows.shift() + } + await nextFrame() + const mutateMs = performance.now() - mutateT0 + const totalMs = mountMs + mutateMs + + return { + framework: 'Regor', + mountMs, + mutateMs, + totalMs, + rowsFinal: app.context.rows().length, + } +} + +const runVue = async (root: HTMLElement): Promise => { + if (lastVueApp) { + lastVueApp.unmount() + lastVueApp = null + } + clearRoot(root) + const vue = await loadVueModule() + const { createApp, reactive } = vue + let nextId = ROW_COUNT + + const state = reactive({ + rows: makeRows(ROW_COUNT), + }) + + await nextFrame() + const mountT0 = performance.now() + const app = createApp({ + data: () => state, + template: ` +
+
+ {{ row.id }} + {{ row.label }} + {{ row.value }} +
+
+ `, + }) + app.mount(root) + lastVueApp = app + await nextFrame() + const mountMs = performance.now() - mountT0 + + const mutateT0 = performance.now() + for (let round = 0; round < ROUNDS; round++) { + const rows = state.rows + for (let i = 0; i < rows.length; i += STRIDE) { + const row = rows[i] + row.value += round + 1 + row.active = !row.active + row.label = `row-${row.id}-${round}` + } + const mid = (rows.length / 2) | 0 + const inserts = makeRows(SPLICE_SIZE, nextId) + nextId += SPLICE_SIZE + rows.splice(mid, SPLICE_SIZE, ...inserts) + rows.push(makeRows(1, nextId++)[0]) + rows.shift() + } + await nextFrame() + const mutateMs = performance.now() - mutateT0 + const totalMs = mountMs + mutateMs + + return { + framework: 'Vue@latest', + mountMs, + mutateMs, + totalMs, + rowsFinal: state.rows.length, + } +} + +const formatMs = (n: number): string => n.toFixed(2) + +const percentile = (sorted: number[], p: number): number => { + if (!sorted.length) return 0 + const idx = Math.min( + sorted.length - 1, + Math.max(0, Math.ceil((p / 100) * sorted.length) - 1), + ) + return sorted[idx] +} + +const summarize = (framework: string, samples: BenchResult[]): BenchSummary => { + const mount = samples.map((x) => x.mountMs).sort((a, b) => a - b) + const mutate = samples.map((x) => x.mutateMs).sort((a, b) => a - b) + const total = samples.map((x) => x.totalMs).sort((a, b) => a - b) + return { + framework, + mountMedian: percentile(mount, 50), + mountP90: percentile(mount, 90), + mutateMedian: percentile(mutate, 50), + mutateP90: percentile(mutate, 90), + totalMedian: percentile(total, 50), + totalP90: percentile(total, 90), + rowsFinal: samples[samples.length - 1]?.rowsFinal ?? 0, + samples: samples.length, + } +} + +const renderResults = (rows: BenchSummary[]): void => { + const tbody = + document.querySelector('#results tbody') + if (!tbody) return + tbody.innerHTML = '' + for (const r of rows) { + const tr = document.createElement('tr') + tr.innerHTML = ` + ${r.framework} + ${formatMs(r.mountMedian)} + ${formatMs(r.mountP90)} + ${formatMs(r.mutateMedian)} + ${formatMs(r.mutateP90)} + ${formatMs(r.totalMedian)} + ${formatMs(r.totalP90)} + ${r.rowsFinal} + ${r.samples} + ` + tbody.appendChild(tr) + } +} + +const setLog = (msg: string): void => { + const log = document.getElementById('log') + if (log) log.textContent = msg +} + +const setButtonsDisabled = (disabled: boolean): void => { + const ids = ['run', 'run-regor', 'run-vue'] + for (const id of ids) { + const button = document.getElementById(id) as HTMLButtonElement | null + if (button) button.disabled = disabled + } +} + +const runRegorSamples = async ( + root: HTMLElement, + count: number, +): Promise => { + const out: BenchResult[] = [] + for (let i = 0; i < count; i++) { + out.push(await runRegor(root)) + } + return out +} + +const runVueSamples = async ( + root: HTMLElement, + count: number, +): Promise => { + const out: BenchResult[] = [] + for (let i = 0; i < count; i++) { + out.push(await runVue(root)) + } + return out +} + +const runComparativeSamples = async ( + regorRoot: HTMLElement, + vueRoot: HTMLElement, + samples: number, +): Promise<{ regor: BenchResult[]; vue: BenchResult[] }> => { + const regor: BenchResult[] = [] + const vue: BenchResult[] = [] + for (let i = 0; i < samples; i++) { + const regorFirst = (i & 1) === 0 + if (regorFirst) { + regor.push(await runRegor(regorRoot)) + vue.push(await runVue(vueRoot)) + } else { + vue.push(await runVue(vueRoot)) + regor.push(await runRegor(regorRoot)) + } + setLog(`Measured sample ${i + 1}/${samples}...`) + } + return { regor, vue } +} + +const run = async (): Promise => { + setButtonsDisabled(true) + try { + setLog( + `Warmup ${WARMUP_RUNS} rounds (unmeasured), then ${SAMPLE_RUNS} measured samples (A/B alternating)...`, + ) + const regorRoot = document.getElementById('regor-root') + const vueRoot = document.getElementById('vue-root') + if (!regorRoot || !vueRoot) { + setLog('Missing benchmark roots in DOM.') + return + } + for (let i = 0; i < WARMUP_RUNS; i++) { + if ((i & 1) === 0) { + await runRegor(regorRoot) + await runVue(vueRoot) + } else { + await runVue(vueRoot) + await runRegor(regorRoot) + } + } + const measured = await runComparativeSamples( + regorRoot, + vueRoot, + SAMPLE_RUNS, + ) + const regorSummary = summarize('Regor', measured.regor) + const vueSummary = summarize('Vue@latest', measured.vue) + renderResults([regorSummary, vueSummary]) + setLog('Done. Showing median + p90. Warmups excluded.') + console.table([regorSummary, vueSummary]) + } catch (e) { + console.error(e) + setLog(`Benchmark failed: ${(e as Error).message}`) + } finally { + setButtonsDisabled(false) + } +} + +const runOnlyRegor = async (): Promise => { + setButtonsDisabled(true) + try { + setLog( + `Regor: warmup ${WARMUP_RUNS} rounds (unmeasured), ${SAMPLE_RUNS} measured samples...`, + ) + const regorRoot = document.getElementById('regor-root') + if (!regorRoot) { + setLog('Missing Regor root in DOM.') + return + } + await runRegorSamples(regorRoot, WARMUP_RUNS) + const samples = await runRegorSamples(regorRoot, SAMPLE_RUNS) + const summary = summarize('Regor', samples) + renderResults([summary]) + setLog('Regor done. Showing median + p90.') + console.table([summary]) + } catch (e) { + console.error(e) + setLog(`Regor benchmark failed: ${(e as Error).message}`) + } finally { + setButtonsDisabled(false) + } +} + +const runOnlyVue = async (): Promise => { + setButtonsDisabled(true) + try { + setLog( + `Vue: warmup ${WARMUP_RUNS} rounds (unmeasured), ${SAMPLE_RUNS} measured samples...`, + ) + const vueRoot = document.getElementById('vue-root') + if (!vueRoot) { + setLog('Missing Vue root in DOM.') + return + } + await runVueSamples(vueRoot, WARMUP_RUNS) + const samples = await runVueSamples(vueRoot, SAMPLE_RUNS) + const summary = summarize('Vue@latest', samples) + renderResults([summary]) + setLog('Vue done. Showing median + p90.') + console.table([summary]) + } catch (e) { + console.error(e) + setLog(`Vue benchmark failed: ${(e as Error).message}`) + } finally { + setButtonsDisabled(false) + } +} + +const wire = (): void => { + const runRegorButton = document.getElementById( + 'run-regor', + ) as HTMLButtonElement | null + const runVueButton = document.getElementById( + 'run-vue', + ) as HTMLButtonElement | null + const runButton = document.getElementById('run') as HTMLButtonElement | null + setButtonsDisabled(true) + setLog('Loading Vue runtime (excluded from benchmark timing)...') + void loadVueModule() + .then(() => { + setButtonsDisabled(false) + setLog( + `Ready. Default run uses ${WARMUP_RUNS} warmups + ${SAMPLE_RUNS} samples (median/p90).`, + ) + }) + .catch((e) => { + setLog(`Failed to load Vue runtime: ${(e as Error).message}`) + }) + + runRegorButton?.addEventListener('click', () => { + void runOnlyRegor() + }) + + runVueButton?.addEventListener('click', () => { + void runOnlyVue() + }) + + runButton?.addEventListener('click', () => { + void run() + }) +} + +wire() diff --git a/benchmarks/src/initial-load.ts b/benchmarks/src/initial-load.ts new file mode 100644 index 0000000..2888902 --- /dev/null +++ b/benchmarks/src/initial-load.ts @@ -0,0 +1,355 @@ +import { createApp as createRegorApp, html, RegorConfig, sref } from '../../src' + +type Row = { + id: number + label: string + value: number + active: boolean +} + +type BenchResult = { + framework: string + mountMs: number + rowsFinal: number +} + +type BenchSummary = { + framework: string + mountMedian: number + mountP90: number + rowsFinal: number + samples: number +} + +const ROW_COUNT = 500 +const SAMPLE_RUNS = 20 +const WARMUP_RUNS = 6 +const VUE_ESM_URL = + 'https://cdn.jsdelivr.net/npm/vue@latest/dist/vue.esm-browser.prod.js' + +let vueModulePromise: Promise | null = null +let lastRegorApp: { unbind: () => void } | null = null +let lastVueApp: { unmount: () => void } | null = null +const regorBenchConfig = (() => { + const config = new RegorConfig() + config.useInterpolation = true + return config +})() + +const nextFrame = async (): Promise => { + await new Promise((resolve) => requestAnimationFrame(() => resolve())) +} + +const makeRows = (count: number, startId = 0): Row[] => { + const out: Row[] = [] + for (let i = 0; i < count; i++) { + const id = startId + i + out.push({ + id, + label: `row-${id}`, + value: id % 97, + active: id % 2 === 1, + }) + } + return out +} + +const clearRoot = (root: HTMLElement): void => { + root.innerHTML = '' +} + +const loadVueModule = async (): Promise => { + if (!vueModulePromise) { + vueModulePromise = import(/* @vite-ignore */ VUE_ESM_URL) + } + return vueModulePromise +} + +const runRegor = async (root: HTMLElement): Promise => { + if (lastRegorApp) { + lastRegorApp.unbind() + lastRegorApp = null + } + clearRoot(root) + const rows = makeRows(ROW_COUNT) + await nextFrame() + const t0 = performance.now() + const app = createRegorApp( + { + rows: sref(rows), + }, + { + element: root, + template: html`
+
+ {{ row.id }}{{ row.label }}{{ row.value }} +
+
`, + }, + regorBenchConfig, + ) + lastRegorApp = app + await nextFrame() + const mountMs = performance.now() - t0 + return { + framework: 'Regor', + mountMs, + rowsFinal: app.context.rows().length, + } +} + +const runVue = async (root: HTMLElement): Promise => { + if (lastVueApp) { + lastVueApp.unmount() + lastVueApp = null + } + clearRoot(root) + const vue = await loadVueModule() + const { createApp, reactive } = vue + + const state = reactive({ + rows: makeRows(ROW_COUNT), + }) + + await nextFrame() + const t0 = performance.now() + const app = createApp({ + data: () => state, + template: ` +
+
+ {{ row.id }} + {{ row.label }} + {{ row.value }} +
+
+ `, + }) + app.mount(root) + lastVueApp = app + await nextFrame() + const mountMs = performance.now() - t0 + + return { + framework: 'Vue@latest', + mountMs, + rowsFinal: state.rows.length, + } +} + +const formatMs = (n: number): string => n.toFixed(2) + +const percentile = (sorted: number[], p: number): number => { + if (!sorted.length) return 0 + const idx = Math.min( + sorted.length - 1, + Math.max(0, Math.ceil((p / 100) * sorted.length) - 1), + ) + return sorted[idx] +} + +const summarize = (framework: string, samples: BenchResult[]): BenchSummary => { + const mount = samples.map((x) => x.mountMs).sort((a, b) => a - b) + return { + framework, + mountMedian: percentile(mount, 50), + mountP90: percentile(mount, 90), + rowsFinal: samples[samples.length - 1]?.rowsFinal ?? 0, + samples: samples.length, + } +} + +const renderResults = (rows: BenchSummary[]): void => { + const tbody = + document.querySelector('#results tbody') + if (!tbody) return + tbody.innerHTML = '' + for (const r of rows) { + const tr = document.createElement('tr') + tr.innerHTML = ` + ${r.framework} + ${formatMs(r.mountMedian)} + ${formatMs(r.mountP90)} + ${r.rowsFinal} + ${r.samples} + ` + tbody.appendChild(tr) + } +} + +const setLog = (msg: string): void => { + const log = document.getElementById('log') + if (log) log.textContent = msg +} + +const setButtonsDisabled = (disabled: boolean): void => { + const ids = ['run', 'run-regor', 'run-vue'] + for (const id of ids) { + const button = document.getElementById(id) as HTMLButtonElement | null + if (button) button.disabled = disabled + } +} + +const runRegorSamples = async ( + root: HTMLElement, + count: number, +): Promise => { + const out: BenchResult[] = [] + for (let i = 0; i < count; i++) out.push(await runRegor(root)) + return out +} + +const runVueSamples = async ( + root: HTMLElement, + count: number, +): Promise => { + const out: BenchResult[] = [] + for (let i = 0; i < count; i++) out.push(await runVue(root)) + return out +} + +const runComparativeSamples = async ( + regorRoot: HTMLElement, + vueRoot: HTMLElement, + samples: number, +): Promise<{ regor: BenchResult[]; vue: BenchResult[] }> => { + const regor: BenchResult[] = [] + const vue: BenchResult[] = [] + for (let i = 0; i < samples; i++) { + const regorFirst = (i & 1) === 0 + if (regorFirst) { + regor.push(await runRegor(regorRoot)) + vue.push(await runVue(vueRoot)) + } else { + vue.push(await runVue(vueRoot)) + regor.push(await runRegor(regorRoot)) + } + setLog(`Measured sample ${i + 1}/${samples}...`) + } + return { regor, vue } +} + +const run = async (): Promise => { + setButtonsDisabled(true) + try { + setLog( + `Warmup ${WARMUP_RUNS} rounds (unmeasured), then ${SAMPLE_RUNS} measured samples (A/B alternating)...`, + ) + const regorRoot = document.getElementById('regor-root') + const vueRoot = document.getElementById('vue-root') + if (!regorRoot || !vueRoot) { + setLog('Missing benchmark roots in DOM.') + return + } + for (let i = 0; i < WARMUP_RUNS; i++) { + if ((i & 1) === 0) { + await runRegor(regorRoot) + await runVue(vueRoot) + } else { + await runVue(vueRoot) + await runRegor(regorRoot) + } + } + const measured = await runComparativeSamples( + regorRoot, + vueRoot, + SAMPLE_RUNS, + ) + const regorSummary = summarize('Regor', measured.regor) + const vueSummary = summarize('Vue@latest', measured.vue) + renderResults([regorSummary, vueSummary]) + setLog('Done. Showing mount median + p90 only.') + console.table([regorSummary, vueSummary]) + } catch (e) { + console.error(e) + setLog(`Benchmark failed: ${(e as Error).message}`) + } finally { + setButtonsDisabled(false) + } +} + +const runOnlyRegor = async (): Promise => { + setButtonsDisabled(true) + try { + setLog( + `Regor: warmup ${WARMUP_RUNS} rounds (unmeasured), ${SAMPLE_RUNS} measured samples...`, + ) + const regorRoot = document.getElementById('regor-root') + if (!regorRoot) { + setLog('Missing Regor root in DOM.') + return + } + await runRegorSamples(regorRoot, WARMUP_RUNS) + const samples = await runRegorSamples(regorRoot, SAMPLE_RUNS) + const summary = summarize('Regor', samples) + renderResults([summary]) + setLog('Regor done. Showing mount median + p90.') + console.table([summary]) + } catch (e) { + console.error(e) + setLog(`Regor benchmark failed: ${(e as Error).message}`) + } finally { + setButtonsDisabled(false) + } +} + +const runOnlyVue = async (): Promise => { + setButtonsDisabled(true) + try { + setLog( + `Vue: warmup ${WARMUP_RUNS} rounds (unmeasured), ${SAMPLE_RUNS} measured samples...`, + ) + const vueRoot = document.getElementById('vue-root') + if (!vueRoot) { + setLog('Missing Vue root in DOM.') + return + } + await runVueSamples(vueRoot, WARMUP_RUNS) + const samples = await runVueSamples(vueRoot, SAMPLE_RUNS) + const summary = summarize('Vue@latest', samples) + renderResults([summary]) + setLog('Vue done. Showing mount median + p90.') + console.table([summary]) + } catch (e) { + console.error(e) + setLog(`Vue benchmark failed: ${(e as Error).message}`) + } finally { + setButtonsDisabled(false) + } +} + +const wire = (): void => { + const runRegorButton = document.getElementById( + 'run-regor', + ) as HTMLButtonElement | null + const runVueButton = document.getElementById( + 'run-vue', + ) as HTMLButtonElement | null + const runButton = document.getElementById('run') as HTMLButtonElement | null + + setButtonsDisabled(true) + setLog('Loading Vue runtime (excluded from benchmark timing)...') + void loadVueModule() + .then(() => { + setButtonsDisabled(false) + setLog( + `Ready. Initial-load benchmark with ${ROW_COUNT} rows, ${WARMUP_RUNS} warmups, ${SAMPLE_RUNS} samples.`, + ) + }) + .catch((e) => { + setLog(`Failed to load Vue runtime: ${(e as Error).message}`) + }) + + runRegorButton?.addEventListener('click', () => { + void runOnlyRegor() + }) + runVueButton?.addEventListener('click', () => { + void runOnlyVue() + }) + runButton?.addEventListener('click', () => { + void run() + }) +} + +wire() diff --git a/docs-site/src/content/docs/index.mdx b/docs-site/src/content/docs/index.mdx index 646189f..b94eb46 100644 --- a/docs-site/src/content/docs/index.mdx +++ b/docs-site/src/content/docs/index.mdx @@ -67,6 +67,7 @@ Regor empowers developers to craft high-performance, reactive user interfaces ef + > **Note:** Regor is open-source. Contributions and feedback are welcome! diff --git a/docs-site/src/content/docs/overview.md b/docs-site/src/content/docs/overview.md index ab76be5..dabc091 100644 --- a/docs-site/src/content/docs/overview.md +++ b/docs-site/src/content/docs/overview.md @@ -4,26 +4,60 @@ sidebar: order: 3 --- -Regor is a powerful UI framework designed for building HTML5-based applications in both web and desktop environments. With a focus on simplicity, performance, and flexibility, Regor provides developers with the tools to create dynamic and interactive user interfaces. +Regor is a reactive UI framework built for developers who want direct control over the DOM, expressive runtime behavior, and strong TypeScript ergonomics. -## Key Features +It is designed to be practical, not restrictive. -- **Simplicity without a Virtual DOM:** Develop UIs without the complexity of a Virtual DOM, simplifying implementation and debugging. +## What Regor Optimizes For -- **Native TypeScript Support:** Enjoy native TypeScript support for strong typing and code consistency. +1. **Control over abstraction** + Regor does not force one universal rendering model for every case. You can use high-level directives where they help and drop to direct JavaScript/DOM logic where needed. -- **No Build Step Required:** Define components in TypeScript using tagged string templates, eliminating the need for a build step. +2. **Runtime flexibility** + Regor can bind existing HTML markup and progressively enhance server-rendered or mixed-stack pages without requiring a full ownership rewrite. -- **Secure JavaScript VM:** Regor's secure JavaScript Virtual Machine ensures safe runtime compilation under strict content security policies. +3. **Composable reactivity** + Regor provides `ref`, `sref`, computed and observer-based primitives so you can choose the right level of reactivity per feature. -- **Flexible Reactivity:** Empower developers with a highly flexible reactivity system. +4. **TypeScript-first development** + Components, props, and reactive state can be modeled in TypeScript directly, with predictable runtime behavior. -- **Non-JavaScript Server-Side Rendering (SSR):** Support server-side rendering by not removing existing HTML elements. +5. **Security-aware runtime evaluation** + Regor supports CSP-friendly environments and practical secure evaluation scenarios for runtime expressions. -- **Reentrance:** Enable multiple mountings in previously mounted areas for dynamic rendering. +## Design Philosophy -- **Compatibility:** Seamlessly integrate with other DOM-manipulating libraries. +Regor intentionally avoids over-constraining architecture choices. -Explore the Regor documentation to learn more about its features and how to leverage them in your projects. +That means: + +1. Component and context patterns are flexible. +2. Parent/child interaction is not artificially limited to one rigid style. +3. Framework internals aim to stay understandable and hackable for real-world optimization work. + +## Tradeoff Model + +Regor prioritizes flexibility and control. In highly generic, binding-dense scenarios, that flexibility can add runtime overhead compared to highly specialized pipelines. + +In return, Regor gives you: + +1. Better escape hatches for custom optimization. +2. Better fit for progressive enhancement and mixed DOM ownership. +3. A development model that does not force one-size-fits-all constraints. + +## When Regor Is a Strong Fit + +Regor is especially strong when you need: + +1. Runtime binding over existing markup. +2. Fine-grained control over component and context behavior. +3. Custom domain-specific rendering optimizations. +4. Direct integration with other DOM-manipulating systems. + +## Next Steps + +1. Read [Getting Started](/getting-started). +2. Explore [Directives](/directives). +3. Use the [Performance Guide](/performance) for profiling and optimization strategy. [Back to the main](/) diff --git a/docs-site/src/content/docs/performance.md b/docs-site/src/content/docs/performance.md new file mode 100644 index 0000000..844688c --- /dev/null +++ b/docs-site/src/content/docs/performance.md @@ -0,0 +1,73 @@ +--- +title: Performance Guide +sidebar: + order: 5 +--- + +This page summarizes practical performance guidance for Regor apps, based on real benchmark work and profiling. + +## What Matters Most + +Performance is usually dominated by: + +1. Number of DOM nodes created/updated. +2. Number of reactive bindings attached. +3. Amount of work done per binding (`r-text`, `:class`, `:style`, etc.). +4. List size and list mutation strategy (`r-for` + `:key`). + +In large templates, total cost is almost always cumulative. Many small costs can add up. + +## Benchmark Correctness Checklist + +Before comparing Regor with another framework, ensure: + +1. Same rendered output and same feature set on both sides. +2. Same timing boundary on both sides (for example, both measured with one `requestAnimationFrame` after mount, or neither). +3. Warmup runs are excluded from reported numbers. +4. A/B alternating order is used per sample to reduce bias. +5. Median and P90 are reported, not single-run results. + +If timing boundaries differ, benchmark numbers are not comparable. + +## Interpreting Numbers + +Use this rough UX scale for one interaction: + +1. Under `100ms`: usually feels instant. +2. `100ms` to `300ms`: noticeable, usually acceptable. +3. Over `300ms`: often perceived as slow. + +Absolute numbers matter more than framework-vs-framework deltas if user experience is already smooth on target devices. + +## Large Lists: Recommended Strategy + +If you render many rows: + +1. Prefer windowing/pagination/chunking over fully reactive huge lists. +2. Keep row templates minimal. +3. Avoid expensive computed object bindings per row where possible. +4. Use stable keys (`:key`) for predictable updates. + +## Regor-Specific Strength + +Regor is designed for runtime flexibility: + +1. Bind to existing HTML markup in place. +2. Mix framework bindings with direct DOM/JS escape hatches in critical paths. +3. Keep control when you need custom, domain-specific optimization. + +This flexibility is valuable in progressive enhancement and mixed-stack environments. + +## Practical Optimization Order + +When a page feels slow, optimize in this order: + +1. Reduce rendered nodes. +2. Reduce active bindings per node. +3. Simplify hot directives in repeated rows. +4. Profile and fix the top one or two real hotspots. +5. Only then consider deeper framework-internal changes. + +## When Not To Optimize + +If your measured path is already in an acceptable UX range, prioritize maintainability and product features. Complexity-heavy hot paths are only worth it when they solve a real user-visible bottleneck. diff --git a/package.json b/package.json index a8b7691..04b1e98 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "regor", - "version": "1.3.3", + "version": "1.3.4", "description": "A modern UI framework for web and desktop applications, inspired by Vue's concepts and powered by simplicity and flexibility.", "author": "Ahmed Yasin Koculu", "license": "MIT", @@ -10,11 +10,18 @@ "types": "dist/regor.d.ts", "scripts": { "build": "yarn tsx scripts/bundle.ts && yarn dts", + "bench:build": "yarn tsx benchmarks/build.ts", + "bench:serve": "yarn bench:build && yarn tsx benchmarks/server.ts", + "perf": "yarn tsx benchmarks/minidom/perf.ts", + "perf:record": "yarn tsx benchmarks/minidom/perf.ts record", + "perf:check": "yarn tsx benchmarks/minidom/perf.ts check", + "perf:profile": "yarn tsx benchmarks/minidom/profile.ts", "dts": "yarn dts-bundle-generator src/index.ts -o dist/regor.d.ts", "lint": "yarn eslint src/**/*.ts tests/**/*.ts", "watch": "yarn tsx scripts/bundle.ts --watch", "format": "yarn prettier --write \"**/*.ts\"", - "test": "vitest" + "test": "vitest", + "coverage": "yarn vitest run --coverage --coverage.include='src/**/*.ts' --coverage.reporter=text-summary --coverage.reporter=text" }, "files": [ "dist" @@ -35,6 +42,7 @@ "@eslint/js": "^9.39.2", "@stylistic/eslint-plugin": "^5.7.1", "@types/node": "^25.2.1", + "@vitest/coverage-istanbul": "3.2.3", "dts-bundle-generator": "^9.5.1", "esbuild": "^0.25.5", "eslint": "^9.39.2", diff --git a/src/api/types.ts b/src/api/types.ts index e0ac0fc..0a9685b 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -209,6 +209,10 @@ export type Unbinder = () => void export interface ParseResult { value: SRef stop: StopObserving + subscribe?: ( + observer: ObserveCallback, + init?: boolean, + ) => StopObserving refs: Array context: Record } diff --git a/src/bind/Binder.ts b/src/bind/Binder.ts index a437c65..d150883 100644 --- a/src/bind/Binder.ts +++ b/src/bind/Binder.ts @@ -11,7 +11,6 @@ import { import { isNullOrWhitespace } from '../common/is-what' import { teleportDirective } from '../directives/teleport' // import { warning, WarningType } from '../log/warnings' -import { observe } from '../observer/observe' import { type Parser } from '../parser/Parser' import { ComponentBinder } from './ComponentBinder' import { DirectiveCollector } from './DirectiveCollector' @@ -90,9 +89,11 @@ export class Binder { console.error('directive not found:', name) continue } - item.__elements.forEach((el) => { + const elements = item.__elements + for (let i = 0; i < elements.length; ++i) { + const el = elements[i] this.__bind(directive, el, attribute, false, option, item.__flags) - }) + } } } @@ -108,10 +109,13 @@ export class Binder { const bindExpression = el.getAttribute(attribute) el.removeAttribute(attribute) const getParentSwitch = (el: HTMLElement): string | null => { - const switchId = el.getAttribute(rswitch) - if (switchId) return switchId - if (!el.parentElement) return null - return getParentSwitch(el.parentElement) + let cursor: HTMLElement | null = el + while (cursor) { + const switchId = cursor.getAttribute(rswitch) + if (switchId) return switchId + cursor = cursor.parentElement + } + return null } if (hasSwitch()) { const switchId = getParentSwitch(el) @@ -167,11 +171,12 @@ export class Binder { config.once, ) const stopObserverList = [] as StopObserving[] + const hasOnChange = !!config.onChange const unbinder = (): void => { result.stop() dynamicOption?.stop() - for (const stopObserver of stopObserverList) { - stopObserver() + for (let i = 0; i < stopObserverList.length; ++i) { + stopObserverList[i]() } stopObserverList.length = 0 } @@ -188,38 +193,60 @@ export class Binder { config.once, ) } - let previousValues: any[] - const getValues = (): any[] => { - previousValues = result.value() - return previousValues - } - let previousOption: any - const getOptionValue = (): any => { - if (!dynamicOption) { - previousOption = option - return option - } - previousOption = dynamicOption.value()[0] - return previousOption - } + const dynamicOpt = dynamicOption + const hasDynamicOption = dynamicOpt != null + let previousValues: any[] = result.value() + let previousOption: any = hasDynamicOption + ? dynamicOption!.value()[0] + : option - const observeTailChanges = (): void => { - if (!config.onChange) return - const stopObserving = observe(result.value, (_) => { - const pre = previousValues - const pre2 = previousOption - config.onChange?.(el, getValues(), pre, getOptionValue(), pre2, flags) - }) + if (!config.once && hasOnChange) { + const stopObserving = ( + result.subscribe + ? result.subscribe(() => { + const preValues = previousValues + const preOption = previousOption + const nextValues = result.value() + const nextOption = hasDynamicOption + ? dynamicOpt.value()[0] + : option + previousValues = nextValues + previousOption = nextOption + config.onChange?.( + el, + nextValues, + preValues, + nextOption, + preOption, + flags, + ) + }) + : () => {} + ) as StopObserving stopObserverList.push(stopObserving) - if (dynamicOption) { - const stopObserving = observe(dynamicOption.value, (_) => { - const pre = previousOption - config.onChange?.(el, getValues(), pre, getOptionValue(), pre, flags) - }) + if (dynamicOpt) { + const stopObserving = ( + dynamicOpt.subscribe + ? dynamicOpt.subscribe(() => { + const preOption = previousOption + const nextValues = result.value() + const nextOption = dynamicOpt.value()[0] + previousValues = nextValues + previousOption = nextOption + config.onChange?.( + el, + nextValues, + preOption, + nextOption, + preOption, + flags, + ) + }) + : () => {} + ) as StopObserving stopObserverList.push(stopObserving) } } - if (!config.once) observeTailChanges() if (config.onBind) stopObserverList.push( config.onBind( @@ -233,9 +260,9 @@ export class Binder { ) config.onChange?.( el, - getValues(), + previousValues, undefined, - getOptionValue(), + previousOption, undefined, flags, ) diff --git a/src/bind/ComponentBinder.ts b/src/bind/ComponentBinder.ts index ca257be..47f15ba 100644 --- a/src/bind/ComponentBinder.ts +++ b/src/bind/ComponentBinder.ts @@ -31,6 +31,8 @@ export class ComponentBinder { __binder: Binder __inherit: string + __registeredComponentSelector = '' + __registeredComponentSize = -1 constructor(binder: Binder) { this.__binder = binder @@ -41,17 +43,49 @@ export class ComponentBinder { this.__unwrapComponents(element) } + __getRegisteredComponentSelector( + registeredComponents: Map, + ): string { + if (this.__registeredComponentSize !== registeredComponents.size) { + const names = [...registeredComponents.keys()] + this.__registeredComponentSelector = names + .concat(names.map(hyphenate)) + .join(',') + this.__registeredComponentSize = registeredComponents.size + } + return this.__registeredComponentSelector + } + + __hasContextComponents(contexts: any[]): boolean { + for (let i = 0; i < contexts.length; ++i) { + const components = contexts[i]?.components + if (!components) continue + for (const _ in components) { + return true + } + } + return false + } + __unwrapComponents(element: Element): void { const binder = this.__binder const parser = binder.__parser const registeredComponents = binder.__config.__components const registeredComponentsUpperCase = binder.__config.__componentsUpperCase + if ( + registeredComponents.size === 0 && + !this.__hasContextComponents(parser.__contexts) + ) { + return + } + const contextComponents = parser.__getComponents() const contextComponentSelectors = parser.__getComponentSelectors() + const registeredSelector = + this.__getRegisteredComponentSelector(registeredComponents) const selector = [ - ...registeredComponents.keys(), + ...(registeredSelector ? [registeredSelector] : []), ...contextComponentSelectors, - ...[...registeredComponents.keys()].map(hyphenate), ...contextComponentSelectors.map(hyphenate), ].join(',') if (isNullOrWhitespace(selector)) return diff --git a/src/bind/DirectiveCollector.ts b/src/bind/DirectiveCollector.ts index 4b4e9ea..e61a5ff 100644 --- a/src/bind/DirectiveCollector.ts +++ b/src/bind/DirectiveCollector.ts @@ -6,41 +6,17 @@ import { type Binder } from './Binder' * @internal */ export class DirectiveElement { - __name: string // r-on @click @submit r-on:click.prevent @submit.prevent @[event-name].self.camel :src :className.prop .class-name.camel r-if r-for key - /** Contains: `['@', 'submit'], ['r-on', 'click'], ['\\@', '[dynamicKey]']` */ - __terms: string[] = [] + __terms: string[] /** Contains directive flags. ['camel', 'prevent',...] */ - __flags: string[] = [] + __flags: string[] __elements: HTMLElement[] = [] - constructor(name: string) { - this.__name = name - this.__parse() - } - - __parse(): void { - let name = this.__name - const isPropShortcut = name.startsWith('.') - if (isPropShortcut) name = ':' + name.slice(1) - const firstFlagIndex = name.indexOf('.') - const terms = (this.__terms = ( - firstFlagIndex < 0 ? name : name.substring(0, firstFlagIndex) - ).split(/[:@]/)) - if (isNullOrWhitespace(terms[0])) terms[0] = isPropShortcut ? '.' : name[0] - - if (firstFlagIndex >= 0) { - const flags = (this.__flags = name.slice(firstFlagIndex + 1).split('.')) - if (flags.includes('camel')) { - const index = terms.length - 1 - terms[index] = camelize(terms[index]) - } - if (flags.includes('prop')) { - terms[0] = '.' - } - } + constructor(terms: string[], flags: string[]) { + this.__terms = terms + this.__flags = flags } } @@ -52,28 +28,93 @@ export type DirectiveMap = Map export class DirectiveCollector { __binder: Binder __prefixes: string[] + __prefixesByHead: Map + __parsedNameCache: Map constructor(binder: Binder) { this.__binder = binder this.__prefixes = binder.__config.__getPrefixes() + this.__parsedNameCache = new Map() + const groups = new Map() + for (const p of this.__prefixes) { + const key = p[0] ?? '' + const list = groups.get(key) + if (list) list.push(p) + else groups.set(key, [p]) + } + this.__prefixesByHead = groups + } + + __parseName(name: string): { terms: string[]; flags: string[] } { + const cached = this.__parsedNameCache.get(name) + if (cached) return cached + + let normalized = name + const isPropShortcut = normalized.startsWith('.') + if (isPropShortcut) normalized = ':' + normalized.slice(1) + + const firstFlagIndex = normalized.indexOf('.') + const rawTerms = + firstFlagIndex < 0 ? normalized : normalized.substring(0, firstFlagIndex) + const terms = rawTerms.split(/[:@]/) + if (isNullOrWhitespace(terms[0])) { + terms[0] = isPropShortcut ? '.' : normalized[0] + } + + const flags = + firstFlagIndex >= 0 ? normalized.slice(firstFlagIndex + 1).split('.') : [] + let hasCamel = false + let hasProp = false + for (let i = 0; i < flags.length; ++i) { + const f = flags[i] + if (!hasCamel && f === 'camel') hasCamel = true + else if (!hasProp && f === 'prop') hasProp = true + if (hasCamel && hasProp) break + } + if (hasCamel) terms[terms.length - 1] = camelize(terms[terms.length - 1]) + if (hasProp) terms[0] = '.' + + const parsed = { terms, flags } + this.__parsedNameCache.set(name, parsed) + return parsed } __collect(element: Element, isRecursive: boolean): DirectiveMap { const map = new Map() if (!isHTMLElement(element)) return map - const prefixes = this.__prefixes - const processNode = (node: HTMLElement): void => { - const names = node - .getAttributeNames() - .filter((name) => prefixes.some((p) => name.startsWith(p))) - for (const name of names) { - if (!map.has(name)) map.set(name, new DirectiveElement(name)) - const item = map.get(name) as DirectiveElement + const prefixesByHead = this.__prefixesByHead + const appendDirective = (node: HTMLElement, name: string): void => { + const candidates = prefixesByHead.get(name[0] ?? '') + if (!candidates) return + for (let i = 0; i < candidates.length; ++i) { + if (!name.startsWith(candidates[i])) continue + let item = map.get(name) + if (!item) { + const parsed = this.__parseName(name) + item = new DirectiveElement(parsed.terms, parsed.flags) + map.set(name, item) + } item.__elements.push(node) + return + } + } + const processNode = (node: HTMLElement): void => { + const attrs = node.attributes + if (!attrs || attrs.length === 0) return + const attrsAny = attrs as any + for (let i = 0; i < attrs.length; ++i) { + const name = (attrsAny[i] ?? attrs.item(i))?.name + if (!name) continue + appendDirective(node, name) } } processNode(element) if (!isRecursive) return map + // IMPORTANT: + // Keep this on native `querySelectorAll('*')`. + // Browser engines optimize this traversal heavily and it is faster than + // handmade JS tree walking in real DOM benchmarks. + // Do not replace with custom recursion/stack traversal. const nodes = element.querySelectorAll('*') for (const node of nodes) { processNode(node) diff --git a/src/bind/DynamicBinder.ts b/src/bind/DynamicBinder.ts index 2fb388a..a59d7fa 100644 --- a/src/bind/DynamicBinder.ts +++ b/src/bind/DynamicBinder.ts @@ -9,7 +9,6 @@ import { unmount, } from '../common/common' import { isNullOrWhitespace, isObject, isString } from '../common/is-what' -import { observe } from '../observer/observe' import { type Binder } from './Binder' import { setSwitchOwner } from './switch' @@ -165,7 +164,9 @@ export class DynamicBinder { addUnbinder(commentBegin, unbinder) refresh() - const stopObserving = observe(value, refresh) + const stopObserving = ( + parseResult.subscribe ? parseResult.subscribe(refresh) : () => {} + ) as StopObserving stopObserverList.push(stopObserving) } } diff --git a/src/bind/ForBinder.ts b/src/bind/ForBinder.ts index 0ec9b99..cb7f305 100644 --- a/src/bind/ForBinder.ts +++ b/src/bind/ForBinder.ts @@ -10,7 +10,6 @@ import { } from '../common/common' import { isArray, isFunction, isNullOrUndefined } from '../common/is-what' import { warning, WarningType } from '../log/warnings' -import { observe } from '../observer/observe' import { sref } from '../reactivity/sref' import { unref } from '../reactivity/unref' import { type Binder } from './Binder' @@ -18,6 +17,7 @@ import { MountList } from './MountList' import { setSwitchOwner } from './switch' const forMarker = Symbol('r-for') +const noIndexRef = ((_: number) => -1) as SRef /** * @internal @@ -95,15 +95,26 @@ export class ForBinder { warning(WarningType.InvalidForExpression, this.__for, forPath, el) return } - // TODO: key binding requires expression evaluation on each step. - // For now, :key="id" is supported but :key="item.id" is not. const nameKey = this.__binder.__config.__builtInNames.key const nameKeyBind = this.__binder.__config.__builtInNames.keyBind - const key = el.getAttribute(nameKey) ?? el.getAttribute(nameKeyBind) + /** + * r-for key handling is path-based for speed. + * + * - `key` and `:key` are treated identically here. + * - The value is NOT evaluated as a full directive expression. + * - Supported key forms: + * - `id` (single property on current item) + * - dotted property paths (any depth), e.g. `row.id`, `row.meta.id`, `a.b.c.d` + * - For alias-like dotted keys (`row.id`), a fallback strips the first segment + * when needed so keys still resolve against the current item shape. + * - Not supported as key syntax: + * - arbitrary JS expressions (e.g. `row.id + '-' + row.type`) + * - function calls / computed expressions + */ + const keyExpression = + el.getAttribute(nameKey) ?? el.getAttribute(nameKeyBind) el.removeAttribute(nameKey) el.removeAttribute(nameKeyBind) - const getKey = key ? (v: any) => unref(unref(v)?.[key]) : (v: any) => v - const areEqual = (a: any, b: any): boolean => getKey(a) === getKey(b) const nodes = getNodes(el) const parent = el.parentNode as HTMLElement @@ -122,6 +133,12 @@ export class ForBinder { const binder = this.__binder const parser = binder.__parser const capturedContext = parser.__capture() + const singleCapturedContext = capturedContext.length === 1 + const rowContexts = singleCapturedContext + ? [undefined as any, capturedContext[0]] + : undefined + const getKey = this.__createKeyGetter(keyExpression) + const areEqual = (a: any, b: any): boolean => getKey(a) === getKey(b) const mountNewValue = ( i: number, newValue: any, @@ -129,17 +146,17 @@ export class ForBinder { ): MountListItem => { const result = config.createContext(newValue, i) const mountItem = MountList.__createItem(result.index, newValue) - parser.__scoped(capturedContext, () => { - parser.__push(result.ctx) + const renderInContext = (): void => { const insertParent = ((commentEnd.parentNode ?? commentBegin.parentNode) as HTMLElement | null) ?? parent let start = nextSibling.previousSibling as ChildNode const childNodes: ChildNode[] = [] - for (const x of nodes) { - const node = x.cloneNode(true) + // Perf improvement note: Do not try to create fragment to insert all nodes at once. It is not faster! + for (let j = 0; j < nodes.length; ++j) { + const node = nodes[j].cloneNode(true) as ChildNode insertParent.insertBefore(node, nextSibling) - childNodes.push(node as ChildNode) + childNodes.push(node) } bindChildNodes(binder, childNodes) start = start.nextSibling as ChildNode @@ -147,7 +164,16 @@ export class ForBinder { mountItem.items.push(start) start = start.nextSibling as ChildNode } - }) + } + if (rowContexts) { + rowContexts[0] = result.ctx + parser.__scoped(rowContexts, renderInContext) + } else { + parser.__scoped(capturedContext, () => { + parser.__push(result.ctx) + renderInContext() + }) + } return mountItem } @@ -266,7 +292,9 @@ export class ForBinder { } const observeTailChanges = (): void => { - stopObserving = observe(value, updateDom) + stopObserving = ( + parseResult.subscribe ? parseResult.subscribe(updateDom) : () => {} + ) as StopObserving } const unbinder = (): void => { @@ -336,7 +364,10 @@ export class ForBinder { ctx[k] = unrefValue[k] } } - const result = { ctx, index: sref(-1) } + const result: { ctx: any; index: SRef } = { + ctx, + index: noIndexRef, + } if (index) { result.index = ctx[ index.startsWith('#') ? index.substring(1) : index @@ -346,4 +377,38 @@ export class ForBinder { }, } } + + __createKeyGetter(keyExpression: string | null): (v: any) => unknown { + // Fast key resolver for r-for: + // - plain key: direct property lookup + // - dotted key: compiled path walk + // This intentionally avoids parser/evaluator overhead in hot diff paths. + if (!keyExpression) return (v: any) => v + const expression = keyExpression.trim() + if (!expression) return (v: any) => v + if (expression.includes('.')) { + const path = this.__compilePath(expression) + const fallbackPath = path.length > 1 ? path.slice(1) : undefined + return (v: any) => { + const base = unref(v) + const primary = this.__walkPath(base, path) + if (primary !== undefined || !fallbackPath) return primary + // Supports alias-prefixed keys like "row.id" without carrying alias metadata. + return this.__walkPath(base, fallbackPath) + } + } + return (v: any) => unref(unref(v)?.[expression]) + } + + __compilePath(path: string): string[] { + return path.split('.').filter((x) => x.length > 0) + } + + __walkPath(source: any, path: string[]): unknown { + let value = source + for (const part of path) { + value = unref(value)?.[part] + } + return unref(value) + } } diff --git a/src/bind/IfBinder.ts b/src/bind/IfBinder.ts index 586955a..87591b9 100644 --- a/src/bind/IfBinder.ts +++ b/src/bind/IfBinder.ts @@ -9,7 +9,6 @@ import { unmount, } from '../common/common' import { warning, WarningType } from '../log/warnings' -import { observe } from '../observer/observe' import { type Binder } from './Binder' import { setSwitchOwner } from './switch' @@ -193,7 +192,9 @@ export class IfBinder { } addUnbinder(commentBegin, unbinder) - const stopObserving = observe(value, refresh) + const stopObserving = ( + parseResult.subscribe ? parseResult.subscribe(refresh) : () => {} + ) as StopObserving stopObserverList.push(stopObserving) return [ @@ -268,7 +269,9 @@ export class IfBinder { addUnbinder(commentBegin, unbinder) refresh() - const stopObserving = observe(value, refresh) + const stopObserving = ( + parseResult.subscribe ? parseResult.subscribe(refresh) : () => {} + ) as StopObserving stopObserverList.push(stopObserving) } } diff --git a/src/cleanup/removeNode.ts b/src/cleanup/removeNode.ts index 46ebc3b..4860fd1 100644 --- a/src/cleanup/removeNode.ts +++ b/src/cleanup/removeNode.ts @@ -1,6 +1,30 @@ import { unbind } from './unbind' +const pendingUnbind: ChildNode[] = [] +let flushScheduled = false +let flushTimer: ReturnType | undefined + +const flushPendingUnbind = (): void => { + flushScheduled = false + flushTimer = undefined + if (pendingUnbind.length === 0) return + for (let i = 0; i < pendingUnbind.length; ++i) { + unbind(pendingUnbind[i]) + } + pendingUnbind.length = 0 +} + export const removeNode = (node: ChildNode): void => { node.remove() - setTimeout(() => unbind(node), 1) + pendingUnbind.push(node) + if (!flushScheduled) { + flushScheduled = true + flushTimer = setTimeout(flushPendingUnbind, 1) + } +} + +export const drainUnbind = async (): Promise => { + if (pendingUnbind.length === 0 && !flushScheduled) return + if (flushTimer) clearTimeout(flushTimer) + flushPendingUnbind() } diff --git a/src/cleanup/unbind.ts b/src/cleanup/unbind.ts index 59de9c1..26f6c24 100644 --- a/src/cleanup/unbind.ts +++ b/src/cleanup/unbind.ts @@ -1,15 +1,16 @@ import { bindDataSymbol } from './bindDataSymbol' export const unbind = (node: Node): void => { - const queue: any[] = [node] - - while (queue.length > 0) { - const currentElement = queue.shift() - unbindSingle(currentElement) - const childNodes = currentElement.childNodes - if (!childNodes) continue - for (const item of childNodes) { - queue.push(item) + const stack: Node[] = [node] + while (stack.length > 0) { + const currentNode = stack.pop() as Node + unbindSingle(currentNode) + for ( + let child = currentNode.lastChild; + child != null; + child = child.previousSibling + ) { + stack.push(child) } } } @@ -17,10 +18,10 @@ export const unbind = (node: Node): void => { const unbindSingle = (node: Node): void => { const bindData = (node as any)[bindDataSymbol] if (!bindData) return - for (const unbinder of bindData.unbinders) { - unbinder() + const unbinders = bindData.unbinders + for (let i = 0; i < unbinders.length; ++i) { + unbinders[i]() } - bindData.unbinders.splice(0) - - delete (node as any)[bindDataSymbol] + unbinders.length = 0 + ;(node as any)[bindDataSymbol] = undefined } diff --git a/src/common/common.ts b/src/common/common.ts index 41b71ca..8dbd0db 100644 --- a/src/common/common.ts +++ b/src/common/common.ts @@ -22,7 +22,9 @@ export const bindChildNodes = ( binder: Binder, childNodes: ChildNode[], ): void => { - for (const child of childNodes) { + for (let i = 0; i < childNodes.length; ++i) { + const child = childNodes[i] + if (child.nodeType !== Node.ELEMENT_NODE) continue // r-if binding can remove sibling else nodes if (!isElseNode(child)) binder.__bindDefault(child as Element) } @@ -84,21 +86,44 @@ export const unmount = (start: Node, end: Node): void => { } } +/** + * @internal + */ +const getRefValue = function (this: AnyRef): unknown { + return this() +} + +const setRefValue = function (this: AnyRef, value: unknown): unknown { + return this(value) +} + +const setReadonlyRefValue = (): never => { + throw new Error('value is readonly.') +} + +const writableRefValueDescriptor: PropertyDescriptor = { + get: getRefValue as () => unknown, + set: setRefValue as (value: unknown) => unknown, + enumerable: true, + configurable: false, +} + +const readonlyRefValueDescriptor: PropertyDescriptor = { + get: getRefValue as () => unknown, + set: setReadonlyRefValue as (value: unknown) => never, + enumerable: true, + configurable: false, +} + /** * @internal */ export const defineRefValue = (result: AnyRef, isReadOnly: boolean): void => { - Object.defineProperty(result, 'value', { - get() { - return result() - }, - set(value: unknown) { - if (isReadOnly) throw new Error('value is readonly.') - return result(value) - }, - enumerable: true, - configurable: false, - }) + Object.defineProperty( + result, + 'value', + isReadOnly ? readonlyRefValueDescriptor : writableRefValueDescriptor, + ) } /** diff --git a/src/common/looseEqual.ts b/src/common/looseEqual.ts index 2cc9554..c6dfba0 100644 --- a/src/common/looseEqual.ts +++ b/src/common/looseEqual.ts @@ -37,28 +37,20 @@ export function looseEqual(a: any, b: any): boolean { aValidType = isObject(a) bValidType = isObject(b) if (aValidType || bValidType) { - /* istanbul ignore if: this if will probably never be called */ - if (!aValidType || !bValidType) { - return false - } + if (!aValidType || !bValidType) return false const aKeysCount = Object.keys(a).length const bKeysCount = Object.keys(b).length if (aKeysCount !== bKeysCount) { return false } for (const key in a) { - // eslint-disable-next-line no-prototype-builtins - const aHasKey = a.hasOwnProperty(key) - // eslint-disable-next-line no-prototype-builtins - const bHasKey = b.hasOwnProperty(key) - if ( - (aHasKey && !bHasKey) || - (!aHasKey && bHasKey) || - !looseEqual(a[key], b[key]) - ) { + const aHasKey = Object.prototype.hasOwnProperty.call(a, key) + const bHasKey = Object.prototype.hasOwnProperty.call(b, key) + if ((aHasKey && !bHasKey) || !looseEqual(a[key], b[key])) { return false } } + return true } return String(a) === String(b) } diff --git a/src/directives/attr.ts b/src/directives/attr.ts index d5ef188..45811dd 100644 --- a/src/directives/attr.ts +++ b/src/directives/attr.ts @@ -101,7 +101,7 @@ const patchAttribute = ( } if (isNullOrUndefined(key)) { - warning(WarningType.KeyIsEmpty, name, el) + warning(WarningType.KeyIsEmpty, 'r-bind', el) return } diff --git a/src/directives/on.ts b/src/directives/on.ts index 66ea606..0b9fce6 100644 --- a/src/directives/on.ts +++ b/src/directives/on.ts @@ -94,7 +94,7 @@ export const onDirective: Directive = { unbinders.push(attachEventListener(el, eventType, method, flags)) } } else { - warning(WarningType.BindingRequiresObjectExpressions, name, el) + warning(WarningType.BindingRequiresObjectExpressions, 'r-on', el) } } return unbinder @@ -150,7 +150,7 @@ export const attachEventListener = ( flags: any, ): Unbinder => { if (isNullOrWhitespace(eventType)) { - warning(WarningType.MissingEventType, name, el) + warning(WarningType.MissingEventType, 'r-on', el) return () => {} } diff --git a/src/directives/prop.ts b/src/directives/prop.ts index a42c158..1b481c0 100644 --- a/src/directives/prop.ts +++ b/src/directives/prop.ts @@ -60,7 +60,7 @@ function includeBooleanAttr(value: unknown): boolean { */ export const patchProp = (el: any, key: string, value: any): void => { if (isNullOrUndefined(key)) { - warning(WarningType.KeyIsEmpty, name, el) + warning(WarningType.KeyIsEmpty, ':prop', el) return } diff --git a/src/directives/single-prop.ts b/src/directives/single-prop.ts index 754b663..66c91be 100644 --- a/src/directives/single-prop.ts +++ b/src/directives/single-prop.ts @@ -36,6 +36,7 @@ import { ref } from '../reactivity/ref' * - Preserves nested propagation while avoiding previous-source overwrite. */ const modelBridgeSymbol = Symbol('modelBridge') +const noop: Unbinder = () => {} const isModelBridge = (value: unknown): boolean => !!(value as Record)?.[modelBridgeSymbol] const markModelBridge = (value: AnyRef): void => { @@ -60,21 +61,21 @@ export const singlePropDirective: Directive = { _dynamicOption?: ParseResult, _flags?: string[], ): Unbinder => { - if (!option) return () => {} + if (!option) return noop const key = camelize(option) let currentSource: AnyRef | undefined let bridge: AnyRef | undefined - let stopEntangle: Unbinder = () => {} + let stopEntangle: Unbinder = noop const resetSync = (): void => { stopEntangle() - stopEntangle = () => {} + stopEntangle = noop currentSource = undefined bridge = undefined } const clearEntangle = (): void => { stopEntangle() - stopEntangle = () => {} + stopEntangle = noop } const syncRefs = (source: AnyRef, target: AnyRef): void => { if (currentSource === source) return diff --git a/src/directives/value.ts b/src/directives/value.ts index 3ce106e..a08d828 100644 --- a/src/directives/value.ts +++ b/src/directives/value.ts @@ -5,7 +5,7 @@ import { patchProp } from './prop' * @internal */ export const valueDirective: Directive = { - onChange: (el: HTMLElement, values: any[]) => { + onChange: (el: HTMLElement, values: unknown[]) => { patchProp(el, 'value', values[0]) }, } diff --git a/src/index.ts b/src/index.ts index 34359e4..0444799 100644 --- a/src/index.ts +++ b/src/index.ts @@ -30,6 +30,7 @@ export { toFragment } from './app/toFragment' export { toJsonTemplate } from './app/toJsonTemplate' export { addUnbinder } from './cleanup/addUnbinder' export { getBindData } from './cleanup/getBindData' +export { drainUnbind } from './cleanup/removeNode' export { removeNode } from './cleanup/removeNode' export { unbind } from './cleanup/unbind' export { onMounted } from './composition/onMounted' diff --git a/src/observer/observe.ts b/src/observer/observe.ts index 76cb60b..3ce48d5 100644 --- a/src/observer/observe.ts +++ b/src/observer/observe.ts @@ -14,6 +14,7 @@ export const observe = ( source: TValueType, observer: ObserveCallback>, init?: boolean, + trackUnmount = true, ): StopObserving => { if (!isRef(source)) throw getError(ErrorType.RequiresRefSourceArgument, 'observe') @@ -25,6 +26,6 @@ export const observe = ( RefOperation.observe, observer, ) as StopObserving - onUnmounted(stop, true) + if (trackUnmount) onUnmounted(stop, true) return stop } diff --git a/src/parser/Parser.ts b/src/parser/Parser.ts index b36df42..235b4d9 100644 --- a/src/parser/Parser.ts +++ b/src/parser/Parser.ts @@ -74,21 +74,38 @@ export class Parser { ): ParseResult { const value = sref([]) const stopObserverList = [] as StopObserving[] - const unbinder = (): void => { + const subscribers = new Set<(values: unknown[]) => void>() + const clearObservers = (): void => { for (const stopObserver of stopObserverList) { stopObserver() } stopObserverList.length = 0 } + const unbinder = (): void => { + clearObservers() + subscribers.clear() + } + const subscribe = ( + observer: (values: unknown[]) => void, + init?: boolean, + ): StopObserving => { + subscribers.add(observer) + if (init) observer(value() as unknown[]) + return () => { + subscribers.delete(observer) + } + } const result: ParseResult = { value, stop: unbinder, + subscribe, refs: [], context: this.__contexts[0], } if (isNullOrWhitespace(expression)) return result const globalContext = this.__config.globalContext - const refs: any[] = [] + const refs: AnyRef[] = [] + const uniqueRefs = new Set() const evaluate = ( expr: Expression, contexts: any[], @@ -117,27 +134,42 @@ export class Parser { astCache[expression] ?? (jsep('[' + expression + ']') as ArrayExpression) astCache[expression] = ast - const contexts = this.__contexts + const contexts = this.__contexts.slice() + const elements = ast.elements + const len = elements.length const refresh = (): void => { - refs.splice(0) - unbinder() - const evaluated = ast.elements.map((x, i) => { - if (isLazy?.(i, -1)) - return { - value: (e: Event) => - evaluate(x, contexts, false, { $event: e }).value, - refs: [], - } - return evaluate(x, contexts, true) - }) + refs.length = 0 + uniqueRefs.clear() + clearObservers() + const values = new Array(len) + const expressionRefs = new Array(len) + for (let i = 0; i < len; ++i) { + const expr = elements[i] + if (isLazy?.(i, -1)) { + values[i] = (e: Event) => + evaluate(expr, contexts, false, { $event: e }).value + continue + } + const evaluated = evaluate(expr, contexts, true) + values[i] = evaluated.value + expressionRefs[i] = evaluated.ref + } if (!once) { for (const r of refs) { + if (uniqueRefs.has(r)) continue + uniqueRefs.add(r) const stopObserving = observe(r, refresh) stopObserverList.push(stopObserving) } } - result.refs = evaluated.map((x) => x.ref) - value(evaluated.map((x) => x.value)) + result.refs = expressionRefs + value(values) + if (subscribers.size !== 0) { + for (const subscriber of subscribers) { + if (!subscribers.has(subscriber)) continue + subscriber(values) + } + } } refresh() } catch (e) { @@ -147,7 +179,7 @@ export class Parser { } __capture(): any[] { - return this.__contexts + return this.__contexts.slice() } __replaced: any[][] = [] diff --git a/src/parser/jsep/jsep.ts b/src/parser/jsep/jsep.ts index 2db762b..fb1fa0c 100644 --- a/src/parser/jsep/jsep.ts +++ b/src/parser/jsep/jsep.ts @@ -3,13 +3,22 @@ // Copyright (c) 2013 Stephen Oney, https://ericsmekens.github.io/jsep/ // Modifications: // - removed plugin code -// - include required plugins directly in main Jsep classs. +// - include required plugin behavior directly in the main Jsep class // - converted javascript to typescript // - converted expression type from string to enum // - reduced code size +// - optimized for parser throughput: +// - removed dynamic hook dispatch from hot paths +// - parse arrow/assignment nodes directly (no AST-wide fixup passes) +// - replaced substring operator scans with char-code matching +// - reduced per-token allocations in prefix/postfix parsing +// - switched numeric/string/template literal scanning to index-based parsing +// with late materialization to reduce string concatenation allocations import type { ArrayExpression, + ArrowExpression, + AssignmentExpression, BinaryExpression, Compound, Expression, @@ -18,7 +27,7 @@ import type { TemplateLiteral, UnaryExpression, } from './jsep-types' -import { ExpressionType, HookType } from './jsep-types' +import { ExpressionType } from './jsep-types' const TAB_CODE = 9 const LF_CODE = 10 @@ -43,16 +52,10 @@ const BTICK_CODE = 96 // ` const FSLASH_CODE = 47 // '/' const BSLASH_CODE = 92 // '\\' -const updateNodeTypes = [ExpressionType.Identifier, ExpressionType.Member] -const updateOperators = [PLUS_CODE, MINUS_CODE] - -const unaryOps = { - '-': 1, - '!': 1, - '~': 1, - '+': 1, - new: 1, -} +const updateNodeTypes = new Set([ + ExpressionType.Identifier, + ExpressionType.Member, +]) const assignmentOps = { '=': 2.5, @@ -102,10 +105,8 @@ const binaryOps = { const assignmentOperatorKeys = Object.keys(assignmentOps) const assigmentOperatorsSet = new Set(assignmentOperatorKeys) -const rightAssociative = new Set() -rightAssociative.add('=>') +const rightAssociative = new Set(['=>']) assignmentOperatorKeys.forEach((x) => rightAssociative.add(x as BinaryOperator)) -const additionalIdentifierChars = new Set(['$', '_']) const literals = { true: true, @@ -115,14 +116,6 @@ const literals = { const thisStr = 'this' -function getMaxKeyLen(obj: any): number { - return Math.max(0, ...Object.keys(obj).map((k) => k.length)) -} - -const maxUnopLen = getMaxKeyLen(unaryOps) - -const maxBinopLen = getMaxKeyLen(binaryOps) - interface HookResult { node?: Expression | false } @@ -135,7 +128,7 @@ export type BinaryOperator = keyof typeof binaryOps /** * @internal */ -export type UnaryOperator = keyof typeof unaryOps +export type UnaryOperator = '-' | '!' | '~' | '+' | 'new' /** * @internal @@ -163,44 +156,15 @@ const msgVariablesCannotStartWithANumber = 'Variable names cannot start with a number (' const msgUnclosedQuoteAfter = msgUnclosed + 'quote after "' -enum HookCallType { - All, - Single, -} - const isDecimalDigit = (ch: number): boolean => { return ch >= 48 && ch <= 57 // 0...9 } const binaryPrecedence = (opVal: BinaryOperator): number => { - return binaryOps[opVal] || 0 + return binaryOps[opVal] } -type HookFunction = (env: HookResult) => void class Jsep { - __hooks: { - [K in HookType]: HookFunction[] - } = { - [HookType.gobbleExpression]: [this.__gobbleEmptyArrowArg], - [HookType.afterExpression]: [ - this.__fixBinaryArrow, - this.__fixAssignmentOperators, - this.__gobbleTernary, - ], - [HookType.gobbleToken]: [ - this.__gobbleObjectExpression, - this.__gobbleUpdatePrefix, - this.__gobbleSpread, - this.__gobbleTemplateLiteral, - this.__gobbleRegexLiteral, - ], - [HookType.afterToken]: [ - this.__gobbleNew, - this.__gobbleUpdatePostfix, - this.__gobbleTaggedTemplateIdentifier, - ], - } - __expr: string __index: number @@ -223,14 +187,13 @@ class Jsep { } __isIdentifierStart(ch: number): boolean { - const char = String.fromCharCode(ch) return ( (ch >= 65 && ch <= 90) || // A...Z (ch >= 97 && ch <= 122) || // a...z - (ch >= 128 && !(char in binaryOps)) || - // any non-ASCII that is not an operator - additionalIdentifierChars.has(char) - ) // additional characters + ch === 36 || // $ + ch === 95 || // _ + ch >= 128 + ) } __isIdentifierPart(ch: number): boolean { @@ -241,22 +204,6 @@ class Jsep { return new Error(`${message} at character ${this.__index}`) } - __runHook( - hookType: HookType, - hookCalltype: HookCallType, - node?: Expression | false, - ): GobbleResult { - const hook = this.__hooks[hookType] - if (!hook) return node - const env: HookResult = { node } - const hookFn = (f: HookFunction): void => { - f.call(this, env) - } - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - hookCalltype === HookCallType.All ? hook.forEach(hookFn) : hook.find(hookFn) - return env.node - } - __gobbleSpaces(): void { let ch = this.__code const expr = this.__expr @@ -311,10 +258,9 @@ class Jsep { __gobbleExpression(): GobbleResult { const node = - this.__runHook(HookType.gobbleExpression, HookCallType.Single) ?? - this.__gobbleBinaryExpression() + this.__gobbleEmptyArrowArgNode() ?? this.__gobbleBinaryExpression() this.__gobbleSpaces() - return this.__runHook(HookType.afterExpression, HookCallType.All, node) + return this.__gobbleTernaryNode(node) } /** @@ -325,28 +271,139 @@ class Jsep { */ __gobbleBinaryOp(): false | BinaryOperator { this.__gobbleSpaces() - let index = this.__index const expr = this.__expr - let toCheck = expr.substr(index, maxBinopLen) - let tcLen = toCheck.length - - while (tcLen > 0) { - // Don't accept a binary op when it is an identifier. - // Binary ops that start with a identifier-valid character must be followed - // by a non identifier-part valid character - if ( - toCheck in binaryOps && - (!this.__isIdentifierStart(this.__code) || - (index + toCheck.length < expr.length && - !this.__isIdentifierPart(expr.charCodeAt(index + toCheck.length)))) - ) { - index += tcLen - this.__index = index - return toCheck as BinaryOperator + const index = this.__index + const c1 = expr.charCodeAt(index) + const c2 = expr.charCodeAt(index + 1) + const c3 = expr.charCodeAt(index + 2) + const c4 = expr.charCodeAt(index + 3) + + if (isNaN(c1)) return false + + let op: BinaryOperator | false = false + let opLen = 0 + + if (c1 === 62 && c2 === 62 && c3 === 62 && c4 === 61) { + op = '>>>=' + opLen = 4 + } else if (c1 === 61 && c2 === 61 && c3 === 61) { + op = '===' + opLen = 3 + } else if (c1 === 33 && c2 === 61 && c3 === 61) { + op = '!==' + opLen = 3 + } else if (c1 === 62 && c2 === 62 && c3 === 62) { + op = '>>>' + opLen = 3 + } else if (c1 === 60 && c2 === 60 && c3 === 61) { + op = '<<=' + opLen = 3 + } else if (c1 === 62 && c2 === 62 && c3 === 61) { + op = '>>=' + opLen = 3 + } else if (c1 === 42 && c2 === 42 && c3 === 61) { + op = '**=' + opLen = 3 + } else if (c1 === 61 && c2 === 62) { + op = '=>' + opLen = 2 + } else if (c1 === 124 && c2 === 124) { + op = '||' + opLen = 2 + } else if (c1 === 63 && c2 === 63) { + op = '??' + opLen = 2 + } else if (c1 === 38 && c2 === 38) { + op = '&&' + opLen = 2 + } else if (c1 === 61 && c2 === 61) { + op = '==' + opLen = 2 + } else if (c1 === 33 && c2 === 61) { + op = '!=' + opLen = 2 + } else if (c1 === 60 && c2 === 61) { + op = '<=' + opLen = 2 + } else if (c1 === 62 && c2 === 61) { + op = '>=' + opLen = 2 + } else if (c1 === 60 && c2 === 60) { + op = '<<' + opLen = 2 + } else if (c1 === 62 && c2 === 62) { + op = '>>' + opLen = 2 + } else if (c1 === 43 && c2 === 61) { + op = '+=' + opLen = 2 + } else if (c1 === 45 && c2 === 61) { + op = '-=' + opLen = 2 + } else if (c1 === 42 && c2 === 61) { + op = '*=' + opLen = 2 + } else if (c1 === 47 && c2 === 61) { + op = '/=' + opLen = 2 + } else if (c1 === 37 && c2 === 61) { + op = '%=' + opLen = 2 + } else if (c1 === 38 && c2 === 61) { + op = '&=' + opLen = 2 + } else if (c1 === 94 && c2 === 61) { + op = '^=' + opLen = 2 + } else if (c1 === 124 && c2 === 61) { + op = '|=' + opLen = 2 + } else if (c1 === 42 && c2 === 42) { + op = '**' + opLen = 2 + } else if (c1 === 105 && c2 === 110) { + if (!this.__isIdentifierPart(expr.charCodeAt(index + 2))) { + op = 'in' + opLen = 2 } - toCheck = toCheck.substr(0, --tcLen) + } else if (c1 === 61) { + op = '=' + opLen = 1 + } else if (c1 === 124) { + op = '|' + opLen = 1 + } else if (c1 === 94) { + op = '^' + opLen = 1 + } else if (c1 === 38) { + op = '&' + opLen = 1 + } else if (c1 === 60) { + op = '<' + opLen = 1 + } else if (c1 === 62) { + op = '>' + opLen = 1 + } else if (c1 === 43) { + op = '+' + opLen = 1 + } else if (c1 === 45) { + op = '-' + opLen = 1 + } else if (c1 === 42) { + op = '*' + opLen = 1 + } else if (c1 === 47) { + op = '/' + opLen = 1 + } else if (c1 === 37) { + op = '%' + opLen = 1 } - return false + + if (!op) return false + this.__index += opLen + return op } /** @@ -358,7 +415,7 @@ class Jsep { biop: BinaryOperator | false, prec: number, biopInfo: { - value: BinaryOperator | false + value: BinaryOperator prec: number right_a: boolean }, @@ -401,11 +458,6 @@ class Jsep { while ((biop = this.__gobbleBinaryOp())) { prec = binaryPrecedence(biop) - if (prec === 0) { - this.__index -= biop.length - break - } - biopInfo = { value: biop, prec, @@ -425,12 +477,11 @@ class Jsep { right = stack.pop() biop = (stack.pop() as typeof biopInfo).value left = stack.pop() - node = { - type: ExpressionType.Binary, - operator: biop, - left, - right, - } + node = this.__createBinaryNode( + biop as BinaryOperator, + left as Expression, + right as Expression, + ) stack.push(node) } @@ -447,12 +498,11 @@ class Jsep { node = stack[i] as GobbleResult while (i > 1) { - node = { - type: ExpressionType.Binary, - operator: (stack[i - 1] as typeof biopInfo).value, - left: stack[i - 2], - right: node, - } + node = this.__createBinaryNode( + (stack[i - 1] as typeof biopInfo).value as BinaryOperator, + stack[i - 2] as Expression, + node as Expression, + ) i -= 2 } @@ -464,13 +514,10 @@ class Jsep { * e.g. `foo.bar(baz)`, `1`, `"abc"`, `(a % 2)` (because it's in parenthesis) */ __gobbleToken(): false | Expression | undefined { - let toCheck, tcLen, node: GobbleResult - + let node: GobbleResult this.__gobbleSpaces() - node = this.__runHook(HookType.gobbleToken, HookCallType.Single) - if (node) { - return this.__runHook(HookType.afterToken, HookCallType.All, node) - } + node = this.__gobbleTokenFromPrefix() + if (node) return this.__postProcessToken(node) const ch = this.__code @@ -485,34 +532,17 @@ class Jsep { } else if (ch === OBRACK_CODE) { node = this.__gobbleArray() } else { - toCheck = this.__expr.substr(this.__index, maxUnopLen) - tcLen = toCheck.length - - while (tcLen > 0) { - // Don't accept an unary op when it is an identifier. - // Unary ops that start with a identifier-valid character must be followed - // by a non identifier-part valid character - if ( - Object.prototype.hasOwnProperty.call(unaryOps, toCheck) && - (!this.__isIdentifierStart(this.__code) || - (this.__index + toCheck.length < this.__expr.length && - !this.__isIdentifierPart( - this.__expr.charCodeAt(this.__index + toCheck.length), - ))) - ) { - this.__index += tcLen - const argument = this.__gobbleToken() - if (!argument) { - throw this.__getError(msgMissingUnaryOp) - } - return this.__runHook(HookType.afterToken, HookCallType.All, { - type: ExpressionType.Unary, - operator: toCheck, - argument, - }) + const unary = this.__gobbleUnaryOp() + if (unary) { + const argument = this.__gobbleToken() + if (!argument) { + throw this.__getError(msgMissingUnaryOp) } - - toCheck = toCheck.substr(0, --tcLen) + return this.__postProcessToken({ + type: ExpressionType.Unary, + operator: unary, + argument, + }) } if (this.__isIdentifierStart(ch)) { @@ -533,11 +563,115 @@ class Jsep { } if (!node) { - return this.__runHook(HookType.afterToken, HookCallType.All, false) + return false } node = this.__gobbleTokenProperty(node) - return this.__runHook(HookType.afterToken, HookCallType.All, node) + return this.__postProcessToken(node) + } + + __createBinaryNode( + operator: BinaryOperator, + left: Expression, + right: Expression, + ): BinaryExpression | AssignmentExpression | ArrowExpression { + if (operator === '=>') { + const params = + left.type === ExpressionType.Sequence + ? (left.expressions as any) + : [left] + return { + type: ExpressionType.Arrow, + params, + body: right, + } + } + + if (assigmentOperatorsSet.has(operator)) { + return { + type: ExpressionType.Assignment, + operator: operator as AssignmentOperator, + left, + right, + } + } + + return { + type: ExpressionType.Binary, + operator, + left, + right, + } + } + + __gobbleTokenFromPrefix(): GobbleResult { + const env: HookResult = { node: false } + this.__gobbleObjectExpression(env) + if (env.node) return env.node + this.__gobbleUpdatePrefix(env) + if (env.node) return env.node + this.__gobbleSpread(env) + if (env.node) return env.node + this.__gobbleTemplateLiteral(env) + if (env.node) return env.node + this.__gobbleRegexLiteral(env) + return env.node + } + + __postProcessToken(node: Expression): Expression { + const env: HookResult = { node } + this.__gobbleNew(env) + this.__gobbleUpdatePostfix(env) + this.__gobbleTaggedTemplateIdentifier(env) + return env.node as Expression + } + + __gobbleUnaryOp(): UnaryOperator | false { + const expr = this.__expr + const index = this.__index + const c1 = expr.charCodeAt(index) + const c2 = expr.charCodeAt(index + 1) + const c3 = expr.charCodeAt(index + 2) + const c4 = expr.charCodeAt(index + 3) + + if (c1 === MINUS_CODE) { + this.__index++ + return '-' + } + if (c1 === 33) { + this.__index++ + return '!' + } + if (c1 === 126) { + this.__index++ + return '~' + } + if (c1 === PLUS_CODE) { + this.__index++ + return '+' + } + if ( + c1 === 110 && + c2 === 101 && + c3 === 119 && + !this.__isIdentifierPart(c4) + ) { + this.__index += 3 + return 'new' + } + return false + } + + __gobbleEmptyArrowArgNode(): GobbleResult { + const env: HookResult = {} + this.__gobbleEmptyArrowArg(env) + return env.node + } + + __gobbleTernaryNode(node: GobbleResult): GobbleResult { + const env: HookResult = { node } + this.__gobbleTernary(env) + return env.node } /** @@ -588,7 +722,7 @@ class Jsep { arguments: this.__gobbleArguments(CPAREN_CODE), callee: node, } - } else if (ch === PERIOD_CODE || optional) { + } else { if (optional) { this.__index-- } @@ -617,45 +751,46 @@ class Jsep { * keep track of everything in the numeric literal and then calling `parseFloat` on that string */ __gobbleNumericLiteral(): Literal { - let number = '' - let ch + const expr = this.__expr + const start = this.__index + let index = start - while (isDecimalDigit(this.__code)) { - number += this.__expr.charAt(this.__index++) + while (isDecimalDigit(expr.charCodeAt(index))) { + index++ } - if (this.__isCode(PERIOD_CODE)) { + if (expr.charCodeAt(index) === PERIOD_CODE) { // can start with a decimal marker - number += this.__expr.charAt(this.__index++) - - while (isDecimalDigit(this.__code)) { - number += this.__expr.charAt(this.__index++) + index++ + while (isDecimalDigit(expr.charCodeAt(index))) { + index++ } } - ch = this.__char - - if (ch === 'e' || ch === 'E') { + const exponentCode = expr.charCodeAt(index) + if (exponentCode === 101 || exponentCode === 69) { // exponent marker - number += this.__expr.charAt(this.__index++) - ch = this.__char - - if (ch === '+' || ch === '-') { + index++ + const signCode = expr.charCodeAt(index) + if (signCode === PLUS_CODE || signCode === MINUS_CODE) { // exponent sign - number += this.__expr.charAt(this.__index++) + index++ } - - while (isDecimalDigit(this.__code)) { + const exponentStart = index + while (isDecimalDigit(expr.charCodeAt(index))) { // exponent itself - number += this.__expr.charAt(this.__index++) + index++ } - - if (!isDecimalDigit(this.__expr.charCodeAt(this.__index - 1))) { + if (exponentStart === index) { + this.__index = index + const number = expr.slice(start, index) throw this.__getError(msgExpectedExponent + number + this.__char + ')') } } - const chCode = this.__code + this.__index = index + const number = expr.slice(start, index) + const chCode = expr.charCodeAt(index) // Check to make sure this isn't a variable name that start with a number (123abc) if (this.__isIdentifierStart(chCode)) { @@ -681,56 +816,70 @@ class Jsep { * e.g. `"hello world"`, `'this is\nJSEP'` */ __gobbleStringLiteral(): Literal { - let str = '' + const expr = this.__expr + const length = expr.length const startIndex = this.__index - const quote = this.__expr.charAt(this.__index++) + const quoteCode = expr.charCodeAt(this.__index++) + let index = this.__index + let segmentStart = index + const cookedParts: string[] = [] + let hadEscape = false let closed = false - while (this.__index < this.__expr.length) { - let ch = this.__expr.charAt(this.__index++) - - if (ch === quote) { + while (index < length) { + const code = expr.charCodeAt(index) + if (code === quoteCode) { closed = true + this.__index = index + 1 break - } else if (ch === '\\') { - // Check for all of the common escape codes - ch = this.__expr.charAt(this.__index++) - - switch (ch) { - case 'n': - str += '\n' - break - case 'r': - str += '\r' - break - case 't': - str += '\t' - break - case 'b': - str += '\b' - break - case 'f': - str += '\f' - break - case 'v': - str += '\x0B' - break - default: - str += ch - } + } + if (code === BSLASH_CODE) { + if (!hadEscape) hadEscape = true + cookedParts.push(expr.slice(segmentStart, index)) + const escCode = expr.charCodeAt(index + 1) + cookedParts.push(this.__decodeEscapedChar(escCode)) + index += 2 + segmentStart = index } else { - str += ch + index++ } } + const str = hadEscape + ? cookedParts.join('') + expr.slice(segmentStart, closed ? index : length) + : expr.slice(segmentStart, closed ? index : length) + if (!closed) { + this.__index = index throw this.__getError(msgUnclosedQuoteAfter + str + '"') } return { type: ExpressionType.Literal, value: str, - raw: this.__expr.substring(startIndex, this.__index), + raw: expr.substring(startIndex, this.__index), + } + } + + __decodeEscapedChar(code: number): string { + switch (code) { + case 110: + return '\n' + case 114: + return '\r' + case 116: + return '\t' + case 98: + return '\b' + case 102: + return '\f' + case 118: + return '\x0B' + default: + if (isNaN(code)) { + return '' + } + return String.fromCharCode(code) } } @@ -806,7 +955,7 @@ class Jsep { // missing argument if (termination === CPAREN_CODE) { throw this.__getError(msgUnexpectedToken + ',') - } else if (termination === CBRACK_CODE) { + } else { for (let arg = args.length; arg < separatorCount; arg++) { args.push(null) } @@ -926,7 +1075,7 @@ class Jsep { shorthand: false, }) this.__gobbleSpaces() - } else if (key) { + } else { // spread, assignment (object destructuring with defaults), etc. properties.push(key) } @@ -942,9 +1091,8 @@ class Jsep { __gobbleUpdatePrefix(env: HookResult): void { const code = this.__code if ( - updateOperators.some( - (c) => c === code && c === this.__expr.charCodeAt(this.__index + 1), - ) + (code === PLUS_CODE || code === MINUS_CODE) && + code === this.__expr.charCodeAt(this.__index + 1) ) { this.__index += 2 const expr = (env.node = { @@ -953,39 +1101,39 @@ class Jsep { argument: this.__gobbleTokenProperty(this.__gobbleIdentifier()), prefix: true, }) - if (!expr.argument || !updateNodeTypes.includes(expr.argument.type)) { + if (!expr.argument || !updateNodeTypes.has(expr.argument.type)) { throw this.__getError(msgUnexpected + expr.operator) } } } __gobbleUpdatePostfix(env: HookResult): void { - if (env.node) { - const code = this.__code - if ( - updateOperators.some( - (c) => c === code && c === this.__expr.charCodeAt(this.__index + 1), + const node = env.node as Expression + const code = this.__code + if ( + (code === PLUS_CODE || code === MINUS_CODE) && + code === this.__expr.charCodeAt(this.__index + 1) + ) { + if (!updateNodeTypes.has(node.type)) { + throw this.__getError( + msgUnexpected + (code === PLUS_CODE ? '++' : '--'), ) - ) { - if (!updateNodeTypes.includes(env.node.type)) { - throw this.__getError(msgUnexpected + env.node.operator) - } - this.__index += 2 - env.node = { - type: ExpressionType.Update, - operator: code === PLUS_CODE ? '++' : '--', - argument: env.node, - prefix: false, - } + } + this.__index += 2 + env.node = { + type: ExpressionType.Update, + operator: code === PLUS_CODE ? '++' : '--', + argument: node, + prefix: false, } } } __gobbleSpread(env: HookResult): void { if ( - [0, 1, 2].every( - (i) => this.__expr.charCodeAt(this.__index + i) === PERIOD_CODE, - ) + this.__expr.charCodeAt(this.__index) === PERIOD_CODE && + this.__expr.charCodeAt(this.__index + 1) === PERIOD_CODE && + this.__expr.charCodeAt(this.__index + 2) === PERIOD_CODE ) { this.__index += 3 env.node = { @@ -1020,24 +1168,6 @@ class Jsep { consequent, alternate, } - - // check for operators of higher priority than ternary (i.e. assignment) - // jsep sets || at 1, and assignment at 0.9, and conditional should be between them - if ( - test.operator && - binaryOps[test.operator as BinaryOperator] <= 0.9 - ) { - let newTest = test as BinaryExpression - while ( - newTest.right.operator && - binaryOps[newTest.right.operator as BinaryOperator] <= 0.9 - ) { - newTest = newTest.right as BinaryExpression - } - env.node.test = newTest.right - newTest.right = env.node - env.node = test - } } else { throw this.__getError(msgExpectedColon) } @@ -1072,55 +1202,9 @@ class Jsep { } } - __fixBinaryArrow(env: HookResult): void { - this.__updateBinariesToArrows(env.node) - } - - __updateBinariesToArrows(node?: Expression | false): void { - if (!node) return - Object.values(node).forEach((val) => { - if (val && typeof val === 'object') { - this.__updateBinariesToArrows(val as Expression | false) - } - }) - - if (node.operator === '=>') { - node.type = ExpressionType.Arrow - node.params = node.left ? [node.left] : null - node.body = node.right - if ( - node.params && - (node.params as any)[0].type === ExpressionType.Sequence - ) { - node.params = (node.params as any)[0].expressions - } - delete node.left - delete node.right - delete node.operator - } - } - - __fixAssignmentOperators(env: HookResult): void { - if (env.node) this.__updateBinariesToAssignments(env.node) - } - - __updateBinariesToAssignments(node: Record): void { - if (assigmentOperatorsSet.has(node.operator)) { - node.type = ExpressionType.Assignment - this.__updateBinariesToAssignments(node.left) - this.__updateBinariesToAssignments(node.right) - } else if (!node.operator) { - Object.values(node).forEach((val) => { - if (val && typeof val === 'object') { - this.__updateBinariesToAssignments(val) - } - }) - } - } - __gobbleTaggedTemplateIdentifier(env: HookResult): void { - if (!env.node) return - const type = env.node.type + const node = env.node as Expression + const type = node.type const condition = (type === ExpressionType.Identifier || type === ExpressionType.Member) && this.__isCode(BTICK_CODE) @@ -1128,82 +1212,86 @@ class Jsep { env.node = { type: ExpressionType.TaggedTemplateExpression, - tag: env.node, + tag: node, quasi: this.__gobbleTemplateLiteral(env), } } __gobbleTemplateLiteral(env: HookResult): TemplateLiteral | undefined { if (!this.__isCode(BTICK_CODE)) return + const expr = this.__expr + const length = expr.length const node: TemplateLiteral = { type: ExpressionType.TemplateLiteral, quasis: [], expressions: [], } - let cooked = '' - let raw = '' - let closed = false - const length = this.__expr.length - const pushQuasi = (): number => - node.quasis.push({ + let segmentStart = ++this.__index + const rawParts: string[] = [] + const cookedParts: string[] = [] + let hasEscape = false + + const pushQuasi = (tail: boolean): number => { + if (!hasEscape) { + const value = expr.slice(segmentStart, this.__index) + return node.quasis.push({ + type: ExpressionType.TemplateElement, + value: { + raw: value, + cooked: value, + }, + tail, + }) + } + + rawParts.push(expr.slice(segmentStart, this.__index)) + cookedParts.push(expr.slice(segmentStart, this.__index)) + const raw = rawParts.join('') + const cooked = cookedParts.join('') + rawParts.length = 0 + cookedParts.length = 0 + hasEscape = false + return node.quasis.push({ type: ExpressionType.TemplateElement, value: { raw, cooked, }, - tail: closed, + tail, }) + } while (this.__index < length) { - let ch = this.__expr.charAt(++this.__index) + const code = expr.charCodeAt(this.__index) - if (ch === '`') { + if (code === BTICK_CODE) { + pushQuasi(true) this.__index += 1 - closed = true - pushQuasi() - env.node = node return node - } else if (ch === '$' && this.__expr.charAt(this.__index + 1) === '{') { + } else if ( + code === 36 && // $ + expr.charCodeAt(this.__index + 1) === OCURLY_CODE + ) { + pushQuasi(false) this.__index += 2 - pushQuasi() - raw = '' - cooked = '' node.expressions.push(...this.__gobbleExpressions(CCURLY_CODE)) if (!this.__isCode(CCURLY_CODE)) { throw this.__getError('unclosed ${') } - } else if (ch === '\\') { - // Check for all of the common escape codes - raw += ch - ch = this.__expr.charAt(++this.__index) - raw += ch - - switch (ch) { - case 'n': - cooked += '\n' - break - case 'r': - cooked += '\r' - break - case 't': - cooked += '\t' - break - case 'b': - cooked += '\b' - break - case 'f': - cooked += '\f' - break - case 'v': - cooked += '\x0B' - break - default: - cooked += ch - } + this.__index += 1 + segmentStart = this.__index + } else if (code === BSLASH_CODE) { + if (!hasEscape) hasEscape = true + rawParts.push(expr.slice(segmentStart, this.__index)) + cookedParts.push(expr.slice(segmentStart, this.__index)) + const escCode = expr.charCodeAt(this.__index + 1) + rawParts.push(expr.slice(this.__index, this.__index + 2)) + cookedParts.push(this.__decodeEscapedChar(escCode)) + this.__index += 2 + segmentStart = this.__index } else { - cooked += ch - raw += ch + this.__index += 1 } } throw this.__getError('Unclosed `') @@ -1224,7 +1312,7 @@ class Jsep { while ( callNode.type === ExpressionType.Member || (callNode.type === ExpressionType.Call && - callNode?.callee?.type === ExpressionType.Member) + callNode.callee.type === ExpressionType.Member) ) { callNode = callNode.type === ExpressionType.Member diff --git a/src/reactivity/isRef.ts b/src/reactivity/isRef.ts index 5e2d982..2a75f76 100644 --- a/src/reactivity/isRef.ts +++ b/src/reactivity/isRef.ts @@ -2,5 +2,5 @@ import { type AnyRef } from '../api/types' import { srefSymbol } from './refSymbols' export const isRef = (value: unknown): value is AnyRef => { - return (value as any)?.[srefSymbol] === 1 + return value != null && (value as any)[srefSymbol] === 1 } diff --git a/src/reactivity/unref.ts b/src/reactivity/unref.ts index 7f8324d..75beafb 100644 --- a/src/reactivity/unref.ts +++ b/src/reactivity/unref.ts @@ -1,6 +1,9 @@ import { type UnwrapRef } from '../api/types' -import { isRef } from './isRef' +import { srefSymbol } from './refSymbols' export const unref = (value: TValueType): UnwrapRef => { - return (isRef(value) ? value() : value) as UnwrapRef + const anyValue = value as any + return ( + anyValue != null && anyValue[srefSymbol] === 1 ? anyValue() : anyValue + ) as UnwrapRef } diff --git a/tests/app/binders-reactivity-e2e.spec.ts b/tests/app/binders-reactivity-e2e.spec.ts new file mode 100644 index 0000000..d2d9894 --- /dev/null +++ b/tests/app/binders-reactivity-e2e.spec.ts @@ -0,0 +1,401 @@ +import { expect, test } from 'vitest' + +import { createApp, createComponent, drainUnbind, html, ref } from '../../src' + +type KeyedLabelSource = { + payload: string + label: string +} + +type NameRow = { + id: number + name: string +} + +type ViewState = { + show: boolean + rows: NameRow[] +} + +type CardItem = { + title: string +} + +type LabelRow = { + id: number + label: string +} + +type MetaSource = { + payload: string +} + +type TextStyleShowSource = { + text: string + active: boolean + color: string + visible: boolean +} + +type FormState = { + title: string +} + +const withApp = async ( + context: Record, + template: string, + run: (root: HTMLElement) => void | Promise, +) => { + const root = document.createElement('div') + const app = createApp(context, { + element: root, + template: html`${template}`, + }) + try { + await run(root) + } finally { + app.unmount() + await drainUnbind() + } +} + +test('e2e: dynamic r-bind option reacts to source replacement and detaches old source', async () => { + const dynamicKey = ref('title') + const source = ref({ + payload: 'A', + label: 'alpha', + }) + + await withApp( + { dynamicKey, source }, + ``, + (root) => { + const btn = root.querySelector('#btn') as HTMLButtonElement | null + if (!btn) throw new Error('missing #btn') + + expect(btn.getAttribute('title')).toBe('A') + expect(btn.textContent).toBe('alpha') + + source().payload('B') + source().label('beta') + expect(btn.getAttribute('title')).toBe('B') + expect(btn.textContent).toBe('beta') + + dynamicKey('data-id') + expect(btn.getAttribute('title')).toBeNull() + expect(btn.getAttribute('data-id')).toBe('B') + + const oldSource = source() + source( + ref({ + payload: '42', + label: 'gamma', + }), + ) + + expect(btn.getAttribute('data-id')).toBe('42') + expect(btn.textContent).toBe('gamma') + + source().payload('43') + source().label('delta') + expect(btn.getAttribute('data-id')).toBe('43') + expect(btn.textContent).toBe('delta') + + // Old source must be detached after replacement. + oldSource.payload('stale') + oldSource.label('stale-label') + dynamicKey('aria-label') + expect(btn.getAttribute('data-id')).toBeNull() + expect(btn.getAttribute('aria-label')).toBe('43') + dynamicKey('data-id') + expect(btn.getAttribute('data-id')).toBe('43') + expect(btn.textContent).toBe('delta') + }, + ) +}) + +test('e2e: r-if + r-for stays reactive across source replacement', async () => { + const view = ref({ + show: true, + rows: [ + { id: 1, name: 'A' }, + { id: 2, name: 'B' }, + ], + }) + + await withApp( + { view }, + `
+
    +
  • +
+

empty

+
`, + (root) => { + const names = () => + [...root.querySelectorAll('li')].map((x) => x.textContent) + const isEmpty = () => !!root.querySelector('#empty') + + expect(names()).toStrictEqual(['A', 'B']) + expect(isEmpty()).toBe(false) + + view().show(false) + expect(names()).toStrictEqual([]) + expect(isEmpty()).toBe(true) + + const oldView = view() + view( + ref({ + show: true, + rows: [ + { id: 10, name: 'X' }, + { id: 11, name: 'Y' }, + ], + }), + ) + + expect(names()).toStrictEqual(['X', 'Y']) + expect(isEmpty()).toBe(false) + + view().rows()[0]().name('Z') + expect(names()).toStrictEqual(['Z', 'Y']) + + // Old source must no longer drive UI. + oldView.show(false) + oldView.rows()[0]().name('OLD') + expect(names()).toStrictEqual(['Z', 'Y']) + expect(isEmpty()).toBe(false) + }, + ) +}) + +test('e2e: :is component switching keeps prop reactivity and detaches old sources', async () => { + const currentCard = ref('cardA') + const item = ref({ title: 'first' }) + + const cardA = createComponent( + html`

`, + { + props: ['item'], + }, + ) + const cardB = createComponent( + html`

`, + { + props: ['item'], + }, + ) + + await withApp( + { + currentCard, + item, + components: { cardA, cardB }, + }, + `
`, + (root) => { + const title = () => root.querySelector('h3')?.textContent + const cardClass = () => root.querySelector('article')?.className + + expect(cardClass()).toBe('a') + expect(title()).toBe('first') + + item().title('second') + expect(title()).toBe('second') + + currentCard('cardB') + expect(cardClass()).toBe('b') + expect(title()).toBe('second') + + const oldItem = item() + item(ref({ title: 'third' })) + expect(title()).toBe('third') + + oldItem.title('stale') + expect(title()).toBe('third') + }, + ) +}) + +test('e2e: mixed binders detach after unmount (post-unmount source churn is safe)', async () => { + const root = document.createElement('div') + const show = ref(true) + const rows = ref([ + { id: 1, label: 'A' }, + { id: 2, label: 'B' }, + ]) + const dynamicKey = ref('title') + const meta = ref({ payload: 'x' }) + const currentCard = ref('cardA') + + const cardA = createComponent( + html`
`, + { props: ['meta'] }, + ) + const cardB = createComponent( + html`
`, + { props: ['meta'] }, + ) + + const app = createApp( + { + show, + rows, + dynamicKey, + meta, + currentCard, + components: { cardA, cardB }, + }, + { + element: root, + template: html`
+
    +
  • +
+

empty

+ +
+
`, + }, + ) + + expect(root.querySelectorAll('li').length).toBe(2) + expect(root.querySelector('#meta')?.getAttribute('title')).toBe('x') + expect(root.querySelector('article')?.className).toBe('a') + + app.unmount() + await drainUnbind() + const htmlAfterUnmount = root.innerHTML + const listCountAfterUnmount = root.querySelectorAll('li').length + const metaAfterUnmount = root.querySelector('#meta')?.getAttribute('title') + const articleClassAfterUnmount = root.querySelector('article')?.className + + // Post-unmount source churn must not remount/update anything. + show(false) + rows().push(ref({ id: 3, label: 'C' })) + dynamicKey('data-id') + meta().payload('y') + currentCard('cardB') + + expect(root.innerHTML).toBe(htmlAfterUnmount) + expect(root.querySelectorAll('li').length).toBe(listCountAfterUnmount) + expect(root.querySelector('#meta')?.getAttribute('title')).toBe( + metaAfterUnmount, + ) + expect(root.querySelector('article')?.className).toBe( + articleClassAfterUnmount, + ) +}) + +test('e2e: r-text/r-class/r-style/r-show stay reactive after source replacement', async () => { + const source = ref({ + text: 'alpha', + active: true, + color: 'red', + visible: true, + }) + + await withApp( + { source }, + `
+

+

x

+

x

+

visible

+
`, + (root) => { + const text = root.querySelector('#text') as HTMLElement | null + const classEl = root.querySelector('#class') as HTMLElement | null + const styleEl = root.querySelector('#style') as HTMLElement | null + const showEl = root.querySelector('#show') as HTMLElement | null + if (!text || !classEl || !styleEl || !showEl) + throw new Error('missing nodes') + + expect(text.textContent).toBe('alpha') + expect(classEl.classList.contains('active')).toBe(true) + expect(styleEl.style.color).toBe('red') + expect(showEl.style.display).toBe('') + + source().text('beta') + source().active(false) + source().color('blue') + source().visible(false) + expect(text.textContent).toBe('beta') + expect(classEl.classList.contains('active')).toBe(false) + expect(styleEl.style.color).toBe('blue') + expect(showEl.style.display).toBe('none') + + const oldSource = source() + source( + ref({ + text: 'gamma', + active: true, + color: 'green', + visible: true, + }), + ) + expect(text.textContent).toBe('gamma') + expect(classEl.classList.contains('active')).toBe(true) + expect(styleEl.style.color).toBe('green') + expect(showEl.style.display).toBe('') + + // Ensure detached old source does not affect current DOM bindings. + oldSource.text('stale') + oldSource.active(false) + oldSource.color('black') + oldSource.visible(false) + expect(text.textContent).toBe('gamma') + expect(classEl.classList.contains('active')).toBe(true) + expect(styleEl.style.color).toBe('green') + expect(showEl.style.display).toBe('') + }, + ) +}) + +test('e2e: r-model input stays linked to latest source object after replacement', async () => { + const form = ref({ title: 'A' }) + + await withApp( + { form }, + `
+ +

+
`, + (root) => { + const input = root.querySelector( + '#title-input', + ) as HTMLInputElement | null + const text = root.querySelector('#title-text') as HTMLElement | null + if (!input || !text) throw new Error('missing input/text') + + expect(input.value).toBe('A') + expect(text.textContent).toBe('A') + + input.value = 'B' + input.dispatchEvent(new Event('input')) + expect(form().title()).toBe('B') + expect(text.textContent).toBe('B') + + const oldForm = form() + form(ref({ title: 'C' })) + expect(input.value).toBe('C') + expect(text.textContent).toBe('C') + + input.value = 'D' + input.dispatchEvent(new Event('input')) + expect(form().title()).toBe('D') + expect(text.textContent).toBe('D') + + // Old source should be detached and remain unchanged by new writes. + expect(oldForm.title()).toBe('B') + oldForm.title('STALE') + expect(input.value).toBe('D') + expect(text.textContent).toBe('D') + expect(form().title()).toBe('D') + }, + ) +}) diff --git a/tests/app/class-context.spec.ts b/tests/app/class-context.spec.ts index cccee77..ad191c4 100644 --- a/tests/app/class-context.spec.ts +++ b/tests/app/class-context.spec.ts @@ -613,7 +613,7 @@ test('class r-model binding survives multiple prop target replacements in nested expect(app.context.selectedHost().hostname()).toBe('gamma-child') }) -test('known issue: named slot content should render with class component context', () => { +test('named slot content should render with class component context', () => { class ShellContext { constructor(head: ComponentHead) { head.enableSwitch = true diff --git a/tests/app/component-head.spec.ts b/tests/app/component-head.spec.ts new file mode 100644 index 0000000..4d1b746 --- /dev/null +++ b/tests/app/component-head.spec.ts @@ -0,0 +1,51 @@ +import { expect, test, vi } from 'vitest' + +import { ComponentHead } from '../../src/app/ComponentHead' +import { + peekScope, + popScope, + pushScope, + setScope, +} from '../../src/composition/stack' + +test('component head emits custom events from source element', () => { + const host = document.createElement('div') + const start = document.createComment('s') + const end = document.createComment('e') + host.appendChild(start) + host.appendChild(end) + const head = new ComponentHead({}, host, [], start, end) + const spy = vi.fn() + host.addEventListener('save', spy) + + head.emit('save', { x: 1 }) + + expect(spy).toHaveBeenCalledTimes(1) + expect((spy.mock.calls[0][0] as CustomEvent).detail).toEqual({ x: 1 }) +}) + +test('component head unmount removes nodes between markers and calls unmounted hooks', async () => { + const host = document.createElement('div') + const start = document.createComment('s') + const n1 = document.createElement('span') + const n2 = document.createElement('span') + const end = document.createComment('e') + host.appendChild(start) + host.appendChild(n1) + host.appendChild(n2) + host.appendChild(end) + + const stop = vi.fn() + const ctx = {} as any + pushScope() + setScope(ctx) + ;(peekScope() as any).onUnmounted.push(stop) + popScope() + const head = new ComponentHead({}, host, [ctx], start, end) + + head.unmount() + await new Promise((resolve) => setTimeout(resolve, 5)) + + expect(host.contains(n1)).toBe(false) + expect(stop).toHaveBeenCalledTimes(1) +}) diff --git a/tests/app/createApp.spec.ts b/tests/app/createApp.spec.ts index 8a67b14..c37d532 100644 --- a/tests/app/createApp.spec.ts +++ b/tests/app/createApp.spec.ts @@ -1,6 +1,6 @@ import { expect, test } from 'vitest' -import { createApp, html, raw, ref } from '../../src' +import { createApp, html, raw, ref, RegorConfig, useScope } from '../../src' test('hello world', () => { const root = document.createElement('div') @@ -70,3 +70,74 @@ test('interpolation supports both syntaxes at once', () => { expect(root.textContent).toBe('hello world') }) + +test('createApp mounts json template and supports unbind', () => { + const root = document.createElement('div') + root.appendChild(document.createElement('span')) + const app = createApp( + { msg: ref('json') }, + { + element: root, + json: { + t: 'div', + c: [{ d: 'ok' }], + } as any, + }, + ) + + expect(root.textContent).toBe('ok') + app.unbind() +}) + +test('createApp supports string templates and scope contexts', () => { + class ScopeCtx { + msg = ref('scope-ok') + } + const appRoot = document.createElement('div') + appRoot.id = 'app' + document.body.appendChild(appRoot) + createApp( + useScope(() => new ScopeCtx()), + '

{{ msg }}

', + ) + expect(appRoot.querySelector('p')?.textContent).toBe('scope-ok') + appRoot.remove() + + const root = document.createElement('div') + createApp( + { msg: ref('inline') }, + { element: root, template: '

{{ msg }}

' }, + ) + expect(root.querySelector('p')?.textContent).toBe('inline') +}) + +test('createApp throws when selector root is missing and can disable interpolation', () => { + expect(() => + createApp({}, { selector: '#__missing__regor_root__' }), + ).toThrow() + + const root = document.createElement('div') + const cfg = new RegorConfig() + cfg.useInterpolation = false + createApp( + { msg: ref('x') }, + { element: root, template: '

{{ msg }}

' }, + cfg, + ) + expect(root.querySelector('p')?.textContent).toBe('{{ msg }}') +}) + +test('createApp supports default #app selector and throws when no root source provided', () => { + const appRoot = document.createElement('div') + appRoot.id = 'app' + document.body.appendChild(appRoot) + try { + const app = createApp({ msg: ref('auto-root') }) + expect(app.context.msg()).toBe('auto-root') + app.unbind() + } finally { + appRoot.remove() + } + + expect(() => createApp({}, { template: '
x
' } as any)).toThrow() +}) diff --git a/tests/app/createComponent.spec.ts b/tests/app/createComponent.spec.ts index a2256ef..14aa5b2 100644 --- a/tests/app/createComponent.spec.ts +++ b/tests/app/createComponent.spec.ts @@ -1,4 +1,4 @@ -import { test } from 'vitest' +import { expect, test } from 'vitest' import { createApp, @@ -7,7 +7,11 @@ import { IRegorContext, Ref, ref, + RegorConfig, } from '../../src' +import { Binder } from '../../src/bind/Binder' +import { hasSwitch } from '../../src/bind/switch' +import { Parser } from '../../src/parser/Parser' import { htmlEqual } from '../common/html-equal' test('should render components with reactive properties', () => { @@ -332,3 +336,348 @@ test('should render nested component with reactive properties', () => { `, ) }) + +test('createComponent supports template element and selector sources', () => { + const source = document.createElement('template') + source.innerHTML = '
x
' + const fromElement = createComponent({ element: source }) + expect( + (fromElement.template as ParentNode).querySelector('.from-element'), + ).toBeTruthy() + + const host = document.createElement('div') + host.innerHTML = + '' + document.body.appendChild(host) + try { + const fromSelector = createComponent({ selector: '#cc-select' }) + expect( + (fromSelector.template as ParentNode).querySelector('.from-selector'), + ).toBeTruthy() + } finally { + host.remove() + } +}) + +test('createComponent throws for missing selector template', () => { + expect(() => + createComponent({ selector: '#__regor_component_missing__' }), + ).toThrow() +}) + +test('createComponent supports json templates and svg upgrade path', () => { + const fromJson = createComponent({ + json: { t: 'div', c: [{ d: 'json-ok' }] }, + }) + expect( + (fromJson.template as ParentNode).querySelector('div')?.textContent, + ).toContain('json-ok') + + const fromSvg = createComponent({ + template: + '
', + isSVG: true, + }) + const svg = (fromSvg.template as ParentNode).querySelector('svg') + expect(svg).toBeTruthy() +}) + +test('component inheritAttrs merges class and style from host onto inheritor root', () => { + const root = document.createElement('div') + const boxComp = createComponent(html`
box
`) + + createApp( + { components: { boxComp } }, + { + element: root, + template: html``, + }, + ) + + const div = root.querySelector('div') as HTMLDivElement + expect(div.classList.contains('base')).toBe(true) + expect(div.classList.contains('host-a')).toBe(true) + expect(div.classList.contains('host-b')).toBe(true) + expect(div.style.color).toBe('red') + expect(div.style.marginTop).toBe('5px') +}) + +test('component named slot fallback renders when slot content is not provided', () => { + const root = document.createElement('div') + const slotComp = createComponent( + html`
+ fallback-extra +
`, + ) + + createApp( + { components: { slotComp } }, + { + element: root, + template: html``, + }, + ) + + expect(root.querySelector('.fallback-extra')?.textContent).toBe( + 'fallback-extra', + ) +}) + +test('component with slot-only root expands default slot content', () => { + const root = document.createElement('div') + const onlySlot = createComponent(html``) + + createApp( + { components: { onlySlot } }, + { + element: root, + template: html`inside`, + }, + ) + + expect(root.querySelector('.inside')?.textContent).toBe('inside') +}) + +test('component named slot falls back to slot body when name is missing in host templates', () => { + const root = document.createElement('div') + const comp = createComponent( + html`
+ fallback +
`, + ) + createApp( + { components: { comp } }, + { + element: root, + template: html`default`, + }, + ) + expect(root.querySelector('.slot-fallback')?.textContent).toBe('fallback') +}) + +test('default slot ignores named-only template shortcuts and keeps element children', () => { + const root = document.createElement('div') + const shell = createComponent(html`
`) + createApp( + { components: { shell } }, + { + element: root, + template: html` + + plain + `, + }, + ) + expect(root.querySelector('.named')).toBeNull() + expect(root.querySelector('.plain')?.textContent).toBe('plain') +}) + +test('slot switch contexts are released on unmount', async () => { + const root = document.createElement('div') + const shell = createComponent(html`
`, { + context: (head) => { + head.enableSwitch = true + return {} + }, + }) + + const app = createApp( + { components: { shell } }, + { + element: root, + template: html`

slot

`, + }, + ) + + expect(hasSwitch()).toBe(true) + app.unmount() + await new Promise((resolve) => setTimeout(resolve, 5)) + expect(hasSwitch()).toBe(false) +}) + +test('createComponent supports disabling interpolation and direct element children scan', () => { + const rootEl = document.createElement('div') + rootEl.innerHTML = '

{{ msg }}

' + const comp = createComponent({ element: rootEl }, { useInterpolation: false }) + const template = comp.template as ParentNode + expect(template.querySelector('p')?.textContent).toBe('{{ msg }}') +}) + +test('component slot shorthand #name on slot element resolves named templates', () => { + const root = document.createElement('div') + const shell = createComponent( + html`
+ fallback +
`, + ) + + createApp( + { components: { shell } }, + { + element: root, + template: html` + + `, + }, + ) + + expect(root.querySelector('.named')?.textContent).toBe('named-slot') + expect(root.querySelector('.fallback')).toBeNull() +}) + +test('registered-only component lookup works without context components', () => { + const root = document.createElement('div') + const cfg = new RegorConfig() + const regOnly = createComponent('
ok
', { + defaultName: 'RegOnly', + }) + cfg.addComponent(regOnly) + + createApp( + {}, + { + element: root, + template: '', + }, + cfg, + ) + + expect(root.querySelector('.reg-only')?.textContent).toBe('ok') +}) + +test('dashed context component key is safely skipped when lookup key camelizes differently', () => { + const root = document.createElement('div') + const dashComp = createComponent('
x
') + + createApp( + { + components: { + 'my-comp': dashComp, + } as any, + }, + { + element: root, + template: '', + }, + ) + + // unresolved component remains untouched + expect(root.querySelector('my-comp')).toBeTruthy() +}) + +test('component binder handles components mounted under parent nodes without parentElement', () => { + const cfg = new RegorConfig() + const regOnly = createComponent('
ok
', { + defaultName: 'FragOnly', + }) + cfg.addComponent(regOnly) + const parser = new Parser([{}], cfg) + const binder = new Binder(parser) + + const frag = document.createDocumentFragment() + const el = document.createElement('frag-only') + frag.appendChild(el) + + expect(() => + (binder as any).__componentBinder.__bindAll(frag as any), + ).not.toThrow() + expect(frag.querySelector('frag-only')).toBeTruthy() +}) + +test('component props binding accepts .prop and r-bind:prop paths', () => { + const root = document.createElement('div') + const comp = createComponent(html`
{{ msg }}
`, { + props: ['msg'], + context: (head) => ({ + msg: head.props.msg, + }), + }) + + createApp( + { + components: { comp }, + dotMsg: ref('dot'), + bindMsg: ref('bind'), + }, + { + element: root, + template: html`
+ + +
`, + }, + ) + + const texts = [...root.querySelectorAll('.msg')].map((x) => x.textContent) + expect(texts).toContain('dot') + expect(texts).toContain('bind') +}) + +test('component context fallback to empty object works when context() returns undefined', () => { + const root = document.createElement('div') + const undefCtx = createComponent('
ok
', { + context: () => undefined as any, + }) + + createApp( + { + components: { undefCtx }, + }, + { + element: root, + template: '', + }, + ) + + expect(root.querySelector('.ctx-undef')?.textContent).toBe('ok') +}) + +test('component autoProps can be disabled', () => { + const root = document.createElement('div') + const comp = createComponent(html`
{{ msg }}
`, { + props: ['msg'], + context: (head) => { + head.autoProps = false + return { + msg: ref('local'), + } + }, + }) + + createApp( + { + components: { comp }, + external: ref('external'), + }, + { + element: root, + template: '', + }, + ) + + expect(root.querySelector('.no-auto')?.textContent).toBe('local') +}) + +test('component inheritAttrs false keeps host attrs off child root', () => { + const root = document.createElement('div') + const comp = createComponent('
x
', { + inheritAttrs: false, + }) + + createApp( + { components: { comp } }, + { + element: root, + template: + '', + }, + ) + + const child = root.querySelector('.base') as HTMLElement + expect(child.classList.contains('host-class')).toBe(false) +}) diff --git a/tests/app/preprocess-template.spec.ts b/tests/app/preprocess-template.spec.ts index 6c4f8c3..73044f5 100644 --- a/tests/app/preprocess-template.spec.ts +++ b/tests/app/preprocess-template.spec.ts @@ -70,3 +70,19 @@ test('should normalize self-closing kebab-case components in aliased tr', () => '', ) }) + +test('keeps malformed tags and unmatched closing tags unchanged', () => { + expect(preprocess('
< >
')).toBe('
< >
') + expect(preprocess('
x
')).toBe('
x
') +}) + +test('keeps special tags and unfinished tags unchanged', () => { + const special = '
x
' + expect(preprocess(special)).toBe(special) + expect(preprocess(' { + expect(preprocess('plain text only')).toBe('plain text only') + expect(preprocess('a