Skip to content

Commit d2de0a7

Browse files
committed
Add root outlet URL resolution
1 parent b99e87c commit d2de0a7

15 files changed

Lines changed: 282 additions & 46 deletions

.changeset/root-outlet-for-url.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"rescript-relay-router": minor
3+
---
4+
5+
Expose root-scoped route declaration helpers for resolving the effective outlet for a URL.

examples/client-rendering/src/routes/__generated__/RouteDeclarations.res

Lines changed: 21 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

examples/client-rendering/src/routes/__generated__/RouteDeclarations.resi

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

examples/client-rendering/test/UrlEncodingDecoding.test.res

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,12 @@ describe("parsing", () => {
129129
cleanup()
130130
})
131131

132+
test("outletForUrl returns the deepest match effective outlet", _t => {
133+
expect(RouteDeclarations.Root.outletForUrl("/settings"))->Expect.toBe(Some("Overlay"))
134+
expect(RouteDeclarations.Root.outletForUrl("/todos"))->Expect.toBe(None)
135+
expect(RouteDeclarations.Root.outletForUrl("/not-found"))->Expect.toBe(None)
136+
})
137+
132138
test("parseRoute correctly decode query params", _t => {
133139
let queryParams =
134140
Routes.Root.Todos.Route.parseRoute(

packages/rescript-relay-router/README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,17 @@ let renderer = Routes.Shell.Route.makeRenderer(
221221

222222
Slot routes are still regular routes. They use their normal route renderer, `prepare`, `prepareCode`, query params, path params, links, and preloading. The `outlet` property only controls where the matched branch renders.
223223

224+
The generated route declarations also expose a root-scoped `outletForUrl` helper:
225+
226+
```rescript
227+
let renderTarget = switch RouteDeclarations.Shell.outletForUrl("/preferences/account") {
228+
| Some("Overlay") => "overlay"
229+
| Some(_) | None => "primary"
230+
}
231+
```
232+
233+
`outletForUrl` uses the same route matcher as the runtime router and returns the deepest matched route's effective outlet. Effective outlets are inherited by descendants, so a child route under an outlet route reports the same outlet even when the child does not repeat the `outlet` field.
234+
224235
This initial implementation supports one active outlet branch per matched URL. A route tree may declare multiple slots, but a single URL match can only split once into the first descendant route that declares `outlet`.
225236

226237
To create a "catch all" route, use the `*`, character as the route path. Typically used for the "not found" route. Example:

packages/rescript-relay-router/cli/RescriptRelayRouterCli__Codegen.res

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -632,14 +632,17 @@ let addIndentation = (str, indentation) => {
632632
}
633633
}
634634

635-
let rec getRouteDefinition = (route: printableRoute, ~indentation): string => {
635+
let rec getRouteDefinition = (route: printableRoute, ~indentation, ~inheritedOutlet=?): string => {
636636
let routeName = route.name->RouteName.getFullRouteName
637+
let effectiveOutlet = route.outlet->Option.orElse(inheritedOutlet)
637638
let childrenDefinition = switch route.children {
638639
| [] => ""
639640
| children =>
640641
"\n" ++
641642
children
642-
->Array.map(route => getRouteDefinition(route, ~indentation=indentation + 1))
643+
->Array.map(route =>
644+
getRouteDefinition(route, ~indentation=indentation + 1, ~inheritedOutlet=?effectiveOutlet)
645+
)
643646
->Array.join(",\n") ++ "\n"
644647
}
645648

@@ -656,6 +659,10 @@ let rec getRouteDefinition = (route: printableRoute, ~indentation): string => {
656659
| Some(outlet) => `Some("${outlet}")`
657660
| None => "None"
658661
}},
662+
effectiveOutlet: ${switch effectiveOutlet {
663+
| Some(outlet) => `Some("${outlet}")`
664+
| None => "None"
665+
}},
659666
loadRouteRenderer,
660667
preloadCode: (
661668
~environment: RescriptRelay.Environment.t,

packages/rescript-relay-router/cli/RescriptRelayRouterCli__Commands.res

Lines changed: 41 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -137,25 +137,41 @@ let generateRoutes = (~scaffoldAfter, ~deleteRemoved, ~config) => {
137137
})
138138

139139
// Write the full route declarations file
140-
let entrypointRootModules =
140+
let rootRouteMakerName = (route: Types.printableRoute) =>
141+
`make${route.name->Types.RouteName.getRouteName}Route`
142+
let rootRouteMakers =
143+
routes
144+
->Array.map(route => {
145+
let makeRouteName = route->rootRouteMakerName
146+
`let ${makeRouteName} = (~prepareDisposeTimeout=5 * 60 * 1000): RelayRouter.Types.route => {
147+
let {prepareRoute, getPrepared} = makePrepareAssets(~loadedRouteRenderers, ~prepareDisposeTimeout)
148+
149+
${Codegen.getRouteDefinition(route, ~indentation=1)}
150+
}`
151+
})
152+
->Array.join("\n\n")
153+
154+
let rootModules =
141155
routes
142-
->Array.filter(route => route.entrypoint)
143156
->Array.map(route => {
144157
let moduleName = route.name->Types.RouteName.getRouteName
145-
`module ${moduleName} = {
158+
let makeRouteName = route->rootRouteMakerName
159+
let makeFunction = switch route.entrypoint {
160+
| true =>
161+
`
146162
let make = (~prepareDisposeTimeout=5 * 60 * 1000): array<RelayRouter.Types.route> => {
147-
let {prepareRoute, getPrepared} = makePrepareAssets(~loadedRouteRenderers, ~prepareDisposeTimeout)
148-
149-
[
150-
${Codegen.getRouteDefinition(route, ~indentation=3)}
151-
]
152-
}
163+
[${makeRouteName}(~prepareDisposeTimeout)]
164+
}`
165+
| false => ""
166+
}
167+
`module ${moduleName} = {
168+
let outletForUrl = url => RelayRouter.Internal.outletForUrl([${makeRouteName}()], url)${makeFunction}
153169
}`
154170
})
155171
->Array.join("\n\n")
156-
let entrypointRootModulesSection = switch entrypointRootModules {
172+
let rootModulesSection = switch rootModules {
157173
| "" => ""
158-
| entrypointRootModules => `${entrypointRootModules}\n\n`
174+
| rootModules => `${rootModules}\n\n`
159175
}
160176

161177
let fileContents = `open RelayRouter__Internal__DeclarationsSupport
@@ -164,12 +180,12 @@ external unsafe_toPrepareProps: 'any => prepareProps = "%identity"
164180
165181
let loadedRouteRenderers: Map.t<string, loadedRouteRenderer> = Map.make()
166182
167-
${entrypointRootModulesSection}let make = (~prepareDisposeTimeout=5 * 60 * 1000): array<RelayRouter.Types.route> => {
168-
let {prepareRoute, getPrepared} = makePrepareAssets(~loadedRouteRenderers, ~prepareDisposeTimeout)
183+
${rootRouteMakers}
169184
185+
${rootModulesSection}let make = (~prepareDisposeTimeout=5 * 60 * 1000): array<RelayRouter.Types.route> => {
170186
[
171187
${routes
172-
->Array.map(route => Codegen.getRouteDefinition(route, ~indentation=1))
188+
->Array.map(route => `${route->rootRouteMakerName}(~prepareDisposeTimeout)`)
173189
->Array.join(",\n")}
174190
]
175191
}`
@@ -179,23 +195,28 @@ ${entrypointRootModulesSection}let make = (~prepareDisposeTimeout=5 * 60 * 1000)
179195
)
180196

181197
// Write interface file as the signature of this will never change
182-
let entrypointRootModuleSignatures =
198+
let rootModuleSignatures =
183199
routes
184-
->Array.filter(route => route.entrypoint)
185200
->Array.map(route => {
186201
let moduleName = route.name->Types.RouteName.getRouteName
187-
`module ${moduleName}: {
202+
let makeSignature = switch route.entrypoint {
203+
| true => `
188204
let make: (~prepareDisposeTimeout: int=?) => array<RelayRouter.Types.route>
205+
`
206+
| false => ""
207+
}
208+
`module ${moduleName}: {
209+
let outletForUrl: string => option<string>${makeSignature}
189210
}`
190211
})
191212
->Array.join("\n\n")
192-
let entrypointRootModuleSignaturesSection = switch entrypointRootModuleSignatures {
213+
let rootModuleSignaturesSection = switch rootModuleSignatures {
193214
| "" => ""
194-
| entrypointRootModuleSignatures => `${entrypointRootModuleSignatures}\n\n`
215+
| rootModuleSignatures => `${rootModuleSignatures}\n\n`
195216
}
196217

197218
Utils.pathInGeneratedFolder(~config, ~fileName="RouteDeclarations.resi")->Fs.writeFileIfChanged(
198-
`${entrypointRootModuleSignaturesSection}let make: (~prepareDisposeTimeout: int=?) => array<RelayRouter.Types.route>`,
219+
`${rootModuleSignaturesSection}let make: (~prepareDisposeTimeout: int=?) => array<RelayRouter.Types.route>`,
199220
)
200221

201222
if scaffoldAfter {

packages/rescript-relay-router/cli/RescriptRelayRouterCli__Parser.res

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -901,11 +901,11 @@ module Decode = {
901901
let slots = slotsProp->validateSlots(~ctx)
902902
let outlet = outletProp->validateOutlet(~ctx, ~parentContext)
903903
let entrypoint = entrypointProp->Validators.validateEntrypoint(~ctx, ~parentContext)
904-
switch (entrypoint, name) {
905-
| (true, Some({name: "RelayRouter", loc})) =>
904+
switch (parentContext.routeDepth, name) {
905+
| (0, Some({name: "RelayRouter", loc})) =>
906906
ctx.addDecodeError(
907907
~loc,
908-
~message=`"RelayRouter" cannot be used as an entrypoint route name because it would shadow the router runtime module in generated route declarations.`,
908+
~message=`"RelayRouter" cannot be used as a top-level route name because it would shadow the router runtime module in generated route declarations.`,
909909
)
910910
| _ => ()
911911
}

packages/rescript-relay-router/src/RelayRouter__Internal.res

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,48 @@ external matchPath: (string, string) => option<pathMatch> = "matchPath"
116116
external matchPathWithOptions: ({"path": string, "end": bool}, string) => option<pathMatch> =
117117
"matchPath"
118118

119+
type compiledRoutes
120+
121+
@module("./vendor/react-router.js")
122+
external compileRoutes: array<route> => compiledRoutes = "compileRoutes"
123+
124+
@module("./vendor/react-router.js") @return(nullable)
125+
external matchCompiledRoutes: (
126+
compiledRoutes,
127+
RelayRouter__History.location,
128+
) => option<array<routeMatch>> = "matchCompiledRoutes"
129+
130+
let locationFromUrl = url => {
131+
let urlObj = switch (url->String.startsWith("http://"), url->String.startsWith("https://")) {
132+
| (true, _) | (_, true) => url
133+
| (false, false) =>
134+
switch url->String.startsWith("/") {
135+
| true => `http://localhost${url}`
136+
| false => `http://localhost/${url}`
137+
}
138+
}->RelayRouter__Bindings.URL.make
139+
140+
{
141+
RelayRouter__History.pathname: urlObj->RelayRouter__Bindings.URL.getPathname,
142+
search: urlObj->RelayRouter__Bindings.URL.getSearch->Option.getOr(""),
143+
hash: urlObj->RelayRouter__Bindings.URL.getHash,
144+
state: urlObj->RelayRouter__Bindings.URL.getState,
145+
key: "-",
146+
}
147+
}
148+
149+
let outletForUrl = (routes: array<route>, url: string): option<string> => {
150+
let location = url->locationFromUrl
151+
152+
switch matchCompiledRoutes(routes->compileRoutes, location) {
153+
| Some(matches) =>
154+
matches
155+
->Array.get(matches->Array.length - 1)
156+
->Option.flatMap(match => match.route.effectiveOutlet)
157+
| None => None
158+
}
159+
}
160+
119161
type prepared
120162
external toObject: Type.Classify.object => {..} = "%identity"
121163
external objectToPreparedArrayUnsafe: Type.Classify.object => array<prepared> = "%identity"

packages/rescript-relay-router/src/RelayRouter__Internal.resi

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ external matchPath: (string, string) => option<pathMatch> = "matchPath"
3535
external matchPathWithOptions: ({"path": string, "end": bool}, string) => option<pathMatch> =
3636
"matchPath"
3737

38+
let outletForUrl: (array<RelayRouter__Types.route>, string) => option<string>
39+
3840
type prepared
3941

4042
module RouteKey: {

0 commit comments

Comments
 (0)