web/repo: the loading strip goes off once the page's skeletons are gone, also on a page the app renders itself
Signed-off-by: dawn <dawn@tangled.org>
123456<script lang="ts"> // suspends the nearest boundary until the promise settles, for stories of pending states let { promise }: { promise: Promise<string> } = $props();</script>
<p>{await promise}</p>1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677<script module lang="ts"> import { defineMeta } from "@storybook/addon-svelte-csf"; import { expect, userEvent, waitFor } from "storybook/test"; import Pending from "#lib/components/ui/Pending.svelte"; import Awaits from "#storybook/Awaits.svelte";
const { Story } = defineMeta({ title: "UI/Pending", tags: ["autodocs"] });
const after = (ms: number, value: string) => new Promise<string>((resolve) => setTimeout(() => resolve(value), ms)); const suspended = () => document.documentElement.hasAttribute("data-suspended");</script>
<script lang="ts"> let shown = $state(true); let round = $state(0);</script>
<Story name="Resolves" play={async ({ canvas }) => { await expect(suspended()).toBe(true); await waitFor(() => expect(canvas.getByText("second")).toBeInTheDocument()); await waitFor(() => expect(suspended()).toBe(false)); }}> {#snippet template()} <Pending> {#snippet skeleton()}<p>loading</p>{/snippet} <Awaits promise={after(300, "first")} /> </Pending> <Pending> <Awaits promise={after(400, "second")} /> </Pending> {/snippet}</Story>
<Story name="Unmounts while pending" play={async ({ canvas }) => { await expect(suspended()).toBe(true); await userEvent.click(canvas.getByRole("button", { name: "hide" })); await waitFor(() => expect(suspended()).toBe(false)); }}> {#snippet template()} <button onclick={() => (shown = false)}>hide</button> {#if shown} <Pending> <Awaits promise={new Promise<string>(() => {})} /> </Pending> {/if} {/snippet}</Story>
<Story name="Suspends again and again" play={async ({ canvas }) => { for (let i = 0; i < 5; i++) await userEvent.click(canvas.getByRole("button", { name: "again" })); await waitFor(() => expect(canvas.getByText("round 5")).toBeInTheDocument()); await waitFor(() => expect(suspended()).toBe(false)); }}> {#snippet template()} <button onclick={() => round++}>again</button> <Pending key={round}> <Awaits promise={after(30, `round ${round}`)} /> </Pending> <Pending> <Awaits promise={after(20 + round, `also ${round}`)} /> </Pending> {/snippet}</Story>12344567834 unmodified lines<script lang="ts"> import { browser } from "$app/env"; import type { Snippet } from "svelte"; import { suspend } from "./suspended.svelte.ts"; import { suspend } from "./suspended.ts";
interface Props { /** omit to fill in silently, the boundary still owns the suspension */ skeleton?: Snippet;34 unmodified lines123456789101112import { untrack } from "svelte";
// the pending boundaries showing their skeleton: the loading strip runs while any doeslet count = $state(0);
export const suspended = (): boolean => count > 0;
// untracked, so the attachment that calls it doesn't rerun on the count it changesexport const suspend = (): (() => void) => { untrack(() => count++); return () => untrack(() => count--);};123456789101112131415// the pending boundaries showing their skeleton, counted outside svelte's state: with async// rendering each batch keeps its own copy of a state while it's pending, so a count kept there// loses updates and leaves the loading strip on. html[data-suspended] runs the striplet count = 0;
export const suspend = (): (() => void) => { count++; document.documentElement.dataset.suspended = ""; let held = true; return () => { if (!held) return; held = false; if (--count === 0) delete document.documentElement.dataset.suspended; };};3 unmodified lines45678891011144 unmodified linesExpand all156157158159161162163160161162163164165166167272 unmodified linesExpand all440441442443444444445446447448449156 unmodified linesExpand all3 unmodified lines // tabs instead of the profile card import { afterNavigate, goto, onNavigate } from "$app/navigation"; import { resolve } from "$app/paths"; import { navigating, page } from "$app/state"; import { suspended } from "#lib/components/ui/suspended.svelte.ts"; import RepoChrome from "#lib/components/repo/RepoChrome.svelte"; import RepoSidebar from "#lib/components/repo/sidebar/RepoSidebar.svelte"; import AccountCorner from "#lib/components/shell/AccountCorner.svelte"; import PageBar from "#lib/components/repo/PageBar.svelte";144 unmodified linesExpand all event.preventDefault(); drawer = !drawer; };
// the strip runs from the click until the page's skeletons are gone. a page loaded as a // document keeps it from the old page until the new one's app is up (app.html) const loading = $derived(!!navigating.to || suspended()); // the strip runs from the click until the page's skeletons are gone (Pending sets // html[data-suspended]). a page loaded as a document keeps it from the old page until the // new one's app is up (app.html) const loading = $derived(!!navigating.to); // a client navigation renders the new page in one long task: a frame first, so the strip shows onNavigate( () => new Promise<void>((resolve) => requestAnimationFrame(() => setTimeout(resolve))) );272 unmodified linesExpand all 0 0 / 64px 100%; mask: var(--y-dz-top) 0 0 / 8px 12px repeat-x; } .prog.on, :global(html[data-loading]) .prog { :global(html[data-loading]) .prog, :global(html[data-suspended]) .prog { opacity: 1; animation: prog 0.8s steps(32) infinite; } @keyframes prog {156 unmodified linesExpand all