├── .codeclimate.yml ├── .eslintignore ├── .eslintrc.cjs ├── .github ├── ISSUE_TEMPLATE │ ├── bug-report.md │ ├── config.yml │ └── feature-request.md ├── pull_request_template.md ├── release-drafter.yml └── workflows │ ├── branch-validations.yaml │ ├── check-required-labels.yaml │ ├── deploy-published-releases.yaml │ ├── publish-pr-preview.yaml │ └── update-release-notes.yaml ├── .gitignore ├── LICENSE ├── README.md ├── jest.config.mjs ├── package-lock.json ├── package.json ├── post-build.mjs ├── renovate.json ├── src ├── CroctProvider.test.tsx ├── CroctProvider.tsx ├── api.ts ├── components │ ├── Personalization │ │ ├── index.d.test.tsx │ │ ├── index.test.tsx │ │ └── index.tsx │ ├── Slot │ │ ├── index.d.test.tsx │ │ ├── index.test.tsx │ │ └── index.tsx │ └── index.ts ├── global.d.ts ├── hash.test.ts ├── hash.ts ├── hooks │ ├── Cache.test.ts │ ├── Cache.ts │ ├── index.ts │ ├── useContent.d.test.tsx │ ├── useContent.ssr.test.ts │ ├── useContent.test.ts │ ├── useContent.ts │ ├── useCroct.ssr.test.tsx │ ├── useCroct.test.tsx │ ├── useCroct.ts │ ├── useEvaluation.d.test.tsx │ ├── useEvaluation.ssr.test.ts │ ├── useEvaluation.test.ts │ ├── useEvaluation.ts │ ├── useLoader.test.ts │ └── useLoader.ts ├── index.ts ├── react-app-env.d.ts ├── ssr-polyfills.ssr.test.ts ├── ssr-polyfills.test.ts └── ssr-polyfills.ts ├── tsconfig.json └── tsup.config.ts /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | checks: 3 | argument-count: 4 | config: 5 | threshold: 4 6 | complex-logic: 7 | config: 8 | threshold: 4 9 | file-lines: 10 | config: 11 | threshold: 500 12 | method-complexity: 13 | config: 14 | threshold: 5 15 | method-count: 16 | config: 17 | threshold: 30 18 | method-lines: 19 | config: 20 | threshold: 100 21 | nested-control-flow: 22 | config: 23 | threshold: 4 24 | return-statements: 25 | config: 26 | threshold: 4 27 | identical-code: 28 | config: 29 | threshold: 80 30 | similar-code: 31 | enabled: false 32 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | build/ 2 | dist/ 3 | node_modules/ 4 | .snapshots/ 5 | *.min.js 6 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | // Workaround for https://github.com/eslint/eslint/issues/3458 2 | require('@rushstack/eslint-patch/modern-module-resolution'); 3 | 4 | module.exports = { 5 | root: true, 6 | plugins: ['@croct'], 7 | extends: [ 8 | 'plugin:@croct/react', 9 | 'plugin:@croct/typescript', 10 | ], 11 | parserOptions: { 12 | project: ['./tsconfig.json'], 13 | ecmaFeatures: { 14 | jsx: true, 15 | }, 16 | }, 17 | settings: { 18 | react: { 19 | version: 'detect', 20 | }, 21 | jest: { 22 | version: 'detect', 23 | }, 24 | }, 25 | overrides: [ 26 | { 27 | files: [ 28 | '**/*.stories.tsx', 29 | 'jest.config.mjs', 30 | ], 31 | rules: { 32 | 'import/no-default-export': 'off', 33 | }, 34 | }, 35 | ], 36 | }; 37 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "🐞 Bug report" 3 | about: "Create a report to help us improve" 4 | labels: bug 5 | --- 6 | 7 | ## 🐞 Bug report 8 | Please describe the problem you are experiencing. 9 | 10 | ### Steps to reproduce 11 | Please provide a minimal code snippet and the detailed steps for reproducing the issue. 12 | 13 | 1. Step 1 14 | 2. Step 2 15 | 3. Step 3 16 | 17 | ### Expected behavior 18 | Please describe the behavior you are expecting. 19 | 20 | ### Screenshots 21 | If applicable, add screenshots to help explain your problem. 22 | 23 | ### Additional context 24 | Please provide any relevant information about your setup. This is important in case the issue is not reproducible except for under certain conditions. -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "✨ Feature request" 3 | about: "Suggest an idea for this project" 4 | labels: enhancement 5 | --- 6 | 7 | ## ✨ Feature request 8 | Please provide a brief explanation of the feature. 9 | 10 | ### Motivation 11 | Please share the motivation for the new feature, and what problem it is solving. 12 | 13 | ### Example 14 | If the proposal involves a new or changed API, please include a basic code example. 15 | 16 | ### Alternatives 17 | Please describe the alternative solutions or features you've considered. 18 | 19 | ### Additional context 20 | Please provide any other context or screenshots about the feature request here. -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Summary 2 | Please include a summary of the change and which issue is addressed. 3 | 4 | - Fixes #(issue 1) 5 | - Fixes #(issue 2) 6 | - Fixes #(issue 3) 7 | 8 | ### Checklist 9 | 10 | - [ ] My code follows the style guidelines of this project 11 | - [ ] I have performed a self-review of my own code 12 | - [ ] I have commented my code, particularly in hard-to-understand areas 13 | - [ ] I have made corresponding changes to the documentation 14 | - [ ] My changes generate no new warnings 15 | - [ ] I have added tests that prove my fix is effective or that my feature works 16 | - [ ] New and existing unit tests pass locally with my changes 17 | - [ ] Any dependent changes have been merged and published in downstream modules 18 | - [ ] I have checked my code and corrected any misspellings -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name-template: '$NEXT_PATCH_VERSION' 2 | tag-template: '$NEXT_PATCH_VERSION' 3 | prerelease: true 4 | categories: 5 | - title: '🚀 Features' 6 | labels: 7 | - feature 8 | - title: '🔧 Enhancements' 9 | labels: 10 | - enhancement 11 | - title: '🐞 Bug Fixes' 12 | labels: 13 | - bug 14 | - title: '🚧 Maintenance' 15 | labels: 16 | - maintenance 17 | change-template: '- $TITLE (#$NUMBER), thanks [$AUTHOR](https://github.com/$AUTHOR)!' 18 | sort-by: merged_at 19 | sort-direction: descending 20 | branches: 21 | - master 22 | exclude-labels: 23 | - wontfix 24 | - duplicate 25 | - invalid 26 | - question 27 | no-changes-template: 'This release contains minor changes and bugfixes.' 28 | template: |- 29 | ## What's Changed 30 | 31 | $CHANGES 32 | 33 | 🎉 **Thanks to all contributors helping with this release:** 34 | $CONTRIBUTORS 35 | -------------------------------------------------------------------------------- /.github/workflows/branch-validations.yaml: -------------------------------------------------------------------------------- 1 | name: Validations 2 | 3 | on: 4 | push: 5 | tags-ignore: 6 | - '**' 7 | branches: 8 | - master 9 | pull_request: 10 | types: 11 | - synchronize 12 | - opened 13 | 14 | jobs: 15 | security-checks: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: actions/setup-node@v4 20 | with: 21 | registry-url: 'https://registry.npmjs.org' 22 | node-version: 23 23 | env: 24 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 25 | 26 | - name: Cache dependencies 27 | id: cache-dependencies 28 | uses: actions/cache@v4 29 | with: 30 | path: node_modules 31 | key: node_modules-${{ hashFiles('package-lock.json') }} 32 | 33 | - name: Install dependencies 34 | if: steps.cache-dependencies.outputs.cache-hit != 'true' 35 | run: npm ci 36 | 37 | - name: Check dependency vulnerabilities 38 | run: npm audit --omit=dev 39 | 40 | validate: 41 | runs-on: ubuntu-latest 42 | steps: 43 | - uses: actions/checkout@v4 44 | - uses: actions/setup-node@v4 45 | with: 46 | node-version: 23 47 | 48 | - name: Cache dependencies 49 | id: cache-dependencies 50 | uses: actions/cache@v4 51 | with: 52 | path: node_modules 53 | key: node_modules-${{ hashFiles('package-lock.json') }} 54 | 55 | - name: Install dependencies 56 | if: steps.cache-dependencies.outputs.cache-hit != 'true' 57 | run: npm ci 58 | 59 | - name: Check compilation errors 60 | run: npm run validate 61 | 62 | lint: 63 | runs-on: ubuntu-latest 64 | needs: 65 | - validate 66 | steps: 67 | - uses: actions/checkout@v4 68 | - uses: actions/setup-node@v4 69 | with: 70 | node-version: 23 71 | 72 | - name: Cache dependencies 73 | id: cache-dependencies 74 | uses: actions/cache@v4 75 | with: 76 | path: node_modules 77 | key: node_modules-${{ hashFiles('package-lock.json') }} 78 | 79 | - name: Install dependencies 80 | if: steps.cache-dependencies.outputs.cache-hit != 'true' 81 | run: npm ci 82 | 83 | - name: Check coding standard violations 84 | run: npm run lint 85 | 86 | test: 87 | runs-on: ubuntu-latest 88 | needs: 89 | - validate 90 | steps: 91 | - uses: actions/checkout@v4 92 | - uses: actions/setup-node@v4 93 | with: 94 | node-version: 23 95 | 96 | - name: Cache dependencies 97 | id: cache-dependencies 98 | uses: actions/cache@v4 99 | with: 100 | path: node_modules 101 | key: node_modules-${{ hashFiles('package-lock.json') }} 102 | 103 | - name: Install dependencies 104 | if: steps.cache-dependencies.outputs.cache-hit != 'true' 105 | run: npm ci 106 | 107 | - uses: paambaati/codeclimate-action@v9.0.0 108 | env: 109 | CC_TEST_REPORTER_ID: ${{ secrets.CC_TEST_REPORTER_ID }} 110 | with: 111 | coverageCommand: npm test 112 | coverageLocations: 113 | ./coverage/lcov.info:lcov 114 | -------------------------------------------------------------------------------- /.github/workflows/check-required-labels.yaml: -------------------------------------------------------------------------------- 1 | name: Label requirements 2 | on: 3 | pull_request: 4 | types: 5 | - opened 6 | - synchronize 7 | - reopened 8 | - labeled 9 | - unlabeled 10 | 11 | jobs: 12 | check-labels: 13 | name: Check labels 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: docker://agilepathway/pull-request-label-checker:latest 17 | with: 18 | any_of: maintenance,feature,bug,enhancement 19 | repo_token: ${{ secrets.GITHUB_TOKEN }} 20 | -------------------------------------------------------------------------------- /.github/workflows/deploy-published-releases.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: 6 | - published 7 | 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-node@v4 14 | with: 15 | registry-url: 'https://registry.npmjs.org' 16 | node-version: 23 17 | env: 18 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 19 | 20 | - name: Cache dependencies 21 | id: cache-dependencies 22 | uses: actions/cache@v4 23 | with: 24 | path: node_modules 25 | key: node_modules-${{ hashFiles('package-lock.json') }} 26 | 27 | - name: Install dependencies 28 | if: steps.cache-dependencies.outputs.cache-hit != 'true' 29 | run: npm ci 30 | 31 | - name: Build package 32 | run: npm run build 33 | 34 | - name: Prepare release 35 | run: |- 36 | cp LICENSE README.md build/ 37 | cd build 38 | find . -type f -path '*/*\.js.map' -exec sed -i -e "s~../src~src~" {} + 39 | sed -i -e "s~\"version\": \"0.0.0-dev\"~\"version\": \"${GITHUB_REF##*/}\"~" package.json 40 | sed -i -e "s~\./build~.~" package.json 41 | sed -i -e "s~./src~.~" package.json 42 | 43 | - name: Publish pre-release to NPM 44 | if: ${{ github.event.release.prerelease }} 45 | run: |- 46 | cd build 47 | npm publish --access public --tag next 48 | env: 49 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 50 | 51 | - name: Publish release to NPM 52 | if: ${{ !github.event.release.prerelease }} 53 | run: |- 54 | cd build 55 | npm publish --access public 56 | env: 57 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 58 | -------------------------------------------------------------------------------- /.github/workflows/publish-pr-preview.yaml: -------------------------------------------------------------------------------- 1 | name: Publish PR preview 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - synchronize 7 | - opened 8 | 9 | jobs: 10 | preview: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: actions/setup-node@v4 15 | with: 16 | node-version: 23 17 | 18 | - name: Cache dependencies 19 | id: cache-dependencies 20 | uses: actions/cache@v4 21 | with: 22 | path: node_modules 23 | key: node_modules-${{ hashFiles('package-lock.json') }} 24 | 25 | - name: Install dependencies 26 | if: steps.cache-dependencies.outputs.cache-hit != 'true' 27 | run: npm ci 28 | 29 | - name: Build package 30 | run: npm run build 31 | 32 | - name: Prepare release 33 | run: |- 34 | cp LICENSE README.md build/ 35 | cd build 36 | find . -type f -path '*/*\.js.map' -exec sed -i -e "s~../src~src~" {} + 37 | sed -i -e "s~\"version\": \"0.0.0-dev\"~\"version\": \"${GITHUB_REF##*/}\"~" package.json 38 | sed -i -e "s~\./build~.~" package.json 39 | sed -i -e "s~./src~.~" package.json 40 | 41 | - name: Publish preview 42 | run: |- 43 | npx pkg-pr-new publish \ 44 | --compact --comment=update \ 45 | ./build 46 | -------------------------------------------------------------------------------- /.github/workflows/update-release-notes.yaml: -------------------------------------------------------------------------------- 1 | name: Update release notes 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | tags-ignore: 8 | - '**' 9 | 10 | jobs: 11 | release-draft: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: release-drafter/release-drafter@v6 15 | env: 16 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage/ 2 | node_modules/ 3 | build/ 4 | storybook-static/ 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Croct.com 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 | 5 | 6 | 7 | 8 | Croct React SDK 9 | 10 | 11 |
12 | Croct React SDK
13 | Bring dynamic, personalized content natively into your applications. 14 |

15 |
16 | 📘 Quick start → 17 |
18 |
19 |

20 | Version 21 | Coverage 22 |

