diff --git a/src/routes/(3)building-apps/(3)server-functions/(7)durable-generation.mdx b/src/routes/(3)building-apps/(3)server-functions/(7)durable-generation.mdx index 7f2a0006b..447530bd4 100644 --- a/src/routes/(3)building-apps/(3)server-functions/(7)durable-generation.mdx +++ b/src/routes/(3)building-apps/(3)server-functions/(7)durable-generation.mdx @@ -280,7 +280,7 @@ The action's overlay is released a task after the data that satisfied its `until :::caution[Server components are an experimental preview] A function returned from a `"use server"` function is a server component, mounted in the browser with `dynamicComponent`. The runtime ships in `@solidjs/web` and is excluded from Solid 2.0's stability guarantees: the API and the wire format may change between prereleases. -The `chat` example in the Solid repository is this section's reference implementation. +The `chat` example in the Solid repository is this section's reference implementation, and [Server components](/building-apps/server-components) introduces the model. ::: When the markdown renderer, the highlighter, and the model client should stay on the server, the thread can be server markup that keeps changing. diff --git a/src/routes/(3)building-apps/(8)server-components/(0)index.mdx b/src/routes/(3)building-apps/(8)server-components/(0)index.mdx new file mode 100644 index 000000000..e7e93b9bd --- /dev/null +++ b/src/routes/(3)building-apps/(8)server-components/(0)index.mdx @@ -0,0 +1,233 @@ +--- +title: "Server components" +version: "2.0" +description: "Return a component from a server function so the product page's description and reviews arrive as HTML, mount it with dynamicComponent, and know what the browser no longer downloads." +--- + +The product page renders a long description from Markdown and a list of reviews. +None of it changes after it arrives, yet the browser downloads the Markdown renderer, the review template, and the review data as JSON, and then renders HTML the server already rendered once. +A server component moves that part of the page to the server for good: the server renders it, the browser receives the markup, and the code that drew it never ships. + +In Solid a server component is a server function whose answer is a component. +There is no separate component kind, no module boundary, and no client directive. +The client calls the function like any other read and mounts what it answers with [`dynamicComponent`](/reference/solid-web/components/dynamic#dynamiccomponent). + +:::caution[Server components are an experimental preview] +Server components ship in `@solidjs/web` with Solid 2.0 and are excluded from its stability guarantees. +The API and the wire format may change between prereleases, and stabilization will be announced separately. +The `hackernews`, `notes`, `todos-server`, `chat`, and `server-dbmon` examples in the Solid repository are the reference implementations. +::: + +## Turn on server components + +Server components ride the server-function transport, so they start from the setup in [Server functions](/building-apps/server-functions#enable-server-functions). +Replace `serverFunctions: true` with an object that sets `components`: + +```ts +// vite.config.ts +import { defineConfig } from "vite"; +import solid from "@solidjs/vite-plugin"; + +export default defineConfig({ + plugins: [ + solid({ + start: true, + ssr: true, + serverFunctions: { components: true }, + }), + ], +}); +``` + +The option installs the server's result transforms and serves component answers through the same development middleware and production handler as every other server function. +When the plugin generates your entries, it also adds the document-rendering pieces and the client install, so nothing else changes in your project. + +A server function that answers with data behaves exactly as before. +Only a function that answers with a function takes the server-component path. + +## Write a server component + +Return a component from a `"use server"` function, and wrap the function in `query()` so the router caches and preloads it like any other read: + +```tsx +// src/data/product-view.tsx +import { query } from "@solidjs/router"; +import type { Slot } from "@solidjs/web/frames"; +import { database } from "./database"; +import { renderMarkdown } from "./markdown"; + +type ProductViewProps = { + buy: Slot<{ productId: string; price: number }>; +}; + +export const getProductView = query(async (id: string) => { + "use server"; + const product = await database.products.find(id); + const reviews = await database.reviews.forProduct(id); + + return (props: ProductViewProps) => ( +
+

{product.name}

+
+ +
+

Reviews

+ {reviews.map((review) => ( +
+

{review.text}

+ +
+ ))} +
+
+ ); +}, "product-view"); +``` + +`database` and `renderMarkdown` are stand-ins for your own modules; both are imported only by the server function's body, so the build removes them from the client bundle with it. + +The function and the component it returns have different jobs: + +- The function's arguments are the server's inputs. + `id` decides what to fetch, and it travels to the server on every call. +- The component's props are positions in the markup that the client fills, called slots. + `props.buy` marks where the client places its own component; nothing in `props` travels to the server. + +Unless the function is declared live, the component renders once per response. +`reviews.map` and a ternary are the whole control flow it needs, and `` and `` work the same way when you prefer them. + +## Mount it in the page + +Call the function inside the source of `dynamicComponent`, and fill the slot with a client component: + +```tsx +// src/pages/Product.tsx +import { dynamicComponent, Loading } from "solid-js"; +import { AddToCart } from "../components/AddToCart"; +import { getProductView } from "../data/product-view"; + +export function Product(props: { id: string }) { + const ProductView = dynamicComponent(() => getProductView(props.id)); + + return ( + Loading the product…

}> + } + /> +
+ ); +} +``` + +Load the page and view its source. +The description and every review appear in the HTML once, as markup, and nowhere as data. +Search the client bundle for the review template or the Markdown renderer and neither is there. +`AddToCart` is an ordinary client component: it rendered on the server with the rest of the page, it hydrates, and its click handler runs in the browser. + +The first call is a pending promise, so the nearest `Loading` boundary shows its fallback until the component's markup starts to arrive, as it does for any async read. +During the first page load the server renders the component inline, so the page needs no extra request when it boots. + +## Change the arguments + +The source of `dynamicComponent` is tracked. +When `props.id` changes, the source calls `getProductView` again with the new id, and the server's new markup is applied in the same place on the page. +The mounted component is not torn down and rebuilt. + +Calling again with the same arguments, as a revalidation does, gives back the same component. +The server's markup is updated in place, so focus, input values, and the state of client components in slots survive the refresh. +A call the page has already shown is drawn again at once from what the browser kept, and a fresh request then updates it. + +## Use a server component as a route + +When a route's whole content is a server component that takes no slots, `serverRouteComponent` from `@solidjs/router` mounts it as the route's component. +The router calls the function with the route's params, and with its validated search when the route declares a `search` schema: + +```tsx +// src/routes/review.tsx +import { + query, + serverRouteComponent, + type RouteParams, + type ServerRouteArgs, +} from "@solidjs/router"; +import { database } from "../data/database"; + +const getReview = query( + async ({ params }: ServerRouteArgs>) => { + "use server"; + const review = await database.reviews.find(params.id); + return () => ( + + ); + }, + "review" +); + +export default serverRouteComponent(getReview); +``` + +Hover the link to the product and the router makes the next route's call ahead of the click, as it preloads any route. +Links and forms in server markup are claimed by the router like links it rendered itself, so they navigate in place and show active state. + +The only position a server route has is `children`, which the router fills with the nested route's outlet. +A server component that needs any other slot is mounted by an ordinary route component with `dynamicComponent`, as above. + +## What moves to the server, and what it costs + +Use a server component for a part of the page whose rendering code is larger than its data, or that needs a library or a secret only the server should hold: an article, a product description, a list of reviews, a comment thread. + +What you give up: + +- The server renders the component on every call. + A page that the browser rendered from cached JSON now asks the server for markup each time its arguments change. +- The client cannot read the data behind the markup. + A value the client needs, such as a price for a quantity picker, has to be passed to a slot as an argument. +- Markup the server renders has no client code. + Behavior goes in a client component placed through a slot, or in a form, as [Client components in server markup](/building-apps/server-components/client-components-in-slots) shows. +- The page loads the frames runtime that applies server markup. + An application that never returns a component does not import it and does not pay for it. + +Keep a route fully client-side when it is mostly interaction, such as a drag-and-drop board. +A page can mix both: one server component for the read-mostly part, client components everywhere else. + +## Common problems + +### A click handler in a server component does nothing + +The component's code runs only on the server, so an `onClick` written in its markup never reaches the browser. +Move the control into a client component and place it through a slot, or make the action a `
`, which works before JavaScript loads. +[Behavior on server markup](/building-apps/server-components/client-components-in-slots#behavior-on-server-markup) shows both. + +### Part of a server component disappears or lands in the wrong place + +The browser restructured invalid HTML in the server's markup, such as a `
` inside a `

