Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
57 changes: 44 additions & 13 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,22 @@ on:
types:
- published

# Least privilege by default. No job writes to the repo through GITHUB_TOKEN;
# the publish job authenticates to npm with NODE_AUTH_TOKEN.
permissions:
contents: read

jobs:
build:
runs-on: ubuntu-latest
name: Build
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
persist-credentials: false
- name: Setup node
uses: actions/setup-node@v4
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: '24'
cache: 'npm'
Expand All @@ -31,14 +38,34 @@ jobs:
id: pack-dir
run: ./build.sh
- name: 'Upload Artifact'
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: reactfire-${{ github.run_id }}
path: |
reactfire.tgz
publish.sh
unpack.sh
retention-days: 1
verify-package:
runs-on: ubuntu-latest
needs: build
name: Verify built artifact
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
# Checks the packed tarball the publish job uploads verbatim, so this
# verifies the exact bytes that ship. No deps needed; the gate is
# dependency-free by design.
- name: Release gate
run: node scripts/release-gate.mjs reactfire-${{ github.run_id }}/reactfire.tgz
test:
runs-on: ubuntu-latest
needs: build
Expand All @@ -49,30 +76,32 @@ jobs:
name: Test Node.js ${{ matrix.node }} (Ubuntu)
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
persist-credentials: false
- name: Setup node
uses: actions/setup-node@v4
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: ${{ matrix.node }}
check-latest: true
cache: 'npm'
- name: Install deps
run: npm ci
- name: Setup Java
uses: actions/setup-java@v4
uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4.8.0
with:
distribution: 'temurin'
java-version: '21'
- name: Install deps for functions
run: npm install
working-directory: ./functions
- name: Firebase emulator cache
uses: actions/cache@v4
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ~/.cache/firebase/emulators
key: firebase_emulators
- name: 'Download Artifacts'
uses: actions/download-artifact@v4
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
- name: Expand Artifact
run: |
chmod +x reactfire-${{ github.run_id }}/unpack.sh
Expand All @@ -88,9 +117,11 @@ jobs:
name: Type check (React ${{ matrix.react }})
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
persist-credentials: false
- name: Setup node
uses: actions/setup-node@v4
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: '24'
cache: 'npm'
Expand All @@ -105,16 +136,16 @@ jobs:
publish:
runs-on: ubuntu-latest
name: Publish (NPM)
needs: test
needs: [test, verify-package]
if: ${{ github.ref == 'refs/heads/main' || github.event_name == 'release' }}
steps:
- name: Setup node
uses: actions/setup-node@v4
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: '24'
registry-url: 'https://registry.npmjs.org'
- name: 'Download Artifacts'
uses: actions/download-artifact@v4
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
- name: Publish
run: |
cd ./reactfire-${{ github.run_id }}/
Expand Down
15 changes: 9 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,13 @@
"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:gate": "vitest release-gate",
"format": "prettier src test scripts vite.config.ts -w",
"lint": "eslint src/* test/* scripts/* vite.config.ts",
"size": "size-limit",
"analyze": "size-limit --why",
"gate": "node scripts/release-gate.mjs",
"gate:accept": "node scripts/release-gate.mjs --accept",
"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"
},
Expand Down Expand Up @@ -62,12 +65,12 @@
"module": "./dist/index.js",
"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": {
Expand Down
115 changes: 115 additions & 0 deletions release-gate/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# Built-artifact release gate

Verifies the packed tarball before it is published. Runs in CI as the
**Verify built artifact** job, between `build` and `publish`, against the
`reactfire.tgz` that the publish job uploads verbatim, so it checks the exact
bytes that ship.

Implemented by [`scripts/release-gate.mjs`](../scripts/release-gate.mjs).
Dependency-free by design: it needs no `npm ci`, so the CI job is fast and
cannot itself be broken by a dependency change.

## Why

Two dist-level regressions shipped as patch releases with nothing in the release
flow comparing the built artifact:

- **4.2.4** changed `ObservableStatus<T>` from a flat interface into a
discriminated union. Type-only, but it broke strict-TS consumers on a patch
bump ([#749](https://github.com/FirebaseExtended/reactfire/issues/749)).
- **4.2.4 / 4.2.5** bundled the CJS `use-sync-external-store/shim` into the ESM
output, producing a dynamic `require()` that threw in any browser bundle
([#759](https://github.com/FirebaseExtended/reactfire/issues/759), fixed by
[#760](https://github.com/FirebaseExtended/reactfire/pull/760)).

In both cases the source was fine and only the emitted artifact regressed. Both
are caught by this gate (verified by running it against the published 4.2.4 and
4.2.5 tarballs).

## Checks

| Check | What it catches |
| --- | --- |
| `no-cjs-in-esm` | A CJS module inlined into **any** ESM chunk, i.e. the #759 crash. Matches the bare `require` identifier, since the output is minified and the shipped 4.2.5 bundle never literally wrote `require(`. |
| `externals` | Externals getting inlined (duplicate React instance, the shim regression) or new dependencies leaking out as runtime imports. |
| `exports-map` | Any path in `exports` / `main` / `module` / `typings` missing from the tarball. |
| `types` | Any change to the emitted `.d.ts` versus the accepted baseline, i.e. the #749 class. Covers added and removed declaration files as well as changed ones. |
| `size` | Packed tarball and entry-point gzip size moving more than ±2%. |

### What `size` is and is not

It is a coarse guard against gross packaging changes, for example `files`
sweeping in something it should not. **It is not a reliable inlining detector**,
and the table above should not be read as claiming otherwise.

Measured against the real artifacts, the #759 shim inlining moved the ESM entry
only **+3.1%** gzip (16815 to 17330 B) and the packed tarball **+0.3%**. An
earlier draft of this gate used a ±10% band, which would have missed that
entirely. The tolerance is now ±2%: tight enough to see a #759-sized change,
loose enough to clear the version stamp CI writes into the bundle (measured at
+0.5% on the tarball, +0.2% on the entries).

`no-cjs-in-esm` and `externals` are what actually catch inlining. Treat `size`
as a backstop.

## Tests

The detection logic is pinned by `test/release-gate.test.mjs`:

```sh
npm run test:gate # no emulators needed
```

A gate that silently stops gating is worse than no gate, so the checks are
covered by tests rather than by having been verified by hand once. The fixtures
are byte-faithful to the shapes that actually shipped, including the 4.2.5
`require` shim.

## Running it

```sh
npm run build # or: npx tsc && npx vite build
npm run gate # packs and verifies
npm run gate -- path/to/reactfire.tgz # verify a specific tarball
```

The local pack stages from `git ls-files` plus `dist/`, not the working tree.
`files` includes `src`, so packing the working tree directly would sweep in any
untracked scratch work under `src/`. Staging keeps a local run comparable with
what CI packs from a clean checkout.

## Accepting a change

`baseline/types/` and `baseline/metrics.json` are checked in. When a change to
the type surface or the bundle size is intended:

```sh
npm run gate:accept
```

Then **commit the updated baseline in the same pull request**. This is the
enforcement [#749](https://github.com/FirebaseExtended/reactfire/issues/749)
asks for: a textual diff cannot classify additive versus non-additive on its
own, so instead the gate fails on any delta and the acknowledgement is a
reviewable diff. Reviewing that diff means answering:

- Is this additive (a patch bump is fine), or non-additive (needs a minor or
major bump plus a changelog note)?

Semantic additive/non-additive classification via api-extractor would remove the
judgement call, but it is a much larger project and deliberately not attempted
here.

## Not covered

Items 3 and 7 of [#765](https://github.com/FirebaseExtended/reactfire/issues/765)
are not implemented:

- **Both entry points load** (`import('reactfire')` and `require('reactfire')`
in a fixture with `react` and `firebase` installed).
- **Runtime smoke render in CI** against a Next App Router and a Vite app. This
is the strongest check and the only one that catches runtime regressions the
static checks miss, but it is roughly a day of work on its own and adds real
CI minutes and flake surface.

Neither is required to close the two holes that actually shipped.
13 changes: 13 additions & 0 deletions release-gate/baseline/metrics.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"packed": 130974,
"sizes": {
"dist/index.js": {
"bytes": 63875,
"gzip": 16068
},
"dist/index.umd.cjs": {
"bytes": 46910,
"gzip": 14058
}
}
}
27 changes: 27 additions & 0 deletions release-gate/baseline/types/SuspenseSubject.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Observable, Subject, Subscriber, Subscription } from 'rxjs';
import { ObservableStatus } from './useObservable';
export declare class SuspenseSubject<T> extends Subject<T> {
private _timeoutWindow;
private _suspenseEnabled;
private _value;
private _hasValue;
private _timeoutHandler;
private _firstEmission;
private _error;
private _innerObservable;
private _warmupSubscription;
private _immutableStatus;
private _isComplete;
private _innerSubscriber;
private _resolveFirstEmission;
constructor(innerObservable: Observable<T>, _timeoutWindow: number, _suspenseEnabled: boolean);
get hasValue(): boolean;
get value(): T;
get firstEmission(): Promise<void>;
private _updateImmutableStatus;
private _next;
private _reset;
_subscribe(subscriber: Subscriber<T>): Subscription;
get ourError(): any;
get immutableStatus(): ObservableStatus<T>;
}
Loading
Loading