Skip to content

web: dynamicComponent — component-only sibling of dynamic - #3870

Merged
ryansolid merged 1 commit into
wip/frames-tiers-integrationfrom
feat/web-dynamic-component
Oct 7, 2026
Merged

ryansolid merged 1 commit into
wip/frames-tiers-integrationfrom
feat/web-dynamic-component

Conversation

@ryansolid

Copy link
Copy Markdown
Member

Summary

The 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()). This is option (a) of documentation/plans/frames-b3-sync.md §5 — the only sync form of B.3 that keeps dynamic()'s documented contract and the <Dynamic> deprecation story intact.

The cost model. dynamic must be able to render a tag name, so one dynamic anywhere on a page retains the element runtime — staticElement, createElement, spread and the prop-collection helpers, the SVG/MathML tables, getNextElement — for everyone, whether or not any source ever answers with a string (a bundler cannot know what a source resolves to). dynamicComponent has no tag arm and never references that runtime, so a page whose only dynamic mounts are components (every server-component page) pays nothing for it.

The shape. dynamic is factored into a shared core and the tag arm: dynamicCore(source, options, tagArm?) is the whole implementation — the hoisted lazy factory memo (boxed FLIGHT, never re-runs for a reset, #3848 / A7), the per-instance value memo (async-memo adoption under hydration, #3666, 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, C16 / C17), static: true. The arm decides one thing — what a string value renders as: dynamic passes staticElement, dynamicComponent passes nothing (a string renders nothing there, like any other non-component value; the type is the guard). The server twin is factored the same way (the arm is one ssrElement()), so hydration-id consumption is identical across the four combinations.

Type. dynamicComponent<C extends Component<any>>(source: () => C | Promise<C> | AsyncIterable<C> | null | undefined | false, options?: DynamicOptions): Component<ComponentProps<C>> — 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<P> (LiveSource<Component<P>> for a live declaration — an intersection that is still a Component<P>), so Component<any> covers it without a special case.

Public API changes

  • New export dynamicComponent on @solidjs/web — client entry (src/index.ts) and server entry (src/index.server.ts), in types/index.d.ts with JSDoc stating the cost model. Signature above; DynamicOptions (static, deferStream) shared with dynamic.
  • dynamic: unchanged contract, signature, behaviour. Its JSDoc gains the cost-model note and a pointer to dynamicComponent.
  • No new diagnostic codes. The dev-only static-source guard message still reads dynamic(): a static source must resolve synchronously… for both entry points (shared code).

Measured before written

Edited-dist model (re-attribution §7 method: the design written into a copy of the built web.js, fixtures on dynamicComponent, through the harness's own bundler) vs the untouched head dists, then the real build:

scenario base (min / br) edited-dist model built
page: base server components 112,003 / 35,722 −7,423 / −2,158 → 33,564 −7,417 / −2,152 → 33,570
page: live server components 123,903 / 39,412 −7,424 / −2,237 → 37,175 −7,418 / −2,171 → 37,241
frames: eager client consumer 32,802 / 10,888 0 / 0 0 / 0
every other scenario (14) — 0 / 0 0 / 0

Rendered exports of web.js on page base after: staticElement, createElement, spread, SVGElements, MathMLElements, getNextElement gone; assign and below stay for the lazy bind chunk (as the note predicted). The model and the build differ by +6 B minified (the dynamic/dynamicComponent wrappers as the minifier prints them) and brotli layout noise.

Pins

Existing dynamic pins — all green, shared code: frames-dynamic-contract, dynamic-async-loading-3666 (inline + streamed), c16-reference-identity, c17-gate-bound-address, frames-errored-reset-refetch (9 arms), frames-adopted-error-outward, the lifecycle matrix (8 specs), dynamic.spec, dynamic-namespace, dynamic-static, dynamic-hydration-events, the server dynamic-* specs.

New:

  • (a) Parity, SSR + hydrate — test/harness/dynamic-component-parity.tsx, test/server/dynamic-component-parity.spec.tsx, test/hydration/dynamic-component-parity-{inline,streamed}{,-via-dynamic}.spec.tsx (+ -run.tsx). One page — a client component (sync source) and a non-live server-component reference (async source, under <Loading>) — rendered through the server dynamic and the server dynamicComponent: the documents are byte-identical (same _hk keys, same frame markup, same _fr record), inline and streamed. The dynamicComponent document is then hydrated through each client entry point: no hydration key miss, record adopted (computes === 1, no request), same nodes for the client component, the frame and the article, both client slots live. 2 server tests, 4 hydration arms, 2 recorded artifacts (deterministic across runs).
  • (b) A7 / C16 / C17 through dynamicComponent — frames-errored-reset-refetch.spec.tsx (9 arms × 2 = 18), c16-reference-identity.spec.tsx (2 × 2), c17-gate-bound-address.spec.tsx (5 × 2) are describe.each over both entry points.
  • (c) Type test — test/dynamic-component.type-tests.tsx: a tag-name source ("input", a "input" | "textarea" accessor, a promise of one, a Component | "input" union, an arbitrary string) is a compile error for dynamicComponent and fine for dynamic; a Component, a Promise<Component>, a LiveSource<Component> and the nullish/false forms type-check, props flow from the resolved component (test-types green; the program includes the file).

Size

pnpm size on the built head vs base d9395e398 and vs next @ 53ef0e69e (both built fresh, turbo --force, each measured by its own checkout's harness):

scenario vs base (min / br) vs next (min / br) head (min / br) cap
page: base server components −7,417 / −2,152 −41,805 / −11,547 104,586 / 33,570 35.74 → 33.58 KB
page: live server components −7,418 / −2,171 −41,992 / −11,590 116,485 / 37,241 39.43 → 37.26 KB
frames: eager client consumer 0 / 0 −11,230 / −3,115 32,802 / 10,888 —
14 non-SC scenarios 0 / 0 each (the stack's deltas) — —

Caps: the two page floors ratcheted (measured + 10 B at the 0.01 KB step; recorded minified updated; ledger lines in scenarios.js); check-floor-caps: no cap raised. No other cap touched (the ratchet would also have nudged + createStore and observe + attribution by 10 B of pre-existing slack — left alone). The three FAIL lines size.mjs prints on this machine (core floor +11, isPending/latest +23, app: CSR +45) are the known macOS brotli layout noise gate.mjs discounts, identical on base and head.

Suites / harness

  • @solidjs/web: client 130 files / 1227 passed (1 expected fail); server 160 / 1513 passed (3 expected fail, 2 skipped); hydrate 93 / 463 passed (16 expected fail, 2 skipped); test-types green; tsc -p tsconfig.build.json clean.
  • Consistency harness, 500 cases: SC arm seeds 3289 / 91501 → 0 findings; generic arm with CONSISTENCY_IGNORE=C1,C9,C19,E, seeds 3289 / 91501 → 0 findings.
  • welcome-status-streamed.json moved under the server suite (nondeterministic here) and was reverted; no other artifact moved. 2 new artifacts recorded.

Docs touched

  • documentation/solid-2.0/11-server-components.md — the mount (### Using one) → dynamicComponent, a paragraph naming the sibling and the cost; the two "entire client surface is dynamic" sentences → dynamicComponent.
  • documentation/server-components/server-components.md — "Using it from the client": import + mount → dynamicComponent; the dynamic() read / router-source mentions.
  • documentation/server-components/server-components-principles.md — the Todos mount example.
  • documentation/solid-2.0/10-server-functions.md — the Feed live mount example.
  • documentation/solid-2.0/03-control-flow.md — dynamic's docs: one note line pointing at dynamicComponent.
  • scripts/size/sc-base-app.js, sc-live-app.js — mount through dynamicComponent.
  • Not touched: examples/{hackernews,notes,room} still mount with dynamic (working code, out of this PR's scope — a follow-up if the maintainer wants the examples on the documented mount); MIGRATION.md; the scenario names (… + dynamic + …) which are floor-caps.json keys.

Changeset: .changeset/web-dynamic-component.md (@solidjs/web patch).

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<C extends Component<any>>(source: () => C |
Promise<C> | AsyncIterable<C> | 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<P>` (`LiveSource<Component<P>>` for `live`, an intersection
that is still one), so `Component<any>` 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 d9395e3): 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 <noreply@cursor.com>
@changeset-bot

changeset-bot Bot commented Oct 7, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9991514

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 12 packages
Name Type
@solidjs/web Patch
@solidjs/babel-plugin Patch
@solidjs/diagnostics Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html Patch
test-integration Patch
todos-server-example Patch
@solidjs/compiler Patch
@solidjs/signals Patch
solid-js Patch
@solidjs/universal Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@ryansolid
ryansolid merged commit 9991514 into wip/frames-tiers-integration Oct 7, 2026
2 checks passed
@ryansolid

Copy link
Copy Markdown
Member Author

Folded into #3860 (wip/frames-tiers-integration) by fast-forward — this branch was linear on #3860 at d9395e398, so its commit 99915141a rides #3860's history as its own (with #3875's two on top; #3860's head is now 3b70dd4c5). Nothing re-authored; feat/web-dynamic-component is kept.

GitHub flipped this PR to "merged" on its own when the fast-forward landed the head commit on the base branch — the same thing it did for #3862 yesterday. No merge button was pressed; it is a fold, and the maintainer merges #3860.

#3860's body now carries dynamicComponent in its Summary, as its own item under Public API changes (new export on @solidjs/web client + server, the signature, the cost model, dynamic unchanged, the docs moved to the sibling, the examples not touched), the parity / describe.each / type-test pins, and the two page floors' ratchet (35.74 → 33.58 KB, 39.43 → 37.26 KB) in the Size table.

— Claude via Cursor

ryansolid added a commit that referenced this pull request Oct 7, 2026
…ext (#3838)

The two router scenarios are the base / live server-component pages plus
@solidjs/router; those pages mount through dynamicComponent since #3870
(the documented mount), so the router fixtures do too — with dynamic they
measured +2,332 / +2,278 B brotli more (the element runtime dynamic's tag
arm keeps).

Re-based onto next @ 7233451 (the frames tiers): page base + router
57.02 -> 45.95 KB (45,940 B; 144,082 B min), page live + router
58.26 -> 47.21 KB (47,197 B; 148,526 B min), measured + 10 B rounded up
to 0.01 KB.

Co-authored-by: Cursor <cursoragent@cursor.com>
ryansolid added a commit that referenced this pull request Oct 7, 2026
* chore(size): add page base + router and page live + router scenarios

The two server-component page floors carry no router, so the "base case"
they gate is a page nobody deploys. Two scenarios add @solidjs/router 2.0
(2.0.0-next.35 — the Solid 2 line, peers solid-js / @solidjs/web
^2.0.0-rc.13 — pinned exactly as a devDependency of scripts/size, resolved
from its node_modules; .npmrc's legacy-peer-deps keeps the peers out of the
lockfile since the page alias routes them to the dists): createRouter with
two routes (one preload, one lazy), the instance as the hydrated root and
useNavigate in a route component, so the router's runtime — not just its
imports — is retained.

Measured locally on next @ 49a8dca: base + router 57,001 B br
(184,378 minified) against the base page's 44,864 (145,599), the router's
contribution +12,137 br; live + router 58,243 (188,800) against 48,527
(157,562), +9,716 br. The router standalone is 32,430 / 11,216 B. Inline caps
at local measured + 10 B rounded up to 0.01 KB (57.02 / 58.26 KB), recorded
minified from the same measurement; to be confirmed against CI.

Co-authored-by: Claude via Cursor <noreply@cursor.com>

* size: router pages mount through dynamicComponent; caps re-based on next (#3838)

The two router scenarios are the base / live server-component pages plus
@solidjs/router; those pages mount through dynamicComponent since #3870
(the documented mount), so the router fixtures do too — with dynamic they
measured +2,332 / +2,278 B brotli more (the element runtime dynamic's tag
arm keeps).

Re-based onto next @ 7233451 (the frames tiers): page base + router
57.02 -> 45.95 KB (45,940 B; 144,082 B min), page live + router
58.26 -> 47.21 KB (47,197 B; 148,526 B min), measured + 10 B rounded up
to 0.01 KB.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant