From 88b48ced7fe676ff533c69813c1416be385f4a08 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Sun, 19 May 2024 15:03:01 +0800 Subject: [PATCH 01/12] parallel routes --- src/index.tsx | 3 +- src/routers/components.tsx | 188 +++++++++++++++++++++++++++++-------- src/routing.ts | 46 ++++++--- src/types.ts | 32 +++++-- 4 files changed, 206 insertions(+), 63 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index b8b73634e..2dc0ccefe 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -6,12 +6,13 @@ export { useIsRouting, useLocation, useMatch, + useMatches, useCurrentMatches, useNavigate, useParams, useResolvedPath, useSearchParams, - useBeforeLeave, + useBeforeLeave } from "./routing.js"; export { mergeSearchString as _mergeSearchString } from "./utils.js"; export * from "./data/index.js"; diff --git a/src/routers/components.tsx b/src/routers/components.tsx index 087d4f833..1bcc174c9 100644 --- a/src/routers/components.tsx +++ b/src/routers/components.tsx @@ -6,9 +6,12 @@ import { children, createMemo, createRoot, + createSignal, + For, getOwner, mergeProps, on, + onCleanup, Show, untrack } from "solid-js"; @@ -20,7 +23,8 @@ import { getRouteMatches, RouteContextObj, RouterContextObj, - setInLoadFn + setInLoadFn, + useMatch } from "../routing.js"; import type { MatchFilters, @@ -30,7 +34,8 @@ import type { RouterIntegration, RouterContext, Branch, - RouteSectionProps + RouteSectionProps, + RouteMatch } from "../types.js"; export type BaseRouterProps = { @@ -56,7 +61,7 @@ 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 ( @@ -89,7 +94,7 @@ function Root(props: { return ( {Root => ( - + {props.children} )} @@ -115,67 +120,172 @@ function Routes(props: { routerState: RouterContext; branches: Branch[] }) { })))); } - const disposers: (() => void)[] = []; + type Disposers = { dispose: () => void; slots?: Record }[]; + + const globalDisposers: Disposers = []; 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(disposer: Disposers) { + for (const { dispose, slots } of disposer) { + dispose(); + slots && Object.values(slots).forEach(disposeAll); + } + } + + function renderRouteMatches( + nextMatches: RouteMatch[], + prevMatches?: RouteMatch[], + prev?: RouteContext[], + disposerPath: [number, string][] = [] + ): RouteContext[] { + 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]; - createRoot(dispose => { - disposers[i] = dispose; - next[i] = createRouteContext( - props.routerState, - next[i - 1] || props.routerState.base, - createOutlet(() => routeStates()[i + 1]), - () => props.routerState.matches()[i] + if (prev && prevMatch && nextMatch.route.key === prevMatch.route.key) { + next[i] = prev[i]; + + if (prevMatch.slots && nextMatch.slots) { + const nextSlots = (next[i].slots ??= {}); + for (const [name, slot] of Object.entries(nextMatch.slots)) { + nextSlots[name] = renderRouteMatches( + slot, + prevMatch.slots?.[name], + prev[i].slots?.[name], + [...disposerPath, [i, name]] ); - }); + } } - } + } else { + equal = false; + + let disposers = globalDisposers; + for (const [i, slot] of disposerPath) { + disposers = (disposers[i].slots ??= {})[slot] ??= []; + } + + if (disposers[i]) { + disposeAll([disposers[i]]); + } + + createRoot(dispose => { + disposers[i] = { dispose }; + + const outlets: Record JSX.Element> = {}; + const slots: Record = {}; + + if (nextMatch.slots) + for (const [name, slot] of Object.entries(nextMatch.slots)) { + const rendered = renderRouteMatches( + slot, + prevMatch?.slots?.[name], + prev?.[i].slots?.[name], + [...disposerPath, [i, name]] + ); + slots[name] = rendered; + outlets[name] = createOutlet(() => { + const context = rendered[0]; + return { + context, + outlet: context.outlet + }; + }); + } - disposers.splice(nextMatches.length).forEach(dispose => dispose()); + outlets["children"] = createOutlet(() => { + const context = createMemo(() => { + let traversed = routeStates(); - if (prev && equal) { - return prev; + for (const [i, slot] of disposerPath) { + traversed = traversed[i].slots?.[slot]!; + } + return traversed[i + 1]; + }); + + return { + context: context(), + outlet: context().outlet + }; + }); + + next[i] = createRouteContext( + props.routerState, + next[i - 1] || props.routerState.base, + outlets, + nextMatches[i] + ); + + next[i].slots = slots; + }); } + } + + globalDisposers.splice(nextMatches.length).forEach(disposer => { + disposeAll([disposer]); + }); + + if (prev && equal) { + return prev; + } + + return next; + } + + const routeStates = createMemo( + on(props.routerState.matches, (nextMatches, prevMatches, prev: RouteContext[] | undefined) => { + const next = renderRouteMatches(nextMatches, prevMatches, prev); + root = next[0]; return next; }) ); - return createOutlet(() => routeStates() && root)(); + + return createOutlet(() => { + if (routeStates() && root) return root; + })(); } -const createOutlet = (child: () => RouteContext | undefined) => { +const createOutlet = ( + child: () => RouteContext | { context: RouteContext; outlet: () => JSX.Element } | undefined +) => { + let memoed: { context: RouteContext; outlet: () => JSX.Element } | undefined; + + // not using createMemo as we can't call routeStates eagerly + const _child = () => { + const c = child(); + if (!c) return; + if ("context" in c) return c; + else if (memoed?.context === c) return memoed; + return (memoed = { + context: c, + outlet: c.outlet + }); + }; + return () => ( - - {child => {child.outlet()}} + + {child => ( + {child.outlet()} + )} ); }; -export type RouteProps = { +export type RouteProps = { path?: S | S[]; children?: JSX.Element; load?: RouteLoadFunc; matchFilters?: MatchFilters; - component?: Component>; + component?: Component>; info?: Record; }; -export const Route = (props: RouteProps) => { +export const Route = ( + props: RouteProps +) => { const childRoutes = children(() => props.children); return mergeProps(props, { get children() { diff --git a/src/routing.ts b/src/routing.ts index 49b01a762..8f263f6b4 100644 --- a/src/routing.ts +++ b/src/routing.ts @@ -1,4 +1,4 @@ -import { JSX, Accessor, runWithOwner } from "solid-js"; +import { JSX, Accessor, runWithOwner, createEffect } from "solid-js"; import { createComponent, createContext, @@ -78,6 +78,7 @@ export const useNavigate = () => useRouter().navigatorFactory(); export const useLocation = () => useRouter().location as Location; export const useIsRouting = () => useRouter().isRouting; +export const useMatches = () => useRouter().matches; export const useMatch = (path: () => S, matchFilters?: MatchFilters) => { const location = useLocation(); const matchers = createMemo(() => @@ -123,8 +124,9 @@ export const useBeforeLeave = (listener: (e: BeforeLeaveEventArgs) => void) => { onCleanup(s); }; +// specific to route-level stuff, not slots since those are nested and act like children export function createRoutes(routeDef: RouteDefinition, base: string = ""): Route[] { - const { component, load, children, info } = routeDef; + const { component, load, children, info, slots } = routeDef; const isLeaf = !children || (Array.isArray(children) && !children.length); const shared = { @@ -134,7 +136,7 @@ export function createRoutes(routeDef: RouteDefinition, base: string = ""): Rout info }; - return asArray(routeDef.path).reduce((acc, originalPath) => { + return asArray(routeDef.path ?? "").reduce((acc, originalPath) => { for (const expandedPath of expandOptionals(originalPath)) { const path = joinPaths(base, expandedPath); let pattern = isLeaf ? path : path.split("/*", 1)[0]; @@ -196,9 +198,16 @@ export function createBranches( const routes = createRoutes(def, base); for (const route of routes) { stack.push(route); + const isEmptyArray = Array.isArray(def.children) && def.children.length === 0; if (def.children && !isEmptyArray) { createBranches(def.children, route.pattern, stack, branches); + + if (def.slots) { + for (const [name, slot] of Object.entries(def.slots) as [string, RouteDefinition][]) { + (route.slots ??= {})[name] = createBranches(slot, route.pattern); + } + } } else { const branch = createBranch([...stack], branches.length); branches.push(branch); @@ -217,6 +226,11 @@ export function getRouteMatches(branches: Branch[], location: string): RouteMatc for (let i = 0, len = branches.length; i < len; i++) { const match = branches[i].matcher(location); if (match) { + match.forEach(m => { + for (const [name, slot] of Object.entries(m.route.slots ?? {})) { + (m.slots ??= {})[name] = getRouteMatches(slot, location); + } + }); return match; } } @@ -282,7 +296,7 @@ export function createRouterContext( integration: RouterIntegration, branches: () => Branch[], getContext?: () => any, - options: { base?: string; singleFlight?: boolean, transformUrl?: (url: string) => string } = {} + options: { base?: string; singleFlight?: boolean; transformUrl?: (url: string) => string } = {} ): RouterContext { const { signal: [source, setSource], @@ -494,21 +508,21 @@ export function createRouterContext( function initFromFlash() { const e = getRequestEvent(); - return (e && e.router && e.router.submission - ? [e.router.submission] - : []) as Array>; + return (e && e.router && e.router.submission ? [e.router.submission] : []) as Array< + Submission + >; } } export function createRouteContext( router: RouterContext, parent: RouteContext, - outlet: () => JSX.Element, - match: () => RouteMatch, + { children, ...slots }: Record JSX.Element>, + match: RouteMatch ): RouteContext { const { base, location, params } = router; - const { pattern, component, load } = match().route; - const path = createMemo(() => match().path); + const { pattern, component, load } = match.route; + const path = createMemo(() => match.path); component && (component as MaybePreloadableComponent).preload && @@ -528,10 +542,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 73c59471a..520202572 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,21 +74,33 @@ export interface RouteLoadFuncArgs { export type RouteLoadFunc = (args: RouteLoadFuncArgs) => 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 = any +> = { path?: S; matchFilters?: MatchFilters; load?: RouteLoadFunc; children?: RouteDefinition | RouteDefinition[]; - component?: Component>; + component?: Component>; info?: Record; -}; +} & ( + | { component?: Component>; slots?: never } + // slots require a component to render them + | { + component: Component>; + slots: Record; + } +); export type MatchFilter = readonly string[] | RegExp | ((s: string) => boolean); @@ -114,6 +126,7 @@ export interface PathMatch { export interface RouteMatch extends PathMatch { route: Route; + slots?: Record; } export interface OutputMatch { @@ -133,6 +146,7 @@ export interface Route { matcher: (location: string) => PathMatch | null; matchFilters?: MatchFilters; info?: Record; + slots?: Record; } export interface Branch { @@ -143,11 +157,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 { @@ -206,4 +220,4 @@ export interface MaybePreloadableComponent extends Component { preload?: () => void; } -export type CacheEntry = [number, any, Intent | undefined, Signal & { count: number }]; \ No newline at end of file +export type CacheEntry = [number, any, Intent | undefined, Signal & { count: number }]; From bf70779d52d72e06380ecfd7e6040475ed89d2ba Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Sun, 19 May 2024 18:46:59 +0800 Subject: [PATCH 02/12] works with solid start --- src/routers/components.tsx | 5 +++++ src/routing.ts | 12 ++++++------ src/types.ts | 2 +- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/routers/components.tsx b/src/routers/components.tsx index 1bcc174c9..d9c9b446d 100644 --- a/src/routers/components.tsx +++ b/src/routers/components.tsx @@ -185,9 +185,14 @@ function Routes(props: { routerState: RouterContext; branches: Branch[] }) { prev?.[i].slots?.[name], [...disposerPath, [i, name]] ); + + if (rendered.length === 0) continue; + slots[name] = rendered; outlets[name] = createOutlet(() => { const context = rendered[0]; + if (!context) return; + return { context, outlet: context.outlet diff --git a/src/routing.ts b/src/routing.ts index 8f263f6b4..359885372 100644 --- a/src/routing.ts +++ b/src/routing.ts @@ -199,15 +199,15 @@ export function createBranches( 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); - - if (def.slots) { - for (const [name, slot] of Object.entries(def.slots) as [string, RouteDefinition][]) { - (route.slots ??= {})[name] = createBranches(slot, route.pattern); - } - } } else { const branch = createBranch([...stack], branches.length); branches.push(branch); diff --git a/src/types.ts b/src/types.ts index 520202572..293415d05 100644 --- a/src/types.ts +++ b/src/types.ts @@ -98,7 +98,7 @@ export type RouteDefinition< // slots require a component to render them | { component: Component>; - slots: Record; + slots: Record; } ); From f826cc6f6713c14efdc1805e814432010fc49719 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Mon, 20 May 2024 16:15:10 +0800 Subject: [PATCH 03/12] ignore empty disposers --- src/routers/components.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/routers/components.tsx b/src/routers/components.tsx index d9c9b446d..afd1f0516 100644 --- a/src/routers/components.tsx +++ b/src/routers/components.tsx @@ -164,7 +164,10 @@ function Routes(props: { routerState: RouterContext; branches: Branch[] }) { let disposers = globalDisposers; for (const [i, slot] of disposerPath) { - disposers = (disposers[i].slots ??= {})[slot] ??= []; + const disposer = disposers[i]; + if (!disposer) break; + + disposers = (disposer.slots ??= {})[slot] ??= []; } if (disposers[i]) { From e1ac939f9d3d6388845b44f705090ce5c8e8a532 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Mon, 20 May 2024 21:10:06 +0800 Subject: [PATCH 04/12] reimplement renderRouteContexts --- src/routers/components.tsx | 291 +++++++++++++++++++++---------------- src/routing.ts | 2 +- 2 files changed, 164 insertions(+), 129 deletions(-) diff --git a/src/routers/components.tsx b/src/routers/components.tsx index afd1f0516..3d28378b1 100644 --- a/src/routers/components.tsx +++ b/src/routers/components.tsx @@ -23,8 +23,7 @@ import { getRouteMatches, RouteContextObj, RouterContextObj, - setInLoadFn, - useMatch + setInLoadFn } from "../routing.js"; import type { MatchFilters, @@ -132,155 +131,80 @@ function Routes(props: { routerState: RouterContext; branches: Branch[] }) { } } - function renderRouteMatches( - nextMatches: RouteMatch[], - prevMatches?: RouteMatch[], - prev?: RouteContext[], - disposerPath: [number, string][] = [] + // Renders an array of route matches, recursively calling itself to branch + // off for slots + // Takes linear branches of matches and creates linear branches of contexts + // Almost but not quite a regular tree since children aren't included in slots + function renderRouteContexts( + matches: RouteMatch[], + parent: RouteContext = props.routerState.base ): RouteContext[] { - 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]; - - if (prevMatch.slots && nextMatch.slots) { - const nextSlots = (next[i].slots ??= {}); - for (const [name, slot] of Object.entries(nextMatch.slots)) { - nextSlots[name] = renderRouteMatches( - slot, - prevMatch.slots?.[name], - prev[i].slots?.[name], - [...disposerPath, [i, name]] - ); - } - } - } else { - equal = false; + const renderedContexts: RouteContext[] = []; - let disposers = globalDisposers; - for (const [i, slot] of disposerPath) { - const disposer = disposers[i]; - if (!disposer) break; + for (let i = 0; i < matches.length; i++) { + // matches get processed linearly unless a slot is encountered, at which point + // this function recurses + const match = matches[i]; - disposers = (disposer.slots ??= {})[slot] ??= []; - } + // the context above the one about to be rendered + const matchParentContext = renderedContexts[i - 1] ?? parent; + + // outlets rendered for the slots of the parent - includes 'children' + const slotOutlets: Record JSX.Element> = {}; + // context branches for each slot of this match + let slotContexts: Record | undefined; - if (disposers[i]) { - disposeAll([disposers[i]]); + const dispose = createRoot(dispose => { + if ("slots" in match && match.slots) { + slotContexts = {}; + + for (const [slot, matches] of Object.entries(match.slots)) { + slotContexts[slot] = renderRouteContexts(matches, matchParentContext); + slotOutlets[slot] = createOutlet(() => slotContexts?.[slot]?.[0]); + } } - createRoot(dispose => { - disposers[i] = { dispose }; - - const outlets: Record JSX.Element> = {}; - const slots: Record = {}; - - if (nextMatch.slots) - for (const [name, slot] of Object.entries(nextMatch.slots)) { - const rendered = renderRouteMatches( - slot, - prevMatch?.slots?.[name], - prev?.[i].slots?.[name], - [...disposerPath, [i, name]] - ); - - if (rendered.length === 0) continue; - - slots[name] = rendered; - outlets[name] = createOutlet(() => { - const context = rendered[0]; - if (!context) return; - - return { - context, - outlet: context.outlet - }; - }); - } - - outlets["children"] = createOutlet(() => { - const context = createMemo(() => { - let traversed = routeStates(); - - for (const [i, slot] of disposerPath) { - traversed = traversed[i].slots?.[slot]!; - } - return traversed[i + 1]; - }); - - return { - context: context(), - outlet: context().outlet - }; - }); - - next[i] = createRouteContext( - props.routerState, - next[i - 1] || props.routerState.base, - outlets, - nextMatches[i] - ); - - next[i].slots = slots; - }); - } - } + // children renders the next match in the next context + slotOutlets["children"] = createOutlet(() => renderedContexts[i + 1]); + + const context = createRouteContext( + props.routerState, + matchParentContext, + slotOutlets, + match + ); + context.slots = slotContexts; - globalDisposers.splice(nextMatches.length).forEach(disposer => { - disposeAll([disposer]); - }); + renderedContexts[i] = context; - if (prev && equal) { - return prev; + return dispose; + }); + + onCleanup(dispose); } - return next; + return renderedContexts; } const routeStates = createMemo( - on(props.routerState.matches, (nextMatches, prevMatches, prev: RouteContext[] | undefined) => { - const next = renderRouteMatches(nextMatches, prevMatches, prev); + on(props.routerState.matches, nextMatches => { + const next = renderRouteContexts(nextMatches); root = next[0]; + return next; }) ); - return createOutlet(() => { - if (routeStates() && root) return root; - })(); + return createOutlet(() => routeStates() && root)(); } -const createOutlet = ( - child: () => RouteContext | { context: RouteContext; outlet: () => JSX.Element } | undefined -) => { - let memoed: { context: RouteContext; outlet: () => JSX.Element } | undefined; - - // not using createMemo as we can't call routeStates eagerly - const _child = () => { - const c = child(); - if (!c) return; - if ("context" in c) return c; - else if (memoed?.context === c) return memoed; - return (memoed = { - context: c, - outlet: c.outlet - }); - }; - - return () => ( - - {child => ( - {child.outlet()} - )} +const createOutlet = (child: () => RouteContext | undefined) => () => + ( + + {child => {child.outlet()}} ); -}; export type RouteProps = { path?: S | S[]; @@ -322,3 +246,114 @@ function dataOnly(event: RequestEvent, routerState: RouterContext, branches: Bra }); } } + +// function renderRouteMatches( +// nextMatches: RouteMatch[], +// // prevMatches?: RouteMatch[], +// // prev?: RouteContext[], +// disposerPath: [number, string][] = [] +// ): RouteContext[] { +// // 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]; + +// // if (prevMatch.slots && nextMatch.slots) { +// // const nextSlots = (next[i].slots ??= {}); +// // for (const [name, slot] of Object.entries(nextMatch.slots)) { +// // nextSlots[name] = renderRouteMatches( +// // slot, +// // prevMatch.slots?.[name], +// // prev[i].slots?.[name], +// // [...disposerPath, [i, name]] +// // ); +// // } +// // } +// // } else { +// // equal = false; + +// let disposers = globalDisposers; +// for (const [i, slot] of disposerPath) { +// const disposer = disposers[i]; +// if (!disposer) break; + +// disposers = (disposer.slots ??= {})[slot] ??= []; +// } + +// if (disposers[i]) { +// disposeAll([disposers[i]]); +// } + +// createRoot(dispose => { +// disposers[i] = { dispose }; + +// const outlets: Record JSX.Element> = {}; +// const slots: Record = {}; + +// if (nextMatch.slots) +// for (const [name, slot] of Object.entries(nextMatch.slots)) { +// const rendered = renderRouteMatches( +// slot, +// // prevMatch?.slots?.[name], +// // prev?.[i].slots?.[name], +// [...disposerPath, [i, name]] +// ); + +// if (rendered.length === 0) continue; + +// slots[name] = rendered; +// outlets[name] = createOutlet(() => { +// const context = rendered[0]; +// if (!context) return; + +// return { +// context, +// outlet: context.outlet +// }; +// }); +// } + +// outlets["children"] = createOutlet(() => { +// const context = createMemo(() => { +// let traversed = routeStates(); + +// for (const [i, slot] of disposerPath) { +// traversed = traversed[i].slots?.[slot]!; +// } + +// return traversed[i + 1]; +// }); + +// return { +// context: context(), +// outlet: context().outlet +// }; +// }); + +// next[i] = createRouteContext( +// props.routerState, +// next[i - 1] || props.routerState.base, +// outlets, +// nextMatches[i] +// ); + +// next[i].slots = slots; +// }); +// // } +// } + +// globalDisposers.splice(nextMatches.length).forEach(disposer => { +// disposeAll([disposer]); +// }); + +// // if (prev && equal) { +// // return prev; +// // } + +// return next; +// } diff --git a/src/routing.ts b/src/routing.ts index 359885372..8eac8e394 100644 --- a/src/routing.ts +++ b/src/routing.ts @@ -126,7 +126,7 @@ export const useBeforeLeave = (listener: (e: BeforeLeaveEventArgs) => void) => { // specific to route-level stuff, not slots since those are nested and act like children export function createRoutes(routeDef: RouteDefinition, base: string = ""): Route[] { - const { component, load, children, info, slots } = routeDef; + const { component, load, children, info } = routeDef; const isLeaf = !children || (Array.isArray(children) && !children.length); const shared = { From 0fe97de85b73a6484dbc873aaa98f7788202a3f9 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Tue, 21 May 2024 14:41:35 +0800 Subject: [PATCH 05/12] prev checks to avoid constant remounting --- src/routers/components.tsx | 121 ++++++++++++++++++++++++------------- src/routing.ts | 8 +-- 2 files changed, 83 insertions(+), 46 deletions(-) diff --git a/src/routers/components.tsx b/src/routers/components.tsx index 3d28378b1..040e3fb8b 100644 --- a/src/routers/components.tsx +++ b/src/routers/components.tsx @@ -6,12 +6,9 @@ import { children, createMemo, createRoot, - createSignal, - For, getOwner, mergeProps, on, - onCleanup, Show, untrack } from "solid-js"; @@ -119,16 +116,14 @@ function Routes(props: { routerState: RouterContext; branches: Branch[] }) { })))); } - type Disposers = { dispose: () => void; slots?: Record }[]; + type Disposer = { dispose?: () => void; slots?: Record }; - const globalDisposers: Disposers = []; + const globalDisposers: Disposer[] = []; let root: RouteContext | undefined; - function disposeAll(disposer: Disposers) { - for (const { dispose, slots } of disposer) { - dispose(); - slots && Object.values(slots).forEach(disposeAll); - } + function disposeAll({ dispose, slots }: Disposer) { + dispose?.(); + if (slots) Object.values(slots).forEach(d => d.forEach(disposeAll)); } // Renders an array of route matches, recursively calling itself to branch @@ -137,63 +132,105 @@ function Routes(props: { routerState: RouterContext; branches: Branch[] }) { // Almost but not quite a regular tree since children aren't included in slots function renderRouteContexts( matches: RouteMatch[], - parent: RouteContext = props.routerState.base + 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++) { - // matches get processed linearly unless a slot is encountered, at which point - // this function recurses 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; + let slotContexts: Record = {}; // outlets rendered for the slots of the parent - includes 'children' - const slotOutlets: Record JSX.Element> = {}; - // context branches for each slot of this match - let slotContexts: Record | undefined; + let 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] ?? [] + ); + } + } - const dispose = createRoot(dispose => { - if ("slots" in match && match.slots) { - slotContexts = {}; + if (prev && match.route.key === prevMatch?.route.key) { + renderedContexts[i] = prev.contexts[i]; + renderedContexts[i].slots = slotContexts; + } else { + equal = false; - for (const [slot, matches] of Object.entries(match.slots)) { - slotContexts[slot] = renderRouteContexts(matches, matchParentContext); - slotOutlets[slot] = createOutlet(() => slotContexts?.[slot]?.[0]); - } - } + if (disposers?.[i]) disposers[i].dispose?.(); - // children renders the next match in the next context - slotOutlets["children"] = createOutlet(() => renderedContexts[i + 1]); + createRoot(dispose => { + disposers[i] = { + ...disposers[i], + dispose + }; - const context = createRouteContext( - props.routerState, - matchParentContext, - slotOutlets, - match - ); - context.slots = slotContexts; + // children renders the next match in the next context + slotOutlets["children"] = createOutlet(() => fullyRenderedRoutes()[i + 1]); - renderedContexts[i] = context; + for (const slot of Object.keys(match.slots ?? {})) { + const fullyRenderedSlotRoutes = () => fullyRenderedRoutes()[i]?.slots?.[slot]; - return dispose; - }); + slotOutlets[slot] = createOutlet(() => fullyRenderedSlotRoutes()?.[0]); + } + + renderedContexts[i] = createRouteContext( + props.routerState, + matchParentContext, + slotOutlets, + () => getLiveMatches()[i] + ); - onCleanup(dispose); + renderedContexts[i].slots = slotContexts; + }); + } } + disposers.splice(renderedContexts.length).forEach(disposeAll); + + if (prev && equal) return prev.contexts; + return renderedContexts; } const routeStates = createMemo( - on(props.routerState.matches, nextMatches => { - const next = renderRouteContexts(nextMatches); + 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]; + root = next[0]; - return next; - }) + return next; + } + ) ); return createOutlet(() => routeStates() && root)(); diff --git a/src/routing.ts b/src/routing.ts index 8eac8e394..ee8622834 100644 --- a/src/routing.ts +++ b/src/routing.ts @@ -1,4 +1,4 @@ -import { JSX, Accessor, runWithOwner, createEffect } from "solid-js"; +import { JSX, Accessor, runWithOwner, createEffect, onMount } from "solid-js"; import { createComponent, createContext, @@ -518,11 +518,11 @@ export function createRouteContext( router: RouterContext, parent: RouteContext, { children, ...slots }: Record JSX.Element>, - match: RouteMatch + match: () => RouteMatch ): RouteContext { const { base, location, params } = router; - const { pattern, component, load } = match.route; - const path = createMemo(() => match.path); + const { pattern, component, load } = match().route; + const path = createMemo(() => match().path); component && (component as MaybePreloadableComponent).preload && From e4a5d83d91983f52bd357b74073236263f536e64 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Thu, 23 May 2024 02:50:14 +0800 Subject: [PATCH 06/12] Create wet-fishes-tap.md --- .changeset/wet-fishes-tap.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/wet-fishes-tap.md 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 From 8b4901a0361f85454513e931a3e76a06f8679b10 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Tue, 11 Jun 2024 14:12:29 +0800 Subject: [PATCH 07/12] preload slots --- src/index.tsx | 1 - src/routers/components.tsx | 146 +++++++------------------------------ src/routing.ts | 1 - 3 files changed, 26 insertions(+), 122 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index c6f91688e..62995a672 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -6,7 +6,6 @@ export { useIsRouting, useLocation, useMatch, - useMatches, useCurrentMatches, useNavigate, useParams, diff --git a/src/routers/components.tsx b/src/routers/components.tsx index 040e3fb8b..ad5ee41a6 100644 --- a/src/routers/components.tsx +++ b/src/routers/components.tsx @@ -152,7 +152,7 @@ function Routes(props: { routerState: RouterContext; branches: Branch[] }) { // the context above the one about to be rendered const matchParentContext = renderedContexts[i - 1] ?? parent; - let slotContexts: Record = {}; + const slotContexts: Record = {}; // outlets rendered for the slots of the parent - includes 'children' let slotOutlets: Record JSX.Element> = {}; @@ -160,10 +160,12 @@ function Routes(props: { routerState: RouterContext; branches: Branch[] }) { const slotsDisposers: Record = ((disposers[i] ??= {}).slots ??= {}); for (const [slot, matches] of Object.entries(match.slots)) { + slotsDisposers[slot] ??= []; + slotContexts[slot] = renderRouteContexts( matches, renderedContexts[i], - (slotsDisposers[slot] ??= []), + slotsDisposers[slot], prevMatch?.slots?.[slot] && prevContext?.slots?.[slot] ? { matches: prevMatch?.slots?.[slot], contexts: prevContext?.slots?.[slot] } : undefined, @@ -188,7 +190,7 @@ function Routes(props: { routerState: RouterContext; branches: Branch[] }) { }; // children renders the next match in the next context - slotOutlets["children"] = createOutlet(() => fullyRenderedRoutes()[i + 1]); + slotOutlets.children = createOutlet(() => fullyRenderedRoutes()[i + 1]); for (const slot of Object.keys(match.slots ?? {})) { const fullyRenderedSlotRoutes = () => fullyRenderedRoutes()[i]?.slots?.[slot]; @@ -271,126 +273,30 @@ function dataOnly(event: RequestEvent, routerState: RouterContext, branches: Bra new URL(event.router!.previousUrl || event.request.url).pathname ); const matches = getRouteMatches(branches, url.pathname); + + preloadMatches(event, routerState, prevMatches, matches); +} + +function preloadMatches( + event: RequestEvent, + routerState: RouterContext, + 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.load && - route.load({ - params, - location: routerState.location, - intent: "preload" - }); + route.load?.({ + params, + location: routerState.location, + intent: "preload" + }); + + if (matches[match].slots) { + for (const [slot, slotMatches] of Object.entries(matches[match].slots ?? {})) { + preloadMatches(event, routerState, prevMatches[match].slots?.[slot] ?? [], slotMatches); + } + } } } - -// function renderRouteMatches( -// nextMatches: RouteMatch[], -// // prevMatches?: RouteMatch[], -// // prev?: RouteContext[], -// disposerPath: [number, string][] = [] -// ): RouteContext[] { -// // 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]; - -// // if (prevMatch.slots && nextMatch.slots) { -// // const nextSlots = (next[i].slots ??= {}); -// // for (const [name, slot] of Object.entries(nextMatch.slots)) { -// // nextSlots[name] = renderRouteMatches( -// // slot, -// // prevMatch.slots?.[name], -// // prev[i].slots?.[name], -// // [...disposerPath, [i, name]] -// // ); -// // } -// // } -// // } else { -// // equal = false; - -// let disposers = globalDisposers; -// for (const [i, slot] of disposerPath) { -// const disposer = disposers[i]; -// if (!disposer) break; - -// disposers = (disposer.slots ??= {})[slot] ??= []; -// } - -// if (disposers[i]) { -// disposeAll([disposers[i]]); -// } - -// createRoot(dispose => { -// disposers[i] = { dispose }; - -// const outlets: Record JSX.Element> = {}; -// const slots: Record = {}; - -// if (nextMatch.slots) -// for (const [name, slot] of Object.entries(nextMatch.slots)) { -// const rendered = renderRouteMatches( -// slot, -// // prevMatch?.slots?.[name], -// // prev?.[i].slots?.[name], -// [...disposerPath, [i, name]] -// ); - -// if (rendered.length === 0) continue; - -// slots[name] = rendered; -// outlets[name] = createOutlet(() => { -// const context = rendered[0]; -// if (!context) return; - -// return { -// context, -// outlet: context.outlet -// }; -// }); -// } - -// outlets["children"] = createOutlet(() => { -// const context = createMemo(() => { -// let traversed = routeStates(); - -// for (const [i, slot] of disposerPath) { -// traversed = traversed[i].slots?.[slot]!; -// } - -// return traversed[i + 1]; -// }); - -// return { -// context: context(), -// outlet: context().outlet -// }; -// }); - -// next[i] = createRouteContext( -// props.routerState, -// next[i - 1] || props.routerState.base, -// outlets, -// nextMatches[i] -// ); - -// next[i].slots = slots; -// }); -// // } -// } - -// globalDisposers.splice(nextMatches.length).forEach(disposer => { -// disposeAll([disposer]); -// }); - -// // if (prev && equal) { -// // return prev; -// // } - -// return next; -// } diff --git a/src/routing.ts b/src/routing.ts index a3cfbdd0b..16bc9b8c8 100644 --- a/src/routing.ts +++ b/src/routing.ts @@ -78,7 +78,6 @@ export const useNavigate = () => useRouter().navigatorFactory(); export const useLocation = () => useRouter().location as Location; export const useIsRouting = () => useRouter().isRouting; -export const useMatches = () => useRouter().matches; export const useMatch = (path: () => S, matchFilters?: MatchFilters) => { const location = useLocation(); const matchers = createMemo(() => From 0c37fc018f8975e4a18838e2d1fac207426c503e Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Tue, 11 Jun 2024 14:46:06 +0800 Subject: [PATCH 08/12] more slot preloading --- src/routers/components.tsx | 97 +++++++++++++++++++++----------------- src/routing.ts | 60 +++++++++++++---------- src/types.ts | 3 +- 3 files changed, 90 insertions(+), 70 deletions(-) diff --git a/src/routers/components.tsx b/src/routers/components.tsx index ad5ee41a6..977b99d64 100644 --- a/src/routers/components.tsx +++ b/src/routers/components.tsx @@ -31,7 +31,8 @@ import type { RouterContext, Branch, RouteSectionProps, - RouteMatch + RouteMatch, + OutputMatch } from "../types.js"; export type BaseRouterProps = { @@ -98,22 +99,37 @@ 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()); + } } type Disposer = { dispose?: () => void; slots?: Record }; @@ -123,13 +139,13 @@ function Routes(props: { routerState: RouterContext; branches: Branch[] }) { function disposeAll({ dispose, slots }: Disposer) { dispose?.(); - if (slots) Object.values(slots).forEach(d => d.forEach(disposeAll)); + 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 - // Takes linear branches of matches and creates linear branches of contexts - // Almost but not quite a regular tree since children aren't included in slots + // off for slots. Almost but not quite a regular tree since children aren't included in slots function renderRouteContexts( matches: RouteMatch[], parent: RouteContext, @@ -154,18 +170,16 @@ function Routes(props: { routerState: RouterContext; branches: Branch[] }) { const slotContexts: Record = {}; // outlets rendered for the slots of the parent - includes 'children' - let slotOutlets: Record JSX.Element> = {}; + const slotOutlets: Record JSX.Element> = {}; if (match.slots) { const slotsDisposers: Record = ((disposers[i] ??= {}).slots ??= {}); for (const [slot, matches] of Object.entries(match.slots)) { - slotsDisposers[slot] ??= []; - slotContexts[slot] = renderRouteContexts( matches, renderedContexts[i], - slotsDisposers[slot], + (slotsDisposers[slot] ??= []), prevMatch?.slots?.[slot] && prevContext?.slots?.[slot] ? { matches: prevMatch?.slots?.[slot], contexts: prevContext?.slots?.[slot] } : undefined, @@ -189,15 +203,15 @@ function Routes(props: { routerState: RouterContext; branches: Branch[] }) { dispose }; - // children renders the next match in the next context - slotOutlets.children = createOutlet(() => fullyRenderedRoutes()[i + 1]); - 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, @@ -274,28 +288,23 @@ function dataOnly(event: RequestEvent, routerState: RouterContext, branches: Bra ); const matches = getRouteMatches(branches, url.pathname); - preloadMatches(event, routerState, prevMatches, matches); -} - -function preloadMatches( - event: RequestEvent, - routerState: RouterContext, - 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.load?.({ - params, - location: routerState.location, - intent: "preload" - }); - - if (matches[match].slots) { - for (const [slot, slotMatches] of Object.entries(matches[match].slots ?? {})) { - preloadMatches(event, routerState, prevMatches[match].slots?.[slot] ?? [], slotMatches); + 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.load?.({ + 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/routing.ts b/src/routing.ts index 16bc9b8c8..d31f569a5 100644 --- a/src/routing.ts +++ b/src/routing.ts @@ -474,34 +474,44 @@ export function createRouterContext( function preloadRoute(url: URL, 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 { load } = route; - inLoadFn = true; - preloadData && - load && - runWithOwner(getContext!(), () => - load({ - params, - location: { - pathname: url.pathname, - search: url.search, - hash: url.hash, - query: extractSearchParams(url), - state: null, - key: "" - }, - intent: "preload" - }) - ); - inLoadFn = false; - } + preloadMatches(matches); intent = prevIntent; + + function preloadMatches(matches: RouteMatch[]) { + for (const match in matches) { + const { route, params, slots } = matches[match]; + + const component: MaybePreloadableComponent | undefined = route.component; + component?.preload?.(); + + const { load } = route; + inLoadFn = true; + preloadData && + load && + runWithOwner(getContext!(), () => + load({ + params, + location: { + pathname: url.pathname, + search: url.search, + hash: url.hash, + query: extractSearchParams(url), + state: null, + key: "" + }, + intent: "preload" + }) + ); + inLoadFn = false; + + if (slots) { + for (const matches of Object.values(slots)) preloadMatches(matches); + } + } + } } function initFromFlash() { diff --git a/src/types.ts b/src/types.ts index 626bfab5b..d2a0d940c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -135,6 +135,7 @@ export interface OutputMatch { match: string; params: Params; info?: Record; + slots?: Record; } export interface RouteDescription { @@ -216,7 +217,7 @@ export type Submission = { retry: () => void; }; -export interface MaybePreloadableComponent extends Component { +export interface MaybePreloadableComponent extends Component { preload?: () => void; } From 3fbc0781f5c5530043f7d5fe6036d3ac16cc0bea Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Tue, 11 Jun 2024 17:28:23 +0800 Subject: [PATCH 09/12] omit path from slot definition --- src/routing.ts | 20 +++++++++++++------- src/types.ts | 5 ++--- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/src/routing.ts b/src/routing.ts index 16bc9b8c8..4c7b7908f 100644 --- a/src/routing.ts +++ b/src/routing.ts @@ -222,16 +222,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) { - match.forEach(m => { - for (const [name, slot] of Object.entries(m.route.slots ?? {})) { - (m.slots ??= {})[name] = getRouteMatches(slot, location); + 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 match; + } } + + return matches; } + return []; } diff --git a/src/types.ts b/src/types.ts index 626bfab5b..e20e6c2bf 100644 --- a/src/types.ts +++ b/src/types.ts @@ -85,20 +85,19 @@ export interface RouteSectionProps { export type RouteDefinition< S extends string | string[] = any, T = unknown, - TSlots extends string = any + TSlots extends string = never > = { path?: S; matchFilters?: MatchFilters; load?: RouteLoadFunc; children?: RouteDefinition | RouteDefinition[]; - component?: Component>; info?: Record; } & ( | { component?: Component>; slots?: never } // slots require a component to render them | { component: Component>; - slots: Record; + slots: Record>; } ); From 8649ad25a72ab78f42042f81430104c9f615c118 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Tue, 11 Jun 2024 18:13:30 +0800 Subject: [PATCH 10/12] don't require component with slots --- src/types.ts | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/types.ts b/src/types.ts index f9d47b093..9d041d437 100644 --- a/src/types.ts +++ b/src/types.ts @@ -91,15 +91,10 @@ export type RouteDefinition< matchFilters?: MatchFilters; load?: RouteLoadFunc; children?: RouteDefinition | RouteDefinition[]; + component?: Component>; info?: Record; -} & ( - | { component?: Component>; slots?: never } - // slots require a component to render them - | { - component: Component>; - slots: Record>; - } -); + slots?: Record>; +}; export type MatchFilter = readonly string[] | RegExp | ((s: string) => boolean); From dc94116fb8b00e36023464f8fa2a58d94a89d2a1 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Tue, 11 Jun 2024 18:52:11 +0800 Subject: [PATCH 11/12] add basic tests for createBranches --- test/route.spec.ts | 52 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 2 deletions(-) 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"); + }); + }); }); From fffdf1c858c3c67c090173fd28b29f1b2862fbbe Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Sun, 14 Jul 2024 12:36:15 +0800 Subject: [PATCH 12/12] format --- src/components.tsx | 12 ++---------- src/data/action.ts | 11 +++++++++-- src/data/createAsync.ts | 8 ++++++-- src/data/index.ts | 1 - src/lifecycle.ts | 9 +++++++-- src/routers/HashRouter.ts | 18 +++++++++++++++--- src/routers/MemoryRouter.ts | 8 ++++++-- src/routers/Router.ts | 32 ++++++++++++++++++++++++++------ src/routers/StaticRouter.ts | 4 ++-- src/routers/createRouter.ts | 10 +++++----- src/routers/index.ts | 2 +- src/utils.ts | 11 +++++++++-- 12 files changed, 88 insertions(+), 38 deletions(-) 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/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/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");