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
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,19 @@
- bootstrap an anonymous local Convex deployment and run the backend health query: `pnpm bootstrap:backend:local`
- keep the local Convex backend watcher running: `pnpm dev:backend:local`
- run the one-shot local Convex health check alias: `pnpm run:backend:health:local`
- run the web app with the local Convex URL bridged into `NEXT_PUBLIC_CONVEX_URL`: `pnpm dev:web`
- typecheck the web app with the same Convex URL bridge when available: `pnpm typecheck:web`
- build the web app with the same Convex URL bridge when available: `pnpm build:web`
- typecheck Convex backend files: `pnpm typecheck:backend`
- re-run the local backend verification pass: `pnpm verify:backend:local`
- confirm committed Convex codegen is current: `pnpm check:backend:generated`
- run the web app: `pnpm dev:web`
- lint the web app: `pnpm lint:web`
- typecheck the web app: `pnpm typecheck:web`
- build the web app: `pnpm build:web`
- run the baseline local verification pass: `pnpm verify`

Convex writes repo-root deployment configuration to `.env.local` during local setup and keeps anonymous local state under `.convex-home/` plus `.convex-tmp/`. Keep all of those uncommitted. The committed `convex/_generated/` files are expected to stay clean after `pnpm check:backend:generated`.

The repo-root web commands bridge `CONVEX_URL` from `.env.local` into `NEXT_PUBLIC_CONVEX_URL` automatically when the file exists, so the first `apps/web -> convex/` runtime path works without a second hand-maintained env file.

`pnpm verify` is the full repo verification pass and now includes the local Convex bootstrap checks. If you are iterating on the web app only, use `pnpm verify:web` for the lighter web-only path.

## Start here
Expand Down
6 changes: 5 additions & 1 deletion apps/web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@ From the repo root:

```bash
pnpm install
pnpm dev:backend:local
pnpm dev:web
```

Useful follow-up commands:

