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
69 changes: 68 additions & 1 deletion .agents/skills/btst-client-plugin-dev/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,73 @@ src/plugins/{name}/
pages/
my-page.tsx ← wrapper: ComposedRoute + lazy import
my-page.internal.tsx ← actual UI: useSuspenseQuery
query-keys.ts ← React Query key factory
query-keys.ts ← resource declaration + query key factory
```

## Data hooks: use `createResource` (new plugins)

Don't hand-write the useQuery/useMutation + `isErrorResponse`/`toError` plumbing.
Declare the plugin's resources once in `query-keys.ts` and generate everything:

```typescript
// query-keys.ts (server-safe — no React)
import { createResourceQueryKeys, type ResourcesDeclaration } from "@btst/stack/plugins/client";

export const myResources = {
posts: {
queries: {
list: { path: "/posts", query: (p?: ListParams) => ({...}), key: (p?: ListParams) => [discriminator(p)],
select: (d: any): Item[] => d?.items ?? [], infinite: true, pageSize: (p?: ListParams) => p?.limit ?? 10 },
detail: { path: "/posts", query: (slug: string) => ({ slug, limit: 1 }), key: (slug: string) => [slug],
select: (d: any): Item | null => d?.items?.[0] ?? null, skip: (slug: string) => !slug },
},
mutations: {
create: { path: "@post/posts", method: "POST", input: (vars: CreateInput) => ({ body: vars }),
select: (d: any) => d as Item | null, invalidates: ["posts.list"],
setData: { query: "detail", args: (r: Item | null) => (r?.slug ? [r.slug] : null) } },
},
},
} satisfies ResourcesDeclaration;

// SSR loaders keep using the same factory (keys match @lukemorales shapes)
export function createMyQueryKeys(client, headers?: HeadersInit) {
return createResourceQueryKeys(client, myResources, headers);
}
```

```typescript
// client/hooks.ts ("use client")
import { createResource } from "@btst/stack/plugins/client/hooks";
import { myResources } from "../query-keys";

const my = createResource({ plugin: "my-plugin", resources: myResources });

