Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/web-dynamic-component.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/web": patch
---

Add `dynamicComponent`, the component-only sibling of `dynamic` (client and server entries). Same contract, semantics and hydration shape as `dynamic` — the two share one implementation — but its source type excludes tag names, and it never references the element runtime: `dynamic` must be able to render a tag, so one `dynamic` on a page retains `createElement`, `spread`, the prop-collection helpers and the SVG/MathML tables for everyone; `dynamicComponent` never does. It is the documented way to mount a server component (`dynamicComponent(() => getStory(id))`); a server-component page mounted through it sheds ≈ 2.2 KB brotli. `dynamic` is unchanged.
Original file line number Diff line number Diff line change
Expand Up @@ -2156,7 +2156,7 @@ const toggleAll = action(function* (ids: string[], completed: boolean) {
yield toggleAllTodos(ids, completed);
});

const Todos = dynamic(() => getTodos(filter()));
const Todos = dynamicComponent(() => getTodos(filter()));

<Todos
row={p => ({ class: { completed: done(p) }, hidden: !!pending.byId[p.id]?.removed })}
Expand Down
15 changes: 8 additions & 7 deletions documentation/server-components/server-components.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,22 +130,23 @@ thing here.

## Using it from the client

There is no server-component API on the client. `dynamic` — the same
utility you'd use to swap any component — is the whole surface:
There is no server-component API on the client. `dynamicComponent` — the
component-only form of `dynamic`, the same utility you'd use to swap any
component — is the whole surface:

```tsx
import { dynamic } from "@solidjs/web";
import { dynamicComponent } from "@solidjs/web";

function StoryPage(props) {
const [collapsedAll, setCollapsedAll] = createSignal(false);

// The source is tracked: when props.storyId changes it re-calls the
// server function. Every call for the same (function, args) resolves to
// the IDENTICAL component — a refetch passes dynamic's equals-gate, so
// the IDENTICAL component — a refetch passes the equals-gate, so
// nothing remounts and the stream morphs the boundary in place. A new
// storyId resolves that story's own boundary and the site swaps to it,
// seeded from retained content when the story has shown before.
const Story = dynamic(() => getStory(props.storyId));
const Story = dynamicComponent(() => getStory(props.storyId));

return (
<Story
Expand Down Expand Up @@ -223,7 +224,7 @@ own:

- **Wrap the section function in `query`** and route-level `preload` warms
it on intent — the response's chunks buffer until a boundary mounts,
then drain. The `dynamic()` read resolves through the same cached
then drain. The `dynamicComponent()` read resolves through the same cached
in-flight call, and a later fresh cache hit re-materializes the boundary
from retained state with no request at all — back/forward navigation
renders like a bfcache restore.
Expand Down Expand Up @@ -365,7 +366,7 @@ must not break:
### What a router does with this

A router integration is thin by design: translate URL changes into
server-function calls and let a `dynamic` source (or
server-function calls and let a `dynamicComponent` source (or
`applyFrameResponse(response, host, { as })` directly) read them — the
router names nothing, because the calls themselves name the boundaries
(invariant 3). Wrapping the section functions in the router's `query`
Expand Down
1 change: 1 addition & 0 deletions documentation/solid-2.0/03-control-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,7 @@ const Article = dynamic(() => loadArticle(params.id), { deferStream: true });
#### Notes

- The source evaluation is shared across all mounted instances of the returned component, so using one `dynamic(...)` in many places doesn't duplicate work.
- Because a source may answer with a tag name, one `dynamic` on a page retains the element runtime (`spread`, the attribute helpers, the namespace tables) for everyone; `dynamicComponent(source)` is the same primitive for a source that only ever answers with a component — a server component mount ([RFC 11](11-server-components.md#using-one)) — and never does.

### Client-only components: `clientOnly`

Expand Down
2 changes: 1 addition & 1 deletion documentation/solid-2.0/10-server-functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ export const feed = live(
);

// client
const Feed = dynamic(() => feed(props.room));
const Feed = dynamicComponent(() => feed(props.room));
```

The client's binding reconnects on death through `live`'s loop; the document render takes first values from the component's sources and hands off (the brand on the component marks the frame's scope); hydration adopts the document's markup and the loop reconnects from there. How frames make the resume _quiet_ — a stable binding identity per address, conditional re-emission by hole — is frames' business, recorded in `documentation/server-components/server-components-principles.md` §9.5. What is RFC-10-level is that liveness has one declaration, one loop, and one status surface across both tiers.
Expand Down
10 changes: 6 additions & 4 deletions documentation/solid-2.0/11-server-components.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

