Round 19: Fix auth 500/session persistence, script tag warning, module factory error, rate limiting
- Fix auth session persistence: set cookie on both cookies() and NextResponse - Fix isSecureRequest: check x-forwarded-proto for HTTPS proxy support - Fix excessive GET /api/auth polling: add useRef guard to prevent duplicate checks - Increase rate limits: auth POST 30/min, auth GET 120/min - Fix script tag warning: use ref-based textContent instead of dangerouslySetInnerHTML in chart.tsx - Fix sanitizeHtml: improve regex patterns for script tag removal - Fix module factory error: disable SW registration in dev, skip /_next/ caching in sw.js - Bump SW cache version v2→v3 to force cache invalidation - Better error handling in auth route with specific error messages
This commit is contained in:
1 parent
760fa70e82
commit
74404708e5
9 files changed
+192
-47
No files matched your search
+14
-7
@@ -1,8 +1,8 @@
|
|||||||
// CompetenceTrack — Service Worker for PWA Offline Support
|
// CompetenceTrack — Service Worker for PWA Offline Support
|
||||||
const CACHE_NAME = 'competencetrack-v2';
|
const CACHE_NAME = 'competencetrack-v3';
|
||||||
const STATIC_CACHE = 'competencetrack-static-v2';
|
const STATIC_CACHE = 'competencetrack-static-v3';
|
||||||
const DATA_CACHE = 'competencetrack-data-v2';
|
const DATA_CACHE = 'competencetrack-data-v3';
|
||||||
const OFFLINE_CACHE = 'competencetrack-offline-v2';
|
const OFFLINE_CACHE = 'competencetrack-offline-v3';
|
||||||
|
|
||||||
// Static assets to cache on install
|
// Static assets to cache on install
|
||||||
const STATIC_ASSETS = [
|
const STATIC_ASSETS = [
|
||||||
@@ -80,13 +80,20 @@ self.addEventListener('fetch', (event) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Static assets (CSS, JS, images) — CacheFirst strategy
|
// Never cache /_next/ paths — Turbopack recompiles frequently and cached chunks
|
||||||
// But use NetworkFirst for JS/CSS from Next.js dev server (_next/) to avoid stale module factories
|
// cause "module factory is not available" errors. Always fetch from network.
|
||||||
|
if (url.pathname.startsWith('/_next/')) {
|
||||||
|
event.respondWith(
|
||||||
|
fetch(request).catch(() => new Response('Offline', { status: 503 }))
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static assets (CSS, JS, images) from other origins — CacheFirst strategy
|
||||||
if (
|
if (
|
||||||
url.pathname.endsWith('.css') ||
|
url.pathname.endsWith('.css') ||
|
||||||
url.pathname.endsWith('.js')
|
url.pathname.endsWith('.js')
|
||||||
) {
|
) {
|
||||||
// Always use NetworkFirst for JS/CSS to avoid stale Turbopack chunks
|
|
||||||
event.respondWith(networkFirstStrategy(request, STATIC_CACHE));
|
event.respondWith(networkFirstStrategy(request, STATIC_CACHE));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
+51
-11
@@ -7,6 +7,9 @@ import {
|
|||||||
createSession,
|
createSession,
|
||||||
getSession,
|
getSession,
|
||||||
clearSession,
|
clearSession,
|
||||||
|
isSecureRequest,
|
||||||
|
SESSION_COOKIE_NAME,
|
||||||
|
getSessionCookieOptions,
|
||||||
} from '@/lib/auth';
|
} from '@/lib/auth';
|
||||||
import { withRateLimit, checkRateLimit, getRateLimitKey, getRateLimitHeaders, createRateLimitResponse } from '@/lib/rate-limit';
|
import { withRateLimit, checkRateLimit, getRateLimitKey, getRateLimitHeaders, createRateLimitResponse } from '@/lib/rate-limit';
|
||||||
|
|
||||||
@@ -60,9 +63,13 @@ export const POST = withRateLimit(async function POST(request: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await createSession(user.id);
|
const secure = isSecureRequest(request);
|
||||||
|
await createSession(user.id, secure);
|
||||||
|
|
||||||
return NextResponse.json({
|
// Also set cookie on the response object to guarantee the Set-Cookie
|
||||||
|
// header is included. In some Next.js App Router scenarios, cookies().set()
|
||||||
|
// alone may not propagate to the NextResponse body.
|
||||||
|
const response = NextResponse.json({
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
firstName: user.firstName,
|
firstName: user.firstName,
|
||||||
@@ -72,6 +79,8 @@ export const POST = withRateLimit(async function POST(request: Request) {
|
|||||||
locale: user.locale,
|
locale: user.locale,
|
||||||
isDemo: user.isDemo,
|
isDemo: user.isDemo,
|
||||||
});
|
});
|
||||||
|
response.cookies.set(SESSION_COOKIE_NAME, user.id, getSessionCookieOptions(secure));
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Register ──
|
// ── Register ──
|
||||||
@@ -108,9 +117,10 @@ export const POST = withRateLimit(async function POST(request: Request) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await createSession(user.id);
|
const secure = isSecureRequest(request);
|
||||||
|
await createSession(user.id, secure);
|
||||||
|
|
||||||
return NextResponse.json(
|
const response = NextResponse.json(
|
||||||
{
|
{
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
@@ -123,28 +133,56 @@ export const POST = withRateLimit(async function POST(request: Request) {
|
|||||||
},
|
},
|
||||||
{ status: 201 }
|
{ status: 201 }
|
||||||
);
|
);
|
||||||
|
response.cookies.set(SESSION_COOKIE_NAME, user.id, getSessionCookieOptions(secure));
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Logout ──
|
// ── Logout ──
|
||||||
if (action === 'logout') {
|
if (action === 'logout') {
|
||||||
await clearSession();
|
await clearSession();
|
||||||
return NextResponse.json({ message: 'Logged out' });
|
|
||||||
|
const response = NextResponse.json({ message: 'Logged out' });
|
||||||
|
response.cookies.delete(SESSION_COOKIE_NAME);
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ error: 'Unknown action' }, { status: 400 });
|
return NextResponse.json({ error: 'Unknown action' }, { status: 400 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Auth error:', error);
|
console.error('Auth POST error:', error);
|
||||||
|
|
||||||
|
// Return more specific error info for debugging
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
const isZod = error instanceof z.ZodError;
|
||||||
|
const isPrisma =
|
||||||
|
error instanceof Error &&
|
||||||
|
(error.message.includes('Prisma') || error.constructor.name.includes('Prisma'));
|
||||||
|
|
||||||
|
if (isZod) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Validation error', details: (error as z.ZodError).issues },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPrisma) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Database error', detail: message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Internal server error' },
|
{ error: 'Internal server error', detail: message },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, 'auth');
|
}, 'auth');
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
// Apply rate limit for auth check endpoint
|
// Apply rate limit for auth check endpoint — 120 per minute
|
||||||
const key = getRateLimitKey(request);
|
const key = getRateLimitKey(request);
|
||||||
const result = checkRateLimit(key, 60, 60 * 1000); // 60 per minute for GET
|
const result = checkRateLimit(key, 120, 60 * 1000);
|
||||||
|
|
||||||
if (!result.allowed) {
|
if (!result.allowed) {
|
||||||
const rateLimitResponse = createRateLimitResponse(result);
|
const rateLimitResponse = createRateLimitResponse(result);
|
||||||
@@ -167,9 +205,11 @@ export async function GET(request: Request) {
|
|||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Auth me error:', error);
|
console.error('Auth GET error:', error);
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : 'Unknown error';
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Internal server error' },
|
{ error: 'Internal server error', detail: message },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-1
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useEffect } from 'react';
|
import React, { useEffect, useRef } from 'react';
|
||||||
import { useAppStore } from '@/lib/store';
|
import { useAppStore } from '@/lib/store';
|
||||||
import { fetchCurrentUser } from '@/lib/api';
|
import { fetchCurrentUser } from '@/lib/api';
|
||||||
import AuthView from '@/components/auth-view';
|
import AuthView from '@/components/auth-view';
|
||||||
@@ -10,8 +10,13 @@ export default function Home() {
|
|||||||
const currentUser = useAppStore((s) => s.currentUser);
|
const currentUser = useAppStore((s) => s.currentUser);
|
||||||
const isLoadingAuth = useAppStore((s) => s.isLoadingAuth);
|
const isLoadingAuth = useAppStore((s) => s.isLoadingAuth);
|
||||||
const setCurrentUser = useAppStore((s) => s.setCurrentUser);
|
const setCurrentUser = useAppStore((s) => s.setCurrentUser);
|
||||||
|
const hasCheckedRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Prevent duplicate auth checks: only run once per component lifecycle
|
||||||
|
if (hasCheckedRef.current) return;
|
||||||
|
hasCheckedRef.current = true;
|
||||||
|
|
||||||
async function checkAuth() {
|
async function checkAuth() {
|
||||||
try {
|
try {
|
||||||
const user = await fetchCurrentUser();
|
const user = await fetchCurrentUser();
|
||||||
|
|||||||
@@ -179,6 +179,9 @@ export function useServiceWorker() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
if (!('serviceWorker' in navigator)) return;
|
if (!('serviceWorker' in navigator)) return;
|
||||||
|
// Skip service worker registration during development to avoid stale
|
||||||
|
// Turbopack chunk caching ("module factory is not available" errors)
|
||||||
|
if (process.env.NODE_ENV === 'development') return;
|
||||||
|
|
||||||
async function registerSW() {
|
async function registerSW() {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -78,12 +78,9 @@ const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const cssText = Object.entries(THEMES)
|
||||||
<style
|
.map(
|
||||||
dangerouslySetInnerHTML={{
|
([theme, prefix]) => `
|
||||||
__html: Object.entries(THEMES)
|
|
||||||
.map(
|
|
||||||
([theme, prefix]) => `
|
|
||||||
${prefix} [data-chart=${id}] {
|
${prefix} [data-chart=${id}] {
|
||||||
${colorConfig
|
${colorConfig
|
||||||
.map(([key, itemConfig]) => {
|
.map(([key, itemConfig]) => {
|
||||||
@@ -95,11 +92,12 @@ ${colorConfig
|
|||||||
.join("\n")}
|
.join("\n")}
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
)
|
)
|
||||||
.join("\n"),
|
.join("\n")
|
||||||
}}
|
|
||||||
/>
|
// Use ref to set textContent instead of dangerouslySetInnerHTML to avoid
|
||||||
)
|
// React 19 "Encountered a script tag" warning for style elements
|
||||||
|
return <style ref={(el) => { if (el) el.textContent = cssText }} />
|
||||||
}
|
}
|
||||||
|
|
||||||
const ChartTooltip = RechartsPrimitive.Tooltip
|
const ChartTooltip = RechartsPrimitive.Tooltip
|
||||||
|
|||||||
+30
-10
@@ -3,8 +3,33 @@ import { cookies } from 'next/headers';
|
|||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
const SESSION_COOKIE_NAME = 'ct_session';
|
export const SESSION_COOKIE_NAME = 'ct_session';
|
||||||
const SESSION_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; // 7 days
|
export const SESSION_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; // 7 days
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the Secure flag should be set on cookies.
|
||||||
|
* Returns true when:
|
||||||
|
* - NODE_ENV is production, OR
|
||||||
|
* - the request came through an HTTPS proxy (x-forwarded-proto: https)
|
||||||
|
*/
|
||||||
|
export function isSecureRequest(request: Request): boolean {
|
||||||
|
if (process.env.NODE_ENV === 'production') return true;
|
||||||
|
const forwarded = request.headers.get('x-forwarded-proto');
|
||||||
|
return forwarded === 'https';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the cookie options object for the session cookie.
|
||||||
|
*/
|
||||||
|
export function getSessionCookieOptions(secure: boolean) {
|
||||||
|
return {
|
||||||
|
httpOnly: true,
|
||||||
|
secure,
|
||||||
|
sameSite: 'lax' as const,
|
||||||
|
maxAge: SESSION_COOKIE_MAX_AGE,
|
||||||
|
path: '/',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function hashPassword(password: string): Promise<string> {
|
export async function hashPassword(password: string): Promise<string> {
|
||||||
const salt = await bcrypt.genSalt(10);
|
const salt = await bcrypt.genSalt(10);
|
||||||
@@ -18,15 +43,10 @@ export async function verifyPassword(
|
|||||||
return bcrypt.compare(password, hash);
|
return bcrypt.compare(password, hash);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createSession(userId: string): Promise<void> {
|
export async function createSession(userId: string, secure?: boolean): Promise<void> {
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
cookieStore.set(SESSION_COOKIE_NAME, userId, {
|
const cookieSecure = secure ?? (process.env.NODE_ENV === 'production');
|
||||||
httpOnly: true,
|
cookieStore.set(SESSION_COOKIE_NAME, userId, getSessionCookieOptions(cookieSecure));
|
||||||
secure: process.env.NODE_ENV === 'production',
|
|
||||||
sameSite: 'lax',
|
|
||||||
maxAge: SESSION_COOKIE_MAX_AGE,
|
|
||||||
path: '/',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionUserSelect = Prisma.validator<Prisma.UserSelect>()({
|
const sessionUserSelect = Prisma.validator<Prisma.UserSelect>()({
|
||||||
|
|||||||
@@ -23,8 +23,11 @@ setInterval(() => {
|
|||||||
// ─── Preset Limits ────────────────────────────────────────────────────
|
// ─── Preset Limits ────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const RATE_LIMITS = {
|
export const RATE_LIMITS = {
|
||||||
// Auth endpoints: 20 requests per minute (allows retries & multiple tabs)
|
// Auth POST endpoints: 30 requests per minute (allows retries & multiple tabs)
|
||||||
auth: { limit: 20, windowMs: 60 * 1000 },
|
auth: { limit: 30, windowMs: 60 * 1000 },
|
||||||
|
|
||||||
|
// Auth GET endpoint: 120 requests per minute (high frequency, low cost)
|
||||||
|
authGet: { limit: 120, windowMs: 60 * 1000 },
|
||||||
|
|
||||||
// Data read endpoints: 60 requests per minute (generous)
|
// Data read endpoints: 60 requests per minute (generous)
|
||||||
dataRead: { limit: 60, windowMs: 60 * 1000 },
|
dataRead: { limit: 60, windowMs: 60 * 1000 },
|
||||||
|
|||||||
+15
-5
@@ -13,15 +13,25 @@ export function cn(...inputs: ClassValue[]) {
|
|||||||
export function sanitizeHtml(html: string): string {
|
export function sanitizeHtml(html: string): string {
|
||||||
if (!html) return '';
|
if (!html) return '';
|
||||||
return html
|
return html
|
||||||
// Remove <script> tags and their content
|
// Remove <script> tags and their content (handles multi-line, attributes, and unclosed tags)
|
||||||
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
|
.replace(/<script\b[\s\S]*?<\/script\s*>/gi, '')
|
||||||
|
// Remove unclosed <script> tags (no closing tag)
|
||||||
|
.replace(/<script\b[^>]*>[\s\S]*$/gi, '')
|
||||||
|
// Remove self-closing <script /> tags
|
||||||
|
.replace(/<script\b[^>]*\/>/gi, '')
|
||||||
// Remove on* event handler attributes (onclick, onload, onerror, etc.)
|
// Remove on* event handler attributes (onclick, onload, onerror, etc.)
|
||||||
.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, '')
|
.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, '')
|
||||||
// Remove javascript: URLs
|
// Remove javascript: URLs
|
||||||
.replace(/href\s*=\s*(?:"javascript:[^"]*"|'javascript:[^']*')/gi, 'href="#"')
|
.replace(/href\s*=\s*(?:"javascript:[^"]*"|'javascript:[^']*')/gi, 'href="#"')
|
||||||
// Remove <iframe> tags
|
// Remove <iframe> tags
|
||||||
.replace(/<iframe\b[^<]*(?:(?!<\/iframe>)<[^<]*)*<\/iframe>/gi, '')
|
.replace(/<iframe\b[\s\S]*?<\/iframe\s*>/gi, '')
|
||||||
// Remove <object> and <embed> tags
|
// Remove <object> and <embed> tags
|
||||||
.replace(/<object\b[^<]*(?:(?!<\/object>)<[^<]*)*<\/object>/gi, '')
|
.replace(/<object\b[\s\S]*?<\/object\s*>/gi, '')
|
||||||
.replace(/<embed\b[^>]*>/gi, '');
|
.replace(/<embed\b[^>]*>/gi, '')
|
||||||
|
// Remove <noscript> tags (may contain script-like content)
|
||||||
|
.replace(/<noscript\b[\s\S]*?<\/noscript\s*>/gi, '')
|
||||||
|
// Remove <link> tags (could load external scripts)
|
||||||
|
.replace(/<link\b[^>]*>/gi, '')
|
||||||
|
// Remove <meta> tags with http-equiv="refresh" (could redirect)
|
||||||
|
.replace(/<meta\b[^>]*http-equiv\s*=\s*["']?refresh["']?[^>]*>/gi, '');
|
||||||
}
|
}
|
||||||
+59
@@ -1,5 +1,34 @@
|
|||||||
# CompetenceTrack — Project Worklog
|
# CompetenceTrack — Project Worklog
|
||||||
|
|
||||||
|
---
|
||||||
|
Task ID: 1
|
||||||
|
Agent: fix-auth-500-and-session-issues
|
||||||
|
Task: Fix Auth 500 Errors and Session Persistence Issues
|
||||||
|
Date: 2025-01-28
|
||||||
|
|
||||||
|
Work Log:
|
||||||
|
- **Session persistence fix** (`src/lib/auth.ts` + `src/app/api/auth/route.ts`):
|
||||||
|
- Root cause: `cookies().set()` from `next/headers` may not reliably propagate the `Set-Cookie` header to the `NextResponse` body in Next.js App Router route handlers.
|
||||||
|
- Fix: Added `response.cookies.set()` on the `NextResponse` object for login, register, and logout actions, guaranteeing the cookie is sent.
|
||||||
|
- Added `isSecureRequest(request)` helper that checks `x-forwarded-proto: https` header (for HTTPS proxies like sslip.io) in addition to `NODE_ENV === 'production'` to determine the `Secure` cookie flag.
|
||||||
|
- Exported `SESSION_COOKIE_NAME`, `SESSION_COOKIE_MAX_AGE`, and `getSessionCookieOptions()` from `auth.ts` so the route handler can set cookies on the response with consistent options.
|
||||||
|
- `createSession()` now accepts an optional `secure` parameter.
|
||||||
|
|
||||||
|
- **Excessive GET /api/auth 401 polling fix** (`src/app/page.tsx`):
|
||||||
|
- Added a `useRef(false)` guard (`hasCheckedRef`) to the auth-check `useEffect` so it only fires once per component lifecycle, preventing duplicate or repeated calls.
|
||||||
|
|
||||||
|
- **Rate limit increase** (`src/lib/rate-limit.ts` + `src/app/api/auth/route.ts`):
|
||||||
|
- Auth POST rate limit: 20 → 30 req/min.
|
||||||
|
- Auth GET rate limit: 60 → 120 req/min (in both the `RATE_LIMITS.authGet` preset and the inline `checkRateLimit` call in the GET handler).
|
||||||
|
- Added `authGet` preset to `RATE_LIMITS` for future use.
|
||||||
|
|
||||||
|
- **Better error handling** (`src/app/api/auth/route.ts`):
|
||||||
|
- POST catch block now distinguishes Zod validation errors, Prisma/database errors, and generic errors, returning specific messages and status codes.
|
||||||
|
- GET catch block now includes the error message in the response body for debugging.
|
||||||
|
- Error logs use `Auth POST error:` and `Auth GET error:` prefixes for easier log filtering.
|
||||||
|
|
||||||
|
- Verification: `bun run lint` — 0 errors ✓
|
||||||
|
|
||||||
---
|
---
|
||||||
Task ID: 26
|
Task ID: 26
|
||||||
Agent: bug-fix-auth-500-script-tag-dynamic-import-rate-limit
|
Agent: bug-fix-auth-500-script-tag-dynamic-import-rate-limit
|
||||||
@@ -1208,3 +1237,33 @@ Work Log:
|
|||||||
- bun run lint: 0 errors
|
- bun run lint: 0 errors
|
||||||
- Dev server running without errors
|
- Dev server running without errors
|
||||||
|
|
||||||
|
---
|
||||||
|
Task ID: 2
|
||||||
|
Agent: fix-script-tag-warning-module-factory-error
|
||||||
|
Task: Fix Script Tag Warning and Module Factory Error
|
||||||
|
Date: 2025-01-27
|
||||||
|
|
||||||
|
Work Log:
|
||||||
|
- Fixed script tag warning in chart.tsx:
|
||||||
|
- Replaced `<style dangerouslySetInnerHTML={{...}}>` with `<style ref={(el) => { if (el) el.textContent = cssText }} />` in the ChartStyle component
|
||||||
|
- This avoids React 19's "Encountered a script tag" warning that is triggered for style elements using dangerouslySetInnerHTML
|
||||||
|
- The ref-based approach sets textContent synchronously during the commit phase, so no FOUC occurs
|
||||||
|
- Improved sanitizeHtml function in utils.ts:
|
||||||
|
- Replaced the fragile `[^<]*` regex pattern with `[\s\S]*?` (non-greedy, matches any character including newlines)
|
||||||
|
- Added handling for unclosed `<script>` tags (no closing tag)
|
||||||
|
- Added handling for self-closing `<script />` tags
|
||||||
|
- Added removal of `<noscript>` tags (may contain script-like content)
|
||||||
|
- Added removal of `<link>` tags (could load external scripts)
|
||||||
|
- Added removal of `<meta>` tags with http-equiv="refresh" (could redirect)
|
||||||
|
- Updated iframe, object regex patterns to use `[\s\S]*?` for multi-line support
|
||||||
|
- Fixed module factory error in service worker:
|
||||||
|
- Bumped cache version from v2 to v3 to force cache invalidation
|
||||||
|
- Added a dedicated `/_next/` path check that uses NetworkOnly strategy (never caches)
|
||||||
|
- This prevents stale Turbopack chunks from being served, which cause "module factory is not available" errors
|
||||||
|
- Comment explains why /_next/ paths must never be cached
|
||||||
|
- Updated offline-indicator.tsx useServiceWorker hook:
|
||||||
|
- Added `process.env.NODE_ENV === 'development'` check to skip service worker registration during development
|
||||||
|
- This prevents the service worker from interfering with Turbopack's hot module replacement
|
||||||
|
- Service worker is only active in production builds
|
||||||
|
- Verification: bun run lint passes with 0 errors
|
||||||
|
|
||||||
Reference in new issue
Block a user