From a389ad963e919f1e99dddaa82b7c6408bbe8fce5 Mon Sep 17 00:00:00 2001 From: Suherdy Yacob Date: Fri, 5 Jun 2026 22:21:20 +0700 Subject: [PATCH] perf: implement aggressive POS UI optimizations including product caching, lazy loading throttling, and hardware-accelerated rendering. --- __manifest__.py | 3 ++ models/pos_config.py | 8 ++- models/res_config_settings.py | 6 +++ .../order_display/order_display_patch.js | 21 +++++--- .../components/orderline/orderline_patch.xml | 12 +++++ .../product_card/product_card_patch.xml | 9 ++++ .../app/screens/product_screen/notes_patch.js | 8 ++- .../product_screen/product_screen_patch.js | 19 ++++--- static/src/app/services/pos_store_patch.js | 49 +++++++++++++++++-- static/src/scss/portrait.scss | 21 ++++++++ views/res_config_settings_views.xml | 3 ++ 11 files changed, 139 insertions(+), 20 deletions(-) create mode 100644 static/src/app/components/orderline/orderline_patch.xml create mode 100644 static/src/app/components/product_card/product_card_patch.xml diff --git a/__manifest__.py b/__manifest__.py index 6003ec2..f658d8d 100644 --- a/__manifest__.py +++ b/__manifest__.py @@ -48,6 +48,9 @@ Features # ProductScreen portrait tab state + layout 'pos_ui_optimization/static/src/app/screens/product_screen/portrait_mode_patch.js', 'pos_ui_optimization/static/src/app/screens/product_screen/portrait_screen.xml', + # ProductCard and Orderline optimization patches + 'pos_ui_optimization/static/src/app/components/product_card/product_card_patch.xml', + 'pos_ui_optimization/static/src/app/components/orderline/orderline_patch.xml', # Note splitting and defensive parsing patches 'pos_ui_optimization/static/src/app/screens/product_screen/notes_patch.js', # Portrait CSS (must load after all XML patches) diff --git a/models/pos_config.py b/models/pos_config.py index 2f92e13..ea5a5c1 100644 --- a/models/pos_config.py +++ b/models/pos_config.py @@ -10,9 +10,15 @@ class PosConfig(models.Model): help="Hide the Cash In/Out button in the POS Navbar menu" ) + hide_cart_images = fields.Boolean( + string="Hide Product Images in Cart", + default=False, + help="Hide product images in the order/cart lines inside POS" + ) + @api.model def _load_pos_data_fields(self, config): fields_list = super()._load_pos_data_fields(config) if fields_list: - fields_list.append('hide_cash_in_out_button') + fields_list.extend(['hide_cash_in_out_button', 'hide_cart_images']) return fields_list diff --git a/models/res_config_settings.py b/models/res_config_settings.py index 4c6ced7..5d2a62a 100644 --- a/models/res_config_settings.py +++ b/models/res_config_settings.py @@ -9,3 +9,9 @@ class ResConfigSettings(models.TransientModel): related='pos_config_id.hide_cash_in_out_button', readonly=False ) + + pos_hide_cart_images = fields.Boolean( + string="Hide Product Images in Cart", + related='pos_config_id.hide_cart_images', + readonly=False + ) diff --git a/static/src/app/components/order_display/order_display_patch.js b/static/src/app/components/order_display/order_display_patch.js index 321fff1..f7b00a6 100644 --- a/static/src/app/components/order_display/order_display_patch.js +++ b/static/src/app/components/order_display/order_display_patch.js @@ -37,15 +37,22 @@ patch(OrderDisplay.prototype, { return; } + let orderScrollTimeout = null; const onScroll = () => { - if ( - scrollable.scrollTop + scrollable.clientHeight >= - scrollable.scrollHeight - 100 - ) { - if (this.state.displayedCount < this.comboSortedLines.length) { - this.state.displayedCount += 20; - } + if (orderScrollTimeout) { + return; } + orderScrollTimeout = setTimeout(() => { + orderScrollTimeout = null; + if ( + scrollable.scrollTop + scrollable.clientHeight >= + scrollable.scrollHeight - 100 + ) { + if (this.state.displayedCount < this.comboSortedLines.length) { + this.state.displayedCount += 20; + } + } + }, 150); }; scrollable.addEventListener("scroll", onScroll); diff --git a/static/src/app/components/orderline/orderline_patch.xml b/static/src/app/components/orderline/orderline_patch.xml new file mode 100644 index 0000000..0c79d40 --- /dev/null +++ b/static/src/app/components/orderline/orderline_patch.xml @@ -0,0 +1,12 @@ + + + + + vals.productImage and !pos.config.hide_cart_images + + + lazy + async + + + diff --git a/static/src/app/components/product_card/product_card_patch.xml b/static/src/app/components/product_card/product_card_patch.xml new file mode 100644 index 0000000..030b63f --- /dev/null +++ b/static/src/app/components/product_card/product_card_patch.xml @@ -0,0 +1,9 @@ + + + + + lazy + async + + + diff --git a/static/src/app/screens/product_screen/notes_patch.js b/static/src/app/screens/product_screen/notes_patch.js index 754ba6b..7e07b7b 100644 --- a/static/src/app/screens/product_screen/notes_patch.js +++ b/static/src/app/screens/product_screen/notes_patch.js @@ -3,6 +3,7 @@ import { NoteButton, InternalNoteButton } from "@point_of_sale/app/screens/product_screen/control_buttons/orderline_note_button/orderline_note_button"; import { Orderline } from "@point_of_sale/app/components/orderline/orderline"; import { patch } from "@web/core/utils/patch"; +import { usePos } from "@point_of_sale/app/hooks/pos_hook"; // 1. Patch NoteButton to fix line-splitting bug for customer notes in standard Odoo patch(NoteButton.prototype, { @@ -64,8 +65,13 @@ patch(InternalNoteButton.prototype, { } }); -// 3. Patch Orderline to prevent UI crashes if note contains invalid JSON +// 3. Patch Orderline to prevent UI crashes if note contains invalid JSON and inject pos service patch(Orderline.prototype, { + setup() { + super.setup(); + this.pos = usePos(); + }, + get lineScreenValues() { const originalParse = JSON.parse; JSON.parse = function (str) { diff --git a/static/src/app/screens/product_screen/product_screen_patch.js b/static/src/app/screens/product_screen/product_screen_patch.js index acb1160..7e6284d 100644 --- a/static/src/app/screens/product_screen/product_screen_patch.js +++ b/static/src/app/screens/product_screen/product_screen_patch.js @@ -10,17 +10,24 @@ patch(ProductScreen.prototype, { this.state.displayedProductsCount = 80; const originalOnScroll = this.onScroll; + let scrollTimeout = null; this.onScroll = (ev) => { if (originalOnScroll) { originalOnScroll(ev); } - const el = ev.target; - if (el && el.scrollTop + el.clientHeight >= el.scrollHeight - 400) { - const totalCount = this.pos.productToDisplayByCateg.reduce((acc, cat) => acc + cat[1].length, 0); - if (this.state.displayedProductsCount < totalCount) { - this.state.displayedProductsCount += 80; - } + if (scrollTimeout) { + return; } + scrollTimeout = setTimeout(() => { + scrollTimeout = null; + const el = ev.target; + if (el && el.scrollTop + el.clientHeight >= el.scrollHeight - 450) { + const totalCount = this.pos.productsToDisplay.length; + if (this.state.displayedProductsCount < totalCount) { + this.state.displayedProductsCount += 80; + } + } + }, 150); }; useEffect( diff --git a/static/src/app/services/pos_store_patch.js b/static/src/app/services/pos_store_patch.js index c0404c0..2e82122 100644 --- a/static/src/app/services/pos_store_patch.js +++ b/static/src/app/services/pos_store_patch.js @@ -4,11 +4,31 @@ import { PosStore } from "@point_of_sale/app/services/pos_store"; import { patch } from "@web/core/utils/patch"; patch(PosStore.prototype, { + clearProductsCache() { + this._productsToDisplayCacheKey = null; + this._productsToDisplayCache = null; + this._productToDisplayByCategCacheKey = null; + this._productToDisplayByCategCache = null; + }, + + async editProduct(product) { + this.clearProductsCache(); + return await super.editProduct(...arguments); + }, + get productsToDisplay() { + const searchWord = this.searchProductWord.trim(); + const categoryId = this.selectedCategory?.id || 0; + const templatesLength = this.models["product.template"].length; + const cacheKey = `${searchWord}_${categoryId}_${templatesLength}`; + + if (this._productsToDisplayCacheKey === cacheKey && this._productsToDisplayCache) { + return this._productsToDisplayCache; + } + // We override this getter to remove Odoo's hardcoded 100-product limit. // Because our custom UI optimization module uses progressive rendering (lazy loading) // on scroll, we can safely return the entire product list without risking browser freezing. - const searchWord = this.searchProductWord.trim(); let recordIterator; const isSearchByWord = searchWord !== ""; @@ -56,26 +76,43 @@ patch(PosStore.prototype, { filteredList.push(p); } + let result = []; if ( !isSearchByWord && !this.selectedCategory?.id && this.areAllProductsSpecial(filteredList) ) { - return []; + result = []; + } else { + result = this.orderProductBySequenceAndFav(filteredList); } - return this.orderProductBySequenceAndFav(filteredList); + this._productsToDisplayCacheKey = cacheKey; + this._productsToDisplayCache = result; + return result; }, get productToDisplayByCateg() { + const searchWord = this.searchProductWord.trim(); + const categoryId = this.selectedCategory?.id || 0; + const templatesLength = this.models["product.template"].length; + const categoriesLength = this.models["pos.category"].length; + const cacheKey = `${searchWord}_${categoryId}_${templatesLength}_${categoriesLength}`; + + if (this._productToDisplayByCategCacheKey === cacheKey && this._productToDisplayByCategCache) { + return this._productToDisplayByCategCache; + } + // We override this getter to remove Odoo's hardcoded 100-product category limit. const sortedProducts = this.productsToDisplay; if (!this.config.iface_group_by_categ) { - return sortedProducts.length ? [["0", sortedProducts]] : []; + const result = sortedProducts.length ? [["0", sortedProducts]] : []; + this._productToDisplayByCategCacheKey = cacheKey; + this._productToDisplayByCategCache = result; + return result; } const results = []; - const searchWord = this.searchProductWord.trim(); const byCateg = this.models["product.template"].toRaw().getAllBy("pos_categ_ids"); const selectedCategoryIds = !this.selectedCategory ? this.models["pos.category"].map((c) => c.id) @@ -117,6 +154,8 @@ patch(PosStore.prototype, { } } + this._productToDisplayByCategCacheKey = cacheKey; + this._productToDisplayByCategCache = results; return results; } }); diff --git a/static/src/scss/portrait.scss b/static/src/scss/portrait.scss index fb0d81f..82b3741 100644 --- a/static/src/scss/portrait.scss +++ b/static/src/scss/portrait.scss @@ -415,3 +415,24 @@ } } } + +/* ============================================================ + GENERAL POS PERFORMANCE OPTIMIZATIONS (All Display Modes) + ============================================================ */ +.pos .overflow-y-auto { + will-change: scroll-position; + -webkit-overflow-scrolling: touch; +} + +.pos .product { + /* Disable transitions/animations on low-end ARM devices to make UI snappier */ + transition: none !important; + animation: none !important; + transform: translateZ(0); /* GPU acceleration */ + backface-visibility: hidden; +} + +.pos .product-img img { + image-rendering: -webkit-optimize-contrast; + will-change: transform; +} diff --git a/views/res_config_settings_views.xml b/views/res_config_settings_views.xml index eee794b..54d0c62 100644 --- a/views/res_config_settings_views.xml +++ b/views/res_config_settings_views.xml @@ -9,6 +9,9 @@ + + +