Compare commits

...

1 Commits

Author SHA1 Message Date
Guido D'Orsi
6bb19aaee4 feat: remove experimental mark from SSR rendering 2025-06-10 15:18:07 +02:00
12 changed files with 82 additions and 405 deletions

View File

@@ -37,24 +37,24 @@ When a user loads a Jazz application for the first time, we create a new Account
## Detecting Authentication State
You can detect the current authentication state using `useAccountOrGuest` and `useIsAuthenticated`.
You can detect the current authentication state using `useAccount` and `useIsAuthenticated`.
<ContentByFramework framework="react">
<CodeGroup>
```tsx twoslash
import * as React from "react";
// ---cut---
import { useAccountOrGuest, useIsAuthenticated } from "jazz-react";
import { useAccount, useIsAuthenticated } from "jazz-react";
function AuthStateIndicator() {
const { me } = useAccountOrGuest();
const { agent } = useAccount();
const isAuthenticated = useIsAuthenticated();
// Check if guest mode is enabled in JazzProvider
const isGuest = me._type !== "Account"
const isGuest = agent._type !== "Account"
// Anonymous authentication: has an account but not fully authenticated
const isAnonymous = me._type === "Account" && !isAuthenticated;
const isAnonymous = agent._type === "Account" && !isAuthenticated;
return (
<div>
{isGuest && <span>Guest Mode</span>}
@@ -136,7 +136,7 @@ export async function onAnonymousAccountDiscarded(
```
</CodeGroup>
To see how this works, try uploading a song in the [music player demo](https://music.demo.jazz.tools/) and then log in with an existing account.
To see how this works, try uploading a song in the [music player demo](https://music-demo.jazz.tools/) and then log in with an existing account.
## Provider Configuration for Authentication

View File

@@ -4,7 +4,7 @@ export const metadata = {
import { CodeGroup } from "@/components/forMdx";
# React Installation and Setup
# Installation and Setup
Add Jazz to your React application in minutes. This setup covers standard React apps, Next.js, and gives an overview of experimental SSR approaches.
@@ -191,220 +191,34 @@ export function Profile() {
### SSR Support (Experimental)
For server-side rendering, Jazz offers experimental approaches:
For server-side rendering, Jazz offers an `enableSSR` property for use with `JazzProvider`:
- Pure SSR
- Hybrid SSR + Client Hydration
#### Pure SSR
Use Jazz in server components by directly loading data with `CoValue.load()`.
{/*
<CodeGroup>
```tsx twoslash
// @errors: 18047
// @filename: schema.ts
import { co, CoList, CoMap } from "jazz-tools";
export class MyItem extends CoMap {
title = co.string;
}
export class MyCollection extends CoList.Of(co.ref(MyItem)) {}
// @filename: PublicData.tsx
import * as React from "react";
import { ID } from "jazz-tools";
const collectionID = "co_z123" as ID<MyCollection>;
// ---cut---
// Server Component (no "use client" directive)
import { MyCollection, MyItem } from "./schema";
"use client";
export default async function PublicData() {
// Load data directly in the server component
const items = await MyCollection.load(collectionID);
if (!items) {
return <div>Loading...</div>;
}
import { JazzProvider } from "jazz-react";
export function Jazz({ children }: { children: React.ReactNode }) {
return (
<ul>
{items.map(item => (
item ? <li key={item.id}>{item.title}</li> : null
))}
</ul>
<JazzProvider
enableSSR
sync={{
peer: `wss://cloud.jazz.tools/`,
}}
>
{children}
</JazzProvider>
);
}
```
</CodeGroup>
*/}
This works well for public data accessible to the server account.
Components are rendered on the server with no data, while data is loaded client-side.
#### Hybrid SSR + Client Hydration
For more complex cases, you can pre-render on the server and hydrate on the client:
1. Create a shared rendering component.
{/*
<CodeGroup>
```tsx twoslash
// @filename: schema.ts
import { co, CoList, CoMap } from "jazz-tools";
export class MyItem extends CoMap {
title = co.string;
}
// @filename: ItemList.tsx
import * as React from "react";
import { MyItem } from "./schema";
// ---cut---
// ItemList.tsx - works in both server and client contexts
export function ItemList({ items }: { items: MyItem[] }) {
return (
<ul>
{items.map(item => (
<li key={item.id}>{item.title}</li>
))}
</ul>
);
}
```
</CodeGroup>
*/}
2. Create a client hydration component.
{/*
<CodeGroup>
```tsx twoslash
// @filename: schema.ts
import { co, CoList, CoMap } from "jazz-tools";
export class MyItem extends CoMap {
title = co.string;
}
export class MyCollection extends CoList.Of(co.ref(MyItem)) {}
// @filename: ItemList.tsx
import * as React from "react";
import { MyItem } from "./schema";
export function ItemList({ items }: { items: MyItem[] }) {
return (
<ul>
{items.map(item => (
<li key={item.id}>{item.title}</li>
))}
</ul>
);
}
// @filename: ItemListHydrator.tsx
// ItemListHydrator.tsx
import * as React from "react";
import { useCoState } from "jazz-react";
import { ID } from "jazz-tools";
const myCollectionID = "co_z123" as ID<MyCollection>;
// ---cut---
"use client"
import { MyCollection, MyItem } from "./schema";
import { ItemList } from "./ItemList";
export function ItemListHydrator({ initialItems }: { initialItems: MyItem[] }) {
// Hydrate with real-time data once client loads
const myCollection = useCoState(MyCollection, myCollectionID);
// Filter out nulls for type safety
const items = Array.from(myCollection?.values() || []).filter(
(item): item is MyItem => !!item
);
// Use server data until client data is available
const displayItems = items || initialItems;
return <ItemList items={displayItems} />;
}
```
</CodeGroup>
*/}
3. Create a server component that pre-loads data.
{/*
<CodeGroup>
```tsx twoslash
// @filename: schema.ts
import { co, CoList, CoMap } from "jazz-tools";
export class MyItem extends CoMap {
title = co.string;
}
export class MyCollection extends CoList.Of(co.ref(MyItem)) {}
// @filename: ItemList.tsx
import * as React from "react";
import { MyItem } from "./schema";
export function ItemList({ items }: { items: MyItem[] }) {
return (
<ul>
{items.map(item => (
<li key={item.id}>{item.title}</li>
))}
</ul>
);
}
// @filename: ItemListHydrator.tsx
// ItemListHydrator.tsx
import * as React from "react";
import { useCoState } from "jazz-react";
import { ID } from "jazz-tools";
const myCollectionID = "co_z123" as ID<MyCollection>;
// ---cut---
"use client"
import { MyCollection, MyItem } from "./schema";
import { ItemList } from "./ItemList";
export function ItemListHydrator({ initialItems }: { initialItems: MyItem[] }) {
// Hydrate with real-time data once client loads
const myCollection = useCoState(MyCollection, myCollectionID);
// Filter out nulls for type safety
const items = Array.from(myCollection?.values() || []).filter(
(item): item is MyItem => !!item
);
// Use server data until client data is available
const displayItems = items || initialItems;
return <ItemList items={displayItems} />;
}
// @filename: ServerItemPage.tsx
import * as React from 'react';
import { ID } from "jazz-tools";
import { MyCollection, MyItem } from "./schema";
import { ItemListHydrator } from "./ItemListHydrator";
const myCollectionID = "co_z123" as ID<MyCollection>;
// ---cut---
// ServerItemPage.tsx
export default async function ServerItemPage() {
// Pre-load data on the server
const initialItems = await MyCollection.load(myCollectionID);
// Filter out nulls for type safety
const items = Array.from(initialItems?.values() || []).filter(
(item): item is MyItem => !!item
);
// Pass to client hydrator
return <ItemListHydrator initialItems={items} />;
}
```
</CodeGroup>
*/}
This approach gives you the best of both worlds: fast initial loading with server rendering, plus real-time updates on the client.
An [example Jazz application with SSR using Next.js](https://github.com/garden-co/jazz/tree/main/examples/jazz-nextjs) can be found on GitHub.
## Further Reading

View File

@@ -230,22 +230,7 @@ function useAccountSubscription<
return subscription.subscription;
}
function useAccount<A extends AccountClass<Account> | AnyAccountSchema>(
AccountSchema?: A,
): {
me: Loaded<A, true>;
logOut: () => void;
};
function useAccount<
A extends AccountClass<Account> | AnyAccountSchema,
R extends ResolveQuery<A>,
>(
AccountSchema: A,
options?: {
resolve?: ResolveQueryStrict<A, R>;
},
): { me: Loaded<A, R> | undefined | null; logOut: () => void };
function useAccount<
export function useAccount<
A extends AccountClass<Account> | AnyAccountSchema,
R extends ResolveQuery<A>,
>(
@@ -289,48 +274,6 @@ function useAccount<
};
}
function useAccountOrGuest<A extends AccountClass<Account> | AnyAccountSchema>(
AccountSchema?: A,
): {
me: Loaded<A, true> | AnonymousJazzAgent;
};
function useAccountOrGuest<
A extends AccountClass<Account> | AnyAccountSchema,
R extends ResolveQuery<A>,
>(
AccountSchema?: A,
options?: { resolve?: ResolveQueryStrict<A, R> },
): {
me: Loaded<A, R> | undefined | null | AnonymousJazzAgent;
};
function useAccountOrGuest<
A extends AccountClass<Account> | AnyAccountSchema,
R extends ResolveQuery<A>,
>(
AccountSchema: A = Account as unknown as A,
options?: { resolve?: ResolveQueryStrict<A, R> },
): {
me:
| InstanceOfSchema<A>
| Loaded<A, R>
| undefined
| null
| AnonymousJazzAgent;
} {
const context = useJazzContext<InstanceOfSchema<A>>();
const account = useAccount(AccountSchema, options);
if ("me" in context) {
return {
me: account.me,
};
} else {
return { me: context.guest };
}
}
export { useAccount, useAccountOrGuest };
export function experimental_useInboxSender<
I extends CoValue,
O extends CoValue | undefined,

View File

@@ -1,16 +1,6 @@
// @vitest-environment happy-dom
import {
Account,
CoMap,
Loaded,
RefsToResolve,
Resolved,
co,
coField,
z,
zodSchemaToCoSchema,
} from "jazz-tools";
import { RefsToResolve, co, z, zodSchemaToCoSchema } from "jazz-tools";
import { beforeEach, describe, expect, it } from "vitest";
import { useAccount, useJazzContextManager } from "../hooks.js";
import { useIsAuthenticated } from "../index.js";
@@ -84,14 +74,21 @@ describe("useAccount", () => {
const isAuthenticated = useIsAuthenticated();
const account = useAccount();
if (!accounts.includes(account.me.id)) {
if (account.me && !accounts.includes(account.me.id)) {
accounts.push(account.me.id);
}
updates.push({
isAuthenticated,
accountIndex: accounts.indexOf(account.me.id),
});
if (account.me) {
updates.push({
isAuthenticated,
accountIndex: accounts.indexOf(account.me.id),
});
} else {
updates.push({
isAuthenticated,
accountIndex: -1,
});
}
return { isAuthenticated, account };
},
@@ -111,7 +108,7 @@ describe("useAccount", () => {
});
expect(result.current?.isAuthenticated).toBe(false);
expect(result.current?.account?.me.id).not.toBe(id);
expect(result.current?.account?.me?.id).not.toBe(id);
expect(updates).toMatchInlineSnapshot(`
[
@@ -144,14 +141,21 @@ describe("useAccount", () => {
const account = useAccount();
const contextManager = useJazzContextManager();
if (!accounts.includes(account.me.id)) {
if (account.me && !accounts.includes(account.me.id)) {
accounts.push(account.me.id);
}
updates.push({
isAuthenticated,
accountIndex: accounts.indexOf(account.me.id),
});
if (account.me) {
updates.push({
isAuthenticated,
accountIndex: accounts.indexOf(account.me.id),
});
} else {
updates.push({
isAuthenticated,
accountIndex: -1,
});
}
return { isAuthenticated, account, contextManager };
},
@@ -175,7 +179,7 @@ describe("useAccount", () => {
});
expect(result.current?.isAuthenticated).toBe(true);
expect(result.current?.account?.me.id).not.toBe(id);
expect(result.current?.account?.me?.id).not.toBe(id);
expect(updates).toMatchInlineSnapshot(`
[

View File

@@ -1,91 +0,0 @@
// @vitest-environment happy-dom
import {
Account,
CoMap,
Loaded,
RefsToResolve,
co,
coField,
z,
zodSchemaToCoSchema,
} from "jazz-tools";
import { describe, expect, it } from "vitest";
import { useAccountOrGuest } from "../index.js";
import { createJazzTestAccount, createJazzTestGuest } from "../testing.js";
import { renderHook } from "./testUtils.js";
describe("useAccountOrGuest", () => {
const AccountRoot = co.map({
value: z.string(),
});
const AccountSchema = co
.account({
root: AccountRoot,
profile: co.profile(),
})
.withMigration((account, creationProps) => {
if (!account._refs.root) {
account.root = AccountRoot.create({ value: "123" }, { owner: account });
}
});
it("should return the correct me value", async () => {
const account = await createJazzTestAccount();
const { result } = renderHook(() => useAccountOrGuest(), {
account,
});
expect(result.current?.me).toEqual(account);
});
it("should return the guest agent if the account is a guest", async () => {
const account = await createJazzTestGuest();
const { result } = renderHook(() => useAccountOrGuest(), {
account,
});
expect(result.current?.me).toBe(account.guest);
});
it("should load nested values if requested", async () => {
const account = await createJazzTestAccount({
AccountSchema: zodSchemaToCoSchema(AccountSchema),
});
const { result } = renderHook(
() =>
useAccountOrGuest(AccountSchema, {
resolve: {
root: true,
},
}),
{
account,
},
);
// @ts-expect-error
expect(result.current.me?.root?.value).toBe("123");
});
it("should not load nested values if the account is a guest", async () => {
const account = await createJazzTestGuest();
const { result } = renderHook(
() =>
useAccountOrGuest(AccountSchema, {
resolve: {
root: true,
},
}),
{
account,
},
);
expect(result.current.me).toBe(account.guest);
});
});

View File

@@ -17,13 +17,11 @@ export function useAcceptInvite<S extends CoValueOrZodSchema>({
}): void {
const context = useJazzContext();
if (!("me" in context)) {
throw new Error(
"useAcceptInvite can't be used in a JazzProvider with auth === 'guest'.",
);
}
useEffect(() => {
if (!("me" in context)) {
return;
}
const handleInvite = () => {
const result = consumeInviteLinkFromWindowLocation({
as: context.me,
@@ -43,7 +41,7 @@ export function useAcceptInvite<S extends CoValueOrZodSchema>({
window.addEventListener("hashchange", handleInvite);
return () => window.removeEventListener("hashchange", handleInvite);
}, [onAccept]);
}, [onAccept, context]);
}
export {

View File

@@ -1,5 +1,6 @@
import {
Account,
AnonymousJazzAgent,
CoValue,
CoValueClass,
ID,
@@ -15,7 +16,10 @@ export function waitForCoValue<
coMap: CoValueClass<T>,
valueId: ID<T>,
predicate: (value: T) => boolean,
options: { loadAs: Account; resolve?: RefsToResolveStrict<T, R> },
options: {
loadAs: Account | AnonymousJazzAgent;
resolve?: RefsToResolveStrict<T, R>;
},
) {
return new Promise<T>((resolve, reject) => {
function subscribe() {

View File

@@ -8,23 +8,23 @@ export function DownloaderPeer(props: { testCoMapId: string }) {
const [synced, setSynced] = useState(false);
useEffect(() => {
async function run(me: Account, uploadedFileId: string) {
async function run(me: Account | null | undefined, uploadedFileId: string) {
const uploadedFile = await UploadedFile.load(uploadedFileId, {
loadAs: me,
loadAs: me ?? undefined,
});
if (!uploadedFile) {
throw new Error("Uploaded file not found");
}
me.waitForAllCoValuesSync().then(() => {
me?.waitForAllCoValuesSync().then(() => {
setSynced(true);
});
uploadedFile.coMapDownloaded = true;
await FileStream.loadAsBlob(uploadedFile._refs.file!.id, {
loadAs: me,
loadAs: me ?? undefined,
});
uploadedFile.syncCompleted = true;

View File

@@ -37,7 +37,7 @@ export function UploaderPeer() {
setSyncDuration(null);
setUploadedFileId(file.id);
account.me.waitForAllCoValuesSync().then(() => {
account.me?.waitForAllCoValuesSync().then(() => {
setSynced(true);
});
@@ -47,7 +47,7 @@ export function UploaderPeer() {
zodSchemaToCoSchema(UploadedFile),
file.id,
(value) => value.syncCompleted,
{ loadAs: account.me },
{ loadAs: account.agent },
);
iframe.remove();

View File

@@ -1,8 +1,11 @@
import { Account, Group, co } from "jazz-tools";
import { UploadedFile } from "../schema";
export async function generateTestFile(me: Account, bytes: number) {
const group = Group.create({ owner: me });
export async function generateTestFile(
me: Account | null | undefined,
bytes: number,
) {
const group = Group.create(me ? { owner: me } : undefined);
group.addMember("everyone", "writer");
const ownership = { owner: group };

View File

@@ -20,24 +20,26 @@ export function InboxPage() {
const [id] = useState(getIdParam);
const { me } = useAccount();
const [pingPong, setPingPong] = useState<PingPong | null>(null);
const iframeRef = useRef<HTMLIFrameElement>(null);
const iframeRef = useRef<HTMLIFrameElement>();
useEffect(() => {
let unsubscribe = () => {};
let unmounted = false;
async function load() {
const inbox = await Inbox.load(me);
const inbox = me ? await Inbox.load(me) : undefined;
if (unmounted) return;
unsubscribe = inbox.subscribe(PingPong, async (message) => {
const pingPong = PingPong.create(
{ ping: message.ping, pong: Date.now() },
{ owner: message._owner },
);
setPingPong(pingPong);
});
unsubscribe = inbox
? inbox.subscribe(PingPong, async (message) => {
const pingPong = PingPong.create(
{ ping: message.ping, pong: Date.now() },
{ owner: message._owner },
);
setPingPong(pingPong);
})
: () => {};
}
load();

View File

@@ -48,7 +48,7 @@ export function Sharing() {
if (
member.account &&
member.role !== "admin" &&
member.account.id !== me.id
member.account.id !== me?.id
) {
coMapGroup.removeMember(member.account);
}