Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .jules/bolt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
## 2025-05-22 - Global O(N) selection optimization with WeakMap
**Learning:** In applications using immutable state updates (like Redux or React setState), utility functions that iterate over state objects can be optimized globally using a `WeakMap`. By caching the result based on the object reference, redundant O(N) calculations during re-renders are reduced to O(1).
**Action:** Always check if core utility functions that process state objects (like `getSelectedIds`) can benefit from reference-based memoization.

## 2025-05-22 - Gating expensive calculations in render
**Learning:** Calculating derived data (like selected ID lists) at the top of a `render()` method is wasteful if that data is only needed for a conditional UI element (like an editor footer).
**Action:** Move expensive calculations inside the conditional block that requires them, or use a ternary to gate their execution based on the relevant state (e.g., `isEditorActive`).
4 changes: 3 additions & 1 deletion frontend/src/Author/Details/AuthorDetails.js
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,9 @@ class AuthorDetails extends Component {
expandIcon = icons.EXPAND;
}

const selectedBookIds = this.getSelectedIds();
// ⚑ Bolt: Only calculate selected IDs if the editor is active,
// avoiding unnecessary O(N) calculations on every render.
const selectedBookIds = isEditorActive ? this.getSelectedIds() : [];

return (
<PageContent title={authorName}>
Expand Down
68 changes: 39 additions & 29 deletions frontend/src/Author/Details/AuthorDetailsSeason.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import TextInput from 'Components/Form/TextInput';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import TablePager from 'Components/Table/TablePager';
import TextInput from 'Components/Form/TextInput';
import { sortDirections } from 'Helpers/Props';
import hasDifferentItemsOrOrder from 'Utilities/Object/hasDifferentItemsOrOrder';
import getToggledRange from 'Utilities/Table/getToggledRange';
Expand All @@ -31,10 +31,6 @@ class AuthorDetailsSeason extends Component {
this.props.setSelectedState(this.props.items);
}

onFilterChange = ({ value }) => {
this.setState({ filter: value, page: 1 });
};

componentDidUpdate(prevProps) {
const {
items,
Expand All @@ -56,6 +52,10 @@ class AuthorDetailsSeason extends Component {
}
}

onFilterChange = ({ value }) => {
this.setState({ filter: value, page: 1 });
};

onFirstPagePress = () => {
this.setState({ page: 1 });
};
Expand Down Expand Up @@ -111,39 +111,49 @@ class AuthorDetailsSeason extends Component {
return onSelectedChange(items, id, value, shiftKey);
};

// ⚑ Bolt: Memoize filtered and paged items to prevent expensive O(N) operations on every render.
getFilteredAndPagedItems(items, filter, page, pageSize) {
if (this._lastItems !== items || this._lastFilter !== filter) {
const lowerFilter = filter.trim().toLowerCase();
this._filteredItems =
lowerFilter.length === 0 ?
items :
items.filter((item) => {
const title = item.title?.toLowerCase() || '';
const authorName = item.author?.name?.toLowerCase() || '';
const topics = (item.topics || item.genres || []).join(' ').toLowerCase();
return title.includes(lowerFilter) || authorName.includes(lowerFilter) || topics.includes(lowerFilter);
});
this._lastItems = items;
this._lastFilter = filter;
this._lastPage = null;
}

if (this._lastPage !== page || this._lastPageSize !== pageSize) {
const start = (page - 1) * pageSize;
const end = start + pageSize;
this._pagedItems = this._filteredItems.slice(start, end);
this._lastPage = page;
this._lastPageSize = pageSize;
}

return {
filteredItems: this._filteredItems,
pagedItems: this._pagedItems
};
}

//
// Render

render() {
const {
items,
isEditorActive,
columns,
sortKey,
sortDirection,
onSortPress,
onTableOptionChange,
selectedState
} = this.props;
const { items, isEditorActive, columns, sortKey, sortDirection, onSortPress, onTableOptionChange, selectedState } = this.props;

const { page, pageSize, filter } = this.state;

const lowerFilter = filter.trim().toLowerCase();
const filteredItems = lowerFilter.length === 0
? items
: items.filter((item) => {
const title = item.title?.toLowerCase() || '';
const authorName = item.author?.name?.toLowerCase() || '';
const topics = (item.topics || item.genres || []).join(' ').toLowerCase();
return title.includes(lowerFilter) ||
authorName.includes(lowerFilter) ||
topics.includes(lowerFilter);
});
const { filteredItems, pagedItems } = this.getFilteredAndPagedItems(items, filter, page, pageSize);

const totalPages = Math.max(1, Math.ceil(filteredItems.length / pageSize));
const start = (page - 1) * pageSize;
const end = start + pageSize;
const pagedItems = filteredItems.slice(start, end);

let titleColumns = columns;
if (!isEditorActive) {
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/Author/Index/AuthorIndex.js
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,9 @@ class AuthorIndex extends Component {
allUnselected
} = this.state;

const selectedAuthorIds = this.getSelectedIds();
// ⚑ Bolt: Only calculate selected IDs if the editor is active,
// avoiding unnecessary O(N) calculations on every render.
const selectedAuthorIds = isEditorActive ? this.getSelectedIds() : [];

const ViewComponent = getViewComponent(view);
const isLoaded = !!(!error && isPopulated && items.length && scroller);
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/Book/Index/BookIndex.js
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,9 @@ class BookIndex extends Component {
allUnselected
} = this.state;

const selectedBookIds = this.getSelectedIds();
// ⚑ Bolt: Only calculate selected IDs if the editor is active,
// avoiding unnecessary O(N) calculations on every render.
const selectedBookIds = isEditorActive ? this.getSelectedIds() : [];

const ViewComponent = getViewComponent(view);
const isLoaded = !!(!error && isPopulated && items.length && scroller);
Expand Down
40 changes: 33 additions & 7 deletions frontend/src/Utilities/Table/getSelectedIds.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,41 @@
import _ from 'lodash';

// ⚑ Bolt: Use WeakMap to cache selected IDs by selectedState object reference.
// This optimizes re-renders where the selection state hasn't changed,
// reducing O(N) iteration to O(1).
const cache = new WeakMap();

function getSelectedIds(selectedState, { parseIds = true } = {}) {
return _.reduce(selectedState, (result, value, id) => {
if (value) {
const parsedId = parseIds ? parseInt(id) : id;
if (!selectedState || typeof selectedState !== 'object') {
return [];
}

let stateCache = cache.get(selectedState);
if (stateCache && stateCache.has(parseIds)) {
return stateCache.get(parseIds);
}

const result = _.reduce(
selectedState,
(res, value, id) => {
if (value) {
const parsedId = parseIds ? parseInt(id) : id;

res.push(parsedId);
}

return res;
},
[]
);

result.push(parsedId);
}
if (!stateCache) {
stateCache = new Map();
cache.set(selectedState, stateCache);
}
stateCache.set(parseIds, result);

return result;
}, []);
return result;
}

export default getSelectedIds;