@jay-framework/jay-stack-cli 0.25.4 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agent-kit-template/designer/contracts-and-plugins.md +14 -0
- package/agent-kit-template/designer/validation-guide.md +26 -10
- package/agent-kit-template/developer/INSTRUCTIONS.md +19 -18
- package/agent-kit-template/developer/configuration.md +4 -0
- package/agent-kit-template/developer/page-components.md +9 -6
- package/agent-kit-template/developer/query-param-pages.md +426 -0
- package/agent-kit-template/developer/render-results.md +28 -1
- package/agent-kit-template/devops/INSTRUCTIONS.md +88 -11
- package/agent-kit-template/devops/fetch-handler.md +21 -0
- package/agent-kit-template/devops/invalidation.md +2 -7
- package/agent-kit-template/devops/production-build.md +8 -3
- package/agent-kit-template/devops/serving-modes.md +25 -0
- package/agent-kit-template/devops/source-editing.md +89 -0
- package/agent-kit-template/plugin/INSTRUCTIONS.md +25 -24
- package/agent-kit-template/plugin/actions-guide.md +57 -0
- package/agent-kit-template/plugin/contracts-guide.md +42 -0
- package/agent-kit-template/plugin/render-results.md +19 -0
- package/agent-kit-template/plugin/source-editing.md +70 -0
- package/agent-kit-template/plugin/validation.md +36 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +340 -249
- package/package.json +16 -16
- package/dist/index-B5oavmfA.js +0 -1803
|
@@ -328,3 +328,17 @@ For repeated lists, add a **boolean variant** (e.g. `hasItems`, `hasCategories`)
|
|
|
328
328
|
```
|
|
329
329
|
|
|
330
330
|
Alternatively, expose a **number** data tag (`itemCount`) and use numeric comparison: `if="itemCount===0"`. See [jay-html-template-syntax.md](jay-html-template-syntax.md#expression-limits-important) and [contracts/examples/category-list.md](https://github.com/jay-framework/jay/blob/main/packages/jay-stack/stack-cli/agent-kit-template/contracts/examples/category-list.md).
|
|
331
|
+
|
|
332
|
+
### Plugin messages are defaults — write your own copy
|
|
333
|
+
|
|
334
|
+
A plugin's `data` strings (`errorMessage`, `statusMessage`, …) are default text, usually English. When the project needs other wording or another language, branch on the plugin's **variants** (`hasError`, an outcome `enum`) and write the text in jay-html. Never detect an outcome by testing a message string, or by matching its text.
|
|
335
|
+
|
|
336
|
+
```html
|
|
337
|
+
<!-- ✅ The page owns the words -->
|
|
338
|
+
<p if="form.submitFailure === NETWORK">Problème de connexion, réessayez.</p>
|
|
339
|
+
|
|
340
|
+
<!-- ❌ On a localized page: shows the plugin's English text and uses it as the failure signal -->
|
|
341
|
+
<p if="form.statusMessage">{form.statusMessage}</p>
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
If a plugin exposes an outcome only as a string, that is a gap in the plugin's contract: report it, don't work around it.
|
|
@@ -31,9 +31,22 @@ So a warning about an `<img>` might come from a full-stack component's template,
|
|
|
31
31
|
**static routes + public assets**. An href that resolves to nothing is an error: a placeholder (`href="#"` or
|
|
32
32
|
empty), a typo, or a link to a page/asset that does not exist. Links that match a **dynamic** route pattern
|
|
33
33
|
(e.g. `/blog/[slug]`) are **not** checked here — the concrete slug list isn't known at validate time — so they
|
|
34
|
-
never false-error. External URLs, `mailto:`/`tel:`,
|
|
34
|
+
never false-error. External URLs, `mailto:`/`tel:`, and `{binding}` hrefs are skipped.
|
|
35
35
|
Fix the path, use `<button>` for JS-driven anchors, or suppress per-page (see the `jay-stack` table below).
|
|
36
36
|
|
|
37
|
+
Link checking has these additional severities and markers (DL#210 v2):
|
|
38
|
+
|
|
39
|
+
- **`#fragment` links are validated** — `<a href="#team">` requires an element with `id="team"` on the page;
|
|
40
|
+
`<a href="/about#team">` requires `id="team"` on the `/about` page. A missing anchor is an **error** (not
|
|
41
|
+
suppressible). `#` alone is a degenerate placeholder (error); `#top` and empty fragments are always allowed.
|
|
42
|
+
- **Self-links are a warning** — an `<a href="/here">` on the `/here` page goes nowhere. It's a **warning**
|
|
43
|
+
(doesn't block the build). When a self-link is intentional, suppress it **on that one anchor** with the
|
|
44
|
+
`jay-allow-self-link` attribute: `<a href="/here" jay-allow-self-link>`.
|
|
45
|
+
- **TODO links** — mark a link you haven't wired yet with the `todo:` scheme:
|
|
46
|
+
`<a href="todo:/pricing">Pricing</a>` or `<a href="todo: write the pricing page">Pricing</a>`. It emits a
|
|
47
|
+
**warning on every validate** (the text after `todo:` is echoed as a reminder) and **cannot be suppressed** —
|
|
48
|
+
it's a deliberate, recurring nudge until you replace it with the real destination.
|
|
49
|
+
|
|
37
50
|
One design-system rule is a **hard error**, not a warning: `REGION-CSS-NO-REF` (DL#209) — a `<jay:X>` region
|
|
38
51
|
whose `template=` ships CSS but which has no `ref=`. Without a `ref` the CSS has no scope anchor, so `sync`
|
|
39
52
|
silently drops it; the fix is to add a `ref` (see `design-system-guide.md` → _The shape of a region's CSS_).
|
|
@@ -127,13 +140,15 @@ viewport-height containers — see `jay-html-styling.md`), `allow-custom-breakpo
|
|
|
127
140
|
The core `jay-stack validate` emits four warnings that nudge you to reuse UI through design-system elements
|
|
128
141
|
(see `design-system-guide.md`). Suppress the legitimate case:
|
|
129
142
|
|
|
130
|
-
| Rule
|
|
131
|
-
|
|
|
132
|
-
| `REGION-NOT-LINKED`
|
|
133
|
-
| `REGION-OVERRIDE-NON-CONTENT`
|
|
134
|
-
| `COMPONENT-NO-TEMPLATE`
|
|
135
|
-
| `NO-DESIGN-SYSTEM`
|
|
136
|
-
| Broken internal link (DL#210)
|
|
143
|
+
| Rule | How to suppress |
|
|
144
|
+
| -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
|
145
|
+
| `REGION-NOT-LINKED` | `jay-validations="REGION-NOT-LINKED"` on the `application/jay-headless` import, or `jay-stack: allow-inline-region: [Contract]` |
|
|
146
|
+
| `REGION-OVERRIDE-NON-CONTENT` | `jay-validations="REGION-OVERRIDE-NON-CONTENT"` on the import |
|
|
147
|
+
| `COMPONENT-NO-TEMPLATE` | `jay-stack: allow-no-template: [Contract]` |
|
|
148
|
+
| `NO-DESIGN-SYSTEM` | `jay-stack: allow-no-design-system: true` |
|
|
149
|
+
| Broken / degenerate internal link (DL#210) | `jay-stack: allow-broken-links: true` (per-page — suppresses broken + `#`/empty link errors; does **not** affect TODO or fragment checks) |
|
|
150
|
+
| Self-link warning (DL#210 v2) | `jay-allow-self-link` attribute on the specific `<a>` (per-anchor, not per-page) |
|
|
151
|
+
| Missing `#fragment` anchor / TODO link (DL#210 v2) | Not suppressible — fix the anchor id, or replace the `todo:` href |
|
|
137
152
|
|
|
138
153
|
```html
|
|
139
154
|
<script type="application/jay-validations">
|
|
@@ -183,8 +198,9 @@ Validation comes in two tiers. This guide covers **Tier 1**, the always-on gate.
|
|
|
183
198
|
route scan only — **no build needed** — so it's cheap enough for the hot agent loop and runs on every `validate`.
|
|
184
199
|
Everything described in this guide is Tier 1. For links, Tier 1 (DL#210) resolves every hand-authored `<a href>`
|
|
185
200
|
against the project's static routes + public assets and **errors** on broken internal links (typos, missing
|
|
186
|
-
pages), degenerate `#` / empty placeholder links, and
|
|
187
|
-
(`/design-log/[...slug]`) are deferred — the concrete
|
|
201
|
+
pages), degenerate `#` / empty placeholder links, and missing `#fragment` anchors; **warns** on self-links and
|
|
202
|
+
`todo:` links. Links matching a **dynamic** route pattern (`/design-log/[...slug]`) are deferred — the concrete
|
|
203
|
+
slug set isn't known without a build.
|
|
188
204
|
|
|
189
205
|
**Tier 2 — opt-in, against a build (`jay-stack validate --tier-2`)** adds deeper checks that need a build:
|
|
190
206
|
dynamic-slug links, broken links in rendered content, and per-instance meta/SEO. It's documented separately to
|
|
@@ -21,21 +21,22 @@ The developer sets up the project, configures plugins, creates page-level compon
|
|
|
21
21
|
|
|
22
22
|
## Guides
|
|
23
23
|
|
|
24
|
-
| File | Topic
|
|
25
|
-
| ------------------------------------------------------------ |
|
|
26
|
-
| [project-structure.md](project-structure.md) | Directory layout, configuration files
|
|
27
|
-
| [routing.md](routing.md) | Directory-based routing, dynamic routes
|
|
28
|
-
| [configuration.md](configuration.md) | .jay file, plugin config, init.ts
|
|
29
|
-
| [page-contracts.md](page-contracts.md) | Page-level contracts (page.jay-contract)
|
|
30
|
-
| [Contract Authoring Guide](../contracts/GUIDE.md) | Writing contracts: syntax, page/component/linked contracts, examples
|
|
31
|
-
| [page-components.md](page-components.md) | page.ts: makeJayStackComponent for pages
|
|
32
|
-
| [
|
|
33
|
-
| [
|
|
34
|
-
| [component-
|
|
35
|
-
| [component-
|
|
36
|
-
| [component-
|
|
37
|
-
| [
|
|
38
|
-
| [
|
|
39
|
-
| [
|
|
40
|
-
| [
|
|
41
|
-
|
|
|
24
|
+
| File | Topic |
|
|
25
|
+
| ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------- |
|
|
26
|
+
| [project-structure.md](project-structure.md) | Directory layout, configuration files |
|
|
27
|
+
| [routing.md](routing.md) | Directory-based routing, dynamic routes |
|
|
28
|
+
| [configuration.md](configuration.md) | .jay file, plugin config, init.ts |
|
|
29
|
+
| [page-contracts.md](page-contracts.md) | Page-level contracts (page.jay-contract) |
|
|
30
|
+
| [Contract Authoring Guide](../contracts/GUIDE.md) | Writing contracts: syntax, page/component/linked contracts, examples |
|
|
31
|
+
| [page-components.md](page-components.md) | page.ts: makeJayStackComponent for pages |
|
|
32
|
+
| [query-param-pages.md](query-param-pages.md) | Page load performance for query-param pages: slow-phase default, carry-forward, scoped fast queries |
|
|
33
|
+
| [design-system-guide.md](../designer/design-system-guide.md) | Shared components as design-system elements: ship a template, flatten, drift, `sync` |
|
|
34
|
+
| [component-structure.md](../plugin/component-structure.md) | Builder API for shared/headless components: `.withProps()`, phases, render results |
|
|
35
|
+
| [component-state.md](component-state.md) | createSignal, createMemo, createEffect, createDerivedArray |
|
|
36
|
+
| [component-refs.md](component-refs.md) | Refs, collection refs, element types |
|
|
37
|
+
| [component-data.md](component-data.md) | Immutable data, JSON Patch, patching |
|
|
38
|
+
| [render-results.md](render-results.md) | phaseOutput, RenderPipeline, errors, redirects |
|
|
39
|
+
| [error-pages-guide.md](error-pages-guide.md) | Returning enriched errors (title/code/details) and which error page renders |
|
|
40
|
+
| [seo-guide.md](seo-guide.md) | SEO head tags: title, meta, OG, canonical via phaseOutput |
|
|
41
|
+
| [cli-commands.md](cli-commands.md) | CLI commands: setup, validate, dev, agent-kit |
|
|
42
|
+
| `../references/<plugin>/` | Plugin reference data |
|
|
@@ -12,6 +12,8 @@ devServer:
|
|
|
12
12
|
pagesBase: ./src/pages
|
|
13
13
|
componentsBase: ./src/components
|
|
14
14
|
publicFolder: ./public
|
|
15
|
+
# allowedHosts: ['.example.dev'] # extra hosts the dev server's host-check accepts (DL#220)
|
|
16
|
+
# sourceEditing: true # inject source-editing markers for the inspector (DL#219) — dev only
|
|
15
17
|
editorServer:
|
|
16
18
|
portRange:
|
|
17
19
|
- 3101
|
|
@@ -24,6 +26,8 @@ editorServer:
|
|
|
24
26
|
- `devServer.componentsBase` — Root directory for components
|
|
25
27
|
- `devServer.publicFolder` — Static assets directory
|
|
26
28
|
- `devServer.portRange` — Port range for the dev server
|
|
29
|
+
- `devServer.allowedHosts` — Hosts the dev server's host-check accepts beyond localhost — a list in Vite host-suffix syntax (e.g. `['.example.dev']`) or `true` to disable the check (cloud preview only). Absent = localhost-only (DL#220)
|
|
30
|
+
- `devServer.sourceEditing` — When `true`, inject source-editing markers (`jay-dev-*`) so the AIditor source-editing inspector can map a rendered pixel back to its jay-html source. **Off by default; dev-only** — the markers change rendered output, so enable only for AIditor / source-editing sessions, never in production. See the devops **source-editing** guide (DL#219)
|
|
27
31
|
|
|
28
32
|
## Plugin Configuration (config/)
|
|
29
33
|
|
|
@@ -59,8 +59,9 @@ export const page = makeJayStackComponent<ProductPageContract>()
|
|
|
59
59
|
{ productId: product.id },
|
|
60
60
|
);
|
|
61
61
|
})
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
// carryForward (the slow phase's output) is the 2nd positional arg — services follow it.
|
|
63
|
+
.withFastRender(async (props, carryForward, db) => {
|
|
64
|
+
const price = await db.getPrice(carryForward.productId);
|
|
64
65
|
return phaseOutput({ price, inStock: price > 0 }, {});
|
|
65
66
|
});
|
|
66
67
|
```
|
|
@@ -75,15 +76,17 @@ export const page = makeJayStackComponent<ProductPageContract>()
|
|
|
75
76
|
.withSlowlyRender(async (props, db) => {
|
|
76
77
|
// ... slow render
|
|
77
78
|
})
|
|
78
|
-
|
|
79
|
-
|
|
79
|
+
// With a slow phase, carryForward is the 2nd arg — the service (db) follows it.
|
|
80
|
+
.withFastRender(async (props, carryForward, db) => {
|
|
81
|
+
// ... fast render (use db for queries, carryForward for the slow phase's output)
|
|
80
82
|
})
|
|
81
|
-
|
|
83
|
+
// carryForward is the 4th positional arg: (props, refs, fastViewState, carryForward, ...contexts).
|
|
84
|
+
.withInteractive(function ProductPage(props, refs, fastViewState, carryForward) {
|
|
82
85
|
const [quantity, setQuantity] = createSignal(1);
|
|
83
86
|
|
|
84
87
|
refs.addToCart.onClick(async () => {
|
|
85
88
|
await addToCartAction({
|
|
86
|
-
productId:
|
|
89
|
+
productId: carryForward.productId,
|
|
87
90
|
quantity: quantity(),
|
|
88
91
|
});
|
|
89
92
|
});
|
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
# Fast Query-Param Pages — Precompute the Default in the Slow Phase
|
|
2
|
+
|
|
3
|
+
> **Rendering phases — read this first.** Jay pages use three phases: **slow** (build-time static
|
|
4
|
+
> generation), **fast** (per-request server rendering), and **interactive** (client after hydration).
|
|
5
|
+
> Put only **slow-changing** data in the slow phase — content that is safe to fetch once, serialize into
|
|
6
|
+
> `page.cache.json`, and replay until the next build or republish. **Fast-changing** data (live
|
|
7
|
+
> availability, per-user or session state, real-time status, token lookups, anything that must be fresh
|
|
8
|
+
> on every visit) belongs in **fast** or **interactive**, not in the slow carry. **Hybrid pages** may
|
|
9
|
+
> still keep a **publish-time snapshot** in slow when the default URL is allowed to be stale until
|
|
10
|
+
> rebuild; named query params then trigger a live re-read in fast (see Hybrid freshness). Examples
|
|
11
|
+
> later assume CMS-backed data that changes on publish, not volatile counts. For how phases work on
|
|
12
|
+
> pages, see [page-components.md](page-components.md); for the
|
|
13
|
+
> builder API and phase hooks, see [Component Structure](../plugin/component-structure.md); for
|
|
14
|
+
> choosing `slow` vs `fast` on contract tags, see the [Contract Authoring Guide](../contracts/GUIDE.md)
|
|
15
|
+
> decision checklist.
|
|
16
|
+
|
|
17
|
+
Search pages, listing pages, filter pages and shareable-link pages read query parameters
|
|
18
|
+
(`props.query`) to decide what to render. The common mistake is to do **all** the work in the
|
|
19
|
+
fast phase and query on **every** request — including the request that has **no** query
|
|
20
|
+
parameters, whose result is a fixed default that never changes per request.
|
|
21
|
+
|
|
22
|
+
A second mistake is to treat **any** non-empty `props.query` as a reason to re-query the CMS.
|
|
23
|
+
Many params only change **markup** (layout mode, which panel is open, sheet height) while the
|
|
24
|
+
**data** is unchanged. Re-fetching on `?view=list` wastes work and breaks cacheability.
|
|
25
|
+
|
|
26
|
+
This guide shows how to make those pages load fast: precompute query-independent data in the
|
|
27
|
+
**slow** phase (free at build time), carry it forward, and in the fast phase either forward
|
|
28
|
+
that data into a pure `buildView` or query only when a **specific** param needs live data.
|
|
29
|
+
|
|
30
|
+
## First paint vs deferrable data (default policy)
|
|
31
|
+
|
|
32
|
+
Two axes apply on every page — do not confuse them:
|
|
33
|
+
|
|
34
|
+
| Axis | Question |
|
|
35
|
+
| -------- | ------------------------------------------------------------------------------------------------- |
|
|
36
|
+
| **When** | Build-time carry vs per-request fetch (slow / fast / interactive) |
|
|
37
|
+
| **What** | **Critical path for first paint** vs data that appears only after interaction or a hidden surface |
|
|
38
|
+
|
|
39
|
+
**Default (best performance):** if the payload is **small** and everything on the default view
|
|
40
|
+
is needed for first paint, **fetch it in the slow phase and carry it forward**. No extra client
|
|
41
|
+
or per-request reads on the common path.
|
|
42
|
+
|
|
43
|
+
**Defer** when data is not on the critical path (e.g. a tab the user has not opened yet). **Shrink
|
|
44
|
+
carry** when [slow-phase cache size](#slow-phase-cache-size-pagecachejson) exceeds the soft budget
|
|
45
|
+
— that section is the source of truth for byte limits and deferral under size pressure.
|
|
46
|
+
|
|
47
|
+
Classify each field: _visible on load_ → slow/fast carry; _hover-only, other tab, or N×M URL
|
|
48
|
+
explosion_ → candidate for deferral once tier-2 reports a soft warning.
|
|
49
|
+
|
|
50
|
+
## Why the slow phase is the right place for the default
|
|
51
|
+
|
|
52
|
+
The three phases differ in cost and in what they can see:
|
|
53
|
+
|
|
54
|
+
| Phase | Runs | Cost per request | `props.query` |
|
|
55
|
+
| ------------------ | ------------------------------------ | --------------------- | ----------------------------------- |
|
|
56
|
+
| `withSlowlyRender` | build time (SSG), replayed from disk | **free** | **not available** (compile error) |
|
|
57
|
+
| `withFastRender` | request time (SSR) | one server round trip | available: `Record<string, string>` |
|
|
58
|
+
| `withInteractive` | client, after hydration | client CPU | reads `location.search` |
|
|
59
|
+
|
|
60
|
+
`props.query` is **fast-phase only** — the slow phase cannot see query parameters at all. That
|
|
61
|
+
is not a limitation; it is the signal. Whatever a page renders when there are **no** query
|
|
62
|
+
parameters is, by definition, independent of the URL — so the slow phase can compute it once,
|
|
63
|
+
at build, for free. Paying a per-request query for it is wasted work on the most common path.
|
|
64
|
+
|
|
65
|
+
## TypeScript: use contract phase types
|
|
66
|
+
|
|
67
|
+
Generated definitions on `page.jay-contract` export phase-specific ViewState types. **Use them**
|
|
68
|
+
in `page.ts` — they catch mistakes early (wrong carry shape, missing fields in `buildView`,
|
|
69
|
+
accidentally putting CMS payloads in slow `viewState` when the contract says slow is empty).
|
|
70
|
+
|
|
71
|
+
Typical names (see [page-components.md](page-components.md)):
|
|
72
|
+
|
|
73
|
+
- `PageSlowViewState` — tags with `phase: slow` only (often `{}` when every tag is `fast`)
|
|
74
|
+
- `PageFastViewState` — tags resolved at request time
|
|
75
|
+
- `PageViewState` — full merged shape for the template
|
|
76
|
+
|
|
77
|
+
Import from the contract module (not a hand-rolled duplicate):
|
|
78
|
+
|
|
79
|
+
```typescript
|
|
80
|
+
import { PageFastViewState, PageSlowViewState, type PageContract } from './page.jay-contract.js';
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Type `buildView` as returning `PageFastViewState`. Type the fast phase’s second argument as your
|
|
84
|
+
**carry-forward payload** (often `PageData` from `loadData()`, not the rendered view — see
|
|
85
|
+
below). Use `RenderPipeline.for<PageSlowViewState, PageData>()` so `toPhaseOutput` is checked
|
|
86
|
+
against those generics.
|
|
87
|
+
|
|
88
|
+
When every contract tag is `phase: fast`, `PageSlowViewState` is `{}`. That is intentional: the
|
|
89
|
+
slow phase should not duplicate the full fast view in `viewState` **and** in `carryForward`.
|
|
90
|
+
|
|
91
|
+
## The carry-forward mechanism
|
|
92
|
+
|
|
93
|
+
Every phase returns `phaseOutput(viewState, carryForward)` (or the equivalent from
|
|
94
|
+
`RenderPipeline.toPhaseOutput`). The `carryForward` value is handed to the **next** phase as a
|
|
95
|
+
**positional argument** — not a field on `props`. The fast render receives it as its **second
|
|
96
|
+
parameter**, before any services:
|
|
97
|
+
`withFastRender(async (props, carryForward, ...services) => …)`. (The interactive phase receives
|
|
98
|
+
it as its **fourth** parameter, after `props`, `refs` and the fast ViewState signals.)
|
|
99
|
+
|
|
100
|
+
The carry-forward argument is present only when a slow phase is declared; with no slow phase the
|
|
101
|
+
fast render is just `async (props, ...services)`. See [render-results.md](render-results.md) for
|
|
102
|
+
`phaseOutput` and the carry-forward contract, and [page-components.md](page-components.md) for the
|
|
103
|
+
positional carry-forward argument across phases.
|
|
104
|
+
|
|
105
|
+
### Carry raw data, render in fast — do not duplicate between slots
|
|
106
|
+
|
|
107
|
+
On hybrid slow+fast pages, split output by **role**, not “always empty slow ViewState”:
|
|
108
|
+
|
|
109
|
+
- **Slow `viewState`:** whatever `PageSlowViewState` requires — slow-phase contract tags bound in
|
|
110
|
+
jay-html. Use `{}` only when `PageSlowViewState` is `{}` (all tags are `phase: fast`).
|
|
111
|
+
- **`carryForward`:** query-independent **source data** for the fast phase (`PageData` from
|
|
112
|
+
`loadData()`), not a second copy of the same fields already in slow `viewState`, and not a
|
|
113
|
+
duplicate of the full `buildView` output unless the default view is a true constant.
|
|
114
|
+
|
|
115
|
+
In both `viewState` and `carryForward`, ship only what the next phase or your jay-html bindings
|
|
116
|
+
actually use — drop extra fields that are never read or bound.
|
|
117
|
+
|
|
118
|
+
Fast phase always runs `buildView(carry, query)` so markup that depends on `props.query` stays
|
|
119
|
+
in one pure function for server and client.
|
|
120
|
+
|
|
121
|
+
```typescript
|
|
122
|
+
import {
|
|
123
|
+
makeJayStackComponent,
|
|
124
|
+
phaseOutput,
|
|
125
|
+
RenderPipeline,
|
|
126
|
+
} from '@jay-framework/fullstack-component';
|
|
127
|
+
import { PageFastViewState, PageSlowViewState } from './page.jay-contract.js';
|
|
128
|
+
|
|
129
|
+
async function loadData(): Promise<PageData> {
|
|
130
|
+
const [inventory, marks] = await Promise.all([getInventory({}), getMarks({})]);
|
|
131
|
+
return { inventory, marks };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
type PageData = Awaited<ReturnType<typeof loadData>>;
|
|
135
|
+
|
|
136
|
+
function buildView(
|
|
137
|
+
{ inventory, marks }: PageData,
|
|
138
|
+
query: Record<string, string>,
|
|
139
|
+
): PageFastViewState {
|
|
140
|
+
const view = viewFromQuery(query) ?? 'map';
|
|
141
|
+
return { ...inventory, ...marksToViewState(marks), ...viewSwitchMarkup(view) };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export const page = makeJayStackComponent<PageContract>()
|
|
145
|
+
.withProps()
|
|
146
|
+
.withSlowlyRender(async () =>
|
|
147
|
+
RenderPipeline.for<PageSlowViewState, PageData>()
|
|
148
|
+
.try(loadData)
|
|
149
|
+
.toPhaseOutput((data) => ({
|
|
150
|
+
viewState: {},
|
|
151
|
+
carryForward: data,
|
|
152
|
+
})),
|
|
153
|
+
)
|
|
154
|
+
.withFastRender(async (props, carry: PageData) => phaseOutput(buildView(carry, props.query), {}));
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
**Tests:** assert `slow.rendered` is `{}`, data lives on `slow.carryForward`, fast with no CMS
|
|
158
|
+
mocks makes **zero** action calls, and `?view=list` still makes zero CMS calls while changing
|
|
159
|
+
only markup fields.
|
|
160
|
+
|
|
161
|
+
## The pattern
|
|
162
|
+
|
|
163
|
+
```
|
|
164
|
+
slow → load query-independent data once → viewState {} (if contract says so), carryForward: PageData
|
|
165
|
+
fast → buildView(carry, props.query) — no CMS unless a specific param needs live data
|
|
166
|
+
→ param branch that needs freshness → query + optional no-store
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Snippets below use `phaseOutput` directly; the main example uses `RenderPipeline` in slow when you
|
|
170
|
+
want typed error handling — both return the same `viewState` / `carryForward` shape.
|
|
171
|
+
|
|
172
|
+
### ✅ Do — default data in slow, `buildView` in fast, query only on relevant params
|
|
173
|
+
|
|
174
|
+
```typescript
|
|
175
|
+
.withSlowlyRender(async (props, catalog) => {
|
|
176
|
+
const results = await catalog.all();
|
|
177
|
+
return phaseOutput({}, { results }); // empty slow VS when contract allows; carry the data
|
|
178
|
+
})
|
|
179
|
+
.withFastRender(async (props, carryForward: { results: Item[] }, catalog) => {
|
|
180
|
+
const q = props.query.q?.trim() ?? '';
|
|
181
|
+
if (!q) {
|
|
182
|
+
return phaseOutput(buildListView(carryForward.results, props.query), {});
|
|
183
|
+
}
|
|
184
|
+
const results = await catalog.search(q);
|
|
185
|
+
return phaseOutput(buildListView(results, props.query), {});
|
|
186
|
+
});
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
### ❌ Don't — query unconditionally, or on any query key
|
|
190
|
+
|
|
191
|
+
```typescript
|
|
192
|
+
// No slow phase: the build-time opportunity is wasted.
|
|
193
|
+
.withFastRender(async (props, catalog) => {
|
|
194
|
+
const results = await catalog.search(props.query.q ?? ''); // every request, including ''
|
|
195
|
+
return phaseOutput(results, {});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// Slow carry exists but fast ignores it whenever props.query is non-empty:
|
|
199
|
+
.withFastRender(async (props, carry: PageData) => {
|
|
200
|
+
if (Object.keys(props.query).length > 0) {
|
|
201
|
+
const data = await loadData(); // wrong for ?view=list — markup-only param
|
|
202
|
+
return phaseOutput(buildView(data, props.query), {});
|
|
203
|
+
}
|
|
204
|
+
return phaseOutput(buildView(carry, props.query), {});
|
|
205
|
+
});
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
### ✅ Do — short-circuit on a token / id param (constant default in carry)
|
|
209
|
+
|
|
210
|
+
Shareable links: the no-token default is fixed; only a present token needs a lookup.
|
|
211
|
+
|
|
212
|
+
```typescript
|
|
213
|
+
const EMPTY_VIEW: PageFastViewState = buildEmptyInvite();
|
|
214
|
+
|
|
215
|
+
interface DefaultCarry {
|
|
216
|
+
view: typeof EMPTY_VIEW;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
.withSlowlyRender(async () =>
|
|
220
|
+
RenderPipeline.for<PageSlowViewState, DefaultCarry>()
|
|
221
|
+
.ok({ view: EMPTY_VIEW })
|
|
222
|
+
.toPhaseOutput(() => ({
|
|
223
|
+
viewState: {},
|
|
224
|
+
carryForward: { view: EMPTY_VIEW },
|
|
225
|
+
})),
|
|
226
|
+
)
|
|
227
|
+
.withFastRender(async (props, carry: DefaultCarry, store) => {
|
|
228
|
+
const token = props.query.share;
|
|
229
|
+
if (!token) return phaseOutput(carry.view, { stops: [] });
|
|
230
|
+
const doc = await store.getByToken(token);
|
|
231
|
+
return phaseOutput(
|
|
232
|
+
buildViewFromDoc(doc),
|
|
233
|
+
{ stops: doc.stops },
|
|
234
|
+
{ responseHeaders: { 'Cache-Control': 'no-store' } },
|
|
235
|
+
);
|
|
236
|
+
});
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
`Cache-Control: no-store` belongs only on the **param** branch. The no-token default is
|
|
240
|
+
cacheable.
|
|
241
|
+
|
|
242
|
+
### ✅ Do — hybrid freshness (slow default + live branch for specific params)
|
|
243
|
+
|
|
244
|
+
Some pages need **mostly** build-time data but a **live** read when certain query params appear
|
|
245
|
+
(e.g. share token, a booking-change flow). Carry a **snapshot** in slow that is safe to show
|
|
246
|
+
**stale on the default path** (no live param); re-query only inside an explicit
|
|
247
|
+
`needsFresh…(props.query)` helper — not `Object.keys(props.query).length > 0`.
|
|
248
|
+
|
|
249
|
+
```typescript
|
|
250
|
+
function needsFreshRules(query: Record<string, string>): boolean {
|
|
251
|
+
return Boolean(query.live?.trim() || query.bookingId?.trim());
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function rulesToPhaseOutput(
|
|
255
|
+
rules: VisitRules,
|
|
256
|
+
query: Record<string, string>,
|
|
257
|
+
responseHeaders?: Record<string, string>,
|
|
258
|
+
) {
|
|
259
|
+
return phaseOutput(buildViewFromRules(rules, query), {}, { responseHeaders });
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
.withFastRender(async (props, carry: { rules: VisitRules }) => {
|
|
263
|
+
if (needsFreshRules(props.query)) {
|
|
264
|
+
const rules = await getVisitRules({});
|
|
265
|
+
return rulesToPhaseOutput(rules, props.query, { 'Cache-Control': 'no-store' });
|
|
266
|
+
}
|
|
267
|
+
return rulesToPhaseOutput(carry.rules, props.query);
|
|
268
|
+
});
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
Document which params are markup-only vs live in a comment next to the helper.
|
|
272
|
+
|
|
273
|
+
## When a query param only changes markup, not data
|
|
274
|
+
|
|
275
|
+
Params like `?view=list` vs map, which collection or item is open, or sheet/panel layout select
|
|
276
|
+
**presentation**. The underlying dataset is the same.
|
|
277
|
+
|
|
278
|
+
- Keep **data** on `carryForward` from slow (or from the last full `loadData` on live branches).
|
|
279
|
+
- In fast, only run `buildView(carry, query)` — derive `view` / `viewSwitchMarkup` / server-side
|
|
280
|
+
opening state inside `buildView`.
|
|
281
|
+
- Do **not** call `loadData()` again because `props.query` is non-empty.
|
|
282
|
+
|
|
283
|
+
```typescript
|
|
284
|
+
.withFastRender(async (props, carry: PageData) =>
|
|
285
|
+
phaseOutput(buildView(carry, props.query), {}), // ?view=list changes markup only
|
|
286
|
+
);
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
## Filters and pagination
|
|
290
|
+
|
|
291
|
+
Do not confuse **re-querying** in fast (usually wrong for filter params) with **applying** filter
|
|
292
|
+
or page params in fast (often required for correct first paint).
|
|
293
|
+
|
|
294
|
+
Shared links like `?brand=x` or `?page=3` must match the **first HTML** (SEO, no flash of wrong
|
|
295
|
+
content). When the full dataset already lives in slow `carryForward`, fast should run the same
|
|
296
|
+
pure `buildView(carry, props.query)` and **filter or slice in memory** — zero CMS calls.
|
|
297
|
+
**Interactive** then owns filter/page changes after load (read `location.search`, update URL with
|
|
298
|
+
`history` APIs, optionally call actions). Applying filters only in interactive, with no fast pass
|
|
299
|
+
over `props.query`, is wrong for deep links.
|
|
300
|
+
|
|
301
|
+
| Dataset | First load (`props.query` in fast) | After hydration (interactive) |
|
|
302
|
+
| -------------------------- | -------------------------------------------------------------------- | ----------------------------------------------------- |
|
|
303
|
+
| Small, fully carried | `buildView` filters or paginates over carry (no CMS) | Same logic on carry, or DOM narrow; sync URL |
|
|
304
|
+
| Large or over cache budget | Query with filter + limit/offset (or cursor); `no-store` if volatile | Action for page/filter changes; sync URL if shareable |
|
|
305
|
+
|
|
306
|
+
**Large catalogs:** “ship the full list” does not scale — slow `page.cache.json` caps (see below)
|
|
307
|
+
and client download cost. Server-side filter/pagination is required; filtering only the current
|
|
308
|
+
page on the client is wrong (you would filter page _N_, not the whole result set).
|
|
309
|
+
|
|
310
|
+
**Security:** client hide/show is not access control — never put rows the user must not see in
|
|
311
|
+
carry or ViewState.
|
|
312
|
+
|
|
313
|
+
## Pages with no query params at all
|
|
314
|
+
|
|
315
|
+
If the route never reads `props.query`, do **not** add a fast phase just for freshness. Put the
|
|
316
|
+
whole CMS read in `withSlowlyRender` and omit `withFastRender` (home page, static listings, static
|
|
317
|
+
content walls, idle form shells). Rely on platform rebuild / republish when content changes.
|
|
318
|
+
|
|
319
|
+
```typescript
|
|
320
|
+
.withSlowlyRender(async () => {
|
|
321
|
+
const content = await getHomePageContent({});
|
|
322
|
+
return phaseOutput(mapToViewState(content), {});
|
|
323
|
+
});
|
|
324
|
+
// no withFastRender — tests should expect page.fastRender === undefined
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
## Slow-phase cache size (`page.cache.json`)
|
|
328
|
+
|
|
329
|
+
Each route’s slow-phase snapshot is written as
|
|
330
|
+
`build/<version>/backend/pre-rendered/<route>/page.cache.json` (slow `viewState` +
|
|
331
|
+
`carryForward` serialized together).
|
|
332
|
+
|
|
333
|
+
**Jay enforces carry budgets in CI and pre-deploy:** `jay-stack validate --tier-2` measures each
|
|
334
|
+
`*.cache.json` and reports a **soft warning** at **307 200 bytes (300 KiB)** and a **hard error**
|
|
335
|
+
at **524 288 bytes (512 KiB)**. Those limits match common hosted deploy storage caps for a single
|
|
336
|
+
pre-rendered row; staying under them keeps local validation aligned with production. See
|
|
337
|
+
[validation-tier-2-guide.md](../designer/validation-tier-2-guide.md) for tier-2 output.
|
|
338
|
+
|
|
339
|
+
Payloads are usually **URLs and text**, not image blobs — but dense lists (many markers × many
|
|
340
|
+
preview fields) still blow the limit.
|
|
341
|
+
|
|
342
|
+
### Soft vs hard limits (agent behavior)
|
|
343
|
+
|
|
344
|
+
| Tier | Bytes | Meaning |
|
|
345
|
+
| ---------------- | --------------------------- | -------------------------------------------------- |
|
|
346
|
+
| **OK** | Under **307 200** (300 KiB) | Prefer full slow carry for first-paint data. |
|
|
347
|
+
| **Soft warning** | **307 200 – 524 287** | Tier 2 warns; deploy may still work on some hosts. |
|
|
348
|
+
| **Hard** | **≥ 524 288** (512 KiB) | Tier 2 errors; shrink carry before merge. |
|
|
349
|
+
|
|
350
|
+
After a production build, agents **must** run tier-2 (or `wc -c` on each cache file), **surface**
|
|
351
|
+
the sizes, and **propose** deferral when soft — smaller carry, tab-scoped fetch, post-hydration
|
|
352
|
+
enrich (see pattern below). Do not silently trim user-visible data without calling it out.
|
|
353
|
+
|
|
354
|
+
On **hard** limit: defer non-critical fields, lazy enrich after hydration, or split via a
|
|
355
|
+
platform-supported mechanism. **Do not** move the entire dataset to fast-only rendering just to
|
|
356
|
+
avoid the cache file — you lose the build-time default path and pay per-request cost instead.
|
|
357
|
+
|
|
358
|
+
### Verify size
|
|
359
|
+
|
|
360
|
+
**After `npm run build:production` (or `jay-stack build`), run Tier 2 validation:**
|
|
361
|
+
|
|
362
|
+
```bash
|
|
363
|
+
jay-stack validate --tier-2
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
Or inspect sizes manually:
|
|
367
|
+
|
|
368
|
+
```bash
|
|
369
|
+
wc -c build/v*/backend/pre-rendered/*/*.cache.json
|
|
370
|
+
find build -path '*/pre-rendered/*/*.cache.json' -size +300k -print # soft (approx)
|
|
371
|
+
find build -path '*/pre-rendered/*/*.cache.json' -size +512k -print # hard (512 KiB)
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
**Keep the cache small when over budget:**
|
|
375
|
+
|
|
376
|
+
- Put slow-phase fields in **slow `viewState`** or in **`carryForward`**, not both — never duplicate
|
|
377
|
+
the same payload across slots; both serialize into `page.cache.json`.
|
|
378
|
+
- Carry only what rebuilds the **default** view; defer the rest.
|
|
379
|
+
- Re-read [production-build.md](../devops/production-build.md).
|
|
380
|
+
|
|
381
|
+
Local dev is more forgiving; **verify on a production build** before deploying to a size-capped host.
|
|
382
|
+
|
|
383
|
+
### Example pattern: dense lists — minimal carry, enrich after hydration
|
|
384
|
+
|
|
385
|
+
On pages with **many markers or rows** (maps, dense catalogs): put **first-paint** preview
|
|
386
|
+
fields in slow carry (e.g. one thumbnail + labels per item); fetch heavier media or extra fields
|
|
387
|
+
in `withInteractive` via actions or client queries — start those fetches after hydration, not only
|
|
388
|
+
on hover. Keep the full gallery or detail payload out of `page.cache.json`. Merge deferred data
|
|
389
|
+
into ViewState or the DOM via refs per [component-refs.md](component-refs.md).
|
|
390
|
+
|
|
391
|
+
## Trade-off: freshness
|
|
392
|
+
|
|
393
|
+
Slow-phase output is built once and replayed from disk; it does **not** re-run per request. If
|
|
394
|
+
the default is a true constant (empty state, fixed invite, static form), there is no downside. If
|
|
395
|
+
the default is derived from CMS data that changes between builds, moving it to slow pins it until
|
|
396
|
+
the next build or platform-triggered rebuild.
|
|
397
|
+
|
|
398
|
+
Choose per page:
|
|
399
|
+
|
|
400
|
+
- **Immutable default** → slow only or slow carry + fast forward (no CMS on default path).
|
|
401
|
+
- **Default changes occasionally** → slow + rebuild on publish; optional interactive refresh for
|
|
402
|
+
niceties only.
|
|
403
|
+
- **Highly volatile slice** (live availability, per-user share token) → keep **that slice** in
|
|
404
|
+
fast behind an explicit param check; do not move it to slow.
|
|
405
|
+
|
|
406
|
+
## Checklist
|
|
407
|
+
|
|
408
|
+
- [ ] Does the fast phase query when the **relevant** query param is empty/absent? → carry data
|
|
409
|
+
in slow; fast uses `buildView(carry, query)`.
|
|
410
|
+
- [ ] Is the default data independent of `props.query`? → `loadData()` in slow, typed
|
|
411
|
+
`carryForward`.
|
|
412
|
+
- [ ] Slow `viewState` matches `PageSlowViewState` (may be `{}` when every tag is `fast`); same data
|
|
413
|
+
must not also sit in `carryForward`.
|
|
414
|
+
- [ ] Are `PageSlowViewState`, `PageFastViewState`, and `buildView`’s return type imported from
|
|
415
|
+
`page.jay-contract`?
|
|
416
|
+
- [ ] Does `Object.keys(props.query).length > 0` trigger a CMS call? → replace with a named
|
|
417
|
+
`needsFresh…(query)` for live params only.
|
|
418
|
+
- [ ] Is `Cache-Control: no-store` on the no-params default? → restrict to live param branches.
|
|
419
|
+
- [ ] Does a markup-only param trigger a re-query? → derive in `buildView`, forward carry.
|
|
420
|
+
- [ ] Shared filter/page URLs: does fast `buildView(carry, props.query)` apply them in memory (small
|
|
421
|
+
carry) or query the server (large set)? Interactive-only apply → wrong first paint.
|
|
422
|
+
- [ ] Phase tests: slow carry populated, fast default zero CMS calls, markup param zero CMS calls.
|
|
423
|
+
- [ ] After production build, run **`jay-stack validate --tier-2`** (or `wc -c` on each
|
|
424
|
+
`*.cache.json`); **under 300 KiB** OK for full carry; **soft** → warn and propose deferral;
|
|
425
|
+
**hard (≥ 512 KiB)** → must defer/shrink before deploy (not fast-only to dodge the cap).
|
|
426
|
+
- [ ] Dense lists/maps: minimal preview in slow carry; defer heavy fields to interactive/actions.
|