Skip to content
Draft
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
65 changes: 43 additions & 22 deletions packages/swingset/src/components/DocsViewer.tsx
Original file line number Diff line number Diff line change
@@ -1,45 +1,66 @@
'use client';

import dynamic from 'next/dynamic';

import { getModule } from '@/lib/registry';

import AccordionDoc from '../stories/accordion.mdx';
import AutocompleteDoc from '../stories/autocomplete.mdx';
import ButtonDoc from '../stories/button.mdx';
import CollapsibleDoc from '../stories/collapsible.mdx';
import DeleteOrganizationDoc from '../stories/delete-organization.mdx';
import DestructiveDoc from '../stories/destructive.mdx';
import DialogComponentDoc from '../stories/dialog.component.mdx';
import DialogDoc from '../stories/dialog.mdx';
import InputDoc from '../stories/input.mdx';
import LeaveOrganizationDoc from '../stories/leave-organization.mdx';
import MenuDoc from '../stories/menu.mdx';
import OrganizationProfileDoc from '../stories/organization-profile.mdx';
import OrganizationProfileGeneralDoc from '../stories/organization-profile-general.mdx';
import PopoverDoc from '../stories/popover.mdx';
import SelectDoc from '../stories/select.mdx';
import TabsComponentDoc from '../stories/tabs.component.mdx';
import TabsDoc from '../stories/tabs.mdx';
import TooltipDoc from '../stories/tooltip.mdx';
import { PlaygroundProvider } from './PlaygroundContext';
import { ViewSource } from './ViewSource';

// MDX docs keyed by `group` slug → `component` slug. Group-aware so identically-named
// entries (the headless `Dialog` primitive vs. the styled `Dialog` component) stay distinct.
// MDX docs are imported statically (not via `next/dynamic`). A `next/dynamic` lazy boundary
// resolves differently on the server vs. the client, shifting React's `useId` tree-path and
// hydration-mismatching any `useId` component inside (Tabs, Dialog, …). Static imports render an
// identical tree on both sides, so `useId` stays stable and SSR is preserved.
//
// Keyed by `group` slug → `component` slug. Group-aware so identically-named entries (the headless
// `Dialog` primitive vs. the styled `Dialog` component) stay distinct.
const docModules: Record<string, Record<string, React.ComponentType>> = {
aio: {
'organization-profile': dynamic(() => import('../stories/organization-profile.mdx')),
'organization-profile': OrganizationProfileDoc,
},
panels: {
'organization-profile-general': dynamic(() => import('../stories/organization-profile-general.mdx')),
'organization-profile-general': OrganizationProfileGeneralDoc,
},
sections: {
'leave-organization': dynamic(() => import('../stories/leave-organization.mdx')),
'delete-organization': dynamic(() => import('../stories/delete-organization.mdx')),
'leave-organization': LeaveOrganizationDoc,
'delete-organization': DeleteOrganizationDoc,
},
blocks: {
destructive: dynamic(() => import('../stories/destructive.mdx')),
destructive: DestructiveDoc,
},
components: {
button: dynamic(() => import('../stories/button.mdx')),
input: dynamic(() => import('../stories/input.mdx')),
dialog: dynamic(() => import('../stories/dialog.component.mdx')),
tabs: dynamic(() => import('../stories/tabs.component.mdx')),
button: ButtonDoc,
input: InputDoc,
dialog: DialogComponentDoc,
tabs: TabsComponentDoc,
},
primitives: {
// Headless primitives — alphabetical.
accordion: dynamic(() => import('../stories/accordion.mdx')),
autocomplete: dynamic(() => import('../stories/autocomplete.mdx')),
collapsible: dynamic(() => import('../stories/collapsible.mdx')),
dialog: dynamic(() => import('../stories/dialog.mdx')),
menu: dynamic(() => import('../stories/menu.mdx')),
popover: dynamic(() => import('../stories/popover.mdx')),
select: dynamic(() => import('../stories/select.mdx')),
tabs: dynamic(() => import('../stories/tabs.mdx')),
tooltip: dynamic(() => import('../stories/tooltip.mdx')),
accordion: AccordionDoc,
autocomplete: AutocompleteDoc,
collapsible: CollapsibleDoc,
dialog: DialogDoc,
menu: MenuDoc,
popover: PopoverDoc,
select: SelectDoc,
tabs: TabsDoc,
tooltip: TooltipDoc,
},
};

