@voltro/cli 0.52.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 +229 -0
- package/THIRD-PARTY-NOTICES.md +8311 -3318
- package/dist/{apiBuild-CSFI8QGq.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-COmqc2cB.js → checkCommand-C5elt0tW.js} +6 -6
- package/dist/{checkCommand-2SbqzukH.js → checkCommand-D2ZduVlh.js} +1 -1
- package/dist/codegen-BWpt3VgF.js +2 -0
- package/dist/{codegen-VF479Cnb.js → codegen-FEk8AZHb.js} +2 -2
- package/dist/{codegenCommand-DCdG2JN-.js → codegenCommand-BOiWQ5hz.js} +4 -4
- package/dist/{codemodRunner-r7J9lIa7.js → codemodRunner-BjtB2lq6.js} +109 -11
- package/dist/{commands-Cc_nV8WI.js → commands-DyxAmhP0.js} +30 -30
- package/dist/{dashboardCommand-C-gKvwqh.js → dashboardCommand-BdKTyT13.js} +5 -5
- package/dist/{dataCommand-BgpBHnlB.js → dataCommand-Bab9X7s8.js} +2 -2
- package/dist/{dbCommand-sHedr-NJ.js → dbCommand-06O2finM.js} +277 -236
- package/dist/dbCommand-B1EXBC6f.js +2 -0
- package/dist/{dev-CRHoCEiy.js → dev-C6LGF4iY.js} +3006 -2426
- package/dist/{dev--A3nsxA3.js → dev-GjJWAYo2.js} +1 -1
- package/dist/doctorCommand-B0hX0tdz.js +2 -0
- package/dist/{doctorCommand-DtfJ3FA6.js → doctorCommand-etMkflRc.js} +101 -69
- package/dist/{dormancyCommand-Drn7o0No.js → dormancyCommand-UwZ1AZzB.js} +1 -1
- package/dist/{embeddingsCommand-Z-jO1fWN.js → embeddingsCommand-C70zWHwo.js} +1 -1
- package/dist/{envCommand-D4gCrrTZ.js → envCommand-dSyKvRkM.js} +18 -18
- package/dist/{evolveCommand-CMROeKeA.js → evolveCommand-CG0_ebO5.js} +2 -2
- package/dist/fileConventions-DASGEmj-.js +35 -0
- package/dist/{fileTaxonomy-DvDUV9wq.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-w-XnLa3q.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-DXM868o_.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/manifestBuild-C4-J1-m_.js +2 -0
- package/dist/{manifestBuild-CuU1VrSm.js → manifestBuild-Cqgsx2bM.js} +1 -1
- package/dist/{metaCommands-C6RFmF1r.js → metaCommands-Cn2oboG4.js} +8 -2
- package/dist/{migrate-D0F-eTlK.js → migrate-Cko9rswM.js} +2 -2
- package/dist/{pageConvention-CzUiSbtU.js → pageConvention-C938S8oC.js} +1 -1
- package/dist/{privacyCommand-DGdopOI6.js → privacyCommand-DWTQMC6R.js} +2 -2
- package/dist/{probeCommand-C9gazU0H.js → probeCommand-DkGGLknv.js} +83 -24
- package/dist/{renderModeScan-CcH2X1_D.js → renderModeScan-CUbOeOAg.js} +3 -1
- package/dist/{renderProfile-Ck32Fzxr.js → renderProfile-CskIgAfn.js} +2 -2
- package/dist/{runtimeTrace-BPQyCmC5.js → runtimeTrace-c0APJz7E.js} +1 -1
- package/dist/{sdkgen-Se88ifTd.js → sdkgen-BiQCgIEr.js} +1 -1
- package/dist/{serveCommand-DkP3OT0W.js → serveCommand-CueKQgzl.js} +869 -805
- 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-BwNEDlSU.js → staticCommand-xlSL-IWk.js} +1 -1
- package/dist/{test-f3amja6a.js → test-BWPQcRoB.js} +1 -1
- package/dist/updateCommand-Bqql_rsQ.js +2 -0
- package/dist/{updateCommand-BMk2e4ky.js → updateCommand-C_8I8Rzo.js} +139 -115
- package/dist/webDev-C7jWJ5dX.js +2 -0
- package/dist/{webDev-BgWL9gKV.js → webDev-oczpugbx.js} +1455 -922
- package/dist/{webhooksCommand-CoIO3jbj.js → webhooksCommand-4SVPDjKg.js} +1 -1
- package/package.json +60 -19
- package/templates/AGENTS.md +5 -3
- package/templates/agent-docs/_index.md +5 -3
- package/templates/agent-docs/_manifest.json +20 -4
- package/templates/agent-docs/ai.md +2 -2
- package/templates/agent-docs/authentication.md +1 -1
- package/templates/agent-docs/cli.md +2 -2
- package/templates/agent-docs/configuration.md +17 -0
- package/templates/agent-docs/data.md +470 -26
- 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 +1 -1
- package/templates/agent-docs/database/seedsdialects.md +1 -1
- package/templates/agent-docs/database/transactions.md +3 -3
- package/templates/agent-docs/deployment.md +8 -0
- package/templates/agent-docs/internationalization.md +2 -2
- package/templates/agent-docs/introduction.md +7 -1
- package/templates/agent-docs/local-first-mobile.md +102 -4
- package/templates/agent-docs/observability.md +2 -0
- package/templates/agent-docs/plugins/auth.md +1 -1
- 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.md +11 -7
- package/templates/agent-docs/reference.md +16 -4
- package/templates/agent-docs/routing.md +527 -3
- package/templates/agent-docs/schema-driven-ui.md +214 -3
- package/templates/agent-docs/security.md +3 -3
- package/templates/agent-docs/whats-new.md +160 -66
- package/templates/apps/api-ai/package.json +6 -6
- 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 -9
- 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-row-history/package.json +8 -8
- package/templates/apps/api-saas/package.json +10 -10
- 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-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 -6
- 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 -6
- 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/apiBuild-BYBpL7Pz.js +0 -2
- package/dist/build-CPgcMQug.js +0 -793
- package/dist/codegen-CctkDO-1.js +0 -2
- package/dist/dbCommand-DNb6yeOG.js +0 -2
- package/dist/doctorCommand-CqoWA2p5.js +0 -2
- package/dist/fileConventions-DOqD3lPS.js +0 -34
- package/dist/frameworkTableAssembly-C6ETawPR.js +0 -2
- package/dist/inspect-CuGDYES0.js +0 -2
- package/dist/manifestBuild-CPjhvM62.js +0 -2
- package/dist/serveCommand-DLc-BznW.js +0 -2
- package/dist/start-DfL3fOiN.js +0 -3
- package/dist/start-jw89Xbqy.js +0 -1339
- package/dist/updateCommand-5gFVfK5q.js +0 -2
- package/dist/webDev-CZbTsDcH.js +0 -2
- 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
|
@@ -599,7 +599,7 @@ else — React's render loop still runs to completion in one pass, so a slow
|
|
|
599
599
|
render is still a slow render. The lever that matters is `defer()`: it puts a
|
|
600
600
|
real `<Suspense>` boundary in the tree, which is what lets the server return to
|
|
601
601
|
the event loop while a slow value is still pending. See
|
|
602
|
-
[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).
|
|
603
603
|
|
|
604
604
|
Measured against a real server rendering a page with one 400ms deferred field:
|
|
605
605
|
first body byte at 7ms, the deferred chunk at 408ms — and a probe firing every
|
|
@@ -614,8 +614,11 @@ Two consequences worth knowing:
|
|
|
614
614
|
framework hands the URL to React instead, so it goes out `async` at the end
|
|
615
615
|
of the shell and hydration starts immediately.
|
|
616
616
|
- **`isr` and `static` are still buffered**, because both produce a stored
|
|
617
|
-
artefact rather than a response.
|
|
618
|
-
|
|
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.
|
|
619
622
|
|
|
620
623
|
Apps do not call the renderer directly. If you are building your own server on
|
|
621
624
|
top of `@voltro/web/ssr`, `renderPageToStream` is the entry point — it takes the
|
|
@@ -671,6 +674,69 @@ the first visitor's data for everyone. This applies identically under
|
|
|
671
674
|
silently serve shared HTML in production. A page whose loader needs the signed-in
|
|
672
675
|
subject belongs on `renderMode: 'ssr'`.
|
|
673
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
|
+
|
|
674
740
|
## Tenant-aware ISR
|
|
675
741
|
|
|
676
742
|
For multi-tenant ISR (each tenant gets its own cache entry):
|
|
@@ -1474,6 +1540,55 @@ They're plain async functions. They can't call `useSubscription`, `useState`, et
|
|
|
1474
1540
|
|
|
1475
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.
|
|
1476
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
|
+
|
|
1477
1592
|
## Anti-patterns
|
|
1478
1593
|
|
|
1479
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.
|
|
@@ -2230,6 +2345,304 @@ the `@source` is misconfigured.
|
|
|
2230
2345
|
|
|
2231
2346
|
|
|
2232
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
|
+
|
|
2233
2646
|
---
|
|
2234
2647
|
|
|
2235
2648
|
<!-- source: en/routing/middleware.md -->
|
|
@@ -2409,3 +2822,114 @@ The file is loaded **once per boot** — it is app code with a stable identity,
|
|
|
2409
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.
|
|
2410
2823
|
|
|
2411
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.
|