diff --git a/.changeset/wet-fishes-tap.md b/.changeset/wet-fishes-tap.md new file mode 100644 index 000000000..126d9b48d --- /dev/null +++ b/.changeset/wet-fishes-tap.md @@ -0,0 +1,5 @@ +--- +"@solidjs/router": minor +--- + +Parallel Routes diff --git a/src/components.tsx b/src/components.tsx index c5a54098c..c0bc4f66b 100644 --- a/src/components.tsx +++ b/src/components.tsx @@ -1,16 +1,8 @@ /*@refresh skip*/ import type { JSX } from "solid-js"; import { createMemo, mergeProps, splitProps } from "solid-js"; -import { - useHref, - useLocation, - useNavigate, - useResolvedPath -} from "./routing.js"; -import type { - Location, - Navigator -} from "./types.js"; +import { useHref, useLocation, useNavigate, useResolvedPath } from "./routing.js"; +import type { Location, Navigator } from "./types.js"; import { normalizePath } from "./utils.js"; declare module "solid-js" { diff --git a/src/data/action.ts b/src/data/action.ts index 4a59d1bda..9066b9701 100644 --- a/src/data/action.ts +++ b/src/data/action.ts @@ -1,7 +1,13 @@ import { $TRACK, createMemo, createSignal, JSX, onCleanup, getOwner } from "solid-js"; import { isServer } from "solid-js/web"; import { useRouter } from "../routing.js"; -import type { RouterContext, Submission, SubmissionStub, Navigator, NarrowResponse } from "../types.js"; +import type { + RouterContext, + Submission, + SubmissionStub, + Navigator, + NarrowResponse +} from "../types.js"; import { mockBase } from "../utils.js"; import { cacheKeyOp, hashKey, revalidate, cache } from "./cache.js"; @@ -44,7 +50,8 @@ export function useSubmission, U>( {}, { get(_, property) { - if (submissions.length === 0 && property === "clear" || property === "retry") return (() => {}); + if ((submissions.length === 0 && property === "clear") || property === "retry") + return () => {}; return submissions[submissions.length - 1]?.[property as keyof Submission]; } } diff --git a/src/data/createAsync.ts b/src/data/createAsync.ts index e3218da11..c8f42161e 100644 --- a/src/data/createAsync.ts +++ b/src/data/createAsync.ts @@ -30,7 +30,8 @@ export function createAsync( } ): Accessor { let resource: () => T; - let prev = () => !resource || (resource as any).state === "unresolved" ? undefined : (resource as any).latest; + let prev = () => + !resource || (resource as any).state === "unresolved" ? undefined : (resource as any).latest; [resource] = createResource( () => subFetch(fn, untrack(prev)), v => v, @@ -67,7 +68,10 @@ export function createAsyncStore( } = {} ): Accessor { let resource: () => T; - let prev = () => !resource || (resource as any).state === "unresolved" ? undefined : unwrap((resource as any).latest); + let prev = () => + !resource || (resource as any).state === "unresolved" + ? undefined + : unwrap((resource as any).latest); [resource] = createResource( () => subFetch(fn, untrack(prev)), v => v, diff --git a/src/data/index.ts b/src/data/index.ts index 9c3809869..a351fcdcf 100644 --- a/src/data/index.ts +++ b/src/data/index.ts @@ -2,4 +2,3 @@ export { createAsync, createAsyncStore } from "./createAsync.js"; export { action, useSubmission, useSubmissions, useAction, type Action } from "./action.js"; export { cache, revalidate, type CachedFunction } from "./cache.js"; export { redirect, reload, json } from "./response.js"; - diff --git a/src/lifecycle.ts b/src/lifecycle.ts index c8f9f504e..4793be0f7 100644 --- a/src/lifecycle.ts +++ b/src/lifecycle.ts @@ -1,5 +1,10 @@ import { isServer } from "solid-js/web"; -import { BeforeLeaveLifecycle, BeforeLeaveListener, LocationChange, NavigateOptions } from "./types.js"; +import { + BeforeLeaveLifecycle, + BeforeLeaveListener, + LocationChange, + NavigateOptions +} from "./types.js"; export function createBeforeLeave(): BeforeLeaveLifecycle { let listeners = new Set(); @@ -24,7 +29,7 @@ export function createBeforeLeave(): BeforeLeaveLifecycle { from: l.location, retry: (force?: boolean) => { force && (ignore = true); - l.navigate(to as string, {...options, resolve: false}); + l.navigate(to as string, { ...options, resolve: false }); } }); return !e.defaultPrevented; diff --git a/src/routers/HashRouter.ts b/src/routers/HashRouter.ts index 9d5f28e51..5a8791d31 100644 --- a/src/routers/HashRouter.ts +++ b/src/routers/HashRouter.ts @@ -2,7 +2,12 @@ import type { JSX } from "solid-js"; import { setupNativeEvents } from "../data/events.js"; import type { BaseRouterProps } from "./components.js"; import { createRouter, scrollToHash, bindEvent } from "./createRouter.js"; -import { createBeforeLeave, keepDepth, notifyIfNotBlocked, saveCurrentDepth } from "../lifecycle.js"; +import { + createBeforeLeave, + keepDepth, + notifyIfNotBlocked, + saveCurrentDepth +} from "../lifecycle.js"; export function hashParser(str: string) { const to = str.replace(/^.*?#/, ""); @@ -16,7 +21,11 @@ export function hashParser(str: string) { return to; } -export type HashRouterProps = BaseRouterProps & { actionBase?: string, explicitLinks?: boolean, preload?: boolean }; +export type HashRouterProps = BaseRouterProps & { + actionBase?: string; + explicitLinks?: boolean; + preload?: boolean; +}; export function HashRouter(props: HashRouterProps): JSX.Element { const getSource = () => window.location.hash.slice(1); @@ -34,7 +43,10 @@ export function HashRouter(props: HashRouterProps): JSX.Element { scrollToHash(hash, scroll); saveCurrentDepth(); }, - init: notify => bindEvent(window, "hashchange", + init: notify => + bindEvent( + window, + "hashchange", notifyIfNotBlocked( notify, delta => !beforeLeave.confirm(delta && delta < 0 ? delta : getSource()) diff --git a/src/routers/MemoryRouter.ts b/src/routers/MemoryRouter.ts index 1a67b2b38..f5a737a75 100644 --- a/src/routers/MemoryRouter.ts +++ b/src/routers/MemoryRouter.ts @@ -41,7 +41,6 @@ export function createMemoryHistory() { scrollToHash(value.split("#")[1] || "", true); } }, 0); - }, back: () => { go(-1); @@ -60,7 +59,12 @@ export function createMemoryHistory() { }; } -export type MemoryRouterProps = BaseRouterProps & { history?: MemoryHistory, actionBase?: string, explicitLinks?: boolean, preload?: boolean }; +export type MemoryRouterProps = BaseRouterProps & { + history?: MemoryHistory; + actionBase?: string; + explicitLinks?: boolean; + preload?: boolean; +}; export function MemoryRouter(props: MemoryRouterProps): JSX.Element { const memoryHistory = props.history || createMemoryHistory(); diff --git a/src/routers/Router.ts b/src/routers/Router.ts index e79f01ca8..3f821c774 100644 --- a/src/routers/Router.ts +++ b/src/routers/Router.ts @@ -4,18 +4,30 @@ import { StaticRouter } from "./StaticRouter.js"; import { setupNativeEvents } from "../data/events.js"; import type { BaseRouterProps } from "./components.jsx"; import type { JSX } from "solid-js"; -import { createBeforeLeave, keepDepth, notifyIfNotBlocked, saveCurrentDepth } from "../lifecycle.js"; +import { + createBeforeLeave, + keepDepth, + notifyIfNotBlocked, + saveCurrentDepth +} from "../lifecycle.js"; -export type RouterProps = BaseRouterProps & { url?: string, actionBase?: string, explicitLinks?: boolean, preload?: boolean }; +export type RouterProps = BaseRouterProps & { + url?: string; + actionBase?: string; + explicitLinks?: boolean; + preload?: boolean; +}; export function Router(props: RouterProps): JSX.Element { if (isServer) return StaticRouter(props); const getSource = () => { const url = window.location.pathname.replace(/^\/+/, "/") + window.location.search; return { - value: props.transformUrl ? props.transformUrl(url) + window.location.hash : url + window.location.hash, + value: props.transformUrl + ? props.transformUrl(url) + window.location.hash + : url + window.location.hash, state: window.history.state - } + }; }; const beforeLeave = createBeforeLeave(); return createRouter({ @@ -29,7 +41,10 @@ export function Router(props: RouterProps): JSX.Element { scrollToHash(decodeURIComponent(window.location.hash.slice(1)), scroll); saveCurrentDepth(); }, - init: notify => bindEvent(window, "popstate", + init: notify => + bindEvent( + window, + "popstate", notifyIfNotBlocked(notify, delta => { if (delta && delta < 0) { return !beforeLeave.confirm(delta); @@ -39,7 +54,12 @@ export function Router(props: RouterProps): JSX.Element { } }) ), - create: setupNativeEvents(props.preload, props.explicitLinks, props.actionBase, props.transformUrl), + create: setupNativeEvents( + props.preload, + props.explicitLinks, + props.actionBase, + props.transformUrl + ), utils: { go: delta => window.history.go(delta), beforeLeave diff --git a/src/routers/StaticRouter.ts b/src/routers/StaticRouter.ts index dc5500c02..cf308229e 100644 --- a/src/routers/StaticRouter.ts +++ b/src/routers/StaticRouter.ts @@ -11,9 +11,9 @@ export type StaticRouterProps = BaseRouterProps & { url?: string }; export function StaticRouter(props: StaticRouterProps): JSX.Element { let e; - const url = props.url || ((e = getRequestEvent()) && getPath(e.request.url)) || "" + const url = props.url || ((e = getRequestEvent()) && getPath(e.request.url)) || ""; const obj = { - value: props.transformUrl ? props.transformUrl(url) : url, + value: props.transformUrl ? props.transformUrl(url) : url }; return createRouterComponent({ signal: [() => obj, next => Object.assign(obj, next)] diff --git a/src/routers/components.tsx b/src/routers/components.tsx index eceb4b76b..e79f58b54 100644 --- a/src/routers/components.tsx +++ b/src/routers/components.tsx @@ -30,6 +30,8 @@ import type { RouterContext, Branch, RouteSectionProps, + RouteMatch, + OutputMatch, RoutePreloadFunc } from "../types.js"; @@ -58,12 +60,16 @@ export const createRouterComponent = (router: RouterIntegration) => (props: Base const routerState = createRouterContext(router, branches, () => context, { base, singleFlight: props.singleFlight, - transformUrl: props.transformUrl, + transformUrl: props.transformUrl }); router.create && router.create(routerState); return ( - + {(context = getOwner()!) && null} @@ -91,7 +97,7 @@ function Root(props: { return ( {Root => ( - + {props.children} )} @@ -99,87 +105,180 @@ function Root(props: { ); } +function createOutputMatches(matches: RouteMatch[]): OutputMatch[] { + return matches.map(({ route, path, params, slots }) => { + const match: OutputMatch = { + path: route.originalPath, + pattern: route.pattern, + match: path, + params, + info: route.info + }; + + if (slots) { + match.slots = {}; + + for (const [slot, matches] of Object.entries(slots)) + match.slots[slot] = createOutputMatches(matches); + } + + return match; + }); +} + function Routes(props: { routerState: RouterContext; branches: Branch[] }) { if (isServer) { const e = getRequestEvent(); - if (e && e.router && e.router.dataOnly) { + if (e?.router?.dataOnly) { dataOnly(e, props.routerState, props.branches); return; } - e && - ((e.router || (e.router = {})).matches || - (e.router.matches = props.routerState.matches().map(({ route, path, params }) => ({ - path: route.originalPath, - pattern: route.pattern, - match: path, - params, - info: route.info - })))); + if (e) { + (e.router ??= {}).matches ??= createOutputMatches(props.routerState.matches()); + } } - const disposers: (() => void)[] = []; + type Disposer = { dispose?: () => void; slots?: Record }; + + const globalDisposers: Disposer[] = []; let root: RouteContext | undefined; - const routeStates = createMemo( - on(props.routerState.matches, (nextMatches, prevMatches, prev: RouteContext[] | undefined) => { - let equal = prevMatches && nextMatches.length === prevMatches.length; - const next: RouteContext[] = []; - for (let i = 0, len = nextMatches.length; i < len; i++) { - const prevMatch = prevMatches && prevMatches[i]; - const nextMatch = nextMatches[i]; - - if (prev && prevMatch && nextMatch.route.key === prevMatch.route.key) { - next[i] = prev[i]; - } else { - equal = false; - if (disposers[i]) { - disposers[i](); - } + function disposeAll({ dispose, slots }: Disposer) { + dispose?.(); + if (slots) { + for (const d of Object.values(slots)) d.forEach(disposeAll); + } + } + + // Renders an array of route matches, recursively calling itself to branch + // off for slots. Almost but not quite a regular tree since children aren't included in slots + function renderRouteContexts( + matches: RouteMatch[], + parent: RouteContext, + disposers: Disposer[], + prev?: { matches: RouteMatch[]; contexts: RouteContext[] }, + fullyRenderedRoutes = () => routeStates(), + getLiveMatches = () => props.routerState.matches() + ): RouteContext[] { + let equal = matches.length === prev?.matches.length; + + const renderedContexts: RouteContext[] = []; + + // matches get processed linearly unless a slot is encountered, at which point + // this function recurses + for (let i = 0; i < matches.length; i++) { + const match = matches[i]; + const prevMatch = prev?.matches[i]; + const prevContext = prev?.contexts[i]; + + // the context above the one about to be rendered + const matchParentContext = renderedContexts[i - 1] ?? parent; - createRoot(dispose => { - disposers[i] = dispose; - next[i] = createRouteContext( - props.routerState, - next[i - 1] || props.routerState.base, - createOutlet(() => routeStates()[i + 1]), - () => props.routerState.matches()[i] - ); - }); + const slotContexts: Record = {}; + // outlets rendered for the slots of the parent - includes 'children' + const slotOutlets: Record JSX.Element> = {}; + + if (match.slots) { + const slotsDisposers: Record = ((disposers[i] ??= {}).slots ??= {}); + + for (const [slot, matches] of Object.entries(match.slots)) { + slotContexts[slot] = renderRouteContexts( + matches, + renderedContexts[i], + (slotsDisposers[slot] ??= []), + prevMatch?.slots?.[slot] && prevContext?.slots?.[slot] + ? { matches: prevMatch?.slots?.[slot], contexts: prevContext?.slots?.[slot] } + : undefined, + () => fullyRenderedRoutes()[i]?.slots?.[slot] ?? [], + () => getLiveMatches()[i]?.slots?.[slot] ?? [] + ); } } - disposers.splice(nextMatches.length).forEach(dispose => dispose()); + if (prev && match.route.key === prevMatch?.route.key) { + renderedContexts[i] = prev.contexts[i]; + renderedContexts[i].slots = slotContexts; + } else { + equal = false; + + if (disposers?.[i]) disposers[i].dispose?.(); + + createRoot(dispose => { + disposers[i] = { + ...disposers[i], + dispose + }; + + for (const slot of Object.keys(match.slots ?? {})) { + const fullyRenderedSlotRoutes = () => fullyRenderedRoutes()[i]?.slots?.[slot]; + + slotOutlets[slot] = createOutlet(() => fullyRenderedSlotRoutes()?.[0]); + } + + // children renders the next match in the next context + slotOutlets.children = createOutlet(() => fullyRenderedRoutes()[i + 1]); + + renderedContexts[i] = createRouteContext( + props.routerState, + matchParentContext, + slotOutlets, + () => getLiveMatches()[i] + ); + + renderedContexts[i].slots = slotContexts; + }); + } + } + + disposers.splice(renderedContexts.length).forEach(disposeAll); + + if (prev && equal) return prev.contexts; - if (prev && equal) { - return prev; + return renderedContexts; + } + + const routeStates = createMemo( + on( + props.routerState.matches, + (nextMatches, prevMatches, prevContexts: RouteContext[] | undefined) => { + const next = renderRouteContexts( + nextMatches, + props.routerState.base, + globalDisposers, + prevMatches && prevContexts ? { matches: prevMatches, contexts: prevContexts } : undefined + ); + + root = next[0]; + + return next; } - root = next[0]; - return next; - }) + ) ); + return createOutlet(() => routeStates() && root)(); } -const createOutlet = (child: () => RouteContext | undefined) => { - return () => ( +const createOutlet = (child: () => RouteContext | undefined) => () => + ( {child => {child.outlet()}} ); -}; -export type RouteProps = { +export type RouteProps = { path?: S | S[]; children?: JSX.Element; preload?: RoutePreloadFunc; matchFilters?: MatchFilters; - component?: Component>; + component?: Component>; info?: Record; /** @deprecated use preload */ load?: RoutePreloadFunc; }; -export const Route = (props: RouteProps) => { +export const Route = ( + props: RouteProps +) => { const childRoutes = children(() => props.children); return mergeProps(props, { get children() { @@ -196,15 +295,26 @@ function dataOnly(event: RequestEvent, routerState: RouterContext, branches: Bra new URL(event.router!.previousUrl || event.request.url).pathname ); const matches = getRouteMatches(branches, url.pathname); - for (let match = 0; match < matches.length; match++) { - if (!prevMatches[match] || matches[match].route !== prevMatches[match].route) - event.router!.dataOnly = true; - const { route, params } = matches[match]; - route.preload && - route.preload({ - params, - location: routerState.location, - intent: "preload" - }); + + preloadMatches(prevMatches, matches); + + function preloadMatches(prevMatches: RouteMatch[], matches: RouteMatch[]) { + for (let match = 0; match < matches.length; match++) { + if (!prevMatches[match] || matches[match].route !== prevMatches[match].route) + event.router!.dataOnly = true; + const { route, params } = matches[match]; + route.preload && + route.preload({ + params, + location: routerState.location, + intent: "preload" + }); + + if (matches[match].slots) { + for (const [slot, slotMatches] of Object.entries(matches[match].slots ?? {})) { + preloadMatches(prevMatches[match].slots?.[slot] ?? [], slotMatches); + } + } + } } } diff --git a/src/routers/createRouter.ts b/src/routers/createRouter.ts index b2265e03c..b4f3a79af 100644 --- a/src/routers/createRouter.ts +++ b/src/routers/createRouter.ts @@ -23,11 +23,11 @@ function querySelector(selector: string) { } export function createRouter(config: { - get: () => string | LocationChange, - set: (next: LocationChange) => void, - init?: (notify: (value?: string | LocationChange) => void) => () => void, - create?: (router: RouterContext) => void, - utils?: Partial + get: () => string | LocationChange; + set: (next: LocationChange) => void; + init?: (notify: (value?: string | LocationChange) => void) => () => void; + create?: (router: RouterContext) => void; + utils?: Partial; }) { let ignore = false; const wrap = (value: string | LocationChange) => (typeof value === "string" ? { value } : value); diff --git a/src/routers/index.ts b/src/routers/index.ts index f18ac0e2a..a1b331b65 100644 --- a/src/routers/index.ts +++ b/src/routers/index.ts @@ -8,4 +8,4 @@ export type { HashRouterProps } from "./HashRouter.js"; export { MemoryRouter, createMemoryHistory } from "./MemoryRouter.js"; export type { MemoryRouterProps, MemoryHistory } from "./MemoryRouter.js"; export { StaticRouter } from "./StaticRouter.js"; -export type { StaticRouterProps } from "./StaticRouter.js"; \ No newline at end of file +export type { StaticRouterProps } from "./StaticRouter.js"; diff --git a/src/routing.ts b/src/routing.ts index 208cfd6a8..c778266f5 100644 --- a/src/routing.ts +++ b/src/routing.ts @@ -77,7 +77,7 @@ export const useHref = (to: () => string | undefined) => { export const useNavigate = () => useRouter().navigatorFactory(); export const useLocation = () => useRouter().location as Location; export const useIsRouting = () => useRouter().isRouting; -export const usePreloadRoute = () => useRouter().preloadRoute +export const usePreloadRoute = () => useRouter().preloadRoute; export const useMatch = (path: () => S, matchFilters?: MatchFilters) => { const location = useLocation(); @@ -197,6 +197,13 @@ export function createBranches( const routes = createRoutes(def, base); for (const route of routes) { stack.push(route); + + if (def.slots) { + for (const [name, slot] of Object.entries(def.slots) as [string, RouteDefinition][]) { + (route.slots ??= {})[name] = createBranches(slot, route.pattern); + } + } + const isEmptyArray = Array.isArray(def.children) && def.children.length === 0; if (def.children && !isEmptyArray) { createBranches(def.children, route.pattern, stack, branches); @@ -216,11 +223,22 @@ export function createBranches( export function getRouteMatches(branches: Branch[], location: string): RouteMatch[] { for (let i = 0, len = branches.length; i < len; i++) { - const match = branches[i].matcher(location); - if (match) { - return match; + const matches = branches[i].matcher(location); + if (!matches) continue; + + for (const match of matches) { + if (match.route.slots) { + match.slots = {}; + + for (const [name, branches] of Object.entries(match.route.slots)) { + match.slots[name] = getRouteMatches(branches, location); + } + } } + + return matches; } + return []; } @@ -462,34 +480,43 @@ export function createRouterContext( function preloadRoute(url: URL, options: { preloadData?: boolean } = {}) { const matches = getRouteMatches(branches(), url.pathname); + const prevIntent = intent; intent = "preload"; - for (let match in matches) { - const { route, params } = matches[match]; - route.component && - (route.component as MaybePreloadableComponent).preload && - (route.component as MaybePreloadableComponent).preload!(); - const { preload } = route; - inPreloadFn = true; - options.preloadData && - preload && - runWithOwner(getContext!(), () => - preload({ - params, - location: { - pathname: url.pathname, - search: url.search, - hash: url.hash, - query: extractSearchParams(url), - state: null, - key: "" - }, - intent: "preload" - }) - ); - inPreloadFn = false; - } + preloadMatches(matches); intent = prevIntent; + + function preloadMatches(matches: RouteMatch[]) { + for (const match in matches) { + const { route, params, slots } = matches[match]; + route.component && + (route.component as MaybePreloadableComponent).preload && + (route.component as MaybePreloadableComponent).preload!(); + const { preload } = route; + inPreloadFn = true; + options.preloadData && + preload && + runWithOwner(getContext!(), () => + preload({ + params, + location: { + pathname: url.pathname, + search: url.search, + hash: url.hash, + query: extractSearchParams(url), + state: null, + key: "" + }, + intent: "preload" + }) + ); + inPreloadFn = false; + + if (slots) { + for (const matches of Object.values(slots)) preloadMatches(matches); + } + } + } } function initFromFlash() { @@ -503,7 +530,7 @@ export function createRouterContext( export function createRouteContext( router: RouterContext, parent: RouteContext, - outlet: () => JSX.Element, + { children, ...slots }: Record JSX.Element>, match: () => RouteMatch ): RouteContext { const { base, location, params } = router; @@ -528,10 +555,14 @@ export function createRouteContext( location, data, get children() { - return outlet(); - } + return children(); + }, + slots: Object.entries(slots).reduce((acc, [key, slot]) => { + Object.defineProperty(acc, key, { get: slot }); + return acc; + }, {}) }) - : outlet(), + : children(), resolvePath(to: string) { return resolvePath(base.path(), to, path()); } diff --git a/src/types.ts b/src/types.ts index 5be02dad0..a43ae1be4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -5,9 +5,9 @@ declare module "solid-js/web" { response: { status?: number; statusText?: string; - headers: Headers + headers: Headers; }; - router? : { + router?: { matches?: OutputMatch[]; cache?: Map; submission?: { @@ -18,7 +18,7 @@ declare module "solid-js/web" { dataOnly?: boolean | string[]; data?: Record; previousUrl?: string; - } + }; serverOnly?: boolean; } } @@ -74,22 +74,28 @@ export interface RoutePreloadFuncArgs { export type RoutePreloadFunc = (args: RoutePreloadFuncArgs) => T; -export interface RouteSectionProps { +export interface RouteSectionProps { params: Params; location: Location; data: T; children?: JSX.Element; + slots: Record; } -export type RouteDefinition = { +export type RouteDefinition< + S extends string | string[] = any, + T = unknown, + TSlots extends string = never +> = { path?: S; matchFilters?: MatchFilters; preload?: RoutePreloadFunc; children?: RouteDefinition | RouteDefinition[]; - component?: Component>; + component?: Component>; info?: Record; /** @deprecated use preload */ load?: RoutePreloadFunc; + slots?: Record>; }; export type MatchFilter = readonly string[] | RegExp | ((s: string) => boolean); @@ -116,6 +122,7 @@ export interface PathMatch { export interface RouteMatch extends PathMatch { route: RouteDescription; + slots?: Record; } export interface OutputMatch { @@ -124,6 +131,7 @@ export interface OutputMatch { match: string; params: Params; info?: Record; + slots?: Record; } export interface RouteDescription { @@ -135,6 +143,7 @@ export interface RouteDescription { matcher: (location: string) => PathMatch | null; matchFilters?: MatchFilters; info?: Record; + slots?: Record; } export interface Branch { @@ -145,11 +154,11 @@ export interface Branch { export interface RouteContext { parent?: RouteContext; - child?: RouteContext; pattern: string; path: () => string; outlet: () => JSX.Element; resolvePath(to: string): string | undefined; + slots?: Record; } export interface RouterUtils { @@ -224,9 +233,12 @@ export type NarrowResponse = T extends CustomResponse ? U : Exclude< export type RouterResponseInit = Omit & { revalidate?: string | string[] }; // export type CustomResponse = Response & { customBody: () => T }; // hack to avoid it thinking it inherited from Response -export type CustomResponse = Omit & { customBody: () => T; clone(...args: readonly unknown[]): CustomResponse }; +export type CustomResponse = Omit & { + customBody: () => T; + clone(...args: readonly unknown[]): CustomResponse; +}; /** @deprecated */ export type RouteLoadFunc = RoutePreloadFunc; /** @deprecated */ -export type RouteLoadFuncArgs = RoutePreloadFuncArgs; \ No newline at end of file +export type RouteLoadFuncArgs = RoutePreloadFuncArgs; diff --git a/src/utils.ts b/src/utils.ts index 0ba560335..dba1f78b3 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,9 +1,16 @@ import { createMemo, getOwner, runWithOwner } from "solid-js"; -import type { MatchFilter, MatchFilters, Params, PathMatch, RouteDescription, SetParams } from "./types.ts"; +import type { + MatchFilter, + MatchFilters, + Params, + PathMatch, + RouteDescription, + SetParams +} from "./types.ts"; const hasSchemeRegex = /^(?:[a-z0-9]+:)?\/\//i; const trimPathRegex = /^\/+|(\/)\/+$/g; -export const mockBase = "http://sr" +export const mockBase = "http://sr"; export function normalizePath(path: string, omitSlash: boolean = false) { const s = path.replace(trimPathRegex, "$1"); diff --git a/test/route.spec.ts b/test/route.spec.ts index e86d3c5f2..82833f9ec 100644 --- a/test/route.spec.ts +++ b/test/route.spec.ts @@ -1,4 +1,4 @@ -import { vi } from 'vitest' +import { vi } from "vitest"; import { createBranch, createBranches, createRoutes } from "../src/routing.js"; import type { RouteDefinition } from "../src/index.js"; @@ -174,7 +174,6 @@ describe("createRoutes should", () => { expect(match).not.toBeNull(); expect(match.path).toBe("/foo/123/bar/solid.html"); }); - }); describe(`expand optional parameters`, () => { @@ -564,4 +563,53 @@ describe("createBranches should", () => { expect(branchPaths).toEqual(["/root/%E3%81%BB%E3%81%92/:ふが/*ぴよ"]); }); + + describe(`traverse slots`, () => { + test("with no children", () => { + const branches = createBranches({ + path: "root", + children: { + path: "nested", + slots: { nestedSlot: {} } + }, + slots: { rootSlot: {} } + }); + + const root = branches[0].routes.find(r => r.originalPath === "root")!; + expect(root.slots).toHaveProperty("rootSlot"); + const rootSlot = root.slots!.rootSlot; + expect(rootSlot.length).toBe(1); + expect(rootSlot[0].routes.length).toBe(1); + expect(rootSlot[0].routes[0].pattern).toEqual("/root"); + + const nested = branches[0].routes.find(r => r.originalPath === "nested")!; + expect(nested.slots).toHaveProperty("nestedSlot"); + const nestedSlot = nested.slots!.nestedSlot; + expect(nestedSlot.length).toBe(1); + expect(nestedSlot[0].routes.length).toBe(1); + expect(nestedSlot[0].routes[0].pattern).toEqual("/root/nested"); + }); + + test("with children", () => { + const branches = createBranches({ + path: "root", + children: { + path: "nested", + slots: { nestedSlot: { children: [{ path: "one" }, { path: "two" }] } } + }, + slots: { rootSlot: { children: [{ path: "one" }, { path: "two" }] } } + }); + + const rootSlot = branches[0].routes.find(r => r.originalPath === "root")!.slots!.rootSlot; + expect(rootSlot.length).toBe(2); + expect(rootSlot[1].routes.length).toBe(2); + expect(rootSlot[1].routes[1].pattern).toEqual("/root/two"); + + const nestedSlot = branches[0].routes.find(r => r.originalPath === "nested")!.slots! + .nestedSlot; + expect(nestedSlot.length).toBe(2); + expect(nestedSlot[1].routes.length).toBe(2); + expect(nestedSlot[1].routes[1].pattern).toEqual("/root/nested/two"); + }); + }); });