From 7e1588c45af8ce43e326a5c551f881a14f4f5ff4 Mon Sep 17 00:00:00 2001
From: Ahmed Yasin Koculu
Date: Sun, 18 Oct 2026 23:16:53 +0200
Subject: [PATCH] Optimize component host matching and add component-heavy
benchmarks
---
benchmarks/README.md | 27 ++++-
benchmarks/index.html | 17 ++-
benchmarks/initial-load.html | 7 ++
benchmarks/minidom/README.md | 28 +++++
benchmarks/minidom/perf-baseline.1000.json | 112 +++++++++++++------
benchmarks/minidom/perf-baseline.2000.json | 112 +++++++++++++------
benchmarks/minidom/perf-baseline.500.json | 110 ++++++++++++------
benchmarks/minidom/perf.ts | 123 ++++++++++++++++++---
benchmarks/src/component-workload.ts | 99 +++++++++++++++++
benchmarks/src/index.ts | 86 +++++++++++---
benchmarks/src/initial-load.ts | 52 +++++++--
benchmarks/src/shared.ts | 5 +
benchmarks/tsconfig.json | 8 ++
package.json | 4 +-
src/bind/ComponentBinder.ts | 98 ++++++++++------
tests/bind/component-binder-edge.spec.ts | 58 ++++++++++
16 files changed, 768 insertions(+), 178 deletions(-)
create mode 100644 benchmarks/src/component-workload.ts
create mode 100644 benchmarks/tsconfig.json
diff --git a/benchmarks/README.md b/benchmarks/README.md
index 60f2d61..d56c027 100644
--- a/benchmarks/README.md
+++ b/benchmarks/README.md
@@ -16,6 +16,25 @@ The benchmark mounts a large bound list and then runs repeated manipulations:
Each framework runs the same operation pattern; timings are measured with
`performance.now()`.
+Use the **Workload** selector to choose the original **Bound list** or
+**Nested components (3 per row)**. Both workloads run the same keyed list
+operations. The component workload renders a row card, label component, and
+value component for each row, with reactive props, class fallthrough, and
+parent-scoped slot content. Regor uses an app-local row component, a label
+registered in each row's context, and a globally registered value component.
+Vue renders the equivalent component tree and DOM.
+
+At 500 rows this mounts 1,500 component instances; at 2,000 rows it mounts 6,000.
+Component definitions are created before mount timing. Regor prepares its
+component templates there; Vue's runtime template compilation occurs during
+mount. Rendering is checked after mount and each operation, outside the timed
+sections. A rendering mismatch fails the run.
+
+Regor's swap uses batched `splice` calls, shuffle commits the shuffled order
+with `splice`, and object replacement updates each existing element ref with
+`rows[i](ref(replacements[i]))`. This preserves reactive updates rather than replacing the
+ref functions stored at array indexes.
+
## Run
1. Build benchmark bundle:
@@ -34,11 +53,15 @@ yarn bench:serve
`http://localhost:4177/`
-4. Click **Run Benchmark**.
+4. Select a workload and click **Run Regor**, **Run Vue**, or **Run Both**.
+
+The mount-only benchmark at `http://localhost:4177/benchmarks/initial-load.html`
+has the same workload selector.
## Notes
- `Vue@latest` requires internet access.
- Run multiple times and compare medians.
- Keep browser/devtools conditions consistent for fair comparisons.
-
+- The Node-based [minidom suite](minidom/README.md) also includes component
+ mounting, prop updates, and repeated subtree remounting.
diff --git a/benchmarks/index.html b/benchmarks/index.html
index 9693e27..8f143fc 100644
--- a/benchmarks/index.html
+++ b/benchmarks/index.html
@@ -15,7 +15,7 @@
}
body {
margin: 0;
- font-family: Consolas, "Courier New", monospace;
+ font-family: Consolas, 'Courier New', monospace;
background: radial-gradient(circle at 0% 0%, #1e293b 0, var(--bg) 45%);
color: var(--fg);
}
@@ -98,12 +98,20 @@
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).
+ Workload: mount a bound list, then run repeated list/data
+ manipulations (`for` binding updates, splices, push/shift, property
+ updates).
Run Regor
Run Vue
Run Both
+
+ Workload
+
+ Bound list
+ Nested components (3 per row)
+
+
Row count
@@ -173,7 +181,8 @@ DOM Benchmark: Regor vs Vue (latest)
- Tip: run a few times and compare medians in devtools Performance panel.
+ Tip: run a few times and compare medians in devtools Performance
+ panel.
diff --git a/benchmarks/initial-load.html b/benchmarks/initial-load.html
index 68dddfc..889e38e 100644
--- a/benchmarks/initial-load.html
+++ b/benchmarks/initial-load.html
@@ -94,6 +94,13 @@ Initial Load Benchmark: Regor vs Vue
Run Regor
Run Vue
Run Both
+
+ Workload
+
+ Bound list
+ Nested components (3 per row)
+
+
Row count
diff --git a/benchmarks/minidom/README.md b/benchmarks/minidom/README.md
index ec83761..11af997 100644
--- a/benchmarks/minidom/README.md
+++ b/benchmarks/minidom/README.md
@@ -2,6 +2,28 @@
Regor performance suite running on `tests/minidom`, with baseline comparison and optional regression gate.
+## Component Workloads
+
+The suite includes three component-heavy scenarios in addition to the original
+list and branch scenarios:
+
+- `mount_nested_components`: mount a keyed list of row cards, each containing
+ a local label component, a globally registered value component, and slot content.
+- `mutate_component_props`: mount that list, then update every row's label,
+ value, and active class through reactive props.
+- `remount_component_tree`: mount that list, then hide and show the entire
+ subtree three times using `r-if`.
+
+Each tree has three component instances per row: 1,500 at the default 500 rows,
+or 6,000 at 2,000 rows. Component definitions and data setup are excluded from
+timing. As in the original scenarios, the primary time includes initial mount
+plus the scenario's operations; unmount and cleanup are reported separately.
+Output checks verify every row's props, classes, order, and slot content outside
+both timed sections. A mismatch fails the run.
+
+These measurements use minidom's selector and DOM implementations. Use the
+browser benchmark's **Nested components** workload to measure browser DOM costs.
+
## Quick Start
Run with default row count (`500`):
@@ -62,6 +84,7 @@ What this does:
- `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`
@@ -93,6 +116,11 @@ If a baseline does not exist, create it first:
yarn perf:record 500
```
+When adding scenarios, existing baselines report them as missing and skip their
+regression checks. Run `yarn perf:record ` on the version you want to compare
+against before evaluating subsequent changes. Recording replaces the baseline
+for every scenario at that row count.
+
## Environment Variables
- `PERF_SAMPLES` (default `20`)
diff --git a/benchmarks/minidom/perf-baseline.1000.json b/benchmarks/minidom/perf-baseline.1000.json
index a1abde1..9c122d8 100644
--- a/benchmarks/minidom/perf-baseline.1000.json
+++ b/benchmarks/minidom/perf-baseline.1000.json
@@ -1,60 +1,108 @@
{
"meta": {
"version": 1,
- "createdAt": "2026-02-24T07:24:04.513Z",
+ "createdAt": "2026-10-18T21:12:13.447Z",
"platform": "win32",
"arch": "x64",
- "node": "v22.16.0",
+ "node": "v22.22.2",
"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,
+ "median": 19.946,
+ "p90": 22.29,
+ "mean": 20.268,
+ "min": 18.218,
+ "max": 23.051,
"samples": 20,
"unmount": {
- "median": 7.723,
- "p90": 8.965,
- "mean": 8.021,
- "min": 7.033,
- "max": 11.321,
+ "median": 2.377,
+ "p90": 3.258,
+ "mean": 2.493,
+ "min": 1.942,
+ "max": 3.66,
"samples": 20
}
},
"mutate_rows_fields": {
- "median": 87.079,
- "p90": 92.333,
- "mean": 87.982,
- "min": 84.215,
- "max": 101.536,
+ "median": 26.691,
+ "p90": 28.762,
+ "mean": 27.269,
+ "min": 25.111,
+ "max": 33.975,
"samples": 20,
"unmount": {
- "median": 8.655,
- "p90": 9.838,
- "mean": 8.678,
- "min": 7.588,
- "max": 10.206,
+ "median": 2.915,
+ "p90": 3.777,
+ "mean": 3.038,
+ "min": 2.421,
+ "max": 4.007,
"samples": 20
}
},
"toggle_if_loop": {
- "median": 11.49,
- "p90": 11.779,
- "mean": 11.277,
- "min": 10.591,
- "max": 12.1,
+ "median": 2.922,
+ "p90": 3.538,
+ "mean": 3.014,
+ "min": 2.517,
+ "max": 3.742,
"samples": 20,
"unmount": {
- "median": 0.309,
- "p90": 0.337,
- "mean": 0.31,
- "min": 0.259,
- "max": 0.411,
+ "median": 0.201,
+ "p90": 0.253,
+ "mean": 0.196,
+ "min": 0.092,
+ "max": 0.286,
+ "samples": 20
+ }
+ },
+ "mount_nested_components": {
+ "median": 111.82,
+ "p90": 119.062,
+ "mean": 113.184,
+ "min": 107.991,
+ "max": 122.338,
+ "samples": 20,
+ "unmount": {
+ "median": 13.151,
+ "p90": 14.278,
+ "mean": 13.005,
+ "min": 11.269,
+ "max": 14.454,
+ "samples": 20
+ }
+ },
+ "mutate_component_props": {
+ "median": 128.921,
+ "p90": 135.131,
+ "mean": 129.951,
+ "min": 123.638,
+ "max": 144.569,
+ "samples": 20,
+ "unmount": {
+ "median": 13.973,
+ "p90": 16.402,
+ "mean": 14.368,
+ "min": 12.345,
+ "max": 16.669,
+ "samples": 20
+ }
+ },
+ "remount_component_tree": {
+ "median": 487.153,
+ "p90": 509.412,
+ "mean": 489.175,
+ "min": 461.947,
+ "max": 515.111,
+ "samples": 20,
+ "unmount": {
+ "median": 57.912,
+ "p90": 65.122,
+ "mean": 59.324,
+ "min": 53.72,
+ "max": 76.936,
"samples": 20
}
}
diff --git a/benchmarks/minidom/perf-baseline.2000.json b/benchmarks/minidom/perf-baseline.2000.json
index 8c1578f..fb15f42 100644
--- a/benchmarks/minidom/perf-baseline.2000.json
+++ b/benchmarks/minidom/perf-baseline.2000.json
@@ -1,60 +1,108 @@
{
"meta": {
"version": 1,
- "createdAt": "2026-02-24T07:24:39.020Z",
+ "createdAt": "2026-10-18T21:13:10.689Z",
"platform": "win32",
"arch": "x64",
- "node": "v22.16.0",
+ "node": "v22.22.2",
"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,
+ "median": 43.156,
+ "p90": 45.905,
+ "mean": 43.776,
+ "min": 41.581,
+ "max": 48.444,
"samples": 20,
"unmount": {
- "median": 16.02,
- "p90": 18.554,
- "mean": 16.629,
- "min": 15.139,
- "max": 20.52,
+ "median": 5.599,
+ "p90": 6.815,
+ "mean": 5.91,
+ "min": 4.429,
+ "max": 10.142,
"samples": 20
}
},
"mutate_rows_fields": {
- "median": 175.106,
- "p90": 182.876,
- "mean": 175.632,
- "min": 169.708,
- "max": 183.928,
+ "median": 54.742,
+ "p90": 59.306,
+ "mean": 56.094,
+ "min": 53.532,
+ "max": 61.56,
"samples": 20,
"unmount": {
- "median": 17.867,
- "p90": 19.179,
- "mean": 18.065,
- "min": 17.053,
- "max": 19.766,
+ "median": 6.125,
+ "p90": 8.821,
+ "mean": 6.953,
+ "min": 5.383,
+ "max": 8.942,
"samples": 20
}
},
"toggle_if_loop": {
- "median": 21.255,
- "p90": 21.506,
- "mean": 21.269,
- "min": 20.292,
- "max": 22.939,
+ "median": 5.66,
+ "p90": 6.113,
+ "mean": 5.717,
+ "min": 5.053,
+ "max": 6.505,
"samples": 20,
"unmount": {
- "median": 0.549,
- "p90": 0.663,
- "mean": 0.573,
- "min": 0.497,
- "max": 0.706,
+ "median": 0.317,
+ "p90": 0.404,
+ "mean": 0.342,
+ "min": 0.181,
+ "max": 0.98,
+ "samples": 20
+ }
+ },
+ "mount_nested_components": {
+ "median": 239.129,
+ "p90": 260.508,
+ "mean": 243.411,
+ "min": 230.259,
+ "max": 265.034,
+ "samples": 20,
+ "unmount": {
+ "median": 30.04,
+ "p90": 32.028,
+ "mean": 30.731,
+ "min": 28.52,
+ "max": 38.422,
+ "samples": 20
+ }
+ },
+ "mutate_component_props": {
+ "median": 275.123,
+ "p90": 287.663,
+ "mean": 277.252,
+ "min": 267.29,
+ "max": 296.496,
+ "samples": 20,
+ "unmount": {
+ "median": 31.046,
+ "p90": 33.681,
+ "mean": 31.728,
+ "min": 28.907,
+ "max": 36.633,
+ "samples": 20
+ }
+ },
+ "remount_component_tree": {
+ "median": 1087.746,
+ "p90": 1185.255,
+ "mean": 1090.872,
+ "min": 990.04,
+ "max": 1228.357,
+ "samples": 20,
+ "unmount": {
+ "median": 124.421,
+ "p90": 134.372,
+ "mean": 125.695,
+ "min": 118.597,
+ "max": 148.524,
"samples": 20
}
}
diff --git a/benchmarks/minidom/perf-baseline.500.json b/benchmarks/minidom/perf-baseline.500.json
index 926c4ee..5c438a7 100644
--- a/benchmarks/minidom/perf-baseline.500.json
+++ b/benchmarks/minidom/perf-baseline.500.json
@@ -1,7 +1,7 @@
{
"meta": {
"version": 1,
- "createdAt": "2026-04-04T06:56:12.948Z",
+ "createdAt": "2026-10-18T21:10:36.216Z",
"platform": "win32",
"arch": "x64",
"node": "v22.22.2",
@@ -11,50 +11,98 @@
},
"results": {
"mount_rfor_rtext": {
- "median": 9.874,
- "p90": 11.656,
- "mean": 10.36,
- "min": 8.808,
- "max": 14.918,
+ "median": 9.934,
+ "p90": 11.59,
+ "mean": 10.245,
+ "min": 8.6,
+ "max": 13.486,
"samples": 20,
"unmount": {
- "median": 1.495,
- "p90": 3.106,
- "mean": 1.731,
- "min": 1.116,
- "max": 3.266,
+ "median": 1.095,
+ "p90": 1.648,
+ "mean": 1.193,
+ "min": 0.992,
+ "max": 1.89,
"samples": 20
}
},
"mutate_rows_fields": {
- "median": 12.934,
- "p90": 14.633,
- "mean": 13.398,
- "min": 11.568,
- "max": 19.316,
+ "median": 11.674,
+ "p90": 13.487,
+ "mean": 11.967,
+ "min": 10.911,
+ "max": 14.458,
"samples": 20,
"unmount": {
- "median": 1.348,
- "p90": 1.673,
- "mean": 1.443,
- "min": 1.206,
- "max": 2.07,
+ "median": 0.976,
+ "p90": 1.225,
+ "mean": 1.131,
+ "min": 0.838,
+ "max": 3.066,
"samples": 20
}
},
"toggle_if_loop": {
- "median": 1.857,
- "p90": 2.398,
- "mean": 2.027,
- "min": 1.469,
- "max": 3.55,
+ "median": 1.599,
+ "p90": 2.433,
+ "mean": 1.895,
+ "min": 1.358,
+ "max": 4.226,
"samples": 20,
"unmount": {
- "median": 0.124,
- "p90": 0.158,
- "mean": 0.126,
- "min": 0.07,
- "max": 0.177,
+ "median": 0.113,
+ "p90": 0.149,
+ "mean": 0.117,
+ "min": 0.048,
+ "max": 0.18,
+ "samples": 20
+ }
+ },
+ "mount_nested_components": {
+ "median": 54.105,
+ "p90": 57.576,
+ "mean": 54.895,
+ "min": 52.25,
+ "max": 58.661,
+ "samples": 20,
+ "unmount": {
+ "median": 5.938,
+ "p90": 7.189,
+ "mean": 6.032,
+ "min": 4.589,
+ "max": 7.831,
+ "samples": 20
+ }
+ },
+ "mutate_component_props": {
+ "median": 61.746,
+ "p90": 66.589,
+ "mean": 63.126,
+ "min": 59.045,
+ "max": 75.018,
+ "samples": 20,
+ "unmount": {
+ "median": 6.105,
+ "p90": 7.261,
+ "mean": 6.301,
+ "min": 5.215,
+ "max": 9.489,
+ "samples": 20
+ }
+ },
+ "remount_component_tree": {
+ "median": 224.032,
+ "p90": 245.404,
+ "mean": 230.13,
+ "min": 216.788,
+ "max": 257.214,
+ "samples": 20,
+ "unmount": {
+ "median": 27.524,
+ "p90": 29.136,
+ "mean": 27.751,
+ "min": 25.228,
+ "max": 32.462,
"samples": 20
}
}
diff --git a/benchmarks/minidom/perf.ts b/benchmarks/minidom/perf.ts
index 72a3166..3d3a811 100644
--- a/benchmarks/minidom/perf.ts
+++ b/benchmarks/minidom/perf.ts
@@ -5,9 +5,14 @@ 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 { createApp, drainUnbind, html, ref, RegorConfig, sref } from '../../src'
import { App } from '../../src/api/types'
import { createDom } from '../../tests/minidom/createDom'
+import {
+ createRegorComponentWorkload,
+ REGOR_COMPONENT_TEMPLATE,
+ verifyComponentWorkload,
+} from '../src/component-workload'
type Stats = {
median: number
@@ -98,29 +103,39 @@ const stats = (data: number[]): Stats => {
}
}
-const withApp = async (
+const withApp = async (
template: string,
- contextFactory: () => Record,
- run: (app: App>) => void,
+ contextFactory: () => { context: TContext; config?: RegorConfig },
+ run: (app: App) => void,
+ verify?: (app: App, root: HTMLElement) => void,
): Promise<{ runMs: number; unmountMs: number }> => {
const cleanup = createDom('
')
+ let app: App | undefined
try {
- const context = contextFactory()
+ const { context, config } = contextFactory()
const t0 = performance.now()
- const app = createApp(context, {
- selector: '#app',
- template: html`${template}`,
- })
+ app = createApp(
+ context,
+ {
+ selector: '#app',
+ template: html`${template}`,
+ },
+ config,
+ )
run(app)
const t1 = performance.now()
+ verify?.(app, document.querySelector('#app') as HTMLElement)
+ const unmountStart = performance.now()
app.unmount()
+ app = undefined
await drainUnbind()
const t2 = performance.now()
return {
runMs: t1 - t0,
- unmountMs: t2 - t1,
+ unmountMs: t2 - unmountStart,
}
} finally {
+ app?.unmount()
await drainUnbind()
cleanup()
}
@@ -135,6 +150,45 @@ const createRows = (count: number) =>
}),
)
+const createComponentContext = () => {
+ const { config, components } = createRegorComponentWorkload()
+ return {
+ config,
+ context: {
+ components,
+ visible: sref(true),
+ rows: ref(
+ Array.from({ length: rowCount }, (_, i) => ({
+ id: i,
+ label: `row-${i}`,
+ value: i * 2,
+ active: (i & 1) === 0,
+ })),
+ ),
+ },
+ }
+}
+
+type ComponentContext = ReturnType['context']
+
+const verifyComponents = (
+ app: App,
+ root: HTMLElement,
+): void => {
+ verifyComponentWorkload(
+ root,
+ app.context.rows().map((row) => {
+ const value = row()
+ return {
+ id: value.id(),
+ label: value.label(),
+ value: value.value(),
+ active: value.active(),
+ }
+ }),
+ )
+}
+
const scenarios: Scenario[] = [
{
name: 'mount_rfor_rtext',
@@ -147,7 +201,7 @@ const scenarios: Scenario[] = [
`,
- () => ({ rows: sref(createRows(rowCount)) }),
+ () => ({ context: { rows: sref(createRows(rowCount)) } }),
() => {},
),
},
@@ -162,7 +216,7 @@ const scenarios: Scenario[] = [
`,
- () => ({ rows: sref(createRows(rowCount)) }),
+ () => ({ context: { rows: sref(createRows(rowCount)) } }),
(app) => {
const rows = app.context.rows as ReturnType>
const list = rows()
@@ -182,7 +236,7 @@ const scenarios: Scenario[] = [
a
b
`,
- () => ({ visible: sref(true) }),
+ () => ({ context: { visible: sref(true) } }),
(app) => {
const visible = app.context.visible as ReturnType<
typeof sref
@@ -191,6 +245,49 @@ const scenarios: Scenario[] = [
},
),
},
+ {
+ name: 'mount_nested_components',
+ run: () =>
+ withApp(
+ REGOR_COMPONENT_TEMPLATE,
+ createComponentContext,
+ () => {},
+ verifyComponents,
+ ),
+ },
+ {
+ name: 'mutate_component_props',
+ run: () =>
+ withApp(
+ REGOR_COMPONENT_TEMPLATE,
+ createComponentContext,
+ (app) => {
+ const rows = app.context.rows()
+ for (let i = 0; i < rows.length; ++i) {
+ const row = rows[i]()
+ row.label(`next-${i}`)
+ row.value(i + 1000)
+ row.active(!row.active())
+ }
+ },
+ verifyComponents,
+ ),
+ },
+ {
+ name: 'remount_component_tree',
+ run: () =>
+ withApp(
+ `${REGOR_COMPONENT_TEMPLATE}
`,
+ createComponentContext,
+ (app) => {
+ for (let i = 0; i < 3; ++i) {
+ app.context.visible(false)
+ app.context.visible(true)
+ }
+ },
+ verifyComponents,
+ ),
+ },
]
const samples = Number(process.env.PERF_SAMPLES ?? 20)
diff --git a/benchmarks/src/component-workload.ts b/benchmarks/src/component-workload.ts
new file mode 100644
index 0000000..1ef98f7
--- /dev/null
+++ b/benchmarks/src/component-workload.ts
@@ -0,0 +1,99 @@
+import { defineComponent, RegorConfig } from '../../src'
+
+export const REGOR_COMPONENT_TEMPLATE = ``
+
+export const createRegorComponentWorkload = () => {
+ const config = new RegorConfig()
+ const BenchLabel = defineComponent(
+ ' ',
+ { props: ['label'], config },
+ )
+ const BenchValue = defineComponent(
+ ' ',
+ { defaultName: 'BenchValue', props: ['value'], config },
+ )
+ config.addComponent(BenchValue)
+ const BenchRow = defineComponent(
+ `
+
+
+
+ `,
+ {
+ props: ['row'],
+ context: () => ({ components: { BenchLabel } }),
+ config,
+ },
+ )
+ return { config, components: { BenchRow } }
+}
+
+export type VueBenchmarkComponent = {
+ props?: string[]
+ components?: Record
+ template: string
+}
+
+export const createVueComponentWorkload = (): VueBenchmarkComponent => {
+ const BenchLabel = {
+ props: ['label'],
+ template: '{{ label }} ',
+ }
+ const BenchValue = {
+ props: ['value'],
+ template: '{{ value }} ',
+ }
+ const BenchRow = {
+ props: ['row'],
+ components: { BenchLabel, BenchValue },
+ template: `
+
+
+
+ `,
+ }
+ return {
+ components: { BenchRow },
+ template: ``,
+ }
+}
+
+// Check actual rendered output after timing, so incomplete rendering cannot win.
+export const verifyComponentWorkload = (
+ root: HTMLElement,
+ rows: ReadonlyArray<{
+ id: number
+ label: string
+ value: number
+ active: boolean
+ }>,
+ scenario = 'mount',
+): void => {
+ const cards = root.querySelectorAll('.bench-row')
+ if (cards.length !== rows.length)
+ throw new Error(
+ `${scenario}: expected ${rows.length} component rows, got ${cards.length}`,
+ )
+ for (let i = 0; i < rows.length; ++i) {
+ const card = cards[i]
+ const row = rows[i]
+ if (
+ card.querySelector('.bench-label')?.textContent !== row.label ||
+ card.querySelector('.bench-value')?.textContent !== String(row.value) ||
+ card.querySelector('.bench-slot')?.textContent !== String(row.id) ||
+ card.classList.contains('active') !== row.active ||
+ !card.classList.contains('bench-item')
+ )
+ throw new Error(
+ `${scenario}: incorrect component output for row ${row.id} at index ${i}`,
+ )
+ }
+}
diff --git a/benchmarks/src/index.ts b/benchmarks/src/index.ts
index 80e55d3..d7e4913 100644
--- a/benchmarks/src/index.ts
+++ b/benchmarks/src/index.ts
@@ -1,16 +1,25 @@
import {
+ batch,
createApp as createRegorApp,
html,
Ref,
ref,
RegorConfig,
} from '../../src'
+import {
+ createRegorComponentWorkload,
+ createVueComponentWorkload,
+ REGOR_COMPONENT_TEMPLATE,
+ verifyComponentWorkload,
+ type VueBenchmarkComponent,
+} from './component-workload'
import {
clearRoot,
formatMs,
getRowCount,
getSampleRuns,
getWarmupRuns,
+ isComponentWorkload,
loadVueModule,
nextFrame,
percentile,
@@ -78,7 +87,9 @@ const SCENARIOS = [
type ScenarioName = (typeof SCENARIOS)[number]
type VueRuntime = {
- createApp: (options: { data: () => { rows: Row[] }; template: string }) => {
+ createApp: (
+ options: VueBenchmarkComponent & { data: () => { rows: Row[] } },
+ ) => {
mount: (root: HTMLElement) => void
unmount: () => void
}
@@ -138,8 +149,10 @@ const SCENARIO_DEFINITIONS: readonly PairedScenarioDefinition[] = [
const rows = getRows()
if (rows.length < 3) return
const tmp = rows[1]
- rows[1] = rows[rows.length - 1]
- rows[rows.length - 1] = tmp
+ batch(() => {
+ rows.splice(1, 1, rows[rows.length - 1])
+ rows.splice(rows.length - 1, 1, tmp)
+ })
},
vue: ({ getRows }) => {
const rows = getRows()
@@ -251,7 +264,9 @@ const SCENARIO_DEFINITIONS: readonly PairedScenarioDefinition[] = [
name: 'shuffle_deterministic',
regor: ({ getRows }) => {
const rows = getRows()
- shuffleDeterministic(rows)
+ const shuffled = [...rows]
+ shuffleDeterministic(shuffled)
+ rows.splice(0, rows.length, ...shuffled)
},
vue: ({ getRows }) => {
const rows = getRows()
@@ -272,7 +287,7 @@ const SCENARIO_DEFINITIONS: readonly PairedScenarioDefinition[] = [
}
})
for (let i = 0; i < rows.length; i++) {
- rows[i] = ref(replacements[i])
+ rows[i](ref(replacements[i]))
}
},
vue: ({ getRows }) => {
@@ -322,6 +337,7 @@ const timeScenario = async (
const measureScenarioDurations = async (
resetRows: () => void,
mutateForScenario: (scenario: PairedScenarioDefinition) => void,
+ verify?: (scenario: ScenarioName) => void,
): Promise> => {
const scenarioMs: Record = {}
for (const scenario of SCENARIO_DEFINITIONS) {
@@ -329,6 +345,7 @@ const measureScenarioDurations = async (
mutateForScenario(scenario),
)
scenarioMs[name] = ms
+ verify?.(name)
}
return scenarioMs
}
@@ -344,20 +361,32 @@ const runRegor = async (root: HTMLElement): Promise => {
clearRoot(root)
const rowCount = getRowCount()
- const config = new RegorConfig()
+ const componentWorkload = isComponentWorkload()
+ ? createRegorComponentWorkload()
+ : undefined
+ const config = componentWorkload?.config ?? new RegorConfig()
await nextFrame()
const mountT0 = performance.now()
const app = createRegorApp(
- { rows: ref(makeRows(rowCount)) },
+ {
+ rows: ref(makeRows(rowCount)),
+ components: componentWorkload?.components,
+ },
{
element: root,
- template: html`
-
- {{ row.id }} {{ row.label }} {{ row.value }}
-
-
`,
+ template: componentWorkload
+ ? REGOR_COMPONENT_TEMPLATE
+ : html`
+
+ {{ row.id }} {{ row.label }} {{ row.value }}
+
+
`,
},
config,
)
@@ -365,6 +394,23 @@ const runRegor = async (root: HTMLElement): Promise => {
await nextFrame()
const mountMs = performance.now() - mountT0
+ const verify = componentWorkload
+ ? (scenario = 'mount') =>
+ verifyComponentWorkload(
+ root,
+ app.context.rows().map((row) => {
+ const value = row()
+ return {
+ id: value.id(),
+ label: value.label(),
+ value: value.value(),
+ active: value.active(),
+ }
+ }),
+ scenario,
+ )
+ : undefined
+ verify?.()
let nextScenarioId = rowCount * 10
const scenarioMs = await measureScenarioDurations(
@@ -380,6 +426,7 @@ const runRegor = async (root: HTMLElement): Promise => {
return inserts
},
}),
+ verify,
)
const mutateMs = sumScenarioDurations(scenarioMs)
@@ -404,12 +451,16 @@ const runVue = async (root: HTMLElement): Promise => {
const { createApp, reactive } = vue
const rowCount = getRowCount()
const state = reactive({ rows: makeRows(rowCount) })
+ const componentWorkload = isComponentWorkload()
+ ? createVueComponentWorkload()
+ : undefined
await nextFrame()
const mountT0 = performance.now()
const app = createApp({
data: () => state,
- template: `
+ ...(componentWorkload ?? {
+ template: `
{{ row.id }}
@@ -418,12 +469,18 @@ const runVue = async (root: HTMLElement): Promise => {
`,
+ }),
})
app.mount(root)
lastVueApp = app
await nextFrame()
const mountMs = performance.now() - mountT0
+ const verify = componentWorkload
+ ? (scenario = 'mount') =>
+ verifyComponentWorkload(root, state.rows, scenario)
+ : undefined
+ verify?.()
let nextScenarioId = rowCount * 10
const scenarioMs = await measureScenarioDurations(
@@ -439,6 +496,7 @@ const runVue = async (root: HTMLElement): Promise => {
return inserts
},
}),
+ verify,
)
const mutateMs = sumScenarioDurations(scenarioMs)
diff --git a/benchmarks/src/initial-load.ts b/benchmarks/src/initial-load.ts
index 537710a..8c09b26 100644
--- a/benchmarks/src/initial-load.ts
+++ b/benchmarks/src/initial-load.ts
@@ -1,10 +1,18 @@
import { createApp as createRegorApp, html, RegorConfig, sref } from '../../src'
+import {
+ createRegorComponentWorkload,
+ createVueComponentWorkload,
+ REGOR_COMPONENT_TEMPLATE,
+ verifyComponentWorkload,
+ type VueBenchmarkComponent,
+} from './component-workload'
import {
clearRoot,
formatMs,
getRowCount,
getSampleRuns,
getWarmupRuns,
+ isComponentWorkload,
loadVueModule,
nextFrame,
percentile,
@@ -36,7 +44,9 @@ type BenchSummary = {
}
type VueRuntime = {
- createApp: (options: { data: () => { rows: Row[] }; template: string }) => {
+ createApp: (
+ options: VueBenchmarkComponent & { data: () => { rows: Row[] } },
+ ) => {
mount: (root: HTMLElement) => void
unmount: () => void
}
@@ -76,28 +86,39 @@ const runRegor = async (root: HTMLElement): Promise => {
clearRoot(root)
const rows = makeRows(getRowCount())
+ const componentWorkload = isComponentWorkload()
+ ? createRegorComponentWorkload()
+ : undefined
await nextFrame()
const t0 = performance.now()
const app = createRegorApp(
- { rows: sref(rows) },
+ { rows: sref(rows), components: componentWorkload?.components },
{
element: root,
- template: html`
-
- {{ row.id }} {{ row.label }} {{ row.value }}
-
-
`,
+ template: componentWorkload
+ ? REGOR_COMPONENT_TEMPLATE
+ : html`
+
+ {{ row.id }} {{ row.label }} {{ row.value }}
+
+
`,
},
- createRegorBenchConfig(),
+ componentWorkload?.config ?? createRegorBenchConfig(),
)
lastRegorApp = app
await nextFrame()
+ const mountMs = performance.now() - t0
+ if (componentWorkload) verifyComponentWorkload(root, rows)
return {
framework: getRegorLabel(),
- mountMs: performance.now() - t0,
+ mountMs,
rowsFinal: app.context.rows().length,
}
}
@@ -112,12 +133,16 @@ const runVue = async (root: HTMLElement): Promise => {
const vue = (await loadVueModule()) as VueRuntime
const { createApp, reactive } = vue
const state = reactive({ rows: makeRows(getRowCount()) })
+ const componentWorkload = isComponentWorkload()
+ ? createVueComponentWorkload()
+ : undefined
await nextFrame()
const t0 = performance.now()
const app = createApp({
data: () => state,
- template: `
+ ...(componentWorkload ?? {
+ template: `
{{ row.id }}
@@ -126,14 +151,17 @@ const runVue = async (root: HTMLElement): Promise => {
`,
+ }),
})
app.mount(root)
lastVueApp = app
await nextFrame()
+ const mountMs = performance.now() - t0
+ if (componentWorkload) verifyComponentWorkload(root, state.rows)
return {
framework: 'Vue@latest',
- mountMs: performance.now() - t0,
+ mountMs,
rowsFinal: state.rows.length,
}
}
diff --git a/benchmarks/src/shared.ts b/benchmarks/src/shared.ts
index 7f541e8..da03daa 100644
--- a/benchmarks/src/shared.ts
+++ b/benchmarks/src/shared.ts
@@ -22,6 +22,10 @@ export const getRowCount = (): number => {
return Number.isFinite(value) && value > 0 ? value : DEFAULT_ROW_COUNT
}
+export const isComponentWorkload = (): boolean =>
+ (document.getElementById('workload') as HTMLSelectElement | null)?.value ===
+ 'components'
+
const getIntFromSelect = (
id: string,
min: number,
@@ -69,6 +73,7 @@ export const setButtonsDisabled = (disabled: boolean): void => {
'row-count',
'warmup-runs',
'sample-runs',
+ 'workload',
]
for (const id of ids) {
const button = document.getElementById(id) as
diff --git a/benchmarks/tsconfig.json b/benchmarks/tsconfig.json
new file mode 100644
index 0000000..50e6527
--- /dev/null
+++ b/benchmarks/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "../tsconfig.json",
+ "compilerOptions": {
+ "rootDir": "..",
+ "types": ["node"]
+ },
+ "include": ["**/*.ts"]
+}
diff --git a/package.json b/package.json
index 4dfefeb..2953cfd 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "regor",
- "version": "1.7.5",
+ "version": "1.7.6",
"description": "A reactive UI library for TypeScript and JavaScript. Connect state to HTML and SVG with direct DOM updates.",
"author": "Ahmed Yasin Koculu",
"license": "MIT",
@@ -41,7 +41,7 @@
"bugs": {
"url": "https://github.com/PureStackStudio/Regor/issues"
},
- "homepage": "https://github.com/PureStackStudio/Regor#readme",
+ "homepage": "https://regor.purestack.studio/",
"devDependencies": {
"@eslint/js": "^9.39.2",
"@stylistic/eslint-plugin": "^5.7.1",
diff --git a/src/bind/ComponentBinder.ts b/src/bind/ComponentBinder.ts
index 985f68f..c2cfc20 100644
--- a/src/bind/ComponentBinder.ts
+++ b/src/bind/ComponentBinder.ts
@@ -25,6 +25,31 @@ import { isRef } from '../reactivity/isRef'
import { type Binder } from './Binder'
import { addSwitch, refSwitch, removeSwitch, rswitch } from './switch'
+interface ComponentHostMatcher {
+ selector: string
+ matches: (element: Element) => boolean
+}
+
+const plainComponentNameRE = /^[A-Za-z][A-Za-z0-9_-]*$/
+
+/**
+ * Component names are CSS type selectors. Plain names match by tag name in
+ * constant time; anything else keeps full selector matching.
+ */
+const createComponentHostMatcher = (names: string[]): ComponentHostMatcher => {
+ const selector = names.join(',')
+ if (isNullOrWhitespace(selector)) return { selector, matches: () => false }
+ if (!names.every((name) => plainComponentNameRE.test(name)))
+ return { selector, matches: (element) => !!element.matches?.(selector) }
+ const tagNames = new Set(names.map((name) => name.toLowerCase()))
+ return {
+ selector,
+ matches: (element) =>
+ typeof element.tagName === 'string' &&
+ tagNames.has(element.tagName.toLowerCase()),
+ }
+}
+
/**
* @internal
*/
@@ -32,8 +57,9 @@ export class ComponentBinder {
__binder: Binder
__inherit: string
- __registeredComponentSelector = ''
- __registeredComponentSize = -1
+ __hostMatcher: ComponentHostMatcher | undefined
+ __hostMatcherContexts: Record[] | undefined
+ __hostMatcherSize = -1
constructor(binder: Binder) {
this.__binder = binder
@@ -44,20 +70,6 @@ export class ComponentBinder {
this.__unwrapComponents(element)
}
- __getRegisteredComponentSelector(
- registeredComponents: Map,
- ): string {
- if (this.__registeredComponentSize !== registeredComponents.size) {
- const names = [...registeredComponents.keys()]
- this.__registeredComponentSelector = [
- ...names,
- ...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
@@ -95,9 +107,8 @@ export class ComponentBinder {
}
const contextComponents = parser.__getComponents()
- const selector = this.__getComponentSelector()
- if (isNullOrWhitespace(selector)) return
- const components = this.__collectTopLevelComponentHosts(element, selector)
+ const host = this.__getComponentHostMatcher()
+ const components = this.__collectTopLevelComponentHosts(element, host)
for (const component of components) {
if (component.hasAttribute(binder.__pre)) continue
const parent = component.parentNode
@@ -479,37 +490,51 @@ export class ComponentBinder {
}
}
- __getComponentSelector(): string {
+ /**
+ * Cache by context-array identity and global registry size. Parser scope
+ * changes replace the array. Local component names are assumed to stay fixed
+ * for each context.
+ */
+ __getComponentHostMatcher(): ComponentHostMatcher {
const binder = this.__binder
const parser = binder.__parser
const registeredComponents = binder.__config.__components
- const contextComponentSelectors = parser.__getComponentSelectors()
- const registeredSelector =
- this.__getRegisteredComponentSelector(registeredComponents)
- return [
- ...(registeredSelector ? [registeredSelector] : []),
- ...contextComponentSelectors,
- ...contextComponentSelectors.map(hyphenate),
- ].join(',')
+ const contexts = parser.__contexts
+ if (
+ this.__hostMatcher &&
+ this.__hostMatcherContexts === contexts &&
+ this.__hostMatcherSize === registeredComponents.size
+ )
+ return this.__hostMatcher
+ const names = [
+ ...registeredComponents.keys(),
+ ...parser.__getComponentSelectors(),
+ ]
+ this.__hostMatcher = createComponentHostMatcher([
+ ...names,
+ ...names.map(hyphenate),
+ ])
+ this.__hostMatcherContexts = contexts
+ this.__hostMatcherSize = registeredComponents.size
+ return this.__hostMatcher
}
__isComponentHost(element: Element): boolean {
- const selector = this.__getComponentSelector()
- return !isNullOrWhitespace(selector) && element.matches?.(selector)
+ return this.__getComponentHostMatcher().matches(element)
}
__collectTopLevelComponentHosts(
root: Element,
- selector: string,
+ host: ComponentHostMatcher,
): HTMLElement[] {
const result: HTMLElement[] = []
- if (isNullOrWhitespace(selector)) return result
- if (root.matches?.(selector)) return [root as HTMLElement]
+ if (isNullOrWhitespace(host.selector)) return result
+ if (host.matches(root)) return [root as HTMLElement]
const stack = this.__getChildElements(root).reverse()
while (stack.length > 0) {
const current = stack.pop() as HTMLElement
if (current.hasAttribute(this.__binder.__pre)) continue
- if (current.matches(selector)) {
+ if (host.matches(current)) {
result.push(current)
continue
}
@@ -523,14 +548,15 @@ export class ComponentBinder {
action: (element: HTMLElement) => void,
): void {
const binder = this.__binder
- const selector = this.__getComponentSelector()
+ // Nested binding can change parser contexts; keep this traversal's matcher.
+ const host = this.__getComponentHostMatcher()
const isName = binder.__config.__builtInNames.is
const stack = this.__getChildElements(root).reverse()
while (stack.length > 0) {
const current = stack.pop() as HTMLElement
if (current.hasAttribute(binder.__pre)) continue
action(current)
- if (!isNullOrWhitespace(selector) && current.matches(selector)) continue
+ if (host.matches(current)) continue
if (current.hasAttribute(isName) || current.hasAttribute('is')) continue
stack.push(...this.__getChildElements(current).reverse())
}
diff --git a/tests/bind/component-binder-edge.spec.ts b/tests/bind/component-binder-edge.spec.ts
index 6ba50c1..e7630c0 100644
--- a/tests/bind/component-binder-edge.spec.ts
+++ b/tests/bind/component-binder-edge.spec.ts
@@ -138,3 +138,61 @@ test('component binder ignores :context/r-context during attribute fallthrough t
expect(inner.hasAttribute(':context')).toBe(false)
expect(inner.hasAttribute('r-context')).toBe(false)
})
+
+test('component host matcher matches names by tag and refreshes when components change', () => {
+ const cfg = new RegorConfig()
+ cfg.addComponent(defineComponent('
', { defaultName: 'HostCard' }))
+ const binder = createBinder(
+ [{ components: { innerPanel: defineComponent('
') } }],
+ cfg,
+ )
+ const componentBinder = (binder as any).__componentBinder
+ const isHost = (tagName: string) =>
+ componentBinder.__isComponentHost(document.createElement(tagName))
+
+ expect(isHost('host-card')).toBe(true)
+ expect(isHost('HOSTCARD')).toBe(true)
+ expect(isHost('inner-panel')).toBe(true)
+ expect(isHost('innerpanel')).toBe(true)
+ expect(isHost('div')).toBe(false)
+ expect(isHost('late-comp')).toBe(false)
+
+ cfg.addComponent(defineComponent('
', { defaultName: 'LateComp' }))
+ expect(isHost('late-comp')).toBe(true)
+ ;(binder as any).__parser.__push({
+ components: { pushedItem: defineComponent('
') },
+ })
+ expect(isHost('pushed-item')).toBe(true)
+})
+
+test('component host matcher matches nothing without components', () => {
+ const componentBinder = createBinder([{}]).__componentBinder
+ expect(componentBinder.__getComponentHostMatcher().selector).toBe('')
+ expect(componentBinder.__isComponentHost(document.createElement('div'))).toBe(
+ false,
+ )
+})
+
+test('component host matcher excludes child-local names after restoring the parent scope', () => {
+ const binder = createBinder([
+ { components: { ParentPanel: defineComponent('
') } },
+ ])
+ const parser = binder.__parser
+ const componentBinder = binder.__componentBinder
+ const parentHost = document.createElement('parent-panel')
+ const childHost = document.createElement('child-panel')
+
+ expect(componentBinder.__isComponentHost(parentHost)).toBe(true)
+ expect(componentBinder.__isComponentHost(childHost)).toBe(false)
+
+ parser.__scoped(parser.__capture(), () => {
+ parser.__push({
+ components: { ChildPanel: defineComponent('
') },
+ })
+ expect(componentBinder.__isComponentHost(parentHost)).toBe(true)
+ expect(componentBinder.__isComponentHost(childHost)).toBe(true)
+ })
+
+ expect(componentBinder.__isComponentHost(parentHost)).toBe(true)
+ expect(componentBinder.__isComponentHost(childHost)).toBe(false)
+})