Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Named layouts — solves pathless routes and granular layout resets #4275

Closed
Rich-Harris opened this issue Mar 8, 2022 · 20 comments · Fixed by #4388
Closed

Named layouts — solves pathless routes and granular layout resets #4275

Rich-Harris opened this issue Mar 8, 2022 · 20 comments · Fixed by #4388
Labels
feature request New feature or request
Milestone

Comments

@Rich-Harris
Copy link
Member

Rich-Harris commented Mar 8, 2022

Describe the problem

There's a whole slew of issues surrounding layout resets, such as these:

There might well be others. The essence of them is that __layout.reset.svelte is too blunt an instrument for the problem it purports to solve — your / page might be unique (but you can't have a root-level reset), or your nested route might want to reset an intermediate layout but not the root layout, or you might just want to reset a layout for a specific component without the overhead of creating a neighbouring reset component.

A separate issue asks for 'pathless' routes, so that your marketing pages and your app itself can both live at the root level, even though they have totally different layouts. But it's actually the same problem, in disguise.

Describe the proposed solution

Credit for this belongs mostly to @mrkishi, not me. The idea is to have named layouts, and give components a way to declare which layouts they belong to.

We name a layout like so:

routes/
├ __layout.{js,svelte}
└ __layout-special.{js,svelte}

Any page can now mount inside the special layout rather than the default layout by specifying so in its own filename:

routes/
├ __layout.{js,svelte}
├ __layout-special.{js,svelte}
├ normal-page.{js,svelte}
├ abnormal-page#special.{js,svelte}

This applies at arbitrary depths: in this case, /deeply/nested/path will ignore the __layout, deeply/__layout and deeply/nested/__layout files.

routes/
├ __layout.{js,svelte}
├ __layout-special.{js,svelte}
├ deeply
│ ├ nested
│ │ ├ path#special.{js,svelte}
│ │ └ __layout.{js,svelte}
│ └ __layout.{js,svelte}

If a folder specifies a layout name, its contents will inherit it — /deeply/nested/path/contains/more/stuff would use __layout-special as well as any __layout files contained inside path#special:

routes/
├ __layout.{js,svelte}
├ __layout-special.{js,svelte}
├ deeply
│ ├ nested
│ │ ├ path#special
│ │ │ ├ contains
│ │ │ │ ├ more
│ │ │ │ │ └ stuff

At any point, you can reset to a layout further up the tree. This is a horribly contrived example, bear with me — /deeply/nested/path/contains/more/things would use deeply/__layout-x (which itself uses the root __layout), rather than __layout-special specified up-tree:

routes/
├ __layout.{js,svelte}
├ __layout-special.{js,svelte}
├ deeply
│ ├ __layout-x.{js,svelte}
│ ├ nested
│ │ ├ path#special
│ │ │ ├ contains
│ │ │ │ ├ more
│ │ │ │ │ ├ stuff
│ │ │ │ │ └ things#x

The most common cases are probably a) resetting to the default root layout, and b) resetting to no layout (i.e. a completely blank page). We could do these by using ~ as an alias for 'default', and omitting a name for 'complete reset':

page-that-uses-default-layout#~.svelte
page-that-uses-no-layout#.svelte

A little punctuation-heavy, but it reuses concepts efficiently I think.


We can now see how this solves the issues listed above.

#3832

Since sibling components can now use different layouts, there's no need to invent syntax for 'pathless' routes, and the filesystem continues to match the routing table, rather than having things nested confusingly:

routes/
├ __layout-app.{js,svelte}
├ __layout-auth.{js,svelte}
├ __layout-marketing.{js,svelte}
├ [userId]#app
│ └ profile.svelte
├ dashboard#app.svelte
├ index#marketing.svelte
├ reset-password#auth.svelte
├ signin#auth.svelte
├ signup#auth.svelte
└ product#marketing.svelte

(In reality, you'd designate either app or marketing as the default, so you wouldn't need to declare names for all these.)

#4103

Instead of this...

login
├ __layout.reset.svelte
└ index.svelte

...we could have this...

login
  index#.svelte

...or even this:

login#.svelte

#4133, #2647

Pages can now easily break out of any intermediate layouts between them and the root. The #2647 example, where you want test.svelte to render inside the root layout but not the admin layout...

routes
├ __layout.svelte
├ admin
│ ├ __layout.svelte
│ ├ spec
│ │ ├ __layout.reset.svelte
│ │ └ test.svelte

...is easily achieved:

routes
  __layout.svelte
  admin
    __layout.svelte
    spec
      test#~.svelte

#1685

