fix(frontend): resolve app-wide 'Oops' crash + PWA middleware interception
Root cause (found via Playwright browser probe — curl could not detect client-side
hydration errors):
1. ROOT-LAYOUT INTL CRASH (the 'Oops' on every page incl /login):
app/layout.tsx renders global client components (PwaInstallPrompt → useTranslations,
Toaster, Sonner) as siblings of {children} inside <Providers>, but only each
route-group layout wrapped its own children in NextIntlClientProvider. So those
global components mounted with NO intl context → 'No intl context found' → React
hydration crash → global-error 'Oops'. Fix: wrap the root body in
NextIntlClientProvider via getMessages() (RootLayout now async). Nested providers
stay valid (next-intl supports nesting).
2. PWA MIDDLEWARE INTERCEPTION (manifest.json syntax error + stale cache):
middleware matcher did not exclude /manifest.json or /sw.js, so unauthenticated
browsers got 307→/login (HTML) for both. Browser parsed HTML as JSON
('manifest.json:1 Syntax error') and an HTML/old service worker kept serving
stale bundles ('website hasn't changed' after redeploys). Fix: exclude
manifest.json, sw.js, icons, offline from the matcher.
3. SERVICE-WORKER STALE CACHE: bump CACHE_NAME v1→v2 so the activate handler purges
old cached bundles from clients that loaded the broken build.
Also adds scripts/debug/dashboard-probe.mjs — a Playwright probe that logs in and
captures real client-side console/network errors + screenshot.
This commit is contained in:
@@ -1,6 +1,10 @@
|
|||||||
/// <reference lib="webworker" />
|
/// <reference lib="webworker" />
|
||||||
|
|
||||||
const CACHE_NAME = "cannamanage-v1"
|
// Bump this version on every release that changes cached assets. The `activate`
|
||||||
|
// handler below deletes all caches whose name !== CACHE_NAME, so incrementing
|
||||||
|
// this string force-purges stale bundles from clients that cached the old
|
||||||
|
// (broken) build — fixes "website hasn't changed after redeploy".
|
||||||
|
const CACHE_NAME = "cannamanage-v2"
|
||||||
const OFFLINE_URL = "/offline"
|
const OFFLINE_URL = "/offline"
|
||||||
|
|
||||||
// Assets to pre-cache
|
// Assets to pre-cache
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { Cairo, Lato } from "next/font/google"
|
import { Cairo, Lato } from "next/font/google"
|
||||||
|
import { NextIntlClientProvider } from "next-intl"
|
||||||
|
import { getMessages } from "next-intl/server"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
@@ -50,9 +52,17 @@ const cairoFont = Cairo({
|
|||||||
variable: "--font-cairo",
|
variable: "--font-cairo",
|
||||||
})
|
})
|
||||||
|
|
||||||
export default function RootLayout(props: { children: ReactNode }) {
|
export default async function RootLayout(props: { children: ReactNode }) {
|
||||||
const { children } = props
|
const { children } = props
|
||||||
|
|
||||||
|
// Load messages at the root so GLOBAL components rendered here (PwaInstallPrompt,
|
||||||
|
// Toaster, etc.) have next-intl context. Without this, those components — which
|
||||||
|
// are siblings of {children} and therefore outside every route-group's
|
||||||
|
// NextIntlClientProvider — call useTranslations() with no provider and crash
|
||||||
|
// hydration on EVERY page (the "Oops" error). Nested route-group providers
|
||||||
|
// remain valid; next-intl supports provider nesting.
|
||||||
|
const messages = await getMessages()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="en" dir="ltr" suppressHydrationWarning>
|
<html lang="en" dir="ltr" suppressHydrationWarning>
|
||||||
<body
|
<body
|
||||||
@@ -63,13 +73,15 @@ export default function RootLayout(props: { children: ReactNode }) {
|
|||||||
cairoFont.variable // Include Cairo font variable
|
cairoFont.variable // Include Cairo font variable
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Providers locale="de">
|
<NextIntlClientProvider messages={messages}>
|
||||||
{children}
|
<Providers locale="de">
|
||||||
<Toaster />
|
{children}
|
||||||
<Sonner />
|
<Toaster />
|
||||||
<SwRegistration />
|
<Sonner />
|
||||||
<PwaInstallPrompt />
|
<SwRegistration />
|
||||||
</Providers>
|
<PwaInstallPrompt />
|
||||||
|
</Providers>
|
||||||
|
</NextIntlClientProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -61,8 +61,13 @@ export const config = {
|
|||||||
// - /portal-login (portal auth page)
|
// - /portal-login (portal auth page)
|
||||||
// - /api/auth (NextAuth API routes)
|
// - /api/auth (NextAuth API routes)
|
||||||
// - /_next/static, /_next/image (Next.js internals)
|
// - /_next/static, /_next/image (Next.js internals)
|
||||||
// - /favicon.ico, /images (public assets)
|
// - /favicon.ico, /images, /icons (public assets)
|
||||||
|
// - /manifest.json, /sw.js, /offline (PWA assets — MUST be public, otherwise the
|
||||||
|
// browser fetches them unauthenticated, gets a 307→/login HTML page, and:
|
||||||
|
// (1) parses the HTML as JSON → "manifest.json:1 Syntax error",
|
||||||
|
// (2) registers an HTML "sw.js" or keeps a STALE service worker in control →
|
||||||
|
// cached old bundles keep serving → "website hasn't changed" after redeploys.
|
||||||
// - /pricing, /impressum, /datenschutz, /agb (public marketing pages)
|
// - /pricing, /impressum, /datenschutz, /agb (public marketing pages)
|
||||||
"/((?!login|register|forgot-password|portal-login|api/auth|_next/static|_next/image|favicon.ico|images|pricing|impressum|datenschutz|agb).*)",
|
"/((?!login|register|forgot-password|portal-login|api/auth|_next/static|_next/image|favicon.ico|images|icons|manifest.json|sw.js|offline|pricing|impressum|datenschutz|agb).*)",
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Debug probe deps — installed on demand via `npm install playwright`
|
||||||
|
node_modules/
|
||||||
|
package-lock.json
|
||||||
|
*.png
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// Headless-browser probe: logs in and captures the REAL client-side error on /dashboard.
|
||||||
|
// Run via the official Playwright Docker image (no local Node needed):
|
||||||
|
// docker run --rm --network host -v "$PWD/scripts/debug:/work" -w /work \
|
||||||
|
// mcr.microsoft.com/playwright:v1.49.0-jammy \
|
||||||
|
// sh -c "npm i playwright@1.49.0 -s && node dashboard-probe.mjs"
|
||||||
|
//
|
||||||
|
// Captures: console messages, page errors (un-minified via sourcemaps if present),
|
||||||
|
// failed requests, and a screenshot.
|
||||||
|
|
||||||
|
import { chromium } from "playwright"
|
||||||
|
|
||||||
|
const BASE = process.env.BASE_URL || "http://192.168.188.119:3000"
|
||||||
|
const EMAIL = process.env.LOGIN_EMAIL || "admin@test.de"
|
||||||
|
const PASSWORD = process.env.LOGIN_PASSWORD || "test123"
|
||||||
|
|
||||||
|
const browser = await chromium.launch()
|
||||||
|
const ctx = await browser.newContext({ ignoreHTTPSErrors: true })
|
||||||
|
const page = await ctx.newPage()
|
||||||
|
|
||||||
|
const log = []
|
||||||
|
page.on("console", (m) => log.push(`[console.${m.type()}] ${m.text()}`))
|
||||||
|
page.on("pageerror", (e) => log.push(`[pageerror] ${e.stack || e.message}`))
|
||||||
|
page.on("requestfailed", (r) =>
|
||||||
|
log.push(`[requestfailed] ${r.method()} ${r.url()} — ${r.failure()?.errorText}`)
|
||||||
|
)
|
||||||
|
page.on("response", (r) => {
|
||||||
|
if (r.status() >= 400) log.push(`[http ${r.status()}] ${r.url()}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`==> GET ${BASE}/login`)
|
||||||
|
await page.goto(`${BASE}/login`, { waitUntil: "networkidle", timeout: 20000 })
|
||||||
|
|
||||||
|
await page.fill('input[type="email"], input[name="email"], #email', EMAIL)
|
||||||
|
await page.fill('input[type="password"], input[name="password"], #password', PASSWORD)
|
||||||
|
await page.click('button[type="submit"]')
|
||||||
|
|
||||||
|
// Wait for navigation to settle on the dashboard (or wherever it lands)
|
||||||
|
await page.waitForTimeout(5000)
|
||||||
|
console.log(`==> landed on: ${page.url()}`)
|
||||||
|
|
||||||
|
// Force-navigate to dashboard to reproduce the crash deterministically
|
||||||
|
await page.goto(`${BASE}/dashboard`, { waitUntil: "networkidle", timeout: 20000 })
|
||||||
|
await page.waitForTimeout(3000)
|
||||||
|
|
||||||
|
await page.screenshot({ path: "dashboard-probe.png", fullPage: true })
|
||||||
|
console.log("==> screenshot saved: dashboard-probe.png")
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`[probe-error] ${e.message}`)
|
||||||
|
} finally {
|
||||||
|
console.log("\n===== CAPTURED BROWSER EVENTS =====")
|
||||||
|
console.log(log.join("\n") || "(none)")
|
||||||
|
await browser.close()
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "debug",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "^1.60.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user