Skip to content
Merged
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
10 changes: 10 additions & 0 deletions .claude/coding-standards.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@

This document outlines coding conventions and standards for this project.

## Branch Workflow (MUST DO FIRST)

**Before making any code changes, create a feature branch off main.** Never work directly on main.

```bash
git checkout -b feat/descriptive-branch-name
```

This is the very first step for every task — no exceptions. If you find yourself about to push to main, **STOP IMMEDIATELY** and ask the user what to do instead.

## Strict Typing and Nullability

Prefer strict, explicit typings and clear nullability rules; don't auto-widen.
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/build_and_push_nonproduction_images.yml
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ jobs:
NEXT_PUBLIC_BACKEND_SERVICE_API_PATH=${{ vars.BACKEND_SERVICE_API_PATH }}
NEXT_PUBLIC_BACKEND_API_VERSION=${{ vars.BACKEND_API_VERSION }}
NEXT_PUBLIC_TIPTAP_APP_ID=${{ vars.TIPTAP_APP_ID }}
GIT_COMMIT_SHA=${{ github.sha }}
FRONTEND_SERVICE_PORT=${{ vars.FRONTEND_SERVICE_PORT }}
FRONTEND_SERVICE_INTERFACE=${{ vars.FRONTEND_SERVICE_INTERFACE }}
tags: ${{ steps.tags.outputs.frontend_tags }}
Expand Down
5 changes: 5 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ ARG NEXT_PUBLIC_BACKEND_SERVICE_API_PATH
ARG NEXT_PUBLIC_BACKEND_API_VERSION
ARG NEXT_PUBLIC_TIPTAP_APP_ID
ARG NEXT_PUBLIC_BASE_PATH
ARG GIT_COMMIT_SHA
ARG FRONTEND_SERVICE_INTERFACE
ARG FRONTEND_SERVICE_PORT

Expand Down Expand Up @@ -45,6 +46,7 @@ ARG NEXT_PUBLIC_BACKEND_SERVICE_API_PATH
ARG NEXT_PUBLIC_BACKEND_API_VERSION
ARG NEXT_PUBLIC_TIPTAP_APP_ID
ARG NEXT_PUBLIC_BASE_PATH
ARG GIT_COMMIT_SHA

# Pass them as ENV so Next.js static build can access
ENV NEXT_PUBLIC_BACKEND_SERVICE_PROTOCOL=$NEXT_PUBLIC_BACKEND_SERVICE_PROTOCOL
Expand All @@ -54,6 +56,7 @@ ENV NEXT_PUBLIC_BACKEND_SERVICE_API_PATH=$NEXT_PUBLIC_BACKEND_SERVICE_API_PATH
ENV NEXT_PUBLIC_BACKEND_API_VERSION=$NEXT_PUBLIC_BACKEND_API_VERSION
ENV NEXT_PUBLIC_TIPTAP_APP_ID=$NEXT_PUBLIC_TIPTAP_APP_ID
ENV NEXT_PUBLIC_BASE_PATH=$NEXT_PUBLIC_BASE_PATH
ENV GIT_COMMIT_SHA=$GIT_COMMIT_SHA

# Build the Next.js application
# Note: Use `next build` to build the application for production
Expand Down Expand Up @@ -86,6 +89,7 @@ ARG NEXT_PUBLIC_BACKEND_SERVICE_API_PATH
ARG NEXT_PUBLIC_BACKEND_API_VERSION
ARG NEXT_PUBLIC_TIPTAP_APP_ID
ARG NEXT_PUBLIC_BASE_PATH
ARG GIT_COMMIT_SHA
ARG FRONTEND_SERVICE_INTERFACE
ARG FRONTEND_SERVICE_PORT

Expand All @@ -96,6 +100,7 @@ ENV NEXT_PUBLIC_BACKEND_SERVICE_API_PATH=$NEXT_PUBLIC_BACKEND_SERVICE_API_PATH
ENV NEXT_PUBLIC_BACKEND_API_VERSION=$NEXT_PUBLIC_BACKEND_API_VERSION
ENV NEXT_PUBLIC_TIPTAP_APP_ID=$NEXT_PUBLIC_TIPTAP_APP_ID
ENV NEXT_PUBLIC_BASE_PATH=$NEXT_PUBLIC_BASE_PATH
ENV GIT_COMMIT_SHA=$GIT_COMMIT_SHA

