@jay-framework/jay-stack-cli 0.25.5 → 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.
@@ -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:`, in-page `#fragments`, and `{binding}` hrefs are skipped.
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 | How to suppress |
131
- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
132
- | `REGION-NOT-LINKED` | `jay-validations="REGION-NOT-LINKED"` on the `application/jay-headless` import, or `jay-stack: allow-inline-region: [Contract]` |
133
- | `REGION-OVERRIDE-NON-CONTENT` | `jay-validations="REGION-OVERRIDE-NON-CONTENT"` on the import |
134
- | `COMPONENT-NO-TEMPLATE` | `jay-stack: allow-no-template: [Contract]` |
135
- | `NO-DESIGN-SYSTEM` | `jay-stack: allow-no-design-system: true` |
136
- | Broken internal link (DL#210) | `jay-stack: allow-broken-links: true` (per-page — skips all link checks for that page) |
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 self-links. Links matching a **dynamic** route pattern
187
- (`/design-log/[...slug]`) are deferred — the concrete slug set isn't known without a build.
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
- | [design-system-guide.md](../designer/design-system-guide.md) | Shared components as design-system elements: ship a template, flatten, drift, `sync` |
33
- | [component-structure.md](../plugin/component-structure.md) | Builder API for shared/headless components: `.withProps()`, phases, render results |
34
- | [component-state.md](component-state.md) | createSignal, createMemo, createEffect, createDerivedArray |
35
- | [component-refs.md](component-refs.md) | Refs, collection refs, element types |
36
- | [component-data.md](component-data.md) | Immutable data, JSON Patch, patching |
37
- | [render-results.md](render-results.md) | phaseOutput, RenderPipeline, errors, redirects |
38
- | [error-pages-guide.md](error-pages-guide.md) | Returning enriched errors (title/code/details) and which error page renders |
39
- | [seo-guide.md](seo-guide.md) | SEO head tags: title, meta, OG, canonical via phaseOutput |
40
- | [cli-commands.md](cli-commands.md) | CLI commands: setup, validate, dev, agent-kit |
41
- | `../references/<plugin>/` | Plugin reference data |
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
- .withFastRender(async (props, db) => {
63
- const price = await db.getPrice(props.carryForward.productId);
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
- .withFastRender(async (props, db) => {
79
- // ... fast render
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
- .withInteractive(function ProductPage(props, refs) {
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: props.carryForward.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.