diff --git a/package-lock.json b/package-lock.json index 5775c46ee..0b50a9fb0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,8 +24,6 @@ "@testing-library/jest-dom": "^5.14.1", "@testing-library/react": "^14.0.0", "@testing-library/user-event": "^14.4.3", - "@types/lodash.debounce": "^4.0.6", - "@types/lodash.sortby": "^4.7.6", "@types/lunr": "^2.3.4", "@types/marked": "^4.0.1", "@types/node": "^24.12.0", @@ -36,8 +34,6 @@ "dompurify": "^3.2.5", "file-saver": "^2.0.5", "framer-motion": "^10.2.4", - "lodash.debounce": "^4.0.8", - "lodash.sortby": "^4.7.0", "lunr": "^2.3.9", "lunr-languages": "^1.14.0", "lzma": "^2.3.2", @@ -5110,30 +5106,6 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@types/lodash": { - "version": "4.17.24", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", - "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", - "license": "MIT" - }, - "node_modules/@types/lodash.debounce": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@types/lodash.debounce/-/lodash.debounce-4.0.9.tgz", - "integrity": "sha512-Ma5JcgTREwpLRwMM+XwBR7DaWe96nC38uCBDFKZWbNKD+osjVzdpnUSwBcqCptrp16sSOLBAUb50Car5I0TCsQ==", - "license": "MIT", - "dependencies": { - "@types/lodash": "*" - } - }, - "node_modules/@types/lodash.sortby": { - "version": "4.7.9", - "resolved": "https://registry.npmjs.org/@types/lodash.sortby/-/lodash.sortby-4.7.9.tgz", - "integrity": "sha512-PDmjHnOlndLS59GofH0pnxIs+n9i4CWeXGErSB5JyNFHu2cmvW6mQOaUKjG8EDPkni14IgF8NsRW8bKvFzTm9A==", - "license": "MIT", - "dependencies": { - "@types/lodash": "*" - } - }, "node_modules/@types/lunr": { "version": "2.3.7", "resolved": "https://registry.npmjs.org/@types/lunr/-/lunr-2.3.7.tgz", @@ -9648,6 +9620,7 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "dev": true, "license": "MIT" }, "node_modules/lodash.merge": { @@ -9660,6 +9633,7 @@ "version": "4.7.0", "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==", + "dev": true, "license": "MIT" }, "node_modules/lodash.truncate": { diff --git a/package.json b/package.json index 224118a10..dc904a8f3 100644 --- a/package.json +++ b/package.json @@ -28,8 +28,6 @@ "@testing-library/jest-dom": "^5.14.1", "@testing-library/react": "^14.0.0", "@testing-library/user-event": "^14.4.3", - "@types/lodash.debounce": "^4.0.6", - "@types/lodash.sortby": "^4.7.6", "@types/lunr": "^2.3.4", "@types/marked": "^4.0.1", "@types/node": "^24.12.0", @@ -40,8 +38,6 @@ "dompurify": "^3.2.5", "file-saver": "^2.0.5", "framer-motion": "^10.2.4", - "lodash.debounce": "^4.0.8", - "lodash.sortby": "^4.7.0", "lunr": "^2.3.9", "lunr-languages": "^1.14.0", "lzma": "^2.3.2", diff --git a/src/common/debounce-util.test.ts b/src/common/debounce-util.test.ts new file mode 100644 index 000000000..aa8e049c5 --- /dev/null +++ b/src/common/debounce-util.test.ts @@ -0,0 +1,50 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { vi } from "vitest"; +import { debounce } from "./debounce-util"; + +describe("debounce", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("calls the function once with the latest arguments after the wait", () => { + const fn = vi.fn(); + const debounced = debounce(fn, 100); + debounced("first"); + debounced("second"); + vi.advanceTimersByTime(99); + expect(fn).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(fn).toHaveBeenCalledTimes(1); + expect(fn).toHaveBeenCalledWith("second"); + }); + + it("restarts the wait on each call", () => { + const fn = vi.fn(); + const debounced = debounce(fn, 100); + debounced(); + vi.advanceTimersByTime(60); + debounced(); + vi.advanceTimersByTime(60); + expect(fn).not.toHaveBeenCalled(); + vi.advanceTimersByTime(40); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it("fires again for calls after a completed wait", () => { + const fn = vi.fn(); + const debounced = debounce(fn, 100); + debounced(); + vi.advanceTimersByTime(100); + debounced(); + vi.advanceTimersByTime(100); + expect(fn).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/common/debounce-util.ts b/src/common/debounce-util.ts new file mode 100644 index 000000000..7dc9cdb3a --- /dev/null +++ b/src/common/debounce-util.ts @@ -0,0 +1,20 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ + +/** + * Returns a function that delays calling fn until waitMs have elapsed + * since the last call, invoking it with the most recent arguments. + */ +export const debounce = ( + fn: (...args: A) => void, + waitMs: number +): ((...args: A) => void) => { + let timeout: ReturnType | undefined; + return (...args: A) => { + clearTimeout(timeout); + timeout = setTimeout(() => fn(...args), waitMs); + }; +}; diff --git a/src/common/sort-util.test.ts b/src/common/sort-util.test.ts new file mode 100644 index 000000000..0ca64b5c9 --- /dev/null +++ b/src/common/sort-util.test.ts @@ -0,0 +1,42 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { sortBy } from "./sort-util"; + +describe("sortBy", () => { + it("sorts ascending by a single iteratee", () => { + expect(sortBy(["banana", "apple", "cherry"], (s) => s)).toEqual([ + "apple", + "banana", + "cherry", + ]); + }); + + it("uses later iteratees as tie-breakers", () => { + const files = [ + { name: "b.py", main: false }, + { name: "main.py", main: true }, + { name: "a.py", main: false }, + ]; + expect( + sortBy( + files, + (f) => !f.main, + (f) => f.name + ).map((f) => f.name) + ).toEqual(["main.py", "a.py", "b.py"]); + }); + + it("is stable and does not mutate its input", () => { + const input = [ + { key: 1, id: "first" }, + { key: 0, id: "a" }, + { key: 1, id: "second" }, + ]; + const result = sortBy(input, (x) => x.key); + expect(result.map((x) => x.id)).toEqual(["a", "first", "second"]); + expect(input.map((x) => x.id)).toEqual(["first", "a", "second"]); + }); +}); diff --git a/src/common/sort-util.ts b/src/common/sort-util.ts new file mode 100644 index 000000000..8e92078f6 --- /dev/null +++ b/src/common/sort-util.ts @@ -0,0 +1,30 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ + +type Iteratee = (item: T) => string | number | boolean; + +/** + * Returns a copy of the array sorted ascending by the iteratees, + * comparing by the first iteratee and using the others as tie-breakers. + * The sort is stable. + */ +export const sortBy = ( + items: readonly T[], + ...iteratees: Iteratee[] +): T[] => + [...items].sort((a, b) => { + for (const iteratee of iteratees) { + const left = iteratee(a); + const right = iteratee(b); + if (left < right) { + return -1; + } + if (left > right) { + return 1; + } + } + return 0; + }); diff --git a/src/documentation/api/ApiDocumentation.tsx b/src/documentation/api/ApiDocumentation.tsx index 32f0d10ea..ef6db311f 100644 --- a/src/documentation/api/ApiDocumentation.tsx +++ b/src/documentation/api/ApiDocumentation.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: MIT */ import { Divider, Link, List, ListItem } from "@microbit/ui"; -import sortBy from "lodash.sortby"; +import { sortBy } from "../../common/sort-util"; import { ReactNode, useCallback } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import { SystemStyleObject } from "styled-system/types"; diff --git a/src/documentation/search/extracts.ts b/src/documentation/search/extracts.ts index a70d1c2a7..cb3b83d19 100644 --- a/src/documentation/search/extracts.ts +++ b/src/documentation/search/extracts.ts @@ -3,16 +3,13 @@ * * SPDX-License-Identifier: MIT */ +import { sortBy } from "../../common/sort-util"; import { Extract } from "./common"; export type Position = [number, number]; -// Avoid lodash in the worker -export const sortByStart = (positions: Position[]): Position[] => { - const copy = [...positions]; - copy.sort((a, b) => (a[0] > b[0] ? 1 : a[0] < b[0] ? -1 : 0)); - return copy; -}; +export const sortByStart = (positions: Position[]): Position[] => + sortBy(positions, (p) => p[0]); /** * Return text or matches covering the string from start to end. diff --git a/src/documentation/search/search-hooks.tsx b/src/documentation/search/search-hooks.tsx index d482c44cc..0fc140ed1 100644 --- a/src/documentation/search/search-hooks.tsx +++ b/src/documentation/search/search-hooks.tsx @@ -3,7 +3,7 @@ * * SPDX-License-Identifier: MIT */ -import debounce from "lodash.debounce"; +import { debounce } from "../../common/debounce-util"; import { createContext, ReactNode, diff --git a/src/editor/codemirror/language-server/autocompletion.ts b/src/editor/codemirror/language-server/autocompletion.ts index fd3f04615..14a9e6d66 100644 --- a/src/editor/codemirror/language-server/autocompletion.ts +++ b/src/editor/codemirror/language-server/autocompletion.ts @@ -11,7 +11,7 @@ import { insertBracket, } from "@codemirror/autocomplete"; import { TransactionSpec } from "@codemirror/state"; -import sortBy from "lodash.sortby"; +import { sortBy } from "../../../common/sort-util"; import { IntlShape } from "react-intl"; import * as LSP from "vscode-languageserver-protocol"; import { diff --git a/src/fs/fs.ts b/src/fs/fs.ts index e2fa57e68..15ef769c9 100644 --- a/src/fs/fs.ts +++ b/src/fs/fs.ts @@ -9,7 +9,7 @@ import { MicropythonFsHex, } from "@microbit/microbit-fs"; import { fromByteArray, toByteArray } from "base64-js"; -import sortBy from "lodash.sortby"; +import { sortBy } from "../common/sort-util"; import { lineNumFromUint8Array } from "../common/text-util"; import { FlashDataError, BoardVersion } from "@microbit/microbit-connection"; import { Logging } from "../logging/logging"; diff --git a/src/fs/host.ts b/src/fs/host.ts index b49485cf6..0ad04cb1b 100644 --- a/src/fs/host.ts +++ b/src/fs/host.ts @@ -3,7 +3,7 @@ * * SPDX-License-Identifier: MIT */ -import debounce from "lodash.debounce"; +import { debounce } from "../common/debounce-util"; import { FileSystem, VersionAction, MAIN_FILE } from "./fs"; import { Logging } from "../logging/logging"; import { diff --git a/src/project/ChooseMainScriptQuestion.tsx b/src/project/ChooseMainScriptQuestion.tsx index 78480575d..2a3ea98c8 100644 --- a/src/project/ChooseMainScriptQuestion.tsx +++ b/src/project/ChooseMainScriptQuestion.tsx @@ -14,7 +14,7 @@ import { UnorderedList, } from "@microbit/ui"; import { ReactNode } from "react"; -import sortBy from "lodash.sortby"; +import { sortBy } from "../common/sort-util"; import { RiFileSettingsLine } from "react-icons/ri"; import { IntlShape, useIntl } from "react-intl"; import { HStack } from "styled-system/jsx";