cookie state update

This commit is contained in:
Nate Kelley 2025-07-11 15:16:40 -06:00
parent 90a11d5fd8
commit f7a7ab7e7a
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
6 changed files with 247 additions and 45 deletions

View File

@ -17,6 +17,7 @@ import { Splitter } from './Splitter';
import { AppSplitterProvider } from './AppSplitterProvider';
import { sizeToPixels, easeInOutCubic, createAutoSaveId } from './helpers';
import { useMemoizedFn } from '@/hooks';
import { useMount } from '@/hooks/useMount';
interface IAppSplitterProps {
leftChildren: React.ReactNode;
@ -59,7 +60,6 @@ interface SplitterState {
containerSize: number;
isDragging: boolean;
isAnimating: boolean;
isInitialized: boolean;
sizeSetByAnimation: boolean;
hasUserInteracted: boolean;
}
@ -68,20 +68,28 @@ const AppSplitterWrapper = forwardRef<AppSplitterRef, IAppSplitterProps>(
({ autoSaveId, style, className, split = 'vertical', ...props }, componentRef) => {
const containerRef = useRef<HTMLDivElement>(null);
const isVertical = split === 'vertical';
const [mounted, setMounted] = useState(false);
const splitterAutoSaveId = createAutoSaveId(autoSaveId);
useMount(() => {
setMounted(true);
});
return (
<div
ref={containerRef}
className={cn('swag1 flex h-full w-full', isVertical ? 'flex-row' : 'flex-col', className)}
id={splitterAutoSaveId}
className={cn('flex h-full w-full', isVertical ? 'flex-row' : 'flex-col', className)}
style={style}>
<AppSplitterBase
{...props}
ref={componentRef}
isVertical={isVertical}
containerRef={containerRef}
splitterAutoSaveId={splitterAutoSaveId}
/>
{mounted && (
<AppSplitterBase
{...props}
ref={componentRef}
isVertical={isVertical}
containerRef={containerRef}
splitterAutoSaveId={splitterAutoSaveId}
/>
)}
</div>
);
}
@ -132,7 +140,6 @@ const AppSplitterBase = forwardRef<
const bustStorageOnInitSplitter = (preservedSideValue: number | null) => {
const refWidth = containerRef.current?.offsetWidth;
console.log('bustStorageOnInitSplitter', splitterAutoSaveId, refWidth);
// Don't bust storage if container hasn't been sized yet
if (!refWidth || refWidth === 0) return false;
return typeof bustStorageOnInit === 'function'
@ -151,7 +158,6 @@ const AppSplitterBase = forwardRef<
containerSize: containerRef.current?.offsetWidth ?? 0,
isDragging: false,
isAnimating: false,
isInitialized: false,
sizeSetByAnimation: false,
hasUserInteracted: false
});
@ -232,16 +238,10 @@ const AppSplitterBase = forwardRef<
// Calculate panel sizes with simplified logic
const { leftSize, rightSize } = useMemo(() => {
const {
containerSize,
isInitialized,
isAnimating,
sizeSetByAnimation,
isDragging,
hasUserInteracted
} = state;
const { containerSize, isAnimating, sizeSetByAnimation, isDragging, hasUserInteracted } =
state;
if (!containerSize || !isInitialized) {
if (!containerSize) {
return { leftSize: 0, rightSize: 0 };
}
@ -302,9 +302,7 @@ const AppSplitterBase = forwardRef<
const newState = { ...prev, containerSize: size };
// Initialize if needed - only when container has actual size
if (!prev.isInitialized && !prev.isAnimating && size > 0) {
newState.isInitialized = true;
if (!prev.isAnimating && size > 0) {
// Set initial size if no saved layout exists
if (savedLayout === null || savedLayout === undefined) {
const initialSize = calculateInitialSize(size);
@ -314,13 +312,7 @@ const AppSplitterBase = forwardRef<
// Handle container resize when one panel is at 0px
// Only adjust layout during resize if we're not currently animating
if (
prev.isInitialized &&
prev.containerSize > 0 &&
size > 0 &&
savedLayout !== null &&
!prev.isAnimating
) {
if (prev.containerSize > 0 && size > 0 && savedLayout !== null && !prev.isAnimating) {
const currentSavedSize = savedLayout;
// If a panel is at 0px, preserve the other panel's size during resize
@ -570,7 +562,6 @@ const AppSplitterBase = forwardRef<
hidden={leftHidden}>
{renderLeftPanel && leftChildren}
</Panel>
{showSplitter && (
<Splitter
onMouseDown={handleMouseDown}
@ -581,7 +572,6 @@ const AppSplitterBase = forwardRef<
hidden={shouldHideSplitter}
/>
)}
<Panel
className={rightPanelClassName}
width={isVertical ? rightSize : 'auto'}

View File

@ -1,4 +1,4 @@
export const createAutoSaveId = (id: string) => `splitter-${id}`;
export const createAutoSaveId = (id: string) => `app-splitter-${id}`;
// Helper function to convert size values to pixels
export const sizeToPixels = (size: string | number, containerSize: number): number => {

View File

@ -0,0 +1,203 @@
'use client';
import { useState, useEffect } from 'react';
import { useMemoizedFn } from './useMemoizedFn';
import { useMount } from './useMount';
import { isServer } from '@tanstack/react-query';
type SetState<S> = S | ((prevState?: S) => S);
// Default expiration time: 7 days in milliseconds
const DEFAULT_EXPIRATION_TIME = 7 * 24 * 60 * 60 * 1000;
interface StorageData<T> {
value: T;
timestamp: number;
}
interface CookieOptions {
domain?: string;
path?: string;
secure?: boolean;
sameSite?: 'strict' | 'lax' | 'none';
}
interface Options<T> {
defaultValue?: T | (() => T);
serializer?: (value: T) => string;
deserializer?: (value: string) => T;
onError?: (error: unknown) => void;
bustStorageOnInit?: boolean | ((layout: T) => boolean);
expirationTime?: number;
cookieOptions?: CookieOptions;
}
// Helper function to parse cookies
const parseCookies = (): Record<string, string> => {
if (isServer) return {};
return document.cookie.split(';').reduce(
(cookies, cookie) => {
const [name, value] = cookie.trim().split('=');
if (name && value) {
cookies[name] = decodeURIComponent(value);
}
return cookies;
},
{} as Record<string, string>
);
};
// Helper function to set a cookie
const setCookie = (
name: string,
value: string,
expirationTime: number,
options: CookieOptions = {}
): void => {
if (isServer) return;
const expires = new Date(Date.now() + expirationTime).toUTCString();
const { domain, path = '/', secure = true, sameSite = 'lax' } = options;
let cookieString = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=${path}; SameSite=${sameSite}`;
if (secure) {
cookieString += '; Secure';
}
if (domain) {
cookieString += `; Domain=${domain}`;
}
document.cookie = cookieString;
};
// Helper function to remove a cookie
const removeCookie = (name: string, options: CookieOptions = {}): void => {
if (isServer) return;
const { domain, path = '/' } = options;
let cookieString = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=${path}`;
if (domain) {
cookieString += `; Domain=${domain}`;
}
document.cookie = cookieString;
};
export function useCookieState<T>(
key: string,
options?: Options<T>
): [T | undefined, (value?: SetState<T>) => void, () => T | undefined] {
const {
defaultValue,
serializer = JSON.stringify,
deserializer = JSON.parse,
onError,
bustStorageOnInit = false,
expirationTime = DEFAULT_EXPIRATION_TIME,
cookieOptions = {}
} = options || {};
const executeBustStorage = useMemoizedFn(() => {
if (!isServer) removeCookie(key, cookieOptions);
return typeof defaultValue === 'function' ? (defaultValue as () => T)() : defaultValue;
});
// Get initial value from cookies or use default
const getInitialValue = useMemoizedFn((): T | undefined => {
// If bustStorageOnInit is true, ignore cookies and use default value
if (bustStorageOnInit === true) {
return executeBustStorage();
}
try {
const cookies = parseCookies();
const cookieValue = cookies[key];
if (!cookieValue) {
return executeBustStorage();
}
// Parse the stored data which includes value and timestamp
const storageData: StorageData<T> = JSON.parse(cookieValue);
// Check if the stored data has the expected structure
if (
typeof storageData !== 'object' ||
storageData === null ||
!('value' in storageData) ||
!('timestamp' in storageData)
) {
// If the data doesn't have the expected structure (legacy data), treat as expired
return executeBustStorage();
}
// Check if the data has expired
const currentTime = Date.now();
const timeDifference = currentTime - storageData.timestamp;
if (timeDifference > expirationTime) {
// Data has expired, remove it and return default value
return executeBustStorage();
}
// Data is still valid, deserialize and return the value
const deserializedValue = deserializer(JSON.stringify(storageData.value));
if (typeof bustStorageOnInit === 'function' && bustStorageOnInit(deserializedValue)) {
return executeBustStorage();
}
return deserializedValue;
} catch (error) {
onError?.(error);
return executeBustStorage();
}
});
const [state, setState] = useState<T | undefined>(() => getInitialValue());
// Initialize state from cookies on mount
useMount(() => {
setState(getInitialValue());
});
// Update cookies when state changes
useEffect(() => {
try {
if (state === undefined && !isServer) {
removeCookie(key, cookieOptions);
} else {
// Create storage data with current timestamp
const storageData: StorageData<T> = {
value: JSON.parse(serializer(state)),
timestamp: Date.now()
};
setCookie(key, JSON.stringify(storageData), expirationTime, cookieOptions);
}
} catch (error) {
onError?.(error);
}
}, [key, state, serializer, onError, expirationTime, cookieOptions]);
// Setter function that handles both direct values and function updates
const setStoredState = useMemoizedFn((value?: SetState<T>) => {
try {
if (typeof value === 'function') {
setState((prevState) => {
const newState = (value as (prevState?: T) => T)(prevState);
return newState;
});
} else {
setState(value);
}
} catch (error) {
onError?.(error);
}
});
return [state, setStoredState, executeBustStorage];
}

View File

@ -38,6 +38,11 @@ export function useLocalStorageState<T>(
} = options || {};
const executeBustStorage = useMemoizedFn(() => {
console.log(
'***executeBustStorage',
key,
typeof defaultValue === 'function' ? (defaultValue as () => T)() : defaultValue
);
if (!isServer) window.localStorage.removeItem(key);
return typeof defaultValue === 'function' ? (defaultValue as () => T)() : defaultValue;
});
@ -92,7 +97,7 @@ export function useLocalStorageState<T>(
}
});
const [state, setState] = useState<T | undefined>(() => getInitialValue());
const [state, setState] = useState<T | undefined>(getInitialValue);
// Initialize state from localStorage on mount
useMount(() => {

View File

@ -4,6 +4,7 @@ import { ChatContent } from './ChatContent';
import { ChatHeader } from './ChatHeader';
export const ChatContainer = React.memo(({ mounted }: { mounted?: boolean }) => {
console.log('ChatContainer', mounted);
return (
<AppPageLayout
headerSizeVariant="default"

View File

@ -20,7 +20,6 @@ interface ChatSplitterProps {
export const ChatLayout: React.FC<ChatSplitterProps> = ({ children }) => {
const appSplitterRef = useRef<AppSplitterRef>(null);
const [mounted, setMounted] = useState(false);
const chatLayoutProps = useChatLayoutContext({ appSplitterRef });
const { selectedLayout, selectedFile } = chatLayoutProps;
@ -39,25 +38,29 @@ export const ChatLayout: React.FC<ChatSplitterProps> = ({ children }) => {
const renderLeftPanel = selectedLayout !== 'file-only';
const renderRightPanel = selectedLayout !== 'chat-only';
const secondaryFileView = chatLayoutProps.secondaryView;
const mounted = true;
const bustStorageOnInit = (preservedSideValue: number | null) => {
console.log('bustStorageOnInit', autoSaveId, preservedSideValue, {
selectedLayout,
secondaryFileView
});
const bustStorageOnInit = (preservedSideValue: number | null, containerSize: number) => {
console.log(
selectedLayout === 'chat-only' || selectedLayout === 'file-only' || !!secondaryFileView,
'bustStorageOnInit',
autoSaveId,
preservedSideValue,
{
selectedLayout,
secondaryFileView,
containerSize
}
);
return selectedLayout === 'chat-only' || selectedLayout === 'file-only' || !!secondaryFileView;
};
useMount(() => {
setMounted(true); //we need to wait for the app splitter to be mounted because this is nested in the app splitter
});
return (
<ChatLayoutContextProvider chatLayoutProps={chatLayoutProps}>
<ChatContextProvider>
<AppSplitter
ref={appSplitterRef}
leftChildren={useMemo(() => mounted && <ChatContainer mounted={mounted} />, [mounted])}
leftChildren={<div>HUH?</div>}
rightChildren={useMemo(
() => mounted && <FileContainer>{children}</FileContainer>,
[children, mounted]