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).
+
+
+
+
+
+
+
+ | Framework |
+ Mount Median |
+ Mount P90 |
+ Mutate Median |
+ Mutate P90 |
+ Total Median |
+ Total P90 |
+ Rows Final |
+ Samples |
+
+
+
+
+
+
+
+ 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.
+
+
+
+
+
+
+ | Framework |
+ Mount Median |
+ Mount P90 |
+ Rows Final |
+ Samples |
+
+
+
+
+
+
+
+
+
+
+
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(
+ ``,
+ () => ({ 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 },
+ ``,
+ (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``,
+ },
+ )
+
+ 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 },
+ ``,
+ (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 =
+ 'y'
+ 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``,
+ )
+
+ 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``,
+ )
+ 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`
+ named
+ 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``,
+ )
+
+ createApp(
+ { components: { shell } },
+ {
+ element: root,
+ template: html`
+ named-slot
+ `,
+ },
+ )
+
+ 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