import { isSameOriginPath, type ResponseResult, status as statusResult } from "@nifrajs/core/server" import type { BoundaryRegistration, BoundaryStates } from "../boundary.ts" import { DEFERRED_ERROR_CODE, DEFERRED_RUNTIME, prepareDeferred } from "../deferred.ts" import { ISR_REVALIDATE_HEADER, ISR_REVALIDATE_TAGS_HEADER, serializeISRTags } from "../isr.ts" import type { Loader as LayoutLoader, LinkDescriptor, Meta, MetaDescriptor, MetaInput, RouteModule, ScriptDescriptor, UnsafeScriptDescriptor, } from "../manifest.ts" import type { RenderAdapter, RenderProps } from "../render-seam.ts" import { ACTION_GLOBAL, BOUNDARY_GLOBAL, DATA_GLOBAL, LAYOUT_DATA_GLOBAL, ROOT_ATTRIBUTE, ROUTE_GLOBAL, } from "../render-seam.ts" import { PRERENDERED_GLOBAL, REDIRECT_HEADER } from "../router.ts" import { trustedHeadAttributes } from "./head-attributes.ts" import { isStaticMeta, mergeHeads } from "./head-merge.ts" import { PRE_HYDRATION_GUARD } from "./runtime-contract.ts" import { EXECUTABLE_SCRIPT_TYPES, INERT_SCRIPT_TYPES } from "./script-types.ts" const TEXT_ENCODER = new TextEncoder() type MaybePromise = T | Promise // XSS-safe `` : "" // The regex only injects the CSP nonce into the (constant) hydration head; with no nonce it's a // no-op that still scans the whole script every request. Skip it on the common no-nonce path. // The request-invariant document pieces. With a caller-supplied per-route cache (and no per-request // nonce, which would bake into them) they're built once and reused; the per-request seams - the // deferred runtime in the shell, the action/layout-data globals and the serialized loader data in // the tail - are always assembled fresh. Byte-identical to building the whole document inline. const slot: RenderAssemblyCache = nonce === undefined && options.assemblyCache !== undefined ? options.assemblyCache : {} if (slot.shellPre === undefined) { // Matched-route chunk preloads, concatenated directly. De-duped against the entry, which is // preloaded separately below. let preloadLinks = "" for (const url of preload) { if (url !== clientEntry) preloadLinks += `` } // The matched route's stylesheets - `` in `` so CSS arrives with the // first paint (no FOUC). Render-blocking by design, and emitted regardless of `hydrate` (a static // or `_error` page still wants its styles). In dev (Vite) CSS is injected client-side instead. let styleLinks = "" for (const url of styles) styleLinks += `` // Island bundles are referenced only by a `` : "" // `` attributes. `lang` defaults to `"en"`; `dir` is omitted entirely when unset, which IS // HTML's `ltr` default. Both attribute-escaped. `client.ts`'s `applyHead` mirrors this exact // defaulting on soft-nav, so a hard load and a client navigation produce the same ``. const htmlAttrs = ` lang="${escapeAttr(head?.lang ?? "en")}"${head?.dir === undefined ? "" : ` dir="${escapeAttr(head.dir)}"`}` // Marks the container for the client entry when the id is not the one the entry falls back to. const rootMarker = rootId === "root" ? "" : ` ${ROOT_ATTRIBUTE}` // A non-hydrated page omits the adapter's hydration bootstrap entirely (Solid's `_$HY` registry // script, etc.) - there is no client takeover to feed, so it's dead bytes on a static document. // The nonce rewrite only runs when a nonce is set; with none it was a whole-string no-op scan. const rawHydrationHead = hydrate ? adapter.hydrationHead(nonce) : "" const hydrationHead = nonce === undefined ? rawHydrationHead : rawHydrationHead.replace(/]*\bnonce=)(?=[\s>])/g, `${preloadLinks}` : "" slot.shellPre = `${hydrationGuard}${escapeHtml(head?.title ?? title)}${headTags(head)}${styleLinks}${entryPreloads}` slot.shellPost = `${islandPreloads}${hydrationHead}
` // Island bundles load regardless of `hydrate` - a static page (hydrate:false) ships no framework // client but can still mount no-framework islands (`@nifrajs/web/islands`). let islandTags = "" for (const src of islandScripts) islandTags += `` if (hydrate) { slot.tailPre = `${route}` slot.tailMid = prerendered slot.tailData = `window.${DATA_GLOBAL}=` slot.tailPost = `${islandTags}` } else { slot.tailPre = "" slot.tailMid = "" slot.tailData = "" slot.tailPost = `${islandTags}` } } // The deferred runtime rides only on a hydrating page (a static page has no client takeover), and // only when something actually deferred - matching the pre-cache emission exactly. const runtimeSeam = hydrate && allDeferred.length > 0 ? deferredRuntime : "" const shellHtml = `${slot.shellPre}${runtimeSeam}${slot.shellPost}` // Closes the hydration container; deferred resolve scripts go AFTER it (outside `#root`) so they // aren't part of the adapter's hydrated tree (an inline script inside it breaks hydration). const closeRootHtml = "
" // Tail - the loader-data globals + the client module. Module scripts defer (run after parse), so // the data global + every streamed deferred resolution are set before the entry hydrates. const tailHtml = hydrate ? `${slot.tailPre}${action}${slot.tailMid}${layoutTail}${boundaryTail}${slot.tailData}${serializeData(forClient)}${slot.tailPost}` : (slot.tailPost as string) const headers: Record = { "content-type": "text/html; charset=utf-8" } // Caller-supplied headers (a terminal status page's `cache-control`, say). Applied before the // framework's own below, so `content-type` and the ISR channel stay authoritative - a caller must // not be able to mislabel the document or forge the freshness header by passing them here. if (extraHeaders !== undefined) { for (const [name, value] of new Headers(extraHeaders)) { if (name !== "content-type") headers[name] = value } } // ISR: advertise the route's freshness so a `withISR` wrapper can set this page's cache TTL. A // dedicated header (not the action-revalidation `x-nifra-revalidate`) so the TTL channel never aliases // the client's path-list channel. if (revalidate !== undefined) headers[ISR_REVALIDATE_HEADER] = String(revalidate) const serializedTags = serializeISRTags(revalidateTags) if (serializedTags !== undefined) headers[ISR_REVALIDATE_TAGS_HEADER] = serializedTags const renderProps: RenderProps = { data: forComponent, actionData: actionSplit?.forComponent, // `params`/`path` thread the matched route + URL to the adapter's `useParams`/`useLocation` so they // render SSR-correct. Spread only when supplied (exactOptionalPropertyTypes) - an adapter with no // router bindings simply never reads them. ...(options.params !== undefined ? { params: options.params } : {}), ...(options.path !== undefined ? { path: options.path } : {}), // Same rule for `search`: the validated query the adapter's `useSearch` reads, spread only when the // caller supplied it (a non-router render never has it). ...(options.search !== undefined ? { search: options.search } : {}), // Spread only when present, so a page-only render produces exactly the props it did before. ...(options.layoutData !== undefined ? { layoutData: layoutSplits.map((split) => split.forComponent) } : {}), ...(boundarySplit !== undefined ? { boundaries: boundarySplit.forComponent as BoundaryStates } : {}), } // Fast path: nothing `defer()`s and the adapter can render synchronously to a string → buffer the // whole document in one pass. Progressive streaming only benefits pages with deferred boundaries // (those take the streaming path below); for a plain page the streaming pipeline + the framework's // streaming renderer are pure overhead vs a single sync render + concat. A buffered string body also // gets an automatic Content-Length. A render throw surfaces here exactly as the streaming path's // shell-readiness `await` does, so the `_error` boundary still maps it to a status. if (allDeferred.length === 0 && adapter.renderToString !== undefined) { const out = adapter.renderToString(chain, renderProps) return typeof out === "string" ? new BufferedRenderedPage(shellHtml + out + closeRootHtml + tailHtml, status, headers) : out.then( (bodyHtml) => new BufferedRenderedPage( shellHtml + bodyHtml + closeRootHtml + tailHtml, status, headers, ), ) } return renderStreamedPage( adapter, chain, renderProps, shellHtml, closeRootHtml, allDeferred, tailHtml, status, headers, nonceAttr, ).then((response) => new ResponseRenderedPage(response)) } async function renderStreamedPage( adapter: RenderAdapter, chain: readonly unknown[], renderProps: RenderProps, shellHtml: string, closeRootHtml: string, allDeferred: ReadonlyArray<{ readonly id: number; readonly promise: Promise }>, tailHtml: string, status: number, headers: Record, nonceAttr: string, ): Promise { // Streaming path - required for `defer()` (progressive `` resolution) and used by any adapter // that doesn't implement `renderToString`. Awaiting `renderToStream` resolves on shell-readiness // (React: on-shell-ready; Solid: synchronously), so a shell-render throw surfaces before any byte is // sent. A *mid*-stream failure errors the body instead. const enc = TEXT_ENCODER const shell = enc.encode(shellHtml) const closeRoot = enc.encode(closeRootHtml) const tail = enc.encode(tailHtml) const appStream = await adapter.renderToStream(chain, renderProps) const body = streamDocument(shell, appStream, closeRoot, allDeferred, tail, enc, nonceAttr) return new Response(body, { status, headers }) } class BufferedRenderedPage implements RenderedPage { readonly [RESPONSE_RESULT] = true private readonly body: string private readonly status: number private readonly headers: Readonly> constructor(body: string, status: number, headers: Readonly>) { this.body = body this.status = status this.headers = headers } toResponse(): Response { return htmlResponse(this.body, { status: this.status, headers: this.headers }) } toNodeBody(): { readonly status: number readonly headers: Readonly> readonly body: string } { return { status: this.status, headers: this.headers, body: this.body } } } class ResponseRenderedPage implements RenderedPage { readonly [RESPONSE_RESULT] = true private readonly response: Response constructor(response: Response) { this.response = response } toResponse(): Response { return this.response } } function htmlResponse(body: string, init: ResponseInit): Response { const response = new Response(body, init) // @nifrajs/node can write buffered HTML straight to ServerResponse with `end(body)`, avoiding a Web // Response stream drain on Node. Non-enumerable + Symbol.for keeps this invisible to Web runtimes and // cross-package without adding a runtime dependency from @nifrajs/node to @nifrajs/web. Object.defineProperty(response, NODE_RESPONSE_BODY, { value: body }) return response } /** * Assemble the document stream: `shell` → the app `stream` (forwarded chunk-by-chunk, so a streaming * renderer's progressive flushing is preserved) → `closeRoot` → one `__nifraResolve`/`__nifraReject` * script per deferred value (emitted once its promise settles - by now the app stream has awaited * the same Suspense boundaries - and placed OUTSIDE `#root`) → `tail`. A mid-stream app error errors * the result (the body breaks) rather than silently truncating a 200. */ function streamDocument( shell: Uint8Array, stream: ReadableStream, closeRoot: Uint8Array, deferred: ReadonlyArray<{ readonly id: number; readonly promise: Promise }>, tail: Uint8Array, enc: TextEncoder, nonceAttr: string, ): ReadableStream { return new ReadableStream({ async start(controller) { const reader = stream.getReader() try { controller.enqueue(shell) for (;;) { const { done, value } = await reader.read() if (done) break controller.enqueue(value) } controller.enqueue(closeRoot) // Stream each resolution as ITS OWN promise settles - NOT in array order. A slow // defer() must not block a faster one; each script self-addresses by id, so order is irrelevant. await Promise.all( deferred.map(async (d) => { try { const value = serializeData(await d.promise) controller.enqueue( enc.encode(`window.__nifraResolve(${d.id},${value})`), ) } catch (err) { // A rejected deferred streams __nifraReject (the client `` surfaces it) - it must // not break the whole body. Redact: stream a stable opaque code, never the raw error // text; log the real reason server-side. console.error("[nifra/web] deferred value rejected:", err) controller.enqueue( enc.encode( `window.__nifraReject(${d.id},${serializeData(DEFERRED_ERROR_CODE)})`, ), ) } }), ) controller.enqueue(tail) controller.close() } catch (err) { controller.error(err) } finally { reader.releaseLock() } }, }) } /** Options for {@link redirect}. */ export interface RedirectOptions { /** HTTP status (default 303 See Other; pass 307/308 to preserve the method). */ readonly status?: number /** Allow an off-origin / absolute destination. Off by default: only a same-origin path (a single * leading `/`) is permitted, so an action can't be turned into an open redirect by passing * attacker-controlled input straight through. Set `true` for a deliberate external redirect. */ readonly external?: boolean /** Extra response headers. A redirect is no longer a `Response`, so there is no `.headers` to * mutate after the fact - name them here. Cookies still ride `c.set`, as on any other response. */ readonly headers?: Readonly> } /** A same-origin destination is an absolute path: one leading `/`, but NOT `//` (protocol-relative → * another origin), and nothing a URL parser resolves off-origin. Everything else (absolute URL with * a scheme, `//host`, `javascript:`, a bare relative `foo`) requires `external: true`. The predicate * lives in the kernel - see `isSameOriginPath` - so this gate and the auth guards' `redirectTo` * cannot drift apart. */ /** * Build a redirect - return it from a route `action` for the Post/Redirect/Get pattern (POST * mutates, 303 sends the browser to a fresh GET, so a reload doesn't re-submit). Defaults to 303 * (See Other); pass `{ status: 307 }` or `{ status: 308 }` to preserve the method. * * **Secure by default:** `location` must be a same-origin path (begins with `/`, not `//`). An * off-origin/absolute destination throws unless you pass `{ external: true }` - this closes the * open-redirect footgun of `return redirect(formData.get("next"))` on the no-JS (native-form) path, * which serves the action's control-flow value verbatim. * * Returns a plain render, not a `Response`. A redirect is a status line and one header - the most * body-less response there is - and building a `Response` for it costs the whole Web object plus, on * Node, a stream drained back out. As data it renders on the same lane a handler's return takes. * `redirect(...)` is still returned or thrown from exactly the same places; only `.status` / * `.headers` are gone from the value, replaced by `options.headers` and the request's `c.set`. * * @param options redirect status, extra headers, and whether an off-origin destination is intentional. */ export function redirect(location: string, options: RedirectOptions = {}): ResponseResult { if (options.external !== true && !isSameOriginPath(location)) { throw new Error( `[nifra/web] redirect(${JSON.stringify(location)}) is not a same-origin path. Use a path beginning with "/" (not "//", no backslash or control character), or redirect(location, { external: true }) for a deliberate off-origin redirect. This guards against open redirects from unvalidated input.`, ) } // Reject CR/LF in the Location explicitly - defense-in-depth (response splitting / header // injection). Spec-correct runtimes' Headers setter throws on CR/LF, but `external: true` lets // unvalidated input reach this sink, so we don't rely on the runtime. Same posture as // serializeCookie / the SSE frame formatter, which strip CRLF at their sinks. if (/[\r\n]/.test(location)) { throw new Error( `[nifra/web] redirect location contains a CR/LF character - refusing to emit a header-injecting redirect.`, ) } return statusResult(options.status ?? 303, undefined, { headers: options.headers === undefined ? { location } : { ...options.headers, location }, }) } /** * Brand marking a `Response` as a **terminal-status signal** rather than a response to serve verbatim. * * A loader that throws a bare `Response` has always been passed straight through - that is how * `throw redirect(...)` works, and apps rely on it. So "render the `_404` boundary at 404" cannot be * expressed by throwing a plain `Response`: the framework would have no way to tell it apart from a * hand-rolled body the app wants served exactly as written. The brand is that distinction, and * checking it *before* the pass-through is what keeps every existing throw working unchanged. * * `Symbol.for` matches `RESPONSE_RESULT` above: a registry symbol survives two copies of this module, * which a `unique symbol` would not - and a duplicate-install turning a 404 into a raw 404 body would * be a maddening bug to trace. */ /** Derived from the `Headers` constructor rather than the DOM lib's `HeadersInit`: `@nifrajs/web` * is consumed from DOM-free programs (the root tsconfig runs `types: ["bun"]`), and naming the DOM * type here would break their build. Same workaround as `SSEInit.headers` in core. */ export type HeadersLike = ConstructorParameters[0] /** * The signatures a **duplicate module instance** produces during SSR, across engines. * * Two copies of React (or of `@nifrajs/core`) at the SAME version still fail, because module identity * is path-based: hooks read a dispatcher off the copy that rendered, and the component imported the * other one. The error that surfaces names a React internal, so it reads as a React bug and the actual * cause - two directories - is a long inference away. Naming the cause at the point of failure is the * difference between that and a five-second read. */ const DUPLICATE_INSTANCE_SIGNATURES: readonly RegExp[] = [ /resolveDispatcher\(\)/, /Invalid hook call/, /Cannot read propert(?:y|ies) of null \(reading '(?:use[A-Z]\w*)'\)/, /null is not an object \(evaluating '.*\.use[A-Z]\w*'\)/, ] /** * Append the likely cause to an SSR error that carries a duplicate-instance signature. * * Deliberately does NOT claim the resolved paths: `@nifrajs/web` is framework-agnostic and does not * depend on React, so it cannot resolve the copies without guessing. It names the condition and the * command that reports the paths, which is the actionable part. Returns the error untouched when the * signature does not match, so an ordinary render error reads exactly as before. */ export function withDuplicateInstanceHint(err: unknown): unknown { if (!(err instanceof Error)) return err if (!DUPLICATE_INSTANCE_SIGNATURES.some((rx) => rx.test(err.message))) return err const augmented = new Error( `${err.message}\n\n[nifra/web] This signature usually means TWO COPIES of an identity-sensitive package (react, react-dom, or @nifrajs/core) are installed at different paths. Module identity is path-based, so matching versions do NOT fix it - the copies must resolve to the same directory. Run \`nifra check\` to list the paths.`, { cause: err }, ) augmented.name = err.name // `stack` is optional under exactOptionalPropertyTypes; assigning `undefined` would replace a real // stack with nothing on engines that always populate it. if (err.stack !== undefined) augmented.stack = err.stack return augmented } /** * An action's control-flow value passes straight through - except a redirect on a client-submit data * request: fetch would follow the 3xx into HTML the client can't use, so the redirect rides the * X-Nifra-Redirect header on a 204 and the client navigates. One conversion shared by the returned- * and thrown- paths, so `return redirect()` and `throw redirect()` agree. * * Takes either shape: `redirect()` is a plain render, while a hand-rolled `new Response(...)` from an * action still arrives as a `Response`. The rewrite stays on the lane its input was on - a plain * redirect converts to a plain 204, and never materializes the `Response` it is replacing. */ export function actionResponse( result: Response | ResponseResult, isDataRequest: boolean, ): Response | ResponseResult { if (!isDataRequest) return result if (isResponseResult(result)) { const plain = result.plain // No `plain` means a carrier that only knows how to build a `Response` (not one of ours) - fall // back rather than guess at its status. if (plain === undefined) return actionResponse(result.toResponse(), isDataRequest) if (plain.status < 300 || plain.status >= 400) return result const location = plain.headers?.location ?? "/" return statusResult(204, undefined, { headers: { [REDIRECT_HEADER]: location } }) } if (result.status >= 300 && result.status < 400) { const location = result.headers.get("location") ?? "/" return statusResult(204, undefined, { headers: { [REDIRECT_HEADER]: location } }) } return result } /** A loaded layout module. `loader`/`gate` are the layout-loader surface; `meta` predates it. */ export type LoadedLayoutModules = ReadonlyArray<{ default: unknown meta?: MetaInput loader?: LayoutLoader action?: unknown gate?: boolean // A layout may declare its own `searchSchema`; the route's effective search merges the layout chain's // schemas with the page's (page-wins). Present on the raw module already - typed here so it is readable. searchSchema?: RouteModule["searchSchema"] boundaries?: readonly BoundaryRegistration[] }> /** * Narrow the route's params to the ones a layout owns. * * A layout wraps a URL prefix, so anything deeper belongs to a route beneath it. Handing it the full * set would let a layout read a param it does not own - which reads fine until the layout is reused * under a route that has no such param and the value silently becomes `undefined`. */ export function scopeParams( params: Record, owned: readonly string[] | undefined, ): Record { if (owned === undefined) return params // hand-built manifest with no scope info: unchanged behaviour if (owned.length === 0) return EMPTY_LAYOUT_PARAMS const scoped: Record = {} for (const name of owned) { const value = params[name] if (value !== undefined) scoped[name] = value } return scoped } const EMPTY_LAYOUT_PARAMS: Record = Object.freeze({}) export const EMPTY_RETAIN: ReadonlySet = new Set() /** * Marks an error as coming from a LAYOUT loader, carrying that layout's id. * * The boundary that catches it must be at or above the failing layout's own segment. The route's * innermost `_error` may live BELOW the layout that failed, and rendering there would wrap the * boundary in a layout whose data never arrived - so the page would render inside a component that * threw. A registry symbol, matching the other cross-module brands here. */ const LAYOUT_ERROR_ID = Symbol.for("nifra.web.layout-error-id") export const tagLayoutError = (err: unknown, layoutId: string): unknown => { if (typeof err === "object" && err !== null && !(LAYOUT_ERROR_ID in err)) { Object.defineProperty(err, LAYOUT_ERROR_ID, { value: layoutId, enumerable: false }) } return err } export const layoutErrorId = (err: unknown): string | undefined => typeof err === "object" && err !== null ? ((err as Record)[LAYOUT_ERROR_ID] as string | undefined) : undefined export const STATUS_SIGNAL = Symbol.for("nifra.web.status-signal") /** Reason phrases for the plain-text fallback, used only when the app authored no `_` and no * `_404` page. Deliberately not exhaustive - anything unlisted falls back to "Error", which is more * useful than shipping a table of every RFC status for a body almost nobody will see. */ export const STATUS_TEXT: Readonly> = { 400: "Bad Request", 401: "Unauthorized", 402: "Payment Required", 403: "Forbidden", 404: "Not Found", 410: "Gone", 451: "Unavailable For Legal Reasons", 500: "Internal Server Error", } export interface StatusSignal extends Response { readonly [STATUS_SIGNAL]: { readonly status: number; readonly headers?: HeadersLike } } export function isStatusSignal(value: unknown): value is StatusSignal { return value instanceof Response && STATUS_SIGNAL in value } function statusSignal(status: number, options: StatusPageOptions): never { if (!Number.isInteger(status) || status < 400 || status > 599) { throw new Error( `[nifra/web] statusPage(${JSON.stringify(status)}) must be an integer 4xx or 5xx status. Use redirect() for 3xx, and return data for a successful render.`, ) } const response = new Response(null, { status }) // Non-enumerable so the brand never lands in a structured clone or a JSON log of the response. Object.defineProperty(response, STATUS_SIGNAL, { value: { status, ...(options.headers !== undefined ? { headers: options.headers } : {}) }, enumerable: false, }) throw response } /** Options shared by {@link notFound}, {@link gone}, and {@link statusPage}. */ export interface StatusPageOptions { /** * Extra headers for the rendered response - `cache-control` above all. * * The defaults differ by status for a reason: a 404 may be a race with publication, so it wants a * short TTL, while a 410 is a promise that the URL is permanently gone and can be cached hard. * Getting this wrong is the difference between a crawler dropping a dead URL and re-fetching it * for weeks. */ readonly headers?: HeadersLike } /** * Render the nearest `_404` page at status **404**. `throw` it from a loader when the record does not * exist. * * This is the fix for the soft 404: a matched route whose loader finds nothing has otherwise no way to * answer 404, so the path of least resistance is to return empty data and render "not found" inside a * **200**. That looks correct in a browser and is invisible in review, which is why it ships and stays * shipped - while search engines penalise it and keep the dead URL indexed. * * Returns `never`: these throw, so a loader narrows without a redundant `return`, and the type states * the thing the "loaders `throw` redirect, actions `return` it" rule already trips people on. */ export function notFound(options: StatusPageOptions = {}): never { return statusSignal(404, options) } /** * Render a terminal page at status **410 Gone**. `throw` it from a loader for a record that existed and * was deliberately removed - a withdrawn listing, a deleted post. * * 410 is not a pedantic 404: it tells a crawler to **drop** the URL rather than re-fetch it for weeks * on the assumption the 404 was transient. Uses `_410.tsx` if the app has one, otherwise `_404`. */ export function gone(options: StatusPageOptions = {}): never { return statusSignal(410, options) } /** * Render a terminal page at any 4xx/5xx status - the escape hatch behind {@link notFound} and * {@link gone} (402, 451, …). Uses `_.tsx` if present, otherwise `_404`. */ export function statusPage(status: number, options: StatusPageOptions = {}): never { return statusSignal(status, options) } /** The wrapper `revalidate()` returns: the action's `data` plus the paths it changed. A plain tagged * shape (not a class) so `@nifrajs/client`'s `ActionData` can unwrap it structurally without importing * from `@nifrajs/web`. `createWebApp` strips the wrapper - the client receives `data` as the body and * the paths via the `X-Nifra-Revalidate` header. */ export interface RevalidateResult { readonly __nifraRevalidate: readonly string[] readonly data: T } /** * Return this from an action to declare which routes the mutation changed (alongside the action's * `data`). `createWebApp` sets the `X-Nifra-Revalidate` response header; after the submit the client * marks those cached routes stale - refetching the active one and any mounted fetcher showing them - * so a mutation can refresh views beyond the one that was submitted. `data` is still surfaced to the * component as `actionData` (the wrapper is transparent to `ActionData`). */ export function revalidate(paths: readonly string[], data: T): RevalidateResult { return { __nifraRevalidate: paths, data } } /** * Serialize loader data for embedding inside an inline `` or `