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

@@ -1,18 +1,18 @@
{ {
"generatedAt": "2026-05-19T13:18:49.389Z", "generatedAt": "2026-05-19T19:19:27.597Z",
"commit": "ae4e0f2", "commit": "33e3c09",
"repo": { "repo": {
"statements": 96.72, "statements": 96.74,
"branches": 92.06, "branches": 92.11,
"functions": 96.83, "functions": 96.86,
"lines": 96.72, "lines": 96.74,
"counts": { "counts": {
"lf": 4607, "lf": 4632,
"lh": 4456, "lh": 4481,
"brf": 907, "brf": 912,
"brh": 835, "brh": 840,
"fnf": 284, "fnf": 287,
"fnh": 275 "fnh": 278
} }
}, },
"byPackage": { "byPackage": {
@@ -59,17 +59,17 @@
} }
}, },
"@repo/core-consent": { "@repo/core-consent": {
"statements": 99.69, "statements": 99.72,
"branches": 94.57, "branches": 94.85,
"functions": 96.77, "functions": 97.06,
"lines": 99.69, "lines": 99.72,
"counts": { "counts": {
"lf": 326, "lf": 351,
"lh": 325, "lh": 350,
"brf": 92, "brf": 97,
"brh": 87, "brh": 92,
"fnf": 31, "fnf": 34,
"fnh": 30 "fnh": 33
} }
}, },
"@repo/core-shared": { "@repo/core-shared": {

View File

@@ -5,7 +5,8 @@
"type": "module", "type": "module",
"exports": { "exports": {
".": "./src/index.ts", ".": "./src/index.ts",
"./di": "./src/di/bind-production.ts" "./di": "./src/di/bind-production.ts",
"./react": "./src/react/index.ts"
}, },
"scripts": { "scripts": {
"build": "tsc --noEmit", "build": "tsc --noEmit",
@@ -19,19 +20,27 @@
"zod": "^3.24.0" "zod": "^3.24.0"
}, },
"peerDependencies": { "peerDependencies": {
"payload": "^3.0.0" "payload": "^3.0.0",
"react": "^18.0.0 || ^19.0.0"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"payload": { "payload": {
"optional": true "optional": true
},
"react": {
"optional": true
} }
}, },
"devDependencies": { "devDependencies": {
"@repo/core-eslint": "workspace:*", "@repo/core-eslint": "workspace:*",
"@repo/core-testing": "workspace:*", "@repo/core-testing": "workspace:*",
"@repo/core-typescript": "workspace:*", "@repo/core-typescript": "workspace:*",
"@testing-library/react": "^16.0.0",
"@types/react": "^19.0.0",
"@vitest/coverage-v8": "^3.0.0", "@vitest/coverage-v8": "^3.0.0",
"jsdom": "^25.0.0",
"payload": "^3.14.0", "payload": "^3.14.0",
"react": "^19.0.0",
"typescript": "^5.8.0", "typescript": "^5.8.0",
"vitest": "^3.0.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": { "compilerOptions": {
"outDir": "dist", "outDir": "dist",
"rootDir": ".", "rootDir": ".",
@@ -7,6 +7,6 @@
"@/*": ["./src/*"] "@/*": ["./src/*"]
} }
}, },
"include": ["**/*.ts"], "include": ["**/*.ts", "**/*.tsx"],
"exclude": ["node_modules", "dist"] "exclude": ["node_modules", "dist"]
} }

View File

@@ -1,9 +1,17 @@
import path from "node:path"; 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"; import { nodeVitestConfig } from "@repo/core-typescript/vitest.base.node";
export default mergeConfig(nodeVitestConfig, { export default mergeConfig(
resolve: { nodeVitestConfig,
alias: { "@": path.resolve(__dirname, "./src") }, 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") },
},
}),
);

12
pnpm-lock.yaml generated
View File

@@ -608,12 +608,24 @@ importers:
"@repo/core-typescript": "@repo/core-typescript":
specifier: workspace:* specifier: workspace:*
version: link:../core-typescript version: link:../core-typescript
"@testing-library/react":
specifier: ^16.0.0
version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
"@types/react":
specifier: ^19.0.0
version: 19.2.14
"@vitest/coverage-v8": "@vitest/coverage-v8":
specifier: ^3.0.0 specifier: ^3.0.0
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.5.2)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0)) version: 3.2.4(vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.5.2)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0))
jsdom:
specifier: ^25.0.0
version: 25.0.1
payload: payload:
specifier: ^3.14.0 specifier: ^3.14.0
version: 3.81.0(graphql@16.13.2)(typescript@5.9.3) version: 3.81.0(graphql@16.13.2)(typescript@5.9.3)
react:
specifier: ^19.0.0
version: 19.2.4
typescript: typescript:
specifier: ^5.8.0 specifier: ^5.8.0
version: 5.9.3 version: 5.9.3