← Back to list

Odoo Migration Decoded: 16 → 17 → 18 → 19 — Frontend Revolution: OWL, Services & Modern JS (Part 2)

The frontend half of the migration — where your components go from “it works” to “it works differently” and your dates quietly betray you.

Osama Alhalabi · 2026-03-08 16:22 · 1 claps · 14.4 min read
#owls #odoo-16 #odoo18 #odoo-17 #odoo-19
Open on Medium ↗
Wiki topics: 🌐 · Web Development 🔧 · Data Engineering

Odoo Migration Decoded: 16 → 17 → 18 → 19 — Frontend Revolution: OWL, Services & Modern JS (Part 2)

The frontend half of the migration — where your components go from “it works” to “it works differently” and your dates quietly betray you.

🔗 Missed Part 1? Read Part 1: Backend Overhaul — ORM, Views & Controllers — covering attrs removal, name_get() deprecation, ORM changes, views, controllers, and all the Python-side gotchas.

In Part 1, we covered the backend: attrs removal, name_get() deprecation, ORM changes, views, controllers, and all the Python-side gotchas. If you survived that, congratulations — you've earned the right to face what many consider the harder half.

Welcome to the JavaScript & OWL migration. 🦅

This is where Odoo’s frontend framework went through its own identity crisis. OWL 1.x became OWL 2.x. Lifecycle methods became hooks. moment.js got replaced by luxon. The widget system was retired in favor of proper components. And somewhere in the middle of it all, YYYY stopped meaning what you thought it meant.

By the end of this article, you’ll know how to migrate every JS component, field widget, service call, and tour in your codebase — with real before/after code and the pitfalls that catch even senior developers.

Let’s go. 🦅

🗺 The OWL Version Map: Quick Orientation

Before we get our hands dirty, here’s what you’re dealing with at each version boundary:

OWL framework versions across Odoo releases — the big shift happened in 17.0.

OWL framework versions across Odoo releases — the big shift happened in 17.0.

The 16 to 17 jump is where 90% of the pain lives. If OWL 1.x was a caterpillar, OWL 2.x is the butterfly — same DNA, completely different shape, and it definitely can’t crawl anymore.

🦅 The Great OWL Migration: 1.x to 2.x

The Lifecycle Overhaul

This table is the single most important reference for your OWL migration. Print it. Tape it to your monitor. Tattoo it on your forearm if you have to.

OWL lifecycle migration cheat sheet — class methods become hook functions called inside setup().

OWL lifecycle migration cheat sheet — class methods become hook functions called inside setup().

The fundamental shift: in OWL 1.x, lifecycle methods were class methods you overrode. In OWL 2.x, they’re hooks you call inside setup(). This isn't just a syntax change — it's an architectural philosophy change. Hooks are composable, reusable, and don't rely on inheritance chains. Think React hooks if you come from that world.

Full Component Migration: The DashboardWidget

Let’s migrate a real-world component — a dashboard widget that loads data asynchronously, initializes a chart on mount, cleans it up on unmount, and updates it on re-render. This touches every major lifecycle concern.

Before — OWL 1.x (Odoo 16)

/** @odoo-module **/
import { Component, useState } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";

class DashboardWidget extends Component {
    static props = ["title", "modelName"];

    setup() {
        this.state = useState({
            data: [],
            loading: true,
            error: null,
        });
        this.rpc = useService("rpc");
    }

    // OWL 1.x: Async initialization as overridable method
    async willStart() {
        await this.loadData();
    }

    // OWL 1.x: DOM-ready code as overridable method
    mounted() {
        this.initializeChart();
    }

    // OWL 1.x: Cleanup as overridable method
    willUnmount() {
        if (this.chart) {
            this.chart.destroy();
        }
    }

    // OWL 1.x: Post-render update as overridable method
    patched() {
        this.updateChart();
    }

