diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index bda18a9..3706a57 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,8 +1,8 @@ {"id":"light-session-16d","title":"Implement code review recommendations","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-08T15:51:21.70403199+03:00","updated_at":"2026-01-08T15:54:39.953267761+03:00","closed_at":"2026-01-08T15:54:39.953267761+03:00","close_reason":"Closed"} {"id":"light-session-2hd","title":"Test cache + navigation trigger for load more feature","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-09T00:46:45.660702419+03:00","updated_at":"2026-01-09T10:45:29.927755814+03:00","closed_at":"2026-01-09T10:45:29.927759601+03:00"} -{"id":"light-session-3qq","title":"Set up CI for Chrome Web Store publishing","description":"Configure GitHub Actions workflow to build and publish extension to Chrome Web Store on release","status":"in_progress","priority":2,"issue_type":"task","created_at":"2026-01-09T22:58:36.99477884+03:00","created_by":"mayor","updated_at":"2026-01-09T23:01:15.306260186+03:00"} +{"id":"light-session-3qq","title":"Set up CI for Chrome Web Store publishing","description":"Configure GitHub Actions workflow to build and publish extension to Chrome Web Store on release","status":"in_progress","priority":2,"issue_type":"task","created_at":"2026-01-09T22:58:36.99477884+03:00","updated_at":"2026-01-09T23:01:15.306260186+03:00","created_by":"mayor"} {"id":"light-session-4ec","title":"Fix off-by-one error in turn counting - trimMapping","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-07T21:21:25.453075056+03:00","updated_at":"2026-01-07T21:23:59.887784624+03:00","closed_at":"2026-01-07T21:23:59.887784624+03:00","close_reason":"Closed"} -{"id":"light-session-67h","title":"Bug: trimming not working on page reload with extension enabled","description":"Settings show keep=5 but more messages visible. Race condition or localStorage sync issue.","status":"in_progress","priority":1,"issue_type":"task","created_at":"2026-01-09T22:39:11.800084105+03:00","created_by":"mayor","updated_at":"2026-01-09T22:40:24.864320247+03:00"} +{"id":"light-session-67h","title":"Bug: trimming not working on page reload with extension enabled","description":"Settings show keep=5 but more messages visible. Race condition or localStorage sync issue.","status":"in_progress","priority":1,"issue_type":"task","created_at":"2026-01-09T22:39:11.800084105+03:00","updated_at":"2026-01-09T22:40:24.864320247+03:00","created_by":"mayor"} {"id":"light-session-6sc","title":"Implement code review improvements","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-07T22:59:50.319980052+03:00","updated_at":"2026-01-07T23:06:39.948042561+03:00","closed_at":"2026-01-07T23:06:39.948042561+03:00","close_reason":"Closed"} {"id":"light-session-7sq","title":"Rename 'turns' to 'messages' in UI","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-07T21:50:55.111515332+03:00","updated_at":"2026-01-07T21:53:42.151552035+03:00","closed_at":"2026-01-07T21:53:42.151552035+03:00","close_reason":"Closed"} {"id":"light-session-8bq","title":"Fix 'Body has already been consumed' error in fetch interceptor","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-07T22:06:28.74686929+03:00","updated_at":"2026-01-07T22:07:29.911516018+03:00","closed_at":"2026-01-07T22:07:29.911516018+03:00","close_reason":"Fixed by extracting URL/method before nativeFetch"} @@ -11,4 +11,4 @@ {"id":"light-session-hqc","title":"Fix Firefox Xray vision bug - cloneInto for CustomEvent detail","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-07T21:09:56.751185775+03:00","updated_at":"2026-01-07T21:12:56.968676465+03:00","closed_at":"2026-01-07T21:12:56.968676465+03:00","close_reason":"Closed"} {"id":"light-session-kf1","title":"Fix ESLint error for cloneInto Firefox API","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-08T19:23:38.808971016+03:00","updated_at":"2026-01-08T19:24:30.315635209+03:00","closed_at":"2026-01-08T19:24:30.315635209+03:00","close_reason":"Closed"} {"id":"light-session-oho","title":"Fix: empty user nodes counted but not rendered","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-07T22:21:42.141200219+03:00","updated_at":"2026-01-07T22:44:23.70359928+03:00","closed_at":"2026-01-07T22:44:23.70359928+03:00","close_reason":"Fixed: preserve original root node as tree anchor for ChatGPT"} -{"id":"light-session-q3p","title":"Fix race condition: sync settings to localStorage for page-script","status":"in_progress","priority":1,"issue_type":"task","created_at":"2026-01-09T22:28:10.93947054+03:00","created_by":"mayor","updated_at":"2026-01-09T22:28:17.158771046+03:00"} +{"id":"light-session-q3p","title":"Fix race condition: sync settings to localStorage for page-script","status":"in_progress","priority":1,"issue_type":"task","created_at":"2026-01-09T22:28:10.93947054+03:00","updated_at":"2026-01-09T22:28:17.158771046+03:00","created_by":"mayor"} diff --git a/extension/manifest.chrome.json b/extension/manifest.chrome.json index 9531be1..1a7ef51 100644 --- a/extension/manifest.chrome.json +++ b/extension/manifest.chrome.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "LightSession Pro for ChatGPT", - "version": "1.6.1", + "version": "1.6.2", "description": "Keep ChatGPT fast by keeping only the last N messages in the DOM. Local-only.", "icons": { "16": "icons/icon-16.png", diff --git a/extension/manifest.firefox.json b/extension/manifest.firefox.json index 1089414..39e8035 100644 --- a/extension/manifest.firefox.json +++ b/extension/manifest.firefox.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "LightSession Pro for ChatGPT", - "version": "1.6.1", + "version": "1.6.2", "description": "Keep ChatGPT fast by keeping only the last N messages in the DOM. Local-only.", "icons": { "16": "icons/icon-16.png", diff --git a/extension/src/content/content.ts b/extension/src/content/content.ts index acf1a7c..a017084 100644 --- a/extension/src/content/content.ts +++ b/extension/src/content/content.ts @@ -11,7 +11,7 @@ import browser from '../shared/browser-polyfill'; import type { LsSettings, TrimStatus } from '../shared/types'; -import { loadSettings, validateSettings } from '../shared/storage'; +import { loadSettings, validateSettings, syncToLocalStorage } from '../shared/storage'; import { TIMING } from '../shared/constants'; import { setDebugMode, logDebug, logInfo, logWarn, logError } from '../shared/logger'; import { @@ -199,6 +199,10 @@ function handleStorageChange( // Validate settings to ensure proper types and ranges const newSettings = validateSettings(changes.ls_settings.newValue as Partial); logInfo('Settings changed via storage:', newSettings); + + // Sync to localStorage for page-script access (Chrome MV3 workaround) + syncToLocalStorage(newSettings); + applySettings(newSettings); } @@ -296,6 +300,9 @@ async function initialize(): Promise { const settings = await loadSettings(); logInfo('Loaded settings:', settings); + // Sync to localStorage for page-script access (Chrome MV3 workaround) + syncToLocalStorage(settings); + // Apply settings applySettings(settings); diff --git a/extension/src/content/page-inject.ts b/extension/src/content/page-inject.ts index c06c735..0f4ad4e 100644 --- a/extension/src/content/page-inject.ts +++ b/extension/src/content/page-inject.ts @@ -15,24 +15,28 @@ const STORAGE_KEY = 'ls_settings'; const LOCAL_STORAGE_KEY = 'ls_config'; /** - * Sync settings from browser.storage to localStorage. - * This runs BEFORE page-script injection to ensure localStorage has correct data. + * Sync settings from browser.storage to localStorage AND dispatch CustomEvent. + * Runs in parallel with page-script injection. The CustomEvent signals config ready + * to page-script, allowing it to gate first fetch until config is available. */ async function syncSettingsToLocalStorage(): Promise { try { const result = await browser.storage.local.get(STORAGE_KEY); const stored = result[STORAGE_KEY] as { enabled?: boolean; keep?: number; debug?: boolean } | undefined; - + if (stored) { const config = { enabled: stored.enabled ?? true, limit: stored.keep ?? 10, debug: stored.debug ?? false, }; + // Write to localStorage for page-script access localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(config)); + // Dispatch event immediately - faster than waiting for content.ts (document_idle) + window.dispatchEvent(new CustomEvent('lightsession-config', { detail: JSON.stringify(config) })); } } catch { - // Storage access failed - page-script will use defaults or existing localStorage + // Storage access failed - page-script will use defaults after timeout } } @@ -57,8 +61,9 @@ function injectPageScript(): void { } // Main execution: -// 1. Start syncing settings (async, but fast) -// 2. Inject page script immediately (can't wait - need to patch fetch early) -// The sync will complete and update localStorage, which page-script checks on each fetch. -void syncSettingsToLocalStorage(); +// 1. Inject page script IMMEDIATELY to patch fetch before ChatGPT's code runs +// 2. Sync localStorage in parallel (best effort for first fetch) +// 3. content.ts will dispatch config via CustomEvent as fallback +// Priority is early patching - page-script uses defaults if localStorage not ready. injectPageScript(); +void syncSettingsToLocalStorage(); diff --git a/extension/src/page/page-script.ts b/extension/src/page/page-script.ts index f7c129a..344d271 100644 --- a/extension/src/page/page-script.ts +++ b/extension/src/page/page-script.ts @@ -48,6 +48,48 @@ const DEFAULT_CONFIG: LsConfig = { debug: false, }; +// ============================================================================ +// Config Ready Gating +// ============================================================================ + +/** + * Promise that resolves when config is ready (from localStorage or CustomEvent). + * First fetch waits on this to ensure correct config is used. + */ +let resolveConfigReady: (() => void) | null = null; +const configReady = new Promise((resolve) => { + resolveConfigReady = resolve; +}); + +/** + * Resolve the configReady promise (idempotent - only resolves once). + */ +function tryResolveConfigReady(): void { + if (resolveConfigReady) { + resolveConfigReady(); + resolveConfigReady = null; + } +} + +/** + * Wait for config to be ready with timeout. + * Returns immediately if config already loaded. + * After timeout, marks config as ready to avoid repeated delays on subsequent fetches. + * @param timeoutMs Max time to wait (default 50ms) + */ +async function ensureConfigReady(timeoutMs = 50): Promise { + if (!resolveConfigReady) { + // Already resolved + return; + } + await Promise.race([ + configReady, + new Promise((resolve) => setTimeout(resolve, timeoutMs)), + ]); + // After timeout (or config arrived), mark as ready so subsequent fetches don't wait + tryResolveConfigReady(); +} + /** * localStorage key - must match storage.ts LOCAL_STORAGE_KEY */ @@ -197,13 +239,6 @@ async function interceptedFetch( nativeFetch: typeof fetch, ...args: Parameters ): Promise { - const cfg = getConfig(); - - // Skip if disabled - if (!cfg.enabled) { - return nativeFetch(...args); - } - // Extract URL/method BEFORE fetching (handles string, URL, Request) // This avoids "Body has already been consumed" error when args[0] is a Request const [input, init] = args; @@ -223,11 +258,21 @@ async function interceptedFetch( const url = new URL(urlString, location.href); - // Early return for non-matching requests (before fetching) + // Early return for non-matching requests - no config wait needed if (!isConversationRequest(method, url)) { return nativeFetch(...args); } + // Wait for config only for ChatGPT API requests (max 50ms on first request) + await ensureConfigReady(); + + const cfg = getConfig(); + + // Skip if disabled + if (!cfg.enabled) { + return nativeFetch(...args); + } + // Fetch and process matching requests const res = await nativeFetch(...args); @@ -355,6 +400,9 @@ function setupConfigListener(): void { debug: config.debug ?? DEFAULT_CONFIG.debug, }; log('Config updated:', window.__LS_CONFIG__); + + // Signal that config is ready (unblocks first fetch) + tryResolveConfigReady(); } }) as EventListener); } @@ -369,6 +417,14 @@ function setupConfigListener(): void { window.__LS_DEBUG__ = false; } + // Check localStorage first - if already synced by page-inject, resolve immediately + const stored = loadFromLocalStorage(); + if (stored) { + window.__LS_CONFIG__ = stored; + window.__LS_DEBUG__ = stored.debug; + tryResolveConfigReady(); + } + setupConfigListener(); patchFetch(); diff --git a/extension/src/shared/messages.ts b/extension/src/shared/messages.ts index a6cac9b..6e558f7 100644 --- a/extension/src/shared/messages.ts +++ b/extension/src/shared/messages.ts @@ -4,7 +4,7 @@ */ import browser from './browser-polyfill'; -import type { RuntimeMessage, RuntimeResponse } from './types'; +import type { RuntimeMessage, RuntimeResponse, ErrorResponse } from './types'; import { TIMING } from './constants'; import { logError } from './logger'; @@ -18,7 +18,11 @@ export async function sendMessageWithTimeout( message: RuntimeMessage, timeoutMs: number = TIMING.MESSAGE_TIMEOUT_MS ): Promise { - const isChrome = typeof chrome !== 'undefined' && typeof browser === 'undefined'; + // Detect Chrome vs Firefox using feature detection + // Firefox has browser.runtime.getBrowserInfo() which Chrome doesn't have + // This is more reliable than checking global objects which can be polyfilled + const isFirefox = typeof browser.runtime.getBrowserInfo === 'function'; + const isChrome = !isFirefox && typeof chrome !== 'undefined' && !!chrome.runtime; const retryDelays = TIMING.MESSAGE_RETRY_DELAYS_MS; let lastError: Error | undefined; @@ -33,11 +37,8 @@ export async function sendMessageWithTimeout( ]); // Check Chrome lastError (set when no listener exists) - if (isChrome) { - const chromeLastError = (chrome as { runtime: { lastError?: { message?: string } } }).runtime.lastError; - if (chromeLastError) { - throw new Error(chromeLastError.message ?? 'Chrome runtime error'); - } + if (isChrome && chrome.runtime.lastError) { + throw new Error(chrome.runtime.lastError.message ?? 'Chrome runtime error'); } // Validate response is not undefined (Chrome returns undefined if service worker inactive) @@ -50,12 +51,17 @@ export async function sendMessageWithTimeout( throw new Error('Service worker not responding - received undefined after retries'); } + // Check for error response from handler (don't retry real errors) + if ('error' in response && typeof response.error === 'string') { + throw new Error(`Handler error: ${response.error}`); + } + return response; } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)); - // Don't retry on timeout - it's already waited long enough - if (lastError.message === 'Message timeout') { + // Don't retry on timeout or handler errors - these are real failures + if (lastError.message === 'Message timeout' || lastError.message.startsWith('Handler error:')) { throw lastError; } @@ -115,8 +121,10 @@ export function createMessageHandler( sendResponse(response); } catch (error) { logError('Message handler error:', error); - // Send error response so caller doesn't hang - sendResponse({ error: String(error) } as unknown as RuntimeResponse); + // Send error response so caller doesn't hang waiting for response + // Caller must check for error field in response + const errorResponse: ErrorResponse = { error: String(error) }; + sendResponse(errorResponse); } })(); diff --git a/extension/src/shared/storage.ts b/extension/src/shared/storage.ts index 882c4c2..f8ee1ff 100644 --- a/extension/src/shared/storage.ts +++ b/extension/src/shared/storage.ts @@ -41,6 +41,12 @@ export function validateSettings(input: Partial): LsSettings { * This eliminates race conditions on page load. */ export function syncToLocalStorage(settings: LsSettings): void { + // Guard: localStorage unavailable in service worker (Chrome MV3) + if (typeof localStorage === 'undefined') { + logDebug('localStorage not available (service worker context)'); + return; + } + try { const config = { enabled: settings.enabled, @@ -67,21 +73,15 @@ export async function loadSettings(): Promise { if (stored) { logDebug('Loaded settings from storage:', stored); - const validated = validateSettings(stored); - syncToLocalStorage(validated); - return validated; + return validateSettings(stored); } // No stored settings, return defaults logDebug('No stored settings found, using defaults'); - const defaults = validateSettings({}); - syncToLocalStorage(defaults); - return defaults; + return validateSettings({}); } catch (error) { logError('Failed to load settings:', error); - const defaults = validateSettings({}); - syncToLocalStorage(defaults); - return defaults; + return validateSettings({}); } } @@ -99,7 +99,6 @@ export async function updateSettings(updates: Partial { if (!result[STORAGE_KEY]) { await browser.storage.local.set({ [STORAGE_KEY]: DEFAULT_SETTINGS }); - syncToLocalStorage(DEFAULT_SETTINGS); logDebug('Initialized default settings'); } } catch (error) { diff --git a/extension/src/shared/types.ts b/extension/src/shared/types.ts index 2f04848..e8f27c8 100644 --- a/extension/src/shared/types.ts +++ b/extension/src/shared/types.ts @@ -79,6 +79,13 @@ export interface PongMessage { timestamp: number; } +/** + * Error response from message handler + */ +export interface ErrorResponse { + error: string; +} + /** * Union of all runtime messages */ @@ -87,4 +94,4 @@ export type RuntimeMessage = GetSettingsMessage | SetSettingsMessage | PingMessa /** * Union of all runtime responses */ -export type RuntimeResponse = GetSettingsResponse | SetSettingsResponse | PongMessage; +export type RuntimeResponse = GetSettingsResponse | SetSettingsResponse | PongMessage | ErrorResponse; diff --git a/package.json b/package.json index bd81009..5689f0b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "light-session", - "version": "1.6.1", + "version": "1.6.2", "type": "module", "description": "LightSession Pro - Browser extension to optimize ChatGPT performance", "engines": {