Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
73 changes: 61 additions & 12 deletions REACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ released against.
4. [Mutations](#4-mutations)
5. [Forms with validation](#5-forms-with-validation)
6. [Testing with a mock layer](#6-testing-with-a-mock-layer)
7. [Caveats](#7-caveats)
7. [Developing against the mock backend](#7-developing-against-the-mock-backend)
8. [Caveats](#8-caveats)

---

Expand Down Expand Up @@ -69,27 +70,25 @@ import { Client } from '@effect-firebase/client';
import { firestoreLayerAtom } from '../lib/atoms.js';

export function App({ children }) {
const layer = useMemo(() => {
// useState initializer: Firebase setup runs once per mount and the layer
// keeps a stable identity.
const [layer] = useState(() => {
const firestore = initializeFirestore(initializeApp({...}), {...});
connectFirestoreEmulator(firestore, 'localhost', 8080);
return Client.layer({ firestore });
}, []);

const initialValues = useMemo(
() => [[firestoreLayerAtom, layer] as const] as const,
[layer],
);
});

return (
<RegistryProvider initialValues={initialValues}>
<RegistryProvider initialValues={[[firestoreLayerAtom, layer] as const]}>
{children}
</RegistryProvider>
);
}
```

Wrap the layer in `useMemo` so Firebase initialization doesn't re-run on
every render. Note that `RegistryProvider` reads `initialValues` only when
Create the layer in a `useState` initializer so Firebase initialization runs
once per mount with a stable identity (a side-effecting `useMemo` is rejected
by the React Compiler lint). Note that `RegistryProvider` reads `initialValues` only when
the registry is first created — changing the array (or the layer's identity)
on a later render is silently ignored. To swap the layer at runtime, set the
atom's value in the registry instead — `registry.set(firestoreLayerAtom, newLayer)`
Expand Down Expand Up @@ -324,7 +323,57 @@ The components under test never change between production and test — only the
layer at the registry boundary differs. Vitest needs `environment: 'jsdom'`;
see the `test` block in [`example/app/vite.config.ts`](./example/app/vite.config.ts).

## 7. Caveats
## 7. Developing against the mock backend

For building pages, `@effect-firebase/mock` goes further than per-method
overrides: `make()` returns a full in-memory backend seeded from
schema-encoded fixtures, with a controller for toggling every collection
between **data / empty / loading / error** at runtime. Because the layer atom
is the only seam, the swap is one `initialValues` entry:

```tsx
// lib/mock.ts — shared by the app runtime and the devtools panel
export const mockBackend = make({
fixtures: [
fixture(PostModel, { collectionPath: 'posts', idField: 'id', docs: [...] }),
],
});

// app.tsx — seed the registry with the mock instead of Client.layer
<RegistryProvider
initialValues={[[firestoreLayerAtom, Layer.orDie(mockBackend.layer)] as const]}
>
```

`@effect-firebase/devtools` ships the controller as a TanStack Devtools
plugin, so the states can be flipped from a panel while the page is running:

```tsx
import { TanStackDevtools } from '@tanstack/react-devtools';
import { firestoreMockPlugin } from '@effect-firebase/devtools';

<TanStackDevtools
plugins={[
firestoreMockPlugin(mockBackend.controller, {
// `loading` streams never emit and `error` streams fail terminally
// (onSnapshot semantics), while atom results retain their previous
// value across refreshes and remounts. Bumping an epoch that keys the
// read atoms (Atom.family) gives them a fresh identity, so they
// re-subscribe from Initial against the toggled state.
onStateChange: () => bumpEpoch((epoch) => epoch + 1),
}),
]}
/>;
```

The example app wires this up behind an env flag — run `pnpm example:mock`
and open the devtools panel on the Firestore page. See
[`example/app/src/lib/atoms.ts`](./example/app/src/lib/atoms.ts) (the
`mockEpochAtom` / `Atom.family` pattern),
[`example/app/src/lib/mock.ts`](./example/app/src/lib/mock.ts) and
[`example/app/src/app/app.tsx`](./example/app/src/app/app.tsx).
Comment thread
coderabbitai[bot] marked this conversation as resolved.

## 8. Caveats

- **`@effect/atom-react` is lockstep with `effect` betas.** Each release of
`@effect/atom-react@4.0.0-beta.N` peer-depends on `effect@^4.0.0-beta.N`. Bump
Expand Down
4 changes: 3 additions & 1 deletion example/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,14 @@
"packageManager": "pnpm@10.25.0",
"dependencies": {
"@effect-firebase/client": "workspace:*",
"@effect-firebase/devtools": "workspace:*",
"@effect-firebase/mock": "workspace:*",
"@effect/atom-react": "catalog:",
"@effect/platform-browser": "catalog:",
"@example/shared": "workspace:*",
"@nx/vite": "23.1.1",
"@tailwindcss/vite": "^4.3.3",
Comment thread
fwal marked this conversation as resolved.
"@tanstack/react-devtools": "^0.10.8",
"@tanstack/react-form": "^1.33.3",
"@tanstack/react-router": "^1.170.19",
"@tanstack/react-router-devtools": "^1.167.1",
Expand All @@ -30,7 +33,6 @@
"tailwind-merge": "^3.6.0"
},
"devDependencies": {
"@effect-firebase/mock": "workspace:*",
"vite": "8.2.0"
}
}
64 changes: 59 additions & 5 deletions example/app/src/app/app.tsx
Original file line number Diff line number Diff line change
@@ -1,33 +1,86 @@
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import { initializeApp } from 'firebase/app';
import { getFunctions, connectFunctionsEmulator } from 'firebase/functions';
import {
connectFirestoreEmulator,
initializeFirestore,
} from 'firebase/firestore';
import { Layer } from 'effect';
import { Client } from '@effect-firebase/client';
import { RegistryProvider } from '@effect/atom-react';
import { RegistryProvider, useAtomSet } from '@effect/atom-react';
import { TanStackDevtools } from '@tanstack/react-devtools';
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools';
import {
firestoreMockPlugin,
type TanStackDevtoolsReactPlugin,
} from '@effect-firebase/devtools';
import SideMenu from '../components/menu/side-menu.js';
import MenuItem from '../components/menu/menu-item.js';
import { firestoreLayerAtom } from '../lib/atoms.js';
import { firestoreLayerAtom, mockEpochAtom } from '../lib/atoms.js';
import { mockBackend } from '../lib/mock.js';

interface AppProps {
children: React.ReactNode;
}

/**
* Start the app with `VITE_MOCK_BACKEND=1` (e.g. `pnpm example:mock`) to run
* Firestore against the in-memory mock backend instead of the emulator.
*/
const useMockBackend = import.meta.env['VITE_MOCK_BACKEND'] === '1';

/**
* One TanStack Devtools shell hosting the router panel and, in mock mode,
* the Firestore Mock panel. Every state toggle bumps `mockEpochAtom`, which
* remounts the data views: their atoms are disposed and the fresh
* subscriptions start from `Initial` against the toggled state. A refresh
* would not be enough — atoms keep their previous value while re-running,
* and a `loading` stream never emits, so stale data would stay on screen.
*/
function Devtools() {
const bumpEpoch = useAtomSet(mockEpochAtom);
const plugins = useMemo(() => {
const all: Array<TanStackDevtoolsReactPlugin> = [
{
name: 'TanStack Router',
render: <TanStackRouterDevtoolsPanel />,
},
];
if (useMockBackend) {
all.push(
firestoreMockPlugin(mockBackend.controller, {
defaultOpen: true,
onStateChange: () => {
bumpEpoch((epoch) => epoch + 1);
},
}),
);
}
return all;
}, [bumpEpoch]);
return <TanStackDevtools plugins={plugins} />;
}

export function App({ children }: AppProps) {
const layer = useMemo(() => {
// useState initializer: Firebase setup runs once per mount, and the layer
// keeps a stable identity without a memo the compiler can't verify.
const [layer] = useState(() => {
const app = initializeApp({ projectId: 'effect-firebase-example' });
const functions = getFunctions(app, 'europe-north1');
connectFunctionsEmulator(functions, 'localhost', 5001);

if (useMockBackend) {
// Fixture encoding errors are defects, not recoverable failures.
return Layer.orDie(mockBackend.layer);
}

const firestore = initializeFirestore(app, {
ignoreUndefinedProperties: true,
});
connectFirestoreEmulator(firestore, 'localhost', 8080);

return Client.layer({ firestore });
}, []);
});

// RegistryProvider reads initialValues only when the registry is first
// created, so the array doesn't need a stable identity.
Expand All @@ -45,6 +98,7 @@ export function App({ children }: AppProps) {
<div className="max-w-4xl mx-auto">{children}</div>
</main>
</div>
<Devtools />
</RegistryProvider>
);
}
Expand Down
25 changes: 21 additions & 4 deletions example/app/src/lib/atoms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,16 @@ export const firestoreLayerAtom = Atom.keepAlive(
),
);

/**
* Bumped by the Firestore Mock devtools after every state toggle (mock mode
* only). Views key their data subtree on this value, so a toggle remounts
* the subtree: the old atoms are disposed and the fresh subscriptions start
* from `Initial` against the new state. A plain refresh is not enough —
* atom results keep their previous value while re-running, and a stream in
* the `loading` state never emits, so the stale data would stay on screen.
*/
export const mockEpochAtom = Atom.keepAlive(Atom.make(0));

/**
* Runtime atom — rebuilds whenever `firestoreLayerAtom` changes in the
* registry (via `registry.set` / `useAtomSet`; `initialValues` is only read
Expand Down Expand Up @@ -58,10 +68,17 @@ export const postByIdLiveAtom = Atom.family((id: typeof PostId.Type) =>
.pipe(Atom.setIdleTTL('30 seconds')),
);

// Live list of latest posts. A single canonical atom (no family) so every
// subscriber shares one Firestore subscription.
export const latestPostsAtom = clientRuntime.atom(
Stream.unwrap(Effect.map(PostRepository, (r) => r.latestPosts())),
// Live list of latest posts, keyed by the mock epoch. All subscribers pass
// the same epoch, so they share one Firestore subscription; a bumped epoch
// yields a *new* atom identity that re-subscribes from `Initial`. That is
// what makes the devtools' simulated `loading`/`error` states visible on an
// already-mounted page — an atom's retained value survives both refreshes
// and remounts, so only a fresh identity starts over. Outside mock mode the
// epoch is always `0` and this behaves like a single canonical atom.
export const latestPostsAtom = Atom.family((_epoch: number) =>
clientRuntime.atom(
Stream.unwrap(Effect.map(PostRepository, (r) => r.latestPosts())),
),
);

// Mutations — writable atoms exposing AsyncResult state and a setter.
Expand Down
74 changes: 74 additions & 0 deletions example/app/src/lib/mock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { DateTime, Option } from 'effect';
import { fixture, make } from '@effect-firebase/mock';
import { AuthorId, AuthorModel, PostId, PostModel } from '@example/shared';

const at = (iso: string) => DateTime.makeUnsafe(iso);

const author = (id: string, name: string, created: string) =>
new AuthorModel({
id: AuthorId.make(id),
name,
createdAt: at(created),
updatedAt: at(created),
});

const post = (
id: string,
title: string,
content: string,
created: string,
authorId = 'ada',
) =>
new PostModel({
id: PostId.make(id),
title,
content,
author: AuthorId.make(authorId),
createdAt: at(created),
updatedAt: at(created),
checked: false,
optional: Option.none(),
list: [],
});

/**
* A static mock backend for developing pages without the Firebase emulator.
*
* Enabled by starting the app with `VITE_MOCK_BACKEND=1` (see `app.tsx`).
* The handle is shared between the app runtime (which provides
* `mockBackend.layer` through `firestoreLayerAtom`) and the Firestore Mock
* devtools panel (which drives `mockBackend.controller`).
*/
export const mockBackend = make({
fixtures: [
fixture(AuthorModel, {
collectionPath: 'authors',
idField: 'id',
docs: [author('ada', 'Ada Lovelace', '2024-01-01T09:00:00Z')],
}),
fixture(PostModel, {
collectionPath: 'posts',
idField: 'id',
docs: [
post(
'welcome',
'Welcome to mock mode',
'This post is served from the in-memory mock backend — no emulator running. Open the TanStack Devtools panel to toggle this collection between data, empty, loading and error.',
'2024-05-03T10:00:00Z',
),
post(
'fixtures',
'Fixtures are schema-encoded',
'These documents were written through PostModel, so timestamps, references and options decode exactly like production data.',
'2024-05-02T15:30:00Z',
),
post(
'try-writing',
'Writes are live',
'Create, edit or delete posts — the mock store is reactive, so the stream behind this list re-emits just like onSnapshot.',
'2024-05-01T08:15:00Z',
),
],
}),
],
});
4 changes: 2 additions & 2 deletions example/app/src/routes/__root.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
import { Outlet, createRootRoute } from '@tanstack/react-router';
import { TanStackRouterDevtools } from '@tanstack/react-router-devtools';
import App from '../app/app';
Comment thread
fwal marked this conversation as resolved.

export const Route = createRootRoute({
component: RootComponent,
});

// Devtools (router + Firestore mock) are mounted by <App /> in a single
// TanStack Devtools shell.
function RootComponent() {
return (
<App>
<Outlet />
<TanStackRouterDevtools position="bottom-right" />
</App>
);
}
7 changes: 6 additions & 1 deletion example/app/src/routes/firestore.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
addPostAtom,
updatePostAtom,
deletePostAtom,
mockEpochAtom,
} from '../lib/atoms.js';

export const Route = createFileRoute('/firestore')({
Expand Down Expand Up @@ -180,7 +181,11 @@ function PostForm({
}

export function PostList({ onEdit }: { onEdit: (post: Post) => void }) {
const result = useAtomValue(latestPostsAtom);
// The epoch is bumped by the Firestore Mock devtools on every state
// toggle; a new epoch keys a new atom identity, so the list re-subscribes
// from `Initial` against the toggled state (always 0 outside mock mode).
const mockEpoch = useAtomValue(mockEpochAtom);
const result = useAtomValue(latestPostsAtom(mockEpoch));
const remove = useAtomSet(deletePostAtom, { mode: 'promise' });
const [deleteError, setDeleteError] = useState<string | null>(null);

Expand Down
3 changes: 3 additions & 0 deletions example/app/tsconfig.app.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@
{
"path": "../../packages/mock/tsconfig.lib.json"
},
{
"path": "../../packages/devtools/tsconfig.lib.json"
},
{
"path": "../shared/tsconfig.lib.json"
},
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"start": "nx start",
"example:emulator": "nx run @example/backend:emulator",
"example:hosting": "nx run @example/app:dev",
"example:mock": "VITE_MOCK_BACKEND=1 nx run @example/app:dev",
"release": "nx release --skip-publish"
},
"private": true,
Expand Down
Loading
Loading