perf: implement aggressive POS UI optimizations including product caching, lazy loading throttling, and hardware-accelerated rendering.

This commit is contained in:
Suherdy Yacob 2026-06-05 22:21:20 +07:00
parent af4e446a7e
commit a389ad963e
11 changed files with 139 additions and 20 deletions

View File

@ -48,6 +48,9 @@ Features
# ProductScreen portrait tab state + layout # 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_mode_patch.js',
'pos_ui_optimization/static/src/app/screens/product_screen/portrait_screen.xml', '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 # Note splitting and defensive parsing patches
'pos_ui_optimization/static/src/app/screens/product_screen/notes_patch.js', 'pos_ui_optimization/static/src/app/screens/product_screen/notes_patch.js',
# Portrait CSS (must load after all XML patches) # Portrait CSS (must load after all XML patches)

View File

@ -10,9 +10,15 @@ class PosConfig(models.Model):
help="Hide the Cash In/Out button in the POS Navbar menu" 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 @api.model
def _load_pos_data_fields(self, config): def _load_pos_data_fields(self, config):
fields_list = super()._load_pos_data_fields(config) fields_list = super()._load_pos_data_fields(config)
if fields_list: 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 return fields_list

View File

@ -9,3 +9,9 @@ class ResConfigSettings(models.TransientModel):
related='pos_config_id.hide_cash_in_out_button', related='pos_config_id.hide_cash_in_out_button',
readonly=False readonly=False
) )
pos_hide_cart_images = fields.Boolean(
string="Hide Product Images in Cart",
related='pos_config_id.hide_cart_images',
readonly=False
)

View File

@ -37,7 +37,13 @@ patch(OrderDisplay.prototype, {
return; return;
} }
let orderScrollTimeout = null;
const onScroll = () => { const onScroll = () => {
if (orderScrollTimeout) {
return;
}
orderScrollTimeout = setTimeout(() => {
orderScrollTimeout = null;
if ( if (
scrollable.scrollTop + scrollable.clientHeight >= scrollable.scrollTop + scrollable.clientHeight >=
scrollable.scrollHeight - 100 scrollable.scrollHeight - 100
@ -46,6 +52,7 @@ patch(OrderDisplay.prototype, {
this.state.displayedCount += 20; this.state.displayedCount += 20;
} }
} }
}, 150);
}; };
scrollable.addEventListener("scroll", onScroll); scrollable.addEventListener("scroll", onScroll);

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-inherit="point_of_sale.Orderline" t-inherit-mode="extension" owl="1">
<xpath expr="//div[hasclass('o_line_container')]" position="attributes">
<attribute name="t-if">vals.productImage and !pos.config.hide_cart_images</attribute>
</xpath>
<xpath expr="//div[hasclass('o_line_container')]/img" position="attributes">
<attribute name="loading">lazy</attribute>
<attribute name="decoding">async</attribute>
</xpath>
</t>
</templates>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-inherit="point_of_sale.ProductCard" t-inherit-mode="extension" owl="1">
<xpath expr="//img" position="attributes">
<attribute name="loading">lazy</attribute>
<attribute name="decoding">async</attribute>
</xpath>
</t>
</templates>

View File

