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:
Tom Moor
2026-03-30 19:44:37 -04:00
committed by GitHub
parent e354db8164
commit b70950627e
7 changed files with 162 additions and 77 deletions
@@ -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>
+79
View File
@@ -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>
+19 -8
View File
@@ -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 && <>&middot; {domain}</>}
</Button>
</PopoverTrigger>
@@ -66,6 +76,7 @@ function ShareButton({ document }: Props) {
document={document}
onRequestClose={closePopover}
visible={open}
loading={loading}
/>
</Suspense>
</PopoverContent>