diff --git a/bunfig.toml b/bunfig.toml index a32da4c..ede45b0 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -1,4 +1,16 @@ [test] preload = ["./packages/wouter/test/setup.ts"] coverageSkipTestFiles = true -coveragePathIgnorePatterns = ["**/test/**"] +coveragePathIgnorePatterns = [ + "**/test/**", + # temporary copies of packages/wouter/src/* created by preact.test.tsx; + # already measured at their original path (react-deps.js is preact-specific and stays measured) + "packages/wouter-preact/src/index.js", + "packages/wouter-preact/src/paths.js", + "packages/wouter-preact/src/memory-location.js", + "packages/wouter-preact/src/use-browser-location.js", + "packages/wouter-preact/src/use-hash-location.js", + "packages/wouter-preact/src/use-sync-external-store.js", + "packages/wouter-preact/src/use-sync-external-store.native.js", +] +coverageThreshold = { lines = 0.99 } diff --git a/packages/wouter-preact/test/preact.test.tsx b/packages/wouter-preact/test/preact.test.tsx index a02f27d..2679f33 100644 --- a/packages/wouter-preact/test/preact.test.tsx +++ b/packages/wouter-preact/test/preact.test.tsx @@ -157,6 +157,56 @@ describe("Preact support", () => { }); }); +describe("useSyncExternalStore shim", () => { + test("re-renders when the store mutates between render and layout effect", async () => { + // the internal shim is untyped, it mirrors the React useSyncExternalStore signature + const { useSyncExternalStore } = (await import( + // @ts-expect-error + "../src/react-deps.js" + )) as { + useSyncExternalStore: ( + subscribe: (cb: () => void) => () => void, + getSnapshot: () => T, + getSSRSnapshot?: () => T + ) => T; + }; + const { useLayoutEffect } = await import("preact/hooks"); + + let value = "initial"; + const listeners = new Set<() => void>(); + const subscribe = (cb: () => void) => { + listeners.add(cb); + return () => listeners.delete(cb); + }; + + // child layout effects run before the parent's, so this mutation happens + // after the parent has rendered but before its layout effect compares snapshots + const Child = () => { + useLayoutEffect(() => { + value = "mutated"; + }, []); + return null; + }; + + const Parent = () => { + const snapshot = useSyncExternalStore(subscribe, () => value); + return ( +
+ {snapshot} + +
+ ); + }; + + const container = document.body.appendChild(document.createElement("div")); + act(() => { + render(, container); + }); + + expect(container.textContent).toBe("mutated"); + }); +}); + describe("Preact SSR", () => { test.skip("supports SSR (fix: useSyncExternalStore polyfill in Bun)", async () => { const { Router, useLocation } = await loadPreact(); diff --git a/packages/wouter/test/setup.ts b/packages/wouter/test/setup.ts index 731fbd5..9883205 100644 --- a/packages/wouter/test/setup.ts +++ b/packages/wouter/test/setup.ts @@ -13,6 +13,12 @@ GlobalRegistrator.register({ // Extend Bun's expect with jest-dom matchers (expect as any).extend(matchers); +// Load the library first so the history monkey-patch in use-browser-location.js +// is applied (and attributed) to this copy of the module, not the temporary +// wouter-preact copy created by preact.test.tsx. Must run after happy-dom +// registration, hence a dynamic import. +await import("../src/use-browser-location.js"); + /** * Runs a function with `location` temporarily removed from globalThis. * Simulates pure Node.js SSR environment for testing. diff --git a/packages/wouter/test/use-search.test.tsx b/packages/wouter/test/use-search.test.tsx index 0005ffb..1011f6f 100644 --- a/packages/wouter/test/use-search.test.tsx +++ b/packages/wouter/test/use-search.test.tsx @@ -11,6 +11,14 @@ test("returns browser search string", () => { expect(result.current).toEqual("active=true"); }); +test("returns search string as-is when it contains malformed escapes", () => { + history.replaceState(null, "", "/users?q=100%"); + const { result } = renderHook(() => useSearch()); + + // decodeURI throws on "%", sanitizeSearch falls back to the raw string + expect(result.current).toEqual("q=100%"); +}); + test("can be customized in the Router", () => { const customSearchHook = ({ customOption = "unused" }) => "none";