From 7f49408c89e5eb2dd180fba75c33f41b26d05ae9 Mon Sep 17 00:00:00 2001 From: wotan-allfather Date: Tue, 3 Feb 2026 07:28:16 +0000 Subject: [PATCH] feat(memory-location): add state support This PR adds state support to memoryLocation, similar to how useBrowserLocation handles history.state. This allows passing down state in testing scenarios and in-memory routing. Changes: - Add optional `state` parameter to memoryLocation options for initial state - Accept `state` option in navigate function to update current state - Add `state` getter property to access current state value - Add `stateHook` for subscribing to state changes in components - Reset state to initial value when `reset()` is called The implementation follows the pattern suggested in issue #492 by @molefrog. Closes #492 --- packages/wouter/src/memory-location.js | 26 +++++- .../wouter/test/memory-location.test-d.ts | 22 +++++ packages/wouter/test/memory-location.test.ts | 84 +++++++++++++++++++ packages/wouter/types/memory-location.d.ts | 14 +++- 4 files changed, 143 insertions(+), 3 deletions(-) diff --git a/packages/wouter/src/memory-location.js b/packages/wouter/src/memory-location.js index 9fb0de35..59ff533a 100644 --- a/packages/wouter/src/memory-location.js +++ b/packages/wouter/src/memory-location.js @@ -10,6 +10,7 @@ export const memoryLocation = ({ searchPath = "", static: staticLocation, record, + state: initialState = null, } = {}) => { let initialPath = path; if (searchPath) { @@ -19,10 +20,11 @@ export const memoryLocation = ({ } let [currentPath, currentSearch = ""] = initialPath.split("?"); + let currentState = initialState; const history = [initialPath]; const emitter = mitt(); - const navigateImplementation = (path, { replace = false } = {}) => { + const navigateImplementation = (path, { replace = false, state } = {}) => { if (record) { if (replace) { history.splice(history.length - 1, 1, path); @@ -32,6 +34,13 @@ export const memoryLocation = ({ } [currentPath, currentSearch = ""] = path.split("?"); + + // Update state if provided, otherwise keep current state + // This matches browser behavior where state persists unless explicitly changed + if (state !== undefined) { + currentState = state; + } + emitter.emit("navigate", path); }; @@ -50,6 +59,9 @@ export const memoryLocation = ({ const useMemoryQuery = () => useSyncExternalStore(subscribe, () => currentSearch); + const useMemoryState = () => + useSyncExternalStore(subscribe, () => currentState); + // Attach searchHook to the location hook for auto-inheritance in Router useMemoryLocation.searchHook = useMemoryQuery; @@ -57,14 +69,26 @@ export const memoryLocation = ({ // clean history array with mutation to preserve link history.splice(0, history.length); + // Reset state to initial state + currentState = initialState; + navigateImplementation(initialPath); } + // Create a getter for state that always returns current value + const stateGetter = { + get current() { + return currentState; + }, + }; + return { hook: useMemoryLocation, searchHook: useMemoryQuery, + stateHook: useMemoryState, navigate, history: record ? history : undefined, reset: record ? reset : undefined, + state: stateGetter, }; }; diff --git a/packages/wouter/test/memory-location.test-d.ts b/packages/wouter/test/memory-location.test-d.ts index d02f638e..ef709605 100644 --- a/packages/wouter/test/memory-location.test-d.ts +++ b/packages/wouter/test/memory-location.test-d.ts @@ -46,3 +46,25 @@ test("should support `static` option", () => { expectTypeOf(hook).toMatchTypeOf(); }); + +test("should support `state` option", () => { + const { state, navigate, stateHook } = memoryLocation({ + state: { foo: "bar", count: 0 }, + }); + + assertType(state.current); + assertType(navigate); + assertType(stateHook); +}); + +test("should return state getter", () => { + const { state } = memoryLocation({ state: { test: true } }); + + assertType<{ readonly current: any }>(state); +}); + +test("should return stateHook", () => { + const { stateHook } = memoryLocation(); + + assertType(stateHook); +}); diff --git a/packages/wouter/test/memory-location.test.ts b/packages/wouter/test/memory-location.test.ts index 3010bdfa..fb42ecba 100644 --- a/packages/wouter/test/memory-location.test.ts +++ b/packages/wouter/test/memory-location.test.ts @@ -161,3 +161,87 @@ test("should have reset method that reset hook location", () => { unmount(); }); + +test("should support initial state", () => { + const { state } = memoryLocation({ state: { foo: "bar" } }); + + expect(state.current).toStrictEqual({ foo: "bar" }); +}); + +test("should have state as null by default", () => { + const { state } = memoryLocation(); + + expect(state.current).toBe(null); +}); + +test("should update state when navigating with state option", () => { + const { state, navigate } = memoryLocation(); + + expect(state.current).toBe(null); + + navigate("/new-path", { state: { modal: "promo" } }); + + expect(state.current).toStrictEqual({ modal: "promo" }); +}); + +test("should preserve state when navigating without state option", () => { + const { state, navigate } = memoryLocation({ state: { initial: true } }); + + expect(state.current).toStrictEqual({ initial: true }); + + navigate("/new-path"); + + // State should be preserved when not explicitly changed + expect(state.current).toStrictEqual({ initial: true }); +}); + +test("should allow setting state to null explicitly", () => { + const { state, navigate } = memoryLocation({ state: { foo: "bar" } }); + + expect(state.current).toStrictEqual({ foo: "bar" }); + + navigate("/new-path", { state: null }); + + expect(state.current).toBe(null); +}); + +test("should return stateHook that subscribes to state changes", () => { + const { stateHook, navigate } = memoryLocation({ state: { count: 0 } }); + + const { result, unmount } = renderHook(() => stateHook()); + + expect(result.current).toStrictEqual({ count: 0 }); + + act(() => navigate("/somewhere", { state: { count: 1 } })); + + expect(result.current).toStrictEqual({ count: 1 }); + + unmount(); +}); + +test("should reset state to initial state when reset is called", () => { + const { state, navigate, reset } = memoryLocation({ + record: true, + state: { initial: true }, + }); + + navigate("/somewhere", { state: { modified: true } }); + + expect(state.current).toStrictEqual({ modified: true }); + + reset(); + + expect(state.current).toStrictEqual({ initial: true }); +}); + +test("should work with navigate from hook return value", () => { + const { hook, state } = memoryLocation(); + + const { result, unmount } = renderHook(() => hook()); + + act(() => result.current[1]("/new-path", { state: { from: "hook" } })); + + expect(state.current).toStrictEqual({ from: "hook" }); + + unmount(); +}); diff --git a/packages/wouter/types/memory-location.d.ts b/packages/wouter/types/memory-location.d.ts index 04888b41..0ce103f4 100644 --- a/packages/wouter/types/memory-location.d.ts +++ b/packages/wouter/types/memory-location.d.ts @@ -5,15 +5,23 @@ import { SearchString, } from "./location-hook.js"; -type Navigate = ( +type Navigate = ( to: Path, - options?: { replace?: boolean; state?: S; transition?: boolean } + options?: { replace?: boolean; state?: any; transition?: boolean } ) => void; +type StateHook = () => any; + +type StateGetter = { + readonly current: any; +}; + type HookReturnValue = { hook: BaseLocationHook; searchHook: BaseSearchHook; + stateHook: StateHook; navigate: Navigate; + state: StateGetter; }; type StubHistory = { history: Path[]; reset: () => void }; @@ -22,10 +30,12 @@ export function memoryLocation(options?: { searchPath?: SearchString; static?: boolean; record?: false; + state?: any; }): HookReturnValue; export function memoryLocation(options?: { path?: Path; searchPath?: SearchString; static?: boolean; record: true; + state?: any; }): HookReturnValue & StubHistory;