23 | 24 | ## Introduction 25 | 26 | Croct is a headless CMS that helps you manage content, run AB tests, and personalize experiences without the hassle of complex integrations. 27 | 28 | ## Installation 29 | 30 | Run this command to install the SDK: 31 | 32 | ```sh 33 | npm install @croct/plug-react 34 | ``` 35 | See our [quick start guide](https://docs.croct.com/reference/sdk/react/installation) for more details. 36 | 37 | ## Documentation 38 | 39 | Visit our [official documentation](https://docs.croct.com/reference/sdk/react/installation). 40 | 41 | ## Support 42 | 43 | Join our official [Slack channel](https://croct.link/community) to get help from the Croct team and other developers. 44 | 45 | ## Contribution 46 | 47 | Contributions are always welcome! 48 | 49 | - Report any bugs or issues on the [issue tracker](https://github.com/croct-tech/plug-react/issues). 50 | - For major changes, please [open an issue](https://github.com/croct-tech/plug-react/issues) first to discuss what you would like to change. 51 | - Please make sure to update tests as appropriate. Run tests with `npm test`. 52 | 53 | ## License 54 | 55 | This library is licensed under the [MIT license](LICENSE). 56 | -------------------------------------------------------------------------------- /jest.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | export default { 3 | testEnvironment: 'jsdom', 4 | preset: 'ts-jest', 5 | testMatch: ['/src/**/*.test.{ts,tsx}'], 6 | }; 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@croct/plug-react", 3 | "version": "0.0.0-dev", 4 | "description": "React components and hooks to plug your React applications into Croct.", 5 | "author": { 6 | "name": "Croct", 7 | "url": "https://croct.com", 8 | "email": "lib+plug-react@croct.com" 9 | }, 10 | "keywords": [ 11 | "croct", 12 | "personalization", 13 | "react", 14 | "typescript" 15 | ], 16 | "license": "MIT", 17 | "repository": { 18 | "type": "git", 19 | "url": "git+https://github.com/croct-tech/plug-react.git" 20 | }, 21 | "type": "module", 22 | "main": "./index.js", 23 | "types": "./index.d.ts", 24 | "exports": { 25 | "./*": { 26 | "import": "./*.js", 27 | "require": "./*.cjs" 28 | } 29 | }, 30 | "engines": { 31 | "node": ">=10" 32 | }, 33 | "scripts": { 34 | "lint": "eslint 'src/**/*.ts' 'src/**/*.tsx'", 35 | "test": "jest -c jest.config.mjs --coverage", 36 | "validate": "tsc --noEmit", 37 | "build": "tsup", 38 | "postbuild": "./post-build.mjs" 39 | }, 40 | "peerDependencies": { 41 | "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", 42 | "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" 43 | }, 44 | "dependencies": { 45 | "@croct/plug": "^0.18.1", 46 | "@croct/sdk": "^0.18.0" 47 | }, 48 | "devDependencies": { 49 | "@babel/core": "^7.25.2", 50 | "@babel/preset-react": "^7.24.7", 51 | "@babel/preset-typescript": "^7.24.7", 52 | "@croct/eslint-plugin": "^0.7.1", 53 | "@testing-library/jest-dom": "^6.6.3", 54 | "@testing-library/react": "^16.1.0", 55 | "@types/jest": "^29.5.12", 56 | "@types/node": "^22.5.4", 57 | "@types/react": "^19.0.0", 58 | "@types/react-dom": "^19.0.0", 59 | "@typescript-eslint/eslint-plugin": "^6.18.0", 60 | "@typescript-eslint/parser": "^6.21.0", 61 | "esbuild-fix-imports-plugin": "^1.0.19", 62 | "eslint": "^8.57.0", 63 | "jest": "^29.7.0", 64 | "jest-environment-jsdom": "^29.7.0", 65 | "jest-environment-node": "^29.7.0", 66 | "react": "^19.0.0", 67 | "react-dom": "^19.0.0", 68 | "ts-jest": "^29.0.3", 69 | "ts-node": "^10.9.2", 70 | "tsup": "^8.4.0", 71 | "typescript": "^5.6.2", 72 | "webpack": "^5.94.0" 73 | }, 74 | "files": [ 75 | "**/*.js", 76 | "**/*.cjs", 77 | "**/*.mjs", 78 | "**/*.ts", 79 | "**/*.cts", 80 | "**/*.map" 81 | ] 82 | } 83 | -------------------------------------------------------------------------------- /post-build.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import fs from 'fs'; 3 | import path from 'path'; 4 | 5 | const BUILD_DIR = path.resolve('build'); 6 | const PACKAGE_JSON = path.resolve('package.json'); 7 | 8 | function findIndexFiles(dir, fileList = []) { 9 | const files = fs.readdirSync(dir); 10 | 11 | for (const file of files) { 12 | const filePath = path.join(dir, file); 13 | const stats = fs.statSync(filePath); 14 | 15 | if (stats.isDirectory()) { 16 | findIndexFiles(filePath, fileList); 17 | } else if (/^index\.(js|mjs|cjs)$/.test(file)) { 18 | fileList.push(filePath); 19 | } 20 | } 21 | 22 | return fileList; 23 | } 24 | 25 | function updateExports() { 26 | const pkg = JSON.parse(fs.readFileSync(PACKAGE_JSON, 'utf-8')); 27 | const indexFiles = findIndexFiles(BUILD_DIR); 28 | 29 | pkg.exports = pkg.exports || {}; 30 | 31 | for (const file of indexFiles) { 32 | const relativeDir = `./${path.relative(BUILD_DIR, path.dirname(file)).replace(/\\/g, '/')}`.replace(/\/$/, ''); 33 | const relativeFile = `./${path.relative(BUILD_DIR, file).replace(/\\/g, '/')}`; 34 | 35 | if (pkg.exports[relativeDir] === undefined) { 36 | pkg.exports[relativeDir] = { 37 | import: {}, 38 | require: {}, 39 | }; 40 | } 41 | 42 | if (file.endsWith('.cjs')) { 43 | pkg.exports[relativeDir].require = relativeFile; 44 | } else { 45 | pkg.exports[relativeDir].import = relativeFile; 46 | } 47 | } 48 | 49 | fs.writeFileSync(path.join(BUILD_DIR, 'package.json'), JSON.stringify(pkg, null, 2)); 50 | 51 | console.log('✅ Updated package.json exports'); 52 | } 53 | 54 | updateExports(); 55 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "github>croct-tech/renovate-public-presets:js" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /src/CroctProvider.test.tsx: -------------------------------------------------------------------------------- 1 | import {render} from '@testing-library/react'; 2 | import {Plug} from '@croct/plug'; 3 | import {croct} from './ssr-polyfills'; 4 | import {CroctContext, CroctProvider, CroctProviderProps} from './CroctProvider'; 5 | 6 | jest.mock( 7 | './ssr-polyfills', 8 | () => ({ 9 | ...jest.requireActual('./ssr-polyfills'), 10 | croct: { 11 | plug: jest.fn(), 12 | unplug: jest.fn().mockResolvedValue(undefined), 13 | plugged: true, 14 | }, 15 | }), 16 | ); 17 | 18 | // eslint-disable-next-line no-console -- Needed to test console output. 19 | const consoleError = console.error; 20 | 21 | describe('', () => { 22 | afterEach(() => { 23 | // eslint-disable-next-line no-console -- Needed to restore the original console.error. 24 | console.error = consoleError; 25 | }); 26 | 27 | beforeEach(() => { 28 | jest.clearAllMocks(); 29 | }); 30 | 31 | it('should fail if nested', () => { 32 | jest.spyOn(console, 'error').mockImplementation(); 33 | 34 | expect( 35 | () => render( 36 | 37 | 38 | , 39 | ), 40 | ).toThrow('You cannot render inside another '); 41 | }); 42 | 43 | it('should initialize the Plug when accessed', () => { 44 | const options: CroctProviderProps = { 45 | appId: '00000000-0000-0000-0000-000000000000', 46 | debug: true, 47 | track: true, 48 | }; 49 | 50 | let initialized = false; 51 | 52 | Object.defineProperty(croct, 'initialized', { 53 | get: jest.fn().mockImplementation(() => initialized), 54 | }); 55 | 56 | jest.mocked(croct.plug).mockImplementation(() => { 57 | initialized = true; 58 | }); 59 | 60 | const callback = jest.fn((context: {plug: Plug}|null) => { 61 | // eslint-disable-next-line @typescript-eslint/no-unused-expressions -- Trigger initialization. 62 | context?.plug; 63 | 64 | return 'foo'; 65 | }); 66 | 67 | render( 68 | 69 | {callback} 70 | , 71 | ); 72 | 73 | expect(callback).toHaveBeenCalledTimes(1); 74 | 75 | expect(croct.plug).toHaveBeenCalledTimes(2); 76 | expect(croct.plug).toHaveBeenNthCalledWith(1, options); 77 | expect(croct.plug).toHaveBeenNthCalledWith(2, options); 78 | }); 79 | 80 | it('should unplug on unmount', () => { 81 | const {unmount} = render(); 82 | 83 | unmount(); 84 | 85 | expect(croct.unplug).toHaveBeenCalled(); 86 | }); 87 | 88 | it('should allow to plug after unmount', () => { 89 | const options: CroctProviderProps = { 90 | appId: '00000000-0000-0000-0000-000000000000', 91 | debug: true, 92 | track: true, 93 | }; 94 | 95 | let plug: Plug|undefined; 96 | 97 | const callback = jest.fn((context: {plug: Plug}|null) => { 98 | plug = context?.plug; 99 | 100 | return 'foo'; 101 | }); 102 | 103 | render( 104 | 105 | {callback} 106 | , 107 | ); 108 | 109 | const appId = '11111111-1111-1111-1111-111111111111'; 110 | 111 | plug?.plug({appId: appId}); 112 | 113 | expect(plug?.plugged).toBe(croct.plugged); 114 | 115 | expect(croct.plug).toHaveBeenLastCalledWith({ 116 | ...options, 117 | appId: appId, 118 | }); 119 | }); 120 | 121 | it('should ignore errors on unmount', () => { 122 | jest.mocked(croct.unplug).mockRejectedValue(new Error('foo')); 123 | 124 | const {unmount} = render(); 125 | 126 | expect(() => unmount()).not.toThrow(); 127 | }); 128 | }); 129 | -------------------------------------------------------------------------------- /src/CroctProvider.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { 4 | createContext, 5 | FunctionComponent, 6 | MutableRefObject, 7 | PropsWithChildren, 8 | ReactElement, 9 | useContext, 10 | useEffect, 11 | useMemo, 12 | useRef, 13 | } from 'react'; 14 | import {Configuration, Plug} from '@croct/plug'; 15 | import {croct} from './ssr-polyfills'; 16 | 17 | export type CroctProviderProps = PropsWithChildren>>; 18 | 19 | export const CroctContext = createContext<{plug: Plug}|null>(null); 20 | CroctContext.displayName = 'CroctContext'; 21 | 22 | function useLiveRef(value: T): MutableRefObject { 23 | const ref = useRef(value); 24 | 25 | ref.current = value; 26 | 27 | return ref; 28 | } 29 | 30 | export const CroctProvider: FunctionComponent = (props): ReactElement => { 31 | const {children, ...configuration} = props; 32 | const parent = useContext(CroctContext); 33 | const baseConfiguration = useLiveRef(configuration); 34 | 35 | if (parent !== null) { 36 | throw new Error( 37 | 'You cannot render inside another . ' 38 | + 'Croct should only be initialized once in the application.', 39 | ); 40 | } 41 | 42 | const context = useMemo( 43 | () => ({ 44 | get plug(): Plug { 45 | if (!croct.initialized) { 46 | croct.plug(baseConfiguration.current); 47 | } 48 | 49 | return new Proxy(croct, { 50 | get: function getProperty(target, property: keyof Plug): any { 51 | if (property === 'plug') { 52 | return (options: Configuration): void => { 53 | target.plug({...baseConfiguration.current, ...options}); 54 | }; 55 | } 56 | 57 | return target[property]; 58 | }, 59 | }); 60 | }, 61 | }), 62 | [baseConfiguration], 63 | ); 64 | 65 | useEffect( 66 | () => { 67 | croct.plug(baseConfiguration.current); 68 | 69 | return () => { 70 | croct.unplug().catch(() => { 71 | // Suppress errors. 72 | }); 73 | }; 74 | }, 75 | [baseConfiguration], 76 | ); 77 | 78 | return ( 79 | 80 | {children} 81 | 82 | ); 83 | }; 84 | -------------------------------------------------------------------------------- /src/api.ts: -------------------------------------------------------------------------------- 1 | export * from '@croct/plug/api'; 2 | -------------------------------------------------------------------------------- /src/components/Personalization/index.d.test.tsx: -------------------------------------------------------------------------------- 1 | import {join as pathJoin} from 'path'; 2 | import {create} from 'ts-node'; 3 | 4 | const ts = create({ 5 | cwd: __dirname, 6 | ignore: [ 7 | 'lib/slots.d.ts', 8 | ], 9 | }); 10 | 11 | const testFilename = pathJoin(__dirname, 'test.tsx'); 12 | 13 | describe(' typing', () => { 14 | it('should a renderer that accepts JSON values or covariants', () => { 15 | const code = ` 16 | import {Personalization} from './index'; 17 | 18 | 19 | {(foo: string) => typeof foo} 20 | ; 21 | `; 22 | 23 | expect(() => ts.compile(code, testFilename)).not.toThrow(); 24 | }); 25 | 26 | it('should require a renderer that accepts JSON values or covariants', () => { 27 | const code = ` 28 | import {Personalization} from './index'; 29 | 30 | 31 | {(foo: Error) => typeof foo} 32 | ; 33 | `; 34 | 35 | expect(() => ts.compile(code, testFilename)).toThrow(); 36 | }); 37 | 38 | it('should allow a renderer that accepts the initial value', () => { 39 | const code = ` 40 | import {Personalization} from './index'; 41 | 42 | 43 | {(foo: string|boolean) => typeof foo} 44 | ; 45 | `; 46 | 47 | expect(() => ts.compile(code, testFilename)).not.toThrow(); 48 | }); 49 | 50 | it('should require a renderer that accepts the initial value', () => { 51 | const code = ` 52 | import {Personalization} from './index'; 53 | 54 | 55 | {(foo: string) => typeof foo} 56 | ; 57 | `; 58 | 59 | expect(() => ts.compile(code, testFilename)).toThrow(); 60 | }); 61 | 62 | it('should allow a renderer that accepts the fallback value', () => { 63 | const code = ` 64 | import {Personalization} from './index'; 65 | 66 | 67 | {(foo: string|number) => typeof foo} 68 | ; 69 | `; 70 | 71 | expect(() => ts.compile(code, testFilename)).not.toThrow(); 72 | }); 73 | 74 | it('should require a renderer that accepts the fallback value', () => { 75 | const code = ` 76 | import {Personalization} from './index'; 77 | 78 | 79 | {(foo: string) => typeof foo} 80 | ; 81 | `; 82 | 83 | expect(() => ts.compile(code, testFilename)).toThrow(); 84 | }); 85 | 86 | it('should allow a renderer that accepts both the initial and fallback values', () => { 87 | const code = ` 88 | import {Personalization} from './index'; 89 | 90 | 91 | {(foo: string|boolean|number) => typeof foo} 92 | ; 93 | `; 94 | 95 | expect(() => ts.compile(code, testFilename)).not.toThrow(); 96 | }); 97 | 98 | it('should require a renderer that accepts both the initial and fallback values', () => { 99 | const code = ` 100 | import {Personalization} from './index'; 101 | 102 | 103 | {(foo: string|boolean) => typeof foo} 104 | ; 105 | `; 106 | 107 | expect(() => ts.compile(code, testFilename)).toThrow(); 108 | }); 109 | 110 | it('should require a renderer that accepts both the fallback and initial values', () => { 111 | const code = ` 112 | import {Personalization} from './index'; 113 | 114 | 115 | {(foo: string|number) => typeof foo} 116 | ; 117 | `; 118 | 119 | expect(() => ts.compile(code, testFilename)).toThrow(); 120 | }); 121 | }); 122 | -------------------------------------------------------------------------------- /src/components/Personalization/index.test.tsx: -------------------------------------------------------------------------------- 1 | import {render, screen} from '@testing-library/react'; 2 | import {Personalization, PersonalizationProps} from './index'; 3 | import {useEvaluation} from '../../hooks'; 4 | import '@testing-library/jest-dom'; 5 | 6 | jest.mock( 7 | '../../hooks/useEvaluation', 8 | () => ({ 9 | useEvaluation: jest.fn(), 10 | }), 11 | ); 12 | 13 | describe('', () => { 14 | it('should evaluate and render a query', () => { 15 | const {query, children, ...options}: PersonalizationProps = { 16 | query: '"example"', 17 | children: jest.fn(result => result), 18 | fallback: 'fallback', 19 | }; 20 | 21 | const result = 'result'; 22 | 23 | jest.mocked(useEvaluation).mockReturnValue(result); 24 | 25 | render( 26 | 27 | {children} 28 | , 29 | ); 30 | 31 | expect(useEvaluation).toHaveBeenCalledWith(query, options); 32 | expect(children).toHaveBeenCalledWith(result); 33 | expect(screen.getByText(result)).toBeInTheDocument(); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /src/components/Personalization/index.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import {ReactElement, Fragment} from 'react'; 4 | import {JsonValue} from '@croct/plug/sdk/json'; 5 | import {UseEvaluationOptions, useEvaluation} from '../../hooks'; 6 | 7 | type Renderer = (result: T) => ReactElement | string | number; 8 | 9 | export type PersonalizationProps = UseEvaluationOptions & { 10 | query: string, 11 | children: Renderer, 12 | }; 13 | 14 | export function Personalization( 15 | props: 16 | Extract extends never 17 | ? PersonalizationProps 18 | : PersonalizationProps, 19 | ): ReactElement; 20 | 21 | export function Personalization(props: PersonalizationProps): ReactElement { 22 | const {query, children, ...options} = props; 23 | const result = useEvaluation(query, options); 24 | 25 | return ({children(result)}); 26 | } 27 | -------------------------------------------------------------------------------- /src/components/Slot/index.d.test.tsx: -------------------------------------------------------------------------------- 1 | import {join as pathJoin} from 'path'; 2 | import {create} from 'ts-node'; 3 | 4 | const ts = create({ 5 | cwd: __dirname, 6 | transpileOnly: false, 7 | ignore: [ 8 | 'lib/slots.d.ts', 9 | ], 10 | }); 11 | 12 | const testFilename = pathJoin(__dirname, 'test.tsx'); 13 | 14 | describe(' typing', () => { 15 | const header = ` 16 | import {Slot} from './index'; 17 | `; 18 | 19 | const slotMapping = ` 20 | type HomeBanner = { 21 | title: string, 22 | subtitle: string, 23 | }; 24 | 25 | declare module '@croct/plug/slot' { 26 | type HomeBannerV1 = HomeBanner & {_component: 'banner@v1' | null}; 27 | 28 | interface SlotMap { 29 | 'home-banner': HomeBannerV1; 30 | } 31 | } 32 | `; 33 | 34 | type CodeOptions = { 35 | code: string, 36 | mapping: boolean, 37 | }; 38 | 39 | type AssembledCode = { 40 | code: string, 41 | codePosition: number, 42 | }; 43 | 44 | function assembleCode({code, mapping}: CodeOptions): AssembledCode { 45 | const prefix = mapping 46 | ? header + slotMapping 47 | : header; 48 | 49 | const fullCode = prefix + code.trim(); 50 | 51 | return { 52 | code: fullCode, 53 | codePosition: fullCode.lastIndexOf('=>') + 1, 54 | }; 55 | } 56 | 57 | function compileCode(opts: CodeOptions): void { 58 | ts.compile(assembleCode(opts).code, testFilename); 59 | } 60 | 61 | function getParameterType(opts: CodeOptions): string { 62 | const assembledCode = assembleCode(opts); 63 | 64 | const info = ts.getTypeInfo(assembledCode.code, testFilename, assembledCode.codePosition); 65 | 66 | const match = info.name.match(/function\(\w+: (.+?)\):/s); 67 | 68 | if (match !== null) { 69 | return match[1].replace(/\s*\n\s*/g, ''); 70 | } 71 | 72 | return info.name; 73 | } 74 | 75 | it('should allow a renderer that accepts JSON objects or covariants for unmapped slots', () => { 76 | const code: CodeOptions = { 77 | code: ` 78 | 79 | {(params: {foo: string}) => typeof params} 80 | ; 81 | `, 82 | mapping: false, 83 | }; 84 | 85 | expect(() => compileCode(code)).not.toThrow(); 86 | }); 87 | 88 | it('should require a renderer that accepts JSON objects or covariants for unmapped slots', () => { 89 | const code: CodeOptions = { 90 | code: ` 91 | 92 | {(params: true) => typeof params} 93 | ; 94 | `, 95 | mapping: false, 96 | }; 97 | 98 | expect(() => compileCode(code)).toThrow(); 99 | }); 100 | 101 | it('should allow a renderer that accepts the initial value for unmapped slots', () => { 102 | const code: CodeOptions = { 103 | code: ` 104 | 105 | {(params: {foo: string}|boolean) => typeof params} 106 | ; 107 | `, 108 | mapping: false, 109 | }; 110 | 111 | expect(() => compileCode(code)).not.toThrow(); 112 | }); 113 | 114 | it('should require a renderer that accepts the initial value for unmapped slots', () => { 115 | const code: CodeOptions = { 116 | code: ` 117 | 118 | {(params: {foo: string}) => typeof params} 119 | ; 120 | `, 121 | mapping: false, 122 | }; 123 | 124 | expect(() => compileCode(code)).toThrow(); 125 | }); 126 | 127 | it('should allow a renderer that accepts the fallback value for unmapped slots', () => { 128 | const code: CodeOptions = { 129 | code: ` 130 | 131 | {(params: {foo: string}|boolean) => typeof params} 132 | ; 133 | `, 134 | mapping: false, 135 | }; 136 | 137 | expect(() => compileCode(code)).not.toThrow(); 138 | }); 139 | 140 | it('should require a renderer that accepts the fallback value for unmapped slots', () => { 141 | const code: CodeOptions = { 142 | code: ` 143 | 144 | {(params: {foo: string}) => typeof params} 145 | ; 146 | `, 147 | mapping: false, 148 | }; 149 | 150 | expect(() => compileCode(code)).toThrow(); 151 | }); 152 | 153 | it('should allow a renderer that accepts both the initial and fallback values for unmapped slots', () => { 154 | const code: CodeOptions = { 155 | code: ` 156 | 157 | {(params: {foo: string}|boolean|number) => typeof params} 158 | ; 159 | `, 160 | mapping: false, 161 | }; 162 | 163 | expect(() => compileCode(code)).not.toThrow(); 164 | }); 165 | 166 | it('should require a renderer that accepts both the initial and fallback values for unmapped slots', () => { 167 | const code: CodeOptions = { 168 | code: ` 169 | 170 | {(params: {foo: string}|boolean) => typeof params} 171 | ; 172 | `, 173 | mapping: false, 174 | }; 175 | 176 | expect(() => compileCode(code)).toThrow(); 177 | }); 178 | 179 | it('should require a renderer that accepts both the fallback and initial values for unmapped slots', () => { 180 | const code: CodeOptions = { 181 | code: ` 182 | 183 | {(params: {foo: string}|number) => typeof params} 184 | ; 185 | `, 186 | mapping: false, 187 | }; 188 | 189 | expect(() => compileCode(code)).toThrow(); 190 | }); 191 | 192 | it('should infer the renderer parameter type for mapped slots', () => { 193 | const code: CodeOptions = { 194 | code: ` 195 | 196 | {params => typeof params} 197 | ; 198 | `, 199 | mapping: true, 200 | }; 201 | 202 | expect(() => compileCode(code)).not.toThrow(); 203 | 204 | expect(getParameterType(code)).toBe('HomeBannerV1'); 205 | }); 206 | 207 | it('should allow a covariant renderer parameter type for mapped slots', () => { 208 | const code: CodeOptions = { 209 | code: ` 210 | 211 | {(params: {title: string}) => typeof params} 212 | ; 213 | `, 214 | mapping: true, 215 | }; 216 | 217 | expect(() => compileCode(code)).not.toThrow(); 218 | }); 219 | 220 | it('should require a compatible renderer for mapped slots', () => { 221 | const code: CodeOptions = { 222 | code: ` 223 | 224 | {(params: {foo: string}) => typeof params} 225 | ; 226 | `, 227 | mapping: true, 228 | }; 229 | 230 | expect(() => compileCode(code)).toThrow(); 231 | }); 232 | 233 | it('should infer the renderer parameter type also from the initial value for mapped slots', () => { 234 | const code: CodeOptions = { 235 | code: ` 236 | 237 | {params => typeof params} 238 | ; 239 | `, 240 | mapping: true, 241 | }; 242 | 243 | expect(() => compileCode(code)).not.toThrow(); 244 | 245 | expect(getParameterType(code)).toBe('boolean | HomeBannerV1'); 246 | }); 247 | 248 | it('should allow a renderer that accepts the initial value for mapped slots', () => { 249 | const code: CodeOptions = { 250 | code: ` 251 | 252 | {(params: {title: string}|boolean) => typeof params} 253 | ; 254 | `, 255 | mapping: true, 256 | }; 257 | 258 | expect(() => compileCode(code)).not.toThrow(); 259 | }); 260 | 261 | it('should require a renderer that accepts the initial value for mapped slots', () => { 262 | const code: CodeOptions = { 263 | code: ` 264 | 265 | {(params: {title: string}) => typeof params} 266 | ; 267 | `, 268 | mapping: true, 269 | }; 270 | 271 | expect(() => compileCode(code)).toThrow(); 272 | }); 273 | 274 | it('should infer the renderer parameter type also from the fallback value for mapped slots', () => { 275 | const code: CodeOptions = { 276 | code: ` 277 | 278 | {params => typeof params} 279 | ; 280 | `, 281 | mapping: true, 282 | }; 283 | 284 | expect(() => compileCode(code)).not.toThrow(); 285 | 286 | expect(getParameterType(code)).toBe('boolean | HomeBannerV1'); 287 | }); 288 | 289 | it('should allow a renderer that accepts the fallback value for mapped slots', () => { 290 | const code: CodeOptions = { 291 | code: ` 292 | 293 | {(params: {title: string}|boolean) => typeof params} 294 | ; 295 | `, 296 | mapping: true, 297 | }; 298 | 299 | expect(() => compileCode(code)).not.toThrow(); 300 | }); 301 | 302 | it('should require a renderer that accepts the fallback value for mapped slots', () => { 303 | const code: CodeOptions = { 304 | code: ` 305 | 306 | {(params: {title: string}) => typeof params} 307 | ; 308 | `, 309 | mapping: true, 310 | }; 311 | 312 | expect(() => compileCode(code)).toThrow(); 313 | }); 314 | 315 | it('should infer the renderer parameter type from both the initial and fallback values for mapped slots', () => { 316 | const code: CodeOptions = { 317 | code: ` 318 | 319 | {params => typeof params} 320 | ; 321 | `, 322 | mapping: true, 323 | }; 324 | 325 | expect(() => compileCode(code)).not.toThrow(); 326 | 327 | expect(getParameterType(code)).toBe('number | boolean | HomeBannerV1'); 328 | }); 329 | 330 | it('should allow a renderer that accepts both the initial and fallback values for mapped slots', () => { 331 | const code: CodeOptions = { 332 | code: ` 333 | 334 | {(params: {title: string}|boolean|number) => typeof params} 335 | ; 336 | `, 337 | mapping: true, 338 | }; 339 | 340 | expect(() => compileCode(code)).not.toThrow(); 341 | }); 342 | 343 | it('should require a renderer that accepts both the initial and fallback values for mapped slots', () => { 344 | const code: CodeOptions = { 345 | code: ` 346 | 347 | {(params: {title: string}|boolean) => typeof params} 348 | ; 349 | `, 350 | mapping: true, 351 | }; 352 | 353 | expect(() => compileCode(code)).toThrow(); 354 | }); 355 | 356 | it('should require a renderer that accepts both the fallback and initial values for mapped slots', () => { 357 | const code: CodeOptions = { 358 | code: ` 359 | 360 | {(params: {title: string}|number) => typeof params} 361 | ; 362 | `, 363 | mapping: true, 364 | }; 365 | 366 | expect(() => compileCode(code)).toThrow(); 367 | }); 368 | }); 369 | -------------------------------------------------------------------------------- /src/components/Slot/index.test.tsx: -------------------------------------------------------------------------------- 1 | import {render, screen} from '@testing-library/react'; 2 | import {Slot, SlotProps} from './index'; 3 | import {useContent} from '../../hooks'; 4 | import '@testing-library/jest-dom'; 5 | 6 | jest.mock( 7 | '../../hooks/useContent', 8 | () => ({ 9 | useContent: jest.fn(), 10 | }), 11 | ); 12 | 13 | describe('', () => { 14 | it('should fetch and render a slot', () => { 15 | const {id, children, ...options}: SlotProps<{title: string}> = { 16 | id: 'home-banner', 17 | children: jest.fn(({title}) => title), 18 | fallback: {title: 'fallback'}, 19 | }; 20 | 21 | const result = {title: 'result'}; 22 | 23 | jest.mocked(useContent).mockReturnValue(result); 24 | 25 | render( 26 | 27 | {children} 28 | , 29 | ); 30 | 31 | expect(useContent).toHaveBeenCalledWith(id, options); 32 | expect(children).toHaveBeenCalledWith(result); 33 | expect(screen.getByText(result.title)).toBeInTheDocument(); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /src/components/Slot/index.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import {Fragment, ReactElement, ReactNode} from 'react'; 4 | import {SlotContent, VersionedSlotId, VersionedSlotMap} from '@croct/plug/slot'; 5 | import {JsonObject} from '@croct/plug/sdk/json'; 6 | import {useContent, UseContentOptions} from '../../hooks'; 7 | 8 | type Renderer

