Skip to content

[18.0][IMP] web_form_banner: client-side fast path for simple rules#1

Open
dnplkndll wants to merge 2 commits into
18.0from
18.0-imp-web_form_banner-client-side
Open

[18.0][IMP] web_form_banner: client-side fast path for simple rules#1
dnplkndll wants to merge 2 commits into
18.0from
18.0-imp-web_form_banner-client-side

Conversation

@dnplkndll

@dnplkndll dnplkndll commented May 17, 2026

Copy link
Copy Markdown

Why

Every banner rule today fires a server RPC (compute_message) per trigger-field change. For typical conditions — state == 'draft' and amount_total > 1000 — Odoo's view compiler already evaluates exactly that kind of expression purely client-side via py.js in invisible= attributes. The round-trip isn't needed for the 80% case.

What

Per-rule client_side opt-in. When enabled:

  • Visibility is evaluated by Odoo's view compiler against the in-memory record. Zero RPC.
  • Message text uses inline <field name="..."/> tags (Odoo-native, reactive) plus the existing ${field_name} sugar that rewrites to those tags at get_view time.
  • The existing JS RPC machinery is untouched and continues to drive server-side rules.

Generated arch

For a rule with client_condition: not email and name, message Contact <strong>${name}</strong> has no email., target xpath //sheet, on res.partner form:

<form>
  <!-- ...standard partner header... -->
  <field name="email" invisible="True"/>   <!-- auto-injected so py.js can resolve -->
  <div class="alert alert-warning"
       invisible="not (not email and name)"
       role="status"
       data-rule-id="123"
       data-model="res.partner"
       data-client="1">
    Contact <strong><field name="name"/></strong> has no email.
  </div>
  <sheet>...</sheet>
</form>

Toggle email on/off in the form → banner appears/disappears instantly. Zero Network-tab activity, zero compute_message RPC.

Defensive auto-injection

If client_condition references a field that the form view doesn't declare, py.js raises Name 'X' is not defined at render time and crashes any browser test that opens the form. The get_view override parses the condition via ast.parse, extracts every leftmost-Name reference, filters out py.js reserved names + names not on the model + names already declared, and auto-injects the remaining as <field name="X" invisible="True"/> siblings. Any admin-defined rule referencing a non-loaded field now Just Works.

What works in client_condition

Comparisons, boolean ops, in/not in, attribute access (partner_id.email), built-ins (len, bool, min, max, set), ternary x if cond else y.

What does NOT work — keep server-side mode

Arbitrary method calls (.filtered(), .mapped()), slicing, lambdas, comprehensions, ORM searches, per-record severity overrides, ${X.Y} dotted interpolation.

Files

File Change
models/web_form_banner_rule.py client_side + client_condition fields, _check_client_condition constraint via ast.parse, _to_client_arch for ${var}<field/>
models/ir_model.py get_view branches on rule.client_side; _client_rule_missing_fields returns hidden field shims; banner built via lxml element API
static/src/js/web_form_banner.esm.js One-line filter in bannersIn() skips data-client="1" divs
views/web_form_banner_rule_views.xml New fields exposed, notebook page toggles between server- and client-side help
tests/test_web_form_banner.py 11 new test cases — arch emission, ${var} sugar, HTML escaping, missing/malformed condition rejection, hidden-field auto-injection with dedup, py.js-reserved-name skipping, dotted-${} rejection, quote-bearing conditions, position=after, multi-rule field sharing
demo/web_form_banner_rule_demo.xml One extra fast-path demo using base-only fields (not email and name)
readme/USAGE.md + ROADMAP.md + CONTRIBUTORS.md Client-side section, limitations, follow-ups, Ledoweb co-author
__manifest__.py Bump to 18.0.1.2.0, add Ledoweb co-author + maintainers=["dnplkndll"]

Notable correctness details

  • Banner element built via lxml's etree.Element API rather than f-string XML — single quotes in conditions like state == 'draft' would otherwise terminate the invisible='...' attribute mid-expression. Caught during review; covered by test_client_side_handles_quoted_string_literals.
  • ${var} sugar restricted to bare field names. Dotted paths like ${partner_id.email} would rewrite to invalid form arch. Covered by test_client_side_rejects_dotted_var_sugar. Documented in USAGE.

Verification

Local fresh-DB --test-enable: 20 tests, 0 failures.

Fork CI on ledoent self-hosted runners (hetzner-k3s-ledoent): all 5 jobs green (commit effeea0 on 18.0-imp-web_form_banner-client-side).

Practice run scope

PR opened against the fork (not OCA upstream) to exercise the review workflow. Will close after squashing/promoting to OCA/web.

Test plan

  • All 20 unit tests pass on a fresh DB
  • All 5 CI jobs pass on the fork (Detect deps, test with Odoo, test with OCB, both rebel variants)
  • Client-side rule with quoted string literal serializes correctly
  • Hidden-field auto-injection works for missing references
  • Server-driven rules continue to work unchanged
  • Pre-flight strip of >>> FORK-LOCAL BLOCK <<< before promoting upstream

Speculative migration test (19.0)

ledoent/openupgrade-lab@feat/banner-migration-speculative-test
adds a Playwright spec + admin seeder + make benchmark-banner
target that catches future field-spec migrations of
web.form.banner.rule. Auto-skipped while web_form_banner is
absent from OCA/web 19.0; un-skip when PR OCA#3309 lands.

Performance — server vs client, matched 100-vuser comparison

After validating the harness on agent_test_18 (server-side only),
ran both modes against the same target — the OpenUpgrade lab's
oca_18_enriched DB on localhost:8018 with our 18.0.1.2.0 build
installed. Same Odoo configuration (4 workers, 256 DB connections),
same 280-partner seed, same 100-vuser ramp over 5 minutes.