    async loadData() {
        try {
            const result = await this.rpc("/web/dataset/call_kw", {
                model: this.props.modelName,
                method: "search_read",
                args: [[]],
                kwargs: { fields: ["name", "amount"], limit: 10 },
            });
            this.state.data = result;
        } catch (e) {
            this.state.error = e.message;
        } finally {
            this.state.loading = false;
        }
    }

    initializeChart() {
        const canvas = this.el.querySelector(".chart-canvas");
        // initialize chart library on the canvas element
    }

    updateChart() {
        // update chart with new reactive data
    }
}

DashboardWidget.template = "my_module.DashboardWidget";
registry.category("actions").add("dashboard_widget", DashboardWidget);

After — OWL 2.x (Odoo 17+)

/** @odoo-module **/
import { Component, useState, onWillStart, onMounted, onWillUnmount, onPatched } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";

class DashboardWidget extends Component {
    // OWL 2.x: Props are a validated object schema (not an array)
    static props = {
        title: { type: String },
        modelName: { type: String },
    };
    static template = "my_module.DashboardWidget";

    setup() {
        this.state = useState({
            data: [],
            loading: true,
            error: null,
        });

        // OWL 2.x: Use orm service instead of raw rpc
        this.orm = useService("orm");

        // Replaces willStart()
        onWillStart(async () => {
            await this.loadData();
        });

        // Replaces mounted()
        onMounted(() => {
            this.initializeChart();
        });

        // Replaces willUnmount()
        onWillUnmount(() => {
            if (this.chart) {
                this.chart.destroy();
            }
        });

        // Replaces patched()
        onPatched(() => {
            this.updateChart();
        });
    }

    async loadData() {
        try {
            // OWL 2.x: orm.searchRead instead of raw RPC
            const result = await this.orm.searchRead(
                this.props.modelName,
                [],
                ["name", "amount"],
                { limit: 10 }
            );
            this.state.data = result;
        } catch (e) {
            this.state.error = e.message;
        } finally {
            this.state.loading = false;
        }
    }

    initializeChart() {
        const canvas = this.el.querySelector(".chart-canvas");
        // initialize chart on canvas
    }

    updateChart() {
        // update chart with new data
    }
}

registry.category("actions").add("dashboard_widget", DashboardWidget);

Notice three things beyond the lifecycle changes: template and props are now static class properties (not assigned after the class), the rpc service is replaced by the orm service, and props moved from a simple array to a typed object schema.

🧰 Hooks Deep Dive: useState, useRef, useEffect

These three hooks are the building blocks of every OWL 2.x component. If you understand them, you understand the framework.

useState — Reactive State

import { useState } from "@odoo/owl";

setup() {
    this.state = useState({
        count: 0,
        items: [],
        selectedId: null,
    });
}

// Any mutation triggers a re-render
increment() {
    this.state.count++;  // component re-renders
}

addItem(item) {
    this.state.items.push(item);  // push is reactive in OWL
}

useState creates a reactive proxy. Mutate any property, and the component re-renders. This works the same in OWL 1.x and 2.x — it's one of the few things that didn't break.

useRef — DOM Element References

import { useRef, onMounted } from "@odoo/owl";

setup() {
    this.inputRef = useRef("myInput");

    onMounted(() => {
        // Safe to access .el here -- DOM is ready
        if (this.inputRef.el) {
            this.inputRef.el.focus();
        }
    });
}

In your template: <input t-ref="myInput" type="text" />

The key change from OWL 1.x: you used to access this.inputRef.el in the mounted() method. Now you access it inside the onMounted callback. Same result, different ceremony. Do not try to access .el outside of a lifecycle hook — it will be null.

useEffect — Reactive Side Effects 🔁

import { useState, useEffect, useRef } from "@odoo/owl";