@ -3,6 +3,7 @@
import { NoteButton, InternalNoteButton } from "@point_of_sale/app/screens/product_screen/control_buttons/orderline_note_button/orderline_note_button"; 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 { Orderline } from "@point_of_sale/app/components/orderline/orderline";
import { patch } from "@web/core/utils/patch"; 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 // 1. Patch NoteButton to fix line-splitting bug for customer notes in standard Odoo
patch(NoteButton.prototype, { 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, { patch(Orderline.prototype, {
setup() {
super.setup();
this.pos = usePos();
},
get lineScreenValues() { get lineScreenValues() {
const originalParse = JSON.parse; const originalParse = JSON.parse;
JSON.parse = function (str) { JSON.parse = function (str) {

View File

@ -10,17 +10,24 @@ patch(ProductScreen.prototype, {
this.state.displayedProductsCount = 80; this.state.displayedProductsCount = 80;
const originalOnScroll = this.onScroll; const originalOnScroll = this.onScroll;
let scrollTimeout = null;
this.onScroll = (ev) => { this.onScroll = (ev) => {
if (originalOnScroll) { if (originalOnScroll) {
originalOnScroll(ev); originalOnScroll(ev);
} }
if (scrollTimeout) {
return;
}
scrollTimeout = setTimeout(() => {
scrollTimeout = null;
const el = ev.target; const el = ev.target;
if (el && el.scrollTop + el.clientHeight >= el.scrollHeight - 400) { if (el && el.scrollTop + el.clientHeight >= el.scrollHeight - 450) {
const totalCount = this.pos.productToDisplayByCateg.reduce((acc, cat) => acc + cat[1].length, 0); const totalCount = this.pos.productsToDisplay.length;
if (this.state.displayedProductsCount < totalCount) { if (this.state.displayedProductsCount < totalCount) {
this.state.displayedProductsCount += 80; this.state.displayedProductsCount += 80;
} }
} }
}, 150);
}; };
useEffect( useEffect(

View File

@ -4,11 +4,31 @@ import { PosStore } from "@point_of_sale/app/services/pos_store";
import { patch } from "@web/core/utils/patch"; import { patch } from "@web/core/utils/patch";
patch(PosStore.prototype, { 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() { 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. // We override this getter to remove Odoo's hardcoded 100-product limit.
// Because our custom UI optimization module uses progressive rendering (lazy loading) // 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. // on scroll, we can safely return the entire product list without risking browser freezing.
const searchWord = this.searchProductWord.trim();
let recordIterator; let recordIterator;
const isSearchByWord = searchWord !== ""; const isSearchByWord = searchWord !== "";
@ -56,26 +76,43 @@ patch(PosStore.prototype, {
filteredList.push(p); filteredList.push(p);
} }
let result = [];
if ( if (
!isSearchByWord && !isSearchByWord &&
!this.selectedCategory?.id && !this.selectedCategory?.id &&
this.areAllProductsSpecial(filteredList) this.areAllProductsSpecial(filteredList)
) { ) {
return []; result = [];
} else {
result = this.orderProductBySequenceAndFav(filteredList);
} }
return this.orderProductBySequenceAndFav(filteredList); this._productsToDisplayCacheKey = cacheKey;
this._productsToDisplayCache = result;
return result;
}, },
get productToDisplayByCateg() { 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. // We override this getter to remove Odoo's hardcoded 100-product category limit.
const sortedProducts = this.productsToDisplay; const sortedProducts = this.productsToDisplay;
if (!this.config.iface_group_by_categ) { 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 results = [];
const searchWord = this.searchProductWord.trim();
const byCateg = this.models["product.template"].toRaw().getAllBy("pos_categ_ids"); const byCateg = this.models["product.template"].toRaw().getAllBy("pos_categ_ids");
const selectedCategoryIds = !this.selectedCategory const selectedCategoryIds = !this.selectedCategory
? this.models["pos.category"].map((c) => c.id) ? this.models["pos.category"].map((c) => c.id)
@ -117,6 +154,8 @@ patch(PosStore.prototype, {
} }
} }
this._productToDisplayByCategCacheKey = cacheKey;
this._productToDisplayByCategCache = results;
return results; return results;
} }
}); });

View File

@ -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;
}

View File

@ -9,6 +9,9 @@
<setting string="Hide Cash In/Out (Navbar)" help="Hide Cash In/Out button in POS Navbar"> <setting string="Hide Cash In/Out (Navbar)" help="Hide Cash In/Out button in POS Navbar">
<field name="pos_hide_cash_in_out_button"/> <field name="pos_hide_cash_in_out_button"/>
</setting> </setting>
<setting string="Hide Product Images in Cart" help="Hide product images in the order/cart lines for faster rendering">
<field name="pos_hide_cart_images"/>
</setting>
</xpath> </xpath>
</field> </field>
</record> </record>