## Summary

A **server component is a function returned from a server function** — there is no new component API, no new directive, and no `"use client"`. The server function’s _arguments_ are the server’s inputs (ids, filters); the returned component’s _props_ are client positions (**slots**) the server marks but never renders. On the client, `dynamic` is the entire consumption surface: a server-function call that resolves to a frame stream produces a **stable per-call component** — one boundary per (function, arguments), the same per-args rule a query cache keys values by — so refetches of the same call never remount: server content **morphs in place** underneath and client state inside the boundary (focus, inputs, toggles, video) survives server updates. A call with different arguments resolves its own boundary; the site swaps to it, re-materialized instantly from retained state when that call has shown before.
A **server component is a function returned from a server function** — there is no new component API, no new directive, and no `"use client"`. The server function’s _arguments_ are the server’s inputs (ids, filters); the returned component’s _props_ are client positions (**slots**) the server marks but never renders. On the client, `dynamicComponent` — `dynamic`'s component-only form — is the entire consumption surface: a server-function call that resolves to a frame stream produces a **stable per-call component** — one boundary per (function, arguments), the same per-args rule a query cache keys values by — so refetches of the same call never remount: server content **morphs in place** underneath and client state inside the boundary (focus, inputs, toggles, video) survives server updates. A call with different arguments resolves its own boundary; the site swaps to it, re-materialized instantly from retained state when that call has shown before.

The governing invariant is **single-copy**: server content travels as HTML, values the client needs travel as data records, and nothing travels as both. The acceptance test is literal — view-source the page or a navigation response and search for any piece of content; it appears exactly once.

Expand Down Expand Up @@ -64,7 +64,7 @@ A complete working setup (no Vite, no metaframework) is `examples/hackernews`; i
## Motivation

- **Islands fall apart on navigation; RSC ships everything twice.** Islands architectures give a lean initial page but degenerate to full-page loads or bespoke protocols when you navigate. RSC-style server components keep rich composition but serialize the rendered tree alongside the HTML — every piece of server content pays twice. This design — _lakes, not islands_ — keeps one copy: the server owns and streams content, the client owns islands of interactivity **inside** it, and neither re-sends what the other has.
- **No new API surface.** Every prior server-components design grew a parallel component model. Here the entire client surface is `dynamic` + server functions (RFC 10) plus one `installServerComponents()` call. Boundary identity is **derived, never declared** — the call’s intrinsic (function, arguments) address keys the boundary, mirroring a data layer’s cache keys by construction, so panes over different calls are independent with nothing annotated and multi-instance mounting fans one stream out to every mounted frame.
- **No new API surface.** Every prior server-components design grew a parallel component model. Here the entire client surface is `dynamicComponent` + server functions (RFC 10) plus one `installServerComponents()` call. Boundary identity is **derived, never declared** — the call’s intrinsic (function, arguments) address keys the boundary, mirroring a data layer’s cache keys by construction, so panes over different calls are independent with nothing annotated and multi-instance mounting fans one stream out to every mounted frame.
- **Client state must survive server updates.** The failure mode that kills server-driven UIs is the refetch that blows away a half-typed reply. Policy here is structural: refetching into the same boundary morphs server content in place; teardown is disposal, never a version bump.

