From 99915141a70f20bb5805bbdb4f2f3b47a3dc98f2 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Wed, 7 Oct 2026 08:47:21 -0700 Subject: [PATCH] =?UTF-8?q?web:=20dynamicComponent=20=E2=80=94=20component?= =?UTF-8?q?-only=20sibling=20of=20dynamic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ruling (2026-10-07): `dynamic` stays the combo (component or tag name); `dynamicComponent` is the component-only sibling; the server-component docs mount with `dynamicComponent(() => getStory())`. Option (a) of documentation/plans/frames-b3-sync.md §5. `dynamic` is factored into a shared core and the tag arm: `dynamicCore( source, options, tagArm?)` is the whole implementation — the hoisted lazy factory memo (FLIGHT box, never re-runs for a reset), the per-instance value memo (async-memo adoption under hydration, the `latest` token, the `untrack(cached)` warm-up), the three-memo owner shape matching index.server.ts, the render memo's per-site `createSignal(address, { ownedWrite })` + `sites` + `untrack(() => binding.component(props, address))`, kept-resolution delivery (`resolveBinding` / `sameInstance` / `deliveredAddress`), `static: true`. The only thing the arm decides is what a string value renders as: `dynamic` passes `staticElement`, `dynamicComponent` passes nothing. The core never names the element runtime, so a bundle whose only consumer is `dynamicComponent` sheds `staticElement`, `createElement`, `spread`, the prop-collection helpers, the SVG/MathML tables and `getNextElement`. Server twin in index.server.ts, same factoring (the arm is one `ssrElement()`), identical hydration-id consumption: the parity spec renders one page — a client component and a non-live server component reference — through both entry points and asserts the documents are byte-identical (same `_hk` keys, same frame markup, same `_fr` record), inline and streamed; four hydration arms replay the `dynamicComponent` document through each client entry point (adopted, no request, same nodes, both slots live). Type: `dynamicComponent>(source: () => C | Promise | AsyncIterable | null | undefined | false, options?)` — no `string` in the union. A server-component reference has no type-level brand: it is typed as the server function's declared answer, a `Component

` (`LiveSource>` for `live`, an intersection that is still one), so `Component` covers it. The type test pins a tag-name source as a compile error here and fine on `dynamic`. Pins: A7 reset/re-ask (9 arms), C16, C17 parametrized over both entry points; every other `dynamic` pin unchanged (shared code). Size (built, vs base d9395e398): page base −7,417 min / −2,152 br → 33,570; page live −7,418 / −2,171 → 37,241; every other scenario 0 / 0. Floors ratcheted to 33.58 / 37.26 KB (measured + 10 B at the 0.01 KB step). The edited-dist model said −2,158 / −2,237 before the source was written. Fixtures (sc-base-app, sc-live-app) and the server-component docs' mount examples switched to `dynamicComponent`; `dynamic`'s docs carry a one-line pointer. Public API: new export `dynamicComponent` on `@solidjs/web` (client and server entries). `dynamic` unchanged. Co-authored-by: Claude via Cursor --- .changeset/web-dynamic-component.md | 5 + .../server-components-principles.md | 2 +- .../server-components/server-components.md | 15 +- documentation/solid-2.0/03-control-flow.md | 1 + .../solid-2.0/10-server-functions.md | 2 +- .../solid-2.0/11-server-components.md | 10 +- packages/web/src/index.server.ts | 39 +++- packages/web/src/index.ts | 78 +++++++- .../c16-reference-identity.spec.tsx | 24 ++- .../c17-gate-bound-address.spec.tsx | 30 ++-- .../web/test/dynamic-component.type-tests.tsx | 67 +++++++ .../frames-errored-reset-refetch.spec.tsx | 40 +++-- .../dynamic-component-parity-inline.json | 5 + .../dynamic-component-parity-streamed.json | 5 + .../test/harness/dynamic-component-parity.tsx | 85 +++++++++ ...mponent-parity-inline-via-dynamic.spec.tsx | 15 ++ .../dynamic-component-parity-inline.spec.tsx | 14 ++ .../dynamic-component-parity-run.tsx | 167 ++++++++++++++++++ ...onent-parity-streamed-via-dynamic.spec.tsx | 14 ++ ...dynamic-component-parity-streamed.spec.tsx | 15 ++ .../server/dynamic-component-parity.spec.tsx | 119 +++++++++++++ scripts/size/floor-caps.json | 8 +- scripts/size/sc-base-app.js | 17 +- scripts/size/sc-live-app.js | 4 +- scripts/size/scenarios.js | 15 ++ 25 files changed, 731 insertions(+), 65 deletions(-) create mode 100644 .changeset/web-dynamic-component.md create mode 100644 packages/web/test/dynamic-component.type-tests.tsx create mode 100644 packages/web/test/harness/__artifacts__/dynamic-component-parity-inline.json create mode 100644 packages/web/test/harness/__artifacts__/dynamic-component-parity-streamed.json create mode 100644 packages/web/test/harness/dynamic-component-parity.tsx create mode 100644 packages/web/test/hydration/dynamic-component-parity-inline-via-dynamic.spec.tsx create mode 100644 packages/web/test/hydration/dynamic-component-parity-inline.spec.tsx create mode 100644 packages/web/test/hydration/dynamic-component-parity-run.tsx create mode 100644 packages/web/test/hydration/dynamic-component-parity-streamed-via-dynamic.spec.tsx create mode 100644 packages/web/test/hydration/dynamic-component-parity-streamed.spec.tsx create mode 100644 packages/web/test/server/dynamic-component-parity.spec.tsx diff --git a/.changeset/web-dynamic-component.md b/.changeset/web-dynamic-component.md new file mode 100644 index 000000000..70d51cd44 --- /dev/null +++ b/.changeset/web-dynamic-component.md @@ -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. diff --git a/documentation/server-components/server-components-principles.md b/documentation/server-components/server-components-principles.md index ae09535af..d90c2fbf9 100644 --- a/documentation/server-components/server-components-principles.md +++ b/documentation/server-components/server-components-principles.md @@ -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())); ({ class: { completed: done(p) }, hidden: !!pending.byId[p.id]?.removed })} diff --git a/documentation/server-components/server-components.md b/documentation/server-components/server-components.md index 23d0fc0a7..ee46dac40 100644 --- a/documentation/server-components/server-components.md +++ b/documentation/server-components/server-components.md @@ -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 ( 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` diff --git a/documentation/solid-2.0/10-server-functions.md b/documentation/solid-2.0/10-server-functions.md index 29ee8c416..d279e87f8 100644 --- a/documentation/solid-2.0/10-server-functions.md +++ b/documentation/solid-2.0/10-server-functions.md @@ -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. diff --git a/documentation/solid-2.0/11-server-components.md b/documentation/solid-2.0/11-server-components.md index aa6e65479..f5cbf0f83 100644 --- a/documentation/solid-2.0/11-server-components.md +++ b/documentation/solid-2.0/11-server-components.md @@ -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. @@ -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 @@ -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 ( {p.children}}> @@ -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 ``; 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 ``; refetches don’t re-fallback. ### What routers get diff --git a/packages/web/src/index.server.ts b/packages/web/src/index.server.ts index 27c4f7894..5fb1ba377 100644 --- a/packages/web/src/index.server.ts +++ b/packages/web/src/index.server.ts @@ -117,6 +117,36 @@ export function dynamic( source: () => T | Promise | null | undefined | false, options?: DynamicOptions ): Component> { + 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>( + source: () => C | Promise | null | undefined | false, + options?: DynamicOptions +): Component> { + 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 { // 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. @@ -125,8 +155,7 @@ export function dynamic( 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 @@ -238,9 +267,9 @@ export function dynamic( 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 diff --git a/packages/web/src/index.ts b/packages/web/src/index.ts index 381c3e5df..8f463cfc6 100644 --- a/packages/web/src/index.ts +++ b/packages/web/src/index.ts @@ -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( source: () => T | Promise | AsyncIterable | null | undefined | false, options?: DynamicOptions ): Component> { - 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 {p.children}} />; + * + * // 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>( + source: () => C | Promise | AsyncIterable | null | undefined | false, + options?: DynamicOptions +): Component> { + 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 { + 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 @@ -499,7 +566,10 @@ export function dynamic( } 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; @@ -512,7 +582,7 @@ export function dynamic( // 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 { +function staticDynamic(component: any, tagArm?: TagArm): Component { 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") { @@ -526,7 +596,7 @@ function staticDynamic(component: any): Component { } 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; } diff --git a/packages/web/test/consistency/c16-reference-identity.spec.tsx b/packages/web/test/consistency/c16-reference-identity.spec.tsx index 2e18e99bf..adc7821de 100644 --- a/packages/web/test/consistency/c16-reference-identity.spec.tsx +++ b/packages/web/test/consistency/c16-reference-identity.spec.tsx @@ -26,8 +26,8 @@ * plain roots (a fill's claim is C1/C10's business). */ import { afterEach, describe, expect, test, vi } from "vitest"; -import { createMemo, createRoot, createSignal, Loading } from "solid-js"; -import { dynamic, hydrate } from "@solidjs/web"; +import { createMemo, createRoot, createSignal, Loading, type Component } from "solid-js"; +import { dynamic, dynamicComponent, hydrate } from "@solidjs/web"; import { installServerComponents } from "../../frames/src/client.js"; import { COMPONENT_BINDING, @@ -106,13 +106,23 @@ afterEach(async () => { document.body.innerHTML = ""; }); +// Both entry points: `dynamic` and its component-only sibling +// `dynamicComponent` share one implementation (`bindingOf`, `sameInstance`, +// the delivery into mounted sites included); the sibling is the documented +// server-component mount, so the rule is pinned on it by name. +type Dyn = (source: () => any) => Component; +const VIA: ReadonlyArray<[string, Dyn]> = [ + ["dynamic", dynamic], + ["dynamicComponent", dynamicComponent] +]; + /** * A site over `source`. The document face hydrates INTO `container` (the * shell is the bare ``); the dom face mounts a fresh root under * a ``, appended to `container`. */ -function mountSite(source: () => unknown, container: Element, face: "document" | "dom") { - const Site = dynamic(() => source() as any); +function mountSite(dyn: Dyn, source: () => unknown, container: Element, face: "document" | "dom") { + const Site = dyn(() => source() as any); let div: Element = container; if (face === "document") { disposers.push( @@ -147,7 +157,7 @@ function mountSite(source: () => unknown, container: Element, face: "document" | }; } -describe("C16 — one component identity per function", () => { +describe.each(VIA)("C16 — one component identity per function — via %s", (_via, dyn) => { // Arms (a), (b), (d) on one document page: the hydration reference // (`_$SC.r(fid, A)`) mounts first and adopts the SSR'd element; then a // refetch of A (staged — the site shows A), a switch to B, a re-call of A. @@ -175,7 +185,7 @@ describe("C16 — one component identity per function", () => { resolutions.push(Promise.resolve(call)); return call; }; - const site = mountSite(source, page.container, "document"); + const site = mountSite(dyn, source, page.container, "document"); await quiesce(); await quiesce(); const el = site.frame()!; @@ -259,7 +269,7 @@ describe("C16 — one component identity per function", () => { }) ); const list = createRoot(() => createMemo(() => (cache().list ?? getX()) as any)); - const site = mountSite(() => list(), document.body, "dom"); + const site = mountSite(dyn, () => list(), document.body, "dom"); await pump(); for (const c of chunks(fid, 1, "v1")) held[0].send(c); held[0].close(); diff --git a/packages/web/test/consistency/c17-gate-bound-address.spec.tsx b/packages/web/test/consistency/c17-gate-bound-address.spec.tsx index 2c6690bf0..6a8c2d118 100644 --- a/packages/web/test/consistency/c17-gate-bound-address.spec.tsx +++ b/packages/web/test/consistency/c17-gate-bound-address.spec.tsx @@ -22,8 +22,8 @@ * every distinct text the site showed is recorded (`watchFrames`). */ import { afterEach, describe, expect, test, vi } from "vitest"; -import { createRoot, createSignal, Errored, Loading } from "solid-js"; -import { dynamic } from "@solidjs/web"; +import { createRoot, createSignal, Errored, Loading, type Component } from "solid-js"; +import { dynamic, dynamicComponent } from "@solidjs/web"; import { installServerComponents } from "../../frames/src/client.js"; import { createServerReference } from "../../server-functions/src/client.js"; import { freshFid, heldStream, makeHost, pump, stubHeldFetch, watchFrames } from "./support.js"; @@ -52,9 +52,19 @@ afterEach(() => { // nearest client `` as any rejected `createAsync` does — without // one, the core halts the reactive system. Its fallback is an ``, so // `pending` (the `` fallback) still reads the alone. -function mountSite(getX: (...args: any[]) => unknown) { +// Both entry points: `dynamic` and its component-only sibling +// `dynamicComponent` share one implementation (the address accessor the gate +// is bound through included); the sibling is the documented server-component +// mount, so the rule is pinned on it by name. +type Dyn = (source: () => any) => Component; +const VIA: ReadonlyArray<[string, Dyn]> = [ + ["dynamic", dynamic], + ["dynamicComponent", dynamicComponent] +]; + +function mountSite(dyn: Dyn, getX: (...args: any[]) => unknown) { const [n, setN] = createSignal(1); - const Site = dynamic(() => getX(n()) as any); + const Site = dyn(() => getX(n()) as any); let div!: HTMLDivElement; const dispose = createRoot(d => {

@@ -75,7 +85,7 @@ function mountSite(getX: (...args: any[]) => unknown) { return { div, frames: watch.frames, pending, h1, error, setN }; } -describe("C17 — the shell gate answers only to the bound address", () => { +describe.each(VIA)("C17 — the shell gate answers only to the bound address (%s)", (_, dyn) => { // Arm (a): A's stream is held open after its header (the mount pends on // A's landing, the fallback shows); the site switches to B (B's header // resolved and the switch delivered — the second request is out); then @@ -100,7 +110,7 @@ describe("C17 — the shell gate answers only to the bound address", () => { installServerComponents(makeHost().host); const { held, calls } = stubHeldFetch([WIRE, WIRE]); const [a, b] = held; - const site = mountSite(getX); + const site = mountSite(dyn, getX); await pump(); a.send(start); await pump(1); @@ -149,7 +159,7 @@ describe("C17 — the shell gate answers only to the bound address", () => { installServerComponents(makeHost().host); const { held } = stubHeldFetch([WIRE, WIRE]); const [a, b] = held; - const site = mountSite(getX); + const site = mountSite(dyn, getX); await pump(); a.send(start); await pump(1); @@ -198,7 +208,7 @@ describe("C17 — the shell gate answers only to the bound address", () => { await bHeader; return b.response; }); - const site = mountSite(getX); + const site = mountSite(dyn, getX); await pump(); a.send(start); await pump(1); @@ -238,7 +248,7 @@ describe("C17 — the shell gate answers only to the bound address", () => { installServerComponents(makeHost().host); const { held } = stubHeldFetch([WIRE, WIRE]); const [a, b] = held; - const site = mountSite(getX); + const site = mountSite(dyn, getX); await pump(); a.send(start); await pump(1); @@ -268,7 +278,7 @@ describe("C17 — the shell gate answers only to the bound address", () => { installServerComponents(makeHost().host); const { held } = stubHeldFetch([WIRE]); const [a] = held; - const site = mountSite(getX); + const site = mountSite(dyn, getX); await pump(); a.send(start); await pump(1); diff --git a/packages/web/test/dynamic-component.type-tests.tsx b/packages/web/test/dynamic-component.type-tests.tsx new file mode 100644 index 000000000..2d6cbbf71 --- /dev/null +++ b/packages/web/test/dynamic-component.type-tests.tsx @@ -0,0 +1,67 @@ +/** @jsxImportSource @solidjs/web */ + +// `dynamicComponent` is `dynamic` with the tag arm removed, and its SOURCE +// TYPE is the user's guard: a source that can answer with a tag name is a +// compile error here and fine on `dynamic`. A server component reference is +// typed as the server function's declared answer — a `Component

` (or +// `LiveSource>` for a `live` declaration, an intersection that +// is still a `Component

`) — so it needs no special case in the union. +import { dynamic, dynamicComponent } from "@solidjs/web"; +import type { LiveSource } from "@solidjs/web/server-functions/client"; +import type { Component, Element as SolidElement } from "solid-js"; + +declare const Editor: Component<{ value: string }>; +declare const Viewer: Component<{ value: string }>; +declare const editing: () => boolean; +declare const tag: () => "input" | "textarea"; + +// A server function answering with a server component: on the client its +// call is a promise of the component; a `live` declaration's call is the +// branded iterable, typed as the component itself. +declare function getStory( + id: number +): Promise SolidElement }>>; +declare function feed(room: string): LiveSource SolidElement }>>; + +// --- dynamicComponent: components only ----------------------------------- + +const Active = dynamicComponent(() => (editing() ? Editor : Viewer)); +; +// @ts-expect-error props are the resolved component's +; + +const Story = dynamicComponent(() => getStory(1)); + {p.cid}} />; +// @ts-expect-error the slot's props are the server component's + p.nope} />; + +const Feed = dynamicComponent(() => feed("room")); + } />; + +dynamicComponent(() => (editing() ? Editor : undefined)); +dynamicComponent(() => (editing() ? Editor : null)); +dynamicComponent(() => editing() && Editor); +dynamicComponent(() => Editor, { static: true }); +dynamicComponent(() => getStory(1), { deferStream: true }); + +// A tag name is not a component — use `dynamic` for one. +// @ts-expect-error a tag name +dynamicComponent(() => "input"); +// @ts-expect-error a tag name from a union +dynamicComponent(() => tag()); +// @ts-expect-error a promise of a tag name +dynamicComponent(() => Promise.resolve("input" as const)); +// @ts-expect-error a source that MAY answer with a tag name +dynamicComponent(() => (editing() ? Editor : "input")); +// @ts-expect-error an arbitrary string +dynamicComponent(() => "my-element" as string); + +// --- dynamic: the full contract, unchanged -------------------------------- + +dynamic(() => "input"); +dynamic(() => tag()); +dynamic(() => Promise.resolve("input" as const)); +const Field = dynamic(() => (editing() ? Editor : "input")); +; +dynamic(() => getStory(1)); +dynamic(() => feed("room")); diff --git a/packages/web/test/frames-errored-reset-refetch.spec.tsx b/packages/web/test/frames-errored-reset-refetch.spec.tsx index fda8f83f7..08680696d 100644 --- a/packages/web/test/frames-errored-reset-refetch.spec.tsx +++ b/packages/web/test/frames-errored-reset-refetch.spec.tsx @@ -33,8 +33,15 @@ // (its fallback is content); a keyed error chunk (a fragment's or a live // hole's diagnostic) is not the frame's error. Both are controls here. import { afterEach, describe, expect, test, vi } from "vitest"; -import { createRoot, createSignal, Errored, Loading, resetErrorHalt } from "solid-js"; -import { dynamic } from "../src/index.js"; +import { + createRoot, + createSignal, + Errored, + Loading, + resetErrorHalt, + type Component +} from "solid-js"; +import { dynamic, dynamicComponent } from "../src/index.js"; import { installServerComponents } from "../frames/src/client.js"; import { createServerReference, GET } from "../server-functions/src/client.js"; import { frameAddress } from "../server-functions/src/shared.js"; @@ -81,7 +88,16 @@ function mount(code: () => any) { }; } -describe("a frame's error is an errored async value (3.3)", () => { +// Every arm runs through both entry points: `dynamic` and its component-only +// sibling `dynamicComponent` share one implementation (the hoisted factory +// memo the re-ask depends on included), and the sibling is the documented +// server-component mount, so the contract is pinned on it by name. +const VIA: ReadonlyArray<[string, (source: () => any) => Component]> = [ + ["dynamic", dynamic], + ["dynamicComponent", dynamicComponent] +]; + +describe.each(VIA)("a frame's error is an errored async value (3.3) — via %s", (_via, dyn) => { test("(a) the frame errors → the client catches (never an empty frame) → reset() → a new request, the new content shows", async () => { const { host } = makeHost(); installServerComponents(host); @@ -93,7 +109,7 @@ describe("a frame's error is an errored async value (3.3)", () => { return call === 1 ? errored("srv", "boom") : content("srv", "recovered"); }); const getStory = createServerReference("frames-reset/story"); - const Page = dynamic(() => getStory() as any); + const Page = dyn(() => getStory() as any); let resetFn: (() => void) | undefined; const m = mount(() => ( { return call === 1 ? errored("srv", "boom") : content("srv", "recovered"); }); const getStory = createServerReference("frames-reset/inner"); - const Page = dynamic(() => getStory() as any); + const Page = dyn(() => getStory() as any); let resetFn: (() => void) | undefined; const m = mount(() => ( shell-fallback}> @@ -183,7 +199,7 @@ describe("a frame's error is an errored async value (3.3)", () => { return call === 1 ? errored("srv", "boom") : content("srv", "recovered"); }); const getStory = GET(createServerReference("frames-reset/get")); - const Page = dynamic(() => getStory(7, "x") as any); + const Page = dyn(() => getStory(7, "x") as any); let resetFn: (() => void) | undefined; const m = mount(() => ( { return content("srv", "recovered"); }); const getStory = createServerReference("frames-reset/wire"); - const Page = dynamic(() => getStory() as any); + const Page = dyn(() => getStory() as any); let resetFn: (() => void) | undefined; const m = mount(() => ( { ); const error = vi.spyOn(console, "error").mockImplementation(() => {}); const getStory = createServerReference("frames-reset/uncaught"); - const Page = dynamic(() => getStory() as any); + const Page = dyn(() => getStory() as any); const m = mount(() => ( shell-fallback}> @@ -291,7 +307,7 @@ describe("a frame's error is an errored async value (3.3)", () => { return call === 1 ? held.response : content("srv", "recovered"); }); const getStory = createServerReference("frames-reset/late"); - const Page = dynamic(() => getStory() as any); + const Page = dyn(() => getStory() as any); let resetFn: (() => void) | undefined; const m = mount(() => ( { const held = openFrameResponse("srv"); vi.stubGlobal("fetch", async () => held.response); const getStory = createServerReference("frames-reset/truncated"); - const Page = dynamic(() => getStory() as any); + const Page = dyn(() => getStory() as any); const m = mount(() => ( failed: {(err() as any)?.message}}> shell-fallback}> @@ -365,7 +381,7 @@ describe("a frame's error is an errored async value (3.3)", () => { }); const getStory = createServerReference("frames-reset/refetch"); const [tick, setTick] = createSignal(0); - const Page = dynamic(() => (tick(), getStory() as any)); + const Page = dyn(() => (tick(), getStory() as any)); let resetFn: (() => void) | undefined; const m = mount(() => ( { ]) ); const getStory = createServerReference("frames-reset/server-caught"); - const Page = dynamic(() => getStory() as any); + const Page = dyn(() => getStory() as any); const m = mount(() => ( client fallback}> shell-fallback}> diff --git a/packages/web/test/harness/__artifacts__/dynamic-component-parity-inline.json b/packages/web/test/harness/__artifacts__/dynamic-component-parity-inline.json new file mode 100644 index 000000000..26eae2386 --- /dev/null +++ b/packages/web/test/harness/__artifacts__/dynamic-component-parity-inline.json @@ -0,0 +1,5 @@ +{ + "name": "dynamic-component-parity-inline", + "shell": "

client

note v1

", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/dynamic-component-parity-streamed.json b/packages/web/test/harness/__artifacts__/dynamic-component-parity-streamed.json new file mode 100644 index 000000000..7951ecb14 --- /dev/null +++ b/packages/web/test/harness/__artifacts__/dynamic-component-parity-streamed.json @@ -0,0 +1,5 @@ +{ + "name": "dynamic-component-parity-streamed", + "shell": "

client

shell-fallback

", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/dynamic-component-parity.tsx b/packages/web/test/harness/dynamic-component-parity.tsx new file mode 100644 index 000000000..0d48cc614 --- /dev/null +++ b/packages/web/test/harness/dynamic-component-parity.tsx @@ -0,0 +1,85 @@ +/** + * @jsxImportSource @solidjs/web + * + * `dynamicComponent` parity fixture: one page, two mounts — a plain client + * component (a sync source) and a NON-LIVE server component reference (an + * async source, under ``) — each through ONE entry point, `dynamic` + * or `dynamicComponent`. Same tree on both sides so hydration ids agree; + * only the SOURCE of the note differs (the server's in-process answer, the + * client's server reference). + * + * Two variants, as in frame-nonlive-document-3666: `inline` settles on a + * microtask (the boundary settles before the shell flush; no fallback + * markup), `streamed` takes a timer (fallback in the shell, the frame + * streamed later with its `_fr` record). + * + * Shared by test/server/dynamic-component-parity.spec.tsx (renders the page + * through each entry point, asserts the documents are byte-identical — same + * markup, same hydration keys, same records — and writes the artifact) and + * test/hydration/dynamic-component-parity.spec.tsx (hydrates the artifact + * with each entry point). + */ +import { createSignal, Loading, type Component } from "solid-js"; +import { dynamic, dynamicComponent } from "@solidjs/web"; + +export const VIAS = ["dynamic", "dynamicComponent"] as const; +export type Via = (typeof VIAS)[number]; +export const VARIANTS = ["inline", "streamed"] as const; +export type Variant = (typeof VARIANTS)[number]; +export const fidFor = (variant: Variant) => `dynamic-component-parity/note-${variant}`; +export const artifactFor = (variant: Variant) => `dynamic-component-parity-${variant}`; +export const ARGS = ["a"]; + +/** The entry point under test, as the one shape both share. */ +export const mountOf = (via: Via): ((source: () => any) => Component) => + via === "dynamic" ? dynamic : dynamicComponent; + +/** A client component with state of its own — the button proves interactivity. */ +export function Counter(props: { id: string; label: string }) { + const [count, setCount] = createSignal(0); + return ( + + ); +} + +/** A plain client component, mounted through the entry point from a sync source. */ +export function Panel(props: { title: string }) { + return ( +
+

{props.title}

+ +
+ ); +} + +/** The page: the client component, then the note under a `Loading`, both through `via`. */ +export function makeApp(via: Via, noteSource: () => any) { + const mount = mountOf(via); + return () => { + const Client = mount(() => Panel); + const Note = mount(noteSource); + return ( +
+ + shell-fallback

}> + } /> +
+
+ ); + }; +} + +/** The server component: static markup plus a client slot. */ +export function makeNoteComponent(title: string) { + const NoteComponent = (props: { counter: any }) => ( +
+

{title}

+
    + +
+
+ ); + return NoteComponent; +} diff --git a/packages/web/test/hydration/dynamic-component-parity-inline-via-dynamic.spec.tsx b/packages/web/test/hydration/dynamic-component-parity-inline-via-dynamic.spec.tsx new file mode 100644 index 000000000..d67cfaaae --- /dev/null +++ b/packages/web/test/hydration/dynamic-component-parity-inline-via-dynamic.spec.tsx @@ -0,0 +1,15 @@ +/** + * @jsxImportSource @solidjs/web + * + * The cross arm: the `dynamicComponent` document hydrated through `dynamic` + * — the two entry points share one owner shape, so a document either + * rendered is a document either hydrates. Inline variant. One page per + * file: the frames client's boundary index is module state. See + * ./dynamic-component-parity-run.tsx. + */ +import { test } from "vitest"; +import { runParity } from "./dynamic-component-parity-run.jsx"; + +test("dynamicComponent document, hydrated through dynamic — inline", async () => { + await runParity("inline", "dynamic"); +}); diff --git a/packages/web/test/hydration/dynamic-component-parity-inline.spec.tsx b/packages/web/test/hydration/dynamic-component-parity-inline.spec.tsx new file mode 100644 index 000000000..8c4ac8156 --- /dev/null +++ b/packages/web/test/hydration/dynamic-component-parity-inline.spec.tsx @@ -0,0 +1,14 @@ +/** + * @jsxImportSource @solidjs/web + * + * `dynamicComponent` hydrates the `dynamicComponent` document — a client + * component and a non-live server component, inline (settled before the + * shell flush). One page per file: the frames client's boundary index is + * module state. See ./dynamic-component-parity-run.tsx. + */ +import { test } from "vitest"; +import { runParity } from "./dynamic-component-parity-run.jsx"; + +test("dynamicComponent document, hydrated through dynamicComponent — inline", async () => { + await runParity("inline", "dynamicComponent"); +}); diff --git a/packages/web/test/hydration/dynamic-component-parity-run.tsx b/packages/web/test/hydration/dynamic-component-parity-run.tsx new file mode 100644 index 000000000..c913181f5 --- /dev/null +++ b/packages/web/test/hydration/dynamic-component-parity-run.tsx @@ -0,0 +1,167 @@ +/** + * @jsxImportSource @solidjs/web + * + * `dynamicComponent` — client half. Replays the artifacts + * test/server/dynamic-component-parity.spec.tsx wrote (the page rendered + * through the server `dynamicComponent`, asserted byte-identical to the + * server `dynamic`'s) and hydrates with EITHER client entry point: + * `dynamicComponent` (the documented server-component mount) and `dynamic`. + * Either must adopt the document — no hydration key miss, the same nodes for + * the client component, the frame and the article, the value memo's record + * adopted (`computes === 1`, no request) — and both client slots must be + * live after. One (variant, via) per spec file: the frames client's boundary + * index and claim set are module state. + */ +import { expect, vi } from "vitest"; +import { flush } from "solid-js"; +import { hydrate } from "@solidjs/web"; +import { installServerComponents } from "../../frames/src/client.js"; +import { createServerReference } from "../../server-functions/src/client.js"; +import { makeHost } from "../lifecycle-matrix/harness.js"; +import { + ARGS, + artifactFor, + fidFor, + makeApp, + type Variant, + type Via +} from "../harness/dynamic-component-parity.jsx"; +import { applyChunk, drain } from "./frame-live-document-helpers.js"; +import { existsSync, readFileSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const artifactsDir = resolve(dirname(fileURLToPath(import.meta.url)), "../harness/__artifacts__"); +function loadArtifact(name: string): { shell: string; rest: string } { + const file = resolve(artifactsDir, `${name}.json`); + if (!existsSync(file)) + throw new Error( + `Missing artifact "${name}" — run the server spec first: ` + + `vitest run --config vite.config.server.mjs test/server/dynamic-component-parity.spec.tsx` + ); + return JSON.parse(readFileSync(file, "utf-8")); +} + +export async function runParity(variant: Variant, via: Via) { + const FID = fidFor(variant); + const { shell, rest } = loadArtifact(artifactFor(variant)); + (globalThis as any)._$HY = { events: [], completed: new WeakSet(), r: {}, fe() {} }; + const container = document.createElement("div"); + document.body.appendChild(container); + const { host } = makeHost(); + installServerComponents(host); + + const urls: string[] = []; + vi.stubGlobal("fetch", async (input: any) => { + urls.push(typeof input === "string" ? input : input.url); + throw new Error("unexpected fetch"); + }); + + const note = createServerReference(FID); + let computes = 0; + const App = makeApp(via, () => { + computes++; + return note(...ARGS); + }); + + const shown: string[] = []; + const observe = () => { + const text = container.querySelector("main")?.textContent ?? container.textContent!; + if (shown[shown.length - 1] !== text) shown.push(text); + }; + const mo = new MutationObserver(observe); + + applyChunk(container, shell, true); + // `streamed`: the late fragment lands AFTER hydrate, below. + if (variant === "inline") applyChunk(container, rest, false); + mo.observe(container, { childList: true, subtree: true, characterData: true }); + observe(); + + const nodes = () => ({ + panel: container.querySelector("#panel"), + panelButton: container.querySelector("#panel-counter"), + frame: container.querySelector(`solid-frame[data-fid="${FID}"]`), + article: container.querySelector("#content"), + h1: container.querySelector("h1"), + noteButton: container.querySelector("#note-counter") + }); + let before = nodes(); + expect(before.panel, "client component in the document").not.toBeNull(); + + const warnings: string[] = []; + vi.spyOn(console, "warn").mockImplementation((...args: unknown[]) => { + warnings.push(args.map(String).join(" ")); + }); + const errors: string[] = []; + vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => { + errors.push(args.map(String).join(" ")); + }); + + const dispose = hydrate(() => , container); + flush(); + await drain(); + observe(); + + if (variant === "streamed") { + expect(container.querySelector("h1")).toBeNull(); + expect(container.querySelector("main")!.textContent).toContain("shell-fallback"); + applyChunk(container, rest, false); + observe(); + await drain(); + observe(); + before = nodes(); + } + + await drain(); + observe(); + const after = nodes(); + + console.log(`[${variant} via ${via}]`, { + computes, + urls, + warnings, + errors, + shown, + samePanel: after.panel === before.panel, + sameFrame: after.frame === before.frame, + sameArticle: after.article === before.article, + sameH1: after.h1 === before.h1, + html: container.innerHTML + }); + + expect(warnings.filter(w => w.includes("Hydration key miss"))).toEqual([]); + expect(errors).toEqual([]); + // The factory runs the source once (the hydration warm-up); the value + // memo adopts the server's record and never re-runs it. + expect(computes, "source runs once on the client").toBe(1); + expect(urls, "no request left the browser").toEqual([]); + // With the content in the document at hydration the boundary never + // selects its fallback (in `streamed` the server's own fallback is + // legitimately showing until the fragment lands). + if (variant === "inline") expect(shown.some(t => t.includes("shell-fallback"))).toBe(false); + expect(after.frame, "frame present").not.toBeNull(); + expect(after.h1!.textContent).toBe("note v1"); + expect(after.panel, "same client component node").toBe(before.panel); + expect(after.panelButton, "same client button node").toBe(before.panelButton); + expect(after.frame, "same frame node").toBe(before.frame); + expect(after.article, "same article node").toBe(before.article); + expect(after.h1, "same h1 node").toBe(before.h1); + expect(container.querySelector("main")!.textContent).not.toContain("shell-fallback"); + + // Interactivity: the client component's own state, and the client slot + // inside the server component. + (after.panelButton as HTMLButtonElement).click(); + flush(); + await drain(); + expect(container.querySelector("#panel-counter")!.textContent).toBe("Panel: 1"); + (after.noteButton as HTMLButtonElement).click(); + flush(); + await drain(); + expect(container.querySelector("#note-counter")!.textContent).toBe("Count: 1"); + expect(container.querySelector("#content")).toBe(after.article); + expect(container.querySelector("#panel")).toBe(after.panel); + + mo.disconnect(); + dispose(); + container.remove(); +} diff --git a/packages/web/test/hydration/dynamic-component-parity-streamed-via-dynamic.spec.tsx b/packages/web/test/hydration/dynamic-component-parity-streamed-via-dynamic.spec.tsx new file mode 100644 index 000000000..076a14335 --- /dev/null +++ b/packages/web/test/hydration/dynamic-component-parity-streamed-via-dynamic.spec.tsx @@ -0,0 +1,14 @@ +/** + * @jsxImportSource @solidjs/web + * + * The cross arm, streamed: the `dynamicComponent` document hydrated through + * `dynamic`, the frame landing as a late fragment after hydrate. One page + * per file: the frames client's boundary index is module state. See + * ./dynamic-component-parity-run.tsx. + */ +import { test } from "vitest"; +import { runParity } from "./dynamic-component-parity-run.jsx"; + +test("dynamicComponent document, hydrated through dynamic — streamed", async () => { + await runParity("streamed", "dynamic"); +}); diff --git a/packages/web/test/hydration/dynamic-component-parity-streamed.spec.tsx b/packages/web/test/hydration/dynamic-component-parity-streamed.spec.tsx new file mode 100644 index 000000000..da8efca36 --- /dev/null +++ b/packages/web/test/hydration/dynamic-component-parity-streamed.spec.tsx @@ -0,0 +1,15 @@ +/** + * @jsxImportSource @solidjs/web + * + * `dynamicComponent` hydrates the `dynamicComponent` document — streamed + * variant: the fallback is in the shell, the frame lands as a late fragment + * after hydrate, with its `_fr` record. One page per file: the frames + * client's boundary index is module state. See + * ./dynamic-component-parity-run.tsx. + */ +import { test } from "vitest"; +import { runParity } from "./dynamic-component-parity-run.jsx"; + +test("dynamicComponent document, hydrated through dynamicComponent — streamed", async () => { + await runParity("streamed", "dynamicComponent"); +}); diff --git a/packages/web/test/server/dynamic-component-parity.spec.tsx b/packages/web/test/server/dynamic-component-parity.spec.tsx new file mode 100644 index 000000000..7e229dfd4 --- /dev/null +++ b/packages/web/test/server/dynamic-component-parity.spec.tsx @@ -0,0 +1,119 @@ +/** + * @jsxImportSource @solidjs/web + */ +// `dynamicComponent` — server half. The page of +// test/harness/dynamic-component-parity.tsx rendered through `dynamic` and +// through `dynamicComponent` over the in-process answer of a NON-LIVE server +// component (a promise of the component wrapped by +// `frameTransformDirectResult`) and a sync client component. The two +// documents must be byte-identical: the entry points share one +// implementation with one owner shape (factory / value / render memo), so +// the hydration keys, the frame markup and the `_fr` record all agree. +// Writes the chunk artifacts test/hydration/dynamic-component-parity.spec.tsx +// replays (from the `dynamicComponent` render — the documented mount). +import { describe, expect, test } from "vitest"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { renderToStream } from "@solidjs/web"; +import { frameTransformDirectResult, ServerComponentPlugin } from "../../frames/src/frame-sink.js"; +import { + ARGS, + VARIANTS, + VIAS, + artifactFor, + fidFor, + makeApp, + makeNoteComponent, + type Via +} from "../harness/dynamic-component-parity.jsx"; + +const artifactsDir = resolve(dirname(fileURLToPath(import.meta.url)), "../harness/__artifacts__"); +mkdirSync(artifactsDir, { recursive: true }); + +const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)); + +function collectChunks(code: () => any): Promise<{ shell: string; rest: string }> { + return new Promise(resolvePromise => { + const chunks: string[] = []; + let shell = ""; + let shellDone = false; + renderToStream(code, { + plugins: [ServerComponentPlugin], + onCompleteShell() { + shellDone = true; + } + } as any).pipe({ + write(chunk: string) { + chunks.push(chunk); + if (shellDone && !shell) shell = chunks.join(""); + }, + end() { + const full = chunks.join(""); + if (!shell) shell = full; + resolvePromise({ shell, rest: full.slice(shell.length) }); + } + }); + }); +} + +const visibleText = (html: string) => + html.replace(//g, "").replace(/<[^>]*>/g, ""); +// The `_hk` family — the framework's hydration keys (`ssrHydrationKey`), one +// per claimable element, including the memo-derived paths under each mount. +const hydrationKeys = (html: string) => [...html.matchAll(/ _hk=([^ >]+)/g)].map(m => m[1]); + +describe("dynamicComponent — the server twin renders exactly what dynamic renders", () => { + for (const variant of VARIANTS) { + const FID = fidFor(variant); + test(`${variant}: same document, same hydration keys; writes the artifact`, async () => { + const Inline = frameTransformDirectResult(makeNoteComponent("note v1"), { + id: FID, + args: ARGS + }) as any; + const source = + variant === "inline" + ? () => Promise.resolve(Inline) + : async () => { + await sleep(5); + return Inline; + }; + + const rendered = {} as Record; + for (const via of VIAS) { + const App = makeApp(via, source); + rendered[via] = await collectChunks(() => ); + const full = rendered[via].shell + rendered[via].rest; + console.log( + `${FID} via ${via} SHELL:\n${rendered[via].shell}\n\nREST:\n${rendered[via].rest}` + ); + + expect(visibleText(full)).toContain("client"); + expect(visibleText(full)).toContain("note v1"); + expect(full).toContain(`data-fid="${FID}"`); + if (variant === "inline") { + expect(visibleText(rendered[via].shell)).not.toContain("shell-fallback"); + expect(rendered[via].rest).toBe(""); + } else { + expect(visibleText(rendered[via].shell)).toContain("shell-fallback"); + expect(rendered[via].rest).toContain(`data-fid="${FID}"`); + } + } + + // Same hydration keys (the owner shape), stated on their own before + // the byte-for-byte comparison says the same thing less legibly. + expect( + hydrationKeys(rendered.dynamicComponent.shell + rendered.dynamicComponent.rest) + ).toEqual(hydrationKeys(rendered.dynamic.shell + rendered.dynamic.rest)); + expect(hydrationKeys(rendered.dynamic.shell).length).toBeGreaterThan(0); + expect(rendered.dynamicComponent.shell).toBe(rendered.dynamic.shell); + expect(rendered.dynamicComponent.rest).toBe(rendered.dynamic.rest); + + const { shell, rest } = rendered.dynamicComponent; + writeFileSync( + resolve(artifactsDir, `${artifactFor(variant)}.json`), + JSON.stringify({ name: artifactFor(variant), shell, rest }, null, 2) + ); + }); + } +}); diff --git a/scripts/size/floor-caps.json b/scripts/size/floor-caps.json index af4518f46..7ecbb5ec4 100644 --- a/scripts/size/floor-caps.json +++ b/scripts/size/floor-caps.json @@ -12,12 +12,12 @@ "minified": 52794 }, "page: base server components (hydrating + dynamic + frames + sf reference)": { - "cap": "35.74 KB", - "minified": 112003 + "cap": "33.58 KB", + "minified": 104586 }, "page: live server components (base + live/GET + action + isPending/latest)": { - "cap": "39.43 KB", - "minified": 123903 + "cap": "37.26 KB", + "minified": 116485 }, "server: floor (getRequestEvent + isServer)": { "cap": "1.34 KB", diff --git a/scripts/size/sc-base-app.js b/scripts/size/sc-base-app.js index 0c349a03b..ac6ad4c72 100644 --- a/scripts/size/sc-base-app.js +++ b/scripts/size/sc-base-app.js @@ -1,19 +1,20 @@ // The base server-component page: a hydrating client (no client stores) // that installs the frames transport and mounts one server component -// through `dynamic()` over a server-function reference. This is the whole -// eager graph such a page ships — signals floor, solid-js hydration, the -// web runtime, the frames client and the server-function transport — with -// the seroval codec left lazy as it is in production. Unlike the -// "frames: eager client consumer" scenario, nothing is external here: this -// measures the page, not the package. -import { hydrate, Show, For, Loading, Errored, dynamic } from "@solidjs/web"; +// through `dynamicComponent()` over a server-function reference — the +// documented mount (2026-10-07; `dynamic()` would also carry the element +// runtime for its tag arm). This is the whole eager graph such a page ships +// — signals floor, solid-js hydration, the web runtime, the frames client +// and the server-function transport — with the seroval codec left lazy as +// it is in production. Unlike the "frames: eager client consumer" scenario, +// nothing is external here: this measures the page, not the package. +import { hydrate, Show, For, Loading, Errored, dynamicComponent } from "@solidjs/web"; import { createSignal, createMemo, lazy } from "solid-js"; import { installServerComponents } from "@solidjs/web/frames"; import { createServerReference } from "@solidjs/web/server-functions/client"; installServerComponents(); const getStory = createServerReference("story", "getStory"); -const Story = dynamic(() => getStory()); +const Story = dynamicComponent(() => getStory()); const [n, setN] = createSignal(0); const Page = lazy(() => import("./lazy-page.js")); hydrate(() => { diff --git a/scripts/size/sc-live-app.js b/scripts/size/sc-live-app.js index bf3876f95..7b74aa001 100644 --- a/scripts/size/sc-live-app.js +++ b/scripts/size/sc-live-app.js @@ -5,14 +5,14 @@ // stores: the store engine on this page, if any, is the frames client's // container-trace materializer, which is exactly what this scenario keeps // honest. -import { hydrate, Show, For, Loading, Errored, dynamic } from "@solidjs/web"; +import { hydrate, Show, For, Loading, Errored, dynamicComponent } from "@solidjs/web"; import { createSignal, createMemo, action, isPending, latest, lazy } from "solid-js"; import { installServerComponents } from "@solidjs/web/frames"; import { createServerReference, live, GET } from "@solidjs/web/server-functions/client"; installServerComponents(); const getStory = live(GET(createServerReference("story", "getStory"))); -const Story = dynamic(() => getStory()); +const Story = dynamicComponent(() => getStory()); const send = action(async () => {}); const [n, setN] = createSignal(0); const Page = lazy(() => import("./lazy-page.js")); diff --git a/scripts/size/scenarios.js b/scripts/size/scenarios.js index 11c2a2b51..2137233f5 100644 --- a/scripts/size/scenarios.js +++ b/scripts/size/scenarios.js @@ -3793,6 +3793,16 @@ module.exports = [ // 1,922 / 934, reported not counted) — −935 min / −349 br. Cap set at // measured + 10 B at the 0.01 KB step (the ratchet); recorded minified // 112,003 B. + // `dynamicComponent` (B.3-sync, 2026-10-07): 35.74 -> 33.58 KB, measured + // at 33,570 B (104,586 B minified): the fixture mounts its server + // component through `dynamicComponent`, the component-only sibling of + // `dynamic` (documentation/plans/frames-b3-sync.md), so `dynamic`'s + // string-tag arm — `staticElement`, `createElement`, `spread`, the + // prop-collection helpers, the SVG/MathML tables — is no longer + // reachable from this page: −7,417 min / −2,152 br (the edited-dist + // model said −7,423 / −2,158). `assign` and below stay for the lazy bind + // chunk. Cap set at measured + 10 B at the 0.01 KB step (the ratchet); + // recorded minified 104,586 B. limit: floorCaps["page: base server components (hydrating + dynamic + frames + sf reference)"], capMinified: floorMinified["page: base server components (hydrating + dynamic + frames + sf reference)"], @@ -3974,6 +3984,11 @@ module.exports = [ // against the base's 39,747) — −907 min / −335 br eager. Cap set at // measured + 10 B at the 0.01 KB step (the ratchet); recorded minified // 123,903 B. + // `dynamicComponent` (B.3-sync, 2026-10-07): 39.43 -> 37.26 KB, measured + // at 37,241 B (116,485 B minified): the fixture mounts through + // `dynamicComponent`, shedding `dynamic`'s string-tag arm as on the base + // page — −7,418 min / −2,171 br. Cap set at measured + 10 B at the + // 0.01 KB step (the ratchet); recorded minified 116,485 B. limit: floorCaps["page: live server components (base + live/GET + action + isPending/latest)"], capMinified: floorMinified["page: live server components (base + live/GET + action + isPending/latest)"],