Skip to content

[LiveComponent] Skip the bracket pipeline for plain model names - #3786

Open
Kocal wants to merge 1 commit into
3.xfrom
perf/live-component-normalize-model-name
Open

[LiveComponent] Skip the bracket pipeline for plain model names#3786
Kocal wants to merge 1 commit into
3.xfrom
perf/live-component-normalize-model-name

Conversation

@Kocal

@Kocal Kocal commented Aug 15, 2026

Copy link
Copy Markdown
Member
Q A
Bug fix? no
New feature? no
Deprecations? no
Documentation? no
Issues -
License MIT

normalizeModelName() ran a replace(), a split(), a map() with a second replace() per part and a join() on every call, even for a plain name like query where the whole pipeline is a no-op.

It runs on every input event through getValueFromElement(), and twice per ValueStore.set() since set() normalizes and then calls get(), which normalizes again.

Return the name untouched when it contains neither [ nor ]. Names that do use the bracket syntax take the same path as before.

Measured over 400k calls, alternating both implementations to cancel any warm-up ordering effect:

live form, every name bracketed   71.0 ms -> 72.1 ms   (-2%)
half plain, half bracketed        51.0 ms -> 37.8 ms   (+35%)
plain props only                  36.2 ms ->  6.0 ms   (+507%)

So it costs about 2% on a page that's nothing but live form fields, and pays off as soon as any plain model name is in the mix. In absolute terms this is nanoseconds per keystroke either way; the change is three lines and output is identical.

Browser-side JavaScript, so there is no Blackfire profile for this one. Benchmarked on node 22 with node bench.mjs, comparing both implementations side by side:

function current(model) {
    return model.replace(/\[]$/, '').split('[').map((s) => s.replace(']', '')).join('.');
}

function candidate(model) {
    if (!model.includes('[') && !model.includes(']')) {
        return model;
    }

    return model.replace(/\[]$/, '').split('[').map((s) => s.replace(']', '')).join('.');
}

const plain = ['firstName', 'email', 'query', 'isEnabled', 'selectedId'];
const bracketed = ['user[firstName]', 'user[mailing][]', 'form[items][0][label]'];

for (const [label, names] of [
    ['plain only', plain],
    ['bracketed only', bracketed],
    ['mixed 80/20', [...plain, ...plain, ...plain, ...plain, ...bracketed]],
]) {
    for (const [impl, fn] of [['current', current], ['candidate', candidate]]) {
        for (const n of names) fn(n); // warm up

        const start = process.hrtime.bigint();
        for (let i = 0; i < 400000; i++) {
            fn(names[i % names.length]);
        }
        console.log(`${label} ${impl} -> ${(Number(process.hrtime.bigint() - start) / 1e6).toFixed(2)} ms`);
    }
}

A stray ] with no [ was the one input where the fast path could have diverged, so it is now covered by a unit test.

Analysis, implementation and benchmarks by Claude Opus 5.

| Q              | A
| -------------- | ---
| Bug fix?       | no
| New feature?   | no
| Deprecations?  | no
| Documentation? | no
| Issues         | -
| License        | MIT

`normalizeModelName()` ran a `replace()`, a `split()`, a `map()` with a
second `replace()` per part and a `join()` on every call, even for a plain
name like `query` where the whole pipeline is a no-op.

It runs on every input event through `getValueFromElement()`, and twice per
`ValueStore.set()` since `set()` normalizes and then calls `get()`, which
normalizes again.

Return the name untouched when it contains neither `[` nor `]`. Names that
do use the bracket syntax take the same path as before.

400k calls: plain names ~45 ms -> ~12 ms, a realistic 80/20 mix ~43 ms ->
~17 ms, bracketed names unchanged (~84 ms). These are micro-seconds per
event in absolute terms, but the change is three lines and output is
identical.

Browser-side JavaScript, so there is no Blackfire profile for this one.
Benchmarked on node 22 with `node bench.mjs`, comparing both
implementations side by side:

```js
function current(model) {
    return model.replace(/\[]$/, '').split('[').map((s) => s.replace(']', '')).join('.');
}

function candidate(model) {
    if (!model.includes('[') && !model.includes(']')) {
        return model;
    }

    return model.replace(/\[]$/, '').split('[').map((s) => s.replace(']', '')).join('.');
}

const plain = ['firstName', 'email', 'query', 'isEnabled', 'selectedId'];
const bracketed = ['user[firstName]', 'user[mailing][]', 'form[items][0][label]'];

for (const [label, names] of [
    ['plain only', plain],
    ['bracketed only', bracketed],
    ['mixed 80/20', [...plain, ...plain, ...plain, ...plain, ...bracketed]],
]) {
    for (const [impl, fn] of [['current', current], ['candidate', candidate]]) {
        for (const n of names) fn(n); // warm up

        const start = process.hrtime.bigint();
        for (let i = 0; i < 400000; i++) {
            fn(names[i % names.length]);
        }
        console.log(`${label} ${impl} -> ${(Number(process.hrtime.bigint() - start) / 1e6).toFixed(2)} ms`);
    }
}
```

A stray `]` with no `[` was the one input where the fast path could have
diverged, so it is now covered by a unit test.

Analysis, implementation and benchmarks by Claude Opus 5.
@Kocal Kocal self-assigned this Aug 15, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📊 Packages dist files size difference

Thanks for the PR! Here is the difference in size of the packages dist files between the base branch and the PR.
Please review the changes and make sure they are expected.

FileBefore (Size / Gzip)After (Size / Gzip)
LiveComponent
live_controller.js 82.79 kB / 18.4 kB 82.85 kB0% / 18.41 kB0%

Comment on lines +34 to +35
// Most model names are plain (e.g. "query"), and the pipeline below is a
// no-op for them. ValueStore also normalizes already-normalized names.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Most model names are plain (e.g. "query"), and the pipeline below is a
// no-op for them. ValueStore also normalizes already-normalized names.
// Some model names are plain (e.g. "query")

Because with LiveComponent and live form, most model names are prefixed by their form name prefix, like that: myform[foo].

@smnandre smnandre left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's not add misleading comments

@carsonbot carsonbot added Status: Reviewed Has been reviewed by a maintainer and removed Status: Needs Review Needs to be reviewed labels Aug 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

LiveComponent Performance Status: Reviewed Has been reviewed by a maintainer

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants