From 75f26d9c184269d76ef1994a8cc1d062f4350daf Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Tue, 28 Apr 2026 17:59:12 -0400 Subject: [PATCH] chore: enable typescript/no-misused-spread lint rule Replace spread operations on class instances and iterables with safer patterns (Object.fromEntries, Object.assign, explicit field copies, or toJSON()) to preserve prototypes and avoid silently dropping data. Notable changes: - Sequelize \`where\` clauses now use \`Op.and\` instead of spreading WhereOptions (which may be a Literal/Fn instance). - HeadersInit values are funneled through a Headers instance before use rather than being spread directly. - Model.toJSON() is used to convert MobX model instances into plain objects before re-spreading. Co-Authored-By: Claude Opus 4.7 --- .oxlintrc.json | 1 + app/editor/components/SuggestionsMenu.tsx | 5 +- app/editor/index.tsx | 17 ++-- app/index.tsx | 1 + app/models/AuthenticationProvider.ts | 5 +- app/models/Document.ts | 2 +- app/models/base/Model.ts | 2 +- app/utils/ApiClient.ts | 14 +-- server/models/Document.ts | 7 +- server/models/FileOperation.ts | 14 +-- server/models/Team.ts | 4 +- server/models/base/Model.ts | 6 +- server/queues/tasks/base/CronTask.ts | 3 +- server/storage/files/BaseStorage.ts | 14 ++- server/utils/fetch.ts | 19 ++-- shared/editor/lib/ChangesetHelper.ts | 5 +- shared/editor/lib/ExtensionManager.ts | 101 +++++++++------------- 17 files changed, 114 insertions(+), 106 deletions(-) diff --git a/.oxlintrc.json b/.oxlintrc.json index 7f2d7660d6..d5e24afd1b 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -87,6 +87,7 @@ "import/no-named-as-default": "off", "import/no-named-as-default-member": "off", "typescript/consistent-type-imports": "error", + "typescript/no-misused-spread": "error", "no-unused-vars": [ "error", { diff --git a/app/editor/components/SuggestionsMenu.tsx b/app/editor/components/SuggestionsMenu.tsx index 2f812cc642..b7081624a9 100644 --- a/app/editor/components/SuggestionsMenu.tsx +++ b/app/editor/components/SuggestionsMenu.tsx @@ -411,10 +411,7 @@ function SuggestionsMenu(props: Props) { for (const embed of embeds) { if (embed.title && embed.visible !== false && !embed.disabled) { embedItems.push( - new EmbedDescriptor({ - ...embed, - name: "embed", - }) + new EmbedDescriptor(Object.assign({}, embed, { name: "embed" })) ); } } diff --git a/app/editor/index.tsx b/app/editor/index.tsx index 563cac22f9..68d0f8d60b 100644 --- a/app/editor/index.tsx +++ b/app/editor/index.tsx @@ -369,12 +369,12 @@ export class Editor extends React.PureComponent< } private createNodeViews() { - return this.extensions.extensions - .filter((extension: ReactNode) => extension.component) - .reduce( - (nodeViews, extension: ReactNode) => ({ - ...nodeViews, - [extension.name]: ( + return Object.fromEntries( + this.extensions.extensions + .filter((extension: ReactNode) => extension.component) + .map((extension: ReactNode) => [ + extension.name, + ( node: ProsemirrorNode, view: EditorView, getPos: () => number, @@ -388,9 +388,8 @@ export class Editor extends React.PureComponent< getPos, decorations, }), - }), - {} - ); + ]) + ) as { [name: string]: NodeViewConstructor }; } private createCommands() { diff --git a/app/index.tsx b/app/index.tsx index f8104b3d36..13b4766a52 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -57,6 +57,7 @@ if (element) { const App = () => ( + {/* oxlint-disable-next-line typescript/no-misused-spread */} diff --git a/app/models/AuthenticationProvider.ts b/app/models/AuthenticationProvider.ts index cdc68ec6c5..b3c52c6ac9 100644 --- a/app/models/AuthenticationProvider.ts +++ b/app/models/AuthenticationProvider.ts @@ -37,8 +37,11 @@ class AuthenticationProvider extends Model { @AfterDelete static afterDelete(model: AuthenticationProvider) { // Restore a placeholder record to allow re-connection + const data = model.toJSON() as Partial & { + id: string; + }; return (model.store as AuthenticationProvidersStore).add({ - ...model, + ...data, isEnabled: false, isConnected: false, }); diff --git a/app/models/Document.ts b/app/models/Document.ts index ef361602de..8cd7fde376 100644 --- a/app/models/Document.ts +++ b/app/models/Document.ts @@ -570,7 +570,7 @@ export default class Document extends ArchivableModel implements Searchable { ); // if saving is successful set the new values on the model itself - set(this, { ...params, ...model }); + set(this, { ...params, ...model.toJSON() }); this.persistedAttributes = this.toAPI(); diff --git a/app/models/base/Model.ts b/app/models/base/Model.ts index 67f80f2037..005af4463b 100644 --- a/app/models/base/Model.ts +++ b/app/models/base/Model.ts @@ -120,7 +120,7 @@ export default abstract class Model { ); // if saving is successful set the new values on the model itself - this.updateData({ ...params, ...model }); + this.updateData({ ...params, ...model.toJSON() }); if (isNew) { LifecycleManager.executeHooks(this.constructor, "afterCreate", this); diff --git a/app/utils/ApiClient.ts b/app/utils/ApiClient.ts index 45e1b4fb35..55522f5d7d 100644 --- a/app/utils/ApiClient.ts +++ b/app/utils/ApiClient.ts @@ -70,10 +70,14 @@ class ApiClient { if (this.shareId) { // add to data - data = { - ...(data || {}), - shareId: this.shareId, - }; + if (data instanceof FormData) { + data.append("shareId", this.shareId); + } else { + data = { + ...(data || {}), + shareId: this.shareId, + }; + } } if (method === "GET") { @@ -285,7 +289,7 @@ class ApiClient { post = ( path: string, - data?: JSONObject | FormData | undefined, + data?: JSONObject | FormData , options?: FetchOptions ): Promise => { if (data instanceof FormData) { diff --git a/server/models/Document.ts b/server/models/Document.ts index eacaa28f2b..86e7f9e1fc 100644 --- a/server/models/Document.ts +++ b/server/models/Document.ts @@ -923,10 +923,9 @@ class Document extends ArchivableModel< collaborators = async (options?: FindOptions): Promise => await User.findAll({ ...options, - where: { - ...options?.where, - id: this.collaboratorIds, - }, + where: options?.where + ? { [Op.and]: [options.where, { id: this.collaboratorIds }] } + : { id: this.collaboratorIds }, }); /** diff --git a/server/models/FileOperation.ts b/server/models/FileOperation.ts index 1431143092..4a980b275b 100644 --- a/server/models/FileOperation.ts +++ b/server/models/FileOperation.ts @@ -169,11 +169,15 @@ class FileOperation extends ParanoidModel< ): Promise { return this.count({ where: { - teamId, - createdAt: { - [Op.gt]: startDate, - }, - ...where, + [Op.and]: [ + { + teamId, + createdAt: { + [Op.gt]: startDate, + }, + }, + where, + ], }, }); } diff --git a/server/models/Team.ts b/server/models/Team.ts index cb1fb192e1..79e8306d5a 100644 --- a/server/models/Team.ts +++ b/server/models/Team.ts @@ -560,7 +560,9 @@ class Team extends ParanoidModel< return this.findOne({ ...options, - where: { ...options?.where, domain: normalized }, + where: options?.where + ? { [Op.and]: [options.where, { domain: normalized }] } + : { domain: normalized }, }); } diff --git a/server/models/base/Model.ts b/server/models/base/Model.ts index 608ab944ac..b25faa52a1 100644 --- a/server/models/base/Model.ts +++ b/server/models/base/Model.ts @@ -159,7 +159,11 @@ class Model< // Record not found, try to create it try { const created = await this.create( - { ...options.defaults, ...options.where } as CreationAttributes, + Object.assign( + {}, + options.defaults, + options.where + ) as CreationAttributes, { ...hookContext, transaction } ); return [created as M, true]; diff --git a/server/queues/tasks/base/CronTask.ts b/server/queues/tasks/base/CronTask.ts index 635f59c8f9..71d421c498 100644 --- a/server/queues/tasks/base/CronTask.ts +++ b/server/queues/tasks/base/CronTask.ts @@ -1,4 +1,3 @@ -import type { WhereOptions } from "sequelize"; import { Op } from "sequelize"; import { Minute } from "@shared/utils/time"; import { BaseTask } from "./BaseTask"; @@ -178,7 +177,7 @@ export abstract class CronTask extends BaseTask { protected getPartitionWhereClause( idField: string, partitionInfo: PartitionInfo | undefined - ): WhereOptions { + ): Record { if (!partitionInfo) { return {}; } diff --git a/server/storage/files/BaseStorage.ts b/server/storage/files/BaseStorage.ts index ae4fc02c18..7bcba4db82 100644 --- a/server/storage/files/BaseStorage.ts +++ b/server/storage/files/BaseStorage.ts @@ -7,8 +7,8 @@ import { isBase64Url, isInternalUrl } from "@shared/utils/urls"; import { Week } from "@shared/utils/time"; import env from "@server/env"; import Logger from "@server/logging/Logger"; -import type { RequestInit } from "@server/utils/fetch"; -import fetch, { chromeUserAgent } from "@server/utils/fetch"; +import type { HeadersInit, RequestInit } from "@server/utils/fetch"; +import fetch, { Headers, chromeUserAgent } from "@server/utils/fetch"; import type { AppContext } from "@server/types"; export default abstract class BaseStorage { @@ -189,10 +189,16 @@ export default abstract class BaseStorage { } } else { try { - const headers = { + const headers: Record = { "User-Agent": chromeUserAgent, - ...init?.headers, }; + if (init?.headers) { + for (const [name, value] of new Headers( + init.headers as HeadersInit + ).entries()) { + headers[name] = value; + } + } const initWithoutHeaders = omit(init, ["headers"]); const res = await fetch(url, { diff --git a/server/utils/fetch.ts b/server/utils/fetch.ts index 206db26113..661eb2ec33 100644 --- a/server/utils/fetch.ts +++ b/server/utils/fetch.ts @@ -1,7 +1,11 @@ /* oxlint-disable no-restricted-imports, react/rules-of-hooks */ import type http from "node:http"; import type https from "node:https"; -import nodeFetch, { type RequestInit, type Response } from "node-fetch"; +import nodeFetch, { + Headers, + type RequestInit, + type Response, +} from "node-fetch"; import { getProxyForUrl } from "proxy-from-env"; import tunnelAgent, { type TunnelAgent } from "tunnel-agent"; import { useAgent as useFilteringAgent } from "request-filtering-agent"; @@ -23,7 +27,8 @@ const DefaultOptions = { maxCachedSessions: 500, }; -export type { RequestInit } from "node-fetch"; +export type { HeadersInit, RequestInit } from "node-fetch"; +export { Headers } from "node-fetch"; /** * Default user agent string for outgoing requests. @@ -72,13 +77,15 @@ export default async function fetch( const signal = abortController?.signal || rest.signal; + const headers = new Headers(rest?.headers); + if (!headers.has("User-Agent")) { + headers.set("User-Agent", outlineUserAgent); + } + try { const response = await nodeFetch(url, { ...rest, - headers: { - "User-Agent": outlineUserAgent, - ...rest?.headers, - }, + headers, signal, agent: buildAgent(url, { signal, allowPrivateIPAddress }), }); diff --git a/shared/editor/lib/ChangesetHelper.ts b/shared/editor/lib/ChangesetHelper.ts index b3542f3aae..14d2c94940 100644 --- a/shared/editor/lib/ChangesetHelper.ts +++ b/shared/editor/lib/ChangesetHelper.ts @@ -253,7 +253,10 @@ export class ChangesetHelper { } return { - ...change, + fromA: change.fromA, + toA: change.toA, + fromB: change.fromB, + toB: change.toB, deleted: change.deleted.filter( (_, index) => !matchedDeletionIndices.has(index) ), diff --git a/shared/editor/lib/ExtensionManager.ts b/shared/editor/lib/ExtensionManager.ts index caf3e7f475..68deb55c38 100644 --- a/shared/editor/lib/ExtensionManager.ts +++ b/shared/editor/lib/ExtensionManager.ts @@ -1,7 +1,7 @@ import type { Options, PluginSimple } from "markdown-it"; import { observer } from "mobx-react"; import { keymap } from "prosemirror-keymap"; -import { MarkdownParser } from "prosemirror-markdown"; +import { MarkdownParser, type ParseSpec } from "prosemirror-markdown"; import type { MarkSpec, NodeSpec, Schema } from "prosemirror-model"; import type { EditorView } from "prosemirror-view"; import type { Primitive } from "utility-types"; @@ -62,27 +62,21 @@ export default class ExtensionManager { } get widgets() { - return this.extensions - .filter((extension) => extension.widget({ rtl: false, readOnly: false })) - .reduce( - (memo, node: Node) => ({ - ...memo, - [node.name]: observer(node.widget as any), - }), - {} - ); + return Object.fromEntries( + this.extensions + .filter((extension) => + extension.widget({ rtl: false, readOnly: false }) + ) + .map((node: Node) => [node.name, observer(node.widget as any)]) + ); } get nodes() { - const nodes: Record = this.extensions - .filter((extension) => extension.type === "node") - .reduce( - (memo, node: Node) => ({ - ...memo, - [node.name]: node.schema, - }), - {} - ); + const nodes: Record = Object.fromEntries( + this.extensions + .filter((extension) => extension.type === "node") + .map((node: Node) => [node.name, node.schema]) + ); for (const i in nodes) { const { marks } = nodes[i]; @@ -100,15 +94,11 @@ export default class ExtensionManager { } get marks() { - const marks: Record = this.extensions - .filter((extension) => extension.type === "mark") - .reduce( - (memo, mark: Mark) => ({ - ...memo, - [mark.name]: mark.schema, - }), - {} - ); + const marks: Record = Object.fromEntries( + this.extensions + .filter((extension) => extension.type === "mark") + .map((mark: Mark) => [mark.name, mark.schema]) + ); for (const i in marks) { const { excludes } = marks[i]; @@ -126,25 +116,17 @@ export default class ExtensionManager { } serializer() { - const nodes = this.extensions - .filter((extension) => extension.type === "node") - .reduce( - (memo, extension: Node) => ({ - ...memo, - [extension.name]: extension.toMarkdown, - }), - {} - ); + const nodes = Object.fromEntries( + this.extensions + .filter((extension) => extension.type === "node") + .map((extension: Node) => [extension.name, extension.toMarkdown]) + ); - const marks = this.extensions - .filter((extension) => extension.type === "mark") - .reduce( - (memo, extension: Mark) => ({ - ...memo, - [extension.name]: extension.toMarkdown, - }), - {} - ); + const marks = Object.fromEntries( + this.extensions + .filter((extension) => extension.type === "mark") + .map((extension: Mark) => [extension.name, extension.toMarkdown]) + ); return new MarkdownSerializer(nodes, marks); } @@ -158,21 +140,18 @@ export default class ExtensionManager { rules?: Options; plugins?: PluginSimple[]; }): MarkdownParser { - const tokens = this.extensions - .filter( - (extension) => extension.type === "mark" || extension.type === "node" - ) - .reduce((nodes, extension: Node | Mark) => { - const parseSpec = extension.parseMarkdown(); - if (!parseSpec) { - return nodes; - } - - return { - ...nodes, - [extension.markdownToken || extension.name]: parseSpec, - }; - }, {}); + const tokens: Record = {}; + for (const extension of this.extensions) { + if (extension.type !== "mark" && extension.type !== "node") { + continue; + } + const node = extension as Node | Mark; + const parseSpec = node.parseMarkdown(); + if (!parseSpec) { + continue; + } + tokens[node.markdownToken || node.name] = parseSpec; + } return new MarkdownParser( schema,