From f860caaeebe8e0185ca108510eba8d052bd0d180 Mon Sep 17 00:00:00 2001 From: Jeffrey Chupp Date: Tue, 11 Mar 2025 11:51:19 -0400 Subject: [PATCH] Support typed configs and hooks - Adds new typesafe functionality with PrefabTypesafeClass and usePrefabTypesafe hook - Adds createPrefabHook for creating typed hooks - Adds ProvidedContext for better type safety --- .npmignore | 7 + jest.config.js | 4 +- package-lock.json | 251 +++++-- package.json | 6 + src/PrefabProvider.test.tsx | 286 -------- src/PrefabProvider.tsx | 95 ++- src/PrefabTestProvider.test.tsx | 82 --- src/PrefabTestProvider.tsx | 39 +- src/__tests__/PrefabProvider.test.tsx | 614 ++++++++++++++++++ src/__tests__/PrefabTestProvider.test.tsx | 263 ++++++++ src/{ => __tests__}/jest.setup.ts | 0 src/{ => __tests__}/nested-providers.test.tsx | 2 +- .../nested-test-providers.test.tsx | 6 +- src/__tests__/test-helpers.tsx | 92 +++ src/index.tsx | 8 + 15 files changed, 1289 insertions(+), 466 deletions(-) delete mode 100644 src/PrefabProvider.test.tsx delete mode 100644 src/PrefabTestProvider.test.tsx create mode 100644 src/__tests__/PrefabProvider.test.tsx create mode 100644 src/__tests__/PrefabTestProvider.test.tsx rename src/{ => __tests__}/jest.setup.ts (100%) rename src/{ => __tests__}/nested-providers.test.tsx (99%) rename src/{ => __tests__}/nested-test-providers.test.tsx (99%) create mode 100644 src/__tests__/test-helpers.tsx diff --git a/.npmignore b/.npmignore index 71bba82..5148d46 100644 --- a/.npmignore +++ b/.npmignore @@ -2,6 +2,13 @@ src/ tests/ +# Test files and helpers +**/__tests__/ +**/*.test.* +**/*.spec.* +**/test-*.* +**/test/ + # Development/build files scripts/ tsconfig.json diff --git a/jest.config.js b/jest.config.js index e0b325d..a1f2b0b 100644 --- a/jest.config.js +++ b/jest.config.js @@ -12,8 +12,8 @@ module.exports = { }, ], }, - setupFilesAfterEnv: ["/src/jest.setup.ts"], - testRegex: "(/__tests__/.*|(\\.|/)(test|spec))\\.[jt]sx?$", + setupFilesAfterEnv: ["/src/__tests__/jest.setup.ts"], + testRegex: "(/__tests__/.*\\.)(test|spec)\\.[jt]sx?$", moduleNameMapper: { // Handle module aliases and resolve paths "^(\\.{1,2}/.*)\\.js$": "$1", diff --git a/package-lock.json b/package-lock.json index 9d5a46e..574ce23 100644 --- a/package-lock.json +++ b/package-lock.json @@ -992,6 +992,33 @@ "node": ">=18" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.5.0.tgz", + "integrity": "sha512-RoV8Xs9eNwiDvhv7M+xcL4PWyRyIXRY/FLp3buU4h1EYfdF7unWUy3dOjPqb3C7rMUewIcqwW850PgS8h1o1yg==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, "node_modules/@eslint/eslintrc": { "version": "1.3.0", "integrity": "sha512-UWW0TMTmk2d7hLcWD1/e2g5HDM/HQ3csaLSqXCfqwh4uNDuNqlaKWXmEsL4Cs41Z0KnILNvwbHAah3C2yt06kw==", @@ -2314,6 +2341,12 @@ "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==", "dev": true }, + "node_modules/@types/semver": { + "version": "7.5.8", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz", + "integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==", + "dev": true + }, "node_modules/@types/stack-utils": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz", @@ -2350,17 +2383,19 @@ "dev": true }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "5.33.0", - "integrity": "sha512-jHvZNSW2WZ31OPJ3enhLrEKvAZNyAFWZ6rx9tUwaessTc4sx9KmgMNhVcqVAl1ETnT5rU5fpXTLmY9YvC1DCNg==", + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz", + "integrity": "sha512-TiZzBSJja/LbhNPvk6yc0JrX9XqhQ0hdh6M2svYfsHGejaKFIAGd9MQ+ERIMzLGlN/kZoYIgdxFV0PuljTKXag==", "dev": true, "dependencies": { - "@typescript-eslint/scope-manager": "5.33.0", - "@typescript-eslint/type-utils": "5.33.0", - "@typescript-eslint/utils": "5.33.0", + "@eslint-community/regexpp": "^4.4.0", + "@typescript-eslint/scope-manager": "5.62.0", + "@typescript-eslint/type-utils": "5.62.0", + "@typescript-eslint/utils": "5.62.0", "debug": "^4.3.4", - "functional-red-black-tree": "^1.0.1", + "graphemer": "^1.4.0", "ignore": "^5.2.0", - "regexpp": "^3.2.0", + "natural-compare-lite": "^1.4.0", "semver": "^7.3.7", "tsutils": "^3.21.0" }, @@ -2382,13 +2417,14 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "5.33.0", - "integrity": "sha512-cgM5cJrWmrDV2KpvlcSkelTBASAs1mgqq+IUGKJvFxWrapHpaRy5EXPQz9YaKF3nZ8KY18ILTiVpUtbIac86/w==", + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.62.0.tgz", + "integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==", "dev": true, "dependencies": { - "@typescript-eslint/scope-manager": "5.33.0", - "@typescript-eslint/types": "5.33.0", - "@typescript-eslint/typescript-estree": "5.33.0", + "@typescript-eslint/scope-manager": "5.62.0", + "@typescript-eslint/types": "5.62.0", + "@typescript-eslint/typescript-estree": "5.62.0", "debug": "^4.3.4" }, "engines": { @@ -2408,12 +2444,13 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "5.33.0", - "integrity": "sha512-/Jta8yMNpXYpRDl8EwF/M8It2A9sFJTubDo0ATZefGXmOqlaBffEw0ZbkbQ7TNDK6q55NPHFshGBPAZvZkE8Pw==", + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.62.0.tgz", + "integrity": "sha512-VXuvVvZeQCQb5Zgf4HAxc04q5j+WrNAtNh9OwCsCgpKqESMTu3tF/jhZ3xG6T4NZwWl65Bg8KuS2uEvhSfLl0w==", "dev": true, "dependencies": { - "@typescript-eslint/types": "5.33.0", - "@typescript-eslint/visitor-keys": "5.33.0" + "@typescript-eslint/types": "5.62.0", + "@typescript-eslint/visitor-keys": "5.62.0" }, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -2424,11 +2461,13 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "5.33.0", - "integrity": "sha512-2zB8uEn7hEH2pBeyk3NpzX1p3lF9dKrEbnXq1F7YkpZ6hlyqb2yZujqgRGqXgRBTHWIUG3NGx/WeZk224UKlIA==", + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.62.0.tgz", + "integrity": "sha512-xsSQreu+VnfbqQpW5vnCJdq1Z3Q0U31qiWmRhr98ONQmcp/yhiPJFPq8MXiJVLiksmOKSjIldZzkebzHuCGzew==", "dev": true, "dependencies": { - "@typescript-eslint/utils": "5.33.0", + "@typescript-eslint/typescript-estree": "5.62.0", + "@typescript-eslint/utils": "5.62.0", "debug": "^4.3.4", "tsutils": "^3.21.0" }, @@ -2449,8 +2488,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "5.33.0", - "integrity": "sha512-nIMt96JngB4MYFYXpZ/3ZNU4GWPNdBbcB5w2rDOCpXOVUkhtNlG2mmm8uXhubhidRZdwMaMBap7Uk8SZMU/ppw==", + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.62.0.tgz", + "integrity": "sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==", "dev": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -2461,12 +2501,13 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "5.33.0", - "integrity": "sha512-tqq3MRLlggkJKJUrzM6wltk8NckKyyorCSGMq4eVkyL5sDYzJJcMgZATqmF8fLdsWrW7OjjIZ1m9v81vKcaqwQ==", + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.62.0.tgz", + "integrity": "sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA==", "dev": true, "dependencies": { - "@typescript-eslint/types": "5.33.0", - "@typescript-eslint/visitor-keys": "5.33.0", + "@typescript-eslint/types": "5.62.0", + "@typescript-eslint/visitor-keys": "5.62.0", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", @@ -2487,16 +2528,19 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "5.33.0", - "integrity": "sha512-JxOAnXt9oZjXLIiXb5ZIcZXiwVHCkqZgof0O8KPgz7C7y0HS42gi75PdPlqh1Tf109M0fyUw45Ao6JLo7S5AHw==", + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.62.0.tgz", + "integrity": "sha512-n8oxjeb5aIbPFEtmQxQYOLI0i9n5ySBEY/ZEHHZqKQSFnxio1rv6dthascc9dLuwrL0RC5mPCxB7vnAVGAYWAQ==", "dev": true, "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", "@types/json-schema": "^7.0.9", - "@typescript-eslint/scope-manager": "5.33.0", - "@typescript-eslint/types": "5.33.0", - "@typescript-eslint/typescript-estree": "5.33.0", + "@types/semver": "^7.3.12", + "@typescript-eslint/scope-manager": "5.62.0", + "@typescript-eslint/types": "5.62.0", + "@typescript-eslint/typescript-estree": "5.62.0", "eslint-scope": "^5.1.1", - "eslint-utils": "^3.0.0" + "semver": "^7.3.7" }, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -2510,11 +2554,12 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "5.33.0", - "integrity": "sha512-/XsqCzD4t+Y9p5wd9HZiptuGKBlaZO5showwqODii5C0nZawxWLF+Q6k5wYHBrQv96h6GYKyqqMHCSTqta8Kiw==", + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.62.0.tgz", + "integrity": "sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw==", "dev": true, "dependencies": { - "@typescript-eslint/types": "5.33.0", + "@typescript-eslint/types": "5.62.0", "eslint-visitor-keys": "^3.3.0" }, "engines": { @@ -4151,11 +4196,15 @@ } }, "node_modules/eslint-visitor-keys": { - "version": "3.3.0", - "integrity": "sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==", + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", "dev": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, "node_modules/eslint/node_modules/chalk": { @@ -4668,6 +4717,12 @@ "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==", "dev": true }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true + }, "node_modules/has": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", @@ -6763,6 +6818,12 @@ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, + "node_modules/natural-compare-lite": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare-lite/-/natural-compare-lite-1.4.0.tgz", + "integrity": "sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==", + "dev": true + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -9477,6 +9538,21 @@ "dev": true, "optional": true }, + "@eslint-community/eslint-utils": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.5.0.tgz", + "integrity": "sha512-RoV8Xs9eNwiDvhv7M+xcL4PWyRyIXRY/FLp3buU4h1EYfdF7unWUy3dOjPqb3C7rMUewIcqwW850PgS8h1o1yg==", + "dev": true, + "requires": { + "eslint-visitor-keys": "^3.4.3" + } + }, + "@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true + }, "@eslint/eslintrc": { "version": "1.3.0", "integrity": "sha512-UWW0TMTmk2d7hLcWD1/e2g5HDM/HQ3csaLSqXCfqwh4uNDuNqlaKWXmEsL4Cs41Z0KnILNvwbHAah3C2yt06kw==", @@ -10464,6 +10540,12 @@ "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==", "dev": true }, + "@types/semver": { + "version": "7.5.8", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz", + "integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==", + "dev": true + }, "@types/stack-utils": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz", @@ -10500,63 +10582,71 @@ "dev": true }, "@typescript-eslint/eslint-plugin": { - "version": "5.33.0", - "integrity": "sha512-jHvZNSW2WZ31OPJ3enhLrEKvAZNyAFWZ6rx9tUwaessTc4sx9KmgMNhVcqVAl1ETnT5rU5fpXTLmY9YvC1DCNg==", + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz", + "integrity": "sha512-TiZzBSJja/LbhNPvk6yc0JrX9XqhQ0hdh6M2svYfsHGejaKFIAGd9MQ+ERIMzLGlN/kZoYIgdxFV0PuljTKXag==", "dev": true, "requires": { - "@typescript-eslint/scope-manager": "5.33.0", - "@typescript-eslint/type-utils": "5.33.0", - "@typescript-eslint/utils": "5.33.0", + "@eslint-community/regexpp": "^4.4.0", + "@typescript-eslint/scope-manager": "5.62.0", + "@typescript-eslint/type-utils": "5.62.0", + "@typescript-eslint/utils": "5.62.0", "debug": "^4.3.4", - "functional-red-black-tree": "^1.0.1", + "graphemer": "^1.4.0", "ignore": "^5.2.0", - "regexpp": "^3.2.0", + "natural-compare-lite": "^1.4.0", "semver": "^7.3.7", "tsutils": "^3.21.0" } }, "@typescript-eslint/parser": { - "version": "5.33.0", - "integrity": "sha512-cgM5cJrWmrDV2KpvlcSkelTBASAs1mgqq+IUGKJvFxWrapHpaRy5EXPQz9YaKF3nZ8KY18ILTiVpUtbIac86/w==", + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.62.0.tgz", + "integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==", "dev": true, "requires": { - "@typescript-eslint/scope-manager": "5.33.0", - "@typescript-eslint/types": "5.33.0", - "@typescript-eslint/typescript-estree": "5.33.0", + "@typescript-eslint/scope-manager": "5.62.0", + "@typescript-eslint/types": "5.62.0", + "@typescript-eslint/typescript-estree": "5.62.0", "debug": "^4.3.4" } }, "@typescript-eslint/scope-manager": { - "version": "5.33.0", - "integrity": "sha512-/Jta8yMNpXYpRDl8EwF/M8It2A9sFJTubDo0ATZefGXmOqlaBffEw0ZbkbQ7TNDK6q55NPHFshGBPAZvZkE8Pw==", + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.62.0.tgz", + "integrity": "sha512-VXuvVvZeQCQb5Zgf4HAxc04q5j+WrNAtNh9OwCsCgpKqESMTu3tF/jhZ3xG6T4NZwWl65Bg8KuS2uEvhSfLl0w==", "dev": true, "requires": { - "@typescript-eslint/types": "5.33.0", - "@typescript-eslint/visitor-keys": "5.33.0" + "@typescript-eslint/types": "5.62.0", + "@typescript-eslint/visitor-keys": "5.62.0" } }, "@typescript-eslint/type-utils": { - "version": "5.33.0", - "integrity": "sha512-2zB8uEn7hEH2pBeyk3NpzX1p3lF9dKrEbnXq1F7YkpZ6hlyqb2yZujqgRGqXgRBTHWIUG3NGx/WeZk224UKlIA==", + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.62.0.tgz", + "integrity": "sha512-xsSQreu+VnfbqQpW5vnCJdq1Z3Q0U31qiWmRhr98ONQmcp/yhiPJFPq8MXiJVLiksmOKSjIldZzkebzHuCGzew==", "dev": true, "requires": { - "@typescript-eslint/utils": "5.33.0", + "@typescript-eslint/typescript-estree": "5.62.0", + "@typescript-eslint/utils": "5.62.0", "debug": "^4.3.4", "tsutils": "^3.21.0" } }, "@typescript-eslint/types": { - "version": "5.33.0", - "integrity": "sha512-nIMt96JngB4MYFYXpZ/3ZNU4GWPNdBbcB5w2rDOCpXOVUkhtNlG2mmm8uXhubhidRZdwMaMBap7Uk8SZMU/ppw==", + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.62.0.tgz", + "integrity": "sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==", "dev": true }, "@typescript-eslint/typescript-estree": { - "version": "5.33.0", - "integrity": "sha512-tqq3MRLlggkJKJUrzM6wltk8NckKyyorCSGMq4eVkyL5sDYzJJcMgZATqmF8fLdsWrW7OjjIZ1m9v81vKcaqwQ==", + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.62.0.tgz", + "integrity": "sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA==", "dev": true, "requires": { - "@typescript-eslint/types": "5.33.0", - "@typescript-eslint/visitor-keys": "5.33.0", + "@typescript-eslint/types": "5.62.0", + "@typescript-eslint/visitor-keys": "5.62.0", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", @@ -10565,24 +10655,28 @@ } }, "@typescript-eslint/utils": { - "version": "5.33.0", - "integrity": "sha512-JxOAnXt9oZjXLIiXb5ZIcZXiwVHCkqZgof0O8KPgz7C7y0HS42gi75PdPlqh1Tf109M0fyUw45Ao6JLo7S5AHw==", + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.62.0.tgz", + "integrity": "sha512-n8oxjeb5aIbPFEtmQxQYOLI0i9n5ySBEY/ZEHHZqKQSFnxio1rv6dthascc9dLuwrL0RC5mPCxB7vnAVGAYWAQ==", "dev": true, "requires": { + "@eslint-community/eslint-utils": "^4.2.0", "@types/json-schema": "^7.0.9", - "@typescript-eslint/scope-manager": "5.33.0", - "@typescript-eslint/types": "5.33.0", - "@typescript-eslint/typescript-estree": "5.33.0", + "@types/semver": "^7.3.12", + "@typescript-eslint/scope-manager": "5.62.0", + "@typescript-eslint/types": "5.62.0", + "@typescript-eslint/typescript-estree": "5.62.0", "eslint-scope": "^5.1.1", - "eslint-utils": "^3.0.0" + "semver": "^7.3.7" } }, "@typescript-eslint/visitor-keys": { - "version": "5.33.0", - "integrity": "sha512-/XsqCzD4t+Y9p5wd9HZiptuGKBlaZO5showwqODii5C0nZawxWLF+Q6k5wYHBrQv96h6GYKyqqMHCSTqta8Kiw==", + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.62.0.tgz", + "integrity": "sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw==", "dev": true, "requires": { - "@typescript-eslint/types": "5.33.0", + "@typescript-eslint/types": "5.62.0", "eslint-visitor-keys": "^3.3.0" } }, @@ -11823,8 +11917,9 @@ } }, "eslint-visitor-keys": { - "version": "3.3.0", - "integrity": "sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==", + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", "dev": true }, "espree": { @@ -12179,6 +12274,12 @@ "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==", "dev": true }, + "graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true + }, "has": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", @@ -13748,6 +13849,12 @@ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, + "natural-compare-lite": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare-lite/-/natural-compare-lite-1.4.0.tgz", + "integrity": "sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==", + "dev": true + }, "node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", diff --git a/package.json b/package.json index 553da90..438475b 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,12 @@ "default": "./dist/index.cjs" } }, + "files": [ + "dist/", + "README.md", + "LICENSE", + "CHANGELOG.md" + ], "scripts": { "prebuild": "node scripts/update-version.js", "build": "rm -rf dist/ && tsup", diff --git a/src/PrefabProvider.test.tsx b/src/PrefabProvider.test.tsx deleted file mode 100644 index a30c27f..0000000 --- a/src/PrefabProvider.test.tsx +++ /dev/null @@ -1,286 +0,0 @@ -import React from "react"; -import { render, screen } from "@testing-library/react"; -import "@testing-library/jest-dom/extend-expect"; -import { act } from "react-dom/test-utils"; -import { ContextValue } from "@prefab-cloud/prefab-cloud-js"; -import { ContextAttributes, PrefabProvider, usePrefab } from "./PrefabProvider"; - -type Config = { [key: string]: any }; - -function MyComponent() { - const { get, isEnabled, loading, keys } = usePrefab(); - const greeting = get("greeting") || "Default"; - const subtitle = get("subtitle")?.actualSubtitle || "Default Subtitle"; - - if (loading) { - return
Loading...
; - } - - return ( -
-

{greeting}

-

{subtitle}

- {isEnabled("secretFeature") && ( - - )} - -
{JSON.stringify(keys)}
-
- ); -} - -let warn: ReturnType; -let error: ReturnType; - -beforeEach(() => { - error = jest.spyOn(console, "error").mockImplementation(() => {}); - warn = jest.spyOn(console, "warn").mockImplementation(() => {}); -}); - -afterEach(() => { - warn.mockReset(); - error.mockReset(); -}); - -describe("PrefabProvider", () => { - const defaultContextAttributes = { user: { email: "test@example.com" } }; - - const renderInProvider = ({ - contextAttributes, - onError, - }: { - contextAttributes?: { [key: string]: Record }; - onError?: (err: Error) => void; - }) => - render( - - - - ); - - const stubConfig = (config: Config) => - new Promise((resolve) => { - global.fetch = jest.fn(() => - Promise.resolve({ - ok: true, - json: () => { - setTimeout(resolve); - return { evaluations: config }; - }, - }) - ) as jest.Mock; - }); - - const renderWithConfig = async ( - config: Config, - providerConfig: Parameters[0] = { - contextAttributes: defaultContextAttributes, - onError: (e) => { - throw e; - }, - } - ) => { - const promise = stubConfig(config); - - const rendered = renderInProvider(providerConfig); - - await act(async () => { - await promise; - }); - - // wait for the loading content to go away - screen.findByRole("alert"); - - return rendered; - }; - - it("renders without config", async () => { - await renderWithConfig({}); - - const alert = screen.queryByRole("alert"); - expect(alert).toHaveTextContent("Default"); - const secretFeature = screen.queryByTitle("secret-feature"); - expect(secretFeature).not.toBeInTheDocument(); - }); - - it("allows providing flag values", async () => { - await renderWithConfig({ greeting: { value: { string: "CUSTOM" } } }); - - const alert = screen.queryByRole("alert"); - expect(alert).toHaveTextContent("CUSTOM"); - const secretFeature = screen.queryByTitle("secret-feature"); - expect(secretFeature).not.toBeInTheDocument(); - }); - - it("allows providing true flag booleans", async () => { - await renderWithConfig({ - greeting: { value: { string: "CUSTOM" } }, - secretFeature: { value: { boolean: true } }, - }); - - const alert = screen.queryByRole("alert"); - expect(alert).toHaveTextContent("CUSTOM"); - const secretFeature = screen.queryByTitle("secret-feature"); - expect(secretFeature).toBeInTheDocument(); - }); - - it("allows providing false flag booleans", async () => { - await renderWithConfig({ - greeting: { value: { string: "CUSTOM" } }, - secretFeature: { value: { boolean: false } }, - }); - - const alert = screen.queryByRole("alert"); - expect(alert).toHaveTextContent("CUSTOM"); - const secretFeature = screen.queryByTitle("secret-feature"); - expect(secretFeature).not.toBeInTheDocument(); - }); - - it("allows providing json configs", async () => { - await renderWithConfig({ - subtitle: { value: { json: '{ "actualSubtitle": "Json Subtitle" }' } }, - }); - - const alert = screen.queryByRole("banner"); - expect(alert).toHaveTextContent("Json Subtitle"); - }); - - it("warns when you do not provide contextAttributes", async () => { - const rendered = await renderWithConfig( - { - greeting: { value: { string: "CUSTOM" } }, - secretFeature: { value: { boolean: true } }, - }, - { contextAttributes: { user: { email: "old@example.com" } } } - ); - - let alert = screen.queryByRole("alert"); - expect(alert).toHaveTextContent("CUSTOM"); - - const newConfigPromise = stubConfig({ - greeting: { value: { string: "ANOTHER" } }, - secretFeature: { value: { boolean: false } }, - }); - - act(() => { - rendered.rerender( - {}} - > - - - ); - }); - - await newConfigPromise; - - // wait for re-render - // eslint-disable-next-line no-promise-executor-return - await new Promise((r) => setTimeout(r, 1)); - - alert = screen.queryByRole("alert"); - expect(alert).toHaveTextContent("ANOTHER"); - }); - - it("re-fetches when you update the contextAttributes prop on the provider", async () => { - let setContextAttributes: (attributes: ContextAttributes) => void = () => { - // eslint-disable-next-line no-console - console.warn("setContextAttributes not set"); - }; - - const promise = stubConfig({ greeting: { value: { string: "CUSTOM" } } }); - - function Wrapper({ context }: { context: ContextAttributes }) { - const [contextAttributes, innerSetContextAttributes] = React.useState(context); - - setContextAttributes = innerSetContextAttributes; - - return ( - {}}> - - - ); - } - - render(); - - await act(async () => { - await promise; - }); - - const alert = screen.queryByRole("alert"); - expect(alert).toHaveTextContent("CUSTOM"); - - const newRequestPromise = stubConfig({ - greeting: { value: { string: "UPDATED FROM CONTEXT" } }, - }); - - setContextAttributes({ user: { email: "foo@example.com" } }); - - await newRequestPromise; - // wait for render - // eslint-disable-next-line no-promise-executor-return - await new Promise((r) => setTimeout(r, 1)); - - const updatedAlert = screen.queryByRole("alert"); - expect(updatedAlert).toHaveTextContent("UPDATED FROM CONTEXT"); - }); - - it("allows providing an afterEvaluationCallback", async () => { - const context = { user: { email: "test@example.com" } }; - - const callback = jest.fn(); - - const promise = stubConfig({ greeting: { value: { string: "afterEvaluationCallback" } } }); - - render( - {}} - > - - - ); - - await act(async () => { - await promise; - }); - - // wait for async callback to be called - // eslint-disable-next-line no-promise-executor-return - await new Promise((r) => setTimeout(r, 1)); - - expect(callback).toHaveBeenCalledWith("greeting", "afterEvaluationCallback", { - contexts: context, - }); - }); - - it("triggers onError if something goes wrong", async () => { - const context = { user: { name: "🥰", phone: "(555) 555–5555" } }; - const onError = jest.fn(); - - await renderWithConfig({}, { contextAttributes: context, onError }); - expect(onError).toHaveBeenCalledWith( - expect.objectContaining({ - // NOTE: While context-encoding bug is fixed in the in-browser version - // of prefab-cloud-js since - // https://github.com/prefab-cloud/prefab-cloud-js/pull/65 the Node - // version (which is only intended to be run in unit-tests) still - // exhibits the bug. It is convenient for us to test this onError. - name: "InvalidCharacterError", - message: "The string to be encoded contains invalid characters.", - }) - ); - - const alert = screen.queryByRole("alert"); - expect(alert).toHaveTextContent("Default"); - const secretFeature = screen.queryByTitle("secret-feature"); - expect(secretFeature).not.toBeInTheDocument(); - }); -}); diff --git a/src/PrefabProvider.tsx b/src/PrefabProvider.tsx index e1307f0..6a63865 100644 --- a/src/PrefabProvider.tsx +++ b/src/PrefabProvider.tsx @@ -14,6 +14,12 @@ type ContextAttributes = { [key: string]: Record }; type EvaluationCallback = (key: string, value: ConfigValue, context: Context | undefined) => void; +type ClassMethods = { + [K in keyof T]: T[K]; +}; + +type PrefabTypesafeClass = new (prefabInstance: Prefab) => T; + type SharedSettings = { apiKey?: string; endpoints?: string[]; @@ -27,7 +33,8 @@ type SharedSettings = { collectContextMode?: CollectContextModeType; }; -export type ProvidedContext = { +// Extract base context without ClassMethods +export type BaseContext = { get: (key: string) => any; getDuration(key: string): Duration | undefined; contextAttributes: ContextAttributes; @@ -38,20 +45,39 @@ export type ProvidedContext = { settings: SharedSettings; }; -export const defaultContext: ProvidedContext = { +export type ProvidedContext> = BaseContext & ClassMethods; + +export const defaultContext: BaseContext = { get: (_: string) => undefined, getDuration: (_: string) => undefined, isEnabled: (_: string) => false, - keys: [], + keys: [] as string[], loading: true, contextAttributes: {}, prefab, settings: {}, }; -export const PrefabContext = React.createContext(defaultContext); +export const PrefabContext = React.createContext( + defaultContext as ProvidedContext +); + +// This is a factory function that creates a fully typed usePrefab hook for a specific PrefabTypesafe class +export const createPrefabHook = + (_typesafeClass: PrefabTypesafeClass) => + (): ProvidedContext => + React.useContext(PrefabContext) as ProvidedContext; + +// Basic hook for general use - requires type parameter +export const useBasePrefab = () => React.useContext(PrefabContext); -export const usePrefab = () => React.useContext(PrefabContext); +// Helper hook for explicit typing +export const usePrefabTypesafe = (): ProvidedContext => + useBasePrefab() as unknown as ProvidedContext; + +// General hook that returns the context with any explicit type +export const usePrefab = (): ProvidedContext => + useBasePrefab() as unknown as ProvidedContext; let globalPrefabIsTaken = false; @@ -64,8 +90,9 @@ export const assignPrefabClient = () => { return prefab; }; -export type Props = SharedSettings & { +export type Props> = SharedSettings & { contextAttributes?: ContextAttributes; + PrefabTypesafeClass?: PrefabTypesafeClass; }; const getContext = ( @@ -90,7 +117,32 @@ const getContext = ( } }; -function PrefabProvider({ +// Helper to extract methods from a TypesafeClass instance +export const extractTypesafeMethods = (instance: any): Record => { + const methods: Record = {}; + const prototype = Object.getPrototypeOf(instance); + + const descriptors = Object.getOwnPropertyDescriptors(prototype); + + Object.keys(descriptors).forEach((key) => { + if (key === "constructor") return; + + const descriptor = descriptors[key]; + + // Handle regular methods + if (typeof instance[key] === "function") { + methods[key] = instance[key].bind(instance); + } + // Handle getters - convert to regular properties + else if (descriptor.get) { + methods[key] = instance[key]; + } + }); + + return methods; +}; + +function PrefabProvider({ apiKey, contextAttributes = {}, onError = (e: unknown) => { @@ -106,7 +158,8 @@ function PrefabProvider({ collectEvaluationSummaries, collectLoggerNames, collectContextMode, -}: PropsWithChildren) { + PrefabTypesafeClass: TypesafeClass, +}: PropsWithChildren>) { const settings = { apiKey, endpoints, @@ -198,8 +251,16 @@ function PrefabProvider({ prefabClient.instanceHash, ]); - const value: ProvidedContext = React.useMemo( - () => ({ + // Memoize typesafe instance separately + const typesafeInstance = React.useMemo(() => { + if (TypesafeClass && prefabClient) { + return new TypesafeClass(prefabClient); + } + return null; + }, [TypesafeClass, prefabClient.instanceHash, loading]); + + const value = React.useMemo(() => { + const baseContext: ProvidedContext = { isEnabled: prefabClient.isEnabled.bind(prefabClient), contextAttributes, get: prefabClient.get.bind(prefabClient), @@ -208,11 +269,17 @@ function PrefabProvider({ prefab: prefabClient, loading, settings, - }), - [loadedContextKey, loading, prefabClient.instanceHash, settings] - ); + }; + + if (typesafeInstance) { + const methods = extractTypesafeMethods(typesafeInstance); + return { ...baseContext, ...methods }; + } + + return baseContext; + }, [loadedContextKey, loading, prefabClient.instanceHash, settings, typesafeInstance]); return {children}; } -export { PrefabProvider, ConfigValue, ContextAttributes, SharedSettings }; +export { PrefabProvider, ConfigValue, ContextAttributes, SharedSettings, PrefabTypesafeClass }; diff --git a/src/PrefabTestProvider.test.tsx b/src/PrefabTestProvider.test.tsx deleted file mode 100644 index 4134586..0000000 --- a/src/PrefabTestProvider.test.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import React from "react"; -import { render, screen } from "@testing-library/react"; -import "@testing-library/jest-dom/extend-expect"; -import { usePrefab } from "./PrefabProvider"; -import { PrefabTestProvider } from "./PrefabTestProvider"; - -function MyComponent() { - const { get, isEnabled, loading, keys } = usePrefab(); - const greeting = get("greeting") || "Default"; - const subtitle = get("subtitle")?.actualSubtitle || "Default Subtitle"; - - if (loading) { - return
Loading...
; - } - - return ( -
-

{greeting}

-

{subtitle}

- {isEnabled("secretFeature") && ( - - )} - -
{JSON.stringify(keys)}
-
- ); -} - -describe("PrefabTestProvider", () => { - const renderInTestProvider = (config: Record) => { - render( - - - - ); - }; - - it("renders without config", () => { - renderInTestProvider({}); - - const alert = screen.queryByRole("alert"); - expect(alert).toHaveTextContent("Default"); - const secretFeature = screen.queryByTitle("secret-feature"); - expect(secretFeature).not.toBeInTheDocument(); - }); - - it("allows providing flag values", async () => { - renderInTestProvider({ greeting: "CUSTOM" }); - - const alert = screen.queryByRole("alert"); - expect(alert).toHaveTextContent("CUSTOM"); - const secretFeature = screen.queryByTitle("secret-feature"); - expect(secretFeature).not.toBeInTheDocument(); - }); - - it("allows providing true flag booleans", async () => { - renderInTestProvider({ greeting: "CUSTOM", secretFeature: true }); - - const alert = screen.queryByRole("alert"); - expect(alert).toHaveTextContent("CUSTOM"); - const secretFeature = screen.queryByTitle("secret-feature"); - expect(secretFeature).toBeInTheDocument(); - }); - - it("allows providing false flag booleans", async () => { - renderInTestProvider({ greeting: "CUSTOM", secretFeature: false }); - - const alert = screen.queryByRole("alert"); - expect(alert).toHaveTextContent("CUSTOM"); - const secretFeature = screen.queryByTitle("secret-feature"); - expect(secretFeature).not.toBeInTheDocument(); - }); - - it("allows access to the known keys", () => { - renderInTestProvider({ magic: "true", keanu: "whoa" }); - - const keys = screen.getByTestId("known-keys"); - expect(keys).toHaveTextContent('["magic","keanu"]'); - }); -}); diff --git a/src/PrefabTestProvider.tsx b/src/PrefabTestProvider.tsx index 8b74207..2a72684 100644 --- a/src/PrefabTestProvider.tsx +++ b/src/PrefabTestProvider.tsx @@ -1,23 +1,43 @@ import React, { PropsWithChildren } from "react"; -import { PrefabContext, assignPrefabClient, ProvidedContext } from "./PrefabProvider"; +import { + PrefabContext, + assignPrefabClient, + ProvidedContext, + PrefabTypesafeClass, + extractTypesafeMethods, +} from "./PrefabProvider"; export type TestProps = { config: Record; apiKey?: string; }; -function PrefabTestProvider({ apiKey, config, children }: PropsWithChildren) { +function PrefabTestProvider({ + apiKey, + config, + children, + PrefabTypesafeClass: TypesafeClass, +}: PropsWithChildren }>) { const get = (key: string) => config[key]; const getDuration = (key: string) => config[key]; const isEnabled = (key: string) => !!get(key); - const value = React.useMemo((): ProvidedContext => { - const prefabClient = assignPrefabClient(); + const prefabClient = React.useMemo(() => assignPrefabClient(), []); + + // Memoize typesafe instance separately + const typesafeInstance = React.useMemo(() => { + if (TypesafeClass && prefabClient) { + return new TypesafeClass(prefabClient); + } + return null; + }, [TypesafeClass, prefabClient]); + + const value = React.useMemo(() => { prefabClient.get = get; prefabClient.getDuration = getDuration; prefabClient.isEnabled = isEnabled; - return { + const baseContext: ProvidedContext = { isEnabled, contextAttributes: config.contextAttributes, get, @@ -27,7 +47,14 @@ function PrefabTestProvider({ apiKey, config, children }: PropsWithChildren{children}; } diff --git a/src/__tests__/PrefabProvider.test.tsx b/src/__tests__/PrefabProvider.test.tsx new file mode 100644 index 0000000..226093f --- /dev/null +++ b/src/__tests__/PrefabProvider.test.tsx @@ -0,0 +1,614 @@ +/* eslint-disable max-classes-per-file */ +import React from "react"; +import { render, screen, waitFor } from "@testing-library/react"; +import "@testing-library/jest-dom/extend-expect"; +import { act } from "react-dom/test-utils"; +import { ContextValue, Prefab } from "@prefab-cloud/prefab-cloud-js"; +import { + ContextAttributes, + PrefabProvider, + usePrefab, + usePrefabTypesafe, + PrefabTestProvider, + createPrefabHook, +} from "../index"; +import { + AppConfig, + TypesafeComponent, + HookComponent, + mockEvaluationsResponse, +} from "./test-helpers"; + +type Config = { [key: string]: any }; + +function MyComponent() { + const { get, isEnabled, loading, keys } = usePrefab(); + const greeting = get("greeting") || "Default"; + const subtitle = get("subtitle")?.actualSubtitle || "Default Subtitle"; + + if (loading) { + return
Loading...
; + } + + return ( +
+

{greeting}

+

{subtitle}

+ {isEnabled("secretFeature") && ( + + )} + +
{JSON.stringify(keys)}
+
+ ); +} + +let warn: ReturnType; +let error: ReturnType; + +beforeEach(() => { + error = jest.spyOn(console, "error").mockImplementation(() => {}); + warn = jest.spyOn(console, "warn").mockImplementation(() => {}); +}); + +afterEach(() => { + warn.mockReset(); + error.mockReset(); +}); + +describe("PrefabProvider", () => { + const defaultContextAttributes = { user: { email: "test@example.com" } }; + + const renderInProvider = ({ + contextAttributes, + onError, + }: { + contextAttributes?: { [key: string]: Record }; + onError?: (err: Error) => void; + }) => + render( + + + + ); + + const stubConfig = (config: Config) => + new Promise((resolve) => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => { + setTimeout(resolve); + return { evaluations: config }; + }, + }) + ) as jest.Mock; + }); + + const renderWithConfig = async ( + config: Config, + providerConfig: Parameters[0] = { + contextAttributes: defaultContextAttributes, + onError: (e) => { + throw e; + }, + } + ) => { + const promise = stubConfig(config); + + const rendered = renderInProvider(providerConfig); + + await act(async () => { + await promise; + }); + + // wait for the loading content to go away + screen.findByRole("alert"); + + return rendered; + }; + + it("renders without config", async () => { + await renderWithConfig({}); + + const alert = screen.queryByRole("alert"); + expect(alert).toHaveTextContent("Default"); + const secretFeature = screen.queryByTitle("secret-feature"); + expect(secretFeature).not.toBeInTheDocument(); + }); + + it("allows providing flag values", async () => { + await renderWithConfig({ greeting: { value: { string: "CUSTOM" } } }); + + const alert = screen.queryByRole("alert"); + expect(alert).toHaveTextContent("CUSTOM"); + const secretFeature = screen.queryByTitle("secret-feature"); + expect(secretFeature).not.toBeInTheDocument(); + }); + + it("allows providing true flag booleans", async () => { + await renderWithConfig({ + greeting: { value: { string: "CUSTOM" } }, + secretFeature: { value: { boolean: true } }, + }); + + const alert = screen.queryByRole("alert"); + expect(alert).toHaveTextContent("CUSTOM"); + const secretFeature = screen.queryByTitle("secret-feature"); + expect(secretFeature).toBeInTheDocument(); + }); + + it("allows providing false flag booleans", async () => { + await renderWithConfig({ + greeting: { value: { string: "CUSTOM" } }, + secretFeature: { value: { boolean: false } }, + }); + + const alert = screen.queryByRole("alert"); + expect(alert).toHaveTextContent("CUSTOM"); + const secretFeature = screen.queryByTitle("secret-feature"); + expect(secretFeature).not.toBeInTheDocument(); + }); + + it("allows providing json configs", async () => { + await renderWithConfig({ + subtitle: { value: { json: '{ "actualSubtitle": "Json Subtitle" }' } }, + }); + + const alert = screen.queryByRole("banner"); + expect(alert).toHaveTextContent("Json Subtitle"); + }); + + it("warns when you do not provide contextAttributes", async () => { + const rendered = await renderWithConfig( + { + greeting: { value: { string: "CUSTOM" } }, + secretFeature: { value: { boolean: true } }, + }, + { contextAttributes: { user: { email: "old@example.com" } } } + ); + + const alert = screen.queryByRole("alert"); + expect(alert).toHaveTextContent("CUSTOM"); + + const newConfigPromise = stubConfig({ + greeting: { value: { string: "ANOTHER" } }, + secretFeature: { value: { boolean: false } }, + }); + + act(() => { + rendered.rerender( + {}} + > + + + ); + }); + + await newConfigPromise; + // wait for render + // eslint-disable-next-line no-promise-executor-return + await new Promise((r) => setTimeout(r, 1)); + + const updatedAlert = screen.queryByRole("alert"); + expect(updatedAlert).toHaveTextContent("ANOTHER"); + }); + + it("re-fetches when you update the contextAttributes prop on the provider", async () => { + let setContextAttributes: (attributes: ContextAttributes) => void = () => { + // eslint-disable-next-line no-console + console.warn("setContextAttributes not set"); + }; + + const promise = stubConfig({ greeting: { value: { string: "CUSTOM" } } }); + + function Wrapper({ context }: { context: ContextAttributes }) { + const [contextAttributes, innerSetContextAttributes] = React.useState(context); + + setContextAttributes = innerSetContextAttributes; + + return ( + {}}> + + + ); + } + + render(); + + await act(async () => { + await promise; + }); + + const alert = screen.queryByRole("alert"); + expect(alert).toHaveTextContent("CUSTOM"); + + const newRequestPromise = stubConfig({ + greeting: { value: { string: "UPDATED FROM CONTEXT" } }, + }); + + setContextAttributes({ user: { email: "foo@example.com" } }); + + await newRequestPromise; + // wait for render + // eslint-disable-next-line no-promise-executor-return + await new Promise((r) => setTimeout(r, 1)); + + const updatedAlert = screen.queryByRole("alert"); + expect(updatedAlert).toHaveTextContent("UPDATED FROM CONTEXT"); + }); + + it("allows providing an afterEvaluationCallback", async () => { + const context = { user: { email: "test@example.com" } }; + + const callback = jest.fn(); + + const promise = stubConfig({ greeting: { value: { string: "afterEvaluationCallback" } } }); + + render( + {}} + > + + + ); + + await act(async () => { + await promise; + }); + + // wait for async callback to be called + // eslint-disable-next-line no-promise-executor-return + await new Promise((r) => setTimeout(r, 1)); + + expect(callback).toHaveBeenCalledWith("greeting", "afterEvaluationCallback", { + contexts: context, + }); + }); + + it("triggers onError if something goes wrong", async () => { + const context = { user: { name: "🥰", phone: "(555) 555–5555" } }; + const onError = jest.fn(); + + await renderWithConfig({}, { contextAttributes: context, onError }); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ + // NOTE: While context-encoding bug is fixed in the in-browser version + // of prefab-cloud-js since + // https://github.com/prefab-cloud/prefab-cloud-js/pull/65 the Node + // version (which is only intended to be run in unit-tests) still + // exhibits the bug. It is convenient for us to test this onError. + name: "InvalidCharacterError", + message: "The string to be encoded contains invalid characters.", + }) + ); + + const alert = screen.queryByRole("alert"); + expect(alert).toHaveTextContent("Default"); + const secretFeature = screen.queryByTitle("secret-feature"); + expect(secretFeature).not.toBeInTheDocument(); + }); +}); + +describe("PrefabProvider with TypesafeClass", () => { + const defaultContextAttributes = { user: { email: "test@example.com" } }; + + // Mock prefab client responses for typesafe tests + beforeEach(() => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => mockEvaluationsResponse, + }) + ) as jest.Mock; + }); + + it("makes TypesafeClass methods available through usePrefabTypesafe", async () => { + render( + + + + ); + + // Wait for loading to finish + await act(async () => { + // eslint-disable-next-line no-promise-executor-return + await new Promise((r) => setTimeout(r, 100)); + }); + + expect(screen.queryByText("Loading...")).not.toBeInTheDocument(); + expect(screen.getByTestId("app-name")).toHaveTextContent("Test App"); + expect(screen.getByTestId("raw-theme-color")).toHaveTextContent("#FF5500"); + expect(screen.getByTestId("feature-flag")).toBeInTheDocument(); + }); + + it("provides typesafe methods through the custom hook", async () => { + render( + + + + ); + + // Wait for loading to finish + await act(async () => { + // eslint-disable-next-line no-promise-executor-return + await new Promise((r) => setTimeout(r, 100)); + }); + + expect(screen.queryByText("Loading...")).not.toBeInTheDocument(); + expect(screen.getByTestId("app-name-hook")).toHaveTextContent("Test App"); + expect(screen.getByTestId("api-url")).toHaveTextContent("https://api.test.com"); + expect(screen.getByTestId("timeout")).toHaveTextContent("4000"); // 2000 * 2 + }); + + it("uses default values when configs are not available", async () => { + // Override the mock to return empty configs + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => ({ evaluations: {} }), + }) + ) as jest.Mock; + + render( + + + + + ); + + // Wait for loading to finish + await act(async () => { + // eslint-disable-next-line no-promise-executor-return + await new Promise((r) => setTimeout(r, 100)); + }); + + expect(screen.queryByText("Loading...")).not.toBeInTheDocument(); + expect(screen.getByTestId("app-name")).toHaveTextContent("Default App"); + expect(screen.getByTestId("timeout")).toHaveTextContent("2000"); // 1000 * 2 (default) + expect(screen.queryByTestId("feature-flag")).not.toBeInTheDocument(); + }); +}); + +describe("TypesafeClass instance memoization", () => { + it("memoizes the TypesafeClass instance across renders", async () => { + // Create a mocked version of our TypesafeClass with constructor and method spies + const constructorSpy = jest.fn(); + const methodSpy = jest.fn(); + + class TrackedAppConfig { + constructor(prefab: Prefab) { + constructorSpy(prefab); + this.prefab = prefab; + } + + private prefab: Prefab; + + appName(): string { + methodSpy(); + const name = this.prefab.get("app.name"); + return typeof name === "string" ? name : "Default App"; + } + } + + // Component that forces re-renders and tracks calls + function ReRenderingComponent() { + const [counter, setCounter] = React.useState(0); + const { appName } = usePrefabTypesafe(); + + // Force a re-render after mounting + React.useEffect(() => { + if (counter < 3) { + setTimeout(() => setCounter(counter + 1), 10); + } + }, [counter]); + + return ( +
+ {appName()} (Render count: {counter}) +
+ ); + } + + render( + + + + ); + + // Wait for all re-renders to complete + await waitFor(() => { + expect(screen.getByTestId("counter")).toHaveTextContent("(Render count: 3)"); + }); + + // Constructor should only be called once, but the method should be called for each render + expect(constructorSpy).toHaveBeenCalledTimes(1); + expect(methodSpy).toHaveBeenCalledTimes(4); + }); +}); + +// Adding explicit tests for createPrefabHook functionality +describe("createPrefabHook functionality with PrefabProvider", () => { + const defaultContextAttributes = { user: { email: "test@example.com" } }; + + // Create a custom TypesafeClass for testing + class CustomFeatureFlags { + private prefab: Prefab; + + constructor(prefab: Prefab) { + this.prefab = prefab; + } + + isSecretFeatureEnabled(): boolean { + return this.prefab.isEnabled("secret.feature"); + } + + getGreeting(): string { + const greeting = this.prefab.get("greeting"); + return typeof greeting === "string" ? greeting : "Default Greeting"; + } + + calculateValue(multiplier: number): number { + const baseValue = this.prefab.get("base.value"); + const base = typeof baseValue === "number" ? baseValue : 10; + return base * multiplier; + } + } + + // Create a typed hook using our TypesafeClass + const useCustomFeatureFlags = createPrefabHook(CustomFeatureFlags); + + // Component that uses the custom typed hook + function CustomHookComponent() { + const { isSecretFeatureEnabled, getGreeting, calculateValue, loading } = + useCustomFeatureFlags(); + + if (loading) { + return
Loading...
; + } + + return ( +
+

{getGreeting()}

+ {isSecretFeatureEnabled() &&
Secret Feature Enabled
} +
{calculateValue(5)}
+
+ ); + } + + beforeEach(() => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => ({ + evaluations: { + greeting: { value: { string: "Hello from Custom Hook" } }, + "secret.feature": { value: { boolean: true } }, + "base.value": { value: { int: 20 } }, + }, + }), + }) + ) as jest.Mock; + }); + + it("creates a working custom hook with createPrefabHook", async () => { + render( + + + + ); + + // Wait for loading to finish + await act(async () => { + // eslint-disable-next-line no-promise-executor-return + await new Promise((r) => setTimeout(r, 100)); + }); + + expect(screen.queryByText("Loading...")).not.toBeInTheDocument(); + expect(screen.getByTestId("custom-greeting")).toHaveTextContent("Hello from Custom Hook"); + expect(screen.getByTestId("custom-feature")).toBeInTheDocument(); + expect(screen.getByTestId("calculated-value")).toHaveTextContent("100"); // 20 * 5 + }); + + it("memoizes TypesafeClass instance when used with custom hook", async () => { + // Create a mocked version with constructor and method spies + const constructorSpy = jest.fn(); + const methodSpy = jest.fn().mockReturnValue("test result"); + + class SpiedClass { + private prefab: Prefab; + + constructor(prefab: Prefab) { + constructorSpy(prefab); + this.prefab = prefab; + } + + // eslint-disable-next-line class-methods-use-this + testMethod(): string { + return methodSpy(); + } + } + + const useSpiedHook = createPrefabHook(SpiedClass); + + // Component that forces re-renders + function ReRenderingComponent() { + const [counter, setCounter] = React.useState(0); + const { testMethod } = useSpiedHook(); + + // Call the method on each render + const result = testMethod(); + + React.useEffect(() => { + // Force multiple re-renders + if (counter < 3) { + setTimeout(() => setCounter(counter + 1), 10); + } + }, [counter]); + + return ( +
+ {result} (Render count: {counter}) +
+ ); + } + + // Mock the fetch response for PrefabProvider + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => ({ evaluations: {} }), + }) + ) as jest.Mock; + + render( + + + + ); + + // Wait for all re-renders to complete + await waitFor(() => { + expect(screen.getByTestId("hook-result")).toHaveTextContent("(Render count: 3)"); + }); + + // In PrefabProvider, constructor may be called twice due to React's strict mode + // or the provider's initialization process, which is still valid behavior + expect(constructorSpy).toHaveBeenCalledTimes(2); + // Method is called once on initial render, once during initialization, and three more times for re-renders + expect(methodSpy).toHaveBeenCalledTimes(5); + }); +}); diff --git a/src/__tests__/PrefabTestProvider.test.tsx b/src/__tests__/PrefabTestProvider.test.tsx new file mode 100644 index 0000000..3f6eee0 --- /dev/null +++ b/src/__tests__/PrefabTestProvider.test.tsx @@ -0,0 +1,263 @@ +/* eslint-disable max-classes-per-file */ +import React from "react"; +import { render, screen, waitFor } from "@testing-library/react"; +import "@testing-library/jest-dom/extend-expect"; +import { Prefab } from "@prefab-cloud/prefab-cloud-js"; +import { PrefabTestProvider, usePrefab, createPrefabHook } from "../index"; +import { AppConfig, TypesafeComponent, HookComponent, typesafeTestConfig } from "./test-helpers"; + +function MyComponent() { + const { get, isEnabled, loading, keys } = usePrefab(); + const greeting = get("greeting") || "Default"; + const subtitle = get("subtitle")?.actualSubtitle || "Default Subtitle"; + + if (loading) { + return
Loading...
; + } + + return ( +
+

{greeting}

+

{subtitle}

+ {isEnabled("secretFeature") && ( + + )} + +
{JSON.stringify(keys)}
+
+ ); +} + +describe("PrefabTestProvider", () => { + const renderInTestProvider = (config: Record) => { + render( + + + + ); + }; + + it("renders without config", () => { + renderInTestProvider({}); + + const alert = screen.queryByRole("alert"); + expect(alert).toHaveTextContent("Default"); + const secretFeature = screen.queryByTitle("secret-feature"); + expect(secretFeature).not.toBeInTheDocument(); + }); + + it("allows providing flag values", async () => { + renderInTestProvider({ greeting: "CUSTOM" }); + + const alert = screen.queryByRole("alert"); + expect(alert).toHaveTextContent("CUSTOM"); + const secretFeature = screen.queryByTitle("secret-feature"); + expect(secretFeature).not.toBeInTheDocument(); + }); + + it("allows providing true flag booleans", async () => { + renderInTestProvider({ greeting: "CUSTOM", secretFeature: true }); + + const alert = screen.queryByRole("alert"); + expect(alert).toHaveTextContent("CUSTOM"); + const secretFeature = screen.queryByTitle("secret-feature"); + expect(secretFeature).toBeInTheDocument(); + }); + + it("allows providing false flag booleans", async () => { + renderInTestProvider({ greeting: "CUSTOM", secretFeature: false }); + + const alert = screen.queryByRole("alert"); + expect(alert).toHaveTextContent("CUSTOM"); + const secretFeature = screen.queryByTitle("secret-feature"); + expect(secretFeature).not.toBeInTheDocument(); + }); + + it("allows access to the known keys", () => { + renderInTestProvider({ magic: "true", keanu: "whoa" }); + + const keys = screen.getByTestId("known-keys"); + expect(keys).toHaveTextContent('["magic","keanu"]'); + }); +}); + +describe("PrefabTestProvider with TypesafeClass", () => { + it("makes TypesafeClass methods available in test environment", () => { + render( + + + + + ); + + // No need to wait for loading since PrefabTestProvider is synchronous + expect(screen.getByTestId("app-name")).toHaveTextContent("Test App From TestProvider"); + expect(screen.getByTestId("api-url")).toHaveTextContent("https://test-provider.example.com"); + expect(screen.getByTestId("raw-theme-color")).toHaveTextContent("#00FF00"); + expect(screen.getByTestId("feature-flag")).toBeInTheDocument(); + expect(screen.getByTestId("timeout")).toHaveTextContent("6000"); // 3000 * 2 + }); + + it("uses default values when configs are not provided in test provider", () => { + render( + + + + + ); + + expect(screen.getByTestId("app-name")).toHaveTextContent("Only App Name Set"); + expect(screen.getByTestId("api-url")).toHaveTextContent("https://api.default.com"); + expect(screen.getByTestId("raw-theme-color")).toHaveTextContent("#000000"); + expect(screen.queryByTestId("feature-flag")).not.toBeInTheDocument(); + expect(screen.getByTestId("timeout")).toHaveTextContent("2000"); // 1000 (default) * 2 + }); +}); + +// Adding explicit tests for createPrefabHook functionality +describe("createPrefabHook functionality with PrefabTestProvider", () => { + // Custom TypesafeClass for testing + class CustomFeatureFlags { + private prefab: Prefab; + + constructor(prefab: Prefab) { + this.prefab = prefab; + } + + isCustomFeatureEnabled(): boolean { + return this.prefab.isEnabled("custom.feature"); + } + + getCustomMessage(): string { + const message = this.prefab.get("custom.message"); + return typeof message === "string" ? message : "Default Message"; + } + + calculateCustomValue(multiplier: number): number { + const baseValue = this.prefab.get("custom.base.value"); + const base = typeof baseValue === "number" ? baseValue : 5; + return base * multiplier; + } + } + + // Create a typed hook using our TypesafeClass + const useCustomFeatureFlags = createPrefabHook(CustomFeatureFlags); + + // Component that uses the custom typed hook + function CustomHookComponent() { + const { isCustomFeatureEnabled, getCustomMessage, calculateCustomValue } = + useCustomFeatureFlags(); + + return ( +
+

{getCustomMessage()}

+ {isCustomFeatureEnabled() &&
Custom Feature Enabled
} +
{calculateCustomValue(3)}
+
+ ); + } + + it("creates a working custom hook with createPrefabHook", () => { + render( + + + + ); + + expect(screen.getByTestId("custom-message")).toHaveTextContent("Hello from Test Custom Hook"); + expect(screen.getByTestId("custom-feature")).toBeInTheDocument(); + expect(screen.getByTestId("custom-calculated-value")).toHaveTextContent("30"); // 10 * 3 + }); + + it("provides default values when configs are not provided", () => { + render( + + + + ); + + expect(screen.getByTestId("custom-message")).toHaveTextContent("Only Message Set"); + expect(screen.queryByTestId("custom-feature")).not.toBeInTheDocument(); + expect(screen.getByTestId("custom-calculated-value")).toHaveTextContent("15"); // 5 (default) * 3 + }); + + it("memoizes TypesafeClass instance when used with custom hook", async () => { + // Create a class with spies + const constructorSpy = jest.fn(); + const methodSpy = jest.fn().mockReturnValue("memoized result"); + + class SpiedClass { + private prefab: Prefab; + + constructor(prefab: Prefab) { + constructorSpy(prefab); + this.prefab = prefab; + } + + // eslint-disable-next-line class-methods-use-this + testMethod(): string { + return methodSpy(); + } + } + + const useSpiedHook = createPrefabHook(SpiedClass); + + // Component that forces re-renders + function ReRenderingComponent() { + const [counter, setCounter] = React.useState(0); + const { testMethod } = useSpiedHook(); + + // Call the method on each render + const result = testMethod(); + + React.useEffect(() => { + // Force multiple re-renders + if (counter < 3) { + setTimeout(() => setCounter(counter + 1), 10); + } + }, [counter]); + + return ( +
+ {result} (Count: {counter}) +
+ ); + } + + render( + + + + ); + + // Wait for all re-renders to complete + await waitFor(() => { + expect(screen.getByTestId("test-result")).toHaveTextContent("(Count: 3)"); + }); + + // Constructor should be called only once, method called for each render + expect(constructorSpy).toHaveBeenCalledTimes(1); + expect(methodSpy).toHaveBeenCalledTimes(4); // Initial render + 3 updates + }); +}); diff --git a/src/jest.setup.ts b/src/__tests__/jest.setup.ts similarity index 100% rename from src/jest.setup.ts rename to src/__tests__/jest.setup.ts diff --git a/src/nested-providers.test.tsx b/src/__tests__/nested-providers.test.tsx similarity index 99% rename from src/nested-providers.test.tsx rename to src/__tests__/nested-providers.test.tsx index 638c4b1..5492d38 100644 --- a/src/nested-providers.test.tsx +++ b/src/__tests__/nested-providers.test.tsx @@ -9,7 +9,7 @@ import { usePrefab, ContextAttributes, SharedSettings, -} from "./index"; +} from "../index"; enableFetchMocks(); diff --git a/src/nested-test-providers.test.tsx b/src/__tests__/nested-test-providers.test.tsx similarity index 99% rename from src/nested-test-providers.test.tsx rename to src/__tests__/nested-test-providers.test.tsx index 60ec814..c5b72bc 100644 --- a/src/nested-test-providers.test.tsx +++ b/src/__tests__/nested-test-providers.test.tsx @@ -3,11 +3,11 @@ import "@testing-library/jest-dom/extend-expect"; import { act, render, screen } from "@testing-library/react"; import { prefab as globalPrefab, - PrefabTestProvider, + PrefabProvider, usePrefab, + PrefabTestProvider, TestProps, - PrefabProvider, -} from "./index"; +} from "../index"; type Provider = typeof PrefabTestProvider | typeof PrefabProvider; diff --git a/src/__tests__/test-helpers.tsx b/src/__tests__/test-helpers.tsx new file mode 100644 index 0000000..62b74dc --- /dev/null +++ b/src/__tests__/test-helpers.tsx @@ -0,0 +1,92 @@ +import React from "react"; +import { Prefab } from "@prefab-cloud/prefab-cloud-js"; +import { usePrefabTypesafe, createPrefabHook } from "../index"; + +// Simple TypesafeClass for testing +export class AppConfig { + private prefab: Prefab; + + constructor(prefab: Prefab) { + this.prefab = prefab; + } + + get myCoolFeature(): boolean { + return this.prefab.isEnabled("my.cool.feature"); + } + + get appName(): string { + const name = this.prefab.get("app.name"); + return typeof name === "string" ? name : "Default App"; + } + + get apiUrl(): string { + const url = this.prefab.get("api.url"); + return typeof url === "string" ? url : "https://api.default.com"; + } + + get themeColor(): string { + const color = this.prefab.get("theme.color"); + return typeof color === "string" ? color : "#000000"; + } + + calculateTimeout(multiplier: number): number { + const baseValue = this.prefab.get("timeout.base"); + const base = typeof baseValue === "number" ? baseValue : 1000; + return base * multiplier; + } +} + +// Create a typed hook for our test class +export const useAppConfig = createPrefabHook(AppConfig); + +// Component using the TypesafeClass +export function TypesafeComponent() { + const { myCoolFeature, appName, themeColor, loading } = usePrefabTypesafe(); + + if (loading) { + return
Loading...
; + } + + return ( +
+

{appName}

+
{themeColor}
+ {myCoolFeature &&
Feature Enabled
} +
+ ); +} + +// Component using the custom typed hook +export function HookComponent() { + const { appName, apiUrl, calculateTimeout, loading } = useAppConfig(); + + if (loading) { + return
Loading...
; + } + + return ( +
+

{appName}

+
{apiUrl}
+
{calculateTimeout(2)}
+
+ ); +} + +export const typesafeTestConfig = { + "app.name": "Test App From TestProvider", + "api.url": "https://test-provider.example.com", + "theme.color": "#00FF00", + "my.cool.feature": true, + "timeout.base": 3000, +}; + +export const mockEvaluationsResponse = { + evaluations: { + "app.name": { value: { string: "Test App" } }, + "api.url": { value: { string: "https://api.test.com" } }, + "theme.color": { value: { string: "#FF5500" } }, + "my.cool.feature": { value: { boolean: true } }, + "timeout.base": { value: { int: 2000 } }, + }, +}; diff --git a/src/index.tsx b/src/index.tsx index 65bcc87..82458a7 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -2,10 +2,14 @@ import { prefab } from "@prefab-cloud/prefab-cloud-js"; import { PrefabProvider, usePrefab, + usePrefabTypesafe, ConfigValue, ContextAttributes, SharedSettings, Props, + ProvidedContext, + PrefabTypesafeClass, + createPrefabHook, } from "./PrefabProvider"; import { PrefabTestProvider, TestProps } from "./PrefabTestProvider"; @@ -13,10 +17,14 @@ export { PrefabProvider, PrefabTestProvider, usePrefab, + usePrefabTypesafe, TestProps, Props, ConfigValue, ContextAttributes, prefab, SharedSettings, + PrefabTypesafeClass, + ProvidedContext, + createPrefabHook, };