Files
eranos/src/contexts/LayoutContext.ts
T
Chad Curtis 4961cac19d fix: profile right sidebar persisting after navigation
The cleanup guard in useLayoutOptions compared store.getSnapshot() (a LayoutSnapshot spread copy) against prev.current (the original LayoutOptions reference), which are always different objects. The guard was never true, so store.reset() never fired on unmount and the ProfileRightSidebar leaked to other pages. Added getOptions() to expose the raw options reference for a correct identity comparison.
2026-03-03 01:21:02 -06:00

137 lines
4.7 KiB
TypeScript

import { createContext, useContext, useEffect, useRef, useSyncExternalStore } from 'react';
/** Options that pages can set to configure the persistent MainLayout. */
export interface LayoutOptions {
/** Optional custom right sidebar to replace the default one */
rightSidebar?: React.ReactNode;
/** Whether to show the floating compose button (default: false) */
showFAB?: boolean;
/** The Nostr event kind the FAB creates (default: 1). Only used when showFAB is true. */
fabKind?: number;
/** If set, the FAB navigates to this URL instead of opening a compose dialog. */
fabHref?: string;
/** If set, overrides the default FAB click behavior. */
onFabClick?: () => void;
/** Skip the bottom nav spacer (for pages that handle their own bottom padding) */
noBottomSpacer?: boolean;
/** Additional classes for the wrapper div */
wrapperClassName?: string;
}
type Listener = () => void;
const EMPTY: LayoutOptions = {};
/**
* A mutable store that holds the current layout options.
* Pages call `setOptions` to update, and MainLayout subscribes via useSyncExternalStore.
*/
/** Snapshot returned by the layout store, combining page options with dynamic state. */
export type LayoutSnapshot = LayoutOptions & { fabHidden: boolean };
const EMPTY_SNAPSHOT: LayoutSnapshot = { fabHidden: false };
export class LayoutStore {
private options: LayoutOptions = EMPTY;
private _fabHidden = false;
private _snapshot: LayoutSnapshot = EMPTY_SNAPSHOT;
private listeners = new Set<Listener>();
private buildSnapshot(): void {
this._snapshot = { ...this.options, fabHidden: this._fabHidden };
}
getSnapshot = (): LayoutSnapshot => this._snapshot;
getOptions = (): LayoutOptions => this.options;
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
setOptions = (next: LayoutOptions): void => {
if (this.options === next) return;
this.options = next;
this.buildSnapshot();
this.listeners.forEach((l) => l());
};
setFabHidden = (hidden: boolean): void => {
if (this._fabHidden === hidden) return;
this._fabHidden = hidden;
this.buildSnapshot();
this.listeners.forEach((l) => l());
};
reset = (): void => {
if (this.options === EMPTY && !this._fabHidden) return;
this.options = EMPTY;
this._fabHidden = false;
this._snapshot = EMPTY_SNAPSHOT;
this.listeners.forEach((l) => l());
};
}
export const LayoutStoreContext = createContext<LayoutStore | null>(null);
function useLayoutStore(): LayoutStore {
const store = useContext(LayoutStoreContext);
if (!store) throw new Error('useLayoutOptions must be used within LayoutStoreContext');
return store;
}
/**
* Hook for pages to declare their layout options.
* Call this at the top of a page component to configure the surrounding MainLayout.
*
* Sets options synchronously during render so the layout never shows stale values.
* Resets to defaults on unmount so options don't leak to the next page.
*/
export function useLayoutOptions(options: LayoutOptions): void {
const store = useLayoutStore();
const prev = useRef<LayoutOptions | null>(null);
// Set options synchronously during render (before commit) so the layout
// picks them up in the same paint as the new page.
const changed =
prev.current === null ||
prev.current.showFAB !== options.showFAB ||
prev.current.fabKind !== options.fabKind ||
prev.current.fabHref !== options.fabHref ||
prev.current.onFabClick !== options.onFabClick ||
prev.current.noBottomSpacer !== options.noBottomSpacer ||
prev.current.wrapperClassName !== options.wrapperClassName ||
prev.current.rightSidebar !== options.rightSidebar;
if (changed) {
prev.current = options;
store.setOptions(options);
}
// Clean up on unmount — reset to defaults so the next page starts fresh.
// Only reset if the store still holds this component's options.
// During page transitions the new page's render-phase setOptions runs
// before the old page's cleanup effect, so blindly resetting would
// clobber the incoming page's options (causing the FAB to disappear).
useEffect(() => {
return () => {
if (store.getOptions() === prev.current) {
store.reset();
}
};
}, [store]);
}
/** Hook for MainLayout to read the current layout options reactively. */
export function useLayoutSnapshot(): LayoutSnapshot {
const store = useLayoutStore();
return useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
}
/** Hook for components to signal that the FAB should be hidden (e.g., ComposeBox is visible). */
export function useSetFabHidden(): (hidden: boolean) => void {
const store = useLayoutStore();
return store.setFabHidden;
}