mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Preload share popover data on hover (#11909)
* Preload share popover data on hover with useShareDataLoader hook Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: Route programmatic closes through handleOpenChange and fix race conditions - closePopover now calls handleOpenChange(false) so reset() fires on all close paths, including programmatic closes via onRequestClose - Reset requestedRef when entity id changes so preload fires for new targets - Use request counter to prevent stale loading state when reset() is called during an in-flight request Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -16,7 +16,6 @@ import Scrollable from "~/components/Scrollable";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useMaxHeight from "~/hooks/useMaxHeight";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useRequest from "~/hooks/useRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import type { Permission } from "~/types";
|
||||
import { EmptySelectValue } from "~/types";
|
||||
@@ -38,10 +37,12 @@ type Props = {
|
||||
invitedInSession: string[];
|
||||
/** Whether the popover is visible. */
|
||||
visible: boolean;
|
||||
/** Whether the share data is currently loading. */
|
||||
loading: boolean;
|
||||
};
|
||||
|
||||
export const AccessControlList = observer(
|
||||
({ collection, share, invitedInSession, visible }: Props) => {
|
||||
({ collection, share, invitedInSession, visible, loading }: Props) => {
|
||||
const { memberships, groupMemberships } = useStores();
|
||||
const team = useCurrentTeam();
|
||||
const can = usePolicy(collection);
|
||||
@@ -49,35 +50,13 @@ export const AccessControlList = observer(
|
||||
const theme = useTheme();
|
||||
const collectionId = collection.id;
|
||||
|
||||
const { request: fetchMemberships, loading: membershipLoading } =
|
||||
useRequest(
|
||||
React.useCallback(
|
||||
() => memberships.fetchAll({ id: collectionId }),
|
||||
[memberships, collectionId]
|
||||
)
|
||||
);
|
||||
|
||||
const { request: fetchGroupMemberships, loading: groupMembershipLoading } =
|
||||
useRequest(
|
||||
React.useCallback(
|
||||
() => groupMemberships.fetchAll({ collectionId }),
|
||||
[groupMemberships, collectionId]
|
||||
)
|
||||
);
|
||||
|
||||
const groupMembershipsInCollection =
|
||||
groupMemberships.inCollection(collectionId);
|
||||
const membershipsInCollection = memberships.inCollection(collectionId);
|
||||
const hasMemberships =
|
||||
groupMembershipsInCollection.length > 0 ||
|
||||
membershipsInCollection.length > 0;
|
||||
const showLoading =
|
||||
!hasMemberships && (membershipLoading || groupMembershipLoading);
|
||||
|
||||
React.useEffect(() => {
|
||||
void fetchMemberships();
|
||||
void fetchGroupMemberships();
|
||||
}, [fetchMemberships, fetchGroupMemberships]);
|
||||
const showLoading = !hasMemberships && loading;
|
||||
|
||||
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const publicAccessRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
@@ -18,6 +18,7 @@ import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import usePrevious from "~/hooks/usePrevious";
|
||||
import useShareDataLoader from "~/hooks/useShareDataLoader";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import type { Permission } from "~/types";
|
||||
import { collectionPath, urlify } from "~/utils/routeHelpers";
|
||||
@@ -35,11 +36,22 @@ type Props = {
|
||||
onRequestClose: () => void;
|
||||
/** Whether the popover is visible. */
|
||||
visible: boolean;
|
||||
/** Whether the share data is currently loading, managed externally. */
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
function SharePopover({ collection, visible, onRequestClose }: Props) {
|
||||
function SharePopover({
|
||||
collection,
|
||||
visible,
|
||||
onRequestClose,
|
||||
loading: externalLoading,
|
||||
}: Props) {
|
||||
const team = useCurrentTeam();
|
||||
const { groupMemberships, users, groups, memberships, shares } = useStores();
|
||||
const { preload, loading: internalLoading } = useShareDataLoader({
|
||||
collection,
|
||||
});
|
||||
const loading = externalLoading ?? internalLoading;
|
||||
const { t } = useTranslation();
|
||||
const can = usePolicy(collection);
|
||||
const [query, setQuery] = React.useState("");
|
||||
@@ -94,10 +106,12 @@ function SharePopover({ collection, visible, onRequestClose }: Props) {
|
||||
|
||||
React.useEffect(() => {
|
||||
if (visible) {
|
||||
void collection.share();
|
||||
if (externalLoading === undefined) {
|
||||
preload();
|
||||
}
|
||||
setHasRendered(true);
|
||||
}
|
||||
}, [collection, visible]);
|
||||
}, [visible, externalLoading, preload]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (prevPendingIds && pendingIds.length > prevPendingIds.length) {
|
||||
@@ -368,6 +382,7 @@ function SharePopover({ collection, visible, onRequestClose }: Props) {
|
||||
share={share}
|
||||
invitedInSession={invitedInSession}
|
||||
visible={visible}
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
</Wrapper>
|
||||
|
||||
@@ -4,7 +4,6 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import Squircle from "@shared/components/Squircle";
|
||||
import { Pagination } from "@shared/constants";
|
||||
import { s } from "@shared/styles";
|
||||
import { CollectionPermission, IconType } from "@shared/types";
|
||||
import { determineIconType } from "@shared/utils/icon";
|
||||
@@ -43,6 +42,8 @@ type Props = {
|
||||
onRequestClose: () => void;
|
||||
/** Whether the popover is visible. */
|
||||
visible: boolean;
|
||||
/** Whether the share data is currently loading. */
|
||||
loading: boolean;
|
||||
};
|
||||
|
||||
export const AccessControlList = observer(
|
||||
@@ -53,13 +54,14 @@ export const AccessControlList = observer(
|
||||
sharedParent,
|
||||
onRequestClose,
|
||||
visible,
|
||||
loading,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const collection = document.collection;
|
||||
const usersInCollection = useUsersInCollection(collection);
|
||||
const user = useCurrentUser();
|
||||
const { userMemberships, groupMemberships } = useStores();
|
||||
const { groupMemberships } = useStores();
|
||||
const collectionSharingDisabled = document.collection?.sharing === false;
|
||||
const team = useCurrentTeam();
|
||||
const can = usePolicy(document);
|
||||
@@ -75,36 +77,10 @@ export const AccessControlList = observer(
|
||||
margin: 24,
|
||||
});
|
||||
|
||||
const { loading: userMembershipLoading, request: fetchUserMemberships } =
|
||||
useRequest(
|
||||
React.useCallback(
|
||||
() =>
|
||||
userMemberships.fetchDocumentMemberships({
|
||||
id: documentId,
|
||||
limit: Pagination.defaultLimit,
|
||||
}),
|
||||
[userMemberships, documentId]
|
||||
)
|
||||
);
|
||||
|
||||
const { loading: groupMembershipLoading, request: fetchGroupMemberships } =
|
||||
useRequest(
|
||||
React.useCallback(
|
||||
() => groupMemberships.fetchAll({ documentId }),
|
||||
[groupMemberships, documentId]
|
||||
)
|
||||
);
|
||||
|
||||
const hasMemberships =
|
||||
groupMemberships.inDocument(documentId)?.length > 0 ||
|
||||
document.members.length > 0;
|
||||
const showLoading =
|
||||
!hasMemberships && (groupMembershipLoading || userMembershipLoading);
|
||||
|
||||
React.useEffect(() => {
|
||||
void fetchUserMemberships();
|
||||
void fetchGroupMemberships();
|
||||
}, [fetchUserMemberships, fetchGroupMemberships]);
|
||||
const showLoading = !hasMemberships && loading;
|
||||
|
||||
React.useEffect(() => {
|
||||
calcMaxHeight();
|
||||
|
||||
@@ -18,6 +18,7 @@ import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import usePrevious from "~/hooks/usePrevious";
|
||||
import useShareDataLoader from "~/hooks/useShareDataLoader";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import type { Permission } from "~/types";
|
||||
import { documentPath, urlify } from "~/utils/routeHelpers";
|
||||
@@ -35,9 +36,16 @@ type Props = {
|
||||
onRequestClose: () => void;
|
||||
/** Whether the popover is visible. */
|
||||
visible: boolean;
|
||||
/** Whether the share data is currently loading, managed externally. */
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
function SharePopover({ document, onRequestClose, visible }: Props) {
|
||||
function SharePopover({
|
||||
document,
|
||||
onRequestClose,
|
||||
visible,
|
||||
loading: externalLoading,
|
||||
}: Props) {
|
||||
const team = useCurrentTeam();
|
||||
const { t } = useTranslation();
|
||||
const can = usePolicy(document);
|
||||
@@ -46,6 +54,10 @@ function SharePopover({ document, onRequestClose, visible }: Props) {
|
||||
const sharedParent = shares.getByDocumentParents(document);
|
||||
const [hasRendered, setHasRendered] = React.useState(visible);
|
||||
const { users, userMemberships, groups, groupMemberships } = useStores();
|
||||
const { preload, loading: internalLoading } = useShareDataLoader({
|
||||
document,
|
||||
});
|
||||
const loading = externalLoading ?? internalLoading;
|
||||
const [query, setQuery] = React.useState("");
|
||||
const [picker, showPicker, hidePicker] = useBoolean();
|
||||
const [invitedInSession, setInvitedInSession] = React.useState<string[]>([]);
|
||||
@@ -79,13 +91,14 @@ function SharePopover({ document, onRequestClose, visible }: Props) {
|
||||
}
|
||||
);
|
||||
|
||||
// Fetch sharefocus the link button when the popover is opened
|
||||
React.useEffect(() => {
|
||||
if (visible) {
|
||||
void document.share();
|
||||
if (externalLoading === undefined) {
|
||||
preload();
|
||||
}
|
||||
setHasRendered(true);
|
||||
}
|
||||
}, [document, hidePicker, visible]);
|
||||
}, [visible, externalLoading, preload]);
|
||||
|
||||
// Hide the picker when the popover is closed
|
||||
React.useEffect(() => {
|
||||
@@ -377,6 +390,7 @@ function SharePopover({ document, onRequestClose, visible }: Props) {
|
||||
share={share}
|
||||
sharedParent={sharedParent}
|
||||
visible={visible}
|
||||
loading={loading}
|
||||
onRequestClose={onRequestClose}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Pagination } from "@shared/constants";
|
||||
import type Collection from "~/models/Collection";
|
||||
import type Document from "~/models/Document";
|
||||
import useStores from "./useStores";
|
||||
|
||||
type Params =
|
||||
| { document: Document; collection?: undefined }
|
||||
| { collection: Collection; document?: undefined };
|
||||
|
||||
/**
|
||||
* Hook to preload all data needed by the share popover. Returns a `preload`
|
||||
* function that can be called on hover so the popover renders instantly.
|
||||
*
|
||||
* @param params - the document or collection to load share data for.
|
||||
* @returns preload function, loading state, and reset function.
|
||||
*/
|
||||
export default function useShareDataLoader(params: Params) {
|
||||
const { userMemberships, groupMemberships, memberships } = useStores();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const requestedRef = useRef(false);
|
||||
const requestCountRef = useRef(0);
|
||||
|
||||
const entityId = params.document?.id ?? params.collection?.id;
|
||||
|
||||
// Reset when the entity changes so preload fires for the new target.
|
||||
useEffect(() => {
|
||||
requestedRef.current = false;
|
||||
setLoading(false);
|
||||
}, [entityId]);
|
||||
|
||||
const preload = useCallback(() => {
|
||||
if (requestedRef.current) {
|
||||
return;
|
||||
}
|
||||
requestedRef.current = true;
|
||||
setLoading(true);
|
||||
|
||||
const thisRequest = ++requestCountRef.current;
|
||||
const promises: Promise<unknown>[] = [];
|
||||
|
||||
if (params.document) {
|
||||
const doc = params.document;
|
||||
promises.push(
|
||||
doc.share(),
|
||||
userMemberships.fetchDocumentMemberships({
|
||||
id: doc.id,
|
||||
limit: Pagination.defaultLimit,
|
||||
}),
|
||||
groupMemberships.fetchAll({ documentId: doc.id })
|
||||
);
|
||||
} else {
|
||||
const col = params.collection;
|
||||
promises.push(
|
||||
col.share(),
|
||||
memberships.fetchAll({ id: col.id }),
|
||||
groupMemberships.fetchAll({ collectionId: col.id })
|
||||
);
|
||||
}
|
||||
|
||||
void Promise.all(promises).finally(() => {
|
||||
if (requestCountRef.current === thisRequest) {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
}, [
|
||||
params.document,
|
||||
params.collection,
|
||||
userMemberships,
|
||||
groupMemberships,
|
||||
memberships,
|
||||
]);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
requestedRef.current = false;
|
||||
}, []);
|
||||
|
||||
return { preload, loading, reset };
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "~/components/primitives/Popover";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useShareDataLoader from "~/hooks/useShareDataLoader";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { preventDefault } from "~/utils/events";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
@@ -33,14 +34,23 @@ function ShareButton({ collection }: Props) {
|
||||
const share = shares.getByCollectionId(collection.id);
|
||||
const isPubliclyShared =
|
||||
team.sharing !== false && collection?.sharing !== false && share?.published;
|
||||
const { preload, loading, reset } = useShareDataLoader({ collection });
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(isOpen: boolean) => {
|
||||
setOpen(isOpen);
|
||||
if (isOpen) {
|
||||
preload();
|
||||
} else {
|
||||
reset();
|
||||
}
|
||||
},
|
||||
[preload, reset]
|
||||
);
|
||||
|
||||
const closePopover = useCallback(() => {
|
||||
setOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
void collection.share();
|
||||
}, [collection]);
|
||||
handleOpenChange(false);
|
||||
}, [handleOpenChange]);
|
||||
|
||||
if (isMobile) {
|
||||
return null;
|
||||
@@ -53,9 +63,9 @@ function ShareButton({ collection }: Props) {
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger>
|
||||
<Button icon={icon} neutral onMouseEnter={handleMouseEnter}>
|
||||
<Button icon={icon} neutral onMouseEnter={preload}>
|
||||
{t("Share")}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
@@ -72,6 +82,7 @@ function ShareButton({ collection }: Props) {
|
||||
collection={collection}
|
||||
onRequestClose={closePopover}
|
||||
visible={open}
|
||||
loading={loading}
|
||||
/>
|
||||
</Suspense>
|
||||
</PopoverContent>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
PopoverContent,
|
||||
} from "~/components/primitives/Popover";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useShareDataLoader from "~/hooks/useShareDataLoader";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { preventDefault } from "~/utils/events";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
@@ -31,14 +32,23 @@ function ShareButton({ document }: Props) {
|
||||
const share = shares.getByDocumentId(document.id);
|
||||
const sharedParent = shares.getByDocumentParents(document);
|
||||
const domain = share?.domain || sharedParent?.domain;
|
||||
const { preload, loading, reset } = useShareDataLoader({ document });
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(isOpen: boolean) => {
|
||||
setOpen(isOpen);
|
||||
if (isOpen) {
|
||||
preload();
|
||||
} else {
|
||||
reset();
|
||||
}
|
||||
},
|
||||
[preload, reset]
|
||||
);
|
||||
|
||||
const closePopover = useCallback(() => {
|
||||
setOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
void document.share();
|
||||
}, [document]);
|
||||
handleOpenChange(false);
|
||||
}, [handleOpenChange]);
|
||||
|
||||
if (isMobile) {
|
||||
return null;
|
||||
@@ -47,9 +57,9 @@ function ShareButton({ document }: Props) {
|
||||
const icon = document.isPubliclyShared ? <GlobeIcon /> : undefined;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger>
|
||||
<Button icon={icon} neutral onMouseEnter={handleMouseEnter}>
|
||||
<Button icon={icon} neutral onMouseEnter={preload}>
|
||||
{t("Share")} {domain && <>· {domain}</>}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
@@ -66,6 +76,7 @@ function ShareButton({ document }: Props) {
|
||||
document={document}
|
||||
onRequestClose={closePopover}
|
||||
visible={open}
|
||||
loading={loading}
|
||||
/>
|
||||
</Suspense>
|
||||
</PopoverContent>
|
||||
|
||||
Reference in New Issue
Block a user