diff --git a/.claude/coding-standards.md b/.claude/coding-standards.md index d6bcd6d0..07ac4d02 100644 --- a/.claude/coding-standards.md +++ b/.claude/coding-standards.md @@ -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. diff --git a/.github/workflows/build_and_push_nonproduction_images.yml b/.github/workflows/build_and_push_nonproduction_images.yml index b3952a7c..ab63e862 100644 --- a/.github/workflows/build_and_push_nonproduction_images.yml +++ b/.github/workflows/build_and_push_nonproduction_images.yml @@ -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 }} diff --git a/Dockerfile b/Dockerfile index 5542e06c..91d73fd3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/__tests__/app/global-error.test.tsx b/__tests__/app/global-error.test.tsx new file mode 100644 index 00000000..61a2d175 --- /dev/null +++ b/__tests__/app/global-error.test.tsx @@ -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() + + 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() + + 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() + + // 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() + + 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() + + 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() + + // 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() + + expect(screen.getByText('An unexpected error occurred.')).toBeInTheDocument() + }) + + it('shows "Try again" button that calls reset', async () => { + const reset = vi.fn() + + render() + + 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() + + 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() + + await waitFor(() => { + expect(sessionStorage.getItem('__chunk_error_reloaded')).toBeNull() + }) + }) + + it('sets the flag before triggering reload', async () => { + render() + + await waitFor(() => { + expect(reloadMock).toHaveBeenCalled() + }) + + // Flag was set before reload was called + expect(sessionStorage.getItem('__chunk_error_reloaded')).toBe('true') + }) + }) +}) diff --git a/next.config.mjs b/next.config.mjs index 6a99951c..039e72e0 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -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 diff --git a/src/app/global-error.tsx b/src/app/global-error.tsx new file mode 100644 index 00000000..bf19152e --- /dev/null +++ b/src/app/global-error.tsx @@ -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 ( + + +
+

+ Something went wrong +

+

+ {isChunkError + ? "A new version is available. Reloading..." + : "An unexpected error occurred."} +

+ {!isChunkError && ( + + )} +
+ + + ); +}