Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/golf-client-entries-next31.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/router": patch
---

Smaller client bundles, no behavior change: the `name` diagnostics on the router's internal memos are development-only (`DEV &&`, folded out of shipped builds), the segment matcher and match-filter lookup are a single expression, `query()` settles a cached or fresh read through one helper, the `keysFor()` instance method reuses `match()`'s pathname derivation, and a handful of one-use closures and repeated property chains (`window.history`, a form submit's `evt.target`, the paths proxy's `toString`/`Symbol.toPrimitive` branch) are folded. Production savings for a typical app (`createRouter` + `paths` links + `useNavigate` + `query` + `action`): ~440 bytes minified, ~80 bytes brotli on the router's share of the bundle.
26 changes: 13 additions & 13 deletions src/data/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,10 +91,11 @@ export const actions = /* #__PURE__ */ new Map<string, Action<any, any>>();
*/
export function handleFormAction(evt: SubmitEvent, router: RouterContext, actionBase: string) {
if (evt.defaultPrevented) return;
const form = evt.target as HTMLFormElement;
let actionRef =
evt.submitter && evt.submitter.hasAttribute("formaction")
? evt.submitter.getAttribute("formaction")
: (evt.target as HTMLElement).getAttribute("action");
: form.getAttribute("action");
if (!actionRef) return;
const serverAction = !actionRef.startsWith("https://action/");
if (serverAction) {
Expand All @@ -103,7 +104,7 @@ export function handleFormAction(evt: SubmitEvent, router: RouterContext, action
actionRef = router.parsePath(url.pathname + url.search);
if (!actionRef.startsWith(actionBase)) return;
}
if ((evt.target as HTMLFormElement).method.toUpperCase() !== "POST")
if (form.method.toUpperCase() !== "POST")
throw new Error("Only POST forms are supported for Actions");
// A registry miss on a server-action url is a direct bind whose module
// never loaded client-side (server components): the url is self-describing
Expand All @@ -115,12 +116,10 @@ export function handleFormAction(evt: SubmitEvent, router: RouterContext, action
const handler = actions.get(actionRef) || (serverAction && createServerFormAction(actionRef));
if (handler) {
evt.preventDefault();
const data = new FormData(evt.target as HTMLFormElement, evt.submitter);
const data = new FormData(form, evt.submitter);
handler.call(
{ r: router, f: evt.target },
(evt.target as HTMLFormElement).enctype === "multipart/form-data"
? data
: new URLSearchParams(data as any)
{ r: router, f: form },
form.enctype === "multipart/form-data" ? data : new URLSearchParams(data as any)
);
}
}
Expand Down Expand Up @@ -307,8 +306,8 @@ function actionImpl<T extends Array<any>, U = void>(
submission = {
input: variables,
url,
result: response && response.data,
error: response && response.error,
result: response?.data,
error: response?.error,
clear() {
router.submissions[1](entries => entries.filter(entry => entry !== submission));
},
Expand Down Expand Up @@ -365,15 +364,14 @@ function toAction<T extends Array<any>, U, V = T>(
) {
const uri = new URL(url, mockBase);
uri.searchParams.set("args", hashKey(args));
const next = toAction<B, U, V>(
return toAction<B, U, V>(
invoke,
(uri.origin === "https://action" ? uri.origin : "") + uri.pathname + uri.search,
[...boundArgs, ...args],
base,
submitHooks,
settledHooks
) as unknown as InternalAction<B, U, V>;
return next;
};
fn.onSubmit = function (hook: (...args: V extends Array<any> ? V : T) => void) {
const id = Symbol("actionOnSubmitHook");
Expand Down Expand Up @@ -503,8 +501,10 @@ function applyResponseMetadata(
cacheKeyOp(keys, entry => (entry[0] = 0));
// set cache — and fan the payload out to other keyed stores (live query
// channels adopt delivered values instead of reconnecting)
flightData && Object.keys(flightData).forEach(k => query.set(k, flightData[k]));
flightData && deliverFlightData(flightData);
if (flightData) {
Object.keys(flightData).forEach(k => query.set(k, flightData[k]));
deliverFlightData(flightData);
}
// trigger revalidation inside the same transition as the navigation, so
// the redirect commits atomically with fresh data: surviving consumers
// (shared layouts) the flight payload didn't seed refetch and hold the
Expand Down
2 changes: 1 addition & 1 deletion src/data/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export function setupNativeEvents({
if (target || (!href && !a.hasAttribute("state"))) return;

const rel = (a.getAttribute("rel") || "").split(/\s+/);
if (a.hasAttribute("download") || (rel && rel.includes("external"))) return;
if (a.hasAttribute("download") || rel.includes("external")) return;

const url = svg ? new URL(href, document.baseURI) : new URL(href);
// Skip non-http(s) schemes (blob:, mailto:, tel:, data:, ...). blob: URLs
Expand Down
19 changes: 9 additions & 10 deletions src/data/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,10 +191,7 @@ export function query<T extends (...args: any) => any>(fn: T, name: string): Cac
}
let res = cached[1];
if (intent !== "preload") {
res =
"then" in cached[1]
? cached[1].then(handleResponse(false), handleResponse(true))
: handleResponse(false)(cached[1]);
res = settle(res);
!isServer && intent === "navigate" && cached[4][1](cached[0]); // update version
}
inPreloadFn && "then" in res && res.catch(() => {});
Expand Down Expand Up @@ -261,12 +258,7 @@ export function query<T extends (...args: any) => any>(fn: T, name: string): Cac
const e = getRequestEvent();
if (e && e.router!.dataOnly) return (e.router!.data![key] = res);
}
if (intent !== "preload") {
res =
"then" in res
? res.then(handleResponse(false), handleResponse(true))
: handleResponse(false)(res);
}
if (intent !== "preload") res = settle(res);
inPreloadFn && "then" in res && res.catch(() => {});
// serialize on server
if (
Expand All @@ -280,6 +272,13 @@ export function query<T extends (...args: any) => any>(fn: T, name: string): Cac
}
return res;

// a settled read: the response handling applied to the value or the promise
function settle(v: any) {
return "then" in v
? v.then(handleResponse(false), handleResponse(true))
: handleResponse(false)(v);
}

function handleResponse(error: boolean) {
return async (v: any) => {
let enveloped: any;
Expand Down
9 changes: 2 additions & 7 deletions src/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,13 +250,8 @@ export function createPathsProxy(
};
return new Proxy(build, {
get(_, prop) {
if (prop === "toString") return () => toHref(pathname);
if (typeof prop === "symbol")
return prop === Symbol.toPrimitive
? () => toHref(pathname)
: prop === HREF
? pathname || "/"
: undefined;
if (prop === "toString" || prop === Symbol.toPrimitive) return () => toHref(pathname);
if (typeof prop === "symbol") return prop === HREF ? pathname || "/" : undefined;
return node(`${pathname}/${prop}`);
}
});
Expand Down
4 changes: 1 addition & 3 deletions src/routers/components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,7 @@ export function Routes(props: { routerState: RouterContext; branches: () => Bran
next[i] = prev[i];
} else {
equal = false;
if (disposers[i]) {
disposers[i]();
}
disposers[i]?.();

runWithOwner(owner, () =>
createRoot(dispose => {
Expand Down
4 changes: 1 addition & 3 deletions src/routers/factory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -465,10 +465,8 @@ export function createRouter<const R extends readonly RouteDefinition[]>(
}));
},
keysFor(url: string | TypedPath): string[] {
const pathname =
typeof url === "string" ? new URL(url, mockBase).pathname : ((url as any)[HREF] as string);
const keys: string[] = [];
for (const { route } of matchPath(pathname)) {
for (const { route } of matchPath(pathnameOf(url))) {
const key = (serverRouteOf(route.component)?.call as { key?: unknown } | undefined)?.key;
typeof key === "string" && key && !keys.includes(key) && keys.push(key);
}
Expand Down
19 changes: 7 additions & 12 deletions src/routers/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,11 @@ export function bindEvent(target: EventTarget, type: string, handler: EventListe

let depth: number;
export function saveCurrentDepth() {
if (!window.history.state || window.history.state._depth == null) {
window.history.replaceState({ ...window.history.state, _depth: window.history.length - 1 }, "");
const h = window.history;
if (!h.state || h.state._depth == null) {
h.replaceState({ ...h.state, _depth: h.length - 1 }, "");
}
depth = window.history.state?._depth ?? window.history.length - 1;
depth = h.state?._depth ?? h.length - 1;
}

function keepDepth(state: any) {
Expand Down Expand Up @@ -72,16 +73,10 @@ export interface RouterHistory {

export function browserHistory(): RouterHistory {
const getSource = () => {
const url = window.location.pathname + window.location.search;
const state =
window.history.state &&
window.history.state._depth &&
Object.keys(window.history.state).length === 1
? undefined
: window.history.state;
const s = window.history.state;
return {
value: url + window.location.hash,
state
value: window.location.pathname + window.location.search + window.location.hash,
state: s && s._depth && Object.keys(s).length === 1 ? undefined : s
};
};
const beforeLeave: BeforeLeaveSlot = {};
Expand Down
3 changes: 1 addition & 2 deletions src/routers/scrollRestoration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,7 @@ export function createScrollRestoration() {
// reload/back_forward document loads land on an existing entry (a fresh
// navigation starts a new one and belongs at the top); the effect's
// initial run performs the restore after first render
const [nav] = (performance.getEntriesByType &&
performance.getEntriesByType("navigation")) as PerformanceNavigationTiming[];
const [nav] = performance.getEntriesByType?.("navigation") as PerformanceNavigationTiming[];
if (nav && nav.type !== "navigate") pending = depth();
}
};
Expand Down
26 changes: 9 additions & 17 deletions src/routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -575,9 +575,7 @@ export function createRoutes(routeDef: RouteDefinition, base: string = ""): Rout
let pattern = isLeaf ? path : path.split("/*", 1)[0];
pattern = pattern
.split("/")
.map((s: string) => {
return s.startsWith(":") || s.startsWith("*") ? s : encodeSegment(s);
})
.map(s => (/^[:*]/.test(s) ? s : encodeSegment(s)))
.join("/");
acc.push({
...shared,
Expand Down Expand Up @@ -686,14 +684,13 @@ function createLocation(
state: Accessor<any>,
queryWrapper?: (getQuery: () => SearchParams) => SearchParams
): Location {
const origin = new URL(mockBase);
const url = createMemo<URL>(
(prev = origin) => {
(prev = new URL(mockBase)) => {
const path_ = path();
try {
// anchor rooted paths against the origin explicitly - a path with
// doubled leading slashes would otherwise parse as protocol-relative
return new URL(path_[0] === "/" ? mockBase + path_ : path_, origin);
return new URL(path_[0] === "/" ? mockBase + path_ : path_, mockBase);
} catch (err) {
DEV && console.error(`Invalid path ${path_}`);
return prev;
Expand All @@ -707,7 +704,6 @@ function createLocation(
const pathname = createMemo(() => url().pathname);
const search = createMemo(() => url().search);
const hash = createMemo(() => url().hash);
const key = () => "";
const queryFn = createMemo(() => extractSearchParams(url()));

return {
Expand All @@ -724,7 +720,7 @@ function createLocation(
return state();
},
get key() {
return key();
return "";
},
query: queryWrapper ? queryWrapper(queryFn) : createMemoObject(queryFn)
};
Expand Down Expand Up @@ -909,7 +905,7 @@ export function createRouterContext(
const result = resolveLazySubtree(record);
return result instanceof Promise ? result.then(() => undefined) : undefined;
},
{ name: "lazyRoutes" }
DEV && { name: "lazyRoutes" }
)
);
lazyReaders.set(record, read);
Expand Down Expand Up @@ -948,7 +944,7 @@ export function createRouterContext(
}
return m;
},
{ name: "matches" }
DEV && { name: "matches" }
);

const routingPending = createMemo(
Expand All @@ -962,7 +958,7 @@ export function createRouterContext(
location.search;
location.hash;
}),
{ name: "routingPending" }
DEV && { name: "routingPending" }
);
const isRouting = () => routingPending() || isPending(source);

Expand Down Expand Up @@ -1192,10 +1188,8 @@ export function createRouterContext(
preloadIntent = "preload";
for (let match in next) {
const { route, params } = next[match];
route.component &&
(route.component as MaybePreloadableComponent).preload &&
(route.component as MaybePreloadableComponent).preload!();
const { preload, component } = route;
(component as MaybePreloadableComponent | undefined)?.preload?.();
const now = current && current[match];
const unchanged =
now &&
Expand Down Expand Up @@ -1271,9 +1265,7 @@ export function createRouteContext(
// components and preloads never observe another route's params.
const params = wrapParams(() => mergeParams(matches()));

component &&
(component as MaybePreloadableComponent).preload &&
(component as MaybePreloadableComponent).preload!();
(component as MaybePreloadableComponent | undefined)?.preload?.();
inPreloadFn = true;
const data = preload
? preload({ params, location, intent: router.intent?.() || "initial" })
Expand Down
33 changes: 13 additions & 20 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,16 +95,15 @@ export function createMatcher<S extends string>(
params: {}
};

const matchFilter = (s: string) =>
matchFilters === undefined ? undefined : (matchFilters as Record<string, MatchFilter>)[s];
const filters = matchFilters as Record<string, MatchFilter> | undefined;

for (let i = 0; i < len; i++) {
const segment = segments[i];
const dynamic = segment[0] === ":";
const locSegment = dynamic ? locSegments[i] : locSegments[i].toLowerCase();
const key = dynamic ? segment.slice(1) : segment.toLowerCase();

if (dynamic && matchSegment(locSegment, matchFilter(key))) {
if (dynamic && matchSegment(locSegment, filters?.[key])) {
match.params[key] = locSegment;
} else if (dynamic || !matchSegment(locSegment, key)) {
return null;
Expand All @@ -114,7 +113,7 @@ export function createMatcher<S extends string>(

if (splat) {
const remainder = lenDiff ? locSegments.slice(-lenDiff).join("/") : "";
if (matchSegment(remainder, matchFilter(splat))) {
if (matchSegment(remainder, filters?.[splat])) {
match.params[splat] = remainder;
} else {
return null;
Expand All @@ -125,22 +124,16 @@ export function createMatcher<S extends string>(
};
}

function matchSegment(input: string, filter?: string | MatchFilter): boolean {
const isEqual = (s: string) => s === input;

if (filter === undefined) {
return true;
} else if (typeof filter === "string") {
return isEqual(filter);
} else if (typeof filter === "function") {
return (filter as Function)(input);
} else if (Array.isArray(filter)) {
return (filter as string[]).some(isEqual);
} else if (filter instanceof RegExp) {
return (filter as RegExp).test(input);
}
return false;
}
// a string filter is the segment itself; anything else not listed never matches
const matchSegment = (input: string, filter?: string | MatchFilter): boolean =>
filter === undefined ||
(typeof filter === "function"
? (filter as Function)(input)
: Array.isArray(filter)
? filter.includes(input)
: filter instanceof RegExp
? filter.test(input)
: filter === input);

export function scoreRoute(route: RouteDescription): number {
const [pattern, splat] = route.pattern.split("/*", 2);
Expand Down
Loading