The index page can now have a special layout:

__layout.{js,svelte}
__layout-special.{js,svelte}
index#special.svelte
other.svelte
stuff.svelte

Alternatives considered

The alternative people have suggested is exporting something from the page component — export const reset = 2, or export const Layout = SomeComponent.

But we can't then know which layouts to use or omit without loading the component. Using filenames gives us the ability to figure out all the important stuff at build time.

Importance

would make my life easier

Additional Information

No response

@ebeloded
Copy link

ebeloded commented Mar 9, 2022

The solution is superb 👏

@rmunn
Copy link
Contributor

rmunn commented Mar 9, 2022

Can deeper sections inherit from named sections above them using the # syntax? E.g., can you write a deeply/__section-x.{js,svelte} and a deeply/__section-y#special.{js.svelte}? Then deeply/nested/foo#x.svelte would use the root section and section x, while deeply/nested/bar#y.svelte would use the special section (and NOT the root section) and then section y.

@dominikg
Copy link
Member

dominikg commented Mar 9, 2022

# in particular might not work with vite. I remember a question where a user had # in their project dir and it didn't start. There are a couple of regexes in vites path / id handling that could interfere eg https:/vitejs/vite/blob/cb10ebfc25aa35a232aeb74b69d09f4484f734aa/packages/vite/src/node/utils.ts#L204

@rgossiaux
Copy link

I think another alternative to consider is supporting overrides via a config file rather than in the filename. I think there could be some advantages to that, like easier renames & the ability to target sections outside of the current directory path.

You could also consider supporting some blessed sections/ directory which could contain the section inheritance. This probably makes the inheritance easier to understand, but the mapping from sections to routes more complicated. You could support a similar structure to the current model by, eg, having __section.json files inside the routes folder which just contained the path to the location in the sections/ folder to use, and then combine this with your filename-based solution, or a config-based solution, to support the additional flexibility.

Just a couple quick thoughts. Glad you're taking a look at this & excited to see this area getting some improvements!

@Rich-Harris
Copy link
Member Author

@rmunn yeah, I don't see why not!

@dominikg hmm. what if the URL was encoded, i.e. import('../src/routes/__section%23special.js')?

@rgossiaux

the ability to target sections outside of the current directory path

I'd consider that a bug rather than a feature. An important aspect of filesystem-based routing is that both human and computer can figure out everything they need by 'walking up the tree'; using the tree structure also eliminates any ambiguity about which foo section we're referencing, etc.

@rgossiaux
Copy link

@rgossiaux

the ability to target sections outside of the current directory path

I'd consider that a bug rather than a feature. An important aspect of filesystem-based routing is that both human and computer can figure out everything they need by 'walking up the tree'; using the tree structure also eliminates any ambiguity about which foo section we're referencing, etc.

