diff --git a/src/App.tsx b/src/App.tsx index fc0eb9b..cdd6e6a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -73,6 +73,17 @@ export default function App() { */ const initialRouteRef = useRef(parseRoute(window.location.pathname, window.location.search)); + /** + * Back-button support for the post view. Every other URL change + * (`replaceState`s the tab/archive) is intentionally NOT pushed — only + * opening a post gets its own history entry, matching Instagram's own + * back-button behaviour: Back closes the post instead of leaving the app. + */ + const pushedPostRef = useRef(false); + /** Set while reacting to a popstate, so the URL-sync effect below does not + * try to push/replace/back() again for a change the browser already made. */ + const suppressNextSyncRef = useRef(false); + const isMobile = useIsMobile(); const fileInputRef = useRef(null); const profilePicInputRef = useRef(null); @@ -367,6 +378,13 @@ export default function App() { // loader below is waiting to read. if (!hasInitialLoaded) return; + // Consumed exactly once per popstate, regardless of what happens below: + // a popstate-driven change often already matches the URL (the browser + // already moved the pointer), which used to leave this flag stuck true + // and silently no-op the NEXT real close/open until something reset it. + const wasPopState = suppressNextSyncRef.current; + suppressNextSyncRef.current = false; + const archive = currentArchive?.name ?? (allPosts.length > 0 ? username : null) ?? null; const nextPath = buildPath({ archive, @@ -374,12 +392,48 @@ export default function App() { post: selectedPost ? postSlug(selectedPost) : null, }); - if (nextPath !== window.location.pathname + window.location.search) { - console.log(`[Permalink] Updating URL to: ${nextPath}`); + if (nextPath === window.location.pathname + window.location.search) return; + if (wasPopState) return; // the browser already navigated; nothing to add + + console.log(`[Permalink] Updating URL to: ${nextPath}`); + + if (selectedPost && !pushedPostRef.current) { + // Opening a post: push, so Back closes it instead of leaving the app. + window.history.pushState(null, '', nextPath); + pushedPostRef.current = true; + } else if (!selectedPost && pushedPostRef.current) { + // Closing a post that was pushed for: consume that entry rather than + // piling a new one on top of it, so Back still means "one step". + pushedPostRef.current = false; + window.history.back(); + } else { window.history.replaceState(null, '', nextPath); } }, [hasInitialLoaded, currentArchive?.name, username, allPosts.length, activeTab, selectedPost?.id]); + /** + * Back/forward support for the post view. Only a post push (above) ever + * creates an entry, so this only ever needs to open or close a post — + * never re-derive the tab or archive, which stayed on replaceState. + */ + useEffect(() => { + const onPopState = () => { + suppressNextSyncRef.current = true; + const route = parseRoute(window.location.pathname, window.location.search); + const post = route.post ? findPostBySlug(allPosts, route.post) : null; + if (post) { + setActiveTab(tabForSource(post.source)); + setSelectedPost(post); + pushedPostRef.current = true; // forward navigation can land back here + } else { + setSelectedPost(null); + pushedPostRef.current = false; + } + }; + window.addEventListener('popstate', onPopState); + return () => window.removeEventListener('popstate', onPopState); + }, [allPosts]); + useEffect(() => { if (hasInitialLoaded) return;