From b539205759301ce0f49f633a5e49454b9d098432 Mon Sep 17 00:00:00 2001 From: Juraj Uhlar Date: Fri, 17 Jul 2026 08:39:06 +0100 Subject: [PATCH 01/41] feat: add React 19 / Next 16 support (INTER-2317) Widen the react peer range to >=18 <20, verify the SDK against React 19 via a second CI matrix job (catalog stays pinned to 18 as the default dev/test toolchain), and bump the Next.js examples to 16.2.10. Add @next/eslint-plugin-next lint rules scoped to the Next examples instead of eslint-config-next, since the latter bundles eslint-plugin-react which only supports ESLint below version 10. Re-add the two @eslint-react rule overrides for React 19-only idioms so linting stays green under both supported versions. --- .changeset/react-19-next-16-support.md | 5 + .github/workflows/ci.yml | 9 + README.md | 2 +- eslint.config.mjs | 30 +- examples/next-appDir/next-env.d.ts | 3 +- examples/next-appDir/package.json | 2 +- examples/next-appDir/tsconfig.json | 9 +- examples/next/next-env.d.ts | 3 +- examples/next/package.json | 2 +- examples/next/tsconfig.json | 22 +- package.json | 5 +- pnpm-lock.yaml | 471 ++++++++++++++++++++----- 12 files changed, 450 insertions(+), 113 deletions(-) create mode 100644 .changeset/react-19-next-16-support.md diff --git a/.changeset/react-19-next-16-support.md b/.changeset/react-19-next-16-support.md new file mode 100644 index 00000000..00d29498 --- /dev/null +++ b/.changeset/react-19-next-16-support.md @@ -0,0 +1,5 @@ +--- +'@fingerprint/react': minor +--- + +Add support for React 19 and Next.js 16. The `react` peer range now accepts `>=18 <20`. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 89b5c796..bf4a551b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,3 +10,12 @@ jobs: build-and-check: name: Build project and run CI checks uses: fingerprintjs/dx-team-toolkit/.github/workflows/build-typescript-project.yml@v1 + + build-and-check-react-19: + name: Build project and run CI checks (React 19) + uses: fingerprintjs/dx-team-toolkit/.github/workflows/build-typescript-project.yml@v1 + with: + # Overrides the pnpm-workspace.yaml catalog (pinned to React 18) for this + # job only, so the SDK itself is additionally verified against React 19. + # Example apps are unaffected: `-w` only touches the root package. + runAfterInstall: pnpm add -w react@19 react-dom@19 @types/react@19 @types/react-dom@19 diff --git a/README.md b/README.md index 0c1a60e8..16174c24 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ Fingerprint is a device intelligence platform offering industry-leading accuracy ## Requirements -- React 18 or higher +- React 18 or 19 - For Preact users: Preact 10.3 or higher - For Next.js users: Next.js 13.1 or higher - For TypeScript users: TypeScript 4.8 or higher diff --git a/eslint.config.mjs b/eslint.config.mjs index 52ff1e27..6c5c4987 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -5,13 +5,28 @@ import cfg from '@fingerprintjs/eslint-config-dx-team/type-checked' import react from '@eslint-react/eslint-plugin' import tseslint from 'typescript-eslint' import reactHooks from 'eslint-plugin-react-hooks' +import nextPlugin from '@next/eslint-plugin-next' const __dirname = fileURLToPath(new URL('.', import.meta.url)) +// The two Next.js examples additionally get Next's own lint rules. This uses +// @next/eslint-plugin-next directly rather than eslint-config-next: the latter +// bundles eslint-plugin-react, which only supports ESLint <10 and crashes on +// this repo's ESLint 10. +const NEXT_EXAMPLE_FILES = ['examples/next/**/*.{js,jsx,ts,tsx}', 'examples/next-appDir/**/*.{js,jsx,ts,tsx}'] + const config = [ includeIgnoreFile(path.resolve(__dirname, '.gitignore')), { - ignores: ['examples/**/build/**', 'examples/**/dist/**', 'examples/**/.next/**', 'examples/**/node_modules/**'], + // next-env.d.ts is regenerated by `next build`/`next dev` on every run (and says + // "should not be edited"), so linting/formatting a committed copy of it is futile. + ignores: [ + 'examples/**/build/**', + 'examples/**/dist/**', + 'examples/**/.next/**', + 'examples/**/node_modules/**', + 'examples/**/next-env.d.ts', + ], }, ...cfg, { @@ -28,6 +43,19 @@ const config = [ }, }, }, + { + files: NEXT_EXAMPLE_FILES, + ...nextPlugin.configs['core-web-vitals'], + }, + { + // peerDependencies declare react >=18 <20, so React 19-only idioms (the `use` hook, + // bare `` as a provider) would break React 18 consumers. + files: ['src/**/*.{ts,tsx}', '__tests__/**/*.{ts,tsx}'], + rules: { + '@eslint-react/no-use-context': 'off', + '@eslint-react/no-context-provider': 'off', + }, + }, { files: ['examples/preact/**/*.{ts,tsx}', 'examples/**/vite.config.ts'], ...tseslint.configs.disableTypeChecked, diff --git a/examples/next-appDir/next-env.d.ts b/examples/next-appDir/next-env.d.ts index 40c3d680..9edff1c7 100644 --- a/examples/next-appDir/next-env.d.ts +++ b/examples/next-appDir/next-env.d.ts @@ -1,5 +1,6 @@ /// /// +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited -// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/examples/next-appDir/package.json b/examples/next-appDir/package.json index a069565b..f738c891 100644 --- a/examples/next-appDir/package.json +++ b/examples/next-appDir/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@fingerprint/react": "workspace:*", - "next": "14.2.35" + "next": "16.2.10" }, "devDependencies": { "@types/node": "catalog:", diff --git a/examples/next-appDir/tsconfig.json b/examples/next-appDir/tsconfig.json index b25c4f83..a7768370 100644 --- a/examples/next-appDir/tsconfig.json +++ b/examples/next-appDir/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "es5", + "target": "es2020", "lib": [ "dom", "dom.iterable", @@ -13,10 +13,10 @@ "noEmit": true, "esModuleInterop": true, "module": "esnext", - "moduleResolution": "node", + "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, - "jsx": "preserve", + "jsx": "react-jsx", "incremental": true, "plugins": [ { @@ -28,7 +28,8 @@ "next-env.d.ts", "**/*.ts", "**/*.tsx", - ".next/types/**/*.ts" + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" ], "exclude": [ "node_modules" diff --git a/examples/next/next-env.d.ts b/examples/next/next-env.d.ts index a4a7b3f5..19709046 100644 --- a/examples/next/next-env.d.ts +++ b/examples/next/next-env.d.ts @@ -1,5 +1,6 @@ /// /// +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited -// see https://nextjs.org/docs/pages/building-your-application/configuring/typescript for more information. +// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information. diff --git a/examples/next/package.json b/examples/next/package.json index 9c5dae5b..4bc5aefc 100644 --- a/examples/next/package.json +++ b/examples/next/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@fingerprint/react": "workspace:*", - "next": "14.2.35" + "next": "16.2.10" }, "devDependencies": { "@types/node": "catalog:", diff --git a/examples/next/tsconfig.json b/examples/next/tsconfig.json index 99710e85..97e1b25f 100644 --- a/examples/next/tsconfig.json +++ b/examples/next/tsconfig.json @@ -1,7 +1,11 @@ { "compilerOptions": { - "target": "es5", - "lib": ["dom", "dom.iterable", "esnext"], + "target": "es2020", + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -9,12 +13,18 @@ "noEmit": true, "esModuleInterop": true, "module": "esnext", - "moduleResolution": "node", + "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, - "jsx": "preserve", + "jsx": "react-jsx", "incremental": true }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], - "exclude": ["node_modules"] + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx" + ], + "exclude": [ + "node_modules" + ] } diff --git a/package.json b/package.json index f50c8421..c0c53af7 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "prepare": "husky", "watch": "vite build --watch", "build": "vite build", - "lint": "eslint --max-warnings 0", + "lint": "eslint --max-warnings 0 --no-warn-ignored", "lint:fix": "pnpm lint --fix", "test": "vitest", "test:coverage": "vitest run --coverage", @@ -63,7 +63,7 @@ "fast-deep-equal": "3.1.3" }, "peerDependencies": { - "react": ">=18" + "react": ">=18 <20" }, "devDependencies": { "@changesets/cli": "^2.31.1", @@ -75,6 +75,7 @@ "@fingerprintjs/prettier-config-dx-team": "^0.3.0", "@fingerprintjs/tsconfig-dx-team": "^0.0.2", "@microsoft/api-extractor": "^7.58.9", + "@next/eslint-plugin-next": "^16.2.10", "@testing-library/preact": "^3.2.4", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b60ec2b0..0e2fdf1f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -63,6 +63,9 @@ importers: '@microsoft/api-extractor': specifier: ^7.58.9 version: 7.58.9(@types/node@26.1.1) + '@next/eslint-plugin-next': + specifier: ^16.2.10 + version: 16.2.10 '@testing-library/preact': specifier: ^3.2.4 version: 3.2.4(preact@10.29.7) @@ -170,8 +173,8 @@ importers: specifier: workspace:* version: link:../.. next: - specifier: 14.2.35 - version: 14.2.35(@babel/core@7.29.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: 16.2.10 + version: 16.2.10(@babel/core@7.29.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) devDependencies: '@types/node': specifier: 'catalog:' @@ -198,8 +201,8 @@ importers: specifier: workspace:* version: link:../.. next: - specifier: 14.2.35 - version: 14.2.35(@babel/core@7.29.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: 16.2.10 + version: 16.2.10(@babel/core@7.29.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) devDependencies: '@types/node': specifier: 'catalog:' @@ -237,7 +240,7 @@ importers: version: 17.4.2 preact-cli: specifier: ^3.5.1 - version: 3.5.1(@types/babel__core@7.20.5)(eslint@10.7.0(jiti@2.6.1))(preact-render-to-string@6.7.0)(preact@10.29.7)(typescript@6.0.3) + version: 3.5.1(@types/babel__core@7.20.5)(bluebird@3.7.2)(eslint@10.7.0(jiti@2.6.1))(preact-render-to-string@6.7.0)(preact@10.29.7)(typescript@6.0.3) sirv-cli: specifier: ^3.0.1 version: 3.0.1 @@ -2112,6 +2115,159 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + '@inquirer/external-editor@1.0.3': resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} engines: {node: '>=18'} @@ -2366,63 +2522,60 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 - '@next/env@14.2.35': - resolution: {integrity: sha512-DuhvCtj4t9Gwrx80dmz2F4t/zKQ4ktN8WrMwOuVzkJfBilwAwGr6v16M5eI8yCuZ63H9TTuEU09Iu2HqkzFPVQ==} + '@next/env@16.2.10': + resolution: {integrity: sha512-zLPxg9M0MEHmygpj5OuxjQ+vHMiy/K7cSp74G8ecYolmgUWw0RwN02tF56npup/+qaI8JB97hQgS/r2Hb6QwVA==} - '@next/swc-darwin-arm64@14.2.33': - resolution: {integrity: sha512-HqYnb6pxlsshoSTubdXKu15g3iivcbsMXg4bYpjL2iS/V6aQot+iyF4BUc2qA/J/n55YtvE4PHMKWBKGCF/+wA==} + '@next/eslint-plugin-next@16.2.10': + resolution: {integrity: sha512-Gs8D2m21VnJeFo9qvYIIqJH94frWerWYu41BprU1pLtRVF7PCQNLiFZZ3fG+iPuj3K83Cwv/rt+msLOy8Qgu3Q==} + + '@next/swc-darwin-arm64@16.2.10': + resolution: {integrity: sha512-v9IdJCa0H0mbo+8z5zwUpOk1Vj7RjkcI5uNYf5Ws1y6szf/p3Mzl9hLaST8SCt6L9h8NGnruZcd2+o0NTNwDhA==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@14.2.33': - resolution: {integrity: sha512-8HGBeAE5rX3jzKvF593XTTFg3gxeU4f+UWnswa6JPhzaR6+zblO5+fjltJWIZc4aUalqTclvN2QtTC37LxvZAA==} + '@next/swc-darwin-x64@16.2.10': + resolution: {integrity: sha512-17IS0jJRViROGmA9uGdNR8VPJpfbnaVG7E9qhso5jDLkmyd0lSDORWxbcKINzcFqzZqGwGtMSnrFRxBpuUYjLQ==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@14.2.33': - resolution: {integrity: sha512-JXMBka6lNNmqbkvcTtaX8Gu5by9547bukHQvPoLe9VRBx1gHwzf5tdt4AaezW85HAB3pikcvyqBToRTDA4DeLw==} + '@next/swc-linux-arm64-gnu@16.2.10': + resolution: {integrity: sha512-GRQRsRtuciNJvB54AvvuQTiq0oZtFwa1owQqtZD8wwnGpM2L39MV22kpI72YSXLKIyY40LC66EiLFv4PiicXxg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@next/swc-linux-arm64-musl@14.2.33': - resolution: {integrity: sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg==} + '@next/swc-linux-arm64-musl@16.2.10': + resolution: {integrity: sha512-zkN9MQYS7UQBro+FnISUq1itaQjXI9xqISzuQ+2bc921NcJ1x4yPCqrn77tVN6/dOOXaaWVX3k6/bR07pPwK+A==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@next/swc-linux-x64-gnu@14.2.33': - resolution: {integrity: sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg==} + '@next/swc-linux-x64-gnu@16.2.10': + resolution: {integrity: sha512-iCVJnwvrPYECvA6WM/7+oo+OiTvedIKLxtCLAZP4xZR3nXa1zmzZyLPbYCmWvpd4CvMYF1EMTafd0ii3DygLvA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@next/swc-linux-x64-musl@14.2.33': - resolution: {integrity: sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA==} + '@next/swc-linux-x64-musl@16.2.10': + resolution: {integrity: sha512-ov2g4H0dHY9bPoOU83m91hWT7Iq5qy13bUnyyshLU3HGR1Ownn0X9QpmDPc5iIUaahTp7f7LeGAhV4DSFtackw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@next/swc-win32-arm64-msvc@14.2.33': - resolution: {integrity: sha512-nscpt0G6UCTkrT2ppnJnFsYbPDQwmum4GNXYTeoTIdsmMydSKFz9Iny2jpaRupTb+Wl298+Rh82WKzt9LCcqSQ==} + '@next/swc-win32-arm64-msvc@16.2.10': + resolution: {integrity: sha512-DwAnhLX76HQiFFQNgWlcK+JzlnD1rZ+UK/WY0ZMI/deXpvgnesjNYrqcfo1JzBuz4Kf7o3brIBL0glI1junatA==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-ia32-msvc@14.2.33': - resolution: {integrity: sha512-pc9LpGNKhJ0dXQhZ5QMmYxtARwwmWLpeocFmVG5Z0DzWq5Uf0izcI8tLc+qOpqxO1PWqZ5A7J1blrUIKrIFc7Q==} - engines: {node: '>= 10'} - cpu: [ia32] - os: [win32] - - '@next/swc-win32-x64-msvc@14.2.33': - resolution: {integrity: sha512-nOjfZMy8B94MdisuzZo9/57xuFVLHJaDj5e/xrduJp9CV2/HrfxTRH2fbyLe+K9QT41WBLUd4iXX3R7jBp0EUg==} + '@next/swc-win32-x64-msvc@16.2.10': + resolution: {integrity: sha512-0JXq3b85Jk9Jg4ntLUbXSPvoDw3gpZou7twuKdoFG2jOw635v7+IiXfTaa0TxVMyx78pUjnrVYwLgjKfX4e6/A==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -2958,11 +3111,8 @@ packages: resolution: {integrity: sha512-DOBOK255wfQxguUta2INKkzPj6AIS6iafZYiYmHn6W3pHlycSRRlvWKCfLDG10fXfLWqE3DJHgRUOyJYmARa7g==} engines: {node: '>=10'} - '@swc/counter@0.1.3': - resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} - - '@swc/helpers@0.5.5': - resolution: {integrity: sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==} + '@swc/helpers@0.5.15': + resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} '@szmarczak/http-timer@1.1.2': resolution: {integrity: sha512-XIB2XbzHTN6ieIjfIMV9hlVcfPU26s2vafYWQcZHWXHOxiaRZYEDKEwdl129Zyg50+foYV2jCgtrqSA6qNuNSA==} @@ -4114,10 +4264,6 @@ packages: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} engines: {node: '>=18'} - busboy@1.6.0: - resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} - engines: {node: '>=10.16.0'} - bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} @@ -5586,6 +5732,10 @@ packages: fast-diff@1.3.0: resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} + fast-glob@3.3.1: + resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==} + engines: {node: '>=8.6.0'} + fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} @@ -7524,21 +7674,24 @@ packages: neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} - next@14.2.35: - resolution: {integrity: sha512-KhYd2Hjt/O1/1aZVX3dCwGXM1QmOV4eNM2UTacK5gipDdPN/oHHK/4oVGy7X8GMfPMsUTUEmGlsy0EY1YGAkig==} - engines: {node: '>=18.17.0'} + next@16.2.10: + resolution: {integrity: sha512-2som5AVXb3kE6Yjine3/mNbBayYF58eguBWIVVUdr1y/L426xyVEgYxgBG+1QC34P2x5E+tcDup6XkuOAX3dCA==} + engines: {node: '>=20.9.0'} hasBin: true peerDependencies: '@opentelemetry/api': ^1.1.0 - '@playwright/test': ^1.41.2 - react: ^18.2.0 - react-dom: ^18.2.0 + '@playwright/test': ^1.51.1 + babel-plugin-react-compiler: '*' + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 sass: ^1.3.0 peerDependenciesMeta: '@opentelemetry/api': optional: true '@playwright/test': optional: true + babel-plugin-react-compiler: + optional: true sass: optional: true @@ -9297,6 +9450,10 @@ packages: resolution: {integrity: sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==} engines: {node: '>=8'} + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + shebang-command@1.2.0: resolution: {integrity: sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==} engines: {node: '>=0.10.0'} @@ -9533,10 +9690,6 @@ packages: stream-shift@1.0.3: resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==} - streamsearch@1.1.0: - resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} - engines: {node: '>=10.0.0'} - string-argv@0.3.2: resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} engines: {node: '>=0.6.19'} @@ -9648,13 +9801,13 @@ packages: peerDependencies: webpack: ^5.0.0 - styled-jsx@5.1.1: - resolution: {integrity: sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==} + styled-jsx@5.1.6: + resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} engines: {node: '>= 12.0.0'} peerDependencies: '@babel/core': '*' babel-plugin-macros: '*' - react: '>= 16.8.0 || 17.x.x || ^18.0.0-0' + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' peerDependenciesMeta: '@babel/core': optional: true @@ -12983,6 +13136,103 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@img/colour@1.1.0': + optional: true + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.11.1 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + '@inquirer/external-editor@1.0.3(@types/node@26.1.1)': dependencies: chardet: 2.2.0 @@ -13389,33 +13639,34 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true - '@next/env@14.2.35': {} + '@next/env@16.2.10': {} - '@next/swc-darwin-arm64@14.2.33': - optional: true + '@next/eslint-plugin-next@16.2.10': + dependencies: + fast-glob: 3.3.1 - '@next/swc-darwin-x64@14.2.33': + '@next/swc-darwin-arm64@16.2.10': optional: true - '@next/swc-linux-arm64-gnu@14.2.33': + '@next/swc-darwin-x64@16.2.10': optional: true - '@next/swc-linux-arm64-musl@14.2.33': + '@next/swc-linux-arm64-gnu@16.2.10': optional: true - '@next/swc-linux-x64-gnu@14.2.33': + '@next/swc-linux-arm64-musl@16.2.10': optional: true - '@next/swc-linux-x64-musl@14.2.33': + '@next/swc-linux-x64-gnu@16.2.10': optional: true - '@next/swc-win32-arm64-msvc@14.2.33': + '@next/swc-linux-x64-musl@16.2.10': optional: true - '@next/swc-win32-ia32-msvc@14.2.33': + '@next/swc-win32-arm64-msvc@16.2.10': optional: true - '@next/swc-win32-x64-msvc@14.2.33': + '@next/swc-win32-x64-msvc@16.2.10': optional: true '@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1': @@ -13909,11 +14160,8 @@ snapshots: transitivePeerDependencies: - supports-color - '@swc/counter@0.1.3': {} - - '@swc/helpers@0.5.5': + '@swc/helpers@0.5.15': dependencies: - '@swc/counter': 0.1.3 tslib: 2.8.1 '@szmarczak/http-timer@1.1.2': @@ -15381,10 +15629,6 @@ snapshots: dependencies: run-applescript: 7.1.0 - busboy@1.6.0: - dependencies: - streamsearch: 1.1.0 - bytes@3.1.2: {} bytestreamjs@2.0.1: {} @@ -15407,7 +15651,7 @@ snapshots: unique-filename: 1.1.1 y18n: 4.0.3 - cacache@15.3.0: + cacache@15.3.0(bluebird@3.7.2): dependencies: '@npmcli/fs': 1.1.1 '@npmcli/move-file': 1.1.2 @@ -15733,9 +15977,9 @@ snapshots: dependencies: mime-db: 1.54.0 - compression-webpack-plugin@6.1.2(webpack@4.47.0): + compression-webpack-plugin@6.1.2(bluebird@3.7.2)(webpack@4.47.0): dependencies: - cacache: 15.3.0 + cacache: 15.3.0(bluebird@3.7.2) find-cache-dir: 3.3.2 schema-utils: 3.3.0 serialize-javascript: 5.0.1 @@ -15840,7 +16084,7 @@ snapshots: copy-webpack-plugin@6.4.1(webpack@4.47.0): dependencies: - cacache: 15.3.0 + cacache: 15.3.0(bluebird@3.7.2) fast-glob: 3.3.3 find-cache-dir: 3.3.2 glob-parent: 5.1.2 @@ -17317,6 +17561,14 @@ snapshots: fast-diff@1.3.0: {} + fast-glob@3.3.1: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -19591,27 +19843,26 @@ snapshots: neo-async@2.6.2: {} - next@14.2.35(@babel/core@7.29.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + next@16.2.10(@babel/core@7.29.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - '@next/env': 14.2.35 - '@swc/helpers': 0.5.5 - busboy: 1.6.0 + '@next/env': 16.2.10 + '@swc/helpers': 0.5.15 + baseline-browser-mapping: 2.10.43 caniuse-lite: 1.0.30001805 - graceful-fs: 4.2.11 postcss: 8.4.31 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - styled-jsx: 5.1.1(@babel/core@7.29.7)(react@18.3.1) + styled-jsx: 5.1.6(@babel/core@7.29.7)(react@18.3.1) optionalDependencies: - '@next/swc-darwin-arm64': 14.2.33 - '@next/swc-darwin-x64': 14.2.33 - '@next/swc-linux-arm64-gnu': 14.2.33 - '@next/swc-linux-arm64-musl': 14.2.33 - '@next/swc-linux-x64-gnu': 14.2.33 - '@next/swc-linux-x64-musl': 14.2.33 - '@next/swc-win32-arm64-msvc': 14.2.33 - '@next/swc-win32-ia32-msvc': 14.2.33 - '@next/swc-win32-x64-msvc': 14.2.33 + '@next/swc-darwin-arm64': 16.2.10 + '@next/swc-darwin-x64': 16.2.10 + '@next/swc-linux-arm64-gnu': 16.2.10 + '@next/swc-linux-arm64-musl': 16.2.10 + '@next/swc-linux-x64-gnu': 16.2.10 + '@next/swc-linux-x64-musl': 16.2.10 + '@next/swc-win32-arm64-msvc': 16.2.10 + '@next/swc-win32-x64-msvc': 16.2.10 + sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' - babel-plugin-macros @@ -20696,7 +20947,7 @@ snapshots: powershell-utils@0.1.0: {} - preact-cli@3.5.1(@types/babel__core@7.20.5)(eslint@10.7.0(jiti@2.6.1))(preact-render-to-string@6.7.0)(preact@10.29.7)(typescript@6.0.3): + preact-cli@3.5.1(@types/babel__core@7.20.5)(bluebird@3.7.2)(eslint@10.7.0(jiti@2.6.1))(preact-render-to-string@6.7.0)(preact@10.29.7)(typescript@6.0.3): dependencies: '@babel/core': 7.29.7 '@babel/plugin-proposal-class-properties': 7.18.6(@babel/core@7.29.7) @@ -20717,7 +20968,7 @@ snapshots: babel-plugin-macros: 3.1.0 babel-plugin-transform-react-remove-prop-types: 0.4.24 browserslist: 4.28.6 - compression-webpack-plugin: 6.1.2(webpack@4.47.0) + compression-webpack-plugin: 6.1.2(bluebird@3.7.2)(webpack@4.47.0) console-clear: 1.1.1 copy-webpack-plugin: 6.4.1(webpack@4.47.0) critters-webpack-plugin: 2.5.0(html-webpack-plugin@3.2.0(webpack@4.47.0)) @@ -21752,6 +22003,38 @@ snapshots: dependencies: kind-of: 6.0.3 + sharp@0.34.5: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + optional: true + shebang-command@1.2.0: dependencies: shebang-regex: 1.0.0 @@ -22040,8 +22323,6 @@ snapshots: stream-shift@1.0.3: {} - streamsearch@1.1.0: {} - string-argv@0.3.2: {} string-length@4.0.2: @@ -22174,7 +22455,7 @@ snapshots: dependencies: webpack: 5.108.4(esbuild@0.28.1)(postcss@8.5.19) - styled-jsx@5.1.1(@babel/core@7.29.7)(react@18.3.1): + styled-jsx@5.1.6(@babel/core@7.29.7)(react@18.3.1): dependencies: client-only: 0.0.1 react: 18.3.1 @@ -22338,7 +22619,7 @@ snapshots: terser-webpack-plugin@4.2.3(webpack@4.47.0): dependencies: - cacache: 15.3.0 + cacache: 15.3.0(bluebird@3.7.2) find-cache-dir: 3.3.2 jest-worker: 26.6.2 p-limit: 3.1.0 From 1ff896ff7ceae3c928e8444b0fd63564fd5a8e53 Mon Sep 17 00:00:00 2001 From: Juraj Uhlar Date: Fri, 17 Jul 2026 08:51:50 +0100 Subject: [PATCH 02/41] fix: declare Node engine requirement in Next example apps Next.js 16 requires Node >=20.9.0; declare it explicitly so contributors on older Node get a clear engine error instead of a confusing runtime failure. --- examples/next-appDir/package.json | 3 +++ examples/next/package.json | 3 +++ 2 files changed, 6 insertions(+) diff --git a/examples/next-appDir/package.json b/examples/next-appDir/package.json index f738c891..771ff4e3 100644 --- a/examples/next-appDir/package.json +++ b/examples/next-appDir/package.json @@ -2,6 +2,9 @@ "name": "next-appDir", "version": "0.1.0", "private": true, + "engines": { + "node": ">=20.9.0" + }, "scripts": { "dev": "next dev --port=3003", "build": "next build", diff --git a/examples/next/package.json b/examples/next/package.json index 4bc5aefc..b41bf330 100644 --- a/examples/next/package.json +++ b/examples/next/package.json @@ -2,6 +2,9 @@ "name": "next-example", "version": "0.1.0", "private": true, + "engines": { + "node": ">=20.9.0" + }, "scripts": { "dev": "next dev --port=3002", "build": "next build", From f99911350041a66f32a51b35708d3628c825449c Mon Sep 17 00:00:00 2001 From: Juraj Uhlar Date: Fri, 17 Jul 2026 08:55:13 +0100 Subject: [PATCH 03/41] chore: update CI workflow name for clarity --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf4a551b..3e56c2ca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,11 +4,11 @@ on: branches-ignore: - main paths-ignore: - - '**.md' + - "**.md" jobs: build-and-check: - name: Build project and run CI checks + name: Build project and run CI checks (React 18) uses: fingerprintjs/dx-team-toolkit/.github/workflows/build-typescript-project.yml@v1 build-and-check-react-19: From ffc711f5822e45656840cc481561af0a139bd79e Mon Sep 17 00:00:00 2001 From: Juraj Uhlar Date: Fri, 17 Jul 2026 12:21:32 +0100 Subject: [PATCH 04/41] fix: configure Next ESLint rootDir and drop stale Next 14 reference no-html-link-for-pages (and other core-web-vitals rules) default to looking for pages/app at the repo root, so they silently no-op since both Next examples live under examples/. Set settings.next.rootDir to point at them explicitly. --- eslint.config.mjs | 6 ++++++ examples/next-appDir/README.md | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 6c5c4987..2edf1826 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -46,6 +46,12 @@ const config = [ { files: NEXT_EXAMPLE_FILES, ...nextPlugin.configs['core-web-vitals'], + settings: { + // Without this, rules like no-html-link-for-pages default to looking for + // pages/app at the repo root and silently no-op, since both examples live + // under examples/. + next: { rootDir: ['examples/next', 'examples/next-appDir'] }, + }, }, { // peerDependencies declare react >=18 <20, so React 19-only idioms (the `use` hook, diff --git a/examples/next-appDir/README.md b/examples/next-appDir/README.md index 43d6f0ca..21a95fd7 100644 --- a/examples/next-appDir/README.md +++ b/examples/next-appDir/README.md @@ -1,4 +1,4 @@ -This example demonstrates the usage of Fingerprint inside Next 14's `app` directory approach.\ +This example demonstrates the usage of Fingerprint inside Next.js's `app` directory approach.\ Note how you can use Fingerprint inside a React Server Component without issues as it is correctly executed in the browser only. See [../next](../next/README.md) for an example using the classic `pages` approach. From 8717c3e8950f8df13ac4bc2bb236c08a2a995bcb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 15:08:02 +0000 Subject: [PATCH 05/41] test(e2e): add Playwright e2e matrix for examples on React 18 & 19 Add a CI matrix that boots each example app with Playwright and asserts the Fingerprint React SDK identifies the visitor (a visitor ID renders in the browser). Each example is tested against the React versions it supports. - e2e/: a single Playwright harness driven by an EXAMPLE env var, with a per-example registry (examples.ts) and one framework-agnostic spec. - .github/workflows/e2e.yml: matrix of {example} x {React 18, 19}. React 19 jobs flip the pnpm catalog so the SDK and every example move in lockstep (a single @types/react, which the Next App Router type-check requires). The preact example runs once (it uses Preact via preact/compat). - examples: optional region env support (the CI key is EU) that leaves the default behavior unchanged. Also fixes pre-existing build breakage: the CRA example was missing react-app-env.d.ts, and the preact example's ESM preact.config.js failed to load on Node 22 (and was redundant with preact-cli's native .env / PREACT_APP_* injection) so it was removed and a scoped process type declaration added. INTER-2322 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_014F14pjZxadmtLq8uTuzwXb --- .github/workflows/e2e.yml | 103 ++++++++++++++++++ .gitignore | 5 + e2e/README.md | 43 ++++++++ e2e/examples.ts | 60 ++++++++++ e2e/playwright.config.ts | 39 +++++++ e2e/tests/example.spec.ts | 21 ++++ examples/create-react-app/.env.example | 4 +- .../src/in_memory_cache/InMemoryCache.tsx | 8 +- .../local_storage_cache/LocalStorageCache.tsx | 3 +- .../src/no_cache/WithoutCache.tsx | 4 +- .../create-react-app/src/react-app-env.d.ts | 5 + .../SessionStorageCache.tsx | 8 +- .../create-react-app/src/shared/utils/env.ts | 4 + examples/next-appDir/.env.example | 4 +- examples/next-appDir/app/layout.tsx | 8 +- examples/next/.env.example | 4 +- examples/next/pages/_app.tsx | 6 +- examples/preact/.env.example | 4 +- examples/preact/package.json | 1 - examples/preact/preact.config.js | 10 -- examples/preact/src/env.d.ts | 9 ++ examples/preact/src/index.tsx | 6 +- examples/vite/.env.example | 2 + examples/vite/src/main.tsx | 6 +- examples/vite/src/vite-env.d.ts | 1 + examples/webpack-based/.env.example | 2 + examples/webpack-based/src/index.js | 5 +- examples/webpack-based/webpack.config.js | 4 + package.json | 2 + pnpm-lock.yaml | 54 +++++++-- tsconfig.eslint.json | 1 + 31 files changed, 397 insertions(+), 39 deletions(-) create mode 100644 .github/workflows/e2e.yml create mode 100644 e2e/README.md create mode 100644 e2e/examples.ts create mode 100644 e2e/playwright.config.ts create mode 100644 e2e/tests/example.spec.ts create mode 100644 examples/create-react-app/src/react-app-env.d.ts delete mode 100644 examples/preact/preact.config.js create mode 100644 examples/preact/src/env.d.ts diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 00000000..222715e6 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,103 @@ +name: e2e + +on: + push: + branches-ignore: + - main + paths-ignore: + - "**.md" + workflow_dispatch: + +concurrency: + group: e2e-${{ github.ref }} + cancel-in-progress: true + +jobs: + e2e: + name: ${{ matrix.example }} (React ${{ matrix.react }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # Every example is tested against each React version it supports. The + # preact example uses Preact (via preact/compat) rather than React, so + # it runs once outside the React matrix. + include: + - { example: create-react-app, react: "18" } + - { example: create-react-app, react: "19" } + - { example: vite, react: "18" } + - { example: vite, react: "19" } + - { example: webpack-based, react: "18" } + - { example: webpack-based, react: "19" } + - { example: next, react: "18" } + - { example: next, react: "19" } + - { example: next-appDir, react: "18" } + - { example: next-appDir, react: "19" } + - { example: preact, react: "preact" } + env: + # A single repo secret feeds every framework's public-key variable; each + # example reads whichever one its bundler exposes. + VITE_FPJS_PUBLIC_API_KEY: ${{ secrets.FPJS_PUBLIC_API_KEY }} + REACT_APP_FPJS_PUBLIC_API_KEY: ${{ secrets.FPJS_PUBLIC_API_KEY }} + NEXT_PUBLIC_FPJS_PUBLIC_API_KEY: ${{ secrets.FPJS_PUBLIC_API_KEY }} + PREACT_APP_FPJS_PUBLIC_API_KEY: ${{ secrets.FPJS_PUBLIC_API_KEY }} + # The key used by CI lives in the EU region. + VITE_FPJS_REGION: eu + REACT_APP_FPJS_REGION: eu + NEXT_PUBLIC_FPJS_REGION: eu + PREACT_APP_FPJS_REGION: eu + steps: + - uses: actions/checkout@v4 + + - name: Ensure the public API key secret is set + run: | + if [ -z "${{ secrets.FPJS_PUBLIC_API_KEY }}" ]; then + echo "::error::FPJS_PUBLIC_API_KEY secret is not set. Add a public API key (EU region) to the repo secrets." + exit 1 + fi + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: lts/* + cache: pnpm + + - name: Switch the workspace to React 19 + if: matrix.react == '19' + # The catalog in pnpm-workspace.yaml is the single source of the React + # version for the SDK and every example, so flipping it moves the whole + # workspace to React 19 in lockstep (avoiding duplicate @types/react, + # which otherwise breaks the Next App Router type-check). + run: | + sed -i -E \ + -e 's/^( react: ).*/\1^19.0.0/' \ + -e 's/^( react-dom: ).*/\1^19.0.0/' \ + -e 's#^( "@types/react": ).*#\1^19.0.0#' \ + -e 's#^( "@types/react-dom": ).*#\1^19.0.0#' \ + pnpm-workspace.yaml + echo "Catalog after switch:" + grep -E '^ (react|react-dom|"@types/react"|"@types/react-dom"):' pnpm-workspace.yaml + + - name: Install dependencies + run: pnpm install ${{ matrix.react == '19' && '--no-frozen-lockfile' || '--frozen-lockfile' }} + + - name: Build the SDK + run: pnpm build + + - name: Install Playwright browser + run: pnpm exec playwright install --with-deps chromium + + - name: Run e2e tests + run: pnpm test:e2e + env: + EXAMPLE: ${{ matrix.example }} + + - name: Upload Playwright report + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v4 + with: + name: playwright-report-${{ matrix.example }}-react-${{ matrix.react }} + path: playwright-report/ + retention-days: 7 + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index 0326b51e..02277b60 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,11 @@ # Coverage /coverage/ +# Playwright +/playwright-report/ +/test-results/ +/e2e/.cache/ + # misc .DS_Store .env.local diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 00000000..bd6234f5 --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,43 @@ +# End-to-end tests + +Browser e2e tests that boot each [example app](../examples) with Playwright and +assert the Fingerprint React SDK identifies the visitor (a visitor ID is +rendered on the page). + +## How it works + +- [`examples.ts`](./examples.ts) is the single source of truth: it maps each + example directory to the command that starts its dev server and the port it + listens on. +- [`playwright.config.ts`](./playwright.config.ts) reads the `EXAMPLE` + environment variable, boots that one example, and points the tests at it. +- [`tests/example.spec.ts`](./tests/example.spec.ts) is framework-agnostic: it + loads the app and waits for a Fingerprint visitor ID to appear. + +CI runs one example per job across a matrix of React versions +(see [`.github/workflows/e2e.yml`](../.github/workflows/e2e.yml)). + +## Running locally + +A live Fingerprint **public API key** is required. The key's region must match +`*_FPJS_REGION` (see each example's `.env.example`). + +```bash +# from the repo root +pnpm install +pnpm build # build the SDK the examples consume + +# point the examples at your key + region +export VITE_FPJS_PUBLIC_API_KEY= +export REACT_APP_FPJS_PUBLIC_API_KEY= +export NEXT_PUBLIC_FPJS_PUBLIC_API_KEY= +export PREACT_APP_FPJS_PUBLIC_API_KEY= +export VITE_FPJS_REGION=eu REACT_APP_FPJS_REGION=eu NEXT_PUBLIC_FPJS_REGION=eu PREACT_APP_FPJS_REGION=eu + +pnpm exec playwright install chromium + +EXAMPLE=vite pnpm test:e2e +``` + +Valid `EXAMPLE` values: `create-react-app`, `next`, `next-appDir`, `preact`, +`vite`, `webpack-based`. diff --git a/e2e/examples.ts b/e2e/examples.ts new file mode 100644 index 00000000..318c85b8 --- /dev/null +++ b/e2e/examples.ts @@ -0,0 +1,60 @@ +/** + * Single source of truth for the example apps exercised by the e2e suite. + * + * Each entry maps an example directory name (also used as the `EXAMPLE` env var + * and the CI matrix key) to how Playwright should start and reach it. A CI job + * runs exactly one example, selected via `EXAMPLE`, so ports only need to be + * unique enough to run locally side by side. + * + * Commands use pnpm path filters (`--filter ./examples/`) so the directory + * name is the only identifier needed here, in CI, and in the React-version + * override step. + */ +export interface ExampleConfig { + /** Port the dev server listens on. */ + port: number + /** Command Playwright runs to boot the example (from the repo root). */ + command: string + /** Extra env for the dev server process. */ + env?: Record +} + +export const EXAMPLES: Record = { + 'create-react-app': { + port: 3001, + command: 'pnpm --filter ./examples/create-react-app dev', + // react-scripts otherwise tries to open a browser and treats warnings as + // errors under CI. + env: { BROWSER: 'none', CI: 'false' }, + }, + next: { + port: 3002, + command: 'pnpm --filter ./examples/next dev', + }, + 'next-appDir': { + port: 3003, + command: 'pnpm --filter ./examples/next-appDir dev', + }, + preact: { + port: 8080, + command: 'pnpm --filter ./examples/preact dev -- -p 8080', + }, + vite: { + port: 5173, + command: 'pnpm --filter ./examples/vite dev -- --port 5173 --strictPort', + }, + 'webpack-based': { + port: 8081, + command: 'pnpm --filter ./examples/webpack-based dev -- --port 8081', + }, +} + +export function resolveExample(name: string | undefined): { name: string; config: ExampleConfig } { + if (name === undefined || name === '') { + throw new Error(`EXAMPLE env var is required. One of: ${Object.keys(EXAMPLES).join(', ')}`) + } + if (!(name in EXAMPLES)) { + throw new Error(`Unknown example "${name}". One of: ${Object.keys(EXAMPLES).join(', ')}`) + } + return { name, config: EXAMPLES[name] } +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 00000000..8fec2ce2 --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,39 @@ +import { defineConfig, devices } from '@playwright/test' +import path from 'path' +import { resolveExample } from './examples' + +// Playwright compiles this config as CommonJS, so `__dirname` (the e2e/ dir) is +// available; its parent is the repo root, from where the dev servers are run. +const repoRoot = path.resolve(__dirname, '..') + +// A CI job runs one example at a time. `EXAMPLE` selects which one; the matching +// dev server is booted by Playwright and torn down when the run finishes. +const { name, config } = resolveExample(process.env.EXAMPLE) +const baseURL = `http://localhost:${String(config.port)}` +const isCI = Boolean(process.env.CI) + +export default defineConfig({ + testDir: './tests', + fullyParallel: true, + forbidOnly: isCI, + // The JS agent talks to a remote service, so the first load can be flaky. + retries: isCI ? 2 : 0, + reporter: isCI ? [['github'], ['list'], ['html', { open: 'never' }]] : 'list', + use: { + baseURL, + trace: 'on-first-retry', + }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], + webServer: { + command: config.command, + cwd: repoRoot, + url: baseURL, + reuseExistingServer: !isCI, + // Dev servers (Next/CRA) compile on boot, so allow a generous window. + timeout: 180_000, + stdout: 'pipe', + stderr: 'pipe', + env: config.env, + }, + metadata: { example: name }, +}) diff --git a/e2e/tests/example.spec.ts b/e2e/tests/example.spec.ts new file mode 100644 index 00000000..b8938892 --- /dev/null +++ b/e2e/tests/example.spec.ts @@ -0,0 +1,21 @@ +import { test, expect } from '@playwright/test' + +// A Fingerprint v4 visitor ID is a 20-character alphanumeric string. Every +// example renders it once identification succeeds (as "Welcome !" or in a +// "VisitorId:" field), so asserting the pattern appears verifies the SDK loaded +// the agent, called the API, and surfaced a result through the React hooks. +const VISITOR_ID = /[A-Za-z0-9]{20}/ + +test('identifies the visitor and renders a visitor ID', async ({ page }) => { + const errors: string[] = [] + page.on('pageerror', (err) => errors.push(err.message)) + + await page.goto('/') + + await expect(async () => { + const body = (await page.locator('body').textContent()) ?? '' + expect(body, `page still shows no visitor ID:\n${body}`).toMatch(VISITOR_ID) + }).toPass({ timeout: 60_000 }) + + expect(errors, `uncaught errors on the page:\n${errors.join('\n')}`).toEqual([]) +}) diff --git a/examples/create-react-app/.env.example b/examples/create-react-app/.env.example index dab2644c..54c8d4a8 100644 --- a/examples/create-react-app/.env.example +++ b/examples/create-react-app/.env.example @@ -1 +1,3 @@ -REACT_APP_FPJS_PUBLIC_API_KEY= \ No newline at end of file +REACT_APP_FPJS_PUBLIC_API_KEY= +# Optional. One of: us (default), eu, ap. Leave empty for the default region. +REACT_APP_FPJS_REGION= diff --git a/examples/create-react-app/src/in_memory_cache/InMemoryCache.tsx b/examples/create-react-app/src/in_memory_cache/InMemoryCache.tsx index 0e552a59..b16170a5 100644 --- a/examples/create-react-app/src/in_memory_cache/InMemoryCache.tsx +++ b/examples/create-react-app/src/in_memory_cache/InMemoryCache.tsx @@ -1,11 +1,15 @@ import { FingerprintProvider } from '@fingerprint/react' import { Outlet } from 'react-router-dom' -import { FPJS_API_KEY } from '../shared/utils/env' +import { FPJS_API_KEY, FPJS_REGION } from '../shared/utils/env' import { Nav } from '../shared/components/Nav' function InMemoryCache() { return ( - +