setup() {
    this.state = useState({ query: "" });
    this.resultsRef = useRef("results");

    useEffect(
        () => {
            // This runs after renders where 'query' changed
            const el = this.resultsRef.el;
            if (el) el.scrollTop = 0;

            // Optional: return a cleanup function
            return () => {
                // Runs before effect re-executes or on unmount
            };
        },
        // Dependency function: re-run when these values change
        () => [this.state.query]
    );
}

useEffect is OWL 2.x only (Odoo 17+). It's the equivalent of React's useEffect — a way to perform side effects that react to specific state changes, with optional cleanup. If you needed patched() but only for certain state changes, this is your tool.

🔌 useService: The New Way to Talk to the Backend

In OWL 1.x and early Odoo 16 components, you might have used this.rpc(), this.do_action(), or this.displayNotification(). All of those are gone. In OWL 2.x, everything goes through services, injected via useService() inside setup().

The ORM Service — Your New Best Friend

import { useService } from "@web/core/utils/hooks";

setup() {
    this.orm = useService("orm");
}

// search_read: Fetch records with a domain
async fetchOrders() {
    const records = await this.orm.searchRead(
        "sale.order",
        [["state", "=", "sale"]],
        ["name", "partner_id", "amount_total"],
        { limit: 10, order: "date_order desc" }
    );
}

// read: Get specific records by ID
async getPartner(id) {
    const [record] = await this.orm.read("res.partner", [id], ["name", "email"]);
}

// create: Create a new record
async createPartner(vals) {
    const id = await this.orm.create("res.partner", [vals]);
}

// write: Update existing records
async updatePartner(id, vals) {
    await this.orm.write("res.partner", [id], vals);
}

// unlink: Delete records
async deletePartner(id) {
    await this.orm.unlink("res.partner", [id]);
}

// call: Invoke any model method
async confirmOrder(orderId) {
    await this.orm.call("sale.order", "action_confirm", [[orderId]], {
        context: { send_email: true },
    });
}

This is a massive improvement over raw RPC calls. The orm service handles serialization, error handling, and context propagation for you.

The Notification Service

setup() {
    this.notification = useService("notification");
}

showNotifications() {
    // Types: "info", "success", "warning", "danger"
    this.notification.add("Record saved successfully!", {
        type: "success",
        sticky: false,
        title: "Success",
    });

    this.notification.add("Something needs your attention.", {
        type: "warning",
        sticky: true,  // requires manual dismiss
    });
}

The Action Service

setup() {
    this.actionService = useService("action");
}

async openSaleOrder(id) {
    await this.actionService.doAction({
        type: "ir.actions.act_window",
        res_model: "sale.order",
        res_id: id,
        views: [[false, "form"]],
        target: "current",
    });
}

async openUrl(url) {
    await this.actionService.doAction({
        type: "ir.actions.act_url",
        url: url,
        target: "new",
    });
}

async openByXmlId(xmlId) {
    await this.actionService.doAction(xmlId);
}

The Dialog Service

import { useService } from "@web/core/utils/hooks";
import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";

setup() {
    this.dialogService = useService("dialog");
}

async confirmDelete() {
    return new Promise((resolve) => {
        this.dialogService.add(ConfirmationDialog, {
            title: "Delete Record",
            body: "Are you sure you want to delete this record?",
            confirm: () => resolve(true),
            cancel: () => resolve(false),
        });
    });
}

The User Service

setup() {
    this.user = useService("user");
}

checkUser() {
    console.log(this.user.userId);
    console.log(this.user.partnerId);
    console.log(this.user.isAdmin);
}

Pro tip: If you find yourself importing useService("rpc") for low-level JSON-RPC calls, ask yourself if useService("orm") can do the job instead. The orm service covers 95% of use cases and gives you a much cleaner API.

🔄 Legacy Widgets to OWL Components

If your Odoo 16 module has widgets built on the old AbstractField / jQuery backbone system, Odoo 17 is the end of the road for them. Here's the full migration pattern using a ColorPicker field as an example.

Before — Legacy Widget (Odoo 16)

