@voltro/cli 0.51.0 → 0.53.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/CHANGELOG.md +356 -0
- package/THIRD-PARTY-NOTICES.md +8311 -3318
- package/dist/{agentsMd-D6yD7IQv.js → agentsMd-0l980yhL.js} +4 -1
- package/dist/agentsMd-SDDSkyl4.js +2 -0
- package/dist/{apiBuild-CPDHXF72.js → apiBuild-CaPfoWku.js} +11 -5
- package/dist/apiBuild-DHtLXYx9.js +2 -0
- package/dist/bin.js +1 -1
- package/dist/build-D-OnvNMf.js +843 -0
- package/dist/{checkCommand-DNuPiWMc.js → checkCommand-C5elt0tW.js} +92 -46
- package/dist/checkCommand-D2ZduVlh.js +2 -0
- package/dist/{cloudCmd-F4YJeqM3.js → cloudCmd-QUXh-b5w.js} +1 -1
- package/dist/codegen-BWpt3VgF.js +2 -0
- package/dist/{codegen-CrMXs4hb.js → codegen-FEk8AZHb.js} +2 -2
- package/dist/{codegenCommand-BNBHcNNj.js → codegenCommand-BOiWQ5hz.js} +12 -12
- package/dist/{codemodRunner-BDVixlSw.js → codemodRunner-BjtB2lq6.js} +691 -545
- package/dist/{commands-B1OiS9bX.js → commands-DyxAmhP0.js} +36 -36
- package/dist/{dashboardCommand-C-vvPY1B.js → dashboardCommand-BdKTyT13.js} +3 -3
- package/dist/{dataCommand-C1GxXW5q.js → dataCommand-Bab9X7s8.js} +27 -27
- package/dist/{dbCommand-If4Y1xQ-.js → dbCommand-06O2finM.js} +277 -236
- package/dist/dbCommand-B1EXBC6f.js +2 -0
- package/dist/{dev-kdAg9Q7l.js → dev-C6LGF4iY.js} +2998 -2379
- package/dist/dev-GjJWAYo2.js +3 -0
- package/dist/doctorCommand-B0hX0tdz.js +2 -0
- package/dist/{doctorCommand-nKmeW78u.js → doctorCommand-etMkflRc.js} +332 -220
- package/dist/{dormancyCommand-CY3wa_SW.js → dormancyCommand-UwZ1AZzB.js} +1 -1
- package/dist/{embeddingsCommand-BDLIgje_.js → embeddingsCommand-C70zWHwo.js} +1 -1
- package/dist/{envCommand-C6V_xVlT.js → envCommand-dSyKvRkM.js} +15 -15
- package/dist/{evolveCommand-D3c4DSfN.js → evolveCommand-CG0_ebO5.js} +2 -2
- package/dist/fileConventions-DASGEmj-.js +35 -0
- package/dist/{fileTaxonomy-CJfgOllU.js → fileTaxonomy-B7uxipWS.js} +55 -55
- package/dist/fontPipeline-LxIHa1vo.js +2 -0
- package/dist/fontPipeline-Tsh8kZfA.js +152 -0
- package/dist/frameworkTableAssembly-C_7Z-rMs.js +2 -0
- package/dist/{frameworkTableAssembly-BwJVEKLr.js → frameworkTableAssembly-DKx3ba3S.js} +5 -5
- package/dist/imagePipeline-B_GVJgm6.js +2 -0
- package/dist/imagePipeline-CBZmjT4i.js +127 -0
- package/dist/index.js +1 -1
- package/dist/{infoCommand-BnRFEF1o.js → infoCommand-_53iOc_j.js} +1 -1
- package/dist/{inspect-CtL_xTbu.js → inspect-Bd8-9wsi.js} +1 -1
- package/dist/inspect-CuoDInfZ.js +2 -0
- package/dist/{inspectGateHint-BjnFubmH.js → inspectGateHint-4LxkNtrz.js} +1 -1
- package/dist/inspectMetrics-CGF94puw.js +143 -0
- package/dist/manifestBuild-C4-J1-m_.js +2 -0
- package/dist/{manifestBuild-CuU1VrSm.js → manifestBuild-Cqgsx2bM.js} +1 -1
- package/dist/{metaCommands-CfRLra0s.js → metaCommands-Cn2oboG4.js} +9 -3
- package/dist/{migrate-DehuBakM.js → migrate-Cko9rswM.js} +2 -2
- package/dist/{pageConvention-cEiRxdab.js → pageConvention-C938S8oC.js} +1 -1
- package/dist/{privacyCommand-XejDMvmu.js → privacyCommand-DWTQMC6R.js} +2 -2
- package/dist/{probeCommand-C9gazU0H.js → probeCommand-DkGGLknv.js} +83 -24
- package/dist/{projectScaffold-mIX_DpSe.js → projectScaffold-B4dmTlwT.js} +1 -1
- package/dist/{projectScaffold-BIl97_E6.js → projectScaffold-EzlErR4E.js} +1 -1
- package/dist/{renderModeScan-D7J1B7Kw.js → renderModeScan-CUbOeOAg.js} +28 -11
- package/dist/{renderProfile-1OWWAAtx.js → renderProfile-CskIgAfn.js} +2 -2
- package/dist/{runtimeTrace-ZsBU7Tkx.js → runtimeTrace-c0APJz7E.js} +1 -1
- package/dist/{sdkgen-O4XqWOjM.js → sdkgen-BiQCgIEr.js} +1 -1
- package/dist/serveCommand-CueKQgzl.js +2443 -0
- package/dist/serveCommand-DsnrVN3U.js +2 -0
- package/dist/serveEntry.js +1 -1
- package/dist/start-BJzZLbt8.js +3 -0
- package/dist/start-ekPan8BT.js +1510 -0
- package/dist/startEntry.js +1 -1
- package/dist/{staticCommand-Dr2M6tpU.js → staticCommand-xlSL-IWk.js} +1 -1
- package/dist/{test-rXFq4S76.js → test-BWPQcRoB.js} +1 -1
- package/dist/updateCommand-Bqql_rsQ.js +2 -0
- package/dist/{updateCommand-Bs322Q78.js → updateCommand-C_8I8Rzo.js} +139 -115
- package/dist/webDev-C7jWJ5dX.js +2 -0
- package/dist/{webDev-B-ubQEMX.js → webDev-oczpugbx.js} +1767 -913
- package/dist/{webhooksCommand-FLYY9IXh.js → webhooksCommand-4SVPDjKg.js} +1 -1
- package/package.json +72 -18
- package/templates/AGENTS.core.md +11 -0
- package/templates/AGENTS.md +19 -6
- package/templates/agent-docs/_index.md +8 -6
- package/templates/agent-docs/_manifest.json +31 -15
- package/templates/agent-docs/ai.md +2 -2
- package/templates/agent-docs/authentication.md +1 -1
- package/templates/agent-docs/cli.md +97 -15
- package/templates/agent-docs/configuration.md +17 -0
- package/templates/agent-docs/data.md +680 -33
- package/templates/agent-docs/database/advancedqueries.md +7 -7
- package/templates/agent-docs/database/columntypes.md +2 -2
- package/templates/agent-docs/database/querying.md +1 -1
- package/templates/agent-docs/database/schema.md +2 -2
- package/templates/agent-docs/database/seedsdialects.md +2 -2
- package/templates/agent-docs/database/transactions.md +3 -3
- package/templates/agent-docs/deployment.md +30 -3
- package/templates/agent-docs/internationalization.md +2 -2
- package/templates/agent-docs/introduction.md +52 -0
- package/templates/agent-docs/local-first-mobile.md +132 -7
- package/templates/agent-docs/observability.md +2 -0
- package/templates/agent-docs/plugins/ai-flows.md +1 -1
- package/templates/agent-docs/plugins/audit.md +5 -5
- package/templates/agent-docs/plugins/auth.md +1 -1
- package/templates/agent-docs/plugins/cdc-out.md +2 -2
- package/templates/agent-docs/plugins/comments.md +142 -0
- package/templates/agent-docs/plugins/notifications.md +47 -4
- package/templates/agent-docs/plugins/presence.md +16 -3
- package/templates/agent-docs/plugins/prometheus.md +1 -1
- package/templates/agent-docs/plugins/queue.md +129 -0
- package/templates/agent-docs/plugins/{versioning.md → row-history.md} +22 -22
- package/templates/agent-docs/plugins/storage.md +2 -2
- package/templates/agent-docs/plugins.md +38 -12
- package/templates/agent-docs/reference.md +54 -5
- package/templates/agent-docs/routing.md +868 -50
- package/templates/agent-docs/schema-driven-ui.md +292 -5
- package/templates/agent-docs/security.md +125 -8
- package/templates/agent-docs/templates/apibackends.md +14 -14
- package/templates/agent-docs/templates/overview.md +1 -1
- package/templates/agent-docs/whats-new.md +171 -54
- package/templates/apps/api-ai/package.json +6 -7
- package/templates/apps/api-ai/tests/summarize.test.ts +1 -1
- package/templates/apps/api-auth/package.json +8 -8
- package/templates/apps/api-backend/package.json +7 -7
- package/templates/apps/api-backend-deactivation/package.json +7 -7
- package/templates/apps/api-backend-mail/package.json +8 -8
- package/templates/apps/api-backend-mariadb/package.json +9 -9
- package/templates/apps/api-backend-sqlite/package.json +8 -8
- package/templates/apps/api-backend-storage/package.json +8 -8
- package/templates/apps/api-cms/package.json +9 -10
- package/templates/apps/api-collab/package.json +8 -8
- package/templates/apps/api-data-advanced/package.json +8 -8
- package/templates/apps/api-durable/package.json +8 -8
- package/templates/apps/api-feature-flags/package.json +9 -9
- package/templates/apps/api-governance/package.json +8 -8
- package/templates/apps/api-kv/package.json +8 -8
- package/templates/apps/api-moderation/package.json +8 -8
- package/templates/apps/api-observability/package.json +8 -8
- package/templates/apps/api-ratelimit/package.json +8 -8
- package/templates/apps/api-rbac/package.json +8 -8
- package/templates/apps/api-rest/package.json +7 -7
- package/templates/apps/{api-versioning → api-row-history}/README.md +3 -3
- package/templates/apps/{api-versioning → api-row-history}/actions/documents.asOf.action.server.ts +1 -1
- package/templates/apps/{api-versioning → api-row-history}/actions/documents.history.action.server.ts +1 -1
- package/templates/apps/{api-versioning → api-row-history}/app.config.ts +3 -3
- package/templates/apps/{api-versioning → api-row-history}/database/schema.ts +1 -1
- package/templates/apps/{api-versioning → api-row-history}/mutations/documents.create.mutation.server.ts +1 -1
- package/templates/apps/{api-versioning → api-row-history}/mutations/documents.create.mutation.ts +1 -1
- package/templates/apps/{api-versioning → api-row-history}/mutations/documents.update.mutation.ts +1 -1
- package/templates/apps/{api-versioning → api-row-history}/package.json +8 -8
- package/templates/apps/api-row-history/template.json +6 -0
- package/templates/apps/{api-versioning → api-row-history}/tests/documents.create.test.ts +1 -1
- package/templates/apps/api-saas/app.config.ts +1 -0
- package/templates/apps/api-saas/package.json +10 -11
- package/templates/apps/api-saas-starter/package.json +10 -10
- package/templates/apps/api-search/package.json +8 -8
- package/templates/apps/api-status/package.json +8 -8
- package/templates/apps/api-webhooks/package.json +9 -9
- package/templates/apps/changelog/app.config.ts +26 -2
- package/templates/apps/changelog/content/releases/{0.1.0.mdx → v0-1-0.mdx} +0 -1
- package/templates/apps/changelog/content/releases/{0.2.0.mdx → v0-2-0.mdx} +0 -1
- package/templates/apps/changelog/package.json +8 -8
- package/templates/apps/changelog/src/collections/releases.collection.ts +48 -0
- package/templates/apps/changelog/src/globals.d.ts +1 -1
- package/templates/apps/changelog/src/locales/de.ts +1 -1
- package/templates/apps/changelog/src/locales/en.ts +1 -1
- package/templates/apps/changelog/src/pages/[locale]/[slug]/page.tsx +7 -4
- package/templates/apps/changelog/src/pages/[locale]/mirrors.test.tsx +13 -41
- package/templates/apps/changelog/src/pages/[slug]/page.test.tsx +26 -51
- package/templates/apps/changelog/src/pages/[slug]/page.tsx +21 -19
- package/templates/apps/changelog/src/pages/page.test.tsx +14 -37
- package/templates/apps/changelog/src/pages/page.tsx +18 -12
- package/templates/apps/edge-functions/package.json +2 -2
- package/templates/apps/frontend-admin/package.json +8 -8
- package/templates/apps/frontend-app/package.json +9 -9
- package/templates/apps/frontend-auth/package.json +8 -8
- package/templates/apps/frontend-blank/package.json +7 -7
- package/templates/apps/frontend-cms/package.json +9 -9
- package/templates/apps/frontend-collab/package.json +10 -10
- package/templates/apps/frontend-collab/src/pages/page.test.tsx +10 -9
- package/templates/apps/frontend-collab/src/pages/page.tsx +42 -65
- package/templates/apps/frontend-contact/package.json +7 -7
- package/templates/apps/frontend-dashboard/package.json +7 -7
- package/templates/apps/frontend-docs/content/docs/de/guides/first-page.md +4 -0
- package/templates/apps/frontend-docs/content/docs/de/intro/getting-started.md +4 -0
- package/templates/apps/frontend-docs/content/docs/en/guides/first-page.md +4 -0
- package/templates/apps/frontend-docs/content/docs/en/intro/getting-started.md +4 -0
- package/templates/apps/frontend-docs/package.json +8 -7
- package/templates/apps/frontend-docs/src/collections/docs.collection.ts +21 -0
- package/templates/apps/frontend-docs/src/locales/de.ts +0 -5
- package/templates/apps/frontend-docs/src/locales/en.ts +0 -5
- package/templates/apps/frontend-docs/src/pages/[locale]/docs/[...slug]/page.tsx +17 -9
- package/templates/apps/frontend-docs/src/pages/[locale]/mirrors.test.tsx +10 -5
- package/templates/apps/frontend-docs/src/pages/[locale]/page.tsx +8 -0
- package/templates/apps/frontend-docs/src/pages/docs/[...slug]/page.test.tsx +53 -15
- package/templates/apps/frontend-docs/src/pages/docs/[...slug]/page.tsx +27 -33
- package/templates/apps/frontend-docs/src/pages/page.test.tsx +17 -3
- package/templates/apps/frontend-docs/src/pages/page.tsx +16 -12
- package/templates/apps/frontend-i18n/package.json +6 -6
- package/templates/apps/frontend-landing/package.json +6 -7
- package/templates/apps/frontend-portal/package.json +8 -8
- package/templates/apps/frontend-saas/package.json +8 -8
- package/templates/apps/frontend-spa/package.json +7 -7
- package/templates/apps/frontend-ssr/package.json +7 -7
- package/templates/apps/frontend-ssr-api/package.json +8 -8
- package/templates/apps/frontend-static-blog/content/posts/cms-to-ssg.md +12 -0
- package/templates/apps/frontend-static-blog/content/posts/hello-static.md +12 -0
- package/templates/apps/frontend-static-blog/content/posts/islands-not-hydration.md +14 -0
- package/templates/apps/frontend-static-blog/package.json +8 -6
- package/templates/apps/frontend-static-blog/src/collections/posts.collection.ts +27 -0
- package/templates/apps/frontend-static-blog/src/pages/[locale]/blog/[slug]/page.tsx +7 -4
- package/templates/apps/frontend-static-blog/src/pages/[locale]/mirrors.test.tsx +7 -1
- package/templates/apps/frontend-static-blog/src/pages/blog/[slug]/page.test.tsx +19 -9
- package/templates/apps/frontend-static-blog/src/pages/blog/[slug]/page.tsx +19 -21
- package/templates/apps/frontend-static-blog/src/pages/page.test.tsx +18 -5
- package/templates/apps/frontend-static-blog/src/pages/page.tsx +21 -14
- package/templates/apps/frontend-status/package.json +8 -8
- package/templates/apps/mobile-app/package.json +4 -4
- package/dist/agentsMd-Bu_XQgVf.js +0 -2
- package/dist/apiBuild-GDKuGOMV.js +0 -2
- package/dist/build-DETLZAFt.js +0 -752
- package/dist/checkCommand-CWcnDArJ.js +0 -2
- package/dist/codegen-DiMn2KkZ.js +0 -2
- package/dist/dbCommand-C27HIsGE.js +0 -2
- package/dist/dev-CK522MV5.js +0 -3
- package/dist/doctorCommand-BK4l18eG.js +0 -2
- package/dist/fileConventions-Cof68_BL.js +0 -33
- package/dist/frameworkTableAssembly-CVDB2hCq.js +0 -2
- package/dist/inspect-CuGDYES0.js +0 -2
- package/dist/inspectMetrics-CfdKLh6t.js +0 -72
- package/dist/manifestBuild-CPjhvM62.js +0 -2
- package/dist/serveCommand-BRnPCxVd.js +0 -2
- package/dist/serveCommand-DdiYNBBu.js +0 -2362
- package/dist/start-BLNmWkLa.js +0 -1154
- package/dist/start-Dzicuyw8.js +0 -3
- package/dist/updateCommand-eXB35SEv.js +0 -2
- package/dist/webDev-DposiF3j.js +0 -2
- package/templates/apps/api-versioning/template.json +0 -6
- package/templates/apps/changelog/scripts/generate-rss.mjs +0 -38
- package/templates/apps/changelog/src/lib/releases.ts +0 -21
- package/templates/apps/frontend-static-blog/src/content/posts.ts +0 -64
- /package/templates/apps/{api-versioning → api-row-history}/actions/documents.asOf.action.ts +0 -0
- /package/templates/apps/{api-versioning → api-row-history}/actions/documents.history.action.ts +0 -0
- /package/templates/apps/{api-versioning → api-row-history}/mutations/documents.update.mutation.server.ts +0 -0
- /package/templates/apps/{api-versioning → api-row-history}/tsconfig.json +0 -0
|
@@ -209,36 +209,51 @@ src/pages/[...rest]/page.tsx # everything else
|
|
|
209
209
|
|
|
210
210
|
## Query strings
|
|
211
211
|
|
|
212
|
-
|
|
212
|
+
Query params are orthogonal to the URL pattern — they never appear in the file path. A page declares its query contract as a **`searchParams` schema export**, the same page-export convention as `meta`, `loader`, and `renderMode`:
|
|
213
213
|
|
|
214
214
|
```tsx
|
|
215
|
-
|
|
215
|
+
// src/pages/search/page.tsx
|
|
216
|
+
import { Schema } from 'effect'
|
|
217
|
+
import { useSearchParams } from '@voltro/web'
|
|
216
218
|
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
+
export const searchParams = Schema.Struct({
|
|
220
|
+
q: Schema.optionalWith(Schema.String, { default: () => '' }),
|
|
221
|
+
page: Schema.optionalWith(Schema.NumberFromString, { default: () => 1 }),
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
export default function SearchPage() {
|
|
225
|
+
const { q, page } = useSearchParams(searchParams) // q: string · page: number
|
|
219
226
|
// …
|
|
220
|
-
// For the search string, parse it from the request URL via useServerRequest()
|
|
221
|
-
// (SSR) or window.location.search (client after hydration).
|
|
222
227
|
}
|
|
223
228
|
```
|
|
224
229
|
|
|
225
|
-
|
|
230
|
+
`useSearchParams(searchParams)` — the page passes its **own** export — returns the decoded, typed shape, SSR-aware: the same call site reads the request URL on the server and `window.location.search` on the client. An invalid query string falls back to the schema's defaults instead of crashing the render, so every field must be optional or carry a default (`Schema.optionalWith(..., { default })` — a schema that cannot decode an empty query throws at the first read, naming the fix). Links to the route type-check against the same schema via [typed `withQuery`](/docs/routing/navigation#typed-withquery).
|
|
226
231
|
|
|
227
|
-
|
|
228
|
-
import { useServerRequest } from '@voltro/web'
|
|
232
|
+
Two boundaries worth knowing:
|
|
229
233
|
|
|
230
|
-
export
|
|
234
|
+
- **`renderMode: 'isr'` + a `searchParams` export is refused at boot** — the isr cache is keyed by path (plus tenant + locale), so the first query's variant would be cached and served for every other query. Use `ssr`, or drop the export and read the query client-side only. See [Render modes](/docs/routing/render-modes#isr-incremental-static-regeneration).
|
|
235
|
+
- **`renderMode: 'static'` build renders see only the schema's defaults** — a build has no query string. The client decodes the live query after hydration; a static page keyed off search params is a client-side concern.
|
|
231
236
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
}
|
|
237
|
+
### Mirror routes share ONE schema
|
|
238
|
+
|
|
239
|
+
Bilingual apps with mirrored trees (`pages/x/page.tsx` + `pages/[locale]/x/page.tsx`) re-export the original page's schema instead of copying it:
|
|
240
|
+
|
|
241
|
+
```tsx
|
|
242
|
+
// src/pages/[locale]/search/page.tsx
|
|
243
|
+
export { searchParams } from '../../search/page'
|
|
239
244
|
```
|
|
240
245
|
|
|
241
|
-
|
|
246
|
+
One schema, no drift — the mirror page decodes exactly what the original declares.
|
|
247
|
+
|
|
248
|
+
### Routes without a schema
|
|
249
|
+
|
|
250
|
+
`useSearchParams()` without an argument stays the raw `URLSearchParams` — nothing changes for a route that declares no schema:
|
|
251
|
+
|
|
252
|
+
```tsx
|
|
253
|
+
import { useSearchParams } from '@voltro/web'
|
|
254
|
+
|
|
255
|
+
const q = useSearchParams().get('q') ?? ''
|
|
256
|
+
```
|
|
242
257
|
|
|
243
258
|
## Co-locating components, hooks and tests
|
|
244
259
|
|
|
@@ -584,7 +599,7 @@ else — React's render loop still runs to completion in one pass, so a slow
|
|
|
584
599
|
render is still a slow render. The lever that matters is `defer()`: it puts a
|
|
585
600
|
real `<Suspense>` boundary in the tree, which is what lets the server return to
|
|
586
601
|
the event loop while a slow value is still pending. See
|
|
587
|
-
[Deferring slow data](/docs/routing/loaders-and-meta#deferring-slow-data-defer
|
|
602
|
+
[Deferring slow data](/docs/routing/loaders-and-meta#deferring-slow-data-defer).
|
|
588
603
|
|
|
589
604
|
Measured against a real server rendering a page with one 400ms deferred field:
|
|
590
605
|
first body byte at 7ms, the deferred chunk at 408ms — and a probe firing every
|
|
@@ -599,8 +614,11 @@ Two consequences worth knowing:
|
|
|
599
614
|
framework hands the URL to React instead, so it goes out `async` at the end
|
|
600
615
|
of the shell and hydration starts immediately.
|
|
601
616
|
- **`isr` and `static` are still buffered**, because both produce a stored
|
|
602
|
-
artefact rather than a response.
|
|
603
|
-
|
|
617
|
+
artefact rather than a response. `defer()` is an error on those modes —
|
|
618
|
+
with ONE exception: an isr page that exports `ppr = true` (partial
|
|
619
|
+
prerendering, below) caches the shell and appends its deferred holes per
|
|
620
|
+
request. On `static` it stays an error even with `ppr` — a static file is
|
|
621
|
+
served by any dumb file host, which cannot append anything.
|
|
604
622
|
|
|
605
623
|
Apps do not call the renderer directly. If you are building your own server on
|
|
606
624
|
top of `@voltro/web/ssr`, `renderPageToStream` is the entry point — it takes the
|
|
@@ -637,6 +655,88 @@ Cache backends:
|
|
|
637
655
|
- `memory` *(default)* — in-process, doesn't survive restarts.
|
|
638
656
|
- `postgres` — `SSR_CACHE=postgres`. Survives restarts, shared across api instances.
|
|
639
657
|
|
|
658
|
+
An `isr` page that also declares a [`searchParams` schema](/docs/routing/pages#query-strings) is refused at boot — the cache is keyed by path (plus tenant + locale), not by query, so the first query's variant would be served for every other query; use `ssr`, or drop the export and read the query client-side only.
|
|
659
|
+
|
|
660
|
+
### isr renders are anonymous
|
|
661
|
+
|
|
662
|
+
An `isr` render is a **shared** render: the HTML it produces is cached and served
|
|
663
|
+
to every visitor inside the revalidate window. The framework therefore strips
|
|
664
|
+
credential material before the render runs — the cookie jar (except the
|
|
665
|
+
`voltro:locale` cookie), the `authorization` header, and every `x-voltro-*`
|
|
666
|
+
header never reach an isr page's loaders, `ctx.query`, or `useServerRequest()`.
|
|
667
|
+
`x-tenant` and `accept-language` survive, because the cache key (tenant + locale)
|
|
668
|
+
is derived from them.
|
|
669
|
+
|
|
670
|
+
Concretely: a loader on an isr page that reads subject-scoped data gets the
|
|
671
|
+
**anonymous** answer — the same one every visitor will see — instead of caching
|
|
672
|
+
the first visitor's data for everyone. This applies identically under
|
|
673
|
+
`voltro dev` and `voltro start`, so a page cannot look personalised in dev and
|
|
674
|
+
silently serve shared HTML in production. A page whose loader needs the signed-in
|
|
675
|
+
subject belongs on `renderMode: 'ssr'`.
|
|
676
|
+
|
|
677
|
+
## Partial prerendering (ppr) — cached shell + per-request holes
|
|
678
|
+
|
|
679
|
+
An isr page can combine a **cached, anonymous shell** with **per-request
|
|
680
|
+
dynamic holes**: the shell comes straight out of the cache (or the miss
|
|
681
|
+
render), and the deferred fields stream in behind it on the SAME response —
|
|
682
|
+
personalised, never cached.
|
|
683
|
+
|
|
684
|
+
```tsx
|
|
685
|
+
export const renderMode = 'isr' as const
|
|
686
|
+
export const revalidate = 60
|
|
687
|
+
export const ppr = true
|
|
688
|
+
|
|
689
|
+
export const loader = ({ headers }) => defer(
|
|
690
|
+
{ title: 'Dashboard' }, // SHELL — cached, anonymous
|
|
691
|
+
{ greeting: personalGreeting(headers) }, // HOLE — per request, never cached
|
|
692
|
+
)
|
|
693
|
+
```
|
|
694
|
+
|
|
695
|
+
How it works, and what each half may do:
|
|
696
|
+
|
|
697
|
+
- **The shell render is fail-closed, not merely stripped.** Its loader context
|
|
698
|
+
and `useServerRequest()` snapshot THROW by name when a credential is read
|
|
699
|
+
(`cookie`, `authorization`, `x-voltro-*` headers; any cookie but
|
|
700
|
+
`voltro:locale`): the first request answers with an error naming the read
|
|
701
|
+
and the fix, instead of baking silently-empty subject data into an artefact
|
|
702
|
+
served to everyone. `x-tenant` and `accept-language` stay readable — they
|
|
703
|
+
are cache-key inputs.
|
|
704
|
+
- **A hole is an `async` function.** Its credential reads happen inside the
|
|
705
|
+
promise: on the shell pass they reject harmlessly into the `<Await>`
|
|
706
|
+
fallback; on the per-request hole pass they see the real request. A
|
|
707
|
+
credential read in the EAGER half (or synchronously while building the
|
|
708
|
+
hole promise) is the named error above — that is the fail-closed contract.
|
|
709
|
+
- **Holes reveal through hydration.** The shell carries the `<Await>`
|
|
710
|
+
fallbacks, the registry bootstrap and the deferred-id payload; each hole
|
|
711
|
+
value is appended as an inline settle script the moment its promise
|
|
712
|
+
resolves, and the hydrated `<Await>` renders it. `ppr` therefore requires
|
|
713
|
+
`interactive: 'full'` — `'none'` ships no JS to reveal anything and
|
|
714
|
+
`'islands'` never hydrates the page root; both are refused by name.
|
|
715
|
+
- **The eager half runs twice per request** (shell render on a cache miss,
|
|
716
|
+
hole pass always). That is the cost model on purpose: eager data is the
|
|
717
|
+
cheap, cacheable half; per-subject work belongs in the holes.
|
|
718
|
+
- **Client-side navigation** to a ppr page runs the loader in the browser —
|
|
719
|
+
holes resolve through the client defer path, same `<Await>` markup.
|
|
720
|
+
- **Invalidation is the shell's**: `revalidate`, `cacheInvalidatesOn` and
|
|
721
|
+
on-demand revalidation purge the SHELL entry; holes are never cached, so
|
|
722
|
+
there is nothing to invalidate.
|
|
723
|
+
- **Layout loaders cannot defer on a ppr page** (v1): holes live in the page
|
|
724
|
+
loader; a deferring layout is refused by name.
|
|
725
|
+
- **CSP nonces are refused on ppr exactly as on isr** — the cached shell
|
|
726
|
+
carries inline registry scripts that cannot be per-request-nonce'd. Use
|
|
727
|
+
`renderMode: 'ssr'` for nonce'd pages, or a hash-based policy.
|
|
728
|
+
- **Without JavaScript** (a text crawler, JS disabled) the hole fallbacks
|
|
729
|
+
stay visible — the shell is complete, correct HTML; only the holes remain
|
|
730
|
+
in their pending state. There is deliberately NO "buffer fully for
|
|
731
|
+
crawlers" mode: user-agent sniffing serves different documents to crawlers
|
|
732
|
+
and users, which is the cloaking failure class.
|
|
733
|
+
- **A client that disconnects mid-response** simply stops receiving settle
|
|
734
|
+
scripts; nothing corrupts, the loader's own work completes server-side.
|
|
735
|
+
|
|
736
|
+
The response carries `x-voltro-ppr: shell+holes` next to the usual
|
|
737
|
+
`x-voltro-cache` headers, and the server counts shell serves, hole passes and
|
|
738
|
+
hole latency (see [Observability](/docs/observability/overview)).
|
|
739
|
+
|
|
640
740
|
## Tenant-aware ISR
|
|
641
741
|
|
|
642
742
|
For multi-tenant ISR (each tenant gets its own cache entry):
|
|
@@ -662,6 +762,63 @@ The framework reads Postgres logical replication; writes to `posts` or `comments
|
|
|
662
762
|
|
|
663
763
|
Requires `SSR_CACHE=postgres` and a `wal_level=logical` Postgres.
|
|
664
764
|
|
|
765
|
+
## On-demand revalidation
|
|
766
|
+
|
|
767
|
+
The third invalidation axis, next to time (`revalidate`) and CDC
|
|
768
|
+
(`cacheInvalidatesOn`): server code in the api process drops ISR cache entries
|
|
769
|
+
imperatively, on **every** web replica — including on dialects that have no
|
|
770
|
+
CDC at all (sqlite, mysql, memory), which is the case this exists for.
|
|
771
|
+
|
|
772
|
+
```ts
|
|
773
|
+
import { revalidatePath, revalidateTable, revalidateTag } from '@voltro/runtime'
|
|
774
|
+
|
|
775
|
+
// inside a mutation / action / webhook receiver / REST route handler:
|
|
776
|
+
await revalidateTable('posts') // drop every route whose cacheInvalidatesOn lists 'posts'
|
|
777
|
+
await revalidatePath('/blog/[slug]') // drop every cached instance of the route
|
|
778
|
+
await revalidatePath('/pricing') // drop one concrete path (all tenant+locale variants)
|
|
779
|
+
await revalidatePath('/pricing', { tenant: 'acme' }) // …one tenant's variants only
|
|
780
|
+
await revalidateTag('pricing') // drop every route whose cacheInvalidatesOn lists the tag
|
|
781
|
+
```
|
|
782
|
+
|
|
783
|
+
**Tags are tables that never were one.** `cacheInvalidatesOn` accepts free
|
|
784
|
+
strings, so one mechanism covers both: declare `cacheInvalidatesOn:
|
|
785
|
+
['posts', 'pricing']` on any number of routes and `revalidateTag('pricing')`
|
|
786
|
+
drops them all — the `revalidateTag` thinking Next.js users bring works
|
|
787
|
+
unchanged.
|
|
788
|
+
|
|
789
|
+
**How it travels.** The api process publishes; every `voltro start` replica
|
|
790
|
+
subscribes. Two transports, either or both:
|
|
791
|
+
|
|
792
|
+
- **postgres**: a `pg_notify` on the same LISTEN connection the CDC
|
|
793
|
+
invalidator already holds — a postgres deployment needs **no broker**.
|
|
794
|
+
- **a broker**: set `BROADCAST_URL` (`redis://` or `nats://`) on **both** the
|
|
795
|
+
api and the web deployment. This is the path for non-postgres dialects. On
|
|
796
|
+
a broker shared by several projects, also set `VOLTRO_BROADCAST_NAMESPACE`
|
|
797
|
+
on both sides — the channel is namespaced by that variable (the api's and
|
|
798
|
+
the web app's names differ, so a name-derived namespace can't pair them).
|
|
799
|
+
|
|
800
|
+
A web process with ISR routes and **neither** transport warns at boot
|
|
801
|
+
(`NO revalidation transport`) — calls then change nothing and cached pages
|
|
802
|
+
live out their own `revalidate` window. Under `voltro dev` there is no ISR
|
|
803
|
+
cache; the calls are debug-logged no-ops.
|
|
804
|
+
|
|
805
|
+
Three edges, all deliberate:
|
|
806
|
+
|
|
807
|
+
- **Only `isr` routes.** `revalidatePath` against a `static` route logs a
|
|
808
|
+
named error on the web process — static HTML is a build artifact `voltro
|
|
809
|
+
start` never re-renders; rebuild to change it. (The transport is
|
|
810
|
+
fire-and-forget, so the error surfaces in the web replica's log, not at the
|
|
811
|
+
call site.)
|
|
812
|
+
- **Purge-during-render is guarded.** A background SWR refresh (or miss fill)
|
|
813
|
+
that started before the purge landed is discarded instead of writing the
|
|
814
|
+
pre-purge page back with a full TTL — a per-key generation counter, on both
|
|
815
|
+
cache backends.
|
|
816
|
+
- **On postgres you don't need this for the plain publish case** — a route
|
|
817
|
+
declaring `cacheInvalidatesOn: ['<table>']` is already dropped by CDC when
|
|
818
|
+
the table changes. Reach for the imperative API for non-postgres dialects,
|
|
819
|
+
pattern purges of routes whose loaders read data indirectly, and tag
|
|
820
|
+
fanout.
|
|
821
|
+
|
|
665
822
|
## spa (client-only, with an optional SSR layout shell)
|
|
666
823
|
|
|
667
824
|
```tsx
|
|
@@ -1383,6 +1540,55 @@ They're plain async functions. They can't call `useSubscription`, `useState`, et
|
|
|
1383
1540
|
|
|
1384
1541
|
If you need a reactive query (live updates), use `useSubscription` in the component AFTER hydration; for the initial render's data, use the loader.
|
|
1385
1542
|
|
|
1543
|
+
## OG images from a template — `ogImage`
|
|
1544
|
+
|
|
1545
|
+
Declare the page's `og:image` as a satori JSX template and the framework
|
|
1546
|
+
produces the PNG: at BUILD time for `static` pages (hashed into
|
|
1547
|
+
`dist/assets/og/`, tags injected with the absolute `seo.siteUrl`), ON DEMAND
|
|
1548
|
+
for `ssr` pages over a signed route with a cache.
|
|
1549
|
+
|
|
1550
|
+
```tsx
|
|
1551
|
+
export const ogImage = ({ params, loaderData, locale }: {
|
|
1552
|
+
params: Record<string, string>
|
|
1553
|
+
loaderData: unknown
|
|
1554
|
+
locale: string
|
|
1555
|
+
}) => ({
|
|
1556
|
+
type: 'div',
|
|
1557
|
+
props: {
|
|
1558
|
+
style: {
|
|
1559
|
+
display: 'flex', width: '100%', height: '100%',
|
|
1560
|
+
background: '#0b1220', color: '#fff', fontSize: 72, fontFamily: 'Inter',
|
|
1561
|
+
alignItems: 'center', justifyContent: 'center',
|
|
1562
|
+
},
|
|
1563
|
+
children: `My post ${params['slug'] ?? ''} (${locale})`,
|
|
1564
|
+
},
|
|
1565
|
+
})
|
|
1566
|
+
```
|
|
1567
|
+
|
|
1568
|
+
`meta` wiring is automatic: `og:image`, `twitter:image` and `twitter:card`
|
|
1569
|
+
land in the head — unless your `meta` already sets `og:image`, which then
|
|
1570
|
+
wins (no duplicate tag for crawlers to pick at random).
|
|
1571
|
+
|
|
1572
|
+
**Preconditions, decided rather than improvised:**
|
|
1573
|
+
|
|
1574
|
+
- **A declared font is REQUIRED** ([Fonts](/docs/routing/fonts)) — satori
|
|
1575
|
+
cannot render text without a font buffer, and there is no bundled default
|
|
1576
|
+
(that would ship a license artifact). Missing font → a named build/boot
|
|
1577
|
+
error with the fix. The renderer uses the ORIGINAL un-subsetted files, so
|
|
1578
|
+
glyphs outside your declared subsets still render.
|
|
1579
|
+
- **`ssr` pages need `VOLTRO_OG_SECRET` in multi-replica deploys.** The
|
|
1580
|
+
render signs the on-demand URL (HMAC over route + params + tenant +
|
|
1581
|
+
locale; tampering answers 403) and a per-boot minted secret only verifies
|
|
1582
|
+
in the process that signed it — behind a load balancer set the env var
|
|
1583
|
+
(same value on every replica), or the deploy boot refuses, loudly. Images
|
|
1584
|
+
are cached in the same backend as the page cache; tenant and locale are
|
|
1585
|
+
part of the key wherever the template uses them.
|
|
1586
|
+
- **Emoji are not supported** — satori renders them only via a per-glyph CDN
|
|
1587
|
+
fetch, which collides with the no-external-requests posture. Use an image
|
|
1588
|
+
in the template instead: a `?image` import's `blurDataURL`/`src`, or any
|
|
1589
|
+
data-URI (`src: \`data:image/png;base64,…\``) inside an `img` element of
|
|
1590
|
+
the template — the standard avatar/logo card works that way.
|
|
1591
|
+
|
|
1386
1592
|
## Anti-patterns
|
|
1387
1593
|
|
|
1388
1594
|
- **Calling `ctx.ai.generate(...)` in a loader without timeouts.** Loaders shouldn't take >2s. For slow data, render a Suspense fallback + `useSubscription` after hydration.
|
|
@@ -1423,7 +1629,7 @@ import { routes } from './.framework/routes' // generated by `voltro dev`
|
|
|
1423
1629
|
```
|
|
1424
1630
|
|
|
1425
1631
|
- `routes['/pattern'](params)` → `VoltroRouteUrl`. Missing/extra params are a type error.
|
|
1426
|
-
- `withQuery(url, { env: 'prod' })` — append a query string, keeps the brand.
|
|
1632
|
+
- `withQuery(url, { env: 'prod' })` — append a query string, keeps the brand. On a route whose page declares a `searchParams` schema, the params type-check against it (below).
|
|
1427
1633
|
- `withHash(url, 'section-3')` — append a `#hash`, keeps the brand.
|
|
1428
1634
|
- `externalUrl('https://example.com')` — the escape hatch for anything the
|
|
1429
1635
|
codegen can't model: cross-origin, `mailto:`, `tel:`, hash-only, or a
|
|
@@ -1438,6 +1644,28 @@ import { withQuery, withHash, externalUrl } from '@voltro/web'
|
|
|
1438
1644
|
<Link to={externalUrl('mailto:hi@x.com')}>Email us</Link>
|
|
1439
1645
|
```
|
|
1440
1646
|
|
|
1647
|
+
### Typed `withQuery`
|
|
1648
|
+
|
|
1649
|
+
For a route whose page exports a [`searchParams` schema](/docs/routing/pages#query-strings),
|
|
1650
|
+
the generated builder brands the URL with the schema's decoded shape — through a
|
|
1651
|
+
**type-only** import, so no page module enters the routes file's value graph and
|
|
1652
|
+
code-splitting stays intact. `withQuery` then type-checks the params against the
|
|
1653
|
+
page's contract: a misspelt key or a wrong value type is a compile error.
|
|
1654
|
+
|
|
1655
|
+
```tsx
|
|
1656
|
+
<Link to={withQuery(routes['/notes'](), { page: 2 })}>Page 2</Link>
|
|
1657
|
+
// withQuery(routes['/notes'](), { pgae: 2 }) → compile error (unknown key)
|
|
1658
|
+
// withQuery(routes['/notes'](), { page: 'x' }) → compile error (wrong type)
|
|
1659
|
+
```
|
|
1660
|
+
|
|
1661
|
+
The encode is canonical and schema-free: strings pass through, numbers and
|
|
1662
|
+
booleans via `String()`, arrays become repeated keys (`?tag=a&tag=b`), and
|
|
1663
|
+
`undefined` params are omitted. A `Date` (or any object) is refused loudly —
|
|
1664
|
+
there is no canonical URL form the type layer could guarantee; declare the field
|
|
1665
|
+
as a string/number transform in the page's `searchParams` schema and pass that
|
|
1666
|
+
instead. Routes of `siblingApps` stay untyped — their pages live in another
|
|
1667
|
+
app's compile graph.
|
|
1668
|
+
|
|
1441
1669
|
## `<Link>`
|
|
1442
1670
|
|
|
1443
1671
|
```tsx
|
|
@@ -1563,6 +1791,59 @@ The router restores the previous scroll position on **back/forward** navigations
|
|
|
1563
1791
|
|
|
1564
1792
|
Push/replace navigations still scroll to top (or to the hash target); only back/forward restores.
|
|
1565
1793
|
|
|
1794
|
+
## View transitions
|
|
1795
|
+
|
|
1796
|
+
Opt in to the browser's [View Transitions API](https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API) for SPA navigations — the browser cross-fades the old and new page (and lets you animate individual elements) with zero animation library:
|
|
1797
|
+
|
|
1798
|
+
```ts
|
|
1799
|
+
// app.config.ts
|
|
1800
|
+
export default {
|
|
1801
|
+
type: 'web' as const,
|
|
1802
|
+
name: 'MyApp',
|
|
1803
|
+
router: {
|
|
1804
|
+
viewTransitions: true,
|
|
1805
|
+
},
|
|
1806
|
+
}
|
|
1807
|
+
```
|
|
1808
|
+
|
|
1809
|
+
With the flag on, every route swap — `<Link>` clicks, `navigate(...)`, back/forward — runs through `document.startViewTransition`. Individual navigations override the default in either direction:
|
|
1810
|
+
|
|
1811
|
+
```tsx
|
|
1812
|
+
navigate('/reports', { transition: false }) // this one swaps plainly
|
|
1813
|
+
<Link to={routes['/photos/[id]']({ id })} transition>Open</Link> // this one transitions even when the app default is off
|
|
1814
|
+
```
|
|
1815
|
+
|
|
1816
|
+
**Fallback is exact.** In a browser without the API, and for users with `prefers-reduced-motion: reduce`, navigation behaves precisely as without the flag — same timing, no animation, nothing to feature-detect yourself.
|
|
1817
|
+
|
|
1818
|
+
**Styling is plain CSS, not a framework DSL.** The default is a full-page cross-fade. To animate a specific element independently (the classic shared-element move), give it a `view-transition-name` and style the browser's pseudo-elements:
|
|
1819
|
+
|
|
1820
|
+
```css
|
|
1821
|
+
.post-cover { view-transition-name: post-cover; }
|
|
1822
|
+
|
|
1823
|
+
/* Tune the root cross-fade */
|
|
1824
|
+
::view-transition-old(root) { animation-duration: 150ms; }
|
|
1825
|
+
::view-transition-new(root) { animation-duration: 150ms; }
|
|
1826
|
+
|
|
1827
|
+
/* The named element morphs between its old and new position */
|
|
1828
|
+
::view-transition-group(post-cover) { animation-duration: 300ms; }
|
|
1829
|
+
```
|
|
1830
|
+
|
|
1831
|
+
An element that keeps its `view-transition-name` across both pages is morphed from its old to its new position automatically — that is the whole shared-element recipe.
|
|
1832
|
+
|
|
1833
|
+
Three behaviors worth knowing, all deliberate:
|
|
1834
|
+
|
|
1835
|
+
- **`defer()` fields resolve outside the transition.** The transition animates to the committed page — with a deferred field still showing its fallback. The field's later resolution is an ordinary React update, not a second animation. Same rule for an explicit `Pending` skeleton: the swap **to** the skeleton is the transition; the settled content arrives un-animated.
|
|
1836
|
+
- **Rapid navigation skips, never queues.** Navigating again while a transition is animating skips the running one (per the API's spec) and the last navigation wins — no queue, no dead time.
|
|
1837
|
+
- **Overlays and modals do not transition.** A view transition snapshots the whole viewport, so running one on an overlay opening would cross-fade the entire page for a change that visually touches one layer. Router view transitions therefore apply to **route navigations only**; overlay/dialog state changes never trigger one.
|
|
1838
|
+
|
|
1839
|
+
**Static / multi-page documents:** a full-document navigation (between `renderMode: 'static'` pages, or any MPA link) never goes through the SPA router — opt those into the browser's cross-document transitions with CSS alone, no framework involvement:
|
|
1840
|
+
|
|
1841
|
+
```css
|
|
1842
|
+
@view-transition { navigation: auto; }
|
|
1843
|
+
```
|
|
1844
|
+
|
|
1845
|
+
**Coming from Astro?** There is no `transition:persist` equivalent because none is needed — persistent state lives in a [layout](/docs/routing/layouts), and layouts stay mounted across SPA navigations natively.
|
|
1846
|
+
|
|
1566
1847
|
## Blocking navigation (unsaved changes)
|
|
1567
1848
|
|
|
1568
1849
|
`useBlocker` holds a pending navigation so you can prompt before the user leaves — the unsaved-changes guard.
|
|
@@ -1631,7 +1912,37 @@ window.history.forward() // forward
|
|
|
1631
1912
|
|
|
1632
1913
|
## Reading + writing search params
|
|
1633
1914
|
|
|
1634
|
-
|
|
1915
|
+
The recommended way to read the query string is **typed**: declare the page's
|
|
1916
|
+
query contract as a `searchParams` schema export and pass that same export to
|
|
1917
|
+
`useSearchParams(...)`:
|
|
1918
|
+
|
|
1919
|
+
```tsx
|
|
1920
|
+
import { Schema } from 'effect'
|
|
1921
|
+
import { useSearchParams } from '@voltro/web'
|
|
1922
|
+
|
|
1923
|
+
export const searchParams = Schema.Struct({
|
|
1924
|
+
tab: Schema.optionalWith(Schema.String, { default: () => 'overview' }),
|
|
1925
|
+
page: Schema.optionalWith(Schema.NumberFromString, { default: () => 1 }),
|
|
1926
|
+
tags: Schema.optionalWith(Schema.Array(Schema.String), { default: () => [] }),
|
|
1927
|
+
})
|
|
1928
|
+
|
|
1929
|
+
export default function Notes() {
|
|
1930
|
+
const { tab, page, tags } = useSearchParams(searchParams)
|
|
1931
|
+
// tab: string · page: number · tags: readonly string[]
|
|
1932
|
+
}
|
|
1933
|
+
```
|
|
1934
|
+
|
|
1935
|
+
- **SSR-aware** — the same call site decodes the request URL on the server and `window.location.search` on the client.
|
|
1936
|
+
- **Total** — an invalid query string is never a crash or a 500: the decode falls back to the schema's defaults, exactly like visiting without a query.
|
|
1937
|
+
- **Every field must be optional or carry a default** (`Schema.optionalWith(..., { default })`). A schema that cannot decode an empty query throws at the first read, naming the fix — that is a definition error, not a runtime input problem.
|
|
1938
|
+
- **Array fields keep their shape** — `?tag=a&tag=b` decodes to `['a', 'b']`, and a single `?tag=a` decodes to `['a']`, not a bare string.
|
|
1939
|
+
|
|
1940
|
+
The same schema types links to the route — see [typed `withQuery`](#typed-withquery)
|
|
1941
|
+
above — and the page-export convention itself is documented in
|
|
1942
|
+
[Pages → Query strings](/docs/routing/pages#query-strings).
|
|
1943
|
+
|
|
1944
|
+
`useSearchParams()` **without** an argument stays the raw `URLSearchParams` —
|
|
1945
|
+
the fallback for routes that declare no schema:
|
|
1635
1946
|
|
|
1636
1947
|
```tsx
|
|
1637
1948
|
import { useSearchParams } from '@voltro/web'
|
|
@@ -1639,7 +1950,7 @@ import { useSearchParams } from '@voltro/web'
|
|
|
1639
1950
|
const tab = useSearchParams().get('tab') ?? 'overview'
|
|
1640
1951
|
```
|
|
1641
1952
|
|
|
1642
|
-
Write
|
|
1953
|
+
Write the query with `useSetSearchParams()` — the setter updates the query string on the current pathname (via `navigate`), so the URL changes **and** every reader re-renders immediately:
|
|
1643
1954
|
|
|
1644
1955
|
```tsx
|
|
1645
1956
|
import { useSearchParams, useSetSearchParams } from '@voltro/web'
|
|
@@ -1673,6 +1984,32 @@ setParams({ page: '2' }, { push: true })
|
|
|
1673
1984
|
|
|
1674
1985
|
During SSR there is no history to write — read `useSearchParams()` off the request URL for the first paint and call `useSetSearchParams()` on the client after hydration.
|
|
1675
1986
|
|
|
1987
|
+
### Typed writes
|
|
1988
|
+
|
|
1989
|
+
Pass the page's `searchParams` schema to get the **typed** setter. Its object form REPLACES the query — same semantics as the untyped form; a field you leave out decodes to its default on the next read. Its updater form receives the **current decoded params**, so a merge is one explicit spread — the pagination flip that keeps `?filter` stops being a hand-rolled merge:
|
|
1990
|
+
|
|
1991
|
+
```tsx
|
|
1992
|
+
import { useSetSearchParams } from '@voltro/web'
|
|
1993
|
+
import { searchParams } from './page'
|
|
1994
|
+
|
|
1995
|
+
const setParams = useSetSearchParams(searchParams)
|
|
1996
|
+
setParams({ page: 2 }) // replaces → ?page=2 (filter dropped)
|
|
1997
|
+
setParams((p) => ({ ...p, page: p.page + 1 })) // keeps ?filter — typed merge
|
|
1998
|
+
```
|
|
1999
|
+
|
|
2000
|
+
A misspelt key or wrong value type in the object form is a compile error; in the updater, the typed `p` is the guard (`p.pgae` does not compile).
|
|
2001
|
+
|
|
2002
|
+
For a plain `<Link>` that keeps the current query, compose the two primitives you already have — decode the current params, spread them into `withQuery`:
|
|
2003
|
+
|
|
2004
|
+
```tsx
|
|
2005
|
+
const current = useSearchParams(searchParams)
|
|
2006
|
+
<Link to={withQuery(routes['/search'](), { ...current, page: current.page + 1 })}>Next</Link>
|
|
2007
|
+
```
|
|
2008
|
+
|
|
2009
|
+
That composition is also the whole story on **retaining params across navigations**: there is no implicit retain list — a param survives a navigation only if the link (or setter) encodes it, which keeps every URL self-describing. Spread what must survive; everything else resets to its schema default.
|
|
2010
|
+
|
|
2011
|
+
**Layout-level schemas are deliberately not a layer of their own:** the schema is a page export. A layout (or any co-located component) that needs the same params imports the page's schema and calls `useSearchParams(searchParams)` with it — composition per schema import, one schema, no drift.
|
|
2012
|
+
|
|
1676
2013
|
## Prefetching programmatically
|
|
1677
2014
|
|
|
1678
2015
|
```tsx
|
|
@@ -1719,15 +2056,13 @@ export const renderMode = 'static' as const
|
|
|
1719
2056
|
export const interactive = 'islands' as const
|
|
1720
2057
|
```
|
|
1721
2058
|
|
|
1722
|
-
> **Islands cut hydration WORK
|
|
1723
|
-
>
|
|
1724
|
-
> `interactive: 'islands'` ships **exactly the same JavaScript** as `interactive: 'full'`. Measured on the framework's reference fixture, the same page: `full` = 195,229 bytes gzipped of first-load JS, `islands` = 195,231 bytes. That is the whole difference — two bytes of noise.
|
|
2059
|
+
> **Islands cut hydration WORK and DOWNLOAD — the page ships its own lean entry.**
|
|
1725
2060
|
>
|
|
1726
|
-
>
|
|
1727
|
-
|
|
1728
|
-
|
|
2061
|
+
> `voltro build` emits a dedicated browser entry per `interactive: 'islands'` page: react + the island runtime + exactly that page's islands — not the router, not the Effect runtime, not the subscription cache, not the app shell. Measured on the framework's reference fixture (pinned in `packages/web/bundle-budget.json`, as of 2026-08-25): an islands page is **59.6 KB gzipped first-load** vs **181.9 KB gzipped** for the same page as `full` — a factor of ~3. The bundle-budget test additionally pins a hard <70 KB bound AND the ratio (<50 % of the full page). `interactive: 'none'` stays at 0 B.
|
|
2062
|
+
|
|
2063
|
+
With `interactive: 'islands'`, the page's HTML is server-rendered and its script tag points at the page's own entry. That entry registers the page's islands, scans for island markers, and hydrates each one on its own schedule — the page component itself never runs in the browser.
|
|
1729
2064
|
|
|
1730
|
-
|
|
2065
|
+
Looking for Astro's **"Server Islands"** — per-request-rendered holes in otherwise static pages? In Voltro that is planned as **partial prerendering (PPR)**, not part of islands mode.
|
|
1731
2066
|
|
|
1732
2067
|
## When to use islands
|
|
1733
2068
|
|
|
@@ -1735,15 +2070,19 @@ With `interactive: 'islands'`, the page's HTML keeps its script tags and the app
|
|
|
1735
2070
|
- **Docs** that are mostly text but have a search modal + theme toggle.
|
|
1736
2071
|
- **Blog posts** with an embedded poll or comment widget.
|
|
1737
2072
|
|
|
1738
|
-
In each case
|
|
2073
|
+
In each case you get back both halves: hydration work runs only inside the islands, and the download shrinks to react + the island runtime + those islands. If the page has no interactive part at all, `interactive: 'none'` is strictly better — it ships no JavaScript.
|
|
2074
|
+
|
|
2075
|
+
`interactive: 'none'` does not take forms with it. The strip removes every module script and modulepreload, but leaves `<form>` markup — and the form-flash JSON script (`#__voltro_form_flash__`, inert JSON, not executable code) — in place. An `<AutoForm>` on an `interactive: 'none'` page is therefore fully usable without a single byte of JavaScript: it renders `action="/form/<mutationTag>"` + `method="post"` and submits as a native form POST. Details: [Forms without JavaScript](/docs/ui/forms-and-tables).
|
|
1739
2076
|
|
|
1740
2077
|
## Writing an island
|
|
1741
2078
|
|
|
1742
2079
|
Wrap a component with `island(Component, { name, hydrate })` and default-export the result. The plain component is NOT enough — without the `island()` call the component is never registered, and at hydration time the runtime logs `island "…" not registered`.
|
|
1743
2080
|
|
|
2081
|
+
`island` comes from the react-only subpath **`@voltro/web/islands`** (only react + react-dom/client in its graph). Importing the `@voltro/web` barrel inside an island file is a BUILD ERROR — see the import rules below.
|
|
2082
|
+
|
|
1744
2083
|
```tsx
|
|
1745
2084
|
// src/components/LikeButton.island.tsx
|
|
1746
|
-
import { island } from '@voltro/web'
|
|
2085
|
+
import { island } from '@voltro/web/islands'
|
|
1747
2086
|
import { useState } from 'react'
|
|
1748
2087
|
|
|
1749
2088
|
const LikeButton = ({ initial }: { initial: number }) => {
|
|
@@ -1759,7 +2098,7 @@ export default island(LikeButton, { name: 'LikeButton', hydrate: 'visible' })
|
|
|
1759
2098
|
```
|
|
1760
2099
|
|
|
1761
2100
|
- **`name`** — the stable id under which the component is registered. Must be unique within the app. Both the SSR and the client bundle import the file, so the same `island()` call runs on both sides and registers the component in each.
|
|
1762
|
-
- **`hydrate`** — when the client runtime should hydrate this island (defaults to `'visible'`). The
|
|
2101
|
+
- **`hydrate`** — when the client runtime should hydrate this island (defaults to `'visible'`). The six strategies are in the table below.
|
|
1763
2102
|
|
|
1764
2103
|
Use it in a page:
|
|
1765
2104
|
|
|
@@ -1790,8 +2129,8 @@ What happens at build:
|
|
|
1790
2129
|
<button>❤ 42</button>
|
|
1791
2130
|
</div>
|
|
1792
2131
|
```
|
|
1793
|
-
2. The island
|
|
1794
|
-
3.
|
|
2132
|
+
2. The island compiles into the page's own browser entry — react + the island runtime + this page's islands (see [How the per-page entry works](#how-the-per-page-entry-works)).
|
|
2133
|
+
3. That entry scans for `[data-voltro-island]` markers, looks each name up in its registry, and hydrates that `<div>` per its `data-island-hydrate` strategy.
|
|
1795
2134
|
|
|
1796
2135
|
The rest of the page stays as inert HTML.
|
|
1797
2136
|
|
|
@@ -1805,33 +2144,57 @@ Each island declares WHEN it hydrates via the `hydrate` option (default `'visibl
|
|
|
1805
2144
|
| `idle` | When the browser is idle (`requestIdleCallback`, `setTimeout` fallback) | Important widgets that don't need instant interactivity — analytics, secondary nav. |
|
|
1806
2145
|
| `visible` (default) | When the element scrolls into the viewport (IntersectionObserver) | Anything below the fold — comment box, related-articles carousel. |
|
|
1807
2146
|
| `interaction` | On the first pointer / keyboard event on the element | Heavy widgets users *might* touch — embedded playground, deep tree viewer. Defers cost until commitment. |
|
|
2147
|
+
| `only` | Client-only: the server renders an empty placeholder, the client mounts fresh with `createRoot` instead of hydrating | Components that touch `window` during render — chart/map libraries. |
|
|
1808
2148
|
| `never` | Never — the server-rendered HTML stays inert | Server-only displays that never change after SSR (a build-time status badge). |
|
|
1809
2149
|
|
|
1810
2150
|
Mix freely inside one page: a `load` search box, a `visible` comment widget, and a `never` build banner can all coexist.
|
|
1811
2151
|
|
|
1812
2152
|
## What each mode actually costs
|
|
1813
2153
|
|
|
1814
|
-
Measured on the framework's reference web fixture — the same page, three values of `interactive`, first-load JavaScript read out of the page's own built HTML (entry script + every `modulepreload`) and gzipped:
|
|
2154
|
+
Measured on the framework's reference web fixture (pinned in `packages/web/bundle-budget.json`, as of 2026-08-25) — the same page, three values of `interactive`, first-load JavaScript read out of the page's own built HTML (entry script + every `modulepreload`) and gzipped:
|
|
1815
2155
|
|
|
1816
2156
|
| Mode | JS shipped | Hydration |
|
|
1817
2157
|
|---|---|---|
|
|
1818
|
-
| `interactive: 'full'` |
|
|
1819
|
-
| `interactive: 'islands'` |
|
|
2158
|
+
| `interactive: 'full'` | ≈181.9 KB gz — the app entry: router, Effect runtime, subscription cache, app shell | The whole page tree |
|
|
2159
|
+
| `interactive: 'islands'` | ≈59.6 KB gz — a per-page entry: react + the island runtime + this page's islands | Only the marked islands, each on its own strategy |
|
|
1820
2160
|
| `interactive: 'none'` | 0 B — every module script and modulepreload is stripped from the HTML | None |
|
|
1821
2161
|
|
|
1822
|
-
Two things to take from that table. **`islands`
|
|
2162
|
+
Two things to take from that table. **`islands` IS a download optimisation now** — an islands page ships roughly a third of the full page's first-load JS, because its entry carries no router, no Effect runtime, no subscription cache and no app shell. And **`none` remains the floor**: it is the only mode that removes the script tags entirely.
|
|
1823
2163
|
|
|
1824
|
-
|
|
2164
|
+
Both islands numbers are pinned in CI — the hard <70 KB bound and the <50 %-of-full ratio — so the gap cannot drift shut silently. Reproduce it yourself:
|
|
1825
2165
|
|
|
1826
2166
|
```sh
|
|
1827
2167
|
node packages/web/scripts/bundle-budget.mjs
|
|
1828
2168
|
```
|
|
1829
2169
|
|
|
1830
|
-
##
|
|
2170
|
+
## How the per-page entry works
|
|
2171
|
+
|
|
2172
|
+
`voltro build` emits one browser entry per `interactive: 'islands'` page. The build finds the page's islands by walking the page's **relative import graph** for `*.island.tsx` files — transitively, through intermediate components. Only what is reachable from an island file ships; the page component itself may import anything, because on an islands page it never runs in the browser.
|
|
2173
|
+
|
|
2174
|
+
Two rules to know:
|
|
1831
2175
|
|
|
1832
|
-
|
|
2176
|
+
- **`interactive` must be a source LITERAL.** `export const interactive = 'islands' as const` selects the lean entry; a computed value does not — the page then ships the full entry as before, and the build says so loudly.
|
|
2177
|
+
- **It applies on all three paths.** `voltro build` (SSG), `voltro start` (ssr/isr islands pages) and `voltro dev` (the same entry mechanism, on demand) — including the import-rule violations below, which fire in dev already, not first in the build.
|
|
1833
2178
|
|
|
1834
|
-
|
|
2179
|
+
### What an island may import (build errors, not runtime crashes)
|
|
2180
|
+
|
|
2181
|
+
An island file — or anything in its relative import graph — must NOT import:
|
|
2182
|
+
|
|
2183
|
+
- `@voltro/web` (the barrel — router hooks, `<Link>`)
|
|
2184
|
+
- `@voltro/i18n` (`useT`)
|
|
2185
|
+
|
|
2186
|
+
An island hydrates provider-less in its own root, so these hooks would throw there — and the barrel would additionally drag the Effect runtime into the lean entry. The build error names the file and the specifier.
|
|
2187
|
+
|
|
2188
|
+
Allowed: `@voltro/web/islands`, react, relative browser-safe imports — and `@voltro/client` / `@voltro/ui` (both count as framework usage and trigger the client boot below).
|
|
2189
|
+
|
|
2190
|
+
### Framework islands: `useSubscription` and friends
|
|
2191
|
+
|
|
2192
|
+
An `@voltro/client` import in the island graph is DETECTED — that page's entry then boots the rpc client (a `VoltroRuntimeProvider` around each island root), and the island receives live data. Pages whose islands are purely presentational never pay the client core.
|
|
2193
|
+
|
|
2194
|
+
### Limits
|
|
2195
|
+
|
|
2196
|
+
- An islands page reached via **SPA navigation** from a full page runs inside the already-loaded app bundle — the saving applies to the first visit / hard load of the islands page.
|
|
2197
|
+
- All islands of one page **share one entry** (no per-island lazy chunk) — the hydrate strategies control WHEN an island hydrates, not when it loads.
|
|
1835
2198
|
|
|
1836
2199
|
## Island boundaries
|
|
1837
2200
|
|
|
@@ -1851,7 +2214,7 @@ Each island is independent — there is no shared React root across islands. To
|
|
|
1851
2214
|
|
|
1852
2215
|
## Props serialisation
|
|
1853
2216
|
|
|
1854
|
-
|
|
2217
|
+
Island props cross the boundary **as JSON in an HTML attribute**. The framework serialises them into the marker's `data-island-props` attribute + hydrates with the same values. `Date` arrives as an ISO string, `Map`/`Set` as `{}`, and functions are lost — in dev the framework warns, naming the island and the prop. Pass JSON shapes and reconstruct richer types inside the island.
|
|
1855
2218
|
|
|
1856
2219
|
OK:
|
|
1857
2220
|
|
|
@@ -1887,13 +2250,13 @@ If you need to pass a function reference, define it INSIDE the island.
|
|
|
1887
2250
|
|
|
1888
2251
|
## Inspecting
|
|
1889
2252
|
|
|
1890
|
-
Each
|
|
2253
|
+
Each islands page gets its own lean entry in `.framework/dist/assets/`, alongside the app entry that full pages share. Listing that directory is the whole report:
|
|
1891
2254
|
|
|
1892
2255
|
```sh
|
|
1893
2256
|
ls -l .framework/dist/assets
|
|
1894
2257
|
```
|
|
1895
2258
|
|
|
1896
|
-
|
|
2259
|
+
All islands of one page share that page's entry — there is no per-island lazy chunk. See the mode table above for what actually reaches the browser.
|
|
1897
2260
|
|
|
1898
2261
|
## Anti-patterns
|
|
1899
2262
|
|
|
@@ -1982,14 +2345,312 @@ the `@source` is misconfigured.
|
|
|
1982
2345
|
|
|
1983
2346
|
|
|
1984
2347
|
|
|
2348
|
+
---
|
|
2349
|
+
|
|
2350
|
+
<!-- source: en/routing/assets.md -->
|
|
2351
|
+
## Images & static assets
|
|
2352
|
+
|
|
2353
|
+
_The build-time image pipeline — ?image imports become srcSet variants (AVIF/WebP + fallback) with inferred dimensions and a blur placeholder; the <Image> primitive, the CDN loader seam, and the sharp setup._
|
|
2354
|
+
|
|
2355
|
+
`<Image>` is the responsive image primitive; the `?image` import suffix is the
|
|
2356
|
+
build-time pipeline behind it. Together they replace next/image: variants and
|
|
2357
|
+
placeholders are produced at build time for static assets, on demand in dev.
|
|
2358
|
+
|
|
2359
|
+
## The pipeline: `?image` imports
|
|
2360
|
+
|
|
2361
|
+
```tsx
|
|
2362
|
+
import { Image } from '@voltro/web'
|
|
2363
|
+
import hero from '../assets/hero.jpg?image'
|
|
2364
|
+
|
|
2365
|
+
export default function Page(): React.ReactElement {
|
|
2366
|
+
return <Image src={hero} alt="Team photo" priority />
|
|
2367
|
+
}
|
|
2368
|
+
```
|
|
2369
|
+
|
|
2370
|
+
The `?image` suffix turns the import into an optimized asset object instead of
|
|
2371
|
+
a URL string: the build encodes every ladder width up to the intrinsic width
|
|
2372
|
+
(640 … 3840, capped) as AVIF + WebP plus a same-family fallback (jpeg/png),
|
|
2373
|
+
hashes the variants into `dist/assets/`, reads the intrinsic `width`/`height`,
|
|
2374
|
+
and inlines a 16px blur placeholder as a data URI. `<Image>` renders it as a
|
|
2375
|
+
`<picture>` with one `<source>` per modern format; dimensions and blur are
|
|
2376
|
+
inferred — the CLS-required `width`/`height` props stop being hand-written for
|
|
2377
|
+
imported assets, and `placeholder="blur"` is the default (opt out with
|
|
2378
|
+
`placeholder="empty"`).
|
|
2379
|
+
|
|
2380
|
+
The suffix is an explicit opt-in **on purpose**: a bare image import keeps
|
|
2381
|
+
Vite's plain hashed-URL semantics, so existing `<img src={imported}>` and CSS
|
|
2382
|
+
references are untouched.
|
|
2383
|
+
|
|
2384
|
+
Add the ambient type once per app (`src/voltro-image.d.ts`):
|
|
2385
|
+
|
|
2386
|
+
```ts
|
|
2387
|
+
declare module '*?image' {
|
|
2388
|
+
const asset: {
|
|
2389
|
+
readonly src: string
|
|
2390
|
+
readonly width: number
|
|
2391
|
+
readonly height: number
|
|
2392
|
+
readonly blurDataURL: string
|
|
2393
|
+
readonly srcSet: string
|
|
2394
|
+
readonly sources: ReadonlyArray<{ readonly type: string; readonly srcSet: string }>
|
|
2395
|
+
}
|
|
2396
|
+
export default asset
|
|
2397
|
+
}
|
|
2398
|
+
```
|
|
2399
|
+
|
|
2400
|
+
## Dev vs build vs start
|
|
2401
|
+
|
|
2402
|
+
- **`voltro build`** encodes variants into `dist/assets/` under content
|
|
2403
|
+
hashes. The transforms run through a persistent cache
|
|
2404
|
+
(`.framework/image-cache/`), so the second build re-encodes nothing — 500
|
|
2405
|
+
posts × 8 widths × 2 formats is a one-time cost.
|
|
2406
|
+
- **`voltro dev`** serves transforms on demand from
|
|
2407
|
+
`/_voltro/image/<assetId>` (same cache). The endpoint answers ONLY for
|
|
2408
|
+
assets registered by an actual `?image` import — a free path parameter
|
|
2409
|
+
would be a dev file-read surface.
|
|
2410
|
+
- **`voltro start`** serves build artifacts only. There is deliberately no
|
|
2411
|
+
production transform endpoint — no transform-DoS surface. This is a
|
|
2412
|
+
documented dev/prod divergence.
|
|
2413
|
+
|
|
2414
|
+
## Configuration
|
|
2415
|
+
|
|
2416
|
+
```ts
|
|
2417
|
+
// app.config.ts (web)
|
|
2418
|
+
export default {
|
|
2419
|
+
type: 'web' as const,
|
|
2420
|
+
name: 'MyApp',
|
|
2421
|
+
images: {
|
|
2422
|
+
formats: ['avif', 'webp'], // modern formats, in <source> order (default)
|
|
2423
|
+
quality: 75, // encode quality for every variant (default)
|
|
2424
|
+
},
|
|
2425
|
+
}
|
|
2426
|
+
```
|
|
2427
|
+
|
|
2428
|
+
## sharp — the native encoder
|
|
2429
|
+
|
|
2430
|
+
The pipeline runs on [sharp](https://sharp.pixelplumbing.com), shipped as an
|
|
2431
|
+
**optional dependency of @voltro/cli** — auto-available in every project,
|
|
2432
|
+
nothing to install. sharp ≥0.33 ships prebuilt binaries as `@img/*` platform
|
|
2433
|
+
packages with no install script, so pnpm 10's build-script approval gate does
|
|
2434
|
+
not apply.
|
|
2435
|
+
|
|
2436
|
+
If your installer **omits optional dependencies**, the platform prebuilds are
|
|
2437
|
+
dropped: sharp resolves but throws on load. The pipeline then serves original
|
|
2438
|
+
images with ONE loud warning naming the fix (`pnpm add -D sharp`, or reinstall
|
|
2439
|
+
without omitting optional deps), and `voltro doctor` distinguishes
|
|
2440
|
+
"not installed" from "installed but binary missing". Never a silent
|
|
2441
|
+
passthrough.
|
|
2442
|
+
|
|
2443
|
+
## Limits (and the answer for each)
|
|
2444
|
+
|
|
2445
|
+
- **Dynamic `src`** — a URL from `loaderData` or CMS frontmatter cannot be
|
|
2446
|
+
seen at build time. Use the loader seam: `<Image src={url} loader={cdn}>`
|
|
2447
|
+
against your image CDN or the storage plugin's public-serve endpoint (which
|
|
2448
|
+
resizes on the fly). The `quality` prop flows into the loader for exactly
|
|
2449
|
+
this path; for `?image` assets quality is baked at build time from
|
|
2450
|
+
`images.quality`.
|
|
2451
|
+
- **Remote images** — same: loader seam, not the build pipeline.
|
|
2452
|
+
- **Markdown-content images** (a blog's relative references) — copied +
|
|
2453
|
+
hashed by the content pipeline; build-time transformation for those is a
|
|
2454
|
+
named non-goal for now.
|
|
2455
|
+
|
|
2456
|
+
## `<Image>` without the pipeline
|
|
2457
|
+
|
|
2458
|
+
Everything from before still holds for plain string `src`: lazy loading +
|
|
2459
|
+
async decode by default, `priority` for the LCP image, required
|
|
2460
|
+
`width`/`height` (or `fill`) for CLS, `sizes`, and the pluggable
|
|
2461
|
+
`ImageLoader`/`ImageConfigProvider` seam. See the reference for the full prop
|
|
2462
|
+
table.
|
|
2463
|
+
|
|
2464
|
+
|
|
2465
|
+
|
|
2466
|
+
---
|
|
2467
|
+
|
|
2468
|
+
<!-- source: en/routing/third-party-scripts.md -->
|
|
2469
|
+
## Third-party scripts
|
|
2470
|
+
|
|
2471
|
+
_The <Script> component — declared loading strategies (afterInteractive, lazyOnload), process-wide dedupe, remount-safe onLoad, behavior per interactive mode, and the CSP story for client-injected tags._
|
|
2472
|
+
|
|
2473
|
+
`<Script>` loads third-party scripts declaratively instead of hand-rolled
|
|
2474
|
+
`useEffect` + `createElement('script')` blocks — with a decided answer for
|
|
2475
|
+
every mode the page can be in.
|
|
2476
|
+
|
|
2477
|
+
```tsx
|
|
2478
|
+
import { Script } from '@voltro/web'
|
|
2479
|
+
|
|
2480
|
+
// Analytics after hydration (the default strategy):
|
|
2481
|
+
<Script
|
|
2482
|
+
src="https://eu.i.posthog.com/static/array.js"
|
|
2483
|
+
onLoad={() => {
|
|
2484
|
+
// The hand-written PostHog browser snippet — @voltro/plugin-posthog is a
|
|
2485
|
+
// SERVER-side track sink and ships no browser snippet; this is the
|
|
2486
|
+
// client half, wired the way PostHog's docs describe.
|
|
2487
|
+
const w = window as { posthog?: { init: (key: string, opts: { api_host: string }) => void } }
|
|
2488
|
+
w.posthog?.init('phc_your_project_key', { api_host: 'https://eu.i.posthog.com' })
|
|
2489
|
+
}}
|
|
2490
|
+
/>
|
|
2491
|
+
|
|
2492
|
+
// GTM bootstrap — the inline variant (id is REQUIRED: it is the dedupe key):
|
|
2493
|
+
<Script id="gtm-init">{`window.dataLayer = window.dataLayer || []`}</Script>
|
|
2494
|
+
|
|
2495
|
+
// A chat widget nobody needs before the browser is idle:
|
|
2496
|
+
<Script src="https://widget.example.com/loader.js" strategy="lazyOnload" />
|
|
2497
|
+
```
|
|
2498
|
+
|
|
2499
|
+
## Strategies
|
|
2500
|
+
|
|
2501
|
+
- **`afterInteractive`** (default) — injected after this component mounts,
|
|
2502
|
+
i.e. after hydration. Never render-blocking; the shell head stays clean.
|
|
2503
|
+
- **`lazyOnload`** — waits for browser idle (`requestIdleCallback`, with a
|
|
2504
|
+
`setTimeout` fallback for Safari).
|
|
2505
|
+
|
|
2506
|
+
There is **no `beforeInteractive`**. The honest alternative for a
|
|
2507
|
+
must-run-first script (a consent manager) is a literal `<script>` tag in the
|
|
2508
|
+
shell head — a `<link rel="preload">` is *not* an answer: it fetches but never
|
|
2509
|
+
executes. And no `worker` strategy (the Partytown class is its own decision).
|
|
2510
|
+
|
|
2511
|
+
## Dedupe + remount semantics
|
|
2512
|
+
|
|
2513
|
+
Scripts deduplicate **process-wide** by `src` (external) or `id` (inline) —
|
|
2514
|
+
the registry is global, so two `<Script>` tags for one widget produce one
|
|
2515
|
+
request and one execution, even across an islands page's separate bundle.
|
|
2516
|
+
|
|
2517
|
+
A script is **never unloaded**. Navigate away and back and the script does
|
|
2518
|
+
not re-execute and nothing is re-fetched — but `onLoad` **fires again**,
|
|
2519
|
+
answered from the registry (the classic next/script bug where a remounted
|
|
2520
|
+
component's `onLoad` never fires is pinned by test here). `onError` behaves
|
|
2521
|
+
the same for a failed load.
|
|
2522
|
+
|
|
2523
|
+
## Behavior per `interactive` mode — decided, not accidental
|
|
2524
|
+
|
|
2525
|
+
- **`full`** — as described above.
|
|
2526
|
+
- **`none`** — zero-JS means zero: the app bundle never ships, so a
|
|
2527
|
+
`<Script>` can never inject. The build **warns by name** instead of
|
|
2528
|
+
silently doing nothing.
|
|
2529
|
+
- **`islands`** — outside an island nothing mounts, so a `<Script>` in the
|
|
2530
|
+
page's static part never fires; the build warns. Inside an island it runs
|
|
2531
|
+
when that island hydrates — a `visible` island's script loads when it
|
|
2532
|
+
scrolls into view, which is often exactly the lazy behavior you want.
|
|
2533
|
+
|
|
2534
|
+
## CSP
|
|
2535
|
+
|
|
2536
|
+
`<Script>` injects client-side, so the per-request nonce your middleware
|
|
2537
|
+
mints ([CSP nonces](/docs/security/production-hardening)) is stamped into
|
|
2538
|
+
server-rendered tags — not into tags created in the browser. The component's
|
|
2539
|
+
answer:
|
|
2540
|
+
|
|
2541
|
+
- An explicit `nonce` prop always wins.
|
|
2542
|
+
- Otherwise the injector propagates the **document's own nonce** (read off an
|
|
2543
|
+
existing nonce'd script element). On an SSR page under a nonce'd CSP the
|
|
2544
|
+
injected tag therefore carries the request's nonce automatically.
|
|
2545
|
+
- On a **`static` page there is no per-request nonce path at all** — the
|
|
2546
|
+
documented options are `'strict-dynamic'` (scripts injected by an
|
|
2547
|
+
allowed/nonce'd bootstrap are permitted, which is exactly this shape) or a
|
|
2548
|
+
hash-based policy.
|
|
2549
|
+
|
|
2550
|
+
The inline variant is covered by the same rules — under a strict CSP an
|
|
2551
|
+
inline snippet needs the nonce or `'strict-dynamic'` like any other injected
|
|
2552
|
+
script.
|
|
2553
|
+
|
|
2554
|
+
|
|
2555
|
+
|
|
2556
|
+
---
|
|
2557
|
+
|
|
2558
|
+
<!-- source: en/routing/fonts.md -->
|
|
2559
|
+
## Fonts
|
|
2560
|
+
|
|
2561
|
+
_Self-hosted local fonts — declared once in app.config.ts, delivered as hashed woff2 with @font-face, a size-adjusted fallback face (the CLS guard), a preload link and opt-in subsetting. No font CDN request ever leaves the browser._
|
|
2562
|
+
|
|
2563
|
+
Declare local font files once and the framework does the rest: content-hashed
|
|
2564
|
+
self-hosting, `@font-face` with `font-display`, a **size-adjusted fallback
|
|
2565
|
+
face** so the swap moves nothing, a `<link rel="preload">` in the shell head,
|
|
2566
|
+
and opt-in unicode-range subsetting.
|
|
2567
|
+
|
|
2568
|
+
```ts
|
|
2569
|
+
// app.config.ts (web)
|
|
2570
|
+
export default {
|
|
2571
|
+
type: 'web' as const,
|
|
2572
|
+
name: 'MyApp',
|
|
2573
|
+
fonts: [{
|
|
2574
|
+
family: 'Inter',
|
|
2575
|
+
src: [
|
|
2576
|
+
{ path: 'src/fonts/Inter-Variable.woff2', weight: '100 900' }, // variable range
|
|
2577
|
+
// …or discrete faces — Regular+Bold is any real project's minimum:
|
|
2578
|
+
// { path: 'src/fonts/Inter-400.woff2', weight: 400 },
|
|
2579
|
+
// { path: 'src/fonts/Inter-700.woff2', weight: 700 },
|
|
2580
|
+
// { path: 'src/fonts/Inter-Italic.woff2', weight: 400, style: 'italic' },
|
|
2581
|
+
],
|
|
2582
|
+
display: 'swap',
|
|
2583
|
+
subsets: ['latin'],
|
|
2584
|
+
fallback: 'Arial',
|
|
2585
|
+
}],
|
|
2586
|
+
}
|
|
2587
|
+
```
|
|
2588
|
+
|
|
2589
|
+
```tsx
|
|
2590
|
+
import { localFont } from '@voltro/web'
|
|
2591
|
+
|
|
2592
|
+
const inter = localFont('Inter') // { variable: '--font-inter', fontFamily: 'var(--font-inter)' }
|
|
2593
|
+
|
|
2594
|
+
export default function Page(): React.ReactElement {
|
|
2595
|
+
return <main style={{ fontFamily: inter.fontFamily }}>…</main>
|
|
2596
|
+
}
|
|
2597
|
+
```
|
|
2598
|
+
|
|
2599
|
+
The shell defines `--font-inter` as `'Inter', 'Inter Fallback', Arial,
|
|
2600
|
+
sans-serif` — use it from any CSS. Every render mode ships the same head tags
|
|
2601
|
+
(the CSS + preload are baked into the ONE generated shell that dev, static
|
|
2602
|
+
prerender, SSR streaming and `voltro start` all serve).
|
|
2603
|
+
|
|
2604
|
+
## Why the fallback face matters (CLS)
|
|
2605
|
+
|
|
2606
|
+
Until the web font arrives, text renders in the fallback — and a fallback
|
|
2607
|
+
with different metrics reflows the page when the swap happens. The pipeline
|
|
2608
|
+
reads the font file's real metrics (fontkit) and emits an `'Inter Fallback'`
|
|
2609
|
+
face: `local('Arial')` with `size-adjust`, `ascent-override`,
|
|
2610
|
+
`descent-override` and `line-gap-override` computed by the capsize formula so
|
|
2611
|
+
the fallback occupies the SAME space. The swap becomes invisible; CLS ≈ 0.
|
|
2612
|
+
|
|
2613
|
+
## Subsetting
|
|
2614
|
+
|
|
2615
|
+
`subsets: ['latin']` (and/or `'latin-ext'`) rewrites each face to just that
|
|
2616
|
+
unicode range via `subset-font`, declared with a matching `unicode-range` so
|
|
2617
|
+
the browser only downloads what the page's characters need. Measured in the
|
|
2618
|
+
framework's own e2e: the subset ships at well under half the source size.
|
|
2619
|
+
|
|
2620
|
+
## GDPR — no font CDN, ever
|
|
2621
|
+
|
|
2622
|
+
Nothing here talks to Google Fonts (or any font host) at runtime — the files
|
|
2623
|
+
ship from YOUR origin, content-hashed and immutable. That is the compliance
|
|
2624
|
+
answer German courts made concrete (LG München, remote Google-Fonts
|
|
2625
|
+
embedding): the user's IP never reaches a font CDN because no request leaves
|
|
2626
|
+
your domain. The framework's e2e asserts exactly that — a full page load with
|
|
2627
|
+
zero foreign-host requests.
|
|
2628
|
+
|
|
2629
|
+
**Getting the files:** there is deliberately no Google-Fonts download helper
|
|
2630
|
+
(license terms differ per family — that step stays yours). The manual path:
|
|
2631
|
+
download the family from fonts.google.com (or the foundry), drop the
|
|
2632
|
+
`woff2`/`ttf` into `src/fonts/`, declare it. Done once, committed with the
|
|
2633
|
+
repo.
|
|
2634
|
+
|
|
2635
|
+
## Tooling (optional, degrades loudly)
|
|
2636
|
+
|
|
2637
|
+
`fontkit` (metrics) and `subset-font` (subsetting) ship as optional
|
|
2638
|
+
dependencies of @voltro/cli — script-free, nothing to approve. If your
|
|
2639
|
+
installer omits optional dependencies: fonts still self-host with
|
|
2640
|
+
`@font-face` + preload, but the fallback metrics and subsets are skipped —
|
|
2641
|
+
with ONE named warning each, and `voltro doctor` reports which half is
|
|
2642
|
+
missing and the fix. Never a silent downgrade.
|
|
2643
|
+
|
|
2644
|
+
|
|
2645
|
+
|
|
1985
2646
|
---
|
|
1986
2647
|
|
|
1987
2648
|
<!-- source: en/routing/middleware.md -->
|
|
1988
2649
|
## Middleware
|
|
1989
2650
|
|
|
1990
|
-
_'`middleware.ts` — the web app''s one server-only hook: renew a credential before the SSR render uses it, and say which routes it runs on.'_
|
|
2651
|
+
_'`middleware.ts` — the web app''s one server-only hook: renew a credential before the SSR render uses it, shape the response (headers, a CSP nonce), and say which routes it runs on.'_
|
|
1991
2652
|
|
|
1992
|
-
`middleware.ts` at the web app root runs **before** a server render binds its data. It exists for
|
|
2653
|
+
`middleware.ts` at the web app root runs **before** a server render binds its data. It exists for two jobs — renewing a credential the render is about to use, and shaping the RESPONSE (`responseHeaders`, a `cspNonce`) — and it is deliberately narrow about everything else.
|
|
1993
2654
|
|
|
1994
2655
|
```ts
|
|
1995
2656
|
// middleware.ts — server-only. NOT app.config.ts, which is imported into the
|
|
@@ -2017,7 +2678,7 @@ A cookie older than the IdP's token lifetime — practically every first page vi
|
|
|
2017
2678
|
|
|
2018
2679
|
## What it receives, and what it can return
|
|
2019
2680
|
|
|
2020
|
-
`run` gets a read-only request and returns `{ headers?, setCookies? }` — or nothing, to change nothing.
|
|
2681
|
+
`run` gets a read-only request and returns `{ headers?, setCookies?, responseHeaders?, cspNonce? }` — or nothing, to change nothing. `headers` and `setCookies` shape the REQUEST this render sees; `responseHeaders` and `cspNonce` shape the RESPONSE it produces (their own sections below).
|
|
2021
2682
|
|
|
2022
2683
|
| Field | |
|
|
2023
2684
|
|---|---|
|
|
@@ -2046,6 +2707,52 @@ The middleware produces one view of the request that everything downstream reads
|
|
|
2046
2707
|
|
|
2047
2708
|
So a hook that renews **only** via `setCookies` — no `headers` at all, which is the normal shape for a cookie-session IdP — still authenticates this render's rpc calls: the `Cookie` header is rebuilt from the updated jar. A `maxAge` of `0` deletes, so a hook that signs someone out renders them signed out. If you return an explicit `cookie` header yourself, yours wins.
|
|
2048
2709
|
|
|
2710
|
+
## Response headers — `responseHeaders`
|
|
2711
|
+
|
|
2712
|
+
`responseHeaders` is applied to what this render SENDS — every render-shaped response on both boot paths: `ssr` and `isr` renders, the `spa` shell, and a loader's redirect or 404.
|
|
2713
|
+
|
|
2714
|
+
```ts
|
|
2715
|
+
export const session = defineMiddleware({
|
|
2716
|
+
match: { under: '/app' },
|
|
2717
|
+
run: async () => ({
|
|
2718
|
+
responseHeaders: {
|
|
2719
|
+
'x-frame-options': 'DENY',
|
|
2720
|
+
'referrer-policy': 'no-referrer',
|
|
2721
|
+
},
|
|
2722
|
+
}),
|
|
2723
|
+
})
|
|
2724
|
+
```
|
|
2725
|
+
|
|
2726
|
+
Two boundaries, stated rather than implied:
|
|
2727
|
+
|
|
2728
|
+
- **`responseHeaders` act on the RENDER — an isr cache HIT does not re-run the middleware,** so a HIT does not carry the headers the MISS's render produced. For an `isr` page, either set cache-independent headers at the proxy, or accept that only MISS/refresh responses carry them.
|
|
2729
|
+
- **Prerendered `static` pages never render at request time,** so there is no middleware run to attach headers to. That is the documented proxy recipe: headers on static files belong on whatever serves them.
|
|
2730
|
+
|
|
2731
|
+
## A per-request CSP nonce — `cspNonce`
|
|
2732
|
+
|
|
2733
|
+
Return `cspNonce` and the framework stamps `nonce="…"` onto every script tag of that render — the state script, the deferred registry, the shell's bundle tags, and React's own bootstrap/settle scripts (via React's nonce support). The POLICY header stays yours: set it via `responseHeaders`, with the same nonce.
|
|
2734
|
+
|
|
2735
|
+
```ts
|
|
2736
|
+
import { randomBytes } from 'node:crypto'
|
|
2737
|
+
import { defineMiddleware } from '@voltro/web/middleware'
|
|
2738
|
+
|
|
2739
|
+
export const csp = defineMiddleware({
|
|
2740
|
+
match: { under: '/app' },
|
|
2741
|
+
run: async () => {
|
|
2742
|
+
const nonce = randomBytes(16).toString('base64url') // fresh per request
|
|
2743
|
+
return {
|
|
2744
|
+
cspNonce: nonce,
|
|
2745
|
+
responseHeaders: {
|
|
2746
|
+
'content-security-policy': `script-src 'nonce-${nonce}' 'strict-dynamic'`,
|
|
2747
|
+
},
|
|
2748
|
+
}
|
|
2749
|
+
},
|
|
2750
|
+
})
|
|
2751
|
+
```
|
|
2752
|
+
|
|
2753
|
+
- **`isr` + `cspNonce` refuses the render, loudly.** A cached nonce is a lie the browser enforces — the second visitor gets HTML whose nonce the policy header no longer matches. The ways out: `ssr` for nonce'd pages, or a hash-based CSP for `isr`.
|
|
2754
|
+
- The PPR variant of that question is open until partial prerendering exists; a component for client-injected script tags (a `Script` component) is planned.
|
|
2755
|
+
|
|
2049
2756
|
## `match` — where it runs
|
|
2050
2757
|
|
|
2051
2758
|
Without a `match`, a middleware runs on every server-rendered route, including your marketing pages. That is an IdP round trip on the page least able to afford one.
|
|
@@ -2115,3 +2822,114 @@ The file is loaded **once per boot** — it is app code with a stable identity,
|
|
|
2115
2822
|
**`voltro dev` therefore RESTARTS when you edit it**, the same way a hard-restart field in `app.config.ts` does, and says so in the log. Once-per-boot is documented, and it is still the rule most easily forgotten — everything else in a dev server hot-reloads, so a sabotaged middleware that changes nothing reads as a hook that was never wired.
|
|
2116
2823
|
|
|
2117
2824
|
It runs on both SSR boot paths, `voltro dev` and `voltro start`, with the cookies written on every response arm.
|
|
2825
|
+
|
|
2826
|
+
|
|
2827
|
+
|
|
2828
|
+
---
|
|
2829
|
+
|
|
2830
|
+
<!-- source: en/routing/intercepting-routes.md -->
|
|
2831
|
+
## Intercepting routes
|
|
2832
|
+
|
|
2833
|
+
_Modal-with-URL — a page that opens as an overlay above the still-mounted origin on soft navigation, renders standalone on a hard load, and closes on Back; declared with one page export, no directory grammar._
|
|
2834
|
+
|
|
2835
|
+
An intercepting route is the **modal-with-URL** pattern: navigating from a
|
|
2836
|
+
gallery to a photo opens the photo as an overlay *above the still-mounted
|
|
2837
|
+
gallery* — the URL is the photo's, sharing/reloading it shows the standalone
|
|
2838
|
+
photo page, and Back closes the overlay with the gallery exactly as you left
|
|
2839
|
+
it (typed filters, scroll position, mounted state — nothing re-runs).
|
|
2840
|
+
|
|
2841
|
+
## Declaring one
|
|
2842
|
+
|
|
2843
|
+
One export on the PAGE, no directory grammar:
|
|
2844
|
+
|
|
2845
|
+
```tsx
|
|
2846
|
+
// src/pages/photos/[id]/page.tsx
|
|
2847
|
+
export const renderMode = 'ssr' as const
|
|
2848
|
+
export const intercept = { from: '/photos' }
|
|
2849
|
+
|
|
2850
|
+
export const loader = ({ params }) => fetchPhoto(params.id)
|
|
2851
|
+
|
|
2852
|
+
export default function PhotoDetail() {
|
|
2853
|
+
const photo = useLoaderData<Photo>()
|
|
2854
|
+
return <figure>…</figure>
|
|
2855
|
+
}
|
|
2856
|
+
```
|
|
2857
|
+
|
|
2858
|
+
`from` names one or more ROUTE PATTERNS (`'/photos'`,
|
|
2859
|
+
`['/photos', '/albums/[id]']`). A soft navigation that arrives from one of
|
|
2860
|
+
them renders this page inside a native `<dialog>` overlay; a soft navigation
|
|
2861
|
+
from anywhere else — and every hard load — renders it standalone. That
|
|
2862
|
+
asymmetry is the feature: the same URL is a lightweight preview in context
|
|
2863
|
+
and a full page out of context.
|
|
2864
|
+
|
|
2865
|
+
## The three paths, precisely
|
|
2866
|
+
|
|
2867
|
+
- **Soft navigation from a `from` route** — the overlay opens. The origin
|
|
2868
|
+
page stays MOUNTED: its state, subscriptions and scroll position are
|
|
2869
|
+
untouched (the router keeps rendering it as the background tree; nothing
|
|
2870
|
+
unmounts, no loader re-runs). Only the modal page's own loader runs —
|
|
2871
|
+
layout loaders do not (the overlay renders the page alone above the
|
|
2872
|
+
background's chrome), and its `Pending` shows *inside* the overlay, never
|
|
2873
|
+
as a full-screen swap.
|
|
2874
|
+
- **Hard load / reload** — standalone, always. The server knows nothing of
|
|
2875
|
+
interception; it renders the page as itself, with its own `meta`. A reload
|
|
2876
|
+
of an open modal deliberately IGNORES the overlay state that survives in
|
|
2877
|
+
`history.state` — the server rendered standalone and hydration must match
|
|
2878
|
+
it.
|
|
2879
|
+
- **Back** — closes the overlay (it is a real history entry). Focus returns
|
|
2880
|
+
to the element that opened it (native `<dialog>` semantics), the body
|
|
2881
|
+
scroll lock releases, and the background — which never went anywhere —
|
|
2882
|
+
needs no restore.
|
|
2883
|
+
|
|
2884
|
+
Nested modals stack: a modal that soft-navigates to another intercepting
|
|
2885
|
+
route (its `from` naming the modal's pattern) opens above it, and Back
|
|
2886
|
+
closes only the topmost.
|
|
2887
|
+
|
|
2888
|
+
## Navigation blockers hold the Back gesture
|
|
2889
|
+
|
|
2890
|
+
`useBlocker` now guards **popstate** too. Back is a modal's primary close
|
|
2891
|
+
gesture, and before this it bypassed every blocker: the browser moves the
|
|
2892
|
+
URL first, so the router *reverts* the move (`history.go(-delta)`) when a
|
|
2893
|
+
blocker holds it and surfaces `retry`/`reset` as for any blocked navigation.
|
|
2894
|
+
ESC inside the overlay routes through the same path — a dirty form holds
|
|
2895
|
+
both. This is a behaviour CHANGE of a documented hook: a blocker that used
|
|
2896
|
+
to be silently skipped on Back now fires.
|
|
2897
|
+
|
|
2898
|
+
## Two trees, two query strings
|
|
2899
|
+
|
|
2900
|
+
While an overlay is open the URL carries the MODAL's query. Each tree reads
|
|
2901
|
+
its own: `useSearchParams` in the background keeps decoding the background's
|
|
2902
|
+
query (an open modal cannot reset a filter), and `useSetSearchParams` writes
|
|
2903
|
+
to the calling tree's URL — a background setter never writes onto the
|
|
2904
|
+
modal's URL, and a modal setter (a `?zoom=` tweak) replaces without tearing
|
|
2905
|
+
down its own background.
|
|
2906
|
+
|
|
2907
|
+
## What it is NOT
|
|
2908
|
+
|
|
2909
|
+
- **Parallel `@slot` routes are a declared non-goal.** Next.js pairs
|
|
2910
|
+
interception with independent slot navigation (`@team`/`@analytics`,
|
|
2911
|
+
per-slot `loading.tsx`/`default.tsx`). Here, dashboard split panes are
|
|
2912
|
+
COMPONENTS in a layout, not a routing concept — this page delivers the
|
|
2913
|
+
intercepting/modal half only.
|
|
2914
|
+
- **Islands / zero-JS pages don't intercept.** Interception is a client
|
|
2915
|
+
router behaviour; `interactive: 'islands'` pages have no SPA navigation
|
|
2916
|
+
and `'none'` ships no JS. Full-hydration pages only.
|
|
2917
|
+
- **Overlays do not View-Transition.** Route transitions apply to route
|
|
2918
|
+
swaps; an overlay opening is a layer change, not a page change (see
|
|
2919
|
+
[Navigation](/docs/routing/navigation)).
|
|
2920
|
+
|
|
2921
|
+
## Chrome, focus, and styling
|
|
2922
|
+
|
|
2923
|
+
The framework renders the overlay as a native `<dialog>` opened with
|
|
2924
|
+
`showModal()` — focus trap, `::backdrop` and focus restoration come from the
|
|
2925
|
+
platform, and the body scroll is locked while open. It is deliberately
|
|
2926
|
+
unstyled: target `dialog[data-vweb-overlay]` (and `::backdrop`) from your
|
|
2927
|
+
CSS or a kit. The route announcer announces the modal's title on open, as it
|
|
2928
|
+
would any navigation.
|
|
2929
|
+
|
|
2930
|
+
## Locale-prefixed apps
|
|
2931
|
+
|
|
2932
|
+
`from` matches route patterns literally, so a `[locale]` mirror declares its
|
|
2933
|
+
own: `/de/photos/[id]`'s page re-exports the base page and sets
|
|
2934
|
+
`intercept: { from: '/[locale]/photos' }` — same pattern as the documented
|
|
2935
|
+
searchParams schema re-export.
|