Skip to content

Commit 84dadad

Browse files
authored
Merge pull request #3237 from garden-co/feat/suspense
feat: suspense hooks
2 parents aabca1e + 67be902 commit 84dadad

34 files changed

+2912
-319
lines changed

.changeset/real-pants-wait.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"jazz-tools": patch
3+
---
4+
5+
Added Suspense hooks for React and implemented subscription deduplication for React hooks

examples/music-player/src/3_HomePage.tsx

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { useCoState } from "jazz-tools/react";
21
import { useParams } from "react-router";
32
import { PlaylistWithTracks } from "./1_schema";
43
import { uploadMusicTracks } from "./4_actions";
@@ -15,6 +14,7 @@ import { SidebarInset, SidebarTrigger } from "./components/ui/sidebar";
1514
import { usePlayState } from "./lib/audio/usePlayState";
1615
import { useState } from "react";
1716
import { useAccountSelector } from "@/components/AccountProvider.tsx";
17+
import { useSuspenseCoState } from "jazz-tools/react-core";
1818

1919
export function HomePage({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
2020
const playState = usePlayState();
@@ -32,16 +32,12 @@ export function HomePage({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
3232

3333
const params = useParams<{ playlistId: string }>();
3434
const playlistId = useAccountSelector({
35-
select: (me) =>
36-
params.playlistId ??
37-
(me.$isLoaded ? me.root.$jazz.refs.rootPlaylist.id : undefined),
35+
select: (me) => params.playlistId ?? me.root.$jazz.refs.rootPlaylist.id,
3836
});
3937

40-
const playlist = useCoState(PlaylistWithTracks, playlistId, {
41-
select: (playlist) => (playlist.$isLoaded ? playlist : undefined),
42-
});
38+
const playlist = useSuspenseCoState(PlaylistWithTracks, playlistId);
4339

44-
const membersIds = playlist?.$jazz.owner.members.map((member) => member.id);
40+
const membersIds = playlist.$jazz.owner.members.map((member) => member.id);
4541
const isRootPlaylist = !params.playlistId;
4642
const canEdit = useAccountSelector({
4743
select: (me) => Boolean(playlist && me.canWrite(playlist)),

examples/music-player/src/5_useMediaPlayer.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,7 @@ export function useMediaPlayer() {
1515
const [loading, setLoading] = useState<string | null>(null);
1616

1717
const activeTrackId = useAccountSelector({
18-
select: (me) =>
19-
me.$isLoaded ? me.root.$jazz.refs.activeTrack?.id : undefined,
18+
select: (me) => me.root.$jazz.refs.activeTrack?.id,
2019
});
2120
// Reference used to avoid out-of-order track loads
2221
const lastLoadedTrackId = useRef<string | null>(null);

examples/music-player/src/components/AuthModal.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export function AuthModal({ open, onOpenChange }: AuthModalProps) {
1919
const [isSignUp, setIsSignUp] = useState(true);
2020
const [error, setError] = useState<string | null>(null);
2121
const profileName = useAccountSelector({
22-
select: (me) => (me.$isLoaded ? me.profile.name : undefined),
22+
select: (me) => me.profile.name,
2323
});
2424

2525
const auth = usePasskeyAuth({
@@ -59,7 +59,6 @@ export function AuthModal({ open, onOpenChange }: AuthModalProps) {
5959
const shouldShowTransferRootPlaylist = useAccountSelector({
6060
select: (me) =>
6161
!isSignUp &&
62-
me.$isLoaded &&
6362
me.root.rootPlaylist.tracks.some((track) => !track.isExampleTrack),
6463
});
6564

examples/music-player/src/components/MusicTrackRow.tsx

Lines changed: 52 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,13 @@ import {
1616
DropdownMenuTrigger,
1717
} from "@/components/ui/dropdown-menu";
1818
import { cn } from "@/lib/utils";
19-
import { useAccount, useCoState } from "jazz-tools/react";
2019
import { MoreHorizontal, Pause, Play } from "lucide-react";
21-
import { Fragment, useCallback, useState } from "react";
20+
import { Fragment, Suspense, useCallback, useState } from "react";
2221
import { EditTrackDialog } from "./RenameTrackDialog";
2322
import { Waveform } from "./Waveform";
2423
import { Button } from "./ui/button";
2524
import { useAccountSelector } from "@/components/AccountProvider.tsx";
25+
import { useSuspenseCoState, useSuspenseAccount } from "jazz-tools/react";
2626

2727
function isPartOfThePlaylist(trackId: string, playlist: PlaylistWithTracks) {
2828
return Array.from(playlist.tracks.$jazz.refs).some((t) => t.id === trackId);
@@ -39,43 +39,32 @@ export function MusicTrackRow({
3939
onClick: (track: MusicTrack) => void;
4040
index: number;
4141
}) {
42-
const track = useCoState(MusicTrack, trackId);
42+
const track = useSuspenseCoState(MusicTrack, trackId);
4343
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
4444
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
4545
const [isHovered, setIsHovered] = useState(false);
4646

47-
const playlists = useAccount(MusicaAccountWithPlaylists, {
48-
select: (account) =>
49-
account.$isLoaded && account.root.playlists.$isLoaded
50-
? account.root.playlists
51-
: undefined,
52-
});
53-
5447
const isActiveTrack = useAccountSelector({
55-
select: (me) => me.$isLoaded && me.root.activeTrack?.$jazz.id === trackId,
48+
select: (me) => me.root.activeTrack?.$jazz.id === trackId,
5649
});
5750

5851
const canEditTrack = useAccountSelector({
59-
select: (me) => me.$isLoaded && track.$isLoaded && me.canWrite(track),
52+
select: (me) => me.canWrite(track),
6053
});
6154

6255
function handleTrackClick() {
63-
if (!track.$isLoaded) return;
6456
onClick(track);
6557
}
6658

6759
function handleAddToPlaylist(playlist: Playlist) {
68-
if (!track.$isLoaded) return;
6960
addTrackToPlaylist(playlist, track);
7061
}
7162

7263
function handleRemoveFromPlaylist(playlist: Playlist) {
73-
if (!track.$isLoaded) return;
7464
removeTrackFromPlaylist(playlist, track);
7565
}
7666

7767
function deleteTrack() {
78-
if (!track.$isLoaded) return;
7968
removeTrackFromAllPlaylists(track);
8069
}
8170

@@ -89,7 +78,6 @@ export function MusicTrackRow({
8978
}, []);
9079

9180
const showWaveform = isHovered || isActiveTrack;
92-
const trackTitle = track.$isLoaded ? track.title : "";
9381

9482
return (
9583
<li
@@ -112,7 +100,7 @@ export function MusicTrackRow({
112100
isActiveTrack && "md:opacity-100 opacity-100",
113101
)}
114102
onClick={handleTrackClick}
115-
aria-label={`${isPlaying ? "Pause" : "Play"} ${trackTitle}`}
103+
aria-label={`${isPlaying ? "Pause" : "Play"} ${track.title}`}
116104
>
117105
{isPlaying ? (
118106
<Pause height={16} width={16} fill="currentColor" />
@@ -133,11 +121,11 @@ export function MusicTrackRow({
133121
onClick={handleTrackClick}
134122
className="flex items-center overflow-hidden text-ellipsis whitespace-nowrap cursor-pointer flex-1 min-w-0"
135123
>
136-
{trackTitle}
124+
{track.title}
137125
</button>
138126

139127
{/* Waveform that appears on hover */}
140-
{track.$isLoaded && showWaveform && (
128+
{showWaveform && (
141129
<div className="flex-1 min-w-0 px-2 items-center hidden md:flex">
142130
<Waveform
143131
track={track}
@@ -155,40 +143,28 @@ export function MusicTrackRow({
155143
<Button
156144
variant="ghost"
157145
className="h-8 w-8 p-0"
158-
aria-label={`Open ${trackTitle} menu`}
146+
aria-label={`Open ${track.title} menu`}
159147
>
160148
<span className="sr-only">Open menu</span>
161149
<MoreHorizontal className="h-4 w-4" />
162150
</Button>
163151
</DropdownMenuTrigger>
164-
<DropdownMenuContent align="end">
165-
<DropdownMenuItem onSelect={handleEdit}>Edit</DropdownMenuItem>
166-
{playlists
167-
?.filter((playlist) => playlist.$isLoaded)
168-
.map((playlist, playlistIndex) => (
169-
<Fragment key={playlistIndex}>
170-
{isPartOfThePlaylist(trackId, playlist) ? (
171-
<DropdownMenuItem
172-
key={`remove-${playlistIndex}`}
173-
onSelect={() => handleRemoveFromPlaylist(playlist)}
174-
>
175-
Remove from {playlist.title}
176-
</DropdownMenuItem>
177-
) : (
178-
<DropdownMenuItem
179-
key={`add-${playlistIndex}`}
180-
onSelect={() => handleAddToPlaylist(playlist)}
181-
>
182-
Add to {playlist.title}
183-
</DropdownMenuItem>
184-
)}
185-
</Fragment>
186-
))}
187-
</DropdownMenuContent>
152+
<Suspense>
153+
<DropdownMenuContent align="end">
154+
<DropdownMenuItem onSelect={handleEdit}>Edit</DropdownMenuItem>
155+
<PlaylistItems
156+
onRemove={handleRemoveFromPlaylist}
157+
onAdd={handleAddToPlaylist}
158+
isPartOfThePlaylist={(playlist) =>
159+
isPartOfThePlaylist(trackId, playlist)
160+
}
161+
/>
162+
</DropdownMenuContent>
163+
</Suspense>
188164
</DropdownMenu>
189165
</div>
190166
)}
191-
{track.$isLoaded && isEditDialogOpen && (
167+
{isEditDialogOpen && (
192168
<EditTrackDialog
193169
track={track}
194170
isOpen={isEditDialogOpen}
@@ -199,3 +175,33 @@ export function MusicTrackRow({
199175
</li>
200176
);
201177
}
178+
179+
function PlaylistItems(props: {
180+
onRemove: (playlist: PlaylistWithTracks) => void;
181+
onAdd: (playlist: PlaylistWithTracks) => void;
182+
isPartOfThePlaylist: (playlist: PlaylistWithTracks) => boolean;
183+
}) {
184+
const playlists = useSuspenseAccount(MusicaAccountWithPlaylists, {
185+
select: (account) => account.root.playlists,
186+
});
187+
188+
const loadedPlaylists = playlists.filter((playlist) => playlist.$isLoaded);
189+
190+
return (
191+
<>
192+
{loadedPlaylists.map((playlist, playlistIndex) => (
193+
<Fragment key={playlistIndex}>
194+
{props.isPartOfThePlaylist(playlist) ? (
195+
<DropdownMenuItem onSelect={() => props.onRemove(playlist)}>
196+
Remove from {playlist.title}
197+
</DropdownMenuItem>
198+
) : (
199+
<DropdownMenuItem onSelect={() => props.onAdd(playlist)}>
200+
Add to {playlist.title}
201+
</DropdownMenuItem>
202+
)}
203+
</Fragment>
204+
))}
205+
</>
206+
);
207+
}

examples/music-player/src/components/PlayerControls.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export function PlayerControls({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
1515

1616
const activePlaylistTitle = useAccountSelector({
1717
select: (me) =>
18-
me.$isLoaded && me.root.activePlaylist?.$isLoaded
18+
me.root.activePlaylist?.$isLoaded
1919
? (me.root.activePlaylist.title ?? "All tracks")
2020
: "All tracks",
2121
});

examples/music-player/src/components/ProfileForm.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ export function ProfileForm({
3434
className = "",
3535
}: ProfileFormProps) {
3636
const profile = useAccountSelector({
37-
select: (me) => (me.$isLoaded ? me.profile : undefined),
37+
select: (me) => me.profile,
3838
});
3939

4040
const [username, setUsername] = useState(

examples/music-player/src/components/WelcomeScreen.tsx

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,20 +9,17 @@ export function WelcomeScreen() {
99
});
1010

1111
const { handleCompleteSetup } = useAccountSelector({
12-
select: (me) =>
13-
me.$isLoaded
14-
? {
15-
id: me.root.$jazz.id,
16-
handleCompleteSetup: () => {
17-
me.root.$jazz.set("accountSetupCompleted", true);
18-
},
19-
}
20-
: { id: null, handleCompleteSetup: null },
12+
select: (me) => ({
13+
id: me.root.$jazz.id,
14+
handleCompleteSetup: () => {
15+
me.root.$jazz.set("accountSetupCompleted", true);
16+
},
17+
}),
2118
equalityFn: (a, b) => a.id === b.id,
2219
});
2320

2421
const initialUsername = useAccountSelector({
25-
select: (me) => (me.$isLoaded ? me.profile.name : undefined),
22+
select: (me) => me.profile.name,
2623
});
2724

2825
if (!handleCompleteSetup) return null;

packages/cojson/src/exports.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@ import {
66
enablePermissionErrors,
77
type AvailableCoValueCore,
88
} from "./coValueCore/coValueCore.js";
9-
import { CoValueUniqueness } from "./coValueCore/verifiedState.js";
9+
import {
10+
CoValueHeader,
11+
CoValueUniqueness,
12+
} from "./coValueCore/verifiedState.js";
1013
import {
1114
ControlledAccount,
1215
ControlledAgent,
@@ -78,7 +81,9 @@ import { getDependedOnCoValuesFromRawData } from "./coValueCore/utils.js";
7881
import {
7982
CO_VALUE_LOADING_CONFIG,
8083
TRANSACTION_CONFIG,
84+
setCoValueLoadingMaxRetries,
8185
setCoValueLoadingRetryDelay,
86+
setCoValueLoadingTimeout,
8287
setIncomingMessagesTimeBudget,
8388
setMaxRecommendedTxSize,
8489
} from "./config.js";
@@ -118,6 +123,8 @@ export const cojsonInternals = {
118123
CO_VALUE_PRIORITY,
119124
setIncomingMessagesTimeBudget,
120125
setCoValueLoadingRetryDelay,
126+
setCoValueLoadingMaxRetries,
127+
setCoValueLoadingTimeout,
121128
ConnectedPeerChannel,
122129
textEncoder,
123130
textDecoder,
@@ -186,6 +193,7 @@ export type {
186193
AccountRole,
187194
AvailableCoValueCore,
188195
PeerState,
196+
CoValueHeader,
189197
};
190198

191199
export * from "./storage/index.js";

packages/cojson/src/localNode.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import {
3131
} from "./coValues/group.js";
3232
import { CO_VALUE_LOADING_CONFIG } from "./config.js";
3333
import { AgentSecret, CryptoProvider } from "./crypto/crypto.js";
34-
import { AgentID, RawCoID, SessionID, isAgentID } from "./ids.js";
34+
import { AgentID, RawCoID, SessionID, isAgentID, isRawCoID } from "./ids.js";
3535
import { logger } from "./logger.js";
3636
import { StorageAPI } from "./storage/index.js";
3737
import { Peer, PeerID, SyncManager } from "./sync.js";
@@ -387,13 +387,17 @@ export class LocalNode {
387387
return coValue;
388388
}
389389

390+
hasLoadingSources(id: RawCoID) {
391+
return this.storage || this.syncManager.getServerPeers(id).length > 0;
392+
}
393+
390394
/** @internal */
391395
async loadCoValueCore(
392396
id: RawCoID,
393397
skipLoadingFromPeer?: PeerID,
394398
skipRetry?: boolean,
395399
): Promise<CoValueCore> {
396-
if (typeof id !== "string" || !id.startsWith("co_z")) {
400+
if (!isRawCoID(id)) {
397401
throw new TypeError(
398402
`Trying to load CoValue with invalid id ${Array.isArray(id) ? JSON.stringify(id) : id}`,
399403
);
@@ -421,6 +425,8 @@ export class LocalNode {
421425
const peers = this.syncManager.getServerPeers(id, skipLoadingFromPeer);
422426

423427
if (!this.storage && peers.length === 0) {
428+
// Flags the coValue as unavailable
429+
coValue.markNotFoundInPeer("storage");
424430
return coValue;
425431
}
426432

0 commit comments

Comments
 (0)