From c54194f97a49076e91e1b6356a7220dc60b0f188 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sun, 15 Feb 2026 15:14:53 -0500 Subject: [PATCH] fix: Unobserved components (#11460) * fix: Unobserved components * mas * More missing observers --- app/components/Avatar/Avatar.tsx | 3 +- app/components/Breadcrumb.tsx | 3 +- .../DefaultCollectionInputSelect.tsx | 165 ++++++---- .../Sidebar/components/CollectionLink.tsx | 2 +- .../Sidebar/components/DocumentLink.tsx | 2 +- .../Sidebar/components/SharedWithMeLink.tsx | 2 +- .../Sidebar/components/SidebarButton.tsx | 75 +++-- .../Sidebar/components/SidebarLink.tsx | 10 +- .../Sidebar/components/StarredLink.tsx | 2 +- app/components/Table.tsx | 8 +- app/editor/components/EmojiMenu.tsx | 3 +- app/editor/components/MentionMenu.tsx | 310 +++++++++--------- app/index.tsx | 2 +- app/routes/settings.tsx | 5 +- .../Settings/components/EmojisTable.tsx | 4 +- .../Settings/components/GroupsTable.tsx | 5 +- .../Settings/components/MembersTable.tsx | 5 +- .../Settings/components/SharesTable.tsx | 5 +- app/stores/PinsStore.ts | 4 +- 19 files changed, 336 insertions(+), 279 deletions(-) diff --git a/app/components/Avatar/Avatar.tsx b/app/components/Avatar/Avatar.tsx index 4f113ecf9a..126a0a12a0 100644 --- a/app/components/Avatar/Avatar.tsx +++ b/app/components/Avatar/Avatar.tsx @@ -1,3 +1,4 @@ +import { observer } from "mobx-react"; import * as React from "react"; import styled from "styled-components"; import useBoolean from "~/hooks/useBoolean"; @@ -109,4 +110,4 @@ const Image = styled.img<{ size: number }>` height: ${(props) => props.size}px; `; -export default Avatar; +export default observer(Avatar); diff --git a/app/components/Breadcrumb.tsx b/app/components/Breadcrumb.tsx index 969a67b563..e1725d0961 100644 --- a/app/components/Breadcrumb.tsx +++ b/app/components/Breadcrumb.tsx @@ -1,4 +1,5 @@ import { GoToIcon } from "outline-icons"; +import { observer } from "mobx-react"; import * as React from "react"; import { Link } from "react-router-dom"; import styled from "styled-components"; @@ -121,4 +122,4 @@ const Item = styled(Link)<{ $highlight: boolean; $withIcon: boolean }>` } `; -export default React.forwardRef(Breadcrumb); +export default observer(React.forwardRef(Breadcrumb)); diff --git a/app/components/DefaultCollectionInputSelect.tsx b/app/components/DefaultCollectionInputSelect.tsx index f02686a67d..f3c6bff9c7 100644 --- a/app/components/DefaultCollectionInputSelect.tsx +++ b/app/components/DefaultCollectionInputSelect.tsx @@ -1,8 +1,15 @@ -import { HomeIcon } from "outline-icons"; +import { + CollectionIcon as CollectionIconComponent, + HomeIcon, + PrivateCollectionIcon, +} from "outline-icons"; +import { observer } from "mobx-react"; +import { getLuminance } from "polished"; import React, { useState } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; -import CollectionIcon from "~/components/Icons/CollectionIcon"; +import Icon from "@shared/components/Icon"; +import { colorPalette } from "@shared/utils/collections"; import type { Option } from "~/components/InputSelect"; import { InputSelect } from "~/components/InputSelect"; import useStores from "~/hooks/useStores"; @@ -12,74 +19,112 @@ type DefaultCollectionInputSelectProps = { defaultCollectionId: string | null; }; -const DefaultCollectionInputSelect = ({ - onSelectCollection, - defaultCollectionId, -}: DefaultCollectionInputSelectProps) => { - const { t } = useTranslation(); - const { collections } = useStores(); - const [fetching, setFetching] = useState(false); - const [fetchError, setFetchError] = useState(); +const DefaultCollectionInputSelect = observer( + ({ + onSelectCollection, + defaultCollectionId, + }: DefaultCollectionInputSelectProps) => { + const { t } = useTranslation(); + const { collections, ui } = useStores(); + const [fetching, setFetching] = useState(false); + const [fetchError, setFetchError] = useState(); - React.useEffect(() => { - async function fetchData() { - if (!collections.isLoaded && !fetching && !fetchError) { - try { - setFetching(true); - await collections.fetchPage({ - limit: 100, - }); - } catch (error) { - toast.error( - t("Collections could not be loaded, please reload the app") - ); - setFetchError(error); - } finally { - setFetching(false); + React.useEffect(() => { + async function fetchData() { + if (!collections.isLoaded && !fetching && !fetchError) { + try { + setFetching(true); + await collections.fetchPage({ + limit: 100, + }); + } catch (error) { + toast.error( + t("Collections could not be loaded, please reload the app") + ); + setFetchError(error); + } finally { + setFetching(false); + } } } - } - void fetchData(); - }, [fetchError, t, fetching, collections]); + void fetchData(); + }, [fetchError, t, fetching, collections]); - const options: Option[] = React.useMemo( - () => - collections.nonPrivate.reduce( - (acc, collection) => [ + if (fetching) { + return null; + } + + const isDark = ui.resolvedTheme === "dark"; + + // Eagerly resolve collection icon properties within this observer context + // to avoid MobX warnings when Radix Select clones elements for the trigger. + const options: Option[] = collections.nonPrivate.reduce( + (acc, collection) => { + const collectionIcon = collection.icon; + const rawColor = collection.color ?? colorPalette[0]; + + let icon: React.ReactElement; + if (!collectionIcon || collectionIcon === "collection") { + const color = + isDark && rawColor !== "currentColor" + ? getLuminance(rawColor) > 0.09 + ? rawColor + : "currentColor" + : rawColor; + const Component = collection.isPrivate + ? PrivateCollectionIcon + : CollectionIconComponent; + icon = ; + } else { + let color = rawColor; + if (color !== "currentColor") { + if (isDark) { + color = getLuminance(color) > 0.09 ? color : "currentColor"; + } else { + color = getLuminance(color) < 0.9 ? color : "currentColor"; + } + } + icon = ( + + ); + } + + return [ ...acc, { - type: "item", + type: "item" as const, label: collection.name, value: collection.id, - icon: , + icon, }, - ], - [ - { - type: "item", - label: t("Home"), - value: "home", - icon: , - }, - ] satisfies Option[] - ), - [collections.nonPrivate, t] - ); + ]; + }, + [ + { + type: "item", + label: t("Home"), + value: "home", + icon: , + }, + ] satisfies Option[] + ); - if (fetching) { - return null; + return ( + + ); } - - return ( - - ); -}; +); export default DefaultCollectionInputSelect; diff --git a/app/components/Sidebar/components/CollectionLink.tsx b/app/components/Sidebar/components/CollectionLink.tsx index 0e7ebb9d57..397cfa05fe 100644 --- a/app/components/Sidebar/components/CollectionLink.tsx +++ b/app/components/Sidebar/components/CollectionLink.tsx @@ -143,7 +143,7 @@ const CollectionLink: React.FC = ({ icon={ } - showActions={menuOpen} + $showActions={menuOpen} isActiveDrop={isOver && canDrop} isActive={( match, diff --git a/app/components/Sidebar/components/DocumentLink.tsx b/app/components/Sidebar/components/DocumentLink.tsx index b801a4ed81..710f7e5fa8 100644 --- a/app/components/Sidebar/components/DocumentLink.tsx +++ b/app/components/Sidebar/components/DocumentLink.tsx @@ -451,7 +451,7 @@ function InnerDocumentLink( isActiveDrop={isOverReparent && canDropToReparent} depth={depth} exact={false} - showActions={menuOpen} + $showActions={menuOpen} scrollIntoViewIfNeeded={sidebarContext === "collections"} isDraft={isDraft} ref={ref} diff --git a/app/components/Sidebar/components/SharedWithMeLink.tsx b/app/components/Sidebar/components/SharedWithMeLink.tsx index 63228f6c70..9704813936 100644 --- a/app/components/Sidebar/components/SharedWithMeLink.tsx +++ b/app/components/Sidebar/components/SharedWithMeLink.tsx @@ -170,7 +170,7 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) { NotificationEventType.AddUserToDocument ).length > 0 } - showActions={menuOpen} + $showActions={menuOpen} menu={ document && !isDragging ? ( diff --git a/app/components/Sidebar/components/SidebarButton.tsx b/app/components/Sidebar/components/SidebarButton.tsx index 3f50ccf9bd..c83f4f451d 100644 --- a/app/components/Sidebar/components/SidebarButton.tsx +++ b/app/components/Sidebar/components/SidebarButton.tsx @@ -1,4 +1,5 @@ import { MoreIcon } from "outline-icons"; +import { observer } from "mobx-react"; import * as React from "react"; import styled from "styled-components"; import { extraArea, hover, s } from "@shared/styles"; @@ -18,44 +19,46 @@ export type SidebarButtonProps = React.ComponentProps & { children?: React.ReactNode; }; -const SidebarButton = React.forwardRef( - function SidebarButton_( - { - position = "top", - showMoreMenu, - image, - title, - children, - onClick, - ...rest - }: SidebarButtonProps, - ref - ) { - return ( - - - {children} - - ); - } + + {children} + + ); + } + ) ); const StyledMoreIcon = styled(MoreIcon)` diff --git a/app/components/Sidebar/components/SidebarLink.tsx b/app/components/Sidebar/components/SidebarLink.tsx index 614a8a2c1c..f2e6110ae5 100644 --- a/app/components/Sidebar/components/SidebarLink.tsx +++ b/app/components/Sidebar/components/SidebarLink.tsx @@ -40,7 +40,7 @@ type Props = Omit & { /** Whether to show an unread badge indicator */ unreadBadge?: boolean; /** Whether to show action buttons on hover */ - showActions?: boolean; + $showActions?: boolean; /** Whether the link is disabled and non-interactive */ disabled?: boolean; /** Whether the link is currently active */ @@ -81,7 +81,7 @@ function SidebarLink( isActiveDrop, isDraft, menu, - showActions, + $showActions, exact, href, depth, @@ -183,7 +183,7 @@ function SidebarLink( {unreadBadge && } - {menu && {menu}} + {menu && {menu}} ); } @@ -205,9 +205,9 @@ const Content = styled.span` min-width: 0; `; -const Actions = styled(EventBoundary)<{ showActions?: boolean }>` +const Actions = styled(EventBoundary)<{ $showActions?: boolean }>` display: inline-flex; - visibility: ${(props) => (props.showActions ? "visible" : "hidden")}; + visibility: ${(props) => (props.$showActions ? "visible" : "hidden")}; position: absolute; top: 3px; right: 4px; diff --git a/app/components/Sidebar/components/StarredLink.tsx b/app/components/Sidebar/components/StarredLink.tsx index 62655e1e3a..6e88c1dc08 100644 --- a/app/components/Sidebar/components/StarredLink.tsx +++ b/app/components/Sidebar/components/StarredLink.tsx @@ -124,7 +124,7 @@ const StarredDocumentLink = observer(function StarredDocumentLink({ ) => !!match && location.state?.sidebarContext === sidebarContext} label={label} exact={false} - showActions={menuOpen} + $showActions={menuOpen} menu={ document && !isDragging ? ( diff --git a/app/components/Table.tsx b/app/components/Table.tsx index 46382c6d39..76a6c1b860 100644 --- a/app/components/Table.tsx +++ b/app/components/Table.tsx @@ -234,7 +234,13 @@ function Table({ ); - return decorateRow ? decorateRow(row.original, baseRow) : baseRow; + return decorateRow ? ( + + {decorateRow(row.original, baseRow)} + + ) : ( + baseRow + ); })} {showPlaceholder && ( diff --git a/app/editor/components/EmojiMenu.tsx b/app/editor/components/EmojiMenu.tsx index 23b86bc1fa..da2b54ca10 100644 --- a/app/editor/components/EmojiMenu.tsx +++ b/app/editor/components/EmojiMenu.tsx @@ -1,4 +1,5 @@ import capitalize from "lodash/capitalize"; +import { observer } from "mobx-react"; import { useCallback, useMemo, useEffect } from "react"; import { emojiMartToGemoji, snakeCase } from "@shared/editor/lib/emoji"; import { search as emojiSearch } from "@shared/utils/emoji"; @@ -76,4 +77,4 @@ const EmojiMenu = (props: Props) => { ); }; -export default EmojiMenu; +export default observer(EmojiMenu); diff --git a/app/editor/components/MentionMenu.tsx b/app/editor/components/MentionMenu.tsx index 3a1265c079..da341bdf8a 100644 --- a/app/editor/components/MentionMenu.tsx +++ b/app/editor/components/MentionMenu.tsx @@ -44,7 +44,6 @@ type Props = Omit< function MentionMenu({ search, isActive, ...rest }: Props) { const [loaded, setLoaded] = useState(false); - const [items, setItems] = useState([]); const { t } = useTranslation(); const { auth, documents, users, collections, groups } = useStores(); const actorId = auth.currentUserId; @@ -76,164 +75,161 @@ function MentionMenu({ search, isActive, ...rest }: Props) { useEffect(() => { if (actorId && !loading) { - const items: MentionItem[] = users - .findByQuery(search, { maxResults: maxResultsInSection }) - .map( - (user) => - ({ - name: "mention", - icon: ( - - - - ), - title: user.name, - section: UserSection, - appendSpace: true, - attrs: { - id: uuidv4(), - type: MentionType.User, - modelId: user.id, - actorId, - label: user.name, - }, - }) as MentionItem - ) - .concat( - groups - .findByQuery(search, { maxResults: maxResultsInSection }) - .map((group) => ({ - name: "mention", - icon: ( - - - - ), - title: group.name, - subtitle: t("{{ count }} members", { count: group.memberCount }), - section: GroupSection, - appendSpace: true, - attrs: { - id: uuidv4(), - type: MentionType.Group, - modelId: group.id, - actorId, - label: group.name, - }, - })) - ) - .concat( - documents - .findByQuery(search, { maxResults: maxResultsInSection }) - .map( - (doc) => - ({ - name: "mention", - icon: doc.icon ? ( - - ) : ( - - ), - title: doc.title, - subtitle: doc.collectionId ? ( - - ) : undefined, - section: DocumentsSection, - appendSpace: true, - attrs: { - id: uuidv4(), - type: MentionType.Document, - modelId: doc.id, - actorId, - label: doc.title, - }, - }) as MentionItem - ) - ) - .concat( - collections - .findByQuery(search, { maxResults: maxResultsInSection }) - .map( - (collection) => - ({ - name: "mention", - icon: collection.icon ? ( - - ) : ( - - ), - title: collection.name, - section: CollectionsSection, - appendSpace: true, - attrs: { - id: uuidv4(), - type: MentionType.Collection, - modelId: collection.id, - actorId, - label: collection.name, - }, - }) as MentionItem - ) - ) - .concat([ - { - name: "link", - icon: , - title: search?.trim(), - section: DocumentsSection, - subtitle: t("Create a new doc"), - visible: !!search && !isEmail(search), - priority: -1, - appendSpace: true, - attrs: { - id: uuidv4(), - type: MentionType.Document, - modelId: uuidv4(), - actorId, - label: search, - }, - } as MentionItem, - ]); - - setItems(items); setLoaded(true); } - }, [ - t, - actorId, - loading, - search, - users, - documents, - maxResultsInSection, - groups, - collections, - ]); + }, [actorId, loading]); + + // Computed in the render body so MobX observer can track store access + // (e.g. searchSuppressed). Previously this lived inside a useEffect which + // runs outside the reactive context and triggered MobX warnings. + const items: MentionItem[] = + actorId && !loading + ? users + .findByQuery(search, { maxResults: maxResultsInSection }) + .map( + (user) => + ({ + name: "mention", + icon: ( + + + + ), + title: user.name, + section: UserSection, + appendSpace: true, + attrs: { + id: uuidv4(), + type: MentionType.User, + modelId: user.id, + actorId, + label: user.name, + }, + }) as MentionItem + ) + .concat( + groups + .findByQuery(search, { maxResults: maxResultsInSection }) + .map((group) => ({ + name: "mention", + icon: ( + + + + ), + title: group.name, + subtitle: t("{{ count }} members", { + count: group.memberCount, + }), + section: GroupSection, + appendSpace: true, + attrs: { + id: uuidv4(), + type: MentionType.Group, + modelId: group.id, + actorId, + label: group.name, + }, + })) + ) + .concat( + documents + .findByQuery(search, { maxResults: maxResultsInSection }) + .map( + (doc) => + ({ + name: "mention", + icon: doc.icon ? ( + + ) : ( + + ), + title: doc.title, + subtitle: doc.collectionId ? ( + + ) : undefined, + section: DocumentsSection, + appendSpace: true, + attrs: { + id: uuidv4(), + type: MentionType.Document, + modelId: doc.id, + actorId, + label: doc.title, + }, + }) as MentionItem + ) + ) + .concat( + collections + .findByQuery(search, { maxResults: maxResultsInSection }) + .map( + (collection) => + ({ + name: "mention", + icon: collection.icon ? ( + + ) : ( + + ), + title: collection.name, + section: CollectionsSection, + appendSpace: true, + attrs: { + id: uuidv4(), + type: MentionType.Collection, + modelId: collection.id, + actorId, + label: collection.name, + }, + }) as MentionItem + ) + ) + .concat([ + { + name: "link", + icon: , + title: search?.trim(), + section: DocumentsSection, + subtitle: t("Create a new doc"), + visible: !!search && !isEmail(search), + priority: -1, + appendSpace: true, + attrs: { + id: uuidv4(), + type: MentionType.Document, + modelId: uuidv4(), + actorId, + label: search, + }, + } as MentionItem, + ]) + : []; const handleSelect = useCallback( async (item: MentionItem) => { diff --git a/app/index.tsx b/app/index.tsx index 1b4ea6c50f..047b79bb62 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -41,7 +41,7 @@ if (env.SENTRY_DSN) { configureMobx({ // TODO: Enable these options and fix any resulting warnings // enforceActions: env.isDevelopment ? "always" : "never", - // computedRequiresReaction: true, + computedRequiresReaction: true, isolateGlobalState: true, }); diff --git a/app/routes/settings.tsx b/app/routes/settings.tsx index e1aa6e1514..1b28f95850 100644 --- a/app/routes/settings.tsx +++ b/app/routes/settings.tsx @@ -6,11 +6,12 @@ import Route from "~/components/ProfiledRoute"; import useSettingsConfig from "~/hooks/useSettingsConfig"; import lazy from "~/utils/lazyWithRetry"; import { matchDocumentSlug, settingsPath } from "~/utils/routeHelpers"; +import { observer } from "mobx-react"; const Application = lazy(() => import("~/scenes/Settings/Application")); const Document = lazy(() => import("~/scenes/Document")); -export default function SettingsRoutes() { +function SettingsRoutes() { const configs = useSettingsConfig(); return ( @@ -45,3 +46,5 @@ export default function SettingsRoutes() { ); } + +export default observer(SettingsRoutes); diff --git a/app/scenes/Settings/components/EmojisTable.tsx b/app/scenes/Settings/components/EmojisTable.tsx index 06cdaf1857..f840a0db42 100644 --- a/app/scenes/Settings/components/EmojisTable.tsx +++ b/app/scenes/Settings/components/EmojisTable.tsx @@ -28,7 +28,7 @@ type Props = Omit, "columns" | "rowHeight"> & { canManage: boolean; }; -function EmojiRowContextMenu({ +const EmojiRowContextMenu = observer(function EmojiRowContextMenu({ emoji, menuLabel, children, @@ -43,7 +43,7 @@ function EmojiRowContextMenu({ {children} ); -} +}); const EmojisTable = observer(function EmojisTable({ canManage, diff --git a/app/scenes/Settings/components/GroupsTable.tsx b/app/scenes/Settings/components/GroupsTable.tsx index a391e32696..c1a982381e 100644 --- a/app/scenes/Settings/components/GroupsTable.tsx +++ b/app/scenes/Settings/components/GroupsTable.tsx @@ -1,4 +1,5 @@ import compact from "lodash/compact"; +import { observer } from "mobx-react"; import { GroupIcon } from "outline-icons"; import * as React from "react"; import { useCallback, useMemo } from "react"; @@ -32,7 +33,7 @@ const STICKY_OFFSET = HEADER_HEIGHT + FILTER_HEIGHT; type Props = Omit, "columns" | "rowHeight">; -function GroupRowContextMenu({ +const GroupRowContextMenu = observer(function GroupRowContextMenu({ group, menuLabel, children, @@ -47,7 +48,7 @@ function GroupRowContextMenu({ {children} ); -} +}); export function GroupsTable(props: Props) { const { t } = useTranslation(); diff --git a/app/scenes/Settings/components/MembersTable.tsx b/app/scenes/Settings/components/MembersTable.tsx index 1c124c2687..5de73e321f 100644 --- a/app/scenes/Settings/components/MembersTable.tsx +++ b/app/scenes/Settings/components/MembersTable.tsx @@ -1,4 +1,5 @@ import compact from "lodash/compact"; +import { observer } from "mobx-react"; import { useMemo, useCallback } from "react"; import { useTranslation } from "react-i18next"; import Text from "@shared/components/Text"; @@ -28,7 +29,7 @@ type Props = Omit, "columns" | "rowHeight"> & { canManage: boolean; }; -function UserRowContextMenu({ +const UserRowContextMenu = observer(function UserRowContextMenu({ user, menuLabel, children, @@ -43,7 +44,7 @@ function UserRowContextMenu({ {children} ); -} +}); export function MembersTable({ canManage, ...rest }: Props) { const { t } = useTranslation(); diff --git a/app/scenes/Settings/components/SharesTable.tsx b/app/scenes/Settings/components/SharesTable.tsx index a2b05f2b4e..bede112a6c 100644 --- a/app/scenes/Settings/components/SharesTable.tsx +++ b/app/scenes/Settings/components/SharesTable.tsx @@ -1,4 +1,5 @@ import compact from "lodash/compact"; +import { observer } from "mobx-react"; import * as React from "react"; import { useMemo, useCallback } from "react"; import { useTranslation } from "react-i18next"; @@ -25,7 +26,7 @@ type Props = Omit, "columns" | "rowHeight"> & { canManage: boolean; }; -function ShareRowContextMenu({ +const ShareRowContextMenu = observer(function ShareRowContextMenu({ share, menuLabel, children, @@ -40,7 +41,7 @@ function ShareRowContextMenu({ {children} ); -} +}); export function SharesTable({ data, canManage, ...rest }: Props) { const { t } = useTranslation(); diff --git a/app/stores/PinsStore.ts b/app/stores/PinsStore.ts index b50f4a485d..659e665c39 100644 --- a/app/stores/PinsStore.ts +++ b/app/stores/PinsStore.ts @@ -75,9 +75,7 @@ export default class PinsStore extends Store { }; inCollection = (collectionId: string) => - computed(() => this.orderedData) - .get() - .filter((pin) => pin.collectionId === collectionId); + this.orderedData.filter((pin) => pin.collectionId === collectionId); @computed get home() {