```bash
pnpm bootstrap:backend:local
pnpm lint:web
pnpm typecheck:web
pnpm build:web
Expand All @@ -25,4 +27,6 @@ pnpm build:web
- framework baseline: `Next.js` App Router
- language baseline: `TypeScript`
- styling baseline: `Tailwind CSS`
- this scaffold intentionally stops before `Convex`, auth, billing, or deployment wiring
- repo-root web commands bridge `CONVEX_URL` from `.env.local` into `NEXT_PUBLIC_CONVEX_URL` when local Convex bootstrap has run
- the homepage now renders the placeholder public query `health:status` from `convex/`
- auth, billing, deployment hardening, and server-side Convex patterns still belong to follow-on issues
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"typecheck": "next typegen && tsc --noEmit --incremental false"
},
"dependencies": {
"convex": "^1.32.0",
"next": "16.1.6",
"react": "19.2.3",
"react-dom": "19.2.3"
Expand Down
38 changes: 38 additions & 0 deletions apps/web/src/app/convex-client-provider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"use client";

import { ConvexProvider, ConvexReactClient } from "convex/react";
import type { ReactNode } from "react";

let convexClient: ConvexReactClient | null = null;
let convexClientUrl: string | null = null;

function getConvexClient(convexUrl: string | undefined) {
if (!convexUrl || typeof window === "undefined") {
return null;
}

if (!convexClient || convexClientUrl !== convexUrl) {
convexClient?.close();
convexClient = new ConvexReactClient(convexUrl);
convexClientUrl = convexUrl;
}

return convexClient;
}

export function resetConvexClientForTests() {
convexClient?.close();
convexClient = null;
convexClientUrl = null;
}

export function ConvexClientProvider({ children }: { children: ReactNode }) {
const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL;
const client = getConvexClient(convexUrl);

if (!client) {
return <>{children}</>;
}

return <ConvexProvider client={client}>{children}</ConvexProvider>;
}
Comment on lines +29 to +38
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Side-effectful function called in render body

getConvexClient can call convexClient?.close() and new ConvexReactClient(convexUrl) when the URL changes. Calling these side effects directly during render — rather than inside a useEffect or event handler — is outside React's render-purity contract. In practice the URL is a compile-time constant so the branch that mutates the singleton only runs on the very first render, but React's concurrent renderer can invoke render functions multiple times before committing (e.g. during startTransition or hydration), which would incorrectly attempt to close and reopen the WebSocket client on each discarded render.

A light guard would make the mutation safe:

// Only mutate when the effect is committed (client-side).
// If the URL cannot change at runtime this is low-risk today, but
// explicit side-effect isolation prevents surprises under concurrent features.

Consider wrapping the close/create path in useEffect (keyed on convexUrl) inside ConvexClientProvider rather than calling it inline in the render body.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/src/app/convex-client-provider.tsx
Line: 29-38

Comment:
**Side-effectful function called in render body**

`getConvexClient` can call `convexClient?.close()` and `new ConvexReactClient(convexUrl)` when the URL changes. Calling these side effects directly during render — rather than inside a `useEffect` or event handler — is outside React's render-purity contract. In practice the URL is a compile-time constant so the branch that mutates the singleton only runs on the very first render, but React's concurrent renderer can invoke render functions multiple times before committing (e.g. during `startTransition` or hydration), which would incorrectly attempt to close and reopen the WebSocket client on each discarded render.

A light guard would make the mutation safe:

```ts
// Only mutate when the effect is committed (client-side).
// If the URL cannot change at runtime this is low-risk today, but
// explicit side-effect isolation prevents surprises under concurrent features.
```

Consider wrapping the close/create path in `useEffect` (keyed on `convexUrl`) inside `ConvexClientProvider` rather than calling it inline in the render body.

How can I resolve this? If you propose a fix, please make it concise.

189 changes: 189 additions & 0 deletions apps/web/src/app/convex-runtime-panel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
"use client";

import { api } from "@convex/_generated/api";
import { Component, type ReactNode, useState, useSyncExternalStore } from "react";
import { useQuery } from "convex/react";

function subscribeToClientReady() {
return () => {};
}

class ConvexQueryErrorBoundary extends Component<
{ children: ReactNode; onRetry: () => void },
{ hasError: boolean }
> {
state = { hasError: false };

static getDerivedStateFromError() {
return { hasError: true };
}

componentDidCatch(error: unknown, info: React.ErrorInfo) {
console.error("[ConvexQueryErrorBoundary] Caught backend query error:", error, info);
}

render() {
if (this.state.hasError) {
return (
<div className="space-y-4">
<div>
<p className="font-mono text-xs uppercase tracking-[0.3em] text-muted">
Live Convex status
</p>
<h2 className="mt-3 text-2xl font-semibold tracking-[-0.03em]">
Backend query needs attention.
</h2>
</div>

<div className="rounded-[1.25rem] border border-dashed border-border bg-surface px-4 py-5 text-sm leading-7 text-foreground">
The live Convex panel hit a backend error. Check{" "}
<code className="font-mono">pnpm dev:backend:local</code> and refresh once the local
backend is healthy again.
</div>

<button
className="inline-flex w-fit items-center rounded-full border border-border px-4 py-2 text-sm font-medium text-foreground transition hover:bg-surface"
onClick={this.props.onRetry}
type="button"
>
Try again
</button>
</div>
);
}

return this.props.children;
}
}

function ConvexRuntimeStatus({ convexUrl }: { convexUrl: string }) {
const status = useQuery(api.health.status);
const statusHeading = status === undefined ? "Connecting to Convex..." : "The first runtime path is active.";

return (
<div className="space-y-4">
<div>
<p className="font-mono text-xs uppercase tracking-[0.3em] text-muted">
Live Convex status
</p>
<h2 className="mt-3 text-2xl font-semibold tracking-[-0.03em]">
{statusHeading}
</h2>
</div>

<p className="text-sm leading-7 text-muted">
This panel reads the placeholder <code className="font-mono">health:status</code>{" "}
query from the real backend bootstrap under <code className="font-mono">convex/</code>.
</p>

<div className="rounded-[1.25rem] border border-border bg-surface p-4">
<p className="font-mono text-[11px] uppercase tracking-[0.28em] text-muted">
Backend endpoint
</p>
<p className="mt-2 break-all font-mono text-sm text-foreground">{convexUrl}</p>
</div>

{status === undefined ? (
<div className="rounded-[1.25rem] border border-dashed border-border bg-surface px-4 py-5">
<p className="font-mono text-[11px] uppercase tracking-[0.28em] text-muted">
Query state
</p>
<p className="mt-2 text-sm leading-7 text-foreground">
Connecting to Convex and waiting for the first placeholder payload.
</p>
</div>
) : status ? (
<dl className="space-y-3 text-sm">
<div className="flex items-start justify-between gap-4 border-b border-border pb-3">
<dt className="text-muted">Status</dt>
<dd className="text-right font-medium text-emerald-700">{status.status}</dd>
</div>
<div className="flex items-start justify-between gap-4 border-b border-border pb-3">
<dt className="text-muted">Backend</dt>
<dd className="text-right font-medium">{status.backend}</dd>
</div>
<div className="flex items-start justify-between gap-4 border-b border-border pb-3">
<dt className="text-muted">Project</dt>
<dd className="text-right font-medium">{status.project}</dd>
</div>
<div className="flex items-start justify-between gap-4 border-b border-border pb-3">
<dt className="text-muted">Scope</dt>
<dd className="text-right font-medium">{status.scope}</dd>
</div>
<div className="space-y-2 pt-1">
<dt className="text-muted">Backend note</dt>
<dd className="text-sm leading-7 text-foreground">{status.note}</dd>
</div>
</dl>
) : (
<div className="rounded-[1.25rem] border border-dashed border-border bg-surface px-4 py-5">
<p className="font-mono text-[11px] uppercase tracking-[0.28em] text-muted">
Query result
</p>
<p className="mt-2 text-sm leading-7 text-foreground">
Convex returned an empty placeholder payload. Refresh after restarting the local
backend if this sticks around.
</p>
</div>
)}
</div>
);
}

export function ConvexRuntimePanel() {
const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL;
const isClient = useSyncExternalStore(subscribeToClientReady, () => true, () => false);
const [retryKey, setRetryKey] = useState(0);

if (!convexUrl) {
return (
<div className="space-y-4">
<div>
<p className="font-mono text-xs uppercase tracking-[0.3em] text-muted">
Convex runtime path
</p>
<h2 className="mt-3 text-2xl font-semibold tracking-[-0.03em]">
Waiting for local backend wiring.
</h2>
</div>

<p className="text-sm leading-7 text-muted">
The app can render without a backend URL, but the live Convex read path only
turns on when the repo-root <code className="font-mono">.env.local</code>{" "}
file exists.
</p>

<div className="rounded-[1.25rem] border border-dashed border-border bg-surface px-4 py-5 text-sm leading-7 text-foreground">
Run <code className="font-mono">pnpm bootstrap:backend:local</code> once, keep{" "}
<code className="font-mono">pnpm dev:backend:local</code> running, and start the web
app with <code className="font-mono">pnpm dev:web</code> from the repo root.
</div>
</div>
);
}

if (!isClient) {
return (
<div className="space-y-4">
<div>
<p className="font-mono text-xs uppercase tracking-[0.3em] text-muted">
Live Convex status
</p>
<h2 className="mt-3 text-2xl font-semibold tracking-[-0.03em]">
Connecting to Convex...
</h2>
</div>

<div className="rounded-[1.25rem] border border-dashed border-border bg-surface px-4 py-5 text-sm leading-7 text-foreground">
Preparing the client-side Convex runtime.
</div>
</div>
);
}

return (
<ConvexQueryErrorBoundary key={retryKey} onRetry={() => setRetryKey((current) => current + 1)}>
<ConvexRuntimeStatus convexUrl={convexUrl} />
</ConvexQueryErrorBoundary>
);
}
3 changes: 2 additions & 1 deletion apps/web/src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import { IBM_Plex_Mono, Space_Grotesk } from "next/font/google";
import { ConvexClientProvider } from "./convex-client-provider";
import "./globals.css";

const spaceGrotesk = Space_Grotesk({
Expand Down Expand Up @@ -28,7 +29,7 @@ export default function RootLayout({
<body
className={`${spaceGrotesk.variable} ${ibmPlexMono.variable} antialiased`}
>
{children}
<ConvexClientProvider>{children}</ConvexClientProvider>
</body>
</html>
);
Expand Down
Loading
Loading