@voltro/web 0.51.0 → 0.52.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 CHANGED
@@ -39,6 +39,133 @@ _Changes staged for the next release accumulate here (rolled up from
39
39
 
40
40
  ---
41
41
 
42
+ ## [0.52.0] — 2026-08-25
43
+
44
+ ### ⚠ BREAKING
45
+
46
+ - **@voltro/protocol, @voltro/runtime, @voltro/cli, @voltro/web, @voltro/plugin-storage, @voltro/plugin-ratelimit** — The HTTP surface is complete — seven gaps closed, each proven against the real listener.
47
+
48
+ **Binary byte streams.** REST handlers return `bytes(stream, { contentType, contentLength?, contentDisposition? })` (lazy thunk form defers opening the source); plugin routes return `byteStream`. Piped, never buffered (a 256 MiB export streams with bounded heap), never compressed. Idempotency × stream is DECIDED: `streaming: true` on an idempotent method with an idempotency binding is a mount error (a stream cannot cache a replayable body — retries would 409 until the TTL); an undeclared stream releases its claim at return. Storage's full-object download rides the generic path now.
49
+
50
+ **Negotiated compression + conditional GET.** brotli/gzip negotiated with a compressible-type allowlist, 1 KiB threshold, `Vary: Accept-Encoding` always on compressible types — on the api's buffered responses AND `voltro start`'s HTML (`http.compression.{enabled,minBytes}`). The BREACH position is structural: `POST /rpc` responses are NEVER compressed. ISR keeps ONE uncompressed entry and compresses per hit. `voltro start` answers `If-None-Match` with 304 (weak md5 tags over the uncompressed body); REST routes can declare `etag: true` (GET, weak SHA-1 content tag).
51
+
52
+ **Raw WebSocket gateways.** `defineWebSocket({ path, auth, onConnection })` in a `*.ws.ts` file — for FOREIGN protocols (a Yjs provider, a device fleet); app realtime stays the subscription protocol. `auth` is required with no default: `'subject'` runs the same chain as rpc/SSR BEFORE the upgrade (401 while it is still http) and binds the connection to the credential's expiry (close code 4001); `'public'` is a written-down decision. Every gateway path joins the upgrade origin guard (cross-site WebSocket hijacking → 403). Teardown at construction; plain GET → 426; duplicate paths refuse the boot.
53
+
54
+ **Body caps everywhere.** The 8 MiB cap used to guard only `/rpc`; plugin routes read uncapped and webhooks read uncapped AND UTF-8-round-tripped (corrupting binary bodies — fixed, proven byte-for-byte). `http.maxBodyBytes` (env `VOLTRO_MAX_BODY_BYTES`), per-route overrides on `defineRestRoute` and webhook handlers; a shared path takes its group's widest override. 413 for both request shapes.
55
+
56
+ **Full method unions.** PATCH/HEAD/OPTIONS are first-class on plugin and REST routes; HEAD is admitted wherever GET is (RFC 9110) with the transport dropping the body.
57
+
58
+ **BREAKING — the interceptor chain is fail-closed.** A throwing `onHttpRequest` interceptor is a 500 + a log line now; it used to be swallowed, which let a crashed security gate silently stop guarding. The manual codemod tells interceptor authors where the decision lives: propagate (a gate) or catch-and-degrade-loudly (protection with a dependency) — plugin-ratelimit's httpShield now does the latter, so a Redis outage cannot become a self-inflicted API outage. No app-authored call sites change shape, hence `apiSurface: compatible` — the break is the error POLARITY of one plugin-author hook, carried by the manual note.
59
+
60
+ **Middleware response headers + CSP nonce.** `middleware.ts` can return `responseHeaders` (applied on every render response shape, both boot paths; prerendered static files are the documented proxy-side limit) and `cspNonce` — the framework stamps every script tag of that render (React's own included) while the policy header stays the middleware's. `isr` + `cspNonce` is refused loudly: a cached nonce is a lie the browser enforces.
61
+
62
+ Deliberate limits: no multipart parser on REST/webhook routes (the storage upload routes are the sanctioned file path); static-file response headers belong at the proxy.
63
+ - **@voltro/plugin-row-history, @voltro/cli** — `@voltro/plugin-versioning` is renamed to `@voltro/plugin-row-history` — the name now says what it does.
64
+
65
+ The old name collided head-on with API versioning (versioned REST routes, `/v1` → `/v2`, sunset flow), which `defineRestRoute` now supports as a first-class `version:` field. What this plugin does is row history + time travel (`rowHistory` / `rowAsOf` / `restoreAsOf` / `diffVersions` over `_voltro_row_history`); every comparison table that filed it under API versioning was reading the name, not the feature.
66
+
67
+ Renamed with it: the factory (`versioningPlugin` → `rowHistoryPlugin`), the options type (`VersioningPluginOptions` → `RowHistoryPluginOptions`), and the default instance alias (`versioning` → `row-history` — the inspect endpoint path and boot-log lines; an explicit `alias:` you passed is untouched). The table name (`_voltro_row_history`) and `VOLTRO_ROW_HISTORY_TTL_HOURS` already carried the new name: no data movement, no env change, no migration.
68
+
69
+ The codemod rewrites imports, the factory call sites and the options-type references, and prints the one step it cannot do — swapping the dependency in package.json.
70
+
71
+ ### Added
72
+
73
+ - **@voltro/cli** — `voltro doctor` now reports every declared `@voltro/*` dependency with no import site — the residue a migration off a framework package leaves in `package.json`, where it keeps getting installed, walked by `voltro update`, and read as evidence the package is in use, its breaking-change notes included.
74
+
75
+ Scoped to `@voltro/*` deliberately (third-party packages have too many legitimate no-import shapes), and three states are distinguished and printed: exempt by name with a reason (`@voltro/cli` is the binary, `@voltro/devtools` is mounted by `voltro dev`, `@voltro/sql-*` drivers are loaded from config); not-measurable-yet for `@voltro/client`/`@voltro/web` on a tree where codegen has never run (a missing measurement, not a dead dependency); and unimported — advisory, never fatal. A mention in a comment or an error string does not count as an import, and a commented-out import counts least of all: it is the artefact the rule exists to see past. Also in `voltro doctor --json` under `unimportedDeps`, `null` when no `package.json` could be read.
76
+ - **@voltro/web, @voltro/cli** — Islands now save real bytes — every `interactive: 'islands'` page gets its OWN browser entry.
77
+
78
+ Measured on the reference fixture (pinned in `bundle-budget.json`, 2026-08-25): an islands page's first load is **59.6 KB gz** against **181.9 KB gz** for a fully hydrated page — react + the island runtime + that page's islands, no router, no Effect runtime, no subscription cache. The bundle-budget gate pins a hard <70 KB bound AND the ratio (<50 % of a full page), so a regression that re-couples the entries fails loudly.
79
+
80
+ - **`@voltro/web/islands`** is the new react-only subpath — `island()` and the hydration runtime import only react + react-dom/client. Importing the `@voltro/web` BARREL (or `@voltro/i18n`) anywhere in an island's import graph is now a BUILD error naming file and specifier: an island hydrates provider-less, router hooks and `useT()` throw there anyway, and the barrel would pull the Effect runtime into the slim entry. - **The build finds each page's islands** through its relative import graph (transitively, through components in between) and emits one shell + entry per islands page. `interactive` must be a source LITERAL to select the slim entry — a computed value ships the full entry as before, and the build says so. - **All three paths**: `voltro build` (SSG renders into the per-page shell, main-shell stylesheets folded in), `voltro start` (ssr/isr islands routes serve their shell), `voltro dev` (same mechanism on demand — violations fire in dev, not first in CI). Fixed on the way: a STATIC islands page fell through dev's render gate to the SPA fallback and loaded the full app entry — dev now server-renders it like production. - **Framework islands**: an island importing `@voltro/client` (`useSubscription`, …) is detected — that page's entry boots the rpc client and wraps each island root in `VoltroRuntimeProvider`, so the island receives live data. Presentational pages never pay for the client core. - **`hydrate: 'only'`** (Astro's `client:only`): the server renders an empty placeholder — a browser-only lib touching `window` in render no longer crashes the SSR pass — and the client mounts fresh with `createRoot`. - **Island props are declared lossy where they are**: props cross an HTML attribute as JSON, so a `Date` arrives as an ISO string and `Map`/`Set`/ functions do not arrive at all — dev warns naming the island and prop. - The per-island `manualChunks` rule is gone: with per-page entries as additional rollup inputs it MERGED the shared react modules into the island group (a second-React-instance shape); per-island chunks were also redundant — the entry already scopes to the page's islands, and they were statically preloaded anyway.
81
+
82
+ Limits: an islands page reached via SPA navigation from a full page runs in the already-loaded app bundle (the saving applies to hard loads of the islands page); a page's islands share one entry (hydrate strategies control WHEN each hydrates, not when it downloads).
83
+
84
+ Why the golden churn is compatible: `hydrate: 'only'` widens a union, `hydrateIslandsOnPage` gains an optional options argument, and the subpath is a new export.
85
+ - **@voltro/ui, @voltro/client, @voltro/web, @voltro/cli** — Forms without JavaScript — `<AutoForm>` on a server-rendered page now works with JS disabled, end to end.
86
+
87
+ The form always renders `action="/form/<mutationTag>"` + `method="post"`; with JS alive, `onSubmit` intercepts exactly as before (optimistic rpc path unchanged). The `/form/*` endpoint is mounted by the WEB listener on BOTH boot paths (`voltro dev` and `voltro start`, one shared builder):
88
+
89
+ - **Origin-checked at the door** with the same `classifyRequestOrigin` the api's rpc listener runs — a cross-origin form POST is a 403 before a byte of the body is parsed. (The server-side forward reaches the api as a no-browser-origin request, so the web listener's check is the one that guards this surface.) - **One validation path.** The urlencoded body maps through the schema-driven `formDataToInput` (checkbox present/absent → true/false, `''` on number/date omits the field — never a silent 0 —, repeated keys → arrays, non-numeric strings pass through RAW so the decode fails honestly instead of minting NaN, unknown keys dropped) and validates with the SAME `validateFields` the client-side submit runs — byte-identical field errors. - **PRG on success**: 303 back to the submitting page, or to `<AutoForm redirectTo>` (same-origin relative paths only — anything else is refused, a hidden field must not become an open redirect). Reloading the redirected-to page cannot resubmit. - **422 re-render on validation failure**: the referring page renders in the same response with field errors + submitted values in the SAME error UI (`role="alert"`, aria unchanged), `cache-control: no-store`, bypassing the ISR cache in both directions. An rpc refusal after valid input (guard, server error) renders as a form-level error. The flash also embeds as a JSON script, so a page whose bundle arrives late hydrates to the identical state. - **Valid submits forward server-side over `POST /rpc`** with the request's cookie — auth middleware, guards and the rpc interceptor chain run identically to every other mutation.
90
+
91
+ New `<AutoForm>` props: `formKey` (several forms per page — the 422 re-render re-fills only the submitted one), `redirectTo`, and `action={false}` for purely static deploys where `/form/*` does not exist. Headless: `useFormBinding` gains `flash` + `formError`, `@voltro/web` gains `useFormFlash(formKey)`.
92
+
93
+ Also fixed on the way: a required `Schema.Boolean` field with no default used to block the JS submit as "missing" while its checkbox rendered visibly unchecked — boolean fields now seed `false`, agreeing with what the user sees (and with the no-JS mapping).
94
+
95
+ Deliberate limits: the no-JS error re-render needs an `ssr`/`isr` page (a static page cannot be re-rendered with request state; a minimal error page is the fallback); a purely static deploy has no `/form/*` endpoint (use `action={false}`); file uploads stay JS-only (multipart → 415). On an ssr page pass `schema` explicitly — descriptor resolution is a client-runtime feature and the SSR render would otherwise show no fields.
96
+
97
+ Bundle note: the `serverContext` chunk group is renamed `serverRequest` by the context's move to @voltro/client — identical 190 B gz, re-pinned in `bundle-budget.json` (fresh full measurement 2026-08-25: firstLoad 185,494 B gz, was 185,309 — +185 B from the form-flash read in the request context).
98
+
99
+ Why the golden churn is compatible: every addition is a new export or an optional prop; `FormBinding.formError` is a new member of the hook's RETURN type (nothing in the public API accepts a caller-built `FormBinding`), and `ServerRequestContextValue.formFlash` is optional.
100
+ - **@voltro/runtime, @voltro/cli, @voltro/cms, @voltro/plugin-broadcast** — On-demand ISR revalidation — the third invalidation axis next to time (`revalidate`) and CDC (`cacheInvalidatesOn`). Server code in the api process calls `revalidatePath('/blog/[slug]')` / `revalidateTable('posts')` / `revalidateTag('pricing')` (exported from `@voltro/runtime`; callable from mutations, actions, webhook receivers and REST routes) and the matching ISR cache entries fall on EVERY `voltro start` replica — including on dialects with no CDC at all, which is the case this exists for.
101
+
102
+ Transport is dialect-shaped, either or both: on postgres a `pg_notify` rides the SAME LISTEN connection the CDC invalidator already holds (no broker needed); everywhere else the broadcast broker carries it (`BROADCAST_URL` on both deployments; channel namespaced by `VOLTRO_BROADCAST_NAMESPACE` — deliberately env-derived, because this channel pairs an api with its WEB app and no shared name is derivable). A web process with isr routes and neither transport warns loudly at boot; under `voltro dev` the calls are documented debug-logged no-ops. Tags share ONE mechanism with tables — `cacheInvalidatesOn` accepts free strings, so `revalidateTag` is the same sink under another name.
103
+
104
+ Correctness edges built in: `revalidatePath` against a `static` route is a NAMED error on the web process (never a silent no-op); a purge landing while an SWR refresh or miss fill renders is guarded by a per-key generation counter on BOTH cache backends — the pre-purge page cannot be written back with a full TTL, and a refused write also suppresses the postgres backend's fire-and-forget upsert so no replica resurrects a deleted row. A content type declares `revalidate: { paths, tags }` and `publish()`/`unpublish()` fire them after commit.
105
+
106
+ Proven end-to-end (`scripts/revalidate-e2e.mjs`): 1 api + 2 `voltro start` replicas behind Redis (warm → purge → both fresh, with a negative control), the sqlite dialect leg, and a broker-less postgres leg where the NOTIFY line alone carries the purge to a LISTEN-only replica.
107
+
108
+ `apiSurface: compatible` — additive only: the new `@voltro/runtime` revalidation exports, an optional `revalidate` on `ContentTypeSpec`, the optional `onRevalidate`/`revalidateChannel` on the CDC invalidator options, a widened `BroadcastChannelKind`, and `IsrCache.set`'s new optional generation guard (plus `generation()`).
109
+ - **@voltro/protocol, @voltro/plugin-openapi** — `defineRestRoute` takes an opt-in `version:` — versioned REST APIs with a sunset flow.
110
+
111
+ `version: 'v2'` + `path: '/customers'` mounts the route at `/v2/customers`, the same `/vN/` convention the `publicApi:` projection and the built-in `/v1/api-keys` surface already use. The path is normalised ONCE at definition time, so every consumer — the mount, path-param matching, the idempotency scope, tracing spans, the OpenAPI generator — sees the mounted path and none can disagree.
112
+
113
+ Deliberate edges:
114
+
115
+ - **Opt-in, no auto-prefix.** A route without `version:` keeps its literal path untouched — an automatic prefix would silently move every deployed route. A path that already starts with `/vN/` AND declares `version:` is refused loudly at definition (both spellings at once is never what the author meant). - **Two versions are two descriptors.** The old version is ordinary code — visible, testable, deletable — carrying `deprecated:` (the replacement pointer) and `sunset:` (the date it starts answering `410 Gone`; the 410 body now also names which `version` died). - **One OpenAPI document for every version.** The `/vN/` prefix already separates the paths; each versioned operation is grouped under a version tag and carries `x-voltro-api-version` for tooling. No `?version=` filtered spec — a second document shape for information the paths already state. - The rpc socket stays outside URL versioning on purpose: the generated client is versioned with the server it was generated from. A stale browser tab runs the previous client until reload — that skew window exists and is documented, not solved by URLs.
116
+ - **@voltro/web, @voltro/cli** — Opt-in View Transitions for SPA navigations. `router.viewTransitions: true` in a web `app.config.ts` runs every route swap — `<Link>` clicks, `navigate(...)`, back/forward — through `document.startViewTransition`; individual navigations override the default in either direction with `navigate(to, { transition })` / `<Link transition>`.
117
+
118
+ The visual swap is the router's deferred-navigation commit, flushed synchronously inside the transition callback — by that point the target's lazy chunk and loaders have settled, so the flushed tree renders with data in hand. Three deliberate behaviors: `defer()` fields (and an explicit `Pending` skeleton's settled content) resolve AFTER the transition as ordinary updates, never a second animation; navigating while a transition is animating skips the running one (last navigation wins, nothing queues); overlay/dialog state changes never trigger one — a root snapshot would cross-fade the whole viewport for a one-layer change.
119
+
120
+ Fallback is exact: a browser without the API, and any user with `prefers-reduced-motion: reduce`, gets today's untransitioned swap — same timing, nothing to feature-detect. Styling is plain `::view-transition-*` CSS (no animation DSL); cross-document transitions for static/MPA pages are a one-line `@view-transition` CSS opt-in with no framework involvement.
121
+
122
+ Proven in a real chromium (`scripts/browser-view-transitions.mjs`): called on navigation, silent under reduced-motion, harmless with the API deleted, one transition across a `defer()` commit, rapid double-navigation lands on the last target — plus the jsdom wiring suite and the generated-entry flag check shared by all three web boot paths.
123
+
124
+ `apiSurface: compatible` — additive only: a new optional `viewTransitions` on `RouterProps`, optional `transition` on `NavigateOptions`/`LinkProps`, and the optional `router` block on the web app config.
125
+ - **@voltro/web, @voltro/cli** — Schema-typed search params — the query-string half of the URL is now part of the type graph.
126
+
127
+ A page declares its contract once:
128
+
129
+ ```ts
130
+ export const searchParams = Schema.Struct({
131
+ q: Schema.optionalWith(Schema.String, { default: () => '' }),
132
+ page: Schema.optionalWith(Schema.NumberFromString, { default: () => 1 }),
133
+ })
134
+ ```
135
+
136
+ and gets, end to end:
137
+
138
+ - **Typed reads** — `useSearchParams(searchParams)` returns the decoded, defaulted shape, SSR-aware; the zero-arg call keeps returning the raw `URLSearchParams`. Decoding is TOTAL: an invalid query falls back to the schema's defaults instead of crashing a render; only a schema that cannot even decode `{}` (a required field with no default) throws, naming the fix. - **Typed links** — the generated `routes` builder brands the route's URL with the schema's shape through a TYPE-ONLY page import (zero value edges: code-splitting is untouched, pinned by test), and `withQuery` type-checks against it — a misspelt key or wrong value type is a compile error. The link-side encode is canonical and schema-free (strings/numbers/booleans, arrays as repeated keys); a roundtrip test pins that it produces exactly what the schema's decode accepts. Deliberately ONE generic signature rather than overloads: with overloads, a wrong key would silently fall through to the permissive untyped form and the compile error would never fire. - **Typed writes** — `useSetSearchParams(searchParams)` returns the typed setter: its object form replaces the query (same semantics as the untyped form), and its updater form receives the CURRENT decoded params, so keeping `?filter` across a page flip is one explicit spread — `setParams((p) => ({ ...p, page: p.page + 1 }))` — instead of a hand-rolled merge. - **A fail-closed isr gate** — `renderMode: 'isr'` plus a `searchParams` export is refused at boot: the isr cache is keyed by path (+tenant+locale), not query, so the first variant would be cached for every query — and the gate catches the re-exported spelling (`export { searchParams } from …`, the mirror-route pattern) too, not only the local declaration. - **A doctor rule** — a page that exports the schema but keeps reading the query with zero-arg `useSearchParams()` is flagged with the typed spelling.
139
+
140
+ Deliberate limits: array fields decode a single occurrence as a one-element array (link shape stays stable); `siblingApps` routes stay untyped (their schemas live in a foreign compile graph); `static` pages see the defaults at build time and decode live on the client.
141
+
142
+ Why the golden churn is compatible: `VoltroRouteUrl` gains a type parameter with a DEFAULT (`<TSearch = unknown>`), so every existing bare `VoltroRouteUrl` spelling still compiles, and the new brand member is an OPTIONAL phantom property of type `unknown` — assignability in both directions is unchanged. `withQuery`'s parameter for the untyped case is strictly WIDER than before (adds `boolean` and array values); no call that compiled stops compiling.
143
+
144
+ ### Fixed
145
+
146
+ - **@voltro/cli** — `voltro agents-md`'s copied `agent-docs/` mirror no longer keeps orphaned modules across a re-seed.
147
+
148
+ The copy fallback (projects where `@voltro/cli` isn't resolvable) merged into an existing `agent-docs/` directory: when a module was renamed upstream (`plugins/versioning.md` → `plugins/row-history.md`), a `--force` re-seed brought the new file and left the old one sitting beside it — a stale generated doc teaching a package name that no longer exists, which is exactly the claim≠code drift the guide exists to prevent. The mirror is wholly framework-owned, so a re-seed now replaces it (the generator prunes its own output dir the same way).
149
+ - **@voltro/cli** — `voltro check`'s observed-diff footer now accounts for every declared procedure, and no result line states a verdict without the count it is a verdict about.
150
+
151
+ Two defects, both found by readers doing arithmetic on the output:
152
+
153
+ - The diff computes TWO kinds of blindness — a procedure that never ran, and one that ran with no table access recorded — and the printer named only the first. The printed counts came out short of the total, with no way to tell an unshown category from a defect in `check` itself. The buckets are now derived from the result type, so a third one cannot be added without the label map failing to compile, and a partition that does not close prints as such instead of quietly under-counting. - `no declared/observed mismatches` was a bare verdict sitting under its denominator, and was quotable — and quoted — without it, as a clean bill of health for a surface where almost nothing had been exercised. Every result line now carries its scope (`no declared/observed mismatch among the 12 that ran`), and at zero coverage the section reports the ABSENCE of a comparison (`nothing was compared — a declaration is only checked against a procedure that RAN`) rather than the absence of findings. The two are different facts and only one of them is evidence.
154
+ - **@voltro/cli** — An `isr` render no longer sees the requesting visitor's credentials — in `voltro dev` and `voltro start` alike.
155
+
156
+ An isr page's HTML is cached under tenant+locale and served to every visitor inside the revalidate window, but the render itself ran with the FULL request: loaders received the session cookie, and `ctx.query` was bound to it. A loader that read subject-scoped data on an isr page therefore cached the first visitor's data and served it to everyone — cache poisoning by construction.
157
+
158
+ The render boundary is fail-closed now: before an isr render runs, the cookie jar (except `voltro:locale`), the `authorization` header and every `x-voltro-*` header are stripped, for the loaders, `ctx.query` AND the `useServerRequest()` snapshot. What survives is exactly what the cache key and locale resolution read: `x-tenant`, `accept-language`, and the locale cookie — so a `de` visitor's fill still lands under the `de` key. One shared helper (`isrCredentialStrip.ts`), called by both boot paths, so dev renders isr anonymously exactly as production does — a page can no longer look personalised in dev and silently serve shared HTML in production.
159
+
160
+ Behavioural consequence, on purpose: a subject-reading loader on an isr page now gets the anonymous answer. A page whose loader needs the signed-in subject belongs on `renderMode: 'ssr'`.
161
+ - **@voltro/cli, @voltro/runtime** — **`voltro serve` registers plugin rpc routes again — every plugin-contributed procedure answered `Unknown request tag` in production while `voltro dev` registered all of them.** The serve path mirrored dev's plugin-route block by hand and mirrored exactly half of it: the collision check ran (so nothing warned) and the returned routes were discarded as a bare expression statement, never reaching the buckets the rpc registry is built from. Every plugin route was indistinguishable from a tag that never existed — `useUpload`'s storage tags, presence, every inspect-less plugin rpc — while the app's own procedures answered normally, so the registry looked alive.
162
+
163
+ Three changes, each aimed at the way this stayed invisible:
164
+
165
+ - Both boot paths now call ONE shared builder (`mergePluginRoutesInto`) whose buckets are required parameters — a returned list can be discarded by a statement that typechecks; a function you cannot call without handing it the sinks cannot have its effect dropped. A reachability test drives a real socket with a plugin tag and an invented-tag control, and a source pin keeps the helper pair from being reassembled by hand in either path. - The boot line `plugin routes registered` prints WHENEVER plugins are installed, count included — zero is a finding, and silence is how this shipped. `voltro check` against a running server now also diffs source-declared tags against the live registry (`declared vs live:`), and its offline manifest includes plugin routes, which it previously did not. - Every `Defect` frame the server sends is now also a server log line (`rpc defect sent to client`, ws and http rpc). The defect string used to exist only inside the WebSocket frame — visible in whoever's browser console, invisible to the operator whose server produced it.
166
+
167
+ ---
168
+
42
169
  ## [0.51.0] — 2026-08-24
43
170
 
44
171
  ### ⚠ BREAKING
@@ -1,4 +1,4 @@
1
- import { t as e } from "./globalContext-d4A-ugDg.js";
1
+ import { t as e } from "./globalContext-AuflFFmy.js";
2
2
  import { useCallback as t, useContext as n, useMemo as r, useRef as i, useState as a } from "react";
3
3
  import { Fragment as o, jsx as s, jsxs as c } from "react/jsx-runtime";
4
4
  //#region src/statusBus.ts
@@ -1,6 +1,6 @@
1
- import { t as e } from "./globalContext-d4A-ugDg.js";
1
+ import { t as e } from "./globalContext-AuflFFmy.js";
2
2
  import { AppClientsContext as t } from "./hooks.js";
3
- import { c as n, l as r, r as i, s as a, u as o } from "./defaultFallbacks-B4Ak-R6R.js";
3
+ import { c as n, l as r, r as i, s as a, u as o } from "./defaultFallbacks-SQ45vAhz.js";
4
4
  import { useCallback as s, useContext as c, useEffect as l, useRef as u, useState as d, useSyncExternalStore as f } from "react";
5
5
  import { FrameworkRuntimesProvider as p, buildApiRuntime as m, ssrClientProxy as h, ssrStubHandle as ee, startApiSupervisor as g, subscribeClientTraces as te, wireAuthRefresh as ne } from "@voltro/client";
6
6
  import { Fragment as re, jsx as _, jsxs as v } from "react/jsx-runtime";
@@ -1,2 +1,2 @@
1
- import { n as e, t } from "./frameworkBoot-CV_o2tIk.js";
1
+ import { n as e, t } from "./frameworkBoot-CnE9vzLQ.js";
2
2
  export { t as FrameworkBoot, e as VoltroRuntimeProvider };
@@ -0,0 +1,2 @@
1
+ import { globalContext as e } from "@voltro/client";
2
+ export { e as t };
package/dist/hooks.js CHANGED
@@ -1,4 +1,4 @@
1
- import { t as e } from "./globalContext-d4A-ugDg.js";
1
+ import { t as e } from "./globalContext-AuflFFmy.js";
2
2
  import { useContext as t } from "react";
3
3
  //#region src/hooks.ts
4
4
  var n = e("appClients", null), r = (e) => {
package/dist/index.d.ts CHANGED
@@ -11,6 +11,7 @@ import { Ref } from 'react';
11
11
  import { ResolvableHeaders } from '@voltro/client';
12
12
  import { Rpc } from '@effect/rpc';
13
13
  import { RpcGroup } from '@effect/rpc';
14
+ import { Schema } from 'effect';
14
15
 
15
16
  /** Decision outcome for a document-level anchor click. */
16
17
  export declare type AnchorNavigationDecision = {
@@ -167,6 +168,13 @@ declare interface CompiledRoute extends PageDescriptor {
167
168
 
168
169
  export declare const compileRoute: (route: PageDescriptor) => CompiledRoute;
169
170
 
171
+ /**
172
+ * Decode a query string against a page's `searchParams` schema. Total: an
173
+ * invalid query yields the schema's defaults; only a schema that cannot even
174
+ * decode `{}` (a required field with no default) throws, naming the fix.
175
+ */
176
+ export declare const decodeSearchParams: <A>(schema: SearchParamsSchema<A>, usp: URLSearchParams) => A;
177
+
170
178
  /** The English defaults. A deployment overrides any subtree via
171
179
  * {@link FallbackStringsProvider} or a per-component `strings` prop;
172
180
  * unspecified keys fall through to these. */
@@ -221,6 +229,18 @@ export declare interface DevStatus {
221
229
 
222
230
  export declare type DevStatusKind = 'compiling' | 'loading' | 'reconnecting' | 'error';
223
231
 
232
+ /**
233
+ * Canonical link-side encode — the schema-free half. `routes.generated.ts`
234
+ * types a link's params through a TYPE-ONLY import; at runtime the values are
235
+ * serialised canonically: strings pass through, numbers/booleans via
236
+ * `String()`, arrays as repeated keys, `undefined` omitted. `Date` (or any
237
+ * object) is refused loudly: a link must encode to the exact string form the
238
+ * schema's decode side accepts, and there is no canonical Date string the type
239
+ * layer could guarantee — declare the field as a string/number transform
240
+ * instead.
241
+ */
242
+ export declare const encodeSearchParams: (params: Readonly<Record<string, unknown>>) => URLSearchParams;
243
+
224
244
  export declare interface ErrorBoundaryProps {
225
245
  readonly error: unknown;
226
246
  readonly reset: () => void;
@@ -322,12 +342,20 @@ export declare const getStatuses: () => ReadonlyArray<DevStatus>;
322
342
 
323
343
  /**
324
344
  * Client-side runtime: scan the document for `[data-voltro-island]`
325
- * markers and schedule each for hydration per its strategy. Called once
326
- * at mount time by `@voltro/web/mount` when the page declares
327
- * `interactive: 'islands'`. Idempotent running twice is a no-op (we
328
- * mark elements as visited).
345
+ * markers and schedule each for hydration per its strategy. Called by the
346
+ * per-page islands entry, and by `@voltro/web/mount` when an islands page
347
+ * is reached through the full app bundle (SPA navigation). Idempotent —
348
+ * running twice is a no-op (we mark elements as visited).
329
349
  */
330
- export declare const hydrateIslandsOnPage: () => void;
350
+ export declare const hydrateIslandsOnPage: (options?: HydrateIslandsOptions) => void;
351
+
352
+ export declare interface HydrateIslandsOptions {
353
+ /** Wrap every island root — the per-page islands entry of a page whose
354
+ * islands use framework hooks passes a `<VoltroRuntimeProvider>` here so
355
+ * a `useSubscription` island gets a live runtime. Purely presentational
356
+ * entries pass nothing and never load the client core. */
357
+ readonly wrap?: (children: ReactNode) => ReactNode;
358
+ }
331
359
 
332
360
  export declare type HydrateStrategy =
333
361
  /** Hydrate as soon as the client runtime mounts (after main script load). */
@@ -341,7 +369,12 @@ export declare type HydrateStrategy =
341
369
  | 'interaction'
342
370
  /** Never hydrate. Useful for fully-static islands (e.g. SSR-only data
343
371
  * display that never changes). */
344
- | 'never';
372
+ | 'never'
373
+ /** CLIENT-ONLY: the server renders an empty placeholder (never the
374
+ * component — a browser-only lib touching `window` in render would
375
+ * crash the SSR pass), and the client mounts fresh with `createRoot`
376
+ * instead of hydrating. Astro's `client:only`. */
377
+ | 'only';
345
378
 
346
379
  declare const Image_2: ({ src, alt, width, height, fill, sizes, priority, loader, placeholder, blurDataURL, style, ...rest }: ImageProps) => ReactElement;
347
380
  export { Image_2 as Image }
@@ -446,7 +479,7 @@ export declare const lazyPageRoute: (pattern: string, load: () => Promise<Record
446
479
  readonly chain?: ReadonlyArray<RouteSegment>;
447
480
  }) => PageDescriptor;
448
481
 
449
- export declare const Link: ({ to, children, onClick, prefetch: prefetchProp, replace, onMouseEnter, onFocus, ref, ...anchorProps }: LinkProps) => ReactNode;
482
+ export declare const Link: ({ to, children, onClick, prefetch: prefetchProp, replace, transition, onMouseEnter, onFocus, ref, ...anchorProps }: LinkProps) => ReactNode;
450
483
 
451
484
  export declare interface LinkProps extends Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, 'href' | 'onClick'> {
452
485
  readonly to: VoltroUrl;
@@ -457,6 +490,11 @@ export declare interface LinkProps extends Omit<React.AnchorHTMLAttributes<HTMLA
457
490
  /** Swap the current history entry instead of pushing a new one —
458
491
  * mirror of `NavigateOptions.replace`. */
459
492
  readonly replace?: boolean;
493
+ /** Run this link's route swap through `document.startViewTransition`
494
+ * (`true`) or force it off (`false`), overriding the app-wide
495
+ * `router.viewTransitions` default — mirror of
496
+ * `NavigateOptions.transition`. */
497
+ readonly transition?: boolean;
460
498
  /**
461
499
  * Forwarded to the underlying `<a>`.
462
500
  *
@@ -707,6 +745,14 @@ export declare interface NavigateOptions {
707
745
  readonly replace?: boolean;
708
746
  /** Set to `false` to preserve the current scroll position. Defaults to true. */
709
747
  readonly scroll?: boolean;
748
+ /**
749
+ * Run this navigation's visual route swap through
750
+ * `document.startViewTransition` (`true`) or force it off (`false`),
751
+ * overriding the app-wide `router.viewTransitions` default for this one
752
+ * navigation. Browsers without the API and users with
753
+ * `prefers-reduced-motion: reduce` always get the plain swap regardless.
754
+ */
755
+ readonly transition?: boolean;
710
756
  }
711
757
 
712
758
  export declare const NavigationIndicator: () => ReactNode;
@@ -1005,7 +1051,7 @@ export declare interface RouteMatch {
1005
1051
  readonly params: Readonly<Record<string, string>>;
1006
1052
  }
1007
1053
 
1008
- export declare const Router: ({ routes, notFounds, notFound: NotFound, errorFallback: ErrorFallback, }: RouterProps) => ReactNode;
1054
+ export declare const Router: ({ routes, notFounds, notFound: NotFound, errorFallback: ErrorFallback, viewTransitions, }: RouterProps) => ReactNode;
1009
1055
 
1010
1056
  /* Excluded from this release type: RouterContext */
1011
1057
 
@@ -1040,6 +1086,15 @@ export declare interface RouterProps {
1040
1086
  readonly notFound?: ComponentType;
1041
1087
  /** Fallback ErrorBoundary used when neither the leaf nor its chain supplies one. */
1042
1088
  readonly errorFallback?: ComponentType<ErrorBoundaryProps>;
1089
+ /**
1090
+ * Run SPA route swaps through `document.startViewTransition` — the browser
1091
+ * cross-fades old and new page (customizable via `::view-transition-*` CSS).
1092
+ * Set from `app.config.ts` `router.viewTransitions`; individual navigations
1093
+ * override it via `navigate(to, { transition })` / `<Link transition>`.
1094
+ * Browsers without the API and `prefers-reduced-motion: reduce` fall back to
1095
+ * the exact untransitioned behavior.
1096
+ */
1097
+ readonly viewTransitions?: boolean;
1043
1098
  }
1044
1099
 
1045
1100
  /**
@@ -1081,10 +1136,21 @@ declare interface RouteSnapshot {
1081
1136
  readonly loaderData: unknown;
1082
1137
  }
1083
1138
 
1139
+ declare const SEARCH_PARAMS_BRAND: unique symbol;
1140
+
1084
1141
  /** The next search-params value: either the params object directly, or an
1085
1142
  * updater that receives the CURRENT `URLSearchParams` and returns the next. */
1086
1143
  export declare type SearchParamsInit = URLSearchParams | Readonly<Record<string, string>> | ((current: URLSearchParams) => URLSearchParams | Record<string, string>);
1087
1144
 
1145
+ /** A search-params schema: decodes a plain `Record<string, string | string[]>`
1146
+ * (the canonical URLSearchParams view) into the page's typed shape. */
1147
+ export declare type SearchParamsSchema<A> = Schema.Schema<A, any, never>;
1148
+
1149
+ /** The canonical object view of a query string: repeated keys become arrays. */
1150
+ export declare const searchParamsToObject: (usp: URLSearchParams, opts?: {
1151
+ readonly arrayKeys?: ReadonlySet<string>;
1152
+ }) => Record<string, string | ReadonlyArray<string>>;
1153
+
1088
1154
  /**
1089
1155
  * Cache key for a layout (chain segment) loader. Distinct from the page's
1090
1156
  * key for the same (pattern, params) so a layout's data and the page's
@@ -1155,23 +1221,6 @@ export declare interface ServerLogRelayOptions {
1155
1221
  readonly originLabel: string;
1156
1222
  }
1157
1223
 
1158
- export declare const ServerRequestContext: Context<ServerRequestContextValue | null>;
1159
-
1160
- export declare interface ServerRequestContextValue {
1161
- readonly cookies: Readonly<Record<string, string>>;
1162
- readonly headers: Readonly<Record<string, string>>;
1163
- /** Raw request URL as it came off the wire (path + query). Useful
1164
- * for SSR pages that need to read `?q=…` style search params
1165
- * without touching anything client-only. Empty string for build-
1166
- * time SSG renders where there is no incoming request. */
1167
- readonly url: string;
1168
- }
1169
-
1170
- export declare const ServerRequestProvider: ({ value, children, }: {
1171
- readonly value: ServerRequestContextValue;
1172
- readonly children: ReactNode;
1173
- }) => ReactNode;
1174
-
1175
1224
  /** Push a new route snapshot. Router calls this from its render
1176
1225
  * effect; observers re-render on the next microtask. */
1177
1226
  export declare const setRouteSnapshot: (next: RouteSnapshot) => void;
@@ -1337,10 +1386,27 @@ export declare const useReconnect: () => (() => void);
1337
1386
  * to router-pushed query changes without a reload should re-render via
1338
1387
  * the router (e.g. `useNavigate`/`useLocation`) — this hook then
1339
1388
  * re-resolves on that render.
1389
+ *
1390
+ * Pass the page's own `searchParams` schema export to get the DECODED, typed
1391
+ * shape instead (defaults applied; an invalid query falls back to the
1392
+ * defaults — see `decodeSearchParams`). The schema value is module-local by
1393
+ * design: the page imports its own export, so no generated module ever holds
1394
+ * schema VALUES and links stay typed through type-only imports.
1395
+ *
1396
+ * ```tsx
1397
+ * export const searchParams = Schema.Struct({
1398
+ * page: Schema.optionalWith(Schema.NumberFromString, { default: () => 1 }),
1399
+ * })
1400
+ * const { page } = useSearchParams(searchParams) // number, SSR-aware
1401
+ * ```
1402
+ *
1403
+ * Overload ORDER is load-bearing (the same rule `@voltro/client` pins): the
1404
+ * schema overload must come after the zero-arg one, or every existing
1405
+ * zero-arg call site would re-infer against the generic signature.
1340
1406
  */
1341
- export declare const useSearchParams: () => URLSearchParams;
1407
+ export declare function useSearchParams(): URLSearchParams;
1342
1408
 
1343
- export declare const useServerRequest: () => ServerRequestContextValue | null;
1409
+ export declare function useSearchParams<A>(schema: SearchParamsSchema<A>): A;
1344
1410
 
1345
1411
  /**
1346
1412
  * The WRITE half of `useSearchParams` — returns a setter that updates the
@@ -1359,8 +1425,25 @@ export declare const useServerRequest: () => ServerRequestContextValue | null;
1359
1425
  * pass `{ push: true }` for a distinct history entry. Client-only — during SSR
1360
1426
  * there is no history to write, so read `useSearchParams()` off the request URL
1361
1427
  * instead and mutate on the client after hydration.
1428
+ *
1429
+ * Pass the page's `searchParams` schema to get the TYPED setter. Its object
1430
+ * form REPLACES the query — same semantics as the untyped form, an absent
1431
+ * field decodes to its default on the next read — and its updater form
1432
+ * receives the CURRENT decoded params, so a merge is an explicit spread:
1433
+ *
1434
+ * ```tsx
1435
+ * const setParams = useSetSearchParams(searchParams)
1436
+ * setParams({ page: 2 }) // → ?page=2 (filter dropped)
1437
+ * setParams((p) => ({ ...p, page: p.page + 1 })) // keeps ?filter — typed merge
1438
+ * ```
1439
+ *
1440
+ * Overload ORDER is load-bearing (same rule as `useSearchParams`): the schema
1441
+ * overload comes after the zero-arg one, or every existing zero-arg call site
1442
+ * would re-infer against the generic signature.
1362
1443
  */
1363
- export declare const useSetSearchParams: () => ((next: SearchParamsInit, opts?: SetSearchParamsOptions) => void);
1444
+ export declare function useSetSearchParams(): (next: SearchParamsInit, opts?: SetSearchParamsOptions) => void;
1445
+
1446
+ export declare function useSetSearchParams<A extends Readonly<Record<string, unknown>>>(schema: SearchParamsSchema<A>): (next: Partial<A> | ((current: A) => Partial<A>), opts?: SetSearchParamsOptions) => void;
1364
1447
 
1365
1448
  /**
1366
1449
  * Read + set the app theme, backed by the SAME `voltro:theme` cookie and `.dark`
@@ -1382,9 +1465,22 @@ export declare type VoltroExternalUrl = string & {
1382
1465
  readonly [EXTERNAL_URL_BRAND]: 'external';
1383
1466
  };
1384
1467
 
1385
- /** A URL minted by the app's generated `routes` builder. */
1386
- export declare type VoltroRouteUrl = string & {
1468
+ /**
1469
+ * A URL minted by the app's generated `routes` builder.
1470
+ *
1471
+ * `TSearch` is a compile-time-only phantom: for a route whose page exports a
1472
+ * `searchParams` schema, the codegen brands the builder's return as
1473
+ * `VoltroRouteUrl<Schema.Schema.Type<typeof searchParams>>` through a
1474
+ * TYPE-ONLY import — no schema value ever enters `routes.generated.ts`, so
1475
+ * code-splitting stays intact — and `withQuery` then type-checks the params
1476
+ * against the page's contract. The default is `unknown`, which every branded
1477
+ * URL is assignable to, so a route without a schema (and every existing call
1478
+ * site) is untouched.
1479
+ */
1480
+ export declare type VoltroRouteUrl<TSearch = unknown> = string & {
1387
1481
  readonly [ROUTE_URL_BRAND]: 'route';
1482
+ } & {
1483
+ readonly [SEARCH_PARAMS_BRAND]?: TSearch;
1388
1484
  };
1389
1485
 
1390
1486
  export declare type VoltroUrl = VoltroRouteUrl | VoltroExternalUrl;
@@ -1401,8 +1497,24 @@ export declare const withHash: (url: VoltroRouteUrl, hash: string) => VoltroRout
1401
1497
  *
1402
1498
  * withQuery(routes['/_/p/[orgSlug]/[projectSlug]']({…}), { env: 'prod' })
1403
1499
  * //→ '/_/p/acme/web?env=prod' (typed as VoltroRouteUrl)
1500
+ *
1501
+ * For a route whose page exports a `searchParams` schema, the URL carries the
1502
+ * decoded shape as a phantom (see {@link VoltroRouteUrl}) and the params here
1503
+ * type-check against it — a misspelt key or a wrong value type is a compile
1504
+ * error. The ENCODE stays canonical and schema-free (strings pass through,
1505
+ * number/boolean via String(), arrays as repeated keys) so the generated
1506
+ * builder never needs the schema VALUE; the searchParams roundtrip test pins
1507
+ * that this canonical form is exactly what the schema's decode accepts.
1508
+ *
1509
+ * ONE generic signature with a CONDITIONAL params type — deliberately not two
1510
+ * overloads. With overloads, a wrong key on a typed URL fails the strict
1511
+ * overload and silently falls through to the permissive one, so the compile
1512
+ * error the phantom exists for never fires (the `.test-d.ts` pins exactly
1513
+ * that). The conditional keeps one resolution path: a schema phantom infers
1514
+ * `Partial<TSearch>`, the bare brand (phantom `unknown`) infers the free
1515
+ * record.
1404
1516
  */
1405
- export declare const withQuery: (url: VoltroRouteUrl, params: Readonly<Record<string, string | number | undefined>>) => VoltroRouteUrl;
1517
+ export declare function withQuery<TSearch = unknown>(url: VoltroRouteUrl<TSearch>, params: [TSearch] extends [Readonly<Record<string, unknown>>] ? Partial<TSearch> : Readonly<Record<string, string | number | boolean | ReadonlyArray<string | number | boolean> | undefined>>): VoltroRouteUrl<TSearch>;
1406
1518
 
1407
1519
 
1408
1520
  export * from "@voltro/client";
package/dist/index.js CHANGED
@@ -1,10 +1,11 @@
1
- import { t as e } from "./globalContext-d4A-ugDg.js";
1
+ import { t as e } from "./globalContext-AuflFFmy.js";
2
2
  import { AppClientsContext as t, useAppClient as n } from "./hooks.js";
3
- import { a as r, c as i, i as a, l as o, o as s, r as c, s as l, u } from "./frameworkBoot-CV_o2tIk.js";
4
- import { a as d, c as f, i as p, l as m, o as h, u as g } from "./defaultFallbacks-B4Ak-R6R.js";
5
- import { a as _, i as ee, n as te, r as ne, t as re } from "./mount-nudb4UCl.js";
6
- import { c as ie, d as ae, h as oe, l as se, p as ce, u as le } from "./routerState-DAT472IC.js";
7
- import { A as ue, B as de, C as fe, D as pe, E as me, F as he, G as ge, H as _e, I as v, K as y, L as b, M as x, N as S, O as C, P as w, R as T, S as E, T as D, U as O, V as k, W as A, _ as j, a as M, b as N, c as P, d as F, f as I, g as ve, h as ye, i as be, j as xe, k as Se, l as Ce, m as we, n as Te, o as Ee, p as De, q as Oe, r as ke, s as Ae, t as je, u as Me, v as Ne, w as Pe, x as Fe, y as Ie, z as Le } from "./serverContext-38JTbYPa.js";
3
+ import { a as r, c as i, i as a, l as o, o as s, r as c, s as l, u } from "./frameworkBoot-CnE9vzLQ.js";
4
+ import { a as d, c as f, i as p, l as m, o as h, u as g } from "./defaultFallbacks-SQ45vAhz.js";
5
+ import { getIslandComponent as _, hydrateIslandsOnPage as ee, island as te } from "./islands.js";
6
+ import { c as ne, d as re, h as ie, l as ae, p as oe, u as se } from "./routerState-DAT472IC.js";
7
+ import { mount as ce, noticeIslandsShipTheFullBundle as le } from "./mount.js";
8
+ import { A as ue, B as de, C as fe, D as pe, E as me, F as he, G as ge, H as _e, I as v, K as y, L as b, M as x, N as S, O as C, P as w, R as T, S as E, T as D, U as O, V as k, W as A, _ as j, a as M, b as N, c as P, d as F, f as I, g as ve, h as ye, i as be, j as xe, k as Se, l as Ce, m as we, n as Te, o as Ee, p as De, q as Oe, r as ke, s as Ae, t as je, u as Me, v as Ne, w as Pe, x as Fe, y as Ie, z as Le } from "./serverContext-CzmZZwuQ.js";
8
9
  import { Component as Re, Suspense as ze, createElement as L, use as Be, useCallback as Ve, useContext as He, useSyncExternalStore as Ue } from "react";
9
10
  import { Fragment as R, jsx as z, jsxs as B } from "react/jsx-runtime";
10
11
  export * from "@voltro/client";
@@ -21,8 +22,8 @@ var We = ({ value: e, fallback: t, children: n, errorFallback: r }) => /* @__PUR
21
22
  })
22
23
  })
23
24
  }), V = ({ body: e }) => /* @__PURE__ */ z("script", { dangerouslySetInnerHTML: { __html: e } }), Ge = ({ value: e, errorFallback: t, children: n }) => {
24
- let r = Be(e), i = le(e), a = oe(r);
25
- return a === void 0 ? /* @__PURE__ */ B(R, { children: [i === void 0 ? null : /* @__PURE__ */ z(V, { body: ae(i, r) }), n(r)] }) : /* @__PURE__ */ B(R, { children: [i === void 0 ? null : /* @__PURE__ */ z(V, { body: se(i, a) }), t] });
25
+ let r = Be(e), i = se(e), a = ie(r);
26
+ return a === void 0 ? /* @__PURE__ */ B(R, { children: [i === void 0 ? null : /* @__PURE__ */ z(V, { body: re(i, r) }), n(r)] }) : /* @__PURE__ */ B(R, { children: [i === void 0 ? null : /* @__PURE__ */ z(V, { body: ae(i, a) }), t] });
26
27
  }, Ke = class extends Re {
27
28
  state = { error: void 0 };
28
29
  static getDerivedStateFromError(e) {
@@ -231,4 +232,4 @@ var We = ({ value: e, fallback: t, children: n, errorFallback: r }) => /* @__PUR
231
232
  };
232
233
  }, ct = "framework-web";
233
234
  //#endregion
234
- export { t as AppClientsContext, We as Await, $e as BlogLayout, p as FallbackStringsProvider, Ye as Image, qe as ImageConfigProvider, Ee as Link, Ae as LoaderCache, P as LoaderDataContext, Ce as NO_LOADER_DATA, i as NavigationIndicator, Le as NotFoundError, Me as PAGE_SLOT_ATTR, F as PageSlot, I as PlainLink, De as RENDER_MODES, o as ReconnectContext, de as RedirectError, we as Router, ye as RouterContext, je as ServerRequestContext, Te as ServerRequestProvider, Y as THEME_COOKIE, ct as WEB_NAME, J as canonicalUrl, ve as compileRoute, d as defaultFallbackStrings, ie as defer, j as externalUrl, Ne as findNotFound, c as formatPrintf, ne as getIslandComponent, ge as getRouteSnapshot, f as getStatuses, ee as hydrateIslandsOnPage, a as installBrowserConsoleBridge, r as installServerLogRelay, ce as isDeferredLoaderResult, k as isNotFound, _e as isRedirect, _ as island, K as joinUrl, Ie as lazyPageRoute, N as loaderCacheKey, q as localizedPath, Fe as matchRoute, re as mount, G as normalizePath, O as notFound, te as noticeIslandsShipTheFullBundle, E as pageRoute, ke as parseCookieHeader, H as passthroughImageLoader, fe as preloadRouteModule, m as pushStatus, A as redirect, s as registerClientTrace, Pe as renderRouterProviderTree, D as resolveAnchorNavigation, me as resolveMeta, pe as segmentLoaderKey, et as seoAlternates, y as setRouteSnapshot, C as sortRoutesByPriority, Oe as subscribeRouteSnapshot, g as subscribeStatuses, l as subscribeTraceErrors, n as useAppClient, Se as useBlocker, h as useFallbackStrings, ue as useLoaderData, xe as useLocation, x as useNavigate, S as useOptionalLoaderData, w as useParams, he as usePrefetch, u as useReconnect, be as useSearchParams, M as useServerRequest, v as useSetSearchParams, st as useTheme, b as withHash, T as withQuery };
235
+ export { t as AppClientsContext, We as Await, $e as BlogLayout, p as FallbackStringsProvider, Ye as Image, qe as ImageConfigProvider, ke as Link, be as LoaderCache, M as LoaderDataContext, Ee as NO_LOADER_DATA, i as NavigationIndicator, Le as NotFoundError, Ae as PAGE_SLOT_ATTR, P as PageSlot, Ce as PlainLink, Me as RENDER_MODES, o as ReconnectContext, de as RedirectError, F as Router, I as RouterContext, Y as THEME_COOKIE, ct as WEB_NAME, J as canonicalUrl, De as compileRoute, v as decodeSearchParams, d as defaultFallbackStrings, ne as defer, b as encodeSearchParams, we as externalUrl, ye as findNotFound, c as formatPrintf, _ as getIslandComponent, ge as getRouteSnapshot, f as getStatuses, ee as hydrateIslandsOnPage, a as installBrowserConsoleBridge, r as installServerLogRelay, oe as isDeferredLoaderResult, k as isNotFound, _e as isRedirect, te as island, K as joinUrl, ve as lazyPageRoute, j as loaderCacheKey, q as localizedPath, Ne as matchRoute, ce as mount, G as normalizePath, O as notFound, le as noticeIslandsShipTheFullBundle, Ie as pageRoute, je as parseCookieHeader, H as passthroughImageLoader, N as preloadRouteModule, m as pushStatus, A as redirect, s as registerClientTrace, Fe as renderRouterProviderTree, E as resolveAnchorNavigation, fe as resolveMeta, T as searchParamsToObject, Pe as segmentLoaderKey, et as seoAlternates, y as setRouteSnapshot, D as sortRoutesByPriority, Oe as subscribeRouteSnapshot, g as subscribeStatuses, l as subscribeTraceErrors, n as useAppClient, me as useBlocker, h as useFallbackStrings, pe as useLoaderData, C as useLocation, Se as useNavigate, ue as useOptionalLoaderData, xe as useParams, x as usePrefetch, u as useReconnect, Te as useSearchParams, S as useSetSearchParams, st as useTheme, w as withHash, he as withQuery };
@@ -0,0 +1,66 @@
1
+ import { ComponentType } from 'react';
2
+ import { ReactNode } from 'react';
3
+
4
+ /**
5
+ * Look up a registered island Component by name. Returns undefined if
6
+ * the name isn't registered (typically means the user forgot to import
7
+ * the island file in this bundle).
8
+ */
9
+ export declare const getIslandComponent: (name: string) => ComponentType<unknown> | undefined;
10
+
11
+ /**
12
+ * Client-side runtime: scan the document for `[data-voltro-island]`
13
+ * markers and schedule each for hydration per its strategy. Called by the
14
+ * per-page islands entry, and by `@voltro/web/mount` when an islands page
15
+ * is reached through the full app bundle (SPA navigation). Idempotent —
16
+ * running twice is a no-op (we mark elements as visited).
17
+ */
18
+ export declare const hydrateIslandsOnPage: (options?: HydrateIslandsOptions) => void;
19
+
20
+ export declare interface HydrateIslandsOptions {
21
+ /** Wrap every island root — the per-page islands entry of a page whose
22
+ * islands use framework hooks passes a `<VoltroRuntimeProvider>` here so
23
+ * a `useSubscription` island gets a live runtime. Purely presentational
24
+ * entries pass nothing and never load the client core. */
25
+ readonly wrap?: (children: ReactNode) => ReactNode;
26
+ }
27
+
28
+ export declare type HydrateStrategy =
29
+ /** Hydrate as soon as the client runtime mounts (after main script load). */
30
+ 'load'
31
+ /** Hydrate when the browser is idle (via `requestIdleCallback`, with a
32
+ * setTimeout fallback for browsers that lack it). */
33
+ | 'idle'
34
+ /** Hydrate when the element scrolls into the viewport (IntersectionObserver). */
35
+ | 'visible'
36
+ /** Hydrate on the first pointer / keyboard interaction with the element. */
37
+ | 'interaction'
38
+ /** Never hydrate. Useful for fully-static islands (e.g. SSR-only data
39
+ * display that never changes). */
40
+ | 'never'
41
+ /** CLIENT-ONLY: the server renders an empty placeholder (never the
42
+ * component — a browser-only lib touching `window` in render would
43
+ * crash the SSR pass), and the client mounts fresh with `createRoot`
44
+ * instead of hydrating. Astro's `client:only`. */
45
+ | 'only';
46
+
47
+ /**
48
+ * Wrap a Component as an island. The returned Component renders the
49
+ * inner content wrapped in a marker `<div data-voltro-island>` that
50
+ * carries the island's name + props + hydrate strategy. The same
51
+ * call also registers the Component under its name so the client
52
+ * runtime can find it when hydrating.
53
+ */
54
+ export declare const island: <P extends Record<string, unknown>>(Component: ComponentType<P>, options: IslandOptions) => ComponentType<P>;
55
+
56
+ export declare interface IslandOptions {
57
+ /** Stable id of this island. Must be unique within an app. The framework
58
+ * uses it to match the server-rendered marker with the client-side
59
+ * Component. */
60
+ readonly name: string;
61
+ /** When the client runtime should hydrate this island. Defaults to
62
+ * `visible` — matches Astro's default and is the best perf/UX balance. */
63
+ readonly hydrate?: HydrateStrategy;
64
+ }
65
+
66
+ export { }