/** @odoo-module legacy=true **/
import AbstractField from "web.AbstractField";
import fieldRegistry from "web.field_registry";

const ColorPickerField = AbstractField.extend({
    template: "my_module.ColorPickerField",

    init: function (parent, name, record, options) {
        this._super.apply(this, arguments);
    },

    _render: function () {
        const value = this.value || "#000000";
        this.$el.html(
            `<input type="color" value="${value}" class="o_color_input"/>`
        );
    },

    _setValue: function (value) {
        return this._super(value);
    },
});

fieldRegistry.add("color_picker", ColorPickerField);

After — OWL Component (Odoo 17+)

/** @odoo-module **/
import { Component } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { standardFieldProps } from "@web/views/fields/standard_field_props";

export class ColorPickerField extends Component {
    static template = "my_module.ColorPickerField";
    static props = { ...standardFieldProps };
    static supportedTypes = ["char"];

    get currentColor() {
        return this.props.record.data[this.props.name] || "#000000";
    }

    onColorChange(ev) {
        this.props.record.update({ [this.props.name]: ev.target.value });
    }
}

registry.category("fields").add("color_picker", {
    component: ColorPickerField,
    supportedTypes: ["char"],
});

Key differences to notice:

  • No more AbstractField — you extend plain Component from OWL
  • **standardFieldProps** gives you record, name, readonly, and other standard props that field widgets need
  • Value access changed from this.value to this.props.record.data[this.props.name]
  • Value updates changed from this._setValue() to this.props.record.update()
  • Registration uses registry.category("fields") with a descriptor object, not the old fieldRegistry
  • No more legacy=true pragma — that compatibility layer is gone in 17+

📅 moment.js to luxon: The Date Format Betrayal

Odoo 17 replaced moment.js with luxon for all date/time handling on the frontend. The API is different, but the real danger is in the format tokens.

The Format Token Table

Moment.js to Luxon format token mapping — watch out for the YYYY vs yyyy trap.

Moment.js to Luxon format token mapping — watch out for the YYYY vs yyyy trap.

That last row is the trap. In luxon, uppercase YYYY means ISO week-numbering year, which differs from the calendar year during the first and last days of the year. Your app will show the correct year 362 days a year and the wrong year for about 3 days around New Year's. Enjoy debugging that on January 2nd. 😅

Before and After

// ---- Odoo 16: moment.js ----
import moment from "moment";

const now = moment();
const formatted = now.format("YYYY-MM-DD HH:mm:ss");
const nextWeek = moment().add(7, "days");
const isBefore = moment("2024-01-01").isBefore(moment());

// ---- Odoo 17+: luxon ----
import { DateTime } from "luxon";

const now = DateTime.now();
const formatted = now.toFormat("yyyy-MM-dd HH:mm:ss");   // lowercase yyyy!
const nextWeek = now.plus({ days: 7 });
const isBefore = DateTime.fromISO("2024-01-01") < DateTime.now();

Odoo also provides locale-aware helpers:

import { formatDate, formatDateTime, parseDate } from "@web/core/l10n/dates";

const displayDate = formatDate(myDateTime);         // Uses user's locale
const displayDatetime = formatDateTime(myDateTime);
const parsed = parseDate("01/15/2024");

🎫 Tour System Changes

If you have onboarding tours or browser integration tests, the registration system changed completely in Odoo 17.

Before (Odoo 16)

/** @odoo-module **/
import tour from "web_tour.tour";

tour.register("my_custom_tour", {
    url: "/web",
    test: true,
}, [
    {
        trigger: ".o_main_navbar",
        content: "Welcome to the tour",
        position: "bottom",
    },
    {
        trigger: ".o_list_button_add",
        content: "Create a new record",
        position: "bottom",
    },
]);

After (Odoo 17+)

/** @odoo-module **/
import { registry } from "@web/core/registry";

registry.category("web_tour.tours").add("my_custom_tour", {
    url: "/web",
    test: true,
    steps: () => [
        {
            trigger: ".o_main_navbar",
            content: "Welcome to the tour",
            position: "bottom",
        },
        {
            trigger: ".o_list_button_add",
            content: "Create a new record",
            run: "click",
        },
    ],
});

Two things changed: tours now go through the standard registry.category("web_tour.tours") system (no more special tour import), and steps is now a function that returns the steps array. The run property also became more explicit — "click", "edit Value", "fill Value" instead of implicit click behavior.

📚 Component Registration: The Registry Categories

The registry is how Odoo’s frontend discovers your components. Here are all the categories you need to know, with registration examples for each.

Fields

registry.category("fields").add("my_custom_field", {
    component: MyCustomFieldComponent,
    supportedTypes: ["char", "text"],
    extractProps: ({ attrs }) => ({
        placeholder: attrs.placeholder,
    }),
});

Views

registry.category("views").add("my_custom_view", {
    type: "my_custom_view",
    display_name: "My Custom View",
    icon: "fa-list",
    multiRecord: true,
    Controller: MyViewController,
    Renderer: MyViewRenderer,
});

Actions (Client Actions)

registry.category("actions").add("my_client_action", MyActionComponent);

Services

registry.category("services").add("my_service", {
    dependencies: ["orm", "notification"],
    start(env, { orm, notification }) {
        return {
            async doSomething(id) {
                const result = await orm.read("res.partner", [id], ["name"]);
                notification.add(`Loaded: ${result[0].name}`, { type: "success" });
                return result;
            },
        };
    },
});

Systray Items

registry.category("systray").add("my_systray_item", {
    Component: MySystrayComponent,
    sequence: 50,
});

User Menu Items

registry.category("user_menuitems").add("my_menu_item", () => ({
    type: "item",
    id: "my_item",
    description: "My Custom Item",
    callback: () => console.log("clicked"),
    sequence: 30,
}));

The registry API itself is stable across Odoo 16–19. What changed is what you register — OWL 2.x components with static properties instead of legacy widgets.

📨 Props Validation and Parent-Child Communication

Props: From Arrays to Schemas

OWL 1.x (Odoo 16) — Simple array syntax

static props = ["title", "recordId", "*"];
// "*" means "accept any additional props"

OWL 2.x (Odoo 17+) — Full object schema with types, defaults, and validation

static props = {
    title: { type: String },
    limit: { type: Number, optional: true },
    readonly: { type: Boolean, optional: true },
    size: {
        type: String,
        validate: (v) => ["sm", "md", "lg"].includes(v),
        optional: true,
    },
    record: {
        type: Object,
        shape: { id: Number, name: String },
    },
    items: { type: Array, element: Object, optional: true },
    onClose: { type: Function, optional: true },
    "*": true,  // wildcard for remaining props
};

static defaultProps = {
    limit: 10,
    readonly: false,
    size: "md",
};

This is a big upgrade for maintainability. You get runtime type checking, optional markers, custom validators, and default values. Use it.

Parent-Child Communication: The Callback Pattern

OWL 2.x uses callback props for child-to-parent communication — no event bus, no trigger_up. Clean and explicit.

// ---- Child Component ----
class ChildComponent extends Component {
    static props = {
        value: { type: Number },
        onValueChange: { type: Function },
    };
    static template = "my_module.ChildComponent";

    increment() {
        this.props.onValueChange(this.props.value + 1);
    }
}

// ---- Parent Component ----
class ParentComponent extends Component {
    static components = { ChildComponent };
    static template = "my_module.ParentComponent";

    setup() {
        this.state = useState({ count: 0 });
    }

    updateCount(newValue) {
        this.state.count = newValue;
    }
}

Parent template:

<div>
    <ChildComponent value="state.count" onValueChange.bind="updateCount"/>
</div>

