From 49ba36fe3e0750a91781396ca331808783ac0494 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 19:27:52 +0000 Subject: [PATCH 1/4] docs(website): Framework selector switches between equivalent pages Pages can name their counterpart in the other framework with `framework_equivalent:` front matter when it has a different doc id, so the selector moves between React's DataProvider and Vue's DataClientPlugin instead of disabling the other framework. framework-docs/index.js `docsFor()` now lists each framework's docs with their routes (honoring `slug`), shared by remarkFramework's link rewriting, docsToMarkdown's routeOf and the selector's equivalents. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Fjs86eqj7vj4ia4om1Cmbo --- docs/core/api/DataClientPlugin.vue.md | 1 + website/docusaurus.config.ts | 4 +- website/framework-docs/README.md | 13 ++++- website/framework-docs/docsToMarkdown.mjs | 22 ++++++--- website/framework-docs/index.js | 52 ++++++++++++++++++-- website/framework-docs/remarkFramework.js | 21 ++++---- website/src/components/FrameworkSelector.tsx | 42 ++++++++++++---- 7 files changed, 118 insertions(+), 37 deletions(-) diff --git a/docs/core/api/DataClientPlugin.vue.md b/docs/core/api/DataClientPlugin.vue.md index a9d8a98ac951..d5f9431731f4 100644 --- a/docs/core/api/DataClientPlugin.vue.md +++ b/docs/core/api/DataClientPlugin.vue.md @@ -1,5 +1,6 @@ --- frameworks: [vue] +framework_equivalent: api/DataProvider title: DataClientPlugin - Normalized async data management in Vue sidebar_label: DataClientPlugin description: High performance, globally consistent data management in Vue diff --git a/website/docusaurus.config.ts b/website/docusaurus.config.ts index 4c51fb78c658..05a5af422b74 100644 --- a/website/docusaurus.config.ts +++ b/website/docusaurus.config.ts @@ -224,6 +224,8 @@ const config: Config = { themes: ['@docusaurus/theme-live-codeblock', '@docusaurus/theme-mermaid'], customFields: { repoUrl: 'https://github.com/reactive/data-client', + // read by FrameworkSelector to switch between differently named pages + frameworkEquivalents: frameworkDocs.frameworkEquivalents(), }, onBrokenLinks: 'log', future: { @@ -298,7 +300,7 @@ const config: Config = { { framework: 'vue', routeBasePath: vueInstance.routeBasePath, - docIds: frameworkDocs.docIds('vue'), + docs: frameworkDocs.docsFor('vue'), }, ], ], diff --git a/website/framework-docs/README.md b/website/framework-docs/README.md index 6333411897d7..33ecaa14c847 100644 --- a/website/framework-docs/README.md +++ b/website/framework-docs/README.md @@ -61,6 +61,7 @@ Errors are caught by :react[[Error Boundaries](./AsyncBoundary.md)]:vue[`onError | Different heading text | `## :react[...]:vue[...] {#stable-id}` | | Page has no Vue equivalent | `frameworks: [react]` in front matter | | Vue-only page, or nothing is shareable | `foo.vue.md` next to (or instead of) `foo.md` | +| Same concept under another doc id | `framework_equivalent: ` (see below) | Framework-agnostic code (managers, middleware, types) imports from `@data-client/react` and adds `framework-imports` to the fence; Vue pages show `@data-client/vue` instead. Never import @@ -73,6 +74,11 @@ in a framework are dropped automatically, so Vue-only docs can be listed there t same `vue_` overrides as front matter, e.g. `"vue_label": "Composables"` on the `Hooks` category (docs are relabeled with `vue_sidebar_label:` in their front matter). +When a framework-only page covers what the other framework documents under a different id (Vue's +`DataClientPlugin` is React's `DataProvider`), name that doc id in `framework_equivalent:` on either +page, so the framework selector switches between them. Declaring it on one page is enough; it works +in both directions. The build fails if the id doesn't exist in the other framework. + Give per-framework headings an explicit id so links to them work in both frameworks. A heading with no text left for a framework (e.g. only `:react[...]`) is dropped from that framework's page. @@ -87,8 +93,11 @@ no text left for a framework (e.g. only `:react[...]`) is dropped from that fram - `docsInstances.js` lists every docs instance (id, source folder, route, `llms.txt` path). `docusaurus.config.ts`, `docsToMarkdown.mjs`, `llms-plugin.js` and `remarkFramework.js` (`FRAMEWORKS`) read routes and frameworks from it. -- `FrameworkSelector` (in the breadcrumbs) switches to the same page in the other docs instance, - and disables a framework when the page doesn't exist there. +- `index.js` `docsFor()` lists each framework's docs with their routes (honoring `slug`) and + `framework_equivalent`; `remarkFramework.js` and `docsToMarkdown.mjs` link with those routes. +- `FrameworkSelector` (in the breadcrumbs) switches to the same page in the other docs instance, or + its `framework_equivalent` (`customFields.frameworkEquivalents`), and disables a framework when + neither exists there. ## Agent skill references diff --git a/website/framework-docs/docsToMarkdown.mjs b/website/framework-docs/docsToMarkdown.mjs index 23f2c6b13151..18148a25fe61 100644 --- a/website/framework-docs/docsToMarkdown.mjs +++ b/website/framework-docs/docsToMarkdown.mjs @@ -26,9 +26,13 @@ const require = createRequire(import.meta.url); const preprocessContent = require('@docusaurus/mdx-loader/lib/preprocessor').default; -const { DOCS_INSTANCES, frameworkInstance } = require('./docsInstances.js'); const { - docIds, + DOCS_INSTANCES, + FRAMEWORKS, + frameworkInstance, +} = require('./docsInstances.js'); +const { + docsFor, docIdOf, pageFrameworks, rewriteFrontMatter, @@ -45,7 +49,7 @@ const instanceOf = (relPath, framework) => relPath.startsWith(`${d.path}/`) && (d.framework ?? framework) === framework, ); -const vueIds = docIds('vue'); +const frameworkDocs = Object.fromEntries(FRAMEWORKS.map(f => [f, docsFor(f)])); const MD = /\.mdx?$/; const processor = unified() @@ -114,13 +118,15 @@ export const routeOf = memoize((file, framework) => { const content = contentFor(file, framework); const docId = docIdOf(relPath.slice(instance.path.length + 1), content); // Vue links to pages without a Vue version go to the React docs - const { routeBasePath } = - instance.framework === 'vue' && !vueIds.has(docId) ? + const target = + instance.framework === 'vue' && !frameworkDocs.vue.has(docId) ? frameworkInstance('react') : instance; + const route = frameworkDocs[target.framework]?.get(docId)?.route; + if (route) return `/${target.routeBasePath}${route}`; const slug = frontMatterValue(content, 'slug'); - if (slug?.startsWith('/')) return `/${routeBasePath}${slug}`; - return `/${routeBasePath}/${docId}`.replace(/\/index$/, '/'); + if (slug?.startsWith('/')) return `/${target.routeBasePath}${slug}`; + return `/${target.routeBasePath}/${docId}`.replace(/\/index$/, '/'); }); /** Relative doc links become site routes; absolute ones are left to remarkFramework */ @@ -489,7 +495,7 @@ function render(file, framework, props = {}) { remarkFramework({ framework, routeBasePath: frameworkInstance('vue').routeBasePath, - docIds: vueIds, + docs: frameworkDocs.vue, })(tree); tree.title = frontMatterValue(content, 'title'); return tree; diff --git a/website/framework-docs/index.js b/website/framework-docs/index.js index 06b9ac2440b4..4d41a013ec42 100644 --- a/website/framework-docs/index.js +++ b/website/framework-docs/index.js @@ -84,14 +84,54 @@ function resolveSources(framework) { return sources; } -/** Doc ids (as used in sidebars) that exist for a framework */ -function docIds(framework) { - const ids = new Set(); +/** + * Docs that exist for a framework, by doc id (as used in sidebars): + * - route: site route relative to the instance's routeBasePath, honoring `slug` + * - equivalent: `framework_equivalent:` front matter, the doc id of the same + * concept in the other framework's docs when it has a different name + */ +function docsFor(framework) { + const docs = new Map(); for (const [out, src] of resolveSources(framework)) { if (!MD.test(out) || path.basename(out).startsWith('_')) continue; - ids.add(docIdOf(out, readSrc(src))); + const content = rewriteFrontMatter(readSrc(src), framework); + const id = docIdOf(out, content); + const slug = frontMatterValue(content, 'slug'); + const route = + !slug ? `/${id}` + : slug.startsWith('/') ? slug + : `/${path.posix.join(path.posix.dirname(out), slug)}`; + docs.set(id, { + route: route.replace(/\/(index|README)$/i, '/'), + equivalent: frontMatterValue(content, 'framework_equivalent'), + }); } - return ids; + return docs; +} + +/** Doc ids (as used in sidebars) that exist for a framework */ +const docIds = framework => new Set(docsFor(framework).keys()); + +/** + * `framework_equivalent:` front matter of every framework, for + * FrameworkSelector: { [framework]: { [doc id]: counterpart's doc id } } + */ +function frameworkEquivalents() { + const docs = Object.fromEntries(FRAMEWORKS.map(f => [f, docsFor(f)])); + return Object.fromEntries( + FRAMEWORKS.map(framework => { + const equivalents = {}; + for (const [id, { equivalent }] of docs[framework]) { + if (!equivalent) continue; + if (!FRAMEWORKS.some(f => f !== framework && docs[f].has(equivalent))) + throw new Error( + `${framework} doc ${id}: framework_equivalent '${equivalent}' is not a doc in any other framework`, + ); + equivalents[id] = equivalent; + } + return [framework, equivalents]; + }), + ); } /** Write the mirror for a framework; only touches files whose output changed */ @@ -181,7 +221,9 @@ module.exports = { watch, sidebarsFor, sourcePath, + docsFor, docIds, + frameworkEquivalents, docIdOf, pageFrameworks, rewriteFrontMatter, diff --git a/website/framework-docs/remarkFramework.js b/website/framework-docs/remarkFramework.js index be21cd9542a0..1f4ccc931aa8 100644 --- a/website/framework-docs/remarkFramework.js +++ b/website/framework-docs/remarkFramework.js @@ -13,8 +13,9 @@ * they are not bundled. * * With `routeBasePath`, absolute `/docs/...` links are pointed at this - * instance when the target doc exists in it (`docIds`), so Vue pages link to - * Vue pages; links to React-only docs keep going to /docs. + * instance's route for the target doc when it exists there (`docs`, from + * `docsFor()` in index.js, which honors `slug`), so Vue pages link to Vue + * pages; links to React-only docs keep going to /docs. */ const { FRAMEWORKS } = require('./docsInstances.js'); @@ -94,26 +95,22 @@ function pruneImports(tree) { const DOCS_LINK = /^\/docs(?:\/([^#?]*))?([#?].*)?$/; -function rewriteLinks(node, routeBasePath, docIds) { +function rewriteLinks(node, routeBasePath, docs) { if (node.type === 'link' || node.type === 'definition') { const match = node.url.match(DOCS_LINK); const id = match?.[1]?.replace(/\.mdx?$/, '').replace(/\/$/, ''); - if (match && (!id || docIds.has(id))) - node.url = `/${routeBasePath}${id ? `/${id}` : ''}${match[2] ?? ''}`; + if (match && (!id || docs.has(id))) + node.url = `/${routeBasePath}${id ? docs.get(id).route : ''}${match[2] ?? ''}`; } - node.children?.forEach(child => rewriteLinks(child, routeBasePath, docIds)); + node.children?.forEach(child => rewriteLinks(child, routeBasePath, docs)); } -module.exports = function remarkFramework({ - framework, - routeBasePath, - docIds, -}) { +module.exports = function remarkFramework({ framework, routeBasePath, docs }) { return tree => { filterChildren(tree, framework); rewriteImports(tree, framework); pruneImports(tree); - if (routeBasePath) rewriteLinks(tree, routeBasePath, docIds); + if (routeBasePath) rewriteLinks(tree, routeBasePath, docs); }; }; module.exports.FRAMEWORKS = FRAMEWORKS; diff --git a/website/src/components/FrameworkSelector.tsx b/website/src/components/FrameworkSelector.tsx index 6ae834f40bb2..33cabe3b02de 100644 --- a/website/src/components/FrameworkSelector.tsx +++ b/website/src/components/FrameworkSelector.tsx @@ -3,6 +3,7 @@ import { useAllDocsData, } from '@docusaurus/plugin-content-docs/client'; import { useHistory, useLocation } from '@docusaurus/router'; +import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; import React, { useState, useRef, useEffect } from 'react'; import styles from './FrameworkSelector.module.css'; @@ -34,22 +35,45 @@ const frameworks: { value: Framework; label: string; Logo: React.FC }[] = [ { value: 'vue', label: 'Vue', Logo: VueLogo }, ]; -/** Same page in each framework's docs, if it exists */ +/** `framework_equivalent:` front matter (framework-docs/index.js) */ +type Equivalents = Record>; + +/** + * Same page in each framework's docs, if it exists: the same doc id, or the + * page that names this one (or that this one names) as its + * `framework_equivalent`. The hash only carries over to the same doc. + */ function useCounterparts(): Record { const allDocs = useAllDocsData(); - const { activeDoc } = useActiveDocContext(frameworkPluginId[useFramework()]); - const find = (fw: Framework) => - allDocs[frameworkPluginId[fw]].versions[0].docs.find( - doc => doc.id === activeDoc?.id, - )?.path; - return { react: find('react'), vue: find('vue') }; + const equivalents = useDocusaurusContext().siteConfig.customFields + ?.frameworkEquivalents as Equivalents; + const framework = useFramework(); + const { activeDoc } = useActiveDocContext(frameworkPluginId[framework]); + const { hash } = useLocation(); + const find = (fw: Framework, id: string | undefined) => + allDocs[frameworkPluginId[fw]].versions[0].docs.find(doc => doc.id === id) + ?.path; + const counterpart = (fw: Framework) => { + if (!activeDoc) return; + const same = find(fw, activeDoc.id); + if (same) return same + hash; + return ( + find(fw, equivalents[framework][activeDoc.id]) ?? + find( + fw, + Object.keys(equivalents[fw]).find( + id => equivalents[fw][id] === activeDoc.id, + ), + ) + ); + }; + return { react: counterpart('react'), vue: counterpart('vue') }; } export default function FrameworkSelector() { const framework = useFramework(); const counterparts = useCounterparts(); const history = useHistory(); - const { hash } = useLocation(); const [isOpen, setIsOpen] = useState(false); const containerRef = useRef(null); @@ -68,7 +92,7 @@ export default function FrameworkSelector() { const handleSelect = (value: Framework) => { setIsOpen(false); const target = counterparts[value]; - if (value !== framework && target) history.push(target + hash); + if (value !== framework && target) history.push(target); }; return ( From 347f4ffe02c194f6c1e63f4b2f4fbaa15f9c17d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 19:35:55 +0000 Subject: [PATCH 2/4] docs(website): Pair renderDataHook with Vue's composables testing guide Also simplifies after review: frameworkEquivalents() emits both directions so the selector does one lookup, and routes come from Docusaurus' own getSlug() instead of a hand-rolled copy of its rules. Vue skill references now link Vue's composables testing guide at its slugged route, so their Vue variants regenerate. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Fjs86eqj7vj4ia4om1Cmbo --- .../data-client-rest/references/Entity.vue.md | 2 +- .../references/RestEndpoint.vue.md | 6 +- .../_entity_lifecycle_methods.vue.md | 2 +- .../references/network-transform.vue.md | 2 +- .../references/resource.vue.md | 2 +- .../data-client-rest/references/schema.vue.md | 267 ++++++++++++++++++ .../references/Entity.vue.md | 2 +- .../references/EntityMixin.vue.md | 2 +- .../data-client-schema/references/Lazy.vue.md | 2 +- .../references/Query.vue.md | 4 +- .../references/Scalar.vue.md | 6 +- .../references/schema.vue.md | 267 ++++++++++++++++++ docs/core/api/makeRenderDataHook.md | 2 +- docs/core/api/renderDataHook.md | 1 + website/framework-docs/docsToMarkdown.mjs | 1 + website/framework-docs/index.js | 53 ++-- website/src/components/FrameworkSelector.tsx | 16 +- 17 files changed, 582 insertions(+), 55 deletions(-) create mode 100644 .agents/skills/data-client-rest/references/schema.vue.md create mode 100644 .agents/skills/data-client-schema/references/schema.vue.md diff --git a/.agents/skills/data-client-rest/references/Entity.vue.md b/.agents/skills/data-client-rest/references/Entity.vue.md index 1c7b06024fe4..9b9e87f73926 100644 --- a/.agents/skills/data-client-rest/references/Entity.vue.md +++ b/.agents/skills/data-client-rest/references/Entity.vue.md @@ -685,7 +685,7 @@ static mergeMetaWithStore( ### static queryKey(args, queryKey, getEntity, getIndex): pk? {#queryKey} -This method enables `Entities` to be [Queryable](./schema.md#queryable) - allowing store access without an endpoint. +This method enables `Entities` to be [Queryable](./schema.vue.md#queryable) - allowing store access without an endpoint. Overriding can allow customization or disabling of this behavior altogether. diff --git a/.agents/skills/data-client-rest/references/RestEndpoint.vue.md b/.agents/skills/data-client-rest/references/RestEndpoint.vue.md index ba46e7b661a4..4c90bf9a0c10 100644 --- a/.agents/skills/data-client-rest/references/RestEndpoint.vue.md +++ b/.agents/skills/data-client-rest/references/RestEndpoint.vue.md @@ -127,7 +127,7 @@ export const getTodo = new RestEndpoint({ export const updateTodo = getTodo.extend({ method: 'PUT' }); ``` -Using a [Schema](./schema.md) enables [automatic data consistency](https://dataclient.io/vue/concepts/normalization) without the need to hurt performance with [refetching](https://dataclient.io/vue/api/Controller#expireAll). +Using a [Schema](./schema.vue.md) enables [automatic data consistency](https://dataclient.io/vue/concepts/normalization) without the need to hurt performance with [refetching](https://dataclient.io/vue/api/Controller#expireAll). ### Typing @@ -826,9 +826,9 @@ const getUserWithId = getUser.extend({ ### schema?: Schema {#schema} -[Declarative data lifecycle](./schema.md) +[Declarative data lifecycle](./schema.vue.md) -- Global data consistency and performance with [DRY](https://www.plutora.com/blog/understanding-the-dry-dont-repeat-yourself-principle) state: [where](./schema.md) to expect [Entities](./Entity.vue.md) +- Global data consistency and performance with [DRY](https://www.plutora.com/blog/understanding-the-dry-dont-repeat-yourself-principle) state: [where](./schema.vue.md) to expect [Entities](./Entity.vue.md) - Functions to [deserialize fields](./network-transform.vue.md#deserializing-fields) - [Race condition handling](./Entity.vue.md#shouldreorder) - [Validation](./Entity.vue.md#validate) diff --git a/.agents/skills/data-client-rest/references/_entity_lifecycle_methods.vue.md b/.agents/skills/data-client-rest/references/_entity_lifecycle_methods.vue.md index 711fbed5b142..0fe8791ccda2 100644 --- a/.agents/skills/data-client-rest/references/_entity_lifecycle_methods.vue.md +++ b/.agents/skills/data-client-rest/references/_entity_lifecycle_methods.vue.md @@ -228,7 +228,7 @@ static mergeMetaWithStore( ### static queryKey(args, queryKey, getEntity, getIndex): pk? {#queryKey} -This method enables `Entities` to be [Queryable](./schema.md#queryable) - allowing store access without an endpoint. +This method enables `Entities` to be [Queryable](./schema.vue.md#queryable) - allowing store access without an endpoint. Overriding can allow customization or disabling of this behavior altogether. diff --git a/.agents/skills/data-client-rest/references/network-transform.vue.md b/.agents/skills/data-client-rest/references/network-transform.vue.md index 97094c09069a..23b7a67ef99b 100644 --- a/.agents/skills/data-client-rest/references/network-transform.vue.md +++ b/.agents/skills/data-client-rest/references/network-transform.vue.md @@ -106,7 +106,7 @@ export const getPrice = new RestEndpoint({ ### Deserializing Date In case you want to use legacy [Date](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date), -you can turn the constructor into a function [schema](./schema.md). +you can turn the constructor into a function [schema](./schema.vue.md). ```ts export class ExchangePrice extends Entity { diff --git a/.agents/skills/data-client-rest/references/resource.vue.md b/.agents/skills/data-client-rest/references/resource.vue.md index e277bf5592c0..a2bbbcf838d7 100644 --- a/.agents/skills/data-client-rest/references/resource.vue.md +++ b/.agents/skills/data-client-rest/references/resource.vue.md @@ -3,7 +3,7 @@ # Resource `Resources` are a collection of [RestEndpoints](./RestEndpoint.vue.md) that operate on a common -data by sharing a [schema](./schema.md) +data by sharing a [schema](./schema.vue.md) ## Usage diff --git a/.agents/skills/data-client-rest/references/schema.vue.md b/.agents/skills/data-client-rest/references/schema.vue.md new file mode 100644 index 000000000000..5be879632946 --- /dev/null +++ b/.agents/skills/data-client-rest/references/schema.vue.md @@ -0,0 +1,267 @@ + + +# Thinking in Schemas + +Consider a typical blog post. The API response for a single post might look something like this: + +```json +{ + "id": "123", + "author": { + "id": "1", + "name": "Paul" + }, + "title": "My awesome blog post", + "comments": [ + { + "id": "324", + "createdAt": "2013-05-29T00:00:00-04:00", + "commenter": { + "id": "2", + "name": "Nicole" + } + }, + { + "id": "544", + "createdAt": "2013-05-30T00:00:00-04:00", + "commenter": { + "id": "1", + "name": "Paul" + } + } + ] +} +``` + +## Declarative definitions + +We have two nested [entity](./Entity.vue.md) types within our `article`: `users` and `comments`. Using various [schema](./Entity.vue.md#schema), we can normalize all three entity types down: + +```typescript +import { schema, Entity } from '@data-client/endpoint'; +import { Temporal } from 'temporal-polyfill'; + +class User extends Entity { + id = ''; + name = ''; +} + +class Comment extends Entity { + id = ''; + createdAt = Temporal.Instant.fromEpochMilliseconds(0); + commenter = User.fromJS(); + + static schema = { + commenter: User, + createdAt: Temporal.Instant.from, + }; +} + +class Article extends Entity { + id = ''; + title = ''; + author = User.fromJS(); + comments: Comment[] = []; + + static schema = { + author: User, + comments: [Comment], + }; +} +``` + +```javascript +import { schema, Entity } from '@data-client/endpoint'; +import { Temporal } from 'temporal-polyfill'; + +class User extends Entity { } + +class Comment extends Entity { + static schema = { + commenter: User, + createdAt: Temporal.Instant.from, + }; +} + +class Article extends Entity { + static schema = { + author: User, + comments: [Comment], + }; +} +``` + +## Normalize + +```js +import { normalize } from '@data-client/normalizr'; + +const args = [{ id: '123' }]; +const normalizedData = normalize(Article, originalData, args); +``` + +Now, `normalizedData` will create a single serializable source of truth for all entities: + +```js +{ + result: "123", + entities: { + articles: { + "123": { + id: "123", + author: "1", + title: "My awesome blog post", + comments: [ "324", "544" ] + } + }, + users: { + "1": { "id": "1", "name": "Paul" }, + "2": { "id": "2", "name": "Nicole" } + }, + comments: { + "324": { + id: "324", + createdAt: "2013-05-29T00:00:00-04:00", + commenter: "2" + }, + "544": { + id: "544", + createdAt: "2013-05-30T00:00:00-04:00", + commenter: "1" + } + } + }, + // contents excluded for brevity + indexes, + entitiesMeta, +} +``` + +## Denormalize + +```js +import { denormalize } from '@data-client/normalizr'; + +const denormalizedData = denormalize( + Article, + normalizedData.result, + normalizedData.entities, + args, +); +``` + +Now, `denormalizedData` will instantiate the classes, ensuring all instances of the same member (like `Paul`) are referentially equal: + +```js +Article { + id: '123', + title: 'My awesome blog post', + author: User { id: '1', name: 'Paul' }, + comments: [ + Comment { + id: '324', + createdAt: Instant [Temporal.Instant] {}, + commenter: [User { id: '2', name: 'Nicole' }] + }, + Comment { + id: '544', + createdAt: Instant [Temporal.Instant] {}, + commenter: [User { id: '1', name: 'Paul' }] + } + ] +} +``` + +### MemoCache + +`MemoCache` is a singleton that can be used to maintain referential equality between calls as well +as potentially improved performance by 2000%. Its methods are memoized. + +#### memo.denormalize + +```js +import { MemoCache } from '@data-client/normalizr'; + +// you can construct a new memo anytime you want to reset the cache +const memo = new MemoCache(); + +const { data, paths } = memo.denormalize( + Article, + normalizedData.result, + normalizedData.entities, + args, +); +const { data: data2 } = memo.denormalize( + Article, + normalizedData.result, + normalizedData.entities, + args, +); + +// referential equality maintained between calls +assert(data === data2); +``` + +`memo.denormalize()` is just like [denormalize()](#denormalize) above but includes `paths` as part of the return value. `paths` +is an Array of paths of all entities included in the result. + +#### memo.query + +`memo.query()` allows denormalizing [Queryable](#queryable) based on args alone, rather than a normalized input. + +```ts +const data = memo.query( + Article, + args, + normalizedData, +); +``` + +## Queryable + +`Queryable` Schemas allow store access without an endpoint. They achieve this using the +[queryKey](./Entity.vue.md#queryKey) method that produces the results normally stored in the endpoint cache. + +This enables their use in these additional cases: + +- [useQuery()](https://dataclient.io/vue/api/useQuery) - Rendering in React +- [schema.Query()](https://dataclient.io/rest/api/Query) - As input to produce a computed memoization. +- [ctrl.get](https://dataclient.io/vue/api/Controller#get)/[snap.get](https://dataclient.io/vue/api/Snapshot#get) + - [Managers](https://dataclient.io/vue/concepts/managers) + - React with [useController()](https://dataclient.io/vue/api/useController) + - [RestEndpoint.getOptimisticResponse](./RestEndpoint.vue.md#getoptimisticresponse) + - [Unit testing hooks](https://dataclient.io/vue/guides/unit-testing-composables) with [renderDataHook()](https://dataclient.io/docs/api/renderDataHook) +- [memo.query()](#memoquery) +- Improve performance of [useSuspense](https://dataclient.io/vue/api/useSuspense), [useDLE](https://dataclient.io/vue/api/useDLE) by rendering before endpoint resolution + +`Querables` include [Entity](./Entity.vue.md), [All](https://dataclient.io/rest/api/All), [Collection](./Collection.vue.md), [Query](https://dataclient.io/rest/api/Query), +[Union](https://dataclient.io/rest/api/Union), and [Scalar](https://dataclient.io/rest/api/Scalar). [Lazy](https://dataclient.io/rest/api/Lazy) fields produce a Queryable via their [`.query`](https://dataclient.io/rest/api/Lazy#query) accessor. + +```ts +interface Queryable { + queryKey( + args: readonly any[], + queryKey: (...args: any) => any, + getEntity: GetEntity, + getIndex: GetIndex, + // `{}` means non-void + ): {}; +} +``` + +## Schema Overview + +| Data Type | Mutable | Schema | Description | [Queryable](./schema.vue.md#queryable) | +| ------------------------------------------------------------------- | ------- | --------------------------------------------------------------- | -------------------------------------------------------- | ---------------------------------- | +| [Object](https://en.wikipedia.org/wiki/Object_\(computer_science\)) | ✅ | [Entity](./Entity.vue.md) | single _unique_ object | ✅ | +| [Object](https://en.wikipedia.org/wiki/Object_\(computer_science\)) | ✅ | [Union(Entity)](https://dataclient.io/rest/api/Union) | polymorphic objects (`A \| B`) | ✅ | +| [Object](https://en.wikipedia.org/wiki/Object_\(computer_science\)) | 🛑 | [Object](https://dataclient.io/rest/api/Object) | statically known keys | 🛑 | +| [Object](https://en.wikipedia.org/wiki/Object_\(computer_science\)) | | [Invalidate(Entity)](https://dataclient.io/rest/api/Invalidate) | [delete an entity](./expiry-policy.vue.md#invalidate-entity) | 🛑 | +| [List](https://en.wikipedia.org/wiki/List_\(abstract_data_type\)) | ✅ | [Collection(Array)](./Collection.vue.md) | growable lists | ✅ | +| [List](https://en.wikipedia.org/wiki/List_\(abstract_data_type\)) | 🛑 | [Array](https://dataclient.io/rest/api/Array) | immutable lists | 🛑 | +| [List](https://en.wikipedia.org/wiki/List_\(abstract_data_type\)) | | [All](https://dataclient.io/rest/api/All) | list of all entities of a kind | ✅ | +| [Map](https://en.wikipedia.org/wiki/Associative_array) | ✅ | [Collection(Values)](./Collection.vue.md) | growable maps | ✅ | +| [Map](https://en.wikipedia.org/wiki/Associative_array) | 🛑 | [Values](https://dataclient.io/rest/api/Values) | immutable maps | 🛑 | +| [Scalar](https://en.wikipedia.org/wiki/Scalar_\(mathematics\)) | ✅ | [Scalar](https://dataclient.io/rest/api/Scalar) | lens-dependent entity fields | ✅ | +| any | | [Query(Queryable)](https://dataclient.io/rest/api/Query) | memoized custom transforms | ✅ | +| any | | [Lazy(Schema)](https://dataclient.io/rest/api/Lazy) | deferred denormalization | ✅ | diff --git a/.agents/skills/data-client-schema/references/Entity.vue.md b/.agents/skills/data-client-schema/references/Entity.vue.md index 93b7a2351d1b..8acc8440f346 100644 --- a/.agents/skills/data-client-schema/references/Entity.vue.md +++ b/.agents/skills/data-client-schema/references/Entity.vue.md @@ -685,7 +685,7 @@ static mergeMetaWithStore( ### static queryKey(args, queryKey, getEntity, getIndex): pk? {#queryKey} -This method enables `Entities` to be [Queryable](./schema.md#queryable) - allowing store access without an endpoint. +This method enables `Entities` to be [Queryable](./schema.vue.md#queryable) - allowing store access without an endpoint. Overriding can allow customization or disabling of this behavior altogether. diff --git a/.agents/skills/data-client-schema/references/EntityMixin.vue.md b/.agents/skills/data-client-schema/references/EntityMixin.vue.md index 8dfccf3d204f..51053281eb38 100644 --- a/.agents/skills/data-client-schema/references/EntityMixin.vue.md +++ b/.agents/skills/data-client-schema/references/EntityMixin.vue.md @@ -406,7 +406,7 @@ static mergeMetaWithStore( ### static queryKey(args, queryKey, getEntity, getIndex): pk? {#queryKey} -This method enables `Entities` to be [Queryable](./schema.md#queryable) - allowing store access without an endpoint. +This method enables `Entities` to be [Queryable](./schema.vue.md#queryable) - allowing store access without an endpoint. Overriding can allow customization or disabling of this behavior altogether. diff --git a/.agents/skills/data-client-schema/references/Lazy.vue.md b/.agents/skills/data-client-schema/references/Lazy.vue.md index b7bc6c53b92f..f882bd5f45c4 100644 --- a/.agents/skills/data-client-schema/references/Lazy.vue.md +++ b/.agents/skills/data-client-schema/references/Lazy.vue.md @@ -16,7 +16,7 @@ This is useful for: new Lazy(innerSchema) ``` -- `innerSchema`: Any [Schema](./schema.md) — an [Entity](./Entity.vue.md), an array shorthand like `[MyEntity]`, a [Collection](./Collection.vue.md), etc. +- `innerSchema`: Any [Schema](./schema.vue.md) — an [Entity](./Entity.vue.md), an array shorthand like `[MyEntity]`, a [Collection](./Collection.vue.md), etc. ## Usage diff --git a/.agents/skills/data-client-schema/references/Query.vue.md b/.agents/skills/data-client-schema/references/Query.vue.md index d8b30971bba2..0345c3015b7a 100644 --- a/.agents/skills/data-client-schema/references/Query.vue.md +++ b/.agents/skills/data-client-schema/references/Query.vue.md @@ -11,8 +11,8 @@ the same high performance and referential equality guarantees expected of Reacti ### schema -[Schema](./schema.md) used to retrieve/denormalize data from the Reactive Data Client cache. -This accepts any [Queryable](./schema.md#queryable) schema: [Entity](./Entity.vue.md), [All](./All.vue.md), [Collection](./Collection.vue.md), [Query](./Query.vue.md), +[Schema](./schema.vue.md) used to retrieve/denormalize data from the Reactive Data Client cache. +This accepts any [Queryable](./schema.vue.md#queryable) schema: [Entity](./Entity.vue.md), [All](./All.vue.md), [Collection](./Collection.vue.md), [Query](./Query.vue.md), [Union](./Union.vue.md), [Scalar](./Scalar.vue.md), and [Object](./Object.vue.md) schemas for joining multiple entities. [Lazy](./Lazy.vue.md) fields produce a Queryable via their [`.query`](./Lazy.vue.md#query) accessor. diff --git a/.agents/skills/data-client-schema/references/Scalar.vue.md b/.agents/skills/data-client-schema/references/Scalar.vue.md index 53f6ce0be1b1..c60c075327f5 100644 --- a/.agents/skills/data-client-schema/references/Scalar.vue.md +++ b/.agents/skills/data-client-schema/references/Scalar.vue.md @@ -17,7 +17,7 @@ different lens args at the same time, each receiving the correct scalar values. > **Note** > > `Scalar` is for scalar values like numbers, strings, booleans, or date-derived values. -> Use normal nested [schemas](./schema.md) for relationships to other entities. +> Use normal nested [schemas](./schema.vue.md) for relationships to other entities. ## Usage @@ -336,7 +336,7 @@ data. ### queryKey {#queryKey} -`Scalar` is a [Queryable](./schema.md#queryable) schema. When used as a +`Scalar` is a [Queryable](./schema.vue.md#queryable) schema. When used as a top-level endpoint schema — or passed to [useQuery](https://dataclient.io/vue/api/useQuery), [Controller.get](https://dataclient.io/vue/api/Controller#get), [schema.Query](./Query.vue.md), or any other Queryable consumer — it reports the cpks of all cells whose lens matches @@ -376,4 +376,4 @@ entities['Scalar(portfolio)']['Company|1|portfolioB'] = { - [Entity](./Entity.vue.md) — defines the base entity that scalar fields attach to - [Values](./Values.vue.md) — used for column-only endpoints (dictionary keyed by entity pk) - [Union](./Union.vue.md) — similar wrapper pattern for polymorphic entities -- [Queryable](./schema.md#queryable) — Scalar participates in [useQuery](https://dataclient.io/vue/api/useQuery), [Controller.get](https://dataclient.io/vue/api/Controller#get), and [schema.Query](./Query.vue.md) +- [Queryable](./schema.vue.md#queryable) — Scalar participates in [useQuery](https://dataclient.io/vue/api/useQuery), [Controller.get](https://dataclient.io/vue/api/Controller#get), and [schema.Query](./Query.vue.md) diff --git a/.agents/skills/data-client-schema/references/schema.vue.md b/.agents/skills/data-client-schema/references/schema.vue.md new file mode 100644 index 000000000000..112d2130df2d --- /dev/null +++ b/.agents/skills/data-client-schema/references/schema.vue.md @@ -0,0 +1,267 @@ + + +# Thinking in Schemas + +Consider a typical blog post. The API response for a single post might look something like this: + +```json +{ + "id": "123", + "author": { + "id": "1", + "name": "Paul" + }, + "title": "My awesome blog post", + "comments": [ + { + "id": "324", + "createdAt": "2013-05-29T00:00:00-04:00", + "commenter": { + "id": "2", + "name": "Nicole" + } + }, + { + "id": "544", + "createdAt": "2013-05-30T00:00:00-04:00", + "commenter": { + "id": "1", + "name": "Paul" + } + } + ] +} +``` + +## Declarative definitions + +We have two nested [entity](./Entity.vue.md) types within our `article`: `users` and `comments`. Using various [schema](./Entity.vue.md#schema), we can normalize all three entity types down: + +```typescript +import { schema, Entity } from '@data-client/endpoint'; +import { Temporal } from 'temporal-polyfill'; + +class User extends Entity { + id = ''; + name = ''; +} + +class Comment extends Entity { + id = ''; + createdAt = Temporal.Instant.fromEpochMilliseconds(0); + commenter = User.fromJS(); + + static schema = { + commenter: User, + createdAt: Temporal.Instant.from, + }; +} + +class Article extends Entity { + id = ''; + title = ''; + author = User.fromJS(); + comments: Comment[] = []; + + static schema = { + author: User, + comments: [Comment], + }; +} +``` + +```javascript +import { schema, Entity } from '@data-client/endpoint'; +import { Temporal } from 'temporal-polyfill'; + +class User extends Entity { } + +class Comment extends Entity { + static schema = { + commenter: User, + createdAt: Temporal.Instant.from, + }; +} + +class Article extends Entity { + static schema = { + author: User, + comments: [Comment], + }; +} +``` + +## Normalize + +```js +import { normalize } from '@data-client/normalizr'; + +const args = [{ id: '123' }]; +const normalizedData = normalize(Article, originalData, args); +``` + +Now, `normalizedData` will create a single serializable source of truth for all entities: + +```js +{ + result: "123", + entities: { + articles: { + "123": { + id: "123", + author: "1", + title: "My awesome blog post", + comments: [ "324", "544" ] + } + }, + users: { + "1": { "id": "1", "name": "Paul" }, + "2": { "id": "2", "name": "Nicole" } + }, + comments: { + "324": { + id: "324", + createdAt: "2013-05-29T00:00:00-04:00", + commenter: "2" + }, + "544": { + id: "544", + createdAt: "2013-05-30T00:00:00-04:00", + commenter: "1" + } + } + }, + // contents excluded for brevity + indexes, + entitiesMeta, +} +``` + +## Denormalize + +```js +import { denormalize } from '@data-client/normalizr'; + +const denormalizedData = denormalize( + Article, + normalizedData.result, + normalizedData.entities, + args, +); +``` + +Now, `denormalizedData` will instantiate the classes, ensuring all instances of the same member (like `Paul`) are referentially equal: + +```js +Article { + id: '123', + title: 'My awesome blog post', + author: User { id: '1', name: 'Paul' }, + comments: [ + Comment { + id: '324', + createdAt: Instant [Temporal.Instant] {}, + commenter: [User { id: '2', name: 'Nicole' }] + }, + Comment { + id: '544', + createdAt: Instant [Temporal.Instant] {}, + commenter: [User { id: '1', name: 'Paul' }] + } + ] +} +``` + +### MemoCache + +`MemoCache` is a singleton that can be used to maintain referential equality between calls as well +as potentially improved performance by 2000%. Its methods are memoized. + +#### memo.denormalize + +```js +import { MemoCache } from '@data-client/normalizr'; + +// you can construct a new memo anytime you want to reset the cache +const memo = new MemoCache(); + +const { data, paths } = memo.denormalize( + Article, + normalizedData.result, + normalizedData.entities, + args, +); +const { data: data2 } = memo.denormalize( + Article, + normalizedData.result, + normalizedData.entities, + args, +); + +// referential equality maintained between calls +assert(data === data2); +``` + +`memo.denormalize()` is just like [denormalize()](#denormalize) above but includes `paths` as part of the return value. `paths` +is an Array of paths of all entities included in the result. + +#### memo.query + +`memo.query()` allows denormalizing [Queryable](#queryable) based on args alone, rather than a normalized input. + +```ts +const data = memo.query( + Article, + args, + normalizedData, +); +``` + +## Queryable + +`Queryable` Schemas allow store access without an endpoint. They achieve this using the +[queryKey](./Entity.vue.md#queryKey) method that produces the results normally stored in the endpoint cache. + +This enables their use in these additional cases: + +- [useQuery()](https://dataclient.io/vue/api/useQuery) - Rendering in React +- [schema.Query()](./Query.vue.md) - As input to produce a computed memoization. +- [ctrl.get](https://dataclient.io/vue/api/Controller#get)/[snap.get](https://dataclient.io/vue/api/Snapshot#get) + - [Managers](https://dataclient.io/vue/concepts/managers) + - React with [useController()](https://dataclient.io/vue/api/useController) + - [RestEndpoint.getOptimisticResponse](https://dataclient.io/rest/api/RestEndpoint#getoptimisticresponse) + - [Unit testing hooks](https://dataclient.io/vue/guides/unit-testing-composables) with [renderDataHook()](https://dataclient.io/docs/api/renderDataHook) +- [memo.query()](#memoquery) +- Improve performance of [useSuspense](https://dataclient.io/vue/api/useSuspense), [useDLE](https://dataclient.io/vue/api/useDLE) by rendering before endpoint resolution + +`Querables` include [Entity](./Entity.vue.md), [All](./All.vue.md), [Collection](./Collection.vue.md), [Query](./Query.vue.md), +[Union](./Union.vue.md), and [Scalar](./Scalar.vue.md). [Lazy](./Lazy.vue.md) fields produce a Queryable via their [`.query`](./Lazy.vue.md#query) accessor. + +```ts +interface Queryable { + queryKey( + args: readonly any[], + queryKey: (...args: any) => any, + getEntity: GetEntity, + getIndex: GetIndex, + // `{}` means non-void + ): {}; +} +``` + +## Schema Overview + +| Data Type | Mutable | Schema | Description | [Queryable](./schema.vue.md#queryable) | +| ------------------------------------------------------------------- | ------- | ------------------------------------- | -------------------------------------------------------------------------------------- | ---------------------------------- | +| [Object](https://en.wikipedia.org/wiki/Object_\(computer_science\)) | ✅ | [Entity](./Entity.vue.md) | single _unique_ object | ✅ | +| [Object](https://en.wikipedia.org/wiki/Object_\(computer_science\)) | ✅ | [Union(Entity)](./Union.vue.md) | polymorphic objects (`A \| B`) | ✅ | +| [Object](https://en.wikipedia.org/wiki/Object_\(computer_science\)) | 🛑 | [Object](./Object.vue.md) | statically known keys | 🛑 | +| [Object](https://en.wikipedia.org/wiki/Object_\(computer_science\)) | | [Invalidate(Entity)](./Invalidate.vue.md) | [delete an entity](https://dataclient.io/vue/concepts/expiry-policy#invalidate-entity) | 🛑 | +| [List](https://en.wikipedia.org/wiki/List_\(abstract_data_type\)) | ✅ | [Collection(Array)](./Collection.vue.md) | growable lists | ✅ | +| [List](https://en.wikipedia.org/wiki/List_\(abstract_data_type\)) | 🛑 | [Array](./Array.vue.md) | immutable lists | 🛑 | +| [List](https://en.wikipedia.org/wiki/List_\(abstract_data_type\)) | | [All](./All.vue.md) | list of all entities of a kind | ✅ | +| [Map](https://en.wikipedia.org/wiki/Associative_array) | ✅ | [Collection(Values)](./Collection.vue.md) | growable maps | ✅ | +| [Map](https://en.wikipedia.org/wiki/Associative_array) | 🛑 | [Values](./Values.vue.md) | immutable maps | 🛑 | +| [Scalar](https://en.wikipedia.org/wiki/Scalar_\(mathematics\)) | ✅ | [Scalar](./Scalar.vue.md) | lens-dependent entity fields | ✅ | +| any | | [Query(Queryable)](./Query.vue.md) | memoized custom transforms | ✅ | +| any | | [Lazy(Schema)](./Lazy.vue.md) | deferred denormalization | ✅ | diff --git a/docs/core/api/makeRenderDataHook.md b/docs/core/api/makeRenderDataHook.md index 521b86d6895c..6c140f518b99 100644 --- a/docs/core/api/makeRenderDataHook.md +++ b/docs/core/api/makeRenderDataHook.md @@ -1,5 +1,6 @@ --- frameworks: [react] +framework_equivalent: guides/unit-testing-hooks title: makeRenderDataHook() --- @@ -33,7 +34,6 @@ The Reactive Data Client [<DataProvider />](./DataProvider.md) ## Example - ```typescript import { DataProvider } from '@data-client/react/redux'; import { makeRenderDataHook } from '@data-client/test'; diff --git a/docs/core/api/renderDataHook.md b/docs/core/api/renderDataHook.md index 360179beab93..9dd4161821dc 100644 --- a/docs/core/api/renderDataHook.md +++ b/docs/core/api/renderDataHook.md @@ -1,5 +1,6 @@ --- frameworks: [react] +framework_equivalent: guides/unit-testing-hooks title: renderDataHook() --- diff --git a/website/framework-docs/docsToMarkdown.mjs b/website/framework-docs/docsToMarkdown.mjs index 18148a25fe61..fdeb397cdfc2 100644 --- a/website/framework-docs/docsToMarkdown.mjs +++ b/website/framework-docs/docsToMarkdown.mjs @@ -124,6 +124,7 @@ export const routeOf = memoize((file, framework) => { : instance; const route = frameworkDocs[target.framework]?.get(docId)?.route; if (route) return `/${target.routeBasePath}${route}`; + // instances docsFor() doesn't cover (rest, graphql) const slug = frontMatterValue(content, 'slug'); if (slug?.startsWith('/')) return `/${target.routeBasePath}${slug}`; return `/${target.routeBasePath}/${docId}`.replace(/\/index$/, '/'); diff --git a/website/framework-docs/index.js b/website/framework-docs/index.js index 4d41a013ec42..85dda207fe18 100644 --- a/website/framework-docs/index.js +++ b/website/framework-docs/index.js @@ -9,6 +9,9 @@ const fs = require('fs'); const path = require('path'); +// Docusaurus' own route rules (slug, category index), so links match its routes +const getSlug = require('@docusaurus/plugin-content-docs/lib/slug.js').default; + const { FRAMEWORKS, frameworkInstance } = require('./docsInstances.js'); const SRC = path.resolve(__dirname, '../..', frameworkInstance('react').path); @@ -96,42 +99,41 @@ function docsFor(framework) { if (!MD.test(out) || path.basename(out).startsWith('_')) continue; const content = rewriteFrontMatter(readSrc(src), framework); const id = docIdOf(out, content); - const slug = frontMatterValue(content, 'slug'); - const route = - !slug ? `/${id}` - : slug.startsWith('/') ? slug - : `/${path.posix.join(path.posix.dirname(out), slug)}`; docs.set(id, { - route: route.replace(/\/(index|README)$/i, '/'), + route: getSlug({ + baseID: path.posix.basename(id), + source: out, + sourceDirName: path.posix.dirname(out), + frontMatterSlug: frontMatterValue(content, 'slug'), + }), equivalent: frontMatterValue(content, 'framework_equivalent'), }); } return docs; } -/** Doc ids (as used in sidebars) that exist for a framework */ -const docIds = framework => new Set(docsFor(framework).keys()); - /** - * `framework_equivalent:` front matter of every framework, for + * `framework_equivalent:` front matter in both directions, for * FrameworkSelector: { [framework]: { [doc id]: counterpart's doc id } } */ function frameworkEquivalents() { const docs = Object.fromEntries(FRAMEWORKS.map(f => [f, docsFor(f)])); - return Object.fromEntries( - FRAMEWORKS.map(framework => { - const equivalents = {}; - for (const [id, { equivalent }] of docs[framework]) { - if (!equivalent) continue; - if (!FRAMEWORKS.some(f => f !== framework && docs[f].has(equivalent))) - throw new Error( - `${framework} doc ${id}: framework_equivalent '${equivalent}' is not a doc in any other framework`, - ); - equivalents[id] = equivalent; - } - return [framework, equivalents]; - }), - ); + const equivalents = Object.fromEntries(FRAMEWORKS.map(f => [f, {}])); + for (const framework of FRAMEWORKS) { + for (const [id, { equivalent }] of docs[framework]) { + if (!equivalent) continue; + const others = FRAMEWORKS.filter( + f => f !== framework && docs[f].has(equivalent), + ); + if (!others.length) + throw new Error( + `${framework} doc ${id}: framework_equivalent '${equivalent}' is not a doc in any other framework`, + ); + equivalents[framework][id] = equivalent; + for (const other of others) equivalents[other][equivalent] ??= id; + } + } + return equivalents; } /** Write the mirror for a framework; only touches files whose output changed */ @@ -202,7 +204,7 @@ function filterSidebar(items, ids, framework) { } function sidebarsFor(framework, sidebars) { - const ids = docIds(framework); + const ids = docsFor(framework); return Object.fromEntries( Object.entries(sidebars).map(([name, items]) => [ name, @@ -222,7 +224,6 @@ module.exports = { sidebarsFor, sourcePath, docsFor, - docIds, frameworkEquivalents, docIdOf, pageFrameworks, diff --git a/website/src/components/FrameworkSelector.tsx b/website/src/components/FrameworkSelector.tsx index 33cabe3b02de..ec48d6ac7c08 100644 --- a/website/src/components/FrameworkSelector.tsx +++ b/website/src/components/FrameworkSelector.tsx @@ -35,12 +35,11 @@ const frameworks: { value: Framework; label: string; Logo: React.FC }[] = [ { value: 'vue', label: 'Vue', Logo: VueLogo }, ]; -/** `framework_equivalent:` front matter (framework-docs/index.js) */ +/** `framework_equivalent:` front matter, both directions (framework-docs/index.js) */ type Equivalents = Record>; /** - * Same page in each framework's docs, if it exists: the same doc id, or the - * page that names this one (or that this one names) as its + * Same page in each framework's docs, if it exists: the same doc id, or its * `framework_equivalent`. The hash only carries over to the same doc. */ function useCounterparts(): Record { @@ -56,16 +55,7 @@ function useCounterparts(): Record { const counterpart = (fw: Framework) => { if (!activeDoc) return; const same = find(fw, activeDoc.id); - if (same) return same + hash; - return ( - find(fw, equivalents[framework][activeDoc.id]) ?? - find( - fw, - Object.keys(equivalents[fw]).find( - id => equivalents[fw][id] === activeDoc.id, - ), - ) - ); + return same ? same + hash : find(fw, equivalents[framework][activeDoc.id]); }; return { react: counterpart('react'), vue: counterpart('vue') }; } From d52f80344a5f493d34f7002c142a4f349cd89fe9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 19:41:55 +0000 Subject: [PATCH 3/4] docs(website): Address Staff review on framework equivalents - frameworkEquivalents() only records a reverse pair when it can be used, and fails the build when two pages would claim it - schema.md says Vue/renderDataCompose() in Vue renders, so the Vue skill reference no longer tells Vue users to use renderDataHook() Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Fjs86eqj7vj4ia4om1Cmbo --- .../skills/data-client-rest/references/schema.vue.md | 6 +++--- .../data-client-schema/references/schema.vue.md | 6 +++--- docs/rest/api/schema.md | 6 +++--- website/framework-docs/index.js | 11 ++++++++++- 4 files changed, 19 insertions(+), 10 deletions(-) diff --git a/.agents/skills/data-client-rest/references/schema.vue.md b/.agents/skills/data-client-rest/references/schema.vue.md index 5be879632946..bef654ed7445 100644 --- a/.agents/skills/data-client-rest/references/schema.vue.md +++ b/.agents/skills/data-client-rest/references/schema.vue.md @@ -224,13 +224,13 @@ const data = memo.query( This enables their use in these additional cases: -- [useQuery()](https://dataclient.io/vue/api/useQuery) - Rendering in React +- [useQuery()](https://dataclient.io/vue/api/useQuery) - Rendering in Vue - [schema.Query()](https://dataclient.io/rest/api/Query) - As input to produce a computed memoization. - [ctrl.get](https://dataclient.io/vue/api/Controller#get)/[snap.get](https://dataclient.io/vue/api/Snapshot#get) - [Managers](https://dataclient.io/vue/concepts/managers) - - React with [useController()](https://dataclient.io/vue/api/useController) + - Vue with [useController()](https://dataclient.io/vue/api/useController) - [RestEndpoint.getOptimisticResponse](./RestEndpoint.vue.md#getoptimisticresponse) - - [Unit testing hooks](https://dataclient.io/vue/guides/unit-testing-composables) with [renderDataHook()](https://dataclient.io/docs/api/renderDataHook) + - [Unit testing composables](https://dataclient.io/vue/guides/unit-testing-composables) with `renderDataCompose()` - [memo.query()](#memoquery) - Improve performance of [useSuspense](https://dataclient.io/vue/api/useSuspense), [useDLE](https://dataclient.io/vue/api/useDLE) by rendering before endpoint resolution diff --git a/.agents/skills/data-client-schema/references/schema.vue.md b/.agents/skills/data-client-schema/references/schema.vue.md index 112d2130df2d..89984fcd1458 100644 --- a/.agents/skills/data-client-schema/references/schema.vue.md +++ b/.agents/skills/data-client-schema/references/schema.vue.md @@ -224,13 +224,13 @@ const data = memo.query( This enables their use in these additional cases: -- [useQuery()](https://dataclient.io/vue/api/useQuery) - Rendering in React +- [useQuery()](https://dataclient.io/vue/api/useQuery) - Rendering in Vue - [schema.Query()](./Query.vue.md) - As input to produce a computed memoization. - [ctrl.get](https://dataclient.io/vue/api/Controller#get)/[snap.get](https://dataclient.io/vue/api/Snapshot#get) - [Managers](https://dataclient.io/vue/concepts/managers) - - React with [useController()](https://dataclient.io/vue/api/useController) + - Vue with [useController()](https://dataclient.io/vue/api/useController) - [RestEndpoint.getOptimisticResponse](https://dataclient.io/rest/api/RestEndpoint#getoptimisticresponse) - - [Unit testing hooks](https://dataclient.io/vue/guides/unit-testing-composables) with [renderDataHook()](https://dataclient.io/docs/api/renderDataHook) + - [Unit testing composables](https://dataclient.io/vue/guides/unit-testing-composables) with `renderDataCompose()` - [memo.query()](#memoquery) - Improve performance of [useSuspense](https://dataclient.io/vue/api/useSuspense), [useDLE](https://dataclient.io/vue/api/useDLE) by rendering before endpoint resolution diff --git a/docs/rest/api/schema.md b/docs/rest/api/schema.md index 80f430e45f93..2dd0b44fa1b6 100644 --- a/docs/rest/api/schema.md +++ b/docs/rest/api/schema.md @@ -233,13 +233,13 @@ const data = memo.query( This enables their use in these additional cases: -- [useQuery()](/docs/api/useQuery) - Rendering in React +- [useQuery()](/docs/api/useQuery) - Rendering in :react[React]:vue[Vue] - [schema.Query()](./Query.md) - As input to produce a computed memoization. - [ctrl.get](/docs/api/Controller#get)/[snap.get](/docs/api/Snapshot#get) - [Managers](/docs/concepts/managers) - - React with [useController()](/docs/api/useController) + - :react[React]:vue[Vue] with [useController()](/docs/api/useController) - [RestEndpoint.getOptimisticResponse](./RestEndpoint.md#getoptimisticresponse) - - [Unit testing hooks](/docs/guides/unit-testing-hooks) with [renderDataHook()](/docs/api/renderDataHook) + - :react[[Unit testing hooks](/docs/guides/unit-testing-hooks) with [renderDataHook()](/docs/api/renderDataHook)]:vue[[Unit testing composables](/docs/guides/unit-testing-hooks) with `renderDataCompose()`] - [memo.query()](#memoquery) - Improve performance of [useSuspense](/docs/api/useSuspense), [useDLE](/docs/api/useDLE) by rendering before endpoint resolution diff --git a/website/framework-docs/index.js b/website/framework-docs/index.js index 85dda207fe18..5be327507472 100644 --- a/website/framework-docs/index.js +++ b/website/framework-docs/index.js @@ -130,7 +130,16 @@ function frameworkEquivalents() { `${framework} doc ${id}: framework_equivalent '${equivalent}' is not a doc in any other framework`, ); equivalents[framework][id] = equivalent; - for (const other of others) equivalents[other][equivalent] ??= id; + // reverse lookup, only needed when this framework has no `equivalent` + if (docs[framework].has(equivalent)) continue; + for (const other of others) { + const existing = equivalents[other][equivalent]; + if (existing && existing !== id) + throw new Error( + `${other} doc ${equivalent} would switch to both ${existing} and ${id}; give it its own framework_equivalent`, + ); + equivalents[other][equivalent] = id; + } } } return equivalents; From afef320337689523323e7e65f240b54d32c5f590 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 19:55:11 +0000 Subject: [PATCH 4/4] docs: Vue composables testing guide gets its own doc id unit-testing-hooks.vue.md only shared React's doc id (plus a slug) so the framework selector could find it. With framework_equivalent it can be a real Vue page: unit-testing-composables.vue.md, same URL. A page that names its own framework_equivalent now keeps it instead of taking a reverse pair from another page. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Fjs86eqj7vj4ia4om1Cmbo --- .agents/skills/data-client-vue-testing/references.json | 2 +- .../references/unit-testing-composables.md | 2 +- docs/core/README.md | 2 +- docs/core/api/Controller.md | 2 +- docs/core/api/makeRenderDataHook.md | 2 +- docs/core/api/renderDataHook.md | 2 +- docs/core/guides/unit-testing-components.vue.md | 4 ++-- ...t-testing-hooks.vue.md => unit-testing-composables.vue.md} | 2 +- docs/rest/api/schema.md | 2 +- website/framework-docs/README.md | 2 +- website/framework-docs/index.js | 2 ++ website/sidebars.json | 4 ++++ 12 files changed, 17 insertions(+), 11 deletions(-) rename docs/core/guides/{unit-testing-hooks.vue.md => unit-testing-composables.vue.md} (99%) diff --git a/.agents/skills/data-client-vue-testing/references.json b/.agents/skills/data-client-vue-testing/references.json index 9d1e5b86ca26..d6ba7065ef80 100644 --- a/.agents/skills/data-client-vue-testing/references.json +++ b/.agents/skills/data-client-vue-testing/references.json @@ -6,6 +6,6 @@ "Fixtures.md": "docs/core/api/Fixtures.md", "mockInitialState.md": "docs/core/api/mockInitialState.md", "unit-testing-components.md": "docs/core/guides/unit-testing-components.md", - "unit-testing-composables.md": "docs/core/guides/unit-testing-hooks.md" + "unit-testing-composables.md": "docs/core/guides/unit-testing-composables.vue.md" } } diff --git a/.agents/skills/data-client-vue-testing/references/unit-testing-composables.md b/.agents/skills/data-client-vue-testing/references/unit-testing-composables.md index c70533e5108f..d7e214b93a9b 100644 --- a/.agents/skills/data-client-vue-testing/references/unit-testing-composables.md +++ b/.agents/skills/data-client-vue-testing/references/unit-testing-composables.md @@ -1,4 +1,4 @@ - + # Unit testing composables diff --git a/docs/core/README.md b/docs/core/README.md index 6ef97ba28cf9..e4384ad825eb 100644 --- a/docs/core/README.md +++ b/docs/core/README.md @@ -834,7 +834,7 @@ const incrementInterceptor: Interceptor = { - :react[[Mock data for storybook](./guides/storybook.md) with [MockResolver](./api/MockResolver.md)]:vue[Mock data with `MockPlugin` from `@data-client/vue/test`] -- :react[[Test hooks](./guides/unit-testing-hooks.md) with [renderDataHook()](./api/renderDataHook.md)]:vue[[Test composables](./guides/unit-testing-hooks.md) with `renderDataCompose()`] +- :react[[Test hooks](./guides/unit-testing-hooks.md) with [renderDataHook()](./api/renderDataHook.md)]:vue[[Test composables](./guides/unit-testing-composables.md) with `renderDataCompose()`] - :react[[Test components](./guides/unit-testing-components.md) with [MockResolver](./api/MockResolver.md)]:vue[[Test components](./guides/unit-testing-components.md) with `mountDataClient()`] and [mockInitialState()](./api/mockInitialState.md) ## Demo diff --git a/docs/core/api/Controller.md b/docs/core/api/Controller.md index 725a8c65af68..5bd689e5befc 100644 --- a/docs/core/api/Controller.md +++ b/docs/core/api/Controller.md @@ -23,7 +23,7 @@ and retrieval performance. - [Managers](./Manager.md) as the first argument in [Manager.middleware](./Manager.md#middleware) - :react[React]:vue[Vue] with [useController()](./useController.md) -- :react[[Unit testing hooks](../guides/unit-testing-hooks.md) with [renderDataHook()](./renderDataHook.md#controller)]:vue[[Unit testing composables](../guides/unit-testing-hooks.md) with `renderDataCompose()` from `@data-client/vue/test`] +- :react[[Unit testing hooks](../guides/unit-testing-hooks.md) with [renderDataHook()](./renderDataHook.md#controller)]:vue[[Unit testing composables](../guides/unit-testing-composables.md) with `renderDataCompose()` from `@data-client/vue/test`] ```ts class Controller { diff --git a/docs/core/api/makeRenderDataHook.md b/docs/core/api/makeRenderDataHook.md index 6c140f518b99..e5516cbace97 100644 --- a/docs/core/api/makeRenderDataHook.md +++ b/docs/core/api/makeRenderDataHook.md @@ -1,6 +1,6 @@ --- frameworks: [react] -framework_equivalent: guides/unit-testing-hooks +framework_equivalent: guides/unit-testing-composables title: makeRenderDataHook() --- diff --git a/docs/core/api/renderDataHook.md b/docs/core/api/renderDataHook.md index 9dd4161821dc..fcd78611d58c 100644 --- a/docs/core/api/renderDataHook.md +++ b/docs/core/api/renderDataHook.md @@ -1,6 +1,6 @@ --- frameworks: [react] -framework_equivalent: guides/unit-testing-hooks +framework_equivalent: guides/unit-testing-composables title: renderDataHook() --- diff --git a/docs/core/guides/unit-testing-components.vue.md b/docs/core/guides/unit-testing-components.vue.md index 2795f1c0496c..cdc2ed3c96eb 100644 --- a/docs/core/guides/unit-testing-components.vue.md +++ b/docs/core/guides/unit-testing-components.vue.md @@ -17,7 +17,7 @@ Instead, load responses with [Fixtures](../api/Fixtures.md). `@data-client/vue/test` mounts components with [DataClientPlugin](../api/DataClientPlugin.md), a `` boundary and [Fixtures](../api/Fixtures.md), so tests can check what a component renders without a network fetch cycle. For composables on their own, see -[Unit testing composables](./unit-testing-hooks.md). +[Unit testing composables](./unit-testing-composables.md). ## Setup @@ -197,7 +197,7 @@ Returns ### Options -`mountDataClient()` and [renderDataCompose()](./unit-testing-hooks.md) take the same options. +`mountDataClient()` and [renderDataCompose()](./unit-testing-composables.md) take the same options. ```typescript interface RenderDataClientOptions

{ diff --git a/docs/core/guides/unit-testing-hooks.vue.md b/docs/core/guides/unit-testing-composables.vue.md similarity index 99% rename from docs/core/guides/unit-testing-hooks.vue.md rename to docs/core/guides/unit-testing-composables.vue.md index 8432311ec384..420bd1503b2d 100644 --- a/docs/core/guides/unit-testing-hooks.vue.md +++ b/docs/core/guides/unit-testing-composables.vue.md @@ -1,6 +1,6 @@ --- title: Unit testing composables -slug: /guides/unit-testing-composables +framework_equivalent: guides/unit-testing-hooks --- Composables pull data logic out of components, so they are often the easiest place to test it. diff --git a/docs/rest/api/schema.md b/docs/rest/api/schema.md index 2dd0b44fa1b6..99d21c630327 100644 --- a/docs/rest/api/schema.md +++ b/docs/rest/api/schema.md @@ -239,7 +239,7 @@ This enables their use in these additional cases: - [Managers](/docs/concepts/managers) - :react[React]:vue[Vue] with [useController()](/docs/api/useController) - [RestEndpoint.getOptimisticResponse](./RestEndpoint.md#getoptimisticresponse) - - :react[[Unit testing hooks](/docs/guides/unit-testing-hooks) with [renderDataHook()](/docs/api/renderDataHook)]:vue[[Unit testing composables](/docs/guides/unit-testing-hooks) with `renderDataCompose()`] + - :react[[Unit testing hooks](/docs/guides/unit-testing-hooks) with [renderDataHook()](/docs/api/renderDataHook)]:vue[[Unit testing composables](/docs/guides/unit-testing-composables) with `renderDataCompose()`] - [memo.query()](#memoquery) - Improve performance of [useSuspense](/docs/api/useSuspense), [useDLE](/docs/api/useDLE) by rendering before endpoint resolution diff --git a/website/framework-docs/README.md b/website/framework-docs/README.md index 33ecaa14c847..546150f069ef 100644 --- a/website/framework-docs/README.md +++ b/website/framework-docs/README.md @@ -77,7 +77,7 @@ same `vue_` overrides as front matter, e.g. `"vue_label": "Composables"` on When a framework-only page covers what the other framework documents under a different id (Vue's `DataClientPlugin` is React's `DataProvider`), name that doc id in `framework_equivalent:` on either page, so the framework selector switches between them. Declaring it on one page is enough; it works -in both directions. The build fails if the id doesn't exist in the other framework. +in both directions, unless the other page names its own `framework_equivalent`. The build fails if the id doesn't exist in the other framework. Give per-framework headings an explicit id so links to them work in both frameworks. A heading with no text left for a framework (e.g. only `:react[...]`) is dropped from that framework's page. diff --git a/website/framework-docs/index.js b/website/framework-docs/index.js index 5be327507472..eb1ad1794038 100644 --- a/website/framework-docs/index.js +++ b/website/framework-docs/index.js @@ -133,6 +133,8 @@ function frameworkEquivalents() { // reverse lookup, only needed when this framework has no `equivalent` if (docs[framework].has(equivalent)) continue; for (const other of others) { + // a page that names its own counterpart keeps it + if (docs[other].get(equivalent).equivalent) continue; const existing = equivalents[other][equivalent]; if (existing && existing !== id) throw new Error( diff --git a/website/sidebars.json b/website/sidebars.json index a88c66a8e6eb..a2ad0c368832 100644 --- a/website/sidebars.json +++ b/website/sidebars.json @@ -90,6 +90,10 @@ "type": "doc", "id": "guides/unit-testing-hooks" }, + { + "type": "doc", + "id": "guides/unit-testing-composables" + }, { "type": "doc", "id": "guides/unit-testing-components"