diff --git a/.agents/skills/data-client-rest/references/expiry-policy.md b/.agents/skills/data-client-rest/references/expiry-policy.md index c5c20f474268..cd082dd7ad5a 100644 --- a/.agents/skills/data-client-rest/references/expiry-policy.md +++ b/.agents/skills/data-client-rest/references/expiry-policy.md @@ -39,6 +39,8 @@ to make it becomes [stale](#stale) almost instantly; or a very large number to s Toggling between 'first' and 'second' changes the parameters. If the data is still considered fresh you will continue to see the old time without any refresh. +Files with a `route` are pages mounted at that path, with its params as props; `Link` and `useNavigate()` stand for your router (with react-router, read params with `useParams()`). + ```ts title="api/lastUpdated" import { Entity, RestEndpoint } from '@data-client/rest'; import { Temporal } from 'temporal-polyfill'; @@ -64,11 +66,11 @@ import { lastUpdated } from './api/lastUpdated'; export const getUpdated = lastUpdated.extend({ dataExpiryLength: 10000 }); ``` -```tsx title="TimePage" +```tsx title="TimePage" route="/time/:id" start="/time/1" import { useSuspense } from '@data-client/react'; import { getUpdated } from './getUpdated'; -export default function TimePage({ id }) { +export default function TimePage({ id }: { id: string }) { const { updatedAt } = useSuspense(getUpdated, { id }); return (
@@ -81,34 +83,21 @@ export default function TimePage({ id }) { } ``` -```tsx title="Navigator" -import React from 'react'; -import { AsyncBoundary } from '@data-client/react'; -import TimePage from './TimePage'; - -function Navigator() { - const [id, setId] = React.useState('1'); - const handleChange = e => setId(e.currentTarget.value); +```tsx title="Navigator" layout +export default function Navigator({ children }) { return (
-
- - -
- loading...
}> - - + + {children}
Current Time:
); } -render(); ```
@@ -166,8 +155,10 @@ const NoRetryResource = resource({ [Endpoint.invalidIfStale](https://dataclient.io/rest/api/Endpoint#invalidifstale) eliminates the '[stale](#stale)' status, making data that expires immediately be considered '[invalid](#invalid)'. -This is demonstrated by the component suspending once its data goes stale. If the data is still -within the expiry time it just continues to display it. +This is demonstrated by the page suspending once its data goes stale (navigation waits on the progress bar). +If the data is still within the expiry time it just continues to display it. + +Files with a `route` are pages mounted at that path, with its params as props; `Link` and `useNavigate()` stand for your router (with react-router, read params with `useParams()`). ```ts title="api/lastUpdated" import { Entity, RestEndpoint } from '@data-client/rest'; @@ -197,11 +188,11 @@ export const getUpdated = lastUpdated.extend({ }); ``` -```tsx title="TimePage" +```tsx title="TimePage" route="/time/:id" start="/time/1" import { useSuspense } from '@data-client/react'; import { getUpdated } from './getUpdated'; -export default function TimePage({ id }) { +export default function TimePage({ id }: { id: string }) { const { updatedAt } = useSuspense(getUpdated, { id }); return (
@@ -214,34 +205,21 @@ export default function TimePage({ id }) { } ``` -```tsx title="Navigator" -import React from 'react'; -import { AsyncBoundary } from '@data-client/react'; -import TimePage from './TimePage'; - -function Navigator() { - const [id, setId] = React.useState('1'); - const handleChange = e => setId(e.currentTarget.value); +```tsx title="Navigator" layout +export default function Navigator({ children }) { return (
-
- - -
- loading...
}> - - + + {children}
Current Time:
); } -render(); ``` ## Force refresh diff --git a/.agents/skills/data-client-rest/references/expiry-policy.vue.md b/.agents/skills/data-client-rest/references/expiry-policy.vue.md index 993a1a6bd0b3..8fdf3744105e 100644 --- a/.agents/skills/data-client-rest/references/expiry-policy.vue.md +++ b/.agents/skills/data-client-rest/references/expiry-policy.vue.md @@ -36,6 +36,8 @@ to make it becomes [stale](#stale) almost instantly; or a very large number to s Toggling between 'first' and 'second' changes the parameters. If the data is still considered fresh you will continue to see the old time without any refresh. +Files with a `route` are pages mounted at that path, with its params as props (vue-router `props: true`). + ```ts title="api/lastUpdated" import { Entity, RestEndpoint } from '@data-client/rest'; import { Temporal } from 'temporal-polyfill'; @@ -61,7 +63,7 @@ import { lastUpdated } from './api/lastUpdated'; export const getUpdated = lastUpdated.extend({ dataExpiryLength: 10000 }); ``` -```html title="TimePage.vue" +```html title="TimePage.vue" route="/time/:id" start="/time/1" - +```html title="Navigator.vue" layout ``` @@ -156,8 +157,10 @@ const NoRetryResource = resource({ [Endpoint.invalidIfStale](https://dataclient.io/rest/api/Endpoint#invalidifstale) eliminates the '[stale](#stale)' status, making data that expires immediately be considered '[invalid](#invalid)'. -This is demonstrated by the component suspending once its data goes stale. If the data is still -within the expiry time it just continues to display it. +This is demonstrated by the page suspending once its data goes stale. +If the data is still within the expiry time it just continues to display it. + +Files with a `route` are pages mounted at that path, with its params as props (vue-router `props: true`). ```ts title="api/lastUpdated" import { Entity, RestEndpoint } from '@data-client/rest'; @@ -187,7 +190,7 @@ export const getUpdated = lastUpdated.extend({ }); ``` -```html title="TimePage.vue" +```html title="TimePage.vue" route="/time/:id" start="/time/1" - +```html title="Navigator.vue" layout ``` diff --git a/docs/core/concepts/error-policy.md b/docs/core/concepts/error-policy.md index 2be89c85f00a..ac04c4154461 100644 --- a/docs/core/concepts/error-policy.md +++ b/docs/core/concepts/error-policy.md @@ -40,7 +40,7 @@ A soft error keeps showing the last data. Hard errors (and soft ones with nothin - @@ -123,41 +123,28 @@ export default function TimePage({ id }) { } ``` -```tsx title="Navigator" collapsed -import React from 'react'; -import { AsyncBoundary } from '@data-client/react'; -import TimePage from './TimePage'; - -function Navigator() { - const [id, setId] = React.useState('1'); - const handleChange = e => setId(e.currentTarget.value); +```tsx title="Navigator" layout collapsed +export default function Navigator({ children }) { return (
-
- - -
- loading...
}> - - + + {children}
Current Time:
); } -render(); ``` ::: :::vue -```html title="TimePage.vue" +```html title="TimePage.vue" route="/time/:id" start="/time/1" - +```html title="Navigator.vue" layout collapsed ``` @@ -255,10 +241,10 @@ const NoRetryResource = resource({ [Endpoint.invalidIfStale](/rest/api/Endpoint#invalidifstale) eliminates the '[stale](#stale)' status, making data that expires immediately be considered '[invalid](#invalid)'. -This is demonstrated by the component suspending once its data goes stale. If the data is still -within the expiry time it just continues to display it. +This is demonstrated by the page suspending once its data goes stale:react[ (navigation waits on the progress bar)]. +If the data is still within the expiry time it just continues to display it. - @@ -322,41 +308,28 @@ export default function TimePage({ id }) { } ``` -```tsx title="Navigator" collapsed -import React from 'react'; -import { AsyncBoundary } from '@data-client/react'; -import TimePage from './TimePage'; - -function Navigator() { - const [id, setId] = React.useState('1'); - const handleChange = e => setId(e.currentTarget.value); +```tsx title="Navigator" layout collapsed +export default function Navigator({ children }) { return (
-
- - -
- loading...
}> - - + + {children}
Current Time:
); } -render(); ``` ::: :::vue -```html title="TimePage.vue" +```html title="TimePage.vue" route="/time/:id" start="/time/1" - +```html title="Navigator.vue" layout collapsed ``` @@ -409,7 +381,7 @@ We sometimes want to fetch new data; while continuing to show the old (stale) da [Controller.fetch](../api/Controller#fetch) can be used to trigger a fetch while still showing the previous data. This can be done even with 'fresh' data. -); [Controller.expireAll()](../api/Controller.md#expireAll) sets all responses' [expiry status](#expiry-status) matching `testKey` to [Stale](#stale). -); [validateRequired()](/rest/api/validateRequired) can be used to check if all defined fields are present. -` marks the one to the current path + `aria-current="page"`. A `start="/time/1"` on a page's fence opens the router + there (and Home returns there) instead of `/`. Navigation is a transition: the page stays until the next one is ready, with a progress bar along the preview's top edge meanwhile, so a page needs no `startTransition` of its own. Pages render in a Suspense boundary whose diff --git a/website/src/components/Playground/editor/__tests__/codeModel.test.tsx b/website/src/components/Playground/editor/__tests__/codeModel.test.tsx index ac28293a1e6c..9569298db67e 100644 --- a/website/src/components/Playground/editor/__tests__/codeModel.test.tsx +++ b/website/src/components/Playground/editor/__tests__/codeModel.test.tsx @@ -82,6 +82,25 @@ describe('code document model', () => { ]); }); + test("reads the router's start path and bare layout flag", () => { + expect( + parseCodeDocuments([ + code({ metastring: 'title="Nav" layout collapsed' }, 'a'), + code( + { metastring: 'title="Page" route="/time/:id" start="/time/1"' }, + 'b', + ), + code({ metastring: 'title="Page layout" route="/layout/:id"' }, 'c'), + code({ metastring: 'title=layout path=src/layout.tsx' }, 'd'), + ]).map(({ layout, start }) => [layout, start]), + ).toEqual([ + [true, undefined], + [false, '/time/1'], + [false, undefined], + [false, undefined], + ]); + }); + test('updates only the addressed document', () => { const documents = parseCodeDocuments([ code({ metastring: 'title="One"' }, 'one'), diff --git a/website/src/components/Playground/editor/codeModel.ts b/website/src/components/Playground/editor/codeModel.ts index c9bd6a08e4f7..5c2151e1a9e1 100644 --- a/website/src/components/Playground/editor/codeModel.ts +++ b/website/src/components/Playground/editor/codeModel.ts @@ -12,6 +12,11 @@ export interface CodeDocument { /** Path pattern (`/posts/:id`) the preview's router renders this file's * default export at */ route?: string; + /** The path the preview's router opens at, when not `/` */ + start?: string; + /** This file's default export wraps every page of the preview's router, + * which renders as its `children` */ + layout?: boolean; language: string; autoFocus?: boolean; } @@ -29,6 +34,14 @@ function metaValue(metastring: string, key: string): string | undefined { .find(value => value !== undefined); } +/** A bare `key` flag in a fence metastring, not the word inside another + * key's value like `title="Page layout"` or `path=src/layout.tsx` */ +function metaFlag(metastring: string, key: string): boolean { + return new RegExp(`(?:^|\\s)${key}(?=\\s|$)`).test( + metastring.replace(/\w+=(?:(["']).*?\1|\S+)/g, ''), + ); +} + export function parseCodeDocuments( children: string | React.ReactNode | React.ReactNode[], defaultTab?: string, @@ -69,13 +82,15 @@ export function parseCodeDocuments( defaultTab ? // Vue SFC tabs are titled `Name.vue`; match them by component name title?.replace(/\.vue$/, '') !== defaultTab - : /\bcollapsed\b/.test(metastring), - col: /\bcolumn\b/.test(metastring), + : metaFlag(metastring, 'collapsed'), + col: metaFlag(metastring, 'column'), path: metaValue(metastring, 'path') || (fileBase.includes('.') ? fileBase : `${fileBase}.${extension}`), highlights: /\{([\d\-,.]+)\}/.exec(metastring)?.[1], route: metaValue(metastring, 'route'), + start: metaValue(metastring, 'start'), + layout: metaFlag(metastring, 'layout'), language, // Direct element props (title, path, collapsed, autoFocus) override // metastring-derived values — Demo/CodeEditor relies on this. diff --git a/website/src/components/Playground/index.tsx b/website/src/components/Playground/index.tsx index 32bfc82e164a..493121a95715 100644 --- a/website/src/components/Playground/index.tsx +++ b/website/src/components/Playground/index.tsx @@ -29,11 +29,10 @@ export interface PlaygroundProps { /** Store inspector open by default */ defaultOpen?: 'y' | 'n'; /** Tabs for files + editor beside preview (when wide enough). - * Use when the code is one tall open file and the preview is short. Stay - * stacked when the Store opens by default (it would cover the code), when - * the fixtures are the lesson (row folds them behind a toggle in the - * preview's header), or when the preview renders a long list (it gets tall - * in the narrow column). */ + * Use when the code is one tall open file and the preview is short; row + * folds fixtures behind a toggle in the preview's header. Stay stacked when + * the Store opens by default (it would cover the code), or when the preview + * renders a long list (it gets tall in the narrow column). */ row?: boolean; /** Inactive (e.g. unselected Demo tab): no Monaco until first shown, no preview */ hidden?: boolean; diff --git a/website/src/components/Playground/preview/FixturePreview.tsx b/website/src/components/Playground/preview/FixturePreview.tsx index 705754e031a7..f362234e8123 100644 --- a/website/src/components/Playground/preview/FixturePreview.tsx +++ b/website/src/components/Playground/preview/FixturePreview.tsx @@ -24,7 +24,8 @@ export default memo(FixturePreview); * mounts while open */ function FixtureRow({ fixture }: { fixture: FixtureOrInterceptor }) { const [open, setOpen] = useState(false); - const { method, path, fnName, hint, error, delay } = summarize(fixture); + const { method, path, fnName, hint, error, status, delay } = + summarize(fixture); const delayFn = typeof fixture.delay === 'function' ? fixture.delay : undefined; // a function's source can differ between the server and browser bundles @@ -33,7 +34,11 @@ function FixtureRow({ fixture }: { fixture: FixtureOrInterceptor }) {