Expand Down
8 changes: 4 additions & 4 deletions packages/swingset/src/lib/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,14 @@ import {
meta as leaveOrganizationMeta,
} from '../stories/leave-organization.stories';
import { meta as menuMeta } from '../stories/menu.stories';
import {
Default as OrganizationProfileGeneralDefault,
meta as organizationProfileGeneralMeta,
} from '../stories/organization-profile-general.stories';
import {
Default as OrganizationProfileDefault,
meta as organizationProfileMeta,
} from '../stories/organization-profile.stories';
import {
Default as OrganizationProfileGeneralDefault,
meta as organizationProfileGeneralMeta,
} from '../stories/organization-profile-general.stories';
import { meta as popoverMeta } from '../stories/popover.stories';
import { meta as selectMeta } from '../stories/select.stories';
import { Default as TabsComponentDefault, meta as tabsComponentMeta } from '../stories/tabs.component.stories';
Expand Down
1 change: 1 addition & 0 deletions packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@
"@solana/wallet-adapter-react": "catalog:module-manager",
"@solana/wallet-standard": "catalog:module-manager",
"@swc/helpers": "catalog:repo",
"@tanstack/react-query": "^5.100.6",
"alien-signals": "2.0.6",
"copy-to-clipboard": "3.3.3",
"core-js": "catalog:repo",
Expand Down
16 changes: 6 additions & 10 deletions packages/ui/src/mosaic/aio/organization-profile.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { ClientSuspense } from '../components/client-suspense';
import { Box } from '../components/box';
import { SectionSkeleton } from '../components/section-skeleton';
import { Tabs } from '../components/tabs';
import { OrganizationProfileGeneral } from '../panels/organization-profile-general';
import { OrganizationMembers } from '../sections/organization-members';

export function OrganizationProfile() {
return (
Expand Down Expand Up @@ -28,16 +31,9 @@ export function OrganizationProfile() {
<OrganizationProfileGeneral />
</Tabs.Panel>
<Tabs.Panel value='members'>
<Box
render={p => <h1 {...p} />}
sx={t => ({
...t.text('base'),
fontWeight: t.font.medium,
textAlign: 'center',
})}
>
Members content
</Box>
<ClientSuspense fallback={<SectionSkeleton />}>
<OrganizationMembers />
</ClientSuspense>
</Tabs.Panel>
</Tabs.Root>
</Box>
Expand Down
26 changes: 26 additions & 0 deletions packages/ui/src/mosaic/components/client-suspense.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { type ReactNode, Suspense, useSyncExternalStore } from 'react';

const subscribe = () => () => {};

/**
* A Suspense boundary that only activates on the client.
*
* On the server (and the first client render, for hydration parity) it renders `fallback`
* directly — it does **not** mount `children`, so nothing suspends server-side. That matters for
* client-only data: a real `<Suspense>` whose promise can only resolve in the browser would keep
* the SSR stream open forever (perpetual tab loading) or re-suspend in a loop. Once mounted, it
* upgrades to a normal `<Suspense>` and the children load + suspend as usual.
*/
export function ClientSuspense({ fallback, children }: { fallback: ReactNode; children: ReactNode }) {
const mounted = useSyncExternalStore(
subscribe,
() => true, // client
() => false, // server + first hydration pass
);

if (!mounted) {
return <>{fallback}</>;
}

return <Suspense fallback={fallback}>{children}</Suspense>;
}
44 changes: 44 additions & 0 deletions packages/ui/src/mosaic/data/members-query.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { infiniteQueryOptions } from '@tanstack/react-query';

const delay = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms));

/**
* Server collection: the org's members, paginated. This is the half that genuinely wants
* TanStack — caching, staleness, infinite pagination, invalidation — none of which a signal
* gives you. A colocated query-options factory is the unit of reuse: the same `membersQuery(id)`
* feeds suspense reads, prefetch, and tests.
*/

export const membersKey = (orgId: string) => ['org', orgId, 'members'] as const;

export interface MemberRecord {
id: string;
name: string;
role: string;
}

interface MembersPage {
members: MemberRecord[];
nextPage: number | null;
}

const PAGE_SIZE = 3;
const TOTAL_PAGES = 3;

export const membersQuery = (orgId: string) =>
infiniteQueryOptions({
queryKey: membersKey(orgId),
initialPageParam: 1,
queryFn: async ({ pageParam }): Promise<MembersPage> => {
await delay(500);
const base = (pageParam - 1) * PAGE_SIZE;
const members = Array.from({ length: PAGE_SIZE }, (_, i) => ({
id: `mem_${base + i}`,
name: `Member ${base + i + 1}`,
role: base + i === 0 ? 'org:admin' : 'org:member',
}));
return { members, nextPage: pageParam < TOTAL_PAGES ? pageParam + 1 : null };
},
getNextPageParam: last => last.nextPage,
staleTime: 60_000,
});
77 changes: 77 additions & 0 deletions packages/ui/src/mosaic/data/organization-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { signal } from 'alien-signals';

/**
* Self-contained store for the org-profile spike. No external data dependencies — it models the
* two distinct kinds of state the real components consume, so component code is written exactly as
* it would be against production and only this file would be swapped:
*
* - the live, client-owned org **identity** (the active organization + the current user's
* membership) — modelled as a signal, the synchronous source of truth;
* - server **collections** (members, invitations) — fetched + cached by TanStack Query, see
* `members-query.ts`.
*
* Resources carry their own mutation methods (`destroy`), mirroring the real resource API so the
* mutation hooks call `organization.destroy()` / `membership.destroy()` rather than reaching into
* the store.
*/

/** Time until the simulated org identity hydrates (analogue of waiting on the SDK to load). */
const LOAD_DELAY_MS = 600;
/** Artificial latency for `destroy()` mutations. */
const MUTATION_DELAY_MS = 2000;

const delay = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms));

