diff --git a/.changeset/dynamic-static-source.md b/.changeset/dynamic-static-source.md new file mode 100644 index 000000000..2a9d35620 --- /dev/null +++ b/.changeset/dynamic-static-source.md @@ -0,0 +1,38 @@ +--- +"@solidjs/signals": patch +"@solidjs/web": patch +--- + +`dynamic(source, { static })` and `isStatic(o, key)` + +`dynamic()` pays for a factory memo plus a per-instance memo so the source can +change. A great many call sites never change: a runtime `styled()` that always +renders `"li"`, and — the case this exists for — a polymorphic component whose +`as` arrived as a literal. The compiler encodes `as="button"` at a call site as +a data property and `as={isLink() ? "a" : "button"}` as a getter, so which one +the caller wrote is readable at runtime. + +`isStatic(o, key)` reads it: one descriptor lookup, no read of the value, +nothing tracked, looking through `merge()`/`omit()` views to the leaf that owns +the key. Data property or absent-from-a-fixed-key-set is static; a getter, a +store key, or a memo-backed `merge()` source is not. + +`dynamic(source, { static: true })` then says the source cannot change: it is +called once, untracked, at `dynamic()` time, and each instance renders the +result with no computation of its own — a tag goes to the compiled element path +(create or claim, spread), a component is called directly. No owner is created +on either side, so hydration ids stay aligned between server and client. A +static source may not return a promise. + +```tsx +function Polymorphic(props) { + const Tag = dynamic(() => props.as, { static: isStatic(props, "as") }); + return ; +} +``` + +`as` stays public and reactive; the literal case stops paying for it. Note the +two paths produce DIFFERENT hydration ids (the memo path's element sits one +owner deeper), which is fine because `isStatic` reads the same descriptors on +both sides — but it is why the classification must be per instance rather than +per component. diff --git a/packages/signals/src/store/index.ts b/packages/signals/src/store/index.ts index 9d665c78d..d098bd61e 100644 --- a/packages/signals/src/store/index.ts +++ b/packages/signals/src/store/index.ts @@ -21,6 +21,7 @@ export { sourceHas, sourceGet, hasStaticKeys, + isStatic, resolvedTable, OmitView, MergeView, diff --git a/packages/signals/src/store/utils.ts b/packages/signals/src/store/utils.ts index 67dc17135..27c6bcdd0 100644 --- a/packages/signals/src/store/utils.ts +++ b/packages/signals/src/store/utils.ts @@ -214,6 +214,39 @@ export function hasStaticKeys(o: any): boolean { return true; } +/** + * Whether `o[key]` can never change for the lifetime of `o`: the key is a + * data property of a plain object, or is absent from an object whose key set + * is fixed. A getter, a key on a store, a memo-backed `merge()` source, or + * any key of an object whose keys can appear later (a store) is not static. + * + * Looks through `merge()`/`omit()` views to the leaf that owns the key. Any + * object will do, but props are the case it exists for: the compiler encodes + * a literal at the call site (`as="button"`) as a data property and an + * expression (`as={isLink() ? "a" : "button"}`) as a getter, so a component + * library reads the caller's own static/dynamic classification of a prop at + * runtime — identically on server and client, the compiled shape being the + * same on both — and can take a no-computation path for the literal: + * + * ```tsx + * const Tag = dynamic(() => props.as, { static: isStatic(props, "as") }); + * ``` + * + * One descriptor lookup; no read of the value, nothing tracked. + */ +export function isStatic(o: object, key: PropertyKey): boolean { + if ($PROXY in o) { + // A store answers its descriptor trap with a value; through a view the + // descriptor is truthful (see `sourceDescriptor`). A foreign proxy is + // opaque: nothing about it is known to be fixed. + if (viewOf(o) === undefined) return false; + const desc = Reflect.getOwnPropertyDescriptor(o, key); + return desc === undefined ? hasStaticKeys(o) : desc.get === undefined; + } + const desc = Reflect.getOwnPropertyDescriptor(o, key); + return desc === undefined || (desc.get === undefined && desc.set === undefined); +} + function accessorDescriptor(get: () => any, enumerable = true): PropertyDescriptor { return { configurable: true, enumerable, get, set: trueFn }; } diff --git a/packages/signals/tests/store/utilities.test.ts b/packages/signals/tests/store/utilities.test.ts index 4842478af..2ae14a38f 100644 --- a/packages/signals/tests/store/utilities.test.ts +++ b/packages/signals/tests/store/utilities.test.ts @@ -11,6 +11,7 @@ import { merge, mergeSources, hasStaticKeys, + isStatic, omit, OmitView, MergeView, @@ -969,6 +970,114 @@ describe("view descriptors", () => { expect(hasStaticKeys(merge(plain, () => ({ b: 2 })))).toBe(false); }); }); + // #3387: the per-key classification a polymorphic component reads to decide + // whether `dynamic(() => props.as)` needs a computation at all. A data + // property is what the compiler emits for a literal at the call site; a + // getter is what it emits for an expression. + describe("isStatic", () => { + test("plain objects: data properties and absent keys are static, accessors are not", () => { + const [sig] = createSignal("a"); + const props = { + as: "button", + get dyn() { + return sig(); + } + }; + expect(isStatic(props, "as")).toBe(true); + expect(isStatic(props, "dyn")).toBe(false); + // Absent from a plain object: it can never appear (no trap can add it). + expect(isStatic(props, "missing")).toBe(true); + // A setter-only accessor is not a fixed value either. + const setterOnly = Object.defineProperty({}, "x", { set() {}, configurable: true }); + expect(isStatic(setterOnly, "x")).toBe(false); + }); + test("through merge() and omit() views, the leaf that owns the key decides", () => { + const [sig, setSig] = createSignal("a"); + const literal = { as: "button", label: "x" }; + const expr = { + get as() { + return sig(); + } + }; + // The compiler's call-site shape: defaults merged under the caller's props. + expect(isStatic(merge({ as: "div" }, literal), "as")).toBe(true); + expect(isStatic(merge({ as: "div" }, expr), "as")).toBe(false); + // Later sources shadow: a static override over a getter is static, and + // vice versa — the descriptor is the WINNING leaf's. + expect(isStatic(merge(expr, literal), "as")).toBe(true); + expect(isStatic(merge(literal, expr), "as")).toBe(false); + // omit() over either keeps the classification of what remains … + expect(isStatic(omit(literal, "label"), "as")).toBe(true); + expect(isStatic(omit(expr, "label"), "as")).toBe(false); + // … and an omitted key is absent from a fixed key set: static. + expect(isStatic(omit(literal, "as"), "as")).toBe(true); + // Deep Kobalte-shaped chain: omit(merge(omit(merge(...)))). + const chain = omit( + merge({ as: "div" }, omit(merge(literal, { extra: 1 }), "extra")), + "label" + ); + expect(isStatic(chain, "as")).toBe(true); + const chainDyn = omit( + merge({ as: "div" }, omit(merge(expr, { extra: 1 }), "extra")), + "label" + ); + expect(isStatic(chainDyn, "as")).toBe(false); + // The check reads no value: nothing was tracked, and the answer for a + // getter does not depend on what it currently returns. + setSig("b"); + flush(); + expect(isStatic(chainDyn, "as")).toBe(false); + }); + test("memo sources, stores, and anything reaching them are not static", () => { + const [store] = createStore({ as: "button" }); + const plain = { label: "x" }; + // A store answers `as` with a value, but the key can change and even + // appear/disappear: never static, whether direct or through a view. + expect(isStatic(store, "as")).toBe(false); + expect(isStatic(store, "missing")).toBe(false); + expect(isStatic(merge(plain, store), "as")).toBe(false); + expect(isStatic(omit(store, "label"), "as")).toBe(false); + // A key the store does NOT own, but the view's key set is not fixed: + // the store could grow it later. + expect(isStatic(merge(plain, store), "missing")).toBe(false); + // A plain literal shadowing the store IS static: the store can't win. + expect(isStatic(merge(store, { as: "a" }), "as")).toBe(true); + // A memo-backed source resolves per read. + expect( + isStatic( + merge(plain, () => ({ as: "a" })), + "as" + ) + ).toBe(false); + expect( + isStatic( + merge(plain, () => ({ as: "a" })), + "label" + ) + ).toBe(true); + expect( + isStatic( + merge(plain, () => ({ as: "a" })), + "missing" + ) + ).toBe(false); + }); + test("a foreign proxy is opaque", () => { + const foreign = new Proxy({ as: "a" }, {}); + expect($PROXY in foreign).toBe(false); + // Not $PROXY-marked: treated as a plain object, its own descriptor rules. + expect(isStatic(foreign, "as")).toBe(true); + // $PROXY-marked but neither a store nor one of our views: unknown, so not static. + const marked = new Proxy( + { as: "a" }, + { + has: (t, k) => k === $PROXY || k in t, + get: (t, k) => (k === $PROXY ? marked : (t as any)[k]) + } + ); + expect(isStatic(marked, "as")).toBe(false); + }); + }); test("a spread copy of a view is a plain snapshot with the right kinds", () => { let n = 0; const view = merge( @@ -1108,7 +1217,7 @@ describe("props chain (component-library shape)", () => { const [label] = createSignal("Open"); const [open] = createSignal(false); const element = polymorphic(buttonRoot(compiledProps(label, open))); - // A consumer (spread's children fast path, isStaticProp) must be able to + // A consumer (spread's children fast path, isStatic) must be able to // tell a compiled static attribute from a reactive one at the bottom of // the chain — that distinction is the compiler's verdict and must not be // erased by the layers in between. diff --git a/packages/solid/src/index.ts b/packages/solid/src/index.ts index d9dd6cd77..d917752c8 100644 --- a/packages/solid/src/index.ts +++ b/packages/solid/src/index.ts @@ -19,6 +19,7 @@ export { isWrappable, mapArray, merge, + isStatic, omit, onCleanup, onSettled, diff --git a/packages/solid/src/server/index.ts b/packages/solid/src/server/index.ts index f22a23e25..892a6057f 100644 --- a/packages/solid/src/server/index.ts +++ b/packages/solid/src/server/index.ts @@ -34,6 +34,7 @@ export { isWrappable, mapArray, merge, + isStatic, omit, onCleanup, onSettled, diff --git a/packages/solid/src/server/signals.ts b/packages/solid/src/server/signals.ts index 8365e671b..b069ddf6c 100644 --- a/packages/solid/src/server/signals.ts +++ b/packages/solid/src/server/signals.ts @@ -27,7 +27,7 @@ export { } from "@solidjs/signals"; export { flatten } from "@solidjs/signals"; -export { snapshot, omit, storePath, $PROXY, $TRACK } from "@solidjs/signals"; +export { snapshot, omit, storePath, isStatic, $PROXY, $TRACK } from "@solidjs/signals"; // === Type re-exports === diff --git a/packages/web/src/index.server.ts b/packages/web/src/index.server.ts index 8a8c72100..4ffe6a581 100644 --- a/packages/web/src/index.server.ts +++ b/packages/web/src/index.server.ts @@ -4,6 +4,7 @@ import { createMemo, omit, onCleanup, + untrack, getOwner, getNextChildId, NotReadyError, @@ -78,12 +79,30 @@ export interface DynamicOptions { * for its module load, since its code is a prerequisite to the render). */ deferStream?: boolean; + /** + * The source cannot change: call it once, untracked, now, and render the + * result with no computation per instance (see the client `dynamic`). The + * source must resolve synchronously. + */ + static?: boolean; } export function dynamic( source: () => T | Promise | null | undefined | false, options?: DynamicOptions ): 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. + if (options?.static) { + const component: any = untrack(source); + 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; + return () => undefined as unknown as JSX.Element; + } // Mirrors the client exactly: a factory-level memo over the source, then a // per-instance memo that applies props. An async source needs no bespoke // handling — the (async-aware, non-`sync`) server memo suspends the read diff --git a/packages/web/src/index.ts b/packages/web/src/index.ts index f01bfb419..3be75138e 100644 --- a/packages/web/src/index.ts +++ b/packages/web/src/index.ts @@ -250,6 +250,18 @@ function portalImpl(props: { mount?: Element; children: JSX.Element }): JSX.Elem * compiled JSX uses for the same purpose. `is` (customized built-ins) and * `xmlns` are read once at creation and then applied as ordinary attributes. * + * `{ static: true }` says the source cannot change: it is called once, + * untracked, when `dynamic()` is called, and each instance renders the result + * with no computation of its own — a tag name goes straight to the compiled + * element path (create or claim, spread), a component is called directly. No + * owner is created on either side, so hydration keys stay aligned with the + * compiled output. Use it for a constant (`dynamic(() => "li", { static: true })` + * in a runtime `styled()`), or per instance from the shape of a prop: + * `isStatic(props, "as")` is true when the caller wrote `as="button"` + * (a data property) and false when they wrote `as={cond() ? …}` (a getter), + * so a polymorphic component keeps a reactive public `as` and still pays + * nothing for the literal case. A static source may not return a promise. + * * @example * ```tsx * // `source` can return either a custom Component or a native tag @@ -261,6 +273,12 @@ function portalImpl(props: { mount?: Element; children: JSX.Element }): JSX.Elem * // An ambiguous tag inside an SVG tree: say which namespace you mean. * const Link = dynamic(() => "a"); * + * + * // A polymorphic component: no memo when `as` arrived as a literal. + * function Polymorphic(props) { + * const Tag = dynamic(() => props.as, { static: isStatic(props, "as") }); + * return ; + * } * ``` * * @description https://docs.solidjs.com/reference/components/dynamic @@ -289,12 +307,19 @@ export interface DynamicOptions { * `deferStream`. Ignored on the client. */ deferStream?: boolean; + /** + * The source cannot change: call it once, untracked, now, and render the + * result with no computation per instance (see `dynamic`). The source must + * resolve synchronously. + */ + static?: boolean; } export function dynamic( source: () => T | Promise | null | undefined | false, - _options?: DynamicOptions + options?: DynamicOptions ): Component> { + if (options?.static) return staticDynamic(untrack(source)); // `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 @@ -375,29 +400,8 @@ export function dynamic( return untrack(() => (component as Function)(props)); } - case "string": { - const hydrating = sharedConfig.hydrating; - // `is` and `xmlns` are attributes of the element that also decide - // how it is CREATED (customized built-in / namespace), so they are - // read once here, untracked — the DOM can't change either after - // creation — and then flow through spread() like any attribute. - // Hydration claims the parser-namespaced node, so neither applies. - const el = hydrating - ? getNextElement() - : createElement( - component as string, - untrack(() => (props as any).is), - untrack(() => (props as any).xmlns) - ); - spread(el, props); - // Compiled JSX emits runHydrationEvents() after an element that - // carries event handlers. Handlers bound through spread() here need - // the same call, or events the hydration script queued for this - // element are only replayed if some other compiled element happens - // to hydrate after it. - if (hydrating) runHydrationEvents(); - return el; - } + case "string": + return staticElement(component, props); default: break; @@ -406,6 +410,55 @@ export function dynamic( }; } +// `dynamic(source, { static: true })`: the resolved value once, no factory +// 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 { + 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") { + if (isDev) Object.assign(component, { [$DEVCOMP]: true }); + const binding = bindingOf(component); + if (binding) { + // A server-function component: its address is fixed too (the source is + // never re-resolved), so the live accessor is a constant. + const address = () => binding.address; + return props => untrack(() => (binding.component as any)(props, address)); + } + return props => untrack(() => component(props)); + } + if (typeof component === "string") return props => staticElement(component, props); + return () => undefined as unknown as JSX.Element; +} + +// One element for a tag: what the compiler emits for `` — +// claim or create, spread, replay hydration events — and nothing else. Both +// dynamic() paths end here; the memo path just reaches it from inside a +// computation. +function staticElement(tag: string, props: any): JSX.Element { + const hydrating = sharedConfig.hydrating; + // `is` and `xmlns` are attributes of the element that also decide how it is + // CREATED (customized built-in / namespace), so they are read once here, + // untracked — the DOM can't change either after creation — and then flow + // through spread() like any attribute. Hydration claims the + // parser-namespaced node, so neither applies. + const el = hydrating + ? getNextElement() + : createElement( + tag, + untrack(() => props.is), + untrack(() => props.xmlns) + ); + spread(el, props); + // Compiled JSX emits runHydrationEvents() after an element that carries + // event handlers. Handlers bound through spread() here need the same call, + // or events the hydration script queued for this element are only replayed + // if some other compiled element happens to hydrate after it. + if (hydrating) runHydrationEvents(); + return el as unknown as JSX.Element; +} + /** * @deprecated Use `dynamic()`. `` is the same primitive, but its * shape puts the tag in the same bag as the element's props: every instance diff --git a/packages/web/test/dynamic-static.spec.tsx b/packages/web/test/dynamic-static.spec.tsx new file mode 100644 index 000000000..22a0be1cb --- /dev/null +++ b/packages/web/test/dynamic-static.spec.tsx @@ -0,0 +1,196 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + */ +import { describe, expect, test } from "vitest"; +import { + createRoot, + createSignal, + flush, + getOwner, + isStatic, + omit, + type Component, + type Owner +} from "solid-js"; +import { dynamic, type JSX } from "../src/index.js"; + +const SVG = "http://www.w3.org/2000/svg"; + +// #3387: `dynamic(source, { static: true })` resolves the source once and +// renders each instance with no computation of its own — the same shape the +// compiler emits for `` / ``. +describe("dynamic(source, { static })", () => { + test("a static tag is the compiled element path: spread, children, reactive attributes", () => { + const [label, setLabel] = createSignal("one"); + const Tag = dynamic(() => "a", { static: true }); + let el!: HTMLDivElement; + const dispose = createRoot(dispose => { + el = ( +
+ + {label()} + +
+ ) as HTMLDivElement; + return dispose; + }); + flush(); + expect(el.innerHTML).toBe('one'); + // No memo owns the element, but the attributes and children the caller + // wrote as expressions are still reactive: spread() binds them. + setLabel("two"); + flush(); + expect(el.innerHTML).toBe('two'); + dispose(); + }); + + test("the source is called once, untracked, at dynamic() time", () => { + const [tag, setTag] = createSignal<"a" | "b">("a"); + let calls = 0; + const Tag = dynamic(() => (calls++, tag()), { static: true }); + expect(calls).toBe(1); + let el!: HTMLDivElement; + const dispose = createRoot(dispose => { + el = ( +
+ + +
+ ) as HTMLDivElement; + return dispose; + }); + flush(); + // `` are the compiler's separators between adjacent expression + // children, not anything dynamic() emits. + expect(el.innerHTML).toBe(""); + expect(calls).toBe(1); + // Static means static: the source's own signal is not a dependency. + setTag("b"); + flush(); + expect(el.innerHTML).toBe(""); + expect(calls).toBe(1); + dispose(); + }); + + test("a static component is called with no owner of its own, like compiled JSX", () => { + const depth = (owner: Owner | null | undefined) => { + let n = 0; + for (let o = owner; o; o = o._parent) n++; + return n; + }; + let viaCompiled: Owner | null | undefined; + let viaStatic: Owner | null | undefined; + let viaMemo: Owner | null | undefined; + const Inner: Component<{ label: string; seen: (o: Owner | null) => void }> = props => { + props.seen(getOwner()); + return {props.label}; + }; + const Static = dynamic(() => Inner, { static: true }); + const Memo = dynamic(() => Inner); + let el!: HTMLDivElement; + const dispose = createRoot(dispose => { + el = ( +
+
+ (viaCompiled = o)} /> +
+
+ (viaStatic = o)} /> +
+
+ (viaMemo = o)} /> +
+
+ ) as HTMLDivElement; + return dispose; + }); + flush(); + expect(el.textContent).toBe("abc"); + // The static path interposes no owner between the call site and the + // component — exactly compiled `` — which is what keeps its + // hydration keys aligned with the server. The memo path interposes the + // per-instance memo. + expect(depth(viaStatic)).toBe(depth(viaCompiled)); + expect(depth(viaMemo)).toBe(depth(viaCompiled) + 1); + dispose(); + }); + + test("a falsy static source renders nothing", () => { + const Nothing = dynamic(() => null, { static: true }); + let el!: HTMLDivElement; + const dispose = createRoot(dispose => { + el = ( +
+ +
+ ) as HTMLDivElement; + return dispose; + }); + flush(); + expect(el.innerHTML).toBe(""); + dispose(); + }); + + test("a static source may not resolve to a promise", () => { + expect(() => dynamic(() => Promise.resolve("a") as any, { static: true })).toThrow( + /static source must resolve synchronously/ + ); + }); + + test("`is` and `xmlns` still decide how the element is created", () => { + const Link = dynamic(() => "a", { static: true }); + let svg!: SVGSVGElement; + const dispose = createRoot(dispose => { + svg = ( + + + + ) as SVGSVGElement; + return dispose; + }); + flush(); + const a = svg.firstChild as Element; + expect(a.namespaceURI).toBe(SVG); + expect(a.getAttribute("href")).toBe("/x"); + dispose(); + }); + + // The polymorphic shape the option exists for: a component keeps a public, + // reactive `as`, and the literal case (`as="a"`) skips the memo entirely. + test("isStatic(props, 'as') picks the path per call site", () => { + function Polymorphic(props: { as: string; children?: JSX.Element; class?: string }) { + const Tag = dynamic(() => props.as as any, { static: isStatic(props, "as") }); + return ; + } + const [asTag, setAsTag] = createSignal<"a" | "span">("a"); + const [cls, setCls] = createSignal("x"); + let el!: HTMLDivElement; + const dispose = createRoot(dispose => { + el = ( +
+ + lit + + + dyn + +
+ ) as HTMLDivElement; + return dispose; + }); + flush(); + const html = () => el.innerHTML.replace(//g, ""); + expect(html()).toBe('dyn'); + // The literal instance took the static path, yet its expression props + // are as reactive as ever. + setCls("y"); + flush(); + expect(html()).toBe('dyn'); + // The getter instance took the memo path: `as` swaps the element. + setAsTag("span"); + flush(); + expect(html()).toBe('dyn'); + dispose(); + }); +}); diff --git a/packages/web/test/harness/__artifacts__/dynamic-static-forms.json b/packages/web/test/harness/__artifacts__/dynamic-static-forms.json new file mode 100644 index 000000000..bb6186d58 --- /dev/null +++ b/packages/web/test/harness/__artifacts__/dynamic-static-forms.json @@ -0,0 +1,5 @@ +{ + "name": "dynamic-static-forms", + "shell": "
oneoneone
", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/polymorphic-chain-static.json b/packages/web/test/harness/__artifacts__/polymorphic-chain-static.json new file mode 100644 index 000000000..4544df5ac --- /dev/null +++ b/packages/web/test/harness/__artifacts__/polymorphic-chain-static.json @@ -0,0 +1,5 @@ +{ + "name": "polymorphic-chain-static", + "shell": "", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/polymorphic.tsx b/packages/web/test/harness/polymorphic.tsx index 49fd39cfd..2e7c08b86 100644 --- a/packages/web/test/harness/polymorphic.tsx +++ b/packages/web/test/harness/polymorphic.tsx @@ -21,6 +21,9 @@ * * Fidelity notes (vs kobalte `solid2` branch): * - `Polymorphic` uses `dynamic()` rather than the deprecated ``. + * `PolymorphicStatic` is the same component on `dynamic()`'s static path, + * deciding per instance from the compiled shape of `as` (#3387); the + * `chain-static` form is the chain built over it. * - `Button.Root`'s native-tag detection reads the resolved `as` instead of * the mounted element's tagName (that path needs a DOM ref and would make * the SSR and DOM trees diverge). Same attribute set results. @@ -30,7 +33,16 @@ * `CompiledRow` is the floor: the same final element written directly, so a * bench delta between it and `TriggerRow` is attributable to the chain alone. */ -import { createContext, createSignal, merge, omit, useContext, For, type Accessor } from "solid-js"; +import { + createContext, + createSignal, + isStatic, + merge, + omit, + useContext, + For, + type Accessor +} from "solid-js"; import { dynamic, type JSX } from "@solidjs/web"; // --- Layer 3: Polymorphic ------------------------------------------------ @@ -44,29 +56,44 @@ export function Polymorphic(props: PolymorphicProps): JSX.Element { return ; } +/** + * The same component deciding per instance: `as` written as a literal at the + * call site is a data property through every merge/omit layer, so the tag + * takes the no-computation path; a reactive `as` keeps the memo. + */ +export function PolymorphicStatic(props: PolymorphicProps): JSX.Element { + const others = omit(props, "as"); + const Tag = dynamic(() => props.as, { static: isStatic(props, "as") }); + return ; +} + // --- Layer 2: Button.Root ----------------------------------------------- /** * Defaults merged in, a few keys consumed, aria derived from what the - * element will be, rest forwarded (kobalte button-root.tsx). + * element will be, rest forwarded (kobalte button-root.tsx). Built over a + * given `Polymorphic`, so the two forms of it render the identical chain. */ -export function ButtonRoot(props: Record): JSX.Element { - const mergedProps = merge({ type: "button" }, props); - const others = omit(mergedProps, "type", "disabled"); - const isNativeButton = () => (mergedProps.as ?? "button") === "button"; - return ( - - ); +export function makeButtonRoot(Polymorphic: (props: PolymorphicProps) => JSX.Element) { + return function ButtonRoot(props: Record): JSX.Element { + const mergedProps = merge({ type: "button" }, props); + const others = omit(mergedProps, "type", "disabled"); + const isNativeButton = () => (mergedProps.as ?? "button") === "button"; + return ( + + ); + }; } +export const ButtonRoot = makeButtonRoot(Polymorphic); // --- Layer 1: Dialog + Dialog.Trigger -------------------------------------- @@ -87,23 +114,27 @@ export function Dialog(props: { open?: boolean; children: JSX.Element }): JSX.El } /** Context-driven aria, consumes `onClick`, forwards the rest (kobalte dialog-trigger.tsx). */ -export function DialogTrigger(props: Record): JSX.Element { - const context = useContext(DialogContext)!; - const others = omit(props, "onClick"); - return ( - { - props.onClick?.(e); - context.toggle(); - }} - {...others} - /> - ); +export function makeDialogTrigger(ButtonRoot: (props: Record) => JSX.Element) { + return function DialogTrigger(props: Record): JSX.Element { + const context = useContext(DialogContext)!; + const others = omit(props, "onClick"); + return ( + { + props.onClick?.(e); + context.toggle(); + }} + {...others} + /> + ); + }; } +export const DialogTrigger = makeDialogTrigger(ButtonRoot); +export const DialogTriggerStatic = makeDialogTrigger(makeButtonRoot(PolymorphicStatic)); // --- Rows ------------------------------------------------------------------- @@ -128,21 +159,25 @@ export function makeRows(start: number, count: number): Row[] { * reactive ones (getters). `as="a"` overrides Button.Root's default, so the * chain exercises shadowing through merge → omit → merge, not just pass-through. */ -export function TriggerRow(props: { row: Row }): JSX.Element { - const { row } = props; - return ( - - {row.label()} - - ); +export function makeTriggerRow(DialogTrigger: (props: Record) => JSX.Element) { + return function TriggerRow(props: { row: Row }): JSX.Element { + const { row } = props; + return ( + + {row.label()} + + ); + }; } +export const TriggerRow = makeTriggerRow(DialogTrigger); +export const TriggerRowStatic = makeTriggerRow(DialogTriggerStatic); /** Floor: the element `TriggerRow` resolves to, written directly. */ export function CompiledRow(props: { row: Row }): JSX.Element { @@ -170,9 +205,10 @@ export function CompiledRow(props: { row: Row }): JSX.Element { export type RowRenderer = (row: Row) => JSX.Element; -export const forms: Record<"compiled" | "chain", RowRenderer> = { +export const forms: Record<"compiled" | "chain" | "chain-static", RowRenderer> = { compiled: row => , - chain: row => + chain: row => , + "chain-static": row => }; /** A `Dialog` wrapping a list of rows — the tree every consumer of this fixture renders. */ diff --git a/packages/web/test/harness/scenarios.tsx b/packages/web/test/harness/scenarios.tsx index ae3e4e5ff..12ee8d0a0 100644 --- a/packages/web/test/harness/scenarios.tsx +++ b/packages/web/test/harness/scenarios.tsx @@ -1794,6 +1794,33 @@ function DynamicNamespaceLink() { ); } +// dynamic(source, { static }) (#3387): the source resolves once and the +// instance creates no owner, so its hydration ids differ from the memo path's +// and must agree between server and client. Three forms in one tree — a tag, +// a component, and a falsy source (which renders nothing yet still has to +// leave the id sequence in the same state on both sides) — followed by a +// reactive tail whose binding only survives if every claim above it landed. +let setStaticLabel!: (v: string) => void; +const StaticTag = dynamic(() => "a", { static: true }); +const StaticComp = dynamic(() => (props: { label: string }) => {props.label}, { + static: true +}); +const StaticNothing = dynamic(() => null, { static: true }); +function DynamicStaticForms() { + const [label, set] = createSignal("one"); + setStaticLabel = set; + return ( +
+ + {label()} + + + + {label()} +
+ ); +} + // Kobalte-shaped component chain (test/harness/polymorphic.tsx): every // element reached through merge → omit → merge layers and a per-instance // `dynamic(() => props.as)`. Hydration must claim the `` through all of @@ -1820,6 +1847,20 @@ export const scenarios: Scenario[] = [ expectedTextAfterUpdate: "row-0ROW-1row-2", stableSelector: "ul, li, a.btn" }, + // The same chain over `dynamic()`'s static path (#3387): `as="a"` is a data + // property through every layer, so no instance memo exists on either side. + // Hydration keys are therefore DIFFERENT from `polymorphic-chain` (one + // owner fewer per element) and must still agree between server and client. + { + name: "polymorphic-chain-static", + App: polymorphicChainApp("chain-static"), + expectedText: "row-0row-1row-2", + adoptAll: true, + noSeparators: true, + update: () => chainRows[1].setLabel("ROW-1"), + expectedTextAfterUpdate: "row-0ROW-1row-2", + stableSelector: "ul, li, a.btn" + }, { name: "polymorphic-chain-compiled-floor", App: polymorphicChainApp("compiled"), @@ -2474,5 +2515,15 @@ export const scenarios: Scenario[] = [ update: () => setNsLabel("went"), expectedTextAfterUpdate: "went", stableSelector: "svg, a, text" + }, + { + name: "dynamic-static-forms", + App: DynamicStaticForms, + expectedText: "oneoneone", + adoptAll: true, + noSeparators: true, + update: () => setStaticLabel("two"), + expectedTextAfterUpdate: "twotwotwo", + stableSelector: "div, a, i, b" } ]; diff --git a/packages/web/test/server/dynamic-static.spec.tsx b/packages/web/test/server/dynamic-static.spec.tsx new file mode 100644 index 000000000..8007d29fb --- /dev/null +++ b/packages/web/test/server/dynamic-static.spec.tsx @@ -0,0 +1,156 @@ +/** + * @jsxImportSource @solidjs/web + */ +import { describe, expect, test } from "vitest"; +import { createSignal, isStatic, omit } from "solid-js"; +import { renderToString, dynamic, type JSX } from "@solidjs/web"; + +const SVG = "http://www.w3.org/2000/svg"; +const noKeys = (html: string) => html.replace(/ _hk=[\w-]+/g, ""); + +// #3387, server side. The static path exists to match the compiled output's +// SHAPE, so the invariant that matters here is hydration keys: a static +// dynamic() creates no owner, so its keys are the compiled element's, not the +// memo path's. +describe("dynamic(source, { static }) on the server", () => { + test("a static tag is the compiled markup, with the key a runtime element needs", () => { + const Tag = dynamic(() => "a", { static: true }); + const viaDynamic = renderToString(() => ( +
+ + hi + +
+ )); + const compiled = renderToString(() => ( +
+ )); + // Same markup; a compiled element inside a template needs no key of its + // own, while any element created at runtime does, so the `` keeps one. + // What the static path removes is the OWNER, which is what the key's + // DEPTH shows — see the memo comparison below. + expect(noKeys(viaDynamic)).toBe(noKeys(compiled)); + expect(viaDynamic).toBe(''); + }); + + test("a static component is byte-identical to the compiled call", () => { + // A component has no element of its own, so there is nothing left to + // distinguish the two: same markup, same keys. + const Inner = (props: { label: string }) => {props.label}; + const Comp = dynamic(() => Inner, { static: true }); + expect( + renderToString(() => ( +
+ +
+ )) + ).toBe( + renderToString(() => ( +
+ +
+ )) + ); + }); + + test("the memo path allocates an owner the static path does not", () => { + const Static = dynamic(() => "a", { static: true }); + const Memo = dynamic(() => "a"); + const staticHtml = renderToString(() => ( +
+ +
+ )); + const memoHtml = renderToString(() => ( +
+ +
+ )); + // Same markup … + expect(noKeys(staticHtml)).toBe(noKeys(memoHtml)); + // … but the memo path's element sits one owner deeper, which its key + // spells out. That is the cost the static path removes, and the reason + // client and server must agree on `static` per instance. + expect(staticHtml).toBe('
'); + expect(memoHtml).toBe('
'); + }); + + test("the source is called once, untracked", () => { + const [tag, setTag] = createSignal<"a" | "b">("a"); + let calls = 0; + const Tag = dynamic(() => (calls++, tag()), { static: true }); + expect(calls).toBe(1); + expect(noKeys(renderToString(() => ))).toBe(""); + expect(noKeys(renderToString(() => ))).toBe(""); + expect(calls).toBe(1); + setTag("b"); + expect(noKeys(renderToString(() => ))).toBe(""); + expect(calls).toBe(1); + }); + + test("a falsy static source renders nothing", () => { + const Nothing = dynamic(() => null, { static: true }); + expect( + noKeys( + renderToString(() => ( +
+ +
+ )) + ) + ).toBe("
"); + }); + + test("a static source may not resolve to a promise", () => { + expect(() => dynamic(() => Promise.resolve("a") as any, { static: true })).toThrow( + /static source must resolve synchronously/ + ); + }); + + test("xmlns serializes as an ordinary attribute, as on the memo path", () => { + const Link = dynamic(() => "a", { static: true }); + const html = renderToString(() => ( + + + + )); + expect(noKeys(html)).toBe(''); + }); + + // The shape the option exists for. `isStatic` reads the same descriptors on + // both sides — the compiler encodes `as="a"` as a data property and + // `as={expr}` as a getter in the server output too — so the two instances + // below choose the same paths the client chooses, which is what makes the + // keys line up. The parity harness (`polymorphic-chain-static`) pins that + // end to end. + test("isStatic picks the path per call site, and the literal matches compiled", () => { + function Polymorphic(props: { as: string; children?: JSX.Element; class?: string }) { + const Tag = dynamic(() => props.as as any, { static: isStatic(props, "as") }); + return ; + } + const [asTag] = createSignal<"a" | "span">("a"); + const html = renderToString(() => ( +
+ + lit + + + dyn + +
+ )); + // ``/`` bracket each component's output on the server. + expect(noKeys(html).replace(//g, "")).toBe( + '
dyn
' + ); + // The literal instance took the static path and the getter instance the + // memo path, which their key depths spell out (one owner apart). + const keys = [...html.matchAll(/<(?:button|a) _hk=([\w-]+)/g)].map(m => m[1]); + expect(keys).toHaveLength(2); + expect(keys[1].length).toBe(keys[0].length + 1); + }); +});