Metric server-side client-side delta
Total requests 8,897 14,449 +62% throughput
Failures 2,551 (28.7%) 0 (0%) -100%
Aggregate RPS 29.8 48.2 +62%
Aggregate p50 640 ms 15 ms −98%
Aggregate p95 6.9 s 21 ms −99.7%
Aggregate p99 21 s 220 ms −99%

Per-endpoint breakdown (server-side):

Endpoint Requests Failures RPS p50 p95 p99
POST compute_message 6,579 1,880 22.0 560 ms 5.8 s 11 s
POST web_read partner 2,218 639 7.4 920 ms 7.4 s 13 s
POST authenticate 100 32 0.3 19 s 135 s 135 s

Per-endpoint (client-side):

Endpoint Requests Failures RPS p50 p95 p99
POST web_read partner 14,349 0 47.9 15 ms 21 ms 98 ms
POST authenticate 100 0 0.3 270 ms 810 ms 1.4 s

Saturation threshold reached. Server-side p95 (6.9 s) is 328×
the client-side baseline (21 ms). The compute_message RPC stream
(22 RPS, p95 5.8 s on success) is what the client-side mode
eliminates entirely — those 6,579 calls become zero in client
mode, and the freed worker capacity lets the lab serve 6.4× more
form opens (14,349 vs 2,218) without a single failure.

What this means

  • At 100 concurrent editors, server-side mode loses 29% of
    requests to pool exhaustion + timeout. Client-side mode handles
    the same workload with zero failures and 21 ms p95.
  • For a single editor, client-side mode makes the form snappier:
    a keystroke that previously required a 560-ms RPC round-trip now
    evaluates in py.js in < 1 ms.
  • The saturation point happens well below SMB scale on the
    doodba 4-worker sizing. Any customer with > ~25 simultaneous
    editors hitting a server-side banner rule will see queueing.

Harness

Locust-based, reusable across PRs. Live at
ledoent/odoo-agent-clerks@feat/load-test-banner-clientside/load/.
~10 min setup to add a bench for another PR; pattern documented in
the perf-kit roadmap.

Every banner rule today fires a server RPC (`compute_message`) per
trigger-field change. For typical conditions like
`state == 'draft' and amount_total > 1000`, Odoo's view compiler can
evaluate the expression client-side in `invisible=` attributes via
py.js — no round-trip needed.

## What changed

Per-rule `client_side` opt-in. When enabled the rule emits a
self-contained `<div invisible="not (...)" class="alert ...">` into
the form arch via `get_view`. Visibility is evaluated by Odoo
natively; field interpolation uses inline `<field name="..."/>` tags
or the `${field_name}` sugar (rewritten to those tags at view-load).
The existing server-driven path is unchanged.

Defensive piece: if `client_condition` references a field that the
form view doesn't declare, py.js raises `Name 'X' is not defined` at
runtime and crashes any browser test that opens the form. The
`get_view` override parses the condition via `ast.parse`, collects
every leftmost-Name reference, filters out py.js reserved names +
names not on the model + names already declared, and auto-injects the
remaining ones as `<field name="X" invisible="True"/>` siblings
adjacent to the banner. Any admin-defined rule referencing a
non-loaded field now Just Works.

## Notable correctness details

- Banner element is built via lxml's `etree.Element` API rather than
  f-string XML — single quotes in conditions like `state == 'draft'`
  would otherwise terminate the `invisible='...'` attribute
  mid-expression.
- `${var}` sugar matches only bare field names. Dotted paths like
  `${partner_id.email}` would rewrite to invalid Odoo form arch.
  Documented in USAGE; admins use a stored related field instead.
- 20 tests covering arch emission, ${var} sugar, HTML escaping,
  malformed/missing condition rejection, hidden-field auto-injection
  with dedup, py.js-reserved-name skipping, dotted-${} rejection,
  quote-bearing conditions, position=after, and multi-rule field
  sharing.

## Files

- `models/web_form_banner_rule.py` — `client_side` + `client_condition`
  fields, `_check_client_condition` constraint, `_to_client_arch` for
  `${var}` rewrite.
- `models/ir_model.py` — `get_view` branches on `rule.client_side`;
  `_client_rule_missing_fields` returns hidden field shims;
  `_build_client_banner` uses lxml element API.
- `static/src/js/web_form_banner.esm.js` — one-line filter in
  `bannersIn()` skips `data-client="1"`.
- `views/web_form_banner_rule_views.xml` — new fields exposed,
  notebook page toggles between server- and client-side help.
- `tests/test_web_form_banner.py` — +11 test cases.
- `demo/web_form_banner_rule_demo.xml` — extra demo using base-only
  fields (`not email and name`) to exercise the fast path on a clean
  install.
- `readme/USAGE.md` — Client-side mode section + limitations.
- `readme/ROADMAP.md` — 5 follow-ups.
- `readme/CONTRIBUTORS.md` — add Ledoweb / dkendall.
- `__manifest__.py` — bump to 18.0.1.2.0, add co-author + maintainer.

Backward-compatible: existing rules and the server-side path are
unchanged. Admins opt in by ticking `Client-side` on the rule form.

Verified locally via fresh-DB `--test-enable`: 20 tests, 0 failures.
Verified on fork CI (#1, ledoent self-hosted runners):
all 5 jobs green including the chatgpt tour that hit the original
hidden-field bug.
@dnplkndll dnplkndll force-pushed the 18.0-imp-web_form_banner-client-side branch 2 times, most recently from 5087a55 to cf9cac4 Compare May 17, 2026 11:48
@dnplkndll dnplkndll closed this May 19, 2026
@dnplkndll dnplkndll reopened this May 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant