From 069cd063b4202851c9c6c1288628e24e4b359803 Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Wed, 19 Feb 2025 14:11:33 -0800 Subject: [PATCH 1/4] [TypeScript] SpreadsheetProvider --- .../components/budget/envelope/HoldMenu.tsx | 22 ++---- .../components/spreadsheet/useSheetValue.ts | 35 +++++---- packages/loot-core/package.json | 2 +- .../src/client/SpreadsheetProvider.tsx | 76 ++++++++++++++----- .../src/platform/server/sqlite/unicodeLike.ts | 5 +- packages/loot-core/src/server/db/index.ts | 6 +- packages/loot-core/src/server/main.ts | 5 +- .../loot-core/src/types/server-handlers.d.ts | 20 +++-- .../webpack/webpack.browser.config.js | 6 ++ yarn.lock | 9 ++- 10 files changed, 114 insertions(+), 72 deletions(-) diff --git a/packages/desktop-client/src/components/budget/envelope/HoldMenu.tsx b/packages/desktop-client/src/components/budget/envelope/HoldMenu.tsx index 57afe34d497..f0e96baa7b7 100644 --- a/packages/desktop-client/src/components/budget/envelope/HoldMenu.tsx +++ b/packages/desktop-client/src/components/budget/envelope/HoldMenu.tsx @@ -1,38 +1,26 @@ -import React, { - useState, - useContext, - useEffect, - type ChangeEvent, -} from 'react'; +import React, { useState, type ChangeEvent } from 'react'; import { Trans } from 'react-i18next'; import { Button } from '@actual-app/components/button'; import { InitialFocus } from '@actual-app/components/initial-focus'; import { View } from '@actual-app/components/view'; -import { useSpreadsheet } from 'loot-core/client/SpreadsheetProvider'; import { evalArithmetic } from 'loot-core/shared/arithmetic'; import { integerToCurrency, amountToInteger } from 'loot-core/shared/util'; import { Input } from '../../common/Input'; -import { NamespaceContext } from '../../spreadsheet/NamespaceContext'; +import { useSheetValue } from '../../spreadsheet/useSheetValue'; type HoldMenuProps = { onSubmit: (amount: number) => void; onClose: () => void; }; export function HoldMenu({ onSubmit, onClose }: HoldMenuProps) { - const spreadsheet = useSpreadsheet(); - const sheetName = useContext(NamespaceContext); - const [amount, setAmount] = useState(null); - useEffect(() => { - (async () => { - const node = await spreadsheet.get(sheetName, 'to-budget'); - setAmount(integerToCurrency(Math.max(node.value as number, 0))); - })(); - }, []); + useSheetValue<'envelope-budget', 'to-budget'>('to-budget', ({ value }) => { + setAmount(integerToCurrency(Math.max(value || 0, 0))); + }); function submit(newAmount: string) { const parsedAmount = evalArithmetic(newAmount); diff --git a/packages/desktop-client/src/components/spreadsheet/useSheetValue.ts b/packages/desktop-client/src/components/spreadsheet/useSheetValue.ts index 86701154557..af7278199f3 100644 --- a/packages/desktop-client/src/components/spreadsheet/useSheetValue.ts +++ b/packages/desktop-client/src/components/spreadsheet/useSheetValue.ts @@ -1,7 +1,6 @@ import { useState, useRef, useLayoutEffect, useMemo } from 'react'; import { useSpreadsheet } from 'loot-core/client/SpreadsheetProvider'; -import { type Query } from 'loot-core/shared/query'; import { useSheetName } from './useSheetName'; @@ -18,7 +17,6 @@ type SheetValueResult< > = { name: string; value: Spreadsheets[SheetName][FieldName] | null; - query?: Query; }; export function useSheetValue< @@ -42,7 +40,6 @@ export function useSheetValue< const [result, setResult] = useState>({ name: fullSheetName, value: bindingObj.value ? bindingObj.value : null, - query: bindingObj.query, }); const latestOnChange = useRef(onChange); latestOnChange.current = onChange; @@ -53,23 +50,25 @@ export function useSheetValue< useLayoutEffect(() => { let isMounted = true; - const unbind = spreadsheet.bind( - sheetName, - bindingObj, - (newResult: SheetValueResult) => { - if (!isMounted) { - return; - } + const unbind = spreadsheet.bind(sheetName, bindingObj, newResult => { + if (!isMounted) { + return; + } - if (latestOnChange.current) { - latestOnChange.current(newResult); - } + const newCastedResult = { + name: newResult.name, + // TODO: Spreadsheets, SheetNames, SheetFields, etc must be moved to the loot-core package + value: newResult.value as Spreadsheets[SheetName][FieldName], + }; - if (newResult.value !== latestValue.current) { - setResult(newResult); - } - }, - ); + if (latestOnChange.current) { + latestOnChange.current(newCastedResult); + } + + if (newResult.value !== latestValue.current) { + setResult(newCastedResult); + } + }); return () => { isMounted = false; diff --git a/packages/loot-core/package.json b/packages/loot-core/package.json index bd09d1c25da..a4aa251e5d0 100644 --- a/packages/loot-core/package.json +++ b/packages/loot-core/package.json @@ -30,7 +30,7 @@ "date-fns": "^2.30.0", "deep-equal": "^2.2.3", "handlebars": "^4.7.8", - "lru-cache": "^5.1.1", + "lru-cache": "^11.0.2", "md5": "^2.3.0", "memoize-one": "^6.0.0", "mitt": "^3.0.1", diff --git a/packages/loot-core/src/client/SpreadsheetProvider.tsx b/packages/loot-core/src/client/SpreadsheetProvider.tsx index 6f8cfafbd78..52ecfc77161 100644 --- a/packages/loot-core/src/client/SpreadsheetProvider.tsx +++ b/packages/loot-core/src/client/SpreadsheetProvider.tsx @@ -1,25 +1,48 @@ -// @ts-strict-ignore -import React, { createContext, useEffect, useMemo, useContext } from 'react'; +import { + createContext, + useEffect, + useMemo, + useContext, + type ReactNode, +} from 'react'; -import LRU from 'lru-cache'; +import { LRUCache } from 'lru-cache'; import { listen, send } from '../platform/client/fetch'; +import { type Node } from '../server/spreadsheet/spreadsheet'; +import { type Query } from '../shared/query'; type SpreadsheetContextValue = ReturnType; -const SpreadsheetContext = createContext(undefined); +const SpreadsheetContext = createContext( + undefined, +); export function useSpreadsheet() { - return useContext(SpreadsheetContext); + const context = useContext(SpreadsheetContext); + if (!context) { + throw new Error('useSpreadsheet must be used within a SpreadsheetProvider'); + } + return context; } +// TODO: Make this generic and replace the Binding type in the desktop-client package. +type Binding = + | string + | { name: string; value?: unknown | null; query?: Query | undefined }; + +type CellCacheValue = { name: string; value: Node['value'] | null }; +type CellCache = { [name: string]: Promise | null }; +type CellObserverCallback = (node: CellCacheValue) => void; +type CellObservers = { [name: string]: CellObserverCallback[] }; + function makeSpreadsheet() { - const cellObservers = {}; - const LRUValueCache = new LRU({ max: 1200 }); - const cellCache = {}; + const cellObservers: CellObservers = {}; + const LRUValueCache = new LRUCache({ max: 1200 }); + const cellCache: CellCache = {}; let observersDisabled = false; class Spreadsheet { - observeCell(name, cb) { + observeCell(name: string, cb: CellObserverCallback): () => void { if (!cellObservers[name]) { cellObservers[name] = []; } @@ -34,23 +57,23 @@ function makeSpreadsheet() { }; } - disableObservers() { + disableObservers(): void { observersDisabled = true; } - enableObservers() { + enableObservers(): void { observersDisabled = false; } - prewarmCache(name, value) { + prewarmCache(name: string, value: CellCacheValue): void { LRUValueCache.set(name, value); } - listen() { - return listen('cells-changed', function (nodes) { + listen(): () => void { + return listen('cells-changed', event => { if (!observersDisabled) { // TODO: batch react so only renders once - nodes.forEach(node => { + event.forEach(node => { const observers = cellObservers[node.name]; if (observers) { observers.forEach(func => func(node)); @@ -62,7 +85,11 @@ function makeSpreadsheet() { }); } - bind(sheetName = '__global', binding, callback) { + bind( + sheetName: string = '__global', + binding: Binding, + callback: CellObserverCallback, + ): () => void { binding = typeof binding === 'string' ? { name: binding, value: null } : binding; @@ -77,7 +104,10 @@ function makeSpreadsheet() { // This is a display optimization to avoid flicker. The LRU cache // will keep a number of recent nodes in memory. if (LRUValueCache.has(resolvedName)) { - callback(LRUValueCache.get(resolvedName)); + const node = LRUValueCache.get(resolvedName); + if (node) { + callback(node); + } } if (cellCache[resolvedName] != null) { @@ -102,15 +132,15 @@ function makeSpreadsheet() { return cleanup; } - get(sheetName, name) { + get(sheetName: string, name: string) { return send('getCell', { sheetName, name }); } - getCellNames(sheetName) { + getCellNames(sheetName: string) { return send('getCellNamesInSheet', { sheetName }); } - createQuery(sheetName, name, query) { + createQuery(sheetName: string, name: string, query: Query) { return send('create-query', { sheetName, name, @@ -122,7 +152,11 @@ function makeSpreadsheet() { return new Spreadsheet(); } -export function SpreadsheetProvider({ children }) { +type SpreadsheetProviderProps = { + children: ReactNode; +}; + +export function SpreadsheetProvider({ children }: SpreadsheetProviderProps) { const spreadsheet = useMemo(() => makeSpreadsheet(), []); useEffect(() => { diff --git a/packages/loot-core/src/platform/server/sqlite/unicodeLike.ts b/packages/loot-core/src/platform/server/sqlite/unicodeLike.ts index 33e9365fd9a..7e60269304f 100644 --- a/packages/loot-core/src/platform/server/sqlite/unicodeLike.ts +++ b/packages/loot-core/src/platform/server/sqlite/unicodeLike.ts @@ -1,7 +1,6 @@ -// @ts-strict-ignore -import LRU from 'lru-cache'; +import { LRUCache } from 'lru-cache'; -const likePatternCache = new LRU({ max: 500 }); +const likePatternCache = new LRUCache({ max: 500 }); export function unicodeLike( pattern: string | null, diff --git a/packages/loot-core/src/server/db/index.ts b/packages/loot-core/src/server/db/index.ts index 12e80e07a1d..40927d042e7 100644 --- a/packages/loot-core/src/server/db/index.ts +++ b/packages/loot-core/src/server/db/index.ts @@ -8,7 +8,7 @@ import { Timestamp, } from '@actual-app/crdt'; import { Database } from '@jlongster/sql.js'; -import LRU from 'lru-cache'; +import { LRUCache } from 'lru-cache'; import { v4 as uuidv4 } from 'uuid'; import * as fs from '../../platform/server/fs'; @@ -132,7 +132,7 @@ export function execQuery(sql: string) { // This manages an LRU cache of prepared query statements. This is // only needed in hot spots when you are running lots of queries. -let _queryCache = new LRU({ max: 100 }); +let _queryCache = new LRUCache({ max: 100 }); export function cache(sql: string) { const cached = _queryCache.get(sql); if (cached) { @@ -145,7 +145,7 @@ export function cache(sql: string) { } function resetQueryCache() { - _queryCache = new LRU({ max: 100 }); + _queryCache = new LRUCache({ max: 100 }); } export function transaction(fn: () => void) { diff --git a/packages/loot-core/src/server/main.ts b/packages/loot-core/src/server/main.ts index 9145391c534..93b1a18c9b8 100644 --- a/packages/loot-core/src/server/main.ts +++ b/packages/loot-core/src/server/main.ts @@ -528,7 +528,10 @@ handlers['getCell'] = async function ({ sheetName, name }) { }; handlers['getCells'] = async function ({ names }) { - return names.map(name => ({ value: sheet.get()._getNode(name).value })); + return names.map(name => { + const node = sheet.get()._getNode(name); + return { name: node.name, value: node.value }; + }); }; handlers['getCellNamesInSheet'] = async function ({ sheetName }) { diff --git a/packages/loot-core/src/types/server-handlers.d.ts b/packages/loot-core/src/types/server-handlers.d.ts index 71dd1ec9cbe..f4ad389278e 100644 --- a/packages/loot-core/src/types/server-handlers.d.ts +++ b/packages/loot-core/src/types/server-handlers.d.ts @@ -130,18 +130,24 @@ export interface ServerHandlers { applySpecialCases?: boolean; }) => Promise<{ filters: unknown[] }>; - getCell: (arg: { - sheetName; - name; - }) => Promise; + getCell: (arg: { sheetName; name }) => Promise<{ + name: SpreadsheetNode['name']; + value: SpreadsheetNode['value']; + }>; - getCells: (arg: { names }) => Promise; + getCells: (arg: { + names; + }) => Promise< + Array<{ name: SpreadsheetNode['name']; value?: SpreadsheetNode['value'] }> + >; - getCellNamesInSheet: (arg: { sheetName }) => Promise; + getCellNamesInSheet: (arg: { + sheetName; + }) => Promise>; debugCell: (arg: { sheetName; name }) => Promise; - 'create-query': (arg: { sheetName; name; query }) => Promise; + 'create-query': (arg: { sheetName; name; query }) => Promise<'ok'>; // eslint-disable-next-line @typescript-eslint/no-explicit-any query: (query: Query) => Promise<{ data: any; dependencies: string[] }>; diff --git a/packages/loot-core/webpack/webpack.browser.config.js b/packages/loot-core/webpack/webpack.browser.config.js index 096633ea009..0b9aba5e5c3 100644 --- a/packages/loot-core/webpack/webpack.browser.config.js +++ b/packages/loot-core/webpack/webpack.browser.config.js @@ -62,6 +62,12 @@ module.exports = { test: /\.pegjs$/, use: { loader: path.resolve(__dirname, '../peg-loader.js') }, }, + { + test: /\.m?js/, + resolve: { + fullySpecified: false, + }, + }, ], }, optimization: { diff --git a/yarn.lock b/yarn.lock index 0c6ecd67aee..f0e30144b31 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16086,7 +16086,7 @@ __metadata: i18next: "npm:^23.11.5" jest: "npm:^27.5.1" jsverify: "npm:^0.8.4" - lru-cache: "npm:^5.1.1" + lru-cache: "npm:^11.0.2" md5: "npm:^2.3.0" memfs: "npm:3.5.3" memoize-one: "npm:^6.0.0" @@ -16145,6 +16145,13 @@ __metadata: languageName: node linkType: hard +"lru-cache@npm:^11.0.2": + version: 11.0.2 + resolution: "lru-cache@npm:11.0.2" + checksum: 10/25fcb66e9d91eaf17227c6abfe526a7bed5903de74f93bfde380eb8a13410c5e8d3f14fe447293f3f322a7493adf6f9f015c6f1df7a235ff24ec30f366e1c058 + languageName: node + linkType: hard + "lru-cache@npm:^4.0.1": version: 4.1.5 resolution: "lru-cache@npm:4.1.5" From 0ac603283364eb387fbaa05a089b3947811a5061 Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Wed, 19 Feb 2025 14:15:55 -0800 Subject: [PATCH 2/4] Cleanup --- packages/loot-core/src/client/SpreadsheetProvider.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/loot-core/src/client/SpreadsheetProvider.tsx b/packages/loot-core/src/client/SpreadsheetProvider.tsx index 52ecfc77161..7913acae576 100644 --- a/packages/loot-core/src/client/SpreadsheetProvider.tsx +++ b/packages/loot-core/src/client/SpreadsheetProvider.tsx @@ -35,6 +35,8 @@ type CellCache = { [name: string]: Promise | null }; type CellObserverCallback = (node: CellCacheValue) => void; type CellObservers = { [name: string]: CellObserverCallback[] }; +const GLOBAL_SHEET_NAME = '__global'; + function makeSpreadsheet() { const cellObservers: CellObservers = {}; const LRUValueCache = new LRUCache({ max: 1200 }); @@ -42,14 +44,14 @@ function makeSpreadsheet() { let observersDisabled = false; class Spreadsheet { - observeCell(name: string, cb: CellObserverCallback): () => void { + observeCell(name: string, callback: CellObserverCallback): () => void { if (!cellObservers[name]) { cellObservers[name] = []; } - cellObservers[name].push(cb); + cellObservers[name].push(callback); return () => { - cellObservers[name] = cellObservers[name].filter(x => x !== cb); + cellObservers[name] = cellObservers[name].filter(cb => cb !== callback); if (cellObservers[name].length === 0) { cellCache[name] = null; @@ -86,7 +88,7 @@ function makeSpreadsheet() { } bind( - sheetName: string = '__global', + sheetName: string = GLOBAL_SHEET_NAME, binding: Binding, callback: CellObserverCallback, ): () => void { From 4f960d32f7282870d077fbbdc6dabc6a08c7cc20 Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Wed, 19 Feb 2025 14:17:28 -0800 Subject: [PATCH 3/4] Release notes --- upcoming-release-notes/4409.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 upcoming-release-notes/4409.md diff --git a/upcoming-release-notes/4409.md b/upcoming-release-notes/4409.md new file mode 100644 index 00000000000..8d46a62893e --- /dev/null +++ b/upcoming-release-notes/4409.md @@ -0,0 +1,6 @@ +--- +category: Maintenance +authors: [MikesGlitch] +--- + +[TypeScript] Add types for SpreadsheetProvider From 5dd6e197c46cb6d890b3a80ff865ba9bf0035a2d Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Wed, 19 Feb 2025 15:21:16 -0800 Subject: [PATCH 4/4] Update upcoming-release-notes/4409.md Co-authored-by: Michael Clark <5285928+MikesGlitch@users.noreply.github.com> --- upcoming-release-notes/4409.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/upcoming-release-notes/4409.md b/upcoming-release-notes/4409.md index 8d46a62893e..a06aae638a8 100644 --- a/upcoming-release-notes/4409.md +++ b/upcoming-release-notes/4409.md @@ -1,6 +1,6 @@ --- category: Maintenance -authors: [MikesGlitch] +authors: [joel-jeremy] --- [TypeScript] Add types for SpreadsheetProvider