From 6d4b2d583d4b4fd63154a4ac9dd617fa23145b53 Mon Sep 17 00:00:00 2001 From: Tyler Dixon Date: Thu, 30 Jul 2026 11:43:08 -0700 Subject: [PATCH 1/4] ci: verify the packed package with publint, attw and size-limit Replaces the bespoke 700-line release gate with off-the-shelf tooling, keeping only the one check nothing off-the-shelf covers. Two new jobs run against the packed tarball the publish job uploads verbatim, so they check the exact bytes that ship: Verify package publint --strict, attw, size-limit Verify package loads entry-load.mjs on React 18 and 19 publint and attw cannot see the #759 class: their output is identical across 4.2.3 through 4.2.6, because that regression was a require call inlined into the body of a structurally valid ESM file. Loading the package is the only thing that catches it, which is why entry-load.mjs survives. They do catch two live consumer-facing bugs the bespoke gate never did. Published 4.2.6 gives TypeScript CJS consumers under node16 resolution no type declarations at all, and the package declared no sideEffects. Both are fixed here: types conditions on both exports branches, a duplicated .d.cts, and sideEffects false. Also repoints the size-limit config, which named TSDX-era filenames the vite build stopped emitting, so npm run size was checking nothing. publint runs with --strict because it exits 0 on warnings, and the missing types condition is a warning. attw ignores internal-resolution-error via .attw.json: the emitted .d.ts use extensionless relative imports, which is a real pre-existing bug with its own fix, and ignoring the one rule keeps every other resolution check enforcing. Addresses #765 items 1 to 5. Item 6 is #749 and lands separately. --- .attw.json | 4 + .eslintrc.json | 16 +++ .github/workflows/test.yaml | 64 +++++++++- package-lock.json | 242 ++++++++++++++++++++++++++++++++++++ package.json | 32 +++-- scripts/entry-load.mjs | 220 ++++++++++++++++++++++++++++++++ test/entry-load.test.mjs | 121 ++++++++++++++++++ 7 files changed, 689 insertions(+), 10 deletions(-) create mode 100644 .attw.json create mode 100644 scripts/entry-load.mjs create mode 100644 test/entry-load.test.mjs diff --git a/.attw.json b/.attw.json new file mode 100644 index 00000000..1c02597e --- /dev/null +++ b/.attw.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://cdn.jsdelivr.net/npm/@arethetypeswrong/cli@0.18.5/config-schema.json", + "ignoreRules": ["internal-resolution-error"] +} diff --git a/.eslintrc.json b/.eslintrc.json index ed012a26..a5c1082b 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -23,6 +23,22 @@ "@typescript-eslint", "no-only-tests" ], + "overrides": [ + { + "files": ["**/*.mjs"], + "env": { + "browser": false, + "node": true + }, + "globals": { + "describe": "readonly", + "it": "readonly", + "expect": "readonly", + "beforeEach": "readonly", + "afterEach": "readonly" + } + } + ], "rules": { "no-only-tests/no-only-tests": "error", "@typescript-eslint/no-explicit-any": "warn", diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index d4158b35..b5d0aac4 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -46,6 +46,68 @@ jobs: publish.sh unpack.sh retention-days: 1 + verify-package: + runs-on: ubuntu-latest + needs: build + name: Verify package + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + - name: Setup node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: '24' + cache: 'npm' + - name: Install deps + run: npm ci + - name: 'Download Artifacts' + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + # Unpacking makes the repo root the shipped package, which is what the + # next two steps need: publint takes a directory, and size-limit reads + # dist/, which is gitignored and so absent from a fresh checkout. + - name: Expand Artifact + run: | + chmod +x reactfire-${{ github.run_id }}/unpack.sh + ./reactfire-${{ github.run_id }}/unpack.sh + # --strict because publint exits 0 on warnings, and the manifest problems + # worth gating on (a missing types condition, for one) are warnings. + - name: Lint package + run: npm run lint:package + # Points at the tarball rather than re-packing, so it checks the exact + # bytes that ship. .attw.json ignores internal-resolution-error, because + # the emitted .d.ts use extensionless relative imports, which node16 + # resolution rejects. That is a real pre-existing bug with its own fix; + # ignoring the one rule keeps every other resolution check enforcing. + - name: Lint types + run: npm run lint:types -- reactfire-${{ github.run_id }}/reactfire.tgz + - name: Bundle size + run: npm run size + verify-loads: + runs-on: ubuntu-latest + needs: build + name: Verify package loads (React ${{ matrix.react }}) + strategy: + matrix: + react: ['18', '19'] + fail-fast: false + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + - name: Setup node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: '24' + - name: 'Download Artifacts' + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + # Installs the packed tarball into a throwaway project with real react and + # firebase, then actually loads both entry points. No npm ci: the script + # builds its own dependency tree, which is why it is a separate job. + - name: Entry-point load test + run: node scripts/entry-load.mjs reactfire-${{ github.run_id }}/reactfire.tgz --react ${{ matrix.react }} test: runs-on: ubuntu-latest needs: build @@ -116,7 +178,7 @@ jobs: publish: runs-on: ubuntu-latest name: Publish (NPM) - needs: test + needs: [test, verify-package, verify-loads] if: ${{ github.ref == 'refs/heads/main' || github.event_name == 'release' }} steps: - name: Setup node diff --git a/package-lock.json b/package-lock.json index a6f696ac..dfa14da1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "use-sync-external-store": "^1.2.0" }, "devDependencies": { + "@arethetypeswrong/cli": "^0.18.5", "@rollup/plugin-typescript": "^12.3.0", "@size-limit/preset-small-lib": "^8.2.6", "@testing-library/jest-dom": "^5.16.5", @@ -38,6 +39,7 @@ "jsdom": "^22.1.0", "markdown-toc": "^1.2.0", "prettier": "^3.8.4", + "publint": "^0.3.22", "react": "^18.2.0", "react-dom": "^18.2.0", "react-test-renderer": "^18.2.0", @@ -64,6 +66,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@andrewbranch/untar.js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@andrewbranch/untar.js/-/untar.js-1.0.3.tgz", + "integrity": "sha512-Jh15/qVmrLGhkKJBdXlK1+9tY4lZruYjsgkDFj08ZmDiWVBLJcqkok7Z0/R0In+i1rScBpJlSvrTS2Lm41Pbnw==", + "dev": true + }, "node_modules/@apidevtools/json-schema-ref-parser": { "version": "9.1.2", "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.1.2.tgz", @@ -83,6 +91,138 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/@arethetypeswrong/cli": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/@arethetypeswrong/cli/-/cli-0.18.5.tgz", + "integrity": "sha512-gM+8vRsQOD/Uc7EnBedUhkG5OCsDWE4uoak5QvomGpMpaky0Eh41p04nIMgrWb8EOmqZUJGc6zz9hsP6E56R7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@arethetypeswrong/core": "0.18.5", + "chalk": "^4.1.2", + "cli-table3": "^0.6.3", + "commander": "^10.0.1", + "marked": "^9.1.2", + "marked-terminal": "^7.1.0", + "semver": "^7.5.4" + }, + "bin": { + "attw": "dist/index.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@arethetypeswrong/cli/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@arethetypeswrong/cli/node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@arethetypeswrong/cli/node_modules/marked": { + "version": "9.1.6", + "resolved": "https://registry.npmjs.org/marked/-/marked-9.1.6.tgz", + "integrity": "sha512-jcByLnIFkd5gSXZmjNvS1TlmRhCXZjIzHYlaGkPlLIekG55JDR2Z4va9tZwCiP+/RDERiNhMOFu01xd6O5ct1Q==", + "dev": true, + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 16" + } + }, + "node_modules/@arethetypeswrong/cli/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@arethetypeswrong/core": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/@arethetypeswrong/core/-/core-0.18.5.tgz", + "integrity": "sha512-9ytjzGwxjm9Uz7I9avfbt5vlQt6uk9uRRESzJjqrznl6WKvI6dwYTo+vJ3U02Wrq/mR3iql/PzhvHhKdJIAjDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@andrewbranch/untar.js": "^1.0.3", + "@loaderkit/resolve": "^1.0.2", + "cjs-module-lexer": "^1.2.3", + "fflate": "^0.8.3", + "lru-cache": "^11.0.1", + "semver": "^7.5.4", + "typescript": "5.6.1-rc", + "validate-npm-package-name": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@arethetypeswrong/core/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@arethetypeswrong/core/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@arethetypeswrong/core/node_modules/typescript": { + "version": "5.6.1-rc", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.1-rc.tgz", + "integrity": "sha512-E3b2+1zEFu84jB0YQi9BORDjz9+jGbwwy1Zi3G0LUNw7a7cePUrHMRNy8aPh53nXpkFGVHSxIZo5vKTfYaFiBQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/@babel/code-frame": { "version": "7.22.5", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.5.tgz", @@ -201,6 +341,13 @@ "node": ">=6.9.0" } }, + "node_modules/@braidai/lang": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@braidai/lang/-/lang-1.1.2.tgz", + "integrity": "sha512-qBcknbBufNHlui137Hft8xauQMTZDKdophmLFv05r2eNmdIv/MlPuP4TdUknHG68UdWLgVZwgxVe735HzJNIwA==", + "dev": true, + "license": "ISC" + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -2017,6 +2164,16 @@ "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", "dev": true }, + "node_modules/@loaderkit/resolve": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@loaderkit/resolve/-/resolve-1.0.6.tgz", + "integrity": "sha512-G8FdIoF5CypfwmD9rl8BXod5HDn8JqB0CCNBXDTaRZ+yRYhARrrSToX1zg1zy9jX3zLqigsELwhT4gNtkdQAUg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@braidai/lang": "^1.0.0" + } + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.29.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", @@ -2668,6 +2825,22 @@ "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", "license": "BSD-3-Clause" }, + "node_modules/@publint/pack": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@publint/pack/-/pack-0.1.6.tgz", + "integrity": "sha512-3uVNyGcVplhPZSLVyeIpL7+cIRn1YCSNHLG/rUIlBQMVH8YuN9++YF+5+UDIIO9RW98dujiUoTltO7RDB5bFJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyexec": "^1.2.4" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://bjornlu.com/sponsor" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", @@ -5395,6 +5568,13 @@ "node": ">=8" } }, + "node_modules/cjs-module-lexer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", + "dev": true, + "license": "MIT" + }, "node_modules/cjson": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/cjson/-/cjson-0.3.3.tgz", @@ -11648,6 +11828,16 @@ "dev": true, "license": "MIT" }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", @@ -12377,6 +12567,13 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/package-manager-detector": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.8.0.tgz", + "integrity": "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==", + "dev": true, + "license": "MIT" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -12989,6 +13186,28 @@ "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==", "dev": true }, + "node_modules/publint": { + "version": "0.3.22", + "resolved": "https://registry.npmjs.org/publint/-/publint-0.3.22.tgz", + "integrity": "sha512-6Z/scsr5CA7APdwyF35EY88CqgDj1textWuY788DVTJYPCWVv/Wn9G6KmLnrVRnStgYcahqN4wCDLZGSbQJ69w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@publint/pack": "^0.1.6", + "package-manager-detector": "^1.7.0", + "picocolors": "^1.1.1", + "sade": "^1.8.1" + }, + "bin": { + "publint": "src/cli.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://bjornlu.com/sponsor" + } + }, "node_modules/punycode.js": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", @@ -13755,6 +13974,19 @@ "tslib": "^2.1.0" } }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -15633,6 +15865,16 @@ "integrity": "sha512-QQDsV8OnSf5Uc30CKSwG9lnhMPe6exHtTXLRYX8uMwKENy640pU+2BgBL0LRbDh/eYRahNCS7aewCx0wf3NYVA==", "dev": true }, + "node_modules/validate-npm-package-name": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-5.0.1.tgz", + "integrity": "sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/package.json b/package.json index 6b6b707f..c7d8d183 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,14 @@ "main": "dist/index.umd.cjs", "exports": { ".": { - "import": "./dist/index.js", - "require": "./dist/index.umd.cjs" + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.umd.cjs" + } } }, "typings": "dist/index.d.ts", @@ -19,7 +25,8 @@ }, "scripts": { "start": "firebase emulators:exec --project=rxfire-525a3 \"vitest --ui\"", - "build": "tsc && vite build && npm run docs", + "build": "tsc && vite build && npm run build:types-cjs && npm run docs", + "build:types-cjs": "node -e \"require('fs').copyFileSync('dist/index.d.ts','dist/index.d.cts')\"", "test": "firebase emulators:exec --project=rxfire-525a3 \"vitest\"", "test:firestore": "firebase emulators:exec --only firestore --ui --project=rxfire-525a3 \"vitest firestore\"", "test:database": "firebase emulators:exec --only database --project=rxfire-525a3 \"vitest database\"", @@ -28,10 +35,14 @@ "test:storage": "firebase emulators:exec --only storage --project=rxfire-525a3 \"vitest storage\"", "test:useObservable": "vitest useObservable", "test:firebaseApp": "vitest firebaseApp", - "format": "prettier src test vite.config.ts -w", - "lint": "eslint src/* test/* vite.config.ts", + "test:loads": "vitest entry-load", + "format": "prettier src test scripts vite.config.ts -w", + "lint": "eslint src/* test/* scripts/* vite.config.ts", "size": "size-limit", "analyze": "size-limit --why", + "loads": "node scripts/entry-load.mjs", + "lint:package": "publint --strict", + "lint:types": "attw", "docs": "typedoc --options typedoc.json && markdown-toc -i docs/use.md", "docs:fork": "typedoc --options typedoc.json --gitRemote upstream && markdown-toc -i docs/use.md" }, @@ -60,17 +71,19 @@ }, "homepage": "https://firebaseopensource.com/projects/firebaseextended/reactfire/", "module": "./dist/index.js", + "sideEffects": false, "size-limit": [ { - "path": "dist/reactfire.cjs.production.min.js", - "limit": "10 KB" + "path": "dist/index.js", + "limit": "18 KB" }, { - "path": "dist/reactfire.esm.js", - "limit": "10 KB" + "path": "dist/index.umd.cjs", + "limit": "16 KB" } ], "devDependencies": { + "@arethetypeswrong/cli": "^0.18.5", "@rollup/plugin-typescript": "^12.3.0", "@size-limit/preset-small-lib": "^8.2.6", "@testing-library/jest-dom": "^5.16.5", @@ -95,6 +108,7 @@ "jsdom": "^22.1.0", "markdown-toc": "^1.2.0", "prettier": "^3.8.4", + "publint": "^0.3.22", "react": "^18.2.0", "react-dom": "^18.2.0", "react-test-renderer": "^18.2.0", diff --git a/scripts/entry-load.mjs b/scripts/entry-load.mjs new file mode 100644 index 00000000..24b23745 --- /dev/null +++ b/scripts/entry-load.mjs @@ -0,0 +1,220 @@ +#!/usr/bin/env node +/** + * Entry-point load test (#765, item 3). + * + * Installs the packed tarball into a throwaway project alongside real `react` + * and `firebase`, then actually loads both entry points: + * + * import('reactfire') the ESM entry + * require('reactfire') the CJS entry + * + * Usage: + * node scripts/entry-load.mjs [--react 18,19] [--keep] + * + * Why this earns its place alongside `publint` and `attw`: those analyse the + * package's structure, and #759 was a `require` inlined into the body of a + * structurally valid ESM file. Their output is byte-identical across 4.2.3, + * 4.2.4, 4.2.5 and 4.2.6. Actually loading the package is the only thing that + * catches it, because it observes the failure rather than pattern-matching for + * it. Verified against the published tarballs: 4.2.5 throws "Calling `require` + * for \"react\" in an environment that doesn't expose the `require` function" + * on the ESM entry while its CJS entry loads fine, and 4.2.6 loads on both. + * + * It runs as its own script and its own CI job because it has to install a real + * dependency tree, which the other package checks do not. + */ + +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +// Mirrors the versions CI already type-checks against, so a break that is +// specific to one React major is visible here too. +export const DEFAULT_REACT_VERSIONS = ['18', '19']; + +// Held to the major the repo develops against rather than "latest", so the +// fixture is deterministic and stays inside the declared peer range +// (^9 || ^10 || ^11 || ^12). +export const FIREBASE_RANGE = '^11.10.0'; + +// A load that resolves but exports nothing is still a broken package, and an +// empty or partial build would otherwise pass. These are load-bearing entry +// points from different submodules, so a missing re-export shows up here. +export const EXPECTED_EXPORTS = ['useFirestoreDocData', 'useUser', 'FirebaseAppProvider', 'useObservable']; + +export const PACKAGE_NAME = 'reactfire'; + +/** package.json for the throwaway consumer project. */ +export function fixtureManifest({ react, firebase = FIREBASE_RANGE } = {}) { + return { + name: 'reactfire-entry-load-fixture', + version: '1.0.0', + private: true, + // No "type" field: the fixture must be able to hold both an .mjs and a .cjs + // probe, and each probe carries its own extension instead. + dependencies: { + react: `^${react}`, + 'react-dom': `^${react}`, + firebase, + }, + }; +} + +/** + * Probe sources. Each prints a single JSON line so the parent does not have to + * scrape human-readable output, and each reports a missing export as a failure + * rather than only reporting a thrown error. + */ +export function probeSource(kind, { name = PACKAGE_NAME, expected = EXPECTED_EXPORTS } = {}) { + const check = ` + const missing = ${JSON.stringify(expected)}.filter((k) => typeof mod[k] === 'undefined'); + if (missing.length > 0) { + console.log(JSON.stringify({ ok: false, reason: 'missing-exports', missing })); + } else { + console.log(JSON.stringify({ ok: true, exports: Object.keys(mod).length })); + }`; + + if (kind === 'esm') { + return ` +import(${JSON.stringify(name)}) + .then((mod) => {${check} + }) + .catch((error) => { + console.log(JSON.stringify({ ok: false, reason: 'threw', message: String(error && error.message || error) })); + }); +`; + } + return ` +try { + const mod = require(${JSON.stringify(name)});${check} +} catch (error) { + console.log(JSON.stringify({ ok: false, reason: 'threw', message: String(error && error.message || error) })); +} +`; +} + +/** Read the single JSON line a probe prints; anything else is itself a failure. */ +export function parseProbeOutput(stdout) { + const line = String(stdout ?? '') + .split('\n') + .map((l) => l.trim()) + .filter(Boolean) + .at(-1); + if (!line) return { ok: false, reason: 'no-output' }; + try { + return JSON.parse(line); + } catch { + return { ok: false, reason: 'unparseable-output', message: line.slice(0, 200) }; + } +} + +/** One-line summary of a probe result, for the CI log. */ +export function describeResult(result) { + if (result.ok) return `loaded, ${result.exports} export(s)`; + if (result.reason === 'missing-exports') return `loaded but missing export(s): ${result.missing.join(', ')}`; + if (result.reason === 'threw') return `threw: ${result.message}`; + if (result.reason === 'no-output') return 'probe produced no output'; + if (result.reason === 'crashed') return `probe exited ${result.code}: ${result.message}`; + return `${result.reason}: ${result.message ?? ''}`; +} + +/** + * Build the fixture and load both entry points for one React major. + * + * `run` is injectable so the orchestration is testable without installing + * anything; the real work is two npm installs and two node processes. + */ +export function loadWithReact(tarball, react, { run = execFileSync, root, expected = EXPECTED_EXPORTS, name = PACKAGE_NAME } = {}) { + const dir = root ?? fs.mkdtempSync(path.join(os.tmpdir(), `reactfire-load-${react}-`)); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'package.json'), `${JSON.stringify(fixtureManifest({ react }), null, 2)}\n`); + fs.writeFileSync(path.join(dir, 'probe.mjs'), probeSource('esm', { name, expected })); + fs.writeFileSync(path.join(dir, 'probe.cjs'), probeSource('cjs', { name, expected })); + + const npm = (args) => run('npm', args, { cwd: dir, stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }); + + // Peers and the package under test go in together: installing the tarball + // first would resolve its peers against an empty tree. + npm(['install', '--no-audit', '--no-fund', '--loglevel', 'error']); + npm(['install', '--no-audit', '--no-fund', '--loglevel', 'error', path.resolve(tarball)]); + + const probe = (file) => { + try { + return parseProbeOutput(run(process.execPath, [path.join(dir, file)], { cwd: dir, stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8' })); + } catch (error) { + // A probe that dies without printing (segfault, OOM, an error thrown at + // top level in a way that escapes the handler) is still a failed load. + return { ok: false, reason: 'crashed', code: error.status ?? null, message: String(error.stderr ?? error.message ?? '').slice(0, 300) }; + } + }; + + return { dir, esm: probe('probe.mjs'), cjs: probe('probe.cjs') }; +} + +/** Collapse per-React results into the list of failures. */ +export function collectFailures(results) { + const failures = []; + for (const { react, esm, cjs } of results) { + if (!esm.ok) failures.push({ react, entry: 'esm', detail: describeResult(esm) }); + if (!cjs.ok) failures.push({ react, entry: 'cjs', detail: describeResult(cjs) }); + } + return failures; +} + +// --------------------------------------------------------------------------- + +function main() { + const args = process.argv.slice(2); + const keep = args.includes('--keep'); + const reactArg = args.includes('--react') ? args[args.indexOf('--react') + 1] : null; + const tarball = args.find((a) => !a.startsWith('--') && a !== reactArg); + + if (!tarball) { + console.error('usage: node scripts/entry-load.mjs [--react 18,19] [--keep]'); + return 2; + } + if (!fs.existsSync(tarball)) { + console.error(`tarball not found: ${tarball}`); + return 2; + } + + const versions = reactArg ? reactArg.split(',').filter(Boolean) : DEFAULT_REACT_VERSIONS; + console.log(`entry load test: ${path.basename(tarball)}\nreact: ${versions.join(', ')} firebase: ${FIREBASE_RANGE}\n`); + + const results = []; + const dirs = []; + try { + for (const react of versions) { + const { dir, esm, cjs } = loadWithReact(tarball, react); + dirs.push(dir); + results.push({ react, esm, cjs }); + console.log(` react ${react} import('${PACKAGE_NAME}') ${describeResult(esm)}`); + console.log(` react ${react} require('${PACKAGE_NAME}') ${describeResult(cjs)}`); + } + + const failures = collectFailures(results); + if (failures.length === 0) { + console.log('\nboth entry points load on every React version tested'); + return 0; + } + + console.error(`\n${failures.length} entry-point load failure(s):\n`); + for (const { react, entry, detail } of failures) { + console.error(` [${entry}] react ${react}: ${detail}`); + } + console.error('\n The packed package does not load. This is the #759 class:'); + console.error(' the files are all present, they just do not run. Check dist/index.js'); + console.error(' for an inlined CJS module, and check that the externals in'); + console.error(' vite.config.ts still cover use-sync-external-store/shim.'); + return 1; + } finally { + if (!keep) for (const dir of dirs) fs.rmSync(dir, { recursive: true, force: true }); + else console.log(`\nfixtures kept: ${dirs.join(', ')}`); + } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + process.exit(main()); +} diff --git a/test/entry-load.test.mjs b/test/entry-load.test.mjs new file mode 100644 index 00000000..2020c93a --- /dev/null +++ b/test/entry-load.test.mjs @@ -0,0 +1,121 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { collectFailures, describeResult, loadWithReact, parseProbeOutput } from '../scripts/entry-load.mjs'; + +/** + * These tests pin the failure detection, not the fixture's shape. `publint` and + * `attw` can tell you the package is structurally valid; only loading it tells + * you it runs. Verified against the published tarballs: 4.2.4 and 4.2.5 throw on + * the ESM entry while their CJS entry loads, and 4.2.6 loads on both. + * + * Kept deliberately small. Each test here fails when the detection breaks, which + * is the only property worth the lines: a check that quietly degrades into + * always passing is worse than no check. + */ + +describe('parseProbeOutput', () => { + it('treats unparseable output as a failure rather than throwing', () => { + const result = parseProbeOutput('Segmentation fault'); + expect(result.ok).toBe(false); + expect(result.reason).toBe('unparseable-output'); + }); +}); + +describe('collectFailures', () => { + it('is empty when every entry loaded', () => { + expect(collectFailures([{ react: '18', esm: { ok: true }, cjs: { ok: true } }])).toEqual([]); + }); + + // The 4.2.5 shape: the ESM entry throws while the CJS entry is fine. A check + // that only looked at one of them would have missed the shipped regression. + it('reports an esm-only failure', () => { + const failures = collectFailures([{ react: '18', esm: { ok: false, reason: 'threw', message: 'require' }, cjs: { ok: true } }]); + expect(failures).toHaveLength(1); + expect(failures[0].entry).toBe('esm'); + }); + + // The mirror of the case above. Without this, dropping the CJS branch + // entirely left every test green: the check would silently have become + // ESM-only, and a UMD-side break would ship. + it('reports a cjs-only failure', () => { + const failures = collectFailures([{ react: '18', esm: { ok: true }, cjs: { ok: false, reason: 'threw', message: 'bad' } }]); + expect(failures).toHaveLength(1); + expect(failures[0].entry).toBe('cjs'); + }); +}); + +describe('loadWithReact', () => { + let dir; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'entry-load-')); + }); + + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + /** Stand in for npm and node without installing or running anything. */ + const runner = ({ esm = '{"ok":true,"exports":74}', cjs = '{"ok":true,"exports":74}', onNode } = {}) => { + const calls = []; + const run = (cmd, args) => { + calls.push({ cmd, args }); + if (cmd === 'npm') return ''; + if (onNode) return onNode(args); + return String(args[0]).endsWith('.mjs') ? esm : cjs; + }; + return { run, calls }; + }; + + // Installing the tarball first would resolve its peers against an empty tree. + it('installs the peers before the package under test', () => { + const { run, calls } = runner(); + loadWithReact('/tmp/reactfire.tgz', '18', { run, root: dir }); + const installs = calls.filter((c) => c.cmd === 'npm'); + expect(installs).toHaveLength(2); + expect(installs[0].args.some((a) => a.endsWith('.tgz'))).toBe(false); + expect(installs[1].args.some((a) => a.endsWith('.tgz'))).toBe(true); + }); + + it('surfaces a throwing ESM entry, the 4.2.5 shape', () => { + const { run } = runner({ esm: '{"ok":false,"reason":"threw","message":"Calling `require` for \\"react\\""}' }); + const result = loadWithReact('/tmp/reactfire.tgz', '18', { run, root: dir }); + expect(result.esm.ok).toBe(false); + expect(result.cjs.ok).toBe(true); + expect(describeResult(result.esm)).toContain('require'); + }); + + // The mirror of the case above, and the one test that keeps the CJS probe + // wired up. Without it, replacing `cjs: probe('probe.cjs')` with a hardcoded + // pass leaves the whole suite green: the check silently becomes ESM-only and + // a UMD-side break ships. Verified by mutation. + it('surfaces a throwing CJS entry', () => { + const { run } = runner({ cjs: '{"ok":false,"reason":"threw","message":"boom"}' }); + const result = loadWithReact('/tmp/reactfire.tgz', '18', { run, root: dir }); + expect(result.cjs.ok).toBe(false); + expect(result.esm.ok).toBe(true); + expect(collectFailures([{ react: '18', ...result }])[0].entry).toBe('cjs'); + }); + + // A probe that dies without printing must not read as a pass. + it('treats a probe that exits without output as a failure', () => { + const onNode = () => { + const error = new Error('killed'); + error.status = 139; + error.stderr = 'Segmentation fault'; + throw error; + }; + const { run } = runner({ onNode }); + const result = loadWithReact('/tmp/reactfire.tgz', '18', { run, root: dir }); + expect(result.esm.ok).toBe(false); + expect(result.esm.reason).toBe('crashed'); + expect(result.esm.code).toBe(139); + }); + + it('fails a package that loads but is missing exports', () => { + const { run } = runner({ esm: '{"ok":false,"reason":"missing-exports","missing":["useUser"]}' }); + const result = loadWithReact('/tmp/reactfire.tgz', '18', { run, root: dir }); + expect(collectFailures([{ react: '18', ...result }])).toHaveLength(1); + }); +}); From c190ec93a19b3f5bd44a9a6c7498b55c2aa08b7e Mon Sep 17 00:00:00 2001 From: Tyler Dixon Date: Mon, 3 Aug 2026 12:39:12 -0700 Subject: [PATCH 2/4] test: canary one export per submodule in the entry-load probe The probe checked four exports against the ten submodules index.ts re-exports, so a build that dropped storage, functions, database, performance, remote-config or sdk from the artifact still passed. The source-level jobs cannot catch that: only this probe loads the packed build. --- scripts/entry-load.mjs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/scripts/entry-load.mjs b/scripts/entry-load.mjs index 24b23745..96a17a1d 100644 --- a/scripts/entry-load.mjs +++ b/scripts/entry-load.mjs @@ -40,9 +40,20 @@ export const DEFAULT_REACT_VERSIONS = ['18', '19']; export const FIREBASE_RANGE = '^11.10.0'; // A load that resolves but exports nothing is still a broken package, and an -// empty or partial build would otherwise pass. These are load-bearing entry -// points from different submodules, so a missing re-export shows up here. -export const EXPECTED_EXPORTS = ['useFirestoreDocData', 'useUser', 'FirebaseAppProvider', 'useObservable']; +// empty or partial build would otherwise pass. One canary per submodule that +// index.ts re-exports, so a drop confined to any single one shows up here. +export const EXPECTED_EXPORTS = [ + 'useUser', // ./auth + 'useDatabaseObject', // ./database + 'FirebaseAppProvider', // ./firebaseApp + 'useFirestoreDocData', // ./firestore + 'useCallableFunctionResponse', // ./functions + 'SuspenseWithPerf', // ./performance + 'useRemoteConfigValue', // ./remote-config + 'useStorageDownloadURL', // ./storage + 'useObservable', // ./useObservable + 'AuthSdkContext', // ./sdk +]; export const PACKAGE_NAME = 'reactfire'; From 1929c678f4271b258e55354d2ce1fb2ae51748d8 Mon Sep 17 00:00:00 2001 From: Tyler Dixon Date: Mon, 3 Aug 2026 13:04:54 -0700 Subject: [PATCH 3/4] test: execute the probe's missing-export filter, note the fixture pin coupling The existing tests drove the runner with canned output, so nothing ran the filter the probe ships, and a green CI run does not either: the filter only reports, so breaking it makes every load pass. Verified by mutation, stubbing the filter to an empty list fails the new case. Also ties FIREBASE_RANGE to the firebase devDependency in a comment. They match today and nothing enforces it, so a move to firebase 12 would silently leave the probe exercising 11. --- scripts/entry-load.mjs | 6 ++++++ test/entry-load.test.mjs | 43 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/scripts/entry-load.mjs b/scripts/entry-load.mjs index 96a17a1d..24905ea7 100644 --- a/scripts/entry-load.mjs +++ b/scripts/entry-load.mjs @@ -37,6 +37,12 @@ export const DEFAULT_REACT_VERSIONS = ['18', '19']; // Held to the major the repo develops against rather than "latest", so the // fixture is deterministic and stays inside the declared peer range // (^9 || ^10 || ^11 || ^12). +// +// Keep this in step with the `firebase` devDependency in package.json. It +// matches today, and nothing enforces that: when the repo moves to firebase 12 +// this fixture keeps loading the package against 11 and stays green, so the +// major the probe actually exercises drifts away from the one CI develops +// against without any signal. export const FIREBASE_RANGE = '^11.10.0'; // A load that resolves but exports nothing is still a broken package, and an diff --git a/test/entry-load.test.mjs b/test/entry-load.test.mjs index 2020c93a..cc593465 100644 --- a/test/entry-load.test.mjs +++ b/test/entry-load.test.mjs @@ -1,7 +1,8 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { collectFailures, describeResult, loadWithReact, parseProbeOutput } from '../scripts/entry-load.mjs'; +import { execFileSync } from 'node:child_process'; +import { EXPECTED_EXPORTS, collectFailures, describeResult, loadWithReact, parseProbeOutput, probeSource } from '../scripts/entry-load.mjs'; /** * These tests pin the failure detection, not the fixture's shape. `publint` and @@ -119,3 +120,43 @@ describe('loadWithReact', () => { expect(collectFailures([{ react: '18', ...result }])).toHaveLength(1); }); }); + +/** + * The tests above drive the runner with canned output, so none of them execute + * the missing-export filter the probe actually ships. A green CI run does not + * either: the filter only reports, so breaking it makes every load pass. These + * run the generated source against a stand-in module instead of a mock. + */ +describe('probeSource', () => { + let dir; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'reactfire-probe-')); + }); + + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + // The probe resolves its target by specifier, so a relative path stands in + // for the installed package without needing a fixture install. + function runProbe(exports) { + fs.writeFileSync(path.join(dir, 'stand-in.mjs'), `export const ${exports.join(' = 1;\nexport const ')} = 1;\n`); + fs.writeFileSync(path.join(dir, 'probe.mjs'), probeSource('esm', { name: './stand-in.mjs' })); + return parseProbeOutput(execFileSync(process.execPath, [path.join(dir, 'probe.mjs')], { encoding: 'utf8' })); + } + + it('passes when every canary is exported', () => { + const result = runProbe(EXPECTED_EXPORTS); + expect(result.ok).toBe(true); + }); + + // One dropped submodule is the case the widened canary list exists for, and + // the case a broken filter would wave through. + it('names the missing canary when one submodule is dropped', () => { + const result = runProbe(EXPECTED_EXPORTS.filter((name) => name !== 'useStorageDownloadURL')); + expect(result.ok).toBe(false); + expect(result.reason).toBe('missing-exports'); + expect(result.missing).toEqual(['useStorageDownloadURL']); + }); +}); From 2411c383cae10ba3de73eef430fbfa81504a04d5 Mon Sep 17 00:00:00 2001 From: Tyler Dixon Date: Tue, 4 Aug 2026 10:35:11 -0700 Subject: [PATCH 4/4] ci: drop the attw internal-resolution-error suppression #770 gave the emitted .d.ts explicit .js extensions, so node16 (from ESM) resolves on its own and the ignore rule is dead weight. Verified against a packed tarball with no config file present: all four resolution modes pass, attw exits 0. Removing .attw.json entirely rather than emptying it, since the ignore rule was its only contents and lint:types is a bare attw invocation. --- .attw.json | 4 ---- .github/workflows/test.yaml | 6 ++---- 2 files changed, 2 insertions(+), 8 deletions(-) delete mode 100644 .attw.json diff --git a/.attw.json b/.attw.json deleted file mode 100644 index 1c02597e..00000000 --- a/.attw.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "$schema": "https://cdn.jsdelivr.net/npm/@arethetypeswrong/cli@0.18.5/config-schema.json", - "ignoreRules": ["internal-resolution-error"] -} diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index b5d0aac4..1d3d55c3 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -76,10 +76,8 @@ jobs: - name: Lint package run: npm run lint:package # Points at the tarball rather than re-packing, so it checks the exact - # bytes that ship. .attw.json ignores internal-resolution-error, because - # the emitted .d.ts use extensionless relative imports, which node16 - # resolution rejects. That is a real pre-existing bug with its own fix; - # ignoring the one rule keeps every other resolution check enforcing. + # bytes that ship. No ignore rules: all four resolution modes pass on + # their own since #770 gave the emitted .d.ts explicit .js extensions. - name: Lint types run: npm run lint:types -- reactfire-${{ github.run_id }}/reactfire.tgz - name: Bundle size