fix: QwebJSON circular reference on non-serializable session_info values

The QwebJSON.dumps default handler returned non-serializable objects
unchanged, causing Python's json encoder to report "Circular reference
detected". Use odoo.tools.json.json_default() as fallback to properly
convert ReadonlyDict, lazy, datetime, bytes, Domain and other types.

🤖 assisted by claude
This commit is contained in:
Ernad Husremovic 2026-03-10 13:56:35 +01:00
parent 6d349fb9e9
commit 2374290414
2 changed files with 74 additions and 1 deletions

View file

@ -0,0 +1,73 @@
# QwebJSON Circular Reference Bugfix
## Problem
When loading the Odoo 19 web client, the `web.webclient_bootstrap` QWeb template
fails with:
```
ValueError: Circular reference detected
```
at the line:
```xml
<t t-out="json.dumps(session_info)"/>
```
## Root Cause
The `QwebJSON.dumps()` default handler in `odoo/addons/base/models/ir_qweb.py`
returned non-serializable objects unchanged:
```python
# Original (broken)
class QwebJSON(json.JSON):
def dumps(self, *args, **kwargs):
prev_default = kwargs.pop('default', lambda obj: obj)
return super().dumps(*args, **kwargs, default=(
lambda obj: prev_default(str(obj) if isinstance(obj, QwebContent) else obj)
))
```
When the JSON encoder encounters a non-serializable type (e.g. `ReadonlyDict`,
`lazy`, `datetime`, `bytes`, `Domain`), it calls the `default` handler. The handler
returns the object unchanged (since it's not `QwebContent`). The encoder tries to
serialize it again, gets the same object back, and raises "Circular reference detected."
This is a **latent bug in the Odoo 19 core**. In vanilla Odoo it rarely triggers
because `session_info` values are typically JSON-serializable. However, any module
(OCA or custom) that introduces a non-serializable type into `session_info` or any
other QWeb JSON-serialized context will expose this bug.
## Fix
Use `odoo.tools.json.json_default()` as the fallback for non-QwebContent objects:
```python
# Fixed
class QwebJSON(json.JSON):
def dumps(self, *args, **kwargs):
prev_default = kwargs.pop('default', lambda obj: obj)
return super().dumps(*args, **kwargs, default=(
lambda obj: prev_default(str(obj) if isinstance(obj, QwebContent) else json.json_default(obj))
))
```
`json_default()` (from `odoo/tools/json.py`) properly handles:
- `datetime` -> string via `fields.Datetime.to_string()`
- `date` -> string via `fields.Date.to_string()`
- `lazy` -> resolved value
- `ReadonlyDict` -> regular `dict`
- `bytes` -> decoded string
- `Domain` -> list
- anything else -> `str(obj)`
## File Changed
- `odoo-bringout-oca-ocb-base/odoo/addons/base/models/ir_qweb.py` (class `QwebJSON`)
## Date
2026-03-10

View file

@ -656,7 +656,7 @@ class QwebJSON(json.JSON):
def dumps(self, *args, **kwargs):
prev_default = kwargs.pop('default', lambda obj: obj)
return super().dumps(*args, **kwargs, default=(
lambda obj: prev_default(str(obj) if isinstance(obj, QwebContent) else obj)
lambda obj: prev_default(str(obj) if isinstance(obj, QwebContent) else json.json_default(obj))
))