= (props: P) => ReactNode; 9 | 10 | export type SlotProps = UseContentOptions & { 11 | id: S, 12 | children: Renderer

, 13 | }; 14 | 15 | type SlotComponent = { 16 | ( 17 | props: 18 | Extract

extends never 19 | ? SlotProps 20 | : SlotProps 21 | ): ReactElement, 22 | 23 | (props: SlotProps, never, never, S>): ReactElement, 24 | 25 | (props: SlotProps, I, never, S>): ReactElement, 26 | 27 | (props: SlotProps, never, F, S>): ReactElement, 28 | 29 | (props: SlotProps, I, F, S>): ReactElement, 30 | 31 | (props: SlotProps): ReactElement, 32 | }; 33 | 34 | export const Slot: SlotComponent = (props: SlotProps): ReactElement => { 35 | const {id, children, ...options} = props; 36 | const data = useContent(id, options); 37 | 38 | return {children(data)}; 39 | }; 40 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Personalization'; 2 | export * from './Slot'; 3 | -------------------------------------------------------------------------------- /src/global.d.ts: -------------------------------------------------------------------------------- 1 | import {EapFeatures} from '@croct/plug/eap'; 2 | 3 | declare global { 4 | interface Window { 5 | croctEap?: Partial; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/hash.test.ts: -------------------------------------------------------------------------------- 1 | import {hash} from './hash'; 2 | 3 | describe('hash', () => { 4 | it('should generate a hash from a string', () => { 5 | const result = hash('foo'); 6 | 7 | expect(result).toEqual('18cc6'); 8 | expect(result).toEqual(hash('foo')); 9 | }); 10 | 11 | it('should handle special characters', () => { 12 | expect(hash('✨')).toEqual('2728'); 13 | expect(hash('💥')).toEqual('d83d'); 14 | expect(hash('✨💥')).toEqual('59615'); 15 | }); 16 | 17 | it('should generate a hash from an empty string', () => { 18 | const result = hash(''); 19 | 20 | expect(result).toEqual('0'); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/hash.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @internal 3 | */ 4 | export function hash(value: string): string { 5 | let code = 0; 6 | 7 | for (const char of value) { 8 | const charCode = char.charCodeAt(0); 9 | 10 | code = (code << 5) - code + charCode; 11 | code |= 0; // Convert to 32bit integer 12 | } 13 | 14 | return code.toString(16); 15 | } 16 | -------------------------------------------------------------------------------- /src/hooks/Cache.test.ts: -------------------------------------------------------------------------------- 1 | import {Cache, EntryOptions} from './Cache'; 2 | 3 | describe('Cache', () => { 4 | afterEach(() => { 5 | jest.clearAllTimers(); 6 | jest.resetAllMocks(); 7 | }); 8 | 9 | it('should load and cache the value for the default cache time', async () => { 10 | jest.useFakeTimers(); 11 | 12 | const cache = new Cache(10); 13 | 14 | const loader = jest.fn() 15 | .mockResolvedValueOnce('result1') 16 | .mockResolvedValueOnce('result2'); 17 | 18 | const options: EntryOptions = { 19 | cacheKey: 'key', 20 | loader: loader, 21 | }; 22 | 23 | let promise: Promise|undefined; 24 | 25 | try { 26 | cache.load(options); 27 | } catch (result: any|undefined) { 28 | promise = result; 29 | } 30 | 31 | await expect(promise).resolves.toEqual('result1'); 32 | 33 | expect(cache.load(options)).toEqual('result1'); 34 | 35 | expect(loader).toHaveBeenCalledTimes(1); 36 | 37 | jest.advanceTimersByTime(10); 38 | 39 | try { 40 | cache.load(options); 41 | } catch (result: any|undefined) { 42 | promise = result; 43 | } 44 | 45 | await expect(promise).resolves.toEqual('result2'); 46 | 47 | expect(loader).toHaveBeenCalledTimes(2); 48 | }); 49 | 50 | it('should load the value once before expiration', async () => { 51 | jest.useFakeTimers(); 52 | 53 | const cache = new Cache(10); 54 | 55 | const loader = jest.fn( 56 | () => new Promise(resolve => { 57 | setTimeout(() => resolve('done'), 10); 58 | }), 59 | ); 60 | 61 | const options: EntryOptions = { 62 | cacheKey: 'key', 63 | loader: loader, 64 | }; 65 | 66 | let promise1: Promise|undefined; 67 | 68 | try { 69 | cache.load(options); 70 | } catch (result: any|undefined) { 71 | promise1 = result; 72 | } 73 | 74 | let promise2: Promise|undefined; 75 | 76 | try { 77 | cache.load(options); 78 | } catch (result: any|undefined) { 79 | promise2 = result; 80 | } 81 | 82 | expect(promise1).toBe(promise2); 83 | 84 | jest.advanceTimersByTime(10); 85 | 86 | await expect(promise1).resolves.toEqual('done'); 87 | await expect(promise2).resolves.toEqual('done'); 88 | 89 | expect(loader).toHaveBeenCalledTimes(1); 90 | }); 91 | 92 | it('should load and cache the value for the specified time', async () => { 93 | jest.useFakeTimers(); 94 | 95 | const cache = new Cache(10); 96 | 97 | const loader = jest.fn() 98 | .mockResolvedValueOnce('result1') 99 | .mockResolvedValueOnce('result2'); 100 | 101 | const options: EntryOptions = { 102 | cacheKey: 'key', 103 | loader: loader, 104 | expiration: 15, 105 | }; 106 | 107 | let promise: Promise|undefined; 108 | 109 | try { 110 | cache.load(options); 111 | } catch (result: any|undefined) { 112 | promise = result; 113 | } 114 | 115 | await expect(promise).resolves.toEqual('result1'); 116 | 117 | expect(cache.load(options)).toEqual('result1'); 118 | 119 | expect(loader).toHaveBeenCalledTimes(1); 120 | 121 | jest.advanceTimersByTime(15); 122 | 123 | try { 124 | cache.load(options); 125 | } catch (result: any|undefined) { 126 | promise = result; 127 | } 128 | 129 | await expect(promise).resolves.toEqual('result2'); 130 | 131 | expect(loader).toHaveBeenCalledTimes(2); 132 | }); 133 | 134 | it('should load and cache the value for undetermined time', async () => { 135 | jest.useFakeTimers(); 136 | 137 | const cache = new Cache(10); 138 | 139 | const loader = jest.fn() 140 | .mockResolvedValueOnce('result1') 141 | .mockResolvedValueOnce('result2'); 142 | 143 | const options: EntryOptions = { 144 | cacheKey: 'key', 145 | loader: loader, 146 | expiration: -1, 147 | }; 148 | 149 | let promise: Promise|undefined; 150 | 151 | try { 152 | cache.load(options); 153 | } catch (result: any|undefined) { 154 | promise = result; 155 | } 156 | 157 | await expect(promise).resolves.toEqual('result1'); 158 | 159 | jest.advanceTimersByTime(60_000); 160 | 161 | expect(cache.load(options)).toEqual('result1'); 162 | 163 | expect(loader).toHaveBeenCalledTimes(1); 164 | }); 165 | 166 | it('should return the fallback value on error', async () => { 167 | const cache = new Cache(10); 168 | 169 | const loader = jest.fn().mockRejectedValue(new Error('failed')); 170 | const options: EntryOptions = { 171 | cacheKey: 'key', 172 | loader: loader, 173 | fallback: 'fallback', 174 | }; 175 | 176 | let promise: Promise|undefined; 177 | 178 | try { 179 | cache.load(options); 180 | } catch (result: any|undefined) { 181 | promise = result; 182 | } 183 | 184 | await expect(promise).resolves.toBe('fallback'); 185 | 186 | expect(cache.load(options)).toEqual('fallback'); 187 | 188 | expect(cache.load({...options, fallback: 'error'})).toEqual('error'); 189 | 190 | expect(loader).toHaveBeenCalledTimes(1); 191 | }); 192 | 193 | it('should throw the error if no fallback is specified', async () => { 194 | const cache = new Cache(10); 195 | 196 | const error = new Error('failed'); 197 | 198 | const loader = jest.fn().mockRejectedValue(error); 199 | const options: EntryOptions = { 200 | cacheKey: 'key', 201 | loader: loader, 202 | }; 203 | 204 | let promise: Promise|undefined; 205 | 206 | try { 207 | cache.load(options); 208 | } catch (result: any|undefined) { 209 | promise = result; 210 | } 211 | 212 | await expect(promise).resolves.toBeUndefined(); 213 | 214 | await expect(() => cache.load(options)).toThrow(error); 215 | }); 216 | 217 | it('should cache the error', async () => { 218 | const cache = new Cache(10); 219 | 220 | const error = new Error('error'); 221 | const loader = jest.fn().mockRejectedValue(error); 222 | const options: EntryOptions = { 223 | cacheKey: 'key', 224 | loader: loader, 225 | }; 226 | 227 | let promise: Promise|undefined; 228 | 229 | try { 230 | cache.load(options); 231 | } catch (result: any|undefined) { 232 | promise = result; 233 | } 234 | 235 | await expect(promise).resolves.toBeUndefined(); 236 | 237 | expect(() => cache.load(options)).toThrow(error); 238 | expect(cache.get(options.cacheKey)?.error).toBe(error); 239 | }); 240 | 241 | it('should provide the cached values', async () => { 242 | jest.useFakeTimers(); 243 | 244 | const cache = new Cache(10); 245 | 246 | const loader = jest.fn().mockResolvedValue('loaded'); 247 | const options: EntryOptions = { 248 | cacheKey: 'key', 249 | loader: loader, 250 | }; 251 | 252 | let promise: Promise|undefined; 253 | 254 | try { 255 | cache.load(options); 256 | } catch (result: any|undefined) { 257 | promise = result; 258 | } 259 | 260 | await promise; 261 | 262 | jest.advanceTimersByTime(9); 263 | 264 | const entry = cache.get(options.cacheKey); 265 | 266 | expect(entry?.result).toBe('loaded'); 267 | expect(entry?.promise).toBe(promise); 268 | expect(entry?.timeout).not.toBeUndefined(); 269 | expect(entry?.error).toBeUndefined(); 270 | 271 | entry?.dispose(); 272 | 273 | jest.advanceTimersByTime(9); 274 | 275 | expect(cache.get(options.cacheKey)).toBe(entry); 276 | 277 | expect(loader).toHaveBeenCalledTimes(1); 278 | }); 279 | }); 280 | -------------------------------------------------------------------------------- /src/hooks/Cache.ts: -------------------------------------------------------------------------------- 1 | export type EntryLoader = (...args: any) => Promise; 2 | 3 | export type EntryOptions = { 4 | cacheKey: string, 5 | loader: EntryLoader, 6 | fallback?: R, 7 | expiration?: number, 8 | }; 9 | 10 | type Entry = { 11 | promise: Promise, 12 | result?: R, 13 | dispose: () => void, 14 | timeout?: number, 15 | error?: any, 16 | }; 17 | 18 | /** 19 | * @internal 20 | */ 21 | export class Cache { 22 | private readonly cache: Record = {}; 23 | 24 | private readonly defaultExpiration: number; 25 | 26 | public constructor(defaultExpiration: number) { 27 | this.defaultExpiration = defaultExpiration; 28 | } 29 | 30 | public load(configuration: EntryOptions): R { 31 | const {cacheKey, loader, fallback, expiration = this.defaultExpiration} = configuration; 32 | 33 | const cachedEntry = this.get(cacheKey); 34 | 35 | if (cachedEntry !== undefined) { 36 | if (cachedEntry.error !== undefined) { 37 | if (fallback !== undefined) { 38 | return fallback; 39 | } 40 | 41 | if (cachedEntry.result === undefined) { 42 | throw cachedEntry.error; 43 | } 44 | } 45 | 46 | if (cachedEntry.result !== undefined) { 47 | return cachedEntry.result; 48 | } 49 | 50 | throw cachedEntry.promise; 51 | } 52 | 53 | const entry: Entry = { 54 | dispose: () => { 55 | if (entry.timeout !== undefined || expiration < 0) { 56 | return; 57 | } 58 | 59 | entry.timeout = window.setTimeout( 60 | (): void => { 61 | delete this.cache[cacheKey]; 62 | }, 63 | expiration, 64 | ); 65 | }, 66 | promise: loader() 67 | .then((result): R => { 68 | entry.result = result; 69 | 70 | return result; 71 | }) 72 | .catch(error => { 73 | entry.result = fallback; 74 | entry.error = error; 75 | 76 | return fallback; 77 | }) 78 | .finally(() => { 79 | entry.dispose(); 80 | }), 81 | }; 82 | 83 | this.cache[cacheKey] = entry; 84 | 85 | throw entry.promise; 86 | } 87 | 88 | public get(cacheKey: string): Entry|undefined { 89 | const entry = this.cache[cacheKey]; 90 | 91 | if (entry === undefined) { 92 | return undefined; 93 | } 94 | 95 | if (entry.timeout !== undefined) { 96 | clearTimeout(entry.timeout); 97 | 98 | delete entry.timeout; 99 | 100 | entry.dispose(); 101 | } 102 | 103 | return entry; 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from './useEvaluation'; 2 | export * from './useContent'; 3 | export * from './useCroct'; 4 | -------------------------------------------------------------------------------- /src/hooks/useContent.d.test.tsx: -------------------------------------------------------------------------------- 1 | import {join as pathJoin} from 'path'; 2 | import {create} from 'ts-node'; 3 | 4 | const tsService = create({ 5 | cwd: __dirname, 6 | transpileOnly: false, 7 | }); 8 | 9 | const testFilename = pathJoin(__dirname, 'test.tsx'); 10 | 11 | describe('useContent typing', () => { 12 | const header = ` 13 | import {useContent} from './useContent'; 14 | `; 15 | 16 | const slotMapping = ` 17 | type HomeBanner = { 18 | title: string, 19 | subtitle: string, 20 | }; 21 | 22 | type Banner = { 23 | title: string, 24 | subtitle: string, 25 | }; 26 | 27 | type Carousel = { 28 | title: string, 29 | subtitle: string, 30 | }; 31 | 32 | declare module '@croct/plug/slot' { 33 | type HomeBannerV1 = HomeBanner & {_component: 'banner@v1' | null}; 34 | 35 | interface VersionedSlotMap { 36 | 'home-banner': { 37 | 'latest': HomeBannerV1, 38 | '1': HomeBannerV1, 39 | }; 40 | } 41 | } 42 | 43 | declare module '@croct/plug/component' { 44 | interface VersionedComponentMap { 45 | 'banner': { 46 | 'latest': Banner, 47 | '1': Banner, 48 | }; 49 | 'carousel': { 50 | 'latest': Carousel, 51 | '1': Carousel, 52 | }; 53 | } 54 | } 55 | `; 56 | 57 | type CodeOptions = { 58 | code: string, 59 | mapping: boolean, 60 | }; 61 | 62 | type AssembledCode = { 63 | code: string, 64 | codePosition: number, 65 | }; 66 | 67 | function assembleCode({code, mapping}: CodeOptions): AssembledCode { 68 | const prefix = mapping 69 | ? header + slotMapping 70 | : header; 71 | 72 | return { 73 | code: prefix + code.trim(), 74 | codePosition: prefix.length + 1, 75 | }; 76 | } 77 | 78 | function compileCode(opts: CodeOptions): void { 79 | tsService.compile(assembleCode(opts).code, testFilename); 80 | } 81 | 82 | function getTypeName(opts: CodeOptions): string { 83 | const assembledCode = assembleCode(opts); 84 | 85 | const info = tsService.getTypeInfo(assembledCode.code, testFilename, assembledCode.codePosition); 86 | 87 | const match = info.name.match(/^\(alias\) (useContent<.+?>)/s); 88 | 89 | if (match !== null) { 90 | return match[1].replace(/\s*\n\s*/g, ''); 91 | } 92 | 93 | return info.name; 94 | } 95 | 96 | function getReturnType(opts: CodeOptions): string { 97 | const assembledCode = assembleCode(opts); 98 | 99 | const info = tsService.getTypeInfo(assembledCode.code, testFilename, assembledCode.codePosition); 100 | 101 | const match = info.name.match(/\): (.+?)(?: \(\+.+\))\nimport useContent$/s); 102 | 103 | if (match !== null) { 104 | return match[1].replace(/\s*\n\s*/g, ''); 105 | } 106 | 107 | return info.name; 108 | } 109 | 110 | it('should define the return type as a JSON object by default for unmapped slots', () => { 111 | const code: CodeOptions = { 112 | code: ` 113 | useContent('home-banner'); 114 | `, 115 | mapping: false, 116 | }; 117 | 118 | expect(() => compileCode(code)).not.toThrow(); 119 | 120 | expect(getTypeName(code)).toBe( 121 | 'useContent', 122 | ); 123 | 124 | expect(getReturnType(code)).toBe('JsonObject'); 125 | }); 126 | 127 | it('should define the return type as an union of component for unknown slots', () => { 128 | const code: CodeOptions = { 129 | code: ` 130 | useContent('dynamic-id' as any); 131 | `, 132 | mapping: true, 133 | }; 134 | 135 | expect(() => compileCode(code)).not.toThrow(); 136 | 137 | expect(getTypeName(code)).toBe( 138 | 'useContent', 139 | ); 140 | 141 | expect(getReturnType(code)).toBe( 142 | '(Banner & {_component: "banner@1" | null;}) | (Carousel & {...;})', 143 | ); 144 | }); 145 | 146 | it('should include the type of the initial value on the return type for unmapped slots', () => { 147 | const code: CodeOptions = { 148 | code: ` 149 | useContent('home-banner', {initial: true}); 150 | `, 151 | mapping: false, 152 | }; 153 | 154 | expect(() => compileCode(code)).not.toThrow(); 155 | 156 | expect(getTypeName(code)).toBe( 157 | 'useContent', 158 | ); 159 | 160 | expect(getReturnType(code)).toBe('boolean | JsonObject'); 161 | }); 162 | 163 | it('should include the type of the fallback value on the return type for unmapped slots', () => { 164 | const code: CodeOptions = { 165 | code: ` 166 | useContent('home-banner', {fallback: 1}); 167 | `, 168 | mapping: false, 169 | }; 170 | 171 | expect(() => compileCode(code)).not.toThrow(); 172 | 173 | expect(getTypeName(code)).toBe( 174 | 'useContent', 175 | ); 176 | 177 | expect(getReturnType(code)).toBe('number | JsonObject'); 178 | }); 179 | 180 | it('should include the types of both the initial and fallback values on the return type for unmapped slots', () => { 181 | const code: CodeOptions = { 182 | code: ` 183 | useContent('home-banner', {initial: true, fallback: 1}); 184 | `, 185 | mapping: false, 186 | }; 187 | 188 | expect(() => compileCode(code)).not.toThrow(); 189 | 190 | expect(getTypeName(code)).toBe( 191 | 'useContent', 192 | ); 193 | 194 | expect(getReturnType(code)).toBe('number | ... 1 more ... | JsonObject'); 195 | }); 196 | 197 | it('should allow narrowing the return type for unmapped slots', () => { 198 | const code: CodeOptions = { 199 | code: ` 200 | useContent<{foo: string}>('home-banner'); 201 | `, 202 | mapping: false, 203 | }; 204 | 205 | expect(() => compileCode(code)).not.toThrow(); 206 | 207 | expect(getTypeName(code)).toBe( 208 | 'useContent<{foo: string;}, {foo: string;}, {foo: string;}>', 209 | ); 210 | 211 | expect(getReturnType(code)).toBe('{foo: string;}'); 212 | }); 213 | 214 | it('should allow specifying the initial value type for mapped slots', () => { 215 | const code: CodeOptions = { 216 | code: ` 217 | useContent<{foo: string}, boolean>('home-banner', {initial: true}); 218 | `, 219 | mapping: false, 220 | }; 221 | 222 | expect(() => compileCode(code)).not.toThrow(); 223 | 224 | expect(getTypeName(code)).toBe( 225 | 'useContent<{foo: string;}, boolean, {foo: string;}>', 226 | ); 227 | 228 | expect(getReturnType(code)).toBe('boolean | {foo: string;}'); 229 | }); 230 | 231 | it('should allow specifying the fallback value type for mapped slots', () => { 232 | const code: CodeOptions = { 233 | code: ` 234 | useContent<{foo: string}, never, number>('home-banner', {fallback: 1}); 235 | `, 236 | mapping: false, 237 | }; 238 | 239 | expect(() => compileCode(code)).not.toThrow(); 240 | 241 | expect(getTypeName(code)).toBe( 242 | 'useContent<{foo: string;}, never, number>', 243 | ); 244 | 245 | expect(getReturnType(code)).toBe('number | {foo: string;}'); 246 | }); 247 | 248 | it('show allow specifying the initial and fallback value types for mapped slots', () => { 249 | const code: CodeOptions = { 250 | code: ` 251 | useContent<{foo: string}, boolean, number>('home-banner', {initial: true, fallback: 1}); 252 | `, 253 | mapping: false, 254 | }; 255 | 256 | expect(() => compileCode(code)).not.toThrow(); 257 | 258 | expect(getTypeName(code)).toBe( 259 | 'useContent<{foo: string;}, boolean, number>', 260 | ); 261 | 262 | expect(getReturnType(code)).toBe('number | ... 1 more ... | {foo: string;}'); 263 | }); 264 | 265 | it('should require specifying JSON object as return type for mapped slots', () => { 266 | const code: CodeOptions = { 267 | code: ` 268 | useContent('home-banner'); 269 | `, 270 | mapping: false, 271 | }; 272 | 273 | expect(() => compileCode(code)).toThrow(); 274 | }); 275 | 276 | it('should infer the return type for mapped slots', () => { 277 | const code: CodeOptions = { 278 | code: ` 279 | useContent('home-banner'); 280 | `, 281 | mapping: true, 282 | }; 283 | 284 | expect(() => compileCode(code)).not.toThrow(); 285 | 286 | expect(getTypeName(code)).toBe('useContent<"home-banner">'); 287 | 288 | expect(getReturnType(code)).toBe('HomeBannerV1'); 289 | }); 290 | 291 | it('should include the type of the initial value on the return type for mapped slots', () => { 292 | const code: CodeOptions = { 293 | code: ` 294 | useContent('home-banner', {initial: true}); 295 | `, 296 | mapping: true, 297 | }; 298 | 299 | expect(() => compileCode(code)).not.toThrow(); 300 | 301 | expect(getTypeName(code)).toBe('useContent'); 302 | 303 | expect(getReturnType(code)).toBe('boolean | HomeBannerV1'); 304 | }); 305 | 306 | it('should include the type of the fallback value on the return type for mapped slots', () => { 307 | const code: CodeOptions = { 308 | code: ` 309 | useContent('home-banner', {fallback: 1}); 310 | `, 311 | mapping: true, 312 | }; 313 | 314 | expect(() => compileCode(code)).not.toThrow(); 315 | 316 | expect(getTypeName(code)).toBe('useContent'); 317 | 318 | expect(getReturnType(code)).toBe('number | HomeBannerV1'); 319 | }); 320 | 321 | it('should include the types of both the initial and fallback values on the return type for mapped slots', () => { 322 | const code: CodeOptions = { 323 | code: ` 324 | useContent('home-banner', {initial: true, fallback: 1}); 325 | `, 326 | mapping: true, 327 | }; 328 | 329 | expect(() => compileCode(code)).not.toThrow(); 330 | 331 | expect(getTypeName(code)).toBe('useContent'); 332 | 333 | expect(getReturnType(code)).toBe('number | boolean | HomeBannerV1'); 334 | }); 335 | 336 | it('should not allow overriding the return type for mapped slots', () => { 337 | const code: CodeOptions = { 338 | code: ` 339 | useContent<{title: string}>('home-banner'); 340 | `, 341 | mapping: true, 342 | }; 343 | 344 | expect(() => compileCode(code)).toThrow(); 345 | }); 346 | }); 347 | -------------------------------------------------------------------------------- /src/hooks/useContent.ssr.test.ts: -------------------------------------------------------------------------------- 1 | import {renderHook} from '@testing-library/react'; 2 | import {getSlotContent} from '@croct/content'; 3 | import {useContent} from './useContent'; 4 | 5 | jest.mock( 6 | '../ssr-polyfills', 7 | () => ({ 8 | __esModule: true, 9 | isSsr: (): boolean => true, 10 | }), 11 | ); 12 | 13 | jest.mock( 14 | '@croct/content', 15 | () => ({ 16 | __esModule: true, 17 | getSlotContent: jest.fn().mockReturnValue(null), 18 | }), 19 | ); 20 | 21 | describe('useContent (SSR)', () => { 22 | beforeEach(() => { 23 | jest.clearAllMocks(); 24 | }); 25 | 26 | it('should render the initial value on the server-side', () => { 27 | const {result} = renderHook(() => useContent('slot-id', {initial: 'foo'})); 28 | 29 | expect(result.current).toBe('foo'); 30 | }); 31 | 32 | it('should require an initial value for server-side rending', () => { 33 | expect(() => useContent('slot-id')) 34 | .toThrow('The initial content is required for server-side rendering (SSR).'); 35 | }); 36 | 37 | it('should use the default content as initial value on the server-side if not provided', () => { 38 | const content = {foo: 'bar'}; 39 | const slotId = 'slot-id'; 40 | const preferredLocale = 'en'; 41 | 42 | jest.mocked(getSlotContent).mockReturnValue(content); 43 | 44 | const {result} = renderHook(() => useContent(slotId, {preferredLocale: preferredLocale})); 45 | 46 | expect(getSlotContent).toHaveBeenCalledWith(slotId, preferredLocale); 47 | 48 | expect(result.current).toBe(content); 49 | }); 50 | 51 | it('should use the provided initial value on the server-side', () => { 52 | const initial = null; 53 | const slotId = 'slot-id'; 54 | const preferredLocale = 'en'; 55 | 56 | jest.mocked(getSlotContent).mockReturnValue(null); 57 | 58 | const {result} = renderHook( 59 | () => useContent(slotId, { 60 | preferredLocale: preferredLocale, 61 | initial: initial, 62 | }), 63 | ); 64 | 65 | expect(result.current).toBe(initial); 66 | }); 67 | 68 | it('should normalize an empty preferred locale to undefined', () => { 69 | const slotId = 'slot-id'; 70 | const preferredLocale = ''; 71 | 72 | jest.mocked(getSlotContent).mockReturnValue({ 73 | foo: 'bar', 74 | }); 75 | 76 | renderHook( 77 | () => useContent(slotId, { 78 | preferredLocale: preferredLocale, 79 | }), 80 | ); 81 | 82 | expect(getSlotContent).toHaveBeenCalledWith(slotId, undefined); 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /src/hooks/useContent.test.ts: -------------------------------------------------------------------------------- 1 | import {renderHook, waitFor} from '@testing-library/react'; 2 | import {getSlotContent} from '@croct/content'; 3 | import {Plug} from '@croct/plug'; 4 | import {useCroct} from './useCroct'; 5 | import {useLoader} from './useLoader'; 6 | import {useContent} from './useContent'; 7 | import {hash} from '../hash'; 8 | 9 | jest.mock( 10 | './useCroct', 11 | () => ({ 12 | useCroct: jest.fn(), 13 | }), 14 | ); 15 | 16 | jest.mock( 17 | './useLoader', 18 | () => ({ 19 | useLoader: jest.fn(), 20 | }), 21 | ); 22 | 23 | jest.mock( 24 | '@croct/content', 25 | () => ({ 26 | __esModule: true, 27 | getSlotContent: jest.fn().mockReturnValue(null), 28 | }), 29 | ); 30 | 31 | describe('useContent (CSR)', () => { 32 | beforeEach(() => { 33 | jest.resetAllMocks(); 34 | }); 35 | 36 | it('should fetch the content', () => { 37 | const fetch: Plug['fetch'] = jest.fn().mockResolvedValue({ 38 | content: {}, 39 | }); 40 | 41 | jest.mocked(useCroct).mockReturnValue({fetch: fetch} as Plug); 42 | jest.mocked(useLoader).mockReturnValue({ 43 | title: 'foo', 44 | }); 45 | 46 | const slotId = 'home-banner@1'; 47 | const preferredLocale = 'en'; 48 | const attributes = {example: 'value'}; 49 | const cacheKey = 'unique'; 50 | 51 | const {result} = renderHook( 52 | () => useContent<{title: string}>(slotId, { 53 | preferredLocale: preferredLocale, 54 | attributes: attributes, 55 | cacheKey: cacheKey, 56 | fallback: { 57 | title: 'error', 58 | }, 59 | expiration: 50, 60 | }), 61 | ); 62 | 63 | expect(useCroct).toHaveBeenCalled(); 64 | expect(useLoader).toHaveBeenCalledWith({ 65 | cacheKey: hash(`useContent:${cacheKey}:${slotId}:${preferredLocale}:${JSON.stringify(attributes)}`), 66 | expiration: 50, 67 | loader: expect.any(Function), 68 | }); 69 | 70 | jest.mocked(useLoader) 71 | .mock 72 | .calls[0][0] 73 | .loader(); 74 | 75 | expect(fetch).toHaveBeenCalledWith(slotId, { 76 | fallback: {title: 'error'}, 77 | preferredLocale: 'en', 78 | attributes: attributes, 79 | }); 80 | 81 | expect(result.current).toEqual({title: 'foo'}); 82 | }); 83 | 84 | it('should use the initial value when the cache key changes if the stale-while-loading flag is false', async () => { 85 | const key = { 86 | current: 'initial', 87 | }; 88 | 89 | const fetch: Plug['fetch'] = jest.fn().mockResolvedValue({content: {}}); 90 | 91 | jest.mocked(useCroct).mockReturnValue({fetch: fetch} as Plug); 92 | 93 | jest.mocked(useLoader).mockImplementation( 94 | () => ({title: key.current === 'initial' ? 'first' : 'second'}), 95 | ); 96 | 97 | const slotId = 'home-banner@1'; 98 | 99 | const {result, rerender} = renderHook( 100 | () => useContent<{title: string}>(slotId, { 101 | cacheKey: key.current, 102 | initial: { 103 | title: 'initial', 104 | }, 105 | }), 106 | ); 107 | 108 | expect(useCroct).toHaveBeenCalled(); 109 | 110 | expect(useLoader).toHaveBeenCalledWith(expect.objectContaining({ 111 | cacheKey: hash(`useContent:${key.current}:${slotId}::${JSON.stringify({})}`), 112 | initial: { 113 | title: 'initial', 114 | }, 115 | })); 116 | 117 | await waitFor(() => expect(result.current).toEqual({title: 'first'})); 118 | 119 | key.current = 'next'; 120 | 121 | rerender(); 122 | 123 | expect(useLoader).toHaveBeenCalledWith(expect.objectContaining({ 124 | cacheKey: hash(`useContent:${key.current}:${slotId}::${JSON.stringify({})}`), 125 | initial: { 126 | title: 'initial', 127 | }, 128 | })); 129 | 130 | await waitFor(() => expect(result.current).toEqual({title: 'second'})); 131 | }); 132 | 133 | it('should use the last fetched content as initial value if the stale-while-loading flag is true', async () => { 134 | const key = { 135 | current: 'initial', 136 | }; 137 | 138 | const fetch: Plug['fetch'] = jest.fn().mockResolvedValue({content: {}}); 139 | 140 | jest.mocked(useCroct).mockReturnValue({fetch: fetch} as Plug); 141 | 142 | const firstResult = { 143 | title: 'first', 144 | }; 145 | 146 | const secondResult = { 147 | title: 'second', 148 | }; 149 | 150 | jest.mocked(useLoader).mockImplementation( 151 | () => (key.current === 'initial' ? firstResult : secondResult), 152 | ); 153 | 154 | const slotId = 'home-banner@1'; 155 | 156 | const {result, rerender} = renderHook( 157 | () => useContent<{title: string}>(slotId, { 158 | cacheKey: key.current, 159 | initial: { 160 | title: 'initial', 161 | }, 162 | staleWhileLoading: true, 163 | }), 164 | ); 165 | 166 | expect(useCroct).toHaveBeenCalled(); 167 | 168 | expect(useLoader).toHaveBeenCalledWith(expect.objectContaining({ 169 | cacheKey: hash(`useContent:${key.current}:${slotId}::${JSON.stringify({})}`), 170 | initial: { 171 | title: 'initial', 172 | }, 173 | })); 174 | 175 | await waitFor(() => expect(result.current).toEqual({title: 'first'})); 176 | 177 | key.current = 'next'; 178 | 179 | rerender(); 180 | 181 | expect(useLoader).toHaveBeenCalledWith(expect.objectContaining({ 182 | cacheKey: hash(`useContent:${key.current}:${slotId}::${JSON.stringify({})}`), 183 | initial: { 184 | title: 'first', 185 | }, 186 | })); 187 | 188 | await waitFor(() => expect(result.current).toEqual({title: 'second'})); 189 | }); 190 | 191 | it('should use the default content as initial value if not provided', () => { 192 | const content = {foo: 'bar'}; 193 | const slotId = 'slot-id'; 194 | const preferredLocale = 'en'; 195 | 196 | jest.mocked(getSlotContent).mockReturnValue(content); 197 | 198 | renderHook(() => useContent(slotId, {preferredLocale: preferredLocale})); 199 | 200 | expect(getSlotContent).toHaveBeenCalledWith(slotId, preferredLocale); 201 | 202 | expect(useLoader).toHaveBeenCalledWith( 203 | expect.objectContaining({ 204 | initial: content, 205 | }), 206 | ); 207 | }); 208 | 209 | it('should use the provided initial value', () => { 210 | const initial = null; 211 | const slotId = 'slot-id'; 212 | const preferredLocale = 'en'; 213 | 214 | jest.mocked(getSlotContent).mockReturnValue(null); 215 | 216 | renderHook( 217 | () => useContent(slotId, { 218 | preferredLocale: preferredLocale, 219 | initial: initial, 220 | }), 221 | ); 222 | 223 | expect(useLoader).toHaveBeenCalledWith( 224 | expect.objectContaining({ 225 | initial: initial, 226 | }), 227 | ); 228 | }); 229 | 230 | it('should use the default content as fallback value if not provided', () => { 231 | const content = {foo: 'bar'}; 232 | const slotId = 'slot-id'; 233 | const preferredLocale = 'en'; 234 | 235 | const fetch: Plug['fetch'] = jest.fn().mockResolvedValue({ 236 | content: {}, 237 | }); 238 | 239 | jest.mocked(useCroct).mockReturnValue({fetch: fetch} as Plug); 240 | 241 | jest.mocked(getSlotContent).mockReturnValue(content); 242 | 243 | renderHook( 244 | () => useContent(slotId, { 245 | preferredLocale: preferredLocale, 246 | fallback: content, 247 | }), 248 | ); 249 | 250 | expect(getSlotContent).toHaveBeenCalledWith(slotId, preferredLocale); 251 | 252 | jest.mocked(useLoader) 253 | .mock 254 | .calls[0][0] 255 | .loader(); 256 | 257 | expect(fetch).toHaveBeenCalledWith(slotId, { 258 | fallback: content, 259 | preferredLocale: preferredLocale, 260 | }); 261 | }); 262 | 263 | it('should use the provided fallback value', () => { 264 | const fallback = null; 265 | const slotId = 'slot-id'; 266 | const preferredLocale = 'en'; 267 | 268 | const fetch: Plug['fetch'] = jest.fn().mockResolvedValue({ 269 | content: {}, 270 | }); 271 | 272 | jest.mocked(useCroct).mockReturnValue({fetch: fetch} as Plug); 273 | 274 | jest.mocked(getSlotContent).mockReturnValue(null); 275 | 276 | renderHook( 277 | () => useContent(slotId, { 278 | preferredLocale: preferredLocale, 279 | fallback: fallback, 280 | }), 281 | ); 282 | 283 | jest.mocked(useLoader) 284 | .mock 285 | .calls[0][0] 286 | .loader(); 287 | 288 | expect(fetch).toHaveBeenCalledWith(slotId, { 289 | fallback: fallback, 290 | preferredLocale: preferredLocale, 291 | }); 292 | }); 293 | 294 | it('should normalize an empty preferred locale to undefined', () => { 295 | const fetch: Plug['fetch'] = jest.fn().mockResolvedValue({ 296 | content: {}, 297 | }); 298 | 299 | jest.mocked(useCroct).mockReturnValue({fetch: fetch} as Plug); 300 | 301 | renderHook( 302 | () => useContent('slot-id', { 303 | preferredLocale: '', 304 | }), 305 | ); 306 | 307 | jest.mocked(useLoader) 308 | .mock 309 | .calls[0][0] 310 | .loader(); 311 | 312 | expect(jest.mocked(fetch).mock.calls[0][1]).toStrictEqual({}); 313 | }); 314 | }); 315 | -------------------------------------------------------------------------------- /src/hooks/useContent.ts: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import {SlotContent, VersionedSlotId, VersionedSlotMap} from '@croct/plug/slot'; 4 | import {JsonObject} from '@croct/plug/sdk/json'; 5 | import {FetchOptions} from '@croct/plug/plug'; 6 | import {useEffect, useMemo, useState} from 'react'; 7 | import {getSlotContent} from '@croct/content'; 8 | import {useLoader} from './useLoader'; 9 | import {useCroct} from './useCroct'; 10 | import {isSsr} from '../ssr-polyfills'; 11 | import {hash} from '../hash'; 12 | 13 | export type UseContentOptions = FetchOptions & { 14 | initial?: I, 15 | cacheKey?: string, 16 | expiration?: number, 17 | staleWhileLoading?: boolean, 18 | }; 19 | 20 | function useCsrContent( 21 | id: VersionedSlotId, 22 | options: UseContentOptions = {}, 23 | ): SlotContent | I | F { 24 | const { 25 | cacheKey, 26 | expiration, 27 | fallback: fallbackContent, 28 | initial: initialContent, 29 | staleWhileLoading = false, 30 | preferredLocale, 31 | ...fetchOptions 32 | } = options; 33 | 34 | const normalizedLocale = normalizePreferredLocale(preferredLocale); 35 | const defaultContent = useMemo( 36 | () => getSlotContent(id, normalizedLocale) as SlotContent|null ?? undefined, 37 | [id, normalizedLocale], 38 | ); 39 | const fallback = fallbackContent === undefined ? defaultContent : fallbackContent; 40 | const [initial, setInitial] = useState( 41 | () => (initialContent === undefined ? defaultContent : initialContent), 42 | ); 43 | 44 | const croct = useCroct(); 45 | 46 | const result: SlotContent | I | F = useLoader({ 47 | cacheKey: hash( 48 | `useContent:${cacheKey ?? ''}` 49 | + `:${id}` 50 | + `:${normalizedLocale ?? ''}` 51 | + `:${JSON.stringify(fetchOptions.attributes ?? {})}`, 52 | ), 53 | loader: () => croct.fetch(id, { 54 | ...fetchOptions, 55 | ...(normalizedLocale !== undefined ? {preferredLocale: normalizedLocale} : {}), 56 | ...(fallback !== undefined ? {fallback: fallback} : {}), 57 | }).then(({content}) => content), 58 | initial: initial, 59 | expiration: expiration, 60 | }); 61 | 62 | useEffect( 63 | () => { 64 | if (staleWhileLoading) { 65 | setInitial(current => { 66 | if (current !== result) { 67 | return result; 68 | } 69 | 70 | return current; 71 | }); 72 | } 73 | }, 74 | [result, staleWhileLoading], 75 | ); 76 | 77 | return result; 78 | } 79 | 80 | function useSsrContent( 81 | slotId: VersionedSlotId, 82 | {initial, preferredLocale}: UseContentOptions = {}, 83 | ): SlotContent | I | F { 84 | const resolvedInitialContent = initial === undefined 85 | ? getSlotContent(slotId, normalizePreferredLocale(preferredLocale)) as I|null ?? undefined 86 | : initial; 87 | 88 | if (resolvedInitialContent === undefined) { 89 | throw new Error( 90 | 'The initial content is required for server-side rendering (SSR). ' 91 | + 'For help, see https://croct.help/sdk/react/missing-slot-content', 92 | ); 93 | } 94 | 95 | return resolvedInitialContent; 96 | } 97 | 98 | function normalizePreferredLocale(preferredLocale: string|undefined): string|undefined { 99 | return preferredLocale !== undefined && preferredLocale !== '' ? preferredLocale : undefined; 100 | } 101 | 102 | type UseContentHook = { 103 |

