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 <noreply@anthropic.com>
This commit is contained in:
Tom Moor
2026-04-28 17:59:12 -04:00
parent 816a474a46
commit 75f26d9c18
17 changed files with 114 additions and 106 deletions
+1
View File
@@ -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",
{
+1 -4
View File
@@ -411,10 +411,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
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" }))
);
}
}
+8 -9
View File
@@ -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() {
+1
View File
@@ -57,6 +57,7 @@ if (element) {
const App = () => (
<StrictMode>
<HelmetProvider>
{/* oxlint-disable-next-line typescript/no-misused-spread */}
<Provider {...stores}>
<Analytics>
<Router history={history}>
+4 -1
View File
@@ -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<AuthenticationProvider> & {
id: string;
};
return (model.store as AuthenticationProvidersStore).add({
...model,
...data,
isEnabled: false,
isConnected: false,
});
+1 -1
View File
@@ -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();
+1 -1
View File
@@ -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);
+9 -5
View File
@@ -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 = <T = any>(
path: string,
data?: JSONObject | FormData | undefined,
data?: JSONObject | FormData ,
options?: FetchOptions
): Promise<T> => {
if (data instanceof FormData) {
+3 -4
View File
@@ -923,10 +923,9 @@ class Document extends ArchivableModel<
collaborators = async (options?: FindOptions<User>): Promise<User[]> =>
await User.findAll({
...options,
where: {
...options?.where,
id: this.collaboratorIds,
},
where: options?.where
? { [Op.and]: [options.where, { id: this.collaboratorIds }] }
: { id: this.collaboratorIds },
});
/**
+9 -5
View File
@@ -169,11 +169,15 @@ class FileOperation extends ParanoidModel<
): Promise<number> {
return this.count({
where: {
teamId,
createdAt: {
[Op.gt]: startDate,
},
...where,
[Op.and]: [
{
teamId,
createdAt: {
[Op.gt]: startDate,
},
},
where,
],
},
});
}
+3 -1
View File
@@ -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 },
});
}
+5 -1
View File
@@ -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<M>,
Object.assign(
{},
options.defaults,
options.where
) as CreationAttributes<M>,
{ ...hookContext, transaction }
);
return [created as M, true];
+1 -2
View File
@@ -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<Props> {
protected getPartitionWhereClause(
idField: string,
partitionInfo: PartitionInfo | undefined
): WhereOptions {
): Record<string, unknown> {
if (!partitionInfo) {
return {};
}
+10 -4
View File
@@ -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<string, string | string[]> = {
"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, {
+13 -6
View File
@@ -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 }),
});
+4 -1
View File
@@ -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)
),
+40 -61
View File
@@ -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<string, NodeSpec> = this.extensions
.filter((extension) => extension.type === "node")
.reduce(
(memo, node: Node) => ({
...memo,
[node.name]: node.schema,
}),
{}
);
const nodes: Record<string, NodeSpec> = 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<string, MarkSpec> = this.extensions
.filter((extension) => extension.type === "mark")
.reduce(
(memo, mark: Mark) => ({
...memo,
[mark.name]: mark.schema,
}),
{}
);
const marks: Record<string, MarkSpec> = 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<string, ParseSpec> = {};
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,