diff --git a/.changeset/brave-skills-point.md b/.changeset/brave-skills-point.md new file mode 100644 index 0000000..e156798 --- /dev/null +++ b/.changeset/brave-skills-point.md @@ -0,0 +1,7 @@ +--- +"solid-relay": patch +--- + +feat: add `solid-relay` agent skill + +Adds a pointer skill under `skills/solid-relay` that routes agents to the guide docs. The guides are embedded into the skill's `references/` directory at build time, and the reference table is generated from each guide's `skillPointer` frontmatter field. diff --git a/.gitignore b/.gitignore index 38349db..040d4b4 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ dist/ __generated__/ .pnpm-store/ coverage/ +skills/solid-relay/references/ diff --git a/.oxfmtrc.jsonc b/.oxfmtrc.jsonc index 8f1204f..890460b 100644 --- a/.oxfmtrc.jsonc +++ b/.oxfmtrc.jsonc @@ -4,5 +4,5 @@ "sortImports": { "newlinesBetween": false, }, - "ignorePatterns": [".changeset/", "docs/**/api"], + "ignorePatterns": [".changeset/", "docs/**/api", "skills/"], } diff --git a/docs/src/routes/guide/fragments.mdx b/docs/src/routes/guide/fragments.mdx index c4f5706..7f51bbb 100644 --- a/docs/src/routes/guide/fragments.mdx +++ b/docs/src/routes/guide/fragments.mdx @@ -1,5 +1,6 @@ --- title: Fragments +skillPointer: "`createFragment`, composition, data masking, `createRefetchableFragment`, fragment variables" --- # Fragments diff --git a/docs/src/routes/guide/index.mdx b/docs/src/routes/guide/index.mdx index f7ff3e6..cbfc6ad 100644 --- a/docs/src/routes/guide/index.mdx +++ b/docs/src/routes/guide/index.mdx @@ -1,5 +1,6 @@ --- title: Getting Started +skillPointer: "Installing, Vite/SolidStart plugin config, `relay.config`, environment + `RelayEnvironmentProvider`" --- # Getting Started diff --git a/docs/src/routes/guide/invalidation.mdx b/docs/src/routes/guide/invalidation.mdx index 0208817..04b4ab1 100644 --- a/docs/src/routes/guide/invalidation.mdx +++ b/docs/src/routes/guide/invalidation.mdx @@ -1,5 +1,6 @@ --- title: Store Invalidation +skillPointer: "`createSubscriptionToInvalidationState`, reacting to `invalidateRecord` / `invalidateStore`, stale-data indicators, refetch-on-invalidation" --- # Store Invalidation diff --git a/docs/src/routes/guide/mutations.mdx b/docs/src/routes/guide/mutations.mdx index 42fb0d4..a85de1d 100644 --- a/docs/src/routes/guide/mutations.mdx +++ b/docs/src/routes/guide/mutations.mdx @@ -1,5 +1,6 @@ --- title: Mutations +skillPointer: "`createMutation`, optimistic updates, updater functions, connection updates" --- # Mutations diff --git a/docs/src/routes/guide/pagination.mdx b/docs/src/routes/guide/pagination.mdx index 0a5f91b..b796583 100644 --- a/docs/src/routes/guide/pagination.mdx +++ b/docs/src/routes/guide/pagination.mdx @@ -1,5 +1,6 @@ --- title: Pagination +skillPointer: "`createPaginationFragment`, `@connection`, bidirectional / infinite scroll, search, virtualized lists" --- # Pagination diff --git a/docs/src/routes/guide/querying.mdx b/docs/src/routes/guide/querying.mdx index d19da73..fd5ebc4 100644 --- a/docs/src/routes/guide/querying.mdx +++ b/docs/src/routes/guide/querying.mdx @@ -1,5 +1,6 @@ --- title: Querying Data +skillPointer: "Fetching with `createLazyLoadQuery`, variables, fetch policies, route preloading with `loadQuery` / `createQueryLoader` / `createPreloadedQuery`, error and loading states" --- # Querying Data diff --git a/docs/src/routes/guide/subscriptions.mdx b/docs/src/routes/guide/subscriptions.mdx index aa91358..4418502 100644 --- a/docs/src/routes/guide/subscriptions.mdx +++ b/docs/src/routes/guide/subscriptions.mdx @@ -1,5 +1,6 @@ --- title: Subscriptions +skillPointer: "`createSubscription`, real-time store updates" --- # Subscriptions diff --git a/package.json b/package.json index 7250565..1aec29d 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "url": "git+https://github.com/XiNiHa/solid-relay.git" }, "files": [ - "dist" + "dist", + "skills" ], "type": "module", "main": "./dist/index.cjs", @@ -31,7 +32,8 @@ }, "scripts": { "prepare": "playwright install", - "build": "tsdown", + "build": "tsdown && pnpm build:skill", + "build:skill": "node scripts/build-skill.ts", "lint": "oxlint", "format": "oxfmt", "check": "oxlint && oxfmt --check", diff --git a/scripts/build-skill.ts b/scripts/build-skill.ts new file mode 100644 index 0000000..a20882a --- /dev/null +++ b/scripts/build-skill.ts @@ -0,0 +1,79 @@ +/** + * Embeds the guide docs (docs/src/routes/guide) into skills/solid-relay/references + * as plain markdown, and regenerates the reference table in SKILL.md from each + * guide's `skillPointer` frontmatter field. + */ +import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; + +const root = path.resolve(import.meta.dirname, ".."); +const guideDir = path.join(root, "docs/src/routes/guide"); +const skillDir = path.join(root, "skills/solid-relay"); +const outDir = path.join(skillDir, "references"); +const skillFile = path.join(skillDir, "SKILL.md"); + +const outputName = (file: string) => + file === "index.mdx" ? "getting-started.md" : file.replace(/\.mdx$/, ".md"); + +// YAML scalars quoted for reserved characters (backticks, colons) — unwrap and unescape them +const unquote = (value: string) => { + const match = value.match(/^"(.*)"$/) ?? value.match(/^'(.*)'$/); + if (!match) return value; + return value.startsWith('"') + ? match[1].replace(/\\(["\\])/g, "$1") + : match[1].replace(/''/g, "'"); +}; + +const parseFrontmatter = (source: string) => { + const match = source.match(/^---\n([\s\S]*?)\n---\n+/); + if (!match) throw new Error("missing frontmatter"); + const fields = Object.fromEntries( + match[1].split("\n").map((line) => { + const [key, ...rest] = line.split(":"); + return [key.trim(), unquote(rest.join(":").trim())]; + }), + ); + return { fields, body: source.slice(match[0].length) }; +}; + +const transform = (body: string) => + body + // docs-site-only fences for the package manager tabs + .replace( + /```package-install-dev\n([\s\S]*?)```/g, + (_, pkgs: string) => "```sh\nnpm install -D " + pkgs.trim() + "\n```", + ) + .replace( + /```package-install\n([\s\S]*?)```/g, + (_, pkgs: string) => "```sh\nnpm install " + pkgs.trim() + "\n```", + ) + // site-relative links → sibling files + .replace(/\]\(\/guide\/([\w-]+)\)/g, (_, slug: string) => `](./${slug}.md)`) + .replace(/\]\(\/guide\/?\)/g, "](./getting-started.md)") + .trimEnd() + "\n"; + +await rm(outDir, { recursive: true, force: true }); +await mkdir(outDir, { recursive: true }); + +const files = (await readdir(guideDir)).filter((f) => f.endsWith(".mdx")).sort(); + +const tableHeader = "| Task | Reference |"; +const rows = [tableHeader, "| --- | --- |"]; +for (const file of files) { + const { fields, body } = parseFrontmatter(await readFile(path.join(guideDir, file), "utf8")); + if (!fields.skillPointer) throw new Error(`${file}: missing \`skillPointer\` frontmatter field`); + const name = outputName(file); + await writeFile(path.join(outDir, name), transform(body)); + rows.push(`| ${fields.skillPointer} | \`references/${name}\` |`); +} + +// replace the markdown table that opens with `tableHeader` (header row through the last contiguous `|` row) +const skill = await readFile(skillFile, "utf8"); +const lines = skill.split("\n"); +const start = lines.indexOf(tableHeader); +if (start === -1) throw new Error(`SKILL.md: missing table with header ${tableHeader}`); +let end = start; +while (end + 1 < lines.length && lines[end + 1].startsWith("|")) end++; +lines.splice(start, end - start + 1, ...rows); +await writeFile(skillFile, lines.join("\n")); +console.log(`Embedded ${files.length} guide docs into ${path.relative(root, outDir)}`); diff --git a/skills/solid-relay/SKILL.md b/skills/solid-relay/SKILL.md new file mode 100644 index 0000000..4fd1fc3 --- /dev/null +++ b/skills/solid-relay/SKILL.md @@ -0,0 +1,325 @@ +--- +name: solid-relay +description: >- + Best practices for writing idiomatic Relay code in SolidJS. ALWAYS use this skill when + writing or modifying Solid components that use Relay for data fetching. Covers + fragments, queries, mutations, pagination, and common anti-patterns. Use when + you see `createFragment`, `createLazyLoadQuery`, `createPreloadedQuery`, + `createMutation`, `createPaginationFragment`, `graphql` template literals, + `solid-relay` imports, or `__generated__/*.graphql` files. Also use when asked + to explain Relay concepts, debug Relay issues, or review Relay code. +--- + +# solid-relay + +Solid Relay replaces only the React-specific layer of Relay (the `react-relay` equivalent): its primitives return Solid accessors instead of React hooks returning values. Everything below that layer — schema, `relay-compiler`, `relay-runtime` store and network layer, GraphQL directives — is unchanged. + +## References + +Since the runtime layer is unchanged, the official Relay docs (available in `node_modules/relay-runtime/llm-docs/`) remain authoritative for those parts. Reach for the reference here for the component-level API only. + +Read the reference that matches the task before writing code; each file carries complete, runnable examples. + +| Task | Reference | +| --- | --- | +| `createFragment`, composition, data masking, `createRefetchableFragment`, fragment variables | `references/fragments.md` | +| Installing, Vite/SolidStart plugin config, `relay.config`, environment + `RelayEnvironmentProvider` | `references/getting-started.md` | +| `createSubscriptionToInvalidationState`, reacting to `invalidateRecord` / `invalidateStore`, stale-data indicators, refetch-on-invalidation | `references/invalidation.md` | +| `createMutation`, optimistic updates, updater functions, connection updates | `references/mutations.md` | +| `createPaginationFragment`, `@connection`, bidirectional / infinite scroll, search, virtualized lists | `references/pagination.md` | +| Fetching with `createLazyLoadQuery`, variables, fetch policies, route preloading with `loadQuery` / `createQueryLoader` / `createPreloadedQuery`, error and loading states | `references/querying.md` | +| `createSubscription`, real-time store updates | `references/subscriptions.md` | + +## Core Philosophy + +These principles are the foundation of every decision below. When in doubt, +refer back to them. + +- **Co-location**: Each component declares its own data requirements via a + GraphQL fragment, right next to the rendering code. Data needs travel with the + component, not separately. +- **Data masking**: A component can only access the fields it explicitly + selected in its own fragment. Parents cannot see child fragment data, and vice + versa. This prevents implicit coupling between components. +- **Composition**: Fragments compose into parent fragments and ultimately into + queries, forming a tree that mirrors the component tree. The compiler flattens + this into a single network request per query. +- **Render-as-you-fetch**: Start fetching data before the component that needs + it renders. This avoids sequential request waterfalls. +- **Normalized store**: Relay maintains a flat, ID-keyed cache. When a mutation + returns updated data, every component reading that data re-renders + automatically. You do not need to manually propagate changes. + +## Decision Rules + +### Queries belong at roots — never in hooks + +Queries belong at **route entrypoints** (the top-level component for a URL). +Hooks are leaves — reused across many components. A query inside a hook fires +late (after the hook's host renders) AND duplicates across every caller. The fix +is always: accept a fragment key as a parameter, use `createFragment`. + +Use `createPreloadedQuery` + `createQueryLoader` (or `loadQuery`). Start the fetch in +an event handler, route transition, or during app initialization — before the +component renders. `createLazyLoadQuery` does not start fetching until render, +creating waterfalls. See `references/querying.md` for the full pattern. + +### Before fixing where a query lives, ask if it should exist + +| Question | If YES | +|----------|--------| +| Parent already fetches this GraphQL type? | Delete query, use `createFragment` | +| Component only fetches and passes data down? | Delete the wrapper component entirely (loader anti-pattern) | +| Query is inside a custom hook? | Delete query, accept a fragment key param | +| Two components fetch the same data? | Delete one query, fetch in a common ancestor | +| Data is only used for logging/analytics? | Move to `@defer` or server-side logging | +| Data is static config (same for every user)? | Inject server-side, no round-trip needed | + +### `loadQuery` in `createEffect` is worse than `createLazyLoadQuery` + +`createEffect` runs **after paint**, so the fetch starts even later than +`createLazyLoadQuery` (which at least starts during render). Call `loadQuery` in +event handlers, route transitions, or app initialization — never in effects. + +### Walking the ancestor tree for `createFragment` + +When deciding whether `createFragment` can replace a query, walk **up** the +component tree from your component. Stop at: route boundaries, feature gates, +conditional renders, or user-triggered interactions. Keep walking through: +unconditional renders, layout/wrapper components, context providers. + +If any ancestor already queries the GraphQL type you need, `createFragment` is +the answer. Threading fragment keys through several layers of props is fine — +it IS the correct pattern. + +### Don't default query variables to empty values + +Never default a query variable to `''`, `0`, or `null` when the real value is +unavailable. This fires the query with bad data, returning wrong results or +errors. Instead, use conditional rendering (`if (!id) return null`) or +`@include`/`@skip` directives to omit the field entirely. + +### Data flow: Always use fragments + +Every component that displays server data should declare a fragment and receive +a fragment reference (the `$key` type) as a prop. The parent spreads the +child's fragment in its own query or fragment and passes the result down. See +the "Maintain fragment co-location" anti-pattern below for an example. + +### Mutations: Spread fragments into responses + +Spread the consuming component's fragment into the mutation response rather than +selecting fields individually. This keeps them in sync automatically. See the +anti-pattern example below. + +### Error handling: Use `@throwOnFieldError` and `@catch` + +The recommended approach for handling field errors and nullability is to add +`@throwOnFieldError` to your fragment or query. This causes Relay to throw a +JavaScript exception if a field error is encountered, which can be caught by a +Solid error boundary. It also enables non-null types for `@semanticNonNull` +fields, eliminating unnecessary null checks. Note that this pattern depends on +Solid error boundaries being configured in your application — proceed with +caution if error boundaries are not set up robustly. + +For fields where you want to handle errors locally instead of throwing, use +`@catch` to receive errors inline as `{ ok: true, value: T } | { ok: false, +errors: [...] }`. + +`@required` is also available for declaring that specific fields must be +non-null, but `@throwOnFieldError` + `@catch` is the preferred pattern for new +code. + +### Pagination: Use the three-directive pattern + +Always use `@argumentDefinitions` (for cursor/count variables), +`@refetchable` (to auto-generate the pagination query), and `@connection` (to +identify the connection for store management) together. Never write manual +pagination queries. See `references/pagination.md`. + +## Critical Anti-Patterns + +### Never copy Relay data into Solid state + +This is the single most important rule. Do not read data from `createFragment` and +copy it into `createSignal`, and do not update that state manually in mutation +`onCompleted` callbacks. + +```tsx +// WRONG: Copying Relay data into Solid state +function UserProfile(props) { + const data = createFragment(UserProfileFragment, () => props.$user); + const [name, setName] = createSignal(data().name); // broken + + const [commit] = createMutation(UpdateNameMutation); + const handleSave = (newName) => { + commit({ + variables: {name: newName}, + onCompleted: (response) => { + setName(response.updateName.user.name); // broken + }, + }); + }; + return {name()}; +} +``` + +Why this is wrong: Relay's normalized store is the single source of truth. When +a mutation returns updated data with a matching `id`, Relay automatically +updates every component reading that data via `createFragment`. By copying into +`createState`, you create a second source of truth that Relay cannot update. The +component will show stale data whenever the record is updated by another +mutation, subscription, or refetch elsewhere in the app. + +```tsx +// CORRECT: Read directly from the fragment +function UserProfile(props) { + const data = createFragment(UserProfileFragment, () => props.$user); + const [commit, isInFlight] = createMutation(UpdateNameMutation); + + const handleSave = (newName) => { + commit({variables: {name: newName}}); + // No onCompleted needed — Relay updates the store automatically, + // and createFragment updates with the new data. + }; + return {data()?.name}; +} +``` + +Similarly, do not store a fragment key (the `$key` prop) in Solid state. Relay +garbage collects data that is no longer retained by a mounted query component — +if the component that originally fetched the data unmounts, a stashed key may +point to data that is no longer in the store. + +### Maintain fragment co-location + +Do not fetch all data in a parent's query and pass raw data objects as props to +children. This defeats data masking and creates tight coupling — adding a field +to a child component requires editing the parent's query. + +```tsx +// WRONG: Parent fetches everything, passes raw data +function Parent(props) { + const data = createPreloadedQuery(graphql` + query ParentQuery { + user { + name + email + avatarUrl + } + } + `, () => props.$queryRef); + return ; +} + +// CORRECT: Child declares its own fragment +function Parent(props) { + const data = createPreloadedQuery(graphql` + query ParentQuery { + user { + ...UserCard_user + } + } + `, () => props.$queryRef); + return ; +} +``` + +### Spread fragments into mutation responses + +Do not select fields individually in both a fragment and a mutation response — +they will drift out of sync. Spread the fragment instead: + +```graphql +# WRONG +mutation UpdateUserMutation($input: UpdateUserInput!) { + updateUser(input: $input) { + user { id, name, email, avatarUrl } + } +} + +# CORRECT +mutation UpdateUserMutation($input: UpdateUserInput!) { + updateUser(input: $input) { + user { ...UserCard_user } + } +} +``` + +## Correctness + +### Use `optimisticUpdater` for store-dependent values + +If an optimistic value depends on current store state (e.g., incrementing a +like count), use `optimisticUpdater` instead of `optimisticResponse`. Multiple +overlapping optimistic responses can compound incorrectly — two simultaneous +"like" mutations both read count=5 and set count=6, instead of 5→6→7. When one +rolls back, the store is left in an inconsistent state. + +### Invalidate data after wide-effect mutations + +When a mutation has side effects too broad to capture in a single response +payload, use `invalidateRecord()` for targeted invalidation or +`invalidateStore()` for global invalidation. Pair with +`createSubscriptionToInvalidationState` on mounted components to trigger refetches +for stale data automatically. + +### Handle staleness explicitly + +Relay treats cached data as fresh **indefinitely** by default. Two approaches: + +- **Time-based**: Set `queryCacheExpirationTime` on the Relay Store to + automatically mark data stale after a duration. +- **Event-based**: Call `invalidateRecord()` after mutations whose side effects + extend beyond the mutation response payload. + +Without explicit staleness handling, components can display arbitrarily old data +after the user returns to a previously visited screen. + +### Use `@updatable` for store manipulation + +Prefer typesafe updatable queries/fragments over raw store manipulation with +string-based field access (e.g., `store.get(id).setValue(newName, 'name')`). +Updatable fragments provide getters and setters, reducing the risk of typos +and type mismatches. + +### Avoid unnecessary refetches after mutations + +Do not call `refetch()` or `fetchQuery()` after mutations when spreading +component fragments in the mutation response would auto-update the store. Each +unnecessary refetch is a wasted network request and delays the UI update. + +Reserve manual refetches for cases where the mutation's side effects are too +broad to capture in the response payload — and in those cases, prefer +`invalidateRecord()` (see above). + +### Use subscriptions for real-time data + +Prefer GraphQL Subscriptions over polling (`setInterval` + `fetchQuery`) or +manual refresh buttons for data that must stay current. Subscriptions push +updates only when data changes and integrate with Relay's normalized store +automatically. + +### Renaming operations when extracting to a new file + +When moving a component to a new file, rename only the operations **defined in +that file** to match the new filename. Do NOT rename fragment spreads that +reference fragments owned by other modules — those names belong to their +defining component. + +Renaming an operation also changes its generated type name (e.g., +`UserCard_user$key` → `ProfileCard_user$key`), so update all downstream imports +of those generated types. + +### Never hand-edit `__generated__/` files + +The next compiler run overwrites any manual edits. If you see type errors about +missing generated types, run the compiler first — the types are just out of +date, not missing. + +### Verify mutation variable keys after auto-formatting + +Auto-formatters and linters can rename variables in ways that silently break +mutation calls. After running lint auto-fix, verify that variable keys in +`commit({ variables: { ... } })` still match the generated `Mutation$variables` +type (check for `data` vs `input` mismatches in particular).