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
-
-
-
-
-
-
-
-
loading...
-
+
+
+
+
+
+
loading...
+
+
```
@@ -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
-
-
-
-
-
-
-
-
loading...
-
+
+
+
+
+
+
loading...
+
+
```
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 (
```
@@ -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 (
```
@@ -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 }) {