From 0f35c7c7a8acdc00ddabb5f3bd360b2326337c55 Mon Sep 17 00:00:00 2001 From: Jackson Morgan Date: Tue, 14 Jan 2025 09:57:51 -0500 Subject: [PATCH] Added useSubscribeToNotifications --- .../solid-react/src/useSubscribeToResource.ts | 52 ++++++++ .../solid-react/test/Integration.test.tsx | 116 ++++++++++++++++++ .../src/react/useTypeIndex.ts | 20 +-- .../src/react/util/useSubscribeToUris.ts | 35 ++++++ .../notifications/NotificationSubscription.ts | 2 +- 5 files changed, 210 insertions(+), 15 deletions(-) create mode 100644 packages/solid-react/src/useSubscribeToResource.ts create mode 100644 packages/solid-type-index/src/react/util/useSubscribeToUris.ts diff --git a/packages/solid-react/src/useSubscribeToResource.ts b/packages/solid-react/src/useSubscribeToResource.ts new file mode 100644 index 0000000..d6dc8b0 --- /dev/null +++ b/packages/solid-react/src/useSubscribeToResource.ts @@ -0,0 +1,52 @@ +import { useLdo } from "./SolidLdoProvider"; +import { useEffect, useRef } from "react"; + +export function useSubscribeToResource(...uris: string[]): void { + const { dataset } = useLdo(); + const currentlySubscribed = useRef>({}); + useEffect(() => { + const resources = uris.map((uri) => dataset.getResource(uri)); + const previousSubscriptions = { ...currentlySubscribed.current }; + Promise.all( + resources.map(async (resource) => { + if (!previousSubscriptions[resource.uri]) { + // Prevent multiple triggers from created subscriptions while waiting + // for connection + currentlySubscribed.current[resource.uri] = "AWAITING"; + // Read and subscribe + await resource.readIfUnfetched(); + currentlySubscribed.current[resource.uri] = + await resource.subscribeToNotifications(); + } else { + delete previousSubscriptions[resource.uri]; + } + }), + ).then(async () => { + // Unsubscribe from all remaining previous subscriptions + await Promise.all( + Object.entries(previousSubscriptions).map( + async ([resourceUri, subscriptionId]) => { + // Unsubscribe + delete currentlySubscribed.current[resourceUri]; + const resource = dataset.getResource(resourceUri); + await resource.unsubscribeFromNotifications(subscriptionId); + }, + ), + ); + }); + }, [uris]); + + // Cleanup Subscriptions + useEffect(() => { + return () => { + Promise.all( + Object.entries(currentlySubscribed.current).map( + async ([resourceUri, subscriptionId]) => { + const resource = dataset.getResource(resourceUri); + await resource.unsubscribeFromNotifications(subscriptionId); + }, + ), + ); + }; + }, []); +} diff --git a/packages/solid-react/test/Integration.test.tsx b/packages/solid-react/test/Integration.test.tsx index 4497be4..b6e5641 100644 --- a/packages/solid-react/test/Integration.test.tsx +++ b/packages/solid-react/test/Integration.test.tsx @@ -16,6 +16,7 @@ import type { PostSh } from "./.ldo/post.typings"; import { useSubject } from "../src/useSubject"; import { useMatchSubject } from "../src/useMatchSubject"; import { useMatchObject } from "../src/useMatchObject"; +import { useSubscribeToResource } from "../src/useSubscribeToResource"; // Use an increased timeout, since the CSS server takes too much setup time. jest.setTimeout(40_000); @@ -519,4 +520,119 @@ describe("Integration Tests", () => { expect(list.children[1].innerHTML).toBe("https://example.com/Publisher2"); }); }); + + /** + * =========================================================================== + * useSubscribeToResource + * =========================================================================== + */ + describe("useSubscribeToResource", () => { + it("handles useSubscribeToResource", async () => { + const NotificationTest: FunctionComponent = () => { + const [subscribedUris, setSubScribedUris] = useState([ + SAMPLE_DATA_URI, + ]); + useSubscribeToResource(...subscribedUris); + + const resource1 = useResource(SAMPLE_DATA_URI); + const resource2 = useResource(SAMPLE_BINARY_URI); + const post = useSubject(PostShShapeType, `${SAMPLE_DATA_URI}#Post1`); + + const addPublisher = useCallback(async () => { + await fetch(SAMPLE_DATA_URI, { + method: "PATCH", + body: `INSERT DATA { <${SAMPLE_DATA_URI}#Post1> . }`, + headers: { + "Content-Type": "application/sparql-update", + }, + }); + }, []); + + if (resource1.isLoading() || resource2.isLoading()) + return

Loading

; + + return ( +
+

+ {resource1.isSubscribedToNotifications().toString()} +

+

+ {resource2.isSubscribedToNotifications().toString()} +

+
    + {post.publisher.map((publisher) => { + return
  • {publisher["@id"]}
  • ; + })} +
+ + + +
+ ); + }; + const { unmount } = render( + + + , + ); + + const preResource1P = await screen.findByRole("resource1"); + expect(preResource1P.innerHTML).toBe("false"); + + // Wait for subscription to connect + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + }); + + const list = await screen.findByRole("list"); + expect(list.children[0].innerHTML).toBe("https://example.com/Publisher1"); + expect(list.children[1].innerHTML).toBe("https://example.com/Publisher2"); + const resource1P = await screen.findByRole("resource1"); + expect(resource1P.innerHTML).toBe("true"); + const resource2P = await screen.findByRole("resource2"); + expect(resource2P.innerHTML).toBe("false"); + + // Click button to add a publisher + await fireEvent.click(screen.getByText("Add Publisher")); + await screen.findByText("https://example.com/Publisher3"); + + // Verify the new publisher is in the list + const updatedList = await screen.findByRole("list"); + expect(updatedList.children[2].innerHTML).toBe( + "https://example.com/Publisher3", + ); + + await fireEvent.click(screen.getByText("Subscribe More")); + // Wait for subscription to connect + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + }); + + const resource1PUpdated = await screen.findByRole("resource1"); + expect(resource1PUpdated.innerHTML).toBe("true"); + const resource2PUpdated = await screen.findByRole("resource2"); + expect(resource2PUpdated.innerHTML).toBe("true"); + + await fireEvent.click(screen.getByText("Subscribe Less")); + // Wait for subscription to connect + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + }); + + const resource1PUpdatedAgain = await screen.findByRole("resource1"); + expect(resource1PUpdatedAgain.innerHTML).toBe("false"); + const resource2PUpdatedAgain = await screen.findByRole("resource2"); + expect(resource2PUpdatedAgain.innerHTML).toBe("true"); + + unmount(); + }); + }); }); diff --git a/packages/solid-type-index/src/react/useTypeIndex.ts b/packages/solid-type-index/src/react/useTypeIndex.ts index 122c069..87ce97a 100644 --- a/packages/solid-type-index/src/react/useTypeIndex.ts +++ b/packages/solid-type-index/src/react/useTypeIndex.ts @@ -1,12 +1,11 @@ import type { LeafUri } from "@ldo/solid"; import { useTypeIndexProfile } from "./useTypeIndexProfile"; -import { useEffect, useMemo } from "react"; -import { useLdo } from "@ldo/solid-react"; +import { useMemo } from "react"; +import { useSubscribeToUris } from "./util/useSubscribeToUris"; export function useTypeIndex(classUri: string): Promise { - const { dataset } = useLdo(); - const profile = useTypeIndexProfile(); + const typeIndexUris: string[] = useMemo(() => { const uris: string[] = []; profile?.privateTypeIndex?.forEach((indexNode) => { @@ -15,17 +14,10 @@ export function useTypeIndex(classUri: string): Promise { profile?.publicTypeIndex?.forEach((indexNode) => { uris.push(indexNode["@id"]); }); + return uris; }, [profile]); - useEffect(() => { - const resources = typeIndexUris.map((uri) => dataset.getResource(uri)); - resources.forEach((resource) => { - resource.readIfUnfetched(); - resource.subscribeToNotifications(); - }); + useSubscribeToUris(typeIndexUris); - return () => { - resources.forEach((resource) => resource.unsubscribeFromNotifications()); - } - }, [typeIndexUris]); + } diff --git a/packages/solid-type-index/src/react/util/useSubscribeToUris.ts b/packages/solid-type-index/src/react/util/useSubscribeToUris.ts new file mode 100644 index 0000000..4141f34 --- /dev/null +++ b/packages/solid-type-index/src/react/util/useSubscribeToUris.ts @@ -0,0 +1,35 @@ +import { useLdo } from "@ldo/solid-react"; +import { useEffect, useRef } from "react"; + +export function useSubscribeToUris(uris: string[]) { + const { dataset } = useLdo(); + const currentlySubscribed = useRef>({}); + useEffect(() => { + const resources = uris.map((uri) => dataset.getResource(uri)); + const previousSubscriptions = { ...currentlySubscribed.current }; + Promise.all( + resources.map(async (resource) => { + if (!previousSubscriptions[resource.uri]) { + // Read and subscribe + await resource.readIfUnfetched(); + currentlySubscribed.current[resource.uri] = + await resource.subscribeToNotifications(); + } else { + delete previousSubscriptions[resource.uri]; + } + }), + ).then(async () => { + // Unsubscribe from all remaining previous subscriptions + await Promise.all( + Object.entries(previousSubscriptions).map( + async ([resourceUri, subscriptionId]) => { + // Unsubscribe + delete currentlySubscribed.current[resourceUri]; + const resource = dataset.getResource(resourceUri); + await resource.unsubscribeFromNotifications(subscriptionId); + }, + ), + ); + }); + }, [uris]); +} diff --git a/packages/solid/src/resource/notifications/NotificationSubscription.ts b/packages/solid/src/resource/notifications/NotificationSubscription.ts index ce77b89..4961075 100644 --- a/packages/solid/src/resource/notifications/NotificationSubscription.ts +++ b/packages/solid/src/resource/notifications/NotificationSubscription.ts @@ -137,7 +137,7 @@ export abstract class NotificationSubscription { * setIsOpen */ protected setIsOpen(status: boolean) { - const shouldUpdate = status === this.isOpen; + const shouldUpdate = status !== this.isOpen; this.isOpen = status; if (shouldUpdate) this.resource.emit("update"); }