Fixed a race condition where the state machine would navigate to /chat before initializing from the URL, causing direct navigation to /galaxy URLs to redirect. **The Problem:** 1. Component mounts, state machine starts in 'convo' state (default) 2. State-to-URL effect fires: "state is convo → navigate to /chat" 3. URL-to-state initialization fires: "we're on /galaxy → NAVIGATE_TO_GALAXY" 4. State transitions to 'galaxy' 5. State-to-URL effect fires again: "state is galaxy → navigate to /galaxy" This caused a brief redirect to /chat before settling on /galaxy. **The Solution:** - Don't mark as initialized immediately after sending the initial event - Add a second effect that watches for state to match the URL - Only mark as initialized once state matches the target state for the URL - This prevents the state-to-URL effect from running before initialization **Changes:** - Modified URL-to-state initialization to not mark as initialized immediately - Added new effect to mark as initialized once state matches URL target - Added console log: "State initialized from URL, marking as ready" **Testing:** Verified with Playwright MCP that navigating directly to /galaxy?node=xxx no longer redirects to /chat. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
130 lines
4.3 KiB
TypeScript
130 lines
4.3 KiB
TypeScript
'use client';
|
|
|
|
/**
|
|
* AppStateMachine Provider
|
|
*
|
|
* Wraps the application with the app-level state machine.
|
|
* Provides state and send function to all child components via context.
|
|
* Also handles responsive mode detection and route synchronization.
|
|
*/
|
|
|
|
import { useEffect, useRef } from 'react';
|
|
import { useSelector } from '@xstate/react';
|
|
import { createActor } from 'xstate';
|
|
import { usePathname, useRouter } from 'next/navigation';
|
|
import { useMediaQuery } from '@mantine/hooks';
|
|
import { appMachine } from '@/lib/app-machine';
|
|
import { AppMachineContext } from '@/hooks/useAppMachine';
|
|
|
|
// Create the actor singleton outside the component to persist state
|
|
const appActor = createActor(appMachine);
|
|
appActor.start();
|
|
|
|
export function AppStateMachineProvider({ children }: { children: React.ReactNode }) {
|
|
const state = useSelector(appActor, (state) => state);
|
|
const send = appActor.send;
|
|
const pathname = usePathname();
|
|
const router = useRouter();
|
|
|
|
// Track if this is the initial mount
|
|
const isInitializedRef = useRef(false);
|
|
// Track the last path we navigated to, to prevent loops
|
|
const lastNavigatedPathRef = useRef<string | null>(null);
|
|
|
|
// Detect mobile vs desktop
|
|
const isMobile = useMediaQuery('(max-width: 768px)');
|
|
|
|
// Update mode in state machine
|
|
useEffect(() => {
|
|
send({ type: 'SET_MODE', mode: isMobile ? 'mobile' : 'desktop' });
|
|
}, [isMobile, send]);
|
|
|
|
// Initialize state machine from URL on first mount ONLY
|
|
useEffect(() => {
|
|
if (isInitializedRef.current) return;
|
|
|
|
console.log('[App Provider] Initializing state from URL:', pathname);
|
|
|
|
// Determine which state the current path corresponds to
|
|
let initialEvent: string | null = null;
|
|
|
|
if (pathname === '/chat') {
|
|
initialEvent = 'NAVIGATE_TO_CONVO';
|
|
} else if (pathname === '/edit') {
|
|
initialEvent = 'NAVIGATE_TO_EDIT';
|
|
} else if (pathname === '/galaxy' || pathname.startsWith('/galaxy/')) {
|
|
initialEvent = 'NAVIGATE_TO_GALAXY';
|
|
}
|
|
|
|
// Send the event to initialize state from URL
|
|
if (initialEvent) {
|
|
console.log('[App Provider] Setting initial state:', initialEvent);
|
|
send({ type: initialEvent as any });
|
|
} else {
|
|
// No specific route matched, we're probably on homepage or other route
|
|
// Mark as initialized immediately so we don't interfere
|
|
isInitializedRef.current = true;
|
|
}
|
|
|
|
// DON'T mark as initialized yet if we sent an event
|
|
// Wait for the state change to complete
|
|
}, [pathname, send]); // Remove 'state' from dependencies!
|
|
|
|
// Mark as initialized once state matches the target from URL
|
|
useEffect(() => {
|
|
if (isInitializedRef.current) return;
|
|
|
|
const targetStateForPath =
|
|
pathname === '/chat' ? 'convo' :
|
|
pathname === '/edit' ? 'edit' :
|
|
(pathname === '/galaxy' || pathname.startsWith('/galaxy/')) ? 'galaxy' :
|
|
null;
|
|
|
|
if (targetStateForPath && state.matches(targetStateForPath)) {
|
|
console.log('[App Provider] State initialized from URL, marking as ready');
|
|
isInitializedRef.current = true;
|
|
}
|
|
}, [pathname, state]);
|
|
|
|
// State machine is source of truth: sync state → URL only
|
|
// This effect ONLY runs when state changes, not when pathname changes
|
|
useEffect(() => {
|
|
// Don't navigate until initialized
|
|
if (!isInitializedRef.current) {
|
|
return;
|
|
}
|
|
|
|
let targetPath: string | null = null;
|
|
|
|
if (state.matches('convo')) {
|
|
targetPath = '/chat';
|
|
} else if (state.matches('edit')) {
|
|
targetPath = '/edit';
|
|
} else if (state.matches('galaxy')) {
|
|
targetPath = '/galaxy';
|
|
}
|
|
|
|
// ONLY navigate if we need to change the pathname (not the whole URL)
|
|
// This preserves query params and prevents unnecessary navigation
|
|
if (targetPath && pathname !== targetPath) {
|
|
console.log('[App Provider] State machine navigating to:', targetPath);
|
|
lastNavigatedPathRef.current = targetPath;
|
|
router.push(targetPath);
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [state.value]); // ONLY depend on state.value, NOT pathname or router!
|
|
|
|
// Log state changes
|
|
useEffect(() => {
|
|
console.log('[App Provider] State:', state.value);
|
|
console.log('[App Provider] Tags:', Array.from(state.tags));
|
|
console.log('[App Provider] Context:', state.context);
|
|
}, [state]);
|
|
|
|
return (
|
|
<AppMachineContext.Provider value={appActor}>
|
|
{children}
|
|
</AppMachineContext.Provider>
|
|
);
|
|
}
|