( 104 | id: keyof VersionedSlotMap extends never ? string : never, 105 | options?: UseContentOptions 106 | ): P | I | F, 107 | 108 | ( 109 | id: S, 110 | options?: UseContentOptions 111 | ): SlotContent, 112 | 113 | ( 114 | id: S, 115 | options?: UseContentOptions 116 | ): SlotContent | I, 117 | 118 | ( 119 | id: S, 120 | options?: UseContentOptions 121 | ): SlotContent | F, 122 | 123 | ( 124 | id: S, 125 | options?: UseContentOptions 126 | ): SlotContent | I | F, 127 | }; 128 | 129 | export const useContent: UseContentHook = isSsr() ? useSsrContent : useCsrContent; 130 | -------------------------------------------------------------------------------- /src/hooks/useCroct.ssr.test.tsx: -------------------------------------------------------------------------------- 1 | import {renderHook} from '@testing-library/react'; 2 | import {useCroct} from './useCroct'; 3 | import {CroctProvider} from '../CroctProvider'; 4 | 5 | jest.mock( 6 | '../ssr-polyfills', 7 | () => ({ 8 | __esModule: true, 9 | ...jest.requireActual('../ssr-polyfills'), 10 | isSsr: (): boolean => true, 11 | }), 12 | ); 13 | 14 | describe('useCroct', () => { 15 | it('should not fail on server-side rendering', () => { 16 | const {result} = renderHook(() => useCroct(), { 17 | wrapper: ({children}) => ( 18 | 19 | {children} 20 | 21 | ), 22 | }); 23 | 24 | expect(result).not.toBeUndefined(); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /src/hooks/useCroct.test.tsx: -------------------------------------------------------------------------------- 1 | import croct from '@croct/plug'; 2 | import {renderHook} from '@testing-library/react'; 3 | import {useCroct} from './useCroct'; 4 | import {CroctContext} from '../CroctProvider'; 5 | 6 | describe('useCroct', () => { 7 | it('should fail if used out of the component', () => { 8 | expect(() => renderHook(() => useCroct())) 9 | .toThrow('useCroct() can only be used in the context of a component.'); 10 | }); 11 | 12 | it('should return the Plug instance', () => { 13 | const {result} = renderHook(() => useCroct(), { 14 | wrapper: ({children}) => ( 15 | {children} 16 | ), 17 | }); 18 | 19 | expect(result.current).toBe(croct); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/hooks/useCroct.ts: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import {Plug} from '@croct/plug'; 4 | import {useContext} from 'react'; 5 | import {CroctContext} from '../CroctProvider'; 6 | 7 | export function useCroct(): Plug { 8 | const context = useContext(CroctContext); 9 | 10 | if (context === null) { 11 | throw new Error( 12 | 'useCroct() can only be used in the context of a component. ' 13 | + 'For help, see https://croct.help/sdk/react/missing-provider', 14 | ); 15 | } 16 | 17 | return context.plug; 18 | } 19 | -------------------------------------------------------------------------------- /src/hooks/useEvaluation.d.test.tsx: -------------------------------------------------------------------------------- 1 | import {join as pathJoin} from 'path'; 2 | import {create} from 'ts-node'; 3 | 4 | const tsService = create({ 5 | cwd: __dirname, 6 | transpileOnly: false, 7 | }); 8 | 9 | const testFilename = pathJoin(__dirname, 'test.tsx'); 10 | 11 | describe('useEvaluation typing', () => { 12 | const header = ` 13 | import {useEvaluation} from './useEvaluation'; 14 | `; 15 | 16 | function compileCode(code: string): void { 17 | tsService.compile(header + code, testFilename); 18 | } 19 | 20 | function getTypeName(code: string): string { 21 | const info = tsService.getTypeInfo(header + code.trim(), testFilename, header.length + 1); 22 | 23 | const match = info.name.match(/^\(alias\) (useEvaluation<.+?>)/s); 24 | 25 | if (match !== null) { 26 | return match[1].replace(/\s*\n\s*/g, ''); 27 | } 28 | 29 | return info.name; 30 | } 31 | 32 | function getReturnType(code: string): string { 33 | const info = tsService.getTypeInfo(header + code.trim(), testFilename, header.length + 1); 34 | 35 | const match = info.name.match(/\): (.+?)(?: \(\+.+\))?\nimport useEvaluation$/s); 36 | 37 | if (match !== null) { 38 | return match[1].replace(/\s*\n\s*/g, ''); 39 | } 40 | 41 | return info.name; 42 | } 43 | 44 | it('should define the return type as a JSON object by default', () => { 45 | const code = ` 46 | useEvaluation('x'); 47 | `; 48 | 49 | expect(() => compileCode(code)).not.toThrow(); 50 | 51 | expect(getTypeName(code)).toBe('useEvaluation'); 52 | 53 | expect(getReturnType(code)).toBe('JsonValue'); 54 | }); 55 | 56 | it('should allow narrowing the return type', () => { 57 | const code = ` 58 | useEvaluation('x'); 59 | `; 60 | 61 | expect(() => compileCode(code)).not.toThrow(); 62 | 63 | expect(getTypeName(code)).toBe('useEvaluation'); 64 | 65 | expect(getReturnType(code)).toBe('string'); 66 | }); 67 | 68 | it('should include the type of the initial value on the return type', () => { 69 | const code = ` 70 | useEvaluation('x', {initial: undefined}); 71 | `; 72 | 73 | expect(() => compileCode(code)).not.toThrow(); 74 | 75 | expect(getTypeName(code)).toBe('useEvaluation'); 76 | 77 | expect(getReturnType(code)).toBe('JsonValue | undefined'); 78 | }); 79 | 80 | it('should include the type of the fallback value on the return type', () => { 81 | const code = ` 82 | useEvaluation('x', {fallback: new Error()}); 83 | `; 84 | 85 | expect(() => compileCode(code)).not.toThrow(); 86 | 87 | expect(getTypeName(code)).toBe('useEvaluation'); 88 | 89 | expect(getReturnType(code)).toBe('Error | JsonValue'); 90 | }); 91 | 92 | it('should include the types of both the initial and fallback values on the return type', () => { 93 | const code = ` 94 | useEvaluation('x', {initial: undefined, fallback: new Error()}); 95 | `; 96 | 97 | expect(() => compileCode(code)).not.toThrow(); 98 | 99 | expect(getTypeName(code)).toBe('useEvaluation'); 100 | 101 | expect(getReturnType(code)).toBe('Error | JsonValue | undefined'); 102 | }); 103 | 104 | it('should allow specifying the type of the initial and fallback values', () => { 105 | const code = ` 106 | useEvaluation('x', {initial: undefined, fallback: new Error()}); 107 | `; 108 | 109 | expect(() => compileCode(code)).not.toThrow(); 110 | 111 | expect(getTypeName(code)).toBe('useEvaluation'); 112 | 113 | expect(getReturnType(code)).toBe('string | Error | undefined'); 114 | }); 115 | 116 | it('should require specifying a JSON value as return type', () => { 117 | const code = ` 118 | useEvaluation('x'); 119 | `; 120 | 121 | expect(() => compileCode(code)).toThrow(); 122 | }); 123 | }); 124 | -------------------------------------------------------------------------------- /src/hooks/useEvaluation.ssr.test.ts: -------------------------------------------------------------------------------- 1 | import {renderHook} from '@testing-library/react'; 2 | import {useEvaluation} from './useEvaluation'; 3 | 4 | jest.mock( 5 | '../ssr-polyfills', 6 | () => ({ 7 | __esModule: true, 8 | isSsr: (): boolean => true, 9 | }), 10 | ); 11 | 12 | describe('useEvaluation (SSR)', () => { 13 | it('should render the initial value on the server-side', () => { 14 | const {result} = renderHook(() => useEvaluation('location', {initial: 'foo'})); 15 | 16 | expect(result.current).toBe('foo'); 17 | }); 18 | 19 | it('should require an initial value for server-side rending', () => { 20 | expect(() => useEvaluation('location')) 21 | .toThrow('The initial value is required for server-side rendering (SSR).'); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/hooks/useEvaluation.test.ts: -------------------------------------------------------------------------------- 1 | import {renderHook, waitFor} from '@testing-library/react'; 2 | import {EvaluationOptions} from '@croct/sdk/facade/evaluatorFacade'; 3 | import {Plug} from '@croct/plug'; 4 | import {useEvaluation} from './useEvaluation'; 5 | import {useCroct} from './useCroct'; 6 | import {useLoader} from './useLoader'; 7 | import {hash} from '../hash'; 8 | 9 | jest.mock( 10 | './useCroct', 11 | () => ({ 12 | useCroct: jest.fn(), 13 | }), 14 | ); 15 | 16 | jest.mock( 17 | './useLoader', 18 | () => ({ 19 | useLoader: jest.fn(), 20 | }), 21 | ); 22 | 23 | describe('useEvaluation', () => { 24 | beforeEach(() => { 25 | jest.resetAllMocks(); 26 | }); 27 | 28 | it('should evaluate a query', () => { 29 | const evaluationOptions: EvaluationOptions = { 30 | timeout: 100, 31 | attributes: { 32 | foo: 'bar', 33 | }, 34 | }; 35 | 36 | const evaluate: Plug['evaluate'] = jest.fn(); 37 | 38 | jest.mocked(useCroct).mockReturnValue({evaluate: evaluate} as Plug); 39 | jest.mocked(useLoader).mockReturnValue('foo'); 40 | 41 | const query = 'location'; 42 | const cacheKey = 'unique'; 43 | 44 | const {result} = renderHook( 45 | () => useEvaluation(query, { 46 | ...evaluationOptions, 47 | cacheKey: cacheKey, 48 | fallback: 'error', 49 | expiration: 50, 50 | }), 51 | ); 52 | 53 | expect(useCroct).toHaveBeenCalled(); 54 | expect(useLoader).toHaveBeenCalledWith({ 55 | cacheKey: hash(`useEvaluation:${cacheKey}:${query}:${JSON.stringify(evaluationOptions.attributes)}`), 56 | fallback: 'error', 57 | expiration: 50, 58 | loader: expect.any(Function), 59 | }); 60 | 61 | jest.mocked(useLoader) 62 | .mock 63 | .calls[0][0] 64 | .loader(); 65 | 66 | expect(evaluate).toHaveBeenCalledWith(query, evaluationOptions); 67 | 68 | expect(result.current).toBe('foo'); 69 | }); 70 | 71 | it('should remove undefined evaluation options', () => { 72 | const evaluationOptions: EvaluationOptions = { 73 | timeout: undefined, 74 | attributes: undefined, 75 | }; 76 | 77 | const evaluate: Plug['evaluate'] = jest.fn(); 78 | 79 | jest.mocked(useCroct).mockReturnValue({evaluate: evaluate} as Plug); 80 | jest.mocked(useLoader).mockReturnValue('foo'); 81 | 82 | const query = 'location'; 83 | 84 | renderHook(() => useEvaluation(query, evaluationOptions)); 85 | 86 | jest.mocked(useLoader) 87 | .mock 88 | .calls[0][0] 89 | .loader(); 90 | 91 | expect(evaluate).toHaveBeenCalledWith(query, {}); 92 | }); 93 | 94 | it('should use the initial value when the cache key changes if the stale-while-loading flag is false', async () => { 95 | const key = { 96 | current: 'initial', 97 | }; 98 | 99 | const evaluate: Plug['evaluate'] = jest.fn(); 100 | 101 | jest.mocked(useCroct).mockReturnValue({evaluate: evaluate} as Plug); 102 | 103 | jest.mocked(useLoader).mockImplementation( 104 | () => (key.current === 'initial' ? 'first' : 'second'), 105 | ); 106 | 107 | const query = 'location'; 108 | 109 | const {result, rerender} = renderHook( 110 | () => useEvaluation(query, { 111 | cacheKey: key.current, 112 | initial: 'initial', 113 | }), 114 | ); 115 | 116 | expect(useCroct).toHaveBeenCalled(); 117 | 118 | expect(useLoader).toHaveBeenCalledWith(expect.objectContaining({ 119 | cacheKey: hash(`useEvaluation:${key.current}:${query}:${JSON.stringify({})}`), 120 | initial: 'initial', 121 | })); 122 | 123 | await waitFor(() => expect(result.current).toEqual('first')); 124 | 125 | key.current = 'next'; 126 | 127 | rerender(); 128 | 129 | expect(useLoader).toHaveBeenCalledWith(expect.objectContaining({ 130 | cacheKey: hash(`useEvaluation:${key.current}:${query}:${JSON.stringify({})}`), 131 | initial: 'initial', 132 | })); 133 | 134 | await waitFor(() => expect(result.current).toEqual('second')); 135 | }); 136 | 137 | it('should use the last evaluation result if the stale-while-loading flag is true', async () => { 138 | const key = { 139 | current: 'initial', 140 | }; 141 | 142 | const evaluate: Plug['evaluate'] = jest.fn(); 143 | 144 | jest.mocked(useCroct).mockReturnValue({evaluate: evaluate} as Plug); 145 | 146 | jest.mocked(useLoader).mockImplementation( 147 | () => (key.current === 'initial' ? 'first' : 'second'), 148 | ); 149 | 150 | const query = 'location'; 151 | 152 | const {result, rerender} = renderHook( 153 | () => useEvaluation(query, { 154 | cacheKey: key.current, 155 | initial: 'initial', 156 | staleWhileLoading: true, 157 | }), 158 | ); 159 | 160 | expect(useCroct).toHaveBeenCalled(); 161 | 162 | expect(useLoader).toHaveBeenCalledWith(expect.objectContaining({ 163 | cacheKey: hash(`useEvaluation:${key.current}:${query}:${JSON.stringify({})}`), 164 | initial: 'initial', 165 | })); 166 | 167 | await waitFor(() => expect(result.current).toEqual('first')); 168 | 169 | key.current = 'next'; 170 | 171 | rerender(); 172 | 173 | expect(useLoader).toHaveBeenCalledWith(expect.objectContaining({ 174 | cacheKey: hash(`useEvaluation:${key.current}:${query}:${JSON.stringify({})}`), 175 | initial: 'first', 176 | })); 177 | 178 | await waitFor(() => expect(result.current).toEqual('second')); 179 | }); 180 | }); 181 | -------------------------------------------------------------------------------- /src/hooks/useEvaluation.ts: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import {JsonValue} from '@croct/plug/sdk/json'; 4 | import {EvaluationOptions} from '@croct/sdk/facade/evaluatorFacade'; 5 | import {useEffect, useState} from 'react'; 6 | import {useLoader} from './useLoader'; 7 | import {useCroct} from './useCroct'; 8 | import {isSsr} from '../ssr-polyfills'; 9 | import {hash} from '../hash'; 10 | 11 | export type UseEvaluationOptions = EvaluationOptions & { 12 | initial?: I, 13 | fallback?: F, 14 | cacheKey?: string, 15 | expiration?: number, 16 | staleWhileLoading?: boolean, 17 | }; 18 | 19 | type UseEvaluationHook = ( 20 | query: string, 21 | options?: UseEvaluationOptions, 22 | ) => T | I | F; 23 | 24 | function useCsrEvaluation( 25 | query: string, 26 | options: UseEvaluationOptions = {}, 27 | ): T | I | F { 28 | const { 29 | cacheKey, 30 | fallback, 31 | expiration, 32 | staleWhileLoading = false, 33 | initial: initialValue, 34 | ...evaluationOptions 35 | } = options; 36 | 37 | const [initial, setInitial] = useState(initialValue); 38 | const croct = useCroct(); 39 | 40 | const result = useLoader({ 41 | cacheKey: hash( 42 | `useEvaluation:${cacheKey ?? ''}` 43 | + `:${query}` 44 | + `:${JSON.stringify(options.attributes ?? {})}`, 45 | ), 46 | loader: () => croct.evaluate(query, cleanEvaluationOptions(evaluationOptions)), 47 | initial: initial, 48 | fallback: fallback, 49 | expiration: expiration, 50 | }); 51 | 52 | useEffect( 53 | () => { 54 | if (staleWhileLoading) { 55 | setInitial(current => { 56 | if (current !== result) { 57 | return result; 58 | } 59 | 60 | return current; 61 | }); 62 | } 63 | }, 64 | [result, staleWhileLoading], 65 | ); 66 | 67 | return result; 68 | } 69 | 70 | function cleanEvaluationOptions(options: EvaluationOptions): EvaluationOptions { 71 | const result: EvaluationOptions = {}; 72 | 73 | for (const [key, value] of Object.entries(options) as Array<[keyof EvaluationOptions, any]>) { 74 | if (value !== undefined) { 75 | result[key] = value; 76 | } 77 | } 78 | 79 | return result; 80 | } 81 | 82 | function useSsrEvaluation( 83 | _: string, 84 | {initial}: UseEvaluationOptions = {}, 85 | ): T | I | F { 86 | if (initial === undefined) { 87 | throw new Error( 88 | 'The initial value is required for server-side rendering (SSR). ' 89 | + 'For help, see https://croct.help/sdk/react/missing-evaluation-result', 90 | ); 91 | } 92 | 93 | return initial; 94 | } 95 | 96 | export const useEvaluation: UseEvaluationHook = isSsr() ? useSsrEvaluation : useCsrEvaluation; 97 | -------------------------------------------------------------------------------- /src/hooks/useLoader.test.ts: -------------------------------------------------------------------------------- 1 | import {act, renderHook, waitFor} from '@testing-library/react'; 2 | import {StrictMode} from 'react'; 3 | import {useLoader} from './useLoader'; 4 | 5 | describe('useLoader', () => { 6 | const cacheKey = { 7 | index: 0, 8 | next: function next(): string { 9 | this.index++; 10 | 11 | return this.current(); 12 | }, 13 | current: function current(): string { 14 | return `key-${this.index}`; 15 | }, 16 | }; 17 | 18 | beforeEach(() => { 19 | cacheKey.next(); 20 | jest.resetAllMocks(); 21 | jest.clearAllTimers(); 22 | }); 23 | 24 | // Needed to use fake timers and promises: 25 | // https://github.com/testing-library/react-testing-library/issues/244#issuecomment-449461804 26 | function flushPromises(): Promise { 27 | return Promise.resolve(); 28 | } 29 | 30 | it('should return the load the value and cache on success', async () => { 31 | const loader = jest.fn().mockResolvedValue('foo'); 32 | 33 | const {result, rerender} = renderHook( 34 | () => useLoader({ 35 | cacheKey: cacheKey.current(), 36 | loader: loader, 37 | }), 38 | ); 39 | 40 | rerender(); 41 | 42 | await waitFor(() => expect(result.current).toBe('foo')); 43 | 44 | expect(loader).toHaveBeenCalledTimes(1); 45 | }); 46 | 47 | it('should load the value and cache on error', async () => { 48 | const error = new Error('fail'); 49 | const loader = jest.fn().mockRejectedValue(error); 50 | 51 | const {result, rerender} = renderHook( 52 | () => useLoader({ 53 | cacheKey: cacheKey.current(), 54 | fallback: error, 55 | loader: loader, 56 | }), 57 | ); 58 | 59 | rerender(); 60 | 61 | await waitFor(() => expect(result.current).toBe(error)); 62 | 63 | expect(loader).toHaveBeenCalledTimes(1); 64 | }); 65 | 66 | it('should reload the value on error', async () => { 67 | const content = {foo: 'qux'}; 68 | 69 | const loader = jest.fn() 70 | .mockImplementationOnce(() => { 71 | throw new Error('fail'); 72 | }) 73 | .mockImplementationOnce(() => Promise.resolve(content)); 74 | 75 | const {result, rerender} = renderHook( 76 | () => useLoader({ 77 | cacheKey: cacheKey.current(), 78 | initial: {}, 79 | loader: loader, 80 | }), 81 | ); 82 | 83 | await act(flushPromises); 84 | 85 | rerender(); 86 | 87 | await waitFor(() => expect(result.current).toBe(content)); 88 | 89 | expect(loader).toHaveBeenCalledTimes(2); 90 | }); 91 | 92 | it('should return the initial state on the initial render', async () => { 93 | const loader = jest.fn(() => Promise.resolve('loaded')); 94 | 95 | const {result} = renderHook( 96 | () => useLoader({ 97 | cacheKey: cacheKey.current(), 98 | initial: 'loading', 99 | loader: loader, 100 | }), 101 | ); 102 | 103 | expect(result.current).toBe('loading'); 104 | 105 | await waitFor(() => expect(result.current).toBe('loaded')); 106 | }); 107 | 108 | it('should update the initial state with the fallback state on error', async () => { 109 | const loader = jest.fn().mockRejectedValue(new Error('fail')); 110 | 111 | const {result} = renderHook( 112 | () => useLoader({ 113 | cacheKey: cacheKey.current(), 114 | initial: 'loading', 115 | fallback: 'error', 116 | loader: loader, 117 | }), 118 | ); 119 | 120 | expect(result.current).toBe('loading'); 121 | 122 | await waitFor(() => expect(result.current).toBe('error')); 123 | }); 124 | 125 | it('should return the fallback state on error', async () => { 126 | const loader = jest.fn().mockRejectedValue(new Error('fail')); 127 | 128 | const {result} = renderHook( 129 | () => useLoader({ 130 | cacheKey: cacheKey.current(), 131 | fallback: 'foo', 132 | loader: loader, 133 | }), 134 | ); 135 | 136 | await waitFor(() => expect(result.current).toBe('foo')); 137 | 138 | expect(loader).toHaveBeenCalled(); 139 | }); 140 | 141 | it('should extend the cache expiration on every render', async () => { 142 | jest.useFakeTimers(); 143 | 144 | const loader = jest.fn().mockResolvedValue('foo'); 145 | 146 | const {rerender, unmount} = renderHook( 147 | () => useLoader({ 148 | cacheKey: cacheKey.current(), 149 | loader: loader, 150 | expiration: 15, 151 | }), 152 | ); 153 | 154 | await act(flushPromises); 155 | 156 | jest.advanceTimersByTime(14); 157 | 158 | rerender(); 159 | 160 | jest.advanceTimersByTime(14); 161 | 162 | rerender(); 163 | 164 | expect(loader).toHaveBeenCalledTimes(1); 165 | 166 | jest.advanceTimersByTime(15); 167 | 168 | unmount(); 169 | 170 | renderHook( 171 | () => useLoader({ 172 | cacheKey: cacheKey.current(), 173 | loader: loader, 174 | expiration: 15, 175 | }), 176 | ); 177 | 178 | await act(flushPromises); 179 | 180 | expect(loader).toHaveBeenCalledTimes(2); 181 | }); 182 | 183 | it('should not expire the cache when the expiration is negative', async () => { 184 | jest.useFakeTimers(); 185 | 186 | const loader = jest.fn( 187 | () => new Promise(resolve => { 188 | setTimeout(() => resolve('foo'), 10); 189 | }), 190 | ); 191 | 192 | const {rerender} = renderHook( 193 | () => useLoader({ 194 | cacheKey: cacheKey.current(), 195 | loader: loader, 196 | expiration: -1, 197 | }), 198 | ); 199 | 200 | jest.advanceTimersByTime(10); 201 | 202 | await act(flushPromises); 203 | 204 | // First rerender 205 | rerender(); 206 | 207 | // Second rerender 208 | rerender(); 209 | 210 | expect(loader).toHaveBeenCalledTimes(1); 211 | }); 212 | 213 | it('should reload the value when the cache key changes without initial value', async () => { 214 | jest.useFakeTimers(); 215 | 216 | const loader = jest.fn() 217 | .mockResolvedValueOnce('foo') 218 | .mockImplementationOnce( 219 | () => new Promise(resolve => { 220 | setTimeout(() => resolve('bar'), 10); 221 | }), 222 | ); 223 | 224 | const {result, rerender} = renderHook( 225 | props => useLoader({ 226 | cacheKey: cacheKey.current(), 227 | loader: loader, 228 | initial: props?.initial, 229 | }), 230 | ); 231 | 232 | await act(flushPromises); 233 | 234 | rerender(); 235 | 236 | await waitFor(() => expect(result.current).toBe('foo')); 237 | 238 | expect(loader).toHaveBeenCalledTimes(1); 239 | 240 | cacheKey.next(); 241 | 242 | rerender({initial: 'loading'}); 243 | 244 | await waitFor(() => expect(result.current).toBe('loading')); 245 | 246 | jest.advanceTimersByTime(10); 247 | 248 | await waitFor(() => expect(result.current).toBe('bar')); 249 | 250 | expect(loader).toHaveBeenCalledTimes(2); 251 | }); 252 | 253 | it('should reload the value when the cache key changes with initial value', async () => { 254 | jest.useFakeTimers(); 255 | 256 | const loader = jest.fn() 257 | .mockImplementationOnce( 258 | () => new Promise(resolve => { 259 | setTimeout(() => resolve('foo'), 10); 260 | }), 261 | ) 262 | .mockImplementationOnce( 263 | () => new Promise(resolve => { 264 | setTimeout(() => resolve('bar'), 10); 265 | }), 266 | ); 267 | 268 | const {result, rerender} = renderHook( 269 | props => useLoader({ 270 | cacheKey: cacheKey.current(), 271 | initial: props?.initial ?? 'first content', 272 | loader: loader, 273 | }), 274 | ); 275 | 276 | await act(flushPromises); 277 | 278 | expect(result.current).toBe('first content'); 279 | 280 | jest.advanceTimersByTime(10); 281 | 282 | await act(flushPromises); 283 | 284 | await waitFor(() => expect(result.current).toBe('foo')); 285 | 286 | expect(loader).toHaveBeenCalledTimes(1); 287 | 288 | cacheKey.next(); 289 | 290 | rerender({initial: 'second content'}); 291 | 292 | await waitFor(() => expect(result.current).toBe('second content')); 293 | 294 | jest.advanceTimersByTime(10); 295 | 296 | await act(flushPromises); 297 | 298 | await waitFor(() => expect(result.current).toBe('bar')); 299 | 300 | expect(loader).toHaveBeenCalledTimes(2); 301 | }); 302 | 303 | it.each<[number, number|undefined]>( 304 | [ 305 | // [Expected elapsed time, Expiration] 306 | [60_000, undefined], 307 | [15_000, 15_000], 308 | ], 309 | )('should cache the values for %d milliseconds', async (step, expiration) => { 310 | jest.useFakeTimers(); 311 | 312 | const delay = 10; 313 | const loader = jest.fn( 314 | () => new Promise(resolve => { 315 | setTimeout(() => resolve('foo'), delay); 316 | }), 317 | ); 318 | 319 | const {result: firstTime} = renderHook( 320 | () => useLoader({ 321 | cacheKey: cacheKey.current(), 322 | expiration: expiration, 323 | loader: loader, 324 | }), 325 | ); 326 | 327 | jest.advanceTimersByTime(delay); 328 | 329 | await act(flushPromises); 330 | 331 | await waitFor(() => expect(firstTime.current).toBe('foo')); 332 | 333 | const {result: secondTime} = renderHook( 334 | () => useLoader({ 335 | cacheKey: cacheKey.current(), 336 | expiration: expiration, 337 | loader: loader, 338 | }), 339 | ); 340 | 341 | expect(secondTime.current).toBe('foo'); 342 | 343 | expect(loader).toHaveBeenCalledTimes(1); 344 | 345 | jest.advanceTimersByTime(step); 346 | 347 | const {result: thirdTime} = renderHook( 348 | () => useLoader({ 349 | cacheKey: cacheKey.current(), 350 | expiration: expiration, 351 | loader: loader, 352 | }), 353 | ); 354 | 355 | jest.advanceTimersByTime(delay); 356 | 357 | await act(flushPromises); 358 | 359 | await waitFor(() => expect(thirdTime.current).toBe('foo')); 360 | 361 | expect(loader).toHaveBeenCalledTimes(2); 362 | }); 363 | 364 | it('should dispose the cache on unmount', async () => { 365 | jest.useFakeTimers(); 366 | 367 | const delay = 10; 368 | const loader = jest.fn( 369 | () => new Promise(resolve => { 370 | setTimeout(() => resolve('foo'), delay); 371 | }), 372 | ); 373 | 374 | const {unmount} = renderHook( 375 | () => useLoader({ 376 | cacheKey: cacheKey.current(), 377 | expiration: 5, 378 | loader: loader, 379 | }), 380 | ); 381 | 382 | jest.advanceTimersByTime(delay); 383 | 384 | await act(flushPromises); 385 | 386 | unmount(); 387 | 388 | jest.advanceTimersByTime(5); 389 | 390 | await act(flushPromises); 391 | 392 | const {result: secondTime} = renderHook( 393 | () => useLoader({ 394 | cacheKey: cacheKey.current(), 395 | expiration: 5, 396 | loader: loader, 397 | }), 398 | ); 399 | 400 | jest.advanceTimersByTime(delay); 401 | 402 | await act(flushPromises); 403 | 404 | expect(loader).toHaveBeenCalledTimes(2); 405 | 406 | await waitFor(() => expect(secondTime.current).toBe('foo')); 407 | }); 408 | 409 | it('should update the content in StrictMode', async () => { 410 | jest.useFakeTimers(); 411 | 412 | const delay = 10; 413 | const loader = jest.fn( 414 | () => new Promise(resolve => { 415 | setTimeout(() => resolve('foo'), delay); 416 | }), 417 | ); 418 | 419 | const {result} = renderHook( 420 | () => useLoader({ 421 | cacheKey: cacheKey.current(), 422 | loader: loader, 423 | initial: 'bar', 424 | }), 425 | { 426 | wrapper: StrictMode, 427 | }, 428 | ); 429 | 430 | // Let the loader resolve 431 | await act(async () => { 432 | jest.advanceTimersByTime(delay); 433 | await flushPromises(); 434 | }); 435 | 436 | await waitFor(() => expect(result.current).toBe('foo')); 437 | }); 438 | }); 439 | -------------------------------------------------------------------------------- /src/hooks/useLoader.ts: -------------------------------------------------------------------------------- 1 | import {useCallback, useEffect, useRef, useState} from 'react'; 2 | import {Cache, EntryOptions} from './Cache'; 3 | 4 | const cache = new Cache(60 * 1000); 5 | 6 | export type CacheOptions = EntryOptions & { 7 | initial?: R, 8 | }; 9 | 10 | /** 11 | * @internal 12 | */ 13 | export function useLoader({initial, ...currentOptions}: CacheOptions): R { 14 | const optionsRef = useRef(currentOptions); 15 | const [value, setValue] = useState(() => cache.get(currentOptions.cacheKey)?.result ?? initial); 16 | const mountedRef = useRef(true); 17 | 18 | const load = useCallback( 19 | (options: EntryOptions) => { 20 | try { 21 | setValue(cache.load(options)); 22 | } catch (result: unknown) { 23 | if (result instanceof Promise) { 24 | result.then((resolvedValue: R) => { 25 | if (mountedRef.current) { 26 | setValue(resolvedValue); 27 | } 28 | }); 29 | 30 | return; 31 | } 32 | 33 | setValue(undefined); 34 | } 35 | }, 36 | [], 37 | ); 38 | 39 | useEffect( 40 | () => { 41 | mountedRef.current = true; 42 | 43 | if (initial !== undefined) { 44 | load(currentOptions); 45 | } 46 | 47 | return () => { 48 | mountedRef.current = false; 49 | }; 50 | }, 51 | // eslint-disable-next-line react-hooks/exhaustive-deps -- Should run only once 52 | [], 53 | ); 54 | 55 | useEffect( 56 | () => { 57 | if (optionsRef.current.cacheKey !== currentOptions.cacheKey) { 58 | setValue(initial); 59 | optionsRef.current = currentOptions; 60 | 61 | if (initial !== undefined) { 62 | load(currentOptions); 63 | } 64 | } 65 | }, 66 | [currentOptions, initial, load], 67 | ); 68 | 69 | if (value === undefined) { 70 | return cache.load(currentOptions); 71 | } 72 | 73 | return value; 74 | } 75 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from '@croct/plug/sdk/json'; 2 | export * from '@croct/plug/slot'; 3 | export * from '@croct/plug/component'; 4 | export * from './CroctProvider'; 5 | export * from './hooks'; 6 | export * from './components'; 7 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/ssr-polyfills.ssr.test.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @jest-environment node 3 | */ 4 | import croct from '@croct/plug'; 5 | import {croct as croctPolyfill, isSsr} from './ssr-polyfills'; 6 | 7 | jest.mock( 8 | '@croct/plug', 9 | () => ({ 10 | plug: jest.fn(), 11 | unplug: jest.fn(), 12 | }), 13 | ); 14 | 15 | describe('Croct polyfill (SSR)', () => { 16 | it('should not plug', () => { 17 | croctPolyfill.plug({appId: '00000000-0000-0000-0000-000000000000'}); 18 | 19 | expect(croct.plug).not.toHaveBeenCalled(); 20 | }); 21 | 22 | it('should not unplug', async () => { 23 | await expect(croctPolyfill.unplug()).resolves.toBeUndefined(); 24 | 25 | expect(croct.unplug).not.toHaveBeenCalled(); 26 | }); 27 | 28 | it('should not initialize', () => { 29 | expect(croctPolyfill.initialized).toBe(false); 30 | 31 | croctPolyfill.plug({appId: '00000000-0000-0000-0000-000000000000'}); 32 | 33 | expect(croctPolyfill.initialized).toBe(false); 34 | }); 35 | 36 | it('should not allow accessing properties other than plug or unplug', () => { 37 | expect(() => croctPolyfill.user) 38 | .toThrow('Property croct.user is not supported on server-side (SSR).'); 39 | }); 40 | }); 41 | 42 | describe('isSsr', () => { 43 | it('should always return true', () => { 44 | expect(isSsr()).toBe(true); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /src/ssr-polyfills.test.ts: -------------------------------------------------------------------------------- 1 | import croct, {Configuration} from '@croct/plug'; 2 | import {croct as croctPolyfill, isSsr} from './ssr-polyfills'; 3 | import spyOn = jest.spyOn; 4 | 5 | describe('Croct polyfill (CSR)', () => { 6 | beforeAll(() => { 7 | jest.clearAllMocks(); 8 | }); 9 | 10 | const config: Configuration = {appId: '00000000-0000-0000-0000-000000000000'}; 11 | 12 | it('should delay unplugging to avoid reconnections', async () => { 13 | jest.useFakeTimers(); 14 | 15 | const unplug = spyOn(croct, 'unplug'); 16 | 17 | const plug = spyOn(croct, 'plug'); 18 | 19 | croctPolyfill.plug(config); 20 | 21 | expect(plug).toHaveBeenCalledTimes(1); 22 | 23 | // First attempt: cancelling 24 | 25 | const firstAttempt = croctPolyfill.unplug(); 26 | 27 | expect(unplug).not.toHaveBeenCalled(); 28 | 29 | croctPolyfill.plug(config); 30 | 31 | jest.runOnlyPendingTimers(); 32 | 33 | await expect(firstAttempt).rejects.toThrow('Unplug cancelled.'); 34 | 35 | expect(unplug).not.toHaveBeenCalled(); 36 | 37 | // Second attempt: failing 38 | 39 | unplug.mockRejectedValueOnce(new Error('Unplug failed.')); 40 | 41 | const secondAttempt = croct.unplug(); 42 | 43 | jest.runOnlyPendingTimers(); 44 | 45 | await expect(secondAttempt).rejects.toThrow('Unplug failed.'); 46 | 47 | // Third attempt: succeeding 48 | 49 | unplug.mockResolvedValueOnce(); 50 | 51 | const thirdAttempt = croct.unplug(); 52 | 53 | jest.runOnlyPendingTimers(); 54 | 55 | await expect(thirdAttempt).resolves.toBeUndefined(); 56 | 57 | expect(unplug).toHaveBeenCalledTimes(2); 58 | }); 59 | }); 60 | 61 | describe('isSsr', () => { 62 | it('should always return false', () => { 63 | expect(isSsr()).toBe(false); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /src/ssr-polyfills.ts: -------------------------------------------------------------------------------- 1 | import type {Plug} from '@croct/plug'; 2 | import {GlobalPlug} from '@croct/plug/plug'; 3 | 4 | /** 5 | * @internal 6 | */ 7 | export function isSsr(): boolean { 8 | return globalThis.window?.document?.createElement === undefined; 9 | } 10 | 11 | /** 12 | * @internal 13 | */ 14 | export const croct: Plug = !isSsr() 15 | ? (function factory(): Plug { 16 | let timeoutId: ReturnType|null = null; 17 | let resolveCallback: () => void; 18 | let rejectCallback: (reason: any) => void; 19 | 20 | return new Proxy(GlobalPlug.GLOBAL, { 21 | get: function getProperty(target, property: keyof Plug): any { 22 | switch (property) { 23 | case 'plug': 24 | if (timeoutId !== null) { 25 | clearTimeout(timeoutId); 26 | timeoutId = null; 27 | rejectCallback?.(new Error('Unplug cancelled.')); 28 | } 29 | 30 | break; 31 | 32 | case 'unplug': 33 | return () => { 34 | // Delay unplugging to avoid reconnections between remounts (e.g. strict mode). 35 | // It can be problematic when aiming to replug the SDK with a different configuration. 36 | // However, since it is an unusual use case and there is a log message to warn about 37 | // the plugin being already plugged, the trade-off is worth it. 38 | timeoutId = setTimeout(() => target.unplug().then(resolveCallback, rejectCallback), 100); 39 | 40 | return new Promise((resolve, reject) => { 41 | resolveCallback = resolve; 42 | rejectCallback = reject; 43 | }); 44 | }; 45 | } 46 | 47 | return target[property]; 48 | }, 49 | }); 50 | }()) 51 | : new Proxy(GlobalPlug.GLOBAL, { 52 | get: function getProperty(_, property: keyof Plug): any { 53 | switch (property) { 54 | case 'initialized': 55 | return false; 56 | 57 | case 'plug': 58 | return () => { 59 | // no-op 60 | }; 61 | 62 | case 'unplug': 63 | return () => Promise.resolve(); 64 | 65 | default: 66 | throw new Error( 67 | `Property croct.${String(property)} is not supported on server-side (SSR). ` 68 | + 'Consider refactoring the logic as a side-effect (useEffect) or a client-side callback ' 69 | + '(onClick, onChange, etc). ' 70 | + 'For help, see https://croct.help/sdk/react/client-logic-ssr', 71 | ); 72 | } 73 | }, 74 | }); 75 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "ES2020", 4 | "target": "ES2020", 5 | "outDir": "build", 6 | "lib": ["dom"], 7 | "types": [ 8 | "jest", 9 | "node" 10 | ], 11 | "moduleResolution": "node", 12 | "jsx": "react-jsx", 13 | "declaration": true, 14 | "esModuleInterop": true, 15 | "noImplicitReturns": true, 16 | "noImplicitThis": true, 17 | "noImplicitAny": true, 18 | "strictNullChecks": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "allowSyntheticDefaultImports": true, 22 | "allowJs": true, 23 | "skipLibCheck": true, 24 | "strict": true, 25 | "forceConsistentCasingInFileNames": true, 26 | "noFallthroughCasesInSwitch": true, 27 | "resolveJsonModule": true, 28 | "isolatedModules": true, 29 | "stripInternal": true, 30 | }, 31 | "include": [ 32 | "src/**/*.ts", 33 | "src/**/*.tsx" 34 | ], 35 | "exclude": [ 36 | "node_modules", 37 | "dist" 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import {defineConfig} from 'tsup'; 2 | import {fixImportsPlugin} from 'esbuild-fix-imports-plugin'; 3 | 4 | export default defineConfig({ 5 | esbuildPlugins: [fixImportsPlugin()], 6 | entry: ['src/**/*.ts', 'src/**/*.tsx', '!src/**/*.test.ts', '!src/**/*.test.tsx'], 7 | dts: true, 8 | clean: true, 9 | sourcemap: false, 10 | outDir: 'build', 11 | splitting: false, 12 | bundle: false, 13 | format: ['cjs', 'esm'], 14 | }); 15 | --------------------------------------------------------------------------------