There'd be no ambiguity in a config file with path names. As for the file path thing, I mentioned it because this whole issue is because the layout structure does not always map cleanly to the URL structure to begin with. But if, expanding @rmunn's comment, sections can inherit from other sections within the same directory (eg foo/__section-special.svelte and foo/__section#special.svelte -- you need to add cycle detection but with only single inheritance that's fine), then I think your scheme is powerful enough to express any arbitrary layout+route combination & everything else is just aesthetics.

@dominikg
Copy link
Member

dominikg commented Mar 9, 2022

@dominikg hmm. what if the URL was encoded, i.e. import('../src/routes/__section%23special.js')?

it could be possible to improve vite (and possibly also sveltekit) to support # in filenames. But right now url to path to id conversions don't have any logic to handle it.
To avoid adding complexity (and possible confusion when users get in contact with different encodings of the same thing)
imho it would be easier to use a character that is inert (does not need escaping) in filenames and urls. It should also be supported by all major file systems. Iirc this was one reason for using the __ prefix.

@benmccann benmccann added this to the 1.0 milestone Mar 10, 2022
@benmccann
Copy link
Member

I wonder if doing export const layout = 'special' might be an alternative to putting #special in the filename.

One advantage that would have is that you don't need to rename all the consumers breaking the history in GitHub if you add/remove/rename a layout. Another advantage would be that if we could continue to introduce more routing features without having to continually come up with more special characters to use in filenames. If we wanted to in the future introduce a feature where we want the user to provide something more complex than a string then it's also a more flexible pattern to be using because it's easier to specify a list, object, etc. this way.

@Rich-Harris
Copy link
Member Author

I address that above under 'Alternatives considered'. tl;dr it's impossible. But it's fine, because git preserves history across file renames

@mrkishi
Copy link
Member

mrkishi commented Mar 10, 2022

What would prevent us from parsing the exports at build time? They'd have to be static, but that's already the case for path-based references.

Granted, I get that we're trying to stay fs-based as much as possible (by default, at least), and the exports version would detangle layouts from tree structure, which might not be desirable.

@benmccann
Copy link
Member

benmccann commented Mar 10, 2022

I guess I should have read more thoroughly. There are probably other options besides the ones mentioned so far. E.g. we could have a routes.metadata file in the directory

But it's fine, because git preserves history across file renames

It's fine unless you're one of the 89.55% of people that use GitHub: isaacs/github#900 😜

@gtm-nayan
Copy link
Contributor

Named sections certainly feel like the way to move forward, although relying on filenames does feel a bit messy. Trying to decipher the directory trees in your examples is quite a cognitive load with all the added symbols and would likely get worse in the context of a larger app.

I floated the idea of 'granular layout resets' on Discord a while ago, and pngwn hinted at the possibility of a <kit:options> tag which could be statically analyzed.

Routify apparently does something similar but with a comment instead of an element and there is some precedent for it in Svelte, in the form of <svelte:options>.

The primary hindrance might be how much info can Svelte and frameworks like SvelteKit can pass between each other, but I think it's an alternative worth considering. The special exports (router, prerender etc.) could also then be moved into the options tag to clean things up a bit.

Bonus points: if typing params in pages gets figured out, then editor support for layout names wouldn't be too far off either

@Rich-Harris
Copy link
Member Author

What would prevent us from parsing the exports at build time? They'd have to be static, but that's already the case for path-based references.

The fact that this would work...

<script context="module">
  export const section = 'special';
</script>

...and this would fail...

<script context="module">
  import { SPECIAL_SECTION } from '$lib/constants';
  export const section = SPECIAL_SECTION;
</script>

...is way too deep in the uncanny valley for my liking.

The reason I'm not too concerned about causing punctuation soup in the file tree is that a) named sections would be the exception, not the rule (I think it's much more common for layout trees to loosely follow URL structure than for every subtree to be unique), and b) inheritance. Besides, being able to see at a glance that these are my marketing pages and these are my app pages (without needing to open up files and scan for specific exports) is a good thing, I'd argue.

@Acmion
Copy link

Acmion commented Mar 13, 2022

In ASP.NET Core all pages have a Layout property that is of type string and represents a file path. It works quite well. This is essentially the same as:

<script context="module">
  export const layout = '/some/file/path/special';
  
  // The property can also be dynamic
  export const layout = '/some/file/path/' + special'
</script>

However, ASP.NET Core resolves everything at runtime, which may not be applicable for Svelte.

I would prefer that this would be resolved with a property, because then future features can be implemented in a similar fashion. See for example my suggestion about "worker threaded" endpoints here: #3653, where the same dilemma of whether the functionality should be denoted either with special filenames or properties.

One key reason for implementing this with a property rather than a special file name in the mentioned issue is that then one can easily add in extra features to further enhance behavior. For example:

<script context="module">
  // The type of layout = string | { path: string, someOtherProp: string }

  // Works
  export const layout = '/some/file/path/special';
  
  // Would also work
  export const layout = { path: '/some/file/path/special', someOtherProp: "Hello World!" }
</script>

@Rich-Harris
Copy link
Member Author

Once again, that's not an option - SvelteKit needs to know which layouts to use without loading the leaf component and working inwards

@Acmion
Copy link

Acmion commented Mar 14, 2022

This does not entirely relate to the issue at hand, but could still be of interest when designing a solution. This is highly related to @rgossiaux comment.

Why should the layout be the only thing that gets inherited and not other properties, such as prerender?

Consider a route structure like this:

routes/
├ __layout.svelte
├ index.svelte
├ deeply
│ ├ index.svelte
│ ├ nested
│ │ ├ index.svelte

Currently __layout.svelte is applied to all routes, which is great, because we do not want to repeat the same configuration all over the place.

Now, consider the case where we would want all of the routes under "deeply" to have, for example, export const prerender = true. The only way to achieve this currently is to specify it in every route explicitly. Alternatively, it can be specified with an app wide option, which essentially translates to us having to disable the option explicitly in all other routes.

This could be solved with something like this:

routes/
├ __layout.svelte
├ index.svelte
├ deeply
│ ├ __properties-inherit.svelte
│ ├ index.svelte
│ ├ nested
│ │ ├ index.svelte
//__properties-inherit.svelte
<script>
  // These properties get inherited to all subroutes
  export const prerender = true
</script>

