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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .changeset/dynamic-static-source.md
Original file line number Diff line number Diff line change
@@ -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 <Tag {...omit(props, "as")} />;
}
```

`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.
1 change: 1 addition & 0 deletions packages/signals/src/store/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export {
sourceHas,
sourceGet,
hasStaticKeys,
isStatic,
resolvedTable,
OmitView,
MergeView,
Expand Down
33 changes: 33 additions & 0 deletions packages/signals/src/store/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
Expand Down
111 changes: 110 additions & 1 deletion packages/signals/tests/store/utilities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
merge,
mergeSources,
hasStaticKeys,
isStatic,
omit,
OmitView,
MergeView,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions packages/solid/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export {
isWrappable,
mapArray,
merge,
isStatic,
omit,
onCleanup,
onSettled,
Expand Down
1 change: 1 addition & 0 deletions packages/solid/src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export {
isWrappable,
mapArray,
merge,
isStatic,
omit,
onCleanup,
onSettled,
Expand Down
2 changes: 1 addition & 1 deletion packages/solid/src/server/signals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ===

Expand Down
19 changes: 19 additions & 0 deletions packages/web/src/index.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
createMemo,
omit,
onCleanup,
untrack,
getOwner,
getNextChildId,
NotReadyError,
Expand Down Expand Up @@ -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<T extends ValidComponent>(
source: () => T | Promise<T> | null | undefined | false,
options?: DynamicOptions
): Component<ComponentProps<T>> {
// 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
Expand Down
Loading