The .bind in the template is critical — it binds the callback to the parent's this context. Without it, this inside updateCount would be wrong.

🔒 CSP and Asset Bundling: No More Inline Scripts

Starting in Odoo 17 and tightened further in 18 and 19, Odoo enforces Content Security Policy headers that block inline JavaScript. If your module injects <script> tags dynamically or loads external CDN scripts at runtime, it will break silently in production.

What breaks:

// This will be blocked by CSP in Odoo 18+
const script = document.createElement("script");
script.src = "https://cdn.example.com/chart-library.js";
document.head.appendChild(script);

What to do instead:

Vendor the library into your module’s static/lib/ folder and declare it in __manifest__.py:

"assets": {
    "web.assets_backend": [
        "my_module/static/lib/chart-library/chart-library.min.js",
        "my_module/static/src/js/my_component.js",
        "my_module/static/src/xml/my_templates.xml",
    ],
},

Also relevant: the @odoo-module pragma. Every JS file must have /** @odoo-module **/ as its first line. In Odoo 16, you could use /** @odoo-module alias=my_module.legacy_name **/ for backward compatibility. In Odoo 17+, the alias option is no longer needed — drop it.

✅ Migration Checklist: JavaScript & OWL (16 to 17 to 18 to 19)

16 to 17 (The Big One)

  1. Migrate ALL OWL 1.x lifecycle methods to hooks in setup():
  • willStart() to onWillStart(async () => { ... })
  • mounted() to onMounted(() => { ... })
  • willUnmount() to onWillUnmount(() => { ... })
  • patched() to onPatched(() => { ... })
  • willUpdateProps() to onWillUpdateProps(() => { ... })
  1. Move template and props to static class properties

  2. Update static props from array syntax to object schema

  3. Add static defaultProps where needed

  4. Replace this.rpc() / raw RPC calls with useService("orm")

  5. Replace this.do_action() with useService("action").doAction()

  6. Replace this.displayNotification() with useService("notification").add()

  7. Replace ALL moment.js usage with luxon (watch for YYYY vs yyyy)

  8. Migrate legacy AbstractField widgets to OWL Components with standardFieldProps

  9. Remove legacy=true from @odoo-module pragmas

  10. Update tour definitions to registry.category("web_tour.tours")

  11. Update tour steps to use explicit run property

  12. Replace web.field_registry with registry.category("fields")

17 to 18

  • Audit all field component extractProps functions (API stabilized)
  • Update custom dialog components to use the dialog service pattern
  • Review useEffect() dependency arrays for correctness
  • Remove any inline <script> injection — use asset bundles instead
  • Vendor third-party JS libraries into static/lib/
  • Test all frontend components for CSP errors in browser console

18 to 19

  • Check for any OWL 2.x API refinements in Odoo 19 release notes
  • Verify all registry categories still exist
  • Update any deprecated service APIs
  • Test all custom OWL components in Odoo 19 runtime

💥 The 5 Common Pitfalls That Will Waste Your Afternoon

Pitfall 1: The Static Property Oversight 🦅

Putting template or props as instance properties instead of static class properties. OWL 2.x silently ignores instance-level declarations.

// WRONG -- OWL will NOT find these
class MyWidget extends Component {
    template = "my_module.MyWidget";
    props = { label: String };
}

// RIGHT -- static is required
class MyWidget extends Component {
    static template = "my_module.MyWidget";
    static props = { label: { type: String } };
}

This one is especially frustrating because there’s no error message. Your component just renders nothing, and you stare at the screen wondering what you did to deserve this.

Pitfall 2: this.el Outside onMounted

Accessing this.el in setup() or anywhere before the component has mounted.

// WRONG -- this.el is null during setup
setup() {
    const chart = this.el.querySelector(".chart"); // TypeError: null
}

// RIGHT -- access this.el only inside onMounted or onPatched
setup() {
    onMounted(() => {
        const chart = this.el.querySelector(".chart"); // Safe
    });
}

If you need DOM access during the component’s life, always wrap it in onMounted, onPatched, or access via useRef.

Pitfall 3: willStart() as a Method Override

Writing willStart() as a class method and expecting it to be called. In OWL 2.x, it won't be.

// WRONG -- this method is never called in OWL 2.x
class MyComponent extends Component {
    async willStart() {
        await this.loadData();
    }
}

// RIGHT -- use the hook inside setup()
class MyComponent extends Component {
    setup() {
        onWillStart(async () => {
            await this.loadData();
        });
    }
}

This is the single most common OWL migration bug we see. The component mounts, but your data never loads. No error, no warning — just an empty component staring back at you.

Pitfall 4: this.rpc() Is Gone

Calling this.rpc() directly on the component. That method doesn't exist in OWL 2.x.

// WRONG -- rpc is not a method on Component
async loadData() {
    const result = await this.rpc("/web/dataset/call_kw", { ... }); // Error!
}

// RIGHT -- use the orm service
setup() {
    this.orm = useService("orm");
}
async loadData() {
    const result = await this.orm.searchRead("my.model", [], ["name"]);
}

Pitfall 5: The Date Format Betrayal (YYYY vs yyyy)

Swapping moment.js for luxon and keeping the uppercase YYYY format token.

// WRONG -- YYYY means ISO week-year in luxon
DateTime.now().toFormat("YYYY-MM-DD")
// Returns "2026" most of the time... but "2027" on Dec 31 if that
// week belongs to next year's ISO week calendar. Fun!

// RIGHT -- lowercase yyyy means calendar year
DateTime.now().toFormat("yyyy-MM-dd")

This bug ships to production because it passes all your tests. It only manifests around New Year’s when the ISO week-year diverges from the calendar year. By then, your client has already sent invoices with the wrong date. Ask me how I know.

👀 TL;DR — For the Skimmers (I See You)

  1. OWL 1.x lifecycle methods are dead. Use hooks (onWillStart, onMounted, onPatched, etc.) inside setup().
  2. **template and props must be static.** Instance properties are silently ignored.
  3. **this.rpc() is gone.** Use useService("orm") for model operations.
  4. **moment.js is gone.** Use luxon. Lowercase yyyy = calendar year. Uppercase YYYY = ISO week-year (the trap).
  5. Legacy widgets are gone. Migrate AbstractField.extend() to OWL Component with standardFieldProps.
  6. Tours use the registry now. web_tour.tour import replaced by registry.category("web_tour.tours").
  7. Services for everything. Notifications, actions, dialogs, user info, and ORM all go through useService().
  8. Props validation upgraded. Array syntax to object schemas with types, optional markers, validators, and defaults.
  9. Parent-child communication uses callback props with .bind, not event buses.
  10. CSP is real. No inline scripts. Vendor your libraries. Use asset bundles.

The 16 to 17 JavaScript migration is the hardest part of the entire Odoo upgrade path. Plan twice the time you think you need. Then add a buffer. You’ll use it.

That wraps up the frontend side of the Odoo migration journey. Combined with ***Part 1***, you now have a complete reference for migrating Odoo modules from 16 all the way to 19.

**Osama Alhalabi**


메타데이터
post_id
2d17b0d37ebd
slug
odoo-migration-decoded-16-17-18-19-frontend-revolution-owl-services-modern-js-part-2-2d17b0d37ebd
url
https://medium.com/@eng.osama1998/odoo-migration-decoded-16-17-18-19-frontend-revolution-owl-services-modern-js-part-2-2d17b0d37ebd
canonical_url
https://medium.com/@eng.osama1998/odoo-migration-decoded-16-17-18-19-frontend-revolution-owl-services-modern-js-part-2-2d17b0d37ebd
author_url
https://medium.com/@eng.osama1998
status
ok
fetched_at
2026-07-13 06:23:13