Some MVC frameworks support behavior like this and it is quite handy. The properties could then also be overridden in each route separately, but those values would then not then be inherited.

If the benefits of this approach is of any value, then the layout path could be implemented likewise. This would at least partially solve the problem of knowing the layout before processing the leaf component, if it could not be overridden explicitly in a route.

Still, if the layout file can not not be overridden in a route, the problem of specifying a separate layout for each route would still remain.

@Rich-Harris Rich-Harris changed the title Named sections (formerly 'layouts') — solves pathless routes and granular layout resets Named layouts — solves pathless routes and granular layout resets Mar 16, 2022
@benmccann benmccann added the feature request New feature or request label Mar 17, 2022
This was referenced Mar 17, 2022
@f-elix
Copy link
Contributor

f-elix commented Mar 19, 2022

This is a clever solution! But to me it screams "config file needed". I know a lot of people like file based routing but the more use cases like this appear, the more contortions and weird syntax are needed. Don't get me wrong, this is a very good solution to the problem, but how far can this go?

It seems to me that a routing config file could solve all of this and more. I'm not saying we should dispense with file based routing, but that it could be augmented/overridden with some external configuration.

The config file could hold all the already existing options (prerender, hydrate and router), all the layout options presented here and all the future features that Sveltekit's users might need.

It could also almost instantly solve the i18n problem, by allowing users, for example, to specify alternate slugs for each route via the config.

Is there some fundamental thing preventing this? It's so obvious to me that I suspect I'm missing something important.

@Rich-Harris
Copy link
Member Author

I definitely get where you're coming from, and I'm not ideological about it. I think it's very easy to get a bit handwavy though about the benefits of an alternative approach until it's specified in detail.

In this case we'd need to consider where such a config would go. For example is it something that's only accessible at build time, or is it part of the app? If it's a build time concern, then we can't use it for route matching, and we can't do things like this:

// we don't need any JS on this page, though we'll load
// it in dev so that we get hot module replacement...
export const hydrate = dev;
// ...but if the client-side router is already loaded
// (i.e. we came here from elsewhere in the app), use it
export const router = browser;

If it's part of the deployed app, then we need to think about how it scales. Can we code-split it, so that an app with 1,000 routes doesn't need to include the config for all of them? (For example, route matchers are designed in such a way that we could code-split them in future; they're already code-split on the server where applicable. This is a lot harder to do if you have a config file.) This becomes particularly acute when we start talking about the i18n issue.

If we can come up with convincing answers to those questions, then we'll be in a position to talk about the relative merits on a design level. Personally I would still expect file based routing to have an edge, simply because you can tell at a glance which layouts and route matchers and localised path segments could be involved in a given route, rather than having that information split between the route itself and a (possibly very large) config file elsewhere.

The constraints of using the filesystem can themselves be beneficial. For example, the relationship between two different layout components is always going to be fixed (one will inherit from the other, or they'll be unrelated), but if you throw a Turing-complete language at it then you have to have some way of dealing with the illogical:

export const routes = {
  'foo': {
    layouts: ['x', 'y']
  },
  'bar': {
    layouts: ['y', 'x']
  }
};

I do agree that there's only so much punctuation you can throw at the filesystem before it becomes incomprehensible, but I'm not too worried that we're going to blow the budget. If anything it will force us to refine our designs until they become elegant!

@f-elix
Copy link
Contributor

f-elix commented Mar 21, 2022

Ha! Well thank you so much for such a detailed reply.

I was indeed quite handwavy with my suggestion and, as I suspected, there is indeed a lot more to it than what I first thought.
This was a gripe I had from the start with Sveltekit, but you've probably convinced me of the file based approach, or at least, I'll have some homework to do if I want to bring a config solution.

@vwkd
Copy link

vwkd commented Mar 22, 2022

Just a lose idea. Maybe it would make sense to have a single metadata file for a route (page and endpoint) that contains the information that should be available without needing to load the route.

This metadata file could contain the layout overrides discussed here, param validators (#4291), and also more meta data like route redirects, etc.

Maybe that would deter too much from SvelteKit’s mentality to keep things straight forward to understand, as reading the file hierarchy isn’t enough anymore to know what route is running when. But then, those extra files (first normal layouts, now special layouts, then route parameters, etc.) introduce only increasing complexity and arguably just as well make it not straight forward anymore to understand what runs when.

I guess it remains to be seen where the tipping point is, when encoding metadata in one dimensional length-an-character-restricted file/folder names becomes more complex than in two dimensional unrestricted code files.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
feature request New feature or request
Projects
None yet
Development

Successfully merging a pull request may close this issue.