## Detailed design
Expand Down Expand Up @@ -102,7 +102,7 @@ async function getStory(id: number) {

```tsx
function StoryPage(props) {
const Story = dynamic(() => getStory(props.storyId));
const Story = dynamicComponent(() => getStory(props.storyId));
return (
<Story comment={p => <CollapsibleComment cid={p.cid}>{p.children}</CollapsibleComment>}>
<ShareBar />
Expand All @@ -111,7 +111,9 @@ function StoryPage(props) {
}
```

Navigation is a prop change: the tracked source re-calls the server function. A same-arguments refetch resolves the _same_ component reference (equals-gated — `dynamic` never remounts) and the stream morphs the boundary; a new `storyId` resolves that story’s own boundary and the site swaps to it — instantly, from retained content, when the story has shown before. Client-only state never reaches the server. State _inside_ a boundary belongs to its call (`CollapsibleComment`’s toggles reset per story — story 1’s collapse state never bleeds into story 2), while state _outside_ the boundary (`StoryPage`’s own signals) survives every navigation. First load composes with `<Loading>`; refetches don’t re-fallback.
`dynamicComponent` is `dynamic` for a source that only ever answers with a component ([RFC 03](03-control-flow.md#dynamic-components-the-dynamic-factory)): the same contract and the same implementation, minus the tag arm — `dynamic` has to be able to render a tag name, so one `dynamic` on a page retains the element runtime (`spread` and the attribute helpers) for everyone; a server-component page that mounts with `dynamicComponent` never pays for it.

Navigation is a prop change: the tracked source re-calls the server function. A same-arguments refetch resolves the _same_ component reference (equals-gated — `dynamicComponent` never remounts) and the stream morphs the boundary; a new `storyId` resolves that story’s own boundary and the site swaps to it — instantly, from retained content, when the story has shown before. Client-only state never reaches the server. State _inside_ a boundary belongs to its call (`CollapsibleComment`’s toggles reset per story — story 1’s collapse state never bleeds into story 2), while state _outside_ the boundary (`StoryPage`’s own signals) survives every navigation. First load composes with `<Loading>`; refetches don’t re-fallback.

### What routers get

Expand Down
39 changes: 34 additions & 5 deletions packages/web/src/index.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,36 @@ export function dynamic<T extends ValidComponent>(
source: () => T | Promise<T> | null | undefined | false,
options?: DynamicOptions
): Component<ComponentProps<T>> {
return dynamicCore(source, options, ssrTag);
}

/**
* The server twin of the client's `dynamicComponent`: `dynamic` for a source
* that only ever answers with a component, never a tag name. Same owner
* shape as `dynamic` on both sides (the factory / value / render memos), so
* hydration ids agree with the client whichever of the two mounts the same
* value. The client entry's JSDoc carries the cost model; here the tag arm
* is simply absent.
*/
export function dynamicComponent<C extends Component<any>>(
source: () => C | Promise<C> | null | undefined | false,
options?: DynamicOptions
): Component<ComponentProps<C>> {
return dynamicCore(source, options);
}

/** `dynamic`'s string arm on the server: a tag name is one `ssrElement()`. */
type TagArm = (tag: string, props: any) => JSX.Element;
const ssrTag: TagArm = (tag, props) =>
ssrElement(tag, props, undefined, true) as unknown as JSX.Element;

// The shared implementation behind `dynamic` and `dynamicComponent`; the tag
// arm is the only thing that differs (what a string value renders as).
function dynamicCore(
source: () => any,
options: DynamicOptions | undefined,
tagArm?: TagArm
): Component<any> {
// Static: the same owner-free path as the client — a tag is one
// ssrElement(), a component one call — so both sides allocate the same
// hydration keys. No memo on either level, so nothing to serialize or hold.
Expand All @@ -125,8 +155,7 @@ export function dynamic<T extends ValidComponent>(
if (isDev && component && typeof component.then === "function")
throw new Error("dynamic(): a static source must resolve synchronously, not to a promise");
if (typeof component === "function") return props => (component as Function)(props);
if (typeof component === "string")
return props => ssrElement(component, props, undefined, true) as unknown as JSX.Element;
if (typeof component === "string" && tagArm) return props => tagArm(component, props);
return () => undefined as unknown as JSX.Element;
}
// Mirrors the client exactly — three memos, the same owner shape on both
Expand Down Expand Up @@ -238,9 +267,9 @@ export function dynamic<T extends ValidComponent>(
const c: unknown = value();
if (c) {
if (typeof c === "function") return (c as Function)(props);
if (typeof c === "string") {
return ssrElement(c, props, undefined, true) as unknown as JSX.Element;
}
// `dynamic` only: `dynamicComponent` has no tag arm, so a string
// renders nothing there — as it does on the client.
if (typeof c === "string" && tagArm) return tagArm(c, props);
}
},
{ sync: true } as any
Expand Down
78 changes: 74 additions & 4 deletions packages/web/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,12 +332,79 @@ export interface DynamicOptions {
* (as a reference) or a serializable value (a tag name). A promise of a
* client component function is a dev error on the server
* (`DYNAMIC_ASYNC_COMPONENT`): resolve the async upstream, or use `lazy()`.
*
* Cost: because a source may answer with a tag name, one `dynamic` on a page
* retains the element runtime (create/claim, `spread`, the namespace tables)
* for everyone. A source that only ever answers with a component — a server
* component mount — should use `dynamicComponent`, which never does.
*/
export function dynamic<T extends ValidComponent>(
source: () => T | Promise<T> | AsyncIterable<T> | null | undefined | false,
options?: DynamicOptions
): Component<ComponentProps<T>> {
if (options?.static) return staticDynamic(untrack(source));
return dynamicCore(source, options, staticElement);
}

/**
* `dynamic` for a source that only ever answers with a component — never a
* tag name. Same contract, same semantics, same hydration shape (the two
* share one implementation): a reactive, optionally async source; a stable
* `Component` back; `{ static: true }` and `{ deferStream: true }` as on
* `dynamic`. The source's type excludes strings, so a tag name is a compile
* error here — use `dynamic` for one.
*
* Cost model, which is the reason this exists: `dynamic` must be able to
* render a tag, so one `dynamic` anywhere on a page retains the element
* runtime — `createElement`, `spread` and the prop-collection helpers, the
* SVG/MathML tables — for everyone, whether or not any source ever answers
* with a string (a bundler cannot know what a source will resolve to).
* `dynamicComponent` has no tag arm and never references that runtime, so a
* page whose only dynamic mounts are components pays nothing for it.
*
* This is the documented way to mount a server component: a server
* function's answer is a component reference, and the frames transport
* resolves every call for the same function to the same mount identity, so
* a refetch or an argument change is delivered into the mounted instance
* rather than remounting it (see `dynamic` for the full account).
*
* @example
* ```tsx
* const Story = dynamicComponent(() => getStory(props.storyId));
* return <Story comment={p => <Comment cid={p.cid}>{p.children}</Comment>} />;
*
* // A client component chosen by (sync) data — the same thing `dynamic`
* // does, without retaining the element runtime for the page.
* const Page = dynamicComponent(() => (page().editable ? Editor : Viewer));
* ```
*/
export function dynamicComponent<C extends Component<any>>(
source: () => C | Promise<C> | AsyncIterable<C> | null | undefined | false,
options?: DynamicOptions
): Component<ComponentProps<C>> {
return dynamicCore(source, options);
}

/**
* How a tag name a source resolved to becomes an element — `dynamic`'s
* string arm (`staticElement`: create or claim, spread, replay hydration
* events). The core takes it as a PARAMETER so that it never names the
* element runtime itself: `dynamic` passes it, `dynamicComponent` passes
* nothing, and a bundle whose only consumer is `dynamicComponent` sheds
* `staticElement` and everything under it.
*/
type TagArm = (tag: string, props: any) => JSX.Element;

// The shared implementation behind `dynamic` and `dynamicComponent`. Every
// line here is load-bearing for both (the pins listed in
// documentation/plans/frames-b3-sync.md §3): the factory/value/render memo
// shape, the FLIGHT box, kept-resolution delivery, the live address accessor.
// The only thing the tag arm decides is what a string value renders as.
function dynamicCore(
source: () => any,
options: DynamicOptions | undefined,
tagArm?: TagArm
): Component<any> {
if (options?.static) return staticDynamic(untrack(source), tagArm);
// `prev` threads into the resolution so a source switching server-component
// calls of the same function DELIVERS instead of swapping: the memo keeps
// its previous value (the mount below never re-renders) and the new call's
Expand Down Expand Up @@ -499,7 +566,10 @@ export function dynamic<T extends ValidComponent>(
}

case "string":
return staticElement(component, props);
// `dynamic` only: `dynamicComponent` has no tag arm (its source
// type excludes strings), so a string there renders nothing, like
// any other non-component value.
return tagArm ? tagArm(component, props) : undefined;

default:
break;
Expand All @@ -512,7 +582,7 @@ export function dynamic<T extends ValidComponent>(
// memo, no per-instance memo — the instance IS the element or the component
// call, owner-free like compiled JSX, so the server's static path (the same
// rule) produces the same hydration keys.
function staticDynamic(component: any): Component<any> {
function staticDynamic(component: any, tagArm?: TagArm): Component<any> {
if (isDev && component && typeof component.then === "function")
throw new Error("dynamic(): a static source must resolve synchronously, not to a promise");
if (typeof component === "function") {
Expand All @@ -526,7 +596,7 @@ function staticDynamic(component: any): Component<any> {
}
return props => untrack(() => component(props));
}
if (typeof component === "string") return props => staticElement(component, props);
if (typeof component === "string" && tagArm) return props => tagArm(component, props);
return () => undefined as unknown as JSX.Element;
}

Expand Down
Loading