diff --git a/.changeset/strong-moose-attend.md b/.changeset/strong-moose-attend.md new file mode 100644 index 000000000..b8b78b251 --- /dev/null +++ b/.changeset/strong-moose-attend.md @@ -0,0 +1,5 @@ +--- +"@solid-primitives/context": minor +--- + +Add `ContextConsumer` diff --git a/packages/context/README.md b/packages/context/README.md index fd117dca6..61bf903dc 100644 --- a/packages/context/README.md +++ b/packages/context/README.md @@ -16,6 +16,7 @@ Primitives simplifying the creation and use of SolidJS Context API. - [`createOptionalContextProvider`](#createoptionalcontextprovider) - Like `createContextProvider`, but returns `undefined` instead of throwing if the context is missing. - [`createLayeredContext`](#createlayeredcontext) - Like `createContextProvider`, but each provider extends the parent context value rather than replacing it. - [`MultiProvider`](#multiprovider) - A component that allows you to provide multiple contexts at once. +- [`ContextConsumer`](#contextconsumer) - A component that allows you to consume contexts directly within JSX. ## Installation @@ -186,6 +187,72 @@ import { MultiProvider } from "@solid-primitives/context"; > **Warning** > Components and values passed to `MultiProvider` will be evaluated only once, so make sure that the structure is static. If it isn't, please use nested provider components instead. +## `ContextConsumer` + +Inspired by React's `Context.Consumer` component, `ContextConsumer` allows using contexts directly within JSX without the needing to extract the content JSX into a separate function. + +This is particularly useful when you want to use the context in the same JSX block where you're providing it and directly bind the context value to the frontend. + +Note that this component solely serves as syntactic sugar and doesn't provide any additional functionality over using `untrack` together with `useContext` in JSX. + +### How to use it + +`ContextConsumer` takes a `provider` prop that can be either one of the following: +* A `use...()` function returned by `createContextProvider` or an inline function that returns the context's value: `() => useContext(MyContext)`. +* A raw SolidJS context returned by `createContext()`. + +In case the context's value is `undefined` (e.g. by using `createOptionalContextProvider`), a fallback element specified in the `fallback` prop is rendered instead. If the context is not provided at all, the component will throw an error. + +```tsx +import { createContextProvider, ContextConsumer } from "@solid-primitives/context"; + +// Create a context provider +const [CounterProvider, useCounter] = createContextProvider(() => { + const [count, setCount] = createSignal(0); + const increment = () => setCount(count() + 1); + return { count, increment }; +}); + +// Provide and consume the context within same JSX block +return ( + + ( +
+

Fallback

+
+ )}> + {({ count, increment }) => ( +
+

Count: {count()}

+ +
+ )} +
+
+); +``` + +With the raw SolidJS context returned by `createContext()`: + +```tsx +import { ContextConsumer } from "@solid-primitives/context"; + +// Create a context +const CounterContext = createContext(/*...*/); + +// Consume it using the raw context + + + {({ count, increment }) => { +
+

Count: {count()}