# Runtime ENV for Compose
ENV HOSTNAME=$FRONTEND_SERVICE_INTERFACE
Expand Down
171 changes: 171 additions & 0 deletions __tests__/app/global-error.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import GlobalError from '@/app/global-error'

// ---------------------------------------------------------------------------
// Integration tests for global-error.tsx
//
// Tests the auto-reload behavior for ChunkLoadErrors and the
// sessionStorage-based infinite loop prevention.
// ---------------------------------------------------------------------------

// Mock window.location.reload — jsdom doesn't support real navigation
const reloadMock = vi.fn()

beforeEach(() => {
sessionStorage.clear()
reloadMock.mockClear()

Object.defineProperty(window, 'location', {
value: { ...window.location, reload: reloadMock },
writable: true,
configurable: true,
})
})

afterEach(() => {
sessionStorage.clear()
})

function chunkLoadError(message = 'Loading chunk abc123 failed'): Error & { digest?: string } {
const error = new Error(message)
error.name = 'ChunkLoadError'
return error
}

function dynamicImportError(): Error & { digest?: string } {
return new Error('Failed to fetch dynamically imported module: /path/to/chunk.js')
}

function genericError(): Error & { digest?: string } {
return new Error('Cannot read properties of undefined')
}

describe('GlobalError', () => {
describe('ChunkLoadError auto-reload', () => {
it('reloads the page on first ChunkLoadError', async () => {
const reset = vi.fn()

render(<GlobalError error={chunkLoadError()} reset={reset} />)

await waitFor(() => {
expect(reloadMock).toHaveBeenCalledTimes(1)
})

// Flag should be set to prevent a second reload
expect(sessionStorage.getItem('__chunk_error_reloaded')).toBe('true')
})

it('reloads on "Failed to fetch dynamically imported module" error', async () => {
const reset = vi.fn()

render(<GlobalError error={dynamicImportError()} reset={reset} />)

await waitFor(() => {
expect(reloadMock).toHaveBeenCalledTimes(1)
})
})

it('does NOT reload a second time (prevents infinite loop)', async () => {
const reset = vi.fn()

// Simulate: a reload already happened
sessionStorage.setItem('__chunk_error_reloaded', 'true')

render(<GlobalError error={chunkLoadError()} reset={reset} />)

// Wait for the useEffect to run
await waitFor(() => {
// The flag should be cleared so the next deploy can try again
expect(sessionStorage.getItem('__chunk_error_reloaded')).toBeNull()
})

// No reload should have been triggered
expect(reloadMock).not.toHaveBeenCalled()
})

it('shows "A new version is available" message for chunk errors', () => {
// Pre-set flag so the useEffect doesn't trigger reload during render
sessionStorage.setItem('__chunk_error_reloaded', 'true')

render(<GlobalError error={chunkLoadError()} reset={vi.fn()} />)

expect(screen.getByText('A new version is available. Reloading...')).toBeInTheDocument()
})

it('does NOT show the "Try again" button for chunk errors', () => {
sessionStorage.setItem('__chunk_error_reloaded', 'true')

render(<GlobalError error={chunkLoadError()} reset={vi.fn()} />)

expect(screen.queryByRole('button', { name: 'Try again' })).not.toBeInTheDocument()
})
})

describe('Non-chunk error fallback UI', () => {
it('does NOT auto-reload for generic errors', async () => {
const reset = vi.fn()

render(<GlobalError error={genericError()} reset={reset} />)

// Give useEffect a chance to run
await waitFor(() => {
expect(screen.getByText('Something went wrong')).toBeInTheDocument()
})

expect(reloadMock).not.toHaveBeenCalled()
})

it('shows "An unexpected error occurred" message', () => {
render(<GlobalError error={genericError()} reset={vi.fn()} />)

expect(screen.getByText('An unexpected error occurred.')).toBeInTheDocument()
})

it('shows "Try again" button that calls reset', async () => {
const reset = vi.fn()

render(<GlobalError error={genericError()} reset={reset} />)

const button = screen.getByRole('button', { name: 'Try again' })
expect(button).toBeInTheDocument()

await userEvent.click(button)
expect(reset).toHaveBeenCalledTimes(1)
})

it('does NOT set the sessionStorage flag for non-chunk errors', async () => {
render(<GlobalError error={genericError()} reset={vi.fn()} />)

await waitFor(() => {
expect(screen.getByText('Something went wrong')).toBeInTheDocument()
})

expect(sessionStorage.getItem('__chunk_error_reloaded')).toBeNull()
})
})

describe('sessionStorage lifecycle', () => {
it('clears the flag after skipping reload so next deploy can retry', async () => {
sessionStorage.setItem('__chunk_error_reloaded', 'true')

render(<GlobalError error={chunkLoadError()} reset={vi.fn()} />)

await waitFor(() => {
expect(sessionStorage.getItem('__chunk_error_reloaded')).toBeNull()
})
})

it('sets the flag before triggering reload', async () => {
render(<GlobalError error={chunkLoadError()} reset={vi.fn()} />)

await waitFor(() => {
expect(reloadMock).toHaveBeenCalled()
})

// Flag was set before reload was called
expect(sessionStorage.getItem('__chunk_error_reloaded')).toBe('true')
})
})
})
4 changes: 4 additions & 0 deletions next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
const nextConfig = {
output: 'standalone',
basePath: process.env.NEXT_PUBLIC_BASE_PATH || '',
// Version skew protection: when the server restarts with a new build,
// Next.js detects the mismatch and triggers a hard reload instead of
// serving stale RSC flight data to the old client JS.
deploymentId: process.env.GIT_COMMIT_SHA || undefined,
turbopack: {
// Ensure Yjs is only loaded once to prevent duplicate instance warnings
// See: https://github.com/yjs/yjs/issues/438
Expand Down
82 changes: 82 additions & 0 deletions src/app/global-error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"use client";

import { useEffect } from "react";

const STORAGE_KEY = "__chunk_error_reloaded";

/**
* Root-level error boundary for the entire app (Next.js convention).
*
* Catches ChunkLoadErrors (stale JS bundles requesting renamed chunks
* after a deploy) and triggers a single hard reload. sessionStorage
* prevents infinite reload loops if the new build is itself broken.
*
* For non-chunk errors, renders a minimal fallback UI with a retry button.
*/
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
const isChunkError =
error.name === "ChunkLoadError" ||
error.message?.includes("Loading chunk") ||
error.message?.includes("Failed to fetch dynamically imported module");

useEffect(() => {
if (!isChunkError) return;

const hasReloaded = sessionStorage.getItem(STORAGE_KEY);
if (!hasReloaded) {
sessionStorage.setItem(STORAGE_KEY, "true");
window.location.reload();
} else {
// Already reloaded once — clear flag so next deploy can retry
sessionStorage.removeItem(STORAGE_KEY);
}
}, [isChunkError]);

return (
<html lang="en">
<body
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "100vh",
fontFamily: "system-ui, sans-serif",
backgroundColor: "#fafafa",
color: "#1a1a1a",
}}
>
<div style={{ textAlign: "center", maxWidth: "420px", padding: "2rem" }}>
<h2 style={{ fontSize: "1.25rem", marginBottom: "0.75rem" }}>
Something went wrong
</h2>
<p style={{ color: "#666", marginBottom: "1.5rem", fontSize: "0.9rem" }}>
{isChunkError
? "A new version is available. Reloading..."
: "An unexpected error occurred."}
</p>
{!isChunkError && (
<button
onClick={reset}
style={{
padding: "0.5rem 1.25rem",
borderRadius: "0.375rem",
border: "1px solid #d4d4d4",
backgroundColor: "#fff",
cursor: "pointer",
fontSize: "0.875rem",
}}
>
Try again
</button>
)}
</div>
</body>
</html>
);
}
Loading