Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/strong-moose-attend.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solid-primitives/context": minor
---

Add `ContextConsumer`
67 changes: 67 additions & 0 deletions packages/context/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 (
<CounterProvider>
<ContextConsumer provider={useCounter} fallback={() => (
<div>
<p>Fallback</p>
</div>
)}>
{({ count, increment }) => (
<div>
<p>Count: {count()}</p>
<button onClick={increment}>Increment</button>
</div>
)}
</ContextConsumer>
</CounterProvider>
);
```

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
<CounterContext>
<ContextConsumer provider={CounterContext}>
{({ count, increment }) => {
<div>
<p>Count: {count()}</p>
<button onClick={increment}>Increment</button>
</div>
}}
</ContextConsumer>
</CounterContext>
```

## Changelog

See [CHANGELOG.md](./CHANGELOG.md)
104 changes: 99 additions & 5 deletions packages/context/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,11 +194,11 @@ Type validation of the `values` array thanks to the amazing @otonashixav (https:
export function MultiProvider<T extends readonly [unknown?, ...unknown[]]>(props: {
values: {
[K in keyof T]:
| readonly [
Context<T[K]> | ContextProviderComponent<T[K]>,
[T[K]][T extends unknown ? 0 : never],
]
| FlowComponent;
| readonly [
Context<T[K]> | ContextProviderComponent<T[K]>,
[T[K]][T extends unknown ? 0 : never],
]
| FlowComponent;
};
children: Element;
}): Element {
Expand All @@ -222,3 +222,97 @@ export function MultiProvider<T extends readonly [unknown?, ...unknown[]]>(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
* <CounterProvider>
* {untrack(() => {
* const context = useContext(counterContext); // or useCounter()
* return children(context);
* })}
* </CounterProvider>
* ```
*
* @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
* <CounterProvider count={1}>
* <ContextConsumer provider={useCounter}>
* {({ count }) => (
* <div>Count: {count()}</div>
* )}
* </ContextConsumer>
* </CounterProvider>
* ```
*
* @example
* with `createOptionalContextProvider` (and `fallback`):
* ```tsx
* // create the optional context
* const [OptionalCounterProvider, useOptionalCounter] = createOptionalContextProvider(...)
*
* // provide and use the context
* <OptionalCounterProvider>
* <ContextConsumer provider={useOptionalCounter} fallback={() => (
* <div>No counter provided</div>
* )}>
* {({ count }) => (
* <div>Count: {count()}</div>
* )}
* </ContextConsumer>
* </OptionalCounterProvider>
* ```
*
* @example
* with `createContext`:
* ```tsx
* // create the context
* const CounterContext = createContext(...);
*
* // provide and use the context
* <CounterContext value={{ count: 1 }}>
* <ContextConsumer provider={CounterContext}>
* {({ count }) => (
* <div>Count: {count}</div>
* )}
* </ContextConsumer>
* </CounterContext>
* ```
*/
export function ContextConsumer<T>(props: {
children: (value: T) => Element,
fallback?: (() => Element) | Element,
provider: (() => T | undefined) | Context<T>,
}): Element {
let context: T | undefined;
if (props.provider.length === 0) {
context = props.provider(undefined!) as T | undefined;
} else {
context = useContext(props.provider as Context<T>);
}
if (context === undefined) {
if (typeof props.fallback === "function") {
return props.fallback?.();
}
return props.fallback;
}
return props.children(context);
}
Comment on lines +311 to +318

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Fallback doesn't suppress ContextNotFoundError, and no-fallback path silently returns undefined instead of throwing.

Two gaps versus the documented contract ("if no fallback is provided, this component will throw an error"):

  1. For default-less contexts, useContext throws synchronously before this undefined check runs, so fallback can never mask that error.
  2. For optional contexts with no fallback supplied, this returns undefined silently rather than throwing.

Please document limitation (1) in the README so users don't expect fallback to suppress errors for required contexts, and consider whether (2) should throw explicitly to match the documented behavior.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/context/src/index.ts` around lines 289 - 296, The fallback handling
in the context wrapper is inconsistent with the documented contract: required
contexts still throw from `useContext` before the `context === undefined`
branch, and the no-fallback path currently returns `undefined` instead of
erroring. Update the implementation around the `context` check and
`props.fallback` handling so the `ContextNotFoundError` behavior is explicit for
missing required contexts, and decide whether the no-fallback branch should
throw rather than silently return `undefined`; also add a README note near the
`useContext`/`fallback` docs clarifying that fallback cannot suppress errors for
required contexts.

151 changes: 151 additions & 0 deletions packages/context/test/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
createOptionalContextProvider,
createLayeredContext,
MultiProvider,
ContextConsumer,
} from "../src/index.js";

type TestContextValue = {
Expand Down Expand Up @@ -341,3 +342,153 @@ describe("MultiProvider", () => {
unmount();
});
});

describe("ContextConsumer", () => {
test("consumes a context directly", () => {
const Context = createContext<string>("Default");

let capture;
const unmount = render(
() => (
<Context value="Actual">
<ContextConsumer provider={Context}>
{value => (
capture = value
)}
</ContextConsumer>
</Context>
),
document.createElement("div")
);

expect(capture).toBe("Actual");

unmount();
});

test("consumes a context via global use-function", () => {
const Context = createContext<string>("Default");

let capture;
const unmount = render(
() => (
<Context value="Actual">
<ContextConsumer provider={() => useContext(Context)}>
{value => (
capture = value
)}
</ContextConsumer>
</Context>
),
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(
() => (
<Provider value="Actual">
<ContextConsumer provider={useContext}>
{value => (
capture = value
)}
</ContextConsumer>
</Provider>
),
document.createElement("div")
);

expect(capture).toBe("Actual");

unmount();
});

test("consumes a context directly without providing context (default value)", () => {
const Context = createContext<string>("Default");

let capture;
const unmount = render(
() => (
<ContextConsumer provider={Context}>
{value => (
capture = value
)}
</ContextConsumer>
),
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(
() => (
<ContextConsumer provider={useContext}>
{value => (
capture = value
)}
</ContextConsumer>
),
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(
() => (
<ContextConsumer provider={useOptional} fallback={() => "Fallback"}>
{value => (
value as any
)}
</ContextConsumer>
),
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(
() => (
<ContextConsumer provider={useRequired}>
{value => (
value as any
)}
</ContextConsumer>
),
document.createElement("div")
)
).toThrow();
});
});
Loading