Solution with an in-memory cache

diff --git a/examples/create-react-app/src/local_storage_cache/LocalStorageCache.tsx b/examples/create-react-app/src/local_storage_cache/LocalStorageCache.tsx index 0524e684..f06db439 100644 --- a/examples/create-react-app/src/local_storage_cache/LocalStorageCache.tsx +++ b/examples/create-react-app/src/local_storage_cache/LocalStorageCache.tsx @@ -1,12 +1,13 @@ import { Outlet } from 'react-router-dom' import { Nav } from '../shared/components/Nav' -import { FPJS_API_KEY } from '../shared/utils/env' +import { FPJS_API_KEY, FPJS_REGION } from '../shared/utils/env' import { FingerprintProvider } from '@fingerprint/react' function LocalStorageCache() { return ( +

Solution without cache

diff --git a/examples/create-react-app/src/react-app-env.d.ts b/examples/create-react-app/src/react-app-env.d.ts new file mode 100644 index 00000000..4ac57bf1 --- /dev/null +++ b/examples/create-react-app/src/react-app-env.d.ts @@ -0,0 +1,5 @@ +/// + +// react-scripts' bundled types don't declare a plain (side-effect) CSS import +// under this repo's TypeScript version, so declare it explicitly. +declare module '*.css' diff --git a/examples/create-react-app/src/session_storage_cache/SessionStorageCache.tsx b/examples/create-react-app/src/session_storage_cache/SessionStorageCache.tsx index f76addca..540fb0be 100644 --- a/examples/create-react-app/src/session_storage_cache/SessionStorageCache.tsx +++ b/examples/create-react-app/src/session_storage_cache/SessionStorageCache.tsx @@ -1,11 +1,15 @@ import { FingerprintProvider } from '@fingerprint/react' import { Outlet } from 'react-router-dom' import { Nav } from '../shared/components/Nav' -import { FPJS_API_KEY } from '../shared/utils/env' +import { FPJS_API_KEY, FPJS_REGION } from '../shared/utils/env' function SessionStorageCache() { return ( - +

Solution with a custom implementation of a session storage cache

diff --git a/examples/create-react-app/src/shared/utils/env.ts b/examples/create-react-app/src/shared/utils/env.ts index 39f62452..468b53eb 100644 --- a/examples/create-react-app/src/shared/utils/env.ts +++ b/examples/create-react-app/src/shared/utils/env.ts @@ -1 +1,5 @@ export const FPJS_API_KEY = process.env.REACT_APP_FPJS_PUBLIC_API_KEY ?? 'test_public_key' + +// Optional. Defaults to the SDK's default region (us) when unset or invalid. +const rawRegion = process.env.REACT_APP_FPJS_REGION +export const FPJS_REGION = rawRegion === 'us' || rawRegion === 'eu' || rawRegion === 'ap' ? rawRegion : undefined diff --git a/examples/next-appDir/.env.example b/examples/next-appDir/.env.example index a7dd9114..e8c721be 100644 --- a/examples/next-appDir/.env.example +++ b/examples/next-appDir/.env.example @@ -1 +1,3 @@ -NEXT_PUBLIC_FPJS_PUBLIC_API_KEY= \ No newline at end of file +NEXT_PUBLIC_FPJS_PUBLIC_API_KEY= +# Optional. One of: us (default), eu, ap. Leave empty for the default region. +NEXT_PUBLIC_FPJS_REGION= diff --git a/examples/next-appDir/app/layout.tsx b/examples/next-appDir/app/layout.tsx index 53c035e1..b5825f6e 100644 --- a/examples/next-appDir/app/layout.tsx +++ b/examples/next-appDir/app/layout.tsx @@ -13,11 +13,17 @@ function getPublicApiKey(): string { const fpjsPublicApiKey = getPublicApiKey() +// Optional. Defaults to the SDK's default region (us) when unset or invalid. +const rawRegion = process.env.NEXT_PUBLIC_FPJS_REGION +const fpjsRegion = rawRegion === 'us' || rawRegion === 'eu' || rawRegion === 'ap' ? rawRegion : undefined + function RootLayout({ children }: PropsWithChildren) { return ( - {children} + + {children} + ) diff --git a/examples/next/.env.example b/examples/next/.env.example index a7dd9114..e8c721be 100644 --- a/examples/next/.env.example +++ b/examples/next/.env.example @@ -1 +1,3 @@ -NEXT_PUBLIC_FPJS_PUBLIC_API_KEY= \ No newline at end of file +NEXT_PUBLIC_FPJS_PUBLIC_API_KEY= +# Optional. One of: us (default), eu, ap. Leave empty for the default region. +NEXT_PUBLIC_FPJS_REGION= diff --git a/examples/next/pages/_app.tsx b/examples/next/pages/_app.tsx index 42fade55..ca71d246 100644 --- a/examples/next/pages/_app.tsx +++ b/examples/next/pages/_app.tsx @@ -13,9 +13,13 @@ function getPublicApiKey(): string { const fpjsPublicApiKey = getPublicApiKey() +// Optional. Defaults to the SDK's default region (us) when unset or invalid. +const rawRegion = process.env.NEXT_PUBLIC_FPJS_REGION +const fpjsRegion = rawRegion === 'us' || rawRegion === 'eu' || rawRegion === 'ap' ? rawRegion : undefined + function MyApp({ Component, pageProps }: AppProps) { return ( - + ) diff --git a/examples/preact/.env.example b/examples/preact/.env.example index 8961791d..e5f8a44b 100644 --- a/examples/preact/.env.example +++ b/examples/preact/.env.example @@ -1 +1,3 @@ -PREACT_APP_FPJS_PUBLIC_API_KEY= \ No newline at end of file +PREACT_APP_FPJS_PUBLIC_API_KEY= +# Optional. One of: us (default), eu, ap. Leave empty for the default region. +PREACT_APP_FPJS_REGION= diff --git a/examples/preact/package.json b/examples/preact/package.json index 3866d6bb..ba126f5f 100644 --- a/examples/preact/package.json +++ b/examples/preact/package.json @@ -15,7 +15,6 @@ "preact-render-to-string": "^6.7.0" }, "devDependencies": { - "dotenv": "^17.4.2", "preact-cli": "^3.5.1", "sirv-cli": "^3.0.1", "typescript": "catalog:" diff --git a/examples/preact/preact.config.js b/examples/preact/preact.config.js deleted file mode 100644 index a92bd82a..00000000 --- a/examples/preact/preact.config.js +++ /dev/null @@ -1,10 +0,0 @@ -import dotenv from 'dotenv' - -dotenv.config() - -export default (config, env, helpers) => { - const { plugin } = helpers.getPluginsByName(config, 'DefinePlugin')[0] - plugin.definitions['process.env.PREACT_APP_FPJS_PUBLIC_API_KEY'] = JSON.stringify( - process.env.PREACT_APP_FPJS_PUBLIC_API_KEY - ) -} diff --git a/examples/preact/src/env.d.ts b/examples/preact/src/env.d.ts new file mode 100644 index 00000000..bdb37b5e --- /dev/null +++ b/examples/preact/src/env.d.ts @@ -0,0 +1,9 @@ +// The build-time env vars are inlined by preact.config.js's DefinePlugin. +// Declare just the ones this example reads so TypeScript knows about `process` +// without pulling in all of @types/node into a browser app. +declare const process: { + env: { + PREACT_APP_FPJS_PUBLIC_API_KEY?: string + PREACT_APP_FPJS_REGION?: string + } +} diff --git a/examples/preact/src/index.tsx b/examples/preact/src/index.tsx index c01937a2..6c90e5c7 100644 --- a/examples/preact/src/index.tsx +++ b/examples/preact/src/index.tsx @@ -9,8 +9,12 @@ const WrappedApp: FunctionalComponent = () => { throw new Error('PREACT_APP_FPJS_PUBLIC_API_KEY is not set') } + // Optional. Defaults to the SDK's default region (us) when unset or invalid. + const rawRegion = process.env.PREACT_APP_FPJS_REGION + const region = rawRegion === 'us' || rawRegion === 'eu' || rawRegion === 'ap' ? rawRegion : undefined + return ( - + ) diff --git a/examples/vite/.env.example b/examples/vite/.env.example index acd0b4e5..42b9c658 100644 --- a/examples/vite/.env.example +++ b/examples/vite/.env.example @@ -1 +1,3 @@ VITE_FPJS_PUBLIC_API_KEY= +# Optional. One of: us (default), eu, ap. Leave empty for the default region. +VITE_FPJS_REGION= diff --git a/examples/vite/src/main.tsx b/examples/vite/src/main.tsx index fae354a3..48e30098 100644 --- a/examples/vite/src/main.tsx +++ b/examples/vite/src/main.tsx @@ -9,6 +9,10 @@ if (apiKey === undefined || apiKey === '') { throw new Error('VITE_FPJS_PUBLIC_API_KEY is not set') } +// Optional. Defaults to the SDK's default region (us) when unset or invalid. +const rawRegion = import.meta.env.VITE_FPJS_REGION +const region = rawRegion === 'us' || rawRegion === 'eu' || rawRegion === 'ap' ? rawRegion : undefined + const rootElement = document.getElementById('root') if (rootElement === null) { throw new Error('Root element not found') @@ -16,7 +20,7 @@ if (rootElement === null) { createRoot(rootElement).render( - + diff --git a/examples/vite/src/vite-env.d.ts b/examples/vite/src/vite-env.d.ts index af5f53d7..f8127547 100644 --- a/examples/vite/src/vite-env.d.ts +++ b/examples/vite/src/vite-env.d.ts @@ -2,6 +2,7 @@ interface ImportMetaEnv { readonly VITE_FPJS_PUBLIC_API_KEY?: string + readonly VITE_FPJS_REGION?: string } interface ImportMeta { diff --git a/examples/webpack-based/.env.example b/examples/webpack-based/.env.example index 994ffaea..39e24857 100644 --- a/examples/webpack-based/.env.example +++ b/examples/webpack-based/.env.example @@ -1 +1,3 @@ REACT_APP_FPJS_PUBLIC_API_KEY= +# Optional. One of: us (default), eu, ap. Leave empty for the default region. +REACT_APP_FPJS_REGION= diff --git a/examples/webpack-based/src/index.js b/examples/webpack-based/src/index.js index 3ba95769..f4283e43 100644 --- a/examples/webpack-based/src/index.js +++ b/examples/webpack-based/src/index.js @@ -6,10 +6,13 @@ import App from './App' const rootElement = document.getElementById('root') const root = createRoot(rootElement) const apiKey = process.env.REACT_APP_FPJS_PUBLIC_API_KEY +// Optional. Defaults to the SDK's default region (us) when unset or invalid. +const rawRegion = process.env.REACT_APP_FPJS_REGION +const region = rawRegion === 'us' || rawRegion === 'eu' || rawRegion === 'ap' ? rawRegion : undefined root.render( - + diff --git a/examples/webpack-based/webpack.config.js b/examples/webpack-based/webpack.config.js index ca3a6dc6..38939c2d 100644 --- a/examples/webpack-based/webpack.config.js +++ b/examples/webpack-based/webpack.config.js @@ -27,6 +27,10 @@ module.exports = { plugins: [ new Dotenv({ path: './.env.local', + // Fall back to process.env (e.g. CI-provided vars) and stay quiet when + // the local file is absent, so the example builds without a .env.local. + systemvars: true, + silent: true, }), new HtmlWebpackPlugin({ template: './src/index.html', // base html diff --git a/package.json b/package.json index c0c53af7..37b35cc2 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "test:coverage": "vitest run --coverage", "test:coverage:diff": "vitest run --coverage --reporter json --outputFile.json=report.json", "test:dts": "tsc --noEmit --isolatedModules --ignoreConfig dist/fingerprint-react.d.ts", + "test:e2e": "playwright test --config e2e/playwright.config.ts", "docs": "typedoc src/index.ts --out docs", "changeset:version": "changeset version", "changeset:publish": "HUSKY=0 changeset publish" @@ -76,6 +77,7 @@ "@fingerprintjs/tsconfig-dx-team": "^0.0.2", "@microsoft/api-extractor": "^7.58.9", "@next/eslint-plugin-next": "^16.2.10", + "@playwright/test": "^1.61.1", "@testing-library/preact": "^3.2.4", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e2fdf1f..9cb6a8de 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,6 +66,9 @@ importers: '@next/eslint-plugin-next': specifier: ^16.2.10 version: 16.2.10 + '@playwright/test': + specifier: ^1.61.1 + version: 1.61.1 '@testing-library/preact': specifier: ^3.2.4 version: 3.2.4(preact@10.29.7) @@ -174,7 +177,7 @@ importers: version: link:../.. next: specifier: 16.2.10 - version: 16.2.10(@babel/core@7.29.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 16.2.10(@babel/core@7.29.7)(@playwright/test@1.61.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) devDependencies: '@types/node': specifier: 'catalog:' @@ -202,7 +205,7 @@ importers: version: link:../.. next: specifier: 16.2.10 - version: 16.2.10(@babel/core@7.29.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 16.2.10(@babel/core@7.29.7)(@playwright/test@1.61.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) devDependencies: '@types/node': specifier: 'catalog:' @@ -235,9 +238,6 @@ importers: specifier: ^6.7.0 version: 6.7.0(preact@10.29.7) devDependencies: - dotenv: - specifier: ^17.4.2 - version: 17.4.2 preact-cli: specifier: ^3.5.1 version: 3.5.1(@types/babel__core@7.20.5)(bluebird@3.7.2)(eslint@10.7.0(jiti@2.6.1))(preact-render-to-string@6.7.0)(preact@10.29.7)(typescript@6.0.3) @@ -2651,6 +2651,11 @@ packages: resolution: {integrity: sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==} engines: {node: ^14.18.0 || >=16.0.0} + '@playwright/test@1.61.1': + resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==} + engines: {node: '>=18'} + hasBin: true + '@pmmmwh/react-refresh-webpack-plugin@0.5.17': resolution: {integrity: sha512-tXDyE1/jzFsHXjhRZQ3hMl0IVhYe5qula43LDWIhVfjp9G/nT5OQY5AORVOrkEGAUltBJOfOWeETbmhm6kHhuQ==} engines: {node: '>= 10.13'} @@ -5206,10 +5211,6 @@ packages: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} - dotenv@17.4.2: - resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} - engines: {node: '>=12'} - dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -5949,6 +5950,11 @@ packages: os: [darwin] deprecated: Upgrade to fsevents v2 to mitigate potential security issues + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -8091,6 +8097,16 @@ packages: resolution: {integrity: sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==} engines: {node: '>=16.0.0'} + playwright-core@1.61.1: + resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.61.1: + resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==} + engines: {node: '>=18'} + hasBin: true + pn@1.1.0: resolution: {integrity: sha512-2qHaIQr2VLRFoxe2nASzsV6ef4yOOH+Fi9FBOVH6cqeSgUnoyySPZkxzLuzd+RYOQTRpROA0ztTMqxROKSb/nA==} @@ -13795,6 +13811,10 @@ snapshots: '@pkgr/core@0.3.6': {} + '@playwright/test@1.61.1': + dependencies: + playwright: 1.61.1 + '@pmmmwh/react-refresh-webpack-plugin@0.5.17(@types/webpack@4.41.40)(react-refresh@0.11.0)(type-fest@0.21.3)(webpack-dev-server@4.15.2(webpack@5.108.4(esbuild@0.28.1)(postcss@8.5.19)))(webpack@5.108.4(esbuild@0.28.1)(postcss@8.5.19))': dependencies: ansi-html: 0.0.9 @@ -16707,8 +16727,6 @@ snapshots: dotenv@16.6.1: {} - dotenv@17.4.2: {} - dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -17837,6 +17855,9 @@ snapshots: nan: 2.28.0 optional: true + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -19843,7 +19864,7 @@ snapshots: neo-async@2.6.2: {} - next@16.2.10(@babel/core@7.29.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + next@16.2.10(@babel/core@7.29.7)(@playwright/test@1.61.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@next/env': 16.2.10 '@swc/helpers': 0.5.15 @@ -19862,6 +19883,7 @@ snapshots: '@next/swc-linux-x64-musl': 16.2.10 '@next/swc-win32-arm64-msvc': 16.2.10 '@next/swc-win32-x64-msvc': 16.2.10 + '@playwright/test': 1.61.1 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' @@ -20307,6 +20329,14 @@ snapshots: pvutils: 1.1.5 tslib: 2.8.1 + playwright-core@1.61.1: {} + + playwright@1.61.1: + dependencies: + playwright-core: 1.61.1 + optionalDependencies: + fsevents: 2.3.2 + pn@1.1.0: {} pnp-webpack-plugin@1.7.0(typescript@6.0.3): diff --git a/tsconfig.eslint.json b/tsconfig.eslint.json index b9b4aebe..851a49cd 100644 --- a/tsconfig.eslint.json +++ b/tsconfig.eslint.json @@ -9,6 +9,7 @@ "__tests__", "vite.config.ts", "vitest.config.ts", + "e2e/**/*.ts", "examples/**/*.ts", "examples/**/*.tsx", "examples/vite/src/vite-env.d.ts" From 25fa8a6746b0754ab5b0841977299a4928152ecf Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 21:38:11 +0000 Subject: [PATCH 06/41] ci(e2e): source the region from the FPJS_REGION repo variable Read the Fingerprint region from the FPJS_REGION Actions variable (defaulting to eu) instead of hardcoding it, so the region is configurable alongside the public key secret. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_014F14pjZxadmtLq8uTuzwXb --- .github/workflows/e2e.yml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 222715e6..2bcedf88 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -41,11 +41,12 @@ jobs: REACT_APP_FPJS_PUBLIC_API_KEY: ${{ secrets.FPJS_PUBLIC_API_KEY }} NEXT_PUBLIC_FPJS_PUBLIC_API_KEY: ${{ secrets.FPJS_PUBLIC_API_KEY }} PREACT_APP_FPJS_PUBLIC_API_KEY: ${{ secrets.FPJS_PUBLIC_API_KEY }} - # The key used by CI lives in the EU region. - VITE_FPJS_REGION: eu - REACT_APP_FPJS_REGION: eu - NEXT_PUBLIC_FPJS_REGION: eu - PREACT_APP_FPJS_REGION: eu + # Region of the CI key, from the FPJS_REGION repo variable (one of us/eu/ + # ap). Defaults to eu, the region of the key this matrix was set up with. + VITE_FPJS_REGION: ${{ vars.FPJS_REGION || 'eu' }} + REACT_APP_FPJS_REGION: ${{ vars.FPJS_REGION || 'eu' }} + NEXT_PUBLIC_FPJS_REGION: ${{ vars.FPJS_REGION || 'eu' }} + PREACT_APP_FPJS_REGION: ${{ vars.FPJS_REGION || 'eu' }} steps: - uses: actions/checkout@v4 From 3e2020a790a41b235ae2e75fbfd07735bca4d9e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 21:45:57 +0000 Subject: [PATCH 07/41] ci(e2e): require FPJS_REGION with no default Drop the eu fallback so the region comes solely from the FPJS_REGION repo variable, and fail fast in the pre-flight check when it (or the API key secret) is missing. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_014F14pjZxadmtLq8uTuzwXb --- .github/workflows/e2e.yml | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 2bcedf88..730834a0 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -41,19 +41,22 @@ jobs: REACT_APP_FPJS_PUBLIC_API_KEY: ${{ secrets.FPJS_PUBLIC_API_KEY }} NEXT_PUBLIC_FPJS_PUBLIC_API_KEY: ${{ secrets.FPJS_PUBLIC_API_KEY }} PREACT_APP_FPJS_PUBLIC_API_KEY: ${{ secrets.FPJS_PUBLIC_API_KEY }} - # Region of the CI key, from the FPJS_REGION repo variable (one of us/eu/ - # ap). Defaults to eu, the region of the key this matrix was set up with. - VITE_FPJS_REGION: ${{ vars.FPJS_REGION || 'eu' }} - REACT_APP_FPJS_REGION: ${{ vars.FPJS_REGION || 'eu' }} - NEXT_PUBLIC_FPJS_REGION: ${{ vars.FPJS_REGION || 'eu' }} - PREACT_APP_FPJS_REGION: ${{ vars.FPJS_REGION || 'eu' }} + # Region of the CI key, from the FPJS_REGION repo variable (one of us/eu/ap). + VITE_FPJS_REGION: ${{ vars.FPJS_REGION }} + REACT_APP_FPJS_REGION: ${{ vars.FPJS_REGION }} + NEXT_PUBLIC_FPJS_REGION: ${{ vars.FPJS_REGION }} + PREACT_APP_FPJS_REGION: ${{ vars.FPJS_REGION }} steps: - uses: actions/checkout@v4 - - name: Ensure the public API key secret is set + - name: Ensure the API key and region are configured run: | if [ -z "${{ secrets.FPJS_PUBLIC_API_KEY }}" ]; then - echo "::error::FPJS_PUBLIC_API_KEY secret is not set. Add a public API key (EU region) to the repo secrets." + echo "::error::FPJS_PUBLIC_API_KEY secret is not set. Add a public API key to the repo secrets." + exit 1 + fi + if [ -z "${{ vars.FPJS_REGION }}" ]; then + echo "::error::FPJS_REGION variable is not set. Set it (us, eu, or ap) to match the key's region." exit 1 fi From 9759b06b376659ba60eeefff37c7dffb24bf6ebe Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 21:57:48 +0000 Subject: [PATCH 08/41] ci(e2e): accept the public API key as a secret or a variable The Fingerprint public key isn't sensitive, so read FPJS_PUBLIC_API_KEY from either a repo secret or a repo variable. Fixes the matrix failing when the key was added under the Variables tab alongside FPJS_REGION. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_014F14pjZxadmtLq8uTuzwXb --- .github/workflows/e2e.yml | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 730834a0..30cee168 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -35,12 +35,14 @@ jobs: - { example: next-appDir, react: "19" } - { example: preact, react: "preact" } env: - # A single repo secret feeds every framework's public-key variable; each - # example reads whichever one its bundler exposes. - VITE_FPJS_PUBLIC_API_KEY: ${{ secrets.FPJS_PUBLIC_API_KEY }} - REACT_APP_FPJS_PUBLIC_API_KEY: ${{ secrets.FPJS_PUBLIC_API_KEY }} - NEXT_PUBLIC_FPJS_PUBLIC_API_KEY: ${{ secrets.FPJS_PUBLIC_API_KEY }} - PREACT_APP_FPJS_PUBLIC_API_KEY: ${{ secrets.FPJS_PUBLIC_API_KEY }} + # A single FPJS_PUBLIC_API_KEY feeds every framework's public-key variable + # (each example reads whichever one its bundler exposes). The key is a + # Fingerprint *public* key, so it works as either a repo secret or a repo + # variable. + VITE_FPJS_PUBLIC_API_KEY: ${{ secrets.FPJS_PUBLIC_API_KEY || vars.FPJS_PUBLIC_API_KEY }} + REACT_APP_FPJS_PUBLIC_API_KEY: ${{ secrets.FPJS_PUBLIC_API_KEY || vars.FPJS_PUBLIC_API_KEY }} + NEXT_PUBLIC_FPJS_PUBLIC_API_KEY: ${{ secrets.FPJS_PUBLIC_API_KEY || vars.FPJS_PUBLIC_API_KEY }} + PREACT_APP_FPJS_PUBLIC_API_KEY: ${{ secrets.FPJS_PUBLIC_API_KEY || vars.FPJS_PUBLIC_API_KEY }} # Region of the CI key, from the FPJS_REGION repo variable (one of us/eu/ap). VITE_FPJS_REGION: ${{ vars.FPJS_REGION }} REACT_APP_FPJS_REGION: ${{ vars.FPJS_REGION }} @@ -51,8 +53,8 @@ jobs: - name: Ensure the API key and region are configured run: | - if [ -z "${{ secrets.FPJS_PUBLIC_API_KEY }}" ]; then - echo "::error::FPJS_PUBLIC_API_KEY secret is not set. Add a public API key to the repo secrets." + if [ -z "${{ secrets.FPJS_PUBLIC_API_KEY || vars.FPJS_PUBLIC_API_KEY }}" ]; then + echo "::error::FPJS_PUBLIC_API_KEY is not set. Add a public API key as a repo secret or variable." exit 1 fi if [ -z "${{ vars.FPJS_REGION }}" ]; then From 790245925d3bcd999e52773aeb1a727b7ab7508e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 22:05:26 +0000 Subject: [PATCH 09/41] ci(e2e): serve the preact example from a build instead of watch preact-cli's dev server (`preact watch`) fails to resolve its entrypoint on the CI Node version and never serves, so the e2e job timed out. Build the example once and serve the static output with sirv instead. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_014F14pjZxadmtLq8uTuzwXb --- e2e/examples.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/e2e/examples.ts b/e2e/examples.ts index 318c85b8..a28cd96d 100644 --- a/e2e/examples.ts +++ b/e2e/examples.ts @@ -36,8 +36,11 @@ export const EXAMPLES: Record = { command: 'pnpm --filter ./examples/next-appDir dev', }, preact: { + // preact-cli's dev server (`watch`) fails to resolve its entrypoint on the + // CI Node version, so build once and serve the static output with sirv + // (its `serve` script listens on 8080). port: 8080, - command: 'pnpm --filter ./examples/preact dev -- -p 8080', + command: 'pnpm --filter ./examples/preact build && pnpm --filter ./examples/preact serve', }, vite: { port: 5173, From 943e78e2f6ee87dbf0c11317edc0ad811b357e97 Mon Sep 17 00:00:00 2001 From: Juraj Uhlar Date: Mon, 20 Jul 2026 00:18:14 +0200 Subject: [PATCH 10/41] ci(e2e): harden workflow validation --- .github/workflows/e2e.yml | 16 +++++++++++----- e2e/playwright.config.ts | 2 ++ examples/preact/src/env.d.ts | 2 +- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 30cee168..5f4bf368 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -8,6 +8,9 @@ on: - "**.md" workflow_dispatch: +permissions: + contents: read + concurrency: group: e2e-${{ github.ref }} cancel-in-progress: true @@ -53,14 +56,17 @@ jobs: - name: Ensure the API key and region are configured run: | - if [ -z "${{ secrets.FPJS_PUBLIC_API_KEY || vars.FPJS_PUBLIC_API_KEY }}" ]; then + if [ -z "$VITE_FPJS_PUBLIC_API_KEY" ]; then echo "::error::FPJS_PUBLIC_API_KEY is not set. Add a public API key as a repo secret or variable." exit 1 fi - if [ -z "${{ vars.FPJS_REGION }}" ]; then - echo "::error::FPJS_REGION variable is not set. Set it (us, eu, or ap) to match the key's region." - exit 1 - fi + case "$VITE_FPJS_REGION" in + us|eu|ap) ;; + *) + echo "::error::FPJS_REGION must be set to us, eu, or ap to match the key's region." + exit 1 + ;; + esac - uses: pnpm/action-setup@v4 diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 8fec2ce2..1389ad2f 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -14,6 +14,8 @@ const isCI = Boolean(process.env.CI) export default defineConfig({ testDir: './tests', + // Leave enough headroom for the test's 60-second remote-service assertion. + timeout: 90_000, fullyParallel: true, forbidOnly: isCI, // The JS agent talks to a remote service, so the first load can be flaky. diff --git a/examples/preact/src/env.d.ts b/examples/preact/src/env.d.ts index bdb37b5e..9f6a0128 100644 --- a/examples/preact/src/env.d.ts +++ b/examples/preact/src/env.d.ts @@ -1,4 +1,4 @@ -// The build-time env vars are inlined by preact.config.js's DefinePlugin. +// Preact CLI inlines PREACT_APP_* environment variables at build time. // Declare just the ones this example reads so TypeScript knows about `process` // without pulling in all of @types/node into a browser app. declare const process: { From 1dd3d8d6762c41a66bd4140bbdc3168623ed6837 Mon Sep 17 00:00:00 2001 From: Juraj Uhlar Date: Mon, 20 Jul 2026 01:28:13 +0200 Subject: [PATCH 11/41] test(e2e): harden visitor-id assertion and React 19 switch Align examples on a shared data-testid for successful identifies, flip React via pnpm overrides instead of sed, and verify the installed React major in CI. --- .github/workflows/e2e.yml | 39 +++++++++++++------ e2e/README.md | 2 +- e2e/tests/example.spec.ts | 15 +++---- .../components/VisitorDataPresenter.tsx | 6 ++- examples/next-appDir/app/HomePage.tsx | 5 ++- examples/next/pages/index.tsx | 5 ++- examples/preact/src/components/app.tsx | 5 ++- examples/vite/src/App.tsx | 14 ++++--- examples/webpack-based/src/App.js | 12 ++++-- 9 files changed, 67 insertions(+), 36 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 5f4bf368..a7ded740 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -77,23 +77,38 @@ jobs: - name: Switch the workspace to React 19 if: matrix.react == '19' - # The catalog in pnpm-workspace.yaml is the single source of the React - # version for the SDK and every example, so flipping it moves the whole - # workspace to React 19 in lockstep (avoiding duplicate @types/react, - # which otherwise breaks the Next App Router type-check). + # Force React 19 for the SDK and every example via pnpm overrides (pnpm 11 + # stores these in pnpm-workspace.yaml). Overrides beat the React 18 catalog + # so every `catalog:` consumer moves in lockstep — needed to avoid + # duplicate @types/react, which breaks the Next App Router type-check. run: | - sed -i -E \ - -e 's/^( react: ).*/\1^19.0.0/' \ - -e 's/^( react-dom: ).*/\1^19.0.0/' \ - -e 's#^( "@types/react": ).*#\1^19.0.0#' \ - -e 's#^( "@types/react-dom": ).*#\1^19.0.0#' \ - pnpm-workspace.yaml - echo "Catalog after switch:" - grep -E '^ (react|react-dom|"@types/react"|"@types/react-dom"):' pnpm-workspace.yaml + pnpm config set overrides --json '{ + "react": "^19.0.0", + "react-dom": "^19.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0" + }' --location project + echo "Overrides after switch:" + pnpm config get overrides --location project - name: Install dependencies run: pnpm install ${{ matrix.react == '19' && '--no-frozen-lockfile' || '--frozen-lockfile' }} + - name: Verify React major version + if: matrix.react == '18' || matrix.react == '19' + run: | + node -e ' + const major = require("react/package.json").version.split(".")[0] + const expected = process.env.EXPECTED_REACT_MAJOR + if (major !== expected) { + console.error(`Expected React ${expected}, got ${require("react/package.json").version}`) + process.exit(1) + } + console.log(`React ${require("react/package.json").version}`) + ' + env: + EXPECTED_REACT_MAJOR: ${{ matrix.react }} + - name: Build the SDK run: pnpm build diff --git a/e2e/README.md b/e2e/README.md index bd6234f5..cda10d80 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -12,7 +12,7 @@ rendered on the page). - [`playwright.config.ts`](./playwright.config.ts) reads the `EXAMPLE` environment variable, boots that one example, and points the tests at it. - [`tests/example.spec.ts`](./tests/example.spec.ts) is framework-agnostic: it - loads the app and waits for a Fingerprint visitor ID to appear. + loads the app and waits for `[data-testid="visitor-id"]` to show a visitor ID. CI runs one example per job across a matrix of React versions (see [`.github/workflows/e2e.yml`](../.github/workflows/e2e.yml)). diff --git a/e2e/tests/example.spec.ts b/e2e/tests/example.spec.ts index b8938892..90ca05fa 100644 --- a/e2e/tests/example.spec.ts +++ b/e2e/tests/example.spec.ts @@ -1,10 +1,10 @@ import { test, expect } from '@playwright/test' -// A Fingerprint v4 visitor ID is a 20-character alphanumeric string. Every -// example renders it once identification succeeds (as "Welcome !" or in a -// "VisitorId:" field), so asserting the pattern appears verifies the SDK loaded -// the agent, called the API, and surfaced a result through the React hooks. -const VISITOR_ID = /[A-Za-z0-9]{20}/ +// Every example renders the visitor ID in: +// … +// Asserting on that node (not the whole body) avoids false passes from +// error copy that can contain 20-char event/request ids. +const VISITOR_ID = /^[A-Za-z0-9]{20}$/ test('identifies the visitor and renders a visitor ID', async ({ page }) => { const errors: string[] = [] @@ -12,10 +12,7 @@ test('identifies the visitor and renders a visitor ID', async ({ page }) => { await page.goto('/') - await expect(async () => { - const body = (await page.locator('body').textContent()) ?? '' - expect(body, `page still shows no visitor ID:\n${body}`).toMatch(VISITOR_ID) - }).toPass({ timeout: 60_000 }) + await expect(page.getByTestId('visitor-id')).toHaveText(VISITOR_ID, { timeout: 60_000 }) expect(errors, `uncaught errors on the page:\n${errors.join('\n')}`).toEqual([]) }) diff --git a/examples/create-react-app/src/shared/components/VisitorDataPresenter.tsx b/examples/create-react-app/src/shared/components/VisitorDataPresenter.tsx index 7d808835..0ef7f4bc 100644 --- a/examples/create-react-app/src/shared/components/VisitorDataPresenter.tsx +++ b/examples/create-react-app/src/shared/components/VisitorDataPresenter.tsx @@ -17,9 +17,11 @@ function VisitorDataPresenter({

Visitor ID:{' '} - {isLoading === true ? 'Loading...' : data !== undefined ? data.visitor_id : 'not established yet'} + + {isLoading === true ? 'Loading...' : (data?.visitor_id ?? 'not established yet')} +

- {data && ( + {data !== undefined && ( <>

Full visitor data: diff --git a/examples/next-appDir/app/HomePage.tsx b/examples/next-appDir/app/HomePage.tsx index 5d0418fb..fba93e70 100644 --- a/examples/next-appDir/app/HomePage.tsx +++ b/examples/next-appDir/app/HomePage.tsx @@ -27,7 +27,10 @@ const HomePage = () => {

- VisitorId: {isLoading ? 'Loading...' : data?.visitor_id} + Visitor ID:{' '} + + {isLoading ? 'Loading...' : data?.visitor_id} +

Full visitor data:

{error ? error.message : JSON.stringify(data, null, 2)}
diff --git a/examples/next/pages/index.tsx b/examples/next/pages/index.tsx index e4b0a3dc..c28fa190 100644 --- a/examples/next/pages/index.tsx +++ b/examples/next/pages/index.tsx @@ -33,7 +33,10 @@ const Home: NextPage = () => {

- VisitorId: {isLoading ? 'Loading...' : data?.visitor_id} + Visitor ID:{' '} + + {isLoading ? 'Loading...' : data?.visitor_id} +

Full visitor data:

{error ? error.message : JSON.stringify(data, null, 2)}
diff --git a/examples/preact/src/components/app.tsx b/examples/preact/src/components/app.tsx index 3e22fa80..07a2c27d 100644 --- a/examples/preact/src/components/app.tsx +++ b/examples/preact/src/components/app.tsx @@ -25,7 +25,10 @@ const App: FunctionalComponent = () => {

- VisitorId: {isLoading ? 'Loading...' : data?.visitor_id} + Visitor ID:{' '} + + {isLoading ? 'Loading...' : data?.visitor_id} +

Full visitor data:

{error ? error.message : JSON.stringify(data, null, 2)}
diff --git a/examples/vite/src/App.tsx b/examples/vite/src/App.tsx index 5b9b35e6..026254dd 100644 --- a/examples/vite/src/App.tsx +++ b/examples/vite/src/App.tsx @@ -1,7 +1,7 @@ import { useVisitorData } from '@fingerprint/react' function App() { - const { isLoading, error, isFetched, data } = useVisitorData() + const { isLoading, error, data } = useVisitorData() if (isLoading) { return
Loading...
@@ -9,11 +9,15 @@ function App() { if (error) { return
An error occurred: {error.message}
} - - if (isFetched) { - return
Welcome {data.visitor_id}!
+ if (data?.visitor_id === undefined || data.visitor_id === '') { + return null } - return null + + return ( +
+ Visitor ID: {data.visitor_id} +
+ ) } export default App diff --git a/examples/webpack-based/src/App.js b/examples/webpack-based/src/App.js index b437892f..026254dd 100644 --- a/examples/webpack-based/src/App.js +++ b/examples/webpack-based/src/App.js @@ -9,11 +9,15 @@ function App() { if (error) { return
An error occurred: {error.message}
} - - if (data) { - return
Welcome {data.visitor_id}!
+ if (data?.visitor_id === undefined || data.visitor_id === '') { + return null } - return null + + return ( +
+ Visitor ID: {data.visitor_id} +
+ ) } export default App From eda0ea9ed7192039a0af95cdfbcb9df4f1c2e18c Mon Sep 17 00:00:00 2001 From: Juraj Uhlar Date: Mon, 20 Jul 2026 13:16:26 +0200 Subject: [PATCH 12/41] chore: region param optional --- .github/workflows/e2e.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index a7ded740..e0335537 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -46,7 +46,7 @@ jobs: REACT_APP_FPJS_PUBLIC_API_KEY: ${{ secrets.FPJS_PUBLIC_API_KEY || vars.FPJS_PUBLIC_API_KEY }} NEXT_PUBLIC_FPJS_PUBLIC_API_KEY: ${{ secrets.FPJS_PUBLIC_API_KEY || vars.FPJS_PUBLIC_API_KEY }} PREACT_APP_FPJS_PUBLIC_API_KEY: ${{ secrets.FPJS_PUBLIC_API_KEY || vars.FPJS_PUBLIC_API_KEY }} - # Region of the CI key, from the FPJS_REGION repo variable (one of us/eu/ap). + # Optional region of the CI key, from the FPJS_REGION repo variable (us/eu/ap). VITE_FPJS_REGION: ${{ vars.FPJS_REGION }} REACT_APP_FPJS_REGION: ${{ vars.FPJS_REGION }} NEXT_PUBLIC_FPJS_REGION: ${{ vars.FPJS_REGION }} @@ -61,9 +61,9 @@ jobs: exit 1 fi case "$VITE_FPJS_REGION" in - us|eu|ap) ;; + ""|us|eu|ap) ;; *) - echo "::error::FPJS_REGION must be set to us, eu, or ap to match the key's region." + echo "::error::FPJS_REGION must be us, eu, or ap when set." exit 1 ;; esac From 7dd5fe75cc8cb7ebcb7f5c65c555629b536b1576 Mon Sep 17 00:00:00 2001 From: Juraj Uhlar Date: Mon, 20 Jul 2026 13:23:58 +0200 Subject: [PATCH 13/41] chore: clean up --- e2e/tests/example.spec.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/e2e/tests/example.spec.ts b/e2e/tests/example.spec.ts index 90ca05fa..824f806d 100644 --- a/e2e/tests/example.spec.ts +++ b/e2e/tests/example.spec.ts @@ -1,9 +1,5 @@ import { test, expect } from '@playwright/test' -// Every example renders the visitor ID in: -// … -// Asserting on that node (not the whole body) avoids false passes from -// error copy that can contain 20-char event/request ids. const VISITOR_ID = /^[A-Za-z0-9]{20}$/ test('identifies the visitor and renders a visitor ID', async ({ page }) => { @@ -12,6 +8,8 @@ test('identifies the visitor and renders a visitor ID', async ({ page }) => { await page.goto('/') + // Every example renders the visitor ID in: + // … await expect(page.getByTestId('visitor-id')).toHaveText(VISITOR_ID, { timeout: 60_000 }) expect(errors, `uncaught errors on the page:\n${errors.join('\n')}`).toEqual([]) From b4cfbc1eb35e66a34525494b377ff3a25503d7f2 Mon Sep 17 00:00:00 2001 From: Juraj Uhlar Date: Mon, 20 Jul 2026 13:28:05 +0200 Subject: [PATCH 14/41] chore: tighten EXAMPLES key typing with satisfies --- e2e/examples.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/e2e/examples.ts b/e2e/examples.ts index a28cd96d..3106f707 100644 --- a/e2e/examples.ts +++ b/e2e/examples.ts @@ -19,7 +19,7 @@ export interface ExampleConfig { env?: Record } -export const EXAMPLES: Record = { +export const EXAMPLES = { 'create-react-app': { port: 3001, command: 'pnpm --filter ./examples/create-react-app dev', @@ -50,13 +50,19 @@ export const EXAMPLES: Record = { port: 8081, command: 'pnpm --filter ./examples/webpack-based dev -- --port 8081', }, +} as const satisfies Record + +export type ExampleName = keyof typeof EXAMPLES + +function isExampleName(name: string): name is ExampleName { + return name in EXAMPLES } -export function resolveExample(name: string | undefined): { name: string; config: ExampleConfig } { +export function resolveExample(name: string | undefined): { name: ExampleName; config: ExampleConfig } { if (name === undefined || name === '') { throw new Error(`EXAMPLE env var is required. One of: ${Object.keys(EXAMPLES).join(', ')}`) } - if (!(name in EXAMPLES)) { + if (!isExampleName(name)) { throw new Error(`Unknown example "${name}". One of: ${Object.keys(EXAMPLES).join(', ')}`) } return { name, config: EXAMPLES[name] } From bca2da54111756e01ed16a4796d9d1e41e5441c7 Mon Sep 17 00:00:00 2001 From: Juraj Uhlar Date: Mon, 20 Jul 2026 14:02:11 +0200 Subject: [PATCH 15/41] test(e2e): validate production builds --- e2e/README.md | 6 ++--- e2e/examples.ts | 37 +++++++++++++++----------- e2e/playwright.config.ts | 8 +++--- examples/create-react-app/package.json | 3 +++ examples/next-appDir/package.json | 3 ++- examples/next/package.json | 3 ++- examples/preact/package.json | 1 + examples/vite/package.json | 3 ++- examples/webpack-based/package.json | 4 ++- pnpm-lock.yaml | 6 +++++ 10 files changed, 48 insertions(+), 26 deletions(-) diff --git a/e2e/README.md b/e2e/README.md index cda10d80..eb775125 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -7,10 +7,10 @@ rendered on the page). ## How it works - [`examples.ts`](./examples.ts) is the single source of truth: it maps each - example directory to the command that starts its dev server and the port it - listens on. + example directory to the build+serve command and the port it listens on. - [`playwright.config.ts`](./playwright.config.ts) reads the `EXAMPLE` - environment variable, boots that one example, and points the tests at it. + environment variable, builds and serves that one example, and points the + tests at it. - [`tests/example.spec.ts`](./tests/example.spec.ts) is framework-agnostic: it loads the app and waits for `[data-testid="visitor-id"]` to show a visitor ID. diff --git a/e2e/examples.ts b/e2e/examples.ts index 3106f707..e1fe4df2 100644 --- a/e2e/examples.ts +++ b/e2e/examples.ts @@ -9,46 +9,53 @@ * Commands use pnpm path filters (`--filter ./examples/`) so the directory * name is the only identifier needed here, in CI, and in the React-version * override step. + * + * Servers are production builds (`build` then `start`/`preview`/`serve`), not + * `dev`, so bundling failures surface before Playwright. Typechecking coverage + * varies by example — see comments on each entry. */ export interface ExampleConfig { - /** Port the dev server listens on. */ + /** Port the example server listens on. */ port: number - /** Command Playwright runs to boot the example (from the repo root). */ + /** Command Playwright runs to build and serve the example (from the repo root). */ command: string - /** Extra env for the dev server process. */ + /** Extra env for the build/serve process. */ env?: Record } export const EXAMPLES = { 'create-react-app': { + // react-scripts build typechecks by default (fails the build on TS errors). port: 3001, - command: 'pnpm --filter ./examples/create-react-app dev', - // react-scripts otherwise tries to open a browser and treats warnings as - // errors under CI. - env: { BROWSER: 'none', CI: 'false' }, + command: 'pnpm --filter ./examples/create-react-app build && pnpm --filter ./examples/create-react-app serve', }, next: { port: 3002, - command: 'pnpm --filter ./examples/next dev', + command: 'pnpm --filter ./examples/next build && pnpm --filter ./examples/next start', }, 'next-appDir': { port: 3003, - command: 'pnpm --filter ./examples/next-appDir dev', + command: 'pnpm --filter ./examples/next-appDir build && pnpm --filter ./examples/next-appDir start', }, preact: { - // preact-cli's dev server (`watch`) fails to resolve its entrypoint on the - // CI Node version, so build once and serve the static output with sirv - // (its `serve` script listens on 8080). + // preact-cli builds with Babel and does not typecheck, so run tsc first. port: 8080, - command: 'pnpm --filter ./examples/preact build && pnpm --filter ./examples/preact serve', + command: + 'pnpm --filter ./examples/preact typecheck && pnpm --filter ./examples/preact build && pnpm --filter ./examples/preact serve', + // preact-cli only replaces environment variables that exist. Keep the + // optional region reference from leaking into the browser as `process.env`. + env: { PREACT_APP_FPJS_REGION: process.env.PREACT_APP_FPJS_REGION ?? '' }, }, vite: { + // `build` is already `tsc -b && vite build`. port: 5173, - command: 'pnpm --filter ./examples/vite dev -- --port 5173 --strictPort', + command: 'pnpm --filter ./examples/vite build && pnpm --filter ./examples/vite preview', }, 'webpack-based': { + // Plain JS via Babel — nothing to typecheck. port: 8081, - command: 'pnpm --filter ./examples/webpack-based dev -- --port 8081', + command: 'pnpm --filter ./examples/webpack-based build && pnpm --filter ./examples/webpack-based serve', + env: { REACT_APP_FPJS_REGION: process.env.REACT_APP_FPJS_REGION ?? '' }, }, } as const satisfies Record diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 1389ad2f..0262c073 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -3,11 +3,11 @@ import path from 'path' import { resolveExample } from './examples' // Playwright compiles this config as CommonJS, so `__dirname` (the e2e/ dir) is -// available; its parent is the repo root, from where the dev servers are run. +// available; its parent is the repo root, from where example servers are run. const repoRoot = path.resolve(__dirname, '..') -// A CI job runs one example at a time. `EXAMPLE` selects which one; the matching -// dev server is booted by Playwright and torn down when the run finishes. +// A CI job runs one example at a time. `EXAMPLE` selects which one; Playwright +// builds and serves it, then tears the server down when the run finishes. const { name, config } = resolveExample(process.env.EXAMPLE) const baseURL = `http://localhost:${String(config.port)}` const isCI = Boolean(process.env.CI) @@ -31,7 +31,7 @@ export default defineConfig({ cwd: repoRoot, url: baseURL, reuseExistingServer: !isCI, - // Dev servers (Next/CRA) compile on boot, so allow a generous window. + // Production builds (esp. Next) can take a while on cold CI runners. timeout: 180_000, stdout: 'pipe', stderr: 'pipe', diff --git a/examples/create-react-app/package.json b/examples/create-react-app/package.json index a5a516e3..c3abde75 100644 --- a/examples/create-react-app/package.json +++ b/examples/create-react-app/package.json @@ -10,12 +10,15 @@ "react-scripts": "5.0.1" }, "devDependencies": { + "sirv-cli": "^3.0.1", "typescript": "catalog:" }, "scripts": { "dev": "PORT=3001 DISABLE_ESLINT_PLUGIN=true react-scripts start", "start": "pnpm dev", "build": "DISABLE_ESLINT_PLUGIN=true react-scripts build", + "serve": "sirv build --port 3001 --cors --single", + "typecheck": "tsc --noEmit --ignoreDeprecations 6.0", "lint": "eslint . --max-warnings 0" }, "browserslist": { diff --git a/examples/next-appDir/package.json b/examples/next-appDir/package.json index 771ff4e3..1f8b1916 100644 --- a/examples/next-appDir/package.json +++ b/examples/next-appDir/package.json @@ -8,7 +8,8 @@ "scripts": { "dev": "next dev --port=3003", "build": "next build", - "start": "next start", + "start": "next start --port 3003", + "typecheck": "tsc --noEmit", "lint": "eslint . --max-warnings 0" }, "dependencies": { diff --git a/examples/next/package.json b/examples/next/package.json index b41bf330..099e4292 100644 --- a/examples/next/package.json +++ b/examples/next/package.json @@ -8,7 +8,8 @@ "scripts": { "dev": "next dev --port=3002", "build": "next build", - "start": "next start", + "start": "next start --port 3002", + "typecheck": "tsc --noEmit", "lint": "eslint . --max-warnings 0" }, "dependencies": { diff --git a/examples/preact/package.json b/examples/preact/package.json index ba126f5f..e0148f8d 100644 --- a/examples/preact/package.json +++ b/examples/preact/package.json @@ -7,6 +7,7 @@ "build": "preact build --no-sw", "serve": "sirv build --port 8080 --cors --single", "dev": "preact watch --no-sw", + "typecheck": "tsc --noEmit --ignoreDeprecations 6.0", "lint": "eslint . --max-warnings 0" }, "dependencies": { diff --git a/examples/vite/package.json b/examples/vite/package.json index 6c68757c..ba33331f 100644 --- a/examples/vite/package.json +++ b/examples/vite/package.json @@ -6,7 +6,8 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", - "preview": "vite preview", + "preview": "vite preview --port 5173 --strictPort", + "typecheck": "tsc -b --pretty false", "lint": "eslint . --max-warnings 0" }, "dependencies": { diff --git a/examples/webpack-based/package.json b/examples/webpack-based/package.json index 47845dc9..b5cc3aa7 100644 --- a/examples/webpack-based/package.json +++ b/examples/webpack-based/package.json @@ -6,7 +6,8 @@ "license": "MIT", "scripts": { "dev": "webpack-dev-server", - "build": "webpack", + "build": "NODE_ENV=production webpack --mode production", + "serve": "sirv dist --port 8081 --cors --single", "lint": "eslint . --max-warnings 0" }, "devDependencies": { @@ -16,6 +17,7 @@ "babel-loader": "^10.1.1", "dotenv-webpack": "^9.0.0", "html-webpack-plugin": "^5.6.7", + "sirv-cli": "^3.0.1", "webpack": "^5.108.4", "webpack-cli": "^7.2.1", "webpack-dev-server": "^6.0.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9cb6a8de..4ab3993d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -166,6 +166,9 @@ importers: specifier: 5.0.1 version: 5.0.1(@babel/plugin-syntax-flow@7.29.7(@babel/core@7.29.7))(@babel/plugin-transform-react-jsx@7.29.7(@babel/core@7.29.7))(@types/babel__core@7.20.5)(@types/webpack@4.41.40)(esbuild@0.28.1)(eslint@10.7.0(jiti@1.21.7))(react@18.3.1)(type-fest@0.21.3)(typescript@6.0.3)(yaml@2.9.0) devDependencies: + sirv-cli: + specifier: ^3.0.1 + version: 3.0.1 typescript: specifier: 'catalog:' version: 6.0.3 @@ -309,6 +312,9 @@ importers: html-webpack-plugin: specifier: ^5.6.7 version: 5.6.7(webpack@5.108.4) + sirv-cli: + specifier: ^3.0.1 + version: 3.0.1 webpack: specifier: ^5.108.4 version: 5.108.4(esbuild@0.28.1)(webpack-cli@7.2.1) From f83b75f5b8947e029c3aabbe11262d764ccfb18d Mon Sep 17 00:00:00 2001 From: Juraj Uhlar Date: Mon, 20 Jul 2026 14:28:16 +0200 Subject: [PATCH 16/41] fix(examples): guard optional region env access --- e2e/examples.ts | 6 ------ e2e/playwright.config.ts | 1 - examples/preact/src/index.tsx | 2 +- examples/webpack-based/src/index.js | 2 +- 4 files changed, 2 insertions(+), 9 deletions(-) diff --git a/e2e/examples.ts b/e2e/examples.ts index e1fe4df2..73569657 100644 --- a/e2e/examples.ts +++ b/e2e/examples.ts @@ -19,8 +19,6 @@ export interface ExampleConfig { port: number /** Command Playwright runs to build and serve the example (from the repo root). */ command: string - /** Extra env for the build/serve process. */ - env?: Record } export const EXAMPLES = { @@ -42,9 +40,6 @@ export const EXAMPLES = { port: 8080, command: 'pnpm --filter ./examples/preact typecheck && pnpm --filter ./examples/preact build && pnpm --filter ./examples/preact serve', - // preact-cli only replaces environment variables that exist. Keep the - // optional region reference from leaking into the browser as `process.env`. - env: { PREACT_APP_FPJS_REGION: process.env.PREACT_APP_FPJS_REGION ?? '' }, }, vite: { // `build` is already `tsc -b && vite build`. @@ -55,7 +50,6 @@ export const EXAMPLES = { // Plain JS via Babel — nothing to typecheck. port: 8081, command: 'pnpm --filter ./examples/webpack-based build && pnpm --filter ./examples/webpack-based serve', - env: { REACT_APP_FPJS_REGION: process.env.REACT_APP_FPJS_REGION ?? '' }, }, } as const satisfies Record diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 0262c073..a4cfeeef 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -35,7 +35,6 @@ export default defineConfig({ timeout: 180_000, stdout: 'pipe', stderr: 'pipe', - env: config.env, }, metadata: { example: name }, }) diff --git a/examples/preact/src/index.tsx b/examples/preact/src/index.tsx index 6c90e5c7..ac451ab2 100644 --- a/examples/preact/src/index.tsx +++ b/examples/preact/src/index.tsx @@ -10,7 +10,7 @@ const WrappedApp: FunctionalComponent = () => { } // Optional. Defaults to the SDK's default region (us) when unset or invalid. - const rawRegion = process.env.PREACT_APP_FPJS_REGION + const rawRegion = typeof process === 'undefined' ? undefined : process.env.PREACT_APP_FPJS_REGION const region = rawRegion === 'us' || rawRegion === 'eu' || rawRegion === 'ap' ? rawRegion : undefined return ( diff --git a/examples/webpack-based/src/index.js b/examples/webpack-based/src/index.js index f4283e43..ffed8ef6 100644 --- a/examples/webpack-based/src/index.js +++ b/examples/webpack-based/src/index.js @@ -7,7 +7,7 @@ const rootElement = document.getElementById('root') const root = createRoot(rootElement) const apiKey = process.env.REACT_APP_FPJS_PUBLIC_API_KEY // Optional. Defaults to the SDK's default region (us) when unset or invalid. -const rawRegion = process.env.REACT_APP_FPJS_REGION +const rawRegion = typeof process === 'undefined' ? undefined : process.env.REACT_APP_FPJS_REGION const region = rawRegion === 'us' || rawRegion === 'eu' || rawRegion === 'ap' ? rawRegion : undefined root.render( From c9b927fda0bb6013edc48dd5be8382bf630b3e97 Mon Sep 17 00:00:00 2001 From: Juraj Uhlar Date: Mon, 20 Jul 2026 14:31:42 +0200 Subject: [PATCH 17/41] chore: fold preact typecheck into example build --- e2e/examples.ts | 14 +++++--------- examples/preact/package.json | 2 +- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/e2e/examples.ts b/e2e/examples.ts index 73569657..73a6724d 100644 --- a/e2e/examples.ts +++ b/e2e/examples.ts @@ -10,9 +10,9 @@ * name is the only identifier needed here, in CI, and in the React-version * override step. * - * Servers are production builds (`build` then `start`/`preview`/`serve`), not - * `dev`, so bundling failures surface before Playwright. Typechecking coverage - * varies by example — see comments on each entry. + * Each command is a production `build` followed by that example's serve path + * (`start` / `preview` / `serve`), not `dev`. Typechecking, if any, lives in + * the example's own `build` script. */ export interface ExampleConfig { /** Port the example server listens on. */ @@ -23,7 +23,6 @@ export interface ExampleConfig { export const EXAMPLES = { 'create-react-app': { - // react-scripts build typechecks by default (fails the build on TS errors). port: 3001, command: 'pnpm --filter ./examples/create-react-app build && pnpm --filter ./examples/create-react-app serve', }, @@ -36,18 +35,15 @@ export const EXAMPLES = { command: 'pnpm --filter ./examples/next-appDir build && pnpm --filter ./examples/next-appDir start', }, preact: { - // preact-cli builds with Babel and does not typecheck, so run tsc first. port: 8080, - command: - 'pnpm --filter ./examples/preact typecheck && pnpm --filter ./examples/preact build && pnpm --filter ./examples/preact serve', + command: 'pnpm --filter ./examples/preact build && pnpm --filter ./examples/preact serve', }, vite: { - // `build` is already `tsc -b && vite build`. port: 5173, command: 'pnpm --filter ./examples/vite build && pnpm --filter ./examples/vite preview', }, 'webpack-based': { - // Plain JS via Babel — nothing to typecheck. + // JS-only example — its `build` does not typecheck. port: 8081, command: 'pnpm --filter ./examples/webpack-based build && pnpm --filter ./examples/webpack-based serve', }, diff --git a/examples/preact/package.json b/examples/preact/package.json index e0148f8d..aee95587 100644 --- a/examples/preact/package.json +++ b/examples/preact/package.json @@ -4,7 +4,7 @@ "version": "0.0.0", "license": "MIT", "scripts": { - "build": "preact build --no-sw", + "build": "pnpm typecheck && preact build --no-sw", "serve": "sirv build --port 8080 --cors --single", "dev": "preact watch --no-sw", "typecheck": "tsc --noEmit --ignoreDeprecations 6.0", From c1cabefa0caa9faf0f38eec973ed1ae5158c2b1b Mon Sep 17 00:00:00 2001 From: Juraj Uhlar Date: Mon, 20 Jul 2026 14:40:46 +0200 Subject: [PATCH 18/41] fix(examples): drop deprecated TypeScript 6 compiler options --- examples/create-react-app/package.json | 2 +- examples/create-react-app/tsconfig.json | 4 ++-- examples/preact/package.json | 2 +- examples/preact/tsconfig.json | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/examples/create-react-app/package.json b/examples/create-react-app/package.json index c3abde75..219c4d6f 100644 --- a/examples/create-react-app/package.json +++ b/examples/create-react-app/package.json @@ -18,7 +18,7 @@ "start": "pnpm dev", "build": "DISABLE_ESLINT_PLUGIN=true react-scripts build", "serve": "sirv build --port 3001 --cors --single", - "typecheck": "tsc --noEmit --ignoreDeprecations 6.0", + "typecheck": "tsc --noEmit", "lint": "eslint . --max-warnings 0" }, "browserslist": { diff --git a/examples/create-react-app/tsconfig.json b/examples/create-react-app/tsconfig.json index b1c7462d..dc3a1ec6 100644 --- a/examples/create-react-app/tsconfig.json +++ b/examples/create-react-app/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "es5", + "target": "es2015", "lib": [ "dom", "dom.iterable", @@ -14,7 +14,7 @@ "forceConsistentCasingInFileNames": true, "noFallthroughCasesInSwitch": true, "module": "esnext", - "moduleResolution": "node", + "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, diff --git a/examples/preact/package.json b/examples/preact/package.json index aee95587..45057057 100644 --- a/examples/preact/package.json +++ b/examples/preact/package.json @@ -7,7 +7,7 @@ "build": "pnpm typecheck && preact build --no-sw", "serve": "sirv build --port 8080 --cors --single", "dev": "preact watch --no-sw", - "typecheck": "tsc --noEmit --ignoreDeprecations 6.0", + "typecheck": "tsc --noEmit", "lint": "eslint . --max-warnings 0" }, "dependencies": { diff --git a/examples/preact/tsconfig.json b/examples/preact/tsconfig.json index 81b4e3a7..fcfc0c39 100644 --- a/examples/preact/tsconfig.json +++ b/examples/preact/tsconfig.json @@ -5,7 +5,7 @@ "react-dom": ["./node_modules/preact/compat/"] }, /* Basic Options */ - "target": "ES5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', or 'ESNEXT'. */ + "target": "ES2015", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', or 'ESNEXT'. */ "module": "ESNext", /* Specify module code generation: 'none', commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ // "lib": [], /* Specify library files to be included in the compilation: */ "allowJs": true, /* Allow javascript files to be compiled. */ @@ -39,7 +39,7 @@ // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ /* Module Resolution Options */ - "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ + "moduleResolution": "bundler", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ "esModuleInterop": true, /* */ // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ From 8e2846b1be2b211ebdbf6dfc06806bc0c3b08dcc Mon Sep 17 00:00:00 2001 From: Juraj Uhlar Date: Mon, 20 Jul 2026 14:41:28 +0200 Subject: [PATCH 19/41] fix: address Copilot review on example name check and env reads --- e2e/examples.ts | 2 +- examples/preact/src/index.tsx | 2 +- examples/webpack-based/src/index.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/e2e/examples.ts b/e2e/examples.ts index 73a6724d..61d6464c 100644 --- a/e2e/examples.ts +++ b/e2e/examples.ts @@ -52,7 +52,7 @@ export const EXAMPLES = { export type ExampleName = keyof typeof EXAMPLES function isExampleName(name: string): name is ExampleName { - return name in EXAMPLES + return Object.hasOwn(EXAMPLES, name) } export function resolveExample(name: string | undefined): { name: ExampleName; config: ExampleConfig } { diff --git a/examples/preact/src/index.tsx b/examples/preact/src/index.tsx index ac451ab2..6c90e5c7 100644 --- a/examples/preact/src/index.tsx +++ b/examples/preact/src/index.tsx @@ -10,7 +10,7 @@ const WrappedApp: FunctionalComponent = () => { } // Optional. Defaults to the SDK's default region (us) when unset or invalid. - const rawRegion = typeof process === 'undefined' ? undefined : process.env.PREACT_APP_FPJS_REGION + const rawRegion = process.env.PREACT_APP_FPJS_REGION const region = rawRegion === 'us' || rawRegion === 'eu' || rawRegion === 'ap' ? rawRegion : undefined return ( diff --git a/examples/webpack-based/src/index.js b/examples/webpack-based/src/index.js index ffed8ef6..f4283e43 100644 --- a/examples/webpack-based/src/index.js +++ b/examples/webpack-based/src/index.js @@ -7,7 +7,7 @@ const rootElement = document.getElementById('root') const root = createRoot(rootElement) const apiKey = process.env.REACT_APP_FPJS_PUBLIC_API_KEY // Optional. Defaults to the SDK's default region (us) when unset or invalid. -const rawRegion = typeof process === 'undefined' ? undefined : process.env.REACT_APP_FPJS_REGION +const rawRegion = process.env.REACT_APP_FPJS_REGION const region = rawRegion === 'us' || rawRegion === 'eu' || rawRegion === 'ap' ? rawRegion : undefined root.render( From 0bf9954ced8524cd1570fcdb9c9d9858b3d63ec2 Mon Sep 17 00:00:00 2001 From: Juraj Uhlar Date: Mon, 20 Jul 2026 15:34:39 +0200 Subject: [PATCH 20/41] fix: avoid setState-in-effect and stale responses in immediate mount fetch --- __tests__/use-visitor-data.test.tsx | 53 +++++++++++++++++++++++++++ src/use-visitor-data.ts | 57 ++++++++++++++++++++++++++--- 2 files changed, 105 insertions(+), 5 deletions(-) diff --git a/__tests__/use-visitor-data.test.tsx b/__tests__/use-visitor-data.test.tsx index 0694b31a..6dc3a026 100644 --- a/__tests__/use-visitor-data.test.tsx +++ b/__tests__/use-visitor-data.test.tsx @@ -191,6 +191,59 @@ describe('useVisitorData', () => { expect(mockGet).toHaveBeenNthCalledWith(2, { tag: 2 }) }) + it('should set error state when immediate mount fetch fails', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) + mockGet.mockRejectedValue(new Error('mount failed')) + + const wrapper = createWrapper() + const { result } = renderHook(() => useVisitorData({ immediate: true }), { wrapper }) + + await actWait(500) + + expect(result.current).toMatchObject({ + isLoading: false, + isFetched: false, + data: undefined, + error: expect.objectContaining({ message: 'mount failed' }), + }) + expect(consoleError).toHaveBeenCalledWith('Failed to fetch visitor data on mount: Error: mount failed') + + consoleError.mockRestore() + }) + + it('should ignore stale immediate responses when options change mid-flight', async () => { + let resolveFirst!: (value: GetResult) => void + const firstRequest = new Promise((resolve) => { + resolveFirst = resolve + }) + const secondResult = { ...mockGetResult, visitor_id: 'second-visitor' } + + mockGet.mockImplementationOnce(() => firstRequest).mockResolvedValueOnce(secondResult) + + const wrapper = createWrapper() + const { result, rerender } = renderHook(({ tag }: { tag: number }) => useVisitorData({ immediate: true, tag }), { + wrapper, + initialProps: { tag: 1 }, + }) + + await actWait(50) + expect(mockGet).toHaveBeenCalledTimes(1) + expect(result.current.isLoading).toBe(true) + + rerender({ tag: 2 }) + expect(result.current.isLoading).toBe(true) + + await actWait(50) + expect(mockGet).toHaveBeenCalledTimes(2) + + act(() => { + resolveFirst(mockGetResult) + }) + await actWait(50) + + expect(result.current.data).toEqual(secondResult) + }) + it('should correctly pass errors from agent', async () => { const ERROR_CLIENT_TIMEOUT = 'timeout' mockGet.mockRejectedValue(new Error(ERROR_CLIENT_TIMEOUT)) diff --git a/src/use-visitor-data.ts b/src/use-visitor-data.ts index 7792c78c..a2c9d4a6 100644 --- a/src/use-visitor-data.ts +++ b/src/use-visitor-data.ts @@ -94,18 +94,65 @@ export function useVisitorData( [currentGetOptions, getVisitorData] ) + /** + * When `immediate` is enabled, fetches visitor data on mount and whenever `getOptions` change. + * We don't reuse `getData` here because it sets loading state synchronously, which is not allowed + * inside an effect — `isLoading: true` is covered by the initial state and the render-phase reset below. + * The `ignore` flag prevents an outdated in-flight response from overwriting a newer request's state: + * https://react.dev/reference/react/useEffect#fetching-data-with-effects + */ useEffect(() => { - if (immediate) { - // TODO: refactor mount fetch to avoid synchronous setState inside the effect - // eslint-disable-next-line react-hooks/set-state-in-effect -- preserve existing getData() mount behavior - getData().catch((unknownError: unknown) => { + if (!immediate) { + return + } + + let ignore = false + + getVisitorData(currentGetOptions) + .then((result) => { + if (ignore) { + return + } + + setQueryState({ + isLoading: false, + isFetched: true, + data: result, + error: undefined, + }) + }) + .catch((unknownError: unknown) => { + if (ignore) { + return + } + + setQueryState({ + isLoading: false, + isFetched: false, + data: undefined, + error: toError(unknownError), + }) console.error(`Failed to fetch visitor data on mount: ${String(unknownError)}`) }) + + return () => { + ignore = true } - }, [immediate, getData]) + }, [immediate, getVisitorData, currentGetOptions]) + // When `getOptions` change, store them (triggering a refetch via the effect above) and reset to loading + // state right away: https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes if (!Object.is(currentGetOptions, getOptions) && !deepEquals(currentGetOptions, getOptions)) { setCurrentGetOptions(getOptions) + + if (immediate) { + setQueryState({ + isLoading: true, + isFetched: false, + data: undefined, + error: undefined, + }) + } } return useMemo( From 8aca20ec22141f3ba11e0910a4b97cdc04ca522a Mon Sep 17 00:00:00 2001 From: Juraj Uhlar Date: Mon, 20 Jul 2026 16:04:28 +0200 Subject: [PATCH 21/41] refactor: share query state helpers in useVisitorData --- src/use-visitor-data.ts | 79 +++++++++++++++++++---------------------- 1 file changed, 36 insertions(+), 43 deletions(-) diff --git a/src/use-visitor-data.ts b/src/use-visitor-data.ts index a2c9d4a6..2241bb4a 100644 --- a/src/use-visitor-data.ts +++ b/src/use-visitor-data.ts @@ -52,17 +52,41 @@ export function useVisitorData( error: undefined, }) + const setLoading = () => { + setQueryState({ + isLoading: true, + isFetched: false, + data: undefined, + error: undefined, + }) + } + + const setSuccess = (data: GetResult) => { + setQueryState({ + isLoading: false, + isFetched: true, + data, + error: undefined, + }) + } + + const setFailure = (unknownError: unknown) => { + const error = toError(unknownError) + setQueryState({ + isLoading: false, + isFetched: false, + data: undefined, + error, + }) + return error + } + const getData = useCallback( async (params = {}) => { assertIsDefined(params, 'getDataParams') try { - setQueryState({ - isLoading: true, - isFetched: false, - data: undefined, - error: undefined, - }) + setLoading() const getDataOptions: GetOptions = { ...currentGetOptions, @@ -70,25 +94,11 @@ export function useVisitorData( } const result = await getVisitorData(getDataOptions) - setQueryState({ - isLoading: false, - isFetched: true, - data: result, - error: undefined, - }) + setSuccess(result) return result } catch (unknownError) { - const error = toError(unknownError) - - setQueryState({ - isLoading: false, - isFetched: false, - data: undefined, - error, - }) - - throw error + throw setFailure(unknownError) } }, [currentGetOptions, getVisitorData] @@ -110,28 +120,16 @@ export function useVisitorData( getVisitorData(currentGetOptions) .then((result) => { - if (ignore) { - return + if (!ignore) { + setSuccess(result) } - - setQueryState({ - isLoading: false, - isFetched: true, - data: result, - error: undefined, - }) }) .catch((unknownError: unknown) => { if (ignore) { return } - setQueryState({ - isLoading: false, - isFetched: false, - data: undefined, - error: toError(unknownError), - }) + setFailure(unknownError) console.error(`Failed to fetch visitor data on mount: ${String(unknownError)}`) }) @@ -146,12 +144,7 @@ export function useVisitorData( setCurrentGetOptions(getOptions) if (immediate) { - setQueryState({ - isLoading: true, - isFetched: false, - data: undefined, - error: undefined, - }) + setLoading() } } From df6eefc039d484c8d327e71cc016534036da7288 Mon Sep 17 00:00:00 2001 From: Juraj Uhlar Date: Mon, 20 Jul 2026 16:27:28 +0200 Subject: [PATCH 22/41] test: clarify stale immediate response race coverage --- __tests__/use-visitor-data.test.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/__tests__/use-visitor-data.test.tsx b/__tests__/use-visitor-data.test.tsx index 6dc3a026..58852dff 100644 --- a/__tests__/use-visitor-data.test.tsx +++ b/__tests__/use-visitor-data.test.tsx @@ -211,7 +211,8 @@ describe('useVisitorData', () => { consoleError.mockRestore() }) - it('should ignore stale immediate responses when options change mid-flight', async () => { + it('should not apply an outdated immediate response after getOptions change mid-flight', async () => { + // First agent call stays pending until we resolve it later. let resolveFirst!: (value: GetResult) => void const firstRequest = new Promise((resolve) => { resolveFirst = resolve @@ -226,16 +227,20 @@ describe('useVisitorData', () => { initialProps: { tag: 1 }, }) + // Mount started one in-flight request for tag: 1. await actWait(50) expect(mockGet).toHaveBeenCalledTimes(1) expect(result.current.isLoading).toBe(true) + // Changing options cancels the previous effect and starts a new immediate fetch. rerender({ tag: 2 }) expect(result.current.isLoading).toBe(true) await actWait(50) expect(mockGet).toHaveBeenCalledTimes(2) + // The slow first response arrives after the second request has already settled. + // Without ignore/cleanup, this would overwrite data with the stale tag: 1 result. act(() => { resolveFirst(mockGetResult) }) From 1e212941b682c7153f1532e5306d2e2d6be075c5 Mon Sep 17 00:00:00 2001 From: Juraj Uhlar Date: Tue, 21 Jul 2026 13:21:22 +0200 Subject: [PATCH 23/41] fix: handle immediate query state transitions --- README.md | 3 +- __tests__/use-visitor-data.test.tsx | 111 ++++++++++++++++++++++++++-- src/use-visitor-data.ts | 30 ++++++-- 3 files changed, 130 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 16174c24..a3bc112a 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,6 @@ export default App The `useVisitorData` hook also returns a `getData` method you can use to make an API call on command. -- You can pass `{ immediate: false }` to `useVisitorData` to disable automatic visitor identification on render. Both `useVisitorData` and `getData` accept all the [get options](https://docs.fingerprint.com/reference/js-agent-get-function#get-options) available in the JavaScript agent `get` function. @@ -184,6 +183,8 @@ function App() { export default App ``` +- You can pass `{ immediate: false }` to disable automatic identification and call `getData` manually instead. + - By default (`{ immediate: true }`), the hook automatically identifies the visitor after mounting and whenever its request options change. Changing `immediate` from `false` to `true` also starts an identification request with the latest options. - See the full code example in the [examples folder](./examples/). - See our [Use cases](https://demo.fingerprint.com) page for [open-source](https://github.com/fingerprintjs/fingerprintjs-pro-use-cases) real-world examples of using Fingerprint to detect fraud and streamline user experiences. diff --git a/__tests__/use-visitor-data.test.tsx b/__tests__/use-visitor-data.test.tsx index 19bfa795..ae4b9ff8 100644 --- a/__tests__/use-visitor-data.test.tsx +++ b/__tests__/use-visitor-data.test.tsx @@ -1,5 +1,5 @@ import { useVisitorData, UseVisitorDataReturn } from '../src' -import { act, render, renderHook, screen } from '@testing-library/react' +import { act, render, renderHook, screen, waitFor } from '@testing-library/react' import { actWait, createWrapper, wait } from './helpers' import { useEffect, useState } from 'react' import userEvent from '@testing-library/user-event' @@ -128,6 +128,85 @@ describe('useVisitorData', () => { expect(mockGet).not.toHaveBeenCalled() }) + it('should fetch and enter loading when immediate changes from false to true', async () => { + let resolveRequest!: (value: GetResult) => void + mockGet.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveRequest = resolve + }) + ) + + const wrapper = createWrapper() + const { result, rerender } = renderHook( + ({ immediate }: { immediate: boolean }) => useVisitorData({ immediate, tag: 1 }), + { + wrapper, + initialProps: { immediate: false }, + } + ) + + // Automatic fetching stays idle while immediate is disabled. + expect(result.current.isLoading).toBe(false) + expect(mockGet).not.toHaveBeenCalled() + + // Enabling immediate after mount starts a request and exposes loading right away. + rerender({ immediate: true }) + + expect(result.current.isLoading).toBe(true) + await waitFor(() => { + expect(mockGet).toHaveBeenCalledTimes(1) + expect(mockGet).toHaveBeenCalledWith({ tag: 1 }) + }) + + // The automatically started request updates the hook like an initial immediate fetch. + act(() => { + resolveRequest(mockGetResult) + }) + + await waitFor(() => { + expect(result.current).toMatchObject({ + isLoading: false, + isFetched: true, + data: mockGetResult, + }) + }) + }) + + it('should leave loading when immediate is disabled during an automatic request', async () => { + let resolveRequest!: (value: GetResult) => void + const request = new Promise((resolve) => { + resolveRequest = resolve + }) + mockGet.mockReturnValueOnce(request) + + const wrapper = createWrapper() + const { result, rerender } = renderHook(({ immediate }: { immediate: boolean }) => useVisitorData({ immediate }), { + wrapper, + initialProps: { immediate: true }, + }) + + await waitFor(() => { + expect(mockGet).toHaveBeenCalledTimes(1) + }) + expect(result.current.isLoading).toBe(true) + + // Disabling automatic fetching makes the active response irrelevant. + rerender({ immediate: false }) + expect(result.current.isLoading).toBe(false) + + await act(async () => { + resolveRequest(mockGetResult) + await request + }) + + expect(result.current).toMatchObject({ + isLoading: false, + isFetched: false, + data: undefined, + }) + }) + it('should support immediate fetch with cache disabled', async () => { const wrapper = createWrapper() renderHook(() => useVisitorData({ immediate: true }), { wrapper }) @@ -211,6 +290,26 @@ describe('useVisitorData', () => { consoleError.mockRestore() }) + it('should set error state when an immediate fetch throws synchronously', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) + + const { result } = renderHook(() => useVisitorData({ immediate: true })) + + await waitFor(() => { + expect(result.current).toMatchObject({ + isLoading: false, + isFetched: false, + data: undefined, + error: expect.objectContaining({ message: 'You forgot to wrap your component in .' }), + }) + }) + expect(consoleError).toHaveBeenCalledWith( + 'Failed to fetch visitor data on mount: Error: You forgot to wrap your component in .' + ) + + consoleError.mockRestore() + }) + it('should not apply an outdated immediate response after getOptions change mid-flight', async () => { // First agent call stays pending until we resolve it later. let resolveFirst!: (value: GetResult) => void @@ -236,15 +335,17 @@ describe('useVisitorData', () => { rerender({ tag: 2 }) expect(result.current.isLoading).toBe(true) - await actWait(50) - expect(mockGet).toHaveBeenCalledTimes(2) + await waitFor(() => { + expect(mockGet).toHaveBeenCalledTimes(2) + expect(result.current.data).toEqual(secondResult) + }) // The slow first response arrives after the second request has already settled. // Without ignore/cleanup, this would overwrite data with the stale tag: 1 result. - act(() => { + await act(async () => { resolveFirst(mockGetResult) + await firstRequest }) - await actWait(50) expect(result.current.data).toEqual(secondResult) }) diff --git a/src/use-visitor-data.ts b/src/use-visitor-data.ts index 2190033e..7833fa32 100644 --- a/src/use-visitor-data.ts +++ b/src/use-visitor-data.ts @@ -7,7 +7,8 @@ import { GetOptions, GetResult } from '@fingerprint/agent' export interface UseVisitorDataConfig { /** - * Determines whether the `getData()` method will be called immediately after the hook mounts or not + * Controls automatic visitor data fetching. When enabled, the hook fetches after mounting, whenever the + * request options change, and whenever `immediate` changes from `false` to `true`. */ immediate: boolean } @@ -45,6 +46,7 @@ export function useVisitorData( const { getVisitorData } = useContext(FingerprintContext) const [currentGetOptions, setCurrentGetOptions] = useState(getOptions) + const [currentImmediate, setCurrentImmediate] = useState(immediate) const [queryState, setQueryState] = useState({ isLoading: immediate, data: undefined, @@ -118,7 +120,8 @@ export function useVisitorData( let ignore = false - getVisitorData(currentGetOptions) + Promise.resolve() + .then(() => getVisitorData(currentGetOptions)) .then((result) => { if (!ignore) { setSuccess(result) @@ -138,14 +141,25 @@ export function useVisitorData( } }, [immediate, getVisitorData, currentGetOptions]) - // When `getOptions` change, store them (triggering a refetch via the effect above) and reset to loading - // state right away: https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes - if (!Object.is(currentGetOptions, getOptions) && !areGetOptionsEqual(currentGetOptions, getOptions)) { + // When automatic-fetch inputs change, store them and reset to loading during render so the effect can start + // the request without synchronously setting state: https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes + const didImmediateChange = currentImmediate !== immediate + const didGetOptionsChange = + !Object.is(currentGetOptions, getOptions) && !areGetOptionsEqual(currentGetOptions, getOptions) + + if (didImmediateChange) { + setCurrentImmediate(immediate) + } + + if (didGetOptionsChange) { setCurrentGetOptions(getOptions) + } - if (immediate) { - setLoading() - } + if (immediate && (didImmediateChange || didGetOptionsChange)) { + setLoading() + } else if (didImmediateChange) { + // Disabling automatic fetching clears loading while the pending response is ignored. + setQueryState((state) => ({ ...state, isLoading: false })) } return useMemo( From 5bf49cb3620266614de4e74839b9afc3e088e921 Mon Sep 17 00:00:00 2001 From: Juraj Uhlar Date: Tue, 21 Jul 2026 13:38:29 +0200 Subject: [PATCH 24/41] test: close coverage gaps and drop dead wait-until helper --- __tests__/detect-env.test.ts | 91 +++++++++++++- __tests__/fpjs-provider.test.tsx | 154 +++++++++++++++++++++++- __tests__/use-visitor-data.test.tsx | 118 ++++++++++++------ __tests__/utils.test.ts | 63 ++++++++++ __tests__/with-environment.test.tsx | 8 -- src/components/fingerprint-provider.tsx | 22 ++-- src/utils/wait-until.ts | 23 ---- 7 files changed, 396 insertions(+), 83 deletions(-) create mode 100644 __tests__/utils.test.ts delete mode 100644 src/utils/wait-until.ts diff --git a/__tests__/detect-env.test.ts b/__tests__/detect-env.test.ts index afd706c7..0f9a0ac7 100644 --- a/__tests__/detect-env.test.ts +++ b/__tests__/detect-env.test.ts @@ -1,8 +1,14 @@ import { detectEnvironment } from '../src/detect-env' import { Env } from '../src/env.types' -import { describe, it, expect } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' describe('Detect user env', () => { + afterEach(() => { + document.getElementById('__NEXT_DATA__')?.remove() + Reflect.deleteProperty(window, 'next') + vi.restoreAllMocks() + }) + describe('Preact', () => { it('should detect preact if class components receive any arguments in render', () => { const env = detectEnvironment({ @@ -67,5 +73,88 @@ describe('Detect user env', () => { version, }) }) + + it('should skip Next detection when window is unavailable', () => { + const originalWindow = globalThis.window + Object.defineProperty(globalThis, 'window', { + value: undefined, + configurable: true, + }) + + try { + const env = detectEnvironment({ + context: { classRenderReceivesAnyArguments: false }, + }) + + expect(env).toEqual({ + name: Env.React, + }) + } finally { + Object.defineProperty(globalThis, 'window', { + value: originalWindow, + configurable: true, + writable: true, + }) + } + }) + }) +}) + +describe('getEnvironment', () => { + afterEach(() => { + vi.resetModules() + vi.doUnmock('../src/env') + }) + + it('returns parsed env details when the build-time env placeholder is valid JSON', async () => { + vi.resetModules() + vi.doMock('../src/env', () => ({ + env: JSON.stringify({ name: 'react', version: '18.0.0' }), + })) + + const { getEnvironment: getEnvironmentFresh } = await import('../src/get-env') + + expect( + getEnvironmentFresh({ + context: { classRenderReceivesAnyArguments: false }, + }) + ).toEqual({ + name: 'react', + version: '18.0.0', + }) + }) + + it('falls back to detection when the build-time env JSON is not env details', async () => { + vi.resetModules() + vi.doMock('../src/env', () => ({ + env: JSON.stringify({ foo: 'bar' }), + })) + + const { getEnvironment: getEnvironmentFresh } = await import('../src/get-env') + + expect( + getEnvironmentFresh({ + context: { classRenderReceivesAnyArguments: false }, + }) + ).toEqual({ + name: Env.React, + }) + }) + + it('falls back to detection when the build-time env is invalid', async () => { + vi.resetModules() + vi.doMock('../src/env', () => ({ + env: '%DETECTED_ENV%', + })) + + const { getEnvironment: getEnvironmentFresh } = await import('../src/get-env') + + expect( + getEnvironmentFresh({ + context: { classRenderReceivesAnyArguments: false }, + }) + ).toEqual({ + name: Env.React, + }) }) }) diff --git a/__tests__/fpjs-provider.test.tsx b/__tests__/fpjs-provider.test.tsx index 3c5efeb2..106d5400 100644 --- a/__tests__/fpjs-provider.test.tsx +++ b/__tests__/fpjs-provider.test.tsx @@ -1,16 +1,48 @@ -import { useContext } from 'react' -import { renderHook } from '@testing-library/react' -import { FingerprintContext } from '../src' +import { PropsWithChildren, useContext } from 'react' +import { act, render, renderHook } from '@testing-library/react' +import { FingerprintContext, FingerprintProvider, FingerprintProviderOptions, useVisitorData } from '../src' import { createWrapper, getDefaultLoadOptions } from './helpers' import { version } from '../package.json' -import { describe, it, expect, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import * as agent from '@fingerprint/agent' +import * as ssr from '../src/ssr' vi.mock('@fingerprint/agent', { spy: true }) +const mockGet = vi.fn() +const mockAgent = { + get: mockGet, + collect: vi.fn(), +} + const mockStart = vi.mocked(agent.start) +const renderProvider = (props: FingerprintProviderOptions = {}) => + render( + +
+ + ) + describe('FingerprintProvider', () => { + beforeEach(() => { + vi.resetAllMocks() + mockStart.mockReturnValue(mockAgent) + mockGet.mockResolvedValue({ + visitor_id: 'visitor', + event_id: 'event', + sealed_result: null, + cache_hit: false, + suspect_score: 0, + }) + }) + + afterEach(() => { + document.getElementById('__NEXT_DATA__')?.remove() + Reflect.deleteProperty(window, 'next') + vi.restoreAllMocks() + }) + it('should configure an instance of the Fp Agent', () => { const loadOptions = getDefaultLoadOptions() const wrapper = createWrapper({ @@ -33,4 +65,118 @@ describe('FingerprintProvider', () => { }, }) }) + + it('should include next version in integrationInfo when Next.js is detected', () => { + Object.assign(window, { next: { version: '14.2.0' } }) + + renderProvider() + + expect(mockStart).toHaveBeenCalledWith( + expect.objectContaining({ + integrationInfo: [`react-sdk/${version}/next/14.2.0`], + }) + ) + }) + + it('should rebuild the agent when forceRebuild is enabled and options change', () => { + const { rerender } = renderProvider({ apiKey: 'key-a', forceRebuild: true }) + + expect(mockStart).toHaveBeenCalledTimes(1) + + rerender( + +
+ + ) + + expect(mockStart).toHaveBeenCalledTimes(2) + expect(mockStart).toHaveBeenLastCalledWith( + expect.objectContaining({ + apiKey: 'key-b', + }) + ) + }) + + it('should not rebuild the agent when options change without forceRebuild', () => { + const { rerender } = renderProvider({ apiKey: 'key-a' }) + + expect(mockStart).toHaveBeenCalledTimes(1) + + rerender( + +
+ + ) + + expect(mockStart).toHaveBeenCalledTimes(1) + }) + + it('should use customAgent.start when a valid custom agent loader is provided', async () => { + const customStart = vi.fn().mockReturnValue(mockAgent) + const wrapper = ({ children }: PropsWithChildren) => ( + + {children} + + ) + + const { result } = renderHook(() => useVisitorData({ immediate: false }), { wrapper }) + + await act(async () => { + await result.current.getData() + }) + + expect(customStart).toHaveBeenCalled() + expect(mockStart).not.toHaveBeenCalled() + }) + + it('should fall back to the default agent when customAgent is invalid', async () => { + const wrapper = ({ children }: PropsWithChildren) => ( + + {children} + + ) + + const { result } = renderHook(() => useVisitorData({ immediate: false }), { wrapper }) + + await act(async () => { + await result.current.getData() + }) + + expect(mockStart).toHaveBeenCalled() + }) + + it('should merge provider getOptions into visitor data requests', async () => { + const wrapper = ({ children }: PropsWithChildren) => ( + + {children} + + ) + + const { result } = renderHook(() => useVisitorData({ immediate: false }), { wrapper }) + + await act(async () => { + await result.current.getData({ tag: { source: 'getData' } }) + }) + + expect(mockGet).toHaveBeenCalledWith({ + linkedId: 'from-provider', + tag: { source: 'getData' }, + }) + }) + + it('should throw when the client is used during SSR', async () => { + vi.spyOn(ssr, 'isSSR').mockReturnValue(true) + + const wrapper = createWrapper() + const { result } = renderHook(() => useVisitorData({ immediate: false }), { wrapper }) + + await expect(result.current.getData()).rejects.toThrow('FingerprintProvider client cannot be used in SSR') + }) }) diff --git a/__tests__/use-visitor-data.test.tsx b/__tests__/use-visitor-data.test.tsx index ae4b9ff8..ade8f8e5 100644 --- a/__tests__/use-visitor-data.test.tsx +++ b/__tests__/use-visitor-data.test.tsx @@ -32,18 +32,6 @@ describe('useVisitorData', () => { mockStart.mockReturnValue(mockAgent) }) - it('should provide the Fp context', () => { - const wrapper = createWrapper() - const { - result: { current }, - rerender, - } = renderHook(() => useVisitorData(), { wrapper }) - - rerender() - - expect(current).toBeDefined() - }) - it('should call getData on mount by default', async () => { mockGet.mockImplementation(() => mockGetResult) @@ -68,6 +56,19 @@ describe('useVisitorData', () => { ) }) + it('should default to immediate fetching when options are omitted', async () => { + mockGet.mockResolvedValue(mockGetResult) + + const wrapper = createWrapper() + const { result } = renderHook(() => useVisitorData(), { wrapper }) + + expect(result.current.isLoading).toBe(true) + + await waitFor(() => { + expect(result.current.data).toEqual(mockGetResult) + }) + }) + it('should avoid duplicate requests if one is already pending', async () => { mockGet.mockImplementation(async () => { await wait(250) @@ -207,30 +208,6 @@ describe('useVisitorData', () => { }) }) - it('should support immediate fetch with cache disabled', async () => { - const wrapper = createWrapper() - renderHook(() => useVisitorData({ immediate: true }), { wrapper }) - - await actWait(500) - - expect(mockGet).toHaveBeenCalledTimes(1) - expect(mockGet).toHaveBeenCalledWith({}) - }) - - it('should support overwriting default cache option in getData call', async () => { - const wrapper = createWrapper() - const hook = renderHook(() => useVisitorData({ immediate: false }), { - wrapper, - }) - - await act(async () => { - await hook.result.current.getData() - }) - - expect(mockGet).toHaveBeenCalledTimes(1) - expect(mockGet).toHaveBeenCalledWith({}) - }) - it('should re-fetch data when options change if "immediate" is set to true', async () => { const Component = () => { const [tag, setTag] = useState(1) @@ -350,6 +327,75 @@ describe('useVisitorData', () => { expect(result.current.data).toEqual(secondResult) }) + it('should not apply an outdated immediate error after getOptions change mid-flight', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) + + let rejectFirst!: (reason?: unknown) => void + const firstRequest = new Promise((_resolve, reject) => { + rejectFirst = reject + }) + const secondResult = { ...mockGetResult, visitor_id: 'second-visitor' } + + mockGet.mockImplementationOnce(() => firstRequest).mockResolvedValueOnce(secondResult) + + const wrapper = createWrapper() + const { result, rerender } = renderHook(({ tag }: { tag: number }) => useVisitorData({ immediate: true, tag }), { + wrapper, + initialProps: { tag: 1 }, + }) + + await actWait(50) + expect(mockGet).toHaveBeenCalledTimes(1) + + rerender({ tag: 2 }) + + await waitFor(() => { + expect(mockGet).toHaveBeenCalledTimes(2) + expect(result.current.data).toEqual(secondResult) + }) + + await act(async () => { + rejectFirst(new Error('stale failure')) + await firstRequest.catch(() => undefined) + }) + + expect(result.current).toMatchObject({ + isLoading: false, + isFetched: true, + data: secondResult, + error: undefined, + }) + expect(consoleError).not.toHaveBeenCalledWith( + expect.stringContaining('Failed to fetch visitor data on mount: Error: stale failure') + ) + + consoleError.mockRestore() + }) + + it('should reject getData when params are null', async () => { + const wrapper = createWrapper() + const { result } = renderHook(() => useVisitorData({ immediate: false }), { wrapper }) + + await expect( + // @ts-expect-error intentional invalid call + result.current.getData(null) + ).rejects.toThrow('getDataParams must not be null or undefined') + }) + + it('should normalize non-Error rejections from getData', async () => { + mockGet.mockRejectedValue('raw failure') + + const wrapper = createWrapper() + const { result } = renderHook(() => useVisitorData({ immediate: false }), { wrapper }) + + await act(async () => { + await expect(result.current.getData()).rejects.toThrow('raw failure') + }) + + expect(result.current.error).toEqual(expect.any(Error)) + expect(result.current.error?.message).toBe('raw failure') + }) + it('should correctly pass errors from agent', async () => { const ERROR_CLIENT_TIMEOUT = 'timeout' mockGet.mockRejectedValue(new Error(ERROR_CLIENT_TIMEOUT)) diff --git a/__tests__/utils.test.ts b/__tests__/utils.test.ts new file mode 100644 index 00000000..744a254b --- /dev/null +++ b/__tests__/utils.test.ts @@ -0,0 +1,63 @@ +import { assertIsDefined } from '../src/utils/assert-is-defined' +import { getOptionsCacheKey, areGetOptionsEqual } from '../src/utils/get-options-cache-key' +import { toError } from '../src/utils/to-error' +import { describe, expect, it } from 'vitest' + +describe('assertIsDefined', () => { + it('allows defined values', () => { + expect(() => { + assertIsDefined('ok', 'value') + }).not.toThrow() + expect(() => { + assertIsDefined(0, 'value') + }).not.toThrow() + }) + + it('throws for null or undefined', () => { + expect(() => { + assertIsDefined(null, 'value') + }).toThrow('value must not be null or undefined') + expect(() => { + assertIsDefined(undefined, 'value') + }).toThrow('value must not be null or undefined') + }) +}) + +describe('toError', () => { + it('returns Error instances as-is', () => { + const error = new Error('boom') + expect(toError(error)).toBe(error) + }) + + it('wraps non-Error values', () => { + expect(toError('raw')).toEqual(new Error('raw')) + expect(toError(42)).toEqual(new Error('42')) + }) +}) + +describe('getOptionsCacheKey', () => { + it('returns an empty key for missing options', () => { + expect(getOptionsCacheKey()).toBe('') + expect(getOptionsCacheKey(undefined)).toBe('') + }) + + it('distinguishes empty-ish tag values', () => { + expect(getOptionsCacheKey({ tag: undefined })).not.toBe(getOptionsCacheKey({ tag: null })) + expect(getOptionsCacheKey({ tag: null })).not.toBe(getOptionsCacheKey({ tag: '' })) + }) + + it('treats object tags with differently ordered keys as equal', () => { + expect(areGetOptionsEqual({ tag: { a: 1, b: 2 } }, { tag: { b: 2, a: 1 } })).toBe(true) + }) + + it('serializes array tags', () => { + expect(getOptionsCacheKey({ tag: [{ b: 2, a: 1 }, 'x'] })).toBe(getOptionsCacheKey({ tag: [{ a: 1, b: 2 }, 'x'] })) + }) + + it('falls back for circular values', () => { + const circular: { self?: unknown } = {} + circular.self = circular + + expect(getOptionsCacheKey({ tag: circular })).toContain('unserializable:object') + }) +}) diff --git a/__tests__/with-environment.test.tsx b/__tests__/with-environment.test.tsx index 884ff5f9..09cfb483 100644 --- a/__tests__/with-environment.test.tsx +++ b/__tests__/with-environment.test.tsx @@ -13,14 +13,6 @@ describe('WithEnvironment', () => { expect(renderChild).toHaveBeenCalledWith(expect.objectContaining({ name: 'react' })) }) - it('keeps the original props of the element', () => { - const Echo = ({ message }: { message: string }) => {message} - - const { container } = render({() => }) - - expect(container.innerHTML).toContain('hello') - }) - it('should not break navigation', async () => { const user = userEvent.setup() const Home = () => ( diff --git a/src/components/fingerprint-provider.tsx b/src/components/fingerprint-provider.tsx index 4dc45d40..da857d62 100644 --- a/src/components/fingerprint-provider.tsx +++ b/src/components/fingerprint-provider.tsx @@ -13,6 +13,14 @@ export interface FingerprintProviderOptions extends StartOptions { * since it can be triggered too often (e.g. on every render) and negatively affect performance of the JS agent. */ forceRebuild?: boolean + /** + * Optional custom agent loader. When provided, its `start` is used instead of the default agent. + */ + customAgent?: Pick + /** + * Default `agent.get()` options merged into every visitor data request from this provider. + */ + getOptions?: GetOptions } /** @@ -40,30 +48,22 @@ interface ProviderWithEnvProps extends FingerprintProviderOptions { * Contains details about the env we're currently running in (e.g. framework, version) */ env: EnvDetails - getOptions?: GetOptions } function isLoader(value: unknown): value is Pick { return typeof value === 'object' && value !== null && 'start' in value && typeof value.start === 'function' } -function getCustomLoader(props: Record) { - if ('customAgent' in props && isLoader(props.customAgent)) { - return props.customAgent - } - - return undefined -} - function ProviderWithEnv({ children, forceRebuild, env, getOptions, + customAgent, ...agentOptions }: PropsWithChildren) { const createClient = useCallback(() => { - const customLoader = getCustomLoader(agentOptions) + const customLoader = isLoader(customAgent) ? customAgent : undefined const envInfo = env.version !== undefined && env.version !== '' ? `${env.name}/${env.version}` : env.name const integrationInfo = `react-sdk/${packageInfo.version}/${envInfo}` @@ -76,7 +76,7 @@ function ProviderWithEnv({ } return customLoader ? customLoader.start(startParams) : start(startParams) - }, [agentOptions, env]) + }, [agentOptions, customAgent, env]) const clientRef = useRef(undefined) diff --git a/src/utils/wait-until.ts b/src/utils/wait-until.ts deleted file mode 100644 index 394122a4..00000000 --- a/src/utils/wait-until.ts +++ /dev/null @@ -1,23 +0,0 @@ -export interface WaitUntilParams { - checkCondition: () => boolean - timeoutMs?: number - intervalMs?: number -} - -export function waitUntil({ checkCondition, intervalMs = 250, timeoutMs = 2000 }: WaitUntilParams) { - return new Promise((resolve, reject) => { - const timeoutId = setTimeout(() => { - clearInterval(interval) - - reject(new Error('Timeout')) - }, timeoutMs) - - const interval = setInterval(() => { - if (checkCondition()) { - clearTimeout(timeoutId) - clearInterval(interval) - resolve() - } - }, intervalMs) - }) -} From f1671b01e1805a167e968c5ec36c5481cb12cddb Mon Sep 17 00:00:00 2001 From: Juraj Uhlar Date: Tue, 21 Jul 2026 13:51:29 +0200 Subject: [PATCH 25/41] chore: changeset --- .changeset/fix-immediate-request-state.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fix-immediate-request-state.md diff --git a/.changeset/fix-immediate-request-state.md b/.changeset/fix-immediate-request-state.md new file mode 100644 index 00000000..b1b72917 --- /dev/null +++ b/.changeset/fix-immediate-request-state.md @@ -0,0 +1,5 @@ +--- +'@fingerprint/react': patch +--- + +Prevent stale automatic `useVisitorData` requests from overwriting newer state and keep loading state synchronized when `immediate` changes. From 61c2213c691432b4cb80dad2d958b0f4d53cc00e Mon Sep 17 00:00:00 2001 From: Juraj Uhlar Date: Tue, 21 Jul 2026 14:02:15 +0200 Subject: [PATCH 26/41] fix: clarify automatic fetch error handling --- README.md | 2 +- __tests__/use-visitor-data.test.tsx | 6 +++--- src/use-visitor-data.ts | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index a3bc112a..f5267ebd 100644 --- a/README.md +++ b/README.md @@ -184,7 +184,7 @@ export default App ``` - You can pass `{ immediate: false }` to disable automatic identification and call `getData` manually instead. - - By default (`{ immediate: true }`), the hook automatically identifies the visitor after mounting and whenever its request options change. Changing `immediate` from `false` to `true` also starts an identification request with the latest options. +- By default (`{ immediate: true }`), the hook automatically identifies the visitor after mounting and whenever its request options change. Changing `immediate` from `false` to `true` also starts an identification request with the latest options. - See the full code example in the [examples folder](./examples/). - See our [Use cases](https://demo.fingerprint.com) page for [open-source](https://github.com/fingerprintjs/fingerprintjs-pro-use-cases) real-world examples of using Fingerprint to detect fraud and streamline user experiences. diff --git a/__tests__/use-visitor-data.test.tsx b/__tests__/use-visitor-data.test.tsx index ae4b9ff8..d91810ea 100644 --- a/__tests__/use-visitor-data.test.tsx +++ b/__tests__/use-visitor-data.test.tsx @@ -272,7 +272,7 @@ describe('useVisitorData', () => { it('should set error state when immediate mount fetch fails', async () => { const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) - mockGet.mockRejectedValue(new Error('mount failed')) + mockGet.mockRejectedValue('mount failed') const wrapper = createWrapper() const { result } = renderHook(() => useVisitorData({ immediate: true }), { wrapper }) @@ -285,7 +285,7 @@ describe('useVisitorData', () => { data: undefined, error: expect.objectContaining({ message: 'mount failed' }), }) - expect(consoleError).toHaveBeenCalledWith('Failed to fetch visitor data on mount: Error: mount failed') + expect(consoleError).toHaveBeenCalledWith('Failed to fetch visitor data automatically: Error: mount failed') consoleError.mockRestore() }) @@ -304,7 +304,7 @@ describe('useVisitorData', () => { }) }) expect(consoleError).toHaveBeenCalledWith( - 'Failed to fetch visitor data on mount: Error: You forgot to wrap your component in .' + 'Failed to fetch visitor data automatically: Error: You forgot to wrap your component in .' ) consoleError.mockRestore() diff --git a/src/use-visitor-data.ts b/src/use-visitor-data.ts index 7833fa32..7b81ad9e 100644 --- a/src/use-visitor-data.ts +++ b/src/use-visitor-data.ts @@ -132,8 +132,8 @@ export function useVisitorData( return } - setFailure(unknownError) - console.error(`Failed to fetch visitor data on mount: ${String(unknownError)}`) + const error = setFailure(unknownError) + console.error(`Failed to fetch visitor data automatically: ${error}`) }) return () => { From 29f44f9e54e17c96666871031a917e33a430164d Mon Sep 17 00:00:00 2001 From: Juraj Uhlar Date: Wed, 22 Jul 2026 10:05:29 +0200 Subject: [PATCH 27/41] fix: docs phrasing Co-authored-by: Dan McNulty <212590662+mcnulty-fp@users.noreply.github.com> --- src/use-visitor-data.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/use-visitor-data.ts b/src/use-visitor-data.ts index 7b81ad9e..526d71e4 100644 --- a/src/use-visitor-data.ts +++ b/src/use-visitor-data.ts @@ -7,8 +7,8 @@ import { GetOptions, GetResult } from '@fingerprint/agent' export interface UseVisitorDataConfig { /** - * Controls automatic visitor data fetching. When enabled, the hook fetches after mounting, whenever the - * request options change, and whenever `immediate` changes from `false` to `true`. + * Controls automatic visitor data fetching. When `true`, the hook fetches after mounting, and whenever the + * request options change. When changed from `false` to `true`, visitor data is initiated after the current render. */ immediate: boolean } From acb505813cee7ed1a814cd4b8f10e107e6d31820 Mon Sep 17 00:00:00 2001 From: Juraj Uhlar Date: Wed, 22 Jul 2026 10:07:35 +0200 Subject: [PATCH 28/41] fix: dont mix promises and async-await Co-authored-by: Dan McNulty <212590662+mcnulty-fp@users.noreply.github.com> --- src/use-visitor-data.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/use-visitor-data.ts b/src/use-visitor-data.ts index 526d71e4..184e97fa 100644 --- a/src/use-visitor-data.ts +++ b/src/use-visitor-data.ts @@ -120,20 +120,23 @@ export function useVisitorData( let ignore = false - Promise.resolve() - .then(() => getVisitorData(currentGetOptions)) - .then((result) => { + const fetchVisitorData = async () => { + try { + const result = await getVisitorData(currentGetOptions) if (!ignore) { setSuccess(result) } - }) - .catch((unknownError: unknown) => { + } catch (unknownError: unknown) { if (ignore) { return } const error = setFailure(unknownError) console.error(`Failed to fetch visitor data automatically: ${error}`) + } + } + + void fetchVisitorData() }) return () => { From c11d5b179119ae2bfb5a4400517237f4ab911b29 Mon Sep 17 00:00:00 2001 From: Juraj Uhlar Date: Wed, 22 Jul 2026 10:31:49 +0200 Subject: [PATCH 29/41] fix: use currentImmediate --- src/use-visitor-data.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/use-visitor-data.ts b/src/use-visitor-data.ts index 184e97fa..77f67aa6 100644 --- a/src/use-visitor-data.ts +++ b/src/use-visitor-data.ts @@ -114,7 +114,7 @@ export function useVisitorData( * https://react.dev/reference/react/useEffect#fetching-data-with-effects */ useEffect(() => { - if (!immediate) { + if (!currentImmediate) { return } @@ -137,12 +137,11 @@ export function useVisitorData( } void fetchVisitorData() - }) return () => { ignore = true } - }, [immediate, getVisitorData, currentGetOptions]) + }, [currentImmediate, getVisitorData, currentGetOptions]) // When automatic-fetch inputs change, store them and reset to loading during render so the effect can start // the request without synchronously setting state: https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes From 046727db27ec22174bc5731706538262f0ee1060 Mon Sep 17 00:00:00 2001 From: Juraj Uhlar Date: Wed, 22 Jul 2026 10:36:10 +0200 Subject: [PATCH 30/41] test: replace actWait with waitFor and act Remove arbitrary timeouts from useVisitorData tests now that async hook updates flush reliably. --- __tests__/helpers.tsx | 7 ---- __tests__/use-visitor-data.test.tsx | 63 +++++++++++++++-------------- 2 files changed, 33 insertions(+), 37 deletions(-) diff --git a/__tests__/helpers.tsx b/__tests__/helpers.tsx index 94faa0fc..437ed5b7 100644 --- a/__tests__/helpers.tsx +++ b/__tests__/helpers.tsx @@ -1,6 +1,5 @@ import { PropsWithChildren } from 'react' import { FingerprintProvider, FingerprintProviderOptions } from '../src' -import { act } from '@testing-library/react' export const getDefaultLoadOptions = () => ({ apiKey: 'test_api_key', @@ -18,9 +17,3 @@ export const wait = (ms: number) => new Promise((resolve) => { setTimeout(resolve, ms) }) - -export const actWait = async (ms: number) => { - await act(async () => { - await wait(ms) - }) -} diff --git a/__tests__/use-visitor-data.test.tsx b/__tests__/use-visitor-data.test.tsx index d91810ea..b5afa450 100644 --- a/__tests__/use-visitor-data.test.tsx +++ b/__tests__/use-visitor-data.test.tsx @@ -1,6 +1,6 @@ import { useVisitorData, UseVisitorDataReturn } from '../src' import { act, render, renderHook, screen, waitFor } from '@testing-library/react' -import { actWait, createWrapper, wait } from './helpers' +import { createWrapper, wait } from './helpers' import { useEffect, useState } from 'react' import userEvent from '@testing-library/user-event' import * as agent from '@fingerprint/agent' @@ -56,16 +56,16 @@ describe('useVisitorData', () => { }) ) - await actWait(500) - - expect(mockStart).toHaveBeenCalled() - expect(mockGet).toHaveBeenCalled() - expect(result.current).toMatchObject( - expect.objectContaining({ - isLoading: false, - data: mockGetResult, - }) - ) + await waitFor(() => { + expect(mockStart).toHaveBeenCalled() + expect(mockGet).toHaveBeenCalled() + expect(result.current).toMatchObject( + expect.objectContaining({ + isLoading: false, + data: mockGetResult, + }) + ) + }) }) it('should avoid duplicate requests if one is already pending', async () => { @@ -83,9 +83,9 @@ describe('useVisitorData', () => { }) ) - await Promise.all([result.current.getData(), result.current.getData()]) - - await actWait(500) + await act(async () => { + await Promise.all([result.current.getData(), result.current.getData()]) + }) expect(mockStart).toHaveBeenCalled() expect(mockGet).toHaveBeenCalledTimes(1) @@ -211,9 +211,9 @@ describe('useVisitorData', () => { const wrapper = createWrapper() renderHook(() => useVisitorData({ immediate: true }), { wrapper }) - await actWait(500) - - expect(mockGet).toHaveBeenCalledTimes(1) + await waitFor(() => { + expect(mockGet).toHaveBeenCalledTimes(1) + }) expect(mockGet).toHaveBeenCalledWith({}) }) @@ -259,13 +259,15 @@ describe('useVisitorData', () => { ) - await actWait(1000) + await waitFor(() => { + expect(mockGet).toHaveBeenCalledTimes(1) + }) await user.click(screen.getByRole('button', { name: 'Change options' })) - await actWait(1000) - - expect(mockGet).toHaveBeenCalledTimes(2) + await waitFor(() => { + expect(mockGet).toHaveBeenCalledTimes(2) + }) expect(mockGet).toHaveBeenNthCalledWith(1, { tag: 1 }) expect(mockGet).toHaveBeenNthCalledWith(2, { tag: 2 }) }) @@ -277,13 +279,13 @@ describe('useVisitorData', () => { const wrapper = createWrapper() const { result } = renderHook(() => useVisitorData({ immediate: true }), { wrapper }) - await actWait(500) - - expect(result.current).toMatchObject({ - isLoading: false, - isFetched: false, - data: undefined, - error: expect.objectContaining({ message: 'mount failed' }), + await waitFor(() => { + expect(result.current).toMatchObject({ + isLoading: false, + isFetched: false, + data: undefined, + error: expect.objectContaining({ message: 'mount failed' }), + }) }) expect(consoleError).toHaveBeenCalledWith('Failed to fetch visitor data automatically: Error: mount failed') @@ -327,8 +329,9 @@ describe('useVisitorData', () => { }) // Mount started one in-flight request for tag: 1. - await actWait(50) - expect(mockGet).toHaveBeenCalledTimes(1) + await waitFor(() => { + expect(mockGet).toHaveBeenCalledTimes(1) + }) expect(result.current.isLoading).toBe(true) // Changing options cancels the previous effect and starts a new immediate fetch. From 837d3e5416e7c5395d60c2e20ff60ff4c8036b08 Mon Sep 17 00:00:00 2001 From: Juraj Uhlar Date: Wed, 22 Jul 2026 10:43:09 +0200 Subject: [PATCH 31/41] fix: clarify changeset --- .changeset/fix-immediate-request-state.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.changeset/fix-immediate-request-state.md b/.changeset/fix-immediate-request-state.md index b1b72917..ea991b33 100644 --- a/.changeset/fix-immediate-request-state.md +++ b/.changeset/fix-immediate-request-state.md @@ -1,5 +1,10 @@ --- -'@fingerprint/react': patch +'@fingerprint/react': minor --- -Prevent stale automatic `useVisitorData` requests from overwriting newer state and keep loading state synchronized when `immediate` changes. +Behavior bug fixes: + +- Stale automatic `useVisitorData` requests no longer overwrite newer state. +- Loading state is now correctly synchronized when `immediate` changes. + +We recommend to double-check that your implementation isn't relying on the previous incorrect behavior when upgrading. From 43b4d295bb328896ec605620a29da15cb2f6a133 Mon Sep 17 00:00:00 2001 From: Juraj Uhlar Date: Wed, 22 Jul 2026 10:59:37 +0200 Subject: [PATCH 32/41] chore: polish changeset --- .changeset/fix-immediate-request-state.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/fix-immediate-request-state.md b/.changeset/fix-immediate-request-state.md index ea991b33..a77d609f 100644 --- a/.changeset/fix-immediate-request-state.md +++ b/.changeset/fix-immediate-request-state.md @@ -4,7 +4,7 @@ Behavior bug fixes: -- Stale automatic `useVisitorData` requests no longer overwrite newer state. +- Stale automatic `useVisitorData` requests (when `immediate` changes from `true` to `false` during an automatic request) no longer overwrite state (the result is ignored). - Loading state is now correctly synchronized when `immediate` changes. -We recommend to double-check that your implementation isn't relying on the previous incorrect behavior when upgrading. +We recommend double-checking that your implementation isn't relying on the previous incorrect behavior when upgrading. From e02506ddc3aeb440e4d494db8ad5de148a896a86 Mon Sep 17 00:00:00 2001 From: Juraj Uhlar Date: Wed, 22 Jul 2026 12:34:15 +0200 Subject: [PATCH 33/41] chore: re-trigger CI after dx-team-toolkit v1.5.8 From 499278d811ac70bed6be7a97a29d6d9d2401a85c Mon Sep 17 00:00:00 2001 From: Juraj Uhlar Date: Thu, 23 Jul 2026 14:08:00 +0200 Subject: [PATCH 34/41] test: keep coverage cleanup API-neutral --- __tests__/fpjs-provider.test.tsx | 26 ++++++++++++++++++------- __tests__/use-visitor-data.test.tsx | 13 ------------- __tests__/utils.test.ts | 16 --------------- src/components/fingerprint-provider.tsx | 10 ++-------- 4 files changed, 21 insertions(+), 44 deletions(-) diff --git a/__tests__/fpjs-provider.test.tsx b/__tests__/fpjs-provider.test.tsx index 106d5400..01dd7658 100644 --- a/__tests__/fpjs-provider.test.tsx +++ b/__tests__/fpjs-provider.test.tsx @@ -5,6 +5,7 @@ import { createWrapper, getDefaultLoadOptions } from './helpers' import { version } from '../package.json' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import * as agent from '@fingerprint/agent' +import type { GetOptions } from '@fingerprint/agent' import * as ssr from '../src/ssr' vi.mock('@fingerprint/agent', { spy: true }) @@ -17,7 +18,16 @@ const mockAgent = { const mockStart = vi.mocked(agent.start) -const renderProvider = (props: FingerprintProviderOptions = {}) => +type InternalFingerprintProviderOptions = FingerprintProviderOptions & { + customAgent?: Pick + getOptions?: GetOptions +} + +const InternalFingerprintProvider = (props: PropsWithChildren) => ( + +) + +const renderProvider = (props: Partial = {}) => render(
@@ -114,9 +124,9 @@ describe('FingerprintProvider', () => { it('should use customAgent.start when a valid custom agent loader is provided', async () => { const customStart = vi.fn().mockReturnValue(mockAgent) const wrapper = ({ children }: PropsWithChildren) => ( - + {children} - + ) const { result } = renderHook(() => useVisitorData({ immediate: false }), { wrapper }) @@ -126,18 +136,19 @@ describe('FingerprintProvider', () => { }) expect(customStart).toHaveBeenCalled() + expect(customStart.mock.calls[0]?.[0]).not.toHaveProperty('customAgent') expect(mockStart).not.toHaveBeenCalled() }) it('should fall back to the default agent when customAgent is invalid', async () => { const wrapper = ({ children }: PropsWithChildren) => ( - {children} - + ) const { result } = renderHook(() => useVisitorData({ immediate: false }), { wrapper }) @@ -147,16 +158,17 @@ describe('FingerprintProvider', () => { }) expect(mockStart).toHaveBeenCalled() + expect(mockStart.mock.calls[0]?.[0]).not.toHaveProperty('customAgent') }) it('should merge provider getOptions into visitor data requests', async () => { const wrapper = ({ children }: PropsWithChildren) => ( - {children} - + ) const { result } = renderHook(() => useVisitorData({ immediate: false }), { wrapper }) diff --git a/__tests__/use-visitor-data.test.tsx b/__tests__/use-visitor-data.test.tsx index 35653e69..31645f31 100644 --- a/__tests__/use-visitor-data.test.tsx +++ b/__tests__/use-visitor-data.test.tsx @@ -56,19 +56,6 @@ describe('useVisitorData', () => { }) }) - it('should default to immediate fetching when options are omitted', async () => { - mockGet.mockResolvedValue(mockGetResult) - - const wrapper = createWrapper() - const { result } = renderHook(() => useVisitorData(), { wrapper }) - - expect(result.current.isLoading).toBe(true) - - await waitFor(() => { - expect(result.current.data).toEqual(mockGetResult) - }) - }) - it('should avoid duplicate requests if one is already pending', async () => { mockGet.mockImplementation(async () => { await wait(250) diff --git a/__tests__/utils.test.ts b/__tests__/utils.test.ts index 744a254b..1918b6a7 100644 --- a/__tests__/utils.test.ts +++ b/__tests__/utils.test.ts @@ -5,9 +5,6 @@ import { describe, expect, it } from 'vitest' describe('assertIsDefined', () => { it('allows defined values', () => { - expect(() => { - assertIsDefined('ok', 'value') - }).not.toThrow() expect(() => { assertIsDefined(0, 'value') }).not.toThrow() @@ -31,14 +28,12 @@ describe('toError', () => { it('wraps non-Error values', () => { expect(toError('raw')).toEqual(new Error('raw')) - expect(toError(42)).toEqual(new Error('42')) }) }) describe('getOptionsCacheKey', () => { it('returns an empty key for missing options', () => { expect(getOptionsCacheKey()).toBe('') - expect(getOptionsCacheKey(undefined)).toBe('') }) it('distinguishes empty-ish tag values', () => { @@ -49,15 +44,4 @@ describe('getOptionsCacheKey', () => { it('treats object tags with differently ordered keys as equal', () => { expect(areGetOptionsEqual({ tag: { a: 1, b: 2 } }, { tag: { b: 2, a: 1 } })).toBe(true) }) - - it('serializes array tags', () => { - expect(getOptionsCacheKey({ tag: [{ b: 2, a: 1 }, 'x'] })).toBe(getOptionsCacheKey({ tag: [{ a: 1, b: 2 }, 'x'] })) - }) - - it('falls back for circular values', () => { - const circular: { self?: unknown } = {} - circular.self = circular - - expect(getOptionsCacheKey({ tag: circular })).toContain('unserializable:object') - }) }) diff --git a/src/components/fingerprint-provider.tsx b/src/components/fingerprint-provider.tsx index da857d62..15265964 100644 --- a/src/components/fingerprint-provider.tsx +++ b/src/components/fingerprint-provider.tsx @@ -13,14 +13,6 @@ export interface FingerprintProviderOptions extends StartOptions { * since it can be triggered too often (e.g. on every render) and negatively affect performance of the JS agent. */ forceRebuild?: boolean - /** - * Optional custom agent loader. When provided, its `start` is used instead of the default agent. - */ - customAgent?: Pick - /** - * Default `agent.get()` options merged into every visitor data request from this provider. - */ - getOptions?: GetOptions } /** @@ -48,6 +40,8 @@ interface ProviderWithEnvProps extends FingerprintProviderOptions { * Contains details about the env we're currently running in (e.g. framework, version) */ env: EnvDetails + getOptions?: GetOptions + customAgent?: Pick } function isLoader(value: unknown): value is Pick { From d38ad11f00c2881bb33438c9c7d48ec49bc4c601 Mon Sep 17 00:00:00 2001 From: Juraj Uhlar Date: Thu, 23 Jul 2026 14:16:33 +0200 Subject: [PATCH 35/41] test: avoid provider runtime changes --- __tests__/fpjs-provider.test.tsx | 2 -- src/components/fingerprint-provider.tsx | 14 ++++++++++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/__tests__/fpjs-provider.test.tsx b/__tests__/fpjs-provider.test.tsx index 01dd7658..51713033 100644 --- a/__tests__/fpjs-provider.test.tsx +++ b/__tests__/fpjs-provider.test.tsx @@ -136,7 +136,6 @@ describe('FingerprintProvider', () => { }) expect(customStart).toHaveBeenCalled() - expect(customStart.mock.calls[0]?.[0]).not.toHaveProperty('customAgent') expect(mockStart).not.toHaveBeenCalled() }) @@ -158,7 +157,6 @@ describe('FingerprintProvider', () => { }) expect(mockStart).toHaveBeenCalled() - expect(mockStart.mock.calls[0]?.[0]).not.toHaveProperty('customAgent') }) it('should merge provider getOptions into visitor data requests', async () => { diff --git a/src/components/fingerprint-provider.tsx b/src/components/fingerprint-provider.tsx index 15265964..4dc45d40 100644 --- a/src/components/fingerprint-provider.tsx +++ b/src/components/fingerprint-provider.tsx @@ -41,23 +41,29 @@ interface ProviderWithEnvProps extends FingerprintProviderOptions { */ env: EnvDetails getOptions?: GetOptions - customAgent?: Pick } function isLoader(value: unknown): value is Pick { return typeof value === 'object' && value !== null && 'start' in value && typeof value.start === 'function' } +function getCustomLoader(props: Record) { + if ('customAgent' in props && isLoader(props.customAgent)) { + return props.customAgent + } + + return undefined +} + function ProviderWithEnv({ children, forceRebuild, env, getOptions, - customAgent, ...agentOptions }: PropsWithChildren) { const createClient = useCallback(() => { - const customLoader = isLoader(customAgent) ? customAgent : undefined + const customLoader = getCustomLoader(agentOptions) const envInfo = env.version !== undefined && env.version !== '' ? `${env.name}/${env.version}` : env.name const integrationInfo = `react-sdk/${packageInfo.version}/${envInfo}` @@ -70,7 +76,7 @@ function ProviderWithEnv({ } return customLoader ? customLoader.start(startParams) : start(startParams) - }, [agentOptions, customAgent, env]) + }, [agentOptions, env]) const clientRef = useRef(undefined) From 08103c9c74db2dcde8c18aab331dd9f5ed89a7ae Mon Sep 17 00:00:00 2001 From: Juraj Uhlar Date: Thu, 23 Jul 2026 14:28:25 +0200 Subject: [PATCH 36/41] chore: add comment --- __tests__/detect-env.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/__tests__/detect-env.test.ts b/__tests__/detect-env.test.ts index 0f9a0ac7..4443b206 100644 --- a/__tests__/detect-env.test.ts +++ b/__tests__/detect-env.test.ts @@ -134,6 +134,7 @@ describe('getEnvironment', () => { expect( getEnvironmentFresh({ + // absence of classRenderReceivesAnyArguments is React signal context: { classRenderReceivesAnyArguments: false }, }) ).toEqual({ From bcd57ca398cbabb3e51d2784706a5707a552fdd6 Mon Sep 17 00:00:00 2001 From: Juraj Uhlar Date: Thu, 23 Jul 2026 14:38:22 +0200 Subject: [PATCH 37/41] test: remove private utility coverage --- __tests__/use-visitor-data.test.tsx | 10 +++--- __tests__/utils.test.ts | 47 ----------------------------- 2 files changed, 5 insertions(+), 52 deletions(-) delete mode 100644 __tests__/utils.test.ts diff --git a/__tests__/use-visitor-data.test.tsx b/__tests__/use-visitor-data.test.tsx index 31645f31..5312ad40 100644 --- a/__tests__/use-visitor-data.test.tsx +++ b/__tests__/use-visitor-data.test.tsx @@ -411,9 +411,9 @@ describe('useVisitorData', () => { expect(result.current.error?.message).toBe('raw failure') }) - it('should correctly pass errors from agent', async () => { - const ERROR_CLIENT_TIMEOUT = 'timeout' - mockGet.mockRejectedValue(new Error(ERROR_CLIENT_TIMEOUT)) + it('should preserve Error instances from agent', async () => { + const error = new Error('timeout') + mockGet.mockRejectedValue(error) const wrapper = createWrapper() const hook = renderHook(() => useVisitorData({ immediate: false }), { wrapper }) @@ -421,10 +421,10 @@ describe('useVisitorData', () => { await act(async () => { const promise = hook.result.current.getData() - await expect(promise).rejects.toThrow(ERROR_CLIENT_TIMEOUT) + await expect(promise).rejects.toBe(error) }) - expect(hook.result.current.error?.message).toBe(ERROR_CLIENT_TIMEOUT) + expect(hook.result.current.error).toBe(error) }) it('`getVisitorData` `getOptions` should be passed from `getVisitorData` `getOptions`', async () => { diff --git a/__tests__/utils.test.ts b/__tests__/utils.test.ts deleted file mode 100644 index 1918b6a7..00000000 --- a/__tests__/utils.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { assertIsDefined } from '../src/utils/assert-is-defined' -import { getOptionsCacheKey, areGetOptionsEqual } from '../src/utils/get-options-cache-key' -import { toError } from '../src/utils/to-error' -import { describe, expect, it } from 'vitest' - -describe('assertIsDefined', () => { - it('allows defined values', () => { - expect(() => { - assertIsDefined(0, 'value') - }).not.toThrow() - }) - - it('throws for null or undefined', () => { - expect(() => { - assertIsDefined(null, 'value') - }).toThrow('value must not be null or undefined') - expect(() => { - assertIsDefined(undefined, 'value') - }).toThrow('value must not be null or undefined') - }) -}) - -describe('toError', () => { - it('returns Error instances as-is', () => { - const error = new Error('boom') - expect(toError(error)).toBe(error) - }) - - it('wraps non-Error values', () => { - expect(toError('raw')).toEqual(new Error('raw')) - }) -}) - -describe('getOptionsCacheKey', () => { - it('returns an empty key for missing options', () => { - expect(getOptionsCacheKey()).toBe('') - }) - - it('distinguishes empty-ish tag values', () => { - expect(getOptionsCacheKey({ tag: undefined })).not.toBe(getOptionsCacheKey({ tag: null })) - expect(getOptionsCacheKey({ tag: null })).not.toBe(getOptionsCacheKey({ tag: '' })) - }) - - it('treats object tags with differently ordered keys as equal', () => { - expect(areGetOptionsEqual({ tag: { a: 1, b: 2 } }, { tag: { b: 2, a: 1 } })).toBe(true) - }) -}) From c8f011a8cd864e712d6680d11eb03c3420ea14ed Mon Sep 17 00:00:00 2001 From: Juraj Uhlar Date: Thu, 23 Jul 2026 15:06:06 +0200 Subject: [PATCH 38/41] test: strengthen visitor data behavior coverage --- __tests__/use-visitor-data.test.tsx | 125 +++++++++++++++++++++++----- 1 file changed, 104 insertions(+), 21 deletions(-) diff --git a/__tests__/use-visitor-data.test.tsx b/__tests__/use-visitor-data.test.tsx index 5312ad40..230af24e 100644 --- a/__tests__/use-visitor-data.test.tsx +++ b/__tests__/use-visitor-data.test.tsx @@ -32,14 +32,15 @@ describe('useVisitorData', () => { mockStart.mockReturnValue(mockAgent) }) - it('should call getData on mount by default', async () => { + it('should fetch on mount when called without options', async () => { mockGet.mockImplementation(() => mockGetResult) const wrapper = createWrapper() - const { result } = renderHook(() => useVisitorData({ immediate: true }), { wrapper }) + const { result } = renderHook(() => useVisitorData(), { wrapper }) expect(result.current).toMatchObject( expect.objectContaining({ isLoading: true, + isFetched: false, data: undefined, }) ) @@ -50,6 +51,7 @@ describe('useVisitorData', () => { expect(result.current).toMatchObject( expect.objectContaining({ isLoading: false, + isFetched: true, data: mockGetResult, }) ) @@ -85,6 +87,24 @@ describe('useVisitorData', () => { ) }) + it('should allow a matching request again after the pending request settles', async () => { + const secondResult = { ...mockGetResult, visitor_id: 'second-visitor' } + mockGet.mockResolvedValueOnce(mockGetResult).mockResolvedValueOnce(secondResult) + + const wrapper = createWrapper() + const { result } = renderHook(() => useVisitorData({ immediate: false }), { wrapper }) + + await act(async () => { + await expect(result.current.getData({ linkedId: 'same-request' })).resolves.toEqual(mockGetResult) + }) + await act(async () => { + await expect(result.current.getData({ linkedId: 'same-request' })).resolves.toEqual(secondResult) + }) + + expect(mockGet).toHaveBeenCalledTimes(2) + expect(result.current.data).toEqual(secondResult) + }) + it('should not deduplicate requests with distinct empty tag values', async () => { mockGet.mockImplementation(async () => { await wait(250) @@ -103,6 +123,50 @@ describe('useVisitorData', () => { expect(mockGet).toHaveBeenCalledTimes(3) }) + it('should reset fetched data while a subsequent request is pending', async () => { + const secondResult = { ...mockGetResult, visitor_id: 'second-visitor' } + let resolveSecondRequest!: (value: GetResult) => void + const secondRequest = new Promise((resolve) => { + resolveSecondRequest = resolve + }) + mockGet.mockResolvedValueOnce(mockGetResult).mockReturnValueOnce(secondRequest) + + const wrapper = createWrapper() + const { result } = renderHook(() => useVisitorData({ immediate: false }), { wrapper }) + + await act(async () => { + await result.current.getData() + }) + expect(result.current).toMatchObject({ + isLoading: false, + isFetched: true, + data: mockGetResult, + error: undefined, + }) + + let pendingRequest!: Promise + act(() => { + pendingRequest = result.current.getData() + }) + expect(result.current).toMatchObject({ + isLoading: true, + isFetched: false, + data: undefined, + error: undefined, + }) + + await act(async () => { + resolveSecondRequest(secondResult) + await pendingRequest + }) + expect(result.current).toMatchObject({ + isLoading: false, + isFetched: true, + data: secondResult, + error: undefined, + }) + }) + it("shouldn't call getData on mount if 'immediate' option is set to false", () => { mockGet.mockImplementation(() => mockGetResult) @@ -560,25 +624,30 @@ describe('useVisitorData', () => { expect(effectCount).toEqual(3) }) - it('should treat tags with differently ordered object keys as equal', async () => { - const getDataValues: UseVisitorDataReturn['getData'][] = [] - const Component = () => { - const [reverseKeys, setReverseKeys] = useState(false) - const { getData } = useVisitorData({ - immediate: false, - tag: reverseKeys ? { second: 2, first: 1 } : { first: 1, second: 2 }, - }) + it('should not refetch for reordered options but refetch when a value changes', async () => { + mockGet.mockResolvedValue(mockGetResult) - getDataValues.push(getData) + const Component = () => { + const [tag, setTag] = useState({ nested: { first: 1, second: 2 } }) + useVisitorData({ immediate: true, tag }) return ( - + <> + + + ) } const Wrapper = createWrapper() @@ -589,9 +658,23 @@ describe('useVisitorData', () => { ) - await user.click(screen.getByRole('button', { name: 'Reverse keys' })) - expect(getDataValues).toHaveLength(2) - expect(getDataValues[1]).toBe(getDataValues[0]) + await waitFor(() => { + expect(mockGet).toHaveBeenCalledTimes(1) + }) + + await user.click(screen.getByRole('button', { name: 'Reorder options' })) + await act(async () => { + await wait(0) + }) + expect(mockGet).toHaveBeenCalledTimes(1) + + await user.click(screen.getByRole('button', { name: 'Change nested value' })) + await waitFor(() => { + expect(mockGet).toHaveBeenCalledTimes(2) + }) + expect(mockGet).toHaveBeenLastCalledWith({ + tag: { nested: { second: 3, first: 1 } }, + }) }) }) From 0ce763b55b473b2f813a483af729fb7ed35c788e Mon Sep 17 00:00:00 2001 From: Juraj Uhlar Date: Thu, 23 Jul 2026 15:13:50 +0200 Subject: [PATCH 39/41] test: restore mocks before each visitor data test --- __tests__/use-visitor-data.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/__tests__/use-visitor-data.test.tsx b/__tests__/use-visitor-data.test.tsx index 230af24e..9ed83e47 100644 --- a/__tests__/use-visitor-data.test.tsx +++ b/__tests__/use-visitor-data.test.tsx @@ -27,6 +27,7 @@ const mockStart = vi.mocked(agent.start) describe('useVisitorData', () => { beforeEach(() => { + vi.restoreAllMocks() vi.resetAllMocks() mockStart.mockReturnValue(mockAgent) From 06e659535bdad9e2121b4f0e2cdbed05e455a199 Mon Sep 17 00:00:00 2001 From: Juraj Uhlar Date: Thu, 23 Jul 2026 15:22:53 +0200 Subject: [PATCH 40/41] test: restore the original window descriptor --- __tests__/detect-env.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/__tests__/detect-env.test.ts b/__tests__/detect-env.test.ts index 4443b206..78d7f5e8 100644 --- a/__tests__/detect-env.test.ts +++ b/__tests__/detect-env.test.ts @@ -75,7 +75,7 @@ describe('Detect user env', () => { }) it('should skip Next detection when window is unavailable', () => { - const originalWindow = globalThis.window + const originalWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'window') Object.defineProperty(globalThis, 'window', { value: undefined, configurable: true, @@ -90,11 +90,11 @@ describe('Detect user env', () => { name: Env.React, }) } finally { - Object.defineProperty(globalThis, 'window', { - value: originalWindow, - configurable: true, - writable: true, - }) + if (originalWindowDescriptor) { + Object.defineProperty(globalThis, 'window', originalWindowDescriptor) + } else { + Reflect.deleteProperty(globalThis, 'window') + } } }) }) From fb82343a38daaecf70051029eef2e179fc939acf Mon Sep 17 00:00:00 2001 From: Juraj Uhlar Date: Thu, 23 Jul 2026 16:59:40 +0200 Subject: [PATCH 41/41] chore: rename to getDefaultStartOptions --- __tests__/fpjs-provider.test.tsx | 12 ++++++------ __tests__/helpers.tsx | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/__tests__/fpjs-provider.test.tsx b/__tests__/fpjs-provider.test.tsx index 51713033..a09068db 100644 --- a/__tests__/fpjs-provider.test.tsx +++ b/__tests__/fpjs-provider.test.tsx @@ -1,7 +1,7 @@ import { PropsWithChildren, useContext } from 'react' import { act, render, renderHook } from '@testing-library/react' import { FingerprintContext, FingerprintProvider, FingerprintProviderOptions, useVisitorData } from '../src' -import { createWrapper, getDefaultLoadOptions } from './helpers' +import { createWrapper, getDefaultStartOptions } from './helpers' import { version } from '../package.json' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import * as agent from '@fingerprint/agent' @@ -29,7 +29,7 @@ const InternalFingerprintProvider = (props: PropsWithChildren = {}) => render( - +
) @@ -54,7 +54,7 @@ describe('FingerprintProvider', () => { }) it('should configure an instance of the Fp Agent', () => { - const loadOptions = getDefaultLoadOptions() + const loadOptions = getDefaultStartOptions() const wrapper = createWrapper({ cache: { cachePrefix: 'cache', @@ -124,7 +124,7 @@ describe('FingerprintProvider', () => { it('should use customAgent.start when a valid custom agent loader is provided', async () => { const customStart = vi.fn().mockReturnValue(mockAgent) const wrapper = ({ children }: PropsWithChildren) => ( - + {children} ) @@ -142,7 +142,7 @@ describe('FingerprintProvider', () => { it('should fall back to the default agent when customAgent is invalid', async () => { const wrapper = ({ children }: PropsWithChildren) => ( @@ -162,7 +162,7 @@ describe('FingerprintProvider', () => { it('should merge provider getOptions into visitor data requests', async () => { const wrapper = ({ children }: PropsWithChildren) => ( {children} diff --git a/__tests__/helpers.tsx b/__tests__/helpers.tsx index 437ed5b7..7bf69748 100644 --- a/__tests__/helpers.tsx +++ b/__tests__/helpers.tsx @@ -1,14 +1,14 @@ import { PropsWithChildren } from 'react' import { FingerprintProvider, FingerprintProviderOptions } from '../src' -export const getDefaultLoadOptions = () => ({ +export const getDefaultStartOptions = () => ({ apiKey: 'test_api_key', }) export const createWrapper = (providerProps: Partial = {}) => ({ children }: PropsWithChildren) => ( - + {children} )