export const usePosts = (params?: ListParams) => my.posts.list.useInfinite([params]);
export const useSuspensePost = (slug: string) => my.posts.detail.useSuspense([slug]);
export const useCreatePost = () => my.posts.create.use();
```

Generated per query: `use(args, { enabled? })`, `useSuspense(args)` (plain) or
`useInfinite`/`useSuspenseInfinite` (when `infinite: true`). Suspense variants re-throw
refetch errors automatically. Mutations get `use()` with declarative `invalidates`
(`"resource"` or `"resource.query"` prefixes), optional `setData` cache seeding, and an
awaited `refresh()` after invalidation. Errors are normalized to `StackError`
(`statusCode`, field-level `errors` from Zod issues).

Each resource also exposes:

- `my.posts.useForm({ action, id?, record?, defaults?, toCreateVars?, toUpdateVars?, successMessage?, errorMessage?, redirect?, onSuccess? })`
— create/edit lifecycle: fetches the record for edit, runs the right mutation,
notifies via `useNotify()`, redirects via the router adapter, and exposes
`fieldErrors` (map server Zod issues onto react-hook-form with `setError`).
- `my.posts.useSelect({ searchArgs, getOptionValue, getOptionLabel, value?, preload? })`
— debounced server-side search + current-value preloading for relation pickers.

`SHARED_QUERY_CONFIG`, `isErrorResponse`, `toError`, and `StackError` live in
`@btst/stack/plugins/client` — never copy them into a plugin. The blog plugin
(`src/plugins/blog/query-keys.ts`, `client/hooks/blog-hooks.tsx`) is the reference
consumer.

## Server/client module boundary

`client/plugin.tsx` must stay import-safe on the server. Next.js (including SSG build)
Expand All @@ -32,6 +96,9 @@ Rules:
- Keep `client/plugin.tsx` free of React hooks (`useState`, `useEffect`, etc.).
- Put hook utilities in a separate client-only module (`client/hooks.ts`) with
`"use client"`, and re-export them from `client/index.ts`.
- `createResource` comes from the client-only entry `@btst/stack/plugins/client/hooks`;
the server-safe `@btst/stack/plugins/client` entry only exposes
`createResourceQueryKeys` + declaration types for `query-keys.ts` and loaders.
- UI components can remain client components as needed; only the plugin factory entry
must stay server-import-safe.

Expand Down
1 change: 1 addition & 0 deletions packages/stack/build.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export default defineBuildConfig({
// plugin development entries
"./src/plugins/api/index.ts",
"./src/plugins/client/index.ts",
"./src/plugins/client/hooks/index.tsx",
// blog plugin entries
"./src/plugins/blog/api/index.ts",
"./src/plugins/blog/client/index.ts",
Expand Down
13 changes: 13 additions & 0 deletions packages/stack/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,16 @@
"default": "./dist/plugins/client/index.cjs"
}
},
"./plugins/client/hooks": {
"import": {
"types": "./dist/plugins/client/hooks/index.d.ts",
"default": "./dist/plugins/client/hooks/index.mjs"
},
"require": {
"types": "./dist/plugins/client/hooks/index.d.cts",
"default": "./dist/plugins/client/hooks/index.cjs"
}
},
"./plugins/blog/api": {
"import": {
"types": "./dist/plugins/blog/api/index.d.ts",
Expand Down Expand Up @@ -650,6 +660,9 @@
"plugins/client": [
"./dist/plugins/client/index.d.ts"
],
"plugins/client/hooks": [
"./dist/plugins/client/hooks/index.d.ts"
],
"plugins/blog/api": [
"./dist/plugins/blog/api/index.d.ts"
],
Expand Down
4 changes: 2 additions & 2 deletions packages/stack/registry/btst-blog.json

Large diffs are not rendered by default.

86 changes: 86 additions & 0 deletions packages/stack/src/__tests__/blog-query-keys.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/**
* SSG guard: the factory-generated blog query keys must stay deep-equal to
* the `BLOG_QUERY_KEYS` builders used by `prefetchForRoute` (DB path).
* Key drift breaks React Query cache hydration silently during `next build`.
*/
import { describe, expect, it, vi } from "vitest";
import { BLOG_QUERY_KEYS } from "../plugins/blog/api/query-key-defs";
import { createBlogQueryKeys } from "../plugins/blog/query-keys";

const client = vi.fn() as any;

describe("blog query keys match SSG prefetch keys", () => {
const queries = createBlogQueryKeys(client);

it("posts list keys match for default params", () => {
expect([...queries.posts.list({ published: true }).queryKey]).toEqual([
...BLOG_QUERY_KEYS.postsList({ published: true }),
]);
});

it("posts list keys match for drafts and custom limits", () => {
expect([
...queries.posts.list({ published: false, limit: 25 }).queryKey,
]).toEqual([...BLOG_QUERY_KEYS.postsList({ published: false, limit: 25 })]);
});

it("posts list keys match for tag filtering", () => {
expect([
...queries.posts.list({ published: true, tagSlug: "news" }).queryKey,
]).toEqual([
...BLOG_QUERY_KEYS.postsList({ published: true, tagSlug: "news" }),
]);
});

it("posts list defaults published to true like the SSR loader", () => {
expect([...queries.posts.list().queryKey]).toEqual([
...BLOG_QUERY_KEYS.postsList({ published: true }),
]);
});

it("normalizes a whitespace-only query the same way", () => {
expect([
...queries.posts.list({ published: true, query: " " }).queryKey,
]).toEqual([...BLOG_QUERY_KEYS.postsList({ published: true })]);
});

it("post detail keys match", () => {
expect([...queries.posts.detail("my-post").queryKey]).toEqual([
...BLOG_QUERY_KEYS.postDetail("my-post"),
]);
});

it("tags list keys match", () => {
expect([...queries.tags.list().queryKey]).toEqual([
...BLOG_QUERY_KEYS.tagsList(),
]);
});

it("exposes the same _def prefixes as the previous factory", () => {
expect([...queries.posts._def]).toEqual(["posts"]);
expect([...queries.posts.list._def]).toEqual(["posts", "list"]);
expect([...queries.drafts.list._def]).toEqual(["drafts", "list"]);
});

it("drafts list keys keep the legacy limit-only shape", () => {
expect([...queries.drafts.list({ limit: 10 }).queryKey]).toEqual([
"drafts",
"list",
{ limit: 10 },
]);
expect([...queries.drafts.list().queryKey]).toEqual(["drafts", "list", {}]);
});

it("bespoke nextPrevious and recent keys keep their legacy shapes", () => {
const date = new Date("2024-01-01T00:00:00.000Z");
expect([...queries.posts.nextPrevious(date).queryKey]).toEqual([
"posts",
"nextPrevious",
"nextPrevious",
date,
]);
expect([
...queries.posts.recent({ limit: 5, excludeSlug: "a" }).queryKey,
]).toEqual(["posts", "recent", "recent", { limit: 5, excludeSlug: "a" }]);
});
});
Loading
Loading