feat(core-consent): add ConsentProvider + useConsent() React subpath

Adds the ./react subpath to @repo/core-consent following the same
pattern as @repo/core-analytics/react:

- ConsentProvider wraps IConsent in React context
- useConsent() returns the injected IConsent or throws ConsentContextError
- RTL tests cover context propagation, grant/withdraw delegation,
  isGranted state reflection, getCategories, and missing-provider error
- package.json: ./react export + React optional peerDep + RTL devDeps
- tsconfig: extend react-library.json, include .tsx
- vitest.config: jsdom environment for .test.tsx + jsdom setup

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-19 19:19:47 +00:00
parent 33e3c093ad
commit 9b6deac954
8 changed files with 188 additions and 32 deletions

View File

@@ -5,7 +5,8 @@
"type": "module",
"exports": {
".": "./src/index.ts",
"./di": "./src/di/bind-production.ts"
"./di": "./src/di/bind-production.ts",
"./react": "./src/react/index.ts"
},
"scripts": {
"build": "tsc --noEmit",
@@ -19,19 +20,27 @@
"zod": "^3.24.0"
},
"peerDependencies": {
"payload": "^3.0.0"
"payload": "^3.0.0",
"react": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"payload": {
"optional": true
},
"react": {
"optional": true
}
},
"devDependencies": {
"@repo/core-eslint": "workspace:*",
"@repo/core-testing": "workspace:*",
"@repo/core-typescript": "workspace:*",
"@testing-library/react": "^16.0.0",
"@types/react": "^19.0.0",
"@vitest/coverage-v8": "^3.0.0",
"jsdom": "^25.0.0",
"payload": "^3.14.0",
"react": "^19.0.0",
"typescript": "^5.8.0",
"vitest": "^3.0.0"
}

View File

@@ -0,0 +1,91 @@
import { describe, expect, it, vi } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { RecordingConsent } from "@repo/core-testing";
import {
ConsentContextError,
ConsentProvider,
useConsent,
} from "@/react/index";
function makeWrapper(consent: RecordingConsent) {
return function Wrapper({ children }: { children: React.ReactNode }) {
return <ConsentProvider value={consent}>{children}</ConsentProvider>;
};
}
describe("ConsentProvider / useConsent", () => {
it("returns the injected IConsent instance", () => {
const recording = new RecordingConsent();
const { result } = renderHook(() => useConsent(), {
wrapper: makeWrapper(recording),
});
expect(result.current).toBe(recording);
});
it("propagates grant() to the injected IConsent", async () => {
const recording = new RecordingConsent();
const { result } = renderHook(() => useConsent(), {
wrapper: makeWrapper(recording),
});
await act(async () => {
await result.current.grant("analytics");
});
expect(recording.grants).toContainEqual({
category: "analytics",
meta: undefined,
});
});
it("propagates withdraw() to the injected IConsent", async () => {
const recording = new RecordingConsent();
await recording.grant("marketing");
const { result } = renderHook(() => useConsent(), {
wrapper: makeWrapper(recording),
});
await act(async () => {
await result.current.withdraw("marketing");
});
expect(recording.withdrawals).toContain("marketing");
});
it("isGranted() reflects the injected instance state", async () => {
const recording = new RecordingConsent();
await recording.grant("functional");
const { result } = renderHook(() => useConsent(), {
wrapper: makeWrapper(recording),
});
expect(result.current.isGranted("functional")).toBe(true);
expect(result.current.isGranted("marketing")).toBe(false);
});
it("getCategories() returns categories from the injected IConsent", async () => {
const recording = new RecordingConsent();
await recording.grant("necessary");
const { result } = renderHook(() => useConsent(), {
wrapper: makeWrapper(recording),
});
const cats = result.current.getCategories();
expect(cats).toHaveLength(1);
expect(cats[0]).toMatchObject({ category: "necessary", state: "granted" });
});
});
describe("useConsent without provider", () => {
it("throws ConsentContextError when called outside a provider", () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
expect(() => renderHook(() => useConsent())).toThrow(ConsentContextError);
} finally {
spy.mockRestore();
}
});
});

View File

@@ -0,0 +1,31 @@
import { createContext, useContext, type ReactNode } from "react";
import type { IConsent } from "../consent.interface";
const ConsentContext = createContext<IConsent | null>(null);
export class ConsentContextError extends Error {
constructor() {
super("useConsent() must be called within a <ConsentProvider>.");
this.name = "ConsentContextError";
}
}
export function ConsentProvider({
value,
children,
}: {
value: IConsent;
children: ReactNode;
}) {
return (
<ConsentContext.Provider value={value}>{children}</ConsentContext.Provider>
);
}
export function useConsent(): IConsent {
const consent = useContext(ConsentContext);
if (consent === null) {
throw new ConsentContextError();
}
return consent;
}

View File

@@ -0,0 +1,5 @@
export {
ConsentProvider,
useConsent,
ConsentContextError,
} from "./consent-provider";

View File

@@ -1,5 +1,5 @@
{
"extends": "@repo/core-typescript/base.json",
"extends": "@repo/core-typescript/react-library.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": ".",
@@ -7,6 +7,6 @@
"@/*": ["./src/*"]
}
},
"include": ["**/*.ts"],
"include": ["**/*.ts", "**/*.tsx"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -1,9 +1,17 @@
import path from "node:path";
import { mergeConfig } from "vitest/config";
import { defineConfig, mergeConfig } from "vitest/config";
import { nodeVitestConfig } from "@repo/core-typescript/vitest.base.node";
export default mergeConfig(nodeVitestConfig, {
resolve: {
alias: { "@": path.resolve(__dirname, "./src") },
},
});
export default mergeConfig(
nodeVitestConfig,
defineConfig({
test: {
include: ["src/**/*.test.{ts,tsx}", "tests/**/*.test.{ts,tsx}"],
environmentMatchGlobs: [["**/*.test.tsx", "jsdom"]],
setupFiles: ["@repo/core-testing/setup/jsdom"],
},
resolve: {
alias: { "@": path.resolve(__dirname, "./src") },
},
}),
);