Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 30 additions & 26 deletions apps/demo/e2e/demo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1123,34 +1123,38 @@ test("AtomRef: an autosave stops when the server's copy equals the draft", async
await expect(requests).toHaveCount(3);
});

test("scoped atoms: each provider has its own atom", async ({ page }) => {
test("scoped atoms: each table provides its own state", async ({ page }) => {
await page.goto("/scoped-atoms");
await page.waitForLoadState("networkidle");
const note = (name: string) => page.getByRole("group", { exact: true, name });
const draft = (name: string) =>
note(name).getByLabel("Draft", { exact: true });

// Each editor provides a draft of its own: its parts follow it, the other stays empty.
await draft("Note A").fill("Some *new* words");
await expect(note("Note A")).toContainText("3 words");
await expect(note("Note A").locator("em")).toHaveText("new");
await expect(note("Note B")).toContainText("0 words");
await expect(note("Note B")).toContainText("Nothing written yet.");
await expect(draft("Note B")).toHaveValue("");

// One draft provided above both: a change in either shows in both.
await page.getByRole("button", { name: "One draft for both" }).click();
await draft("Note B").fill("Shared **draft**");
await expect(draft("Note A")).toHaveValue("Shared **draft**");
await expect(note("Note A")).toContainText("2 words");
await expect(note("Note A").locator("strong")).toHaveText("draft");
await note("Note A").getByRole("button", { name: "Clear" }).click();
await expect(draft("Note B")).toHaveValue("");

// Back to a draft per editor: each starts empty again.
await page.getByRole("button", { name: "A draft per editor" }).click();
await expect(draft("Note A")).toHaveValue("");
await expect(draft("Note B")).toHaveValue("");
const example = page.locator("[data-example]").first();
const table = (name: string) =>
example.getByRole("group", { exact: true, name });
const firstCell = (name: string, column: number) =>
table(name).locator("tbody tr").first().locator("td").nth(column);

// Sorting, searching and selecting in one table leaves the other alone.
await table("Orders")
.getByRole("button", { name: /^Total/u })
.click();
await expect(firstCell("Orders", 4)).toHaveText("$23");
await table("Orders").getByLabel("Search orders").fill("ada");
await expect(table("Orders")).toContainText("2 orders · page 1 of 1");
await table("Orders").getByLabel("Select #1042").check();
await expect(table("Orders")).toContainText("1 selected");

await expect(table("Customers")).not.toContainText("selected");
await expect(table("Customers")).toContainText("7 customers · page 1 of 2");
await expect(firstCell("Customers", 1)).toHaveText("Ada Lovelace");

// Paging the customers leaves the orders' search and page alone.
await table("Customers").getByRole("button", { name: "Next" }).click();
await expect(table("Customers")).toContainText("page 2 of 2");
await expect(table("Orders")).toContainText("2 orders · page 1 of 1");

// The X-ray outlines each table that provides its own state.
await example.getByRole("button", { exact: true, name: "X-ray" }).click();
await expect(table("Orders")).toContainText("Orders: Table.provide()");
await expect(table("Customers")).toContainText("Customers: Table.provide()");
});

test("browser atoms: localStorage kvs survives a reload, the server renders its default", async ({
Expand Down
48 changes: 21 additions & 27 deletions apps/demo/e2e/features-atoms.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,32 +224,26 @@ test.describe("Atoms pages: an example for every feature", () => {
test("scoped atoms: use finds the nearest provider", async ({ page }) => {
await page.goto("/scoped-atoms");
await page.waitForLoadState("networkidle");
const post = page.getByRole("group", { exact: true, name: "Post" });
const reply = page.getByRole("group", { exact: true, name: "Reply" });
// The reply is inside the post, so the post's own input is its first.
const postDraft = post.getByLabel("Draft", { exact: true }).first();
const replyDraft = reply.getByLabel("Draft", { exact: true });

await postDraft.fill("A post");
await expect(postDraft).toHaveValue("A post");
await expect(replyDraft).toHaveValue("");
await expect(reply).toContainText("0 words");
await expect(reply).toContainText("Nothing written yet.");

await replyDraft.fill("A short reply");
await expect(reply).toContainText("3 words");
await expect(postDraft).toHaveValue("A post");
// Each part names the editor whose draft Draft.use() found: the nearest.
const found = (group: typeof post) =>
group.getByTitle("The editor whose draft Draft.use() found");
await expect(found(reply)).toHaveText(["Reply", "Reply", "Reply"]);
await expect(found(post)).toHaveText([
"Post",
"Post",
"Post",
"Reply",
"Reply",
"Reply",
]);
const example = page.locator("[data-example]").nth(1);
const orders = example.getByRole("group", { exact: true, name: "Orders" });
const items = example.getByRole("group", {
exact: true,
name: "Line items",
});

// The line items' table sits inside the orders' and has state of its own.
await items.getByLabel("Select keyboard").check();
await expect(items).toContainText("1 selected");
await expect(orders.getByLabel("Select #1042")).not.toBeChecked();
await items.getByRole("button", { name: /^Price/u }).click();
await expect(items.locator("tbody tr td").nth(1)).toHaveText("Cable");
await expect(orders).toContainText("9 orders · page 1 of 3");

// Collapsing the order unmounts its table; expanding it again starts afresh.
await orders.getByRole("button", { name: "Hide #1042" }).click();
await expect(items).toHaveCount(0);
await orders.getByRole("button", { name: "Show #1042" }).click();
await expect(items.getByLabel("Select keyboard")).not.toBeChecked();
await expect(items).not.toContainText("selected");
});
});
18 changes: 14 additions & 4 deletions apps/demo/src/lib/docs/kit/browser-frame.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -11,27 +11,36 @@
```

`bar` renders a strip between the links and the page, for what the layout itself shows.

For a single page, leave out `pages` and pass the `address` to show: there are then no links.

```svelte
<BrowserFrame address="example.app/feed">…</BrowserFrame>
```
-->
<script lang="ts" generics="Page extends string">
import type { Snippet } from "svelte";

interface Props {
/** The address bar's text, for a frame with no `pages`. */
readonly address?: string;
/** Shown between the page links and the page: the layout's own content. */
readonly bar?: Snippet;
readonly children: Snippet;
/** The current page, one of `pages`. */
page: Page;
readonly pages: readonly Page[];
page?: Page;
readonly pages?: readonly Page[];
}

let { bar, children, page = $bindable(), pages }: Props = $props();
let { address, bar, children, page = $bindable(), pages }: Props = $props();
</script>

<div class="frame not-prose">
<div class="chrome">
<span aria-hidden="true" class="dots"><span></span><span></span><span></span></span>
<span class="address" data-testid="frame-address">example.app/{page}</span>
<span class="address" data-testid="frame-address">{address ?? `example.app/${page}`}</span>
</div>
{#if pages}
<nav aria-label="Pages" class="links">
{#each pages as name (name)}
<button
Expand All @@ -44,6 +53,7 @@
</button>
{/each}
</nav>
{/if}
{#if bar}<div class="bar">{@render bar()}</div>{/if}
<div class="page">{@render children()}</div>
</div>
Expand Down
25 changes: 25 additions & 0 deletions apps/demo/src/routes/hydration/+page.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,31 @@ The prefix doesn't name the client. Two RPC clients with a procedure of the same

</Aside>

### Scoped atoms

A [scoped atom](/scoped-atoms) makes a new atom for each component that provides it, so if that atom is serializable, every copy with the same input gets the same key. That's an easy way to break the rule above. Say each comment in a thread provides its author with `User.provide(comment.authorId)`, and Alice writes two comments:

1. The first comment's provider makes an atom with the key `app/user-a`, and the server records that atom's value under it.
2. The second comment's provider makes another atom, with the same key.
3. The server has two atoms for one key and can't tell whether they mean the same thing, so the render throws `Two different atoms share the serialization key`.

A user's data belongs to the user, not to the comment showing it, so make it a family, and let the scoped atom provide the family's atom, as in [Using both](/scoped-atoms#using-both):

```ts
export const userAtom = Atom.family((id: string) =>
Atom.make(fetchUser(id)).pipe(
Atom.serializable({ key: `app/user-${id}`, schema: UserResult })
)
);

// A comment calls User.provide(comment.authorId); the parts inside it call User.use().
export const User = ScopedAtom.make((id: string) => userAtom(id), {
name: "User",
});
```

Both comments now hold the same atom, so the server sends Alice's data once. A scoped atom whose atom has no serialization key isn't sent to the browser at all: the browser computes it again.

## Which atoms to serialize

Make an atom serializable when its value is plain data and the server's result is the one the browser wants: a todo list, a user's profile, the prices on a product page. A schema can encode it, and the browser is spared a second request and a loading state.
Expand Down
Loading
Loading