Why this chapter? Because almost no real Odoo project is a blank slate — you will spend far more time working inside a codebase that already has years of legacy widgets than building something brand new in pure OWL. What is Odoo trying to solve with this? Odoo needs a way to let old and new frontend code coexist during the multi-year migration from the legacy widget system to OWL, without forcing a risky, all-at-once rewrite of every client's customizations. Real-world application: This is exactly the situation that pushed me to write this book: a client upgrade broke every custom widget we had built for them, and the bridge techniques in this chapter are what let you modernize a module piece by piece instead of freezing a project until a full rewrite is done.
Congratulations on reaching the final core chapter! You've mastered modern JavaScript, built sophisticated OWL components, implemented global state management, and learned professional testing practices. You're fully equipped to build cutting-edge applications in Odoo from scratch.
However, the reality of professional Odoo development is more nuanced. Most of your work won't involve green-field projects where you can use pure OWL throughout. Instead, you'll be working with existing Odoo databases filled with years of customizations, third-party modules, and user interfaces built with Odoo's older JavaScript framework—the legacy widget system.
This creates a critical challenge: How do you modernize applications incrementally? How do you introduce powerful OWL components into existing legacy interfaces without breaking everything? How do you leverage existing legacy widgets in new OWL applications when rebuilding them would take months?
The answer lies in Odoo's legacy-OWL bridge—a sophisticated compatibility layer that enables seamless interoperability between old and new code. This isn't just a technical curiosity; it's an essential tool for any professional Odoo developer working on real-world projects.
Understanding the Legacy Landscape
Before diving into bridge techniques, let's understand what we're bridging between.
The Legacy Widget System
Odoo's legacy JavaScript framework, used from version 8 through parts of version 16, was built around:
Widget-Based Architecture: Components were classes extending Widget
const MyWidget = Widget.extend({
template: 'my_module.MyTemplate',
start: function() {
// Initialization logic
return this._super.apply(this, arguments);
},
destroy: function() {
// Cleanup logic
this._super.apply(this, arguments);
}
});
jQuery-Heavy: Direct DOM manipulation and event handling
events: {
'click .my-button': '_onButtonClick',
},
_onButtonClick: function(event) {
this.$('.result').text('Button clicked!');
}
QWeb Templates: Server-side template compilation
<t t-name="my_module.MyTemplate">
<div class="my-widget">
<button class="my-button">Click me</button>
<div class="result"></div>
</div>
</t>
Why Bridge Instead of Rewrite?
Practical Reality: Large Odoo installations have hundreds of custom widgets representing years of business logic and user interface refinements.
Risk Management: A complete rewrite introduces significant risk of breaking existing functionality that users depend on daily.
Resource Constraints: Rewriting everything at once would require massive development resources and extended project timelines.
Business Continuity: Users need new features while existing functionality continues to work reliably.
The bridge approach allows for gradual modernization—introducing OWL components incrementally while maintaining system stability.
Bridge Architecture Overview
The legacy-OWL bridge works in both directions:
{width=100%}
Direction 1: OWL in Legacy - Mount modern OWL components inside existing legacy widgets - Add new features without rewriting entire screens - Gradually modernize user interfaces
Direction 2: Legacy in OWL - Use existing legacy widgets within new OWL applications - Leverage complex legacy functionality without rebuilding - Maintain compatibility with third-party widgets
Scenario 1: Embedding OWL Components in Legacy Widgets
This is the most common modernization pattern. You have an existing legacy form or dashboard, and you want to add a sophisticated new feature built with OWL.
Example: Adding a Modern Chart to a Legacy Dashboard
Let's say you have a legacy sales dashboard widget, and you want to add an interactive revenue chart built with OWL.
1. The Legacy Dashboard Widget
odoo.define('sales_dashboard.LegacyDashboard', function (require) {
"use strict";
const Widget = require('web.Widget');
const core = require('web.core');
const { mount } = require("@odoo/owl");
// Import our modern OWL component
const { RevenueChart } = require('sales_dashboard.RevenueChart');
const LegacyDashboard = Widget.extend({
template: 'sales_dashboard.LegacyDashboardTemplate',
events: {
'click .refresh-data': '_onRefreshData',
'change .date-filter': '_onDateFilterChange',
},
init: function(parent, options) {
this._super.apply(this, arguments);
this.salesData = options.salesData || [];
this.owlComponents = {}; // Track mounted OWL components
},
async start() {
await this._super(...arguments);
// Initialize legacy functionality
this._setupLegacyFeatures();
// Mount OWL components
await this._mountOWLComponents();
// Load initial data
await this._loadDashboardData();
},
_setupLegacyFeatures: function() {
// Legacy jQuery-based functionality
this.$('.legacy-counter').each(function(index, element) {
$(element).data('value', 0);
});
// Initialize legacy date picker
this.$('.date-filter').datepicker({
format: 'yyyy-mm-dd',
onSelect: this._onDateFilterChange.bind(this)
});
},
async _mountOWLComponents() {
try {
// Mount the revenue chart in its designated container
const chartTarget = this.el.querySelector('.revenue-chart-container');
if (chartTarget) {
this.owlComponents.revenueChart = await mount(RevenueChart, chartTarget, {
props: {
initialData: this.salesData,
onDataPointClick: this._onChartDataPointClick.bind(this),
onDateRangeChange: this._onChartDateRangeChange.bind(this),
// Pass legacy widget reference for complex interactions
legacyWidget: this,
}
});
}
// Mount additional OWL components as needed
const metricsTarget = this.el.querySelector('.metrics-widget-container');
if (metricsTarget) {
const { SalesMetrics } = require('sales_dashboard.SalesMetrics');
this.owlComponents.salesMetrics = await mount(SalesMetrics, metricsTarget, {
props: {
data: this.salesData,
onMetricClick: this._onMetricClick.bind(this)
}
});
}
} catch (error) {
console.error('Failed to mount OWL components:', error);
// Graceful degradation - show error message or fallback UI
this._showComponentError(error);
}
},
Once the OWL components are mounted, the widget loads its data and pushes it into both the legacy counters and the OWL components' props:
async _loadDashboardData() {
try {
const data = await this._rpc({
model: 'sale.order',
method: 'get_dashboard_data',
args: [this._getDateRange()],
});
// Update legacy widgets
this._updateLegacyCounters(data.counters);
// Update OWL components
await this._updateOWLComponents(data);
} catch (error) {
console.error('Failed to load dashboard data:', error);
this._showError('Failed to load dashboard data');
}
},
async _updateOWLComponents(data) {
// Update OWL component props
if (this.owlComponents.revenueChart) {
this.owlComponents.revenueChart.props.data = data.revenue;
// Trigger re-render by updating props
await this.owlComponents.revenueChart.render();
}
if (this.owlComponents.salesMetrics) {
this.owlComponents.salesMetrics.props.data = data.metrics;
await this.owlComponents.salesMetrics.render();
}
},
_updateLegacyCounters: function(counters) {
// Legacy jQuery-based updates
Object.keys(counters).forEach(key => {
const $counter = this.$(`.counter[data-metric="${key}"]`);
this._animateCounter($counter, counters[key]);
});
},
_animateCounter: function($element, targetValue) {
const currentValue = $element.data('value') || 0;
$({ value: currentValue }).animate({ value: targetValue }, {
duration: 1000,
step: function() {
$element.text(Math.floor(this.value));
},
complete: function() {
$element.data('value', targetValue);
}
});
},
// Event handlers for legacy functionality
_onRefreshData: function(event) {
event.preventDefault();
this._loadDashboardData();
},
_onDateFilterChange: function(event) {
const newDateRange = this._getDateRange();
this._loadDashboardData();
},
// Event handlers for OWL component interactions
_onChartDataPointClick: function(dataPoint) {
// Handle clicks from OWL chart - maybe show legacy drill-down dialog
this._showDrillDownDialog(dataPoint);
},
_onChartDateRangeChange: function(dateRange) {
// Update legacy date filters when OWL chart changes date range
this.$('.date-filter').datepicker('setDate', dateRange.start);
this._loadDashboardData();
},
_onMetricClick: function(metric) {
// Navigate to legacy list view
this.do_action({
type: 'ir.actions.act_window',
res_model: 'sale.order',
views: [[false, 'list']],
domain: metric.domain,
context: metric.context,
});
},
// Utility methods
_getDateRange: function() {
return {
start: this.$('.date-start').val(),
end: this.$('.date-end').val(),
};
},
_showDrillDownDialog: function(dataPoint) {
// Legacy dialog implementation
const dialog = new Dialog(this, {
title: `Details for ${dataPoint.label}`,
size: 'medium',
$content: $(`<div>Revenue: ${dataPoint.value}</div>`),
});
dialog.open();
},
_showComponentError: function(error) {
this.$('.owl-error-container').show().find('.error-message').text(
'Failed to load interactive components. Please refresh the page.'
);
},
_showError: function(message) {
// Legacy error display
this.$('.error-container').show().find('.error-text').text(message);
},
The last piece is the most important one for this chapter: cleaning up the mounted OWL components. Because the legacy Widget class has no automatic lifecycle hook for this, you must do it by hand inside destroy, or every mounted OWL component leaks memory every time the legacy widget is closed and reopened.
// Critical: Cleanup OWL components to prevent memory leaks
destroy: function() {
// Unmount all OWL components
Object.values(this.owlComponents).forEach(component => {
if (component && component.destroy) {
component.destroy();
}
});
this.owlComponents = {};
// Call parent destroy
this._super.apply(this, arguments);
},
});
return LegacyDashboard;
});
2. The Legacy Template
<t t-name="sales_dashboard.LegacyDashboardTemplate">
<div class="legacy-sales-dashboard">
<!-- Legacy header with controls -->
<div class="dashboard-header">
<h2>Sales Dashboard (Legacy)</h2>
<div class="dashboard-controls">
<input type="text" class="date-filter date-start" placeholder="Start Date"/>
<input type="text" class="date-filter date-end" placeholder="End Date"/>
<button class="btn btn-primary refresh-data">Refresh</button>
</div>
</div>
<!-- Legacy counters -->
<div class="legacy-counters row">
<div class="col-md-3">
<div class="counter-widget">
<h4>Total Sales</h4>
<div class="counter" data-metric="total_sales">0</div>
</div>
</div>
<div class="col-md-3">
<div class="counter-widget">
<h4>New Customers</h4>
<div class="counter" data-metric="new_customers">0</div>
</div>
</div>
<div class="col-md-3">
<div class="counter-widget">
<h4>Active Deals</h4>
<div class="counter" data-metric="active_deals">0</div>
</div>
</div>
<div class="col-md-3">
<div class="counter-widget">
<h4>Conversion Rate</h4>
<div class="counter" data-metric="conversion_rate">0%</div>
</div>
</div>
</div>
<!-- Modern OWL components mounted here -->
<div class="modern-components">
<div class="row">
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h5>Revenue Trends (Modern OWL Component)</h5>
</div>
<div class="card-body">
<!-- OWL component will be mounted here -->
<div class="revenue-chart-container"></div>
<!-- Fallback for when OWL component fails -->
<div class="owl-error-container" style="display: none;">
<div class="alert alert-warning">
<span class="error-message"></span>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">
<h5>Key Metrics (Modern OWL Component)</h5>
</div>
<div class="card-body">
<!-- Another OWL component mounted here -->
<div class="metrics-widget-container"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Legacy table -->
<div class="legacy-table-section">
<h4>Recent Orders (Legacy Table)</h4>
<table class="table table-striped">
<thead>
<tr>
<th>Order #</th>
<th>Customer</th>
<th>Amount</th>
<th>Date</th>
</tr>
</thead>
<tbody class="recent-orders-tbody">
<!-- Populated by legacy JavaScript -->
</tbody>
</table>
</div>
<!-- Error container -->
<div class="error-container" style="display: none;">
<div class="alert alert-danger">
<span class="error-text"></span>
</div>
</div>
</div>
</t>
3. The Modern OWL Revenue Chart Component
/** @odoo-module **/
import { Component, useState, onMounted, onWillUpdateProps } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
export class RevenueChart extends Component {
static template = xml`
<div class="revenue-chart-owl">
<div class="chart-controls">
<select t-model="state.chartType" t-on-change="onChartTypeChange">
<option value="line">Line Chart</option>
<option value="bar">Bar Chart</option>
<option value="area">Area Chart</option>
</select>
<button class="btn btn-sm btn-secondary" t-on-click="exportChart">
Export
</button>
</div>
<div class="chart-container" t-ref="chartContainer">
<canvas t-ref="chartCanvas"></canvas>
</div>
<div class="chart-summary" t-if="chartSummary">
<small class="text-muted">
Showing <t t-esc="chartSummary.totalDataPoints"/> data points.
Highest: <t t-esc="chartSummary.highest"/>
Average: <t t-esc="chartSummary.average"/>
</small>
</div>
</div>
`;
static props = {
initialData: { type: Array, optional: true },
onDataPointClick: { type: Function, optional: true },
onDateRangeChange: { type: Function, optional: true },
legacyWidget: { type: Object, optional: true }, // Reference to legacy widget
};
setup() {
this.notification = useService("notification");
this.state = useState({
chartType: 'line',
data: this.props.initialData || [],
loading: false,
});
this.chart = null;
this.chartCanvasRef = useRef("chartCanvas");
onMounted(() => {
this.initializeChart();
});
onWillUpdateProps((nextProps) => {
if (JSON.stringify(nextProps.initialData) !== JSON.stringify(this.props.initialData)) {
this.updateChartData(nextProps.initialData);
}
});
}
async initializeChart() {
// Initialize Chart.js or similar charting library
const ctx = this.chartCanvas.getContext('2d');
this.chart = new Chart(ctx, {
type: this.state.chartType,
data: this.getChartData(),
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: 'index',
},
onClick: (event, elements) => {
if (elements.length > 0 && this.props.onDataPointClick) {
const dataPoint = this.state.data[elements[0].index];
this.props.onDataPointClick(dataPoint);
}
},
plugins: {
legend: {
display: true,
position: 'top',
},
tooltip: {
callbacks: {
label: (context) => {
return `Revenue: $${context.parsed.y.toLocaleString()}`;
}
}
}
},
scales: {
x: {
type: 'time',
time: {
unit: 'day'
}
},
y: {
beginAtZero: true,
ticks: {
callback: function(value) {
return '$' + value.toLocaleString();
}
}
}
}
}
});
}
getChartData() {
return {
labels: this.state.data.map(point => point.date),
datasets: [{
label: 'Revenue',
data: this.state.data.map(point => ({
x: point.date,
y: point.revenue
})),
borderColor: 'rgb(75, 192, 192)',
backgroundColor: 'rgba(75, 192, 192, 0.2)',
tension: 0.1
}]
};
}
updateChartData(newData) {
if (this.chart && newData) {
this.state.data = newData;
this.chart.data = this.getChartData();
this.chart.update();
}
}
onChartTypeChange() {
if (this.chart) {
this.chart.config.type = this.state.chartType;
this.chart.update();
}
}
exportChart() {
if (this.chart) {
const url = this.chart.toBase64Image();
const link = document.createElement('a');
link.download = 'revenue-chart.png';
link.href = url;
link.click();
this.notification.add("Chart exported successfully", { type: "success" });
}
}
get chartSummary() {
if (!this.state.data.length) return null;
const revenues = this.state.data.map(d => d.revenue);
return {
totalDataPoints: this.state.data.length,
highest: Math.max(...revenues).toLocaleString(),
average: (revenues.reduce((a, b) => a + b, 0) / revenues.length).toFixed(0),
};
}
get chartCanvas() {
return this.chartCanvasRef.el;
}
}
This example demonstrates several key principles:
- Clear Separation: Legacy and OWL code remain separate but communicate through well-defined interfaces
- Graceful Degradation: If OWL components fail to load, the legacy functionality continues working
- Bidirectional Communication: OWL components can notify legacy widgets of events, and vice versa
- Memory Management: Proper cleanup prevents memory leaks
- Error Handling: Robust error handling ensures system stability
Key Benefits of This Approach
1. Incremental Modernization: We replaced the simple chart with a sophisticated OWL component while keeping complex legacy features
2. Risk Management: Legacy export and dialog functionality continues working
3. Enhanced User Experience: Modern, interactive charts and tables
4. Maintainability: New features can be built in OWL while legacy features remain stable
5. Performance: Only load modern components when needed
Best Practices for Scenario 1
1. Component Isolation
Keep OWL components self-contained:
// Good: OWL component is independent
const owlComponent = await mount(ChartComponent, target, {
props: {
data: this.salesData,
onEvent: this.handleEvent.bind(this)
}
});
// Bad: OWL component depends on legacy widget internals
const owlComponent = await mount(ChartComponent, target, {
props: {
legacyWidget: this, // Too much coupling
domElement: this.$('.some-element') // Direct DOM dependency
}
});
2. Error Boundaries
Always implement graceful fallbacks:
async _mountOWLComponents() {
const targets = [
{ selector: '.chart-container', component: ChartComponent },
{ selector: '.metrics-container', component: MetricsComponent }
];
for (const { selector, component } of targets) {
try {
const target = this.el.querySelector(selector);
if (target) {
this.owlComponents[selector] = await mount(component, target, {
props: this.getComponentProps(component)
});
}
} catch (error) {
console.error(`Failed to mount ${component.name}:`, error);
this._showFallbackUI(selector, error);
}
}
}
_showFallbackUI(selector, error) {
const target = this.el.querySelector(selector);
if (target) {
target.innerHTML = `
<div class="alert alert-warning">
<h6>Component Unavailable</h6>
<p>This feature is temporarily unavailable. Please refresh the page.</p>
<button class="btn btn-sm btn-secondary" onclick="location.reload()">
Refresh Page
</button>
</div>
`;
}
}
3. Memory Management
Critical for preventing leaks:
destroy: function() {
// Clean up OWL components first
this._destroyOWLComponents();
// Then call parent destroy
this._super.apply(this, arguments);
},
_destroyOWLComponents: function() {
Object.entries(this.owlComponents).forEach(([key, component]) => {
try {
if (component && typeof component.destroy === 'function') {
component.destroy();
}
} catch (error) {
console.error(`Error destroying component ${key}:`, error);
}
});
this.owlComponents = {};
}
TL;DR: Wrap legacy widgets from inside an OWL component with useRef + onMounted/onWillUnmount, or mount OWL components inside a legacy widget's start()/destroy() — either direction works as long as you keep the interface between the two explicit.
Try it yourself: This chapter's example is available as an installable Odoo 19 addon: simplifyit_owl_book_ch17_1_ex1. Installation instructions are in the repository README.
Common Pitfalls
Forgetting to destroy mounted OWL components. If you mount an OWL component inside _mountOWLComponents but don't unmount it in destroy, the component (and everything it holds onto — event listeners, timers, service subscriptions) leaks every time the legacy widget is closed and reopened.
Interacting with the legacy widget's DOM before it's ready. The legacy widget's this.el only exists once start() has run. Mounting an OWL component or querying this.el.querySelector(...) from init() will fail silently or throw — always do this from inside (or after) start().
Mixing the two event systems without a clear boundary. It's tempting to have OWL components call legacy this.trigger_up(...) directly, or have the legacy widget reach into OWL internals. Keep the interface between them explicit — callback props going one way, the legacy widget's public methods going the other — so you don't end up with two objects that both think they own the same piece of state.
Assuming the OWL component's props are "live". Legacy widgets mutate plain objects; OWL only re-renders when it detects a change through its reactivity system. Directly poking a value into this.owlComponents.revenueChart.props.data (like the example above does) does not automatically trigger a re-render — that's why the example calls .render() explicitly afterward.
Exercises
- Take the
LegacyDashboardwidget and add a third OWL component (e.g., a simpleTopCustomersList) mounted into a new container. Make sure it's properly destroyed alongside the other two. - Write the
_destroyOWLComponentscleanup by hand (without looking at the "Memory Management" example) for a widget that tracks two mounted OWL components inthis.owlComponents. - Modify
_onChartDataPointClickso that instead of opening a legacy dialog, it calls a callback prop passed down from an OWL parent — describe in a sentence why that's not possible in this direction (legacy widgets aren't OWL components and don't receive props).
In Part 2, we'll explore Scenario 2 (using legacy widgets in OWL components), advanced bridge patterns, testing strategies, and migration planning. This foundational understanding of Scenario 1 will prepare you for the more complex integration challenges ahead.
What's Next?
Part 2 flips the direction you just learned — instead of mounting OWL inside a legacy widget, you'll wrap a legacy widget inside an OWL component, plus cover advanced bridge patterns and a practical migration timeline.