diff --git a/.gitignore b/.gitignore index 865171c..93b40e9 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ tools.db **/*.prof **/*.db screenshot* +Capture* # Created by .ignore support plugin (hsz.mobi) ### Python template diff --git a/src/myfasthtml/assets/core/htmx_debug.js b/src/myfasthtml/assets/core/htmx_debug.js index f70c48c..fb37f32 100644 --- a/src/myfasthtml/assets/core/htmx_debug.js +++ b/src/myfasthtml/assets/core/htmx_debug.js @@ -17,9 +17,13 @@ */ window.HTMX_DEBUG = false; +window.HTMX_DOM_DEBUG = false; +window.HTMX_SETTLE_DEBUG = false; (function () { console.log('Debug HTMX: htmx.logAll();'); console.log('Perf HTMX: window.HTMX_DEBUG=true;'); + console.log('DOM mutations: window.HTMX_DOM_DEBUG=true;'); + console.log('Settle/transition debug: window.HTMX_SETTLE_DEBUG=true;'); })(); (function () { @@ -38,12 +42,108 @@ window.HTMX_DEBUG = false; let counter = 0; const requests = new WeakMap(); + function _startDomObserver(requestId, startTime) { + const observer = new MutationObserver(mutations => { + mutations.forEach(m => { + const el = m.target; + const label = el.id ? `#${el.id}` : `<${el.tagName.toLowerCase()} class="${el.className}">`; + const t = (performance.now() - startTime).toFixed(2); + console.debug(`[HTMX DOM] #${requestId} +${t}ms class changed on ${label} → "${el.className}"`); + }); + }); + observer.observe(document.body, {attributes: true, subtree: true, attributeFilter: ['class']}); + return observer; + } + function getInfo(detail) { const key = detail?.requestConfig ?? detail?.xhr ?? null; if (!key || !requests.has(key)) return null; return requests.get(key); } + // HTMX_SETTLE_DEBUG: trace transitions/animations + computed style on tsm_ after swap + (function () { + ['transitionstart', 'transitionend', 'transitioncancel', + 'animationstart', 'animationend'].forEach(evtName => { + document.addEventListener(evtName, e => { + if (!window.HTMX_SETTLE_DEBUG) return; + const t = window._perfT0 ? (performance.now() - window._perfT0).toFixed(1) : '?'; + const label = e.target.id ? `#${e.target.id}` : `<${e.target.tagName.toLowerCase()}>`; + console.debug(`[SETTLE +${t}ms] ${evtName} on ${label} | property: ${e.propertyName ?? ''} | duration: ${e.elapsedTime ?? ''}s`); + }, true); + }); + + document.addEventListener('htmx:afterSwap', e => { + if (!window.HTMX_SETTLE_DEBUG) return; + const target = e.detail?.target; + if (!target?.id?.startsWith('tsm_')) return; + const t = window._perfT0 ? (performance.now() - window._perfT0).toFixed(1) : '?'; + const cs = getComputedStyle(target); + console.debug(`[SETTLE +${t}ms] tsm_ computed transitionDuration="${cs.transitionDuration}" transition="${cs.transition}"`); + const child = target.firstElementChild; + if (child) { + const cs2 = getComputedStyle(child); + console.debug(`[SETTLE +${t}ms] tsm_>child computed transitionDuration="${cs2.transitionDuration}" transition="${cs2.transition}"`); + } + }); + + // Patch setTimeout to detect delayed settle timers (main thread blocked by rendering) + const _origST = window.setTimeout; + window.setTimeout = function (fn, delay, ...args) { + const createdAt = window._perfT0 ? (performance.now() - window._perfT0) : null; + return _origST.call(window, function () { + if (window.HTMX_SETTLE_DEBUG && createdAt !== null && delay >= 1) { + const firedAt = performance.now() - window._perfT0; + const late = (firedAt - createdAt - delay).toFixed(1); + console.debug(`[TIMER] delay=${delay}ms | scheduled +${createdAt.toFixed(1)}ms | fired +${firedAt.toFixed(1)}ms | late=${late}ms`); + } + return fn.apply(this, args); + }, delay, ...args); + }; + + // PerformanceObserver: log all long tasks (>50ms JS blocks) with timing relative to _perfT0 + try { + new PerformanceObserver(list => { + if (!window.HTMX_SETTLE_DEBUG || !window._perfT0) return; + list.getEntries().forEach(entry => { + const start = (entry.startTime - window._perfT0).toFixed(1); + const dur = entry.duration.toFixed(1); + console.debug(`[LONG TASK] start +${start}ms | duration ${dur}ms`); + }); + }).observe({entryTypes: ['longtask']}); + } catch (e) { /* longtask not supported */ } + + // After swap: measure time until next rendering frame to detect rendering blockage + document.addEventListener('htmx:afterSwap', e => { + if (!window.HTMX_SETTLE_DEBUG || !window._perfT0) return; + if (!e.detail?.target?.id?.startsWith('tsm_')) return; + const swapAt = performance.now() - window._perfT0; + requestAnimationFrame(() => { + const rafAt = (performance.now() - window._perfT0).toFixed(1); + console.debug(`[RENDER] swap at +${swapAt.toFixed(1)}ms | next rAF at +${rafAt}ms | rendering blocked ${(rafAt - swapAt).toFixed(1)}ms`); + }); + }); + + // Patch requestAnimationFrame to identify which callback causes the long task + const _origRAF = window.requestAnimationFrame; + window.requestAnimationFrame = function (fn) { + const stack = (new Error().stack || '').split('\n')[2]?.trim() || '?'; + const scheduledAt = window._perfT0 ? (performance.now() - window._perfT0) : null; + return _origRAF.call(window, function (timestamp) { + const t0 = performance.now(); + const result = fn(timestamp); + if (window.HTMX_SETTLE_DEBUG && scheduledAt !== null) { + const cost = (performance.now() - t0).toFixed(1); + const firedAt = (t0 - window._perfT0).toFixed(1); + if (parseFloat(cost) > 5) { + console.debug(`[RAF SLOW] scheduled +${scheduledAt.toFixed(1)}ms | fired +${firedAt}ms | cost ${cost}ms | ${stack}`); + } + } + return result; + }); + }; + })(); + EVENTS.forEach(eventName => { document.addEventListener(eventName, (e) => { if (!window.HTMX_DEBUG) return; @@ -59,7 +159,8 @@ window.HTMX_DEBUG = false; if (key) { const id = ++counter; const now = performance.now(); - requests.set(key, {id, start: now, last: now}); + const observer = window.HTMX_DOM_DEBUG ? _startDomObserver(id, now) : null; + requests.set(key, {id, start: now, last: now, observer}); prefix = `#${String(id).padStart(3)} + 0.0ms (Δ 0.0ms)`; } else { prefix = `# ? + 0.0ms (Δ 0.0ms)`; @@ -72,6 +173,10 @@ window.HTMX_DEBUG = false; const step = (now - info.last).toFixed(1); info.last = now; prefix = `#${String(info.id).padStart(3)} +${String(total).padStart(7)}ms (Δ${String(step).padStart(7)}ms)`; + + if (eventName === 'htmx:afterSettle' || eventName === 'htmx:sendError') { + info.observer?.disconnect(); + } } else { prefix = `# ? + ?.?ms (Δ ?.?ms)`; } diff --git a/src/myfasthtml/assets/core/keyboard.js b/src/myfasthtml/assets/core/keyboard.js index 2509eb9..b8cb93e 100644 --- a/src/myfasthtml/assets/core/keyboard.js +++ b/src/myfasthtml/assets/core/keyboard.js @@ -4,6 +4,9 @@ // Set window.KEYBOARD_DEBUG = true in the browser console to enable traces window.KEYBOARD_DEBUG = false; +(function () { + console.log('Perf Keyboard: window.KEYBOARD_DEBUG=true;'); +})(); (function () { function kbLog(...args) { @@ -169,6 +172,8 @@ window.KEYBOARD_DEBUG = false; * @param {KeyboardEvent} event - The keyboard event */ function handleKeyboardEvent(event) { + window._perfT0 = performance.now(); + kbLog(`[PERF] keyboard_start: 0ms`); const key = normalizeKey(event.key); // Add key to current pressed keys @@ -260,7 +265,9 @@ window.KEYBOARD_DEBUG = false; // We have matches and NO element has longer sequences possible // Trigger ALL matches immediately for (const match of currentMatches) { + kbLog(`[PERF] before_trigger: ${(performance.now() - window._perfT0).toFixed(2)}ms`); triggerHtmxAction(match.elementId, match.config, match.combinationStr, match.isInside, event); + kbLog(`[PERF] after_trigger: ${(performance.now() - window._perfT0).toFixed(2)}ms`); } // Clear history after triggering @@ -299,6 +306,8 @@ window.KEYBOARD_DEBUG = false; KeyboardRegistry.snapshotHistory = []; } + kbLog(`[PERF] keyboard_end: ${(performance.now() - window._perfT0).toFixed(2)}ms`); + // If we found no match at all, clear the history // This handles invalid sequences like "A C" when only "A B" exists if (!foundAnyMatch) { diff --git a/src/myfasthtml/assets/core/myfasthtml.js b/src/myfasthtml/assets/core/myfasthtml.js index a1da15e..17c6f69 100644 --- a/src/myfasthtml/assets/core/myfasthtml.js +++ b/src/myfasthtml/assets/core/myfasthtml.js @@ -376,8 +376,13 @@ function triggerHtmxAction(elementId, config, combinationStr, isInside, event) { } } + // Use the HTMX target element as source so htmx-request is added there + // instead of , avoiding a full-document style recalculation. + const targetSelector = config['hx-target']; + const sourceElement = targetSelector ? document.querySelector(targetSelector) : element; + htmxOptions.source = sourceElement ?? element; + // Make AJAX call with htmx //console.debug(`Triggering HTMX action for element ${elementId}: ${method} ${url}`, htmxOptions); htmx.ajax(method, url, htmxOptions); } - diff --git a/src/myfasthtml/assets/datagrid/datagrid.css b/src/myfasthtml/assets/datagrid/datagrid.css index 957060c..8172f5b 100644 --- a/src/myfasthtml/assets/datagrid/datagrid.css +++ b/src/myfasthtml/assets/datagrid/datagrid.css @@ -232,6 +232,7 @@ -ms-overflow-style: none; /* IE/Edge: hide scrollbar */ border: 1px solid var(--color-border); border-radius: 10px; + contain: layout style paint; /* Isolate from sibling DOM changes (perf: prevents style recalc cascade) */ } /* Chrome/Safari: hide scrollbar */ diff --git a/src/myfasthtml/assets/datagrid/datagrid.js b/src/myfasthtml/assets/datagrid/datagrid.js index 3339645..bff3a72 100644 --- a/src/myfasthtml/assets/datagrid/datagrid.js +++ b/src/myfasthtml/assets/datagrid/datagrid.js @@ -687,9 +687,10 @@ function updateDatagridSelection(datagridId) { element.style.userSelect = ''; }); - // Loop through the children of the selection manager + // Loop through the children of the inner selection manager (#tsmi_) + const tsmi = document.getElementById(`tsmi_${datagridId}`) ?? selectionManager; let hasFocusedCell = false; - Array.from(selectionManager.children).forEach((selection) => { + Array.from(tsmi.children).forEach((selection) => { const selectionType = selection.getAttribute('selection-type'); const elementId = selection.getAttribute('element-id'); diff --git a/src/myfasthtml/controls/DataGrid.py b/src/myfasthtml/controls/DataGrid.py index 452a654..641a93d 100644 --- a/src/myfasthtml/controls/DataGrid.py +++ b/src/myfasthtml/controls/DataGrid.py @@ -157,21 +157,21 @@ class Commands(BaseCommands): "Click on the table", self._owner, self._owner.handle_on_click - ).htmx(target=f"#tsm_{self._id}") - + ).htmx(target=f"#tsmi_{self._id}") + def on_key_pressed(self): return Command("OnKeyPressed", "Key pressed on the table", self._owner, self._owner.on_key_pressed - ).htmx(target=f"#tsm_{self._id}") - + ).htmx(target=f"#tsmi_{self._id}") + def on_mouse_selection(self): return Command("OnMouseSelection", "Range selection with mouse", self._owner, self._owner.on_mouse_selection - ).htmx(target=f"#tsm_{self._id}") + ).htmx(target=f"#tsmi_{self._id}") def toggle_columns_manager(self): return Command("ToggleColumnsManager", @@ -690,8 +690,8 @@ class DataGrid(MultipleInstance): else: logger.debug(f" is_inside=False") - return self.render_partial() - + return self.render_partial(inner_mode=True) + def on_mouse_selection(self, combination, is_inside, cell_id_mousedown, cell_id_mouseup): logger.debug(f"on_mouse_selection {combination=} {is_inside=} {cell_id_mousedown=} {cell_id_mouseup=}") if (is_inside and @@ -707,22 +707,22 @@ class DataGrid(MultipleInstance): self._state.selection.extra_selected.clear() self._state.selection.extra_selected.append(("range", (min_col, min_row, max_col, max_row))) - - return self.render_partial() - + + return self.render_partial(inner_mode=True) + @profiler.trace_calls() def on_key_pressed(self, combination, has_focus, is_inside): logger.debug(f"on_key_pressed table={self.get_table_name()} {combination=} {has_focus=} {is_inside=}") if combination == "esc": self._update_current_position(None, reset_selection=True) - return self.render_partial("cell", pos=self._state.selection.last_selected) - + return self.render_partial("cell", inner_mode=True, pos=self._state.selection.last_selected) + elif (combination == "enter" and self._settings.enable_edition and self._state.selection.selected and self._state.edition.under_edition is None): return self._enter_edition(self._state.selection.selected) - + elif combination in self._ARROW_KEY_DIRECTIONS: current_pos = (self._state.selection.selected or self._state.selection.last_selected @@ -730,8 +730,8 @@ class DataGrid(MultipleInstance): direction = self._ARROW_KEY_DIRECTIONS[combination] new_pos = self._navigate(current_pos, direction) self._update_current_position(new_pos) - - return self.render_partial() + + return self.render_partial(inner_mode=True) def on_column_changed(self): logger.debug("on_column_changed") @@ -1180,10 +1180,27 @@ class DataGrid(MultipleInstance): selected.append(extra_sel) return Div( - *[Div(selection_type=s_type, element_id=f"{elt_id}") for s_type, elt_id in selected], + Div( + *[Div(selection_type=s_type, element_id=f"{elt_id}") for s_type, elt_id in selected], + id=f"tsmi_{self._id}", + ), id=f"tsm_{self._id}", selection_mode=f"{self._state.selection.selection_mode}", ) + + def mk_selection_manager_inner(self): + selected = [] + + if self._state.selection.selected: + selected.append(("focus", self._get_element_id_from_pos("cell", self._state.selection.selected))) + + for extra_sel in self._state.selection.extra_selected: + selected.append(extra_sel) + + return Div( + *[Div(selection_type=s_type, element_id=f"{elt_id}") for s_type, elt_id in selected], + id=f"tsmi_{self._id}", + ) def mk_aggregation_cell(self, col_def, row_index: int, footer_conf, oob=False): """ @@ -1270,17 +1287,21 @@ class DataGrid(MultipleInstance): cls="grid", style="height: 100%; grid-template-rows: auto 1fr;", tabindex="-1", - **{"hx-on:htmx:after-swap": f"if(event.detail.target.id==='tsm_{self._id}') updateDatagridSelection('{self._id}');"} + **{"hx-on:htmx:after-swap": f"if(event.detail.target.id==='tsm_{self._id}'||event.detail.target.id==='tsmi_{self._id}') updateDatagridSelection('{self._id}');"} ) - def render_partial(self, fragment="cell", **kwargs): + def render_partial(self, fragment="cell", inner_mode=False, **kwargs): """ :param fragment: cell | body | table | header + :param inner_mode: When True, returns mk_selection_manager_inner() (outerHTML swap on #tsmi_) + instead of mk_selection_manager() (outerHTML swap on #tsm_). + Use inner_mode=True for navigation commands (arrow keys, click, mouse selection) + to avoid style recalculation on #tsm_ siblings (e.g. the full table). :param kwargs: Additional parameters for specific fragments (col_id, optimal_width for header) :return: """ - res = [self.mk_selection_manager()] + res = [self.mk_selection_manager_inner() if inner_mode else self.mk_selection_manager()] extra_attr = { "hx-on::after-settle": f"initDataGrid('{self._id}');",