Skip to content

Commit cae1d15

Browse files
fix batch of open issues against main
- useSubmission retry/clear no longer stub out when a submission exists (#504) - disposing an older owner no longer unregisters a newer action with the same URL (#542) - useBeforeLeave listeners observe preventDefault from other listeners (#530) - <A> active state ignores trailing slashes (#532) - useCurrentMatches returns a copy to protect router state (#516) - static path segments keep RFC 3986 pchar characters literal so +foo/@user match (#559, #509) - consecutive synchronous setSearchParams calls compose via pending navigation target (#547) Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent e54fc4a commit cae1d15

10 files changed

Lines changed: 264 additions & 11 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@solidjs/router": patch
3+
---
4+
5+
Fix a batch of long-standing bugs:
6+
7+
- `useSubmission().retry` was always a no-op due to an operator-precedence bug (#504)
8+
- disposing an older owner no longer unregisters a newer action bound to the same URL, which caused forms to fall through to native submission after revalidation (#542)
9+
- `useBeforeLeave` listeners now observe `defaultPrevented` set by other listeners (#530)
10+
- `<A>` active state now ignores trailing slashes on `href` (#532)
11+
- `useCurrentMatches` returns a copy so user mutation can't corrupt router state (#516)
12+
- static path segments no longer percent-encode RFC 3986 pchar characters (`+`, `@`, `:`, `$`, `&`, `,`, `;`, `=`), so routes like `+foo` or `@user` match the browser's raw pathname (#559, #509)
13+
- consecutive synchronous `setSearchParams` calls now compose: the merge applies to the in-flight navigation target instead of the stale committed location (#547)

src/components.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,9 @@ export function A(props: AnchorProps) {
5050
const isActive = createMemo(() => {
5151
const to_ = to();
5252
if (to_ === undefined) return [false, false];
53-
const path = normalizePath(to_.split(/[?#]/, 1)[0]).toLowerCase();
54-
const loc = decodeURI(normalizePath(location.pathname).toLowerCase());
53+
// trailing slashes are ignored so `/route` and `/route/` share active state
54+
const path = normalizePath(to_.split(/[?#]/, 1)[0]).toLowerCase().replace(/\/$/, "");
55+
const loc = decodeURI(normalizePath(location.pathname).toLowerCase().replace(/\/$/, ""));
5556
return [props.end ? path === loc : loc.startsWith(path + "/") || loc === path, path === loc];
5657
});
5758

src/data/action.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ export function useSubmission<T extends Array<any>, U, V>(
5353
{},
5454
{
5555
get(_, property) {
56-
if ((submissions.length === 0 && property === "clear") || property === "retry")
56+
if (submissions.length === 0 && (property === "clear" || property === "retry"))
5757
return () => {};
5858
return submissions[submissions.length - 1]?.[property as keyof Submission<T, U>];
5959
}
@@ -165,7 +165,10 @@ function toAction<T extends Array<any>, U, V = T>(fn: Function, url: string): Ac
165165
(fn as any).url = url;
166166
if (!isServer) {
167167
actions.set(url, fn as Action<T, U, V>);
168-
getOwner() && onCleanup(() => actions.delete(url));
168+
// Only remove the registration if it still belongs to this instance —
169+
// a re-created action (e.g. a new `.with()` binding after revalidation)
170+
// may have registered itself under the same URL since.
171+
getOwner() && onCleanup(() => actions.get(url) === fn && actions.delete(url));
169172
}
170173
return fn as Action<T, U, V>;
171174
}

src/lifecycle.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,14 @@ export function createBeforeLeave(): BeforeLeaveLifecycle {
2020
};
2121
for (const l of listeners)
2222
l.listener({
23-
...e,
23+
to,
24+
options,
25+
// delegate to the shared event so later listeners' preventDefault
26+
// calls are observable from earlier listeners
27+
get defaultPrevented() {
28+
return e.defaultPrevented;
29+
},
30+
preventDefault: e.preventDefault,
2431
from: l.location,
2532
retry: (force?: boolean) => {
2633
force && (ignore = true);

src/routing.ts

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,11 @@ export const useMatch = <S extends string>(path: () => S, matchFilters?: MatchFi
179179
* const breadcrumbs = createMemo(() => matches().map(m => m.route.info.breadcrumb))
180180
* ```
181181
*/
182-
export const useCurrentMatches = () => useRouter().matches;
182+
export const useCurrentMatches = () => {
183+
const router = useRouter();
184+
// return a copy so user mutations (eg. `.reverse()`) can't corrupt router state
185+
return () => router.matches().slice();
186+
};
183187

184188
/**
185189
* Retrieves a reactive, store-like object containing the current route path parameters as defined in the Route.
@@ -225,11 +229,20 @@ export const useSearchParams = <T extends SearchParams>(): [
225229
Partial<T>,
226230
(params: SetSearchParams, options?: Partial<NavigateOptions>) => void
227231
] => {
228-
const location = useLocation();
232+
const router = useRouter();
233+
const location = router.location;
229234
const navigate = useNavigate();
230235
const setSearchParams = (params: SetSearchParams, options?: Partial<NavigateOptions>) => {
231-
const searchString = untrack(() => mergeSearchString(location.search, params) + location.hash);
232-
navigate(searchString, {
236+
const to = untrack(() => {
237+
// merge onto the in-flight navigation target (if any) so consecutive
238+
// synchronous calls compose instead of the later one winning
239+
const pending = router.pendingTarget && new URL(router.pendingTarget.value, mockBase);
240+
const pathname = pending ? pending.pathname : location.pathname;
241+
const search = pending ? pending.search : location.search;
242+
const hash = pending ? pending.hash : location.hash;
243+
return pathname + mergeSearchString(search, params) + hash;
244+
});
245+
navigate(to, {
233246
scroll: false,
234247
resolve: false,
235248
...options
@@ -274,6 +287,13 @@ export const useBeforeLeave = (listener: (e: BeforeLeaveEventArgs) => void) => {
274287
onCleanup(s);
275288
};
276289

290+
// Encodes a static path segment like `encodeURIComponent`, but leaves RFC 3986
291+
// pchar characters (sub-delims / ":" / "@") literal, matching how browsers
292+
// report them in `location.pathname`. Non-ASCII characters (eg. CJK paths) are
293+
// still percent-encoded exactly as before, since browsers encode those too.
294+
const encodeSegment = (s: string) =>
295+
encodeURIComponent(s).replace(/%(2B|40|3A|24|26|2C|3B|3D)/g, m => decodeURIComponent(m));
296+
277297
export function createRoutes(routeDef: RouteDefinition, base: string = ""): RouteDescription[] {
278298
const { component, preload, load, children, info } = routeDef;
279299
const isLeaf = !children || (Array.isArray(children) && !children.length);
@@ -292,7 +312,7 @@ export function createRoutes(routeDef: RouteDefinition, base: string = ""): Rout
292312
pattern = pattern
293313
.split("/")
294314
.map((s: string) => {
295-
return s.startsWith(":") || s.startsWith("*") ? s : encodeURIComponent(s);
315+
return s.startsWith(":") || s.startsWith("*") ? s : encodeSegment(s);
296316
})
297317
.join("/");
298318
acc.push({
@@ -534,6 +554,9 @@ export function createRouterContext(
534554
location,
535555
params,
536556
isRouting,
557+
get pendingTarget() {
558+
return lastTransitionTarget;
559+
},
537560
renderPath,
538561
parsePath,
539562
navigatorFactory,

src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,8 @@ export interface RouterContext {
176176
params: Params;
177177
navigatorFactory: NavigatorFactory;
178178
isRouting: () => boolean;
179+
/** The target of the in-flight navigation transition, if any. Not reactive. */
180+
readonly pendingTarget?: LocationChange;
179181
matches: () => RouteMatch[];
180182
renderPath(path: string): string;
181183
parsePath(str: string): string;

test/data/action.spec.ts

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ import {
55
useAction,
66
useSubmission,
77
useSubmissions,
8-
actions
8+
actions,
9+
type Action
910
} from "../../src/data/action.js";
1011
import type { RouterContext } from "../../src/types.js";
1112
import { createMockRouter } from "../helpers.js";
@@ -85,6 +86,42 @@ describe("action", () => {
8586
expect(actions.get(testAction.url)).toBe(testAction);
8687
});
8788

89+
test("disposing an older owner should not unregister a newer action with the same URL", () => {
90+
const base = action(async (id: string, data: string) => `${id}: ${data}`, "cleanup-test");
91+
92+
let disposeFirst!: () => void;
93+
let first!: ReturnType<typeof base.with>;
94+
createRoot(dispose => {
95+
disposeFirst = dispose;
96+
first = base.with("same-args");
97+
});
98+
99+
let second!: ReturnType<typeof base.with>;
100+
createRoot(() => {
101+
second = base.with("same-args");
102+
});
103+
104+
expect(first.url).toBe(second.url);
105+
expect(actions.get(second.url)).toBe(second);
106+
107+
// the older owner disposing must not delete the newer registration
108+
disposeFirst();
109+
expect(actions.get(second.url)).toBe(second);
110+
});
111+
112+
test("disposing the current owner should unregister its action", () => {
113+
let dispose!: () => void;
114+
let registered!: Action<[], string>;
115+
createRoot(d => {
116+
dispose = d;
117+
registered = action(async () => "result", "self-cleanup-test");
118+
});
119+
120+
expect(actions.get(registered.url)).toBe(registered);
121+
dispose();
122+
expect(actions.has(registered.url)).toBe(false);
123+
});
124+
88125
test("should support `.with` method for currying arguments", () => {
89126
const baseAction = action(async (prefix: string, data: string) => {
90127
return `${prefix}: ${data}`;
@@ -302,6 +339,34 @@ describe("useSubmission", () => {
302339
});
303340
});
304341

342+
test("retry and clear should invoke the underlying submission when one exists", () => {
343+
return createRoot(() => {
344+
const testAction = action(async () => "result", "retry-test");
345+
const retry = vi.fn();
346+
const clear = vi.fn();
347+
348+
mockRouterContext.submissions[1](submissions => [
349+
...submissions,
350+
{
351+
input: ["data"],
352+
url: testAction.url,
353+
result: "result",
354+
error: undefined,
355+
pending: false,
356+
clear,
357+
retry
358+
}
359+
]);
360+
361+
const submission = useSubmission(testAction);
362+
(submission.retry as () => void)();
363+
(submission.clear as () => void)();
364+
365+
expect(retry).toHaveBeenCalledTimes(1);
366+
expect(clear).toHaveBeenCalledTimes(1);
367+
});
368+
});
369+
305370
test("should filter submissions when filter function provided", () => {
306371
return createRoot(() => {
307372
const testAction = action(async (data: string) => data, "filter-submission-test");

test/lifecycle.spec.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { vi } from "vitest";
2+
import { createBeforeLeave } from "../src/lifecycle.js";
3+
import type { BeforeLeaveEventArgs, Location } from "../src/types.js";
4+
5+
const mockLocation = { pathname: "/", search: "", hash: "", query: {}, state: null, key: "" } as unknown as Location;
6+
7+
describe("createBeforeLeave", () => {
8+
test("confirm returns false when a listener prevents default", () => {
9+
const beforeLeave = createBeforeLeave();
10+
beforeLeave.subscribe({
11+
listener: e => e.preventDefault(),
12+
location: mockLocation,
13+
navigate: vi.fn()
14+
});
15+
16+
expect(beforeLeave.confirm("/next")).toBe(false);
17+
});
18+
19+
test("earlier listeners observe preventDefault called by later listeners", () => {
20+
const beforeLeave = createBeforeLeave();
21+
let captured!: BeforeLeaveEventArgs;
22+
23+
beforeLeave.subscribe({
24+
listener: e => (captured = e),
25+
location: mockLocation,
26+
navigate: vi.fn()
27+
});
28+
beforeLeave.subscribe({
29+
listener: e => e.preventDefault(),
30+
location: mockLocation,
31+
navigate: vi.fn()
32+
});
33+
34+
beforeLeave.confirm("/next");
35+
expect(captured.defaultPrevented).toBe(true);
36+
});
37+
38+
test("unsubscribe removes the listener", () => {
39+
const beforeLeave = createBeforeLeave();
40+
const listener = vi.fn();
41+
const unsubscribe = beforeLeave.subscribe({
42+
listener,
43+
location: mockLocation,
44+
navigate: vi.fn()
45+
});
46+
47+
unsubscribe();
48+
expect(beforeLeave.confirm("/next")).toBe(true);
49+
expect(listener).not.toHaveBeenCalled();
50+
});
51+
});

test/route.spec.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -563,4 +563,21 @@ describe("createBranches should", () => {
563563

564564
expect(branchPaths).toEqual(["/root/%E3%81%BB%E3%81%92/:ふが/*ぴよ"]);
565565
});
566+
567+
test(`not encode RFC 3986 pchar characters in static segments`, () => {
568+
// browsers keep sub-delims / ":" / "@" literal in location.pathname,
569+
// so encoding them made these routes unmatchable (#559, #509)
570+
const route = createRoute({ path: "+foo/@user/a=b" });
571+
572+
expect(route.pattern).toBe("/+foo/@user/a=b");
573+
expect(route.matcher("/+foo/@user/a=b")).not.toBeNull();
574+
expect(route.matcher("/%2Bfoo/@user/a=b")).toBeNull();
575+
});
576+
577+
test(`still encode non-pchar characters in static segments`, () => {
578+
const route = createRoute({ path: "foo bar/ほげ" });
579+
580+
expect(route.pattern).toBe("/foo%20bar/%E3%81%BB%E3%81%92");
581+
expect(route.matcher("/foo%20bar/%E3%81%BB%E3%81%92")).not.toBeNull();
582+
});
566583
});

test/search-params.spec.tsx

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { render } from "solid-js/web";
2+
import { MemoryRouter, Route, useSearchParams, useNavigate, useLocation } from "../src/index.js";
3+
import type { Location, Navigator } from "../src/index.js";
4+
import { awaitPromise } from "./helpers.js";
5+
6+
describe("useSearchParams", () => {
7+
test("two synchronous setSearchParams calls both apply", async () => {
8+
let set!: ReturnType<typeof useSearchParams>[1];
9+
let params!: ReturnType<typeof useSearchParams>[0];
10+
11+
const Index = () => {
12+
[params, set] = useSearchParams();
13+
return null;
14+
};
15+
16+
const dispose = render(
17+
() => (
18+
<MemoryRouter>
19+
<Route path="/" component={Index} />
20+
</MemoryRouter>
21+
),
22+
document.body
23+
);
24+
25+
try {
26+
set({ a: "1" });
27+
set({ b: "2" });
28+
await awaitPromise();
29+
expect(params.a).toBe("1");
30+
expect(params.b).toBe("2");
31+
} finally {
32+
document.body.innerHTML = "";
33+
dispose();
34+
}
35+
});
36+
37+
test("setSearchParams during a pending navigation applies to the target route", async () => {
38+
let set!: ReturnType<typeof useSearchParams>[1];
39+
let navigate!: Navigator;
40+
let location!: Location;
41+
42+
const Index = () => {
43+
[, set] = useSearchParams();
44+
navigate = useNavigate();
45+
location = useLocation();
46+
return null;
47+
};
48+
49+
const dispose = render(
50+
() => (
51+
<MemoryRouter>
52+
<Route path="/" component={Index} />
53+
<Route path="/other" component={() => null} />
54+
</MemoryRouter>
55+
),
56+
document.body
57+
);
58+
59+
try {
60+
await awaitPromise();
61+
navigate("/other", { scroll: false });
62+
set({ a: "1" });
63+
await awaitPromise();
64+
expect(location.pathname).toBe("/other");
65+
expect(location.search).toBe("?a=1");
66+
} finally {
67+
document.body.innerHTML = "";
68+
dispose();
69+
}
70+
});
71+
});

0 commit comments

Comments
 (0)