+ +
+ }} +
+
+``` + ## Changelog See [CHANGELOG.md](./CHANGELOG.md) diff --git a/packages/context/src/index.ts b/packages/context/src/index.ts index 39a4dbf57..6d5bb0bff 100644 --- a/packages/context/src/index.ts +++ b/packages/context/src/index.ts @@ -194,11 +194,11 @@ Type validation of the `values` array thanks to the amazing @otonashixav (https: export function MultiProvider(props: { values: { [K in keyof T]: - | readonly [ - Context | ContextProviderComponent, - [T[K]][T extends unknown ? 0 : never], - ] - | FlowComponent; + | readonly [ + Context | ContextProviderComponent, + [T[K]][T extends unknown ? 0 : never], + ] + | FlowComponent; }; children: Element; }): Element { @@ -222,3 +222,97 @@ export function MultiProvider(props }; return fn(0); } + +/** + * A component that allows you to consume a context without extracting the children into a separate function. + * This is particularly useful when the context needs to be used within the same JSX where it is provided. + * + * The `ContextConsumer` component is equivalent to the following code and solely exists as syntactic sugar: + * + * ```tsx + * + * {untrack(() => { + * const context = useContext(counterContext); // or useCounter() + * return children(context); + * })} + * + * ``` + * + * @param provider Either one of the following: + * - A function that returns the context value. Preferably the `use...()` function returned by `createContextProvider()`. + * - The context itself returned by `createContext()`. + * - An inline function that returns the context's value. + * + * Please note that this prop is not reactive. + * + * @param fallback Optional fallback element to render when the context's value is `undefined`. Can be a function or an element. Note that the component still throws an error if the context is not provided, regardless of the fallback. + * + * @example + * with `createContextProvider`: + * ```tsx + * // create the context + * const [CounterProvider, useCounter] = createContextProvider(...) + * + * // provide and use the context + * + * + * {({ count }) => ( + *
Count: {count()}
+ * )} + *
+ *
+ * ``` + * + * @example + * with `createOptionalContextProvider` (and `fallback`): + * ```tsx + * // create the optional context + * const [OptionalCounterProvider, useOptionalCounter] = createOptionalContextProvider(...) + * + * // provide and use the context + * + * ( + *
No counter provided
+ * )}> + * {({ count }) => ( + *
Count: {count()}
+ * )} + *
+ *
+ * ``` + * + * @example + * with `createContext`: + * ```tsx + * // create the context + * const CounterContext = createContext(...); + * + * // provide and use the context + * + * + * {({ count }) => ( + *
Count: {count}
+ * )} + *
+ *
+ * ``` + */ +export function ContextConsumer(props: { + children: (value: T) => Element, + fallback?: (() => Element) | Element, + provider: (() => T | undefined) | Context, +}): Element { + let context: T | undefined; + if (props.provider.length === 0) { + context = props.provider(undefined!) as T | undefined; + } else { + context = useContext(props.provider as Context); + } + if (context === undefined) { + if (typeof props.fallback === "function") { + return props.fallback?.(); + } + return props.fallback; + } + return props.children(context); +} diff --git a/packages/context/test/index.test.tsx b/packages/context/test/index.test.tsx index 191860ee0..613aec457 100644 --- a/packages/context/test/index.test.tsx +++ b/packages/context/test/index.test.tsx @@ -6,6 +6,7 @@ import { createOptionalContextProvider, createLayeredContext, MultiProvider, + ContextConsumer, } from "../src/index.js"; type TestContextValue = { @@ -341,3 +342,153 @@ describe("MultiProvider", () => { unmount(); }); }); + +describe("ContextConsumer", () => { + test("consumes a context directly", () => { + const Context = createContext("Default"); + + let capture; + const unmount = render( + () => ( + + + {value => ( + capture = value + )} + + + ), + document.createElement("div") + ); + + expect(capture).toBe("Actual"); + + unmount(); + }); + + test("consumes a context via global use-function", () => { + const Context = createContext("Default"); + + let capture; + const unmount = render( + () => ( + + useContext(Context)}> + {value => ( + capture = value + )} + + + ), + document.createElement("div") + ); + + expect(capture).toBe("Actual"); + + unmount(); + }); + + test("consumes a context via its use-function from `createContextProvider`", () => { + const [Provider, useContext] = createContextProvider( + (props: { value: string }) => props.value, + "Default"); + + let capture; + const unmount = render( + () => ( + + + {value => ( + capture = value + )} + + + ), + document.createElement("div") + ); + + expect(capture).toBe("Actual"); + + unmount(); + }); + + test("consumes a context directly without providing context (default value)", () => { + const Context = createContext("Default"); + + let capture; + const unmount = render( + () => ( + + {value => ( + capture = value + )} + + ), + document.createElement("div") + ); + + expect(capture).toBe("Default"); + + unmount(); + }); + + test("consumes a context via its use-function from `createContextProvider` without provider (default value)", () => { + const [, useContext] = createContextProvider( + (props: { value: string }) => props.value, + "Default" + ); + + let capture; + const unmount2 = render( + () => ( + + {value => ( + capture = value + )} + + ), + document.createElement("div") + ); + + expect(capture).toBe("Default"); + + unmount2(); + }); + + test("renders fallback prop when context is undefined", () => { + const [, useOptional] = createOptionalContextProvider((props: { value?: string }) => props.value); + const container = document.createElement("div"); + document.body.appendChild(container); + const unmount3 = render( + () => ( + "Fallback"}> + {value => ( + value as any + )} + + ), + container, + ); + + expect(container.innerHTML).toBe("Fallback"); + + unmount3(); + document.body.removeChild(container); + }); + + test("throws when provider absent and no fallback", () => { + const [, useRequired] = createContextProvider(() => ({ value: 1 })); + expect(() => + render( + () => ( + + {value => ( + value as any + )} + + ), + document.createElement("div") + ) + ).toThrow(); + }); +}); diff --git a/packages/context/test/server.test.tsx b/packages/context/test/server.test.tsx index 8ef237aa5..88bfe493b 100644 --- a/packages/context/test/server.test.tsx +++ b/packages/context/test/server.test.tsx @@ -1,7 +1,7 @@ import { describe, test, expect } from "vitest"; import { createContext, type FlowComponent, type Element, untrack, useContext } from "solid-js"; import { renderToString } from "@solidjs/web"; -import { createContextProvider, createLayeredContext, MultiProvider } from "../src/index.js"; +import { ContextConsumer, createContextProvider, createOptionalContextProvider, createLayeredContext, MultiProvider } from "../src/index.js"; type TestContextValue = { message: string; @@ -84,3 +84,119 @@ describe("createLayeredContext (SSR)", () => { expect(capturedInner?.base).toBe("ssr"); }); }); + + +describe("ContextConsumer (SSR)", () => { + test("consumes a context directly", () => { + const Context = createContext("Default"); + + let capture; + renderToString(() => ( + + + {value => ( + capture = value + )} + + + )); + + expect(capture).toBe("Actual"); + }); + + test("consumes a context via global use-function", () => { + const Context = createContext("Default"); + + let capture; + renderToString(() => ( + + useContext(Context)}> + {value => ( + capture = value + )} + + + )); + + expect(capture).toBe("Actual"); + }); + + test("consumes a context via its use-function from `createContextProvider`", () => { + const [Provider, useContext] = createContextProvider( + (props: { value: string }) => props.value, + "Default"); + + let capture; + renderToString(() => ( + + + {value => ( + capture = value + )} + + + )); + + expect(capture).toBe("Actual"); + }); + + test("consumes a context directly without providing context (default value)", () => { + const Context = createContext("Default"); + + let capture; + renderToString(() => ( + + {value => ( + capture = value + )} + + )); + + expect(capture).toBe("Default"); + }); + + test("consumes a context via its use-function from `createContextProvider` without provider (default value)", () => { + const [, useContext] = createContextProvider( + (props: { value: string }) => props.value, + "Default"); + + let capture; + renderToString(() => ( + + {value => ( + capture = value + )} + + )); + + expect(capture).toBe("Default"); + }); + + test("renders fallback prop when context is undefined", () => { + const [, useOptional] = createOptionalContextProvider((props: { value?: string }) => props.value); + + let capture; + renderToString(() => ( + (capture = 'Fallback')}> + {value => ( + value as any + )} + + )); + + expect(capture).toBe("Fallback"); + }); + + test("throws when provider absent and no fallback", () => { + const [, useRequired] = createContextProvider(() => ({ value: 1 })); + expect(() => + renderToString(() => ( + + {value => ( + value as any + )} + + )) + ).toThrow(); + }); +});