` or a `` outside a table, and split the range the client was tracking. +Development reports [`FRAME_MARKER_CORRUPTED`](/reference/solid-js/diagnostics/frame-marker-corrupted). +Fix the nesting in the component, and serve the page with `Cache-Control: no-transform` so a proxy or minifier does not strip the comments that mark each range. + +### The server function answers with data instead of markup + +The project still sets `serverFunctions: true`. +Set `serverFunctions: { components: true }` so the server streams a returned component as markup. + +## Recap + +- A server component is a server function that returns a component; mount it with `dynamicComponent`. +- The function's arguments are the server's inputs, and the component's props are slots the client fills. +- Wrap the function in `query()` so the router caches, preloads, and revalidates it. +- Calling again with the same arguments updates the markup in place, and client state inside survives. +- Use `serverRouteComponent` for a route whose only client position is its outlet. +- Markup the server renders has no client code; put behavior in a client component through a slot, or in a form. +- Server components are an experimental preview, outside Solid 2.0's stability guarantees. + +## Next steps + +- [Client components in server markup](/building-apps/server-components/client-components-in-slots): pass client components into slots, give them data, and keep their state through refreshes. +- [Keep server components fresh](/building-apps/server-components/keeping-fresh): refresh after a mutation, stream changes with `live()`, share one render with `broadcast()`, or bake markup at build time. +- [Data loading and mutations](/routing/solid-router/data): the `query()` and `formAction()` layer that server components reuse. diff --git a/src/routes/(3)building-apps/(8)server-components/(1)client-components-in-slots.mdx b/src/routes/(3)building-apps/(8)server-components/(1)client-components-in-slots.mdx new file mode 100644 index 000000000..a92740f54 --- /dev/null +++ b/src/routes/(3)building-apps/(8)server-components/(1)client-components-in-slots.mdx @@ -0,0 +1,269 @@ +--- +title: "Client components in server markup" +version: "2.0" +description: "Place client components inside a server component through slots, give them the values they need, wrap server markup in client behavior, and keep their state through refreshes and reorders." +--- + +The product view from [Server components](/building-apps/server-components) left one position for the client: `props.buy`, where the page placed `AddToCart`. +Most server markup needs a few of these. +A review needs a "Helpful" toggle, a long review needs to expand and collapse, and the cart button needs the product's price. +Each of those is a client component, and a slot is where the server says it goes. + +The rule for deciding which side an element belongs to: an element lives where the data that creates it lives. +A review exists because of server data, so its markup is server markup. +A toggle's pressed state, an expanded panel, or a quantity picker exists because of the shopper, so it is a client component placed through a slot. + +## Declare a slot + +A slot is a prop of the server component, typed with `Slot` from `@solidjs/web/frames`. +Its type parameter is the props the server passes to the client component: + +```tsx +import type { JSX } from "@solidjs/web"; +import type { Slot } from "@solidjs/web/frames"; + +type ProductViewProps = { + buy: Slot<{ productId: string; price: number }>; + helpful: Slot<{ reviewId: string; count: number }>; + children?: JSX.Element; +}; +``` + +The server places a slot as an element, once or many times: + +```tsx +return (props: ProductViewProps) => ( +

+

{product.name}

+ + {reviews.map((review) => ( +
+

{review.text}

+ +
+ ))} +
{props.children}
+
+); +``` + +Each place a slot is rendered is an occurrence. +`props.helpful` has one occurrence per review, and each one runs the client's component with that review's props. +`props.children` is a direct position: the server marks a range, and the client decides what goes in it. + +The client fills each slot with a component of the slot's props: + +```tsx + } + helpful={(p) => } +> + + +``` + +Click a "Helpful" toggle and its count changes at once; the toggle's own code handles the click. +The review text around it is server markup, and the client never re-renders it. + +When a fill passes its props straight through to one component, type the slot from that component so the two cannot drift apart: + +```tsx +import type { ComponentProps } from "solid-js"; +import type { Slot } from "@solidjs/web/frames"; +import type HelpfulToggle from "../components/HelpfulToggle"; + +type HelpfulSlot = Slot>; +``` + +Import the component with `import type` in the server module: the server only needs its props, and the client is the side that renders it. + +## A client component in a slot is a full client component + +The fill runs in the browser like any component body: once per occurrence, untracked, under that occurrence's owner. +Signals, memos, effects, `onCleanup`, actions, and optimistic state created in it live as long as the occurrence does. + +```tsx +// src/components/HelpfulToggle.tsx +import { action, createOptimistic } from "solid-js"; +import { markHelpful } from "../data/reviews"; + +export default function HelpfulToggle(props: { + reviewId: string; + count: number; +}) { + const [count, setCount] = createOptimistic(() => props.count); + const vote = action(function* () { + setCount((c) => c + 1); + yield markHelpful(props.reviewId); + }); + + return ; +} +``` + +`markHelpful` is a server function called with `POST`, so when it settles the router revalidates every query on screen, the product view included. +The server renders the view again with the new count, the occurrence receives it as `props.count`, and the optimistic value gives way to it when the action settles. + +The props are reactive. +When the server renders the product view again, after a mutation or on a live connection, each occurrence receives its new arguments and `props.count` updates; the component does not remount. +Read props where they are used, as in any component. +A read at the top of the fill runs once, and development reports [`STRICT_READ_UNTRACKED`](/reference/solid-js/diagnostics/strict-read-untracked) naming the slot's fill. + +On the first page load, client components in slots render on the server along with the server markup, so the page is complete before any JavaScript runs, and they hydrate when it arrives. +After that, the server never renders them again: later responses carry the server's markup and each occurrence's arguments, and the browser renders the client components. + +## Pass server markup into a client component + +A slot argument can be JSX. +The server renders it, and the client component decides where and whether to show it: + +```tsx +// in the server component +{review.text.slice(0, 140)}…

} +> +
+ +``` + +```tsx +// src/components/ExpandableReview.tsx +import { createSignal } from "solid-js"; +import type { JSX } from "@solidjs/web"; + +export default function ExpandableReview(props: { + reviewId: string; + excerpt: JSX.Element; + children: JSX.Element; +}) { + const [open, setOpen] = createSignal(false); + + return ( +
+ {open() ? props.children : props.excerpt} + +
+ ); +} +``` + +The review body is server markup inside a client wrapper. +The client moves it, shows it, and hides it, but never rebuilds it, so nested server content stays one copy however deep it goes. + +Content the client did not show during the first render, such as the full review while it is collapsed, is not in the page's HTML. +The server sends it once as data instead, and opening the review mounts it from that data with no request. + +## Place slots as elements, not calls + +Write ``, not `props.helpful({ reviewId: review.id })`. +The compiler wraps each prop of the element form in a getter so the value is read where the slot is placed. +The call form evaluates its arguments in the server component's body, before the slot exists. + +## Keep state with the right review + +Occurrences are matched by position by default. +That is right for a list that does not reorder: refreshes that leave the order alone keep every toggle's state where it was. + +When the list can reorder, sorting reviews by most helpful for example, positional matching leaves an expanded panel at position 0 while its review moves to position 3. +Name each occurrence with `$key` so its client state follows the review: + +```tsx +// Avoid: the expanded state stays at the position when reviews reorder +{ + reviews.map((review) => ( + {review.text} + )); +} + +// Prefer: the occurrence is named by the review +{ + reviews.map((review) => ( + + {review.text} + + )); +} +``` + +`$key` is not passed to the client component; it only names the occurrence. +Keyed occurrences must be siblings for a reorder to follow the key, so let the client component render the per-item wrapper rather than wrapping each slot in its own server element. +The same holds when the arguments change: in the `notes` example, changing the search text keeps the expanded state of every note still in the list, because each occurrence is keyed. + +`$key` on a server element names the element instead. +The server's new markup for the same key updates that element in place, so element state the update preserves, such as an open `
`, a checked box, or focus, follows the entity through a reorder: + +```tsx +{ + reviews.map((review) => ( +
  • +
    + {review.title} +

    {review.text}

    +
    +
  • + )); +} +``` + +Keys on elements are compared among siblings only. + +## Pass a value that is still loading + +A promise or an async iterable passed as a slot argument stays pending without holding up the server's markup. +Wrap it in `asyncArg` so the slot's type says what the client reads: + +```tsx +import { asyncArg } from "@solidjs/web/frames"; + +; +``` + +The client's read of `props.estimate` suspends into the nearest `Loading` boundary until the value arrives, and for an async iterable updates on every yield. + +## Behavior on server markup + +A handler on a server-rendered element cannot come from the server, because a server function never runs in the browser. +Pick one of three shapes: + +- **A form.** + A mutation is `` in the server markup. + It works before JavaScript loads, and after hydration the router submits it in place and sets `aria-busy` on the form while it is in flight, so CSS can show the intent. +- **A client component that owns the element.** + Move the control into a slot, as `HelpfulToggle` does, so the client renders the button and its handler. +- **One delegated listener.** + For many identical controls inside server markup, such as a Copy button on every code block, place one slot occurrence whose component adds a single listener to the document in `onSettled` and finds its target from the event with `closest("button.copy")`. + The `chat` example's Copy button is this shape. + +## Common problems + +### Client state jumps to another item after a refresh + +The list reordered and its occurrences are positional. +Add `$key` with the entity's id to the slot element, and render the slots as siblings. + +### A slot's prop never updates + +The fill read the prop at the top of the component, which runs once. +Read `props.count` in the JSX or in a memo, and check the console for [`STRICT_READ_UNTRACKED`](/reference/solid-js/diagnostics/strict-read-untracked). + +### `document is not defined` while rendering the page + +On the first load, client components in slots render on the server. +Keep browser APIs in effect functions, `onSettled`, or behind `isServer`, as for any component that renders on the server. + +## Recap + +- An element lives where the data that creates it lives: server data makes server markup, client state makes a client component in a slot. +- Type a slot with `Slot`, place it as an element, and fill it with a component on the client. +- A client component in a slot is a full client component, and its props update when the server renders again. +- Pass JSX as a slot argument to wrap server markup in client behavior; the client never rebuilds it. +- Add `$key` to occurrences or elements in a list that reorders, so state follows the entity. +- Behavior on server markup is a form, a client component in a slot, or one delegated listener. + +## Next steps + +- [Keep server components fresh](/building-apps/server-components/keeping-fresh): refresh the product view after a mutation, or keep it connected with `live()`. +- [Mutations](/concepts/mutations): actions and optimistic values, the client side of a toggle like `HelpfulToggle`. diff --git a/src/routes/(3)building-apps/(8)server-components/(2)keeping-fresh.mdx b/src/routes/(3)building-apps/(8)server-components/(2)keeping-fresh.mdx new file mode 100644 index 000000000..4739c4036 --- /dev/null +++ b/src/routes/(3)building-apps/(8)server-components/(2)keeping-fresh.mdx @@ -0,0 +1,356 @@ +--- +title: "Keep server components fresh" +version: "2.0" +description: "Choose how often a server component's markup is rendered: once at build time, on navigation and after mutations, or continuously over a live connection, one render per viewer or one shared by all." +--- + +The product view from [Server components](/building-apps/server-components) is rendered when the page asks for it. +Different parts of a store go stale at different speeds. +The product description changes when someone edits the catalog, the review count changes when a shopper votes, and the stock level can change while the page is open. + +How fresh a server component is belongs to the server function, not to the page. +The same component can be rendered at build time, on request, or continuously, and the code that mounts it does not change: + +| Freshness | Declare | Rendered | +| ---------------- | ------------------------------- | ----------------------------------------------- | +| Per build | `prerendered(fn)` | Once per call during `vite build` | +| Per request | `query(fn, key)` | On navigation, on preload, and after a mutation | +| Live, per viewer | `liveQuery(fn, key)` | Continuously, one render per connection | +| Live, shared | `liveQuery(broadcast(fn), key)` | Continuously, one render per set of arguments | + +## Refresh after a mutation + +A `query()`-wrapped server component is a cached read, so router revalidation refreshes it like any other query. +Post a mutation from the server's own markup with a form: + +```tsx +// src/data/product-view.tsx +import { formAction, query } from "@solidjs/router"; +import { database } from "./database"; + +export const reportReview = formAction(async (reviewId: string) => { + "use server"; + await database.reviews.report(reviewId); +}); + +export const getProductView = query(async (id: string) => { + "use server"; + const reviews = await database.reviews.forProduct(id); + + return () => ( +
      + {reviews.map((review) => ( +
    • +

      {review.text}

      + {review.reported ? ( + Reported + ) : ( + + +
    • + )} + + ))} +
    + ); +}, "product-view"); +``` + +Click **Report** and the review shows "Reported" when the call settles. +The form is server markup, so it works before JavaScript loads; after hydration the router submits it in place, sets `aria-busy` on the form while the submission is in flight, and then revalidates the queries on screen. +The product view's new markup is applied over the old one, so a half-typed review in a client component elsewhere in the view keeps its text. + +Style the in-flight state from `aria-busy` when the server's answer is the only thing that can decide the outcome: + +```css +form[aria-busy="true"] button { + opacity: 0.5; +} +``` + +When the client can predict the outcome, put the control in a client component with an optimistic value, as `HelpfulToggle` does in [Client components in server markup](/building-apps/server-components/client-components-in-slots#a-client-component-in-a-slot-is-a-full-client-component). + +### Answer the mutation and the new markup in one response + +By default, revalidation is a second round trip: the mutation answers, then the router refetches the product view. +Register the router's flight collector and the mutation's own response carries the new markup of every server component it invalidated, alongside its value: + +```ts +// vite.config.ts +solid({ + start: true, + ssr: true, + serverFunctions: { components: true, configure: "src/server-config.ts" }, +}); +``` + +```ts +// src/server-config.ts +import { configureServerFunctionsServer } from "@solidjs/web/server-functions/server"; +import { createFlightDataCollector } from "@solidjs/router/server"; +import { Router } from "./router"; + +configureServerFunctionsServer({ + collectFlightData: createFlightDataCollector(Router), +}); +``` + +`configure` names a module that runs in the server-function handler before any request is dispatched. +With the collector registered, a mutation that returns `redirect()` reruns the destination route's server calls and preloads, and one that does not reruns the route of the page the form was posted from. +Open the network tab and click **Report**: there is one `POST`, and no refetch follows it. +The `notes` and `todos-server` examples use this setup, and [Server integration](/reference/solid-router/server) documents the collector. + +## Cache a response over HTTP + +A `query()`-wrapped function is a `GET` read, so its response can carry cache headers. +Return the component through `respond()` to set them: + +```tsx +import { respond } from "@solidjs/web"; + +export const getCatalogPage = query(async (page: number) => { + "use server"; + const products = await database.products.page(page); + return respond(() => , { + headers: { "cache-control": "public, max-age=60" }, + }); +}, "catalog-page"); +``` + +Hover a link to the next catalog page and the router makes the call ahead of the click; the click then reads the same response from the browser's HTTP cache. +Cache only answers that are the same for every visitor. +[Reads, streams, and live data](/building-apps/server-functions/reads-and-live-data#declare-a-get-read) covers `GET` reads and what a cache may store. + +## Stream changes with `live()` + +A server component declared with `liveQuery` keeps its response open and sends what changed in its markup for as long as the page shows it. +An order's tracking panel is one: it depends on who is asking, and it changes while the shopper watches. + +```tsx +// src/data/order-status.tsx +import { createMemo, onCleanup } from "solid-js"; +import { liveQuery } from "@solidjs/router"; +import { orders, requireUser } from "./orders"; + +export const getOrderStatus = liveQuery(async (orderId: string) => { + "use server"; + const user = await requireUser(); + + return () => { + const gone = new AbortController(); + onCleanup(() => gone.abort()); + const order = createMemo(() => orders.watch(orderId, user.id, gone.signal)); + + return ( +
    +

    Order {orderId}

    +

    {order().status}

    +
      + {order().events.map((event) => ( +
    1. {event.label}
    2. + ))} +
    +
    + ); + }; +}, "order-status"); +``` + +`orders.watch` is a stand-in for your own source: an async iterable that yields the order as it stands now, then again after every change, and stops when the signal aborts. + +Mount it with `dynamicComponent` like any server component: + +```tsx +const OrderStatus = dynamicComponent(() => getOrderStatus(props.orderId)); + +Loading the order…

    }> + +
    ; +``` + +Open the order page and mark the order shipped from another window. +The status line and the new event appear without a reload, and only the parts of the markup that changed are sent. + +How it works: + +- The function body runs once per connection, so `requireUser()` checks the caller each time a connection opens. +- The component's render stays alive for the life of the response. + When a reactive source it reads produces a new value, the parts of the markup that depend on it render again, and the server sends only those parts; a list sends only the rows that changed. +- On the first page load, the document renders the source's first value into the HTML, and after hydration the browser connects to receive the rest. +- When the connection drops, the browser reconnects with backoff and tells the server which markup it already shows, so the server sends only what differs. +- When the shopper leaves the page, the connection closes, the render is disposed, and `onCleanup` aborts the watcher. + +Create the source inside the component, before the JSX, and hand it a signal that ends with the render. +A source created in the outer body has no render to end with, and a source created inside a piece of markup that re-renders starts over on every re-render. + +A live server component holds one connection per mount. +Over HTTP/1.1 a browser opens at most six connections to one origin, so serve live pages over HTTP/2; the Vite development server speaks HTTP/2 when `server.https` is set, and development warns past five live connections without it. +Mutations do not revalidate live keys, because the stream is already current. + +## Share one render with `broadcast()` + +`live()` renders once per viewer, which is right when the answer can depend on who is asking. +The stock panel on a product page shows the same thing to everyone looking at that product, so one render can serve them all. +Wrap the function in [`broadcast()`](/reference/solid-web/server-functions/broadcast) inside `liveQuery`: + +```tsx +// src/data/stock-panel.tsx +import { createMemo, onCleanup } from "solid-js"; +import { broadcast } from "@solidjs/web/server-functions"; +import { liveQuery } from "@solidjs/router"; +import { inventory } from "./inventory"; + +export const getStockPanel = liveQuery( + broadcast(async (productId: string) => { + "use server"; + return () => { + const gone = new AbortController(); + onCleanup(() => gone.abort()); + const stock = createMemo(() => inventory.watch(productId, gone.signal)); + + return ( +

    + {stock().count > 0 ? `${stock().count} left in stock` : "Sold out"} +

    + ); + }; + }), + "stock-panel" +); +``` + +Open the product in ten windows: the server runs one render for that product, serializes each change once, and writes the same bytes to all ten connections. +A different product id is a different render. + +What runs where: + +- The outer body runs for every caller, so an access check, a redirect, or `respond()` works as on any call, and a caller the body rejects never joins. +- The component it returns is shared by every caller with the same arguments, compared as they were encoded on the wire. +- The shared render runs under its own request event, so a request read inside it finds no request and development warns. + The router's `query` cache inside it starts empty, so a `getUser()` there cannot return the first viewer's user. +- A viewer who joins late receives the markup as it stands, then the shared changes. +- The render ends when its last viewer leaves, and the next caller starts a fresh one. + Sharing is per server process. + +The compiler rejects a shared render that reads the outer body's locals: + +```tsx +// Avoid: the first caller's user would be shown to every viewer +export const getStockPanel = broadcast(async (productId: string) => { + "use server"; + const user = await getUser(); + return () =>

    Hello {user.name}

    ; // compile error +}); + +// Prefer: check per caller in the body, read shared data inside the render +export const getStockPanel = broadcast(async (productId: string) => { + "use server"; + await requireShopper(); + return () => { + const gone = new AbortController(); + onCleanup(() => gone.abort()); + const stock = createMemo(() => inventory.watch(productId, gone.signal)); + return

    {stock().count} left in stock

    ; + }; +}); +``` + +The returned component may read the function's parameters, its own locals, and module scope. +Data the body loads from the arguments is rejected too, because the compiler cannot tell `await load(productId)` from `await getUser()`; load it inside the component. + +Choose `broadcast()` deliberately. +Whether a render depends on the caller cannot be detected, and sharing changes behavior even when nothing reads the request: effects and cleanups in the render run once per set of arguments, not once per viewer. + +## Render at build time with `prerendered()` + +A catalog page that changes only when the catalog is edited can be rendered once, during the build. +Wrap the server function in `prerendered()` from `@solidjs/prerender`, inside `query()`: + +```tsx +// src/data/product-view.tsx +import { query } from "@solidjs/router"; +import { prerendered } from "@solidjs/prerender"; + +export const getProductView = query( + prerendered(async (id: string) => { + "use server"; + const product = await database.products.find(id); + return () => ( +
    +

    {product.name}

    +
    +
    + ); + }), + "product-view" +); +``` + +Add the crawler to the plugins, after the Solid plugin: + +```ts +// vite.config.ts +import { defineConfig } from "vite"; +import solid from "@solidjs/vite-plugin"; +import { serverFunctions } from "@solidjs/prerender/integration"; +import { prerender } from "prerender-crawler/vite"; + +export default defineConfig({ + plugins: [ + solid({ start: true, ssr: true, serverFunctions: { components: true } }), + prerender({ mode: "static", integrations: [serverFunctions()] }), + ], +}); +``` + +Run `vite build`. +The crawler renders each page it can reach from `/`, and every `prerendered()` call it made is written to `dist/client/_static//` as the response a live server would have given, markup included. +On the deployed site, the first page's HTML already contains the product view, and navigating to another product fetches that call's file instead of calling a server. + +Outside a prerendered build, in development or in a build without the crawler, the function behaves as a `GET` read against a live server. +Only calls the crawl made have files: in a static build, a call with arguments no page used is rejected, and in a `hybrid` build it falls back to the live server. +[Choose a rendering mode](/guides/choose-a-rendering-mode#prerendered-at-build-time) covers the modes and the build guard that names any server function the deployed client could call but the crawl never captured. + +:::caution[Do not combine prerendered and live] +A live server component needs a server that holds its connection, which a static deployment does not have. +Nothing reports the combination yet, so keep `prerendered()` off functions declared with `live()`, `liveQuery`, or `broadcast()`. +::: + +## Common problems + +### The page never finishes loading and development warns `SSR_UNDECLARED_LIVE_SOURCE` + +A server component rendered into the document reads an async iterable that keeps producing, and the function is not declared live, so the document waits for the source to end. +Declare it with `liveQuery` or `live()`, so the document takes the first value and the browser connects for the rest, or end the source. +[`SSR_UNDECLARED_LIVE_SOURCE`](/reference/solid-js/diagnostics/ssr-undeclared-live-source) has the details. + +### Part of a live server component stops updating + +Development reports [`SSR_LIVE_HOLE_LATCHED`](/reference/solid-js/diagnostics/ssr-live-hole-latched). +A piece of markup that re-renders created an async source of its own, such as a memo over a fetch, so the runtime closed that piece and the browser keeps the markup it last received. +Create the source in the component body, before the JSX, and read it in the markup. + +### A `broadcast` function fails to compile, naming two lines + +The shared render reads a value the outer body computed. +Move the read inside the returned component; keep only per-caller checks, such as `requireShopper()`, in the body. + +### A live page stalls after a few tabs + +The server speaks HTTP/1.1, and the browser's connection limit for the origin is used up by open live responses. +Serve the page over HTTP/2. + +## Recap + +- Freshness is declared on the server function: `prerendered()`, `query()`, `liveQuery()`, or `liveQuery(broadcast())`. +- A `query()`-wrapped server component refreshes when the router revalidates, and its markup updates in place. +- Register `createFlightDataCollector` through `serverFunctions.configure` to answer a mutation and its new markup in one response. +- In a live server component, create the source inside the component and end it with the render. +- Use `broadcast()` only when every viewer with the same arguments sees the same markup; the outer body still runs per caller. +- Use `prerendered()` for markup that changes only when you rebuild, and never on a live function. + +## Next steps + +- [Client components in server markup](/building-apps/server-components/client-components-in-slots): keep client state through every refresh this page describes. +- [Reads, streams, and live data](/building-apps/server-functions/reads-and-live-data): the same `live()` and `broadcast()` declarations for data instead of markup. +- [Durable generation](/building-apps/server-functions/durable-generation#server-owned-the-thread-as-a-live-server-component): a live server component that renders an AI reply as it is written. diff --git a/src/routes/(7)glossary.mdx b/src/routes/(7)glossary.mdx index acd51c292..905db845e 100644 --- a/src/routes/(7)glossary.mdx +++ b/src/routes/(7)glossary.mdx @@ -423,6 +423,13 @@ When given, it is a complete value of the store's type, not a partial one. See [Derive a store with a projection](/concepts/stores#derive-a-store-with-a-projection). +### Server component + +A server function whose answer is a component (experimental preview). +The server renders it and the browser receives only its markup; the component's props are slots, positions where the client places its own components. + +See [Server components](/building-apps/server-components). + ### Server function A function whose body runs on the server because its first statement is `"use server"`. diff --git a/src/routes/reference/(2)solid-web/(5)server-functions/(0)index.mdx b/src/routes/reference/(2)solid-web/(5)server-functions/(0)index.mdx index 6afd542ef..a29071891 100644 --- a/src/routes/reference/(2)solid-web/(5)server-functions/(0)index.mdx +++ b/src/routes/reference/(2)solid-web/(5)server-functions/(0)index.mdx @@ -39,4 +39,4 @@ The configuration hooks and wire decoders are integration-tier APIs. Application code normally uses a generated reference, the response helpers in `@solidjs/web`, and an optional router data layer. Server components and the `@solidjs/web/frames` transport remain experimental. -They are not part of the stable server-function reference. +They are not part of the stable server-function reference; [Server components](/building-apps/server-components) covers them as a preview.