export interface OrganizationResource {
id: string;
name: string;
slug: string | null;
membersCount: number;
/** Permanently delete the organization (admin-only in the real API). */
destroy: () => Promise<void>;
}

export interface MembershipResource {
id: string;
/** e.g. 'org:admin' | 'org:member' */
role: string;
/** Leave the organization (removes the current member). */
destroy: () => Promise<void>;
}

export interface ActiveOrganization {
organization: OrganizationResource;
membership: MembershipResource;
}

// read: organizationSignal() · write: organizationSignal(next)
export const organizationSignal = signal<ActiveOrganization | null>(null);

let hydration: Promise<void> | null = null;

/**
* Resolves once the active organization is available. Idempotent — every `useOrganization()`
* instance awaits the same promise, so independently-mounted sections suspend and resume on the
* same tick instead of racing their own timers.
*/
export function ensureOrganization(): Promise<void> {
if (!hydration) {
hydration = delay(LOAD_DELAY_MS).then(() => {
organizationSignal({
organization: {
id: 'org_mock',
name: "Alex's Organization",
slug: 'alex-org',
membersCount: 4,
destroy: () => delay(MUTATION_DELAY_MS),
},
membership: {
id: 'mem_mock',
role: 'org:admin',
destroy: () => delay(MUTATION_DELAY_MS),
},
});
});
}
return hydration;
}
39 changes: 39 additions & 0 deletions packages/ui/src/mosaic/data/query-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { QueryClient } from '@tanstack/react-query';

/** Builds a QueryClient with Mosaic's cache defaults — the one place construction lives. */
export function makeQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 60_000,
retry: false,
refetchOnWindowFocus: false,
},
},
});
}

let browserClient: QueryClient | undefined;

/**
* The QueryClient to read/mutate against, passed explicitly to each `useQuery`/`useMutation`
* (react-query's optional `queryClient` arg) so any section renders standalone — no
* `QueryClientProvider` required in the tree (e.g. a per-component story).
*
* - Browser: one memoized client per tab → shared cache (dedup, invalidation, staleness).
* - Server: a fresh client per call, so one request's cache never leaks into another's.
*
* That split is what keeps us SSR-**safe**. Going further to SSR-**with-data** (server prefetch →
* `dehydrate()` → `<HydrationBoundary>`) means moving this behind a per-request provider — kept a
* one-file swap by routing all construction through here.
*
* Caveat: on the server each call returns a distinct client, so cross-component cache sharing
* within a single SSR render only holds once that provider exists. Fine for the current sections —
* they suspend on the org signal before any server-side query runs.
*/
export function getMosaicQueryClient(): QueryClient {
if (typeof window === 'undefined') {
return makeQueryClient();
}
return (browserClient ??= makeQueryClient());
}
40 changes: 40 additions & 0 deletions packages/ui/src/mosaic/data/use-org-mutations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { useMutation } from '@tanstack/react-query';

import { membersKey } from './members-query';
import type { MembershipResource, OrganizationResource } from './organization-store';
import { getMosaicQueryClient } from './query-client';

/**
* Mutations via `useMutation`, calling the resource's own method — `isPending` / `error` /
* `onSuccess` come for free, replacing the hand-rolled `isDeleting` `useState` + `await` dance and
* giving cache invalidation a first-class home.
*
* The client is passed explicitly (react-query's optional `queryClient` arg) so a section renders
* standalone — no `QueryClientProvider` needed in the tree (e.g. a per-component story).
*/

/** `organization.destroy()` — an admin deletes the whole org. */
export function useDeleteOrganization(organization: OrganizationResource) {
return useMutation(
{
mutationFn: () => organization.destroy(),
},
getMosaicQueryClient(),
);
}

/** `membership.destroy()` — the current user leaves the org. */
export function useLeaveOrganization(organization: OrganizationResource, membership: MembershipResource) {
const queryClient = getMosaicQueryClient();

return useMutation(
{
mutationFn: () => membership.destroy(),
onSuccess: () => {
// Membership changed — drop the cached members collection so it refetches.
void queryClient.invalidateQueries({ queryKey: membersKey(organization.id) });
},
},
queryClient,
);
}
Loading
Loading